darabos commited on
Commit
ce575c3
·
unverified ·
2 Parent(s): 5bb4aa5 8a4f65f

Merge pull request #70 from biggraph/darabos-lint

Browse files
Files changed (43) hide show
  1. .gitignore +2 -1
  2. .pre-commit-config.yaml +17 -0
  3. .python-version +1 -0
  4. .vscode/settings.json +15 -0
  5. README.md +1 -0
  6. biome.json +24 -0
  7. examples/{night demo → Airlines demo} +0 -0
  8. lynxkite-app/README.md +6 -0
  9. lynxkite-app/pyproject.toml +1 -1
  10. lynxkite-app/src/lynxkite_app/main.py +11 -11
  11. lynxkite-app/uv.lock +53 -2
  12. lynxkite-app/web/eslint.config.js +12 -12
  13. lynxkite-app/web/playwright.config.ts +10 -11
  14. lynxkite-app/web/postcss.config.js +1 -1
  15. lynxkite-app/web/src/Directory.tsx +79 -47
  16. lynxkite-app/web/src/apiTypes.ts +18 -12
  17. lynxkite-app/web/src/index.css +0 -2
  18. lynxkite-app/web/src/main.tsx +9 -9
  19. lynxkite-app/web/src/workspace/EnvironmentSelector.tsx +12 -5
  20. lynxkite-app/web/src/workspace/LynxKiteState.ts +1 -1
  21. lynxkite-app/web/src/workspace/NodeSearch.tsx +41 -26
  22. lynxkite-app/web/src/workspace/Workspace.tsx +211 -152
  23. lynxkite-app/web/src/workspace/nodes/LynxKiteNode.tsx +61 -34
  24. lynxkite-app/web/src/workspace/nodes/NodeParameter.tsx +64 -43
  25. lynxkite-app/web/src/workspace/nodes/NodeWithImage.tsx +4 -2
  26. lynxkite-app/web/src/workspace/nodes/NodeWithParams.tsx +13 -8
  27. lynxkite-app/web/src/workspace/nodes/NodeWithTableView.tsx +59 -32
  28. lynxkite-app/web/src/workspace/nodes/NodeWithVisualization.tsx +10 -6
  29. lynxkite-app/web/src/workspace/nodes/Table.tsx +20 -15
  30. lynxkite-app/web/tailwind.config.js +8 -8
  31. lynxkite-app/web/tests/basic.spec.ts +18 -23
  32. lynxkite-app/web/tests/directory.spec.ts +60 -60
  33. lynxkite-app/web/tests/errors.spec.ts +24 -24
  34. lynxkite-app/web/tests/examples.spec.ts +30 -40
  35. lynxkite-app/web/tests/lynxkite.ts +61 -50
  36. lynxkite-app/web/tests/upload.spec.ts +28 -26
  37. lynxkite-app/web/tsconfig.app.json +0 -4
  38. lynxkite-app/web/tsconfig.node.json +0 -4
  39. lynxkite-app/web/vite.config.ts +9 -12
  40. lynxkite-core/src/lynxkite/core/workspace.py +1 -1
  41. lynxkite-graph-analytics/pyproject.toml +1 -0
  42. lynxkite-graph-analytics/src/lynxkite_graph_analytics/lynxkite_ops.py +12 -17
  43. 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
- @dataclasses.dataclass(order=True)
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 '@eslint/js'
2
- import globals from 'globals'
3
- import reactHooks from 'eslint-plugin-react-hooks'
4
- import reactRefresh from 'eslint-plugin-react-refresh'
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
- )
 
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 '@playwright/test';
2
-
3
 
4
  export default defineConfig({
5
- testDir: './tests',
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 ? [['github'], ['html']] : 'html',
13
  use: {
14
  /* Base URL to use in actions like `await page.goto('/')`. */
15
- baseURL: 'http://127.0.0.1:8000',
16
  /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
17
- trace: 'on-first-retry',
18
- testIdAttribute: 'data-nodeid' // Useful for easily selecting nodes using getByTestId
19
  },
20
  projects: [
21
  {
22
- name: 'chromium',
23
- use: { ...devices['Desktop Chrome'] },
24
  },
25
  ],
26
  webServer: {
27
- command: 'cd ../.. && LYNXKITE_DATA=examples lynxkite',
28
- url: 'http://127.0.0.1:8000',
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
- import useSWR from 'swr'
5
-
 
 
6
 
7
- import logo from './assets/logo.png';
8
  // @ts-ignore
9
- import Home from '~icons/tabler/home'
10
  // @ts-ignore
11
- import Folder from '~icons/tabler/folder'
12
  // @ts-ignore
13
- import FolderPlus from '~icons/tabler/folder-plus'
14
  // @ts-ignore
15
- import File from '~icons/tabler/file'
16
  // @ts-ignore
17
- import FilePlus from '~icons/tabler/file-plus'
18
  // @ts-ignore
19
- import Trash from '~icons/tabler/trash';
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
- function link(item: any) {
35
- if (item.type === 'directory') {
36
  return `/dir/${item.name}`;
37
- } else {
38
- return `/edit/${item.name}`;
39
  }
 
40
  }
41
 
42
- function shortName(item: any) {
43
- return item.name.split('/').pop();
44
  }
45
 
46
- function newName(list: any[], baseName: string = "Untitled") {
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(path: string, list: any[], workspaceName?: string) {
 
 
 
 
58
  const pathSlash = path ? `${path}/` : "";
59
  const name = workspaceName || newName(list);
60
  navigate(`/edit/${pathSlash}${name}`, { replace: true });
61
  }
62
 
63
-
64
- async function newFolderIn(path: string, list: any[], folderName?: string) {
 
 
 
65
  const name = folderName || newName(list, "New Folder");
66
  const pathSlash = path ? `${path}/` : "";
67
 
68
- const res = await fetch(`/api/dir/mkdir`, {
69
- method: 'POST',
70
- headers: { 'Content-Type': 'application/json' },
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: any) {
81
- if (!window.confirm(`Are you sure you want to delete "${item.name}"?`)) return;
 
82
  const pathSlash = path ? `${path}/` : "";
83
 
84
- const apiPath = item.type === "directory" ? `/api/dir/delete` : `/api/delete`;
 
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
- <div className="loading spinner-border" role="status">
105
  <span className="visually-hidden">Loading...</span>
106
- </div>
107
  )}
108
 
109
  {list.data && (
110
  <>
111
  <div className="actions">
112
  <div className="new-workspace">
113
- {isCreatingWorkspace &&
114
  // @ts-ignore
115
- <form onSubmit={(e) => { e.preventDefault(); newWorkspaceIn(path || "", list.data, e.target.workspaceName.value.trim()) }}>
 
 
 
 
 
 
 
 
 
 
 
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 type="button" onClick={() => setIsCreatingWorkspace(true)}>
 
 
 
125
  <FolderPlus /> New workspace
126
  </button>
127
  </div>
128
 
129
  <div className="new-folder">
130
- {isCreatingDir &&
131
  // @ts-ignore
132
- <form onSubmit={(e) => { e.preventDefault(); newFolderIn(path || "", list.data, e.target.folderName.value.trim()) }}>
 
 
 
 
 
 
 
 
 
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: any) => (
157
  <div key={item.name} className="entry">
158
  <a key={link(item)} href={link(item)}>
159
- {item.type === 'directory' ? <Folder /> : <File />}
160
  {shortName(item)}
161
  </a>
162
- <button onClick={() => { deleteItem(item) }}>
 
 
 
 
 
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
- /* eslint-disable */
9
- /**
10
- * This file was automatically generated by json-schema-to-typescript.
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 Position {
19
- x: number;
20
- y: number;
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 'react'
2
- import { createRoot } from 'react-dom/client'
3
- import '@xyflow/react/dist/style.css';
4
- import './index.css'
5
- import Directory from './Directory.tsx'
6
- import Workspace from './workspace/Workspace.tsx'
7
- import { BrowserRouter, Routes, Route } from "react-router";
8
 
9
- createRoot(document.getElementById('root')!).render(
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: { options: string[], value: string, onChange: (val: string) => void }) {
 
 
 
 
2
  return (
3
  <>
4
- <select className="select w-full max-w-xs"
 
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}>{option}</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 '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: { boxes: Catalog, onCancel: any, onAdd: any, pos: { x: number, y: number } }) {
 
 
 
 
 
14
  const searchBox = useRef(null as unknown as HTMLInputElement);
15
- const [searchText, setSearchText] = useState('');
16
- const fuse = useMemo(() => new Fuse(Object.values(props.boxes), {
17
- keys: ['name']
18
- }), [props.boxes]);
19
- const hits: { item: OpsOp }[] = searchText ? fuse.search<OpsOp>(searchText) : Object.values(props.boxes).map(box => ({ item: box }));
 
 
 
 
 
 
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 === 'ArrowDown') {
28
  e.preventDefault();
29
  setSelectedIndex(Math.min(selectedIndex + 1, hits.length - 1));
30
- } else if (e.key === 'ArrowUp') {
31
  e.preventDefault();
32
  setSelectedIndex(Math.max(selectedIndex - 1, 0));
33
- } else if (e.key === 'Enter') {
34
  addSelected();
35
- } else if (e.key === 'Escape') {
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 && e.relatedTarget.closest('.node-search')) return;
47
  props.onCancel();
48
  }
49
 
50
-
51
  return (
52
- <div className="node-search" style={{ top: props.pos.y, left: props.pos.x }}>
 
 
 
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 ? 'selected' : ''}`}>
 
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
- // The LynxKite workspace editor.
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
- ReactFlow,
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
- MiniMap,
20
- } from '@xyflow/react';
 
 
 
 
 
 
 
 
 
 
 
21
  // @ts-ignore
22
- import ArrowBack from '~icons/tabler/arrow-back.jsx';
23
  // @ts-ignore
24
- import Backspace from '~icons/tabler/backspace.jsx';
25
  // @ts-ignore
26
- import Atom from '~icons/tabler/atom.jsx';
27
- import { syncedStore, getYjsDoc } from "@syncedstore/core";
28
- import { WebsocketProvider } from "y-websocket";
29
- import NodeWithParams from './nodes/NodeWithParams';
30
  // import NodeWithTableView from './NodeWithTableView';
31
- import EnvironmentSelector from './EnvironmentSelector';
32
- import { LynxKiteState } from './LynxKiteState';
33
- import { Workspace, WorkspaceNode } from "../apiTypes.ts";
34
- import NodeSearch, { OpsOp, Catalog, Catalogs } from "./NodeSearch.tsx";
35
- import NodeWithVisualization from "./nodes/NodeWithVisualization.tsx";
 
 
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(`ws://${location.host}/ws/crdt`, path!, doc);
 
 
 
 
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('update', onChange);
76
  return () => {
77
  doc.destroy();
78
  wsProvider.destroy();
79
- }
80
- }, [path]);
81
 
82
- const onNodesChange = useCallback((changes: any[]) => {
83
- // An update from the UI. Apply it to the local state...
84
- setNodes((nds) => applyNodeChanges(changes, nds));
85
- // ...and to the CRDT state. (Which could be the same, except for ReactFlow's internal copies.)
86
- const wnodes = state.workspace?.nodes;
87
- if (!wnodes) return;
88
- for (const ch of changes) {
89
- const nodeIndex = wnodes.findIndex((n) => n.id === ch.id);
90
- if (nodeIndex === -1) continue;
91
- const node = wnodes[nodeIndex];
92
- if (!node) continue;
93
- // Position events sometimes come with NaN values. Ignore them.
94
- if (ch.type === 'position' && !isNaN(ch.position.x) && !isNaN(ch.position.y)) {
95
- getYjsDoc(state).transact(() => {
96
- Object.assign(node.position, ch.position);
97
- });
98
- } else if (ch.type === 'select') {
99
- } else if (ch.type === 'dimensions') {
100
- getYjsDoc(state).transact(() => Object.assign(node, ch.dimensions));
101
- } else if (ch.type === 'remove') {
102
- wnodes.splice(nodeIndex, 1);
103
- } else if (ch.type === 'replace') {
104
- // Ideally we would only update the parameter that changed. But ReactFlow does not give us that detail.
105
- const u = {
106
- collapsed: ch.item.data.collapsed,
107
- // The "..." expansion on a Y.map returns an empty object. Copying with fromEntries/entries instead.
108
- params: { ...Object.fromEntries(Object.entries(ch.item.data.params)) },
109
- __execution_delay: ch.item.data.__execution_delay,
110
- };
111
- getYjsDoc(state).transact(() => Object.assign(node.data, u));
112
- } else {
113
- console.log('Unknown node change', ch);
 
 
 
 
 
 
 
 
114
  }
115
- }
116
- }, [state]);
117
- const onEdgesChange = useCallback((changes: any[]) => {
118
- setEdges((eds) => applyEdgeChanges(changes, eds));
119
- const wedges = state.workspace?.edges;
120
- if (!wedges) return;
121
- for (const ch of changes) {
122
- const edgeIndex = wedges.findIndex((e) => e.id === ch.id);
123
- if (ch.type === 'remove') {
124
- wedges.splice(edgeIndex, 1);
125
- } else if (ch.type === 'select') {
126
- } else {
127
- console.log('Unknown edge change', ch);
 
 
 
128
  }
129
- }
130
- }, [state]);
 
131
 
132
- const fetcher: Fetcher<Catalogs> = (resource: string, init?: RequestInit) => fetch(resource, init).then(res => res.json());
133
- const catalog = useSWR('/api/catalog', fetcher);
 
134
  const [suppressSearchUntil, setSuppressSearchUntil] = useState(0);
135
- const [nodeSearchSettings, setNodeSearchSettings] = useState(undefined as {
136
- pos: XYPosition,
137
- boxes: Catalog,
138
- } | undefined);
139
- const nodeTypes = useMemo(() => ({
140
- basic: NodeWithParams,
141
- visualization: NodeWithVisualization,
142
- image: NodeWithImage,
143
- table_view: NodeWithTableView,
144
- }), []);
 
 
 
 
 
 
 
145
  const closeNodeSearch = useCallback(() => {
146
  setNodeSearchSettings(undefined);
147
  setSuppressSearchUntil(Date.now() + 200);
148
- }, [setNodeSearchSettings, setSuppressSearchUntil]);
149
- const toggleNodeSearch = useCallback((event: MouseEvent) => {
150
- if (suppressSearchUntil > Date.now()) return;
151
- if (nodeSearchSettings) {
152
- closeNodeSearch();
153
- return;
154
- }
155
- event.preventDefault();
156
- setNodeSearchSettings({
157
- pos: { x: event.clientX, y: event.clientY },
158
- boxes: catalog.data![state.workspace.env!],
159
- });
160
- }, [catalog, state, setNodeSearchSettings, suppressSearchUntil]);
161
- const addNode = useCallback((meta: OpsOp) => {
162
- const node: Partial<WorkspaceNode> = {
163
- type: meta.type,
164
- data: {
165
- meta: meta,
166
- title: meta.name,
167
- params: Object.fromEntries(
168
- Object.values(meta.params).map((p) => [p.name, p.default])),
169
- },
170
- };
171
- const nss = nodeSearchSettings!;
172
- node.position = reactFlow.screenToFlowPosition({ x: nss.pos.x, y: nss.pos.y });
173
- const title = meta.name;
174
- let i = 1;
175
- node.id = `${title} ${i}`;
176
- const wnodes = state.workspace.nodes!;
177
- while (wnodes.find((x) => x.id === node.id)) {
178
- i += 1;
 
 
 
 
179
  node.id = `${title} ${i}`;
180
- }
181
- wnodes.push(node as WorkspaceNode);
182
- setNodes([...nodes, node as WorkspaceNode]);
183
- closeNodeSearch();
184
- }, [nodeSearchSettings, state, reactFlow, setNodes]);
 
 
 
 
 
 
185
 
186
- const onConnect = useCallback((connection: Connection) => {
187
- setSuppressSearchUntil(Date.now() + 200);
188
- const edge = {
189
- id: `${connection.source} ${connection.target}`,
190
- source: connection.source,
191
- sourceHandle: connection.sourceHandle!,
192
- target: connection.target,
193
- targetHandle: connection.targetHandle!,
194
- };
195
- state.workspace.edges!.push(edge);
196
- setEdges((oldEdges) => [...oldEdges, edge]);
197
- }, [state, setEdges]);
198
- const parentDir = path!.split('/').slice(0, -1).join('/');
 
 
 
199
  return (
200
  <div className="workspace">
201
  <div className="top-bar bg-neutral">
202
- <a className="logo" href=""><img src={favicon} /></a>
203
- <div className="ws-name">
204
- {path}
205
- </div>
206
  <EnvironmentSelector
207
  options={Object.keys(catalog.data || {})}
208
  value={state.workspace.env!}
209
- onChange={(env) => { state.workspace.env = env; }}
 
 
210
  />
211
  <div className="tools text-secondary">
212
- <a href=""><Atom /></a>
213
- <a href=""><Backspace /></a>
214
- <a href={'/dir/' + parentDir}><ArrowBack /></a>
 
 
 
 
 
 
215
  </div>
216
  </div>
217
- <div style={{ height: "100%", width: '100vw' }}>
218
  <LynxKiteState.Provider value={state}>
219
  <ReactFlow
220
  nodes={nodes}
221
  edges={edges}
222
- nodeTypes={nodeTypes} fitView
 
223
  onNodesChange={onNodesChange}
224
  onEdgesChange={onEdgesChange}
225
  onPaneClick={toggleNodeSearch}
@@ -231,13 +286,17 @@ function LynxKiteFlow() {
231
  >
232
  <Controls />
233
  <MiniMap />
234
- {nodeSearchSettings &&
235
- <NodeSearch pos={nodeSearchSettings.pos} boxes={nodeSearchSettings.boxes} onCancel={closeNodeSearch} onAdd={addNode} />
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 { useReactFlow, Handle, NodeResizeControl, Position } from '@xyflow/react';
 
 
 
 
 
2
  // @ts-ignore
3
- import ChevronDownRight from '~icons/tabler/chevron-down-right.jsx';
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: 'top' | 'bottom' | 'left' | 'right',
17
- name: string,
18
- index: number,
19
- offsetPercentage: number,
20
- showLabel: boolean,
21
- type: 'source' | 'target',
22
  }[] = [];
23
  for (const e of Object.values(inputs)) {
24
- handles.push({ ...e, type: 'target' });
25
  }
26
  for (const e of Object.values(outputs)) {
27
- handles.push({ ...e, type: 'source' });
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 = counts.top === 0 && counts.bottom === 0 && handles.length <= 2;
37
- const simpleVertical = counts.left === 0 && counts.right === 0 && handles.length <= 2;
 
 
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 = { top: 'left', bottom: 'left', left: 'top', right: 'top' };
 
 
 
 
 
52
 
53
  return (
54
- <div className={'node-container ' + (expanded ? 'expanded' : 'collapsed')}
55
- style={{ width: props.width || 200, height: expanded ? props.height || 200 : undefined }}>
 
 
 
 
 
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
- {data.error &&
64
- <div className="error">{data.error}</div>
65
- }
66
- {props.children}
67
- <NodeResizeControl
68
- minWidth={100}
69
- minHeight={50}
70
- style={{ 'background': 'transparent', 'border': 'none' }}
71
- >
72
- <ChevronDownRight className="node-resizer" />
73
- </NodeResizeControl>
74
- </>}
75
- {handles.map(handle => (
76
  <Handle
77
  key={handle.name}
78
- id={handle.name} type={handle.type} position={handle.position as Position}
79
- style={{ [handleOffsetDirection[handle.position]]: handle.offsetPercentage + '%' }}>
80
- {handle.showLabel && <span className="handle-name">{handle.name.replace(/_/g, " ")}</span>}
81
- </Handle >
 
 
 
 
 
 
 
 
 
 
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 <span className="param-name bg-base-200">{name.replace(/_/g, ' ')}</span>;
 
 
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({ name, value, meta, onChange }: NodeParameterProps) {
 
 
 
 
 
15
  return (
 
16
  <label className="param">
17
- {meta?.type?.format === 'collapsed' ? <>
18
- <ParamName name={name} />
19
- <button className="collapsed-param">
20
-
21
- </button>
22
- </> : meta?.type?.format === 'textarea' ? <>
23
- <ParamName name={name} />
24
- <textarea className="textarea textarea-bordered w-full max-w-xs"
25
- rows={6}
26
- value={value}
27
- onChange={(evt) => onChange(evt.currentTarget.value, { delay: 2 })}
28
- onBlur={(evt) => onChange(evt.currentTarget.value, { delay: 0 })}
29
- />
30
- </> : meta?.type?.enum ? <>
31
- <ParamName name={name} />
32
- <select className="select select-bordered w-full max-w-xs"
33
- value={value || meta.type.enum[0]}
34
- onChange={(evt) => onChange(evt.currentTarget.value)}
35
- >
36
- {meta.type.enum.map((option: string) =>
37
- <option key={option} value={option}>{option}</option>
38
- )}
39
- </select>
40
- </> : meta?.type?.type === BOOLEAN ? <div className="form-control">
41
- <label className="label cursor-pointer">
42
- <input className="checkbox"
43
- type="checkbox"
44
- checked={value}
45
- onChange={(evt) => onChange(evt.currentTarget.checked)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  />
47
- {name.replace(/_/g, ' ')}
48
- </label>
49
- </div> : <>
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 './NodeWithParams';
2
 
3
  const NodeWithImage = (props: any) => {
4
  return (
5
  <NodeWithParams {...props}>
6
- {props.data.display && <img src={props.data.display} alt="Node 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 LynxKiteNode from './LynxKiteNode';
2
- import { useReactFlow } from '@xyflow/react';
3
- import NodeParameter from './NodeParameter';
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, { params: { ...props.data.params, [name]: newValue }, __execution_delay: opts.delay || 0 });
 
 
 
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) => setParam(name, value, opts || {})}
 
 
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 'react';
2
- import Markdown from 'react-markdown'
3
- import LynxKiteNode from './LynxKiteNode';
4
- import Table from './Table';
5
- import React from 'react';
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,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 = display?.dataframes && Object.keys(display?.dataframes).length === 1;
 
21
  return (
22
  <LynxKiteNode {...props}>
23
  {display && [
24
- Object.entries(display.dataframes || {}).map(([name, df]: [string, any]) => <React.Fragment key={name}>
25
- {!single && <div key={name + '-header'} className="df-head" onClick={() => setOpen({ ...open, [name]: !open[name] })}>{name}</div>}
26
- {(single || open[name]) &&
27
- (df.data.length > 1 ?
28
- <Table key={name + '-table'} columns={df.columns} data={df.data} />
29
- :
30
- df.data.length ?
31
- <dl key={name + '-dl'}>
32
- {df.columns.map((c: string, i: number) =>
33
- <React.Fragment key={name + '-' + c}>
34
- <dt>{c}</dt>
35
- <dd><Markdown>{toMD(df.data[0][i])}</Markdown></dd>
36
- </React.Fragment>)
37
- }
38
- </dl>
39
- :
40
- JSON.stringify(df.data))}
41
- </React.Fragment>),
42
- Object.entries(display.others || {}).map(([name, o]) => <>
43
- <div className="df-head" onClick={() => setOpen({ ...open, [name]: !open[name] })}>{name}</div>
44
- {open[name] && <pre>{(o as any).toString()}</pre>}
45
- </>
46
- )]}
47
- </LynxKiteNode >
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 '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,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, { renderer: 'canvas', width: 250, height: 250 });
 
 
 
 
12
  chartsInstanceRef.current.setOption(opts);
13
  const onResize = () => chartsInstanceRef.current?.resize();
14
- window.addEventListener('resize', onResize);
15
  return () => {
16
- window.removeEventListener('resize', onResize);
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 (<table>
3
- <thead>
4
- <tr>
5
- {props.columns.map((column: string) =>
6
- <th key={column}>{column}</th>)}
7
- </tr>
8
- </thead>
9
- <tbody>
10
- {props.data.map((row: { [column: string]: any }, i: number) =>
11
- <tr key={`row-${i}`}>
12
- {props.columns.map((_column: string, j: number) =>
13
- <td key={`cell ${i}, ${j}`}>{JSON.stringify(row[j])}</td>)}
14
- </tr>)}
15
- </tbody>
16
- </table>);
 
 
 
 
 
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: '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
  ],
 
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 { test, expect } from '@playwright/test';
3
- import { Splash, Workspace } from './lynxkite';
4
-
5
 
6
  let workspace: Workspace;
7
 
8
-
9
  test.beforeEach(async ({ browser }) => {
10
- workspace = await Workspace.empty(await browser.newPage(), 'basic_spec_test');
11
  });
12
 
13
  test.afterEach(async () => {
14
  await workspace.close();
15
  const splash = await new Splash(workspace.page);
16
- splash.page.on('dialog', async dialog => { await dialog.accept(); });
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,23 +31,18 @@ test('Box creation & deletion per env', async () => {
31
  }
32
  });
33
 
34
-
35
- test('Delete multi-handle boxes', async () => {
36
- await workspace.addBox('Compute PageRank');
37
- await workspace.deleteBoxes(['Compute PageRank 1']);
38
- await expect(workspace.getBox('Compute PageRank 1')).not.toBeVisible();
39
  });
40
 
41
-
42
- test ('Drag box', async () => {
43
- await workspace.addBox('Import Parquet');
44
- const originalPos = await workspace.getBox('Import Parquet 1').boundingBox();
45
- await workspace.moveBox('Import Parquet 1', {offsetX: 100, offsetY: 100});
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 { test, expect } from '@playwright/test';
3
- import { Workspace, Splash } from './lynxkite';
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('dialog', async dialog => { await dialog.accept(); });
 
 
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('Untitled');
22
  await workspace.close();
23
  });
24
 
25
- test('Create & delete workspace', async () => {
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('Import Parquet');
31
  await workspace.close();
32
  await splash.deleteEntry(workspaceName);
33
  await expect(splash.getEntry(workspaceName)).not.toBeVisible();
34
  });
35
 
36
- test('Create & delete folder', async () => {
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('Create folder with default name', async () => {
46
  await splash.createFolder();
47
- await expect(splash.currentFolder()).toContainText('Untitled');
48
  });
49
  });
50
 
51
-
52
- test.describe.serial('Nested folders & workspaces operations', () => {
53
-
54
- let splash;
55
-
56
- test.beforeEach(() => {
57
- // Nested navigation doesn't work yet
58
- test.skip();
59
- });
60
-
61
- test.beforeAll(async ({ browser }) => {
62
- const page = await browser.newPage()
63
- // To make deletion confirmation dialog to be automatically accepted
64
- page.on('dialog', async dialog => { await dialog.accept(); });
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
- });
 
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 { test, expect } from '@playwright/test';
3
- import { Splash, Workspace } from './lynxkite';
4
-
5
 
6
  let workspace: Workspace;
7
 
8
-
9
  test.beforeEach(async ({ browser }) => {
10
- workspace = await Workspace.empty(await browser.newPage(), 'error_spec_test');
11
  });
12
 
13
- test.afterEach(async ({ }) => {
14
  await workspace.close();
15
  const splash = await new Splash(workspace.page);
16
- splash.page.on('dialog', async dialog => { await dialog.accept(); });
17
- await splash.deleteEntry('error_spec_test');
 
 
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('Create scale-free graph');
25
- const graphBox = workspace.getBox('Create scale-free graph 1');
26
- await graphBox.locator('input').fill('');
27
- expect(await graphBox.locator('.error').innerText()).toBe("invalid literal for int() with base 10: ''");
28
- await graphBox.locator('input').fill('10');
29
- await expect(graphBox.locator('.error')).not.toBeVisible();
 
 
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('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
  });
 
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 { test, expect } from '@playwright/test';
3
- import { Workspace } from './lynxkite';
4
 
5
-
6
- test('LynxKite Graph Analytics example', async ({ page }) => {
7
- const ws = await Workspace.open(page, "NetworkX demo");
8
- expect(await ws.isErrorFree(process.env.CI? 2000: 1000)).toBeTruthy();
9
  });
10
 
11
-
12
- test('Pytorch example', async ({ page }) => {
13
- const ws = await Workspace.open(page, "PyTorch demo");
14
- expect(await ws.isErrorFree()).toBeTruthy();
15
  });
16
 
17
-
18
- test.fail('AIMO example', async ({ page }) => {
19
- // Fails because of missing OPENAI_API_KEY
20
- const ws = await Workspace.open(page, "AIMO");
21
- expect(await ws.isErrorFree()).toBeTruthy();
22
  });
23
 
24
- test.fail('LynxScribe example', async ({ page }) => {
25
- // Fails because of missing OPENAI_API_KEY
26
- const ws = await Workspace.open(page, "LynxScribe demo");
27
- expect(await ws.isErrorFree()).toBeTruthy();
28
  });
29
 
30
-
31
- test.fail('Graph RAG', async ({ page }) => {
32
- // Fails due to some issue with ChromaDB
33
- const ws = await Workspace.open(page, "Graph RAG");
34
- expect(await ws.isErrorFree(process.env.CI? 2000: 500)).toBeTruthy();
35
  });
36
 
37
-
38
- test.fail('RAG chatbot app', async ({ page }) => {
39
- // Fail due to all operation being unknown
40
- const ws = await Workspace.open(page, "RAG chatbot app");
41
- expect(await ws.isErrorFree()).toBeTruthy();
42
  });
43
 
44
-
45
- test.fail('night demo', async ({ page }) => {
46
- // airlines.graphml file not found
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
- test('Pillow example', async ({ page }) => {
54
- const ws = await Workspace.open(page, "Image processing");
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 { expect, Locator, Page } from '@playwright/test';
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 = this.page.locator('select[name="workspace-env"] option');
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('.ws-name')).toHaveText(name);
50
  }
51
 
52
  async waitForNodesToLoad() {
53
  // This method should be used only on non empty workspaces
54
- await this.page.locator('.react-flow__nodes').waitFor();
55
- await this.page.locator('.react-flow__node').first().waitFor();
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.locator('.react-flow__pane').click({ position: { x: 20, y: 20 }});
69
- await this.page.locator('.node-search').getByText(boxName).click();
70
- await this.page.keyboard.press('Escape');
 
 
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('.react-flow__pane').click();
77
- const catalog = await this.page.locator('.node-search .matches .search-result').allInnerTexts();
 
 
78
  // Dismiss the catalog menu
79
- await this.page.keyboard.press('Escape');
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
- await this.getBoxHandle(boxId).first().click();
87
- await this.page.keyboard.press('Backspace');
88
  }
89
  }
90
 
@@ -93,36 +94,44 @@ export class Workspace {
93
  }
94
 
95
  getBoxes() {
96
- return this.page.locator('.react-flow__node').all();
97
  }
98
 
99
  getBoxHandle(boxId: string) {
100
  return this.page.getByTestId(boxId);
101
  }
102
 
103
- async moveBox(boxId: string, offset? : {offsetX: number, offsetY:number}, targetPosition?: {x: number, y: number}) {
 
 
 
 
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(boxCenterX + offset.offsetX, boxCenterY + offset.offsetY, {steps: 5});
 
 
 
 
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 => setTimeout(resolve, executionWaitTime? executionWaitTime : 500));
 
 
136
  const boxes = await this.getBoxes();
137
  for (const box of boxes) {
138
- if (await box.locator('.error').isVisible()) {
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('#splash');
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('link', { name: name });
174
  }
175
 
176
  getEntry(name: string) {
177
- return this.page.locator('.entry').filter({ hasText: name }).first();
178
  }
179
 
180
  async createWorkspace(name?: string) {
181
- await this.page.getByRole('button', { name: 'New workspace' }).click();
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.locator('input[name="workspaceName"]').inputValue();
 
 
189
  }
190
- await this.page.locator('input[name="workspaceName"]').press('Enter');
191
  const ws = new Workspace(this.page, workspaceName);
192
- await ws.setEnv('LynxKite Graph Analytics');
193
  return ws;
194
  }
195
 
@@ -199,25 +211,24 @@ export class Splash {
199
  }
200
 
201
  async createFolder(folderName?: string) {
202
- await this.page.getByRole('button', { name: 'New folder' }).click();
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('Enter');
208
  }
209
 
210
  async deleteEntry(entryName: string) {
211
- await this.getEntry(entryName).locator('button').click();
212
  await this.page.reload();
213
  }
214
 
215
  currentFolder() {
216
- return this.page.locator('.current-folder');
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 { test, expect } from '@playwright/test';
3
- import { Splash, Workspace } from './lynxkite';
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(await browser.newPage(), 'upload_spec_test');
 
 
 
13
  });
14
 
15
- test.afterEach(async ({ }) => {
16
  await workspace.close();
17
  const splash = await new Splash(workspace.page);
18
- splash.page.on('dialog', async dialog => { await dialog.accept(); });
19
- await splash.deleteEntry('upload_spec_test');
 
 
20
  });
21
 
 
 
 
 
22
 
23
- test('can upload and import a simple CSV', async () => {
24
- const __filename = fileURLToPath(import.meta.url);
25
- const __dirname = dirname(__filename);
26
- const csvPath = join(__dirname, 'data', 'upload_test.csv');
 
 
27
 
28
- await workspace.addBox('Import CSV');
29
- const csvBox = workspace.getBox('Import CSV 1');
30
- const filenameInput = csvBox.locator('input.input-bordered').nth(0);
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
- const tableRows = tableBox.locator('table tbody tr');
40
- await expect(tableRows).toHaveCount(4);
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 { defineConfig } from 'vite'
2
- import react from '@vitejs/plugin-react-swc'
3
- import Icons from 'unplugin-icons/vite'
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
- 'top-level-await': true,
14
  },
15
  },
16
- plugins: [
17
- react(),
18
- Icons({ compiler: 'jsx', jsx: 'react' }),
19
- ],
20
  server: {
21
  proxy: {
22
- '/api': 'http://127.0.0.1:8000',
23
- '/ws': {
24
- target: 'ws://127.0.0.1:8000',
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
- from lynxkite.core import ops, workspace
 
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
- if filename.endswith(".zip"):
200
- with zipfile.ZipFile(filename, "r") as z:
201
- for fn in z.namelist():
202
- if fn.endswith(".graphml"):
203
- with z.open(fn) as f:
204
- G = nx.read_graphml(f)
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, backend="cugraph")
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"