anywidget-graph 0.2.0__py3-none-any.whl → 0.2.1__py3-none-any.whl

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.
anywidget_graph/widget.py CHANGED
@@ -8,1624 +8,93 @@ import anywidget
8
8
  import traitlets
9
9
  from traitlets import observe
10
10
 
11
+ from anywidget_graph.backends import DatabaseBackend
12
+ from anywidget_graph.backends.grafeo import GrafeoBackend
13
+ from anywidget_graph.ui import get_css, get_esm
14
+
11
15
  if TYPE_CHECKING:
12
16
  from collections.abc import Callable
13
17
 
14
- _ESM = r"""
15
- import Graph from "https://esm.sh/graphology@0.25.4";
16
- import Sigma from "https://esm.sh/sigma@3.0.0";
17
- import neo4j from "https://cdn.jsdelivr.net/npm/neo4j-driver@5.28.0/lib/browser/neo4j-web.esm.min.js";
18
-
19
- // === CONSTANTS ===
20
- const QUERY_LANGUAGES = [
21
- { id: "cypher", name: "Cypher", enabled: true },
22
- { id: "gql", name: "GQL", enabled: false },
23
- { id: "sparql", name: "SPARQL", enabled: false },
24
- { id: "gremlin", name: "Gremlin", enabled: false },
25
- { id: "graphql", name: "GraphQL", enabled: false },
26
- ];
27
-
28
- const BACKENDS = [
29
- { id: "neo4j", name: "Neo4j" },
30
- { id: "grafeo", name: "Grafeo" },
31
- ];
32
-
33
- // === SVG ICONS ===
34
- const ICONS = {
35
- play: `<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>`,
36
- settings: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M12 1v4m0 14v4m11-11h-4M5 12H1m17.36-5.64l-2.83 2.83M9.47 14.53l-2.83 2.83m0-10.72l2.83 2.83m5.06 5.06l2.83 2.83"/></svg>`,
37
- close: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg>`,
38
- chevron: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>`,
39
- chevronRight: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg>`,
40
- node: `<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="8"/></svg>`,
41
- edge: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14m-4-4l4 4-4 4"/></svg>`,
42
- property: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 7h16M4 12h16M4 17h10"/></svg>`,
43
- sidebar: `<svg width="16" height="16" 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="M9 3v18"/></svg>`,
44
- refresh: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 4v6h-6M1 20v-6h6"/><path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"/></svg>`,
45
- };
46
-
47
- // === DATABASE CONNECTION ===
48
- let driver = null;
49
-
50
- async function connectNeo4j(uri, username, password, model) {
51
- if (driver) {
52
- await driver.close();
53
- driver = null;
54
- }
55
-
56
- model.set("connection_status", "connecting");
57
- model.save_changes();
58
-
59
- try {
60
- driver = neo4j.driver(uri, neo4j.auth.basic(username, password));
61
- await driver.verifyConnectivity();
62
- model.set("connection_status", "connected");
63
- model.set("query_error", "");
64
- model.save_changes();
65
-
66
- // Fetch schema after successful connection
67
- await fetchSchema(model);
68
- return true;
69
- } catch (error) {
70
- model.set("connection_status", "error");
71
- model.set("query_error", "Connection failed: " + error.message);
72
- model.save_changes();
73
- driver = null;
74
- return false;
75
- }
76
- }
77
-
78
- async function fetchSchema(model) {
79
- if (!driver) return;
80
-
81
- const database = model.get("connection_database") || "neo4j";
82
- const session = driver.session({ database });
83
-
84
- try {
85
- // Fetch node labels and their properties
86
- const labelsResult = await session.run("CALL db.labels()");
87
- const nodeTypes = [];
88
-
89
- for (const record of labelsResult.records) {
90
- const label = record.get(0);
91
- // Get properties for this label
92
- const propsResult = await session.run(
93
- `MATCH (n:\`${label}\`) UNWIND keys(n) AS key RETURN DISTINCT key LIMIT 20`
94
- );
95
- const properties = propsResult.records.map(r => r.get(0));
96
- nodeTypes.push({ label, properties, count: null });
97
- }
98
-
99
- // Fetch relationship types and their properties
100
- const relTypesResult = await session.run("CALL db.relationshipTypes()");
101
- const edgeTypes = [];
102
-
103
- for (const record of relTypesResult.records) {
104
- const type = record.get(0);
105
- // Get properties for this relationship type
106
- const propsResult = await session.run(
107
- `MATCH ()-[r:\`${type}\`]->() UNWIND keys(r) AS key RETURN DISTINCT key LIMIT 20`
108
- );
109
- const properties = propsResult.records.map(r => r.get(0));
110
- edgeTypes.push({ type, properties, count: null });
111
- }
112
-
113
- model.set("schema_node_types", nodeTypes);
114
- model.set("schema_edge_types", edgeTypes);
115
- model.save_changes();
116
- } catch (error) {
117
- console.error("Failed to fetch schema:", error);
118
- } finally {
119
- await session.close();
120
- }
121
- }
122
-
123
- async function disconnectNeo4j(model) {
124
- if (driver) {
125
- await driver.close();
126
- driver = null;
127
- }
128
- model.set("connection_status", "disconnected");
129
- model.save_changes();
130
- }
131
-
132
- async function executeNeo4jQuery(query, database, model) {
133
- if (!driver) {
134
- model.set("query_error", "Not connected to database");
135
- model.save_changes();
136
- return null;
137
- }
138
-
139
- model.set("query_running", true);
140
- model.set("query_error", "");
141
- model.save_changes();
142
-
143
- const session = driver.session({ database: database || "neo4j" });
144
-
145
- try {
146
- const result = await session.run(query);
147
- model.set("query_running", false);
148
- model.save_changes();
149
- return processNeo4jRecords(result.records);
150
- } catch (error) {
151
- model.set("query_running", false);
152
- model.set("query_error", "Query error: " + error.message);
153
- model.save_changes();
154
- return null;
155
- } finally {
156
- await session.close();
157
- }
158
- }
159
-
160
- function processNeo4jRecords(records) {
161
- const nodes = new Map();
162
- const edges = [];
163
-
164
- for (const record of records) {
165
- for (const key of record.keys) {
166
- const value = record.get(key);
167
- processNeo4jValue(value, nodes, edges);
168
- }
169
- }
170
-
171
- return { nodes: Array.from(nodes.values()), edges };
172
- }
173
-
174
- function processNeo4jValue(value, nodes, edges) {
175
- if (!value) return;
176
-
177
- if (neo4j.isNode(value)) {
178
- const nodeId = value.elementId || value.identity.toString();
179
- if (!nodes.has(nodeId)) {
180
- const props = {};
181
- for (const [k, v] of Object.entries(value.properties)) {
182
- props[k] = neo4j.isInt(v) ? v.toNumber() : v;
183
- }
184
- nodes.set(nodeId, {
185
- id: nodeId,
186
- label: props.name || props.title || value.labels[0] || nodeId,
187
- labels: value.labels,
188
- ...props,
189
- });
190
- }
191
- } else if (neo4j.isRelationship(value)) {
192
- const props = {};
193
- for (const [k, v] of Object.entries(value.properties)) {
194
- props[k] = neo4j.isInt(v) ? v.toNumber() : v;
195
- }
196
- edges.push({
197
- source: value.startNodeElementId || value.start.toString(),
198
- target: value.endNodeElementId || value.end.toString(),
199
- label: value.type,
200
- ...props,
201
- });
202
- } else if (neo4j.isPath(value)) {
203
- for (const segment of value.segments) {
204
- processNeo4jValue(segment.start, nodes, edges);
205
- processNeo4jValue(segment.end, nodes, edges);
206
- processNeo4jValue(segment.relationship, nodes, edges);
207
- }
208
- } else if (Array.isArray(value)) {
209
- for (const item of value) {
210
- processNeo4jValue(item, nodes, edges);
211
- }
212
- }
213
- }
214
-
215
- // === UI COMPONENTS ===
216
- function createToolbar(model, wrapper) {
217
- const toolbar = document.createElement("div");
218
- toolbar.className = "awg-toolbar";
219
-
220
- // Schema sidebar toggle (left)
221
- const schemaBtn = document.createElement("button");
222
- schemaBtn.className = "awg-btn";
223
- schemaBtn.innerHTML = ICONS.sidebar;
224
- schemaBtn.title = "Toggle schema browser";
225
- schemaBtn.addEventListener("click", () => toggleSchemaPanel(wrapper));
226
- toolbar.appendChild(schemaBtn);
227
-
228
- // Query container (collapsible input)
229
- if (model.get("show_query_input")) {
230
- const queryContainer = document.createElement("div");
231
- queryContainer.className = "awg-query-container";
232
-
233
- const queryInput = document.createElement("input");
234
- queryInput.type = "text";
235
- queryInput.className = "awg-query-input";
236
- queryInput.placeholder = "Enter query (e.g., MATCH (n) RETURN n LIMIT 25)";
237
- queryInput.value = model.get("query") || "";
238
-
239
- queryInput.addEventListener("input", (e) => {
240
- model.set("query", e.target.value);
241
- model.save_changes();
242
- });
243
-
244
- queryInput.addEventListener("keydown", (e) => {
245
- if (e.key === "Enter" && !e.shiftKey) {
246
- e.preventDefault();
247
- executeQuery(model);
248
- }
249
- });
250
-
251
- model.on("change:query", () => {
252
- if (queryInput.value !== model.get("query")) {
253
- queryInput.value = model.get("query") || "";
254
- }
255
- });
256
-
257
- queryContainer.appendChild(queryInput);
258
-
259
- // Play button
260
- const playBtn = document.createElement("button");
261
- playBtn.className = "awg-btn awg-btn-primary";
262
- playBtn.innerHTML = ICONS.play;
263
- playBtn.title = "Run query";
264
- playBtn.addEventListener("click", () => executeQuery(model));
265
-
266
- model.on("change:query_running", () => {
267
- playBtn.disabled = model.get("query_running");
268
- playBtn.innerHTML = model.get("query_running")
269
- ? '<span class="awg-spinner"></span>'
270
- : ICONS.play;
271
- });
272
-
273
- queryContainer.appendChild(playBtn);
274
- toolbar.appendChild(queryContainer);
275
- }
276
-
277
- // Settings button
278
- if (model.get("show_settings")) {
279
- const settingsBtn = document.createElement("button");
280
- settingsBtn.className = "awg-btn";
281
- settingsBtn.innerHTML = ICONS.settings;
282
- settingsBtn.title = "Connection settings";
283
-
284
- // Status dot
285
- const statusDot = document.createElement("span");
286
- statusDot.className = "awg-status-dot";
287
- updateStatusDot(statusDot, model.get("connection_status"));
288
- settingsBtn.appendChild(statusDot);
289
-
290
- model.on("change:connection_status", () => {
291
- updateStatusDot(statusDot, model.get("connection_status"));
292
- });
293
-
294
- settingsBtn.addEventListener("click", () => {
295
- toggleSettingsPanel(wrapper);
296
- });
297
-
298
- toolbar.appendChild(settingsBtn);
299
- }
300
-
301
- // Properties panel toggle (right)
302
- const propsBtn = document.createElement("button");
303
- propsBtn.className = "awg-btn";
304
- propsBtn.innerHTML = ICONS.property;
305
- propsBtn.title = "Toggle properties panel";
306
- propsBtn.addEventListener("click", () => togglePropertiesPanel(wrapper));
307
- toolbar.appendChild(propsBtn);
308
-
309
- return toolbar;
310
- }
311
-
312
- function updateStatusDot(dot, status) {
313
- dot.className = "awg-status-dot awg-status-" + status;
314
- }
315
-
316
- function toggleSettingsPanel(wrapper) {
317
- const panel = wrapper.querySelector(".awg-settings-panel");
318
- if (panel) {
319
- panel.classList.toggle("awg-panel-open");
320
- }
321
- }
322
-
323
- function toggleSchemaPanel(wrapper) {
324
- const panel = wrapper.querySelector(".awg-schema-panel");
325
- if (panel) {
326
- panel.classList.toggle("awg-panel-open");
327
- }
328
- }
329
-
330
- function createSchemaSidebar(model) {
331
- const panel = document.createElement("div");
332
- panel.className = "awg-schema-panel";
333
-
334
- // Header
335
- const header = document.createElement("div");
336
- header.className = "awg-panel-header";
337
- header.innerHTML = `<span>Schema</span>`;
338
-
339
- const refreshBtn = document.createElement("button");
340
- refreshBtn.className = "awg-btn awg-btn-icon awg-btn-sm";
341
- refreshBtn.innerHTML = ICONS.refresh;
342
- refreshBtn.title = "Refresh schema";
343
- refreshBtn.addEventListener("click", () => fetchSchema(model));
344
- header.appendChild(refreshBtn);
345
-
346
- panel.appendChild(header);
347
-
348
- // Content
349
- const content = document.createElement("div");
350
- content.className = "awg-schema-content";
351
-
352
- function renderSchema() {
353
- content.innerHTML = "";
354
-
355
- const nodeTypes = model.get("schema_node_types") || [];
356
- const edgeTypes = model.get("schema_edge_types") || [];
357
-
358
- if (nodeTypes.length === 0 && edgeTypes.length === 0) {
359
- const empty = document.createElement("div");
360
- empty.className = "awg-schema-empty";
361
- empty.textContent = "Connect to load schema";
362
- content.appendChild(empty);
363
- return;
364
- }
365
-
366
- // Node types section
367
- if (nodeTypes.length > 0) {
368
- const nodeSection = document.createElement("div");
369
- nodeSection.className = "awg-schema-section";
370
-
371
- const nodeHeader = document.createElement("div");
372
- nodeHeader.className = "awg-schema-section-header";
373
- nodeHeader.innerHTML = `${ICONS.node} <span>Node Labels</span>`;
374
- nodeSection.appendChild(nodeHeader);
375
-
376
- nodeTypes.forEach(({ label, properties }) => {
377
- const item = createSchemaItem(label, properties, "node", model);
378
- nodeSection.appendChild(item);
379
- });
380
-
381
- content.appendChild(nodeSection);
382
- }
383
-
384
- // Edge types section
385
- if (edgeTypes.length > 0) {
386
- const edgeSection = document.createElement("div");
387
- edgeSection.className = "awg-schema-section";
388
-
389
- const edgeHeader = document.createElement("div");
390
- edgeHeader.className = "awg-schema-section-header";
391
- edgeHeader.innerHTML = `${ICONS.edge} <span>Relationships</span>`;
392
- edgeSection.appendChild(edgeHeader);
393
-
394
- edgeTypes.forEach(({ type, properties }) => {
395
- const item = createSchemaItem(type, properties, "edge", model);
396
- edgeSection.appendChild(item);
397
- });
398
-
399
- content.appendChild(edgeSection);
400
- }
401
- }
402
-
403
- model.on("change:schema_node_types", renderSchema);
404
- model.on("change:schema_edge_types", renderSchema);
405
- renderSchema();
406
-
407
- panel.appendChild(content);
408
- return panel;
409
- }
410
-
411
- function createSchemaItem(name, properties, type, model) {
412
- const item = document.createElement("div");
413
- item.className = "awg-schema-item";
414
-
415
- const itemHeader = document.createElement("div");
416
- itemHeader.className = "awg-schema-item-header";
417
-
418
- const expandBtn = document.createElement("span");
419
- expandBtn.className = "awg-schema-expand";
420
- expandBtn.innerHTML = ICONS.chevronRight;
421
-
422
- const nameSpan = document.createElement("span");
423
- nameSpan.className = "awg-schema-name";
424
- nameSpan.textContent = name;
425
-
426
- itemHeader.appendChild(expandBtn);
427
- itemHeader.appendChild(nameSpan);
428
-
429
- // Click on name to query
430
- nameSpan.addEventListener("click", (e) => {
431
- e.stopPropagation();
432
- let query;
433
- if (type === "node") {
434
- query = `MATCH (n:\`${name}\`) RETURN n LIMIT 25`;
435
- } else {
436
- query = `MATCH (a)-[r:\`${name}\`]->(b) RETURN a, r, b LIMIT 25`;
437
- }
438
- model.set("query", query);
439
- model.save_changes();
440
- executeQuery(model);
441
- });
442
-
443
- // Properties list (collapsed by default)
444
- const propsList = document.createElement("div");
445
- propsList.className = "awg-schema-props";
446
-
447
- if (properties && properties.length > 0) {
448
- properties.forEach(prop => {
449
- const propItem = document.createElement("div");
450
- propItem.className = "awg-schema-prop";
451
- propItem.innerHTML = `${ICONS.property} <span>${prop}</span>`;
452
-
453
- // Click on property to query with that property
454
- propItem.addEventListener("click", (e) => {
455
- e.stopPropagation();
456
- let query;
457
- if (type === "node") {
458
- query = `MATCH (n:\`${name}\`) RETURN n.\`${prop}\` AS ${prop}, n LIMIT 25`;
459
- } else {
460
- query = `MATCH (a)-[r:\`${name}\`]->(b) RETURN r.\`${prop}\` AS ${prop}, a, r, b LIMIT 25`;
461
- }
462
- model.set("query", query);
463
- model.save_changes();
464
- executeQuery(model);
465
- });
466
-
467
- propsList.appendChild(propItem);
468
- });
469
- } else {
470
- const noProp = document.createElement("div");
471
- noProp.className = "awg-schema-prop awg-schema-prop-empty";
472
- noProp.textContent = "No properties";
473
- propsList.appendChild(noProp);
474
- }
475
-
476
- item.appendChild(itemHeader);
477
- item.appendChild(propsList);
478
-
479
- // Toggle expand/collapse
480
- itemHeader.addEventListener("click", () => {
481
- item.classList.toggle("awg-schema-item-expanded");
482
- });
483
-
484
- return item;
485
- }
486
-
487
- function createSettingsPanel(model) {
488
- const panel = document.createElement("div");
489
- panel.className = "awg-settings-panel";
490
-
491
- // Header
492
- const header = document.createElement("div");
493
- header.className = "awg-panel-header";
494
- header.innerHTML = "<span>Settings</span>";
495
- panel.appendChild(header);
496
-
497
- // Form
498
- const form = document.createElement("div");
499
- form.className = "awg-panel-form";
500
-
501
- // Dark mode toggle
502
- form.appendChild(createFormGroup("Theme", () => {
503
- const toggle = document.createElement("label");
504
- toggle.className = "awg-toggle";
505
- const checkbox = document.createElement("input");
506
- checkbox.type = "checkbox";
507
- checkbox.checked = model.get("dark_mode");
508
- checkbox.addEventListener("change", (e) => {
509
- model.set("dark_mode", e.target.checked);
510
- model.save_changes();
511
- });
512
- model.on("change:dark_mode", () => {
513
- checkbox.checked = model.get("dark_mode");
514
- });
515
- const slider = document.createElement("span");
516
- slider.className = "awg-toggle-slider";
517
- const label = document.createElement("span");
518
- label.className = "awg-toggle-label";
519
- label.textContent = "Dark mode";
520
- toggle.appendChild(checkbox);
521
- toggle.appendChild(slider);
522
- toggle.appendChild(label);
523
- return toggle;
524
- }));
525
-
526
- // Backend selector
527
- form.appendChild(createFormGroup("Backend", () => {
528
- const select = document.createElement("select");
529
- select.className = "awg-select";
530
- BACKENDS.forEach(b => {
531
- const opt = document.createElement("option");
532
- opt.value = b.id;
533
- opt.textContent = b.name;
534
- opt.selected = model.get("database_backend") === b.id;
535
- select.appendChild(opt);
536
- });
537
- select.addEventListener("change", (e) => {
538
- model.set("database_backend", e.target.value);
539
- model.save_changes();
540
- updateFormVisibility();
541
- });
542
- return select;
543
- }));
544
-
545
- // Neo4j fields container
546
- const neo4jFields = document.createElement("div");
547
- neo4jFields.className = "awg-neo4j-fields";
548
-
549
- // URI
550
- neo4jFields.appendChild(createFormGroup("URI", () => {
551
- const input = document.createElement("input");
552
- input.type = "text";
553
- input.className = "awg-input";
554
- input.placeholder = "neo4j+s://localhost:7687";
555
- input.value = model.get("connection_uri") || "";
556
- input.addEventListener("input", (e) => {
557
- model.set("connection_uri", e.target.value);
558
- model.save_changes();
559
- });
560
- return input;
561
- }));
562
-
563
- // Username
564
- neo4jFields.appendChild(createFormGroup("Username", () => {
565
- const input = document.createElement("input");
566
- input.type = "text";
567
- input.className = "awg-input";
568
- input.placeholder = "neo4j";
569
- input.value = model.get("connection_username") || "";
570
- input.addEventListener("input", (e) => {
571
- model.set("connection_username", e.target.value);
572
- model.save_changes();
573
- });
574
- return input;
575
- }));
576
-
577
- // Password
578
- neo4jFields.appendChild(createFormGroup("Password", () => {
579
- const input = document.createElement("input");
580
- input.type = "password";
581
- input.className = "awg-input";
582
- input.value = model.get("connection_password") || "";
583
- input.addEventListener("input", (e) => {
584
- model.set("connection_password", e.target.value);
585
- model.save_changes();
586
- });
587
- return input;
588
- }));
589
-
590
- form.appendChild(neo4jFields);
591
-
592
- // Database name
593
- form.appendChild(createFormGroup("Database", () => {
594
- const input = document.createElement("input");
595
- input.type = "text";
596
- input.className = "awg-input";
597
- input.placeholder = "neo4j";
598
- input.value = model.get("connection_database") || "neo4j";
599
- input.addEventListener("input", (e) => {
600
- model.set("connection_database", e.target.value);
601
- model.save_changes();
602
- });
603
- return input;
604
- }));
605
-
606
- // Query language
607
- form.appendChild(createFormGroup("Language", () => {
608
- const select = document.createElement("select");
609
- select.className = "awg-select";
610
- QUERY_LANGUAGES.forEach(lang => {
611
- const opt = document.createElement("option");
612
- opt.value = lang.id;
613
- opt.textContent = lang.name;
614
- opt.disabled = !lang.enabled;
615
- opt.selected = model.get("query_language") === lang.id;
616
- select.appendChild(opt);
617
- });
618
- select.addEventListener("change", (e) => {
619
- model.set("query_language", e.target.value);
620
- model.save_changes();
621
- });
622
- return select;
623
- }));
624
-
625
- panel.appendChild(form);
626
-
627
- // Connect button
628
- const actions = document.createElement("div");
629
- actions.className = "awg-panel-actions";
630
-
631
- const connectBtn = document.createElement("button");
632
- connectBtn.className = "awg-btn awg-btn-primary awg-btn-full";
633
-
634
- const statusIndicator = document.createElement("div");
635
- statusIndicator.className = "awg-connection-status";
636
-
637
- function updateConnectButton() {
638
- const status = model.get("connection_status");
639
- const backend = model.get("database_backend");
640
-
641
- if (backend === "grafeo") {
642
- connectBtn.textContent = "Python Backend";
643
- connectBtn.disabled = true;
644
- statusIndicator.innerHTML = '<span class="awg-status-dot-inline awg-status-connected"></span> Grafeo';
645
- } else if (status === "connected") {
646
- connectBtn.textContent = "Disconnect";
647
- connectBtn.disabled = false;
648
- statusIndicator.innerHTML = '<span class="awg-status-dot-inline awg-status-connected"></span> Connected';
649
- } else if (status === "connecting") {
650
- connectBtn.textContent = "Connecting...";
651
- connectBtn.disabled = true;
652
- statusIndicator.innerHTML = '<span class="awg-status-dot-inline awg-status-connecting"></span> Connecting';
653
- } else if (status === "error") {
654
- connectBtn.textContent = "Retry";
655
- connectBtn.disabled = false;
656
- statusIndicator.innerHTML = '<span class="awg-status-dot-inline awg-status-error"></span> Error';
657
- } else {
658
- connectBtn.textContent = "Connect";
659
- connectBtn.disabled = false;
660
- statusIndicator.innerHTML = '<span class="awg-status-dot-inline awg-status-disconnected"></span> Disconnected';
661
- }
662
- }
663
-
664
- function updateFormVisibility() {
665
- const backend = model.get("database_backend");
666
- neo4jFields.style.display = backend === "neo4j" ? "block" : "none";
667
- updateConnectButton();
668
- }
669
-
670
- connectBtn.addEventListener("click", async () => {
671
- const backend = model.get("database_backend");
672
- if (backend !== "neo4j") return;
673
-
674
- const status = model.get("connection_status");
675
- if (status === "connected") {
676
- await disconnectNeo4j(model);
677
- } else {
678
- await connectNeo4j(
679
- model.get("connection_uri"),
680
- model.get("connection_username"),
681
- model.get("connection_password"),
682
- model
683
- );
684
- }
685
- });
686
-
687
- model.on("change:connection_status", updateConnectButton);
688
- model.on("change:database_backend", updateFormVisibility);
689
-
690
- actions.appendChild(statusIndicator);
691
- actions.appendChild(connectBtn);
692
- panel.appendChild(actions);
693
-
694
- // Error display
695
- const errorDiv = document.createElement("div");
696
- errorDiv.className = "awg-panel-error";
697
- function updateError() {
698
- const err = model.get("query_error");
699
- errorDiv.textContent = err || "";
700
- errorDiv.style.display = err ? "block" : "none";
701
- }
702
- model.on("change:query_error", updateError);
703
- updateError();
704
- panel.appendChild(errorDiv);
705
-
706
- // Initialize
707
- updateFormVisibility();
708
-
709
- return panel;
710
- }
711
-
712
- function createFormGroup(label, inputFn) {
713
- const group = document.createElement("div");
714
- group.className = "awg-form-group";
715
-
716
- const labelEl = document.createElement("label");
717
- labelEl.className = "awg-label";
718
- labelEl.textContent = label;
719
- group.appendChild(labelEl);
720
-
721
- group.appendChild(inputFn());
722
- return group;
723
- }
724
-
725
- function createPropertiesPanel(model) {
726
- const panel = document.createElement("div");
727
- panel.className = "awg-properties-panel";
728
-
729
- // Header
730
- const header = document.createElement("div");
731
- header.className = "awg-panel-header";
732
- header.innerHTML = "<span>Properties</span>";
733
- panel.appendChild(header);
734
-
735
- // Content
736
- const content = document.createElement("div");
737
- content.className = "awg-properties-content";
738
-
739
- function renderProperties() {
740
- content.innerHTML = "";
741
-
742
- const selectedNode = model.get("selected_node");
743
- const selectedEdge = model.get("selected_edge");
744
-
745
- if (!selectedNode && !selectedEdge) {
746
- const empty = document.createElement("div");
747
- empty.className = "awg-properties-empty";
748
- empty.textContent = "Click a node or edge to view properties";
749
- content.appendChild(empty);
750
- return;
751
- }
752
-
753
- if (selectedNode) {
754
- // Node header
755
- const nodeHeader = document.createElement("div");
756
- nodeHeader.className = "awg-properties-header";
757
- nodeHeader.innerHTML = `${ICONS.node} <span class="awg-properties-type">Node</span>`;
758
- content.appendChild(nodeHeader);
759
-
760
- // Node ID
761
- const idItem = document.createElement("div");
762
- idItem.className = "awg-property-item";
763
- idItem.innerHTML = `<span class="awg-property-key">id</span><span class="awg-property-value">${selectedNode.id || "N/A"}</span>`;
764
- content.appendChild(idItem);
765
-
766
- // Labels
767
- if (selectedNode.labels && selectedNode.labels.length > 0) {
768
- const labelsItem = document.createElement("div");
769
- labelsItem.className = "awg-property-item";
770
- labelsItem.innerHTML = `<span class="awg-property-key">labels</span><span class="awg-property-value awg-property-labels">${selectedNode.labels.map(l => `<span class="awg-label-tag">${l}</span>`).join("")}</span>`;
771
- content.appendChild(labelsItem);
772
- }
773
-
774
- // Other properties
775
- Object.entries(selectedNode).forEach(([key, value]) => {
776
- if (key === "id" || key === "labels" || key === "label" || key === "x" || key === "y" || key === "size" || key === "color") return;
777
- const item = document.createElement("div");
778
- item.className = "awg-property-item";
779
- const displayValue = typeof value === "object" ? JSON.stringify(value) : String(value);
780
- item.innerHTML = `<span class="awg-property-key">${key}</span><span class="awg-property-value">${displayValue}</span>`;
781
- content.appendChild(item);
782
- });
783
- }
784
-
785
- if (selectedEdge) {
786
- // Edge header
787
- const edgeHeader = document.createElement("div");
788
- edgeHeader.className = "awg-properties-header";
789
- edgeHeader.innerHTML = `${ICONS.edge} <span class="awg-properties-type">Relationship</span>`;
790
- content.appendChild(edgeHeader);
791
-
792
- // Edge type/label
793
- if (selectedEdge.label) {
794
- const typeItem = document.createElement("div");
795
- typeItem.className = "awg-property-item";
796
- typeItem.innerHTML = `<span class="awg-property-key">type</span><span class="awg-property-value"><span class="awg-edge-tag">${selectedEdge.label}</span></span>`;
797
- content.appendChild(typeItem);
798
- }
799
-
800
- // Source/Target
801
- const sourceItem = document.createElement("div");
802
- sourceItem.className = "awg-property-item";
803
- sourceItem.innerHTML = `<span class="awg-property-key">source</span><span class="awg-property-value awg-property-truncate">${selectedEdge.source}</span>`;
804
- content.appendChild(sourceItem);
805
-
806
- const targetItem = document.createElement("div");
807
- targetItem.className = "awg-property-item";
808
- targetItem.innerHTML = `<span class="awg-property-key">target</span><span class="awg-property-value awg-property-truncate">${selectedEdge.target}</span>`;
809
- content.appendChild(targetItem);
810
-
811
- // Other properties
812
- Object.entries(selectedEdge).forEach(([key, value]) => {
813
- if (key === "source" || key === "target" || key === "label" || key === "size" || key === "color") return;
814
- const item = document.createElement("div");
815
- item.className = "awg-property-item";
816
- const displayValue = typeof value === "object" ? JSON.stringify(value) : String(value);
817
- item.innerHTML = `<span class="awg-property-key">${key}</span><span class="awg-property-value">${displayValue}</span>`;
818
- content.appendChild(item);
819
- });
820
- }
821
- }
822
-
823
- model.on("change:selected_node", renderProperties);
824
- model.on("change:selected_edge", renderProperties);
825
- renderProperties();
826
-
827
- panel.appendChild(content);
828
- return panel;
829
- }
830
-
831
- function togglePropertiesPanel(wrapper) {
832
- const panel = wrapper.querySelector(".awg-properties-panel");
833
- if (panel) {
834
- panel.classList.toggle("awg-panel-open");
835
- }
836
- }
837
-
838
- async function executeQuery(model) {
839
- const backend = model.get("database_backend");
840
- const query = model.get("query");
841
-
842
- if (!query.trim()) {
843
- model.set("query_error", "Please enter a query");
844
- model.save_changes();
845
- return;
846
- }
847
-
848
- if (backend === "neo4j") {
849
- const result = await executeNeo4jQuery(
850
- query,
851
- model.get("connection_database"),
852
- model
853
- );
854
-
855
- if (result) {
856
- model.set("nodes", result.nodes);
857
- model.set("edges", result.edges);
858
- model.save_changes();
859
- }
860
- } else if (backend === "grafeo") {
861
- // Trigger Python-side execution
862
- model.set("_execute_query", model.get("_execute_query") + 1);
863
- model.save_changes();
864
- }
865
- }
866
-
867
- // === MAIN RENDER FUNCTION ===
868
- function render({ model, el }) {
869
- const wrapper = document.createElement("div");
870
- wrapper.className = "awg-wrapper";
871
-
872
- // Apply dark mode
873
- function updateTheme() {
874
- wrapper.classList.toggle("awg-dark", model.get("dark_mode"));
875
- }
876
- updateTheme();
877
- model.on("change:dark_mode", updateTheme);
878
-
879
- // Create toolbar if enabled
880
- if (model.get("show_toolbar")) {
881
- const toolbar = createToolbar(model, wrapper);
882
- wrapper.appendChild(toolbar);
883
- }
884
-
885
- // Main content area (schema + graph + properties)
886
- const content = document.createElement("div");
887
- content.className = "awg-content";
888
-
889
- // Create schema sidebar (left) - collapsed by default
890
- const schemaSidebar = createSchemaSidebar(model);
891
- content.appendChild(schemaSidebar);
892
-
893
- // Create graph container
894
- const container = document.createElement("div");
895
- container.className = "awg-graph-container";
896
- container.style.width = model.get("width") + "px";
897
- container.style.height = model.get("height") + "px";
898
- content.appendChild(container);
899
-
900
- // Create properties panel (right) - collapsed by default
901
- const propertiesPanel = createPropertiesPanel(model);
902
- content.appendChild(propertiesPanel);
903
-
904
- // Create settings panel if enabled (inside properties panel area)
905
- if (model.get("show_settings")) {
906
- const settingsPanel = createSettingsPanel(model);
907
- content.appendChild(settingsPanel);
908
- }
909
-
910
- wrapper.appendChild(content);
911
- el.appendChild(wrapper);
912
-
913
- const graph = new Graph();
914
-
915
- const nodes = model.get("nodes") || [];
916
- nodes.forEach((node) => {
917
- graph.addNode(node.id, {
918
- label: node.label || node.id,
919
- x: node.x ?? Math.random() * 100,
920
- y: node.y ?? Math.random() * 100,
921
- size: node.size || 10,
922
- color: node.color || "#6366f1",
923
- });
924
- });
925
-
926
- const edges = model.get("edges") || [];
927
- edges.forEach((edge, i) => {
928
- graph.addEdge(edge.source, edge.target, {
929
- label: edge.label || "",
930
- size: edge.size || 2,
931
- color: edge.color || "#94a3b8",
932
- });
933
- });
934
-
935
- const renderer = new Sigma(graph, container, {
936
- renderLabels: model.get("show_labels"),
937
- renderEdgeLabels: model.get("show_edge_labels"),
938
- defaultNodeColor: "#6366f1",
939
- defaultEdgeColor: "#94a3b8",
940
- labelColor: { color: "#333" },
941
- labelSize: 12,
942
- labelWeight: "500",
943
- });
944
-
945
- renderer.on("clickNode", ({ node }) => {
946
- const nodeData = graph.getNodeAttributes(node);
947
- model.set("selected_node", { id: node, ...nodeData });
948
- model.save_changes();
949
- });
950
-
951
- renderer.on("clickEdge", ({ edge }) => {
952
- const edgeData = graph.getEdgeAttributes(edge);
953
- const [source, target] = graph.extremities(edge);
954
- model.set("selected_edge", { source, target, ...edgeData });
955
- model.save_changes();
956
- });
957
-
958
- renderer.on("clickStage", () => {
959
- model.set("selected_node", null);
960
- model.set("selected_edge", null);
961
- model.save_changes();
962
- });
963
-
964
- model.on("change:nodes", () => {
965
- graph.clear();
966
- const newNodes = model.get("nodes") || [];
967
- newNodes.forEach((node) => {
968
- graph.addNode(node.id, {
969
- label: node.label || node.id,
970
- x: node.x ?? Math.random() * 100,
971
- y: node.y ?? Math.random() * 100,
972
- size: node.size || 10,
973
- color: node.color || "#6366f1",
974
- });
975
- });
976
- const newEdges = model.get("edges") || [];
977
- newEdges.forEach((edge) => {
978
- graph.addEdge(edge.source, edge.target, {
979
- label: edge.label || "",
980
- size: edge.size || 2,
981
- color: edge.color || "#94a3b8",
982
- });
983
- });
984
- renderer.refresh();
985
- });
986
-
987
- model.on("change:edges", () => {
988
- graph.clearEdges();
989
- const newEdges = model.get("edges") || [];
990
- newEdges.forEach((edge) => {
991
- graph.addEdge(edge.source, edge.target, {
992
- label: edge.label || "",
993
- size: edge.size || 2,
994
- color: edge.color || "#94a3b8",
995
- });
996
- });
997
- renderer.refresh();
998
- });
999
-
1000
- return () => {
1001
- renderer.kill();
1002
- };
1003
- }
1004
-
1005
- export default { render };
1006
- """
1007
-
1008
- _CSS = """
1009
- /* === CSS VARIABLES === */
1010
- .awg-wrapper {
1011
- --awg-bg: #ffffff;
1012
- --awg-bg-secondary: #f8f9fa;
1013
- --awg-bg-tertiary: #f3f4f6;
1014
- --awg-border: #e5e7eb;
1015
- --awg-text: #111827;
1016
- --awg-text-secondary: #6b7280;
1017
- --awg-text-muted: #9ca3af;
1018
- --awg-accent: #6366f1;
1019
- --awg-accent-hover: #4f46e5;
1020
- --awg-graph-bg: #fafafa;
1021
- --awg-input-bg: #ffffff;
1022
- --awg-input-border: #d1d5db;
1023
- }
1024
-
1025
- .awg-wrapper.awg-dark {
1026
- --awg-bg: #1a1a2e;
1027
- --awg-bg-secondary: #16213e;
1028
- --awg-bg-tertiary: #0f3460;
1029
- --awg-border: #2d3748;
1030
- --awg-text: #e2e8f0;
1031
- --awg-text-secondary: #a0aec0;
1032
- --awg-text-muted: #718096;
1033
- --awg-accent: #818cf8;
1034
- --awg-accent-hover: #6366f1;
1035
- --awg-graph-bg: #0f0f1a;
1036
- --awg-input-bg: #1e1e32;
1037
- --awg-input-border: #3d4a5c;
1038
- }
1039
-
1040
- /* === BASE === */
1041
- .awg-wrapper {
1042
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
1043
- display: flex;
1044
- flex-direction: column;
1045
- border: 1px solid var(--awg-border);
1046
- border-radius: 8px;
1047
- overflow: hidden;
1048
- background: var(--awg-bg);
1049
- color: var(--awg-text);
1050
- }
1051
-
1052
- .awg-content {
1053
- display: flex;
1054
- position: relative;
1055
- }
1056
-
1057
- /* === TOOLBAR === */
1058
- .awg-toolbar {
1059
- display: flex;
1060
- align-items: center;
1061
- gap: 8px;
1062
- padding: 8px 12px;
1063
- background: var(--awg-bg-secondary);
1064
- border-bottom: 1px solid var(--awg-border);
1065
- min-height: 44px;
1066
- }
1067
-
1068
- .awg-query-container {
1069
- flex: 1;
1070
- display: flex;
1071
- align-items: center;
1072
- gap: 8px;
1073
- }
1074
-
1075
- .awg-query-input {
1076
- flex: 1;
1077
- padding: 8px 12px;
1078
- border: 1px solid var(--awg-input-border);
1079
- border-radius: 6px;
1080
- font-family: "Monaco", "Menlo", "Ubuntu Mono", "Consolas", monospace;
1081
- font-size: 13px;
1082
- background: var(--awg-input-bg);
1083
- color: var(--awg-text);
1084
- transition: border-color 0.15s, box-shadow 0.15s;
1085
- }
1086
-
1087
- .awg-query-input:focus {
1088
- outline: none;
1089
- border-color: var(--awg-accent);
1090
- box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2);
1091
- }
1092
-
1093
- .awg-query-input::placeholder {
1094
- color: var(--awg-text-muted);
1095
- }
1096
-
1097
- /* === BUTTONS === */
1098
- .awg-btn {
1099
- display: inline-flex;
1100
- align-items: center;
1101
- justify-content: center;
1102
- min-width: 36px;
1103
- height: 36px;
1104
- padding: 0 10px;
1105
- border: 1px solid var(--awg-input-border);
1106
- border-radius: 6px;
1107
- background: var(--awg-input-bg);
1108
- color: var(--awg-text);
1109
- cursor: pointer;
1110
- transition: all 0.15s;
1111
- position: relative;
1112
- font-size: 13px;
1113
- }
1114
-
1115
- .awg-btn:hover {
1116
- background: var(--awg-bg-tertiary);
1117
- border-color: var(--awg-text-muted);
1118
- }
1119
-
1120
- .awg-btn:disabled {
1121
- opacity: 0.5;
1122
- cursor: not-allowed;
1123
- }
1124
-
1125
- .awg-btn-primary {
1126
- background: var(--awg-accent);
1127
- border-color: var(--awg-accent);
1128
- color: #fff;
1129
- }
1130
-
1131
- .awg-btn-primary:hover {
1132
- background: var(--awg-accent-hover);
1133
- border-color: var(--awg-accent-hover);
1134
- }
1135
-
1136
- .awg-btn-primary:disabled {
1137
- background: var(--awg-text-muted);
1138
- border-color: var(--awg-text-muted);
1139
- }
1140
-
1141
- .awg-btn-full {
1142
- width: 100%;
1143
- }
1144
-
1145
- /* === STATUS DOTS === */
1146
- .awg-status-dot {
1147
- position: absolute;
1148
- top: 4px;
1149
- right: 4px;
1150
- width: 8px;
1151
- height: 8px;
1152
- border-radius: 50%;
1153
- border: 1.5px solid var(--awg-bg);
1154
- }
1155
-
1156
- .awg-status-dot-inline {
1157
- display: inline-block;
1158
- width: 8px;
1159
- height: 8px;
1160
- border-radius: 50%;
1161
- margin-right: 6px;
1162
- }
1163
-
1164
- .awg-status-disconnected { background: #6b7280; }
1165
- .awg-status-connecting { background: #f59e0b; animation: awg-pulse 1s infinite; }
1166
- .awg-status-connected { background: #22c55e; }
1167
- .awg-status-error { background: #ef4444; }
1168
-
1169
- @keyframes awg-pulse {
1170
- 0%, 100% { opacity: 1; }
1171
- 50% { opacity: 0.4; }
1172
- }
1173
-
1174
- /* === SPINNER === */
1175
- .awg-spinner {
1176
- width: 14px;
1177
- height: 14px;
1178
- border: 2px solid rgba(255,255,255,0.3);
1179
- border-top-color: #fff;
1180
- border-radius: 50%;
1181
- animation: awg-spin 0.8s linear infinite;
1182
- }
1183
-
1184
- @keyframes awg-spin {
1185
- to { transform: rotate(360deg); }
1186
- }
1187
-
1188
- /* === GRAPH CONTAINER === */
1189
- .awg-graph-container {
1190
- flex: 1;
1191
- background: var(--awg-graph-bg);
1192
- }
1193
-
1194
- /* === SETTINGS PANEL === */
1195
- .awg-settings-panel {
1196
- width: 0;
1197
- overflow: hidden;
1198
- background: var(--awg-bg-secondary);
1199
- border-left: 1px solid var(--awg-border);
1200
- transition: width 0.2s ease;
1201
- display: flex;
1202
- flex-direction: column;
1203
- }
1204
-
1205
- .awg-settings-panel.awg-panel-open {
1206
- width: 260px;
1207
- }
1208
-
1209
- .awg-panel-header {
1210
- padding: 12px 16px;
1211
- font-weight: 600;
1212
- font-size: 13px;
1213
- text-transform: uppercase;
1214
- letter-spacing: 0.5px;
1215
- color: var(--awg-text-secondary);
1216
- border-bottom: 1px solid var(--awg-border);
1217
- }
1218
-
1219
- .awg-panel-form {
1220
- padding: 16px;
1221
- display: flex;
1222
- flex-direction: column;
1223
- gap: 14px;
1224
- flex: 1;
1225
- overflow-y: auto;
1226
- }
1227
-
1228
- .awg-panel-actions {
1229
- padding: 12px 16px;
1230
- border-top: 1px solid var(--awg-border);
1231
- display: flex;
1232
- flex-direction: column;
1233
- gap: 8px;
1234
- }
1235
-
1236
- .awg-connection-status {
1237
- font-size: 12px;
1238
- color: var(--awg-text-secondary);
1239
- display: flex;
1240
- align-items: center;
1241
- }
1242
-
1243
- .awg-panel-error {
1244
- padding: 10px 16px;
1245
- background: rgba(239, 68, 68, 0.1);
1246
- color: #ef4444;
1247
- font-size: 12px;
1248
- border-top: 1px solid rgba(239, 68, 68, 0.2);
1249
- }
1250
-
1251
- /* === FORM ELEMENTS === */
1252
- .awg-form-group {
1253
- display: flex;
1254
- flex-direction: column;
1255
- gap: 5px;
1256
- }
1257
-
1258
- .awg-label {
1259
- font-size: 11px;
1260
- font-weight: 600;
1261
- text-transform: uppercase;
1262
- letter-spacing: 0.3px;
1263
- color: var(--awg-text-secondary);
1264
- }
1265
-
1266
- .awg-input, .awg-select {
1267
- padding: 8px 10px;
1268
- border: 1px solid var(--awg-input-border);
1269
- border-radius: 5px;
1270
- font-size: 13px;
1271
- background: var(--awg-input-bg);
1272
- color: var(--awg-text);
1273
- transition: border-color 0.15s, box-shadow 0.15s;
1274
- }
1275
-
1276
- .awg-input:focus, .awg-select:focus {
1277
- outline: none;
1278
- border-color: var(--awg-accent);
1279
- box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.15);
1280
- }
1281
-
1282
- .awg-select {
1283
- cursor: pointer;
1284
- }
1285
-
1286
- .awg-select option:disabled {
1287
- color: var(--awg-text-muted);
1288
- }
1289
-
1290
- /* === TOGGLE SWITCH === */
1291
- .awg-toggle {
1292
- display: flex;
1293
- align-items: center;
1294
- cursor: pointer;
1295
- gap: 10px;
1296
- }
1297
-
1298
- .awg-toggle input {
1299
- display: none;
1300
- }
1301
-
1302
- .awg-toggle-slider {
1303
- width: 36px;
1304
- height: 20px;
1305
- background: var(--awg-input-border);
1306
- border-radius: 10px;
1307
- position: relative;
1308
- transition: background 0.2s;
1309
- }
1310
-
1311
- .awg-toggle-slider::after {
1312
- content: "";
1313
- position: absolute;
1314
- top: 2px;
1315
- left: 2px;
1316
- width: 16px;
1317
- height: 16px;
1318
- background: #fff;
1319
- border-radius: 50%;
1320
- transition: transform 0.2s;
1321
- }
1322
-
1323
- .awg-toggle input:checked + .awg-toggle-slider {
1324
- background: var(--awg-accent);
1325
- }
1326
-
1327
- .awg-toggle input:checked + .awg-toggle-slider::after {
1328
- transform: translateX(16px);
1329
- }
1330
-
1331
- .awg-toggle-label {
1332
- font-size: 13px;
1333
- color: var(--awg-text);
1334
- }
1335
-
1336
- /* === SCHEMA PANEL (LEFT) === */
1337
- .awg-schema-panel {
1338
- width: 0;
1339
- overflow: hidden;
1340
- background: var(--awg-bg-secondary);
1341
- border-right: 1px solid var(--awg-border);
1342
- transition: width 0.2s ease;
1343
- display: flex;
1344
- flex-direction: column;
1345
- }
1346
-
1347
- .awg-schema-panel.awg-panel-open {
1348
- width: 220px;
1349
- }
1350
-
1351
- .awg-schema-content {
1352
- flex: 1;
1353
- overflow-y: auto;
1354
- padding: 8px 0;
1355
- }
1356
-
1357
- .awg-schema-empty {
1358
- padding: 20px 16px;
1359
- color: var(--awg-text-muted);
1360
- font-size: 12px;
1361
- text-align: center;
1362
- }
1363
-
1364
- .awg-schema-section {
1365
- margin-bottom: 8px;
1366
- }
1367
-
1368
- .awg-schema-section-header {
1369
- display: flex;
1370
- align-items: center;
1371
- gap: 8px;
1372
- padding: 8px 16px;
1373
- font-size: 11px;
1374
- font-weight: 600;
1375
- text-transform: uppercase;
1376
- letter-spacing: 0.3px;
1377
- color: var(--awg-text-secondary);
1378
- }
1379
-
1380
- .awg-schema-item {
1381
- border-bottom: 1px solid var(--awg-border);
1382
- }
1383
-
1384
- .awg-schema-item:last-child {
1385
- border-bottom: none;
1386
- }
1387
-
1388
- .awg-schema-item-header {
1389
- display: flex;
1390
- align-items: center;
1391
- gap: 6px;
1392
- padding: 8px 16px;
1393
- cursor: pointer;
1394
- transition: background 0.1s;
1395
- }
1396
-
1397
- .awg-schema-item-header:hover {
1398
- background: var(--awg-bg-tertiary);
1399
- }
1400
-
1401
- .awg-schema-expand {
1402
- color: var(--awg-text-muted);
1403
- transition: transform 0.15s;
1404
- }
1405
-
1406
- .awg-schema-item-expanded .awg-schema-expand {
1407
- transform: rotate(90deg);
1408
- }
1409
-
1410
- .awg-schema-name {
1411
- font-size: 13px;
1412
- color: var(--awg-accent);
1413
- cursor: pointer;
1414
- flex: 1;
1415
- }
1416
-
1417
- .awg-schema-name:hover {
1418
- text-decoration: underline;
1419
- }
1420
-
1421
- .awg-schema-props {
1422
- display: none;
1423
- padding: 4px 16px 8px 32px;
1424
- }
1425
-
1426
- .awg-schema-item-expanded .awg-schema-props {
1427
- display: block;
1428
- }
1429
-
1430
- .awg-schema-prop {
1431
- display: flex;
1432
- align-items: center;
1433
- gap: 6px;
1434
- padding: 4px 8px;
1435
- font-size: 12px;
1436
- color: var(--awg-text-secondary);
1437
- cursor: pointer;
1438
- border-radius: 4px;
1439
- transition: background 0.1s;
1440
- }
1441
-
1442
- .awg-schema-prop:hover {
1443
- background: var(--awg-bg-tertiary);
1444
- color: var(--awg-text);
1445
- }
1446
-
1447
- .awg-schema-prop-empty {
1448
- color: var(--awg-text-muted);
1449
- font-style: italic;
1450
- cursor: default;
1451
- }
1452
-
1453
- .awg-schema-prop-empty:hover {
1454
- background: transparent;
1455
- color: var(--awg-text-muted);
1456
- }
1457
-
1458
- /* === PROPERTIES PANEL (RIGHT) === */
1459
- .awg-properties-panel {
1460
- width: 0;
1461
- overflow: hidden;
1462
- background: var(--awg-bg-secondary);
1463
- border-left: 1px solid var(--awg-border);
1464
- transition: width 0.2s ease;
1465
- display: flex;
1466
- flex-direction: column;
1467
- }
1468
-
1469
- .awg-properties-panel.awg-panel-open {
1470
- width: 260px;
1471
- }
1472
-
1473
- .awg-properties-content {
1474
- flex: 1;
1475
- overflow-y: auto;
1476
- padding: 8px 0;
1477
- }
1478
-
1479
- .awg-properties-empty {
1480
- padding: 20px 16px;
1481
- color: var(--awg-text-muted);
1482
- font-size: 12px;
1483
- text-align: center;
1484
- }
1485
-
1486
- .awg-properties-header {
1487
- display: flex;
1488
- align-items: center;
1489
- gap: 8px;
1490
- padding: 10px 16px;
1491
- font-size: 12px;
1492
- font-weight: 600;
1493
- color: var(--awg-text-secondary);
1494
- border-bottom: 1px solid var(--awg-border);
1495
- background: var(--awg-bg-tertiary);
1496
- }
1497
-
1498
- .awg-properties-type {
1499
- text-transform: uppercase;
1500
- letter-spacing: 0.3px;
1501
- }
1502
-
1503
- .awg-property-item {
1504
- display: flex;
1505
- flex-direction: column;
1506
- padding: 8px 16px;
1507
- border-bottom: 1px solid var(--awg-border);
1508
- }
1509
-
1510
- .awg-property-item:last-child {
1511
- border-bottom: none;
1512
- }
1513
-
1514
- .awg-property-key {
1515
- font-size: 10px;
1516
- font-weight: 600;
1517
- text-transform: uppercase;
1518
- letter-spacing: 0.3px;
1519
- color: var(--awg-text-muted);
1520
- margin-bottom: 2px;
1521
- }
1522
-
1523
- .awg-property-value {
1524
- font-size: 13px;
1525
- color: var(--awg-text);
1526
- word-break: break-all;
1527
- }
1528
-
1529
- .awg-property-truncate {
1530
- overflow: hidden;
1531
- text-overflow: ellipsis;
1532
- white-space: nowrap;
1533
- }
1534
-
1535
- .awg-property-labels {
1536
- display: flex;
1537
- flex-wrap: wrap;
1538
- gap: 4px;
1539
- }
1540
-
1541
- .awg-label-tag {
1542
- display: inline-block;
1543
- padding: 2px 8px;
1544
- font-size: 11px;
1545
- font-weight: 500;
1546
- background: var(--awg-accent);
1547
- color: #fff;
1548
- border-radius: 10px;
1549
- }
1550
-
1551
- .awg-edge-tag {
1552
- display: inline-block;
1553
- padding: 2px 8px;
1554
- font-size: 11px;
1555
- font-weight: 500;
1556
- background: var(--awg-bg-tertiary);
1557
- color: var(--awg-text);
1558
- border-radius: 4px;
1559
- border: 1px solid var(--awg-border);
1560
- }
1561
-
1562
- /* === SMALL BUTTONS === */
1563
- .awg-btn-sm {
1564
- min-width: 24px;
1565
- height: 24px;
1566
- padding: 0 4px;
1567
- }
1568
-
1569
- .awg-panel-header {
1570
- display: flex;
1571
- align-items: center;
1572
- justify-content: space-between;
1573
- }
1574
- """
1575
-
1576
18
 
