darabos commited on
Commit
a56f05f
·
2 Parent(s): 4e41b08 95b5494

Merge remote-tracking branch 'public/main' into darabos-open-source-merge

Browse files
README.md CHANGED
@@ -17,67 +17,28 @@ Features include:
17
  - An extensive toolbox of graph analytics operations powered by NVIDIA RAPIDS (CUDA).
18
  - An integrated collaborative code editor makes it easy to add new operations.
19
  - An environment for visually designing neural network model architectures.
20
- - The infrastructure for easily creating other workflow design environments. See `lynxkite-pillow-example` for a simple example.
 
21
 
22
- This is the next evolution of the classical [LynxKite](https://github.com/lynxkite/lynxkite).
23
- The two tools offer similar functionality, but are not compatible.
24
- This version runs on GPU clusters instead of Hadoop clusters.
25
- It targets CUDA instead of Apache Spark. It is much more extensible.
26
 
27
- ## Structure
28
-
29
- - `lynxkite-core`: Core types and utilities. Depend on this lightweight package if you are writing LynxKite plugins.
30
- - `lynxkite-app`: The LynxKite web application. Install some plugins then run this to use LynxKite.
31
- - `lynxkite-graph-analytics`: Graph analytics plugin. The classical LynxKite experience!
32
- - `lynxkite-pillow`: A simple example plugin.
33
- - `lynxkite-lynxscribe`: A plugin for building and running LynxScribe applications.
34
- - `lynxkite-bio`: Bioinformatics additions for LynxKite Graph Analytics.
35
- - `docs`: User-facing documentation. It's shared between all packages.
36
-
37
- ## Development
38
-
39
- Install everything like this:
40
-
41
- ```bash
42
- uv venv
43
- source .venv/bin/activate
44
- uvx pre-commit install
45
- # The [dev] tag is only needed if you intend on running tests
46
- uv pip install -e lynxkite-core/[dev] -e lynxkite-app/[dev] -e lynxkite-graph-analytics/[dev] -e lynxkite-bio -e lynxkite-lynxscribe/ -e lynxkite-pillow-example/
47
- ```
48
-
49
- This also builds the frontend, hopefully very quickly. To run it:
50
 
51
  ```bash
52
- cd examples && lynxkite
53
  ```
54
 
55
- If you also want to make changes to the frontend with hot reloading:
56
-
57
- ```bash
58
- cd lynxkite-app/web
59
- npm run dev
60
- ```
61
 
62
- ## Executing tests
63
-
64
- Run all tests with a single command, or look inside to see how to run them individually:
65
-
66
- ```bash
67
- ./test.sh
68
- ```
69
-
70
- ## Documentation
71
-
72
- To work on the documentation:
73
-
74
- ```bash
75
- uv pip install mkdocs-material mkdocstrings[python]
76
- mkdocs serve
77
- ```
78
 
79
  ## License
80
 
81
- LynxKite 2000:MM Enterprise is built on top of the open-source [LynxKite 2000:MM](https://github.com/lynxkite/lynxkite-2000).
 
82
 
83
  Inquire with [Lynx Analytics](https://www.lynxanalytics.com/) for the licensing of this repository.
 
17
  - An extensive toolbox of graph analytics operations powered by NVIDIA RAPIDS (CUDA).
18
  - An integrated collaborative code editor makes it easy to add new operations.
19
  - An environment for visually designing neural network model architectures.
20
+ - The infrastructure for easily creating other workflow design environments. See `lynxkite-pillow-example` for a simple
21
+ example.
22
 
23
+ This is the next evolution of the classical [LynxKite](https://github.com/lynxkite/lynxkite). The two tools offer
24
+ similar functionality, but are not compatible. This version runs on GPU clusters instead of Hadoop clusters. It targets
25
+ CUDA instead of Apache Spark. It is much more extensible.
 
26
 
27
+ ## Installation
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
 
29
  ```bash
30
+ pip install lynxkite lynxkite-graph-analytics
31
  ```
32
 
33
+ ## Getting started
 
 
 
 
 
34
 
35
+ - [Online demo](https://lynx-analytics-lynxkite.hf.space/)
36
+ - [Quickstart](https://lynxkite.github.io/lynxkite-2000/guides/quickstart/)
37
+ - [Contributing](https://lynxkite.github.io/lynxkite-2000/contributing/)
 
 
 
 
 
 
 
 
 
 
 
 
 
38
 
39
  ## License
40
 
41
+ LynxKite 2000:MM Enterprise is built on top of the open-source
42
+ [LynxKite 2000:MM](https://github.com/lynxkite/lynxkite-2000).
43
 
44
  Inquire with [Lynx Analytics](https://www.lynxanalytics.com/) for the licensing of this repository.
docs/contributing.md ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Contributing
2
+
3
+ The LynxKite 2000:MM repository lives at
4
+ [https://github.com/lynxkite/lynxkite-2000](https://github.com/lynxkite/lynxkite-2000). Bug reports, feature requests,
5
+ and pull requests are welcome!
6
+
7
+ ## Project structure
8
+
9
+ - `lynxkite-core`: Core types and utilities. Depend on this lightweight package if you are writing LynxKite plugins.
10
+ - `lynxkite-app`: The LynxKite web application. Install some plugins then run this to use LynxKite.
11
+ - `lynxkite-graph-analytics`: Graph analytics plugin. The classical LynxKite experience!
12
+ - `lynxkite-pillow`: A simple example plugin.
13
+ - `lynxkite-lynxscribe`: A plugin for building and running LynxScribe applications.
14
+ - `lynxkite-bio`: Bioinformatics additions for LynxKite Graph Analytics.
15
+ - `docs`: User-facing documentation. It's shared between all packages.
16
+
17
+ ## Development setup
18
+
19
+ Install everything like this:
20
+
21
+ ```bash
22
+ uv venv
23
+ source .venv/bin/activate
24
+ uvx pre-commit install
25
+ uv pip install -e 'lynxkite-core/[dev]' -e 'lynxkite-app/[dev]' -e 'lynxkite-graph-analytics/[dev]' -e lynxkite-pillow-example/ -e lynxkite-bio -e lynxkite-lynxscribe/
26
+ ```
27
+
28
+ This also builds the frontend, hopefully very quickly. To run it:
29
+
30
+ ```bash
31
+ cd examples
32
+ lynxkite
33
+ ```
34
+
35
+ If you also want to make changes to the frontend with hot reloading:
36
+
37
+ ```bash
38
+ cd lynxkite-app/web
39
+ npm run dev
40
+ ```
41
+
42
+ ## Executing tests
43
+
44
+ Run all tests with a single command, or look inside to see how to run them individually:
45
+
46
+ ```bash
47
+ ./test.sh
48
+ ```
49
+
50
+ ## Documentation
51
+
52
+ To work on the documentation:
53
+
54
+ ```bash
55
+ uv pip install mkdocs-material mkdocstrings[python]
56
+ mkdocs serve
57
+ ```
docs/guides/analytics.md ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Graph analytics & data science
2
+
3
+ Install LynxKite with the graph analytics package:
4
+
5
+ ```bash
6
+ pip install lynxkite lynxkite-graph-analytics
7
+ ```
8
+
9
+ Run LynxKite in your data directory:
10
+
11
+ ```bash
12
+ cd lynxkite-data
13
+ lynxkite
14
+ ```
15
+
16
+ LynxKite by default runs on port 8000, so you can access it in your browser at
17
+ [http://localhost:8000](http://localhost:8000).
18
+ To run it on a different port, set the `PORT` environment variable (e.g., `PORT=8080 lynxkite`).
19
+
20
+ ## Using a CUDA GPU
21
+
22
+ To make full use of your GPU, install the `lynxkite-graph-analytics` package with GPU support.
23
+
24
+ ```bash
25
+ pip install lynxkite 'lynxkite-graph-analytics[gpu]'
26
+ ```
27
+
28
+ And start it with the cuGraph backend for NetworkX:
29
+
30
+ ```bash
31
+ NX_CUGRAPH_AUTOCONFIG=true lynxkite
32
+ ```
33
+
34
+ ## Directory browser
35
+
36
+ When you open the LynxKite web interface, you arrive in the directory browser. You see
37
+ the files and directories in your data directory — if you just created it, it will be empty.
38
+
39
+ You can create workspaces, [code files](plugins.md), and folders in the directory browser.
40
+
41
+ ## Workspaces
42
+
43
+ A LynxKite workspace is the place where you build a data science pipeline.
44
+ Pipelines are built from boxes, which have inputs and outputs that can be connected to each other.
45
+
46
+ To place a box, click anywhere in the workspace. This opens a search menu where you can
47
+ find the box you want to add.
48
+
49
+ ## Importing your data
50
+
51
+ To import CSV, Parquet, JSON, and Excel files, you can simply drag and drop them into the LynxKite workspace.
52
+ This will upload the file to the server and add an "Import file" box to the workspace.
53
+
54
+ You can also create "Import file" boxes manually and type the path to the file.
55
+ You can either use an absolute path, or a relative path from the data directory.
56
+ (Where you started LynxKite.)
57
+
58
+ ## Neural network design
59
+
60
+ The graph analytics package includes two environments, _"LynxKite Graph Analytics"_, and _"PyTorch model"_.
61
+ Use the dropdown in the top right corner to switch to the "PyTorch model" environment.
62
+ This environment allows you to define neural network architectures visually.
63
+
64
+ The important parts of a neural network definition are:
65
+
66
+ - **Inputs**: These boxes stand for the inputs. You will connect them to actual data in the workspace that
67
+ uses this model.
68
+ - **Layers**: The heart of the model. Use the _"Repeat"_ box looping back from the output of a layer to the
69
+ input of an earlier layer to repeat a set of layers.
70
+ - **Outputs**: These boxes mark the place in the data flow that holds the predictions of the model.
71
+ - **Loss**: Build the loss computation after the output box. This part is only used during training.
72
+ - **Optimizer**: The result of the loss computation goes into the optimizer. Training is partially configured
73
+ in the optimizer box, partially in the training box in the workspace that uses the model.
74
+
75
+ Once the model is defined, you can use it in other workspaces.
76
+
77
+ - Load it with the _"Define model"_ box.
78
+ - Train it with the _"Train model"_ box.
79
+ - Generate predictions with the _"Model inference"_ box.
80
+
81
+ See the [_Model definition_ and _Model use_ workspaces](https://github.com/lynxkite/lynxkite-2000/tree/main/examples)
82
+ for a practical example.
docs/{usage → guides}/plugins.md RENAMED
File without changes
docs/{usage → guides}/quickstart.md RENAMED
@@ -23,3 +23,5 @@ lynxkite
23
  ```
24
 
25
  Open [http://localhost:8000/](http://localhost:8000/) in your browser.
 
 
 
23
  ```
24
 
25
  Open [http://localhost:8000/](http://localhost:8000/) in your browser.
26
+
27
+ Find example workspaces in the [`examples` folder](https://github.com/lynxkite/lynxkite-2000/tree/main/examples).
lynxkite-app/src/lynxkite_app/crdt.py CHANGED
@@ -63,7 +63,10 @@ class WorkspaceWebsocketServer(pycrdt_websocket.WebsocketServer):
63
  room.ws = ws
64
 
65
  def on_change(changes):
66
- asyncio.create_task(workspace_changed(name, changes, ws))
 
 
 
67
 
68
  ws.observe_deep(on_change)
69
  return room
@@ -117,6 +120,7 @@ last_ws_input = None
117
 
118
 
119
  def clean_input(ws_pyd):
 
120
  for node in ws_pyd.nodes:
121
  node.data.display = None
122
  node.data.input_metadata = None
@@ -125,6 +129,8 @@ def clean_input(ws_pyd):
125
  for p in list(node.data.params):
126
  if p.startswith("_"):
127
  del node.data.params[p]
 
 
128
  node.position.x = 0
129
  node.position.y = 0
130
  if node.model_extra:
@@ -296,6 +302,11 @@ async def lifespan(app):
296
  print("closing websocket server")
297
 
298
 
 
 
 
 
 
299
  def sanitize_path(path):
300
  return os.path.relpath(os.path.normpath(os.path.join("/", path)), "/")
301
 
 
63
  room.ws = ws
64
 
65
  def on_change(changes):
66
+ task = asyncio.create_task(workspace_changed(name, changes, ws))
67
+ # We have no way to await workspace_changed(). The best we can do is to
68
+ # dereference its result after it's done, so exceptions are logged normally.
69
+ task.add_done_callback(lambda t: t.result())
70
 
71
  ws.observe_deep(on_change)
72
  return room
 
120
 
121
 
122
  def clean_input(ws_pyd):
123
+ """Delete everything that we want to ignore for the purposes of change detection."""
124
  for node in ws_pyd.nodes:
125
  node.data.display = None
126
  node.data.input_metadata = None
 
129
  for p in list(node.data.params):
130
  if p.startswith("_"):
131
  del node.data.params[p]
132
+ if node.data.title == "Comment":
133
+ node.data.params = {}
134
  node.position.x = 0
135
  node.position.y = 0
136
  if node.model_extra:
 
302
  print("closing websocket server")
303
 
304
 
305
+ def delete_room(name: str):
306
+ if name in ws_websocket_server.rooms:
307
+ del ws_websocket_server.rooms[name]
308
+
309
+
310
  def sanitize_path(path):
311
  return os.path.relpath(os.path.normpath(os.path.join("/", path)), "/")
312
 
lynxkite-app/src/lynxkite_app/main.py CHANGED
@@ -46,29 +46,9 @@ def get_catalog(workspace: str):
46
  return {env: _get_ops(env) for env in ops.CATALOGS}
47
 
48
 
49
- class SaveRequest(workspace.BaseConfig):
50
- path: str
51
- ws: workspace.Workspace
52
-
53
-
54
  data_path = pathlib.Path()
55
 
56
 
57
- def save(req: SaveRequest):
58
- path = data_path / req.path
59
- assert path.is_relative_to(data_path), f"Path '{path}' is invalid"
60
- req.ws.save(path)
61
-
62
-
63
- @app.post("/api/save")
64
- async def save_and_execute(req: SaveRequest):
65
- save(req)
66
- if req.ws.has_executor():
67
- await req.ws.execute()
68
- save(req)
69
- return req.ws
70
-
71
-
72
  @app.post("/api/delete")
73
  async def delete_workspace(req: dict):
74
  json_path: pathlib.Path = data_path / req["path"]
@@ -76,15 +56,7 @@ async def delete_workspace(req: dict):
76
  assert json_path.is_relative_to(data_path), f"Path '{json_path}' is invalid"
77
  json_path.unlink()
78
  crdt_path.unlink()
79
-
80
-
81
- @app.get("/api/load")
82
- def load(path: str):
83
- path = data_path / path
84
- assert path.is_relative_to(data_path), f"Path '{path}' is invalid"
85
- if not path.exists():
86
- return workspace.Workspace()
87
- return workspace.Workspace.load(path)
88
 
89
 
90
  class DirectoryEntry(pydantic.BaseModel):
 
46
  return {env: _get_ops(env) for env in ops.CATALOGS}
47
 
48
 
 
 
 
 
 
49
  data_path = pathlib.Path()
50
 
51
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  @app.post("/api/delete")
53
  async def delete_workspace(req: dict):
54
  json_path: pathlib.Path = data_path / req["path"]
 
56
  assert json_path.is_relative_to(data_path), f"Path '{json_path}' is invalid"
57
  json_path.unlink()
58
  crdt_path.unlink()
59
+ crdt.delete_room(req["path"])
 
 
 
 
 
 
 
 
60
 
61
 
62
  class DirectoryEntry(pydantic.BaseModel):
lynxkite-app/tests/test_main.py CHANGED
@@ -28,36 +28,6 @@ def test_get_catalog():
28
  assert response.status_code == 200
29
 
30
 
31
- def test_save_and_load():
32
- save_request = {
33
- "path": "test",
34
- "ws": {
35
- "env": "test",
36
- "nodes": [
37
- {
38
- "id": "Node_1",
39
- "type": "basic",
40
- "data": {
41
- "display": None,
42
- "input_metadata": None,
43
- "error": "Unknown operation.",
44
- "title": "Test node",
45
- "params": {"param1": "value"},
46
- },
47
- "position": {"x": -493.5496596237119, "y": 20.90123252513356},
48
- }
49
- ],
50
- "edges": [],
51
- },
52
- }
53
- response = client.post("/api/save", json=save_request)
54
- saved_ws = response.json()
55
- assert response.status_code == 200
56
- response = client.get("/api/load?path=test")
57
- assert response.status_code == 200
58
- assert saved_ws == response.json()
59
-
60
-
61
  def test_list_dir():
62
  test_dir = pathlib.Path() / str(uuid.uuid4())
63
  test_dir.mkdir(parents=True, exist_ok=True)
 
28
  assert response.status_code == 200
29
 
30
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  def test_list_dir():
32
  test_dir = pathlib.Path() / str(uuid.uuid4())
33
  test_dir.mkdir(parents=True, exist_ok=True)
lynxkite-app/uv.lock CHANGED
The diff for this file is too large to render. See raw diff
 
lynxkite-app/web/src/Directory.tsx CHANGED
@@ -58,7 +58,7 @@ function EntryCreator(props: {
58
 
59
  const fetcher = (url: string) => fetch(url).then((res) => res.json());
60
 
61
- export default function () {
62
  const path = usePath().replace(/^[/]$|^[/]dir$|^[/]dir[/]/, "");
63
  const encodedPath = encodeURIComponent(path || "");
64
  const list = useSWR(`/api/dir/list?path=${encodedPath}`, fetcher, {
 
58
 
59
  const fetcher = (url: string) => fetch(url).then((res) => res.json());
60
 
61
+ export default function Directory() {
62
  const path = usePath().replace(/^[/]$|^[/]dir$|^[/]dir[/]/, "");
63
  const encodedPath = encodeURIComponent(path || "");
64
  const list = useSWR(`/api/dir/list?path=${encodedPath}`, fetcher, {
lynxkite-app/web/src/Tooltip.tsx CHANGED
@@ -7,9 +7,9 @@ export default function Tooltip(props: any) {
7
  if (!props.doc) return null;
8
  return (
9
  <>
10
- <a data-tooltip-id={id} tabIndex={0}>
11
  {props.children}
12
- </a>
13
  <ReactTooltip id={id} className="tooltip prose" place="top-end">
14
  {props.doc.map?.(
15
  (section: any, i: number) =>
 
7
  if (!props.doc) return null;
8
  return (
9
  <>
10
+ <span data-tooltip-id={id} tabIndex={0}>
11
  {props.children}
12
+ </span>
13
  <ReactTooltip id={id} className="tooltip prose" place="top-end">
14
  {props.doc.map?.(
15
  (section: any, i: number) =>
lynxkite-app/web/src/apiTypes.ts CHANGED
@@ -34,6 +34,8 @@ export interface WorkspaceNode {
34
  type: string;
35
  data: WorkspaceNodeData;
36
  position: Position;
 
 
37
  [k: string]: unknown;
38
  }
39
  export interface WorkspaceNodeData {
 
34
  type: string;
35
  data: WorkspaceNodeData;
36
  position: Position;
37
+ width: number;
38
+ height: number;
39
  [k: string]: unknown;
40
  }
41
  export interface WorkspaceNodeData {
lynxkite-app/web/src/common.ts CHANGED
@@ -5,3 +5,12 @@ export function usePath() {
5
  const path = decodeURIComponent(useLocation().pathname).replace(/[/]$/, "");
6
  return path;
7
  }
 
 
 
 
 
 
 
 
 
 
5
  const path = decodeURIComponent(useLocation().pathname).replace(/[/]$/, "");
6
  return path;
7
  }
8
+
9
+ export const COLORS: { [key: string]: string } = {
10
+ gray: "oklch(95% 0 0)",
11
+ pink: "oklch(75% 0.2 0)",
12
+ orange: "oklch(75% 0.2 55)",
13
+ green: "oklch(75% 0.2 150)",
14
+ blue: "oklch(75% 0.2 230)",
15
+ purple: "oklch(75% 0.2 290)",
16
+ };
lynxkite-app/web/src/index.css CHANGED
@@ -76,6 +76,7 @@ body {
76
  .lynxkite-node {
77
  box-shadow: 0px 5px 50px 0px rgba(0, 0, 0, 0.3);
78
  border-radius: 4px;
 
79
  background: white;
80
  display: flex;
81
  flex-direction: column;
@@ -88,6 +89,46 @@ body {
88
  }
89
  }
90
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  .tooltip {
92
  padding: 8px;
93
  border-radius: 4px;
@@ -102,6 +143,10 @@ body {
102
  max-width: 300px;
103
  }
104
 
 
 
 
 
105
  .expanded .lynxkite-node {
106
  height: 100%;
107
  }
@@ -526,12 +571,17 @@ body {
526
  z-index: -10 !important;
527
  }
528
 
 
529
  .selected .comment-view,
530
  .selected .lynxkite-node {
531
  outline: var(--xy-selection-border, var(--xy-selection-border-default));
532
  outline-offset: 7.5px;
533
  }
534
 
 
 
 
 
535
  .graph-creation-view {
536
  display: flex;
537
  width: 100%;
 
76
  .lynxkite-node {
77
  box-shadow: 0px 5px 50px 0px rgba(0, 0, 0, 0.3);
78
  border-radius: 4px;
79
+ overflow: hidden;
80
  background: white;
81
  display: flex;
82
  flex-direction: column;
 
89
  }
90
  }
91
 
92
+ .in-group .lynxkite-node {
93
+ box-shadow: 0px 1px 5px 0px rgba(0, 0, 0, 0.3);
94
+ opacity: 0.3;
95
+ transition: opacity 0.3s;
96
+ }
97
+
98
+ .in-group .lynxkite-node:hover {
99
+ opacity: 1;
100
+ }
101
+
102
+ .node-group {
103
+ box-shadow: 0px 3px 30px 0px rgba(0, 0, 0, 0.3);
104
+ border-radius: 20px;
105
+ border: none;
106
+ background-color: white;
107
+ opacity: 0.9;
108
+ display: flex;
109
+ flex-direction: column;
110
+ align-items: end;
111
+ padding: 10px 20px;
112
+ }
113
+
114
+ .node-group.in-group {
115
+ opacity: 0.5;
116
+ }
117
+
118
+ .node-group-color-picker-icon {
119
+ font-size: 30px;
120
+ opacity: 0.1;
121
+ transition: opacity 0.3s;
122
+ }
123
+
124
+ .node-group:hover .node-group-color-picker-icon {
125
+ opacity: 1;
126
+ }
127
+
128
+ .color-picker-button {
129
+ font-size: 30px;
130
+ }
131
+
132
  .tooltip {
133
  padding: 8px;
134
  border-radius: 4px;
 
143
  max-width: 300px;
144
  }
145
 
146
+ .prose p {
147
+ margin-bottom: 0;
148
+ }
149
+
150
  .expanded .lynxkite-node {
151
  height: 100%;
152
  }
 
571
  z-index: -10 !important;
572
  }
573
 
574
+ .selected .node-group,
575
  .selected .comment-view,
576
  .selected .lynxkite-node {
577
  outline: var(--xy-selection-border, var(--xy-selection-border-default));
578
  outline-offset: 7.5px;
579
  }
580
 
581
+ .selected .node-group {
582
+ outline-offset: 20px;
583
+ }
584
+
585
  .graph-creation-view {
586
  display: flex;
587
  width: 100%;
lynxkite-app/web/src/workspace/NodeSearch.tsx CHANGED
@@ -10,7 +10,7 @@ export type OpsOp = {
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;
 
10
  export type Catalog = { [op: string]: OpsOp };
11
  export type Catalogs = { [env: string]: Catalog };
12
 
13
+ export default function NodeSearch(props: {
14
  boxes: Catalog;
15
  onCancel: any;
16
  onAdd: any;
lynxkite-app/web/src/workspace/Workspace.tsx CHANGED
@@ -16,7 +16,7 @@ import {
16
  useUpdateNodeInternals,
17
  } from "@xyflow/react";
18
  import axios from "axios";
19
- import { type MouseEvent, useCallback, useEffect, useMemo, useState } from "react";
20
  import { Link } from "react-router";
21
  import useSWR, { type Fetcher } from "swr";
22
  import { WebsocketProvider } from "y-websocket";
@@ -25,10 +25,15 @@ import Atom from "~icons/tabler/atom.jsx";
25
  // @ts-ignore
26
  import Backspace from "~icons/tabler/backspace.jsx";
27
  // @ts-ignore
 
 
 
 
28
  import Restart from "~icons/tabler/rotate-clockwise.jsx";
29
  // @ts-ignore
30
  import Close from "~icons/tabler/x.jsx";
31
- import type { Workspace, WorkspaceNode } from "../apiTypes.ts";
 
32
  import favicon from "../assets/favicon.ico";
33
  import { usePath } from "../common.ts";
34
  // import NodeWithTableView from './NodeWithTableView';
@@ -37,6 +42,7 @@ import LynxKiteEdge from "./LynxKiteEdge.tsx";
37
  import { LynxKiteState } from "./LynxKiteState";
38
  import NodeSearch, { type OpsOp, type Catalog, type Catalogs } from "./NodeSearch.tsx";
39
  import NodeWithGraphCreationView from "./nodes/GraphCreationNode.tsx";
 
40
  import NodeWithComment from "./nodes/NodeWithComment.tsx";
41
  import NodeWithImage from "./nodes/NodeWithImage.tsx";
42
  import NodeWithMolecule from "./nodes/NodeWithMolecule.tsx";
@@ -44,7 +50,7 @@ import NodeWithParams from "./nodes/NodeWithParams";
44
  import NodeWithTableView from "./nodes/NodeWithTableView.tsx";
45
  import NodeWithVisualization from "./nodes/NodeWithVisualization.tsx";
46
 
47
- export default function (props: any) {
48
  return (
49
  <ReactFlowProvider>
50
  <LynxKiteFlow {...props} />
@@ -55,6 +61,7 @@ export default function (props: any) {
55
  function LynxKiteFlow() {
56
  const updateNodeInternals = useUpdateNodeInternals();
57
  const reactFlow = useReactFlow();
 
58
  const [nodes, setNodes] = useState([] as Node[]);
59
  const [edges, setEdges] = useState([] as Edge[]);
60
  const path = usePath().replace(/^[/]edit[/]/, "");
@@ -62,10 +69,10 @@ function LynxKiteFlow() {
62
  .split("/")
63
  .pop()!
64
  .replace(/[.]lynxkite[.]json$/, "");
65
- const [state, setState] = useState({ workspace: {} as Workspace });
66
  const [message, setMessage] = useState(null as string | null);
67
  useEffect(() => {
68
- const state = syncedStore({ workspace: {} as Workspace });
69
  setState(state);
70
  const doc = getYjsDoc(state);
71
  const proto = location.protocol === "https:" ? "wss:" : "ws:";
@@ -78,7 +85,7 @@ function LynxKiteFlow() {
78
  if (!state.workspace.nodes) return;
79
  if (!state.workspace.edges) return;
80
  for (const n of state.workspace.nodes) {
81
- if (n.dragHandle !== ".drag-handle") {
82
  n.dragHandle = ".drag-handle";
83
  }
84
  }
@@ -185,6 +192,7 @@ function LynxKiteFlow() {
185
  graph_creation_view: NodeWithGraphCreationView,
186
  molecule: NodeWithMolecule,
187
  comment: NodeWithComment,
 
188
  }),
189
  [],
190
  );
@@ -203,7 +211,7 @@ function LynxKiteFlow() {
203
  if (event.key === "/") {
204
  event.preventDefault();
205
  setNodeSearchSettings({
206
- pos: { x: 100, y: 100 },
207
  boxes: catalog.data![state.workspace.env!],
208
  });
209
  } else if (event.key === "r") {
@@ -218,6 +226,39 @@ function LynxKiteFlow() {
218
  };
219
  }, [catalog.data, nodeSearchSettings, state.workspace.env]);
220
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
221
  function isTypingInFormElement() {
222
  const activeElement = document.activeElement;
223
  return (
@@ -247,25 +288,27 @@ function LynxKiteFlow() {
247
  },
248
  [catalog, state, nodeSearchSettings, suppressSearchUntil, closeNodeSearch],
249
  );
250
- function addNode(node: Partial<WorkspaceNode>, state: { workspace: Workspace }, nodes: Node[]) {
251
- const title = node.data?.title;
252
  let i = 1;
253
- node.id = `${title} ${i}`;
254
- const wnodes = state.workspace.nodes!;
255
- while (wnodes.find((x) => x.id === node.id)) {
256
  i += 1;
257
- node.id = `${title} ${i}`;
258
  }
259
- wnodes.push(node as WorkspaceNode);
 
 
 
260
  setNodes([...nodes, node as WorkspaceNode]);
261
  }
262
  function nodeFromMeta(meta: OpsOp): Partial<WorkspaceNode> {
263
  const node: Partial<WorkspaceNode> = {
264
  type: meta.type,
265
  data: {
266
- meta: meta,
267
  title: meta.name,
268
- params: Object.fromEntries(Object.values(meta.params).map((p) => [p.name, p.default])),
269
  },
270
  };
271
  return node;
@@ -278,7 +321,8 @@ function LynxKiteFlow() {
278
  x: nss.pos.x,
279
  y: nss.pos.y,
280
  });
281
- addNode(node, state, nodes);
 
282
  closeNodeSearch();
283
  },
284
  [nodeSearchSettings, state, reactFlow, nodes, closeNodeSearch],
@@ -321,6 +365,7 @@ function LynxKiteFlow() {
321
  setMessage(null);
322
  const cat = catalog.data![state.workspace.env!];
323
  const node = nodeFromMeta(cat["Import file"]);
 
324
  node.position = reactFlow.screenToFlowPosition({
325
  x: e.clientX,
326
  y: e.clientY,
@@ -335,7 +380,7 @@ function LynxKiteFlow() {
335
  } else if (file.name.includes(".xls")) {
336
  node.data!.params.file_format = "excel";
337
  }
338
- addNode(node, state, nodes);
339
  } catch (error) {
340
  setMessage("File upload failed.");
341
  }
@@ -346,6 +391,106 @@ function LynxKiteFlow() {
346
  setMessage("Workspace execution failed.");
347
  }
348
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
349
  return (
350
  <div className="workspace">
351
  <div className="top-bar bg-neutral">
@@ -362,21 +507,43 @@ function LynxKiteFlow() {
362
  }}
363
  />
364
  <div className="tools text-secondary">
365
- <button className="btn btn-link">
366
- <Atom />
367
- </button>
368
- <button className="btn btn-link">
369
- <Backspace />
370
- </button>
371
- <button className="btn btn-link" onClick={executeWorkspace}>
372
- <Restart />
373
- </button>
374
- <Link className="btn btn-link" to={`/dir/${parentDir}`} aria-label="close">
375
- <Close />
376
- </Link>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
377
  </div>
378
  </div>
379
- <div style={{ height: "100%", width: "100vw" }} onDragOver={onDragOver} onDrop={onDrop}>
 
 
 
 
 
380
  <LynxKiteState.Provider value={state}>
381
  <ReactFlow
382
  nodes={nodes}
@@ -389,9 +556,13 @@ function LynxKiteFlow() {
389
  onPaneClick={toggleNodeSearch}
390
  onConnect={onConnect}
391
  proOptions={{ hideAttribution: true }}
392
- maxZoom={1}
393
  minZoom={0.2}
394
  zoomOnScroll={false}
 
 
 
 
395
  preventScrolling={false}
396
  defaultEdgeOptions={{
397
  markerEnd: {
@@ -405,6 +576,7 @@ function LynxKiteFlow() {
405
  stroke: "black",
406
  },
407
  }}
 
408
  >
409
  <Controls />
410
  {nodeSearchSettings && (
 
16
  useUpdateNodeInternals,
17
  } from "@xyflow/react";
18
  import axios from "axios";
19
+ import { type MouseEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
20
  import { Link } from "react-router";
21
  import useSWR, { type Fetcher } from "swr";
22
  import { WebsocketProvider } from "y-websocket";
 
25
  // @ts-ignore
26
  import Backspace from "~icons/tabler/backspace.jsx";
27
  // @ts-ignore
28
+ import UngroupIcon from "~icons/tabler/library-minus.jsx";
29
+ // @ts-ignore
30
+ import GroupIcon from "~icons/tabler/library-plus.jsx";
31
+ // @ts-ignore
32
  import Restart from "~icons/tabler/rotate-clockwise.jsx";
33
  // @ts-ignore
34
  import Close from "~icons/tabler/x.jsx";
35
+ import Tooltip from "../Tooltip.tsx";
36
+ import type { WorkspaceNode, Workspace as WorkspaceType } from "../apiTypes.ts";
37
  import favicon from "../assets/favicon.ico";
38
  import { usePath } from "../common.ts";
39
  // import NodeWithTableView from './NodeWithTableView';
 
42
  import { LynxKiteState } from "./LynxKiteState";
43
  import NodeSearch, { type OpsOp, type Catalog, type Catalogs } from "./NodeSearch.tsx";
44
  import NodeWithGraphCreationView from "./nodes/GraphCreationNode.tsx";
45
+ import Group from "./nodes/Group.tsx";
46
  import NodeWithComment from "./nodes/NodeWithComment.tsx";
47
  import NodeWithImage from "./nodes/NodeWithImage.tsx";
48
  import NodeWithMolecule from "./nodes/NodeWithMolecule.tsx";
 
50
  import NodeWithTableView from "./nodes/NodeWithTableView.tsx";
51
  import NodeWithVisualization from "./nodes/NodeWithVisualization.tsx";
52
 
53
+ export default function Workspace(props: any) {
54
  return (
55
  <ReactFlowProvider>
56
  <LynxKiteFlow {...props} />
 
61
  function LynxKiteFlow() {
62
  const updateNodeInternals = useUpdateNodeInternals();
63
  const reactFlow = useReactFlow();
64
+ const reactFlowContainer = useRef<HTMLDivElement>(null);
65
  const [nodes, setNodes] = useState([] as Node[]);
66
  const [edges, setEdges] = useState([] as Edge[]);
67
  const path = usePath().replace(/^[/]edit[/]/, "");
 
69
  .split("/")
70
  .pop()!
71
  .replace(/[.]lynxkite[.]json$/, "");
72
+ const [state, setState] = useState({ workspace: {} as WorkspaceType });
73
  const [message, setMessage] = useState(null as string | null);
74
  useEffect(() => {
75
+ const state = syncedStore({ workspace: {} as WorkspaceType });
76
  setState(state);
77
  const doc = getYjsDoc(state);
78
  const proto = location.protocol === "https:" ? "wss:" : "ws:";
 
85
  if (!state.workspace.nodes) return;
86
  if (!state.workspace.edges) return;
87
  for (const n of state.workspace.nodes) {
88
+ if (n.type !== "node_group" && n.dragHandle !== ".drag-handle") {
89
  n.dragHandle = ".drag-handle";
90
  }
91
  }
 
192
  graph_creation_view: NodeWithGraphCreationView,
193
  molecule: NodeWithMolecule,
194
  comment: NodeWithComment,
195
+ node_group: Group,
196
  }),
197
  [],
198
  );
 
211
  if (event.key === "/") {
212
  event.preventDefault();
213
  setNodeSearchSettings({
214
+ pos: getBestPosition(),
215
  boxes: catalog.data![state.workspace.env!],
216
  });
217
  } else if (event.key === "r") {
 
226
  };
227
  }, [catalog.data, nodeSearchSettings, state.workspace.env]);
228
 
229
+ function getBestPosition() {
230
+ const W = reactFlowContainer.current!.clientWidth;
231
+ const H = reactFlowContainer.current!.clientHeight;
232
+ const w = 200;
233
+ const h = 200;
234
+ const SPEED = 20;
235
+ const GAP = 50;
236
+ const pos = { x: 100, y: 100 };
237
+ while (pos.y < H) {
238
+ // Find a position that is not occupied by a node.
239
+ const fpos = reactFlow.screenToFlowPosition(pos);
240
+ const occupied = state.workspace.nodes!.some((n) => {
241
+ const np = n.position;
242
+ return (
243
+ np.x < fpos.x + w + GAP &&
244
+ np.x + n.width + GAP > fpos.x &&
245
+ np.y < fpos.y + h + GAP &&
246
+ np.y + n.height + GAP > fpos.y
247
+ );
248
+ });
249
+ if (!occupied) {
250
+ return pos;
251
+ }
252
+ // Move the position to the right and down until we find a free spot.
253
+ pos.x += SPEED;
254
+ if (pos.x + w > W) {
255
+ pos.x = 100;
256
+ pos.y += SPEED;
257
+ }
258
+ }
259
+ return { x: 100, y: 100 };
260
+ }
261
+
262
  function isTypingInFormElement() {
263
  const activeElement = document.activeElement;
264
  return (
 
288
  },
289
  [catalog, state, nodeSearchSettings, suppressSearchUntil, closeNodeSearch],
290
  );
291
+ function findFreeId(prefix: string) {
 
292
  let i = 1;
293
+ let id = `${prefix} ${i}`;
294
+ const used = new Set(state.workspace.nodes!.map((n) => n.id));
295
+ while (used.has(id)) {
296
  i += 1;
297
+ id = `${prefix} ${i}`;
298
  }
299
+ return id;
300
+ }
301
+ function addNode(node: Partial<WorkspaceNode>) {
302
+ state.workspace.nodes!.push(node as WorkspaceNode);
303
  setNodes([...nodes, node as WorkspaceNode]);
304
  }
305
  function nodeFromMeta(meta: OpsOp): Partial<WorkspaceNode> {
306
  const node: Partial<WorkspaceNode> = {
307
  type: meta.type,
308
  data: {
309
+ meta: { value: meta },
310
  title: meta.name,
311
+ params: Object.fromEntries(meta.params.map((p) => [p.name, p.default])),
312
  },
313
  };
314
  return node;
 
321
  x: nss.pos.x,
322
  y: nss.pos.y,
323
  });
324
+ node.id = findFreeId(node.data!.title);
325
+ addNode(node);
326
  closeNodeSearch();
327
  },
328
  [nodeSearchSettings, state, reactFlow, nodes, closeNodeSearch],
 
365
  setMessage(null);
366
  const cat = catalog.data![state.workspace.env!];
367
  const node = nodeFromMeta(cat["Import file"]);
368
+ node.id = findFreeId(node.data!.title);
369
  node.position = reactFlow.screenToFlowPosition({
370
  x: e.clientX,
371
  y: e.clientY,
 
380
  } else if (file.name.includes(".xls")) {
381
  node.data!.params.file_format = "excel";
382
  }
383
+ addNode(node);
384
  } catch (error) {
385
  setMessage("File upload failed.");
386
  }
 
391
  setMessage("Workspace execution failed.");
392
  }
393
  }
394
+ function deleteSelection() {
395
+ const selectedNodes = nodes.filter((n) => n.selected);
396
+ const selectedEdges = edges.filter((e) => e.selected);
397
+ reactFlow.deleteElements({ nodes: selectedNodes, edges: selectedEdges });
398
+ }
399
+ function groupSelection() {
400
+ const selectedNodes = nodes.filter((n) => n.selected && !n.parentId);
401
+ const groupNode = {
402
+ id: findFreeId("Group"),
403
+ type: "node_group",
404
+ position: { x: 0, y: 0 },
405
+ width: 0,
406
+ height: 0,
407
+ data: { title: "Group", params: {} },
408
+ };
409
+ let top = Number.POSITIVE_INFINITY;
410
+ let left = Number.POSITIVE_INFINITY;
411
+ let bottom = Number.NEGATIVE_INFINITY;
412
+ let right = Number.NEGATIVE_INFINITY;
413
+ const PAD = 10;
414
+ for (const node of selectedNodes) {
415
+ if (node.position.y - PAD < top) top = node.position.y - PAD;
416
+ if (node.position.x - PAD < left) left = node.position.x - PAD;
417
+ if (node.position.y + PAD + node.height! > bottom)
418
+ bottom = node.position.y + PAD + node.height!;
419
+ if (node.position.x + PAD + node.width! > right) right = node.position.x + PAD + node.width!;
420
+ }
421
+ groupNode.position = {
422
+ x: left,
423
+ y: top,
424
+ };
425
+ groupNode.width = right - left;
426
+ groupNode.height = bottom - top;
427
+ setNodes([
428
+ { ...(groupNode as WorkspaceNode), selected: true },
429
+ ...nodes.map((n) =>
430
+ n.selected
431
+ ? {
432
+ ...n,
433
+ position: { x: n.position.x - left, y: n.position.y - top },
434
+ parentId: groupNode.id,
435
+ extent: "parent" as const,
436
+ selected: false,
437
+ }
438
+ : n,
439
+ ),
440
+ ]);
441
+ getYjsDoc(state).transact(() => {
442
+ state.workspace.nodes!.unshift(groupNode as WorkspaceNode);
443
+ const selectedNodeIds = new Set(selectedNodes.map((n) => n.id));
444
+ for (const node of state.workspace.nodes!) {
445
+ if (selectedNodeIds.has(node.id)) {
446
+ node.position.x -= left;
447
+ node.position.y -= top;
448
+ node.parentId = groupNode.id;
449
+ node.extent = "parent";
450
+ node.selected = false;
451
+ }
452
+ }
453
+ });
454
+ }
455
+ function ungroupSelection() {
456
+ const groups = Object.fromEntries(
457
+ nodes
458
+ .filter((n) => n.selected && n.type === "node_group" && !n.parentId)
459
+ .map((n) => [n.id, n]),
460
+ );
461
+ setNodes(
462
+ nodes
463
+ .filter((n) => !groups[n.id])
464
+ .map((n) => {
465
+ const g = groups[n.parentId!];
466
+ if (!g) return n;
467
+ return {
468
+ ...n,
469
+ position: { x: n.position.x + g.position.x, y: n.position.y + g.position.y },
470
+ parentId: undefined,
471
+ extent: undefined,
472
+ selected: true,
473
+ };
474
+ }),
475
+ );
476
+ getYjsDoc(state).transact(() => {
477
+ const wnodes = state.workspace.nodes!;
478
+ for (const node of state.workspace.nodes!) {
479
+ const g = groups[node.parentId as string];
480
+ if (!g) continue;
481
+ node.position.x += g.position.x;
482
+ node.position.y += g.position.y;
483
+ node.parentId = undefined;
484
+ node.extent = undefined;
485
+ }
486
+ for (const groupId in groups) {
487
+ const groupIdx = wnodes.findIndex((n) => n.id === groupId);
488
+ wnodes.splice(groupIdx, 1);
489
+ }
490
+ });
491
+ }
492
+ const areMultipleNodesSelected = nodes.filter((n) => n.selected).length > 1;
493
+ const isAnyGroupSelected = nodes.some((n) => n.selected && n.type === "node_group");
494
  return (
495
  <div className="workspace">
496
  <div className="top-bar bg-neutral">
 
507
  }}
508
  />
509
  <div className="tools text-secondary">
510
+ {areMultipleNodesSelected && (
511
+ <Tooltip doc="Group selected nodes">
512
+ <button className="btn btn-link" onClick={groupSelection}>
513
+ <GroupIcon />
514
+ </button>
515
+ </Tooltip>
516
+ )}
517
+ {isAnyGroupSelected && (
518
+ <Tooltip doc="Ungroup selected nodes">
519
+ <button className="btn btn-link" onClick={ungroupSelection}>
520
+ <UngroupIcon />
521
+ </button>
522
+ </Tooltip>
523
+ )}
524
+ <Tooltip doc="Delete selected nodes and edges">
525
+ <button className="btn btn-link" onClick={deleteSelection}>
526
+ <Backspace />
527
+ </button>
528
+ </Tooltip>
529
+ <Tooltip doc="Re-run the workspace">
530
+ <button className="btn btn-link" onClick={executeWorkspace}>
531
+ <Restart />
532
+ </button>
533
+ </Tooltip>
534
+ <Tooltip doc="Close workspace">
535
+ <Link className="btn btn-link" to={`/dir/${parentDir}`} aria-label="close">
536
+ <Close />
537
+ </Link>
538
+ </Tooltip>
539
  </div>
540
  </div>
541
+ <div
542
+ style={{ height: "100%", width: "100vw" }}
543
+ onDragOver={onDragOver}
544
+ onDrop={onDrop}
545
+ ref={reactFlowContainer}
546
+ >
547
  <LynxKiteState.Provider value={state}>
548
  <ReactFlow
549
  nodes={nodes}
 
556
  onPaneClick={toggleNodeSearch}
557
  onConnect={onConnect}
558
  proOptions={{ hideAttribution: true }}
559
+ maxZoom={10}
560
  minZoom={0.2}
561
  zoomOnScroll={false}
562
+ panOnScroll={true}
563
+ panOnDrag={false}
564
+ selectionOnDrag={true}
565
+ panOnScrollSpeed={1}
566
  preventScrolling={false}
567
  defaultEdgeOptions={{
568
  markerEnd: {
 
576
  stroke: "black",
577
  },
578
  }}
579
+ fitViewOptions={{ maxZoom: 1 }}
580
  >
581
  <Controls />
582
  {nodeSearchSettings && (
lynxkite-app/web/src/workspace/nodes/Group.tsx ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useReactFlow } from "@xyflow/react";
2
+ import { useState } from "react";
3
+ // @ts-ignore
4
+ import Palette from "~icons/tabler/palette-filled.jsx";
5
+ // @ts-ignore
6
+ import Square from "~icons/tabler/square-filled.jsx";
7
+ import Tooltip from "../../Tooltip.tsx";
8
+ import { COLORS } from "../../common.ts";
9
+
10
+ export default function Group(props: any) {
11
+ const reactFlow = useReactFlow();
12
+ const [displayingColorPicker, setDisplayingColorPicker] = useState(false);
13
+ function setColor(newValue: string) {
14
+ reactFlow.updateNodeData(props.id, (prevData: any) => ({
15
+ ...prevData,
16
+ params: { color: newValue },
17
+ }));
18
+ setDisplayingColorPicker(false);
19
+ }
20
+ function toggleColorPicker(e: React.MouseEvent<HTMLButtonElement, MouseEvent>) {
21
+ e.stopPropagation();
22
+ setDisplayingColorPicker(!displayingColorPicker);
23
+ }
24
+ const currentColor = props.data?.params?.color || "gray";
25
+ return (
26
+ <div
27
+ className={`node-group ${props.parentId ? "in-group" : ""}`}
28
+ style={{
29
+ width: props.width,
30
+ height: props.height,
31
+ backgroundColor: COLORS[currentColor],
32
+ }}
33
+ >
34
+ <button
35
+ className="node-group-color-picker-icon"
36
+ onClick={toggleColorPicker}
37
+ aria-label="Change group color"
38
+ >
39
+ <Tooltip doc="Change color">
40
+ <Palette />
41
+ </Tooltip>
42
+ </button>
43
+ {displayingColorPicker && <ColorPicker currentColor={currentColor} onPick={setColor} />}
44
+ </div>
45
+ );
46
+ }
47
+
48
+ function ColorPicker(props: { currentColor: string; onPick: (color: string) => void }) {
49
+ const colors = Object.keys(COLORS).filter((color) => color !== props.currentColor);
50
+ return (
51
+ <div className="color-picker">
52
+ {colors.map((color) => (
53
+ <button
54
+ key={color}
55
+ className="color-picker-button"
56
+ style={{ color: COLORS[color] }}
57
+ onClick={() => props.onPick(color)}
58
+ >
59
+ <Square />
60
+ </button>
61
+ ))}
62
+ </div>
63
+ );
64
+ }
lynxkite-app/web/src/workspace/nodes/LynxKiteNode.tsx CHANGED
@@ -12,6 +12,7 @@ import Help from "~icons/tabler/question-mark.jsx";
12
  // @ts-ignore
13
  import Skull from "~icons/tabler/skull.jsx";
14
  import Tooltip from "../../Tooltip";
 
15
 
16
  interface LynxKiteNodeProps {
17
  id: string;
@@ -20,6 +21,7 @@ interface LynxKiteNodeProps {
20
  nodeStyle: any;
21
  data: any;
22
  children: any;
 
23
  }
24
 
25
  function getHandles(inputs: any[], outputs: any[]) {
@@ -53,15 +55,6 @@ function getHandles(inputs: any[], outputs: any[]) {
53
  return handles;
54
  }
55
 
56
- const OP_COLORS: { [key: string]: string } = {
57
- gray: "oklch(95% 0 0)",
58
- pink: "oklch(75% 0.2 0)",
59
- orange: "oklch(75% 0.2 55)",
60
- green: "oklch(75% 0.2 150)",
61
- blue: "oklch(75% 0.2 230)",
62
- purple: "oklch(75% 0.2 290)",
63
- };
64
-
65
  function LynxKiteNodeComponent(props: LynxKiteNodeProps) {
66
  const reactFlow = useReactFlow();
67
  const data = props.data;
@@ -78,11 +71,11 @@ function LynxKiteNodeComponent(props: LynxKiteNodeProps) {
78
  };
79
  const titleStyle: { backgroundColor?: string } = {};
80
  if (data.meta?.value?.color) {
81
- titleStyle.backgroundColor = OP_COLORS[data.meta.value.color] || data.meta.value.color;
82
  }
83
  return (
84
  <div
85
- className={`node-container ${expanded ? "expanded" : "collapsed"} `}
86
  style={{
87
  width: props.width || 200,
88
  height: expanded ? props.height || 200 : undefined,
 
12
  // @ts-ignore
13
  import Skull from "~icons/tabler/skull.jsx";
14
  import Tooltip from "../../Tooltip";
15
+ import { COLORS } from "../../common.ts";
16
 
17
  interface LynxKiteNodeProps {
18
  id: string;
 
21
  nodeStyle: any;
22
  data: any;
23
  children: any;
24
+ parentId?: string;
25
  }
26
 
27
  function getHandles(inputs: any[], outputs: any[]) {
 
55
  return handles;
56
  }
57
 
 
 
 
 
 
 
 
 
 
58
  function LynxKiteNodeComponent(props: LynxKiteNodeProps) {
59
  const reactFlow = useReactFlow();
60
  const data = props.data;
 
71
  };
72
  const titleStyle: { backgroundColor?: string } = {};
73
  if (data.meta?.value?.color) {
74
+ titleStyle.backgroundColor = COLORS[data.meta.value.color] || data.meta.value.color;
75
  }
76
  return (
77
  <div
78
+ className={`node-container ${expanded ? "expanded" : "collapsed"} ${props.parentId ? "in-group" : ""}`}
79
  style={{
80
  width: props.width || 200,
81
  height: expanded ? props.height || 200 : undefined,
lynxkite-app/web/src/workspace/nodes/NodeParameter.tsx CHANGED
@@ -237,7 +237,7 @@ export default function NodeParameter({ name, value, meta, data, setParam }: Nod
237
  <textarea
238
  className="textarea textarea-bordered w-full"
239
  rows={6}
240
- value={value}
241
  onChange={(evt) => onChange(evt.currentTarget.value, { delay: 2 })}
242
  onBlur={(evt) => onChange(evt.currentTarget.value, { delay: 0 })}
243
  />
 
237
  <textarea
238
  className="textarea textarea-bordered w-full"
239
  rows={6}
240
+ value={value || ""}
241
  onChange={(evt) => onChange(evt.currentTarget.value, { delay: 2 })}
242
  onBlur={(evt) => onChange(evt.currentTarget.value, { delay: 0 })}
243
  />
lynxkite-app/web/tests/import.spec.ts CHANGED
@@ -63,6 +63,7 @@ test("Can import a JSON file", async () => {
63
  await validateImport(workspace, "import_test.json", "json");
64
  });
65
 
66
- test("Can import an Excel file", async () => {
 
67
  await validateImport(workspace, "import_test.xlsx", "excel");
68
  });
 
63
  await validateImport(workspace, "import_test.json", "json");
64
  });
65
 
66
+ // Needs openpyxl. It's the same code as the other formats, so not worth installing it in CI.
67
+ test.skip("Can import an Excel file", async () => {
68
  await validateImport(workspace, "import_test.xlsx", "excel");
69
  });
lynxkite-app/web/tests/lynxkite.ts CHANGED
@@ -53,14 +53,8 @@ export class Workspace {
53
  }
54
 
55
  async addBox(boxName) {
56
- //TODO: Support passing box parameters (id, position, etc.)
57
  const allBoxes = await this.getBoxes().all();
58
- if (allBoxes) {
59
- // Avoid overlapping with existing nodes
60
- const numNodes = allBoxes.length || 1;
61
- await this.page.mouse.wheel(0, numNodes * 400);
62
- }
63
-
64
  await this.page.locator(".ws-name").click();
65
  await this.page.keyboard.press("/");
66
  await this.page.locator(".node-search").getByText(boxName, { exact: true }).click();
@@ -102,11 +96,8 @@ export class Workspace {
102
  return this.page.locator(".react-flow__node");
103
  }
104
 
105
- getBoxHandle(boxId: string, pos?: string) {
106
- if (pos) {
107
- return this.page.locator(`[data-id="${boxId}"] [data-handlepos="${pos}"]`);
108
- }
109
- return this.page.getByTestId(boxId);
110
  }
111
 
112
  async moveBox(
@@ -135,13 +126,28 @@ export class Workspace {
135
  await this.page.mouse.up();
136
  }
137
 
138
- async connectBoxes(sourceId: string, targetId: string) {
139
  const sourceHandle = this.getBoxHandle(sourceId, "right");
140
  const targetHandle = this.getBoxHandle(targetId, "left");
 
 
141
  await sourceHandle.hover();
142
  await this.page.mouse.down();
 
143
  await targetHandle.hover();
144
  await this.page.mouse.up();
 
 
 
 
 
 
 
 
 
 
 
 
145
  }
146
 
147
  async execute() {
 
53
  }
54
 
55
  async addBox(boxName) {
56
+ // TODO: Support passing box parameters.
57
  const allBoxes = await this.getBoxes().all();
 
 
 
 
 
 
58
  await this.page.locator(".ws-name").click();
59
  await this.page.keyboard.press("/");
60
  await this.page.locator(".node-search").getByText(boxName, { exact: true }).click();
 
96
  return this.page.locator(".react-flow__node");
97
  }
98
 
99
+ getBoxHandle(boxId: string, pos: string) {
100
+ return this.page.locator(`.connectable[data-nodeid="${boxId}"][data-handlepos="${pos}"]`);
 
 
 
101
  }
102
 
103
  async moveBox(
 
126
  await this.page.mouse.up();
127
  }
128
 
129
+ async tryToConnectBoxes(sourceId: string, targetId: string) {
130
  const sourceHandle = this.getBoxHandle(sourceId, "right");
131
  const targetHandle = this.getBoxHandle(targetId, "left");
132
+ await expect(sourceHandle).toBeVisible();
133
+ await expect(targetHandle).toBeVisible();
134
  await sourceHandle.hover();
135
  await this.page.mouse.down();
136
+ await expect(this.page.locator(".react-flow__connectionline")).toBeAttached({ timeout: 1000 });
137
  await targetHandle.hover();
138
  await this.page.mouse.up();
139
+ await expect(
140
+ this.page.locator(`.react-flow__edge[aria-label="Edge from ${sourceId} to ${targetId}"]`),
141
+ ).toBeAttached({ timeout: 1000 });
142
+ }
143
+ async connectBoxes(sourceId: string, targetId: string) {
144
+ // The method above is unreliable. I gave up after a lot of debugging and added these retries.
145
+ while (true) {
146
+ try {
147
+ await this.tryToConnectBoxes(sourceId, targetId);
148
+ return;
149
+ } catch (e) {}
150
+ }
151
  }
152
 
153
  async execute() {
lynxkite-core/src/lynxkite/core/executors/one_by_one.py CHANGED
@@ -18,8 +18,8 @@ class Context(ops.BaseConfig):
18
  Attributes:
19
  node: The workspace node that this context is associated with.
20
  last_result: The last result produced by the operation.
21
- This can be used to incrementally build a result, when the operation
22
- is executed for multiple items.
23
  """
24
 
25
  node: workspace.WorkspaceNode
 
18
  Attributes:
19
  node: The workspace node that this context is associated with.
20
  last_result: The last result produced by the operation.
21
+ This can be used to incrementally build a result, when the operation
22
+ is executed for multiple items.
23
  """
24
 
25
  node: workspace.WorkspaceNode
lynxkite-core/src/lynxkite/core/workspace.py CHANGED
@@ -40,12 +40,13 @@ class WorkspaceNodeData(BaseConfig):
40
 
41
 
42
  class WorkspaceNode(BaseConfig):
43
- # The naming of these attributes matches the ones for the NodeBase type in React flow
44
- # modyfing them will break the frontend.
45
  id: str
46
  type: str
47
  data: WorkspaceNodeData
48
  position: Position
 
 
49
  _crdt: pycrdt.Map
50
 
51
  def publish_started(self):
@@ -202,27 +203,27 @@ class Workspace(BaseConfig):
202
  nc["data"] = pycrdt.Map()
203
  np._crdt = nc
204
 
205
- def add_node(self, func):
206
  """For convenience in e.g. tests."""
207
  random_string = os.urandom(4).hex()
208
- node = WorkspaceNode(
209
- id=f"{func.__op__.name} {random_string}",
210
- type=func.__op__.type,
211
- data=WorkspaceNodeData(
212
- title=func.__op__.name,
213
- params={},
214
- display=None,
215
- input_metadata=None,
216
- error=None,
217
- status=NodeStatus.planned,
218
- ),
219
- position=Position(x=0, y=0),
220
- )
221
  self.nodes.append(node)
222
  return node
223
 
224
  def add_edge(
225
- self, source: WorkspaceNode, sourceHandle: str, target: WorkspaceNode, targetHandle: str
 
 
 
 
226
  ):
227
  """For convenience in e.g. tests."""
228
  edge = WorkspaceEdge(
 
40
 
41
 
42
  class WorkspaceNode(BaseConfig):
43
+ # Most of these fields are shared with ReactFlow.
 
44
  id: str
45
  type: str
46
  data: WorkspaceNodeData
47
  position: Position
48
+ width: Optional[float] = None
49
+ height: Optional[float] = None
50
  _crdt: pycrdt.Map
51
 
52
  def publish_started(self):
 
203
  nc["data"] = pycrdt.Map()
204
  np._crdt = nc
205
 
206
+ def add_node(self, func=None, **kwargs):
207
  """For convenience in e.g. tests."""
208
  random_string = os.urandom(4).hex()
209
+ if func:
210
+ kwargs["type"] = func.__op__.type
211
+ kwargs["data"] = WorkspaceNodeData(title=func.__op__.name, params={})
212
+ kwargs.setdefault("type", "basic")
213
+ kwargs.setdefault("id", f"{kwargs['data'].title} {random_string}")
214
+ kwargs.setdefault("position", Position(x=0, y=0))
215
+ kwargs.setdefault("width", 100)
216
+ kwargs.setdefault("height", 100)
217
+ node = WorkspaceNode(**kwargs)
 
 
 
 
218
  self.nodes.append(node)
219
  return node
220
 
221
  def add_edge(
222
+ self,
223
+ source: WorkspaceNode,
224
+ sourceHandle: str,
225
+ target: WorkspaceNode,
226
+ targetHandle: str,
227
  ):
228
  """For convenience in e.g. tests."""
229
  edge = WorkspaceEdge(
lynxkite-core/tests/test_workspace.py CHANGED
@@ -6,21 +6,15 @@ from lynxkite.core import workspace
6
 
7
  def test_save_load():
8
  ws = workspace.Workspace(env="test")
9
- ws.nodes.append(
10
- workspace.WorkspaceNode(
11
- id="1",
12
- type="node_type",
13
- data=workspace.WorkspaceNodeData(title="Node 1", params={}),
14
- position=workspace.Position(x=0, y=0),
15
- )
16
  )
17
- ws.nodes.append(
18
- workspace.WorkspaceNode(
19
- id="2",
20
- type="node_type",
21
- data=workspace.WorkspaceNodeData(title="Node 2", params={}),
22
- position=workspace.Position(x=0, y=0),
23
- )
24
  )
25
  ws.edges.append(
26
  workspace.WorkspaceEdge(
@@ -72,21 +66,15 @@ def populate_ops_catalog():
72
 
73
  def test_update_metadata():
74
  ws = workspace.Workspace(env="test")
75
- ws.nodes.append(
76
- workspace.WorkspaceNode(
77
- id="1",
78
- type="basic",
79
- data=workspace.WorkspaceNodeData(title="Test Operation", params={}),
80
- position=workspace.Position(x=0, y=0),
81
- )
82
  )
83
- ws.nodes.append(
84
- workspace.WorkspaceNode(
85
- id="2",
86
- type="basic",
87
- data=workspace.WorkspaceNodeData(title="Unknown Operation", params={}),
88
- position=workspace.Position(x=0, y=0),
89
- )
90
  )
91
  ws.update_metadata()
92
  assert ws.nodes[0].data.meta.name == "Test Operation"
 
6
 
7
  def test_save_load():
8
  ws = workspace.Workspace(env="test")
9
+ ws.add_node(
10
+ id="1",
11
+ type="node_type",
12
+ data=workspace.WorkspaceNodeData(title="Node 1", params={}),
 
 
 
13
  )
14
+ ws.add_node(
15
+ id="2",
16
+ type="node_type",
17
+ data=workspace.WorkspaceNodeData(title="Node 2", params={}),
 
 
 
18
  )
19
  ws.edges.append(
20
  workspace.WorkspaceEdge(
 
66
 
67
  def test_update_metadata():
68
  ws = workspace.Workspace(env="test")
69
+ ws.add_node(
70
+ id="1",
71
+ type="basic",
72
+ data=workspace.WorkspaceNodeData(title="Test Operation", params={}),
 
 
 
73
  )
74
+ ws.add_node(
75
+ id="2",
76
+ type="basic",
77
+ data=workspace.WorkspaceNodeData(title="Unknown Operation", params={}),
 
 
 
78
  )
79
  ws.update_metadata()
80
  assert ws.nodes[0].data.meta.name == "Test Operation"
lynxkite-graph-analytics/pyproject.toml CHANGED
@@ -19,6 +19,10 @@ dependencies = [
19
  "torch-geometric>=2.6.1",
20
  "umap-learn>=0.5.7",
21
  ]
 
 
 
 
22
 
23
  [project.optional-dependencies]
24
  dev = [
 
19
  "torch-geometric>=2.6.1",
20
  "umap-learn>=0.5.7",
21
  ]
22
+ classifiers = ["License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)"]
23
+
24
+ [project.urls]
25
+ Homepage = "https://github.com/lynxkite/lynxkite-2000/"
26
 
27
  [project.optional-dependencies]
28
  dev = [
lynxkite-graph-analytics/tests/test_lynxkite_ops.py CHANGED
@@ -8,13 +8,11 @@ from lynxkite_graph_analytics.core import Bundle, execute, ENV
8
 
9
  async def test_execute_operation_not_in_catalog():
10
  ws = workspace.Workspace(env=ENV)
11
- ws.nodes.append(
12
- workspace.WorkspaceNode(
13
- id="1",
14
- type="node_type",
15
- data=workspace.WorkspaceNodeData(title="Non existing op", params={}),
16
- position=workspace.Position(x=0, y=0),
17
- )
18
  )
19
  await execute(ws)
20
  assert ws.nodes[0].data.error == "Operation not found in catalog"
@@ -43,37 +41,29 @@ async def test_execute_operation_inputs_correct_cast():
43
  return bundle
44
 
45
  ws = workspace.Workspace(env="test")
46
- ws.nodes.append(
47
- workspace.WorkspaceNode(
48
- id="1",
49
- type="node_type",
50
- data=workspace.WorkspaceNodeData(title="Create Bundle", params={}),
51
- position=workspace.Position(x=0, y=0),
52
- )
53
  )
54
- ws.nodes.append(
55
- workspace.WorkspaceNode(
56
- id="2",
57
- type="node_type",
58
- data=workspace.WorkspaceNodeData(title="Bundle to Graph", params={}),
59
- position=workspace.Position(x=100, y=0),
60
- )
61
  )
62
- ws.nodes.append(
63
- workspace.WorkspaceNode(
64
- id="3",
65
- type="node_type",
66
- data=workspace.WorkspaceNodeData(title="Graph to Bundle", params={}),
67
- position=workspace.Position(x=200, y=0),
68
- )
69
  )
70
- ws.nodes.append(
71
- workspace.WorkspaceNode(
72
- id="4",
73
- type="node_type",
74
- data=workspace.WorkspaceNodeData(title="Dataframe to Bundle", params={}),
75
- position=workspace.Position(x=300, y=0),
76
- )
77
  )
78
  ws.edges = [
79
  workspace.WorkspaceEdge(
@@ -109,29 +99,23 @@ async def test_multiple_inputs():
109
  return a < b
110
 
111
  ws = workspace.Workspace(env="test")
112
- ws.nodes.append(
113
- workspace.WorkspaceNode(
114
- id="one",
115
- type="cool",
116
- data=workspace.WorkspaceNodeData(title="One", params={}),
117
- position=workspace.Position(x=0, y=0),
118
- )
119
  )
120
- ws.nodes.append(
121
- workspace.WorkspaceNode(
122
- id="two",
123
- type="cool",
124
- data=workspace.WorkspaceNodeData(title="Two", params={}),
125
- position=workspace.Position(x=100, y=0),
126
- )
127
  )
128
- ws.nodes.append(
129
- workspace.WorkspaceNode(
130
- id="smaller",
131
- type="cool",
132
- data=workspace.WorkspaceNodeData(title="Smaller?", params={}),
133
- position=workspace.Position(x=200, y=0),
134
- )
135
  )
136
  ws.edges = [
137
  workspace.WorkspaceEdge(
 
8
 
9
  async def test_execute_operation_not_in_catalog():
10
  ws = workspace.Workspace(env=ENV)
11
+ ws.add_node(
12
+ id="1",
13
+ type="node_type",
14
+ data=workspace.WorkspaceNodeData(title="Non existing op", params={}),
15
+ position=workspace.Position(x=0, y=0),
 
 
16
  )
17
  await execute(ws)
18
  assert ws.nodes[0].data.error == "Operation not found in catalog"
 
41
  return bundle
42
 
43
  ws = workspace.Workspace(env="test")
44
+ ws.add_node(
45
+ id="1",
46
+ type="node_type",
47
+ data=workspace.WorkspaceNodeData(title="Create Bundle", params={}),
48
+ position=workspace.Position(x=0, y=0),
 
 
49
  )
50
+ ws.add_node(
51
+ id="2",
52
+ type="node_type",
53
+ data=workspace.WorkspaceNodeData(title="Bundle to Graph", params={}),
54
+ position=workspace.Position(x=100, y=0),
 
 
55
  )
56
+ ws.add_node(
57
+ id="3",
58
+ type="node_type",
59
+ data=workspace.WorkspaceNodeData(title="Graph to Bundle", params={}),
60
+ position=workspace.Position(x=200, y=0),
 
 
61
  )
62
+ ws.add_node(
63
+ id="4",
64
+ type="node_type",
65
+ data=workspace.WorkspaceNodeData(title="Dataframe to Bundle", params={}),
66
+ position=workspace.Position(x=300, y=0),
 
 
67
  )
68
  ws.edges = [
69
  workspace.WorkspaceEdge(
 
99
  return a < b
100
 
101
  ws = workspace.Workspace(env="test")
102
+ ws.add_node(
103
+ id="one",
104
+ type="cool",
105
+ data=workspace.WorkspaceNodeData(title="One", params={}),
106
+ position=workspace.Position(x=0, y=0),
 
 
107
  )
108
+ ws.add_node(
109
+ id="two",
110
+ type="cool",
111
+ data=workspace.WorkspaceNodeData(title="Two", params={}),
112
+ position=workspace.Position(x=100, y=0),
 
 
113
  )
114
+ ws.add_node(
115
+ id="smaller",
116
+ type="cool",
117
+ data=workspace.WorkspaceNodeData(title="Smaller?", params={}),
118
+ position=workspace.Position(x=200, y=0),
 
 
119
  )
120
  ws.edges = [
121
  workspace.WorkspaceEdge(
lynxkite-graph-analytics/tests/test_pytorch_model_ops.py CHANGED
@@ -9,16 +9,9 @@ def make_ws(env, nodes: dict[str, dict], edges: list[tuple[str, str]]):
9
  for id, data in nodes.items():
10
  title = data["title"]
11
  del data["title"]
12
- ws.nodes.append(
13
- workspace.WorkspaceNode(
14
- id=id,
15
- type="basic",
16
- data=workspace.WorkspaceNodeData(title=title, params=data),
17
- position=workspace.Position(
18
- x=data.get("x", 0),
19
- y=data.get("y", 0),
20
- ),
21
- )
22
  )
23
  ws.edges = [
24
  workspace.WorkspaceEdge(
 
9
  for id, data in nodes.items():
10
  title = data["title"]
11
  del data["title"]
12
+ ws.add_node(
13
+ id=id,
14
+ data=workspace.WorkspaceNodeData(title=title, params=data),
 
 
 
 
 
 
 
15
  )
16
  ws.edges = [
17
  workspace.WorkspaceEdge(
mkdocs.yml CHANGED
@@ -4,12 +4,14 @@ repo_name: lynxkite/lynxkite-2000
4
  watch: [mkdocs.yml, README.md, lynxkite-core, lynxkite-graph-analytics, lynxkite-app]
5
 
6
  nav:
7
- - Home:
8
  - Overview: index.md
9
- - License: license.md
10
- - Usage:
11
- - usage/quickstart.md
12
- - usage/plugins.md
 
 
13
  - API reference:
14
  - LynxKite Core:
15
  - reference/lynxkite-core/ops.md
 
4
  watch: [mkdocs.yml, README.md, lynxkite-core, lynxkite-graph-analytics, lynxkite-app]
5
 
6
  nav:
7
+ - About LynxKite:
8
  - Overview: index.md
9
+ - contributing.md
10
+ - license.md
11
+ - Guides:
12
+ - guides/quickstart.md
13
+ - guides/analytics.md
14
+ - guides/plugins.md
15
  - API reference:
16
  - LynxKite Core:
17
  - reference/lynxkite-core/ops.md