From 93108f917a1df832d57a1c72f7d6335635313d0e Mon Sep 17 00:00:00 2001 From: Sam Bland Date: Tue, 3 Nov 2026 14:53:40 +0000 Subject: [PATCH 01/15] Minor readme and project toml amends --- README.md | 11 +++++++++-- pyproject.toml | 10 ++-------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index d8b84f5..2d13e12 100644 --- a/README.md +++ b/README.md @@ -72,8 +72,7 @@ inputs and outputs connect to the surrounding data: `I(value, as_="argument_name")` entries; the `as_` value becomes the corresponding keyword argument to the function. - **Outputs** are provided by `state_outputs`. The callable receives the - function result and returns `(value, "target.path")` pairs to write into - state. + function result and returns `(value, "target.path")` pairs to write int state. - **Other input sources** include `config_inputs`, `parameters_inputs`, `external_state_inputs`, and `additional_inputs`. - **Process options** include `gate` to skip a process, `comment` and `group` @@ -114,3 +113,11 @@ ruff format . --check ## License proFlow is distributed under the terms of the [MIT License](./LICENSE). + +## Build and Publish + +The publish workflow builds the distributions and publishes them to PyPI using trusted publishing. + +Use `build_and_deploy.sh patch|minor|major` to bump the version and trigger the publish workflow. + +Configure a PyPI trusted publisher for this GitHub repository with workflow filename `publish.yml` and environment `pypi` before pushing the tag. No PyPI API token is needed. diff --git a/pyproject.toml b/pyproject.toml index 4bac615..dc64d4f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,11 +5,7 @@ description = "Functional micro pipeline manager" authors = [{ "name" = "Sam Bland" }] readme = "README.md" requires-python = ">=3.14" -dependencies = [ - "data-helpers>=0.2.1", - "dill>=0.4.1", - "numpy>=1.18.3", -] +dependencies = ["data-helpers>=0.2.1", "dill>=0.4.1", "numpy>=1.18.3"] [dependency-groups] dev = [ "pytest>=8.3.3", @@ -41,9 +37,7 @@ ci = [ "decorator>=5.0.0", ] -web = [ - "flask>=2.3.0", -] +web = ["flask>=2.3.0"] [build-system] requires = ["hatchling"] From 21c19cee2a00d29fc1b5f2a6956856873c8045a9 Mon Sep 17 00:00:00 2001 From: Sam Bland Date: Tue, 3 Nov 2026 15:43:10 +0000 Subject: [PATCH 02/15] Fix parsing interface instance when not using _as kwarg --- proflow/process_inspector.py | 62 ++++++++++++++----------- proflow/tests/process_inspector_test.py | 11 +++++ 2 files changed, 46 insertions(+), 27 deletions(-) diff --git a/proflow/process_inspector.py b/proflow/process_inspector.py index a4a72dd..d812e6f 100644 --- a/proflow/process_inspector.py +++ b/proflow/process_inspector.py @@ -676,9 +676,14 @@ def parse_input_map_to_arg(inp: ast.Call) -> str: return "" # TODO: Should throw an error! raise NotImplementedError("Starred not implemented") - assert len(inp.keywords) >= 0 and inp.keywords[0].arg == "as_", "first keyword should be as_" - as_arg = inp.keywords[0] - return parse_as_arg(as_arg.value) + + if len(inp.keywords) == 0 or inp.keywords[0].arg != "as_": + # Assume second arg is the as_ value + if len(inp.args) >= 2: + return parse_as_arg(inp.args[1]) + return "" + else: + return parse_as_arg(inp.keywords[0].value) def get_inputs(input_list: ast.List) -> ArgMap: @@ -796,7 +801,9 @@ def parse_inputs( return "FAILED_TO_PARSE_PROCESS" -def parse_outputs_b(map_inputs_fn: Callable[[any], list[I]]) -> dict: +def parse_outputs_b( + map_inputs_fn: Callable[[any], list[I]], allow_errors: bool = True, silent: bool = True +) -> dict: source_code = textwrap.dedent(inspect.getsource(map_inputs_fn)) try: ast_tree = ast.parse(source_code) @@ -810,7 +817,7 @@ def parse_outputs_b(map_inputs_fn: Callable[[any], list[I]]) -> dict: def parse_outputs( map_outputs_fn: Callable[[any], list[I]], allow_errors: bool = True, - silent=True, + silent: bool = True, ) -> dict: try: output_lines = parse_outputs_to_interface(map_outputs_fn, allow_errors) @@ -830,42 +837,43 @@ def fieldNotEmpty(f: any | Callable) -> bool: return f and callable(f) -def inspect_process(process: "Process"): - Parsed = namedtuple( - "Parsed", "config_inputs state_inputs parameter_inputs additional_inputs state_outputs" - ) +Parsed = namedtuple( + "Parsed", "config_inputs state_inputs parameter_inputs additional_inputs state_outputs" +) + + +def inspect_process(process: "Process", allow_errors: bool = True, silent: bool = True) -> Parsed: try: return Parsed( config_inputs=fieldNotEmpty(process.config_inputs) - and parse_inputs(process.config_inputs), - state_inputs=fieldNotEmpty(process.state_inputs) and parse_inputs(process.state_inputs), + and parse_inputs(process.config_inputs, allow_errors, silent), + state_inputs=fieldNotEmpty(process.state_inputs) + and parse_inputs(process.state_inputs, allow_errors, silent), parameter_inputs=fieldNotEmpty(process.state_inputs) - and parse_inputs(process.state_inputs), + and parse_inputs(process.state_inputs, allow_errors, silent), additional_inputs=fieldNotEmpty(process.additional_inputs) - and parse_inputs(process.additional_inputs), + and parse_inputs(process.additional_inputs, allow_errors, silent), state_outputs=fieldNotEmpty(process.state_outputs) - and parse_outputs(process.state_outputs), + and parse_outputs(process.state_outputs, allow_errors, silent), ) - except Exception as e: # noqa: BLE001 - normalize parsing failures into the process-level error - raise ProflowParsingError(str(e), process) + except Exception as e: + raise ProflowParsingError(str(e), process) from e -def inspect_process_to_interfaces(process: "Process"): - Parsed = namedtuple( - "Parsed", "config_inputs state_inputs parameter_inputs additional_inputs state_outputs" - ) +def inspect_process_to_interfaces(process: "Process", allow_errors: bool = True) -> Parsed: + try: return Parsed( config_inputs=fieldNotEmpty(process.config_inputs) - and parse_inputs_to_interface(process.config_inputs), + and parse_inputs_to_interface(process.config_inputs, allow_errors), state_inputs=fieldNotEmpty(process.state_inputs) - and parse_inputs_to_interface(process.state_inputs), + and parse_inputs_to_interface(process.state_inputs, allow_errors), parameter_inputs=fieldNotEmpty(process.state_inputs) - and parse_inputs_to_interface(process.state_inputs), + and parse_inputs_to_interface(process.state_inputs, allow_errors), additional_inputs=fieldNotEmpty(process.additional_inputs) - and parse_inputs_to_interface(process.additional_inputs), + and parse_inputs_to_interface(process.additional_inputs, allow_errors), state_outputs=fieldNotEmpty(process.state_outputs) - and parse_outputs_to_interface(process.state_outputs), + and parse_outputs_to_interface(process.state_outputs, allow_errors), ) - except Exception as e: # noqa: BLE001 - normalize parsing failures into the process-level error - raise ProflowParsingError(str(e), process) + except Exception as e: + raise ProflowParsingError(str(e), process) from e diff --git a/proflow/tests/process_inspector_test.py b/proflow/tests/process_inspector_test.py index 0e7348a..e06d944 100644 --- a/proflow/tests/process_inspector_test.py +++ b/proflow/tests/process_inspector_test.py @@ -338,6 +338,17 @@ def test_example(self): "row_count": "state.row_index", } + def test_not_using_as(self): + DEMO_INPUTS = lambda config: [ + I(config.foo, "bar"), + ] + + out = parse_inputs(DEMO_INPUTS, False) + + assert out == { + "bar": "config.foo", + } + # def test_parse_inputs_check(self): # nL = 1 # nLC = 2 From f93652d55bee52aae4edd4454d8e781530d006de Mon Sep 17 00:00:00 2001 From: Sam Bland Date: Tue, 3 Nov 2026 15:59:49 +0000 Subject: [PATCH 03/15] Handle keys as parse output targets For example state_outputs=lambda result, k=k:[ (result, k), ] parses to {"result": "state.k"} --- proflow/process_inspector.py | 2 + proflow/tests/process_inspector_test.py | 87 ++++++++++++++----------- 2 files changed, 50 insertions(+), 39 deletions(-) diff --git a/proflow/process_inspector.py b/proflow/process_inspector.py index d812e6f..42e7046 100644 --- a/proflow/process_inspector.py +++ b/proflow/process_inspector.py @@ -706,6 +706,8 @@ def parse_output_target(out_el): elif isinstance(out_el, ast.JoinedStr): elts = list(map(parse_output_target, out_el.values)) return ".".join(elts) + elif isinstance(out_el, ast.Name): + return out_el.id else: raise NotImplementedError(f"Ast type not implemented: {type(out_el)}") diff --git a/proflow/tests/process_inspector_test.py b/proflow/tests/process_inspector_test.py index e06d944..7f2a892 100644 --- a/proflow/tests/process_inspector_test.py +++ b/proflow/tests/process_inspector_test.py @@ -442,47 +442,43 @@ def test_parse_key(): assert out == "config.a.foo.0.[var].bar" -def test_parse_outputs(): - """Test parse_outputs returns correct value.""" - DEMO_OUTPUTS = lambda result: [ - (result.a.foo.bar, "x"), - (result.a.foo[0], "y"), - ] - out = parse_outputs(DEMO_OUTPUTS) - assert out == {"result.a.foo.bar": "state.x", "result.a.foo.0": "state.y"} - - -def test_parse_outputs_list_comprehension(): - """Test parse_outputs returns correct value.""" - DEMO_OUTPUTS = lambda result: [ - (result[iL][iLC], f"foo.{iL}.{iLC}.bar") for iL in range(3) for iLC in range(3) - ] - out = parse_outputs(DEMO_OUTPUTS) - assert out == {"result.[iL].[iLC]": 'state.f"foo.{iL}.{iLC}.bar'} - - -def test_parse_outputs_list_comprehension_inputed_index(): - """Test parse_outputs returns correct value.""" - iLC = 0 - DEMO_OUTPUTS = lambda result, iLC=iLC: [ - (result[iL][iLC], f"foo.{iL}.{iLC}.bar") for iL in range(3) - ] - out = parse_outputs(DEMO_OUTPUTS, False) - assert out == {"result.[iL].[iLC]": 'state.f"foo.{iL}.{iLC}.bar'} - - -@pytest.mark.skip(reason="functions in output section not currently supported") -def test_parse_outputs_deep_copy(): - """Test parse_outputs returns correct output.""" - deepcopy = lambda: None - DEMO_OUTPUTS = lambda result: [ - (deepcopy(result["a"]), "a"), - ] - out = parse_outputs(DEMO_OUTPUTS, False) - assert out == {"deepcopy(result.a.)": "state.a"} +class TestParseOutputs: + def test_parse_outputs(self): + """Test parse_outputs returns correct value.""" + DEMO_OUTPUTS = lambda result: [ + (result.a.foo.bar, "x"), + (result.a.foo[0], "y"), + ] + out = parse_outputs(DEMO_OUTPUTS) + assert out == {"result.a.foo.bar": "state.x", "result.a.foo.0": "state.y"} + def test_parse_outputs_list_comprehension(self): + """Test parse_outputs returns correct value.""" + DEMO_OUTPUTS = lambda result: [ + (result[iL][iLC], f"foo.{iL}.{iLC}.bar") for iL in range(3) for iLC in range(3) + ] + out = parse_outputs(DEMO_OUTPUTS) + assert out == {"result.[iL].[iLC]": 'state.f"foo.{iL}.{iLC}.bar'} + + def test_parse_outputs_list_comprehension_inputed_index(self): + """Test parse_outputs returns correct value.""" + iLC = 0 + DEMO_OUTPUTS = lambda result, iLC=iLC: [ + (result[iL][iLC], f"foo.{iL}.{iLC}.bar") for iL in range(3) + ] + out = parse_outputs(DEMO_OUTPUTS, False) + assert out == {"result.[iL].[iLC]": 'state.f"foo.{iL}.{iLC}.bar'} + + @pytest.mark.skip(reason="functions in output section not currently supported") + def test_parse_outputs_deep_copy(self): + """Test parse_outputs returns correct output.""" + deepcopy = lambda: None + DEMO_OUTPUTS = lambda result: [ + (deepcopy(result["a"]), "a"), + ] + out = parse_outputs(DEMO_OUTPUTS, False) + assert out == {"deepcopy(result.a.)": "state.a"} -class TestParseOutputs: def test_parse_outputs_to_interface(self): DEMO_OUTPUTS = lambda result: [ (result["a"], "a"), @@ -516,6 +512,19 @@ def test_parse_with_star_arg(self): assert output_interface[0].from_ == 'result["a"]' assert output_interface[0].as_ == "state.a" + def test_parse_outputs_with_dynamic_key(self): + """Test parse_outputs with dynamic key. + + This doesn't work perfectly as we just use the variable name "k" instead of its value. + """ + k = "key1" + state_outputs = lambda result, k=k: [ + (result, k), + ] + + out = parse_outputs(state_outputs, allow_errors=False) + assert out == {"result": "state.k"} + class TestParseArg: def test_parse_max(self): From 80fd93ba89eb642b964e2e706097756edd095157 Mon Sep 17 00:00:00 2001 From: Sam Bland Date: Tue, 3 Nov 2026 16:00:09 +0000 Subject: [PATCH 04/15] Fix run app should only run automatically if __name__ == "__main"" --- proflow/analysis/webview/run_app.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/proflow/analysis/webview/run_app.py b/proflow/analysis/webview/run_app.py index febfbb8..896b0b4 100644 --- a/proflow/analysis/webview/run_app.py +++ b/proflow/analysis/webview/run_app.py @@ -20,4 +20,5 @@ def static_proxy_c(text): app.run(port=port) -run() +if __name__ == "__main__": + run() From 968e4fb8ef672e6c4e5c363c11df7c8b6827ea5b Mon Sep 17 00:00:00 2001 From: Sam Bland Date: Tue, 3 Nov 2026 16:04:43 +0000 Subject: [PATCH 05/15] Add export processes to json function --- proflow/analysis/export.py | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 proflow/analysis/export.py diff --git a/proflow/analysis/export.py b/proflow/analysis/export.py new file mode 100644 index 0000000..b19aa91 --- /dev/null +++ b/proflow/analysis/export.py @@ -0,0 +1,37 @@ +"""Export process analysis results to the force webview JSON format.""" + +from dataclasses import asdict + +from proflow.analysis.network.extract_nodes_and_edges import Edge, Node + + +def processes_to_json( + nodes_and_edges: tuple[list[Node], list[Edge]], +) -> dict[str, object]: + """Write process nodes and edges as force webview node-link JSON. + + The returned graph data is also written to ``output_file``. Unresolved + edges, whose ``target`` is ``-1``, are omitted from the links. + """ + nodes, edges = nodes_and_edges + graph_data: dict[str, object] = { + "directed": True, + "multigraph": True, + "graph": {}, + "nodes": [{**asdict(node), "id": node.index} for node in nodes], + "links": [ + { + "source": edge.target, + "target": edge.source, + "name": edge.name, + "key": 0, + "id": link_id, + } + for link_id, edge in enumerate( + (edge for edge in edges if edge.target != -1), + start=1, + ) + ], + } + + return graph_data From ef58333def497086cb3fbcd22e6b1f1e43befff5 Mon Sep 17 00:00:00 2001 From: Sam Bland Date: Tue, 3 Nov 2026 22:22:24 +0000 Subject: [PATCH 06/15] Update run_app to support custom data location --- proflow/analysis/webview/run_app.py | 19 +++++++++++++------ .../analysis/webview/static/force/force.js | 2 +- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/proflow/analysis/webview/run_app.py b/proflow/analysis/webview/run_app.py index 896b0b4..ff991d6 100644 --- a/proflow/analysis/webview/run_app.py +++ b/proflow/analysis/webview/run_app.py @@ -1,20 +1,27 @@ +import json +from pathlib import Path + import flask +WEBVIEW_STATIC_FOLDER = Path(__file__).resolve().parent / "static" + def run(port=8000, datalocation="force/data.json"): - app = flask.Flask(__name__, static_folder="static") + app = flask.Flask(__name__, static_folder=WEBVIEW_STATIC_FOLDER) @app.route("/") - def static_proxy(): + def root(): return app.send_static_file("force/force.html") @app.route("/force/") - def static_proxy_b(filename): + def static_force_assets(filename): return app.send_static_file("force/" + filename) - @app.route("/data/") - def static_proxy_c(text): - return app.send_static_file(datalocation) + @app.route("/data") + def data_file(): + with open(datalocation) as f: + data = json.load(f) + return flask.jsonify(data) print(f"\nGo to http://localhost:{port} to see the example\n") app.run(port=port) diff --git a/proflow/analysis/webview/static/force/force.js b/proflow/analysis/webview/static/force/force.js index 377a748..c74b392 100644 --- a/proflow/analysis/webview/static/force/force.js +++ b/proflow/analysis/webview/static/force/force.js @@ -122,7 +122,7 @@ var simulation = d3.forceSimulation() .force("center", d3.forceCenter(width / 2, height / 2)); -d3.json("/data/force.json", function (error, graph) { +d3.json("/data", function (error, graph) { // d3.json("force/force.json", function (error, graph) { if (error) throw error; var linksData = graph.links From 0f306da016e6a4ca4f35185fe610a25ad9353a04 Mon Sep 17 00:00:00 2001 From: Sam Bland Date: Tue, 3 Nov 2026 22:28:31 +0000 Subject: [PATCH 07/15] Add control panel with search field to web view --- CHANGELOG.md | 19 ++++++ proflow/analysis/README.md | 15 +++++ .../analysis/webview/static/force/force.css | 63 +++++++++++++++++++ .../analysis/webview/static/force/force.html | 13 +++- .../analysis/webview/static/force/force.js | 47 +++++++++++++- 5 files changed, 154 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ecbca50..bb3aa4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ ## 2026-11-03 +- Date: 2026-11-03 +- Summary: Add node search controls to the process network web view. +- Added: + - An accessible node search panel with a live count of visible nodes. +- Changed: + - The web view hides nonmatching nodes and links connected to hidden nodes. +- Fixed: + - None. +- Docs: + - Documented how to run the web view and use its node filter. +- Tests: + - Verified case-insensitive name matching, ID matching, edge filtering, and clearing the search in the browser. + - Project tests passed: 116 passed, 7 skipped. +- Lint/Format: + - Ruff checks and formatting checks passed for the related Python modules. +- Credits: Copilot SDK in VS Code. + +## 2026-11-03 + - Date: 2026-11-03 - Summary: Restructure the README as user-focused Python library documentation. - Added: diff --git a/proflow/analysis/README.md b/proflow/analysis/README.md index 064770e..0113587 100644 --- a/proflow/analysis/README.md +++ b/proflow/analysis/README.md @@ -122,6 +122,21 @@ though an `Edge` record's `source` field identifies the consumer and its (`target == -1`) are omitted from the exported graph. The function returns the same node-link data it writes. +## Explore a network in the web view + +The optional Flask web view displays the process network as an interactive +force-directed graph. Install Flask if needed, then run it from the project root: + +```bash +python -m pip install flask +python -m proflow.analysis.webview.run_app +``` + +Open the printed local URL and use the **Search nodes** field to match node IDs, +function names, or process descriptions without regard to case. The graph hides +nonmatching nodes and links that connect to hidden nodes; clearing the search +restores the full network. + ## Dependencies The canvas workflow requires `ipycanvas`. JSON export requires `networkx`. diff --git a/proflow/analysis/webview/static/force/force.css b/proflow/analysis/webview/static/force/force.css index f0d5bbb..90eb55c 100644 --- a/proflow/analysis/webview/static/force/force.css +++ b/proflow/analysis/webview/static/force/force.css @@ -1,3 +1,66 @@ +html, +body { + width: 100%; + height: 100%; + margin: 0; +} + +body { + overflow: hidden; + font-family: system-ui, sans-serif; +} + +svg { + display: block; + width: 100%; + height: 100%; +} + +.control-panel { + position: fixed; + z-index: 1; + top: 16px; + left: 16px; + width: min(320px, calc(100vw - 64px)); + padding: 16px; + border: 1px solid #d5d9de; + border-radius: 8px; + background: #fff; + box-shadow: 0 2px 10px rgb(0 0 0 / 14%); +} + +.control-panel h1 { + margin: 0 0 12px; + font-size: 16px; +} + +.control-panel label { + display: block; + margin-bottom: 6px; + font-size: 13px; + font-weight: 600; +} + +.control-panel input { + box-sizing: border-box; + width: 100%; + padding: 9px 10px; + border: 1px solid #aeb5bd; + border-radius: 4px; + font: inherit; +} + +.control-panel input:focus-visible { + outline: 2px solid #2563eb; + outline-offset: 2px; +} + +.control-panel p { + margin: 10px 0 0; + color: #555; + font-size: 12px; +} + .nodes circle { cursor: pointer; fill: #3ace15; diff --git a/proflow/analysis/webview/static/force/force.html b/proflow/analysis/webview/static/force/force.html index e74633e..0061dc7 100644 --- a/proflow/analysis/webview/static/force/force.html +++ b/proflow/analysis/webview/static/force/force.html @@ -14,7 +14,18 @@ - + + diff --git a/proflow/analysis/webview/static/force/force.js b/proflow/analysis/webview/static/force/force.js index c74b392..5af2e8c 100644 --- a/proflow/analysis/webview/static/force/force.js +++ b/proflow/analysis/webview/static/force/force.js @@ -96,7 +96,12 @@ var svg = d3.select("svg"), height = +svg.attr("height"), node, link, - pathInvis; + pathInvis, + pathLabel; + +var nodeFilter = d3.select("#node-filter"), + filterSummary = d3.select("#filter-summary"), + totalNodeCount = 0; svg.append('defs') .append('marker') @@ -132,6 +137,8 @@ d3.json("/data", function (error, graph) { }); function update(links, nodes) { + totalNodeCount = nodes.length; + link = svg.selectAll(".link") .data(links) .enter() @@ -153,7 +160,7 @@ function update(links, nodes) { }) .attr("class", "pathInvis") - var pathLabel = svg.selectAll(".pathLabel") + pathLabel = svg.selectAll(".pathLabel") .data(links) .enter() // .append("g") @@ -196,6 +203,42 @@ function update(links, nodes) { simulation.force("link") .links(links); + + nodeFilter.on("input", applyNodeFilter); + applyNodeFilter(); +} + +/** + * Show nodes matching the search and only links between visible nodes. + */ +function applyNodeFilter() { + var query = nodeFilter.property("value").trim().toLowerCase(); + var visibleIds = new Set(); + + node.each(function (d) { + var matches = !query || + [d.id, d.name, d.text].join(" ").toLowerCase().includes(query); + if (matches) { + visibleIds.add(d.id); + } + d3.select(this).style("display", matches ? null : "none"); + }); + + /** Return whether both endpoints are currently visible. */ + function linkIsVisible(d) { + return visibleIds.has(d.source.id) && visibleIds.has(d.target.id); + } + + link.style("display", function (d) { + return linkIsVisible(d) ? null : "none"; + }); + pathInvis.style("display", function (d) { + return linkIsVisible(d) ? null : "none"; + }); + pathLabel.style("display", function (d) { + return linkIsVisible(d) ? null : "none"; + }); + filterSummary.text(visibleIds.size + " of " + totalNodeCount + " nodes shown"); } From d81a54ed96822e47631da497446ecb785ad0b7f6 Mon Sep 17 00:00:00 2001 From: Sam Bland Date: Wed, 4 Nov 2026 08:17:33 +0000 Subject: [PATCH 08/15] Make nodes in web view use rect instead of dot. --- CHANGELOG.md | 57 +++++++++++++ proflow/analysis/README.md | 5 +- proflow/analysis/webview/run_app.py | 8 +- .../analysis/webview/static/force/force.css | 42 +++++++++- .../analysis/webview/static/force/force.html | 11 +++ .../analysis/webview/static/force/force.js | 79 ++++++++++++++++--- 6 files changed, 188 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb3aa4a..9655fc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,62 @@ # Changelog +## 2026-11-04 + +- Date: 2026-11-04 +- Summary: Keep node labels visible above their shapes in the web view. +- Added: + - None. +- Changed: + - Node shapes are inserted before labels in SVG rendering order. +- Fixed: + - Rectangle nodes no longer cover their labels. +- Docs: + - Clarified that labels remain visible in both node-shape modes. +- Tests: + - Verified node SVG stacking and visible labels in the browser. +- Lint/Format: + - Not applicable to JavaScript and Markdown changes. +- Credits: Copilot SDK in VS Code. + +## 2026-11-03 + +- Date: 2026-11-03 +- Summary: Add a control-panel switch for box and dot graph nodes. +- Added: + - Accessible radio controls for selecting the node shape. +- Changed: + - The graph redraws node shapes, labels, and link endpoints when the selection + changes. +- Fixed: + - None. +- Docs: + - Documented the node-shape switch in the process analysis guide. +- Tests: + - Verified switching between boxes and dots, plus their link endpoints, in + the browser. +- Lint/Format: + - Not applicable to JavaScript, CSS, and Markdown changes. +- Credits: Copilot SDK in VS Code. + +## 2026-11-03 + +- Date: 2026-11-03 +- Summary: Render web-view process nodes as boxes with directional edge ports. +- Added: + - Rectangular process nodes with centered labels. +- Changed: + - Incoming links connect to the left side of a node and outgoing links leave + from its right side. +- Fixed: + - None. +- Docs: + - Documented the box and edge direction in the process analysis guide. +- Tests: + - Verified rendered node shapes and directional link endpoints in the browser. +- Lint/Format: + - Not applicable to JavaScript, CSS, and Markdown changes. +- Credits: Copilot SDK in VS Code. + ## 2026-11-03 - Date: 2026-11-03 diff --git a/proflow/analysis/README.md b/proflow/analysis/README.md index 0113587..9c2fef3 100644 --- a/proflow/analysis/README.md +++ b/proflow/analysis/README.md @@ -135,7 +135,10 @@ python -m proflow.analysis.webview.run_app Open the printed local URL and use the **Search nodes** field to match node IDs, function names, or process descriptions without regard to case. The graph hides nonmatching nodes and links that connect to hidden nodes; clearing the search -restores the full network. +restores the full network. Use **Node shape** to switch between labeled boxes +(the default) and dots. In box mode, incoming links connect to the left edge +and outgoing links leave the right edge. Node labels remain visible in both +modes. ## Dependencies diff --git a/proflow/analysis/webview/run_app.py b/proflow/analysis/webview/run_app.py index ff991d6..ec0f39e 100644 --- a/proflow/analysis/webview/run_app.py +++ b/proflow/analysis/webview/run_app.py @@ -1,4 +1,5 @@ import json +import os from pathlib import Path import flask @@ -6,7 +7,7 @@ WEBVIEW_STATIC_FOLDER = Path(__file__).resolve().parent / "static" -def run(port=8000, datalocation="force/data.json"): +def run(port=8000, datalocation=f"{WEBVIEW_STATIC_FOLDER}/force/data.json"): app = flask.Flask(__name__, static_folder=WEBVIEW_STATIC_FOLDER) @app.route("/") @@ -19,6 +20,11 @@ def static_force_assets(filename): @app.route("/data") def data_file(): + if not Path(datalocation).exists(): + print(f"Data file not found at {datalocation}") + print(os.getcwd()) + print(os.listdir(os.getcwd())) + return flask.jsonify({"error": "Data file not found"}), 404 with open(datalocation) as f: data = json.load(f) return flask.jsonify(data) diff --git a/proflow/analysis/webview/static/force/force.css b/proflow/analysis/webview/static/force/force.css index 90eb55c..18df24d 100644 --- a/proflow/analysis/webview/static/force/force.css +++ b/proflow/analysis/webview/static/force/force.css @@ -41,7 +41,7 @@ svg { font-weight: 600; } -.control-panel input { +.control-panel input[type="search"] { box-sizing: border-box; width: 100%; padding: 9px 10px; @@ -50,6 +50,32 @@ svg { font: inherit; } +.node-style-options { + margin: 14px 0 0; + padding: 0; + border: 0; +} + +.node-style-options legend { + margin-bottom: 6px; + font-size: 13px; + font-weight: 600; +} + +.node-style-options label { + display: inline-flex; + align-items: center; + gap: 6px; + margin: 0 12px 0 0; + font-weight: normal; +} + +.node-style-options input[type="radio"] { + width: auto; + margin: 0; + padding: 0; +} + .control-panel input:focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; @@ -61,13 +87,23 @@ svg { font-size: 12px; } -.nodes circle { +.node rect { + cursor: pointer; + stroke: #000; + stroke-width: .5px; +} + +.node circle { cursor: pointer; - fill: #3ace15; stroke: #000; stroke-width: .5px; } +.node text { + font-size: 12px; + pointer-events: none; +} + .links line{ fill: none; stroke: #9ecae1; diff --git a/proflow/analysis/webview/static/force/force.html b/proflow/analysis/webview/static/force/force.html index 0061dc7..8602d50 100644 --- a/proflow/analysis/webview/static/force/force.html +++ b/proflow/analysis/webview/static/force/force.html @@ -23,6 +23,17 @@