1577
19
  class Graph(anywidget.AnyWidget):
1578
- """Interactive graph visualization widget using Sigma.js."""
20
+ """Interactive graph visualization widget using Sigma.js.
21
+
22
+ Supports Neo4j (browser-side) and Grafeo (Python-side) backends.
23
+
24
+ Examples
25
+ --------
26
+ Basic usage with static data:
27
+
28
+ >>> graph = Graph(
29
+ ... nodes=[{"id": "a", "label": "Alice"}, {"id": "b", "label": "Bob"}],
30
+ ... edges=[{"source": "a", "target": "b", "label": "KNOWS"}],
31
+ ... )
32
+
33
+ With Neo4j connection:
34
+
35
+ >>> graph = Graph(
36
+ ... database_backend="neo4j",
37
+ ... connection_uri="neo4j+s://demo.neo4jlabs.com",
38
+ ... connection_username="neo4j",
39
+ ... connection_password="password",
40
+ ... )
41
+
42
+ With Grafeo backend:
43
+
44
+ >>> import grafeo
45
+ >>> db = grafeo.GrafeoDB()
46
+ >>> graph = Graph(database_backend="grafeo", grafeo_db=db)
47
+ """
1579
48
 
1580
- _esm = _ESM
1581
- _css = _CSS
49
+ _esm = get_esm()
50
+ _css = get_css()
1582
51
 
