Spaces:
Running
Running
Merge pull request #70 from biggraph/darabos-lint
Browse files- .gitignore +2 -1
- .pre-commit-config.yaml +17 -0
- .python-version +1 -0
- .vscode/settings.json +15 -0
- README.md +1 -0
- biome.json +24 -0
- examples/{night demo → Airlines demo} +0 -0
- lynxkite-app/README.md +6 -0
- lynxkite-app/pyproject.toml +1 -1
- lynxkite-app/src/lynxkite_app/main.py +11 -11
- lynxkite-app/uv.lock +53 -2
- lynxkite-app/web/eslint.config.js +12 -12
- lynxkite-app/web/playwright.config.ts +10 -11
- lynxkite-app/web/postcss.config.js +1 -1
- lynxkite-app/web/src/Directory.tsx +79 -47
- lynxkite-app/web/src/apiTypes.ts +18 -12
- lynxkite-app/web/src/index.css +0 -2
- lynxkite-app/web/src/main.tsx +9 -9
- lynxkite-app/web/src/workspace/EnvironmentSelector.tsx +12 -5
- lynxkite-app/web/src/workspace/LynxKiteState.ts +1 -1
- lynxkite-app/web/src/workspace/NodeSearch.tsx +41 -26
- lynxkite-app/web/src/workspace/Workspace.tsx +211 -152
- lynxkite-app/web/src/workspace/nodes/LynxKiteNode.tsx +61 -34
- lynxkite-app/web/src/workspace/nodes/NodeParameter.tsx +64 -43
- lynxkite-app/web/src/workspace/nodes/NodeWithImage.tsx +4 -2
- lynxkite-app/web/src/workspace/nodes/NodeWithParams.tsx +13 -8
- lynxkite-app/web/src/workspace/nodes/NodeWithTableView.tsx +59 -32
- lynxkite-app/web/src/workspace/nodes/NodeWithVisualization.tsx +10 -6
- lynxkite-app/web/src/workspace/nodes/Table.tsx +20 -15
- lynxkite-app/web/tailwind.config.js +8 -8
- lynxkite-app/web/tests/basic.spec.ts +18 -23
- lynxkite-app/web/tests/directory.spec.ts +60 -60
- lynxkite-app/web/tests/errors.spec.ts +24 -24
- lynxkite-app/web/tests/examples.spec.ts +30 -40
- lynxkite-app/web/tests/lynxkite.ts +61 -50
- lynxkite-app/web/tests/upload.spec.ts +28 -26
- lynxkite-app/web/tsconfig.app.json +0 -4
- lynxkite-app/web/tsconfig.node.json +0 -4
- lynxkite-app/web/vite.config.ts +9 -12
- lynxkite-core/src/lynxkite/core/workspace.py +1 -1
- lynxkite-graph-analytics/pyproject.toml +1 -0
- lynxkite-graph-analytics/src/lynxkite_graph_analytics/lynxkite_ops.py +12 -17
- lynxkite-graph-analytics/uv.lock +131 -2
.gitignore
CHANGED
@@ -1,5 +1,6 @@
|
|
1 |
.vscode/*
|
2 |
!.vscode/extensions.json
|
|
|
3 |
.idea
|
4 |
.DS_Store
|
5 |
*.suo
|
@@ -14,4 +15,4 @@ build
|
|
14 |
joblib-cache
|
15 |
*.egg-info
|
16 |
|
17 |
-
lynxkite-app/crdt_data
|
|
|
1 |
.vscode/*
|
2 |
!.vscode/extensions.json
|
3 |
+
!.vscode/settings.json
|
4 |
.idea
|
5 |
.DS_Store
|
6 |
*.suo
|
|
|
15 |
joblib-cache
|
16 |
*.egg-info
|
17 |
|
18 |
+
lynxkite-app/crdt_data
|
.pre-commit-config.yaml
ADDED
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
repos:
|
2 |
+
- repo: https://github.com/pre-commit/pre-commit-hooks
|
3 |
+
rev: v4.5.0
|
4 |
+
hooks:
|
5 |
+
- id: trailing-whitespace
|
6 |
+
- id: end-of-file-fixer
|
7 |
+
- id: check-yaml
|
8 |
+
- repo: https://github.com/astral-sh/ruff-pre-commit
|
9 |
+
rev: v0.9.6
|
10 |
+
hooks:
|
11 |
+
- id: ruff
|
12 |
+
args: [ --fix ]
|
13 |
+
- id: ruff-format
|
14 |
+
- repo: https://github.com/biomejs/pre-commit
|
15 |
+
rev: v1.9.4
|
16 |
+
hooks:
|
17 |
+
- id: biome-check
|
.python-version
ADDED
@@ -0,0 +1 @@
|
|
|
|
|
1 |
+
3.11
|
.vscode/settings.json
ADDED
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"editor.formatOnSave": true,
|
3 |
+
"ruff.enable": true,
|
4 |
+
"html.format.enable": true,
|
5 |
+
"css.format.enable": true,
|
6 |
+
"typescript.format.enable": true,
|
7 |
+
"files.insertFinalNewline": true,
|
8 |
+
"files.trimTrailingWhitespace": true,
|
9 |
+
"[python]": {
|
10 |
+
"editor.defaultFormatter": "charliermarsh.ruff"
|
11 |
+
},
|
12 |
+
"[javascript]": {
|
13 |
+
"editor.defaultFormatter": "biomejs.biome"
|
14 |
+
}
|
15 |
+
}
|
README.md
CHANGED
@@ -23,6 +23,7 @@ Install everything like this:
|
|
23 |
```bash
|
24 |
uv venv
|
25 |
source .venv/bin/activate
|
|
|
26 |
# The [dev] tag is only needed if you intend on running tests
|
27 |
uv pip install -e lynxkite-core/[dev] -e lynxkite-app/[dev] -e lynxkite-graph-analytics/[dev] -e lynxkite-lynxscribe/ -e lynxkite-pillow-example/
|
28 |
```
|
|
|
23 |
```bash
|
24 |
uv venv
|
25 |
source .venv/bin/activate
|
26 |
+
uvx pre-commit install
|
27 |
# The [dev] tag is only needed if you intend on running tests
|
28 |
uv pip install -e lynxkite-core/[dev] -e lynxkite-app/[dev] -e lynxkite-graph-analytics/[dev] -e lynxkite-lynxscribe/ -e lynxkite-pillow-example/
|
29 |
```
|
biome.json
ADDED
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"formatter": {
|
3 |
+
"ignore": ["**/node_modules/**", "**/dist/**"],
|
4 |
+
"indentStyle": "space"
|
5 |
+
},
|
6 |
+
"linter": {
|
7 |
+
"ignore": ["**/node_modules/**", "**/dist/**"],
|
8 |
+
"rules": {
|
9 |
+
"suspicious": {
|
10 |
+
"noExplicitAny": "off",
|
11 |
+
"noArrayIndexKey": "off"
|
12 |
+
},
|
13 |
+
"style": {
|
14 |
+
"noNonNullAssertion": "off"
|
15 |
+
},
|
16 |
+
"a11y": {
|
17 |
+
"useKeyWithClickEvents": "off",
|
18 |
+
"useValidAnchor": "off",
|
19 |
+
"useButtonType": "off",
|
20 |
+
"noNoninteractiveTabindex": "off"
|
21 |
+
}
|
22 |
+
}
|
23 |
+
}
|
24 |
+
}
|
examples/{night demo → Airlines demo}
RENAMED
The diff for this file is too large to render.
See raw diff
|
|
lynxkite-app/README.md
CHANGED
@@ -23,3 +23,9 @@ cd web
|
|
23 |
npm i
|
24 |
npm run dev
|
25 |
```
|
|
|
|
|
|
|
|
|
|
|
|
|
|
23 |
npm i
|
24 |
npm run dev
|
25 |
```
|
26 |
+
|
27 |
+
To update the frontend types with the backend types:
|
28 |
+
|
29 |
+
```bash
|
30 |
+
$ uv run pydantic2ts --module lynxkite_app.main --output ./web/src/apiTypes.ts --json2ts-cmd "npx json-schema-to-typescript"
|
31 |
+
```
|
lynxkite-app/pyproject.toml
CHANGED
@@ -9,12 +9,12 @@ dependencies = [
|
|
9 |
"lynxkite-core",
|
10 |
"orjson>=3.10.13",
|
11 |
"pycrdt-websocket>=0.15.3",
|
12 |
-
"pydantic-to-typescript>=2.0.0",
|
13 |
"sse-starlette>=2.2.1",
|
14 |
]
|
15 |
|
16 |
[project.optional-dependencies]
|
17 |
dev = [
|
|
|
18 |
"pytest>=8.3.4",
|
19 |
]
|
20 |
|
|
|
9 |
"lynxkite-core",
|
10 |
"orjson>=3.10.13",
|
11 |
"pycrdt-websocket>=0.15.3",
|
|
|
12 |
"sse-starlette>=2.2.1",
|
13 |
]
|
14 |
|
15 |
[project.optional-dependencies]
|
16 |
dev = [
|
17 |
+
"pydantic-to-typescript>=2.0.0",
|
18 |
"pytest>=8.3.4",
|
19 |
]
|
20 |
|
lynxkite-app/src/lynxkite_app/main.py
CHANGED
@@ -2,12 +2,7 @@
|
|
2 |
|
3 |
import os
|
4 |
import shutil
|
5 |
-
|
6 |
-
if os.environ.get("NX_CUGRAPH_AUTOCONFIG", "").strip().lower() == "true":
|
7 |
-
import cudf.pandas
|
8 |
-
|
9 |
-
cudf.pandas.install()
|
10 |
-
import dataclasses
|
11 |
import fastapi
|
12 |
import importlib
|
13 |
import pathlib
|
@@ -18,6 +13,11 @@ from lynxkite.core import ops
|
|
18 |
from lynxkite.core import workspace
|
19 |
from . import crdt, config
|
20 |
|
|
|
|
|
|
|
|
|
|
|
21 |
|
22 |
def detect_plugins():
|
23 |
plugins = {}
|
@@ -82,8 +82,7 @@ def load(path: str):
|
|
82 |
return workspace.load(path)
|
83 |
|
84 |
|
85 |
-
|
86 |
-
class DirectoryEntry:
|
87 |
name: str
|
88 |
type: str
|
89 |
|
@@ -95,11 +94,12 @@ def list_dir(path: str):
|
|
95 |
return sorted(
|
96 |
[
|
97 |
DirectoryEntry(
|
98 |
-
p.relative_to(config.DATA_PATH),
|
99 |
-
"directory" if p.is_dir() else "workspace",
|
100 |
)
|
101 |
for p in path.iterdir()
|
102 |
-
]
|
|
|
103 |
)
|
104 |
|
105 |
|
|
|
2 |
|
3 |
import os
|
4 |
import shutil
|
5 |
+
import pydantic
|
|
|
|
|
|
|
|
|
|
|
6 |
import fastapi
|
7 |
import importlib
|
8 |
import pathlib
|
|
|
13 |
from lynxkite.core import workspace
|
14 |
from . import crdt, config
|
15 |
|
16 |
+
if os.environ.get("NX_CUGRAPH_AUTOCONFIG", "").strip().lower() == "true":
|
17 |
+
import cudf.pandas
|
18 |
+
|
19 |
+
cudf.pandas.install()
|
20 |
+
|
21 |
|
22 |
def detect_plugins():
|
23 |
plugins = {}
|
|
|
82 |
return workspace.load(path)
|
83 |
|
84 |
|
85 |
+
class DirectoryEntry(pydantic.BaseModel):
|
|
|
86 |
name: str
|
87 |
type: str
|
88 |
|
|
|
94 |
return sorted(
|
95 |
[
|
96 |
DirectoryEntry(
|
97 |
+
name=str(p.relative_to(config.DATA_PATH)),
|
98 |
+
type="directory" if p.is_dir() else "workspace",
|
99 |
)
|
100 |
for p in path.iterdir()
|
101 |
+
],
|
102 |
+
key=lambda x: x.name,
|
103 |
)
|
104 |
|
105 |
|
lynxkite-app/uv.lock
CHANGED
@@ -194,6 +194,15 @@ wheels = [
|
|
194 |
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 },
|
195 |
]
|
196 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
197 |
[[package]]
|
198 |
name = "jinja2"
|
199 |
version = "3.1.5"
|
@@ -215,17 +224,23 @@ dependencies = [
|
|
215 |
{ name = "lynxkite-core" },
|
216 |
{ name = "orjson" },
|
217 |
{ name = "pycrdt-websocket" },
|
218 |
-
{ name = "pydantic-to-typescript" },
|
219 |
{ name = "sse-starlette" },
|
220 |
]
|
221 |
|
|
|
|
|
|
|
|
|
|
|
|
|
222 |
[package.metadata]
|
223 |
requires-dist = [
|
224 |
{ name = "fastapi", extras = ["standard"], specifier = ">=0.115.6" },
|
225 |
{ name = "lynxkite-core", virtual = "../lynxkite-core" },
|
226 |
{ name = "orjson", specifier = ">=3.10.13" },
|
227 |
{ name = "pycrdt-websocket", specifier = ">=0.15.3" },
|
228 |
-
{ name = "pydantic-to-typescript", specifier = ">=2.0.0" },
|
|
|
229 |
{ name = "sse-starlette", specifier = ">=2.2.1" },
|
230 |
]
|
231 |
|
@@ -234,6 +249,9 @@ name = "lynxkite-core"
|
|
234 |
version = "0.1.0"
|
235 |
source = { virtual = "../lynxkite-core" }
|
236 |
|
|
|
|
|
|
|
237 |
[[package]]
|
238 |
name = "markdown-it-py"
|
239 |
version = "3.0.0"
|
@@ -346,6 +364,24 @@ wheels = [
|
|
346 |
{ url = "https://files.pythonhosted.org/packages/59/ac/5e96cad01083015f7bfdb02ccafa489da8e6caa7f4c519e215f04d2bd856/orjson-3.10.14-cp313-cp313-win_amd64.whl", hash = "sha256:eca04dfd792cedad53dc9a917da1a522486255360cb4e77619343a20d9f35364", size = 133388 },
|
347 |
]
|
348 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
349 |
[[package]]
|
350 |
name = "pycrdt"
|
351 |
version = "0.10.9"
|
@@ -486,6 +522,21 @@ wheels = [
|
|
486 |
{ url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 },
|
487 |
]
|
488 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
489 |
[[package]]
|
490 |
name = "python-dotenv"
|
491 |
version = "1.0.1"
|
|
|
194 |
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 },
|
195 |
]
|
196 |
|
197 |
+
[[package]]
|
198 |
+
name = "iniconfig"
|
199 |
+
version = "2.0.0"
|
200 |
+
source = { registry = "https://pypi.org/simple" }
|
201 |
+
sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 }
|
202 |
+
wheels = [
|
203 |
+
{ url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 },
|
204 |
+
]
|
205 |
+
|
206 |
[[package]]
|
207 |
name = "jinja2"
|
208 |
version = "3.1.5"
|
|
|
224 |
{ name = "lynxkite-core" },
|
225 |
{ name = "orjson" },
|
226 |
{ name = "pycrdt-websocket" },
|
|
|
227 |
{ name = "sse-starlette" },
|
228 |
]
|
229 |
|
230 |
+
[package.optional-dependencies]
|
231 |
+
dev = [
|
232 |
+
{ name = "pydantic-to-typescript" },
|
233 |
+
{ name = "pytest" },
|
234 |
+
]
|
235 |
+
|
236 |
[package.metadata]
|
237 |
requires-dist = [
|
238 |
{ name = "fastapi", extras = ["standard"], specifier = ">=0.115.6" },
|
239 |
{ name = "lynxkite-core", virtual = "../lynxkite-core" },
|
240 |
{ name = "orjson", specifier = ">=3.10.13" },
|
241 |
{ name = "pycrdt-websocket", specifier = ">=0.15.3" },
|
242 |
+
{ name = "pydantic-to-typescript", marker = "extra == 'dev'", specifier = ">=2.0.0" },
|
243 |
+
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.4" },
|
244 |
{ name = "sse-starlette", specifier = ">=2.2.1" },
|
245 |
]
|
246 |
|
|
|
249 |
version = "0.1.0"
|
250 |
source = { virtual = "../lynxkite-core" }
|
251 |
|
252 |
+
[package.metadata]
|
253 |
+
requires-dist = [{ name = "pytest", marker = "extra == 'dev'" }]
|
254 |
+
|
255 |
[[package]]
|
256 |
name = "markdown-it-py"
|
257 |
version = "3.0.0"
|
|
|
364 |
{ url = "https://files.pythonhosted.org/packages/59/ac/5e96cad01083015f7bfdb02ccafa489da8e6caa7f4c519e215f04d2bd856/orjson-3.10.14-cp313-cp313-win_amd64.whl", hash = "sha256:eca04dfd792cedad53dc9a917da1a522486255360cb4e77619343a20d9f35364", size = 133388 },
|
365 |
]
|
366 |
|
367 |
+
[[package]]
|
368 |
+
name = "packaging"
|
369 |
+
version = "24.2"
|
370 |
+
source = { registry = "https://pypi.org/simple" }
|
371 |
+
sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 }
|
372 |
+
wheels = [
|
373 |
+
{ url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 },
|
374 |
+
]
|
375 |
+
|
376 |
+
[[package]]
|
377 |
+
name = "pluggy"
|
378 |
+
version = "1.5.0"
|
379 |
+
source = { registry = "https://pypi.org/simple" }
|
380 |
+
sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 }
|
381 |
+
wheels = [
|
382 |
+
{ url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 },
|
383 |
+
]
|
384 |
+
|
385 |
[[package]]
|
386 |
name = "pycrdt"
|
387 |
version = "0.10.9"
|
|
|
522 |
{ url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 },
|
523 |
]
|
524 |
|
525 |
+
[[package]]
|
526 |
+
name = "pytest"
|
527 |
+
version = "8.3.4"
|
528 |
+
source = { registry = "https://pypi.org/simple" }
|
529 |
+
dependencies = [
|
530 |
+
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
531 |
+
{ name = "iniconfig" },
|
532 |
+
{ name = "packaging" },
|
533 |
+
{ name = "pluggy" },
|
534 |
+
]
|
535 |
+
sdist = { url = "https://files.pythonhosted.org/packages/05/35/30e0d83068951d90a01852cb1cef56e5d8a09d20c7f511634cc2f7e0372a/pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761", size = 1445919 }
|
536 |
+
wheels = [
|
537 |
+
{ url = "https://files.pythonhosted.org/packages/11/92/76a1c94d3afee238333bc0a42b82935dd8f9cf8ce9e336ff87ee14d9e1cf/pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6", size = 343083 },
|
538 |
+
]
|
539 |
+
|
540 |
[[package]]
|
541 |
name = "python-dotenv"
|
542 |
version = "1.0.1"
|
lynxkite-app/web/eslint.config.js
CHANGED
@@ -1,28 +1,28 @@
|
|
1 |
-
import js from
|
2 |
-
import
|
3 |
-
import
|
4 |
-
import
|
5 |
-
import tseslint from
|
6 |
|
7 |
export default tseslint.config(
|
8 |
-
{ ignores: [
|
9 |
{
|
10 |
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
11 |
-
files: [
|
12 |
languageOptions: {
|
13 |
ecmaVersion: 2020,
|
14 |
globals: globals.browser,
|
15 |
},
|
16 |
plugins: {
|
17 |
-
|
18 |
-
|
19 |
},
|
20 |
rules: {
|
21 |
...reactHooks.configs.recommended.rules,
|
22 |
-
|
23 |
-
|
24 |
{ allowConstantExport: true },
|
25 |
],
|
26 |
},
|
27 |
},
|
28 |
-
)
|
|
|
1 |
+
import js from "@eslint/js";
|
2 |
+
import reactHooks from "eslint-plugin-react-hooks";
|
3 |
+
import reactRefresh from "eslint-plugin-react-refresh";
|
4 |
+
import globals from "globals";
|
5 |
+
import tseslint from "typescript-eslint";
|
6 |
|
7 |
export default tseslint.config(
|
8 |
+
{ ignores: ["dist"] },
|
9 |
{
|
10 |
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
11 |
+
files: ["**/*.{ts,tsx}"],
|
12 |
languageOptions: {
|
13 |
ecmaVersion: 2020,
|
14 |
globals: globals.browser,
|
15 |
},
|
16 |
plugins: {
|
17 |
+
"react-hooks": reactHooks,
|
18 |
+
"react-refresh": reactRefresh,
|
19 |
},
|
20 |
rules: {
|
21 |
...reactHooks.configs.recommended.rules,
|
22 |
+
"react-refresh/only-export-components": [
|
23 |
+
"warn",
|
24 |
{ allowConstantExport: true },
|
25 |
],
|
26 |
},
|
27 |
},
|
28 |
+
);
|
lynxkite-app/web/playwright.config.ts
CHANGED
@@ -1,31 +1,30 @@
|
|
1 |
-
import { defineConfig, devices } from
|
2 |
-
|
3 |
|
4 |
export default defineConfig({
|
5 |
-
testDir:
|
6 |
timeout: 30000,
|
7 |
fullyParallel: false,
|
8 |
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
9 |
forbidOnly: !!process.env.CI,
|
10 |
retries: process.env.CI ? 1 : 0,
|
11 |
workers: 1,
|
12 |
-
reporter: process.env.CI ? [[
|
13 |
use: {
|
14 |
/* Base URL to use in actions like `await page.goto('/')`. */
|
15 |
-
baseURL:
|
16 |
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
17 |
-
trace:
|
18 |
-
testIdAttribute:
|
19 |
},
|
20 |
projects: [
|
21 |
{
|
22 |
-
name:
|
23 |
-
use: { ...devices[
|
24 |
},
|
25 |
],
|
26 |
webServer: {
|
27 |
-
command:
|
28 |
-
url:
|
29 |
reuseExistingServer: false,
|
30 |
},
|
31 |
});
|
|
|
1 |
+
import { defineConfig, devices } from "@playwright/test";
|
|
|
2 |
|
3 |
export default defineConfig({
|
4 |
+
testDir: "./tests",
|
5 |
timeout: 30000,
|
6 |
fullyParallel: false,
|
7 |
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
8 |
forbidOnly: !!process.env.CI,
|
9 |
retries: process.env.CI ? 1 : 0,
|
10 |
workers: 1,
|
11 |
+
reporter: process.env.CI ? [["github"], ["html"]] : "html",
|
12 |
use: {
|
13 |
/* Base URL to use in actions like `await page.goto('/')`. */
|
14 |
+
baseURL: "http://127.0.0.1:8000",
|
15 |
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
16 |
+
trace: "on-first-retry",
|
17 |
+
testIdAttribute: "data-nodeid", // Useful for easily selecting nodes using getByTestId
|
18 |
},
|
19 |
projects: [
|
20 |
{
|
21 |
+
name: "chromium",
|
22 |
+
use: { ...devices["Desktop Chrome"] },
|
23 |
},
|
24 |
],
|
25 |
webServer: {
|
26 |
+
command: "cd ../.. && LYNXKITE_DATA=examples lynxkite",
|
27 |
+
url: "http://127.0.0.1:8000",
|
28 |
reuseExistingServer: false,
|
29 |
},
|
30 |
});
|
lynxkite-app/web/postcss.config.js
CHANGED
@@ -3,4 +3,4 @@ export default {
|
|
3 |
tailwindcss: {},
|
4 |
autoprefixer: {},
|
5 |
},
|
6 |
-
}
|
|
|
3 |
tailwindcss: {},
|
4 |
autoprefixer: {},
|
5 |
},
|
6 |
+
};
|
lynxkite-app/web/src/Directory.tsx
CHANGED
@@ -1,73 +1,76 @@
|
|
1 |
-
// The directory browser.
|
2 |
-
import { useParams, useNavigate } from "react-router";
|
3 |
import { useState } from "react";
|
4 |
-
|
5 |
-
|
|
|
|
|
6 |
|
7 |
-
import logo from './assets/logo.png';
|
8 |
// @ts-ignore
|
9 |
-
import
|
10 |
// @ts-ignore
|
11 |
-
import
|
12 |
// @ts-ignore
|
13 |
-
import
|
14 |
// @ts-ignore
|
15 |
-
import
|
16 |
// @ts-ignore
|
17 |
-
import
|
18 |
// @ts-ignore
|
19 |
-
import Trash from
|
20 |
-
|
21 |
-
|
22 |
|
23 |
const fetcher = (url: string) => fetch(url).then((res) => res.json());
|
24 |
|
25 |
export default function () {
|
26 |
const { path } = useParams();
|
27 |
-
const encodedPath = encodeURIComponent(path ||
|
28 |
const list = useSWR(`/api/dir/list?path=${encodedPath}`, fetcher);
|
29 |
const navigate = useNavigate();
|
30 |
const [isCreatingDir, setIsCreatingDir] = useState(false);
|
31 |
const [isCreatingWorkspace, setIsCreatingWorkspace] = useState(false);
|
32 |
|
33 |
-
|
34 |
-
|
35 |
-
if (item.type === 'directory') {
|
36 |
return `/dir/${item.name}`;
|
37 |
-
} else {
|
38 |
-
return `/edit/${item.name}`;
|
39 |
}
|
|
|
40 |
}
|
41 |
|
42 |
-
function shortName(item:
|
43 |
-
return item.name.split(
|
44 |
}
|
45 |
|
46 |
-
function newName(list:
|
47 |
let i = 0;
|
48 |
while (true) {
|
49 |
-
const name = `${baseName}${i ? ` ${i}` :
|
50 |
-
if (!list.find(item => item.name === name)) {
|
51 |
return name;
|
52 |
}
|
53 |
i++;
|
54 |
}
|
55 |
}
|
56 |
|
57 |
-
function newWorkspaceIn(
|
|
|
|
|
|
|
|
|
58 |
const pathSlash = path ? `${path}/` : "";
|
59 |
const name = workspaceName || newName(list);
|
60 |
navigate(`/edit/${pathSlash}${name}`, { replace: true });
|
61 |
}
|
62 |
|
63 |
-
|
64 |
-
|
|
|
|
|
|
|
65 |
const name = folderName || newName(list, "New Folder");
|
66 |
const pathSlash = path ? `${path}/` : "";
|
67 |
|
68 |
-
const res = await fetch(
|
69 |
-
method:
|
70 |
-
headers: {
|
71 |
body: JSON.stringify({ path: pathSlash + name }),
|
72 |
});
|
73 |
if (res.ok) {
|
@@ -77,11 +80,13 @@ export default function () {
|
|
77 |
}
|
78 |
}
|
79 |
|
80 |
-
async function deleteItem(item:
|
81 |
-
if (!window.confirm(`Are you sure you want to delete "${item.name}"?`))
|
|
|
82 |
const pathSlash = path ? `${path}/` : "";
|
83 |
|
84 |
-
const apiPath =
|
|
|
85 |
await fetch(apiPath, {
|
86 |
method: "POST",
|
87 |
headers: { "Content-Type": "application/json" },
|
@@ -97,22 +102,32 @@ export default function () {
|
|
97 |
</a>
|
98 |
<div className="tagline">The Complete Graph Data Science Platform</div>
|
99 |
</div>
|
100 |
-
|
101 |
<div className="entry-list">
|
102 |
{list.error && <p className="error">{list.error.message}</p>}
|
103 |
{list.isLoading && (
|
104 |
-
<
|
105 |
<span className="visually-hidden">Loading...</span>
|
106 |
-
</
|
107 |
)}
|
108 |
|
109 |
{list.data && (
|
110 |
<>
|
111 |
<div className="actions">
|
112 |
<div className="new-workspace">
|
113 |
-
{isCreatingWorkspace &&
|
114 |
// @ts-ignore
|
115 |
-
<form
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
116 |
<input
|
117 |
type="text"
|
118 |
name="workspaceName"
|
@@ -120,16 +135,28 @@ export default function () {
|
|
120 |
placeholder={newName(list.data)}
|
121 |
/>
|
122 |
</form>
|
123 |
-
}
|
124 |
-
<button
|
|
|
|
|
|
|
125 |
<FolderPlus /> New workspace
|
126 |
</button>
|
127 |
</div>
|
128 |
|
129 |
<div className="new-folder">
|
130 |
-
{isCreatingDir &&
|
131 |
// @ts-ignore
|
132 |
-
<form
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
133 |
<input
|
134 |
type="text"
|
135 |
name="folderName"
|
@@ -137,7 +164,7 @@ export default function () {
|
|
137 |
placeholder={newName(list.data)}
|
138 |
/>
|
139 |
</form>
|
140 |
-
}
|
141 |
<button type="button" onClick={() => setIsCreatingDir(true)}>
|
142 |
<FolderPlus /> New folder
|
143 |
</button>
|
@@ -153,20 +180,25 @@ export default function () {
|
|
153 |
</div>
|
154 |
)}
|
155 |
|
156 |
-
{list.data.map((item:
|
157 |
<div key={item.name} className="entry">
|
158 |
<a key={link(item)} href={link(item)}>
|
159 |
-
{item.type ===
|
160 |
{shortName(item)}
|
161 |
</a>
|
162 |
-
<button
|
|
|
|
|
|
|
|
|
|
|
163 |
<Trash />
|
164 |
</button>
|
165 |
</div>
|
166 |
))}
|
167 |
</>
|
168 |
)}
|
169 |
-
</div>
|
170 |
</div>
|
171 |
);
|
172 |
}
|
|
|
|
|
|
|
1 |
import { useState } from "react";
|
2 |
+
// The directory browser.
|
3 |
+
import { useNavigate, useParams } from "react-router";
|
4 |
+
import useSWR from "swr";
|
5 |
+
import type { DirectoryEntry } from "./apiTypes.ts";
|
6 |
|
|
|
7 |
// @ts-ignore
|
8 |
+
import File from "~icons/tabler/file";
|
9 |
// @ts-ignore
|
10 |
+
import FilePlus from "~icons/tabler/file-plus";
|
11 |
// @ts-ignore
|
12 |
+
import Folder from "~icons/tabler/folder";
|
13 |
// @ts-ignore
|
14 |
+
import FolderPlus from "~icons/tabler/folder-plus";
|
15 |
// @ts-ignore
|
16 |
+
import Home from "~icons/tabler/home";
|
17 |
// @ts-ignore
|
18 |
+
import Trash from "~icons/tabler/trash";
|
19 |
+
import logo from "./assets/logo.png";
|
|
|
20 |
|
21 |
const fetcher = (url: string) => fetch(url).then((res) => res.json());
|
22 |
|
23 |
export default function () {
|
24 |
const { path } = useParams();
|
25 |
+
const encodedPath = encodeURIComponent(path || "");
|
26 |
const list = useSWR(`/api/dir/list?path=${encodedPath}`, fetcher);
|
27 |
const navigate = useNavigate();
|
28 |
const [isCreatingDir, setIsCreatingDir] = useState(false);
|
29 |
const [isCreatingWorkspace, setIsCreatingWorkspace] = useState(false);
|
30 |
|
31 |
+
function link(item: DirectoryEntry) {
|
32 |
+
if (item.type === "directory") {
|
|
|
33 |
return `/dir/${item.name}`;
|
|
|
|
|
34 |
}
|
35 |
+
return `/edit/${item.name}`;
|
36 |
}
|
37 |
|
38 |
+
function shortName(item: DirectoryEntry) {
|
39 |
+
return item.name.split("/").pop();
|
40 |
}
|
41 |
|
42 |
+
function newName(list: DirectoryEntry[], baseName = "Untitled") {
|
43 |
let i = 0;
|
44 |
while (true) {
|
45 |
+
const name = `${baseName}${i ? ` ${i}` : ""}`;
|
46 |
+
if (!list.find((item) => item.name === name)) {
|
47 |
return name;
|
48 |
}
|
49 |
i++;
|
50 |
}
|
51 |
}
|
52 |
|
53 |
+
function newWorkspaceIn(
|
54 |
+
path: string,
|
55 |
+
list: DirectoryEntry[],
|
56 |
+
workspaceName?: string,
|
57 |
+
) {
|
58 |
const pathSlash = path ? `${path}/` : "";
|
59 |
const name = workspaceName || newName(list);
|
60 |
navigate(`/edit/${pathSlash}${name}`, { replace: true });
|
61 |
}
|
62 |
|
63 |
+
async function newFolderIn(
|
64 |
+
path: string,
|
65 |
+
list: DirectoryEntry[],
|
66 |
+
folderName?: string,
|
67 |
+
) {
|
68 |
const name = folderName || newName(list, "New Folder");
|
69 |
const pathSlash = path ? `${path}/` : "";
|
70 |
|
71 |
+
const res = await fetch("/api/dir/mkdir", {
|
72 |
+
method: "POST",
|
73 |
+
headers: { "Content-Type": "application/json" },
|
74 |
body: JSON.stringify({ path: pathSlash + name }),
|
75 |
});
|
76 |
if (res.ok) {
|
|
|
80 |
}
|
81 |
}
|
82 |
|
83 |
+
async function deleteItem(item: DirectoryEntry) {
|
84 |
+
if (!window.confirm(`Are you sure you want to delete "${item.name}"?`))
|
85 |
+
return;
|
86 |
const pathSlash = path ? `${path}/` : "";
|
87 |
|
88 |
+
const apiPath =
|
89 |
+
item.type === "directory" ? "/api/dir/delete" : "/api/delete";
|
90 |
await fetch(apiPath, {
|
91 |
method: "POST",
|
92 |
headers: { "Content-Type": "application/json" },
|
|
|
102 |
</a>
|
103 |
<div className="tagline">The Complete Graph Data Science Platform</div>
|
104 |
</div>
|
|
|
105 |
<div className="entry-list">
|
106 |
{list.error && <p className="error">{list.error.message}</p>}
|
107 |
{list.isLoading && (
|
108 |
+
<output className="loading spinner-border">
|
109 |
<span className="visually-hidden">Loading...</span>
|
110 |
+
</output>
|
111 |
)}
|
112 |
|
113 |
{list.data && (
|
114 |
<>
|
115 |
<div className="actions">
|
116 |
<div className="new-workspace">
|
117 |
+
{isCreatingWorkspace && (
|
118 |
// @ts-ignore
|
119 |
+
<form
|
120 |
+
onSubmit={(e) => {
|
121 |
+
e.preventDefault();
|
122 |
+
newWorkspaceIn(
|
123 |
+
path || "",
|
124 |
+
list.data,
|
125 |
+
(
|
126 |
+
e.target as HTMLFormElement
|
127 |
+
).workspaceName.value.trim(),
|
128 |
+
);
|
129 |
+
}}
|
130 |
+
>
|
131 |
<input
|
132 |
type="text"
|
133 |
name="workspaceName"
|
|
|
135 |
placeholder={newName(list.data)}
|
136 |
/>
|
137 |
</form>
|
138 |
+
)}
|
139 |
+
<button
|
140 |
+
type="button"
|
141 |
+
onClick={() => setIsCreatingWorkspace(true)}
|
142 |
+
>
|
143 |
<FolderPlus /> New workspace
|
144 |
</button>
|
145 |
</div>
|
146 |
|
147 |
<div className="new-folder">
|
148 |
+
{isCreatingDir && (
|
149 |
// @ts-ignore
|
150 |
+
<form
|
151 |
+
onSubmit={(e) => {
|
152 |
+
e.preventDefault();
|
153 |
+
newFolderIn(
|
154 |
+
path || "",
|
155 |
+
list.data,
|
156 |
+
(e.target as HTMLFormElement).folderName.value.trim(),
|
157 |
+
);
|
158 |
+
}}
|
159 |
+
>
|
160 |
<input
|
161 |
type="text"
|
162 |
name="folderName"
|
|
|
164 |
placeholder={newName(list.data)}
|
165 |
/>
|
166 |
</form>
|
167 |
+
)}
|
168 |
<button type="button" onClick={() => setIsCreatingDir(true)}>
|
169 |
<FolderPlus /> New folder
|
170 |
</button>
|
|
|
180 |
</div>
|
181 |
)}
|
182 |
|
183 |
+
{list.data.map((item: DirectoryEntry) => (
|
184 |
<div key={item.name} className="entry">
|
185 |
<a key={link(item)} href={link(item)}>
|
186 |
+
{item.type === "directory" ? <Folder /> : <File />}
|
187 |
{shortName(item)}
|
188 |
</a>
|
189 |
+
<button
|
190 |
+
type="button"
|
191 |
+
onClick={() => {
|
192 |
+
deleteItem(item);
|
193 |
+
}}
|
194 |
+
>
|
195 |
<Trash />
|
196 |
</button>
|
197 |
</div>
|
198 |
))}
|
199 |
</>
|
200 |
)}
|
201 |
+
</div>{" "}
|
202 |
</div>
|
203 |
);
|
204 |
}
|
lynxkite-app/web/src/apiTypes.ts
CHANGED
@@ -5,21 +5,22 @@
|
|
5 |
/* Do not modify it by hand - just update the pydantic models and then re-run the script
|
6 |
*/
|
7 |
|
8 |
-
|
9 |
-
|
10 |
-
|
11 |
-
* DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file,
|
12 |
-
* and run json-schema-to-typescript to regenerate this file.
|
13 |
-
*/
|
14 |
-
|
15 |
-
export interface BaseConfig {
|
16 |
-
[k: string]: unknown;
|
17 |
}
|
18 |
-
export interface
|
19 |
-
|
20 |
-
|
21 |
[k: string]: unknown;
|
22 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
23 |
export interface Workspace {
|
24 |
env?: string;
|
25 |
nodes?: WorkspaceNode[];
|
@@ -42,6 +43,11 @@ export interface WorkspaceNodeData {
|
|
42 |
error?: string | null;
|
43 |
[k: string]: unknown;
|
44 |
}
|
|
|
|
|
|
|
|
|
|
|
45 |
export interface WorkspaceEdge {
|
46 |
id: string;
|
47 |
source: string;
|
|
|
5 |
/* Do not modify it by hand - just update the pydantic models and then re-run the script
|
6 |
*/
|
7 |
|
8 |
+
export interface DirectoryEntry {
|
9 |
+
name: string;
|
10 |
+
type: string;
|
|
|
|
|
|
|
|
|
|
|
|
|
11 |
}
|
12 |
+
export interface SaveRequest {
|
13 |
+
path: string;
|
14 |
+
ws: Workspace;
|
15 |
[k: string]: unknown;
|
16 |
}
|
17 |
+
/**
|
18 |
+
* A workspace is a representation of a computational graph that consists of nodes and edges.
|
19 |
+
*
|
20 |
+
* Each node represents an operation or task, and the edges represent the flow of data between
|
21 |
+
* the nodes. Each workspace is associated with an environment, which determines the operations
|
22 |
+
* that can be performed in the workspace and the execution method for the operations.
|
23 |
+
*/
|
24 |
export interface Workspace {
|
25 |
env?: string;
|
26 |
nodes?: WorkspaceNode[];
|
|
|
43 |
error?: string | null;
|
44 |
[k: string]: unknown;
|
45 |
}
|
46 |
+
export interface Position {
|
47 |
+
x: number;
|
48 |
+
y: number;
|
49 |
+
[k: string]: unknown;
|
50 |
+
}
|
51 |
export interface WorkspaceEdge {
|
52 |
id: string;
|
53 |
source: string;
|
lynxkite-app/web/src/index.css
CHANGED
@@ -154,7 +154,6 @@ body {
|
|
154 |
width: fit-content;
|
155 |
padding: 2px 8px;
|
156 |
border-radius: 4px 4px 0 0;
|
157 |
-
;
|
158 |
}
|
159 |
|
160 |
.collapsed-param {
|
@@ -317,7 +316,6 @@ body {
|
|
317 |
}
|
318 |
}
|
319 |
|
320 |
-
|
321 |
path.react-flow__edge-path {
|
322 |
stroke-width: 2;
|
323 |
stroke: black;
|
|
|
154 |
width: fit-content;
|
155 |
padding: 2px 8px;
|
156 |
border-radius: 4px 4px 0 0;
|
|
|
157 |
}
|
158 |
|
159 |
.collapsed-param {
|
|
|
316 |
}
|
317 |
}
|
318 |
|
|
|
319 |
path.react-flow__edge-path {
|
320 |
stroke-width: 2;
|
321 |
stroke: black;
|
lynxkite-app/web/src/main.tsx
CHANGED
@@ -1,12 +1,12 @@
|
|
1 |
-
import { StrictMode } from
|
2 |
-
import { createRoot } from
|
3 |
-
import
|
4 |
-
import
|
5 |
-
import
|
6 |
-
import
|
7 |
-
import
|
8 |
|
9 |
-
createRoot(document.getElementById(
|
10 |
<StrictMode>
|
11 |
<BrowserRouter>
|
12 |
<Routes>
|
@@ -17,4 +17,4 @@ createRoot(document.getElementById('root')!).render(
|
|
17 |
</Routes>
|
18 |
</BrowserRouter>
|
19 |
</StrictMode>,
|
20 |
-
)
|
|
|
1 |
+
import { StrictMode } from "react";
|
2 |
+
import { createRoot } from "react-dom/client";
|
3 |
+
import "@xyflow/react/dist/style.css";
|
4 |
+
import "./index.css";
|
5 |
+
import { BrowserRouter, Route, Routes } from "react-router";
|
6 |
+
import Directory from "./Directory.tsx";
|
7 |
+
import Workspace from "./workspace/Workspace.tsx";
|
8 |
|
9 |
+
createRoot(document.getElementById("root")!).render(
|
10 |
<StrictMode>
|
11 |
<BrowserRouter>
|
12 |
<Routes>
|
|
|
17 |
</Routes>
|
18 |
</BrowserRouter>
|
19 |
</StrictMode>,
|
20 |
+
);
|
lynxkite-app/web/src/workspace/EnvironmentSelector.tsx
CHANGED
@@ -1,14 +1,21 @@
|
|
1 |
-
export default function EnvironmentSelector(props: {
|
|
|
|
|
|
|
|
|
2 |
return (
|
3 |
<>
|
4 |
-
<select
|
|
|
5 |
name="workspace-env"
|
6 |
value={props.value}
|
7 |
onChange={(evt) => props.onChange(evt.currentTarget.value)}
|
8 |
>
|
9 |
-
{props.options.map(option =>
|
10 |
-
<option key={option} value={option}>
|
11 |
-
|
|
|
|
|
12 |
</select>
|
13 |
</>
|
14 |
);
|
|
|
1 |
+
export default function EnvironmentSelector(props: {
|
2 |
+
options: string[];
|
3 |
+
value: string;
|
4 |
+
onChange: (val: string) => void;
|
5 |
+
}) {
|
6 |
return (
|
7 |
<>
|
8 |
+
<select
|
9 |
+
className="select w-full max-w-xs"
|
10 |
name="workspace-env"
|
11 |
value={props.value}
|
12 |
onChange={(evt) => props.onChange(evt.currentTarget.value)}
|
13 |
>
|
14 |
+
{props.options.map((option) => (
|
15 |
+
<option key={option} value={option}>
|
16 |
+
{option}
|
17 |
+
</option>
|
18 |
+
))}
|
19 |
</select>
|
20 |
</>
|
21 |
);
|
lynxkite-app/web/src/workspace/LynxKiteState.ts
CHANGED
@@ -1,4 +1,4 @@
|
|
1 |
import { createContext } from "react";
|
2 |
-
import { Workspace } from "../apiTypes.ts";
|
3 |
|
4 |
export const LynxKiteState = createContext({ workspace: {} as Workspace });
|
|
|
1 |
import { createContext } from "react";
|
2 |
+
import type { Workspace } from "../apiTypes.ts";
|
3 |
|
4 |
export const LynxKiteState = createContext({ workspace: {} as Workspace });
|
lynxkite-app/web/src/workspace/NodeSearch.tsx
CHANGED
@@ -1,22 +1,33 @@
|
|
1 |
-
import Fuse from
|
2 |
-
import { useEffect, useMemo, useRef, useState } from
|
3 |
|
4 |
export type OpsOp = {
|
5 |
-
name: string
|
6 |
-
type: string
|
7 |
-
position: { x: number
|
8 |
-
params: { name: string
|
9 |
-
}
|
10 |
export type Catalog = { [op: string]: OpsOp };
|
11 |
export type Catalogs = { [env: string]: Catalog };
|
12 |
|
13 |
-
export default function (props: {
|
|
|
|
|
|
|
|
|
|
|
14 |
const searchBox = useRef(null as unknown as HTMLInputElement);
|
15 |
-
const [searchText, setSearchText] = useState(
|
16 |
-
const fuse = useMemo(
|
17 |
-
|
18 |
-
|
19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
20 |
const [selectedIndex, setSelectedIndex] = useState(0);
|
21 |
useEffect(() => searchBox.current.focus());
|
22 |
function typed(text: string) {
|
@@ -24,15 +35,15 @@ export default function (props: { boxes: Catalog, onCancel: any, onAdd: any, pos
|
|
24 |
setSelectedIndex(Math.max(0, Math.min(selectedIndex, hits.length - 1)));
|
25 |
}
|
26 |
function onKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
27 |
-
if (e.key ===
|
28 |
e.preventDefault();
|
29 |
setSelectedIndex(Math.min(selectedIndex + 1, hits.length - 1));
|
30 |
-
} else if (e.key ===
|
31 |
e.preventDefault();
|
32 |
setSelectedIndex(Math.max(selectedIndex - 1, 0));
|
33 |
-
} else if (e.key ===
|
34 |
addSelected();
|
35 |
-
} else if (e.key ===
|
36 |
props.onCancel();
|
37 |
}
|
38 |
}
|
@@ -43,33 +54,37 @@ export default function (props: { boxes: Catalog, onCancel: any, onAdd: any, pos
|
|
43 |
}
|
44 |
async function lostFocus(e: any) {
|
45 |
// If it's a click on a result, let the click handler handle it.
|
46 |
-
if (e.relatedTarget
|
47 |
props.onCancel();
|
48 |
}
|
49 |
|
50 |
-
|
51 |
return (
|
52 |
-
<div
|
|
|
|
|
|
|
53 |
<input
|
54 |
ref={searchBox}
|
55 |
value={searchText}
|
56 |
-
onChange={event => typed(event.target.value)}
|
57 |
onKeyDown={onKeyDown}
|
58 |
onBlur={lostFocus}
|
59 |
-
placeholder="Search for box"
|
|
|
60 |
<div className="matches">
|
61 |
-
{hits.map((box, index) =>
|
62 |
<div
|
63 |
key={box.item.name}
|
64 |
tabIndex={0}
|
65 |
onFocus={() => setSelectedIndex(index)}
|
66 |
onMouseEnter={() => setSelectedIndex(index)}
|
67 |
onClick={addSelected}
|
68 |
-
className={`search-result ${index === selectedIndex ?
|
|
|
69 |
{box.item.name}
|
70 |
</div>
|
71 |
-
)}
|
72 |
</div>
|
73 |
-
</div
|
74 |
);
|
75 |
}
|
|
|
1 |
+
import Fuse from "fuse.js";
|
2 |
+
import { useEffect, useMemo, useRef, useState } from "react";
|
3 |
|
4 |
export type OpsOp = {
|
5 |
+
name: string;
|
6 |
+
type: string;
|
7 |
+
position: { x: number; y: number };
|
8 |
+
params: { name: string; default: any }[];
|
9 |
+
};
|
10 |
export type Catalog = { [op: string]: OpsOp };
|
11 |
export type Catalogs = { [env: string]: Catalog };
|
12 |
|
13 |
+
export default function (props: {
|
14 |
+
boxes: Catalog;
|
15 |
+
onCancel: any;
|
16 |
+
onAdd: any;
|
17 |
+
pos: { x: number; y: number };
|
18 |
+
}) {
|
19 |
const searchBox = useRef(null as unknown as HTMLInputElement);
|
20 |
+
const [searchText, setSearchText] = useState("");
|
21 |
+
const fuse = useMemo(
|
22 |
+
() =>
|
23 |
+
new Fuse(Object.values(props.boxes), {
|
24 |
+
keys: ["name"],
|
25 |
+
}),
|
26 |
+
[props.boxes],
|
27 |
+
);
|
28 |
+
const hits: { item: OpsOp }[] = searchText
|
29 |
+
? fuse.search<OpsOp>(searchText)
|
30 |
+
: Object.values(props.boxes).map((box) => ({ item: box }));
|
31 |
const [selectedIndex, setSelectedIndex] = useState(0);
|
32 |
useEffect(() => searchBox.current.focus());
|
33 |
function typed(text: string) {
|
|
|
35 |
setSelectedIndex(Math.max(0, Math.min(selectedIndex, hits.length - 1)));
|
36 |
}
|
37 |
function onKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
38 |
+
if (e.key === "ArrowDown") {
|
39 |
e.preventDefault();
|
40 |
setSelectedIndex(Math.min(selectedIndex + 1, hits.length - 1));
|
41 |
+
} else if (e.key === "ArrowUp") {
|
42 |
e.preventDefault();
|
43 |
setSelectedIndex(Math.max(selectedIndex - 1, 0));
|
44 |
+
} else if (e.key === "Enter") {
|
45 |
addSelected();
|
46 |
+
} else if (e.key === "Escape") {
|
47 |
props.onCancel();
|
48 |
}
|
49 |
}
|
|
|
54 |
}
|
55 |
async function lostFocus(e: any) {
|
56 |
// If it's a click on a result, let the click handler handle it.
|
57 |
+
if (e.relatedTarget?.closest(".node-search")) return;
|
58 |
props.onCancel();
|
59 |
}
|
60 |
|
|
|
61 |
return (
|
62 |
+
<div
|
63 |
+
className="node-search"
|
64 |
+
style={{ top: props.pos.y, left: props.pos.x }}
|
65 |
+
>
|
66 |
<input
|
67 |
ref={searchBox}
|
68 |
value={searchText}
|
69 |
+
onChange={(event) => typed(event.target.value)}
|
70 |
onKeyDown={onKeyDown}
|
71 |
onBlur={lostFocus}
|
72 |
+
placeholder="Search for box"
|
73 |
+
/>
|
74 |
<div className="matches">
|
75 |
+
{hits.map((box, index) => (
|
76 |
<div
|
77 |
key={box.item.name}
|
78 |
tabIndex={0}
|
79 |
onFocus={() => setSelectedIndex(index)}
|
80 |
onMouseEnter={() => setSelectedIndex(index)}
|
81 |
onClick={addSelected}
|
82 |
+
className={`search-result ${index === selectedIndex ? "selected" : ""}`}
|
83 |
+
>
|
84 |
{box.item.name}
|
85 |
</div>
|
86 |
+
))}
|
87 |
</div>
|
88 |
+
</div>
|
89 |
);
|
90 |
}
|
lynxkite-app/web/src/workspace/Workspace.tsx
CHANGED
@@ -1,40 +1,50 @@
|
|
1 |
-
|
2 |
-
import { useParams } from "react-router";
|
3 |
-
import useSWR, { Fetcher } from 'swr';
|
4 |
-
import { useEffect, useMemo, useCallback, useState, MouseEvent } from "react";
|
5 |
-
import favicon from '../assets/favicon.ico';
|
6 |
import {
|
7 |
-
|
8 |
Controls,
|
|
|
9 |
MarkerType,
|
|
|
|
|
|
|
10 |
ReactFlowProvider,
|
|
|
11 |
applyEdgeChanges,
|
12 |
applyNodeChanges,
|
13 |
-
useUpdateNodeInternals,
|
14 |
-
type XYPosition,
|
15 |
-
type Node,
|
16 |
-
type Edge,
|
17 |
-
type Connection,
|
18 |
useReactFlow,
|
19 |
-
|
20 |
-
} from
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
21 |
// @ts-ignore
|
22 |
-
import ArrowBack from
|
23 |
// @ts-ignore
|
24 |
-
import
|
25 |
// @ts-ignore
|
26 |
-
import
|
27 |
-
import {
|
28 |
-
import
|
29 |
-
import NodeWithParams from './nodes/NodeWithParams';
|
30 |
// import NodeWithTableView from './NodeWithTableView';
|
31 |
-
import EnvironmentSelector from
|
32 |
-
import { LynxKiteState } from
|
33 |
-
import
|
34 |
-
|
35 |
-
|
|
|
|
|
36 |
import NodeWithImage from "./nodes/NodeWithImage.tsx";
|
|
|
37 |
import NodeWithTableView from "./nodes/NodeWithTableView.tsx";
|
|
|
38 |
|
39 |
export default function (props: any) {
|
40 |
return (
|
@@ -44,9 +54,8 @@ export default function (props: any) {
|
|
44 |
);
|
45 |
}
|
46 |
|
47 |
-
|
48 |
function LynxKiteFlow() {
|
49 |
-
const updateNodeInternals = useUpdateNodeInternals()
|
50 |
const reactFlow = useReactFlow();
|
51 |
const [nodes, setNodes] = useState([] as Node[]);
|
52 |
const [edges, setEdges] = useState([] as Edge[]);
|
@@ -56,7 +65,11 @@ function LynxKiteFlow() {
|
|
56 |
const state = syncedStore({ workspace: {} as Workspace });
|
57 |
setState(state);
|
58 |
const doc = getYjsDoc(state);
|
59 |
-
const wsProvider = new WebsocketProvider(
|
|
|
|
|
|
|
|
|
60 |
const onChange = (_update: any, origin: any, _doc: any, _tr: any) => {
|
61 |
if (origin === wsProvider) {
|
62 |
// An update from the CRDT. Apply it to the local state.
|
@@ -72,154 +85,196 @@ function LynxKiteFlow() {
|
|
72 |
}
|
73 |
}
|
74 |
};
|
75 |
-
doc.on(
|
76 |
return () => {
|
77 |
doc.destroy();
|
78 |
wsProvider.destroy();
|
79 |
-
}
|
80 |
-
}, [path]);
|
81 |
|
82 |
-
const onNodesChange = useCallback(
|
83 |
-
|
84 |
-
|
85 |
-
|
86 |
-
|
87 |
-
|
88 |
-
|
89 |
-
const
|
90 |
-
|
91 |
-
|
92 |
-
|
93 |
-
|
94 |
-
|
95 |
-
|
96 |
-
|
97 |
-
|
98 |
-
|
99 |
-
|
100 |
-
|
101 |
-
|
102 |
-
|
103 |
-
|
104 |
-
|
105 |
-
|
106 |
-
|
107 |
-
|
108 |
-
|
109 |
-
|
110 |
-
|
111 |
-
|
112 |
-
|
113 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
114 |
}
|
115 |
-
}
|
116 |
-
|
117 |
-
|
118 |
-
|
119 |
-
|
120 |
-
|
121 |
-
|
122 |
-
|
123 |
-
|
124 |
-
wedges.
|
125 |
-
|
126 |
-
|
127 |
-
|
|
|
|
|
|
|
128 |
}
|
129 |
-
}
|
130 |
-
|
|
|
131 |
|
132 |
-
const fetcher: Fetcher<Catalogs> = (resource: string, init?: RequestInit) =>
|
133 |
-
|
|
|
134 |
const [suppressSearchUntil, setSuppressSearchUntil] = useState(0);
|
135 |
-
const [nodeSearchSettings, setNodeSearchSettings] = useState(
|
136 |
-
|
137 |
-
|
138 |
-
|
139 |
-
|
140 |
-
|
141 |
-
|
142 |
-
|
143 |
-
|
144 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
145 |
const closeNodeSearch = useCallback(() => {
|
146 |
setNodeSearchSettings(undefined);
|
147 |
setSuppressSearchUntil(Date.now() + 200);
|
148 |
-
}, [
|
149 |
-
const toggleNodeSearch = useCallback(
|
150 |
-
|
151 |
-
|
152 |
-
|
153 |
-
|
154 |
-
|
155 |
-
|
156 |
-
|
157 |
-
|
158 |
-
|
159 |
-
|
160 |
-
|
161 |
-
|
162 |
-
|
163 |
-
|
164 |
-
|
165 |
-
|
166 |
-
|
167 |
-
|
168 |
-
|
169 |
-
|
170 |
-
|
171 |
-
|
172 |
-
|
173 |
-
|
174 |
-
|
175 |
-
|
176 |
-
|
177 |
-
|
178 |
-
|
|
|
|
|
|
|
|
|
179 |
node.id = `${title} ${i}`;
|
180 |
-
|
181 |
-
|
182 |
-
|
183 |
-
|
184 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
185 |
|
186 |
-
const onConnect = useCallback(
|
187 |
-
|
188 |
-
|
189 |
-
|
190 |
-
|
191 |
-
|
192 |
-
|
193 |
-
|
194 |
-
|
195 |
-
|
196 |
-
|
197 |
-
|
198 |
-
|
|
|
|
|
|
|
199 |
return (
|
200 |
<div className="workspace">
|
201 |
<div className="top-bar bg-neutral">
|
202 |
-
<a className="logo" href=""
|
203 |
-
|
204 |
-
|
205 |
-
</div>
|
206 |
<EnvironmentSelector
|
207 |
options={Object.keys(catalog.data || {})}
|
208 |
value={state.workspace.env!}
|
209 |
-
onChange={(env) => {
|
|
|
|
|
210 |
/>
|
211 |
<div className="tools text-secondary">
|
212 |
-
<a href=""
|
213 |
-
|
214 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
215 |
</div>
|
216 |
</div>
|
217 |
-
<div style={{ height: "100%", width:
|
218 |
<LynxKiteState.Provider value={state}>
|
219 |
<ReactFlow
|
220 |
nodes={nodes}
|
221 |
edges={edges}
|
222 |
-
nodeTypes={nodeTypes}
|
|
|
223 |
onNodesChange={onNodesChange}
|
224 |
onEdgesChange={onEdgesChange}
|
225 |
onPaneClick={toggleNodeSearch}
|
@@ -231,13 +286,17 @@ function LynxKiteFlow() {
|
|
231 |
>
|
232 |
<Controls />
|
233 |
<MiniMap />
|
234 |
-
{nodeSearchSettings &&
|
235 |
-
<NodeSearch
|
236 |
-
|
|
|
|
|
|
|
|
|
|
|
237 |
</ReactFlow>
|
238 |
</LynxKiteState.Provider>
|
239 |
</div>
|
240 |
</div>
|
241 |
-
|
242 |
);
|
243 |
}
|
|
|
1 |
+
import { getYjsDoc, syncedStore } from "@syncedstore/core";
|
|
|
|
|
|
|
|
|
2 |
import {
|
3 |
+
type Connection,
|
4 |
Controls,
|
5 |
+
type Edge,
|
6 |
MarkerType,
|
7 |
+
MiniMap,
|
8 |
+
type Node,
|
9 |
+
ReactFlow,
|
10 |
ReactFlowProvider,
|
11 |
+
type XYPosition,
|
12 |
applyEdgeChanges,
|
13 |
applyNodeChanges,
|
|
|
|
|
|
|
|
|
|
|
14 |
useReactFlow,
|
15 |
+
useUpdateNodeInternals,
|
16 |
+
} from "@xyflow/react";
|
17 |
+
import {
|
18 |
+
type MouseEvent,
|
19 |
+
useCallback,
|
20 |
+
useEffect,
|
21 |
+
useMemo,
|
22 |
+
useState,
|
23 |
+
} from "react";
|
24 |
+
// The LynxKite workspace editor.
|
25 |
+
import { useParams } from "react-router";
|
26 |
+
import useSWR, { type Fetcher } from "swr";
|
27 |
+
import { WebsocketProvider } from "y-websocket";
|
28 |
// @ts-ignore
|
29 |
+
import ArrowBack from "~icons/tabler/arrow-back.jsx";
|
30 |
// @ts-ignore
|
31 |
+
import Atom from "~icons/tabler/atom.jsx";
|
32 |
// @ts-ignore
|
33 |
+
import Backspace from "~icons/tabler/backspace.jsx";
|
34 |
+
import type { Workspace, WorkspaceNode } from "../apiTypes.ts";
|
35 |
+
import favicon from "../assets/favicon.ico";
|
|
|
36 |
// import NodeWithTableView from './NodeWithTableView';
|
37 |
+
import EnvironmentSelector from "./EnvironmentSelector";
|
38 |
+
import { LynxKiteState } from "./LynxKiteState";
|
39 |
+
import NodeSearch, {
|
40 |
+
type OpsOp,
|
41 |
+
type Catalog,
|
42 |
+
type Catalogs,
|
43 |
+
} from "./NodeSearch.tsx";
|
44 |
import NodeWithImage from "./nodes/NodeWithImage.tsx";
|
45 |
+
import NodeWithParams from "./nodes/NodeWithParams";
|
46 |
import NodeWithTableView from "./nodes/NodeWithTableView.tsx";
|
47 |
+
import NodeWithVisualization from "./nodes/NodeWithVisualization.tsx";
|
48 |
|
49 |
export default function (props: any) {
|
50 |
return (
|
|
|
54 |
);
|
55 |
}
|
56 |
|
|
|
57 |
function LynxKiteFlow() {
|
58 |
+
const updateNodeInternals = useUpdateNodeInternals();
|
59 |
const reactFlow = useReactFlow();
|
60 |
const [nodes, setNodes] = useState([] as Node[]);
|
61 |
const [edges, setEdges] = useState([] as Edge[]);
|
|
|
65 |
const state = syncedStore({ workspace: {} as Workspace });
|
66 |
setState(state);
|
67 |
const doc = getYjsDoc(state);
|
68 |
+
const wsProvider = new WebsocketProvider(
|
69 |
+
`ws://${location.host}/ws/crdt`,
|
70 |
+
path!,
|
71 |
+
doc,
|
72 |
+
);
|
73 |
const onChange = (_update: any, origin: any, _doc: any, _tr: any) => {
|
74 |
if (origin === wsProvider) {
|
75 |
// An update from the CRDT. Apply it to the local state.
|
|
|
85 |
}
|
86 |
}
|
87 |
};
|
88 |
+
doc.on("update", onChange);
|
89 |
return () => {
|
90 |
doc.destroy();
|
91 |
wsProvider.destroy();
|
92 |
+
};
|
93 |
+
}, [path, updateNodeInternals]);
|
94 |
|
95 |
+
const onNodesChange = useCallback(
|
96 |
+
(changes: any[]) => {
|
97 |
+
// An update from the UI. Apply it to the local state...
|
98 |
+
setNodes((nds) => applyNodeChanges(changes, nds));
|
99 |
+
// ...and to the CRDT state. (Which could be the same, except for ReactFlow's internal copies.)
|
100 |
+
const wnodes = state.workspace?.nodes;
|
101 |
+
if (!wnodes) return;
|
102 |
+
for (const ch of changes) {
|
103 |
+
const nodeIndex = wnodes.findIndex((n) => n.id === ch.id);
|
104 |
+
if (nodeIndex === -1) continue;
|
105 |
+
const node = wnodes[nodeIndex];
|
106 |
+
if (!node) continue;
|
107 |
+
// Position events sometimes come with NaN values. Ignore them.
|
108 |
+
if (
|
109 |
+
ch.type === "position" &&
|
110 |
+
!Number.isNaN(ch.position.x) &&
|
111 |
+
!Number.isNaN(ch.position.y)
|
112 |
+
) {
|
113 |
+
getYjsDoc(state).transact(() => {
|
114 |
+
Object.assign(node.position, ch.position);
|
115 |
+
});
|
116 |
+
} else if (ch.type === "select") {
|
117 |
+
} else if (ch.type === "dimensions") {
|
118 |
+
getYjsDoc(state).transact(() => Object.assign(node, ch.dimensions));
|
119 |
+
} else if (ch.type === "remove") {
|
120 |
+
wnodes.splice(nodeIndex, 1);
|
121 |
+
} else if (ch.type === "replace") {
|
122 |
+
// Ideally we would only update the parameter that changed. But ReactFlow does not give us that detail.
|
123 |
+
const u = {
|
124 |
+
collapsed: ch.item.data.collapsed,
|
125 |
+
// The "..." expansion on a Y.map returns an empty object. Copying with fromEntries/entries instead.
|
126 |
+
params: {
|
127 |
+
...Object.fromEntries(Object.entries(ch.item.data.params)),
|
128 |
+
},
|
129 |
+
__execution_delay: ch.item.data.__execution_delay,
|
130 |
+
};
|
131 |
+
getYjsDoc(state).transact(() => Object.assign(node.data, u));
|
132 |
+
} else {
|
133 |
+
console.log("Unknown node change", ch);
|
134 |
+
}
|
135 |
}
|
136 |
+
},
|
137 |
+
[state],
|
138 |
+
);
|
139 |
+
const onEdgesChange = useCallback(
|
140 |
+
(changes: any[]) => {
|
141 |
+
setEdges((eds) => applyEdgeChanges(changes, eds));
|
142 |
+
const wedges = state.workspace?.edges;
|
143 |
+
if (!wedges) return;
|
144 |
+
for (const ch of changes) {
|
145 |
+
const edgeIndex = wedges.findIndex((e) => e.id === ch.id);
|
146 |
+
if (ch.type === "remove") {
|
147 |
+
wedges.splice(edgeIndex, 1);
|
148 |
+
} else if (ch.type === "select") {
|
149 |
+
} else {
|
150 |
+
console.log("Unknown edge change", ch);
|
151 |
+
}
|
152 |
}
|
153 |
+
},
|
154 |
+
[state],
|
155 |
+
);
|
156 |
|
157 |
+
const fetcher: Fetcher<Catalogs> = (resource: string, init?: RequestInit) =>
|
158 |
+
fetch(resource, init).then((res) => res.json());
|
159 |
+
const catalog = useSWR("/api/catalog", fetcher);
|
160 |
const [suppressSearchUntil, setSuppressSearchUntil] = useState(0);
|
161 |
+
const [nodeSearchSettings, setNodeSearchSettings] = useState(
|
162 |
+
undefined as
|
163 |
+
| {
|
164 |
+
pos: XYPosition;
|
165 |
+
boxes: Catalog;
|
166 |
+
}
|
167 |
+
| undefined,
|
168 |
+
);
|
169 |
+
const nodeTypes = useMemo(
|
170 |
+
() => ({
|
171 |
+
basic: NodeWithParams,
|
172 |
+
visualization: NodeWithVisualization,
|
173 |
+
image: NodeWithImage,
|
174 |
+
table_view: NodeWithTableView,
|
175 |
+
}),
|
176 |
+
[],
|
177 |
+
);
|
178 |
const closeNodeSearch = useCallback(() => {
|
179 |
setNodeSearchSettings(undefined);
|
180 |
setSuppressSearchUntil(Date.now() + 200);
|
181 |
+
}, []);
|
182 |
+
const toggleNodeSearch = useCallback(
|
183 |
+
(event: MouseEvent) => {
|
184 |
+
if (suppressSearchUntil > Date.now()) return;
|
185 |
+
if (nodeSearchSettings) {
|
186 |
+
closeNodeSearch();
|
187 |
+
return;
|
188 |
+
}
|
189 |
+
event.preventDefault();
|
190 |
+
setNodeSearchSettings({
|
191 |
+
pos: { x: event.clientX, y: event.clientY },
|
192 |
+
boxes: catalog.data![state.workspace.env!],
|
193 |
+
});
|
194 |
+
},
|
195 |
+
[catalog, state, nodeSearchSettings, suppressSearchUntil, closeNodeSearch],
|
196 |
+
);
|
197 |
+
const addNode = useCallback(
|
198 |
+
(meta: OpsOp) => {
|
199 |
+
const node: Partial<WorkspaceNode> = {
|
200 |
+
type: meta.type,
|
201 |
+
data: {
|
202 |
+
meta: meta,
|
203 |
+
title: meta.name,
|
204 |
+
params: Object.fromEntries(
|
205 |
+
Object.values(meta.params).map((p) => [p.name, p.default]),
|
206 |
+
),
|
207 |
+
},
|
208 |
+
};
|
209 |
+
const nss = nodeSearchSettings!;
|
210 |
+
node.position = reactFlow.screenToFlowPosition({
|
211 |
+
x: nss.pos.x,
|
212 |
+
y: nss.pos.y,
|
213 |
+
});
|
214 |
+
const title = meta.name;
|
215 |
+
let i = 1;
|
216 |
node.id = `${title} ${i}`;
|
217 |
+
const wnodes = state.workspace.nodes!;
|
218 |
+
while (wnodes.find((x) => x.id === node.id)) {
|
219 |
+
i += 1;
|
220 |
+
node.id = `${title} ${i}`;
|
221 |
+
}
|
222 |
+
wnodes.push(node as WorkspaceNode);
|
223 |
+
setNodes([...nodes, node as WorkspaceNode]);
|
224 |
+
closeNodeSearch();
|
225 |
+
},
|
226 |
+
[nodeSearchSettings, state, reactFlow, nodes, closeNodeSearch],
|
227 |
+
);
|
228 |
|
229 |
+
const onConnect = useCallback(
|
230 |
+
(connection: Connection) => {
|
231 |
+
setSuppressSearchUntil(Date.now() + 200);
|
232 |
+
const edge = {
|
233 |
+
id: `${connection.source} ${connection.target}`,
|
234 |
+
source: connection.source,
|
235 |
+
sourceHandle: connection.sourceHandle!,
|
236 |
+
target: connection.target,
|
237 |
+
targetHandle: connection.targetHandle!,
|
238 |
+
};
|
239 |
+
state.workspace.edges!.push(edge);
|
240 |
+
setEdges((oldEdges) => [...oldEdges, edge]);
|
241 |
+
},
|
242 |
+
[state],
|
243 |
+
);
|
244 |
+
const parentDir = path!.split("/").slice(0, -1).join("/");
|
245 |
return (
|
246 |
<div className="workspace">
|
247 |
<div className="top-bar bg-neutral">
|
248 |
+
<a className="logo" href="">
|
249 |
+
<img alt="" src={favicon} />
|
250 |
+
</a>
|
251 |
+
<div className="ws-name">{path}</div>
|
252 |
<EnvironmentSelector
|
253 |
options={Object.keys(catalog.data || {})}
|
254 |
value={state.workspace.env!}
|
255 |
+
onChange={(env) => {
|
256 |
+
state.workspace.env = env;
|
257 |
+
}}
|
258 |
/>
|
259 |
<div className="tools text-secondary">
|
260 |
+
<a href="">
|
261 |
+
<Atom />
|
262 |
+
</a>
|
263 |
+
<a href="">
|
264 |
+
<Backspace />
|
265 |
+
</a>
|
266 |
+
<a href={`/dir/${parentDir}`}>
|
267 |
+
<ArrowBack />
|
268 |
+
</a>
|
269 |
</div>
|
270 |
</div>
|
271 |
+
<div style={{ height: "100%", width: "100vw" }}>
|
272 |
<LynxKiteState.Provider value={state}>
|
273 |
<ReactFlow
|
274 |
nodes={nodes}
|
275 |
edges={edges}
|
276 |
+
nodeTypes={nodeTypes}
|
277 |
+
fitView
|
278 |
onNodesChange={onNodesChange}
|
279 |
onEdgesChange={onEdgesChange}
|
280 |
onPaneClick={toggleNodeSearch}
|
|
|
286 |
>
|
287 |
<Controls />
|
288 |
<MiniMap />
|
289 |
+
{nodeSearchSettings && (
|
290 |
+
<NodeSearch
|
291 |
+
pos={nodeSearchSettings.pos}
|
292 |
+
boxes={nodeSearchSettings.boxes}
|
293 |
+
onCancel={closeNodeSearch}
|
294 |
+
onAdd={addNode}
|
295 |
+
/>
|
296 |
+
)}
|
297 |
</ReactFlow>
|
298 |
</LynxKiteState.Provider>
|
299 |
</div>
|
300 |
</div>
|
|
|
301 |
);
|
302 |
}
|
lynxkite-app/web/src/workspace/nodes/LynxKiteNode.tsx
CHANGED
@@ -1,6 +1,11 @@
|
|
1 |
-
import {
|
|
|
|
|
|
|
|
|
|
|
2 |
// @ts-ignore
|
3 |
-
import ChevronDownRight from
|
4 |
|
5 |
interface LynxKiteNodeProps {
|
6 |
id: string;
|
@@ -13,18 +18,18 @@ interface LynxKiteNodeProps {
|
|
13 |
|
14 |
function getHandles(inputs: object, outputs: object) {
|
15 |
const handles: {
|
16 |
-
position:
|
17 |
-
name: string
|
18 |
-
index: number
|
19 |
-
offsetPercentage: number
|
20 |
-
showLabel: boolean
|
21 |
-
type:
|
22 |
}[] = [];
|
23 |
for (const e of Object.values(inputs)) {
|
24 |
-
handles.push({ ...e, type:
|
25 |
}
|
26 |
for (const e of Object.values(outputs)) {
|
27 |
-
handles.push({ ...e, type:
|
28 |
}
|
29 |
const counts = { top: 0, bottom: 0, left: 0, right: 0 };
|
30 |
for (const e of handles) {
|
@@ -32,9 +37,11 @@ function getHandles(inputs: object, outputs: object) {
|
|
32 |
counts[e.position]++;
|
33 |
}
|
34 |
for (const e of handles) {
|
35 |
-
e.offsetPercentage = 100 * (e.index + 1) / (counts[e.position] + 1);
|
36 |
-
const simpleHorizontal =
|
37 |
-
|
|
|
|
|
38 |
e.showLabel = !simpleHorizontal && !simpleVertical;
|
39 |
}
|
40 |
return handles;
|
@@ -48,37 +55,57 @@ export default function LynxKiteNode(props: LynxKiteNodeProps) {
|
|
48 |
function titleClicked() {
|
49 |
reactFlow.updateNodeData(props.id, { collapsed: expanded });
|
50 |
}
|
51 |
-
const handleOffsetDirection = {
|
|
|
|
|
|
|
|
|
|
|
52 |
|
53 |
return (
|
54 |
-
<div
|
55 |
-
|
|
|
|
|
|
|
|
|
|
|
56 |
<div className="lynxkite-node" style={props.nodeStyle}>
|
57 |
<div className="title bg-primary" onClick={titleClicked}>
|
58 |
{data.title}
|
59 |
{data.error && <span className="title-icon">⚠️</span>}
|
60 |
{expanded || <span className="title-icon">⋯</span>}
|
61 |
</div>
|
62 |
-
{expanded &&
|
63 |
-
|
64 |
-
<div className="error">{data.error}</div>
|
65 |
-
|
66 |
-
|
67 |
-
|
68 |
-
|
69 |
-
|
70 |
-
|
71 |
-
|
72 |
-
|
73 |
-
|
74 |
-
|
75 |
-
{handles.map(handle => (
|
76 |
<Handle
|
77 |
key={handle.name}
|
78 |
-
id={handle.name}
|
79 |
-
|
80 |
-
|
81 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
82 |
))}
|
83 |
</div>
|
84 |
</div>
|
|
|
1 |
+
import {
|
2 |
+
Handle,
|
3 |
+
NodeResizeControl,
|
4 |
+
type Position,
|
5 |
+
useReactFlow,
|
6 |
+
} from "@xyflow/react";
|
7 |
// @ts-ignore
|
8 |
+
import ChevronDownRight from "~icons/tabler/chevron-down-right.jsx";
|
9 |
|
10 |
interface LynxKiteNodeProps {
|
11 |
id: string;
|
|
|
18 |
|
19 |
function getHandles(inputs: object, outputs: object) {
|
20 |
const handles: {
|
21 |
+
position: "top" | "bottom" | "left" | "right";
|
22 |
+
name: string;
|
23 |
+
index: number;
|
24 |
+
offsetPercentage: number;
|
25 |
+
showLabel: boolean;
|
26 |
+
type: "source" | "target";
|
27 |
}[] = [];
|
28 |
for (const e of Object.values(inputs)) {
|
29 |
+
handles.push({ ...e, type: "target" });
|
30 |
}
|
31 |
for (const e of Object.values(outputs)) {
|
32 |
+
handles.push({ ...e, type: "source" });
|
33 |
}
|
34 |
const counts = { top: 0, bottom: 0, left: 0, right: 0 };
|
35 |
for (const e of handles) {
|
|
|
37 |
counts[e.position]++;
|
38 |
}
|
39 |
for (const e of handles) {
|
40 |
+
e.offsetPercentage = (100 * (e.index + 1)) / (counts[e.position] + 1);
|
41 |
+
const simpleHorizontal =
|
42 |
+
counts.top === 0 && counts.bottom === 0 && handles.length <= 2;
|
43 |
+
const simpleVertical =
|
44 |
+
counts.left === 0 && counts.right === 0 && handles.length <= 2;
|
45 |
e.showLabel = !simpleHorizontal && !simpleVertical;
|
46 |
}
|
47 |
return handles;
|
|
|
55 |
function titleClicked() {
|
56 |
reactFlow.updateNodeData(props.id, { collapsed: expanded });
|
57 |
}
|
58 |
+
const handleOffsetDirection = {
|
59 |
+
top: "left",
|
60 |
+
bottom: "left",
|
61 |
+
left: "top",
|
62 |
+
right: "top",
|
63 |
+
};
|
64 |
|
65 |
return (
|
66 |
+
<div
|
67 |
+
className={`node-container·${expanded ? "expanded" : "collapsed"} `}
|
68 |
+
style={{
|
69 |
+
width: props.width || 200,
|
70 |
+
height: expanded ? props.height || 200 : undefined,
|
71 |
+
}}
|
72 |
+
>
|
73 |
<div className="lynxkite-node" style={props.nodeStyle}>
|
74 |
<div className="title bg-primary" onClick={titleClicked}>
|
75 |
{data.title}
|
76 |
{data.error && <span className="title-icon">⚠️</span>}
|
77 |
{expanded || <span className="title-icon">⋯</span>}
|
78 |
</div>
|
79 |
+
{expanded && (
|
80 |
+
<>
|
81 |
+
{data.error && <div className="error">{data.error}</div>}
|
82 |
+
{props.children}
|
83 |
+
<NodeResizeControl
|
84 |
+
minWidth={100}
|
85 |
+
minHeight={50}
|
86 |
+
style={{ background: "transparent", border: "none" }}
|
87 |
+
>
|
88 |
+
<ChevronDownRight className="node-resizer" />
|
89 |
+
</NodeResizeControl>
|
90 |
+
</>
|
91 |
+
)}
|
92 |
+
{handles.map((handle) => (
|
93 |
<Handle
|
94 |
key={handle.name}
|
95 |
+
id={handle.name}
|
96 |
+
type={handle.type}
|
97 |
+
position={handle.position as Position}
|
98 |
+
style={{
|
99 |
+
[handleOffsetDirection[handle.position]]:
|
100 |
+
`${handle.offsetPercentage}% `,
|
101 |
+
}}
|
102 |
+
>
|
103 |
+
{handle.showLabel && (
|
104 |
+
<span className="handle-name">
|
105 |
+
{handle.name.replace(/_/g, " ")}
|
106 |
+
</span>
|
107 |
+
)}
|
108 |
+
</Handle>
|
109 |
))}
|
110 |
</div>
|
111 |
</div>
|
lynxkite-app/web/src/workspace/nodes/NodeParameter.tsx
CHANGED
@@ -1,7 +1,9 @@
|
|
1 |
const BOOLEAN = "<class 'bool'>";
|
2 |
|
3 |
function ParamName({ name }: { name: string }) {
|
4 |
-
return
|
|
|
|
|
5 |
}
|
6 |
|
7 |
interface NodeParameterProps {
|
@@ -11,50 +13,69 @@ interface NodeParameterProps {
|
|
11 |
onChange: (value: any, options?: { delay: number }) => void;
|
12 |
}
|
13 |
|
14 |
-
export default function NodeParameter({
|
|
|
|
|
|
|
|
|
|
|
15 |
return (
|
|
|
16 |
<label className="param">
|
17 |
-
{meta?.type?.format ===
|
18 |
-
|
19 |
-
|
20 |
-
|
21 |
-
|
22 |
-
|
23 |
-
|
24 |
-
|
25 |
-
|
26 |
-
|
27 |
-
|
28 |
-
|
29 |
-
|
30 |
-
|
31 |
-
|
32 |
-
|
33 |
-
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
-
|
38 |
-
|
39 |
-
|
40 |
-
|
41 |
-
|
42 |
-
|
43 |
-
|
44 |
-
|
45 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
46 |
/>
|
47 |
-
|
48 |
-
|
49 |
-
|
50 |
-
<ParamName name={name} />
|
51 |
-
<input className="input input-bordered w-full max-w-xs"
|
52 |
-
value={value || ""}
|
53 |
-
onChange={(evt) => onChange(evt.currentTarget.value, { delay: 2 })}
|
54 |
-
onBlur={(evt) => onChange(evt.currentTarget.value, { delay: 0 })}
|
55 |
-
/>
|
56 |
-
</>
|
57 |
-
}
|
58 |
-
</label >
|
59 |
);
|
60 |
}
|
|
|
1 |
const BOOLEAN = "<class 'bool'>";
|
2 |
|
3 |
function ParamName({ name }: { name: string }) {
|
4 |
+
return (
|
5 |
+
<span className="param-name bg-base-200">{name.replace(/_/g, " ")}</span>
|
6 |
+
);
|
7 |
}
|
8 |
|
9 |
interface NodeParameterProps {
|
|
|
13 |
onChange: (value: any, options?: { delay: number }) => void;
|
14 |
}
|
15 |
|
16 |
+
export default function NodeParameter({
|
17 |
+
name,
|
18 |
+
value,
|
19 |
+
meta,
|
20 |
+
onChange,
|
21 |
+
}: NodeParameterProps) {
|
22 |
return (
|
23 |
+
// biome-ignore lint/a11y/noLabelWithoutControl: Most of the time there is a control.
|
24 |
<label className="param">
|
25 |
+
{meta?.type?.format === "collapsed" ? (
|
26 |
+
<>
|
27 |
+
<ParamName name={name} />
|
28 |
+
<button className="collapsed-param">⋯</button>
|
29 |
+
</>
|
30 |
+
) : meta?.type?.format === "textarea" ? (
|
31 |
+
<>
|
32 |
+
<ParamName name={name} />
|
33 |
+
<textarea
|
34 |
+
className="textarea textarea-bordered w-full max-w-xs"
|
35 |
+
rows={6}
|
36 |
+
value={value}
|
37 |
+
onChange={(evt) => onChange(evt.currentTarget.value, { delay: 2 })}
|
38 |
+
onBlur={(evt) => onChange(evt.currentTarget.value, { delay: 0 })}
|
39 |
+
/>
|
40 |
+
</>
|
41 |
+
) : meta?.type?.enum ? (
|
42 |
+
<>
|
43 |
+
<ParamName name={name} />
|
44 |
+
<select
|
45 |
+
className="select select-bordered w-full max-w-xs"
|
46 |
+
value={value || meta.type.enum[0]}
|
47 |
+
onChange={(evt) => onChange(evt.currentTarget.value)}
|
48 |
+
>
|
49 |
+
{meta.type.enum.map((option: string) => (
|
50 |
+
<option key={option} value={option}>
|
51 |
+
{option}
|
52 |
+
</option>
|
53 |
+
))}
|
54 |
+
</select>
|
55 |
+
</>
|
56 |
+
) : meta?.type?.type === BOOLEAN ? (
|
57 |
+
<div className="form-control">
|
58 |
+
<label className="label cursor-pointer">
|
59 |
+
<input
|
60 |
+
className="checkbox"
|
61 |
+
type="checkbox"
|
62 |
+
checked={value}
|
63 |
+
onChange={(evt) => onChange(evt.currentTarget.checked)}
|
64 |
+
/>
|
65 |
+
{name.replace(/_/g, " ")}
|
66 |
+
</label>
|
67 |
+
</div>
|
68 |
+
) : (
|
69 |
+
<>
|
70 |
+
<ParamName name={name} />
|
71 |
+
<input
|
72 |
+
className="input input-bordered w-full max-w-xs"
|
73 |
+
value={value || ""}
|
74 |
+
onChange={(evt) => onChange(evt.currentTarget.value, { delay: 2 })}
|
75 |
+
onBlur={(evt) => onChange(evt.currentTarget.value, { delay: 0 })}
|
76 |
/>
|
77 |
+
</>
|
78 |
+
)}
|
79 |
+
</label>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
80 |
);
|
81 |
}
|
lynxkite-app/web/src/workspace/nodes/NodeWithImage.tsx
CHANGED
@@ -1,9 +1,11 @@
|
|
1 |
-
import NodeWithParams from
|
2 |
|
3 |
const NodeWithImage = (props: any) => {
|
4 |
return (
|
5 |
<NodeWithParams {...props}>
|
6 |
-
{props.data.display &&
|
|
|
|
|
7 |
</NodeWithParams>
|
8 |
);
|
9 |
};
|
|
|
1 |
+
import NodeWithParams from "./NodeWithParams";
|
2 |
|
3 |
const NodeWithImage = (props: any) => {
|
4 |
return (
|
5 |
<NodeWithParams {...props}>
|
6 |
+
{props.data.display && (
|
7 |
+
<img src={props.data.display} alt="Node Display" />
|
8 |
+
)}
|
9 |
</NodeWithParams>
|
10 |
);
|
11 |
};
|
lynxkite-app/web/src/workspace/nodes/NodeWithParams.tsx
CHANGED
@@ -1,6 +1,6 @@
|
|
1 |
-
import
|
2 |
-
import
|
3 |
-
import NodeParameter from
|
4 |
|
5 |
export type UpdateOptions = { delay?: number };
|
6 |
|
@@ -8,23 +8,28 @@ function NodeWithParams(props: any) {
|
|
8 |
const reactFlow = useReactFlow();
|
9 |
const metaParams = props.data.meta?.params;
|
10 |
function setParam(name: string, newValue: any, opts: UpdateOptions) {
|
11 |
-
reactFlow.updateNodeData(props.id, {
|
|
|
|
|
|
|
12 |
}
|
13 |
const params = props.data?.params ? Object.entries(props.data.params) : [];
|
14 |
|
15 |
return (
|
16 |
<LynxKiteNode {...props}>
|
17 |
-
{params.map(([name, value]) =>
|
18 |
<NodeParameter
|
19 |
name={name}
|
20 |
key={name}
|
21 |
value={value}
|
22 |
meta={metaParams?.[name]}
|
23 |
-
onChange={(value: any, opts?: UpdateOptions) =>
|
|
|
|
|
24 |
/>
|
25 |
-
)}
|
26 |
{props.children}
|
27 |
-
</LynxKiteNode
|
28 |
);
|
29 |
}
|
30 |
|
|
|
1 |
+
import { useReactFlow } from "@xyflow/react";
|
2 |
+
import LynxKiteNode from "./LynxKiteNode";
|
3 |
+
import NodeParameter from "./NodeParameter";
|
4 |
|
5 |
export type UpdateOptions = { delay?: number };
|
6 |
|
|
|
8 |
const reactFlow = useReactFlow();
|
9 |
const metaParams = props.data.meta?.params;
|
10 |
function setParam(name: string, newValue: any, opts: UpdateOptions) {
|
11 |
+
reactFlow.updateNodeData(props.id, {
|
12 |
+
params: { ...props.data.params, [name]: newValue },
|
13 |
+
__execution_delay: opts.delay || 0,
|
14 |
+
});
|
15 |
}
|
16 |
const params = props.data?.params ? Object.entries(props.data.params) : [];
|
17 |
|
18 |
return (
|
19 |
<LynxKiteNode {...props}>
|
20 |
+
{params.map(([name, value]) => (
|
21 |
<NodeParameter
|
22 |
name={name}
|
23 |
key={name}
|
24 |
value={value}
|
25 |
meta={metaParams?.[name]}
|
26 |
+
onChange={(value: any, opts?: UpdateOptions) =>
|
27 |
+
setParam(name, value, opts || {})
|
28 |
+
}
|
29 |
/>
|
30 |
+
))}
|
31 |
{props.children}
|
32 |
+
</LynxKiteNode>
|
33 |
);
|
34 |
}
|
35 |
|
lynxkite-app/web/src/workspace/nodes/NodeWithTableView.tsx
CHANGED
@@ -1,15 +1,15 @@
|
|
1 |
-
import { useState } from
|
2 |
-
import
|
3 |
-
import
|
4 |
-
import
|
5 |
-
import
|
6 |
|
7 |
function toMD(v: any): string {
|
8 |
-
if (typeof v ===
|
9 |
return v;
|
10 |
}
|
11 |
if (Array.isArray(v)) {
|
12 |
-
return v.map(toMD).join(
|
13 |
}
|
14 |
return JSON.stringify(v);
|
15 |
}
|
@@ -17,33 +17,60 @@ function toMD(v: any): string {
|
|
17 |
export default function NodeWithTableView(props: any) {
|
18 |
const [open, setOpen] = useState({} as { [name: string]: boolean });
|
19 |
const display = props.data.display?.value;
|
20 |
-
const single =
|
|
|
21 |
return (
|
22 |
<LynxKiteNode {...props}>
|
23 |
{display && [
|
24 |
-
Object.entries(display.dataframes || {}).map(
|
25 |
-
|
26 |
-
|
27 |
-
|
28 |
-
|
29 |
-
|
30 |
-
|
31 |
-
|
32 |
-
|
33 |
-
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
-
|
38 |
-
|
39 |
-
|
40 |
-
|
41 |
-
|
42 |
-
|
43 |
-
|
44 |
-
|
45 |
-
|
46 |
-
|
47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
48 |
);
|
49 |
}
|
|
|
1 |
+
import { useState } from "react";
|
2 |
+
import React from "react";
|
3 |
+
import Markdown from "react-markdown";
|
4 |
+
import LynxKiteNode from "./LynxKiteNode";
|
5 |
+
import Table from "./Table";
|
6 |
|
7 |
function toMD(v: any): string {
|
8 |
+
if (typeof v === "string") {
|
9 |
return v;
|
10 |
}
|
11 |
if (Array.isArray(v)) {
|
12 |
+
return v.map(toMD).join("\n\n");
|
13 |
}
|
14 |
return JSON.stringify(v);
|
15 |
}
|
|
|
17 |
export default function NodeWithTableView(props: any) {
|
18 |
const [open, setOpen] = useState({} as { [name: string]: boolean });
|
19 |
const display = props.data.display?.value;
|
20 |
+
const single =
|
21 |
+
display?.dataframes && Object.keys(display?.dataframes).length === 1;
|
22 |
return (
|
23 |
<LynxKiteNode {...props}>
|
24 |
{display && [
|
25 |
+
Object.entries(display.dataframes || {}).map(
|
26 |
+
([name, df]: [string, any]) => (
|
27 |
+
<React.Fragment key={name}>
|
28 |
+
{!single && (
|
29 |
+
<div
|
30 |
+
key={`${name}-header`}
|
31 |
+
className="df-head"
|
32 |
+
onClick={() => setOpen({ ...open, [name]: !open[name] })}
|
33 |
+
>
|
34 |
+
{name}
|
35 |
+
</div>
|
36 |
+
)}
|
37 |
+
{(single || open[name]) &&
|
38 |
+
(df.data.length > 1 ? (
|
39 |
+
<Table
|
40 |
+
key={`${name}-table`}
|
41 |
+
columns={df.columns}
|
42 |
+
data={df.data}
|
43 |
+
/>
|
44 |
+
) : df.data.length ? (
|
45 |
+
<dl key={`${name}-dl`}>
|
46 |
+
{df.columns.map((c: string, i: number) => (
|
47 |
+
<React.Fragment key={`${name}-${c}`}>
|
48 |
+
<dt>{c}</dt>
|
49 |
+
<dd>
|
50 |
+
<Markdown>{toMD(df.data[0][i])}</Markdown>
|
51 |
+
</dd>
|
52 |
+
</React.Fragment>
|
53 |
+
))}
|
54 |
+
</dl>
|
55 |
+
) : (
|
56 |
+
JSON.stringify(df.data)
|
57 |
+
))}
|
58 |
+
</React.Fragment>
|
59 |
+
),
|
60 |
+
),
|
61 |
+
Object.entries(display.others || {}).map(([name, o]) => (
|
62 |
+
<>
|
63 |
+
<div
|
64 |
+
key={`${name}-header`}
|
65 |
+
className="df-head"
|
66 |
+
onClick={() => setOpen({ ...open, [name]: !open[name] })}
|
67 |
+
>
|
68 |
+
{name}
|
69 |
+
</div>
|
70 |
+
{open[name] && <pre>{(o as any).toString()}</pre>}
|
71 |
+
</>
|
72 |
+
)),
|
73 |
+
]}
|
74 |
+
</LynxKiteNode>
|
75 |
);
|
76 |
}
|
lynxkite-app/web/src/workspace/nodes/NodeWithVisualization.tsx
CHANGED
@@ -1,6 +1,6 @@
|
|
1 |
-
import React, { useEffect } from
|
2 |
-
import NodeWithParams from
|
3 |
-
const echarts = await import(
|
4 |
|
5 |
const NodeWithVisualization = (props: any) => {
|
6 |
const chartsRef = React.useRef<HTMLDivElement>(null);
|
@@ -8,12 +8,16 @@ const NodeWithVisualization = (props: any) => {
|
|
8 |
useEffect(() => {
|
9 |
const opts = props.data?.display?.value;
|
10 |
if (!opts || !chartsRef.current) return;
|
11 |
-
chartsInstanceRef.current = echarts.init(chartsRef.current, null, {
|
|
|
|
|
|
|
|
|
12 |
chartsInstanceRef.current.setOption(opts);
|
13 |
const onResize = () => chartsInstanceRef.current?.resize();
|
14 |
-
window.addEventListener(
|
15 |
return () => {
|
16 |
-
window.removeEventListener(
|
17 |
chartsInstanceRef.current?.dispose();
|
18 |
};
|
19 |
}, [props.data?.display?.value]);
|
|
|
1 |
+
import React, { useEffect } from "react";
|
2 |
+
import NodeWithParams from "./NodeWithParams";
|
3 |
+
const echarts = await import("echarts");
|
4 |
|
5 |
const NodeWithVisualization = (props: any) => {
|
6 |
const chartsRef = React.useRef<HTMLDivElement>(null);
|
|
|
8 |
useEffect(() => {
|
9 |
const opts = props.data?.display?.value;
|
10 |
if (!opts || !chartsRef.current) return;
|
11 |
+
chartsInstanceRef.current = echarts.init(chartsRef.current, null, {
|
12 |
+
renderer: "canvas",
|
13 |
+
width: 250,
|
14 |
+
height: 250,
|
15 |
+
});
|
16 |
chartsInstanceRef.current.setOption(opts);
|
17 |
const onResize = () => chartsInstanceRef.current?.resize();
|
18 |
+
window.addEventListener("resize", onResize);
|
19 |
return () => {
|
20 |
+
window.removeEventListener("resize", onResize);
|
21 |
chartsInstanceRef.current?.dispose();
|
22 |
};
|
23 |
}, [props.data?.display?.value]);
|
lynxkite-app/web/src/workspace/nodes/Table.tsx
CHANGED
@@ -1,17 +1,22 @@
|
|
1 |
export default function Table(props: any) {
|
2 |
-
return (
|
3 |
-
<
|
4 |
-
<
|
5 |
-
|
6 |
-
|
7 |
-
|
8 |
-
|
9 |
-
|
10 |
-
|
11 |
-
|
12 |
-
|
13 |
-
|
14 |
-
|
15 |
-
|
16 |
-
|
|
|
|
|
|
|
|
|
|
|
17 |
}
|
|
|
1 |
export default function Table(props: any) {
|
2 |
+
return (
|
3 |
+
<table>
|
4 |
+
<thead>
|
5 |
+
<tr>
|
6 |
+
{props.columns.map((column: string) => (
|
7 |
+
<th key={column}>{column}</th>
|
8 |
+
))}
|
9 |
+
</tr>
|
10 |
+
</thead>
|
11 |
+
<tbody>
|
12 |
+
{props.data.map((row: { [column: string]: any }, i: number) => (
|
13 |
+
<tr key={`row-${i}`}>
|
14 |
+
{props.columns.map((_column: string, j: number) => (
|
15 |
+
<td key={`cell ${i}, ${j}`}>{JSON.stringify(row[j])}</td>
|
16 |
+
))}
|
17 |
+
</tr>
|
18 |
+
))}
|
19 |
+
</tbody>
|
20 |
+
</table>
|
21 |
+
);
|
22 |
}
|
lynxkite-app/web/tailwind.config.js
CHANGED
@@ -1,21 +1,21 @@
|
|
1 |
/** @type {import('tailwindcss').Config} */
|
2 |
export default {
|
3 |
-
darkMode:
|
4 |
-
content: [
|
5 |
theme: {
|
6 |
extend: {},
|
7 |
},
|
8 |
-
plugins: [require(
|
9 |
daisyui: {
|
10 |
logs: false,
|
11 |
themes: [
|
12 |
{
|
13 |
lynxkite: {
|
14 |
-
primary:
|
15 |
-
secondary:
|
16 |
-
accent:
|
17 |
-
neutral:
|
18 |
-
|
19 |
},
|
20 |
},
|
21 |
],
|
|
|
1 |
/** @type {import('tailwindcss').Config} */
|
2 |
export default {
|
3 |
+
darkMode: "selector",
|
4 |
+
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
5 |
theme: {
|
6 |
extend: {},
|
7 |
},
|
8 |
+
plugins: [require("daisyui")],
|
9 |
daisyui: {
|
10 |
logs: false,
|
11 |
themes: [
|
12 |
{
|
13 |
lynxkite: {
|
14 |
+
primary: "oklch(75% 0.2 55)",
|
15 |
+
secondary: "oklch(75% 0.13 230)",
|
16 |
+
accent: "oklch(55% 0.25 320)",
|
17 |
+
neutral: "oklch(35% 0.1 240)",
|
18 |
+
"base-100": "#ffffff",
|
19 |
},
|
20 |
},
|
21 |
],
|
lynxkite-app/web/tests/basic.spec.ts
CHANGED
@@ -1,25 +1,25 @@
|
|
1 |
// Tests some basic operations like box creation, deletion, and dragging.
|
2 |
-
import {
|
3 |
-
import { Splash, Workspace } from
|
4 |
-
|
5 |
|
6 |
let workspace: Workspace;
|
7 |
|
8 |
-
|
9 |
test.beforeEach(async ({ browser }) => {
|
10 |
-
workspace = await Workspace.empty(await browser.newPage(),
|
11 |
});
|
12 |
|
13 |
test.afterEach(async () => {
|
14 |
await workspace.close();
|
15 |
const splash = await new Splash(workspace.page);
|
16 |
-
splash.page.on(
|
17 |
-
|
|
|
|
|
18 |
});
|
19 |
|
20 |
-
test(
|
21 |
const envs = await workspace.getEnvs();
|
22 |
-
for(const env of envs) {
|
23 |
await workspace.setEnv(env);
|
24 |
const catalog = await workspace.getCatalog();
|
25 |
expect(catalog).not.toHaveLength(0);
|
@@ -31,23 +31,18 @@ test('Box creation & deletion per env', async () => {
|
|
31 |
}
|
32 |
});
|
33 |
|
34 |
-
|
35 |
-
|
36 |
-
await workspace.
|
37 |
-
await workspace.
|
38 |
-
await expect(workspace.getBox('Compute PageRank 1')).not.toBeVisible();
|
39 |
});
|
40 |
|
41 |
-
|
42 |
-
|
43 |
-
await workspace.
|
44 |
-
|
45 |
-
await workspace.
|
46 |
-
const newPos = await workspace.getBox('Import Parquet 1').boundingBox();
|
47 |
// Exact position is not guaranteed, but it should have moved
|
48 |
expect(newPos.x).toBeGreaterThan(originalPos.x);
|
49 |
expect(newPos.y).toBeGreaterThan(originalPos.y);
|
50 |
});
|
51 |
-
|
52 |
-
|
53 |
-
|
|
|
1 |
// Tests some basic operations like box creation, deletion, and dragging.
|
2 |
+
import { expect, test } from "@playwright/test";
|
3 |
+
import { Splash, Workspace } from "./lynxkite";
|
|
|
4 |
|
5 |
let workspace: Workspace;
|
6 |
|
|
|
7 |
test.beforeEach(async ({ browser }) => {
|
8 |
+
workspace = await Workspace.empty(await browser.newPage(), "basic_spec_test");
|
9 |
});
|
10 |
|
11 |
test.afterEach(async () => {
|
12 |
await workspace.close();
|
13 |
const splash = await new Splash(workspace.page);
|
14 |
+
splash.page.on("dialog", async (dialog) => {
|
15 |
+
await dialog.accept();
|
16 |
+
});
|
17 |
+
await splash.deleteEntry("basic_spec_test");
|
18 |
});
|
19 |
|
20 |
+
test("Box creation & deletion per env", async () => {
|
21 |
const envs = await workspace.getEnvs();
|
22 |
+
for (const env of envs) {
|
23 |
await workspace.setEnv(env);
|
24 |
const catalog = await workspace.getCatalog();
|
25 |
expect(catalog).not.toHaveLength(0);
|
|
|
31 |
}
|
32 |
});
|
33 |
|
34 |
+
test("Delete multi-handle boxes", async () => {
|
35 |
+
await workspace.addBox("Compute PageRank");
|
36 |
+
await workspace.deleteBoxes(["Compute PageRank 1"]);
|
37 |
+
await expect(workspace.getBox("Compute PageRank 1")).not.toBeVisible();
|
|
|
38 |
});
|
39 |
|
40 |
+
test("Drag box", async () => {
|
41 |
+
await workspace.addBox("Import Parquet");
|
42 |
+
const originalPos = await workspace.getBox("Import Parquet 1").boundingBox();
|
43 |
+
await workspace.moveBox("Import Parquet 1", { offsetX: 100, offsetY: 100 });
|
44 |
+
const newPos = await workspace.getBox("Import Parquet 1").boundingBox();
|
|
|
45 |
// Exact position is not guaranteed, but it should have moved
|
46 |
expect(newPos.x).toBeGreaterThan(originalPos.x);
|
47 |
expect(newPos.y).toBeGreaterThan(originalPos.y);
|
48 |
});
|
|
|
|
|
|
lynxkite-app/web/tests/directory.spec.ts
CHANGED
@@ -1,39 +1,38 @@
|
|
1 |
// Tests some basic operations like box creation, deletion, and dragging.
|
2 |
-
import {
|
3 |
-
import {
|
4 |
-
|
5 |
|
6 |
test.describe("Directory operations", () => {
|
7 |
-
|
8 |
let splash: Splash;
|
9 |
|
10 |
test.beforeAll(async ({ browser }) => {
|
11 |
-
const page = await browser.newPage()
|
12 |
// To make deletion confirmation dialog to be automatically accepted
|
13 |
-
page.on(
|
|
|
|
|
14 |
splash = await Splash.open(page);
|
15 |
});
|
16 |
|
17 |
-
|
18 |
-
test('Create workspace with default name', async () => {
|
19 |
const workspace = await Workspace.empty(splash.page);
|
20 |
// Not checking for exact match, since there may be pre-existing "Untitled" workspaces
|
21 |
-
expect(workspace.name).toContain(
|
22 |
await workspace.close();
|
23 |
});
|
24 |
|
25 |
-
test(
|
26 |
const workspaceName = `TestWorkspace-${Date.now()}`;
|
27 |
const workspace = await Workspace.empty(splash.page, workspaceName);
|
28 |
await workspace.expectCurrentWorkspaceIs(workspaceName);
|
29 |
// Add a box so the workspace is saved
|
30 |
-
await workspace.addBox(
|
31 |
await workspace.close();
|
32 |
await splash.deleteEntry(workspaceName);
|
33 |
await expect(splash.getEntry(workspaceName)).not.toBeVisible();
|
34 |
});
|
35 |
|
36 |
-
test(
|
37 |
const folderName = `TestFolder-${Date.now()}`;
|
38 |
await splash.createFolder(folderName);
|
39 |
await expect(splash.currentFolder()).toHaveText(folderName);
|
@@ -42,56 +41,57 @@ test.describe("Directory operations", () => {
|
|
42 |
await expect(splash.getEntry(folderName)).not.toBeVisible();
|
43 |
});
|
44 |
|
45 |
-
test(
|
46 |
await splash.createFolder();
|
47 |
-
await expect(splash.currentFolder()).toContainText(
|
48 |
});
|
49 |
});
|
50 |
|
51 |
-
|
52 |
-
|
53 |
-
|
54 |
-
|
55 |
-
|
56 |
-
|
57 |
-
|
58 |
-
|
59 |
-
|
60 |
-
|
61 |
-
|
62 |
-
|
63 |
-
|
64 |
-
|
65 |
-
|
66 |
-
|
67 |
-
|
68 |
-
|
69 |
-
|
70 |
-
|
71 |
-
|
72 |
-
|
73 |
-
|
74 |
-
|
75 |
-
|
76 |
-
|
77 |
-
|
78 |
-
|
79 |
-
|
80 |
-
|
81 |
-
|
82 |
-
|
83 |
-
|
84 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
85 |
});
|
86 |
-
|
87 |
-
test('Create nested workspace', async () => {
|
88 |
-
const workspace = splash.createWorkspace('TestWorkspace');
|
89 |
-
await workspace.expectCurrentWorkspaceIs('TestWorkspace');
|
90 |
-
await workspace.close();
|
91 |
-
});
|
92 |
-
|
93 |
-
test('Delete nested workspace', async () => {
|
94 |
-
await splash.deleteEntry('TestWorkspace');
|
95 |
-
await expect(splash.getEntry('TestWorkspace')).not.toBeVisible();
|
96 |
-
});
|
97 |
-
});
|
|
|
1 |
// Tests some basic operations like box creation, deletion, and dragging.
|
2 |
+
import { expect, test } from "@playwright/test";
|
3 |
+
import { Splash, Workspace } from "./lynxkite";
|
|
|
4 |
|
5 |
test.describe("Directory operations", () => {
|
|
|
6 |
let splash: Splash;
|
7 |
|
8 |
test.beforeAll(async ({ browser }) => {
|
9 |
+
const page = await browser.newPage();
|
10 |
// To make deletion confirmation dialog to be automatically accepted
|
11 |
+
page.on("dialog", async (dialog) => {
|
12 |
+
await dialog.accept();
|
13 |
+
});
|
14 |
splash = await Splash.open(page);
|
15 |
});
|
16 |
|
17 |
+
test("Create workspace with default name", async () => {
|
|
|
18 |
const workspace = await Workspace.empty(splash.page);
|
19 |
// Not checking for exact match, since there may be pre-existing "Untitled" workspaces
|
20 |
+
expect(workspace.name).toContain("Untitled");
|
21 |
await workspace.close();
|
22 |
});
|
23 |
|
24 |
+
test("Create & delete workspace", async () => {
|
25 |
const workspaceName = `TestWorkspace-${Date.now()}`;
|
26 |
const workspace = await Workspace.empty(splash.page, workspaceName);
|
27 |
await workspace.expectCurrentWorkspaceIs(workspaceName);
|
28 |
// Add a box so the workspace is saved
|
29 |
+
await workspace.addBox("Import Parquet");
|
30 |
await workspace.close();
|
31 |
await splash.deleteEntry(workspaceName);
|
32 |
await expect(splash.getEntry(workspaceName)).not.toBeVisible();
|
33 |
});
|
34 |
|
35 |
+
test("Create & delete folder", async () => {
|
36 |
const folderName = `TestFolder-${Date.now()}`;
|
37 |
await splash.createFolder(folderName);
|
38 |
await expect(splash.currentFolder()).toHaveText(folderName);
|
|
|
41 |
await expect(splash.getEntry(folderName)).not.toBeVisible();
|
42 |
});
|
43 |
|
44 |
+
test("Create folder with default name", async () => {
|
45 |
await splash.createFolder();
|
46 |
+
await expect(splash.currentFolder()).toContainText("Untitled");
|
47 |
});
|
48 |
});
|
49 |
|
50 |
+
test.describe
|
51 |
+
.serial("Nested folders & workspaces operations", () => {
|
52 |
+
let splash: Splash;
|
53 |
+
|
54 |
+
test.beforeEach(() => {
|
55 |
+
// Nested navigation doesn't work yet
|
56 |
+
test.skip();
|
57 |
+
});
|
58 |
+
|
59 |
+
test.beforeAll(async ({ browser }) => {
|
60 |
+
const page = await browser.newPage();
|
61 |
+
// To make deletion confirmation dialog to be automatically accepted
|
62 |
+
page.on("dialog", async (dialog) => {
|
63 |
+
await dialog.accept();
|
64 |
+
});
|
65 |
+
splash = await Splash.open(page);
|
66 |
+
await splash.createFolder("TestFolder");
|
67 |
+
});
|
68 |
+
|
69 |
+
test.afterAll(async () => {
|
70 |
+
//cleanup
|
71 |
+
test.skip();
|
72 |
+
await splash.goHome();
|
73 |
+
await splash.deleteEntry("TestFolder");
|
74 |
+
});
|
75 |
+
|
76 |
+
test("Create nested folder", async () => {
|
77 |
+
await splash.createFolder("TestFolder2");
|
78 |
+
await expect(splash.currentFolder()).toHaveText("TestFolder2");
|
79 |
+
await splash.toParent();
|
80 |
+
});
|
81 |
+
|
82 |
+
test("Delete nested folder", async () => {
|
83 |
+
await splash.deleteEntry("TestFolder2");
|
84 |
+
await expect(splash.getEntry("TestFolder2")).not.toBeVisible();
|
85 |
+
});
|
86 |
+
|
87 |
+
test("Create nested workspace", async () => {
|
88 |
+
const workspace = splash.createWorkspace("TestWorkspace");
|
89 |
+
await workspace.expectCurrentWorkspaceIs("TestWorkspace");
|
90 |
+
await workspace.close();
|
91 |
+
});
|
92 |
+
|
93 |
+
test("Delete nested workspace", async () => {
|
94 |
+
await splash.deleteEntry("TestWorkspace");
|
95 |
+
await expect(splash.getEntry("TestWorkspace")).not.toBeVisible();
|
96 |
+
});
|
97 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
lynxkite-app/web/tests/errors.spec.ts
CHANGED
@@ -1,43 +1,43 @@
|
|
1 |
// Tests error reporting.
|
2 |
-
import {
|
3 |
-
import { Splash, Workspace } from
|
4 |
-
|
5 |
|
6 |
let workspace: Workspace;
|
7 |
|
8 |
-
|
9 |
test.beforeEach(async ({ browser }) => {
|
10 |
-
workspace = await Workspace.empty(await browser.newPage(),
|
11 |
});
|
12 |
|
13 |
-
test.afterEach(async (
|
14 |
await workspace.close();
|
15 |
const splash = await new Splash(workspace.page);
|
16 |
-
splash.page.on(
|
17 |
-
|
|
|
|
|
18 |
});
|
19 |
|
20 |
-
|
21 |
-
test('missing parameter', async () => {
|
22 |
// Test the correct error message is displayed when a required parameter is missing,
|
23 |
// and that the error message is removed when the parameter is filled.
|
24 |
-
await workspace.addBox(
|
25 |
-
const graphBox = workspace.getBox(
|
26 |
-
await graphBox.locator(
|
27 |
-
expect(await graphBox.locator(
|
28 |
-
|
29 |
-
|
|
|
|
|
30 |
});
|
31 |
|
32 |
-
|
33 |
-
test('unknown operation', async () => {
|
34 |
// Test that the correct error is displayed when the operation does not belong to
|
35 |
// the current environment.
|
36 |
-
await workspace.addBox(
|
37 |
-
await workspace.setEnv(
|
38 |
-
const csvBox =
|
39 |
-
const errorText = await csvBox.locator(
|
40 |
expect(errorText).toBe('Operation "Create scale-free graph" not found.');
|
41 |
-
await workspace.setEnv(
|
42 |
-
await expect(csvBox.locator(
|
43 |
});
|
|
|
1 |
// Tests error reporting.
|
2 |
+
import { expect, test } from "@playwright/test";
|
3 |
+
import { Splash, Workspace } from "./lynxkite";
|
|
|
4 |
|
5 |
let workspace: Workspace;
|
6 |
|
|
|
7 |
test.beforeEach(async ({ browser }) => {
|
8 |
+
workspace = await Workspace.empty(await browser.newPage(), "error_spec_test");
|
9 |
});
|
10 |
|
11 |
+
test.afterEach(async () => {
|
12 |
await workspace.close();
|
13 |
const splash = await new Splash(workspace.page);
|
14 |
+
splash.page.on("dialog", async (dialog) => {
|
15 |
+
await dialog.accept();
|
16 |
+
});
|
17 |
+
await splash.deleteEntry("error_spec_test");
|
18 |
});
|
19 |
|
20 |
+
test("missing parameter", async () => {
|
|
|
21 |
// Test the correct error message is displayed when a required parameter is missing,
|
22 |
// and that the error message is removed when the parameter is filled.
|
23 |
+
await workspace.addBox("Create scale-free graph");
|
24 |
+
const graphBox = workspace.getBox("Create scale-free graph 1");
|
25 |
+
await graphBox.locator("input").fill("");
|
26 |
+
expect(await graphBox.locator(".error").innerText()).toBe(
|
27 |
+
"invalid literal for int() with base 10: ''",
|
28 |
+
);
|
29 |
+
await graphBox.locator("input").fill("10");
|
30 |
+
await expect(graphBox.locator(".error")).not.toBeVisible();
|
31 |
});
|
32 |
|
33 |
+
test("unknown operation", async () => {
|
|
|
34 |
// Test that the correct error is displayed when the operation does not belong to
|
35 |
// the current environment.
|
36 |
+
await workspace.addBox("Create scale-free graph");
|
37 |
+
await workspace.setEnv("LynxScribe");
|
38 |
+
const csvBox = workspace.getBox("Create scale-free graph 1");
|
39 |
+
const errorText = await csvBox.locator(".error").innerText();
|
40 |
expect(errorText).toBe('Operation "Create scale-free graph" not found.');
|
41 |
+
await workspace.setEnv("LynxKite Graph Analytics");
|
42 |
+
await expect(csvBox.locator(".error")).not.toBeVisible();
|
43 |
});
|
lynxkite-app/web/tests/examples.spec.ts
CHANGED
@@ -1,57 +1,47 @@
|
|
1 |
// Test the execution of the example workspaces
|
2 |
-
import {
|
3 |
-
import { Workspace } from
|
4 |
|
5 |
-
|
6 |
-
|
7 |
-
|
8 |
-
expect(await ws.isErrorFree(process.env.CI? 2000: 1000)).toBeTruthy();
|
9 |
});
|
10 |
|
11 |
-
|
12 |
-
|
13 |
-
|
14 |
-
expect(await ws.isErrorFree()).toBeTruthy();
|
15 |
});
|
16 |
|
17 |
-
|
18 |
-
|
19 |
-
|
20 |
-
|
21 |
-
expect(await ws.isErrorFree()).toBeTruthy();
|
22 |
});
|
23 |
|
24 |
-
test.fail(
|
25 |
-
|
26 |
-
|
27 |
-
|
28 |
});
|
29 |
|
30 |
-
|
31 |
-
|
32 |
-
|
33 |
-
|
34 |
-
expect(await ws.isErrorFree(process.env.CI? 2000: 500)).toBeTruthy();
|
35 |
});
|
36 |
|
37 |
-
|
38 |
-
|
39 |
-
|
40 |
-
|
41 |
-
expect(await ws.isErrorFree()).toBeTruthy();
|
42 |
});
|
43 |
|
44 |
-
|
45 |
-
|
46 |
-
|
47 |
-
// requires cugraph
|
48 |
-
const ws = await Workspace.open(page, "night demo");
|
49 |
-
expect(await ws.isErrorFree(process.env.CI? 10000: 500)).toBeTruthy();
|
50 |
});
|
51 |
|
52 |
-
|
53 |
-
|
54 |
-
|
55 |
-
expect(await ws.isErrorFree()).toBeTruthy();
|
56 |
});
|
57 |
-
|
|
|
1 |
// Test the execution of the example workspaces
|
2 |
+
import { expect, test } from "@playwright/test";
|
3 |
+
import { Workspace } from "./lynxkite";
|
4 |
|
5 |
+
test("LynxKite Graph Analytics example", async ({ page }) => {
|
6 |
+
const ws = await Workspace.open(page, "NetworkX demo");
|
7 |
+
expect(await ws.isErrorFree(process.env.CI ? 2000 : 1000)).toBeTruthy();
|
|
|
8 |
});
|
9 |
|
10 |
+
test("Pytorch example", async ({ page }) => {
|
11 |
+
const ws = await Workspace.open(page, "PyTorch demo");
|
12 |
+
expect(await ws.isErrorFree()).toBeTruthy();
|
|
|
13 |
});
|
14 |
|
15 |
+
test.fail("AIMO example", async ({ page }) => {
|
16 |
+
// Fails because of missing OPENAI_API_KEY
|
17 |
+
const ws = await Workspace.open(page, "AIMO");
|
18 |
+
expect(await ws.isErrorFree()).toBeTruthy();
|
|
|
19 |
});
|
20 |
|
21 |
+
test.fail("LynxScribe example", async ({ page }) => {
|
22 |
+
// Fails because of missing OPENAI_API_KEY
|
23 |
+
const ws = await Workspace.open(page, "LynxScribe demo");
|
24 |
+
expect(await ws.isErrorFree()).toBeTruthy();
|
25 |
});
|
26 |
|
27 |
+
test.fail("Graph RAG", async ({ page }) => {
|
28 |
+
// Fails due to some issue with ChromaDB
|
29 |
+
const ws = await Workspace.open(page, "Graph RAG");
|
30 |
+
expect(await ws.isErrorFree(process.env.CI ? 2000 : 500)).toBeTruthy();
|
|
|
31 |
});
|
32 |
|
33 |
+
test.fail("RAG chatbot app", async ({ page }) => {
|
34 |
+
// Fail due to all operation being unknown
|
35 |
+
const ws = await Workspace.open(page, "RAG chatbot app");
|
36 |
+
expect(await ws.isErrorFree()).toBeTruthy();
|
|
|
37 |
});
|
38 |
|
39 |
+
test("Airlines demo", async ({ page }) => {
|
40 |
+
const ws = await Workspace.open(page, "Airlines demo");
|
41 |
+
expect(await ws.isErrorFree(process.env.CI ? 10000 : 500)).toBeTruthy();
|
|
|
|
|
|
|
42 |
});
|
43 |
|
44 |
+
test("Pillow example", async ({ page }) => {
|
45 |
+
const ws = await Workspace.open(page, "Image processing");
|
46 |
+
expect(await ws.isErrorFree()).toBeTruthy();
|
|
|
47 |
});
|
|
lynxkite-app/web/tests/lynxkite.ts
CHANGED
@@ -1,15 +1,12 @@
|
|
1 |
// Shared testing utilities.
|
2 |
-
import {
|
3 |
-
|
4 |
|
5 |
// Mirrors the "id" filter.
|
6 |
export function toId(x) {
|
7 |
-
return x.toLowerCase().replace(/[ !?,./]/g,
|
8 |
}
|
9 |
|
10 |
-
|
11 |
-
export const ROOT = 'automated-tests';
|
12 |
-
|
13 |
|
14 |
export class Workspace {
|
15 |
readonly page: Page;
|
@@ -31,12 +28,12 @@ export class Workspace {
|
|
31 |
const ws = await splash.openWorkspace(workspaceName);
|
32 |
await ws.waitForNodesToLoad();
|
33 |
await ws.expectCurrentWorkspaceIs(workspaceName);
|
34 |
-
return ws
|
35 |
}
|
36 |
|
37 |
async getEnvs() {
|
38 |
// Return all available workspace environments
|
39 |
-
const envs =
|
40 |
await expect(envs).not.toHaveCount(0);
|
41 |
return await envs.allInnerTexts();
|
42 |
}
|
@@ -46,13 +43,13 @@ export class Workspace {
|
|
46 |
}
|
47 |
|
48 |
async expectCurrentWorkspaceIs(name) {
|
49 |
-
await expect(this.page.locator(
|
50 |
}
|
51 |
|
52 |
async waitForNodesToLoad() {
|
53 |
// This method should be used only on non empty workspaces
|
54 |
-
await this.page.locator(
|
55 |
-
await this.page.locator(
|
56 |
}
|
57 |
|
58 |
async addBox(boxName) {
|
@@ -63,28 +60,32 @@ export class Workspace {
|
|
63 |
const numNodes = allBoxes.length;
|
64 |
await this.page.mouse.wheel(0, numNodes * 500);
|
65 |
}
|
66 |
-
|
67 |
// Some x,y offset, otherwise the box handle may fall outside the viewport.
|
68 |
-
await this.page
|
69 |
-
|
70 |
-
|
|
|
|
|
71 |
// Workaround to wait for the deselection animation after choosing a box. Otherwise, the next box will not be added.
|
72 |
-
await new Promise(resolve => setTimeout(resolve, 200));
|
73 |
}
|
74 |
|
75 |
async getCatalog() {
|
76 |
-
await this.page.locator(
|
77 |
-
const catalog =
|
|
|
|
|
78 |
// Dismiss the catalog menu
|
79 |
-
await this.page.keyboard.press(
|
80 |
-
await new Promise(resolve => setTimeout(resolve, 200));
|
81 |
-
return catalog
|
82 |
}
|
83 |
-
|
84 |
async deleteBoxes(boxIds: string[]) {
|
85 |
for (const boxId of boxIds) {
|
86 |
-
|
87 |
-
|
88 |
}
|
89 |
}
|
90 |
|
@@ -93,36 +94,44 @@ export class Workspace {
|
|
93 |
}
|
94 |
|
95 |
getBoxes() {
|
96 |
-
return this.page.locator(
|
97 |
}
|
98 |
|
99 |
getBoxHandle(boxId: string) {
|
100 |
return this.page.getByTestId(boxId);
|
101 |
}
|
102 |
|
103 |
-
async moveBox(
|
|
|
|
|
|
|
|
|
104 |
// Move a box around, it is a best effort operation, the exact target position may not be reached
|
105 |
const box = await this.getBox(boxId).boundingBox();
|
106 |
-
if (!box) {
|
107 |
-
return
|
108 |
}
|
109 |
const boxCenterX = box.x + box.width / 2;
|
110 |
const boxCenterY = box.y + box.height / 2;
|
111 |
-
await this.page.mouse.move(boxCenterX,boxCenterY);
|
112 |
await this.page.mouse.down();
|
113 |
if (targetPosition) {
|
114 |
await this.page.mouse.move(targetPosition.x, targetPosition.y);
|
115 |
} else if (offset) {
|
116 |
// Without steps the movement is too fast and the box is not dragged. The more steps,
|
117 |
// the better the movement is captured
|
118 |
-
await this.page.mouse.move(
|
|
|
|
|
|
|
|
|
119 |
}
|
120 |
await this.page.mouse.up();
|
121 |
}
|
122 |
|
123 |
async connectBoxes(sourceId: string, targetId: string) {
|
124 |
-
const sourceHandle = this.getBoxHandle(sourceId)
|
125 |
-
const targetHandle = this.getBoxHandle(targetId)
|
126 |
await sourceHandle.hover();
|
127 |
await this.page.mouse.down();
|
128 |
await targetHandle.hover();
|
@@ -132,34 +141,35 @@ export class Workspace {
|
|
132 |
async isErrorFree(executionWaitTime?): Promise<boolean> {
|
133 |
// TODO: Workaround, to account for workspace execution. Once
|
134 |
// we have a load indicator we can use that instead.
|
135 |
-
await new Promise(resolve =>
|
|
|
|
|
136 |
const boxes = await this.getBoxes();
|
137 |
for (const box of boxes) {
|
138 |
-
if (await box.locator(
|
139 |
return false;
|
140 |
}
|
141 |
}
|
142 |
return true;
|
143 |
}
|
144 |
|
145 |
-
async close() {
|
146 |
await this.page.locator('a[href="/dir/"]').click();
|
147 |
}
|
148 |
}
|
149 |
|
150 |
-
|
151 |
export class Splash {
|
152 |
page: Page;
|
153 |
root: Locator;
|
154 |
-
|
155 |
constructor(page) {
|
156 |
this.page = page;
|
157 |
-
this.root = page.locator(
|
158 |
}
|
159 |
|
160 |
// Opens the LynxKite directory browser in the root.
|
161 |
static async open(page: Page): Promise<Splash> {
|
162 |
-
await page.goto(
|
163 |
await page.evaluate(() => {
|
164 |
window.sessionStorage.clear();
|
165 |
window.localStorage.clear();
|
@@ -170,26 +180,28 @@ export class Splash {
|
|
170 |
}
|
171 |
|
172 |
workspace(name: string) {
|
173 |
-
return this.page.getByRole(
|
174 |
}
|
175 |
|
176 |
getEntry(name: string) {
|
177 |
-
return this.page.locator(
|
178 |
}
|
179 |
|
180 |
async createWorkspace(name?: string) {
|
181 |
-
await this.page.getByRole(
|
182 |
await this.page.locator('input[name="workspaceName"]').click();
|
183 |
let workspaceName: string;
|
184 |
if (name) {
|
185 |
workspaceName = name;
|
186 |
await this.page.locator('input[name="workspaceName"]').fill(name);
|
187 |
} else {
|
188 |
-
workspaceName = await this.page
|
|
|
|
|
189 |
}
|
190 |
-
await this.page.locator('input[name="workspaceName"]').press(
|
191 |
const ws = new Workspace(this.page, workspaceName);
|
192 |
-
await ws.setEnv(
|
193 |
return ws;
|
194 |
}
|
195 |
|
@@ -199,25 +211,24 @@ export class Splash {
|
|
199 |
}
|
200 |
|
201 |
async createFolder(folderName?: string) {
|
202 |
-
await this.page.getByRole(
|
203 |
await this.page.locator('input[name="folderName"]').click();
|
204 |
if (folderName) {
|
205 |
await this.page.locator('input[name="folderName"]').fill(folderName);
|
206 |
}
|
207 |
-
await this.page.locator('input[name="folderName"]').press(
|
208 |
}
|
209 |
|
210 |
async deleteEntry(entryName: string) {
|
211 |
-
await this.getEntry(entryName).locator(
|
212 |
await this.page.reload();
|
213 |
}
|
214 |
|
215 |
currentFolder() {
|
216 |
-
return this.page.locator(
|
217 |
}
|
218 |
|
219 |
async goHome() {
|
220 |
await this.page.locator('a[href="/dir/"]').click();
|
221 |
}
|
222 |
-
|
223 |
}
|
|
|
1 |
// Shared testing utilities.
|
2 |
+
import { type Locator, type Page, expect } from "@playwright/test";
|
|
|
3 |
|
4 |
// Mirrors the "id" filter.
|
5 |
export function toId(x) {
|
6 |
+
return x.toLowerCase().replace(/[ !?,./]/g, "-");
|
7 |
}
|
8 |
|
9 |
+
export const ROOT = "automated-tests";
|
|
|
|
|
10 |
|
11 |
export class Workspace {
|
12 |
readonly page: Page;
|
|
|
28 |
const ws = await splash.openWorkspace(workspaceName);
|
29 |
await ws.waitForNodesToLoad();
|
30 |
await ws.expectCurrentWorkspaceIs(workspaceName);
|
31 |
+
return ws;
|
32 |
}
|
33 |
|
34 |
async getEnvs() {
|
35 |
// Return all available workspace environments
|
36 |
+
const envs = this.page.locator('select[name="workspace-env"] option');
|
37 |
await expect(envs).not.toHaveCount(0);
|
38 |
return await envs.allInnerTexts();
|
39 |
}
|
|
|
43 |
}
|
44 |
|
45 |
async expectCurrentWorkspaceIs(name) {
|
46 |
+
await expect(this.page.locator(".ws-name")).toHaveText(name);
|
47 |
}
|
48 |
|
49 |
async waitForNodesToLoad() {
|
50 |
// This method should be used only on non empty workspaces
|
51 |
+
await this.page.locator(".react-flow__nodes").waitFor();
|
52 |
+
await this.page.locator(".react-flow__node").first().waitFor();
|
53 |
}
|
54 |
|
55 |
async addBox(boxName) {
|
|
|
60 |
const numNodes = allBoxes.length;
|
61 |
await this.page.mouse.wheel(0, numNodes * 500);
|
62 |
}
|
63 |
+
|
64 |
// Some x,y offset, otherwise the box handle may fall outside the viewport.
|
65 |
+
await this.page
|
66 |
+
.locator(".react-flow__pane")
|
67 |
+
.click({ position: { x: 20, y: 20 } });
|
68 |
+
await this.page.locator(".node-search").getByText(boxName).click();
|
69 |
+
await this.page.keyboard.press("Escape");
|
70 |
// Workaround to wait for the deselection animation after choosing a box. Otherwise, the next box will not be added.
|
71 |
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
72 |
}
|
73 |
|
74 |
async getCatalog() {
|
75 |
+
await this.page.locator(".react-flow__pane").click();
|
76 |
+
const catalog = await this.page
|
77 |
+
.locator(".node-search .matches .search-result")
|
78 |
+
.allInnerTexts();
|
79 |
// Dismiss the catalog menu
|
80 |
+
await this.page.keyboard.press("Escape");
|
81 |
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
82 |
+
return catalog;
|
83 |
}
|
84 |
+
|
85 |
async deleteBoxes(boxIds: string[]) {
|
86 |
for (const boxId of boxIds) {
|
87 |
+
await this.getBoxHandle(boxId).first().click();
|
88 |
+
await this.page.keyboard.press("Backspace");
|
89 |
}
|
90 |
}
|
91 |
|
|
|
94 |
}
|
95 |
|
96 |
getBoxes() {
|
97 |
+
return this.page.locator(".react-flow__node").all();
|
98 |
}
|
99 |
|
100 |
getBoxHandle(boxId: string) {
|
101 |
return this.page.getByTestId(boxId);
|
102 |
}
|
103 |
|
104 |
+
async moveBox(
|
105 |
+
boxId: string,
|
106 |
+
offset?: { offsetX: number; offsetY: number },
|
107 |
+
targetPosition?: { x: number; y: number },
|
108 |
+
) {
|
109 |
// Move a box around, it is a best effort operation, the exact target position may not be reached
|
110 |
const box = await this.getBox(boxId).boundingBox();
|
111 |
+
if (!box) {
|
112 |
+
return;
|
113 |
}
|
114 |
const boxCenterX = box.x + box.width / 2;
|
115 |
const boxCenterY = box.y + box.height / 2;
|
116 |
+
await this.page.mouse.move(boxCenterX, boxCenterY);
|
117 |
await this.page.mouse.down();
|
118 |
if (targetPosition) {
|
119 |
await this.page.mouse.move(targetPosition.x, targetPosition.y);
|
120 |
} else if (offset) {
|
121 |
// Without steps the movement is too fast and the box is not dragged. The more steps,
|
122 |
// the better the movement is captured
|
123 |
+
await this.page.mouse.move(
|
124 |
+
boxCenterX + offset.offsetX,
|
125 |
+
boxCenterY + offset.offsetY,
|
126 |
+
{ steps: 5 },
|
127 |
+
);
|
128 |
}
|
129 |
await this.page.mouse.up();
|
130 |
}
|
131 |
|
132 |
async connectBoxes(sourceId: string, targetId: string) {
|
133 |
+
const sourceHandle = this.getBoxHandle(sourceId);
|
134 |
+
const targetHandle = this.getBoxHandle(targetId);
|
135 |
await sourceHandle.hover();
|
136 |
await this.page.mouse.down();
|
137 |
await targetHandle.hover();
|
|
|
141 |
async isErrorFree(executionWaitTime?): Promise<boolean> {
|
142 |
// TODO: Workaround, to account for workspace execution. Once
|
143 |
// we have a load indicator we can use that instead.
|
144 |
+
await new Promise((resolve) =>
|
145 |
+
setTimeout(resolve, executionWaitTime ? executionWaitTime : 500),
|
146 |
+
);
|
147 |
const boxes = await this.getBoxes();
|
148 |
for (const box of boxes) {
|
149 |
+
if (await box.locator(".error").isVisible()) {
|
150 |
return false;
|
151 |
}
|
152 |
}
|
153 |
return true;
|
154 |
}
|
155 |
|
156 |
+
async close() {
|
157 |
await this.page.locator('a[href="/dir/"]').click();
|
158 |
}
|
159 |
}
|
160 |
|
|
|
161 |
export class Splash {
|
162 |
page: Page;
|
163 |
root: Locator;
|
164 |
+
|
165 |
constructor(page) {
|
166 |
this.page = page;
|
167 |
+
this.root = page.locator("#splash");
|
168 |
}
|
169 |
|
170 |
// Opens the LynxKite directory browser in the root.
|
171 |
static async open(page: Page): Promise<Splash> {
|
172 |
+
await page.goto("/");
|
173 |
await page.evaluate(() => {
|
174 |
window.sessionStorage.clear();
|
175 |
window.localStorage.clear();
|
|
|
180 |
}
|
181 |
|
182 |
workspace(name: string) {
|
183 |
+
return this.page.getByRole("link", { name: name });
|
184 |
}
|
185 |
|
186 |
getEntry(name: string) {
|
187 |
+
return this.page.locator(".entry").filter({ hasText: name }).first();
|
188 |
}
|
189 |
|
190 |
async createWorkspace(name?: string) {
|
191 |
+
await this.page.getByRole("button", { name: "New workspace" }).click();
|
192 |
await this.page.locator('input[name="workspaceName"]').click();
|
193 |
let workspaceName: string;
|
194 |
if (name) {
|
195 |
workspaceName = name;
|
196 |
await this.page.locator('input[name="workspaceName"]').fill(name);
|
197 |
} else {
|
198 |
+
workspaceName = await this.page
|
199 |
+
.locator('input[name="workspaceName"]')
|
200 |
+
.inputValue();
|
201 |
}
|
202 |
+
await this.page.locator('input[name="workspaceName"]').press("Enter");
|
203 |
const ws = new Workspace(this.page, workspaceName);
|
204 |
+
await ws.setEnv("LynxKite Graph Analytics");
|
205 |
return ws;
|
206 |
}
|
207 |
|
|
|
211 |
}
|
212 |
|
213 |
async createFolder(folderName?: string) {
|
214 |
+
await this.page.getByRole("button", { name: "New folder" }).click();
|
215 |
await this.page.locator('input[name="folderName"]').click();
|
216 |
if (folderName) {
|
217 |
await this.page.locator('input[name="folderName"]').fill(folderName);
|
218 |
}
|
219 |
+
await this.page.locator('input[name="folderName"]').press("Enter");
|
220 |
}
|
221 |
|
222 |
async deleteEntry(entryName: string) {
|
223 |
+
await this.getEntry(entryName).locator("button").click();
|
224 |
await this.page.reload();
|
225 |
}
|
226 |
|
227 |
currentFolder() {
|
228 |
+
return this.page.locator(".current-folder");
|
229 |
}
|
230 |
|
231 |
async goHome() {
|
232 |
await this.page.locator('a[href="/dir/"]').click();
|
233 |
}
|
|
|
234 |
}
|
lynxkite-app/web/tests/upload.spec.ts
CHANGED
@@ -1,41 +1,43 @@
|
|
|
|
|
|
1 |
// Test uploading a file in an import box.
|
2 |
-
import {
|
3 |
-
import { Splash, Workspace } from
|
4 |
-
import { join, dirname } from 'path';
|
5 |
-
import { fileURLToPath } from 'url';
|
6 |
-
|
7 |
|
8 |
let workspace: Workspace;
|
9 |
|
10 |
-
|
11 |
test.beforeEach(async ({ browser }) => {
|
12 |
-
workspace = await Workspace.empty(
|
|
|
|
|
|
|
13 |
});
|
14 |
|
15 |
-
test.afterEach(async (
|
16 |
await workspace.close();
|
17 |
const splash = await new Splash(workspace.page);
|
18 |
-
splash.page.on(
|
19 |
-
|
|
|
|
|
20 |
});
|
21 |
|
|
|
|
|
|
|
|
|
22 |
|
23 |
-
|
24 |
-
|
25 |
-
|
26 |
-
|
|
|
|
|
27 |
|
28 |
-
|
29 |
-
|
30 |
-
|
31 |
-
await filenameInput.click();
|
32 |
-
await filenameInput.fill(csvPath);
|
33 |
-
await filenameInput.press('Enter');
|
34 |
-
|
35 |
-
await workspace.addBox('View tables');
|
36 |
-
const tableBox = workspace.getBox('View tables 1');
|
37 |
-
await workspace.connectBoxes('Import CSV 1', 'View tables 1');
|
38 |
|
39 |
-
|
40 |
-
|
41 |
});
|
|
|
1 |
+
import { dirname, join } from "node:path";
|
2 |
+
import { fileURLToPath } from "node:url";
|
3 |
// Test uploading a file in an import box.
|
4 |
+
import { expect, test } from "@playwright/test";
|
5 |
+
import { Splash, Workspace } from "./lynxkite";
|
|
|
|
|
|
|
6 |
|
7 |
let workspace: Workspace;
|
8 |
|
|
|
9 |
test.beforeEach(async ({ browser }) => {
|
10 |
+
workspace = await Workspace.empty(
|
11 |
+
await browser.newPage(),
|
12 |
+
"upload_spec_test",
|
13 |
+
);
|
14 |
});
|
15 |
|
16 |
+
test.afterEach(async () => {
|
17 |
await workspace.close();
|
18 |
const splash = await new Splash(workspace.page);
|
19 |
+
splash.page.on("dialog", async (dialog) => {
|
20 |
+
await dialog.accept();
|
21 |
+
});
|
22 |
+
await splash.deleteEntry("upload_spec_test");
|
23 |
});
|
24 |
|
25 |
+
test("can upload and import a simple CSV", async () => {
|
26 |
+
const __filename = fileURLToPath(import.meta.url);
|
27 |
+
const __dirname = dirname(__filename);
|
28 |
+
const csvPath = join(__dirname, "data", "upload_test.csv");
|
29 |
|
30 |
+
await workspace.addBox("Import CSV");
|
31 |
+
const csvBox = workspace.getBox("Import CSV 1");
|
32 |
+
const filenameInput = csvBox.locator("input.input-bordered").nth(0);
|
33 |
+
await filenameInput.click();
|
34 |
+
await filenameInput.fill(csvPath);
|
35 |
+
await filenameInput.press("Enter");
|
36 |
|
37 |
+
await workspace.addBox("View tables");
|
38 |
+
const tableBox = workspace.getBox("View tables 1");
|
39 |
+
await workspace.connectBoxes("Import CSV 1", "View tables 1");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
40 |
|
41 |
+
const tableRows = tableBox.locator("table tbody tr");
|
42 |
+
await expect(tableRows).toHaveCount(4);
|
43 |
});
|
lynxkite-app/web/tsconfig.app.json
CHANGED
@@ -6,16 +6,12 @@
|
|
6 |
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
7 |
"module": "ESNext",
|
8 |
"skipLibCheck": true,
|
9 |
-
|
10 |
-
/* Bundler mode */
|
11 |
"moduleResolution": "bundler",
|
12 |
"allowImportingTsExtensions": true,
|
13 |
"isolatedModules": true,
|
14 |
"moduleDetection": "force",
|
15 |
"noEmit": true,
|
16 |
"jsx": "react-jsx",
|
17 |
-
|
18 |
-
/* Linting */
|
19 |
"strict": true,
|
20 |
"noUnusedLocals": true,
|
21 |
"noUnusedParameters": true,
|
|
|
6 |
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
7 |
"module": "ESNext",
|
8 |
"skipLibCheck": true,
|
|
|
|
|
9 |
"moduleResolution": "bundler",
|
10 |
"allowImportingTsExtensions": true,
|
11 |
"isolatedModules": true,
|
12 |
"moduleDetection": "force",
|
13 |
"noEmit": true,
|
14 |
"jsx": "react-jsx",
|
|
|
|
|
15 |
"strict": true,
|
16 |
"noUnusedLocals": true,
|
17 |
"noUnusedParameters": true,
|
lynxkite-app/web/tsconfig.node.json
CHANGED
@@ -5,15 +5,11 @@
|
|
5 |
"lib": ["ES2023"],
|
6 |
"module": "ESNext",
|
7 |
"skipLibCheck": true,
|
8 |
-
|
9 |
-
/* Bundler mode */
|
10 |
"moduleResolution": "bundler",
|
11 |
"allowImportingTsExtensions": true,
|
12 |
"isolatedModules": true,
|
13 |
"moduleDetection": "force",
|
14 |
"noEmit": true,
|
15 |
-
|
16 |
-
/* Linting */
|
17 |
"strict": true,
|
18 |
"noUnusedLocals": true,
|
19 |
"noUnusedParameters": true,
|
|
|
5 |
"lib": ["ES2023"],
|
6 |
"module": "ESNext",
|
7 |
"skipLibCheck": true,
|
|
|
|
|
8 |
"moduleResolution": "bundler",
|
9 |
"allowImportingTsExtensions": true,
|
10 |
"isolatedModules": true,
|
11 |
"moduleDetection": "force",
|
12 |
"noEmit": true,
|
|
|
|
|
13 |
"strict": true,
|
14 |
"noUnusedLocals": true,
|
15 |
"noUnusedParameters": true,
|
lynxkite-app/web/vite.config.ts
CHANGED
@@ -1,6 +1,6 @@
|
|
1 |
-
import
|
2 |
-
import
|
3 |
-
import
|
4 |
|
5 |
// https://vite.dev/config/
|
6 |
export default defineConfig({
|
@@ -10,21 +10,18 @@ export default defineConfig({
|
|
10 |
esbuild: {
|
11 |
supported: {
|
12 |
// For dynamic imports.
|
13 |
-
|
14 |
},
|
15 |
},
|
16 |
-
plugins: [
|
17 |
-
react(),
|
18 |
-
Icons({ compiler: 'jsx', jsx: 'react' }),
|
19 |
-
],
|
20 |
server: {
|
21 |
proxy: {
|
22 |
-
|
23 |
-
|
24 |
-
target:
|
25 |
ws: true,
|
26 |
changeOrigin: true,
|
27 |
},
|
28 |
},
|
29 |
},
|
30 |
-
})
|
|
|
1 |
+
import react from "@vitejs/plugin-react-swc";
|
2 |
+
import Icons from "unplugin-icons/vite";
|
3 |
+
import { defineConfig } from "vite";
|
4 |
|
5 |
// https://vite.dev/config/
|
6 |
export default defineConfig({
|
|
|
10 |
esbuild: {
|
11 |
supported: {
|
12 |
// For dynamic imports.
|
13 |
+
"top-level-await": true,
|
14 |
},
|
15 |
},
|
16 |
+
plugins: [react(), Icons({ compiler: "jsx", jsx: "react" })],
|
|
|
|
|
|
|
17 |
server: {
|
18 |
proxy: {
|
19 |
+
"/api": "http://127.0.0.1:8000",
|
20 |
+
"/ws": {
|
21 |
+
target: "ws://127.0.0.1:8000",
|
22 |
ws: true,
|
23 |
changeOrigin: true,
|
24 |
},
|
25 |
},
|
26 |
},
|
27 |
+
});
|
lynxkite-core/src/lynxkite/core/workspace.py
CHANGED
@@ -65,7 +65,7 @@ async def execute(ws: Workspace):
|
|
65 |
|
66 |
def save(ws: Workspace, path: str):
|
67 |
"""Persist a workspace to a local file in JSON format."""
|
68 |
-
j = ws.model_dump_json(indent=2)
|
69 |
dirname, basename = os.path.split(path)
|
70 |
os.makedirs(dirname, exist_ok=True)
|
71 |
# Create temp file in the same directory to make sure it's on the same filesystem.
|
|
|
65 |
|
66 |
def save(ws: Workspace, path: str):
|
67 |
"""Persist a workspace to a local file in JSON format."""
|
68 |
+
j = ws.model_dump_json(indent=2) + "\n"
|
69 |
dirname, basename = os.path.split(path)
|
70 |
os.makedirs(dirname, exist_ok=True)
|
71 |
# Create temp file in the same directory to make sure it's on the same filesystem.
|
lynxkite-graph-analytics/pyproject.toml
CHANGED
@@ -5,6 +5,7 @@ description = "The graph analytics executor and boxes for LynxKite"
|
|
5 |
readme = "README.md"
|
6 |
requires-python = ">=3.11"
|
7 |
dependencies = [
|
|
|
8 |
"grand-cypher>=0.12.0",
|
9 |
"joblib>=1.4.2",
|
10 |
"lynxkite-core",
|
|
|
5 |
readme = "README.md"
|
6 |
requires-python = ">=3.11"
|
7 |
dependencies = [
|
8 |
+
"fsspec>=2025.2.0",
|
9 |
"grand-cypher>=0.12.0",
|
10 |
"joblib>=1.4.2",
|
11 |
"lynxkite-core",
|
lynxkite-graph-analytics/src/lynxkite_graph_analytics/lynxkite_ops.py
CHANGED
@@ -1,7 +1,8 @@
|
|
1 |
"""Graph analytics operations. To be split into separate files when we have more."""
|
2 |
|
3 |
import os
|
4 |
-
|
|
|
5 |
from collections import deque
|
6 |
import dataclasses
|
7 |
import functools
|
@@ -13,7 +14,6 @@ import pandas as pd
|
|
13 |
import polars as pl
|
14 |
import traceback
|
15 |
import typing
|
16 |
-
import zipfile
|
17 |
|
18 |
mem = joblib.Memory("../joblib-cache")
|
19 |
ENV = "LynxKite Graph Analytics"
|
@@ -178,6 +178,7 @@ def import_parquet(*, filename: str):
|
|
178 |
return pd.read_parquet(filename)
|
179 |
|
180 |
|
|
|
181 |
@op("Import CSV")
|
182 |
def import_csv(
|
183 |
*, filename: str, columns: str = "<from file>", separator: str = "<auto>"
|
@@ -192,26 +193,20 @@ def import_csv(
|
|
192 |
)
|
193 |
|
194 |
|
195 |
-
@op("Import GraphML")
|
196 |
@mem.cache
|
|
|
197 |
def import_graphml(*, filename: str):
|
198 |
"""Imports a GraphML file."""
|
199 |
-
|
200 |
-
|
201 |
-
|
202 |
-
|
203 |
-
|
204 |
-
|
205 |
-
break
|
206 |
-
else:
|
207 |
-
raise ValueError("No GraphML file found in the ZIP archive.")
|
208 |
-
else:
|
209 |
-
G = nx.read_graphml(filename)
|
210 |
-
return G
|
211 |
|
212 |
|
213 |
-
@op("Graph from OSM")
|
214 |
@mem.cache
|
|
|
215 |
def import_osm(*, location: str):
|
216 |
import osmnx as ox
|
217 |
|
@@ -234,7 +229,7 @@ def compute_pagerank(graph: nx.Graph, *, damping=0.85, iterations=100):
|
|
234 |
@op("Compute betweenness centrality")
|
235 |
@nx_node_attribute_func("betweenness_centrality")
|
236 |
def compute_betweenness_centrality(graph: nx.Graph, *, k=10):
|
237 |
-
return nx.betweenness_centrality(graph, k=k
|
238 |
|
239 |
|
240 |
@op("Discard loop edges")
|
|
|
1 |
"""Graph analytics operations. To be split into separate files when we have more."""
|
2 |
|
3 |
import os
|
4 |
+
import fsspec
|
5 |
+
from lynxkite.core import ops
|
6 |
from collections import deque
|
7 |
import dataclasses
|
8 |
import functools
|
|
|
14 |
import polars as pl
|
15 |
import traceback
|
16 |
import typing
|
|
|
17 |
|
18 |
mem = joblib.Memory("../joblib-cache")
|
19 |
ENV = "LynxKite Graph Analytics"
|
|
|
178 |
return pd.read_parquet(filename)
|
179 |
|
180 |
|
181 |
+
@mem.cache
|
182 |
@op("Import CSV")
|
183 |
def import_csv(
|
184 |
*, filename: str, columns: str = "<from file>", separator: str = "<auto>"
|
|
|
193 |
)
|
194 |
|
195 |
|
|
|
196 |
@mem.cache
|
197 |
+
@op("Import GraphML")
|
198 |
def import_graphml(*, filename: str):
|
199 |
"""Imports a GraphML file."""
|
200 |
+
files = fsspec.open_files(filename, compression="infer")
|
201 |
+
for f in files:
|
202 |
+
if ".graphml" in f.path:
|
203 |
+
with f as f:
|
204 |
+
return nx.read_graphml(f)
|
205 |
+
raise ValueError(f"No .graphml file found at {filename}")
|
|
|
|
|
|
|
|
|
|
|
|
|
206 |
|
207 |
|
|
|
208 |
@mem.cache
|
209 |
+
@op("Graph from OSM")
|
210 |
def import_osm(*, location: str):
|
211 |
import osmnx as ox
|
212 |
|
|
|
229 |
@op("Compute betweenness centrality")
|
230 |
@nx_node_attribute_func("betweenness_centrality")
|
231 |
def compute_betweenness_centrality(graph: nx.Graph, *, k=10):
|
232 |
+
return nx.betweenness_centrality(graph, k=k)
|
233 |
|
234 |
|
235 |
@op("Discard loop edges")
|
lynxkite-graph-analytics/uv.lock
CHANGED
@@ -66,6 +66,15 @@ wheels = [
|
|
66 |
{ url = "https://files.pythonhosted.org/packages/0e/f6/65ecc6878a89bb1c23a086ea335ad4bf21a588990c3f535a227b9eea9108/charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85", size = 49767 },
|
67 |
]
|
68 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
69 |
[[package]]
|
70 |
name = "contourpy"
|
71 |
version = "1.3.1"
|
@@ -229,6 +238,15 @@ wheels = [
|
|
229 |
{ url = "https://files.pythonhosted.org/packages/99/3b/406d17b1f63e04a82aa621936e6e1c53a8c05458abd66300ac85ea7f9ae9/fonttools-4.55.3-py3-none-any.whl", hash = "sha256:f412604ccbeee81b091b420272841e5ec5ef68967a9790e80bffd0e30b8e2977", size = 1111638 },
|
230 |
]
|
231 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
232 |
[[package]]
|
233 |
name = "geopandas"
|
234 |
version = "1.0.1"
|
@@ -275,6 +293,15 @@ wheels = [
|
|
275 |
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 },
|
276 |
]
|
277 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
278 |
[[package]]
|
279 |
name = "joblib"
|
280 |
version = "1.4.2"
|
@@ -404,37 +431,48 @@ name = "lynxkite-core"
|
|
404 |
version = "0.1.0"
|
405 |
source = { virtual = "../lynxkite-core" }
|
406 |
|
|
|
|
|
|
|
407 |
[[package]]
|
408 |
name = "lynxkite-graph-analytics"
|
409 |
version = "0.1.0"
|
410 |
source = { virtual = "." }
|
411 |
dependencies = [
|
|
|
412 |
{ name = "grand-cypher" },
|
413 |
{ name = "joblib" },
|
414 |
{ name = "lynxkite-core" },
|
415 |
{ name = "matplotlib" },
|
416 |
-
{ name = "networkx" },
|
417 |
{ name = "osmnx" },
|
418 |
{ name = "pandas" },
|
419 |
{ name = "polars", extra = ["gpu"] },
|
420 |
]
|
421 |
|
422 |
[package.optional-dependencies]
|
|
|
|
|
|
|
|
|
423 |
gpu = [
|
424 |
{ name = "nx-cugraph-cu12" },
|
425 |
]
|
426 |
|
427 |
[package.metadata]
|
428 |
requires-dist = [
|
|
|
429 |
{ name = "grand-cypher", specifier = ">=0.12.0" },
|
430 |
{ name = "joblib", specifier = ">=1.4.2" },
|
431 |
{ name = "lynxkite-core", virtual = "../lynxkite-core" },
|
432 |
{ name = "matplotlib", specifier = ">=3.10.0" },
|
433 |
-
{ name = "networkx", specifier = ">=3.4.2" },
|
434 |
{ name = "nx-cugraph-cu12", marker = "extra == 'gpu'", specifier = ">=24.12.0" },
|
435 |
{ name = "osmnx", specifier = ">=2.0.1" },
|
436 |
{ name = "pandas", specifier = ">=2.2.3" },
|
437 |
{ name = "polars", extras = ["gpu"], specifier = ">=1.14.0" },
|
|
|
|
|
438 |
]
|
439 |
|
440 |
[[package]]
|
@@ -489,6 +527,14 @@ wheels = [
|
|
489 |
{ url = "https://files.pythonhosted.org/packages/b9/54/dd730b32ea14ea797530a4479b2ed46a6fb250f682a9cfb997e968bf0261/networkx-3.4.2-py3-none-any.whl", hash = "sha256:df5d4365b724cf81b8c6a7312509d0c22386097011ad1abe274afd5e9d3bbc5f", size = 1723263 },
|
490 |
]
|
491 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
492 |
[[package]]
|
493 |
name = "numba"
|
494 |
version = "0.60.0"
|
@@ -747,6 +793,15 @@ wheels = [
|
|
747 |
{ url = "https://files.pythonhosted.org/packages/cf/6c/41c21c6c8af92b9fea313aa47c75de49e2f9a467964ee33eb0135d47eb64/pillow-11.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:67cd427c68926108778a9005f2a04adbd5e67c442ed21d95389fe1d595458756", size = 2377651 },
|
748 |
]
|
749 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
750 |
[[package]]
|
751 |
name = "polars"
|
752 |
version = "1.14.0"
|
@@ -918,6 +973,33 @@ wheels = [
|
|
918 |
{ url = "https://files.pythonhosted.org/packages/f8/33/3c8c6302717096b54aa14ccbb271045ba04629e21cbf348f2f2dc94f69b4/pyproj-3.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:10a8dc6ec61af97c89ff032647d743f8dc023645773da42ef43f7ae1125b3509", size = 6218036 },
|
919 |
]
|
920 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
921 |
[[package]]
|
922 |
name = "python-dateutil"
|
923 |
version = "2.9.0.post0"
|
@@ -970,6 +1052,53 @@ wheels = [
|
|
970 |
{ url = "https://files.pythonhosted.org/packages/1d/f2/56faa578aefdab498f6eb73dde3316f99390769786e0cdbb6c7a6abbbf86/rmm_cu12-24.12.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9460a386e34f1921c8d06204f320d705511de899ababb45302d314da036da5a", size = 1975053 },
|
971 |
]
|
972 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
973 |
[[package]]
|
974 |
name = "shapely"
|
975 |
version = "2.0.7"
|
|
|
66 |
{ url = "https://files.pythonhosted.org/packages/0e/f6/65ecc6878a89bb1c23a086ea335ad4bf21a588990c3f535a227b9eea9108/charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85", size = 49767 },
|
67 |
]
|
68 |
|
69 |
+
[[package]]
|
70 |
+
name = "colorama"
|
71 |
+
version = "0.4.6"
|
72 |
+
source = { registry = "https://pypi.org/simple" }
|
73 |
+
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 }
|
74 |
+
wheels = [
|
75 |
+
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
|
76 |
+
]
|
77 |
+
|
78 |
[[package]]
|
79 |
name = "contourpy"
|
80 |
version = "1.3.1"
|
|
|
238 |
{ url = "https://files.pythonhosted.org/packages/99/3b/406d17b1f63e04a82aa621936e6e1c53a8c05458abd66300ac85ea7f9ae9/fonttools-4.55.3-py3-none-any.whl", hash = "sha256:f412604ccbeee81b091b420272841e5ec5ef68967a9790e80bffd0e30b8e2977", size = 1111638 },
|
239 |
]
|
240 |
|
241 |
+
[[package]]
|
242 |
+
name = "fsspec"
|
243 |
+
version = "2025.2.0"
|
244 |
+
source = { registry = "https://pypi.org/simple" }
|
245 |
+
sdist = { url = "https://files.pythonhosted.org/packages/b5/79/68612ed99700e6413de42895aa725463e821a6b3be75c87fcce1b4af4c70/fsspec-2025.2.0.tar.gz", hash = "sha256:1c24b16eaa0a1798afa0337aa0db9b256718ab2a89c425371f5628d22c3b6afd", size = 292283 }
|
246 |
+
wheels = [
|
247 |
+
{ url = "https://files.pythonhosted.org/packages/e2/94/758680531a00d06e471ef649e4ec2ed6bf185356a7f9fbfbb7368a40bd49/fsspec-2025.2.0-py3-none-any.whl", hash = "sha256:9de2ad9ce1f85e1931858535bc882543171d197001a0a5eb2ddc04f1781ab95b", size = 184484 },
|
248 |
+
]
|
249 |
+
|
250 |
[[package]]
|
251 |
name = "geopandas"
|
252 |
version = "1.0.1"
|
|
|
293 |
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 },
|
294 |
]
|
295 |
|
296 |
+
[[package]]
|
297 |
+
name = "iniconfig"
|
298 |
+
version = "2.0.0"
|
299 |
+
source = { registry = "https://pypi.org/simple" }
|
300 |
+
sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 }
|
301 |
+
wheels = [
|
302 |
+
{ url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 },
|
303 |
+
]
|
304 |
+
|
305 |
[[package]]
|
306 |
name = "joblib"
|
307 |
version = "1.4.2"
|
|
|
431 |
version = "0.1.0"
|
432 |
source = { virtual = "../lynxkite-core" }
|
433 |
|
434 |
+
[package.metadata]
|
435 |
+
requires-dist = [{ name = "pytest", marker = "extra == 'dev'" }]
|
436 |
+
|
437 |
[[package]]
|
438 |
name = "lynxkite-graph-analytics"
|
439 |
version = "0.1.0"
|
440 |
source = { virtual = "." }
|
441 |
dependencies = [
|
442 |
+
{ name = "fsspec" },
|
443 |
{ name = "grand-cypher" },
|
444 |
{ name = "joblib" },
|
445 |
{ name = "lynxkite-core" },
|
446 |
{ name = "matplotlib" },
|
447 |
+
{ name = "networkx", extra = ["default"] },
|
448 |
{ name = "osmnx" },
|
449 |
{ name = "pandas" },
|
450 |
{ name = "polars", extra = ["gpu"] },
|
451 |
]
|
452 |
|
453 |
[package.optional-dependencies]
|
454 |
+
dev = [
|
455 |
+
{ name = "pytest" },
|
456 |
+
{ name = "pytest-asyncio" },
|
457 |
+
]
|
458 |
gpu = [
|
459 |
{ name = "nx-cugraph-cu12" },
|
460 |
]
|
461 |
|
462 |
[package.metadata]
|
463 |
requires-dist = [
|
464 |
+
{ name = "fsspec", specifier = ">=2025.2.0" },
|
465 |
{ name = "grand-cypher", specifier = ">=0.12.0" },
|
466 |
{ name = "joblib", specifier = ">=1.4.2" },
|
467 |
{ name = "lynxkite-core", virtual = "../lynxkite-core" },
|
468 |
{ name = "matplotlib", specifier = ">=3.10.0" },
|
469 |
+
{ name = "networkx", extras = ["default"], specifier = ">=3.4.2" },
|
470 |
{ name = "nx-cugraph-cu12", marker = "extra == 'gpu'", specifier = ">=24.12.0" },
|
471 |
{ name = "osmnx", specifier = ">=2.0.1" },
|
472 |
{ name = "pandas", specifier = ">=2.2.3" },
|
473 |
{ name = "polars", extras = ["gpu"], specifier = ">=1.14.0" },
|
474 |
+
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.4" },
|
475 |
+
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.25.3" },
|
476 |
]
|
477 |
|
478 |
[[package]]
|
|
|
527 |
{ url = "https://files.pythonhosted.org/packages/b9/54/dd730b32ea14ea797530a4479b2ed46a6fb250f682a9cfb997e968bf0261/networkx-3.4.2-py3-none-any.whl", hash = "sha256:df5d4365b724cf81b8c6a7312509d0c22386097011ad1abe274afd5e9d3bbc5f", size = 1723263 },
|
528 |
]
|
529 |
|
530 |
+
[package.optional-dependencies]
|
531 |
+
default = [
|
532 |
+
{ name = "matplotlib" },
|
533 |
+
{ name = "numpy" },
|
534 |
+
{ name = "pandas" },
|
535 |
+
{ name = "scipy" },
|
536 |
+
]
|
537 |
+
|
538 |
[[package]]
|
539 |
name = "numba"
|
540 |
version = "0.60.0"
|
|
|
793 |
{ url = "https://files.pythonhosted.org/packages/cf/6c/41c21c6c8af92b9fea313aa47c75de49e2f9a467964ee33eb0135d47eb64/pillow-11.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:67cd427c68926108778a9005f2a04adbd5e67c442ed21d95389fe1d595458756", size = 2377651 },
|
794 |
]
|
795 |
|
796 |
+
[[package]]
|
797 |
+
name = "pluggy"
|
798 |
+
version = "1.5.0"
|
799 |
+
source = { registry = "https://pypi.org/simple" }
|
800 |
+
sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 }
|
801 |
+
wheels = [
|
802 |
+
{ url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 },
|
803 |
+
]
|
804 |
+
|
805 |
[[package]]
|
806 |
name = "polars"
|
807 |
version = "1.14.0"
|
|
|
973 |
{ url = "https://files.pythonhosted.org/packages/f8/33/3c8c6302717096b54aa14ccbb271045ba04629e21cbf348f2f2dc94f69b4/pyproj-3.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:10a8dc6ec61af97c89ff032647d743f8dc023645773da42ef43f7ae1125b3509", size = 6218036 },
|
974 |
]
|
975 |
|
976 |
+
[[package]]
|
977 |
+
name = "pytest"
|
978 |
+
version = "8.3.4"
|
979 |
+
source = { registry = "https://pypi.org/simple" }
|
980 |
+
dependencies = [
|
981 |
+
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
982 |
+
{ name = "iniconfig" },
|
983 |
+
{ name = "packaging" },
|
984 |
+
{ name = "pluggy" },
|
985 |
+
]
|
986 |
+
sdist = { url = "https://files.pythonhosted.org/packages/05/35/30e0d83068951d90a01852cb1cef56e5d8a09d20c7f511634cc2f7e0372a/pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761", size = 1445919 }
|
987 |
+
wheels = [
|
988 |
+
{ url = "https://files.pythonhosted.org/packages/11/92/76a1c94d3afee238333bc0a42b82935dd8f9cf8ce9e336ff87ee14d9e1cf/pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6", size = 343083 },
|
989 |
+
]
|
990 |
+
|
991 |
+
[[package]]
|
992 |
+
name = "pytest-asyncio"
|
993 |
+
version = "0.25.3"
|
994 |
+
source = { registry = "https://pypi.org/simple" }
|
995 |
+
dependencies = [
|
996 |
+
{ name = "pytest" },
|
997 |
+
]
|
998 |
+
sdist = { url = "https://files.pythonhosted.org/packages/f2/a8/ecbc8ede70921dd2f544ab1cadd3ff3bf842af27f87bbdea774c7baa1d38/pytest_asyncio-0.25.3.tar.gz", hash = "sha256:fc1da2cf9f125ada7e710b4ddad05518d4cee187ae9412e9ac9271003497f07a", size = 54239 }
|
999 |
+
wheels = [
|
1000 |
+
{ url = "https://files.pythonhosted.org/packages/67/17/3493c5624e48fd97156ebaec380dcaafee9506d7e2c46218ceebbb57d7de/pytest_asyncio-0.25.3-py3-none-any.whl", hash = "sha256:9e89518e0f9bd08928f97a3482fdc4e244df17529460bc038291ccaf8f85c7c3", size = 19467 },
|
1001 |
+
]
|
1002 |
+
|
1003 |
[[package]]
|
1004 |
name = "python-dateutil"
|
1005 |
version = "2.9.0.post0"
|
|
|
1052 |
{ url = "https://files.pythonhosted.org/packages/1d/f2/56faa578aefdab498f6eb73dde3316f99390769786e0cdbb6c7a6abbbf86/rmm_cu12-24.12.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9460a386e34f1921c8d06204f320d705511de899ababb45302d314da036da5a", size = 1975053 },
|
1053 |
]
|
1054 |
|
1055 |
+
[[package]]
|
1056 |
+
name = "scipy"
|
1057 |
+
version = "1.15.2"
|
1058 |
+
source = { registry = "https://pypi.org/simple" }
|
1059 |
+
dependencies = [
|
1060 |
+
{ name = "numpy" },
|
1061 |
+
]
|
1062 |
+
sdist = { url = "https://files.pythonhosted.org/packages/b7/b9/31ba9cd990e626574baf93fbc1ac61cf9ed54faafd04c479117517661637/scipy-1.15.2.tar.gz", hash = "sha256:cd58a314d92838f7e6f755c8a2167ead4f27e1fd5c1251fd54289569ef3495ec", size = 59417316 }
|
1063 |
+
wheels = [
|
1064 |
+
{ url = "https://files.pythonhosted.org/packages/40/1f/bf0a5f338bda7c35c08b4ed0df797e7bafe8a78a97275e9f439aceb46193/scipy-1.15.2-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:92233b2df6938147be6fa8824b8136f29a18f016ecde986666be5f4d686a91a4", size = 38703651 },
|
1065 |
+
{ url = "https://files.pythonhosted.org/packages/de/54/db126aad3874601048c2c20ae3d8a433dbfd7ba8381551e6f62606d9bd8e/scipy-1.15.2-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:62ca1ff3eb513e09ed17a5736929429189adf16d2d740f44e53270cc800ecff1", size = 30102038 },
|
1066 |
+
{ url = "https://files.pythonhosted.org/packages/61/d8/84da3fffefb6c7d5a16968fe5b9f24c98606b165bb801bb0b8bc3985200f/scipy-1.15.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4c6676490ad76d1c2894d77f976144b41bd1a4052107902238047fb6a473e971", size = 22375518 },
|
1067 |
+
{ url = "https://files.pythonhosted.org/packages/44/78/25535a6e63d3b9c4c90147371aedb5d04c72f3aee3a34451f2dc27c0c07f/scipy-1.15.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:a8bf5cb4a25046ac61d38f8d3c3426ec11ebc350246a4642f2f315fe95bda655", size = 25142523 },
|
1068 |
+
{ url = "https://files.pythonhosted.org/packages/e0/22/4b4a26fe1cd9ed0bc2b2cb87b17d57e32ab72c346949eaf9288001f8aa8e/scipy-1.15.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a8e34cf4c188b6dd004654f88586d78f95639e48a25dfae9c5e34a6dc34547e", size = 35491547 },
|
1069 |
+
{ url = "https://files.pythonhosted.org/packages/32/ea/564bacc26b676c06a00266a3f25fdfe91a9d9a2532ccea7ce6dd394541bc/scipy-1.15.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28a0d2c2075946346e4408b211240764759e0fabaeb08d871639b5f3b1aca8a0", size = 37634077 },
|
1070 |
+
{ url = "https://files.pythonhosted.org/packages/43/c2/bfd4e60668897a303b0ffb7191e965a5da4056f0d98acfb6ba529678f0fb/scipy-1.15.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:42dabaaa798e987c425ed76062794e93a243be8f0f20fff6e7a89f4d61cb3d40", size = 37231657 },
|
1071 |
+
{ url = "https://files.pythonhosted.org/packages/4a/75/5f13050bf4f84c931bcab4f4e83c212a36876c3c2244475db34e4b5fe1a6/scipy-1.15.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6f5e296ec63c5da6ba6fa0343ea73fd51b8b3e1a300b0a8cae3ed4b1122c7462", size = 40035857 },
|
1072 |
+
{ url = "https://files.pythonhosted.org/packages/b9/8b/7ec1832b09dbc88f3db411f8cdd47db04505c4b72c99b11c920a8f0479c3/scipy-1.15.2-cp311-cp311-win_amd64.whl", hash = "sha256:597a0c7008b21c035831c39927406c6181bcf8f60a73f36219b69d010aa04737", size = 41217654 },
|
1073 |
+
{ url = "https://files.pythonhosted.org/packages/4b/5d/3c78815cbab499610f26b5bae6aed33e227225a9fa5290008a733a64f6fc/scipy-1.15.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c4697a10da8f8765bb7c83e24a470da5797e37041edfd77fd95ba3811a47c4fd", size = 38756184 },
|
1074 |
+
{ url = "https://files.pythonhosted.org/packages/37/20/3d04eb066b471b6e171827548b9ddb3c21c6bbea72a4d84fc5989933910b/scipy-1.15.2-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:869269b767d5ee7ea6991ed7e22b3ca1f22de73ab9a49c44bad338b725603301", size = 30163558 },
|
1075 |
+
{ url = "https://files.pythonhosted.org/packages/a4/98/e5c964526c929ef1f795d4c343b2ff98634ad2051bd2bbadfef9e772e413/scipy-1.15.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:bad78d580270a4d32470563ea86c6590b465cb98f83d760ff5b0990cb5518a93", size = 22437211 },
|
1076 |
+
{ url = "https://files.pythonhosted.org/packages/1d/cd/1dc7371e29195ecbf5222f9afeedb210e0a75057d8afbd942aa6cf8c8eca/scipy-1.15.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:b09ae80010f52efddb15551025f9016c910296cf70adbf03ce2a8704f3a5ad20", size = 25232260 },
|
1077 |
+
{ url = "https://files.pythonhosted.org/packages/f0/24/1a181a9e5050090e0b5138c5f496fee33293c342b788d02586bc410c6477/scipy-1.15.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5a6fd6eac1ce74a9f77a7fc724080d507c5812d61e72bd5e4c489b042455865e", size = 35198095 },
|
1078 |
+
{ url = "https://files.pythonhosted.org/packages/c0/53/eaada1a414c026673eb983f8b4a55fe5eb172725d33d62c1b21f63ff6ca4/scipy-1.15.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2b871df1fe1a3ba85d90e22742b93584f8d2b8e6124f8372ab15c71b73e428b8", size = 37297371 },
|
1079 |
+
{ url = "https://files.pythonhosted.org/packages/e9/06/0449b744892ed22b7e7b9a1994a866e64895363572677a316a9042af1fe5/scipy-1.15.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:03205d57a28e18dfd39f0377d5002725bf1f19a46f444108c29bdb246b6c8a11", size = 36872390 },
|
1080 |
+
{ url = "https://files.pythonhosted.org/packages/6a/6f/a8ac3cfd9505ec695c1bc35edc034d13afbd2fc1882a7c6b473e280397bb/scipy-1.15.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:601881dfb761311045b03114c5fe718a12634e5608c3b403737ae463c9885d53", size = 39700276 },
|
1081 |
+
{ url = "https://files.pythonhosted.org/packages/f5/6f/e6e5aff77ea2a48dd96808bb51d7450875af154ee7cbe72188afb0b37929/scipy-1.15.2-cp312-cp312-win_amd64.whl", hash = "sha256:e7c68b6a43259ba0aab737237876e5c2c549a031ddb7abc28c7b47f22e202ded", size = 40942317 },
|
1082 |
+
{ url = "https://files.pythonhosted.org/packages/53/40/09319f6e0f276ea2754196185f95cd191cb852288440ce035d5c3a931ea2/scipy-1.15.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01edfac9f0798ad6b46d9c4c9ca0e0ad23dbf0b1eb70e96adb9fa7f525eff0bf", size = 38717587 },
|
1083 |
+
{ url = "https://files.pythonhosted.org/packages/fe/c3/2854f40ecd19585d65afaef601e5e1f8dbf6758b2f95b5ea93d38655a2c6/scipy-1.15.2-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:08b57a9336b8e79b305a143c3655cc5bdbe6d5ece3378578888d2afbb51c4e37", size = 30100266 },
|
1084 |
+
{ url = "https://files.pythonhosted.org/packages/dd/b1/f9fe6e3c828cb5930b5fe74cb479de5f3d66d682fa8adb77249acaf545b8/scipy-1.15.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:54c462098484e7466362a9f1672d20888f724911a74c22ae35b61f9c5919183d", size = 22373768 },
|
1085 |
+
{ url = "https://files.pythonhosted.org/packages/15/9d/a60db8c795700414c3f681908a2b911e031e024d93214f2d23c6dae174ab/scipy-1.15.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:cf72ff559a53a6a6d77bd8eefd12a17995ffa44ad86c77a5df96f533d4e6c6bb", size = 25154719 },
|
1086 |
+
{ url = "https://files.pythonhosted.org/packages/37/3b/9bda92a85cd93f19f9ed90ade84aa1e51657e29988317fabdd44544f1dd4/scipy-1.15.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9de9d1416b3d9e7df9923ab23cd2fe714244af10b763975bea9e4f2e81cebd27", size = 35163195 },
|
1087 |
+
{ url = "https://files.pythonhosted.org/packages/03/5a/fc34bf1aa14dc7c0e701691fa8685f3faec80e57d816615e3625f28feb43/scipy-1.15.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb530e4794fc8ea76a4a21ccb67dea33e5e0e60f07fc38a49e821e1eae3b71a0", size = 37255404 },
|
1088 |
+
{ url = "https://files.pythonhosted.org/packages/4a/71/472eac45440cee134c8a180dbe4c01b3ec247e0338b7c759e6cd71f199a7/scipy-1.15.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5ea7ed46d437fc52350b028b1d44e002646e28f3e8ddc714011aaf87330f2f32", size = 36860011 },
|
1089 |
+
{ url = "https://files.pythonhosted.org/packages/01/b3/21f890f4f42daf20e4d3aaa18182dddb9192771cd47445aaae2e318f6738/scipy-1.15.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:11e7ad32cf184b74380f43d3c0a706f49358b904fa7d5345f16ddf993609184d", size = 39657406 },
|
1090 |
+
{ url = "https://files.pythonhosted.org/packages/0d/76/77cf2ac1f2a9cc00c073d49e1e16244e389dd88e2490c91d84e1e3e4d126/scipy-1.15.2-cp313-cp313-win_amd64.whl", hash = "sha256:a5080a79dfb9b78b768cebf3c9dcbc7b665c5875793569f48bf0e2b1d7f68f6f", size = 40961243 },
|
1091 |
+
{ url = "https://files.pythonhosted.org/packages/4c/4b/a57f8ddcf48e129e6054fa9899a2a86d1fc6b07a0e15c7eebff7ca94533f/scipy-1.15.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:447ce30cee6a9d5d1379087c9e474628dab3db4a67484be1b7dc3196bfb2fac9", size = 38870286 },
|
1092 |
+
{ url = "https://files.pythonhosted.org/packages/0c/43/c304d69a56c91ad5f188c0714f6a97b9c1fed93128c691148621274a3a68/scipy-1.15.2-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:c90ebe8aaa4397eaefa8455a8182b164a6cc1d59ad53f79943f266d99f68687f", size = 30141634 },
|
1093 |
+
{ url = "https://files.pythonhosted.org/packages/44/1a/6c21b45d2548eb73be9b9bff421aaaa7e85e22c1f9b3bc44b23485dfce0a/scipy-1.15.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:def751dd08243934c884a3221156d63e15234a3155cf25978b0a668409d45eb6", size = 22415179 },
|
1094 |
+
{ url = "https://files.pythonhosted.org/packages/74/4b/aefac4bba80ef815b64f55da06f62f92be5d03b467f2ce3668071799429a/scipy-1.15.2-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:302093e7dfb120e55515936cb55618ee0b895f8bcaf18ff81eca086c17bd80af", size = 25126412 },
|
1095 |
+
{ url = "https://files.pythonhosted.org/packages/b1/53/1cbb148e6e8f1660aacd9f0a9dfa2b05e9ff1cb54b4386fe868477972ac2/scipy-1.15.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7cd5b77413e1855351cdde594eca99c1f4a588c2d63711388b6a1f1c01f62274", size = 34952867 },
|
1096 |
+
{ url = "https://files.pythonhosted.org/packages/2c/23/e0eb7f31a9c13cf2dca083828b97992dd22f8184c6ce4fec5deec0c81fcf/scipy-1.15.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d0194c37037707b2afa7a2f2a924cf7bac3dc292d51b6a925e5fcb89bc5c776", size = 36890009 },
|
1097 |
+
{ url = "https://files.pythonhosted.org/packages/03/f3/e699e19cabe96bbac5189c04aaa970718f0105cff03d458dc5e2b6bd1e8c/scipy-1.15.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:bae43364d600fdc3ac327db99659dcb79e6e7ecd279a75fe1266669d9a652828", size = 36545159 },
|
1098 |
+
{ url = "https://files.pythonhosted.org/packages/af/f5/ab3838e56fe5cc22383d6fcf2336e48c8fe33e944b9037fbf6cbdf5a11f8/scipy-1.15.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f031846580d9acccd0044efd1a90e6f4df3a6e12b4b6bd694a7bc03a89892b28", size = 39136566 },
|
1099 |
+
{ url = "https://files.pythonhosted.org/packages/0a/c8/b3f566db71461cabd4b2d5b39bcc24a7e1c119535c8361f81426be39bb47/scipy-1.15.2-cp313-cp313t-win_amd64.whl", hash = "sha256:fe8a9eb875d430d81755472c5ba75e84acc980e4a8f6204d402849234d3017db", size = 40477705 },
|
1100 |
+
]
|
1101 |
+
|
1102 |
[[package]]
|
1103 |
name = "shapely"
|
1104 |
version = "2.0.7"
|