Node filter

placeholder="Search by name, description, or ID" autocomplete="off" /> +
+ Node shape + + +

Loading nodes...

diff --git a/proflow/analysis/webview/static/force/force.js b/proflow/analysis/webview/static/force/force.js index 5af2e8c..958fe70 100644 --- a/proflow/analysis/webview/static/force/force.js +++ b/proflow/analysis/webview/static/force/force.js @@ -7,6 +7,10 @@ https://bl.ocks.org/mattkohl/146d301c0fc20d89d85880df537de7b0 http://bl.ocks.org/fancellu/2c782394602a93921faff74e594d1bb1 */ +var NODE_WIDTH = 180, + NODE_HEIGHT = 48, + NODE_DOT_RADIUS = 5, + currentNodeShape = "box"; function sortLinks(lks) { lks.sort(function (a, b) { @@ -60,12 +64,41 @@ var getSiblingLinks = function (source, target) { }; return siblings; }; +/** + * Get source and target edge coordinates for the currently selected node shape. + */ +function nodeBoundaryPoints(d) { + var dx = d.target.x - d.source.x, + dy = d.target.y - d.source.y, + distance = Math.sqrt(dx * dx + dy * dy); + + if (currentNodeShape === "dot") { + var offset = distance ? NODE_DOT_RADIUS / distance : 0; + return { + source: { + x: d.source.x + dx * offset, + y: d.source.y + dy * offset + }, + target: { + x: d.target.x - dx * offset, + y: d.target.y - dy * offset + } + }; + } + + return { + source: { x: d.source.x + NODE_WIDTH / 2, y: d.source.y }, + target: { x: d.target.x - NODE_WIDTH / 2, y: d.target.y } + }; +} + function arcPath(leftHand, d) { // var isOddLink = d.linkindex % 2 === 0; - var x1 = (leftHand) ? d.source.x : d.target.x, - y1 = (leftHand) ? d.source.y : d.target.y, - x2 = (leftHand) ? d.target.x : d.source.x, - y2 = (leftHand) ? d.target.y : d.source.y, + var endpoints = nodeBoundaryPoints(d), + x1 = (leftHand) ? endpoints.source.x : endpoints.target.x, + y1 = (leftHand) ? endpoints.source.y : endpoints.target.y, + x2 = (leftHand) ? endpoints.target.x : endpoints.source.x, + y2 = (leftHand) ? endpoints.target.y : endpoints.source.y, dx = x2 - x1, dy = y2 - y1, dr = Math.sqrt(dx * dx + dy * dy); @@ -100,6 +133,7 @@ var svg = d3.select("svg"), pathLabel; var nodeFilter = d3.select("#node-filter"), + nodeShapeControl = d3.selectAll('input[name="node-shape"]'), filterSummary = d3.select("#filter-summary"), totalNodeCount = 0; @@ -186,15 +220,10 @@ function update(links, nodes) { //.on("end", dragended) ); - node.append("circle") - .attr("r", 5) - .style("fill", function (d, i) { return colors(i); }) - node.append("title") .text(function (d) { return d.id; }); node.append("text") - .attr("dy", -3) .text(function (d) { return d.name + ":" + d.text; }); simulation @@ -204,10 +233,42 @@ function update(links, nodes) { simulation.force("link") .links(links); + nodeShapeControl.on("change", renderNodeShape); + renderNodeShape(); nodeFilter.on("input", applyNodeFilter); applyNodeFilter(); } +/** Render the selected node shape and redraw links to match its boundaries. */ +function renderNodeShape() { + currentNodeShape = nodeShapeControl + .filter(function () { return this.checked; }) + .property("value"); + node.selectAll(".node-shape").remove(); + + if (currentNodeShape === "box") { + node.insert("rect", "title") + .attr("class", "node-shape") + .attr("x", -NODE_WIDTH / 2) + .attr("y", -NODE_HEIGHT / 2) + .attr("width", NODE_WIDTH) + .attr("height", NODE_HEIGHT) + .attr("rx", 4) + .style("fill", function (d, i) { return colors(i); }); + } else { + node.insert("circle", "title") + .attr("class", "node-shape") + .attr("r", NODE_DOT_RADIUS) + .style("fill", function (d, i) { return colors(i); }); + } + + node.select("text") + .attr("x", currentNodeShape === "box" ? 0 : NODE_DOT_RADIUS + 4) + .attr("dy", currentNodeShape === "box" ? 4 : -3) + .attr("text-anchor", currentNodeShape === "box" ? "middle" : "start"); + ticked(); +} + /** * Show nodes matching the search and only links between visible nodes. */ From 532b0a5f0f24872ea9381fbd1b36a6d95997c9c0 Mon Sep 17 00:00:00 2001 From: Sam Bland Date: Wed, 4 Nov 2026 08:57:20 +0000 Subject: [PATCH 09/15] Update web view node connection curves to use bezier curves --- CHANGELOG.md | 19 ++++++ proflow/analysis/README.md | 4 +- .../analysis/webview/static/force/force.js | 65 +++++++++++-------- 3 files changed, 60 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9655fc9..1aa00d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ ## 2026-11-04 +- Date: 2026-11-04 +- Summary: Render web-view links as perpendicular Bézier curves. +- Added: + - Cubic Bézier paths for process-network links. +- Changed: + - Box links leave the source's right edge and meet the target's left edge + horizontally. +- Fixed: + - Link paths no longer meet box edges at an angle. +- Docs: + - Documented perpendicular Bézier links in the process analysis guide. +- Tests: + - Verified rendered box-link endpoints and horizontal endpoint tangents in the browser. +- Lint/Format: + - Not applicable to JavaScript and Markdown changes. +- Credits: Copilot SDK in VS Code. + +## 2026-11-04 + - Date: 2026-11-04 - Summary: Keep node labels visible above their shapes in the web view. - Added: diff --git a/proflow/analysis/README.md b/proflow/analysis/README.md index 9c2fef3..f2c4f53 100644 --- a/proflow/analysis/README.md +++ b/proflow/analysis/README.md @@ -137,8 +137,8 @@ function names, or process descriptions without regard to case. The graph hides nonmatching nodes and links that connect to hidden nodes; clearing the search restores the full network. Use **Node shape** to switch between labeled boxes (the default) and dots. In box mode, incoming links connect to the left edge -and outgoing links leave the right edge. Node labels remain visible in both -modes. +and outgoing links leave the right edge, with cubic Bézier curves that leave +and meet the boxes perpendicularly. Node labels remain visible in both modes. ## Dependencies diff --git a/proflow/analysis/webview/static/force/force.js b/proflow/analysis/webview/static/force/force.js index 958fe70..9a19c18 100644 --- a/proflow/analysis/webview/static/force/force.js +++ b/proflow/analysis/webview/static/force/force.js @@ -92,34 +92,47 @@ function nodeBoundaryPoints(d) { }; } -function arcPath(leftHand, d) { - // var isOddLink = d.linkindex % 2 === 0; +/** + * Build a Bézier link with endpoint tangents matching the selected node shape. + * @param {boolean} leftHand Whether to draw from the source toward the target. + * @param {Object} d The graph link datum containing source and target nodes. + * @returns {string} SVG path data for the link. + */ +function bezierPath(leftHand, d) { var endpoints = nodeBoundaryPoints(d), - x1 = (leftHand) ? endpoints.source.x : endpoints.target.x, - y1 = (leftHand) ? endpoints.source.y : endpoints.target.y, - x2 = (leftHand) ? endpoints.target.x : endpoints.source.x, - y2 = (leftHand) ? endpoints.target.y : endpoints.source.y, - dx = x2 - x1, - dy = y2 - y1, - dr = Math.sqrt(dx * dx + dy * dy); - - var sweep = (leftHand) ? 0 : 1; + start = leftHand ? endpoints.source : endpoints.target, + end = leftHand ? endpoints.target : endpoints.source, + startNode = leftHand ? d.source : d.target, + endNode = leftHand ? d.target : d.source, + dx = endNode.x - startNode.x, + dy = endNode.y - startNode.y, + distance = Math.sqrt(dx * dx + dy * dy) || 1, + controlDistance = Math.max(NODE_WIDTH / 2, Math.abs(dx) / 2) + + (d.linkindex - 1) * 12, + control1, + control2; - xRotation = 0, - largeArc = 0; - - var dx = d.target.x - d.source.x, - dy = d.target.y - d.source.y, - dr = Math.sqrt(dx * dx + dy * dy); - - // get the total link numbers between source and target node - var siblingCount = mLinkNum[d.source.id + "," + d.target.id] || mLinkNum[d.target.id + "," + d.source.id]; - if (siblingCount > 1) { - // if there are multiple links between these two nodes, we need generate different dr for each path - dr = dr / (1 + (1 / (siblingCount)) * (d.linkindex - 1 - sweep)); + if (currentNodeShape === "box") { + var direction = leftHand ? 1 : -1; + control1 = { x: start.x + direction * controlDistance, y: start.y }; + control2 = { x: end.x - direction * controlDistance, y: end.y }; + } else { + var unitX = dx / distance, + unitY = dy / distance; + control1 = { + x: start.x + unitX * controlDistance, + y: start.y + unitY * controlDistance + }; + control2 = { + x: end.x - unitX * controlDistance, + y: end.y - unitY * controlDistance + }; } - return "M" + x1 + "," + y1 + "A" + dr + ", " + dr + " " + xRotation + ", " + largeArc + ", " + sweep + " " + x2 + "," + y2; + return "M" + start.x + "," + start.y + + "C" + control1.x + "," + control1.y + + " " + control2.x + "," + control2.y + + " " + end.x + "," + end.y; } var colors = d3.scaleOrdinal(d3.schemeCategory10); @@ -305,11 +318,11 @@ function applyNodeFilter() { function ticked() { link.attr("d", function (d) { - return arcPath(true, d); + return bezierPath(true, d); }); pathInvis.attr("d", function (d) { - return arcPath(d.source.x < d.target.x, d); + return bezierPath(d.source.x < d.target.x, d); }); node.attr("transform", function (d) { return "translate(" + d.x + ", " + d.y + ")"; }); From e8db9ee5c9f3a15d5fe21a7aad95cdd8a03b7eac Mon Sep 17 00:00:00 2001 From: Sam Bland Date: Wed, 4 Nov 2026 09:25:23 +0000 Subject: [PATCH 10/15] Refactor mock data and demo webview data generation --- .../network/extract_nodes_and_edges_test.py | 2 +- .../analysis/webview/demo_data_generation.py | 96 +++++++++++++++++++ .../analysis/webview/static/force/data.json | 52 +--------- proflow/tests/{demodata.py => mock_data.py} | 23 +++++ proflow/tests/process_inspector_test.py | 29 +----- 5 files changed, 125 insertions(+), 77 deletions(-) create mode 100644 proflow/analysis/webview/demo_data_generation.py rename proflow/tests/{demodata.py => mock_data.py} (78%) diff --git a/proflow/analysis/network/extract_nodes_and_edges_test.py b/proflow/analysis/network/extract_nodes_and_edges_test.py index 9e097b9..df95cba 100644 --- a/proflow/analysis/network/extract_nodes_and_edges_test.py +++ b/proflow/analysis/network/extract_nodes_and_edges_test.py @@ -1,4 +1,4 @@ -from proflow.tests.demodata import DEMO_PROCESSES +from proflow.tests.mock_data import DEMO_PROCESSES from .extract_nodes_and_edges import ( Edge, diff --git a/proflow/analysis/webview/demo_data_generation.py b/proflow/analysis/webview/demo_data_generation.py new file mode 100644 index 0000000..833a914 --- /dev/null +++ b/proflow/analysis/webview/demo_data_generation.py @@ -0,0 +1,96 @@ +# %% +import json + +from proflow.analysis.export import processes_to_json +from proflow.analysis.network.extract_nodes_and_edges import processes_to_nodes_and_edges +from proflow.analysis.webview.run_app import run +from proflow.objects.interface import I +from proflow.objects.process import Process + +DEMO_PROCESSES = [ + Process( + func=lambda x, y, z: x + y + z, + comment="Demo process a", + config_inputs=lambda config: [ + I(config.foo.bar, as_="x"), + ], + state_inputs=lambda state: [I(state.info.today, as_="y"), I(state.info.hour, as_="z")], + state_outputs=lambda result: [ + (result, "info.tomorrow"), + ], + ), + Process( + func=lambda x, y: x + y, + comment="Demo process b", + config_inputs=lambda config: [ + I(config.foo.bar, as_="x"), + ], + state_inputs=lambda state: [ + I(state.info.tomorrow, as_="y"), + ], + state_outputs=lambda result: [ + (result, "info.today"), + ], + ), + Process( + func=lambda x, y: x + y, + comment="Demo process c", + config_inputs=lambda config: [ + I(config.foo.bar, as_="x"), + ], + state_inputs=lambda state: [ + I(state.info.tomorrow, as_="y"), + ], + state_outputs=lambda result: [ + (result, "info.today"), + ], + ), + Process( + func=lambda x, y: x + y, + comment="Demo process d", + config_inputs=lambda config: [ + I(config.foo.bar, as_="x"), + ], + state_inputs=lambda state: [ + I(state.info.today, as_="y"), + ], + state_outputs=lambda result: [ + (result, "info.tomorrow"), + ], + ), +] + + +def DEMO_FUNC(x, y, z): + return x + y + z + + +DEMO_PROCESS = Process( + func=DEMO_FUNC, + comment="This is the process comment", + gate=True, + config_inputs=lambda config: [ + I(config.a, as_="x"), + ], + state_inputs=lambda state: [ + I(state.a, as_="y"), + ], + additional_inputs=lambda: [ + I(10, as_="z"), + ], + state_outputs=lambda result: [ + (result, "x"), + ], +) + + +graph_data = processes_to_json(processes_to_nodes_and_edges(DEMO_PROCESSES)) + +# %% +with open("proflow/analysis/webview/static/force/data.json", "w") as output: + json.dump(graph_data, output, indent=4) + output.write("\n") + +# %% +run(port=8002, datalocation="proflow/analysis/webview/static/force/data.json") +# %% diff --git a/proflow/analysis/webview/static/force/data.json b/proflow/analysis/webview/static/force/data.json index b2d53d4..27bfe45 100644 --- a/proflow/analysis/webview/static/force/data.json +++ b/proflow/analysis/webview/static/force/data.json @@ -34,73 +34,29 @@ "name": "", "text": "Demo process d", "id": 3 - }, - { - "index": 4, - "x": 3, - "y": 0, - "name": "", - "text": "Demo process e", - "id": 4 - }, - { - "index": 5, - "x": 3, - "y": 0, - "name": "", - "text": "Demo process f", - "id": 5 } ], "links": [ { "source": 0, "target": 1, - "name": "info.tomorrow", + "name": "state.info.tomorrow", "key": 0, "id": 1 }, { "source": 0, "target": 2, - "name": "info.tomorrow", + "name": "state.info.tomorrow", "key": 0, "id": 2 }, - { - "source": 1, - "target": 4, - "name": "info.other", - "key": 0, - "id": 3 - }, { "source": 2, "target": 3, - "name": "info.today", + "name": "state.info.today", "key": 0, - "id": 4 - }, - { - "source": 2, - "target": 4, - "name": "info.other", - "key": 0, - "id": 5 - }, - { - "source": 2, - "target": 4, - "name": "info.other.extra", - "key": 0, - "id": 6 - }, - { - "source": 4, - "target": 5, - "name": "info.other", - "key": 0, - "id": 7 + "id": 3 } ] } diff --git a/proflow/tests/demodata.py b/proflow/tests/mock_data.py similarity index 78% rename from proflow/tests/demodata.py rename to proflow/tests/mock_data.py index 0874459..e631d8c 100644 --- a/proflow/tests/demodata.py +++ b/proflow/tests/mock_data.py @@ -53,3 +53,26 @@ ], ), ] + + +def DEMO_FUNC(x, y, z): + return x + y + z + + +DEMO_PROCESS = Process( + func=DEMO_FUNC, + comment="This is the process comment", + gate=True, + config_inputs=lambda config: [ + I(config.a, as_="x"), + ], + state_inputs=lambda state: [ + I(state.a, as_="y"), + ], + additional_inputs=lambda: [ + I(10, as_="z"), + ], + state_outputs=lambda result: [ + (result, "x"), + ], +) diff --git a/proflow/tests/process_inspector_test.py b/proflow/tests/process_inspector_test.py index 7f2a892..6857fdb 100644 --- a/proflow/tests/process_inspector_test.py +++ b/proflow/tests/process_inspector_test.py @@ -19,29 +19,7 @@ reset_id, strip_out_comments, ) - - -def DEMO_FUNC(x, y, z): - return x + y + z - - -DEMO_PROCESS = Process( - func=DEMO_FUNC, - comment="This is the process comment", - gate=True, - config_inputs=lambda config: [ - I(config.a, as_="x"), - ], - state_inputs=lambda state: [ - I(state.a, as_="y"), - ], - additional_inputs=lambda: [ - I(10, as_="z"), - ], - state_outputs=lambda result: [ - (result, "x"), - ], -) +from proflow.tests.mock_data import DEMO_PROCESS @pytest.fixture(autouse=True) @@ -51,11 +29,6 @@ def clear_id(): def test_inspect_process(): process_inputs = inspect_process(DEMO_PROCESS) - # assert process_inputs.additional_inputs == {\"10\": \"z\"} - # assert process_inputs.config_inputs == {\"config.a\": \"x\"} - # assert process_inputs.parameter_inputs == {\"state.a\": \"y\"} - # assert process_inputs.state_inputs == {\"state.a\": \"y\"} - # assert process_inputs.state_outputs == {\"result\": \"state.x\"} assert process_inputs.additional_inputs == {"z": "10"} assert process_inputs.config_inputs == {"x": "config.a"} assert process_inputs.parameter_inputs == {"y": "state.a"} From 6616e7a8d52b1952455b64e3a70ccb8a064b4fbc Mon Sep 17 00:00:00 2001 From: Sam Bland Date: Wed, 4 Nov 2026 18:50:36 +0000 Subject: [PATCH 11/15] In webview enable toggle forces --- CHANGELOG.md | 20 ++++++++++++++ proflow/analysis/README.md | 10 ++++--- .../analysis/webview/static/force/force.css | 27 +++++++++++++++++++ .../analysis/webview/static/force/force.html | 9 ++++++- .../analysis/webview/static/force/force.js | 22 ++++++++++++++- 5 files changed, 82 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1aa00d7..0ce38d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,26 @@ ## 2026-11-04 +- Date: 2026-11-04 +- Summary: Add a control-panel toggle for the graph forces. +- Added: + - An accessible checkbox to pause and resume the force-directed layout. +- Changed: + - The control-panel heading now describes the graph controls. + - Dragging nodes while forces are off moves them without restarting the + simulation. +- Fixed: + - None. +- Docs: + - Documented the force toggle in the process analysis guide. +- Tests: + - Verified force simulation pause/resume and node dragging in the browser. +- Lint/Format: + - Passed the configured pre-commit checks. +- Credits: Copilot SDK in VS Code. + +## 2026-11-04 + - Date: 2026-11-04 - Summary: Render web-view links as perpendicular Bézier curves. - Added: diff --git a/proflow/analysis/README.md b/proflow/analysis/README.md index f2c4f53..e6465f6 100644 --- a/proflow/analysis/README.md +++ b/proflow/analysis/README.md @@ -135,10 +135,12 @@ python -m proflow.analysis.webview.run_app Open the printed local URL and use the **Search nodes** field to match node IDs, function names, or process descriptions without regard to case. The graph hides nonmatching nodes and links that connect to hidden nodes; clearing the search -restores the full network. Use **Node shape** to switch between labeled boxes -(the default) and dots. In box mode, incoming links connect to the left edge -and outgoing links leave the right edge, with cubic Bézier curves that leave -and meet the boxes perpendicularly. Node labels remain visible in both modes. +restores the full network. Use **Enable forces** to pause or resume the +force-directed layout, and **Node shape** to switch between labeled boxes (the +default) and dots. Dragging nodes remains available while forces are paused. +In box mode, incoming links connect to the left edge and outgoing links leave +the right edge, with cubic Bézier curves that leave and meet the boxes +perpendicularly. Node labels remain visible in both modes. ## Dependencies diff --git a/proflow/analysis/webview/static/force/force.css b/proflow/analysis/webview/static/force/force.css index 18df24d..83631c1 100644 --- a/proflow/analysis/webview/static/force/force.css +++ b/proflow/analysis/webview/static/force/force.css @@ -56,6 +56,27 @@ svg { border: 0; } +.force-options { + margin: 14px 0 0; + padding: 0; + border: 0; +} + +.force-options legend { + margin-bottom: 6px; + font-size: 13px; + font-weight: 600; +} + +.force-options label { + display: inline-flex; + align-items: center; + gap: 6px; + margin: 0; + font-size: 13px; + font-weight: normal; +} + .node-style-options legend { margin-bottom: 6px; font-size: 13px; @@ -76,6 +97,12 @@ svg { padding: 0; } +.force-options input[type="checkbox"] { + width: auto; + margin: 0; + padding: 0; +} + .control-panel input:focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; diff --git a/proflow/analysis/webview/static/force/force.html b/proflow/analysis/webview/static/force/force.html index 8602d50..59b3728 100644 --- a/proflow/analysis/webview/static/force/force.html +++ b/proflow/analysis/webview/static/force/force.html @@ -15,7 +15,7 @@