1583
- # Graph data
52
+ # === Graph Data ===
1584
53
  nodes = traitlets.List(trait=traitlets.Dict()).tag(sync=True)
1585
54
  edges = traitlets.List(trait=traitlets.Dict()).tag(sync=True)
1586
55
 
1587
- # Display settings
56
+ # === Display Settings ===
1588
57
  width = traitlets.Int(default_value=800).tag(sync=True)
1589
58
  height = traitlets.Int(default_value=600).tag(sync=True)
1590
59
  background = traitlets.Unicode(default_value="#fafafa").tag(sync=True)
1591
60
  show_labels = traitlets.Bool(default_value=True).tag(sync=True)
1592
61
  show_edge_labels = traitlets.Bool(default_value=False).tag(sync=True)
1593
62
 
1594
- # Selection state
63
+ # === Selection State ===
1595
64
  selected_node = traitlets.Dict(allow_none=True, default_value=None).tag(sync=True)
1596
65
  selected_edge = traitlets.Dict(allow_none=True, default_value=None).tag(sync=True)
1597
66
 
1598
- # Toolbar visibility
67
+ # === Toolbar Visibility ===
1599
68
  show_toolbar = traitlets.Bool(default_value=True).tag(sync=True)
1600
69
  show_settings = traitlets.Bool(default_value=True).tag(sync=True)
