anywidget-graph 0.1.0__py3-none-any.whl → 0.2.0__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
@@ -6,22 +6,909 @@ from typing import TYPE_CHECKING, Any
6
6
 
7
7
  import anywidget
8
8
  import traitlets
9
+ from traitlets import observe
9
10
 
10
11
  if TYPE_CHECKING:
11
12
  from collections.abc import Callable
12
13
 
13
- _ESM = """
14
+ _ESM = r"""
14
15
  import Graph from "https://esm.sh/graphology@0.25.4";
15
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";
16
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 ===
17
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
18
894
  const container = document.createElement("div");
895
+ container.className = "awg-graph-container";
19
896
  container.style.width = model.get("width") + "px";
20
897
  container.style.height = model.get("height") + "px";
21
- container.style.border = "1px solid #ddd";
22
- container.style.borderRadius = "4px";
23
- container.style.background = model.get("background") || "#fafafa";
24
- el.appendChild(container);
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);
25
912
 
26
913
  const graph = new Graph();
27
914
 
@@ -119,8 +1006,570 @@ export default { render };
119
1006
  """
120
1007
 
121
1008
  _CSS = """
122
- .anywidget-graph {
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 {
123
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;
124
1573
  }
125
1574
  """
126
1575
 
@@ -131,27 +1580,159 @@ class Graph(anywidget.AnyWidget):
131
1580
  _esm = _ESM
132
1581
  _css = _CSS
133
1582
 
1583
+ # Graph data
134
1584
  nodes = traitlets.List(trait=traitlets.Dict()).tag(sync=True)
135
1585
  edges = traitlets.List(trait=traitlets.Dict()).tag(sync=True)
136
1586
 
1587
+ # Display settings
137
1588
  width = traitlets.Int(default_value=800).tag(sync=True)
138
1589
  height = traitlets.Int(default_value=600).tag(sync=True)
139
1590
  background = traitlets.Unicode(default_value="#fafafa").tag(sync=True)
140
1591
  show_labels = traitlets.Bool(default_value=True).tag(sync=True)
141
1592
  show_edge_labels = traitlets.Bool(default_value=False).tag(sync=True)
142
1593
 
1594
+ # Selection state
143
1595
  selected_node = traitlets.Dict(allow_none=True, default_value=None).tag(sync=True)
144
1596
  selected_edge = traitlets.Dict(allow_none=True, default_value=None).tag(sync=True)
145
1597
 
1598
+ # Toolbar visibility
1599
+ show_toolbar = traitlets.Bool(default_value=True).tag(sync=True)
1600
+ show_settings = traitlets.Bool(default_value=True).tag(sync=True)
1601
+ show_query_input = traitlets.Bool(default_value=True).tag(sync=True)
1602
+
1603
+ # Theme
1604
+ dark_mode = traitlets.Bool(default_value=True).tag(sync=True)
1605
+
1606
+ # Database backend
1607
+ database_backend = traitlets.Unicode(default_value="neo4j").tag(sync=True)
1608
+
1609
+ # Neo4j connection (browser-side)
1610
+ connection_uri = traitlets.Unicode(default_value="").tag(sync=True)
1611
+ connection_username = traitlets.Unicode(default_value="").tag(sync=True)
1612
+ connection_password = traitlets.Unicode(default_value="").tag(sync=True)
1613
+ connection_database = traitlets.Unicode(default_value="neo4j").tag(sync=True)
1614
+
1615
+ # Query state
1616
+ query = traitlets.Unicode(default_value="").tag(sync=True)
1617
+ query_language = traitlets.Unicode(default_value="cypher").tag(sync=True)
1618
+ query_running = traitlets.Bool(default_value=False).tag(sync=True)
1619
+ query_error = traitlets.Unicode(default_value="").tag(sync=True)
1620
+
1621
+ # Connection state
1622
+ connection_status = traitlets.Unicode(default_value="disconnected").tag(sync=True)
1623
+
1624
+ # Schema data
1625
+ schema_node_types = traitlets.List(trait=traitlets.Dict()).tag(sync=True)
1626
+ schema_edge_types = traitlets.List(trait=traitlets.Dict()).tag(sync=True)
1627
+
1628
+ # Query execution trigger (for Python backends)
1629
+ _execute_query = traitlets.Int(default_value=0).tag(sync=True)
1630
+
146
1631
  def __init__(
147
1632
  self,
148
1633
  nodes: list[dict[str, Any]] | None = None,
149
1634
  edges: list[dict[str, Any]] | None = None,
1635
+ *,
1636
+ width: int = 800,
1637
+ height: int = 600,
1638
+ background: str = "#fafafa",
1639
+ show_labels: bool = True,
1640
+ show_edge_labels: bool = False,
1641
+ show_toolbar: bool = True,
1642
+ show_settings: bool = True,
1643
+ show_query_input: bool = True,
1644
+ dark_mode: bool = True,
1645
+ database_backend: str = "neo4j",
1646
+ connection_uri: str = "",
1647
+ connection_username: str = "",
1648
+ connection_password: str = "",
1649
+ connection_database: str = "neo4j",
1650
+ grafeo_db: Any = None,
150
1651
  **kwargs: Any,
151
1652
  ) -> None:
152
- super().__init__(nodes=nodes or [], edges=edges or [], **kwargs)
1653
+ super().__init__(
1654
+ nodes=nodes or [],
1655
+ edges=edges or [],
1656
+ width=width,
1657
+ height=height,
1658
+ background=background,
1659
+ show_labels=show_labels,
1660
+ show_edge_labels=show_edge_labels,
1661
+ show_toolbar=show_toolbar,
1662
+ show_settings=show_settings,
1663
+ show_query_input=show_query_input,
1664
+ dark_mode=dark_mode,
1665
+ database_backend=database_backend,
1666
+ connection_uri=connection_uri,
1667
+ connection_username=connection_username,
1668
+ connection_password=connection_password,
1669
+ connection_database=connection_database,
1670
+ **kwargs,
1671
+ )
153
1672
  self._node_click_callbacks: list[Callable] = []
154
1673
  self._edge_click_callbacks: list[Callable] = []
1674
+ self._grafeo_db = grafeo_db
1675
+
1676
+ @property
1677
+ def grafeo_db(self) -> Any:
1678
+ """Get the Grafeo database instance."""
1679
+ return self._grafeo_db
1680
+
1681
+ @grafeo_db.setter
1682
+ def grafeo_db(self, value: Any) -> None:
1683
+ """Set the Grafeo database instance."""
1684
+ self._grafeo_db = value
1685
+
1686
+ @observe("_execute_query")
1687
+ def _on_execute_query(self, change: dict) -> None:
1688
+ """Handle query execution trigger from JavaScript."""
1689
+ if change["new"] == 0:
1690
+ return # Skip initial value
1691
+ if self.database_backend == "grafeo" and self._grafeo_db is not None:
1692
+ self._execute_grafeo_query()
1693
+
1694
+ def _execute_grafeo_query(self) -> None:
1695
+ """Execute query against Grafeo database."""
1696
+ try:
1697
+ self.query_running = True
1698
+ self.query_error = ""
1699
+ result = self._grafeo_db.execute(self.query)
1700
+ nodes, edges = self._process_grafeo_result(result)
1701
+ self.nodes = nodes
1702
+ self.edges = edges
1703
+ except Exception as e:
1704
+ self.query_error = str(e)
1705
+ finally:
1706
+ self.query_running = False
1707
+
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
155
1736
 
156
1737
  @classmethod
157
1738
  def from_dict(cls, data: dict[str, Any], **kwargs: Any) -> Graph: