anywidget-graph 0.2.6__tar.gz → 0.2.8__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/PKG-INFO +2 -1
- {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/README.md +1 -0
- {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/pyproject.toml +1 -1
- {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/src/anywidget_graph/__init__.py +1 -1
- {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/src/anywidget_graph/backends/grafeo.py +61 -3
- {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/src/anywidget_graph/ui/__init__.py +4 -0
- {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/src/anywidget_graph/ui/icons.js +3 -0
- {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/src/anywidget_graph/ui/index.js +121 -3
- anywidget_graph-0.2.8/src/anywidget_graph/ui/results.js +183 -0
- {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/src/anywidget_graph/ui/styles.css +181 -1
- {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/src/anywidget_graph/ui/toolbar.js +49 -1
- {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/src/anywidget_graph/widget.py +4 -0
- {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/tests/test_graph.py +148 -0
- {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/uv.lock +29 -29
- {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/.github/workflows/ci.yml +0 -0
- {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/.github/workflows/pypi.yml +0 -0
- {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/.gitignore +0 -0
- {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/.python-version +0 -0
- {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/LICENSE +0 -0
- {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/examples/demo.py +0 -0
- {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/src/anywidget_graph/backends/__init__.py +0 -0
- {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/src/anywidget_graph/backends/arango.py +0 -0
- {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/src/anywidget_graph/backends/cosmosdb.py +0 -0
- {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/src/anywidget_graph/backends/ladybug.py +0 -0
- {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/src/anywidget_graph/backends/neo4j.py +0 -0
- {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/src/anywidget_graph/converters/__init__.py +0 -0
- {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/src/anywidget_graph/converters/base.py +0 -0
- {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/src/anywidget_graph/converters/common.py +0 -0
- {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/src/anywidget_graph/converters/cypher.py +0 -0
- {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/src/anywidget_graph/converters/gql.py +0 -0
- {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/src/anywidget_graph/converters/graphql.py +0 -0
- {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/src/anywidget_graph/converters/gremlin.py +0 -0
- {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/src/anywidget_graph/converters/sparql.py +0 -0
- {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/src/anywidget_graph/demo.py +0 -0
- {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/src/anywidget_graph/py.typed +0 -0
- {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/src/anywidget_graph/ui/grafeo-embed.js +0 -0
- {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/src/anywidget_graph/ui/grafeo.js +0 -0
- {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/src/anywidget_graph/ui/neo4j.js +0 -0
- {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/src/anywidget_graph/ui/properties.js +0 -0
- {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/src/anywidget_graph/ui/schema.js +0 -0
- {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/src/anywidget_graph/ui/settings.js +0 -0
- {anywidget_graph-0.2.6 → anywidget_graph-0.2.8}/tests/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: anywidget-graph
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.8
|
|
4
4
|
Summary: Interactive graph visualization for Python notebooks using anywidget
|
|
5
5
|
Project-URL: Homepage, https://grafeo.dev/
|
|
6
6
|
Project-URL: Repository, https://github.com/GrafeoDB/anywidget-graph
|
|
@@ -326,6 +326,7 @@ graph.save_html("graph.html") # Write HTML to file
|
|
|
326
326
|
- [anywidget](https://anywidget.dev/), custom Jupyter widgets made easy
|
|
327
327
|
- [Grafeo](https://github.com/GrafeoDB/grafeo), embeddable graph database
|
|
328
328
|
- [grafeo-web](https://github.com/GrafeoDB/grafeo-web), Grafeo in the browser
|
|
329
|
+
- [Playground](https://grafeo.ai), interactive browser playground for Grafeo
|
|
329
330
|
|
|
330
331
|
## License
|
|
331
332
|
|
|
@@ -285,6 +285,7 @@ graph.save_html("graph.html") # Write HTML to file
|
|
|
285
285
|
- [anywidget](https://anywidget.dev/), custom Jupyter widgets made easy
|
|
286
286
|
- [Grafeo](https://github.com/GrafeoDB/grafeo), embeddable graph database
|
|
287
287
|
- [grafeo-web](https://github.com/GrafeoDB/grafeo-web), Grafeo in the browser
|
|
288
|
+
- [Playground](https://grafeo.ai), interactive browser playground for Grafeo
|
|
288
289
|
|
|
289
290
|
## License
|
|
290
291
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from typing import TYPE_CHECKING, Any
|
|
5
|
+
from typing import TYPE_CHECKING, Any, ClassVar
|
|
6
6
|
|
|
7
7
|
from anywidget_graph.converters import (
|
|
8
8
|
get_node_id,
|
|
@@ -19,6 +19,14 @@ if TYPE_CHECKING:
|
|
|
19
19
|
class GrafeoBackend:
|
|
20
20
|
"""Backend for Grafeo database connections."""
|
|
21
21
|
|
|
22
|
+
_LANGUAGE_METHODS: ClassVar[dict[str, str]] = {
|
|
23
|
+
"cypher": "execute_cypher",
|
|
24
|
+
"gremlin": "execute_gremlin",
|
|
25
|
+
"graphql": "execute_graphql",
|
|
26
|
+
"sparql": "execute_sparql",
|
|
27
|
+
"sql": "execute_sql",
|
|
28
|
+
}
|
|
29
|
+
|
|
22
30
|
def __init__(self, db: Any) -> None:
|
|
23
31
|
"""Initialize with a GrafeoDB instance."""
|
|
24
32
|
self._db = db
|
|
@@ -30,7 +38,11 @@ class GrafeoBackend:
|
|
|
30
38
|
|
|
31
39
|
def execute(self, query: str, *, language: str = "cypher") -> tuple[list[dict], list[dict]]:
|
|
32
40
|
"""Execute a query in the specified language and return (nodes, edges)."""
|
|
33
|
-
|
|
41
|
+
method_name = self._LANGUAGE_METHODS.get(language)
|
|
42
|
+
if method_name and hasattr(self._db, method_name):
|
|
43
|
+
result = getattr(self._db, method_name)(query)
|
|
44
|
+
else:
|
|
45
|
+
result = self._db.execute(query)
|
|
34
46
|
return self._process_result(result)
|
|
35
47
|
|
|
36
48
|
def fetch_schema(self) -> tuple[list[dict], list[dict]]:
|
|
@@ -61,7 +73,14 @@ class GrafeoBackend:
|
|
|
61
73
|
for record in records:
|
|
62
74
|
items = self._extract_items(record)
|
|
63
75
|
for _key, value in items:
|
|
64
|
-
if
|
|
76
|
+
if self._is_grafeo_node(value):
|
|
77
|
+
node_dict = self._grafeo_node_to_dict(value)
|
|
78
|
+
node_id = node_dict["id"]
|
|
79
|
+
if node_id not in nodes:
|
|
80
|
+
nodes[node_id] = node_dict
|
|
81
|
+
elif self._is_grafeo_relationship(value):
|
|
82
|
+
edges.append(self._grafeo_relationship_to_dict(value))
|
|
83
|
+
elif is_node(value):
|
|
65
84
|
node_id = get_node_id(value)
|
|
66
85
|
if node_id not in nodes:
|
|
67
86
|
nodes[node_id] = node_to_dict(value)
|
|
@@ -70,6 +89,45 @@ class GrafeoBackend:
|
|
|
70
89
|
|
|
71
90
|
return list(nodes.values()), edges
|
|
72
91
|
|
|
92
|
+
@staticmethod
|
|
93
|
+
def _is_grafeo_node(obj: Any) -> bool:
|
|
94
|
+
"""Check if a value is a Grafeo node dict (has ``_id`` and ``_labels``)."""
|
|
95
|
+
return isinstance(obj, dict) and "_id" in obj and "_labels" in obj
|
|
96
|
+
|
|
97
|
+
@staticmethod
|
|
98
|
+
def _is_grafeo_relationship(obj: Any) -> bool:
|
|
99
|
+
"""Check if a value is a Grafeo relationship dict (has ``_source``, ``_target``, ``_type``)."""
|
|
100
|
+
return isinstance(obj, dict) and "_source" in obj and "_target" in obj and "_type" in obj
|
|
101
|
+
|
|
102
|
+
@staticmethod
|
|
103
|
+
def _grafeo_node_to_dict(node: dict) -> dict:
|
|
104
|
+
"""Convert a Grafeo node dict to the widget node format."""
|
|
105
|
+
node_id = str(node["_id"])
|
|
106
|
+
labels = node.get("_labels", [])
|
|
107
|
+
result: dict = {"id": node_id}
|
|
108
|
+
if labels:
|
|
109
|
+
result["label"] = labels[0]
|
|
110
|
+
result["labels"] = list(labels)
|
|
111
|
+
for key, value in node.items():
|
|
112
|
+
if not key.startswith("_"):
|
|
113
|
+
result[key] = value
|
|
114
|
+
if "label" not in result and "name" in result:
|
|
115
|
+
result["label"] = result["name"]
|
|
116
|
+
return result
|
|
117
|
+
|
|
118
|
+
@staticmethod
|
|
119
|
+
def _grafeo_relationship_to_dict(rel: dict) -> dict:
|
|
120
|
+
"""Convert a Grafeo relationship dict to the widget edge format."""
|
|
121
|
+
result: dict = {
|
|
122
|
+
"source": str(rel["_source"]),
|
|
123
|
+
"target": str(rel["_target"]),
|
|
124
|
+
"label": str(rel["_type"]),
|
|
125
|
+
}
|
|
126
|
+
for key, value in rel.items():
|
|
127
|
+
if not key.startswith("_"):
|
|
128
|
+
result[key] = value
|
|
129
|
+
return result
|
|
130
|
+
|
|
73
131
|
def _extract_items(self, record: Any) -> list[tuple[str, Any]]:
|
|
74
132
|
"""Extract key-value items from a record."""
|
|
75
133
|
if hasattr(record, "items"):
|
|
@@ -140,6 +140,7 @@ def get_esm() -> str:
|
|
|
140
140
|
schema_js = _read_file(_UI_DIR / "schema.js")
|
|
141
141
|
settings_js = _read_file(_UI_DIR / "settings.js")
|
|
142
142
|
properties_js = _read_file(_UI_DIR / "properties.js")
|
|
143
|
+
results_js = _read_file(_UI_DIR / "results.js")
|
|
143
144
|
toolbar_js = _read_file(_UI_DIR / "toolbar.js")
|
|
144
145
|
index_js = _read_file(_UI_DIR / "index.js")
|
|
145
146
|
|
|
@@ -173,6 +174,9 @@ import neo4j from "https://cdn.jsdelivr.net/npm/neo4j-driver@5.28.0/lib/browser/
|
|
|
173
174
|
// === Properties Panel ===
|
|
174
175
|
{_strip_imports_exports(properties_js)}
|
|
175
176
|
|
|
177
|
+
// === Results Drawer ===
|
|
178
|
+
{_strip_imports_exports(results_js)}
|
|
179
|
+
|
|
176
180
|
// === Toolbar ===
|
|
177
181
|
{_resolve_namespaces(toolbar_js)}
|
|
178
182
|
|
|
@@ -15,4 +15,7 @@ export const ICONS = {
|
|
|
15
15
|
zoomIn: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>`,
|
|
16
16
|
zoomOut: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="8" y1="11" x2="14" y2="11"/></svg>`,
|
|
17
17
|
zoomFit: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/></svg>`,
|
|
18
|
+
search: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>`,
|
|
19
|
+
chevronUp: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 15l-6-6-6 6"/></svg>`,
|
|
20
|
+
table: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M3 15h18M9 3v18"/></svg>`,
|
|
18
21
|
};
|
|
@@ -9,6 +9,7 @@ import { createToolbar } from "./toolbar.js";
|
|
|
9
9
|
import { createSchemaPanel } from "./schema.js";
|
|
10
10
|
import { createSettingsPanel } from "./settings.js";
|
|
11
11
|
import { createPropertiesPanel } from "./properties.js";
|
|
12
|
+
import { createResultsDrawer } from "./results.js";
|
|
12
13
|
import * as neo4jBackend from "./neo4j.js";
|
|
13
14
|
import * as grafeoBackend from "./grafeo.js";
|
|
14
15
|
import * as grafeoEmbedBackend from "./grafeo-embed.js";
|
|
@@ -168,6 +169,8 @@ async function executeQuery(model) {
|
|
|
168
169
|
return;
|
|
169
170
|
}
|
|
170
171
|
|
|
172
|
+
const start = performance.now();
|
|
173
|
+
|
|
171
174
|
if (backend === "neo4j") {
|
|
172
175
|
// Browser-side Neo4j driver
|
|
173
176
|
const result = await neo4jBackend.executeQuery(
|
|
@@ -176,6 +179,7 @@ async function executeQuery(model) {
|
|
|
176
179
|
model
|
|
177
180
|
);
|
|
178
181
|
if (result) {
|
|
182
|
+
model.set("query_time", performance.now() - start);
|
|
179
183
|
model.set("nodes", result.nodes);
|
|
180
184
|
model.set("edges", result.edges);
|
|
181
185
|
model.save_changes();
|
|
@@ -189,6 +193,7 @@ async function executeQuery(model) {
|
|
|
189
193
|
model
|
|
190
194
|
);
|
|
191
195
|
if (result) {
|
|
196
|
+
model.set("query_time", performance.now() - start);
|
|
192
197
|
model.set("nodes", result.nodes);
|
|
193
198
|
model.set("edges", result.edges);
|
|
194
199
|
model.save_changes();
|
|
@@ -197,12 +202,14 @@ async function executeQuery(model) {
|
|
|
197
202
|
// Browser-side Grafeo WASM
|
|
198
203
|
const result = await grafeoEmbedBackend.executeQuery(query, language, model);
|
|
199
204
|
if (result) {
|
|
205
|
+
model.set("query_time", performance.now() - start);
|
|
200
206
|
model.set("nodes", result.nodes);
|
|
201
207
|
model.set("edges", result.edges);
|
|
202
208
|
model.save_changes();
|
|
203
209
|
}
|
|
204
210
|
} else {
|
|
205
211
|
// Python-side backends (grafeo-embedded, ladybug, arango, cosmosdb)
|
|
212
|
+
// Timing is handled on the Python side
|
|
206
213
|
model.set("_execute_query", model.get("_execute_query") + 1);
|
|
207
214
|
model.save_changes();
|
|
208
215
|
}
|
|
@@ -236,9 +243,28 @@ function render({ model, el }) {
|
|
|
236
243
|
// Panel references for mutual exclusion
|
|
237
244
|
const panels = { schema, settings, properties };
|
|
238
245
|
|
|
246
|
+
// Search state (shared with nodeReducer)
|
|
247
|
+
let searchTerm = "";
|
|
248
|
+
let searchMatches = new Set();
|
|
249
|
+
|
|
250
|
+
function onSearch(term) {
|
|
251
|
+
searchTerm = (term || "").toLowerCase().trim();
|
|
252
|
+
searchMatches.clear();
|
|
253
|
+
if (searchTerm) {
|
|
254
|
+
graph.forEachNode((id, attrs) => {
|
|
255
|
+
const label = (attrs.label || "").toLowerCase();
|
|
256
|
+
const nodeId = String(id).toLowerCase();
|
|
257
|
+
if (label.includes(searchTerm) || nodeId.includes(searchTerm)) {
|
|
258
|
+
searchMatches.add(id);
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
renderer.refresh();
|
|
263
|
+
}
|
|
264
|
+
|
|
239
265
|
// Create toolbar if enabled
|
|
240
266
|
if (model.get("show_toolbar")) {
|
|
241
|
-
const toolbar = createToolbar(model, onExecuteQuery, panels);
|
|
267
|
+
const toolbar = createToolbar(model, onExecuteQuery, panels, onSearch);
|
|
242
268
|
wrapper.appendChild(toolbar);
|
|
243
269
|
}
|
|
244
270
|
|
|
@@ -264,6 +290,11 @@ function render({ model, el }) {
|
|
|
264
290
|
}
|
|
265
291
|
|
|
266
292
|
wrapper.appendChild(content);
|
|
293
|
+
|
|
294
|
+
// Results drawer (bottom, hidden by default with lip toggle)
|
|
295
|
+
const results = createResultsDrawer(model);
|
|
296
|
+
wrapper.appendChild(results.element);
|
|
297
|
+
|
|
267
298
|
el.appendChild(wrapper);
|
|
268
299
|
|
|
269
300
|
// Initialize Graphology graph
|
|
@@ -307,6 +338,52 @@ function render({ model, el }) {
|
|
|
307
338
|
case "random":
|
|
308
339
|
random.assign(graph);
|
|
309
340
|
break;
|
|
341
|
+
case "cluster": {
|
|
342
|
+
// Group nodes by first label, arrange clusters in a circle
|
|
343
|
+
const nodes = model.get("nodes") || [];
|
|
344
|
+
const labelGroups = new Map();
|
|
345
|
+
nodes.forEach((node) => {
|
|
346
|
+
const label = (node.labels && node.labels[0]) || node.label || "__other";
|
|
347
|
+
if (!labelGroups.has(label)) labelGroups.set(label, []);
|
|
348
|
+
labelGroups.get(label).push(node.id);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
const clusterLabels = [...labelGroups.entries()].filter(([, g]) => g.length >= 2);
|
|
352
|
+
const useClusters = clusterLabels.length >= 2;
|
|
353
|
+
|
|
354
|
+
if (!useClusters) {
|
|
355
|
+
// Not enough clusters, fall back to force
|
|
356
|
+
applyLayout("force");
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Collect all labels (clusters with 2+ nodes first, then singles)
|
|
361
|
+
const singleNodes = [...labelGroups.entries()]
|
|
362
|
+
.filter(([, g]) => g.length < 2)
|
|
363
|
+
.flatMap(([, g]) => g);
|
|
364
|
+
const allGroups = [...clusterLabels];
|
|
365
|
+
if (singleNodes.length > 0) {
|
|
366
|
+
allGroups.push(["__other", singleNodes]);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const cx = 50, cy = 50;
|
|
370
|
+
const clusterRadius = 40;
|
|
371
|
+
|
|
372
|
+
allGroups.forEach(([, nodeIds], clusterIdx) => {
|
|
373
|
+
const clusterAngle = (2 * Math.PI * clusterIdx) / allGroups.length;
|
|
374
|
+
const clusterCx = cx + clusterRadius * Math.cos(clusterAngle);
|
|
375
|
+
const clusterCy = cy + clusterRadius * Math.sin(clusterAngle);
|
|
376
|
+
const innerRadius = Math.max(5, Math.min(20, nodeIds.length * 2));
|
|
377
|
+
|
|
378
|
+
nodeIds.forEach((nodeId, nodeIdx) => {
|
|
379
|
+
if (!graph.hasNode(nodeId)) return;
|
|
380
|
+
const innerAngle = (2 * Math.PI * nodeIdx) / nodeIds.length;
|
|
381
|
+
graph.setNodeAttribute(nodeId, "x", clusterCx + innerRadius * Math.cos(innerAngle));
|
|
382
|
+
graph.setNodeAttribute(nodeId, "y", clusterCy + innerRadius * Math.sin(innerAngle));
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
break;
|
|
386
|
+
}
|
|
310
387
|
case "force": {
|
|
311
388
|
const n = graph.order;
|
|
312
389
|
// Scale layout parameters to graph size for good spreading
|
|
@@ -351,7 +428,7 @@ function render({ model, el }) {
|
|
|
351
428
|
labelWeight: "500",
|
|
352
429
|
});
|
|
353
430
|
|
|
354
|
-
// Node reducer for selection highlighting
|
|
431
|
+
// Node reducer for selection highlighting, search filtering, and pinned indicator
|
|
355
432
|
renderer.setSetting("nodeReducer", (node, data) => {
|
|
356
433
|
const selectedNodes = model.get("selected_nodes") || [];
|
|
357
434
|
const pinnedNodes = model.get("pinned_nodes") || {};
|
|
@@ -363,6 +440,12 @@ function render({ model, el }) {
|
|
|
363
440
|
res.label = "";
|
|
364
441
|
}
|
|
365
442
|
|
|
443
|
+
// Dim non-matching nodes when search is active
|
|
444
|
+
if (searchTerm && !searchMatches.has(node)) {
|
|
445
|
+
res.color = data.color + "20";
|
|
446
|
+
res.label = "";
|
|
447
|
+
}
|
|
448
|
+
|
|
366
449
|
// Show ring on pinned nodes
|
|
367
450
|
if (pinnedNodes[node]) {
|
|
368
451
|
res.borderColor = "#f59e0b";
|
|
@@ -444,7 +527,7 @@ function render({ model, el }) {
|
|
|
444
527
|
const layoutSelect = document.createElement("select");
|
|
445
528
|
layoutSelect.className = "awg-layout-select";
|
|
446
529
|
layoutSelect.title = "Layout algorithm";
|
|
447
|
-
[["force", "Force"], ["circular", "Circular"], ["random", "Random"]].forEach(([val, text]) => {
|
|
530
|
+
[["force", "Force"], ["cluster", "Cluster"], ["circular", "Circular"], ["random", "Random"]].forEach(([val, text]) => {
|
|
448
531
|
const opt = document.createElement("option");
|
|
449
532
|
opt.value = val;
|
|
450
533
|
opt.textContent = text;
|
|
@@ -676,6 +759,41 @@ function render({ model, el }) {
|
|
|
676
759
|
model.save_changes();
|
|
677
760
|
});
|
|
678
761
|
|
|
762
|
+
// Keyboard shortcuts
|
|
763
|
+
wrapper.tabIndex = 0;
|
|
764
|
+
wrapper.style.outline = "none";
|
|
765
|
+
wrapper.addEventListener("keydown", (e) => {
|
|
766
|
+
const tag = e.target.tagName;
|
|
767
|
+
if (tag === "INPUT" || tag === "SELECT" || tag === "TEXTAREA") return;
|
|
768
|
+
|
|
769
|
+
if (e.key === "f" || e.key === "F") {
|
|
770
|
+
e.preventDefault();
|
|
771
|
+
renderer.getCamera().animatedReset({ duration: 200 });
|
|
772
|
+
} else if (e.key === "Escape") {
|
|
773
|
+
model.set("selected_node", null);
|
|
774
|
+
model.set("selected_edge", null);
|
|
775
|
+
model.set("selected_nodes", []);
|
|
776
|
+
model.set("selected_edges", []);
|
|
777
|
+
model.save_changes();
|
|
778
|
+
} else if (e.key === "Delete" || e.key === "Backspace") {
|
|
779
|
+
const selected = model.get("selected_nodes") || [];
|
|
780
|
+
if (selected.length === 0) return;
|
|
781
|
+
e.preventDefault();
|
|
782
|
+
const removeSet = new Set(selected);
|
|
783
|
+
const nodes = (model.get("nodes") || []).filter(n => !removeSet.has(n.id));
|
|
784
|
+
const edges = (model.get("edges") || []).filter(
|
|
785
|
+
edge => !removeSet.has(edge.source) && !removeSet.has(edge.target)
|
|
786
|
+
);
|
|
787
|
+
model.set("nodes", nodes);
|
|
788
|
+
model.set("edges", edges);
|
|
789
|
+
model.set("selected_node", null);
|
|
790
|
+
model.set("selected_edge", null);
|
|
791
|
+
model.set("selected_nodes", []);
|
|
792
|
+
model.set("selected_edges", []);
|
|
793
|
+
model.save_changes();
|
|
794
|
+
}
|
|
795
|
+
});
|
|
796
|
+
|
|
679
797
|
// Double-click node: unpin if pinned, otherwise expand (fetch neighbors)
|
|
680
798
|
renderer.on("doubleClickNode", ({ node }) => {
|
|
681
799
|
const pinned = { ...(model.get("pinned_nodes") || {}) };
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Results drawer component with collapsible lip toggle.
|
|
3
|
+
*/
|
|
4
|
+
import { ICONS } from "./icons.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Create the results drawer (bottom panel with lip toggle).
|
|
8
|
+
*/
|
|
9
|
+
export function createResultsDrawer(model) {
|
|
10
|
+
const drawer = document.createElement("div");
|
|
11
|
+
drawer.className = "awg-results-drawer";
|
|
12
|
+
|
|
13
|
+
// Lip (always visible, toggles the drawer)
|
|
14
|
+
const lip = document.createElement("div");
|
|
15
|
+
lip.className = "awg-results-lip";
|
|
16
|
+
|
|
17
|
+
const lipIcon = document.createElement("span");
|
|
18
|
+
lipIcon.className = "awg-results-lip-icon";
|
|
19
|
+
lipIcon.innerHTML = ICONS.chevronUp;
|
|
20
|
+
|
|
21
|
+
const lipLabel = document.createElement("span");
|
|
22
|
+
lipLabel.className = "awg-results-lip-label";
|
|
23
|
+
lipLabel.innerHTML = `${ICONS.table} Results`;
|
|
24
|
+
|
|
25
|
+
const lipTiming = document.createElement("span");
|
|
26
|
+
lipTiming.className = "awg-results-lip-timing";
|
|
27
|
+
|
|
28
|
+
function updateLipTiming() {
|
|
29
|
+
const ms = model.get("query_time") || 0;
|
|
30
|
+
const nodes = model.get("nodes") || [];
|
|
31
|
+
const edges = model.get("edges") || [];
|
|
32
|
+
const parts = [];
|
|
33
|
+
if (nodes.length > 0 || edges.length > 0) {
|
|
34
|
+
parts.push(`${nodes.length}n / ${edges.length}e`);
|
|
35
|
+
}
|
|
36
|
+
if (ms > 0) {
|
|
37
|
+
parts.push(ms < 1 ? "<1 ms" : `${ms.toFixed(1)} ms`);
|
|
38
|
+
}
|
|
39
|
+
lipTiming.textContent = parts.join(" \u00b7 ");
|
|
40
|
+
}
|
|
41
|
+
updateLipTiming();
|
|
42
|
+
model.on("change:query_time", updateLipTiming);
|
|
43
|
+
model.on("change:nodes", updateLipTiming);
|
|
44
|
+
model.on("change:edges", updateLipTiming);
|
|
45
|
+
|
|
46
|
+
lip.appendChild(lipLabel);
|
|
47
|
+
lip.appendChild(lipTiming);
|
|
48
|
+
lip.appendChild(lipIcon);
|
|
49
|
+
|
|
50
|
+
// Body (collapsible)
|
|
51
|
+
const body = document.createElement("div");
|
|
52
|
+
body.className = "awg-results-body";
|
|
53
|
+
|
|
54
|
+
// Tab bar
|
|
55
|
+
const tabBar = document.createElement("div");
|
|
56
|
+
tabBar.className = "awg-results-tabs";
|
|
57
|
+
|
|
58
|
+
const nodesTab = document.createElement("button");
|
|
59
|
+
nodesTab.className = "awg-results-tab awg-results-tab-active";
|
|
60
|
+
nodesTab.textContent = "Nodes";
|
|
61
|
+
|
|
62
|
+
const edgesTab = document.createElement("button");
|
|
63
|
+
edgesTab.className = "awg-results-tab";
|
|
64
|
+
edgesTab.textContent = "Edges";
|
|
65
|
+
|
|
66
|
+
tabBar.appendChild(nodesTab);
|
|
67
|
+
tabBar.appendChild(edgesTab);
|
|
68
|
+
body.appendChild(tabBar);
|
|
69
|
+
|
|
70
|
+
// Table container
|
|
71
|
+
const tableContainer = document.createElement("div");
|
|
72
|
+
tableContainer.className = "awg-results-table-wrap";
|
|
73
|
+
body.appendChild(tableContainer);
|
|
74
|
+
|
|
75
|
+
let activeTab = "nodes";
|
|
76
|
+
|
|
77
|
+
function setActiveTab(tab) {
|
|
78
|
+
activeTab = tab;
|
|
79
|
+
nodesTab.classList.toggle("awg-results-tab-active", tab === "nodes");
|
|
80
|
+
edgesTab.classList.toggle("awg-results-tab-active", tab === "edges");
|
|
81
|
+
renderTable();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
nodesTab.addEventListener("click", () => setActiveTab("nodes"));
|
|
85
|
+
edgesTab.addEventListener("click", () => setActiveTab("edges"));
|
|
86
|
+
|
|
87
|
+
function renderTable() {
|
|
88
|
+
tableContainer.innerHTML = "";
|
|
89
|
+
const items = model.get(activeTab === "nodes" ? "nodes" : "edges") || [];
|
|
90
|
+
|
|
91
|
+
if (items.length === 0) {
|
|
92
|
+
const empty = document.createElement("div");
|
|
93
|
+
empty.className = "awg-results-empty";
|
|
94
|
+
empty.textContent = activeTab === "nodes" ? "No nodes" : "No edges";
|
|
95
|
+
tableContainer.appendChild(empty);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Collect all keys across items (skip internal rendering keys)
|
|
100
|
+
const skipKeys = new Set(["x", "y", "size", "color"]);
|
|
101
|
+
const keys = [];
|
|
102
|
+
const seen = new Set();
|
|
103
|
+
items.forEach((item) => {
|
|
104
|
+
Object.keys(item).forEach((k) => {
|
|
105
|
+
if (!seen.has(k) && !skipKeys.has(k)) {
|
|
106
|
+
seen.add(k);
|
|
107
|
+
keys.push(k);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const table = document.createElement("table");
|
|
113
|
+
table.className = "awg-results-table";
|
|
114
|
+
|
|
115
|
+
// Header
|
|
116
|
+
const thead = document.createElement("thead");
|
|
117
|
+
const headerRow = document.createElement("tr");
|
|
118
|
+
keys.forEach((k) => {
|
|
119
|
+
const th = document.createElement("th");
|
|
120
|
+
th.textContent = k;
|
|
121
|
+
headerRow.appendChild(th);
|
|
122
|
+
});
|
|
123
|
+
thead.appendChild(headerRow);
|
|
124
|
+
table.appendChild(thead);
|
|
125
|
+
|
|
126
|
+
// Body (limit to 100 rows for performance)
|
|
127
|
+
const tbody = document.createElement("tbody");
|
|
128
|
+
const limit = Math.min(items.length, 100);
|
|
129
|
+
for (let i = 0; i < limit; i++) {
|
|
130
|
+
const row = document.createElement("tr");
|
|
131
|
+
keys.forEach((k) => {
|
|
132
|
+
const td = document.createElement("td");
|
|
133
|
+
const val = items[i][k];
|
|
134
|
+
if (val === undefined || val === null) {
|
|
135
|
+
td.textContent = "";
|
|
136
|
+
} else if (typeof val === "object") {
|
|
137
|
+
td.textContent = JSON.stringify(val);
|
|
138
|
+
} else {
|
|
139
|
+
td.textContent = String(val);
|
|
140
|
+
}
|
|
141
|
+
row.appendChild(td);
|
|
142
|
+
});
|
|
143
|
+
tbody.appendChild(row);
|
|
144
|
+
}
|
|
145
|
+
table.appendChild(tbody);
|
|
146
|
+
tableContainer.appendChild(table);
|
|
147
|
+
|
|
148
|
+
if (items.length > 100) {
|
|
149
|
+
const more = document.createElement("div");
|
|
150
|
+
more.className = "awg-results-empty";
|
|
151
|
+
more.textContent = `Showing 100 of ${items.length} rows`;
|
|
152
|
+
tableContainer.appendChild(more);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
model.on("change:nodes", () => { if (activeTab === "nodes") renderTable(); });
|
|
157
|
+
model.on("change:edges", () => { if (activeTab === "edges") renderTable(); });
|
|
158
|
+
renderTable();
|
|
159
|
+
|
|
160
|
+
drawer.appendChild(lip);
|
|
161
|
+
drawer.appendChild(body);
|
|
162
|
+
|
|
163
|
+
let isOpen = false;
|
|
164
|
+
|
|
165
|
+
function toggle() {
|
|
166
|
+
isOpen = !isOpen;
|
|
167
|
+
drawer.classList.toggle("awg-results-open", isOpen);
|
|
168
|
+
lipIcon.style.transform = isOpen ? "" : "rotate(180deg)";
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
lip.addEventListener("click", toggle);
|
|
172
|
+
|
|
173
|
+
// Start closed
|
|
174
|
+
lipIcon.style.transform = "rotate(180deg)";
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
element: drawer,
|
|
178
|
+
open: () => { if (!isOpen) toggle(); },
|
|
179
|
+
close: () => { if (isOpen) toggle(); },
|
|
180
|
+
toggle,
|
|
181
|
+
isOpen: () => isOpen,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
@@ -90,6 +90,53 @@
|
|
|
90
90
|
color: var(--awg-text-muted);
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
/* === SEARCH INPUT === */
|
|
94
|
+
.awg-search-wrap {
|
|
95
|
+
display: flex;
|
|
96
|
+
align-items: center;
|
|
97
|
+
position: relative;
|
|
98
|
+
flex-shrink: 0;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.awg-search-icon {
|
|
102
|
+
position: absolute;
|
|
103
|
+
left: 8px;
|
|
104
|
+
color: var(--awg-text-muted);
|
|
105
|
+
display: flex;
|
|
106
|
+
pointer-events: none;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.awg-search-input {
|
|
110
|
+
width: 140px;
|
|
111
|
+
padding: 6px 8px 6px 28px;
|
|
112
|
+
border: 1px solid var(--awg-input-border);
|
|
113
|
+
border-radius: 6px;
|
|
114
|
+
font-size: 12px;
|
|
115
|
+
background: var(--awg-input-bg);
|
|
116
|
+
color: var(--awg-text);
|
|
117
|
+
transition: border-color 0.15s, box-shadow 0.15s, width 0.2s;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
.awg-search-input:focus {
|
|
121
|
+
outline: none;
|
|
122
|
+
border-color: var(--awg-accent);
|
|
123
|
+
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.15);
|
|
124
|
+
width: 180px;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.awg-search-input::placeholder {
|
|
128
|
+
color: var(--awg-text-muted);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/* === COUNT BADGE === */
|
|
132
|
+
.awg-count-badge {
|
|
133
|
+
font-size: 11px;
|
|
134
|
+
color: var(--awg-text-muted);
|
|
135
|
+
white-space: nowrap;
|
|
136
|
+
flex-shrink: 0;
|
|
137
|
+
font-variant-numeric: tabular-nums;
|
|
138
|
+
}
|
|
139
|
+
|
|
93
140
|
/* === BUTTONS === */
|
|
94
141
|
.awg-btn {
|
|
95
142
|
display: inline-flex;
|
|
@@ -218,7 +265,7 @@
|
|
|
218
265
|
display: flex;
|
|
219
266
|
flex-direction: column;
|
|
220
267
|
gap: 2px;
|
|
221
|
-
z-index:
|
|
268
|
+
z-index: 25;
|
|
222
269
|
}
|
|
223
270
|
|
|
224
271
|
.awg-zoom-btn {
|
|
@@ -774,6 +821,139 @@
|
|
|
774
821
|
}
|
|
775
822
|
}
|
|
776
823
|
|
|
824
|
+
/* === TIMING BADGE === */
|
|
825
|
+
.awg-timing-badge {
|
|
826
|
+
color: var(--awg-accent);
|
|
827
|
+
font-size: 11px;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/* === RESULTS DRAWER === */
|
|
831
|
+
.awg-results-drawer {
|
|
832
|
+
border-top: 1px solid var(--awg-border);
|
|
833
|
+
background: var(--awg-bg-secondary);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
.awg-results-lip {
|
|
837
|
+
display: flex;
|
|
838
|
+
align-items: center;
|
|
839
|
+
justify-content: center;
|
|
840
|
+
gap: 8px;
|
|
841
|
+
height: 28px;
|
|
842
|
+
cursor: pointer;
|
|
843
|
+
font-size: 11px;
|
|
844
|
+
color: var(--awg-text-secondary);
|
|
845
|
+
user-select: none;
|
|
846
|
+
transition: background 0.15s;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
.awg-results-lip:hover {
|
|
850
|
+
background: var(--awg-bg-tertiary);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
.awg-results-lip-icon {
|
|
854
|
+
display: flex;
|
|
855
|
+
transition: transform 0.2s;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
.awg-results-lip-label {
|
|
859
|
+
display: flex;
|
|
860
|
+
align-items: center;
|
|
861
|
+
gap: 4px;
|
|
862
|
+
font-weight: 500;
|
|
863
|
+
text-transform: uppercase;
|
|
864
|
+
letter-spacing: 0.3px;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
.awg-results-lip-timing {
|
|
868
|
+
font-variant-numeric: tabular-nums;
|
|
869
|
+
color: var(--awg-text-muted);
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
.awg-results-body {
|
|
873
|
+
max-height: 0;
|
|
874
|
+
overflow: hidden;
|
|
875
|
+
transition: max-height 0.25s ease;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
.awg-results-open .awg-results-body {
|
|
879
|
+
max-height: 220px;
|
|
880
|
+
overflow-y: auto;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
.awg-results-tabs {
|
|
884
|
+
display: flex;
|
|
885
|
+
gap: 0;
|
|
886
|
+
border-bottom: 1px solid var(--awg-border);
|
|
887
|
+
padding: 0 12px;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
.awg-results-tab {
|
|
891
|
+
padding: 6px 14px;
|
|
892
|
+
border: none;
|
|
893
|
+
background: none;
|
|
894
|
+
font-size: 12px;
|
|
895
|
+
font-weight: 500;
|
|
896
|
+
color: var(--awg-text-muted);
|
|
897
|
+
cursor: pointer;
|
|
898
|
+
border-bottom: 2px solid transparent;
|
|
899
|
+
transition: color 0.15s, border-color 0.15s;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
.awg-results-tab:hover {
|
|
903
|
+
color: var(--awg-text);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
.awg-results-tab-active {
|
|
907
|
+
color: var(--awg-accent);
|
|
908
|
+
border-bottom-color: var(--awg-accent);
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
.awg-results-table-wrap {
|
|
912
|
+
overflow-x: auto;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
.awg-results-table {
|
|
916
|
+
width: 100%;
|
|
917
|
+
border-collapse: collapse;
|
|
918
|
+
font-size: 12px;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
.awg-results-table th {
|
|
922
|
+
position: sticky;
|
|
923
|
+
top: 0;
|
|
924
|
+
background: var(--awg-bg-tertiary);
|
|
925
|
+
padding: 6px 10px;
|
|
926
|
+
text-align: left;
|
|
927
|
+
font-weight: 600;
|
|
928
|
+
font-size: 10px;
|
|
929
|
+
text-transform: uppercase;
|
|
930
|
+
letter-spacing: 0.3px;
|
|
931
|
+
color: var(--awg-text-secondary);
|
|
932
|
+
white-space: nowrap;
|
|
933
|
+
border-bottom: 1px solid var(--awg-border);
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
.awg-results-table td {
|
|
937
|
+
padding: 5px 10px;
|
|
938
|
+
color: var(--awg-text);
|
|
939
|
+
border-bottom: 1px solid var(--awg-border);
|
|
940
|
+
max-width: 200px;
|
|
941
|
+
overflow: hidden;
|
|
942
|
+
text-overflow: ellipsis;
|
|
943
|
+
white-space: nowrap;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
.awg-results-table tbody tr:hover {
|
|
947
|
+
background: var(--awg-bg-tertiary);
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
.awg-results-empty {
|
|
951
|
+
padding: 16px;
|
|
952
|
+
text-align: center;
|
|
953
|
+
color: var(--awg-text-muted);
|
|
954
|
+
font-size: 12px;
|
|
955
|
+
}
|
|
956
|
+
|
|
777
957
|
/* === Touch-Friendly Targets === */
|
|
778
958
|
@media (pointer: coarse) {
|
|
779
959
|
.awg-btn {
|
|
@@ -7,8 +7,9 @@ import { updateStatusDot } from "./settings.js";
|
|
|
7
7
|
/**
|
|
8
8
|
* Create the toolbar component.
|
|
9
9
|
* @param {object} panels - { schema, settings, properties } panel objects with toggle/close/isOpen
|
|
10
|
+
* @param {function} onSearch - callback(term) for search filtering
|
|
10
11
|
*/
|
|
11
|
-
export function createToolbar(model, onExecuteQuery, panels) {
|
|
12
|
+
export function createToolbar(model, onExecuteQuery, panels, onSearch) {
|
|
12
13
|
const toolbar = document.createElement("div");
|
|
13
14
|
toolbar.className = "awg-toolbar";
|
|
14
15
|
|
|
@@ -71,6 +72,53 @@ export function createToolbar(model, onExecuteQuery, panels) {
|
|
|
71
72
|
toolbar.appendChild(queryContainer);
|
|
72
73
|
}
|
|
73
74
|
|
|
75
|
+
// Search input (compact filter for nodes)
|
|
76
|
+
const searchWrap = document.createElement("div");
|
|
77
|
+
searchWrap.className = "awg-search-wrap";
|
|
78
|
+
const searchIcon = document.createElement("span");
|
|
79
|
+
searchIcon.className = "awg-search-icon";
|
|
80
|
+
searchIcon.innerHTML = ICONS.search;
|
|
81
|
+
searchWrap.appendChild(searchIcon);
|
|
82
|
+
|
|
83
|
+
const searchInput = document.createElement("input");
|
|
84
|
+
searchInput.type = "text";
|
|
85
|
+
searchInput.className = "awg-search-input";
|
|
86
|
+
searchInput.placeholder = "Filter nodes...";
|
|
87
|
+
searchInput.addEventListener("input", () => {
|
|
88
|
+
onSearch?.(searchInput.value);
|
|
89
|
+
});
|
|
90
|
+
searchWrap.appendChild(searchInput);
|
|
91
|
+
toolbar.appendChild(searchWrap);
|
|
92
|
+
|
|
93
|
+
// Count badge
|
|
94
|
+
const countBadge = document.createElement("span");
|
|
95
|
+
countBadge.className = "awg-count-badge";
|
|
96
|
+
function updateCount() {
|
|
97
|
+
const nodes = model.get("nodes") || [];
|
|
98
|
+
const edges = model.get("edges") || [];
|
|
99
|
+
countBadge.textContent = `${nodes.length} nodes \u00b7 ${edges.length} edges`;
|
|
100
|
+
}
|
|
101
|
+
updateCount();
|
|
102
|
+
model.on("change:nodes", updateCount);
|
|
103
|
+
model.on("change:edges", updateCount);
|
|
104
|
+
toolbar.appendChild(countBadge);
|
|
105
|
+
|
|
106
|
+
// Query timing badge
|
|
107
|
+
const timingBadge = document.createElement("span");
|
|
108
|
+
timingBadge.className = "awg-count-badge awg-timing-badge";
|
|
109
|
+
function updateTiming() {
|
|
110
|
+
const ms = model.get("query_time") || 0;
|
|
111
|
+
if (ms > 0) {
|
|
112
|
+
timingBadge.textContent = ms < 1 ? "<1 ms" : `${ms.toFixed(1)} ms`;
|
|
113
|
+
timingBadge.style.display = "";
|
|
114
|
+
} else {
|
|
115
|
+
timingBadge.style.display = "none";
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
updateTiming();
|
|
119
|
+
model.on("change:query_time", updateTiming);
|
|
120
|
+
toolbar.appendChild(timingBadge);
|
|
121
|
+
|
|
74
122
|
// Settings button (mutual exclusion with properties)
|
|
75
123
|
if (model.get("show_settings")) {
|
|
76
124
|
const settingsBtn = document.createElement("button");
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
|
+
import time
|
|
6
7
|
from typing import TYPE_CHECKING, Any
|
|
7
8
|
|
|
8
9
|
import anywidget
|
|
@@ -115,6 +116,7 @@ class Graph(anywidget.AnyWidget):
|
|
|
115
116
|
query = traitlets.Unicode(default_value="").tag(sync=True)
|
|
116
117
|
query_language = traitlets.Unicode(default_value="cypher").tag(sync=True)
|
|
117
118
|
query_running = traitlets.Bool(default_value=False).tag(sync=True)
|
|
119
|
+
query_time = traitlets.Float(default_value=0.0).tag(sync=True)
|
|
118
120
|
query_error = traitlets.Unicode(default_value="").tag(sync=True)
|
|
119
121
|
|
|
120
122
|
# === Connection State ===
|
|
@@ -271,7 +273,9 @@ class Graph(anywidget.AnyWidget):
|
|
|
271
273
|
try:
|
|
272
274
|
self.query_running = True
|
|
273
275
|
self.query_error = ""
|
|
276
|
+
start = time.perf_counter()
|
|
274
277
|
nodes, edges = self._backend.execute(self.query, language=self.query_language)
|
|
278
|
+
self.query_time = (time.perf_counter() - start) * 1000
|
|
275
279
|
self.nodes = nodes
|
|
276
280
|
self.edges = edges
|
|
277
281
|
except Exception as e:
|
|
@@ -55,6 +55,7 @@ def test_default_options():
|
|
|
55
55
|
assert graph.selection_mode == "click"
|
|
56
56
|
assert graph.database_backend == "grafeo"
|
|
57
57
|
assert graph.max_nodes == 300
|
|
58
|
+
assert graph.query_time == 0.0
|
|
58
59
|
|
|
59
60
|
|
|
60
61
|
def test_custom_options():
|
|
@@ -587,3 +588,150 @@ def test_build_neighbor_query_aql():
|
|
|
587
588
|
q = Graph._build_neighbor_query("doc/1", "aql")
|
|
588
589
|
assert "doc/1" in q
|
|
589
590
|
assert "FOR" in q
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
# ------------------------------------------------------------------ #
|
|
594
|
+
# Grafeo backend: language dispatch #
|
|
595
|
+
# ------------------------------------------------------------------ #
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def test_grafeo_backend_language_dispatch():
|
|
599
|
+
"""execute() uses the language-specific method when available."""
|
|
600
|
+
from anywidget_graph.backends.grafeo import GrafeoBackend
|
|
601
|
+
|
|
602
|
+
calls: list[tuple[str, str]] = []
|
|
603
|
+
|
|
604
|
+
class FakeDB:
|
|
605
|
+
def execute_cypher(self, query):
|
|
606
|
+
calls.append(("cypher", query))
|
|
607
|
+
return []
|
|
608
|
+
|
|
609
|
+
def execute(self, query):
|
|
610
|
+
calls.append(("generic", query))
|
|
611
|
+
return []
|
|
612
|
+
|
|
613
|
+
backend = GrafeoBackend(FakeDB())
|
|
614
|
+
backend.execute("MATCH (n) RETURN n", language="cypher")
|
|
615
|
+
assert calls[-1] == ("cypher", "MATCH (n) RETURN n")
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def test_grafeo_backend_language_fallback():
|
|
619
|
+
"""execute() falls back to generic execute for unknown languages."""
|
|
620
|
+
from anywidget_graph.backends.grafeo import GrafeoBackend
|
|
621
|
+
|
|
622
|
+
calls: list[tuple[str, str]] = []
|
|
623
|
+
|
|
624
|
+
class FakeDB:
|
|
625
|
+
def execute(self, query):
|
|
626
|
+
calls.append(("generic", query))
|
|
627
|
+
return []
|
|
628
|
+
|
|
629
|
+
backend = GrafeoBackend(FakeDB())
|
|
630
|
+
backend.execute("SELECT 1", language="sql")
|
|
631
|
+
assert calls[-1] == ("generic", "SELECT 1")
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
# ------------------------------------------------------------------ #
|
|
635
|
+
# Grafeo backend: native dict processing #
|
|
636
|
+
# ------------------------------------------------------------------ #
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def test_grafeo_node_detection():
|
|
640
|
+
"""_is_grafeo_node detects dicts with _id and _labels."""
|
|
641
|
+
from anywidget_graph.backends.grafeo import GrafeoBackend
|
|
642
|
+
|
|
643
|
+
assert GrafeoBackend._is_grafeo_node({"_id": 1, "_labels": ["Person"]})
|
|
644
|
+
assert not GrafeoBackend._is_grafeo_node({"id": 1, "label": "Person"})
|
|
645
|
+
assert not GrafeoBackend._is_grafeo_node({"_id": 1})
|
|
646
|
+
assert not GrafeoBackend._is_grafeo_node("not a dict")
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def test_grafeo_relationship_detection():
|
|
650
|
+
"""_is_grafeo_relationship detects dicts with _source, _target, _type."""
|
|
651
|
+
from anywidget_graph.backends.grafeo import GrafeoBackend
|
|
652
|
+
|
|
653
|
+
assert GrafeoBackend._is_grafeo_relationship({"_source": 1, "_target": 2, "_type": "KNOWS"})
|
|
654
|
+
assert not GrafeoBackend._is_grafeo_relationship({"source": 1, "target": 2, "label": "KNOWS"})
|
|
655
|
+
assert not GrafeoBackend._is_grafeo_relationship({"_source": 1, "_target": 2})
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def test_grafeo_node_to_dict():
|
|
659
|
+
"""_grafeo_node_to_dict converts Grafeo node format to widget format."""
|
|
660
|
+
from anywidget_graph.backends.grafeo import GrafeoBackend
|
|
661
|
+
|
|
662
|
+
node = {"_id": 42, "_labels": ["Person", "Employee"], "name": "Alice", "age": 30}
|
|
663
|
+
result = GrafeoBackend._grafeo_node_to_dict(node)
|
|
664
|
+
|
|
665
|
+
assert result["id"] == "42"
|
|
666
|
+
assert result["label"] == "Person"
|
|
667
|
+
assert result["labels"] == ["Person", "Employee"]
|
|
668
|
+
assert result["name"] == "Alice"
|
|
669
|
+
assert result["age"] == 30
|
|
670
|
+
assert "_id" not in result
|
|
671
|
+
assert "_labels" not in result
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def test_grafeo_node_to_dict_name_fallback():
|
|
675
|
+
"""_grafeo_node_to_dict uses name as label when no _labels."""
|
|
676
|
+
from anywidget_graph.backends.grafeo import GrafeoBackend
|
|
677
|
+
|
|
678
|
+
node = {"_id": 1, "_labels": [], "name": "Bob"}
|
|
679
|
+
result = GrafeoBackend._grafeo_node_to_dict(node)
|
|
680
|
+
assert result["label"] == "Bob"
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def test_grafeo_relationship_to_dict():
|
|
684
|
+
"""_grafeo_relationship_to_dict converts Grafeo edge format to widget format."""
|
|
685
|
+
from anywidget_graph.backends.grafeo import GrafeoBackend
|
|
686
|
+
|
|
687
|
+
rel = {"_source": 1, "_target": 2, "_type": "KNOWS", "since": 2020}
|
|
688
|
+
result = GrafeoBackend._grafeo_relationship_to_dict(rel)
|
|
689
|
+
|
|
690
|
+
assert result["source"] == "1"
|
|
691
|
+
assert result["target"] == "2"
|
|
692
|
+
assert result["label"] == "KNOWS"
|
|
693
|
+
assert result["since"] == 2020
|
|
694
|
+
assert "_source" not in result
|
|
695
|
+
assert "_type" not in result
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def test_grafeo_backend_process_native_dicts():
|
|
699
|
+
"""Full round-trip: backend processes Grafeo native dict results."""
|
|
700
|
+
from anywidget_graph.backends.grafeo import GrafeoBackend
|
|
701
|
+
|
|
702
|
+
class FakeDB:
|
|
703
|
+
def execute(self, query):
|
|
704
|
+
return [
|
|
705
|
+
{
|
|
706
|
+
"n": {"_id": 1, "_labels": ["Person"], "name": "Alice"},
|
|
707
|
+
"r": {"_source": 1, "_target": 2, "_type": "KNOWS", "since": 2020},
|
|
708
|
+
"m": {"_id": 2, "_labels": ["Person"], "name": "Bob"},
|
|
709
|
+
},
|
|
710
|
+
]
|
|
711
|
+
|
|
712
|
+
backend = GrafeoBackend(FakeDB())
|
|
713
|
+
nodes, edges = backend.execute("MATCH (n)-[r]->(m) RETURN n, r, m")
|
|
714
|
+
|
|
715
|
+
assert len(nodes) == 2
|
|
716
|
+
assert len(edges) == 1
|
|
717
|
+
|
|
718
|
+
node_ids = {n["id"] for n in nodes}
|
|
719
|
+
assert node_ids == {"1", "2"}
|
|
720
|
+
|
|
721
|
+
assert edges[0]["source"] == "1"
|
|
722
|
+
assert edges[0]["target"] == "2"
|
|
723
|
+
assert edges[0]["label"] == "KNOWS"
|
|
724
|
+
assert edges[0]["since"] == 2020
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
# ------------------------------------------------------------------ #
|
|
728
|
+
# Query timing #
|
|
729
|
+
# ------------------------------------------------------------------ #
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def test_query_time_traitlet():
|
|
733
|
+
"""query_time traitlet defaults to 0.0 and can be set."""
|
|
734
|
+
graph = Graph()
|
|
735
|
+
assert graph.query_time == 0.0
|
|
736
|
+
graph.query_time = 42.5
|
|
737
|
+
assert graph.query_time == 42.5
|
|
@@ -154,7 +154,7 @@ wheels = [
|
|
|
154
154
|
|
|
155
155
|
[[package]]
|
|
156
156
|
name = "anywidget-graph"
|
|
157
|
-
version = "0.2.
|
|
157
|
+
version = "0.2.8"
|
|
158
158
|
source = { editable = "." }
|
|
159
159
|
dependencies = [
|
|
160
160
|
{ name = "anywidget" },
|
|
@@ -191,16 +191,16 @@ dev = [
|
|
|
191
191
|
[package.metadata]
|
|
192
192
|
requires-dist = [
|
|
193
193
|
{ name = "anywidget", specifier = ">=0.9.21" },
|
|
194
|
-
{ name = "grafeo", marker = "extra == 'grafeo'", specifier = ">=0.5
|
|
194
|
+
{ name = "grafeo", marker = "extra == 'grafeo'", specifier = ">=0.5" },
|
|
195
195
|
{ name = "gremlinpython", marker = "extra == 'cosmosdb'", specifier = ">=3.7" },
|
|
196
196
|
{ name = "marimo", specifier = ">=0.19.9" },
|
|
197
|
-
{ name = "marimo", marker = "extra == 'dev'", specifier = ">=0.
|
|
197
|
+
{ name = "marimo", marker = "extra == 'dev'", specifier = ">=0.20" },
|
|
198
198
|
{ name = "networkx", marker = "extra == 'networkx'", specifier = ">=3.0" },
|
|
199
199
|
{ name = "pandas", marker = "extra == 'pandas'", specifier = ">=2.0" },
|
|
200
|
-
{ name = "prek", marker = "extra == 'dev'", specifier = ">=0.3
|
|
200
|
+
{ name = "prek", marker = "extra == 'dev'", specifier = ">=0.3" },
|
|
201
201
|
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.2" },
|
|
202
|
-
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15
|
|
203
|
-
{ name = "ty", marker = "extra == 'dev'", specifier = ">=0.0.
|
|
202
|
+
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15" },
|
|
203
|
+
{ name = "ty", marker = "extra == 'dev'", specifier = ">=0.0.20" },
|
|
204
204
|
]
|
|
205
205
|
|
|
206
206
|
[package.metadata.requires-dev]
|
|
@@ -614,7 +614,7 @@ wheels = [
|
|
|
614
614
|
|
|
615
615
|
[[package]]
|
|
616
616
|
name = "marimo"
|
|
617
|
-
version = "0.
|
|
617
|
+
version = "0.20.4"
|
|
618
618
|
source = { registry = "https://pypi.org/simple" }
|
|
619
619
|
dependencies = [
|
|
620
620
|
{ name = "click" },
|
|
@@ -635,9 +635,9 @@ dependencies = [
|
|
|
635
635
|
{ name = "uvicorn" },
|
|
636
636
|
{ name = "websockets" },
|
|
637
637
|
]
|
|
638
|
-
sdist = { url = "https://files.pythonhosted.org/packages/
|
|
638
|
+
sdist = { url = "https://files.pythonhosted.org/packages/9c/eb/3c19ce54be3440cece96bffbb2027c57d618ab336d53ef6a962f570ce468/marimo-0.20.4.tar.gz", hash = "sha256:7f46ce837953717504673e33e47fb42a619bf5f9d2000d1a3a3b1663a47c5498", size = 38336283 }
|
|
639
639
|
wheels = [
|
|
640
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
640
|
+
{ url = "https://files.pythonhosted.org/packages/56/62/bf2c92cc7aab69edfb3907e9bf102c37c1e16b8f4bc6ddd4258ae4c8063c/marimo-0.20.4-py3-none-any.whl", hash = "sha256:3200edd7209f470821b58deb4a45e0577889fab1052748d77088f18bef7d9dd8", size = 38752562 },
|
|
641
641
|
]
|
|
642
642
|
|
|
643
643
|
[[package]]
|
|
@@ -1381,26 +1381,26 @@ wheels = [
|
|
|
1381
1381
|
|
|
1382
1382
|
[[package]]
|
|
1383
1383
|
name = "ty"
|
|
1384
|
-
version = "0.0.
|
|
1385
|
-
source = { registry = "https://pypi.org/simple" }
|
|
1386
|
-
sdist = { url = "https://files.pythonhosted.org/packages/
|
|
1387
|
-
wheels = [
|
|
1388
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
1389
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
1390
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
1391
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
1392
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
1393
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
1394
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
1395
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
1396
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
1397
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
1398
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
1399
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
1400
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
1401
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
1402
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
1403
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
1384
|
+
version = "0.0.20"
|
|
1385
|
+
source = { registry = "https://pypi.org/simple" }
|
|
1386
|
+
sdist = { url = "https://files.pythonhosted.org/packages/56/95/8de69bb98417227b01f1b1d743c819d6456c9fd140255b6124b05b17dfd6/ty-0.0.20.tar.gz", hash = "sha256:ebba6be7974c14efbb2a9adda6ac59848f880d7259f089dfa72a093039f1dcc6", size = 5262529 }
|
|
1387
|
+
wheels = [
|
|
1388
|
+
{ url = "https://files.pythonhosted.org/packages/0b/2c/718abe48393e521bf852cd6b0f984766869b09c258d6e38a118768a91731/ty-0.0.20-py3-none-linux_armv6l.whl", hash = "sha256:7cc12769c169c9709a829c2248ee2826b7aae82e92caeac813d856f07c021eae", size = 10333656 },
|
|
1389
|
+
{ url = "https://files.pythonhosted.org/packages/41/0e/eb1c4cc4a12862e2327b72657bcebb10b7d9f17046f1bdcd6457a0211615/ty-0.0.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3b777c1bf13bc0a95985ebb8a324b8668a4a9b2e514dde5ccf09e4d55d2ff232", size = 10168505 },
|
|
1390
|
+
{ url = "https://files.pythonhosted.org/packages/89/7f/10230798e673f0dd3094dfd16e43bfd90e9494e7af6e8e7db516fb431ddf/ty-0.0.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b2a4a7db48bf8cba30365001bc2cad7fd13c1a5aacdd704cc4b7925de8ca5eb3", size = 9678510 },
|
|
1391
|
+
{ url = "https://files.pythonhosted.org/packages/7a/3d/59d9159577494edd1728f7db77b51bb07884bd21384f517963114e3ab5f6/ty-0.0.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6846427b8b353a43483e9c19936dc6a25612573b44c8f7d983dfa317e7f00d4c", size = 10162926 },
|
|
1392
|
+
{ url = "https://files.pythonhosted.org/packages/9c/a8/b7273eec3e802f78eb913fbe0ce0c16ef263723173e06a5776a8359b2c66/ty-0.0.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:245ceef5bd88df366869385cf96411cb14696334f8daa75597cf7e41c3012eb8", size = 10171702 },
|
|
1393
|
+
{ url = "https://files.pythonhosted.org/packages/9f/32/5f1144f2f04a275109db06e3498450c4721554215b80ae73652ef412eeab/ty-0.0.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c4d21d1cdf67a444d3c37583c17291ddba9382a9871021f3f5d5735e09e85efe", size = 10682552 },
|
|
1394
|
+
{ url = "https://files.pythonhosted.org/packages/6a/db/9f1f637310792f12bd6ed37d5fc8ab39ba1a9b0c6c55a33865e9f1cad840/ty-0.0.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bd4ffd907d1bd70e46af9e9a2f88622f215e1bf44658ea43b32c2c0b357299e4", size = 11242605 },
|
|
1395
|
+
{ url = "https://files.pythonhosted.org/packages/1a/68/cc9cae2e732fcfd20ccdffc508407905a023fc8493b8771c392d915528dc/ty-0.0.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b6594b58d8b0e9d16a22b3045fc1305db4b132c8d70c17784ab8c7a7cc986807", size = 10974655 },
|
|
1396
|
+
{ url = "https://files.pythonhosted.org/packages/1c/c1/b9e3e3f28fe63486331e653f6aeb4184af8b1fe80542fcf74d2dda40a93d/ty-0.0.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3662f890518ce6cf4d7568f57d03906912d2afbf948a01089a28e325b1ef198c", size = 10761325 },
|
|
1397
|
+
{ url = "https://files.pythonhosted.org/packages/39/9e/67db935bdedf219a00fb69ec5437ba24dab66e0f2e706dd54a4eca234b84/ty-0.0.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0e3ffbae58f9f0d17cdc4ac6d175ceae560b7ed7d54f9ddfb1c9f31054bcdc2c", size = 10145793 },
|
|
1398
|
+
{ url = "https://files.pythonhosted.org/packages/c7/de/b0eb815d4dc5a819c7e4faddc2a79058611169f7eef07ccc006531ce228c/ty-0.0.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:176e52bc8bb00b0e84efd34583962878a447a3a0e34ecc45fd7097a37554261b", size = 10189640 },
|
|
1399
|
+
{ url = "https://files.pythonhosted.org/packages/b8/71/63734923965cbb70df1da3e93e4b8875434e326b89e9f850611122f279bf/ty-0.0.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b2bc73025418e976ca4143dde71fb9025a90754a08ac03e6aa9b80d4bed1294b", size = 10370568 },
|
|
1400
|
+
{ url = "https://files.pythonhosted.org/packages/32/a0/a532c2048533347dff48e9ca98bd86d2c224356e101688a8edaf8d6973fb/ty-0.0.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:d52f7c9ec6e363e094b3c389c344d5a140401f14a77f0625e3f28c21918552f5", size = 10853999 },
|
|
1401
|
+
{ url = "https://files.pythonhosted.org/packages/48/88/36c652c658fe96658043e4abc8ea97801de6fb6e63ab50aaa82807bff1d8/ty-0.0.20-py3-none-win32.whl", hash = "sha256:c7d32bfe93f8fcaa52b6eef3f1b930fd7da410c2c94e96f7412c30cfbabf1d17", size = 9744206 },
|
|
1402
|
+
{ url = "https://files.pythonhosted.org/packages/ff/a7/a4a13bed1d7fd9d97aaa3c5bb5e6d3e9a689e6984806cbca2ab4c9233cac/ty-0.0.20-py3-none-win_amd64.whl", hash = "sha256:a5e10f40fc4a0a1cbcb740a4aad5c7ce35d79f030836ea3183b7a28f43170248", size = 10711999 },
|
|
1403
|
+
{ url = "https://files.pythonhosted.org/packages/8d/7e/6bfd748a9f4ff9267ed3329b86a0f02cdf6ab49f87bc36c8a164852f99fc/ty-0.0.20-py3-none-win_arm64.whl", hash = "sha256:53f7a5c12c960e71f160b734f328eff9a35d578af4b67a36b0bb5990ac5cdc27", size = 10150143 },
|
|
1404
1404
|
]
|
|
1405
1405
|
|
|
1406
1406
|
[[package]]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|