1601
70
  show_query_input = traitlets.Bool(default_value=True).tag(sync=True)
1602
71
 
1603
- # Theme
72
+ # === Theme ===
1604
73
  dark_mode = traitlets.Bool(default_value=True).tag(sync=True)
1605
74
 
1606
- # Database backend
75
+ # === Database Backend ===
1607
76
  database_backend = traitlets.Unicode(default_value="neo4j").tag(sync=True)
1608
77
 
1609
- # Neo4j connection (browser-side)
78
+ # === Neo4j Connection (browser-side) ===
1610
79
  connection_uri = traitlets.Unicode(default_value="").tag(sync=True)
1611
80
  connection_username = traitlets.Unicode(default_value="").tag(sync=True)
1612
81
  connection_password = traitlets.Unicode(default_value="").tag(sync=True)
1613
82
  connection_database = traitlets.Unicode(default_value="neo4j").tag(sync=True)
1614
83
 
1615
- # Query state
84
+ # === Query State ===
1616
85
  query = traitlets.Unicode(default_value="").tag(sync=True)
1617
86
  query_language = traitlets.Unicode(default_value="cypher").tag(sync=True)
1618
87
  query_running = traitlets.Bool(default_value=False).tag(sync=True)
1619
88
  query_error = traitlets.Unicode(default_value="").tag(sync=True)
1620
89
 
1621
- # Connection state
90
+ # === Connection State ===
1622
91
  connection_status = traitlets.Unicode(default_value="disconnected").tag(sync=True)
1623
92
 
1624
- # Schema data
93
+ # === Schema Data ===
1625
94
  schema_node_types = traitlets.List(trait=traitlets.Dict()).tag(sync=True)
1626
95
  schema_edge_types = traitlets.List(trait=traitlets.Dict()).tag(sync=True)
1627
96
 
1628
- # Query execution trigger (for Python backends)
97
+ # === Query Execution Trigger (for Python backends) ===
1629
98
  _execute_query = traitlets.Int(default_value=0).tag(sync=True)
1630
99
 
1631
100
  def __init__(
@@ -1648,8 +117,50 @@ class Graph(anywidget.AnyWidget):
1648
117
  connection_password: str = "",
1649
118
  connection_database: str = "neo4j",
1650
119
  grafeo_db: Any = None,
120
+ backend: DatabaseBackend | None = None,
1651
121
  **kwargs: Any,
1652
122
  ) -> None:
123
+ """Initialize the Graph widget.
124
+
125
+ Parameters
126
+ ----------
127
+ nodes : list[dict], optional
128
+ List of node dictionaries with 'id' and optional 'label', 'color', etc.
129
+ edges : list[dict], optional
130
+ List of edge dictionaries with 'source', 'target', and optional 'label'.
131
+ width : int
132
+ Widget width in pixels.
133
+ height : int
134
+ Widget height in pixels.
135
+ background : str
136
+ Background color for the graph area.
137
+ show_labels : bool
138
+ Whether to show node labels.
139
+ show_edge_labels : bool
140
+ Whether to show edge labels.
141
+ show_toolbar : bool
142
+ Whether to show the toolbar.
143
+ show_settings : bool
144
+ Whether to show the settings button.
145
+ show_query_input : bool
146
+ Whether to show the query input.
147
+ dark_mode : bool
148
+ Whether to use dark theme.
149
+ database_backend : str
150
+ Database backend: "neo4j" or "grafeo".
151
+ connection_uri : str
152
+ Neo4j connection URI.
153
+ connection_username : str
154
+ Neo4j username.
155
+ connection_password : str
156
+ Neo4j password.
157
+ connection_database : str
158
+ Neo4j database name.
159
+ grafeo_db : Any
160
+ Grafeo database instance for Python-side execution (legacy).
161
+ backend : DatabaseBackend | None
162
+ Generic database backend implementing the DatabaseBackend protocol.
163
+ """
1653
164
  super().__init__(
1654
165
  nodes=nodes or [],
1655
166
  edges=edges or [],
@@ -1671,33 +182,55 @@ class Graph(anywidget.AnyWidget):
1671
182
  )
1672
183
  self._node_click_callbacks: list[Callable] = []
1673
184
  self._edge_click_callbacks: list[Callable] = []
1674
- self._grafeo_db = grafeo_db
185
+
186
+ # Support both legacy grafeo_db and new generic backend
187
+ if backend is not None:
188
+ self._backend = backend
189
+ elif grafeo_db is not None:
190
+ self._backend = GrafeoBackend(grafeo_db)
191
+ else:
192
+ self._backend = None
193
+
194
+ @property
195
+ def backend(self) -> DatabaseBackend | None:
196
+ """Get the current database backend."""
197
+ return self._backend
198
+
199
+ @backend.setter
200
+ def backend(self, value: DatabaseBackend | None) -> None:
201
+ """Set the database backend."""
202
+ self._backend = value
1675
203
 
1676
204
  @property
1677
205
  def grafeo_db(self) -> Any:
1678
- """Get the Grafeo database instance."""
1679
- return self._grafeo_db
206
+ """Get the Grafeo database instance (legacy property)."""
207
+ if isinstance(self._backend, GrafeoBackend):
208
+ return self._backend.db
209
+ return None
1680
210
 
1681
211
  @grafeo_db.setter
1682
212
  def grafeo_db(self, value: Any) -> None:
1683
- """Set the Grafeo database instance."""
1684
- self._grafeo_db = value
213
+ """Set the Grafeo database instance (legacy property)."""
214
+ self._backend = GrafeoBackend(value) if value else None
1685
215
 
1686
216
  @observe("_execute_query")
1687
217
  def _on_execute_query(self, change: dict) -> None:
1688
218
  """Handle query execution trigger from JavaScript."""
1689
219
  if change["new"] == 0:
1690
220
  return # Skip initial value
1691
- if self.database_backend == "grafeo" and self._grafeo_db is not None:
1692
- self._execute_grafeo_query()
221
+ if self._backend is not None:
222
+ self._execute_backend_query()
223
+
224
+ def _execute_backend_query(self) -> None:
225
+ """Execute query against the configured backend."""
226
+ if not self._backend:
227
+ self.query_error = "No database backend configured"
228
+ return
1693
229
 
1694
- def _execute_grafeo_query(self) -> None:
1695
- """Execute query against Grafeo database."""
1696
230
  try:
1697
231
  self.query_running = True
1698
232
  self.query_error = ""
1699
- result = self._grafeo_db.execute(self.query)
1700
- nodes, edges = self._process_grafeo_result(result)
233
+ nodes, edges = self._backend.execute(self.query)
1701
234
  self.nodes = nodes
1702
235
  self.edges = edges
1703
236
  except Exception as e:
@@ -1705,68 +238,216 @@ class Graph(anywidget.AnyWidget):
1705
238
  finally:
1706
239
  self.query_running = False
1707
240
 
1708
- def _process_grafeo_result(self, result: Any) -> tuple[list[dict], list[dict]]:
1709
- """Process Grafeo query results into nodes and edges."""
1710
- nodes: dict[str, dict] = {}
1711
- edges: list[dict] = []
1712
-
1713
- # Handle result as iterable of records
1714
- records = list(result) if hasattr(result, "__iter__") else [result]
1715
-
1716
- for record in records:
1717
- # Try to extract items from record
1718
- if hasattr(record, "items"):
1719
- items = record.items() if callable(record.items) else record.items
1720
- elif hasattr(record, "data"):
1721
- items = record.data().items()
1722
- elif isinstance(record, dict):
1723
- items = record.items()
1724
- else:
1725
- continue
1726
-
1727
- for key, value in items:
1728
- if _is_node(value):
1729
- node_id = _get_node_id(value)
1730
- if node_id not in nodes:
1731
- nodes[node_id] = _node_to_dict(value)
1732
- elif _is_relationship(value):
1733
- edges.append(_relationship_to_dict(value))
1734
-
1735
- return list(nodes.values()), edges
1736
-
1737
241
  @classmethod
1738
242
  def from_dict(cls, data: dict[str, Any], **kwargs: Any) -> Graph:
1739
- """Create a Graph from a dictionary with nodes and edges keys."""
243
+ """Create a Graph from a dictionary with 'nodes' and 'edges' keys.
244
+
245
+ Parameters
246
+ ----------
247
+ data : dict
248
+ Dictionary with 'nodes' and 'edges' lists.
249
+ **kwargs
250
+ Additional arguments passed to Graph constructor.
251
+
252
+ Returns
253
+ -------
254
+ Graph
255
+ New Graph instance.
256
+ """
1740
257
  return cls(nodes=data.get("nodes", []), edges=data.get("edges", []), **kwargs)
1741
258
 
1742
259
  @classmethod
1743
260
  def from_cypher(cls, result: Any, **kwargs: Any) -> Graph:
1744
- """Create a Graph from Cypher query results."""
1745
- nodes: dict[str, dict] = {}
1746
- edges: list[dict] = []
1747
-
1748
- records = list(result) if hasattr(result, "__iter__") else [result]
1749
-
1750
- for record in records:
1751
- if hasattr(record, "items"):
1752
- items = record.items() if callable(record.items) else record.items
1753
- elif hasattr(record, "data"):
1754
- items = record.data().items()
1755
- else:
1756
- items = record.items() if isinstance(record, dict) else []
1757
-
1758
- for key, value in items:
1759
- if _is_node(value):
1760
- node_id = _get_node_id(value)
1761
- if node_id not in nodes:
1762
- nodes[node_id] = _node_to_dict(value)
1763
- elif _is_relationship(value):
1764
- edges.append(_relationship_to_dict(value))
1765
-
1766
- return cls(nodes=list(nodes.values()), edges=edges, **kwargs)
261
+ """Create a Graph from Cypher query results.
262
+
263
+ Parameters
264
+ ----------
265
+ result : Any
266
+ Query result from Neo4j driver or similar.
267
+ **kwargs
268
+ Additional arguments passed to Graph constructor.
269
+
270
+ Returns
271
+ -------
272
+ Graph
273
+ New Graph instance with extracted nodes and edges.
274
+
275
+ Example
276
+ -------
277
+ >>> from neo4j import GraphDatabase
278
+ >>> driver = GraphDatabase.driver(uri, auth=auth)
279
+ >>> with driver.session() as session:
280
+ ... result = session.run("MATCH (n)-[r]->(m) RETURN n, r, m")
281
+ ... graph = Graph.from_cypher(result)
282
+ """
283
+ from anywidget_graph.converters import CypherConverter
284
+
285
+ data = CypherConverter().convert(result)
286
+ return cls(nodes=data["nodes"], edges=data["edges"], **kwargs)
287
+
288
+ @classmethod
289
+ def from_gql(cls, result: Any, **kwargs: Any) -> Graph:
290
+ """Create a Graph from GQL query results.
291
+
292
+ GQL (ISO Graph Query Language) uses a similar format to Cypher.
293
+
294
+ Parameters
295
+ ----------
296
+ result : Any
297
+ Query result from GQL-compatible database.
298
+ **kwargs
299
+ Additional arguments passed to Graph constructor.
300
+
301
+ Returns
302
+ -------
303
+ Graph
304
+ New Graph instance with extracted nodes and edges.
305
+ """
306
+ from anywidget_graph.converters import GQLConverter
307
+
308
+ data = GQLConverter().convert(result)
309
+ return cls(nodes=data["nodes"], edges=data["edges"], **kwargs)
310
+
311
+ @classmethod
312
+ def from_sparql(
313
+ cls,
314
+ result: Any,
315
+ subject_var: str = "s",
316
+ predicate_var: str = "p",
317
+ object_var: str = "o",
318
+ **kwargs: Any,
319
+ ) -> Graph:
320
+ """Create a Graph from SPARQL query results.
321
+
322
+ Parameters
323
+ ----------
324
+ result : Any
325
+ SPARQL query result (e.g., from RDFLib or SPARQLWrapper).
326
+ subject_var : str
327
+ Variable name for subjects (default: "s").
328
+ predicate_var : str
329
+ Variable name for predicates (default: "p").
330
+ object_var : str
331
+ Variable name for objects (default: "o").
332
+ **kwargs
333
+ Additional arguments passed to Graph constructor.
334
+
335
+ Returns
336
+ -------
337
+ Graph
338
+ New Graph instance with extracted nodes and edges.
339
+
340
+ Example
341
+ -------
342
+ >>> from rdflib import Graph as RDFGraph
343
+ >>> g = RDFGraph()
344
+ >>> g.parse("data.ttl")
345
+ >>> result = g.query("SELECT ?s ?p ?o WHERE { ?s ?p ?o }")
346
+ >>> graph = Graph.from_sparql(result)
347
+ """
348
+ from anywidget_graph.converters import SPARQLConverter
349
+
350
+ converter = SPARQLConverter(
351
+ subject_var=subject_var,
352
+ predicate_var=predicate_var,
353
+ object_var=object_var,
354
+ )
355
+ data = converter.convert(result)
356
+ return cls(nodes=data["nodes"], edges=data["edges"], **kwargs)
357
+
358
+ @classmethod
359
+ def from_gremlin(cls, result: Any, **kwargs: Any) -> Graph:
360
+ """Create a Graph from Gremlin/TinkerPop query results.
361
+
362
+ Parameters
363
+ ----------
364
+ result : Any
365
+ Gremlin traversal result.
366
+ **kwargs
367
+ Additional arguments passed to Graph constructor.
368
+
369
+ Returns
370
+ -------
371
+ Graph
372
+ New Graph instance with extracted nodes and edges.
373
+
374
+ Example
375
+ -------
376
+ >>> from gremlin_python.driver import client
377
+ >>> gremlin_client = client.Client('ws://localhost:8182/gremlin', 'g')
378
+ >>> result = gremlin_client.submit("g.V().limit(10)").all().result()
379
+ >>> graph = Graph.from_gremlin(result)
380
+ """
381
+ from anywidget_graph.converters import GremlinConverter
382
+
383
+ data = GremlinConverter().convert(result)
384
+ return cls(nodes=data["nodes"], edges=data["edges"], **kwargs)
385
+
386
+ @classmethod
387
+ def from_graphql(
388
+ cls,
389
+ result: Any,
390
+ nodes_path: str | None = None,
391
+ edges_path: str | None = None,
392
+ id_field: str = "id",
393
+ label_field: str = "name",
394
+ **kwargs: Any,
395
+ ) -> Graph:
396
+ """Create a Graph from GraphQL JSON response.
397
+
398
+ Parameters
399
+ ----------
400
+ result : Any
401
+ GraphQL JSON response.
402
+ nodes_path : str | None
403
+ Dot-separated path to nodes list (e.g., "data.users").
404
+ edges_path : str | None
405
+ Dot-separated path to edges list.
406
+ id_field : str
407
+ Field name for node IDs (default: "id").
408
+ label_field : str
409
+ Field name for node labels (default: "name").
410
+ **kwargs
411
+ Additional arguments passed to Graph constructor.
412
+
413
+ Returns
414
+ -------
415
+ Graph
416
+ New Graph instance with extracted nodes and edges.
417
+
418
+ Example
419
+ -------
420
+ >>> import requests
421
+ >>> response = requests.post(url, json={"query": query})
422
+ >>> graph = Graph.from_graphql(
423
+ ... response.json(),
424
+ ... nodes_path="data.characters.results",
425
+ ... )
426
+ """
427
+ from anywidget_graph.converters import GraphQLConverter
428
+
429
+ converter = GraphQLConverter(
430
+ nodes_path=nodes_path,
431
+ edges_path=edges_path,
432
+ id_field=id_field,
433
+ label_field=label_field,
434
+ )
435
+ data = converter.convert(result)
436
+ return cls(nodes=data["nodes"], edges=data["edges"], **kwargs)
1767
437
 
1768
438
  def on_node_click(self, callback: Callable[[str, dict], None]) -> Callable:
1769
- """Register a callback for node click events."""
439
+ """Register a callback for node click events.
440
+
441
+ Parameters
442
+ ----------
443
+ callback : Callable[[str, dict], None]
444
+ Function called with (node_id, node_attributes) when a node is clicked.
445
+
446
+ Returns
447
+ -------
448
+ Callable
449
+ The callback function (for decorator usage).
450
+ """
1770
451
  self._node_click_callbacks.append(callback)
1771
452
 
1772
453
  def observer(change: dict) -> None:
@@ -1780,7 +461,18 @@ class Graph(anywidget.AnyWidget):
1780
461
  return callback
1781
462
 
1782
463
  def on_edge_click(self, callback: Callable[[dict], None]) -> Callable:
1783
- """Register a callback for edge click events."""
464
+ """Register a callback for edge click events.
465
+
466
+ Parameters
467
+ ----------
468
+ callback : Callable[[dict], None]
469
+ Function called with edge data when an edge is clicked.
470
+
471
+ Returns
472
+ -------
473
+ Callable
474
+ The callback function (for decorator usage).
475
+ """
1784
476
  self._edge_click_callbacks.append(callback)
1785
477
 
1786
478
  def observer(change: dict) -> None:
@@ -1790,85 +482,3 @@ class Graph(anywidget.AnyWidget):
1790
482
 
1791
483
  self.observe(observer, names=["selected_edge"])
1792
484
  return callback
1793
-
1794
-
1795
- def _is_node(obj: Any) -> bool:
1796
- if hasattr(obj, "labels") and hasattr(obj, "element_id"):
1797
- return True
1798
- if hasattr(obj, "labels") and hasattr(obj, "properties"):
1799
- return True
1800
- if isinstance(obj, dict) and "id" in obj:
1801
- return True
1802
- return False
1803
-
1804
-
1805
- def _is_relationship(obj: Any) -> bool:
1806
- if hasattr(obj, "type") and hasattr(obj, "start_node"):
1807
- return True
1808
- if hasattr(obj, "type") and hasattr(obj, "source") and hasattr(obj, "target"):
1809
- return True
1810
- if isinstance(obj, dict) and "source" in obj and "target" in obj:
1811
- return True
1812
- return False
1813
-
1814
-
1815
- def _get_node_id(node: Any) -> str:
1816
- if hasattr(node, "element_id"):
1817
- return str(node.element_id)
1818
- if hasattr(node, "id"):
1819
- return str(node.id)
1820
- if isinstance(node, dict):
1821
- return str(node.get("id", id(node)))
1822
- return str(id(node))
1823
-
1824
-
1825
- def _node_to_dict(node: Any) -> dict:
1826
- result: dict[str, Any] = {"id": _get_node_id(node)}
1827
-
1828
- if hasattr(node, "labels"):
1829
- labels = list(node.labels) if hasattr(node.labels, "__iter__") else [node.labels]
1830
- if labels:
1831
- result["label"] = labels[0]
1832
- result["labels"] = labels
1833
-
1834
- if hasattr(node, "properties"):
1835
- props = node.properties if isinstance(node.properties, dict) else dict(node.properties)
1836
- result.update(props)
1837
- elif hasattr(node, "items"):
1838
- result.update(dict(node))
1839
- elif isinstance(node, dict):
1840
- result.update(node)
1841
-
1842
- if "label" not in result and "name" in result:
1843
- result["label"] = result["name"]
1844
-
1845
- return result
1846
-
1847
-
1848
- def _relationship_to_dict(rel: Any) -> dict:
1849
- result: dict[str, Any] = {}
1850
-
1851
- if hasattr(rel, "start_node") and hasattr(rel, "end_node"):
1852
- result["source"] = _get_node_id(rel.start_node)
1853
- result["target"] = _get_node_id(rel.end_node)
1854
- elif hasattr(rel, "source") and hasattr(rel, "target"):
1855
- result["source"] = _get_node_id(rel.source)
1856
- result["target"] = _get_node_id(rel.target)
1857
- elif isinstance(rel, dict):
1858
- result["source"] = str(rel.get("source", ""))
1859
- result["target"] = str(rel.get("target", ""))
1860
-
1861
- if hasattr(rel, "type"):
1862
- result["label"] = str(rel.type)
1863
- elif isinstance(rel, dict) and "type" in rel:
1864
- result["label"] = str(rel["type"])
1865
- elif isinstance(rel, dict) and "label" in rel:
1866
- result["label"] = str(rel["label"])
1867
-
1868
- if hasattr(rel, "properties"):
1869
- props = rel.properties if isinstance(rel.properties, dict) else dict(rel.properties)
1870
- result.update(props)
1871
- elif hasattr(rel, "items") and not isinstance(rel, dict):
1872
- result.update(dict(rel))
1873
-
1874
- return result