├── src └── ipyelk │ ├── py.typed │ ├── schema │ ├── README.md │ ├── __init__.py │ └── validator.py │ ├── contrib │ ├── __init__.py │ ├── library │ │ ├── __init__.py │ │ └── block.py │ └── molds │ │ ├── __init__.py │ │ ├── structures.py │ │ └── connectors.py │ ├── loaders │ ├── nx │ │ ├── __init__.py │ │ └── nxloader.py │ ├── __init__.py │ ├── element_loader.py │ ├── json.py │ └── loader.py │ ├── tools │ ├── painter.py │ ├── contol_overlay.py │ ├── __init__.py │ ├── progress.py │ ├── collapser.py │ ├── toolbar.py │ └── tool.py │ ├── diagram │ ├── __init__.py │ ├── flow.py │ ├── export.py │ ├── viewer.py │ └── sprotty_viewer.py │ ├── exceptions.py │ ├── constants.py │ ├── js.py │ ├── pipes │ ├── util.py │ ├── __init__.py │ ├── mappings.py │ ├── elkjs.py │ ├── visibility.py │ ├── flows.py │ ├── marks.py │ ├── text_sizer.py │ └── valid.py │ ├── __init__.py │ ├── trait_types.py │ ├── elements │ ├── common.py │ ├── registry.py │ ├── serialization.py │ ├── __init__.py │ ├── symbol.py │ ├── layout_options │ │ ├── __init__.py │ │ ├── layout.py │ │ └── wrapping_options.py │ └── mark_factory.py │ ├── util.py │ └── styled_widget.py ├── docs ├── changelog.md ├── contributing.md ├── _static │ ├── favicon.ico │ └── screenshots │ │ ├── activities.gif │ │ ├── activity_block.gif │ │ └── hierarchical-1-bit-memory.gif ├── rtd.yml ├── rtd.rst ├── reference │ ├── schema.rst │ ├── index.md │ ├── tools.md │ ├── widgets.md │ ├── loaders.md │ └── pipes.md ├── index.md └── conf.py ├── lite ├── overrides.json ├── jupyter-lite.json └── jupyter_lite_config.json ├── .prettierignore ├── scripts ├── __init__.py ├── not-a-package │ └── package.json ├── pytest-check-links.toml ├── watch.py ├── vale │ ├── output.tmpl │ └── config │ │ └── vocabularies │ │ └── IPyElk │ │ └── accept.txt ├── check-dist.py └── build-ext-cov.py ├── tests ├── __init__.py ├── pipes │ ├── __init__.py │ └── test_pipes.py ├── schema │ ├── __init__.py │ └── test_label_schema.py ├── elements │ ├── __init__.py │ ├── test_edges.py │ ├── test_marks.py │ └── test_nodes.py ├── test_meta.py └── conftest.py ├── examples ├── requirements.txt ├── hier_tree.json ├── flat_graph.json ├── hier_ports.json ├── 08_Simulation_App.ipynb ├── _index.ipynb ├── simple.json ├── 01_Linking.ipynb ├── 14_Text_Styling.ipynb └── 06_SVG_App_Exporter.ipynb ├── js ├── sprotty │ ├── json │ │ ├── index.ts │ │ ├── symbols.ts │ │ └── elkschema.ts │ ├── views │ │ ├── index.ts │ │ ├── symbol_views.tsx │ │ ├── graph_views.tsx │ │ └── base.tsx │ ├── update │ │ ├── index.ts │ │ ├── smodel-utils.ts │ │ └── update-model.ts │ └── sprotty-model.ts ├── index.ts ├── tools │ ├── feedback │ │ ├── index.ts │ │ ├── utils.ts │ │ ├── di.config.ts │ │ ├── model.ts │ │ └── cursor-feedback.ts │ ├── util.ts │ ├── index.ts │ ├── types.ts │ ├── tool.ts │ └── draw-aware-mouse-listener.ts ├── typings.d.ts ├── tsconfig.json ├── tsconfig.cov.json ├── patches.ts ├── tokens.ts └── plugin.ts ├── .gitignore ├── atest ├── _resources │ ├── keywords │ │ ├── CLI.robot │ │ ├── Coverage.robot │ │ ├── LabCompat.robot │ │ └── Browser.robot │ ├── variables │ │ ├── Browser.robot │ │ ├── Server.robot │ │ ├── IPyElk.robot │ │ └── Lab.robot │ └── fixtures │ │ └── jupyter_config.json ├── Notebooks │ ├── __init__.robot │ ├── 13_Compounds.robot │ ├── 11_Logic_Gates.robot │ ├── 10_Diagram_Defs.robot │ ├── 12_Node_Menagerie.robot │ ├── 04_Interactive.robot │ ├── 07_Simulation.robot │ ├── 14_Text_Styling.robot │ ├── 00_Introduction.robot │ ├── 05_SVG_Exporter.robot │ ├── 06_SVG_App_Exporter.robot │ ├── 08_Simulation_App.robot │ ├── 15_Nesting_Plots.robot │ ├── 01_Linking.robot │ ├── 02_Transformer.robot │ └── 03_App.robot ├── Smoke.robot ├── __init__.robot └── _libraries │ └── Ports.py ├── install.json ├── tsconfig.json ├── tsconfig.cov.json ├── style ├── index.css ├── lab.css ├── view.css ├── app.css ├── pipe_status.css └── diagram.css ├── vale.ini ├── .readthedocs.yml ├── .yarnrc.yml ├── tsconfigbase.json ├── webpack.config.js ├── COPYRIGHT.md └── LICENSE.txt /src/ipyelk/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/changelog.md: -------------------------------------------------------------------------------- 1 | ```{include} ../CHANGELOG.md 2 | 3 | ``` 4 | -------------------------------------------------------------------------------- /docs/contributing.md: -------------------------------------------------------------------------------- 1 | ```{include} ../CONTRIBUTING.md 2 | 3 | ``` 4 | -------------------------------------------------------------------------------- /docs/_static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyrdf/ipyelk/HEAD/docs/_static/favicon.ico -------------------------------------------------------------------------------- /docs/rtd.yml: -------------------------------------------------------------------------------- 1 | channels: 2 | - conda-forge 3 | - nodefaults 4 | dependencies: 5 | - pixi ==0.34.0 6 | -------------------------------------------------------------------------------- /docs/rtd.rst: -------------------------------------------------------------------------------- 1 | RTD 2 | === 3 | 4 | This is provided for the default ReadTheDocs build, and is not published. 5 | -------------------------------------------------------------------------------- /docs/reference/schema.rst: -------------------------------------------------------------------------------- 1 | JSON Schema 2 | =========== 3 | 4 | .. jsonschema:: ../../src/ipyelk/schema/elkschema.json 5 | -------------------------------------------------------------------------------- /lite/overrides.json: -------------------------------------------------------------------------------- 1 | { 2 | "@jupyterlab/notebook-extension:tracker": { 3 | "windowingMode": "none" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | _d/ 2 | .pixi/ 3 | **/__pycache__/**/* 4 | **/.ipynb_checkpoints/**/* 5 | build/ 6 | lib/ 7 | node_modules/ 8 | -------------------------------------------------------------------------------- /docs/_static/screenshots/activities.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyrdf/ipyelk/HEAD/docs/_static/screenshots/activities.gif -------------------------------------------------------------------------------- /scripts/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 ipyelk contributors. 2 | # Distributed under the terms of the Modified BSD License. 3 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 ipyelk contributors. 2 | # Distributed under the terms of the Modified BSD License. 3 | -------------------------------------------------------------------------------- /tests/pipes/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 ipyelk contributors. 2 | # Distributed under the terms of the Modified BSD License. 3 | -------------------------------------------------------------------------------- /tests/schema/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 ipyelk contributors. 2 | # Distributed under the terms of the Modified BSD License. 3 | -------------------------------------------------------------------------------- /docs/_static/screenshots/activity_block.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyrdf/ipyelk/HEAD/docs/_static/screenshots/activity_block.gif -------------------------------------------------------------------------------- /tests/elements/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 ipyelk contributors. 2 | # Distributed under the terms of the Modified BSD License. 3 | -------------------------------------------------------------------------------- /examples/requirements.txt: -------------------------------------------------------------------------------- 1 | importnb ; platform_machine == "wasm32" 2 | ipyelk ; platform_machine == "wasm32" 3 | bqplot ; platform_machine == "wasm32" 4 | -------------------------------------------------------------------------------- /js/sprotty/json/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2024 ipyelk contributors. 3 | * Distributed under the terms of the Modified BSD License. 4 | */ 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | .ipynb_checkpoints/ 3 | .pixi/ 4 | *.doit.* 5 | *.log 6 | build/ 7 | dist/ 8 | lib/ 9 | node_modules/ 10 | src/_d/ 11 | -------------------------------------------------------------------------------- /docs/_static/screenshots/hierarchical-1-bit-memory.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyrdf/ipyelk/HEAD/docs/_static/screenshots/hierarchical-1-bit-memory.gif -------------------------------------------------------------------------------- /src/ipyelk/schema/README.md: -------------------------------------------------------------------------------- 1 | # elk schema 2 | 3 | `elkschema.json` can be re-generated in this directory with: 4 | 5 | ```bash 6 | jlpm schema 7 | ``` 8 | -------------------------------------------------------------------------------- /js/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2024 ipyelk contributors. 3 | * Distributed under the terms of the Modified BSD License. 4 | */ 5 | export * from './tokens'; 6 | -------------------------------------------------------------------------------- /scripts/not-a-package/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "description": "not really a package, used to avoid un-needed deps", 4 | "version": "0.0.0" 5 | } 6 | -------------------------------------------------------------------------------- /src/ipyelk/contrib/__init__.py: -------------------------------------------------------------------------------- 1 | """Contributed features for ``ipyelk``.""" 2 | # Copyright (c) 2024 ipyelk contributors. 3 | # Distributed under the terms of the Modified BSD License. 4 | -------------------------------------------------------------------------------- /atest/_resources/keywords/CLI.robot: -------------------------------------------------------------------------------- 1 | *** Keywords *** 2 | Which 3 | [Arguments] ${cmd} 4 | ${path} = Evaluate __import__("shutil").which("${cmd}") 5 | RETURN ${path} 6 | -------------------------------------------------------------------------------- /src/ipyelk/contrib/library/__init__.py: -------------------------------------------------------------------------------- 1 | """Some reusable shapes for ``ipyelk``.""" 2 | # Copyright (c) 2024 ipyelk contributors. 3 | # Distributed under the terms of the Modified BSD License. 4 | -------------------------------------------------------------------------------- /src/ipyelk/contrib/molds/__init__.py: -------------------------------------------------------------------------------- 1 | """Reusable shape factories for ``ipyelk``.""" 2 | # Copyright (c) 2024 ipyelk contributors. 3 | # Distributed under the terms of the Modified BSD License. 4 | -------------------------------------------------------------------------------- /install.json: -------------------------------------------------------------------------------- 1 | { 2 | "packageManager": "python", 3 | "packageName": "ipyelk", 4 | "uninstallInstructions": "Use your Python package manager (pip, conda, etc.) to uninstall the package ipyelk" 5 | } 6 | -------------------------------------------------------------------------------- /lite/jupyter-lite.json: -------------------------------------------------------------------------------- 1 | { 2 | "jupyter-config-data": { 3 | "appName": "ipyelk", 4 | "collaborative": true, 5 | "faviconUrl": "./favicon.ico" 6 | }, 7 | "jupyter-lite-schema-version": 0 8 | } 9 | -------------------------------------------------------------------------------- /docs/reference/index.md: -------------------------------------------------------------------------------- 1 | # API Reference 2 | 3 | This page describe the overall API for ipyelk. 4 | 5 | ```{toctree} 6 | :maxdepth: 2 7 | widgets 8 | tools 9 | loaders 10 | pipes 11 | schema 12 | ``` 13 | -------------------------------------------------------------------------------- /atest/_resources/variables/Browser.robot: -------------------------------------------------------------------------------- 1 | *** Variables *** 2 | # override with `python scripts/atest.py --variable HEADLESS:0` 3 | ${HEADLESS} 1 4 | ${SCREENS ROOT} ${OUTPUT DIR}${/}screens 5 | ${NEXT BROWSER} ${0} 6 | -------------------------------------------------------------------------------- /js/tools/feedback/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2024 ipyelk contributors. 3 | * Distributed under the terms of the Modified BSD License. 4 | */ 5 | import toolFeedbackModule from './di.config'; 6 | 7 | export { toolFeedbackModule }; 8 | -------------------------------------------------------------------------------- /atest/_resources/fixtures/jupyter_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "LabApp": { 3 | "tornado_settings": { 4 | "page_config_data": { 5 | "buildCheck": false, 6 | "buildAvailable": false 7 | } 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": ".", 4 | "outDir": ".", 5 | "tsBuildInfoFile": "./build/tsc/.root.tsbuildinfo" 6 | }, 7 | "extends": "./tsconfigbase.json", 8 | "files": ["package.json"] 9 | } 10 | -------------------------------------------------------------------------------- /atest/_resources/variables/Server.robot: -------------------------------------------------------------------------------- 1 | *** Variables *** 2 | # to help catch hard-coded paths 3 | ${URL PREFIX} /@est/ 4 | ${FIXTURES} ${CURDIR}${/}..${/}fixtures 5 | ${NBSERVER CONF} jupyter_config.json 6 | ${NEXT LAB} ${0} 7 | -------------------------------------------------------------------------------- /tsconfig.cov.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": ".", 4 | "outDir": ".", 5 | "tsBuildInfoFile": "./build/tsc/.root.tsbuildinfo.cov" 6 | }, 7 | "extends": "./tsconfigbase.json", 8 | "files": ["package.json"] 9 | } 10 | -------------------------------------------------------------------------------- /src/ipyelk/loaders/nx/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 ipyelk contributors. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | from .nxloader import NXLoader, from_nx 5 | 6 | __all__ = [ 7 | "NXLoader", 8 | "from_nx", 9 | ] 10 | -------------------------------------------------------------------------------- /js/typings.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2024 ipyelk contributors. 3 | * Distributed under the terms of the Modified BSD License. 4 | */ 5 | declare module '!!worker-loader!*.js' {} 6 | declare module '!!raw-loader!*.css' { 7 | export default content as string; 8 | } 9 | -------------------------------------------------------------------------------- /atest/Notebooks/__init__.robot: -------------------------------------------------------------------------------- 1 | *** Settings *** 2 | Documentation Tests with ipyelk notebooks 3 | 4 | Resource ../_resources/keywords/Browser.robot 5 | 6 | Suite Setup Setup Suite For Screenshots notebooks 7 | 8 | Force Tags ui:notebook 9 | -------------------------------------------------------------------------------- /js/tools/util.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2024 ipyelk contributors. 3 | * Distributed under the terms of the Modified BSD License. 4 | */ 5 | import { SModelElementImpl } from 'sprotty'; 6 | 7 | export function idGetter(e: SModelElementImpl) { 8 | return e.id; 9 | } 10 | -------------------------------------------------------------------------------- /src/ipyelk/schema/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 ipyelk contributors. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | from .validator import SCHEMA, ElkSchemaValidator, validate_elk_json 5 | 6 | __all__ = ["SCHEMA", "ElkSchemaValidator", "validate_elk_json"] 7 | -------------------------------------------------------------------------------- /js/sprotty/views/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2024 ipyelk contributors. 3 | * Distributed under the terms of the Modified BSD License. 4 | */ 5 | 6 | export * from './node_views'; 7 | export * from './symbol_views'; 8 | export * from './edge_views'; 9 | export * from './graph_views'; 10 | -------------------------------------------------------------------------------- /atest/Smoke.robot: -------------------------------------------------------------------------------- 1 | *** Settings *** 2 | Resource _resources/keywords/Browser.robot 3 | Resource _resources/keywords/Lab.robot 4 | 5 | Suite Setup Setup Suite For Screenshots smoke 6 | 7 | 8 | *** Test Cases *** 9 | Lab Loads 10 | Capture Page Screenshot 00-smoke.png 11 | -------------------------------------------------------------------------------- /js/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": ".", 4 | "outDir": "../lib", 5 | "tsBuildInfoFile": "../build/tsc/.src.tsbuildinfo" 6 | }, 7 | "extends": "../tsconfigbase.json", 8 | "include": ["./**/*"], 9 | "references": [{ "path": "../tsconfig.json" }] 10 | } 11 | -------------------------------------------------------------------------------- /style/index.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2024 ipyelk contributors. 3 | * Distributed under the terms of the Modified BSD License. 4 | */ 5 | 6 | @import url('./lab.css'); 7 | @import url('./app.css'); 8 | @import url('./view.css'); 9 | @import url('./diagram.css'); 10 | @import url('./pipe_status.css'); 11 | -------------------------------------------------------------------------------- /js/tools/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2024 ipyelk contributors. 3 | * Distributed under the terms of the Modified BSD License. 4 | */ 5 | import { NodeExpandTool } from './expand'; 6 | import { NodeSelectTool } from './select'; 7 | import { ToolTYPES } from './types'; 8 | 9 | export { NodeSelectTool, NodeExpandTool, ToolTYPES }; 10 | -------------------------------------------------------------------------------- /atest/__init__.robot: -------------------------------------------------------------------------------- 1 | *** Settings *** 2 | Resource _resources/keywords/Server.robot 3 | Resource _resources/keywords/Lab.robot 4 | 5 | Suite Setup Setup Server and Browser 6 | Suite Teardown Tear Down Everything 7 | Test Setup Maybe Reset Application State 8 | 9 | Force Tags os:${os.lower()} 10 | -------------------------------------------------------------------------------- /src/ipyelk/tools/painter.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 ipyelk contributors. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | import traitlets as T 5 | 6 | from .tool import Tool 7 | 8 | 9 | class Painter(Tool): 10 | cssClasses = T.Unicode(default_value="") 11 | marks = T.List() # list of ids? 12 | name = T.Unicode() 13 | -------------------------------------------------------------------------------- /js/tsconfig.cov.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": ".", 4 | "outDir": "../lib", 5 | "inlineSourceMap": true, 6 | "sourceMap": false, 7 | "tsBuildInfoFile": "../build/tsc/.src.tsbuildinfo.cov" 8 | }, 9 | "extends": "../tsconfigbase.json", 10 | "include": ["./**/*"], 11 | "references": [{ "path": "../tsconfig.cov.json" }] 12 | } 13 | -------------------------------------------------------------------------------- /scripts/pytest-check-links.toml: -------------------------------------------------------------------------------- 1 | [tool.pytest.ini_options] 2 | filterwarnings = ["error", "ignore::DeprecationWarning"] 3 | addopts = [ 4 | "--check-links-ignore", 5 | "(https?://|#attributes)", 6 | "--check-anchors", 7 | "-k", 8 | "not (_static or contributing)", 9 | "--html", 10 | "build/reports/pytest-check-links.html", 11 | "--self-contained-html", 12 | ] 13 | -------------------------------------------------------------------------------- /lite/jupyter_lite_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "LiteBuildConfig": { 3 | "output_dir": "../build/lite", 4 | "contents": ["../examples"], 5 | "cache_dir": "../build/.cache/lite", 6 | "federated_extensions": [ 7 | "../src/_d/share/jupyter/labextensions/@jupyrdf/jupyter-elk" 8 | ] 9 | }, 10 | "PipliteAddon": { 11 | "piplite_urls": ["../dist"] 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/ipyelk/diagram/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 ipyelk contributors. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | 5 | from .diagram import Diagram 6 | from .export import Exporter 7 | from .sprotty_viewer import SprottyViewer 8 | from .viewer import Viewer 9 | 10 | __all__ = [ 11 | "Diagram", 12 | "Exporter", 13 | "SprottyViewer", 14 | "Viewer", 15 | ] 16 | -------------------------------------------------------------------------------- /vale.ini: -------------------------------------------------------------------------------- 1 | MinAlertLevel = suggestion 2 | Packages = aoo-mozilla-en-dict-gb, aoo-mozilla-en-dict-us 3 | StylesPath = scripts/vale 4 | Vocab = IPyElk 5 | IgnoredClasses = w, n, o, kt, s2 6 | 7 | [*.{md,py}] 8 | BasedOnStyles = aoo-mozilla-en-dict-gb, aoo-mozilla-en-dict-us 9 | Vale.Spelling = NO 10 | 11 | [*.html] 12 | BasedOnStyles = aoo-mozilla-en-dict-gb, aoo-mozilla-en-dict-us 13 | Vale.Spelling = NO 14 | -------------------------------------------------------------------------------- /docs/reference/tools.md: -------------------------------------------------------------------------------- 1 | # Tool Widgets 2 | 3 | ## Tools 4 | 5 | ```{eval-rst} 6 | .. currentmodule:: ipyelk.tools 7 | .. autoclass:: ipyelk.tools.Tool 8 | :members: 9 | 10 | .. autoclass:: ipyelk.tools.ToolButton 11 | :members: 12 | ``` 13 | 14 | ## Tool View Widgets 15 | 16 | ```{eval-rst} 17 | .. autoclass:: ipyelk.tools.Toolbar 18 | :members: 19 | .. autoclass:: ipyelk.tools.ControlOverlay 20 | :members: 21 | ``` 22 | -------------------------------------------------------------------------------- /docs/reference/widgets.md: -------------------------------------------------------------------------------- 1 | # Widgets 2 | 3 | ## Diagram Widget 4 | 5 | ```{eval-rst} 6 | .. currentmodule:: ipyelk 7 | .. autoclass:: ipyelk.Diagram 8 | :members: 9 | ``` 10 | 11 | ## Viewer Widget 12 | 13 | ```{eval-rst} 14 | .. currentmodule:: ipyelk 15 | .. autoclass:: ipyelk.diagram.Viewer 16 | :members: 17 | ``` 18 | 19 | ```{eval-rst} 20 | .. currentmodule:: ipyelk 21 | .. autoclass:: ipyelk.diagram.SprottyViewer 22 | :members: 23 | ``` 24 | -------------------------------------------------------------------------------- /atest/_libraries/Ports.py: -------------------------------------------------------------------------------- 1 | """get a random port""" 2 | 3 | import socket 4 | 5 | 6 | def get_unused_port(): 7 | """Get an unused port by trying to listen to any random port. 8 | 9 | Probably could introduce race conditions if inside a tight loop. 10 | """ 11 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 12 | sock.bind(("localhost", 0)) 13 | sock.listen(1) 14 | port = sock.getsockname()[1] 15 | sock.close() 16 | return port 17 | -------------------------------------------------------------------------------- /style/lab.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2024 ipyelk contributors. 3 | * Distributed under the terms of the Modified BSD License. 4 | */ 5 | 6 | .jp-LinkedOutputView .jp-OutputArea:only-child, 7 | .jp-LinkedOutputView .jp-OutputArea-child:only-child, 8 | .jp-LinkedOutputView .jp-OutputArea-child:only-child .jp-OutputArea-output:only-child { 9 | position: absolute; 10 | left: 0; 11 | top: 0; 12 | right: 0; 13 | bottom: 0; 14 | margin: 0; 15 | padding: 0; 16 | } 17 | -------------------------------------------------------------------------------- /src/ipyelk/exceptions.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 ipyelk contributors. 2 | # Distributed under the terms of the Modified BSD License. 3 | class ElkDuplicateIDError(Exception): 4 | """Elk Ids must be unique""" 5 | 6 | 7 | class ElkRegistryError(Exception): 8 | """Transformer mark registry missing key""" 9 | 10 | 11 | class NotFoundError(Exception): 12 | pass 13 | 14 | 15 | class NotUniqueError(Exception): 16 | pass 17 | 18 | 19 | class BrokenPipe(Exception): 20 | pass 21 | -------------------------------------------------------------------------------- /src/ipyelk/loaders/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 ipyelk contributors. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | from .element_loader import ElementLoader, from_element 5 | from .json import ElkJSONLoader, from_elkjson 6 | from .loader import Loader 7 | from .nx import NXLoader, from_nx 8 | 9 | __all__ = [ 10 | "ElementLoader", 11 | "ElkJSONLoader", 12 | "Loader", 13 | "NXLoader", 14 | "from_element", 15 | "from_elkjson", 16 | "from_nx", 17 | ] 18 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: ubuntu-22.04 5 | tools: 6 | python: mambaforge-latest 7 | jobs: 8 | pre_build: 9 | - pixi install --environment=build 10 | - pixi install --environment=lite 11 | - pixi install --environment=docs 12 | - pixi run setup-js 13 | - pixi run build 14 | - pixi run dist 15 | - pixi run docs-lite 16 | 17 | conda: 18 | environment: docs/rtd.yml 19 | 20 | sphinx: 21 | builder: html 22 | configuration: docs/conf.py 23 | -------------------------------------------------------------------------------- /src/ipyelk/diagram/flow.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 ipyelk contributors. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | import traitlets as T 5 | 6 | from ..pipes import BrowserTextSizer, ElkJS, Pipeline, ValidationPipe, VisibilityPipe 7 | 8 | 9 | class DefaultFlow(Pipeline): 10 | @T.default("pipes") 11 | def _default_pipes(self): 12 | return [ 13 | ValidationPipe(), 14 | BrowserTextSizer(), 15 | VisibilityPipe(), 16 | ElkJS(), 17 | ] 18 | -------------------------------------------------------------------------------- /atest/Notebooks/13_Compounds.robot: -------------------------------------------------------------------------------- 1 | *** Settings *** 2 | Resource ../_resources/keywords/Browser.robot 3 | Resource ../_resources/keywords/Lab.robot 4 | Resource ../_resources/keywords/IPyElk.robot 5 | Library Collections 6 | 7 | Test Teardown Clean up after IPyElk Example 8 | 9 | 10 | *** Variables *** 11 | ${SCREENS} ${SCREENS ROOT}${/}examples${/}${COMPOUNDS} 12 | 13 | 14 | *** Test Cases *** 15 | 13_Compounds 16 | Example Should Restart-and-Run-All ${COMPOUNDS} 17 | -------------------------------------------------------------------------------- /atest/Notebooks/11_Logic_Gates.robot: -------------------------------------------------------------------------------- 1 | *** Settings *** 2 | Resource ../_resources/keywords/Browser.robot 3 | Resource ../_resources/keywords/Lab.robot 4 | Resource ../_resources/keywords/IPyElk.robot 5 | Library Collections 6 | 7 | Test Teardown Clean up after IPyElk Example 8 | 9 | 10 | *** Variables *** 11 | ${SCREENS} ${SCREENS ROOT}${/}examples${/}11_Logic_Gates 12 | 13 | 14 | *** Test Cases *** 15 | 11_Logic_Gates 16 | Example Should Restart-and-Run-All ${LOGIC GATES} 17 | -------------------------------------------------------------------------------- /atest/Notebooks/10_Diagram_Defs.robot: -------------------------------------------------------------------------------- 1 | *** Settings *** 2 | Resource ../_resources/keywords/Browser.robot 3 | Resource ../_resources/keywords/Lab.robot 4 | Resource ../_resources/keywords/IPyElk.robot 5 | Library Collections 6 | 7 | Test Teardown Clean up after IPyElk Example 8 | 9 | 10 | *** Variables *** 11 | ${SCREENS} ${SCREENS ROOT}${/}examples${/}${DIAGRAM DEFS} 12 | 13 | 14 | *** Test Cases *** 15 | 10_Logic_Gates 16 | Example Should Restart-and-Run-All ${DIAGRAM DEFS} 17 | -------------------------------------------------------------------------------- /atest/Notebooks/12_Node_Menagerie.robot: -------------------------------------------------------------------------------- 1 | *** Settings *** 2 | Resource ../_resources/keywords/Browser.robot 3 | Resource ../_resources/keywords/Lab.robot 4 | Resource ../_resources/keywords/IPyElk.robot 5 | Library Collections 6 | 7 | Test Teardown Clean up after IPyElk Example 8 | 9 | 10 | *** Variables *** 11 | ${SCREENS} ${SCREENS ROOT}${/}examples${/}${NODE MENAGERIE} 12 | 13 | 14 | *** Test Cases *** 15 | 12_Node_Menagerie 16 | Example Should Restart-and-Run-All ${NODE MENAGERIE} 17 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | Open in new tab 4 | 5 | 6 | 10 | 11 | ```{include} ../README.md 12 | 13 | ``` 14 | 15 | ```{toctree} 16 | changelog 17 | contributing 18 | ``` 19 | 20 | ```{toctree} 21 | :maxdepth: 1 22 | :hidden: 23 | :titlesonly: 24 | reference/index 25 | ``` 26 | -------------------------------------------------------------------------------- /src/ipyelk/constants.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 ipyelk contributors. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | try: 5 | from importlib.metadata import version 6 | except ImportError: 7 | from importlib_metadata import version 8 | 9 | NAME = "ipyelk" 10 | 11 | __version__ = version(NAME) 12 | 13 | EXTENSION_NAME = "@jupyrdf/jupyter-elk" 14 | EXTENSION_SPEC_VERSION = ( 15 | __version__.replace("a", "-alpha").replace("b", "-beta").replace("rc", "-rc") 16 | ) 17 | 18 | __all__ = ["EXTENSION_NAME", "EXTENSION_SPEC_VERSION", "__version__"] 19 | -------------------------------------------------------------------------------- /src/ipyelk/js.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 ipyelk contributors. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | import sys 5 | from pathlib import Path 6 | 7 | from .constants import EXTENSION_NAME 8 | 9 | HERE = Path(__file__).parent 10 | 11 | IN_TREE = (HERE / f"../_d/share/jupyter/labextensions/{EXTENSION_NAME}").resolve() 12 | IN_PREFIX = Path(sys.prefix) / f"share/jupyter/labextensions/{EXTENSION_NAME}" 13 | 14 | __prefix__ = IN_TREE if IN_TREE.exists() else IN_PREFIX 15 | 16 | PKG_JSON = __prefix__ / "package.json" 17 | 18 | __all__ = ["__prefix__"] 19 | -------------------------------------------------------------------------------- /tests/schema/test_label_schema.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 ipyelk contributors. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | from ipyelk.schema.validator import ElkSchemaValidator 5 | 6 | 7 | def test_label_label_schema(): 8 | nested_label = { 9 | "id": "root", 10 | "labels": [ 11 | { 12 | "id": "top_label", 13 | "text": "", 14 | "labels": [{"id": "nested_label", "text": "", "properties": {}}], 15 | } 16 | ], 17 | } 18 | 19 | ElkSchemaValidator.validate(nested_label) 20 | -------------------------------------------------------------------------------- /docs/reference/loaders.md: -------------------------------------------------------------------------------- 1 | # Loaders 2 | 3 | The goal of loaders are to make it easier to create a valid ELK JSON from common 4 | sources. 5 | 6 | ## JSON Loader 7 | 8 | ```{eval-rst} 9 | .. currentmodule:: ipyelk.loaders 10 | .. autoclass:: ElkJSONLoader 11 | 12 | ``` 13 | 14 | ## Element Loader 15 | 16 | ```{eval-rst} 17 | .. currentmodule:: ipyelk.loaders.element_loader 18 | .. autoclass:: ipyelk.loaders.ElementLoader 19 | :members: load 20 | ``` 21 | 22 | ## NetworkX Loader 23 | 24 | ```{eval-rst} 25 | .. currentmodule:: ipyelk.loaders 26 | .. autoclass:: NXLoader 27 | :members: 28 | ``` 29 | -------------------------------------------------------------------------------- /atest/Notebooks/04_Interactive.robot: -------------------------------------------------------------------------------- 1 | *** Settings *** 2 | Resource ../_resources/keywords/Browser.robot 3 | Resource ../_resources/keywords/Lab.robot 4 | Resource ../_resources/keywords/IPyElk.robot 5 | Library Collections 6 | 7 | Test Teardown Clean up after IPyElk Example 8 | 9 | 10 | *** Variables *** 11 | ${SCREENS} ${SCREENS ROOT}${/}examples${/}04_Interactive 12 | 13 | 14 | *** Test Cases *** 15 | 04_Interactive 16 | Example Should Restart-and-Run-All ${INTERACTIVE} 17 | # not worth counting anything, as is basically non-deterministic 18 | -------------------------------------------------------------------------------- /atest/Notebooks/07_Simulation.robot: -------------------------------------------------------------------------------- 1 | *** Settings *** 2 | Resource ../_resources/keywords/Browser.robot 3 | Resource ../_resources/keywords/Lab.robot 4 | Resource ../_resources/keywords/IPyElk.robot 5 | Library Collections 6 | 7 | Test Teardown Clean up after IPyElk Example 8 | 9 | 10 | *** Variables *** 11 | ${SCREENS} ${SCREENS ROOT}${/}examples${/}07_Simulation 12 | 13 | 14 | *** Test Cases *** 15 | 07_Simulation 16 | Example Should Restart-and-Run-All ${SIM PLUMBING} 17 | # not worth counting anything, as is basically non-deterministic 18 | -------------------------------------------------------------------------------- /docs/reference/pipes.md: -------------------------------------------------------------------------------- 1 | # Pipes 2 | 3 | ```{eval-rst} 4 | .. currentmodule:: ipyelk.pipes 5 | .. autoclass:: ipyelk.pipes.Pipe 6 | :members: 7 | .. autoclass:: ipyelk.pipes.Pipeline 8 | :members: 9 | .. autoclass:: ipyelk.pipes.base.PipeStatus 10 | :members: 11 | .. autoclass:: ipyelk.pipes.base.PipeStatusView 12 | :members: 13 | ``` 14 | 15 | ```{eval-rst} 16 | .. currentmodule:: ipyelk.pipes 17 | .. autoclass:: ipyelk.pipes.MarkElementWidget 18 | :members: 19 | 20 | ``` 21 | 22 | ```{eval-rst} 23 | .. currentmodule:: ipyelk.pipes 24 | .. autoclass:: ipyelk.pipes.ElkJS 25 | :members: 26 | ``` 27 | -------------------------------------------------------------------------------- /src/ipyelk/tools/contol_overlay.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 ipyelk contributors. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | import ipywidgets as W 5 | 6 | 7 | class ControlOverlay(W.VBox): 8 | """Simple Container Widget for rendering element specific jupyterlab widgets""" 9 | 10 | # TODO config to specify on which side of selected element's bounding box to 11 | # render the controls 12 | # TODO styling for controls 13 | 14 | def __init__(self, *args, **kwargs): 15 | super().__init__(*args, **kwargs) 16 | # self.children = [W.Button(description="Simple Button")] 17 | -------------------------------------------------------------------------------- /examples/hier_tree.json: -------------------------------------------------------------------------------- 1 | { 2 | "directed": true, 3 | "graph": {}, 4 | "links": [ 5 | { 6 | "source": "n0", 7 | "target": "n1" 8 | }, 9 | { 10 | "source": "n1", 11 | "target": "n2" 12 | } 13 | ], 14 | "multigraph": false, 15 | "nodes": [ 16 | { 17 | "id": "n0", 18 | "properties": { 19 | "cssClasses": "example-data-node-class-from-tree" 20 | } 21 | }, 22 | { 23 | "hidden": false, 24 | "id": "n1" 25 | }, 26 | { 27 | "hidden": false, 28 | "id": "n2" 29 | }, 30 | { 31 | "id": "n3" 32 | } 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /src/ipyelk/loaders/element_loader.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 ipyelk contributors. 2 | # Distributed under the terms of the Modified BSD License. 3 | from ..diagram import Diagram 4 | from ..elements import Node 5 | from ..pipes import MarkElementWidget 6 | from .loader import Loader 7 | 8 | 9 | class ElementLoader(Loader): 10 | def load(self, root: Node) -> MarkElementWidget: 11 | return MarkElementWidget( 12 | value=self.apply_layout_defaults(root), 13 | ) 14 | 15 | 16 | def from_element(root: Node, **kwargs): 17 | diagram = Diagram(source=ElementLoader().load(root=root), **kwargs) 18 | return diagram 19 | -------------------------------------------------------------------------------- /atest/Notebooks/14_Text_Styling.robot: -------------------------------------------------------------------------------- 1 | *** Settings *** 2 | Resource ../_resources/keywords/Browser.robot 3 | Resource ../_resources/keywords/Lab.robot 4 | Resource ../_resources/keywords/IPyElk.robot 5 | Library Collections 6 | 7 | Test Teardown Clean up after IPyElk Example 8 | 9 | 10 | *** Variables *** 11 | ${SCREENS} ${SCREENS ROOT}${/}examples${/}14_Text_Styling 12 | 13 | 14 | *** Test Cases *** 15 | 14_Text_Styling 16 | [Tags] gh:100 17 | Example Should Restart-and-Run-All ${TEXT STYLE} 18 | Sleep 2s 19 | Wait Until Computed Element Styles Are 5x 1s .elklabel fontWeight=700 20 | -------------------------------------------------------------------------------- /js/sprotty/update/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2024 ipyelk contributors. 3 | * Distributed under the terms of the Modified BSD License. 4 | * FIX BELOW FROM: 5 | * back porting to fix duplicate ids 6 | * https://github.com/eclipse/sprotty/pull/209/files 7 | * remove after next sprotty release > 0.9 8 | */ 9 | import { ContainerModule } from 'inversify'; 10 | 11 | import { configureCommand } from 'sprotty'; 12 | 13 | import { UpdateModelCommand2 } from './update-model'; 14 | 15 | const updateModule = new ContainerModule((bind, _unbind, isBound) => { 16 | configureCommand({ bind, isBound }, UpdateModelCommand2); 17 | }); 18 | 19 | export default updateModule; 20 | -------------------------------------------------------------------------------- /js/tools/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2024 ipyelk contributors. 3 | * Distributed under the terms of the Modified BSD License. 4 | */ 5 | export const ToolTYPES = { 6 | IFeedbackActionDispatcher: Symbol.for('IFeedbackActionDispatcher'), 7 | IToolFactory: Symbol.for('Factory'), 8 | IEditConfigProvider: Symbol.for('IEditConfigProvider'), 9 | RequestResponseSupport: Symbol.for('RequestResponseSupport'), 10 | SelectionService: Symbol.for('SelectionService'), 11 | SelectionListener: Symbol.for('SelectionListener'), 12 | SModelRootListener: Symbol.for('SModelRootListener'), 13 | MouseTool: Symbol.for('MouseTool'), 14 | ViewerOptions: Symbol.for('ViewerOptions'), 15 | }; 16 | -------------------------------------------------------------------------------- /js/sprotty/json/symbols.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2024 ipyelk contributors. 3 | * Distributed under the terms of the Modified BSD License. 4 | */ 5 | import { Point, SModelElement } from 'sprotty-protocol'; 6 | 7 | import { ElkNode, ElkProperties } from './elkgraph-json'; 8 | 9 | export interface IElement extends SModelElement { 10 | x: number; 11 | y: number; 12 | } 13 | 14 | export interface IElkSymbol extends SModelElement { 15 | element: ElkNode; 16 | properties: ElkProperties; 17 | } 18 | 19 | export interface IElkSymbols { 20 | library: { 21 | [key: string]: IElkSymbol; 22 | }; 23 | } 24 | 25 | export interface SElkConnectorSymbol extends IElkSymbol { 26 | path_offset: Point; 27 | symbol_offset: Point; 28 | } 29 | -------------------------------------------------------------------------------- /js/sprotty/update/smodel-utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2024 ipyelk contributors. 3 | * Distributed under the terms of the Modified BSD License. 4 | * FIX BELOW FROM: 5 | */ 6 | import { SChildElementImpl, SModelRootImpl } from 'sprotty'; 7 | 8 | /** 9 | * Tests if the given model contains an id of then given element or one of its descendants. 10 | */ 11 | export function containsSome( 12 | root: SModelRootImpl, 13 | element: SChildElementImpl, 14 | ): boolean { 15 | const test = (element: SChildElementImpl) => root.index.getById(element.id) != null; 16 | const find = (elements: readonly SChildElementImpl[]): boolean => 17 | elements.some((element) => test(element) || find(element.children)); 18 | return find([element]); 19 | } 20 | -------------------------------------------------------------------------------- /atest/Notebooks/00_Introduction.robot: -------------------------------------------------------------------------------- 1 | *** Settings *** 2 | Resource ../_resources/keywords/Browser.robot 3 | Resource ../_resources/keywords/Lab.robot 4 | Resource ../_resources/keywords/IPyElk.robot 5 | Library Collections 6 | 7 | Test Teardown Clean up after IPyElk Example 8 | 9 | 10 | *** Variables *** 11 | ${SCREENS} ${SCREENS ROOT}${/}examples${/}00_Introduction 12 | 13 | 14 | *** Test Cases *** 15 | 00_Introduction 16 | [Tags] data:simple.json gh:6 17 | Example Should Restart-and-Run-All ${INTRODUCTION} 18 | Elk Counts Should Be &{SIMPLE COUNTS} 19 | Linked Elk Output Counts Should Be &{SIMPLE COUNTS} 20 | Custom Elk Selectors Should Exist @{SIMPLE CUSTOM} 21 | -------------------------------------------------------------------------------- /src/ipyelk/pipes/util.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 ipyelk contributors. 2 | # Distributed under the terms of the Modified BSD License. 3 | import asyncio 4 | 5 | 6 | def wait_for_change(widget, value): 7 | """Initial pattern from 8 | https://ipywidgets.readthedocs.io/en/stable/examples/Widget%20Asynchronous.html?highlight=async#Waiting-for-user-interaction 9 | """ 10 | future = asyncio.Future() 11 | 12 | def getvalue(change): 13 | """Make the new value available""" 14 | future.set_result(change.new) 15 | 16 | def unobserve(f): 17 | """Unobserves the `getvalue` callback""" 18 | widget.unobserve(getvalue, value) 19 | 20 | future.add_done_callback(unobserve) 21 | 22 | widget.observe(getvalue, value) 23 | return future 24 | -------------------------------------------------------------------------------- /src/ipyelk/loaders/json.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 ipyelk contributors. 2 | # Distributed under the terms of the Modified BSD License. 3 | from typing import Dict 4 | 5 | from ..diagram import Diagram 6 | 7 | # from ..schema.validator import validate_elk_json 8 | from ..elements import convert_elkjson 9 | from ..pipes import MarkElementWidget 10 | from .loader import Loader 11 | 12 | 13 | class ElkJSONLoader(Loader): 14 | def load(self, data: Dict) -> MarkElementWidget: 15 | return MarkElementWidget( 16 | value=self.apply_layout_defaults(convert_elkjson(data)), 17 | ) 18 | 19 | 20 | def from_elkjson(data, **kwargs): 21 | from .json import ElkJSONLoader 22 | 23 | diagram = Diagram(source=ElkJSONLoader().load(data), **kwargs) 24 | return diagram 25 | -------------------------------------------------------------------------------- /js/patches.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2024 ipyelk contributors. 3 | * Distributed under the terms of the Modified BSD License. 4 | */ 5 | import { NAME } from './tokens'; 6 | 7 | const KEYSTODELETE = ['defineMetadata', 'getOwnMetadata', 'metadata']; 8 | 9 | /** 10 | * Address issue between reflect-metadata and fast-foundation di 11 | */ 12 | export async function patchReflectMetadata(): Promise { 13 | if (Reflect.hasOwnMetadata != null) { 14 | console.info(`${NAME}: skipping patch of Reflect.metadata`); 15 | return; 16 | } 17 | if (Reflect.metadata) { 18 | console.warn(`${NAME}: patching broken fast-foundation Reflect.metadata shim`); 19 | } 20 | 21 | for (const key of KEYSTODELETE) { 22 | delete Reflect[key]; 23 | } 24 | await import('reflect-metadata'); 25 | } 26 | -------------------------------------------------------------------------------- /src/ipyelk/pipes/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 ipyelk contributors. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | from .base import Pipe, PipeDisposition, SyncedInletPipe, SyncedOutletPipe, SyncedPipe 5 | from .elkjs import ElkJS 6 | from .marks import MarkElementWidget, MarkIndex 7 | from .pipeline import Pipeline 8 | from .text_sizer import BrowserTextSizer, TextSizer 9 | from .valid import ValidationPipe 10 | from .visibility import VisibilityPipe 11 | 12 | __all__ = [ 13 | "BrowserTextSizer", 14 | "ElkJS", 15 | "MarkElementWidget", 16 | "MarkIndex", 17 | "Pipe", 18 | "PipeDisposition", 19 | "Pipeline", 20 | "SyncedInletPipe", 21 | "SyncedOutletPipe", 22 | "SyncedPipe", 23 | "TextSizer", 24 | "ValidationPipe", 25 | "VisibilityPipe", 26 | ] 27 | -------------------------------------------------------------------------------- /atest/Notebooks/05_SVG_Exporter.robot: -------------------------------------------------------------------------------- 1 | *** Settings *** 2 | Resource ../_resources/keywords/Browser.robot 3 | Resource ../_resources/keywords/Lab.robot 4 | Resource ../_resources/keywords/IPyElk.robot 5 | Library Collections 6 | 7 | Test Teardown Clean up after IPyElk Example 8 | 9 | 10 | *** Variables *** 11 | ${SCREENS} ${SCREENS ROOT}${/}examples${/}05_SVG_Exporter 12 | 13 | 14 | *** Test Cases *** 15 | 05_SVG_Exporter 16 | [Tags] data:simple.json feature:svg 17 | Example Should Restart-and-Run-All ${EXPORTER} 18 | Elk Counts Should Be &{SIMPLE COUNTS} 19 | Exported SVG should be valid XML untitled_example.svg 20 | Linked Elk Output Counts Should Be &{SIMPLE COUNTS} 21 | Custom Elk Selectors Should Exist @{SIMPLE CUSTOM} 22 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | enableImmutableInstalls: false 2 | enableInlineBuilds: false 3 | enableTelemetry: false 4 | httpTimeout: 60000 5 | nodeLinker: node-modules 6 | npmRegistryServer: https://registry.npmjs.org/ 7 | installStatePath: ./build/.cache/yarn/install-state.gz 8 | cacheFolder: ./build/.cache/yarn/cache 9 | # these messages provide no actionable information, and make non-TTY output 10 | # almost unreadable, masking real dependency-related information 11 | # see: https://yarnpkg.com/advanced/error-codes 12 | logFilters: 13 | - code: YN0006 # SOFT_LINK_BUILD 14 | level: discard 15 | - code: YN0007 # MUST_BUILD 16 | level: discard 17 | - code: YN0008 # MUST_REBUILD 18 | level: discard 19 | - code: YN0013 # FETCH_NOT_CACHED 20 | level: discard 21 | - code: YN0019 # UNUSED_CACHE_ENTRY 22 | level: discard 23 | -------------------------------------------------------------------------------- /src/ipyelk/tools/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 ipyelk contributors. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | from .collapser import ToggleCollapsedTool 5 | from .contol_overlay import ControlOverlay 6 | from .painter import Painter 7 | from .progress import PipelineProgressBar 8 | from .tool import Tool, ToolButton 9 | from .toolbar import Toolbar 10 | from .view_tools import CenterTool, FitTool, Hover, Pan, Selection, SetTool, Zoom 11 | 12 | # from .tools import ToggleCollapsedBtn, ToolButton 13 | 14 | __all__ = [ 15 | "CenterTool", 16 | "ControlOverlay", 17 | "FitTool", 18 | "Hover", 19 | "Painter", 20 | "Pan", 21 | "PipelineProgressBar", 22 | "Selection", 23 | "SetTool", 24 | "ToggleCollapsedTool", 25 | "Tool", 26 | "ToolButton", 27 | "Toolbar", 28 | "Zoom", 29 | ] 30 | -------------------------------------------------------------------------------- /tsconfigbase.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "composite": true, 5 | "declaration": true, 6 | "emitDecoratorMetadata": true, 7 | "esModuleInterop": true, 8 | "experimentalDecorators": true, 9 | "incremental": true, 10 | // Needs to be disabled to support property injection 11 | "strictPropertyInitialization": false, 12 | "jsx": "react", 13 | "lib": ["ES2017", "DOM"], 14 | "module": "esnext", 15 | "moduleResolution": "node", 16 | "noEmitOnError": true, 17 | "noImplicitAny": false, 18 | "noUnusedLocals": true, 19 | "preserveWatchOutput": true, 20 | "resolveJsonModule": true, 21 | "skipLibCheck": true, 22 | "sourceMap": true, 23 | "strict": true, 24 | "strictNullChecks": false, 25 | "target": "ES2017", 26 | "types": [] 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /atest/Notebooks/06_SVG_App_Exporter.robot: -------------------------------------------------------------------------------- 1 | *** Settings *** 2 | Resource ../_resources/keywords/Browser.robot 3 | Resource ../_resources/keywords/Lab.robot 4 | Resource ../_resources/keywords/IPyElk.robot 5 | Library Collections 6 | 7 | Test Teardown Clean up after IPyElk Example 8 | 9 | 10 | *** Variables *** 11 | ${SCREENS} ${SCREENS ROOT}${/}examples${/}06_SVG_App_Exporter 12 | 13 | 14 | *** Test Cases *** 15 | 06_SVG_App_Exporter 16 | [Tags] data:hier_tree.json data:hier_ports.json feature:svg 17 | Example Should Restart-and-Run-All ${APP EXPORTER} 18 | Elk Counts Should Be &{HIER COUNTS} 19 | Exported SVG should be valid XML untitled_stylish_example.svg 20 | Linked Elk Output Counts Should Be &{HIER COUNTS} 21 | Custom Elk Selectors Should Exist @{HIER PORT CUSTOM} 22 | -------------------------------------------------------------------------------- /js/sprotty/views/symbol_views.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx svg */ 2 | import { VNode } from 'snabbdom'; 3 | 4 | import { injectable } from 'inversify'; 5 | 6 | import { IView, svg } from 'sprotty'; 7 | 8 | import { ElkModelRenderer } from '../renderer'; 9 | import { ElkNode } from '../sprotty-model'; 10 | 11 | @injectable() 12 | export class SymbolNodeView implements IView { 13 | render(symbol: ElkNode, context: ElkModelRenderer): VNode { 14 | let x = symbol.position?.x || 0; 15 | let y = symbol.position?.y || 0; 16 | let width = symbol.size.width || 0; 17 | let height = symbol.size.height || 0; 18 | let attrs = { 19 | class: { 20 | [symbol.id]: true, 21 | elksymbol: true, 22 | }, 23 | }; 24 | if (width && height) { 25 | attrs['viewBox'] = `${x} ${y} ${width} ${height}`; 26 | } 27 | return svg('symbol', attrs, ...context.renderChildren(symbol)); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /scripts/watch.py: -------------------------------------------------------------------------------- 1 | """Watch all the things.""" 2 | 3 | from __future__ import annotations 4 | 5 | import atexit 6 | import subprocess 7 | import sys 8 | import time 9 | 10 | WATCHES = [ 11 | ["jlpm", "watch:lib"], 12 | ["jlpm", "watch:ext"], 13 | ] 14 | 15 | 16 | def main() -> int: 17 | procs: list[subprocess.Popen] = [] 18 | for cmd in WATCHES: 19 | print(">>>", *cmd) 20 | time.sleep(1) 21 | procs += [subprocess.Popen(cmd)] 22 | 23 | def stop(): 24 | """Stop the processes.""" 25 | for proc in procs: 26 | if proc.poll() is None: 27 | continue 28 | proc.terminate() 29 | proc.wait() 30 | 31 | atexit.register(stop) 32 | 33 | try: 34 | procs[0].wait() 35 | except KeyboardInterrupt: 36 | stop() 37 | 38 | return 1 39 | 40 | 41 | if __name__ == "__main__": 42 | sys.exit(main()) 43 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | //@ts-check 2 | 3 | const webpack = require('webpack'); 4 | const path = require('path'); 5 | 6 | const { WEBPACK_WATCH, WITH_TOTAL_COVERAGE } = process.env; 7 | 8 | const COV_PATH = path.resolve('build/labextensions-cov/@jupyrdf/jupyter-elk/static'); 9 | 10 | /** @type {import('webpack').Configuration} */ 11 | const config = { 12 | output: { 13 | clean: true, 14 | ...(WITH_TOTAL_COVERAGE ? { path: COV_PATH } : {}), 15 | }, 16 | target: 'web', 17 | devtool: 'source-map', 18 | mode: WEBPACK_WATCH ? 'development' : 'production', 19 | module: { 20 | rules: [ 21 | { 22 | test: /\.js$/, 23 | use: WITH_TOTAL_COVERAGE 24 | ? ['@ephesoft/webpack.istanbul.loader'] 25 | : ['source-map-loader'], 26 | }, 27 | ], 28 | }, 29 | 30 | plugins: [], 31 | ignoreWarnings: [/Failed to parse source map/], 32 | }; 33 | 34 | module.exports = config; 35 | -------------------------------------------------------------------------------- /js/tools/feedback/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2024 ipyelk contributors. 3 | * Distributed under the terms of the Modified BSD License. 4 | */ 5 | import { SModelElementImpl } from 'sprotty'; 6 | 7 | export function addCssClasses(root: SModelElementImpl, cssClasses: string[]) { 8 | if (root.cssClasses == null) { 9 | root.cssClasses = []; 10 | } 11 | for (const cssClass of cssClasses) { 12 | if (root.cssClasses.indexOf(cssClass) < 0) { 13 | root.cssClasses.push(cssClass); 14 | } 15 | } 16 | } 17 | 18 | export function removeCssClasses(root: SModelElementImpl, cssClasses: string[]) { 19 | if (root.cssClasses == null || root.cssClasses.length === 0) { 20 | return; 21 | } 22 | for (const cssClass of cssClasses) { 23 | const index = root.cssClasses.indexOf(cssClass); 24 | if (index !== -1) { 25 | root.cssClasses.splice(root.cssClasses.indexOf(cssClass), 1); 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /atest/Notebooks/08_Simulation_App.robot: -------------------------------------------------------------------------------- 1 | *** Settings *** 2 | Resource ../_resources/keywords/Browser.robot 3 | Resource ../_resources/keywords/Lab.robot 4 | Resource ../_resources/keywords/IPyElk.robot 5 | Library Collections 6 | 7 | Test Teardown Clean up after IPyElk Example 8 | 9 | 10 | *** Variables *** 11 | ${SCREENS} ${SCREENS ROOT}${/}examples${/}08_Simulation_App 12 | 13 | 14 | *** Test Cases *** 15 | 08_Simulation_App 16 | [Tags] gh:48 17 | Example Should Restart-and-Run-All ${SIM APP} 18 | # not worth counting anything, as is basically non-deterministic 19 | Wait Until Computed Element Styles Are 5x 1s rect.elknode stroke=rgba(0, 0, 0, 0) 20 | Wait Until Computed Element Styles Are 5x 1s .down path strokeDasharray=4px stroke=rgb(255, 0, 0) 21 | Wait Until Computed Element Styles Are 5x 1s .elkedge fontWeight=700 22 | -------------------------------------------------------------------------------- /js/tokens.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2024 ipyelk contributors. 3 | * Distributed under the terms of the Modified BSD License. 4 | */ 5 | import PKG from '../package.json'; 6 | 7 | export const NAME = PKG.name; 8 | export const VERSION = PKG.version; 9 | 10 | export const ELK_DEBUG = window.location.hash.indexOf('ELK_DEBUG') > -1; 11 | 12 | export interface IELKCenterMessage { 13 | action: 'center'; 14 | model_id: string[] | string; 15 | animate?: boolean; 16 | retain_zoom?: boolean; 17 | } 18 | 19 | export interface IELKFitMessage { 20 | action: 'fit'; 21 | model_id: string[] | string; 22 | animate?: boolean; 23 | max_zoom?: number; 24 | padding?: number; 25 | } 26 | 27 | export interface IRunMessage { 28 | action: 'run'; 29 | } 30 | 31 | export const ELK_CSS = { 32 | label: 'elklabel', 33 | widget_class: 'jp-ElkView', 34 | sizer_class: 'jp-ElkSizer', 35 | }; 36 | 37 | export type TAnyELKMessage = IELKCenterMessage | IELKFitMessage; 38 | -------------------------------------------------------------------------------- /COPYRIGHT.md: -------------------------------------------------------------------------------- 1 | # Copyright 2 | 3 | Copyright (c) 2021 Dane Freeman. Distributed under the terms of the Modified BSD 4 | License. 5 | 6 | ## Third-party Software 7 | 8 | Portions of this work are derived from other open source works: 9 | 10 | ### https://github.com/kieler/elkjs 11 | 12 | > Copyright (c) 2017 Kiel University and others. All rights reserved. This program and 13 | > the accompanying materials are made available under the terms of the Eclipse Public 14 | > License v1.0 which accompanies this distribution, and is available at 15 | > http://www.eclipse.org/legal/epl-v10.html 16 | 17 | ### https://github.com/eclipse/sprotty-layout 18 | 19 | > Copyright (c) 2017 TypeFox GmbH (http://www.typefox.io) and others. All rights 20 | > reserved. This program and the accompanying materials are made available under the 21 | > terms of the Eclipse Public License v1.0 which accompanies this distribution, and is 22 | > available at http://www.eclipse.org/legal/epl-v10.html 23 | -------------------------------------------------------------------------------- /examples/flat_graph.json: -------------------------------------------------------------------------------- 1 | { 2 | "directed": true, 3 | "graph": {}, 4 | "links": [ 5 | { 6 | "source": "n1", 7 | "target": "n2", 8 | "key": 0 9 | }, 10 | { 11 | "source": "n1", 12 | "target": "n3", 13 | "key": 0 14 | }, 15 | { 16 | "source": "n2", 17 | "target": "n3", 18 | "key": 0, 19 | "properties": { 20 | "cssClasses": "example-data-edge-class-from-flat" 21 | } 22 | } 23 | ], 24 | "multigraph": true, 25 | "nodes": [ 26 | { 27 | "id": "n1" 28 | }, 29 | { 30 | "id": "n2" 31 | }, 32 | { 33 | "id": "n3", 34 | "properties": { 35 | "cssClasses": "example-data-node-class-from-flat" 36 | }, 37 | "ports": [ 38 | { 39 | "id": "p1", 40 | "properties": { 41 | "cssClasses": "example-data-port-class-from-flat" 42 | } 43 | } 44 | ] 45 | } 46 | ] 47 | } 48 | -------------------------------------------------------------------------------- /src/ipyelk/schema/validator.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 ipyelk contributors. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | 5 | import json 6 | from pathlib import Path 7 | from typing import List 8 | 9 | import jsonschema 10 | 11 | HERE = Path(__file__).parent 12 | SCHEMA = json.loads((HERE / "elkschema.json").read_text(encoding="utf-8")) 13 | SCHEMA["$ref"] = "#/definitions/AnyElkNode" 14 | 15 | 16 | ElkSchemaValidator = jsonschema.Draft7Validator(SCHEMA) 17 | 18 | 19 | def validate_elk_json(value) -> bool: 20 | errors: List[jsonschema.ValidationError] = list( 21 | ElkSchemaValidator.iter_errors(value) 22 | ) 23 | 24 | if errors: 25 | msg = "" 26 | for error in errors: 27 | path = "/".join(map(str, error.path)) 28 | msg += f"\n#/{path}\n\t{error.message}" 29 | msg += f"\n\t\t{json.dumps(error.instance)[:70]}" 30 | raise jsonschema.ValidationError(msg) 31 | return True 32 | -------------------------------------------------------------------------------- /atest/Notebooks/15_Nesting_Plots.robot: -------------------------------------------------------------------------------- 1 | *** Settings *** 2 | Resource ../_resources/keywords/Browser.robot 3 | Resource ../_resources/keywords/Lab.robot 4 | Resource ../_resources/keywords/IPyElk.robot 5 | Library Collections 6 | 7 | Test Teardown Clean up after IPyElk Example 8 | 9 | 10 | *** Variables *** 11 | ${SCREENS} ${SCREENS ROOT}${/}examples${/}${NESTING PLOTS} 12 | 13 | 14 | *** Test Cases *** 15 | 15_Nesting_Plots 16 | Example Should Restart-and-Run-All ${NESTING PLOTS} 17 | Scroll To Last Cell 18 | BQPlot Figure Count Should Be ${0} 19 | ${sel} = Set Variable css:[title="expand and center"] 20 | Click Element ${sel} 21 | Sleep 2s 22 | Capture Page Screenshot 11-expanded.png 23 | BQPlot Figure Count Should Be ${4} 24 | Click Element ${sel} 25 | Sleep 2s 26 | Capture Page Screenshot 12-collapsed.png 27 | BQPlot Figure Count Should Be ${0} 28 | -------------------------------------------------------------------------------- /src/ipyelk/tools/progress.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 ipyelk contributors. 2 | # Distributed under the terms of the Modified BSD License. 3 | import ipywidgets as W 4 | import traitlets as T 5 | 6 | from ..pipes import Pipe 7 | from .tool import Tool 8 | 9 | 10 | class PipelineProgressBar(Tool): 11 | bar = T.Instance(W.FloatProgress, kw={}) 12 | pipe = T.Instance(Pipe) 13 | priority = T.Int(default_value=100) 14 | 15 | @T.default("ui") 16 | def _default_ui(self): 17 | return self.bar 18 | 19 | def update(self, pipe: Pipe): 20 | self.pipe = pipe 21 | bar = self.bar 22 | 23 | bar.value = pipe.get_progress_value() 24 | bar.max = 1 25 | 26 | if pipe.status.exception: 27 | bar.bar_style = "warning" 28 | else: 29 | bar.bar_style = "" 30 | if bar.value == bar.max: 31 | bar.layout.visibility = "hidden" 32 | else: 33 | bar.layout.visibility = "visible" 34 | -------------------------------------------------------------------------------- /atest/Notebooks/01_Linking.robot: -------------------------------------------------------------------------------- 1 | *** Settings *** 2 | Resource ../_resources/keywords/Browser.robot 3 | Resource ../_resources/keywords/Lab.robot 4 | Resource ../_resources/keywords/IPyElk.robot 5 | Library Collections 6 | 7 | Test Teardown Clean up after IPyElk Example 8 | 9 | 10 | *** Variables *** 11 | ${SCREENS} ${SCREENS ROOT}${/}examples${/}01_Linking 12 | 13 | 14 | *** Test Cases *** 15 | 01_Linking 16 | [Tags] data:simple.json tool:fit 17 | Example Should Restart-and-Run-All ${LINKING} 18 | ${counts} = Create Dictionary n=${2} &{SIMPLE COUNTS} 19 | Click Elk Tool Fit 1 20 | Click Elk Tool Fit 2 21 | Elk Counts Should Be &{counts} 22 | Create Linked Elk Output View 23 | Click Elk Tool Fit 3 24 | Click Elk Tool Fit 4 25 | Sleep 1s 26 | Linked Elk Output Counts Should Be &{counts} open=${FALSE} 27 | Custom Elk Selectors Should Exist @{SIMPLE CUSTOM} 28 | -------------------------------------------------------------------------------- /atest/Notebooks/02_Transformer.robot: -------------------------------------------------------------------------------- 1 | *** Settings *** 2 | Resource ../_resources/keywords/Browser.robot 3 | Resource ../_resources/keywords/Lab.robot 4 | Resource ../_resources/keywords/IPyElk.robot 5 | Library Collections 6 | 7 | Test Teardown Clean up after IPyElk Example 8 | 9 | 10 | *** Variables *** 11 | ${SCREENS} ${SCREENS ROOT}${/}examples${/}02_Transformer 12 | 13 | 14 | *** Test Cases *** 15 | 02_Transformer 16 | [Tags] data:flat_graph.json data:hier_tree.json data:hier_ports.json ci:skip-win 17 | Example Should Restart-and-Run-All ${TRANSFORMER} 18 | Click Elk Tool Center 1 19 | Scroll To Cell 10 20 | Click Elk Tool Center 2 21 | Elk Counts Should Be &{FLAT AND HIER COUNTS} 22 | Scroll To First Cell 23 | Linked Elk Output Counts Should Be &{FLAT COUNTS} 24 | Custom Elk Selectors Should Exist @{FLAT CUSTOM} 25 | Custom Elk Selectors Should Exist @{HIER PORT CUSTOM} 26 | -------------------------------------------------------------------------------- /style/view.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2024 ipyelk contributors. 3 | * Distributed under the terms of the Modified BSD License. 4 | */ 5 | 6 | .jp-ElkView, 7 | .jp-ElkView .sprotty { 8 | height: 100%; 9 | display: flex; 10 | flex-direction: column; 11 | flex: 1; 12 | } 13 | 14 | .jp-ElkView .sprotty text { 15 | user-select: none; 16 | } 17 | 18 | /* Root View */ 19 | .jp-ElkView .sprotty-root { 20 | flex: 1; 21 | display: flex; 22 | flex-direction: column; 23 | } 24 | 25 | .jp-ElkView .sprotty > .sprotty-root > svg.sprotty-graph { 26 | width: 100%; 27 | height: 100%; 28 | flex: 1; 29 | } 30 | 31 | .jp-ElkView .sprotty > .sprotty-root > div.sprotty-overlay { 32 | width: 100%; 33 | height: 100%; 34 | flex: 1; 35 | position: absolute; 36 | top: 0; 37 | left: 0; 38 | transform-origin: top left; 39 | pointer-events: none; 40 | } 41 | 42 | .jp-ElkView .sprotty > .sprotty-root > div.sprotty-overlay > div.elkcontainer { 43 | pointer-events: all; 44 | position: absolute; 45 | } 46 | -------------------------------------------------------------------------------- /src/ipyelk/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 ipyelk contributors. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | from .constants import EXTENSION_NAME, __version__ 5 | from .diagram import Diagram, Viewer 6 | from .loaders import ( 7 | ElementLoader, 8 | ElkJSONLoader, 9 | Loader, 10 | NXLoader, 11 | from_element, 12 | from_elkjson, 13 | from_nx, 14 | ) 15 | from .pipes import MarkElementWidget, Pipe, Pipeline 16 | from .tools import Tool 17 | 18 | 19 | def _jupyter_labextension_paths(): 20 | from .js import __prefix__ 21 | 22 | return [dict(src=str(__prefix__), dest=EXTENSION_NAME)] 23 | 24 | 25 | __all__ = [ 26 | "Diagram", 27 | "ElementLoader", 28 | "ElkJSONLoader", 29 | "Loader", 30 | "MarkElementWidget", 31 | "NXLoader", 32 | "Pipe", 33 | "Pipeline", 34 | "Tool", 35 | "Viewer", 36 | "__version__", 37 | "_jupyter_labextension_paths", 38 | "from_element", 39 | "from_elkjson", 40 | "from_nx", 41 | ] 42 | -------------------------------------------------------------------------------- /atest/_resources/keywords/Coverage.robot: -------------------------------------------------------------------------------- 1 | *** Settings *** 2 | Documentation Keywords for working with browser coverage data 3 | 4 | Library OperatingSystem 5 | Library SeleniumLibrary 6 | Library uuid 7 | 8 | 9 | *** Keywords *** 10 | Get Next Coverage File 11 | [Documentation] Get a random filename. 12 | ${uuid} = UUID1 13 | RETURN ${uuid.__str__()} 14 | 15 | Capture Page Coverage 16 | [Documentation] Fetch coverage data from the browser. 17 | [Arguments] ${name}=${EMPTY} 18 | IF not '''${name}''' 19 | ${name} = Get Next Coverage File 20 | END 21 | ${cov_json} = Execute Javascript 22 | ... return window.__coverage__ && JSON.stringify(window.__coverage__, null, 2) 23 | IF ${cov_json} 24 | Create File ${OUTPUT DIR}${/}jscov${/}${name}.json ${cov_json} 25 | Execute Javascript window.__coverage__ = {} 26 | ELSE 27 | Log No browser coverage captured CONSOLE 28 | END 29 | -------------------------------------------------------------------------------- /atest/Notebooks/03_App.robot: -------------------------------------------------------------------------------- 1 | *** Settings *** 2 | Resource ../_resources/keywords/Browser.robot 3 | Resource ../_resources/keywords/Lab.robot 4 | Resource ../_resources/keywords/IPyElk.robot 5 | Library Collections 6 | 7 | Test Teardown Clean up after IPyElk Example 8 | 9 | 10 | *** Variables *** 11 | ${SCREENS} ${SCREENS ROOT}${/}examples${/}03_App 12 | 13 | 14 | *** Test Cases *** 15 | 03_App 16 | [Tags] data:hier_tree.json data:hier_ports.json foo:bar 17 | Example Should Restart-and-Run-All ${APP} 18 | Scroll To Cell 6 19 | Click Elk Tool Center 1 20 | Scroll To Cell 9 21 | Click Elk Tool Center 2 22 | Scroll To Cell 12 23 | Click Elk Tool Center 3 24 | Scroll To Cell 15 25 | Click Elk Tool Center 4 26 | Elk Counts Should Be n=${4} &{HIER COUNTS} 27 | Scroll To Cell 6 28 | Linked Elk Output Counts Should Be &{HIER COUNTS} 29 | Custom Elk Selectors Should Exist @{HIER PORT CUSTOM} 30 | -------------------------------------------------------------------------------- /src/ipyelk/trait_types.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 ipyelk contributors. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | import json 5 | from typing import List 6 | 7 | import jsonschema 8 | import traitlets 9 | 10 | 11 | class Schema(traitlets.Any): 12 | """any... but validated by a jsonschema.Validator""" 13 | 14 | _validator: jsonschema.Draft7Validator = None 15 | 16 | def __init__(self, validator, *args, **kwargs): 17 | super().__init__(*args, **kwargs) 18 | self._validator = validator 19 | 20 | def validate(self, obj, value): 21 | errors: List[jsonschema.ValidationError] = list( 22 | self._validator.iter_errors(value) 23 | ) 24 | if errors: 25 | msg = "" 26 | for error in errors: 27 | path = "/".join(map(str, error.path)) 28 | msg += f"\n#/{path}\n\t{error.message}" 29 | msg += f"\n\t\t{json.dumps(error.instance)[:70]}" 30 | raise traitlets.TraitError(msg) 31 | return value 32 | -------------------------------------------------------------------------------- /js/tools/tool.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2024 ipyelk contributors. 3 | * Distributed under the terms of the Modified BSD License. 4 | */ 5 | import { injectable } from 'inversify'; 6 | 7 | import { Action } from 'sprotty-protocol'; 8 | 9 | import { MouseListener, MouseTool } from 'sprotty'; 10 | 11 | export const TOOL_ID_PREFIX = 'tool'; 12 | 13 | export function deriveToolId(operationKind: string, elementTypeId?: string) { 14 | return `${TOOL_ID_PREFIX}_${operationKind}_${elementTypeId}`; 15 | } 16 | 17 | export interface IMouseTool { 18 | register(mouseListener: MouseListener): void; 19 | deregister(mouseListener: MouseListener): void; 20 | } 21 | 22 | // TODO make this an interface? 23 | @injectable() 24 | export class DiagramTool extends MouseTool { 25 | public elementTypeId: string = 'unknown'; 26 | public operationKind: string = 'generic'; 27 | 28 | get id() { 29 | return deriveToolId(this.operationKind, this.elementTypeId); 30 | } 31 | 32 | enable() {} 33 | 34 | disable() {} 35 | 36 | dispatchFeedback(actions: Action[]) {} 37 | } 38 | -------------------------------------------------------------------------------- /js/sprotty/json/elkschema.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2024 ipyelk contributors. 3 | * Distributed under the terms of the Modified BSD License. 4 | */ 5 | 6 | /** 7 | * this exists for generating a complete JSON schema 8 | */ 9 | import * as ELK from 'elkjs'; 10 | 11 | import { ElkProperties } from './elkgraph-json'; 12 | 13 | export interface AnyElkLabelWithProperties extends ELK.ElkLabel { 14 | properties?: ElkProperties; 15 | labels?: AnyElkLabelWithProperties[]; 16 | } 17 | 18 | export interface AnyElkEdgeWithProperties extends ELK.ElkExtendedEdge { 19 | sources: string[]; 20 | targets: string[]; 21 | labels?: AnyElkLabelWithProperties[]; 22 | properties?: ElkProperties; 23 | } 24 | 25 | export interface AnyElkPort extends ELK.ElkPort { 26 | properties?: ElkProperties; 27 | labels?: AnyElkLabelWithProperties[]; 28 | } 29 | 30 | export interface AnyElkNode extends ELK.ElkNode { 31 | children?: AnyElkNode[]; 32 | ports?: AnyElkPort[]; 33 | edges?: AnyElkEdgeWithProperties[]; 34 | properties?: ElkProperties; 35 | labels?: AnyElkLabelWithProperties[]; 36 | } 37 | -------------------------------------------------------------------------------- /scripts/vale/output.tmpl: -------------------------------------------------------------------------------- 1 | {{- /* Keep track of our various counts */ -}} 2 | 3 | {{- $e := 0 -}} 4 | {{- $w := 0 -}} 5 | {{- $s := 0 -}} 6 | {{- $f := 0 -}} 7 | 8 | {{- /* Range over the linted files */ -}} 9 | 10 | {{- range .Files}} 11 | {{$table := newTable true}} 12 | 13 | {{- $f = add1 $f -}} 14 | {{- .Path | underline | indent 1 -}} 15 | 16 | {{- /* Range over the file's alerts */ -}} 17 | 18 | {{- range .Alerts -}} 19 | 20 | {{- $error := "" -}} 21 | {{- if eq .Severity "error" -}} 22 | {{- $error = .Severity | red -}} 23 | {{- $e = add1 $e -}} 24 | {{- else if eq .Severity "warning" -}} 25 | {{- $error = .Severity | yellow -}} 26 | {{- $w = add1 $w -}} 27 | {{- else -}} 28 | {{- $error = .Severity | blue -}} 29 | {{- $s = add1 $s -}} 30 | {{- end}} 31 | 32 | {{- $loc := printf "%d:%d" .Line (index .Span 0) -}} 33 | {{- $row := list $loc $error .Message .Check | toStrings -}} 34 | 35 | {{- $table = addRow $table $row -}} 36 | {{end -}} 37 | 38 | {{- $table = renderTable $table -}} 39 | {{end}} 40 | {{- $e}} {{"errors"}}, {{$w}} {{"warnings"}} and {{$s}} {{"suggestions"}} in {{$f}} {{$f | int | plural "file" "files"}}. 41 | -------------------------------------------------------------------------------- /src/ipyelk/elements/common.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 ipyelk contributors. 2 | # Distributed under the terms of the Modified BSD License. 3 | from collections import namedtuple 4 | from typing import Dict, List 5 | 6 | EMPTY_SENTINEL = namedtuple("Sentinel", []) 7 | 8 | 9 | def add_excluded_fields(kwargs: Dict, excluded: List) -> Dict: 10 | """Shim function to help manipulate excluded fields from the `dict` 11 | method 12 | """ 13 | exclude = kwargs.pop("exclude", None) or set() 14 | if isinstance(exclude, set): 15 | for i in excluded: 16 | exclude.add(i) 17 | else: 18 | raise TypeError(f"TODO handle other types of exclude e.g. {type(exclude)}") 19 | kwargs["exclude"] = exclude 20 | return kwargs 21 | 22 | 23 | class CounterContextManager: 24 | counter = 0 25 | active: bool = False 26 | 27 | def __enter__(self): 28 | if self.counter == 0: 29 | self.active = True 30 | self.counter += 1 31 | return self 32 | 33 | def __exit__(self, *exc): 34 | if self.counter == 1: 35 | self.active = False 36 | if self.counter >= 1: 37 | self.counter -= 1 38 | -------------------------------------------------------------------------------- /style/app.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2024 ipyelk contributors. 3 | * Distributed under the terms of the Modified BSD License. 4 | */ 5 | 6 | /* App */ 7 | .jp-ElkApp { 8 | display: flex; 9 | flex-direction: column; 10 | flex: 1; 11 | } 12 | 13 | /* Toolbar Styling */ 14 | .jp-ElkApp .jp-ElkToolbar { 15 | width: 100%; 16 | visibility: hidden; 17 | position: absolute; 18 | opacity: 0; 19 | transition: all var(--jp-elk-transition); 20 | transform: translateY(calc(0px - var(--jp-widgets-inline-height))); 21 | } 22 | 23 | .jp-ElkApp:hover .jp-ElkToolbar { 24 | visibility: visible; 25 | opacity: 0.25; 26 | transform: translateY(0); 27 | } 28 | 29 | .jp-ElkApp:hover .jp-ElkToolbar:hover { 30 | opacity: 1; 31 | } 32 | 33 | .jp-ElkToolbar .close-btn { 34 | display: block; 35 | margin-left: auto; 36 | width: var(--jp-widgets-inline-height); 37 | padding: 0; 38 | background: inherit; 39 | border: inherit; 40 | outline: inherit; 41 | } 42 | 43 | .jp-ElkToolbar .close-btn:hover { 44 | box-shadow: inherit; 45 | color: var(--jp-warn-color0); 46 | } 47 | 48 | .jp-ElkSizer { 49 | visibility: hidden; 50 | z-index: -9999; 51 | pointer-events: none; 52 | } 53 | -------------------------------------------------------------------------------- /src/ipyelk/pipes/mappings.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 ipyelk contributors. 2 | # Distributed under the terms of the Modified BSD License. 3 | from collections.abc import Hashable 4 | from dataclasses import dataclass 5 | from typing import Dict, List, Optional 6 | 7 | from .. import elements # import Mark, Node, BaseElement 8 | from ..model.model import ElkNode, ElkPort 9 | 10 | 11 | @dataclass(frozen=True) 12 | class Edge: 13 | source: Hashable 14 | source_port: Optional[Hashable] 15 | target: Hashable 16 | target_port: Optional[Hashable] 17 | owner: Hashable 18 | data: Dict 19 | mark: Optional[elements.Mark] 20 | 21 | def __hash__(self): 22 | return hash((self.source, self.source_port, self.target, self.target_port)) 23 | 24 | 25 | @dataclass(frozen=True) 26 | class Port: 27 | node: Hashable 28 | elkport: ElkPort 29 | mark: Optional[elements.Mark] 30 | 31 | def __hash__(self): 32 | return hash(tuple([hash(self.node), hash(self.elkport.id)])) 33 | 34 | 35 | # TODO investigating following pattern for various map 36 | # https://github.com/pandas-dev/pandas/issues/33025#issuecomment-699636759 37 | NodeMap = Dict[Hashable, ElkNode] 38 | EdgeMap = Dict[Hashable, List[Edge]] 39 | PortMap = Dict[Hashable, Port] 40 | -------------------------------------------------------------------------------- /tests/elements/test_edges.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 ipyelk contributors. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | from ipyelk.elements import Edge, HierarchicalIndex, Label, Node, Port, Registry 5 | 6 | 7 | def test_self_edge_lca(): 8 | """The self edge from one port to another port should be owned by the root node.""" 9 | node = Node(labels=[Label(text="Node")]) 10 | port1 = Port( 11 | labels=[Label(text="1")], 12 | width=10, 13 | height=10, 14 | ) 15 | 16 | port2 = Port( 17 | labels=[Label(text="2")], 18 | width=10, 19 | height=10, 20 | ) 21 | node.add_port(port1) 22 | node.add_port(port2) 23 | 24 | node.add_edge(source=port1, target=port2) 25 | root = Node(children=[node]) 26 | 27 | context = Registry() 28 | with context: 29 | index = HierarchicalIndex.from_els(root) 30 | edge_report, id_report = index.get_reports() 31 | 32 | assert len(edge_report.lca_mismatch) == 1 33 | for edge, (current_owner, expected_owner) in edge_report.lca_mismatch.items(): 34 | assert isinstance(edge, Edge) 35 | assert current_owner is node, "Root should be the new owner of the self edge" 36 | assert expected_owner is root 37 | -------------------------------------------------------------------------------- /examples/hier_ports.json: -------------------------------------------------------------------------------- 1 | { 2 | "directed": true, 3 | "graph": {}, 4 | "links": [ 5 | { 6 | "sourcePort": "x", 7 | "targetPort": "x", 8 | "source": "n1", 9 | "target": "n2", 10 | "key": 0 11 | }, 12 | { 13 | "port": "y", 14 | "source": "n1", 15 | "target": "n2", 16 | "key": 1 17 | }, 18 | { 19 | "port": "y", 20 | "source": "n1", 21 | "target": "n3", 22 | "key": 0 23 | }, 24 | { 25 | "port": "z", 26 | "source": "n1", 27 | "target": "n3", 28 | "key": 1 29 | }, 30 | { 31 | "sourcePort": "z", 32 | "targetPort": "x", 33 | "source": "n2", 34 | "target": "n3", 35 | "key": 0, 36 | "properties": { 37 | "cssClasses": "example-data-edge-class-from-ports" 38 | } 39 | }, 40 | { 41 | "port": "x", 42 | "targetPort": "w", 43 | "source": "n1", 44 | "target": "n3", 45 | "key": 1 46 | } 47 | ], 48 | "multigraph": true, 49 | "nodes": [ 50 | { 51 | "id": "n0", 52 | "properties": { 53 | "cssClasses": "example-data-node-class-from-ports" 54 | } 55 | }, 56 | { 57 | "id": "n1" 58 | }, 59 | { 60 | "id": "n2" 61 | }, 62 | { 63 | "id": "n3" 64 | } 65 | ] 66 | } 67 | -------------------------------------------------------------------------------- /src/ipyelk/pipes/elkjs.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 ipyelk contributors. 2 | # Distributed under the terms of the Modified BSD License. 3 | import traitlets as T 4 | from ipywidgets.widgets.trait_types import TypedTuple 5 | 6 | from ..constants import EXTENSION_NAME, EXTENSION_SPEC_VERSION 7 | from . import flows as F 8 | from .base import SyncedPipe 9 | from .util import wait_for_change 10 | 11 | 12 | class ElkJS(SyncedPipe): 13 | """Jupyterlab widget for calling `elkjs `_ 14 | layout given a valid elkjson dictionary 15 | """ 16 | 17 | _model_name = T.Unicode("ELKLayoutModel").tag(sync=True) 18 | _model_module = T.Unicode(EXTENSION_NAME).tag(sync=True) 19 | _model_module_version = T.Unicode(EXTENSION_SPEC_VERSION).tag(sync=True) 20 | _view_module = T.Unicode(EXTENSION_NAME).tag(sync=True) 21 | 22 | observes = TypedTuple(T.Unicode(), default_value=(F.Anythinglayout,)) 23 | reports = TypedTuple(T.Unicode(), default_value=(F.Layout,)) 24 | 25 | async def run(self): 26 | # watch once 27 | if self.outlet is None: 28 | return 29 | 30 | # signal to browser and wait for done 31 | future_value = wait_for_change(self.outlet, "value") 32 | self.send({"action": "run"}) 33 | 34 | # wait to return until 35 | await future_value 36 | self.outlet.persist() 37 | -------------------------------------------------------------------------------- /js/tools/feedback/di.config.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2024 ipyelk contributors. 3 | * Distributed under the terms of the Modified BSD License. 4 | */ 5 | import { ContainerModule } from 'inversify'; 6 | 7 | import { 8 | GetSelectionCommand, 9 | LocationPostprocessor, 10 | MoveCommand, 11 | SelectAllCommand, 12 | SelectCommand, 13 | TYPES, 14 | configureCommand, 15 | } from 'sprotty/lib'; 16 | 17 | import { ToolTYPES } from '../types'; 18 | 19 | import { ApplyCursorCSSFeedbackActionCommand } from './cursor-feedback'; 20 | import { FeedbackActionDispatcher } from './feedback-action-dispatcher'; 21 | 22 | const toolFeedbackModule = new ContainerModule((bind, _unbind, isBound) => { 23 | bind(ToolTYPES.IFeedbackActionDispatcher) 24 | .to(FeedbackActionDispatcher) 25 | .inSingletonScope(); 26 | 27 | // create node and edge tool feedback 28 | configureCommand({ bind, isBound }, ApplyCursorCSSFeedbackActionCommand); 29 | configureCommand({ bind, isBound }, MoveCommand); 30 | 31 | //Select commands 32 | configureCommand({ bind, isBound }, SelectCommand); 33 | configureCommand({ bind, isBound }, SelectAllCommand); 34 | configureCommand({ bind, isBound }, GetSelectionCommand); 35 | 36 | bind(TYPES.IVNodePostprocessor).to(LocationPostprocessor); 37 | bind(TYPES.HiddenVNodePostprocessor).to(LocationPostprocessor); 38 | }); 39 | 40 | export default toolFeedbackModule; 41 | -------------------------------------------------------------------------------- /atest/_resources/keywords/LabCompat.robot: -------------------------------------------------------------------------------- 1 | *** Settings *** 2 | Documentation JupyterLab compatibility utilities 3 | 4 | Library SeleniumLibrary 5 | Resource ../variables/Lab.robot 6 | 7 | 8 | *** Variables *** 9 | ${LAB VIRTUAL SCROLLING} ${TRUE} 10 | 11 | 12 | *** Keywords *** 13 | Get Cell Count 14 | IF ${LAB VIRTUAL SCROLLING} 15 | Ensure Notebook Window Scrollbar is Open 16 | ${cells} = Get WebElements ${JLAB CSS WINDOW SCROLL} li 17 | ELSE 18 | ${cells} = Get WebElements ${JLAB CSS CELL} 19 | END 20 | 21 | RETURN ${cells.__len__()} 22 | 23 | Scroll To First Cell 24 | Scroll To Cell 1 25 | 26 | Scroll To Last Cell 27 | ${cell_count} = Get Cell Count 28 | Scroll To Cell ${cell_count} 29 | 30 | Scroll To Cell 31 | [Arguments] ${n} 32 | ${cell} = Set Variable ${JLAB CSS CELL}:nth-child(${n}) 33 | 34 | IF ${LAB_VIRTUAL_SCROLLING} 35 | Ensure Notebook Window Scrollbar is Open 36 | Click Element ${JLAB CSS WINDOW SCROLL} li:nth-child(${n}) 37 | ELSE 38 | Execute Javascript 39 | ... document.querySelector(".jp-Cell:nth-child(${n})").scrollIntoView() 40 | END 41 | 42 | Ensure Notebook Window Scrollbar is Open 43 | ${els} = Get WebElements ${JLAB CSS WINDOW SCROLL} 44 | IF not ${els.__len__()} Click Element ${JLAB CSS WINDOW TOGGLE} 45 | -------------------------------------------------------------------------------- /js/plugin.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2024 ipyelk contributors. 3 | * Distributed under the terms of the Modified BSD License. 4 | */ 5 | import { Application, IPlugin } from '@lumino/application'; 6 | import { Widget } from '@lumino/widgets'; 7 | 8 | import { IJupyterWidgetRegistry } from '@jupyter-widgets/base'; 9 | 10 | import '../style/index.css'; 11 | 12 | import { ELK_DEBUG, NAME, VERSION } from './tokens'; 13 | 14 | const EXTENSION_ID = `${NAME}:plugin`; 15 | 16 | const plugin: IPlugin, void> = { 17 | id: EXTENSION_ID, 18 | requires: [IJupyterWidgetRegistry], 19 | autoStart: true, 20 | activate: async (app: Application, registry: IJupyterWidgetRegistry) => { 21 | const { patchReflectMetadata } = await import('./patches'); 22 | await patchReflectMetadata(); 23 | ELK_DEBUG && console.warn('elk activated'); 24 | registry.registerWidget({ 25 | name: NAME, 26 | version: VERSION, 27 | exports: async () => { 28 | const widgetExports = { 29 | ...(await import(/* webpackChunkName: "elklayout" */ './layout_widget')), 30 | ...(await import(/* webpackChunkName: "elkdisplay" */ './display_widget')), 31 | ...(await import(/* webpackChunkName: "elkexporter" */ './exporter')), 32 | }; 33 | ELK_DEBUG && console.warn('widgets loaded'); 34 | return widgetExports; 35 | }, 36 | }); 37 | }, 38 | }; 39 | export default plugin; 40 | -------------------------------------------------------------------------------- /src/ipyelk/contrib/molds/structures.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 ipyelk contributors. 2 | # Distributed under the terms of the Modified BSD License. 3 | from ...elements import Node, NodeProperties, shapes 4 | 5 | 6 | def DoubleCircle(radius=6): 7 | return Node( 8 | children=[ 9 | Node(properties=NodeProperties(shape=shapes.Circle(radius=6))), 10 | Node( 11 | properties=NodeProperties( 12 | cssClasses="inner-circle", 13 | shape=shapes.Circle(radius=3, x=radius, y=radius), 14 | ) 15 | ), 16 | ] 17 | ) 18 | 19 | 20 | def XCircle(radius=6) -> Node: 21 | r = radius 22 | return Node( 23 | children=[ 24 | Node(properties=NodeProperties(shape=shapes.Circle(radius=r))), 25 | Node( 26 | properties=NodeProperties( 27 | shape=shapes.Path.from_list([ 28 | (r + r * 2**-0.5, r + r * 2**-0.5), 29 | (r - r * 2**-0.5, r - r * 2**-0.5), 30 | ]) 31 | ) 32 | ), 33 | Node( 34 | properties=NodeProperties( 35 | shape=shapes.Path.from_list([ 36 | (r - r * 2**-0.5, r + r * 2**-0.5), 37 | (r + r * 2**-0.5, r - r * 2**-0.5), 38 | ]) 39 | ) 40 | ), 41 | ] 42 | ) 43 | -------------------------------------------------------------------------------- /atest/_resources/keywords/Browser.robot: -------------------------------------------------------------------------------- 1 | *** Settings *** 2 | Resource CLI.robot 3 | Library SeleniumLibrary 4 | Library Collections 5 | Resource ../variables/Browser.robot 6 | 7 | 8 | *** Keywords *** 9 | Setup Suite For Screenshots 10 | [Arguments] ${folder} 11 | Set Screenshot Directory ${SCREENS ROOT}${/}${folder} 12 | 13 | Wait Until Computed Element Styles Are 14 | [Documentation] Wait until some computed styles are as expected. 15 | [Arguments] ${times} ${delay} ${css selector} &{styles} 16 | Wait Until Keyword Succeeds ${times} ${delay} 17 | ... Computed Element Style Should Be ${css selector} &{styles} 18 | 19 | Computed Element Style Should Be 20 | [Documentation] Check whether the element style has all the given camelCase-value pairs. 21 | ... Further, some values get translated, e.g. `red` -> `rgb(255, 0, 0)` 22 | [Arguments] ${css selector} &{styles} 23 | Wait Until Page Contains Element css:${css selector} 24 | ${map} = Set Variable return window.getComputedStyle(document.querySelector(`${css selector}`)) 25 | ${observed} = Create Dictionary 26 | ${all} = Execute Javascript ${map} 27 | FOR ${key} ${value} IN &{styles} 28 | ${computed} = Execute JavaScript ${map}\[`${key}`] 29 | Set To Dictionary ${observed} ${key}=${computed} 30 | END 31 | Dictionaries Should Be Equal ${styles} ${observed} 32 | -------------------------------------------------------------------------------- /js/tools/feedback/model.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2024 ipyelk contributors. 3 | * Distributed under the terms of the Modified BSD License. 4 | */ 5 | 6 | /******************************************************************************** 7 | * Copyright (c) 2019 EclipseSource and others. 8 | * 9 | * This program and the accompanying materials are made available under the 10 | * terms of the Eclipse Public License v. 2.0 which is available at 11 | * http://www.eclipse.org/legal/epl-2.0. 12 | * 13 | * This Source Code may also be made available under the following Secondary 14 | * Licenses when the conditions for such availability set forth in the Eclipse 15 | * Public License v. 2.0 are satisfied: GNU General Public License, version 2 16 | * with the GNU Classpath Exception which is available at 17 | * https://www.gnu.org/software/classpath/license.html. 18 | * 19 | * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 20 | ********************************************************************************/ 21 | import { Command, CommandExecutionContext, CommandReturn } from 'sprotty/lib'; 22 | 23 | export abstract class FeedbackCommand extends Command { 24 | // used by the `FeedbackAwareUpdateModelCommand` 25 | readonly priority: number = 0; 26 | 27 | abstract execute(context: CommandExecutionContext): CommandReturn; 28 | undo(context: CommandExecutionContext): CommandReturn { 29 | return context.root; 30 | } 31 | 32 | redo(context: CommandExecutionContext): CommandReturn { 33 | return context.root; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /tests/elements/test_marks.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 ipyelk contributors. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | from ipyelk.elements import MarkFactory, Node 5 | 6 | 7 | def test_simple_factory(): 8 | """Have factory generate graphs from only a single node""" 9 | n1 = Node() 10 | 11 | factory = MarkFactory() 12 | g, tree = factory(n1) 13 | assert len(g) == 1, "Expecting only one nodes" 14 | assert len(g.edges) == 0, "Expect no edges" 15 | assert len(tree) == 0, "Expecting no hierarchy" 16 | assert len(tree.edges) == 0, "Expecting no hierarchy" 17 | 18 | 19 | def test_simple_flat(): 20 | """Have factory generate graphs from two connected nodes""" 21 | n1 = Node() 22 | n2 = Node() 23 | n1.add_edge(n1, n2) 24 | 25 | factory = MarkFactory() 26 | g, tree = factory(n1, n2) 27 | assert len(g) == 2, "Expecting only two nodes" 28 | assert len(g.edges) == 1, "Expect only one edge" 29 | assert len(tree) == 0, "Expecting no hierarchy" 30 | assert len(tree.edges) == 0, "Expecting no hierarchy" 31 | 32 | 33 | def test_simple_hierarchy(): 34 | """Have factory generate graphs from connected parent child""" 35 | n1 = Node() 36 | n2 = Node() 37 | n1.add_edge(n1, n2) 38 | n1.add_child(n2, "x") 39 | 40 | factory = MarkFactory() 41 | g, tree = factory(n1) 42 | assert len(g) == 2, "Expecting only two nodes" 43 | assert len(g.edges) == 1, "Expect only one edge" 44 | assert len(tree) == 2, "Expecting two nodes in hierarchy" 45 | assert len(tree.edges) == 1, "Expect only one edge" 46 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2020, Dane Freeman 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /src/ipyelk/pipes/visibility.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 ipyelk contributors. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | import traitlets as T 5 | from ipywidgets.widgets.trait_types import TypedTuple 6 | 7 | from ..elements import ( 8 | Registry, 9 | VisIndex, 10 | convert_elkjson, 11 | exclude_hidden, 12 | exclude_layout, 13 | index, 14 | ) 15 | from . import flows as F 16 | from .base import Pipe 17 | 18 | 19 | class VisibilityPipe(Pipe): 20 | observes = TypedTuple( 21 | T.Unicode(), 22 | default_value=( 23 | F.AnyHidden, 24 | F.Layout, 25 | ), 26 | ) 27 | 28 | @T.default("reports") 29 | def _default_reports(self): 30 | return (F.Layout,) 31 | 32 | async def run(self): 33 | if self.outlet is None or self.inlet is None: 34 | return None 35 | 36 | root = self.inlet.index.root 37 | # generate an index of hidden elements 38 | vis_index = VisIndex.from_els(root) 39 | 40 | # clear old slack css classes from elements 41 | vis_index.clear_slack(root) 42 | 43 | # serialize the elements excluding hidden 44 | with exclude_hidden, exclude_layout: 45 | data = root.dict() 46 | 47 | # new root node with slack edges / ports introduced due to hidden 48 | # elements 49 | with Registry(): 50 | value = convert_elkjson(data, vis_index) 51 | 52 | for el in index.iter_elements(value): 53 | el.id = el.get_id() 54 | self.outlet.value = value 55 | 56 | return self.outlet 57 | -------------------------------------------------------------------------------- /src/ipyelk/elements/registry.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 ipyelk contributors. 2 | # Distributed under the terms of the Modified BSD License. 3 | from collections import defaultdict 4 | from typing import ClassVar, List, Optional 5 | from uuid import uuid4 6 | 7 | from pydantic.v1 import BaseModel, Field 8 | 9 | 10 | def id_factory(): 11 | return defaultdict(lambda: str(uuid4())) 12 | 13 | 14 | class Registry(BaseModel): 15 | """Context Manager to generate and maintain a lookup of objects to identifiers""" 16 | 17 | ids: defaultdict = Field(repr=False, default_factory=id_factory) 18 | stack: ClassVar[List] = [] 19 | 20 | class Config: 21 | copy_on_model_validation = "none" 22 | 23 | def __enter__(self): 24 | self.get_contexts().append(self) 25 | return self 26 | 27 | def __exit__(self, typ, value, traceback): 28 | self.get_contexts().pop() 29 | 30 | @classmethod 31 | def get_context(cls, error_if_none=True) -> Optional["Registry"]: 32 | try: 33 | return cls.get_contexts()[-1] 34 | except IndexError: 35 | if error_if_none: 36 | raise TypeError("No %s on context stack" % str(cls)) 37 | return None 38 | 39 | @classmethod 40 | def get_contexts(cls) -> List: 41 | return cls.stack 42 | 43 | @classmethod 44 | def get_id(cls, key) -> Optional[str]: 45 | context = cls.get_context(error_if_none=False) 46 | if context: 47 | return context[key] 48 | 49 | def __getitem__(self, key): 50 | return self.ids[key] 51 | 52 | def __hash__(self): 53 | return hash(id(self)) 54 | -------------------------------------------------------------------------------- /src/ipyelk/pipes/flows.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 ipyelk contributors. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | 5 | class Text: 6 | text = "label.text" 7 | size_css = "label.properties.cssClasses-size" 8 | color_css = "label.properties.cssClasses-colors" 9 | size = "label.size" 10 | layout_options = "label.layoutOptions" 11 | labels = "label.labels" 12 | hidden = "label.properties.hidden" 13 | 14 | 15 | class Node: 16 | size = "node.size" 17 | children = "node.children" 18 | size_css = "node.properties.cssClasses-size" 19 | color_css = "node.properties.cssClasses-colors" 20 | layout_options = "node.layoutOptions" 21 | labels = "node.labels" 22 | edges = "node.edges" 23 | hidden = "node.properties.hidden" 24 | ports = "node.ports" 25 | 26 | 27 | class Edge: 28 | size_css = "edge.properties.cssClasses-size" 29 | color_css = "edge.properties.cssClasses-colors" 30 | layout_options = "edge.layoutOptions" 31 | labels = "edge.labels" 32 | route = "edge.sections" 33 | source = "edge.sources" 34 | target = "edge.targets" 35 | hidden = "edge.properties.hidden" 36 | 37 | 38 | class Port: 39 | size = "port.size" 40 | size_css = "port.properties.cssClasses-size" 41 | color_css = "port.properties.cssClasses-colors" 42 | layout_options = "port.layoutOptions" 43 | labels = "port.labels" 44 | hidden = "port.properties.hidden" 45 | 46 | 47 | AnySize = ".*.size" 48 | AnyHidden = ".*.hidden" 49 | ColorCSS = ".*.cssClasses-colors" 50 | Anythinglayout = "((?!.*cssClasses-colors).)*" # exclude matches on css color 51 | Layout = "layout" 52 | New = "new" 53 | -------------------------------------------------------------------------------- /src/ipyelk/diagram/export.py: -------------------------------------------------------------------------------- 1 | """Widget for exporting a diagram. Currently supports SVG.""" 2 | 3 | # Copyright (c) 2024 ipyelk contributors. 4 | # Distributed under the terms of the Modified BSD License. 5 | import ipywidgets as W 6 | import traitlets as T 7 | 8 | from ..constants import EXTENSION_NAME, EXTENSION_SPEC_VERSION 9 | from .diagram import Diagram 10 | from .viewer import Viewer 11 | 12 | 13 | class Exporter(W.Widget): 14 | """exports elk diagrams""" 15 | 16 | _model_name = T.Unicode("ELKExporterModel").tag(sync=True) 17 | _model_module = T.Unicode(EXTENSION_NAME).tag(sync=True) 18 | _model_module_version = T.Unicode(EXTENSION_SPEC_VERSION).tag(sync=True) 19 | _view_name = T.Unicode("ELKExporterView").tag(sync=True) 20 | _view_module = T.Unicode(EXTENSION_NAME).tag(sync=True) 21 | _view_module_version = T.Unicode(EXTENSION_SPEC_VERSION).tag(sync=True) 22 | 23 | viewer: Viewer = T.Instance(Viewer, allow_none=True).tag( 24 | sync=True, **W.widget_serialization 25 | ) 26 | value: str = T.Unicode(allow_none=True).tag(sync=True) 27 | enabled: bool = T.Bool(default_value=True).tag(sync=True) 28 | extra_css: str = T.Unicode(default_value="").tag(sync=True) 29 | padding: float = T.Float(20).tag(sync=True) 30 | diagram: Diagram = T.Instance(Diagram, allow_none=True).tag( 31 | sync=True, **W.widget_serialization 32 | ) 33 | strip_ids = T.Bool(default_value=True).tag(sync=True) 34 | add_xml_header = T.Bool(default_value=True).tag(sync=True) 35 | 36 | @T.observe("diagram") 37 | def _set_viewer(self, change): 38 | if change and isinstance(change.new, Diagram): 39 | self.viewer = change.new.view 40 | -------------------------------------------------------------------------------- /src/ipyelk/tools/collapser.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 ipyelk contributors. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | import ipywidgets as W 5 | import traitlets as T 6 | 7 | from ..elements import BaseElement, Compartment, Node 8 | from ..pipes import flows as F 9 | 10 | # from ..elements import Node 11 | from .tool import Tool 12 | from .view_tools import Selection 13 | 14 | 15 | class ToggleCollapsedTool(Tool): 16 | selection: Selection = T.Instance(Selection) 17 | 18 | @T.default("reports") 19 | def _default_reports(self): 20 | return (F.Node.hidden,) 21 | 22 | @T.default("ui") 23 | def _default_ui(self) -> W.DOMWidget: 24 | btn = W.Button(description="Toggle Collapsed") 25 | btn.on_click(self.handler) 26 | return btn 27 | 28 | async def run(self): 29 | should_refresh = False 30 | for selected in self.selection.elements(): 31 | for element in self.get_related(selected): 32 | self.toggle(element) 33 | should_refresh = True 34 | 35 | # trigger refresh if needed 36 | if should_refresh: 37 | self.tee.inlet.flow = self.reports 38 | 39 | def get_related(self, element: BaseElement): 40 | if isinstance(element, Compartment): 41 | return element.get_parent().children[1:] 42 | if isinstance(element, Node): 43 | return element.children 44 | 45 | return [] 46 | 47 | def toggle(self, element: BaseElement) -> bool: 48 | """Toggle the `hidden` state for the given Node""" 49 | hidden = not element.properties.hidden 50 | element.properties.hidden = hidden 51 | return hidden 52 | -------------------------------------------------------------------------------- /js/sprotty/views/graph_views.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx html */ 2 | import { VNode } from 'snabbdom'; 3 | 4 | import { injectable } from 'inversify'; 5 | 6 | import { IView, SGraphImpl, SParentElementImpl, html, svg } from 'sprotty'; 7 | 8 | import { ElkModelRenderer } from '../renderer'; 9 | 10 | class SSymbolGraph extends SGraphImpl { 11 | symbols: SParentElementImpl; 12 | } 13 | /** 14 | * IView component that turns an SGraph element and its children into a tree of virtual DOM elements. 15 | */ 16 | @injectable() 17 | export class SGraphView implements IView { 18 | render(model: Readonly, context: ElkModelRenderer): VNode { 19 | const x = model.scroll.x ? model.scroll.x : 0; 20 | const y = model.scroll.y ? model.scroll.y : 0; 21 | const transform = `scale(${model.zoom}) translate(${-x},${-y})`; 22 | let graph = svg( 23 | 'svg', 24 | { class: { 'sprotty-graph': true } }, 25 | svg('g', { transform: transform }, ...context.renderChildren(model)), 26 | svg( 27 | 'g', 28 | { class: { elksymbols: true } }, 29 | ...context.renderChildren(model.symbols), 30 | ), 31 | ); 32 | const css_transform = { 33 | transform: `scale(${model.zoom}) translateZ(0) translate(${-model.scroll 34 | .x}px,${-model.scroll.y}px)`, 35 | }; 36 | let overlay = ( 37 |
38 | {context.renderJLNodeWidgets()} 39 |
40 | ); 41 | let element: VNode = ( 42 |
43 |
{context.renderJLOverlayControl()}
44 | {graph} 45 | {overlay} 46 |
47 | ); 48 | return element; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /scripts/vale/config/vocabularies/IPyElk/accept.txt: -------------------------------------------------------------------------------- 1 | -compatible 2 | .+_.+ 3 | (.+)\( 4 | 0a1 5 | 2FA 6 | activity/flow-chart 7 | additionalProperties 8 | AnyElkEdgeWithProperties 9 | AnyElkLabelWithProperties 10 | AnyElkNode 11 | AnyElkPort 12 | APIs 13 | backport 14 | Backporting 15 | backports 16 | bendPoints 17 | binder-badge 18 | bool 19 | BrokenPipe 20 | BSD-3-Clause 21 | bugfixes 22 | Changelog 23 | CHANGELOG 24 | checks/changes 25 | ci 26 | composable 27 | conda 28 | ControlOverlay 29 | css 30 | dist 31 | do… 32 | doit 33 | DOM 34 | e\.g\. 35 | ElementLoader 36 | ElementLoader 37 | ElkEdgeSection 38 | ElkLabel 39 | ElkPoint 40 | ElkProperties 41 | endmacro 42 | env 43 | EPL-2.0 44 | etc 45 | evented 46 | FontAwesome 47 | forward-port 48 | ground-truth 49 | html 50 | ids 51 | in-browser 52 | incomingSections 53 | incomingShape 54 | inside/outside 55 | ipyelk 56 | isSymbol 57 | js 58 | junctionPoints 59 | jupyrdf 60 | jupyter 61 | Jupyter 62 | JupyterLab 63 | labextension 64 | LayoutOptions 65 | lifecycle 66 | live-reloading 67 | lockfiles 68 | maintenance 69 | Mambaforge 70 | MarkElementWidget 71 | Miniforge 72 | multiline 73 | networkx 74 | NetworkX 75 | npm 76 | NXLoad 77 | outgoingSections 78 | outgoingShape 79 | Pan/Zoom 80 | PipeStatus 81 | PipeStatusView 82 | pixi 83 | predator/prey 84 | PRs 85 | PyData 86 | pypi 87 | PyPI 88 | re-build 89 | ReadTheDocs 90 | REPLite 91 | screencast 92 | selectable 93 | self.status.exception 94 | SPDX-License-Identifier 95 | sprotty 96 | standards-compliant 97 | startPoint 98 | sub-pipes 99 | Subclasses 100 | SVG 101 | TBD 102 | ToolButton 103 | traitlet 104 | two-factor 105 | TypeFox 106 | TypeScript 107 | UI 108 | vertical/horizontal 109 | viewport 110 | WebWorker 111 | width/height 112 | x/y 113 | -------------------------------------------------------------------------------- /style/pipe_status.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2024 ipyelk contributors. 3 | * Distributed under the terms of the Modified BSD License. 4 | */ 5 | 6 | /* 7 | Styling elk pipe status widget 8 | */ 9 | .elk-pipe span { 10 | display: inline-block; 11 | margin-left: var(--jp-widgets-margin); 12 | margin-right: var(--jp-widgets-margin); 13 | } 14 | 15 | .elk-pipe-badge > svg { 16 | height: var(--jp-widgets-font-size); 17 | } 18 | 19 | .elk-pipe-elapsed, 20 | .elk-pipe-status { 21 | width: 60px; 22 | } 23 | 24 | .elk-pipe-name { 25 | width: 180px; 26 | } 27 | 28 | .elk-pipe-disposition-waiting .elk-pipe-badge { 29 | stroke: var(--jp-info-color3); 30 | fill: none; 31 | stroke-width: 2px; 32 | color: var(--jp-info-color3); 33 | } 34 | 35 | .elk-pipe-disposition-waiting, 36 | .elk-pipe-accessor { 37 | color: var(--jp-border-color1); 38 | } 39 | 40 | .elk-pipe-disposition-finished .elk-pipe-badge { 41 | fill: var(--jp-success-color1); 42 | } 43 | 44 | .elk-pipe-disposition-running .elk-pipe-badge { 45 | fill: var(--jp-info-color1); 46 | } 47 | 48 | .elk-pipe-disposition-error .elk-pipe-badge { 49 | fill: var(--jp-warn-color0); 50 | } 51 | 52 | .elk-pipe-disposition-error .elk-pipe-error { 53 | display: block; 54 | } 55 | 56 | .elk-pipe-disposition-error .elk-pipe-error > code { 57 | background-color: var(--jp-error-color3); 58 | width: 335px; 59 | display: inline-block; 60 | } 61 | 62 | .widget-button.elk-pipe-toggle-btn, 63 | .elk-pipe-space { 64 | width: 2em; 65 | padding: 0px; 66 | margin: 0px; 67 | margin-bottom: auto; 68 | background-color: unset; 69 | height: var(--jp-code-line-height); 70 | line-height: var(--jp-code-line-height); 71 | } 72 | .widget-button.elk-pipe-toggle-btn i { 73 | transition: all 1s; 74 | } 75 | .elk-pipe-toggle-btn.elk-pipe-closed i { 76 | transform: rotate(-90deg); 77 | } 78 | -------------------------------------------------------------------------------- /examples/08_Simulation_App.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "id": "7395ec07-9e51-436a-9644-d89f25f83c05", 6 | "metadata": {}, 7 | "source": [ 8 | "# 🦌 ELK Simulation Demo 🐺🎭\n", 9 | "\n", 10 | "> With [importnb](https://pypi.org/project/importnb), one can interactively build apps,\n", 11 | "> then reuse single parts for presentation. This simulation is built in the\n", 12 | "> [plumbing notebook](./07_Simulation.ipynb)." 13 | ] 14 | }, 15 | { 16 | "cell_type": "code", 17 | "execution_count": null, 18 | "id": "b3062f2e-fef0-4aba-a25a-36689d60fab4", 19 | "metadata": {}, 20 | "outputs": [], 21 | "source": [ 22 | "if __name__ == \"__main__\":\n", 23 | " %pip install -q -r requirements.txt" 24 | ] 25 | }, 26 | { 27 | "cell_type": "code", 28 | "execution_count": null, 29 | "id": "e47b5e87-0e13-4738-b4e5-d81a50b514a2", 30 | "metadata": {}, 31 | "outputs": [], 32 | "source": [ 33 | "with __import__(\"importnb\").Notebook():\n", 34 | " from __07_Simulation import app\n", 35 | "app" 36 | ] 37 | }, 38 | { 39 | "cell_type": "markdown", 40 | "id": "e3ff004a-1a91-4a45-b276-803343af5833", 41 | "metadata": {}, 42 | "source": [ 43 | "## 🦌 Learn More 📖\n", 44 | "\n", 45 | "See the [other examples](./_index.ipynb)." 46 | ] 47 | } 48 | ], 49 | "metadata": { 50 | "kernelspec": { 51 | "display_name": "Python 3 (ipykernel)", 52 | "language": "python", 53 | "name": "python3" 54 | }, 55 | "language_info": { 56 | "codemirror_mode": { 57 | "name": "ipython", 58 | "version": 3 59 | }, 60 | "file_extension": ".py", 61 | "mimetype": "text/x-python", 62 | "name": "python", 63 | "nbconvert_exporter": "python", 64 | "pygments_lexer": "ipython3", 65 | "version": "3.10.6" 66 | } 67 | }, 68 | "nbformat": 4, 69 | "nbformat_minor": 5 70 | } 71 | -------------------------------------------------------------------------------- /tests/elements/test_nodes.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 ipyelk contributors. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | from ipyelk.elements import Edge, Label, Node, Port, shapes 5 | 6 | 7 | def test_node_instances(): 8 | n1 = Node() 9 | n1.dict() 10 | 11 | 12 | def test_add_child(): 13 | key = "child" 14 | n = Node() 15 | p = n.add_child(Node(), key) 16 | assert p.get_parent() is n, "Expect port parent to be the node" 17 | assert n.get_child(key) is p, "Expect node port dict to return same port" 18 | n.dict() 19 | 20 | 21 | def test_add_port(): 22 | key = "port" 23 | n = Node() 24 | p = n.add_port(Port(), key) 25 | assert p.get_parent() is n, "Expect port parent to be the node" 26 | assert n.get_port(key) is p, "Expect node port dict to return same port" 27 | n.dict() 28 | 29 | 30 | def test_edge_node_instances(): 31 | n1 = Node() 32 | n2 = Node() 33 | e = Edge( 34 | source=n1, 35 | target=n2, 36 | ) 37 | assert e.source is n1, "Edge source instance changed" 38 | assert e.target is n2, "Edge target instance changed" 39 | n1.dict() 40 | n2.dict() 41 | e.dict() 42 | 43 | 44 | def test_node_label_instance(): 45 | label = Label() 46 | n = Node(labels=[label]) 47 | assert n.labels[0] is label, "Expect node label instance to match" 48 | label.dict() 49 | 50 | 51 | def test_edge_port_instances(): 52 | n1 = Node() 53 | x = n1.add_port(Port(), "x") 54 | n2 = Node() 55 | e = Edge( 56 | source=x, 57 | target=n2, 58 | ) 59 | assert e.source is x, "Edge source instance changed" 60 | assert e.target is n2, "Edge target instance changed" 61 | n1.dict() 62 | x.dict() 63 | n2.dict() 64 | e.dict() 65 | 66 | 67 | def test_node_shape(): 68 | shape = shapes.Ellipse() 69 | n = Node(properties={"shape": shape}) 70 | data = n.dict() 71 | assert data["properties"]["shape"].get("type") == shape.type 72 | -------------------------------------------------------------------------------- /scripts/check-dist.py: -------------------------------------------------------------------------------- 1 | """Check for files in dist archives.""" 2 | 3 | import sys 4 | import tarfile 5 | import zipfile 6 | from pathlib import Path 7 | 8 | import tomllib 9 | 10 | HERE = Path(__file__).parent 11 | ROOT = HERE.parent 12 | UTF8 = {"encoding": "utf-8"} 13 | 14 | DIST = ROOT / "dist" 15 | PPT = ROOT / "pyproject.toml" 16 | LICENSE = ROOT / "LICENSE.txt" 17 | COPYRIGHT = ROOT / "COPYRIGHT.md" 18 | EPL = ROOT / "third-party/epl-v10.html" 19 | TPL_PATH = ( 20 | "share/jupyter/labextensions/@jupyrdf/jupyter-elk/static/third-party-licenses.json" 21 | ) 22 | TPL = ROOT / "src/_d" / TPL_PATH 23 | 24 | LICENSE_BYTES = {p: p.read_bytes() for p in [LICENSE, COPYRIGHT, EPL, TPL]} 25 | 26 | PY_VERSION = tomllib.loads(PPT.read_text(**UTF8))["project"]["version"] 27 | PFX = f"ipyelk-{PY_VERSION}" 28 | 29 | WHEEL_FILES = { 30 | f"{PFX}.dist-info/LICENSE.txt": LICENSE_BYTES[LICENSE], 31 | f"{PFX}.data/data/{TPL_PATH}": LICENSE_BYTES[TPL], 32 | } 33 | 34 | SDIST_FILES = { 35 | f"{PFX}/LICENSE.txt": LICENSE_BYTES[LICENSE], 36 | f"{PFX}/COPYRIGHT.md": LICENSE_BYTES[COPYRIGHT], 37 | f"{PFX}/third-party/epl-v10.html": LICENSE_BYTES[EPL], 38 | f"{PFX}/src/_d/{TPL_PATH}": LICENSE_BYTES[TPL], 39 | } 40 | 41 | 42 | def check_whl(path: Path) -> None: 43 | with zipfile.ZipFile(path, "r") as whl: 44 | for fn, fbytes in WHEEL_FILES.items(): 45 | assert whl.read(fn) == fbytes, f"!!! wheel {fn} is wrong" 46 | print(f"OK wheel {fn}") 47 | 48 | 49 | def check_sdist(path: Path) -> None: 50 | with tarfile.open(path, "r:gz") as sdist: 51 | for fn, fbytes in SDIST_FILES.items(): 52 | assert sdist.extractfile(fn).read() == fbytes, f"!!! sdist {fn} is wrong" 53 | print(f"OK sdist {fn}") 54 | 55 | 56 | def main() -> int: 57 | for path in sorted(DIST.glob("*")): 58 | if path.name.endswith(".whl"): 59 | check_whl(path) 60 | elif path.name.endswith(".tar.gz"): 61 | check_sdist(path) 62 | 63 | 64 | if __name__ == "__main__": 65 | sys.exit(main()) 66 | -------------------------------------------------------------------------------- /scripts/build-ext-cov.py: -------------------------------------------------------------------------------- 1 | """Build instrumented extension.""" 2 | 3 | import json 4 | import os 5 | import shutil 6 | import sys 7 | from pathlib import Path 8 | from subprocess import call 9 | 10 | UTF8 = {"encoding": "utf-8"} 11 | HERE = Path(__file__).parent 12 | ROOT = HERE.parent 13 | PKG_JSON = ROOT / "package.json" 14 | PKG_DATA = json.loads(PKG_JSON.read_text(**UTF8)) 15 | LIB = ROOT / "lib" 16 | 17 | BUILD = ROOT / "build" 18 | 19 | LIB_TMP = BUILD / "lib-tmp" 20 | TSBUILDINFO = BUILD / "tsc" 21 | COV_EXT = BUILD / "labextensions-cov" 22 | EXT_PKG_JSON = COV_EXT / PKG_DATA["name"] / PKG_JSON.name 23 | 24 | 25 | def main() -> int: 26 | """Work around webpack limitations to get an out-of-tree build with coverage.""" 27 | BUILD.mkdir(exist_ok=True, parents=True) 28 | 29 | if not EXT_PKG_JSON.exists(): 30 | print("... cleaning", TSBUILDINFO) 31 | [p.unlink() for p in TSBUILDINFO.glob("*.cov")] 32 | 33 | if LIB.exists(): 34 | print("... copying", LIB, "to", LIB_TMP) 35 | shutil.rmtree(LIB_TMP, ignore_errors=True) 36 | LIB.rename(LIB_TMP) 37 | 38 | shutil.rmtree(COV_EXT, ignore_errors=True) 39 | 40 | print("... building instrumented lib") 41 | rc = call(["jlpm", "build:ts:cov"]) 42 | if rc: 43 | return rc 44 | 45 | env = dict(os.environ) 46 | env["WITH_TOTAL_COVERAGE"] = "1" 47 | 48 | print("... building", COV_EXT) 49 | rc = call(["jlpm", "build:ext"], env=env) 50 | 51 | if rc: 52 | return rc 53 | 54 | print("... patching", EXT_PKG_JSON) 55 | remote = min(COV_EXT.rglob("remoteEntry.*.js")) 56 | print("... found remote", remote) 57 | PKG_DATA["jupyterlab"]["_build"] = { 58 | "load": f"static/{remote.name}", 59 | "extension": "./extension", 60 | } 61 | EXT_PKG_JSON.write_text(json.dumps(PKG_DATA, indent=2), **UTF8) 62 | 63 | if LIB_TMP.exists(): 64 | print("... restoring lib") 65 | shutil.rmtree(LIB, ignore_errors=True) 66 | LIB_TMP.rename(LIB) 67 | 68 | return 0 69 | 70 | 71 | if __name__ == "__main__": 72 | sys.exit(main()) 73 | -------------------------------------------------------------------------------- /src/ipyelk/elements/serialization.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 ipyelk contributors. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | from typing import Dict, Optional 5 | 6 | from ipywidgets import DOMWidget 7 | from pydantic.v1 import BaseModel 8 | 9 | from .elements import Node 10 | from .index import HierarchicalIndex, VisIndex 11 | 12 | 13 | def pop_edges(data: Dict, edges=None): 14 | if edges is None: 15 | edges = {} 16 | 17 | if "edges" in data: 18 | edges[data["id"]] = data.pop("edges") 19 | for child in data.get("children", []): 20 | pop_edges(child, edges) 21 | return edges 22 | 23 | 24 | def apply_edges(data: Dict, edges): 25 | node_id = data["id"] 26 | if node_id in edges: 27 | data["edges"] = edges.get(node_id) 28 | for child in data.get("children", []): 29 | apply_edges(child, edges) 30 | return edges 31 | 32 | 33 | def convert_elkjson(data: Dict, vis_index: VisIndex = None) -> Node: 34 | # pop_edges currently mutates `data` by popping the edge dict 35 | edges_map = pop_edges(data) # dict of node.id to edge list 36 | root = Node(**data) # new element hierarchy without edges 37 | el_map = HierarchicalIndex.from_els( 38 | root, vis_index=vis_index 39 | ) # get mapping of ids to elements 40 | el_map.link_edges(edges_map) 41 | # reapplies edges to `data` 42 | apply_edges(data, edges_map) 43 | 44 | return root 45 | 46 | 47 | def to_json(model: Optional[BaseModel], widget: DOMWidget) -> Optional[Dict]: 48 | """Function to serialize a dictionary of symbols for use in a diagram 49 | 50 | :param defs: dictionary of Symbols 51 | :param diagram: elk diagram widget 52 | :return: json dictionary 53 | """ 54 | if model is None: 55 | return None 56 | return model.dict(exclude_none=True) 57 | 58 | 59 | def from_elk_json(js: Optional[Dict], manager) -> Optional[Node]: 60 | if not js: 61 | return None 62 | return convert_elkjson(js) 63 | 64 | 65 | elk_serialization = {"to_json": to_json, "from_json": from_elk_json} 66 | symbol_serialization = {"to_json": to_json} 67 | -------------------------------------------------------------------------------- /src/ipyelk/util.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 ipyelk contributors. 2 | # Distributed under the terms of the Modified BSD License. 3 | from typing import Dict, List, Optional 4 | 5 | from .elements.layout_options.model import strip_none 6 | 7 | 8 | def safely_unobserve(item, handler): 9 | if hasattr(item, "unobserve"): 10 | item.unobserve(handler=handler) 11 | 12 | 13 | def to_dict(obj): 14 | """Shim function to convert obj to a dictionary""" 15 | if obj is None: 16 | data = {} 17 | elif isinstance(obj, dict): 18 | data = obj 19 | elif hasattr(obj, "to_dict"): 20 | data = obj.to_dict() 21 | elif hasattr(obj, "dict"): 22 | data = obj.dict() 23 | else: 24 | raise TypeError("Unable to convert to dictionary") 25 | return data 26 | 27 | 28 | def merge(d1: Optional[Dict], d2: Optional[Dict]) -> Dict: 29 | """Merge two dictionaries while first testing if either are `None`. 30 | The first dictionary's keys take precedence over the second dictionary. 31 | If the final merged dictionary is empty `None` is returned. 32 | 33 | :param d1: primary dictionary 34 | :type d1: Optional[Dict] 35 | :param d2: secondary dictionary 36 | :type d2: Optional[Dict] 37 | :return: merged dictionary 38 | :rtype: Dict 39 | """ 40 | d1 = to_dict(d1) 41 | d2 = to_dict(d2) 42 | 43 | cl1 = d1.get("cssClasses") or "" 44 | cl2 = d2.get("cssClasses") or "" 45 | cl = " ".join(sorted(set([*cl1.split(), *cl2.split()]))).strip() 46 | 47 | value = {**strip_none(d2), **strip_none(d1)} # right most wins if duplicated keys 48 | 49 | # if either had cssClasses, update that 50 | if cl: 51 | value["cssClasses"] = cl 52 | 53 | return value 54 | 55 | 56 | def listed(values: Optional[List]) -> List: 57 | """Checks if incoming `values` is None then either returns a new list or 58 | original value. 59 | 60 | :param values: List of values 61 | :type values: Optional[List] 62 | :return: List of values or empty list 63 | :rtype: List 64 | """ 65 | if values is None: 66 | return [] 67 | return values 68 | -------------------------------------------------------------------------------- /tests/test_meta.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 ipyelk contributors. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | from __future__ import annotations 5 | 6 | try: 7 | from importlib.metadata import version 8 | except Exception: 9 | from importlib_metadata import version 10 | 11 | from pathlib import Path 12 | from typing import Any 13 | 14 | 15 | def test_meta() -> None: 16 | """Verify the version is advertised.""" 17 | import ipyelk 18 | 19 | assert hasattr(ipyelk, "__version__") 20 | assert ipyelk.__version__ == version("ipyelk") 21 | 22 | 23 | def test_pixi_versions( 24 | the_pixi_version: str, 25 | a_file_with_pixi_versions: Path, 26 | pixi_versions_in_a_file: set[str], 27 | ) -> None: 28 | """Verify the ``pixi`` version is consistent.""" 29 | assert len(pixi_versions_in_a_file) == 1, a_file_with_pixi_versions 30 | assert min(pixi_versions_in_a_file) == the_pixi_version, pixi_versions_in_a_file 31 | 32 | 33 | def test_labextension() -> None: 34 | """Verify the labextension path metadata is as expected.""" 35 | import ipyelk 36 | 37 | assert len(ipyelk._jupyter_labextension_paths()) == 1 38 | 39 | 40 | def test_changelog_versions( 41 | the_changelog_text: str, the_js_version: str, the_py_version: str 42 | ) -> None: 43 | """Verify ``CHANGELOG.md`` contains the current versions.""" 44 | assert f"### `ipyelk {the_py_version}`" in the_changelog_text 45 | assert f"### `@jupyrdf/jupyter-elk {the_js_version}`" in the_changelog_text 46 | 47 | 48 | def test_compatible_versions(the_js_version: str, the_py_version: str) -> None: 49 | """Verify the calculated versions are consistent.""" 50 | from ipyelk.constants import EXTENSION_SPEC_VERSION, __version__ 51 | 52 | assert __version__ == the_py_version 53 | assert the_js_version == EXTENSION_SPEC_VERSION 54 | 55 | 56 | def test_py_version(the_readme_text: str, the_pyproject_data: dict[str, Any]) -> None: 57 | """Verify the bottom python pin is accurate.""" 58 | requires_python = the_pyproject_data["project"]["requires-python"] 59 | assert f"""python {requires_python}""" in the_readme_text 60 | -------------------------------------------------------------------------------- /src/ipyelk/elements/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 ipyelk contributors. 2 | # Distributed under the terms of the Modified BSD License. 3 | from .common import EMPTY_SENTINEL 4 | from .elements import ( 5 | BaseElement, 6 | Edge, 7 | EdgeProperties, 8 | ElementMetadata, 9 | HierarchicalElement, 10 | Label, 11 | LabelProperties, 12 | Node, 13 | NodeProperties, 14 | Port, 15 | PortProperties, 16 | exclude_hidden, 17 | exclude_layout, 18 | merge_excluded, 19 | ) 20 | from .extended import Compartment, Partition, Record 21 | from .index import ( 22 | EdgeReport, 23 | ElementIndex, 24 | HierarchicalIndex, 25 | IDReport, 26 | VisIndex, 27 | iter_edges, 28 | iter_elements, 29 | iter_hierarchy, 30 | iter_labels, 31 | iter_visible, 32 | ) 33 | from .mark_factory import Mark, MarkFactory 34 | from .registry import Registry 35 | from .serialization import convert_elkjson, elk_serialization, symbol_serialization 36 | from .shapes import EdgeShape, LabelShape, NodeShape, PortShape 37 | from .symbol import EndpointSymbol, Symbol, SymbolSpec 38 | 39 | __all__ = [ 40 | "EMPTY_SENTINEL", 41 | "BaseElement", 42 | "Compartment", 43 | "Edge", 44 | "EdgeProperties", 45 | "EdgeProperties", 46 | "EdgeReport", 47 | "EdgeShape", 48 | "ElementIndex", 49 | "ElementMetadata", 50 | "ElementShape", 51 | "EndpointSymbol", 52 | "HierarchicalElement", 53 | "HierarchicalIndex", 54 | "IDReport", 55 | "Label", 56 | "LabelProperties", 57 | "LabelShape", 58 | "Mark", 59 | "MarkFactory", 60 | "Node", 61 | "NodeProperties", 62 | "NodeShape", 63 | "Partition", 64 | "Port", 65 | "PortProperties", 66 | "PortShape", 67 | "Record", 68 | "Registry", 69 | "Symbol", 70 | "SymbolSpec", 71 | "VisIndex", 72 | "check_ids", 73 | "convert_elkjson", 74 | "elk_serialization", 75 | "exclude_hidden", 76 | "exclude_layout", 77 | "iter_edges", 78 | "iter_elements", 79 | "iter_hierarchy", 80 | "iter_labels", 81 | "iter_visible", 82 | "merge_excluded", 83 | "symbol_serialization", 84 | ] 85 | -------------------------------------------------------------------------------- /src/ipyelk/pipes/marks.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 ipyelk contributors. 2 | # Distributed under the terms of the Modified BSD License. 3 | from typing import Tuple 4 | 5 | import ipywidgets as W 6 | import traitlets as T 7 | from ipywidgets.widgets.trait_types import TypedTuple 8 | 9 | from ..elements import ( 10 | BaseElement, 11 | ElementIndex, 12 | HierarchicalElement, 13 | Node, 14 | Registry, 15 | elk_serialization, 16 | ) 17 | 18 | 19 | class MarkIndex(W.DOMWidget): 20 | elements: ElementIndex = T.Instance(ElementIndex, allow_none=True) 21 | context: Registry = T.Instance(Registry, kw={}) 22 | 23 | _root: Node = None 24 | 25 | def to_id(self, element: BaseElement): 26 | return element.get_id() 27 | 28 | def from_id(self, key) -> HierarchicalElement: 29 | return self.elements.get(key) 30 | 31 | @property 32 | def root(self) -> Node: 33 | if self._root is None: 34 | self._update_root() 35 | return self._root 36 | 37 | @T.observe("elements") 38 | def _update_root(self, change=None): 39 | self._root = None 40 | if self.elements: 41 | self._root = self.elements.root() 42 | 43 | 44 | class MarkElementWidget(W.DOMWidget): 45 | value: Node = T.Instance(Node, allow_none=True).tag(sync=True, **elk_serialization) 46 | index: MarkIndex = T.Instance(MarkIndex, kw={}).tag( 47 | sync=True, **W.widget_serialization 48 | ) 49 | flow: Tuple[str] = TypedTuple(T.Unicode(), kw={}).tag(sync=True) 50 | 51 | def persist(self): 52 | if self.index.elements is None: 53 | self.build_index() 54 | else: 55 | self.index.elements.update(ElementIndex.from_els(self.value)) 56 | return self 57 | 58 | def build_index(self) -> MarkIndex: 59 | if self.value is None: 60 | index = ElementIndex() 61 | else: 62 | with self.index.context: 63 | index = ElementIndex.from_els(self.value) 64 | self.index.elements = index 65 | return self.index 66 | 67 | def _repr_mimebundle_(self, **kwargs): 68 | from IPython.display import JSON, display 69 | 70 | display(JSON(self.value.dict())) 71 | -------------------------------------------------------------------------------- /src/ipyelk/tools/toolbar.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 ipyelk contributors. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | from collections import defaultdict 5 | from itertools import chain 6 | from typing import Dict, List 7 | 8 | import ipywidgets as W 9 | import traitlets as T 10 | 11 | from ..styled_widget import StyledWidget 12 | from .tool import Tool 13 | 14 | 15 | class Toolbar(W.HBox, StyledWidget): 16 | """Toolbar for an Elk App""" 17 | 18 | tools = T.List(T.Instance(Tool), kw={}) 19 | close_btn: W.Button = T.Instance(W.Button) 20 | on_close = T.Any( 21 | default_value=None 22 | ) # holds a callable function to execute when close button is pressed 23 | 24 | def __init__(self, *args, **kwargs): 25 | super().__init__(*args, **kwargs) 26 | self.add_class("jp-ElkToolbar") 27 | self._update_children() 28 | self._update_close_callback() 29 | 30 | @T.default("close_btn") 31 | def _default_cls_btn(self) -> W.Button: 32 | btn = W.Button(icon="times-circle").add_class("close-btn") 33 | 34 | def pressed(*args): 35 | if callable(self.on_close): 36 | self.on_close() 37 | 38 | btn.on_click(pressed) 39 | return btn 40 | 41 | @T.observe("on_close") 42 | def _update_close_callback(self, change: T.Bunch = None): 43 | """Toggle visiblity of the close button depending on if the `on_close` trait 44 | is callable 45 | """ 46 | shown = "visible" if callable(self.on_close) else "hidden" 47 | self.close_btn.layout.visibility = shown 48 | 49 | @T.observe("tools") 50 | def _update_children(self, change: T.Bunch = None): 51 | self.children = self.tool_order() + [self.close_btn] 52 | 53 | # only have widgets shown if commands are specified or a on_close callback 54 | shown = "visible" if self.tools or callable(self.on_close) else "hidden" 55 | self.layout.visibility = shown 56 | 57 | def tool_order(self) -> List[Tool]: 58 | return list(chain(*[values for k, values in sorted(self.order().items())])) 59 | 60 | def order(self) -> Dict[int, List[Tool]]: 61 | order = defaultdict(list) 62 | for tool in self.tools: 63 | if isinstance(tool.ui, W.DOMWidget): 64 | order[tool.priority].append(tool.ui) 65 | return order 66 | -------------------------------------------------------------------------------- /js/tools/draw-aware-mouse-listener.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2024 ipyelk contributors. 3 | * Distributed under the terms of the Modified BSD License. 4 | */ 5 | import { Action, HoverFeedbackAction } from 'sprotty-protocol'; 6 | 7 | import { MouseListener, SModelElementImpl } from 'sprotty'; 8 | 9 | import { DiagramTool } from './tool'; 10 | 11 | /** 12 | * A mouse listener that is aware of prior mouse dragging. 13 | * 14 | * Therefore, this listener distinguishes between mouse up events after dragging and 15 | * mouse up events without prior dragging. Subclasses may override the methods 16 | * `draggingMouseUp` and/or `nonDraggingMouseUp` to react to only these specific kinds 17 | * of mouse up events. 18 | */ 19 | export class DragAwareMouseListener extends MouseListener { 20 | private isMouseDown: boolean = false; 21 | private isMouseDrag: boolean = false; 22 | 23 | mouseDown(target: SModelElementImpl, event: MouseEvent): Action[] { 24 | this.isMouseDown = true; 25 | return []; 26 | } 27 | 28 | mouseMove(target: SModelElementImpl, event: MouseEvent): Action[] { 29 | if (this.isMouseDown) { 30 | this.isMouseDrag = true; 31 | } 32 | return []; 33 | } 34 | 35 | mouseUp(element: SModelElementImpl, event: MouseEvent): Action[] { 36 | this.isMouseDown = false; 37 | if (this.isMouseDrag) { 38 | this.isMouseDrag = false; 39 | return this.draggingMouseUp(element, event); 40 | } 41 | 42 | return this.nonDraggingMouseUp(element, event); 43 | } 44 | 45 | nonDraggingMouseUp(element: SModelElementImpl, event: MouseEvent): Action[] { 46 | return []; 47 | } 48 | 49 | draggingMouseUp(element: SModelElementImpl, event: MouseEvent): Action[] { 50 | return []; 51 | } 52 | } 53 | 54 | export class DragAwareHoverMouseListener extends DragAwareMouseListener { 55 | constructor( 56 | protected elementTypeId: string, 57 | protected tool: DiagramTool, 58 | ) { 59 | super(); 60 | } 61 | 62 | mouseOver(target: SModelElementImpl, event: MouseEvent): Action[] { 63 | return [ 64 | HoverFeedbackAction.create({ mouseoverElement: target.id, mouseIsOver: true }), 65 | ]; 66 | } 67 | 68 | mouseOut(target: SModelElementImpl, event: MouseEvent): (Action | Promise)[] { 69 | return [ 70 | HoverFeedbackAction.create({ mouseoverElement: target.id, mouseIsOver: false }), 71 | ]; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /js/sprotty/sprotty-model.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2024 ipyelk contributors. 3 | * Distributed under the terms of the Modified BSD License. 4 | */ 5 | // From https://github.com/OpenKieler/elkgraph-web 6 | import { 7 | RectangularNode, 8 | RectangularPort, 9 | SEdgeImpl, 10 | SLabelImpl, 11 | SNodeImpl, 12 | alignFeature, 13 | boundsFeature, 14 | edgeLayoutFeature, 15 | editFeature, 16 | fadeFeature, 17 | hoverFeedbackFeature, 18 | layoutableChildFeature, 19 | moveFeature, 20 | selectFeature, 21 | } from 'sprotty'; 22 | 23 | import { ElkProperties } from './json/elkgraph-json'; 24 | 25 | export class ElkNode extends RectangularNode { 26 | properties: ElkProperties; 27 | 28 | hasFeature(feature: symbol): boolean { 29 | if (feature === moveFeature) return false; 30 | else return super.hasFeature(feature); 31 | } 32 | } 33 | 34 | export class ElkPort extends RectangularPort { 35 | properties: ElkProperties; 36 | 37 | hasFeature(feature: symbol): boolean { 38 | if (feature === moveFeature) return false; 39 | else return super.hasFeature(feature); 40 | } 41 | } 42 | 43 | export class ElkEdge extends SEdgeImpl { 44 | properties: ElkProperties; 45 | 46 | hasFeature(feature: symbol): boolean { 47 | if (feature === editFeature) return false; 48 | else return super.hasFeature(feature); 49 | } 50 | } 51 | 52 | export class ElkJunction extends SNodeImpl { 53 | hasFeature(feature: symbol): boolean { 54 | if ( 55 | feature === moveFeature || 56 | feature === selectFeature || 57 | feature === hoverFeedbackFeature 58 | ) 59 | return false; 60 | else return super.hasFeature(feature); 61 | } 62 | } 63 | 64 | export class ElkLabel extends SLabelImpl { 65 | static readonly DEFAULT_FEATURES = [ 66 | selectFeature, 67 | hoverFeedbackFeature, 68 | boundsFeature, 69 | alignFeature, 70 | layoutableChildFeature, 71 | edgeLayoutFeature, 72 | fadeFeature, 73 | ]; 74 | properties: ElkProperties; 75 | labels: ElkLabel[]; 76 | selected: boolean = false; 77 | hoverFeedback: boolean = false; 78 | 79 | hasFeature(feature: symbol): boolean { 80 | if (feature === selectFeature || feature === hoverFeedbackFeature) { 81 | if (this.properties?.selectable === true) { 82 | return true; 83 | } 84 | } else return super.hasFeature(feature); 85 | } 86 | } 87 | 88 | export class SymbolNode extends SNodeImpl { 89 | hasFeature(feature: symbol): boolean { 90 | if (feature === moveFeature) return false; 91 | else return super.hasFeature(feature); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/ipyelk/diagram/viewer.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 ipyelk contributors. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | from typing import Tuple 5 | 6 | import ipywidgets as W 7 | import traitlets as T 8 | from ipywidgets.widgets.trait_types import TypedTuple 9 | 10 | from ..pipes import MarkElementWidget 11 | from ..tools import CenterTool, ControlOverlay, FitTool, Hover, Pan, Selection, Zoom 12 | 13 | 14 | class Viewer(W.Widget): 15 | """Generic Viewer of ELK Json diagrams. Currently only mainly used by :py:class:`~ipyelk.diagram.SprottyViewer` 16 | 17 | Attributes 18 | ---------- 19 | :parameter source: :py:class:`~ipyelk.pipes.MarkElementWidget` 20 | input source for rendering. 21 | :parameter selection: :py:class:`~ipyelk.tools.Selection` 22 | maintains selected ids and methods to resolve the python elements. 23 | :parameter hover: :py:class:`~ipyelk.tools.Hover` 24 | maintains hovered ids. 25 | :parameter zoom: :py:class:`~ipyelk.tools.Zoom` 26 | :parameter pan: :py:class:`~ipyelk.tools.Pan` 27 | :parameter control_overlay: :py:class:`~ipyelk.tools.ControlOverlay` 28 | additional jupyterlab widgets that can be rendered on top of the diagram 29 | based on the current selected states. 30 | 31 | """ 32 | 33 | source: MarkElementWidget = T.Instance(MarkElementWidget, allow_none=True).tag( 34 | sync=True, **W.widget_serialization 35 | ) 36 | 37 | selection: Selection = T.Instance(Selection, kw={}).tag( 38 | sync=True, **W.widget_serialization 39 | ) 40 | hover: Hover = T.Instance(Hover, kw={}).tag(sync=True, **W.widget_serialization) 41 | zoom = T.Instance(Zoom, kw={}).tag(sync=True, **W.widget_serialization) 42 | pan = T.Instance(Pan, kw={}).tag(sync=True, **W.widget_serialization) 43 | control_overlay: ControlOverlay = T.Instance(ControlOverlay, kw={}).tag( 44 | sync=True, **W.widget_serialization 45 | ) 46 | 47 | viewed: Tuple[str] = TypedTuple(trait=T.Unicode()).tag( 48 | sync=True 49 | ) # list element ids in the current view bounding box 50 | fit_tool: FitTool = T.Instance(FitTool) 51 | center_tool: CenterTool = T.Instance(CenterTool) 52 | 53 | @T.default("fit_tool") 54 | def _default_fit_tool(self) -> FitTool: 55 | return FitTool(handler=lambda _: self.fit()) 56 | 57 | @T.default("center_tool") 58 | def _default_center_tool(self) -> CenterTool: 59 | return CenterTool(handler=lambda _: self.center()) 60 | 61 | def fit(self): 62 | pass 63 | 64 | def center(self): 65 | pass 66 | -------------------------------------------------------------------------------- /src/ipyelk/styled_widget.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 ipyelk contributors. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | import ipywidgets as W 5 | import traitlets as T 6 | 7 | 8 | @W.register 9 | class StyledWidget(W.Box): 10 | style = T.Dict(kw={}) 11 | raw_css = T.Tuple().tag(sync=True) 12 | namespaced_css = T.Unicode().tag(sync=True) 13 | _css_widget = T.Instance(W.HTML, kw={"layout": {"display": "None"}}) 14 | 15 | def __init__(self, *args, **kwargs): 16 | """Initialize the widget and add custom styling and css class""" 17 | super().__init__(*args, **kwargs) 18 | self._update_style() 19 | self.add_class(self._css_class) 20 | 21 | @T.validate("children") 22 | def _valid_children(self, proposal): 23 | """Ensure incoming children include the css widget for the custom styling""" 24 | value = proposal["value"] 25 | if value and self._css_widget not in value: 26 | value = [self._css_widget] + list(value) 27 | return value 28 | 29 | @T.observe("style") 30 | def _update_style(self, change: T.Bunch = None): 31 | """Build the custom css to attach to the dom""" 32 | style = [] 33 | raw_css = [] 34 | for _cls, attrs in self.style.items(): 35 | if "@keyframes" not in _cls: 36 | # if the `_cls` begins with a whitespace prefix the selector 37 | # with the style widget's unique class 38 | selector = f".{self._css_class}{_cls}" if _cls.startswith(" ") else _cls 39 | css_attributes = "\n".join([ 40 | f"{key}: {value};" for key, value in attrs.items() 41 | ]) 42 | raw_css += [f"{_cls}{{ {css_attributes} }}"] 43 | else: 44 | # process keyframe css 45 | selector = _cls 46 | attributes = [] 47 | for key, value in attrs.items(): 48 | steps = [] 49 | for stop, frame in value.items(): 50 | steps.append(f"{stop}:{frame};") 51 | attributes.append(f"{key} {{{''.join(steps)}}}") 52 | css_attributes = "\n".join(attributes) 53 | style.append(f"{selector}{{{css_attributes}}}") 54 | self.namespaced_css = "".join(style) 55 | self.raw_css = raw_css 56 | self._css_widget.value = f"" 57 | 58 | @property 59 | def _css_class(self) -> str: 60 | """CSS Class to namespace custom css classes""" 61 | return f"styled-widget-{id(self)}" 62 | -------------------------------------------------------------------------------- /tests/pipes/test_pipes.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 ipyelk contributors. 2 | # Distributed under the terms of the Modified BSD License. 3 | import pytest 4 | 5 | from ipyelk.elements import Node 6 | from ipyelk.pipes import MarkElementWidget, Pipe, Pipeline 7 | 8 | 9 | @pytest.mark.asyncio 10 | async def test_pipe_run(): 11 | p = Pipe() 12 | p.inlet = MarkElementWidget() 13 | await p.run() 14 | assert p.inlet.value is p.outlet.value 15 | 16 | 17 | @pytest.mark.asyncio 18 | async def test_pipe_check_dirty(): 19 | f1 = ("a",) 20 | f2 = ("b",) 21 | p = Pipe( 22 | observes=f1, 23 | reports=f2, 24 | ) 25 | p.inlet = MarkElementWidget( 26 | flow=f1, 27 | ) 28 | assert len(p.outlet.flow) == 0 29 | assert p.check_dirty() 30 | assert len(p.outlet.flow) == 2, f"Expected two flow flags. Recieved {p.outlet.flow}" 31 | # await p.run() 32 | # assert p.inlet.value is p.outlet.value 33 | 34 | 35 | @pytest.mark.asyncio 36 | async def test_pipeline_check_dirty(): 37 | f1 = ("a",) 38 | f2 = ("b",) 39 | f3 = ("c",) 40 | p1 = Pipe( 41 | observes=f1, 42 | reports=f2, 43 | ) 44 | p2 = Pipe( 45 | observes=f3, 46 | reports=f3, 47 | ) 48 | p = Pipeline(pipes=(p1, p2)) 49 | assert p.check(), "pipeline should contain no broken pipes" 50 | p.inlet = MarkElementWidget( 51 | flow=f1, 52 | value=Node(), 53 | ) 54 | 55 | # test pipeline with only `p1` dirty 56 | assert len(p.outlet.flow) == 0 57 | assert p.check_dirty() 58 | assert p.status.dirty() 59 | assert p1.status.dirty() 60 | assert not p2.status.dirty() 61 | assert len(p.outlet.flow) == 2, f"Expected two flow flags: `{p.outlet.flow}`" 62 | assert p.reports == f2, f"Expected pipeline reports to be f2: `{p.reports}`" 63 | 64 | # test pipeline with only `p2` dirty 65 | p.inlet.flow = f3 66 | assert p.check_dirty() 67 | assert p.status.dirty() 68 | assert not p1.status.dirty() 69 | assert p2.status.dirty() 70 | assert len(p.outlet.flow) == 1, f"Expected two flow flags. Recieved {p.outlet.flow}" 71 | assert p.reports == f3, f"Expected pipeline reports to be f3: `{p.reports}`" 72 | 73 | assert p.inlet.value is not p.outlet.value, ( 74 | "Inlet value should not have propagated yet" 75 | ) 76 | await p.run() 77 | assert p.inlet.value is p.outlet.value, "Inlet value should have propagated" 78 | assert not p.status.dirty(), "Pipeline should not still be dirty" 79 | assert not p1.status.dirty(), "`p1` should not still be dirty" 80 | assert not p2.status.dirty(), "`p2` should not still be dirty" 81 | -------------------------------------------------------------------------------- /examples/_index.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "id": "339b9d02-49ca-4cb9-9224-d07cbeefcf73", 6 | "metadata": {}, 7 | "source": [ 8 | "# 🦌 ELK Examples 🚀" 9 | ] 10 | }, 11 | { 12 | "cell_type": "markdown", 13 | "id": "890eab5b-fe3a-449d-a24f-5ad9f8976f0c", 14 | "metadata": {}, 15 | "source": [ 16 | "## Basic Examples\n", 17 | "\n", 18 | "- [🦌 Introducing ELK 👋](./00_Introduction.ipynb)\n", 19 | "- [🦌 Linking ELK Diagrams 🔗](./01_Linking.ipynb)" 20 | ] 21 | }, 22 | { 23 | "cell_type": "markdown", 24 | "id": "f3978594-4b49-4203-bb75-744cd998fae1", 25 | "metadata": {}, 26 | "source": [ 27 | "## Working with `networkx`\n", 28 | "\n", 29 | "- [🦌 ELK Transformer 🤖](./02_Transformer.ipynb)\n", 30 | "- [🦌 ELK App 🚀](./03_App.ipynb)" 31 | ] 32 | }, 33 | { 34 | "cell_type": "markdown", 35 | "id": "197fadc6-c79e-48df-8f19-e37c22e9d712", 36 | "metadata": {}, 37 | "source": [ 38 | "## Integrating into Larger Applications\n", 39 | "\n", 40 | "- [🦌 Interactive ELK App 🕹️](./04_Interactive.ipynb)\n", 41 | "- [🦌 ELK Simulation Demo 🐺🎭](./08_Simulation_App.ipynb)\n", 42 | " - [🦌 ELK Simulation Plumbing 🐺🤓](./07_Simulation.ipynb)" 43 | ] 44 | }, 45 | { 46 | "cell_type": "markdown", 47 | "id": "d2bfcb8f-b50b-43c9-94fc-1741151cf147", 48 | "metadata": {}, 49 | "source": [ 50 | "## Exporting Diagrams\n", 51 | "\n", 52 | "- [🦌 SVG Exporter 🥡](./05_SVG_Exporter.ipynb)\n", 53 | "- [🦌 SVG App Exporter 🥡](./06_SVG_App_Exporter.ipynb)" 54 | ] 55 | }, 56 | { 57 | "cell_type": "markdown", 58 | "id": "f8aeedb0-4ed6-4c65-a7f8-bbc985a43353", 59 | "metadata": {}, 60 | "source": [ 61 | "## Extending\n", 62 | "\n", 63 | "- [🦌 SVG Defs 🏹](./10_Diagram_Defs.ipynb)\n", 64 | "- [🦌 SVG Logic Gates🔣](./11_Logic_Gates.ipynb)\n", 65 | "- [🦌 Node Menagerie ⚡](./12_Node_Menagerie.ipynb)\n", 66 | "- [🦌 Compounds 🧪](./13_Compounds.ipynb)\n", 67 | "- [🦌 Text Styling 🧪](./14_Text_Styling.ipynb)\n", 68 | "- [🦌 Brushable Plots 🧪](./15_Nesting_Plots.ipynb)" 69 | ] 70 | } 71 | ], 72 | "metadata": { 73 | "kernelspec": { 74 | "display_name": "Python 3 (ipykernel)", 75 | "language": "python", 76 | "name": "python3" 77 | }, 78 | "language_info": { 79 | "codemirror_mode": { 80 | "name": "ipython", 81 | "version": 3 82 | }, 83 | "file_extension": ".py", 84 | "mimetype": "text/x-python", 85 | "name": "python", 86 | "nbconvert_exporter": "python", 87 | "pygments_lexer": "ipython3", 88 | "version": "3.10.6" 89 | } 90 | }, 91 | "nbformat": 4, 92 | "nbformat_minor": 5 93 | } 94 | -------------------------------------------------------------------------------- /src/ipyelk/loaders/loader.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 ipyelk contributors. 2 | # Distributed under the terms of the Modified BSD License. 3 | from typing import Dict, Optional 4 | 5 | import traitlets as T 6 | 7 | from ipyelk.elements.elements import BaseElement 8 | 9 | from ..elements import Edge, Label, Node, Port, index 10 | from ..elements import layout_options as opt 11 | from ..pipes import MarkElementWidget 12 | from ..tools import Tool 13 | 14 | ROOT_OPTS: Dict[str, str] = { 15 | opt.HierarchyHandling.identifier: opt.HierarchyHandling().value 16 | } 17 | NODE_OPTS: Dict[str, str] = { 18 | opt.NodeSizeConstraints.identifier: opt.NodeSizeConstraints().value, 19 | } 20 | PORT_OPTS: Dict[str, str] = {} 21 | LABEL_OPTS: Dict[str, str] = { 22 | opt.NodeLabelPlacement.identifier: opt.NodeLabelPlacement(horizontal="center").value 23 | } 24 | EDGE_OPTS: Dict[str, str] = {} 25 | 26 | 27 | class Loader(Tool): 28 | default_node_opts: Optional[Dict[str, str]] = T.Dict(NODE_OPTS, allow_none=True) 29 | default_root_opts: Optional[Dict[str, str]] = T.Dict(ROOT_OPTS, allow_none=True) 30 | default_label_opts: Optional[Dict[str, str]] = T.Dict(LABEL_OPTS, allow_none=True) 31 | default_port_opts: Optional[Dict[str, str]] = T.Dict(PORT_OPTS, allow_none=True) 32 | default_edge_opts: Optional[Dict[str, str]] = T.Dict(EDGE_OPTS, allow_none=True) 33 | 34 | def load(self) -> MarkElementWidget: 35 | raise NotImplementedError("Subclasses should implement their behavior") 36 | 37 | def apply_layout_defaults(self, root: Node) -> Node: 38 | for el in index.iter_elements(root): 39 | if not el.layoutOptions: 40 | el.layoutOptions = self.get_default_opts(el) 41 | return root 42 | 43 | def get_default_opts(self, element: BaseElement) -> Dict: 44 | if isinstance(element, Node): 45 | if element.get_parent() is None: 46 | opts = self.default_root_opts 47 | else: 48 | opts = self.default_node_opts 49 | elif isinstance(element, Port): 50 | opts = self.default_port_opts 51 | elif isinstance(element, Label): 52 | opts = self.default_label_opts 53 | elif isinstance(element, Edge): 54 | opts = self.default_edge_opts 55 | if opts is None: 56 | return dict() 57 | return dict(**opts) 58 | 59 | def clear_defaults(self) -> "Loader": 60 | """Removes the current default layout options for the loader""" 61 | self.default_node_opts = None 62 | self.default_root_opts = None 63 | self.default_label_opts = None 64 | self.default_port_opts = None 65 | self.default_edge_opts = None 66 | return self 67 | -------------------------------------------------------------------------------- /js/tools/feedback/cursor-feedback.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2024 ipyelk contributors. 3 | * Distributed under the terms of the Modified BSD License. 4 | */ 5 | 6 | /******************************************************************************** 7 | * Copyright (c) 2019 EclipseSource and others. 8 | * 9 | * This program and the accompanying materials are made available under the 10 | * terms of the Eclipse Public License v. 2.0 which is available at 11 | * http://www.eclipse.org/legal/epl-2.0. 12 | * 13 | * This Source Code may also be made available under the following Secondary 14 | * Licenses when the conditions for such availability set forth in the Eclipse 15 | * Public License v. 2.0 are satisfied: GNU General Public License, version 2 16 | * with the GNU Classpath Exception which is available at 17 | * https://www.gnu.org/software/classpath/license.html. 18 | * 19 | * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 20 | ********************************************************************************/ 21 | import { inject, injectable } from 'inversify'; 22 | 23 | import { Action } from 'sprotty-protocol'; 24 | 25 | import { 26 | CommandExecutionContext, 27 | SModelElementImpl, 28 | SModelRootImpl, 29 | TYPES, 30 | } from 'sprotty/lib'; 31 | 32 | import { FeedbackCommand } from './model'; 33 | import { addCssClasses, removeCssClasses } from './utils'; 34 | 35 | export enum CursorCSS { 36 | DEFAULT = 'default-mode', 37 | OVERLAP_FORBIDDEN = 'overlap-forbidden-mode', 38 | NODE_CREATION = 'node-creation-mode', 39 | EDGE_CREATION_SOURCE = 'edge-creation-select-source-mode', 40 | EDGE_CREATION_TARGET = 'edge-creation-select-target-mode', 41 | EDGE_RECONNECT = 'edge-reconnect-select-target-mode', 42 | OPERATION_NOT_ALLOWED = 'edge-modification-not-allowed-mode', 43 | ELEMENT_DELETION = 'element-deletion-mode', 44 | MOUSEOVER = 'mouseover', 45 | SELECTED = 'selected', 46 | } 47 | 48 | export class ApplyCSSFeedbackAction implements Action { 49 | kind = ApplyCursorCSSFeedbackActionCommand.KIND; 50 | constructor( 51 | readonly target?: SModelElementImpl, 52 | readonly cssClass?: CursorCSS, 53 | ) {} 54 | } 55 | 56 | @injectable() 57 | export class ApplyCursorCSSFeedbackActionCommand extends FeedbackCommand { 58 | static readonly KIND = 'applyCursorCssFeedback'; 59 | 60 | constructor(@inject(TYPES.Action) readonly action: ApplyCSSFeedbackAction) { 61 | super(); 62 | } 63 | execute(context: CommandExecutionContext): SModelRootImpl { 64 | removeCssClasses(this.action.target, Object.values(CursorCSS)); 65 | if (this.action.cssClass) { 66 | addCssClasses(this.action.target, [this.action.cssClass]); 67 | } 68 | return context.root; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/ipyelk/loaders/nx/nxloader.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 ipyelk contributors. 2 | # Distributed under the terms of the Modified BSD License. 3 | from collections.abc import Hashable 4 | from typing import Dict, Optional 5 | 6 | import networkx as nx 7 | import traitlets as T 8 | 9 | from ...diagram import Diagram 10 | from ...elements import HierarchicalIndex, Label, Node, Registry, index 11 | from ...pipes import MarkElementWidget 12 | from ..loader import Loader 13 | from .nxutils import ( 14 | from_nx_node, 15 | get_owner, 16 | get_root, 17 | process_endpoints, 18 | process_hierarchy, 19 | ) 20 | 21 | 22 | class NXLoader(Loader): 23 | root_id: str = T.Unicode(allow_none=True) 24 | 25 | def load( 26 | self, 27 | graph: nx.MultiDiGraph, 28 | hierarchy: Optional[nx.DiGraph] = None, 29 | ) -> MarkElementWidget: 30 | hierarchy = process_hierarchy(graph, hierarchy) 31 | 32 | # add graph nodes 33 | nx_node_map: Dict[Node, Hashable] = {} 34 | for n in graph.nodes(): 35 | el = from_nx_node(n, graph) 36 | nx_node_map[el] = n 37 | 38 | # add hierarchy nodes 39 | for n in hierarchy.nodes(): 40 | if n not in graph: 41 | el = from_nx_node(n, hierarchy) 42 | nx_node_map[el] = n 43 | 44 | context = Registry() 45 | with context: 46 | el_map = HierarchicalIndex.from_els(*nx_node_map.keys()) 47 | 48 | # nest elements based on hierarchical edges 49 | for u, v in hierarchy.edges(): 50 | parent = u if isinstance(u, Node) else el_map.get(u) 51 | child = v if isinstance(v, Node) else el_map.get(v) 52 | parent.add_child(child) 53 | 54 | # add element edges 55 | for u, v, d in graph.edges(data=True): 56 | edge = process_endpoints(u, v, d, el_map) 57 | owner = get_owner(edge, hierarchy, el_map, nx_node_map) 58 | owner.edges.append(edge) 59 | 60 | root: Node = get_root(hierarchy) 61 | if not isinstance(root, Node): 62 | root = el_map.get(root) 63 | 64 | if root.id is None and self.root_id is not None: 65 | root.id = self.root_id 66 | 67 | for el in index.iter_elements(root): 68 | el.id = el.get_id() 69 | 70 | for el, n in nx_node_map.items(): 71 | if not el.labels and el.id != root.id: 72 | el.labels.append(Label(text=el.get_id())) 73 | 74 | return MarkElementWidget( 75 | value=self.apply_layout_defaults(root), 76 | ) 77 | 78 | 79 | def from_nx(graph, hierarchy=None, **kwargs): 80 | diagram = Diagram( 81 | source=NXLoader().load(graph=graph, hierarchy=hierarchy), **kwargs 82 | ) 83 | return diagram 84 | -------------------------------------------------------------------------------- /src/ipyelk/elements/symbol.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 ipyelk contributors. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | from typing import Dict 5 | 6 | from pydantic.v1 import BaseModel, Field 7 | 8 | from .elements import Node 9 | from .shapes import Point 10 | 11 | 12 | class Symbol(BaseModel): 13 | identifier: str = Field( 14 | ..., description="Unique identifier for uses of this symbol to reference" 15 | ) 16 | element: Node = Field(..., description="Root element for the symbol") 17 | width: float = Field(..., title="Width", description="Viewbox width") 18 | height: float = Field(..., title="Height", description="Viewbox height") 19 | x: float = Field(0, title="X", description="Viewbox X Position") 20 | y: float = Field(0, title="Y", description="Viewbox Y Position") 21 | 22 | 23 | class EndpointSymbol(Symbol): 24 | path_offset: Point = Field( 25 | default_factory=Point, description="Moves the endpoint of the path" 26 | ) 27 | symbol_offset: Point = Field( 28 | default_factory=Point, description="Moves the origin of the symbol" 29 | ) 30 | width: float = Field(0, title="Width", description="Viewbox width") 31 | height: float = Field(0, title="Height", description="Viewbox height") 32 | 33 | 34 | class SymbolSpec(BaseModel): 35 | """A set of symbols with unique identifiers""" 36 | 37 | library: Dict[str, Symbol] = Field( 38 | default_factory=dict, 39 | description="Mapping of unique symbol identifiers to a symbol", 40 | ) 41 | 42 | def add(self, *symbols: Symbol) -> "SymbolSpec": 43 | """Add a series of symbols to the library 44 | 45 | :return: current SymbolSpec 46 | """ 47 | for symbol in symbols: 48 | assert symbol.identifier not in self.library, ( 49 | f"Identifier should be unique. {symbol.identifier} is duplicated" 50 | ) 51 | self.library[symbol.identifier] = symbol 52 | return self 53 | 54 | def __getitem__(self, key: str) -> str: 55 | """Test is key is an identifier for a symbol in the library and returns 56 | that key 57 | 58 | :param key: potential symbol identifer 59 | :return: symbol identifier 60 | """ 61 | if key not in self.library: 62 | raise KeyError( 63 | f"`{key}` is not a symbol identifier currently in the library" 64 | ) 65 | return key 66 | 67 | def merge(self, *specs: "SymbolSpec") -> "SymbolSpec": 68 | """Merge a series of `SymbolSpec`s into a new `SymbolSpec` 69 | 70 | :param specs: series of `SymbolSpecs` 71 | :return: new SymbolSpec 72 | """ 73 | new = SymbolSpec() 74 | new.add(*self.library.values()) 75 | for spec in specs: 76 | new.add(*spec.library.values()) 77 | return new 78 | -------------------------------------------------------------------------------- /src/ipyelk/tools/tool.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 ipyelk contributors. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | import asyncio 5 | from typing import Callable 6 | 7 | import ipywidgets as W 8 | import traitlets as T 9 | from ipywidgets.widgets.trait_types import TypedTuple 10 | 11 | from ..pipes import Pipe 12 | 13 | 14 | class Tool(W.Widget): 15 | tee: Pipe = T.Instance(Pipe, allow_none=True).tag( 16 | sync=True, **W.widget_serialization 17 | ) 18 | on_done = T.Any(allow_none=True) # callback when done 19 | disable = T.Bool(default_value=False).tag(sync=True, **W.widget_serialization) 20 | reports = TypedTuple(T.Unicode(), kw={}) 21 | _task: asyncio.Future = None 22 | ui = T.Instance(W.DOMWidget, allow_none=True) 23 | priority = T.Int(default_value=10) 24 | _on_run_handlers = W.CallbackDispatcher() 25 | 26 | def handler(self, *args): 27 | """Handler callback for running the tool""" 28 | # canel old work if needed 29 | if self._task: 30 | self._task.cancel() 31 | 32 | # schedule work 33 | self._task = asyncio.create_task(self.run()) 34 | 35 | # callback 36 | self._task.add_done_callback(self._finished) 37 | 38 | if self.tee: 39 | self.tee.inlet.flow = self.reports 40 | 41 | async def run(self): 42 | raise NotImplementedError 43 | 44 | # TODO reconcile `on_run` and `on_done` 45 | def on_run(self, callback, remove=False): 46 | """Register a callback to execute when the button is clicked. 47 | The callback will be called with one argument, the clicked button 48 | widget instance. 49 | 50 | Parameters 51 | ---------- 52 | remove: bool (optional) 53 | Set to true to remove the callback from the list of callbacks. 54 | 55 | """ 56 | self._on_run_handlers.register_callback(callback, remove=remove) 57 | 58 | def _finished(self, future: asyncio.Future): 59 | try: 60 | future.result() 61 | self._on_run_handlers(self) 62 | if callable(self.on_done): 63 | self.on_done() 64 | except asyncio.CancelledError: 65 | pass # cancellation should not log an error 66 | except Exception: 67 | self.log.exception(f"Error running tool: {type(self)}") 68 | 69 | 70 | class ToolButton(Tool): 71 | """Generic Tool that provides a simple button UI 72 | 73 | :param handler: Called when button is pressed. 74 | """ 75 | 76 | handler: Callable = T.Any(allow_none=True) 77 | description: str = T.Unicode(default_value="") 78 | 79 | @T.default("ui") 80 | def _default_ui(self): 81 | btn = W.Button(description=self.description) 82 | T.link((self, "description"), (btn, "description")) 83 | 84 | def click(*args): 85 | if callable(self.handler): 86 | self.handler() 87 | 88 | btn.on_click(click) 89 | return btn 90 | -------------------------------------------------------------------------------- /examples/simple.json: -------------------------------------------------------------------------------- 1 | { 2 | "children": [ 3 | { 4 | "height": 25, 5 | "id": "kernel", 6 | "width": 50, 7 | "labels": [{ "text": "kernel", "id": "kernel_label", "x": 5, "y": 5 }], 8 | "properties": { 9 | "cssClasses": "example-data-node-class-from-simple" 10 | } 11 | }, 12 | { 13 | "height": 25, 14 | "id": "model", 15 | "width": 50, 16 | "labels": [{ "text": "model", "id": "model_label", "x": 5, "y": 5 }] 17 | }, 18 | { 19 | "height": 25, 20 | "id": "elk", 21 | "width": 25, 22 | "labels": [{ "text": "🦌", "id": "elk_label", "x": 5, "y": 5 }] 23 | }, 24 | { 25 | "height": 25, 26 | "id": "view1", 27 | "width": 50, 28 | "labels": [{ "text": "view", "id": "view1_label", "x": 5, "y": 5 }] 29 | }, 30 | { 31 | "height": 25, 32 | "id": "svg1", 33 | "width": 50, 34 | "labels": [{ "text": "SVG", "id": "svg1_label", "x": 5, "y": 5 }] 35 | }, 36 | { 37 | "height": 25, 38 | "id": "sprotty1", 39 | "width": 50, 40 | "labels": [{ "text": "sprotty", "id": "sprotty1_label", "x": 5, "y": 5 }] 41 | }, 42 | { 43 | "height": 25, 44 | "id": "mouse", 45 | "width": 25, 46 | "labels": [{ "text": "🐁", "id": "mouse_label", "x": 5, "y": 5 }] 47 | }, 48 | { 49 | "height": 25, 50 | "id": "user", 51 | "width": 50, 52 | "labels": [{ "text": "YOU", "id": "user_label", "x": 5, "y": 5 }] 53 | }, 54 | { 55 | "height": 25, 56 | "id": "keyboard", 57 | "width": 25, 58 | "labels": [{ "text": "⌨️", "id": "keyboard_label", "x": 5, "y": 5 }] 59 | }, 60 | { 61 | "height": 25, 62 | "id": "display", 63 | "width": 25, 64 | "labels": [{ "text": "🖥️", "id": "display_label", "x": 5, "y": 5 }] 65 | } 66 | ], 67 | "edges": [ 68 | { "id": "e0", "sources": ["keyboard"], "targets": ["kernel"] }, 69 | { "id": "e1", "sources": ["kernel"], "targets": ["model"] }, 70 | { "id": "e2", "sources": ["model"], "targets": ["kernel"] }, 71 | { "id": "e3", "sources": ["model"], "targets": ["elk"] }, 72 | { "id": "e5", "sources": ["model"], "targets": ["view1"] }, 73 | { "id": "e6", "sources": ["view1"], "targets": ["sprotty1"] }, 74 | { "id": "e7", "sources": ["sprotty1"], "targets": ["view1"] }, 75 | { "id": "e8", "sources": ["sprotty1"], "targets": ["svg1"] }, 76 | { "id": "e9", "sources": ["svg1"], "targets": ["sprotty1"] }, 77 | { "id": "e98", "sources": ["mouse"], "targets": ["svg1"] }, 78 | { "id": "e100", "sources": ["user"], "targets": ["mouse"] }, 79 | { "id": "e101", "sources": ["user"], "targets": ["keyboard"] }, 80 | { "id": "e102", "sources": ["display"], "targets": ["user"] }, 81 | { 82 | "id": "e103", 83 | "sources": ["svg1"], 84 | "targets": ["display"], 85 | "properties": { 86 | "cssClasses": "example-data-edge-class-from-simple" 87 | } 88 | } 89 | ], 90 | "id": "root" 91 | } 92 | -------------------------------------------------------------------------------- /src/ipyelk/pipes/text_sizer.py: -------------------------------------------------------------------------------- 1 | """Widget to get text size from DOM""" 2 | 3 | # Copyright (c) 2024 ipyelk contributors. 4 | # Distributed under the terms of the Modified BSD License. 5 | import traitlets as T 6 | 7 | from ..constants import EXTENSION_NAME, EXTENSION_SPEC_VERSION 8 | from ..elements import Label, index 9 | from ..styled_widget import StyledWidget 10 | from . import flows as F 11 | from .base import Pipe, SyncedPipe 12 | from .util import wait_for_change 13 | 14 | 15 | class TextSizer(Pipe): 16 | """simple rule of thumb width height guesser""" 17 | 18 | @T.default("observes") 19 | def _default_observes(self): 20 | return ( 21 | F.Text.text, 22 | F.Text.size_css, 23 | F.Layout, 24 | ) 25 | 26 | @T.default("reports") 27 | def _default_reports(self): 28 | return (F.Text.size,) 29 | 30 | async def run(self): 31 | if self.inlet.value is None: 32 | return None 33 | 34 | # make copy of source value? 35 | for el in index.iter_elements(self.source.value): 36 | if isinstance(el, Label): 37 | size(el) 38 | 39 | self.outlet.changes = tuple(set(*self.outlet.changes, *self.reports)) 40 | return self.value 41 | 42 | 43 | def size(label: Label): 44 | shape = label.properties.get_shape() 45 | if label.width is None: 46 | shape.width = 10 * len(label.text) 47 | if label.height is None: 48 | shape.height = 10 49 | 50 | 51 | def size_nested_label(label: Label) -> Label: 52 | shape = label.properties.get_shape() 53 | width = label.width or shape.width or 0 54 | height = label.height or shape.height or 0 55 | 56 | for sublabel in label.labels or []: 57 | ls = size_nested_label(sublabel) 58 | layout_opts = sublabel.layoutOptions 59 | spacing = float(layout_opts.get("org.eclipse.elk.spacing.labelLabel", 0)) 60 | width += ls.width or 0 + spacing 61 | height = max(height, ls.height or 0) 62 | 63 | label.width = width 64 | label.height = height 65 | return label 66 | 67 | 68 | class BrowserTextSizer(SyncedPipe, StyledWidget, TextSizer): 69 | """Jupyterlab widget for getting rendered text sizes from the DOM""" 70 | 71 | _model_name = T.Unicode("ELKTextSizerModel").tag(sync=True) 72 | _model_module = T.Unicode(EXTENSION_NAME).tag(sync=True) 73 | _model_module_version = T.Unicode(EXTENSION_SPEC_VERSION).tag(sync=True) 74 | _view_name = T.Unicode("ELKTextSizerView").tag(sync=True) 75 | _view_module = T.Unicode(EXTENSION_NAME).tag(sync=True) 76 | _view_module_version = T.Unicode(EXTENSION_SPEC_VERSION).tag(sync=True) 77 | 78 | async def run(self): 79 | """Go measure some DOM""" 80 | # watch once 81 | if self.outlet is None: 82 | return 83 | 84 | # signal to browser and wait for done 85 | future_value = wait_for_change(self.outlet, "value") 86 | 87 | self.send({"action": "run"}) 88 | 89 | # wait to return until 90 | # TODO if there is no change to the input text the 91 | # outlet value doesn't trigger 92 | await future_value 93 | self.outlet.persist() 94 | -------------------------------------------------------------------------------- /examples/01_Linking.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "id": "291e42bc-6fac-493d-91d7-79e60c7eee0a", 6 | "metadata": {}, 7 | "source": [ 8 | "## 🦌 Linking ELK Diagrams 🔗\n", 9 | "\n", 10 | "Example of using one diagram's selected state to control another diagram's view" 11 | ] 12 | }, 13 | { 14 | "cell_type": "code", 15 | "execution_count": null, 16 | "id": "9dd1fdcf-7391-4989-8c73-fd01963d91e7", 17 | "metadata": {}, 18 | "outputs": [], 19 | "source": [ 20 | "if __name__ == \"__main__\":\n", 21 | " %pip install -q -r requirements.txt" 22 | ] 23 | }, 24 | { 25 | "cell_type": "code", 26 | "execution_count": null, 27 | "id": "d85fa348-02f3-4f5e-b139-8edb1dec4a47", 28 | "metadata": {}, 29 | "outputs": [], 30 | "source": [ 31 | "import importnb\n", 32 | "import ipywidgets as W\n", 33 | "from IPython.display import display" 34 | ] 35 | }, 36 | { 37 | "cell_type": "code", 38 | "execution_count": null, 39 | "id": "91311e20-59c3-437f-b54a-2d4c47d67111", 40 | "metadata": {}, 41 | "outputs": [], 42 | "source": [ 43 | "with importnb.Notebook():\n", 44 | " from __00_Introduction import a_simple_elk_json_example" 45 | ] 46 | }, 47 | { 48 | "cell_type": "code", 49 | "execution_count": null, 50 | "id": "0239fd2a-4b87-4a79-b6b2-23cd9025b01b", 51 | "metadata": {}, 52 | "outputs": [], 53 | "source": [ 54 | "def a_linked_elk_example(**kwargs):\n", 55 | " elks = [a_simple_elk_json_example(**kwargs) for i in \"01\"]\n", 56 | "\n", 57 | " def centering(*args):\n", 58 | " if elks[0].view.selection.ids != [\"root\"]:\n", 59 | " elks[1].view.fit(elks[0].view.selection.ids)\n", 60 | " elks[1].view.selection.ids = elks[0].view.selection.ids\n", 61 | "\n", 62 | " elks[0].view.selection.observe(centering, \"ids\")\n", 63 | "\n", 64 | " return W.HBox(elks), elks" 65 | ] 66 | }, 67 | { 68 | "cell_type": "code", 69 | "execution_count": null, 70 | "id": "e1bf8d1c-2d15-4699-8190-d37ed808cf04", 71 | "metadata": {}, 72 | "outputs": [], 73 | "source": [ 74 | "if __name__ == \"__main__\":\n", 75 | " box, elks = a_linked_elk_example()\n", 76 | " display(box)" 77 | ] 78 | }, 79 | { 80 | "cell_type": "markdown", 81 | "id": "c372c49f-b30b-49b3-80e7-d2b93e634b34", 82 | "metadata": {}, 83 | "source": [ 84 | "## 🦌 Learn More 📖\n", 85 | "\n", 86 | "See the [other examples](./_index.ipynb)." 87 | ] 88 | } 89 | ], 90 | "metadata": { 91 | "kernelspec": { 92 | "display_name": "Python 3 (ipykernel)", 93 | "language": "python", 94 | "name": "python3" 95 | }, 96 | "language_info": { 97 | "codemirror_mode": { 98 | "name": "ipython", 99 | "version": 3 100 | }, 101 | "file_extension": ".py", 102 | "mimetype": "text/x-python", 103 | "name": "python", 104 | "nbconvert_exporter": "python", 105 | "pygments_lexer": "ipython3", 106 | "version": "3.11.10" 107 | } 108 | }, 109 | "nbformat": 4, 110 | "nbformat_minor": 5 111 | } 112 | -------------------------------------------------------------------------------- /atest/_resources/variables/IPyElk.robot: -------------------------------------------------------------------------------- 1 | *** Variables *** 2 | # 3 | # notebooks 4 | # 5 | ${INTRODUCTION} 00_Introduction 6 | ${LINKING} 01_Linking 7 | ${TRANSFORMER} 02_Transformer 8 | ${APP} 03_App 9 | ${INTERACTIVE} 04_Interactive 10 | ${EXPORTER} 05_SVG_Exporter 11 | ${APP EXPORTER} 06_SVG_App_Exporter 12 | ${SIM PLUMBING} 07_Simulation 13 | ${SIM APP} 08_Simulation_App 14 | ${DIAGRAM DEFS} 10_Diagram_Defs 15 | ${LOGIC GATES} 11_Logic_Gates 16 | ${NODE MENAGERIE} 12_Node_Menagerie 17 | ${COMPOUNDS} 13_Compounds 18 | ${TEXT STYLE} 14_Text_Styling 19 | ${NESTING PLOTS} 15_Nesting_Plots 20 | # 21 | # some widget-specific CSS 22 | # 23 | ${CSS ELK VIEW} .jp-ElkView 24 | ${CSS SPROTTY GRAPH} .sprotty-graph 25 | ${CSS ELK NODE} .elknode 26 | ${CSS ELK EDGE} .elkedge 27 | ${CSS ELK LABEL} .elklabel 28 | ${CSS ELK PORT} .elkport 29 | # 30 | # from simple.json 31 | # 32 | ${SIMPLE NODE COUNT} ${10} 33 | ${SIMPLE EDGE COUNT} ${14} 34 | ${SIMPLE LABEL COUNT} ${SIMPLE NODE COUNT} 35 | @{SIMPLE CUSTOM} 36 | ... css:.example-data-node-class-from-simple 37 | ... css:.example-data-edge-class-from-simple 38 | # 39 | # from flat_graph.json 40 | # 41 | ${FLAT NODE COUNT} ${3} 42 | ${FLAT EDGE COUNT} ${3} 43 | ${FLAT LABEL COUNT} ${FLAT NODE COUNT} 44 | ${FLAT PORT COUNT} ${1} 45 | @{FLAT CUSTOM} 46 | ... css:.example-data-node-class-from-flat 47 | ... css:.example-data-edge-class-from-flat 48 | # 49 | # from hier_graph.json 50 | # 51 | ${HIER NODE COUNT} ${4} 52 | ${HIER EDGE COUNT} ${5} 53 | ${HIER LABEL COUNT} ${HIER NODE COUNT} 54 | # 55 | # from hier_ports.json 56 | # 57 | ${HIER PORT COUNT} ${8} 58 | @{HIER PORT CUSTOM} 59 | ... css:.example-data-node-class-from-ports 60 | ... css:.example-data-edge-class-from-ports 61 | # 62 | # convenience roll-ups 63 | # 64 | &{SIMPLE COUNTS} 65 | ... nodes=${SIMPLE NODE COUNT} 66 | ... edges=${SIMPLE EDGE COUNT} 67 | ... labels=${SIMPLE LABEL COUNT} 68 | &{HIER COUNTS} 69 | ... nodes=${HIER NODE COUNT} 70 | ... edges=${HIER EDGE COUNT} 71 | ... labels=${HIER LABEL COUNT} 72 | ... ports=${HIER PORT COUNT} 73 | &{FLAT COUNTS} 74 | ... nodes=${FLAT NODE COUNT} 75 | ... edges=${FLAT EDGE COUNT} 76 | ... labels=${FLAT LABEL COUNT} 77 | ... ports=${FLAT PORT COUNT} 78 | &{FLAT AND HIER COUNTS} 79 | ... nodes=${FLAT NODE COUNT.__add__(${HIER NODE COUNT})} 80 | ... edges=${FLAT EDGE COUNT.__add__(${HIER EDGE COUNT})} 81 | ... labels=${FLAT LABEL COUNT.__add__(${HIER LABEL COUNT})} 82 | ... ports=${FLAT PORT COUNT.__add__(${HIER PORT COUNT})} 83 | -------------------------------------------------------------------------------- /src/ipyelk/elements/layout_options/__init__.py: -------------------------------------------------------------------------------- 1 | """Set of Widgets to help configure Elk Layout Options 2 | https://www.eclipse.org/elk/reference/options.html 3 | """ 4 | 5 | # Copyright (c) 2024 ipyelk contributors. 6 | # Distributed under the terms of the Modified BSD License. 7 | 8 | from .edge_options import ( 9 | Direction, 10 | EadesRepulsion, 11 | EdgeCenterLabelPlacementStrategy, 12 | EdgeEdgeLayerSpacing, 13 | EdgeLabelPlacement, 14 | EdgeLabelSideSelection, 15 | EdgeLabelSpacing, 16 | EdgeNodeLayerSpacing, 17 | EdgeNodeSpacing, 18 | EdgeRouting, 19 | EdgeSpacing, 20 | EdgeThickness, 21 | EdgeType, 22 | FeedbackEdges, 23 | MergeEdges, 24 | MergeHierarchyCrossingEdges, 25 | ) 26 | from .layout import ELKRectanglePacking, LayoutAlgorithm 27 | from .node_options import ( 28 | ActivateInsideSelfLoops, 29 | AspectRatio, 30 | ConsiderModelOrder, 31 | ContentAlignment, 32 | ExpandNodes, 33 | HierarchyHandling, 34 | NodeLabelPlacement, 35 | NodeSizeConstraints, 36 | NodeSizeMinimum, 37 | NodeSizeOptions, 38 | Padding, 39 | ) 40 | from .port_options import ( 41 | AdditionalPortSpace, 42 | PortAnchorOffset, 43 | PortBorderOffset, 44 | PortConstraints, 45 | PortIndex, 46 | PortLabelPlacement, 47 | PortSide, 48 | TreatPortLabelsAsGroup, 49 | ) 50 | from .selection_widgets import LayoutOptionWidget, OptionsWidget, SpacingOptionWidget 51 | from .spacing_options import ( 52 | CommentCommentSpacing, 53 | CommentNodeSpacing, 54 | ComponentsSpacing, 55 | LabelNodeSpacing, 56 | LabelSpacing, 57 | NodeSpacing, 58 | SeparateConnectedComponents, 59 | ) 60 | 61 | __all__ = [ 62 | "ActivateInsideSelfLoops", 63 | "AdditionalPortSpace", 64 | "AspectRatio", 65 | "CommentCommentSpacing", 66 | "CommentNodeSpacing", 67 | "ComponentsSpacing", 68 | "ConsiderModelOrder", 69 | "ContentAlignment", 70 | "Direction", 71 | "EadesRepulsion", 72 | "EdgeCenterLabelPlacementStrategy", 73 | "EdgeEdgeLayerSpacing", 74 | "EdgeCenterLabelPlacementStrategy", 75 | "EdgeEdgeLayerSpacing", 76 | "EdgeLabelPlacement", 77 | "EdgeLabelSideSelection", 78 | "EdgeLabelSpacing", 79 | "EdgeLabelSpacing", 80 | "EdgeNodeLayerSpacing", 81 | "EdgeNodeLayerSpacing", 82 | "EdgeNodeSpacing", 83 | "EdgeRouting", 84 | "EdgeSpacing", 85 | "EdgeThickness", 86 | "EdgeType", 87 | "ELKRectanglePacking", 88 | "ExpandNodes", 89 | "FeedbackEdges", 90 | "HierarchyHandling", 91 | "LabelNodeSpacing", 92 | "LabelSpacing", 93 | "LayoutAlgorithm", 94 | "LayoutOptionWidget", 95 | "MergeEdges", 96 | "MergeHierarchyCrossingEdges", 97 | "NodeLabelPlacement", 98 | "NodeSizeConstraints", 99 | "NodeSizeMinimum", 100 | "NodeSizeOptions", 101 | "NodeSpacing", 102 | "OptionsWidget", 103 | "Padding", 104 | "PortAnchorOffset", 105 | "PortBorderOffset", 106 | "PortConstraints", 107 | "PortIndex", 108 | "PortLabelPlacement", 109 | "PortSide", 110 | "SeparateConnectedComponents", 111 | "SpacingOptionWidget", 112 | "TreatPortLabelsAsGroup", 113 | ] 114 | -------------------------------------------------------------------------------- /examples/14_Text_Styling.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "id": "bee6c36a-6a0f-4be6-9601-f3dfc2014062", 6 | "metadata": {}, 7 | "source": [ 8 | "# 🦌 Text Styling 💨📜\n", 9 | "\n", 10 | "This notebook demonstrates updating the diagram's label css styling to a larger bold\n", 11 | "font and testing that the resulting label widths are larger than with the original\n", 12 | "style." 13 | ] 14 | }, 15 | { 16 | "cell_type": "code", 17 | "execution_count": null, 18 | "id": "f5f8de7f-4a3e-45c8-87ba-5992cddff7c8", 19 | "metadata": {}, 20 | "outputs": [], 21 | "source": [ 22 | "if __name__ == \"__main__\":\n", 23 | " %pip install -q -r requirements.txt" 24 | ] 25 | }, 26 | { 27 | "cell_type": "code", 28 | "execution_count": null, 29 | "id": "b7ec61de-5df4-4f90-ac00-7f46ea7e77b2", 30 | "metadata": {}, 31 | "outputs": [], 32 | "source": [ 33 | "import asyncio\n", 34 | "\n", 35 | "with __import__(\"importnb\").Notebook():\n", 36 | " from __13_Compounds import email_activity_example\n", 37 | "\n", 38 | "\n", 39 | "def get_label_widths(diagram):\n", 40 | " widths = {id_: label.width for id_, label in diagram.source.index.elements.labels()}\n", 41 | "\n", 42 | " return widths\n", 43 | "\n", 44 | "\n", 45 | "async def test_widths(diagram):\n", 46 | " \"\"\"Test function to compare text widths with a style change\"\"\"\n", 47 | " await diagram.pipe._task # await for the first completion of the diagram pipe\n", 48 | " await diagram.refresh()\n", 49 | "\n", 50 | " # measure to get baseline text size\n", 51 | " old = get_label_widths(diagram)\n", 52 | "\n", 53 | " email_act_app.style = {\n", 54 | " \" .final-state .inner-circle\": {\"fill\": \"var(--jp-elk-node-stroke)\"},\n", 55 | " \" .activity-filled .elknode\": {\"fill\": \"var(--jp-elk-node-stroke)\"},\n", 56 | " \" .activity-container > .elknode\": {\"rx\": \"var(--jp-code-font-size)\"},\n", 57 | " \" text.elklabel\": {\n", 58 | " \"font-weight\": \"bold\",\n", 59 | " },\n", 60 | " }\n", 61 | "\n", 62 | " # measure text after refreshing the diagram with new bold style\n", 63 | " await diagram.refresh()\n", 64 | " new = get_label_widths(diagram)\n", 65 | "\n", 66 | " assert all(old[elid] < new[elid] for elid in old.keys())\n", 67 | "\n", 68 | "\n", 69 | "if __name__ == \"__main__\":\n", 70 | " email_act_app, email_activities = email_activity_example()\n", 71 | " task = asyncio.ensure_future(test_widths(email_act_app))\n", 72 | " display(email_act_app)\n", 73 | " display(email_act_app.pipe)" 74 | ] 75 | } 76 | ], 77 | "metadata": { 78 | "kernelspec": { 79 | "display_name": "Python 3 (ipykernel)", 80 | "language": "python", 81 | "name": "python3" 82 | }, 83 | "language_info": { 84 | "codemirror_mode": { 85 | "name": "ipython", 86 | "version": 3 87 | }, 88 | "file_extension": ".py", 89 | "mimetype": "text/x-python", 90 | "name": "python", 91 | "nbconvert_exporter": "python", 92 | "pygments_lexer": "ipython3", 93 | "version": "3.10.8" 94 | } 95 | }, 96 | "nbformat": 4, 97 | "nbformat_minor": 5 98 | } 99 | -------------------------------------------------------------------------------- /src/ipyelk/elements/layout_options/layout.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 ipyelk contributors. 2 | # Distributed under the terms of the Modified BSD License. 3 | from dataclasses import dataclass 4 | from typing import List 5 | 6 | import ipywidgets as W 7 | import traitlets as T 8 | 9 | from .selection_widgets import LayoutOptionWidget 10 | 11 | 12 | @dataclass 13 | class Algorithm: 14 | identifier: str 15 | metadata_provider: str 16 | title: str 17 | 18 | 19 | class Draw2DLayout(Algorithm): 20 | """https://www.eclipse.org/elk/reference/algorithms/org-eclipse-elk-conn-gmf-layouter-Draw2D.html""" 21 | 22 | identifier = "org.eclipse.elk.conn.gmf.layouter.Draw2D" 23 | metadata_provider = "GmfMetaDataProvider" 24 | title = "Draw2D Layout" 25 | 26 | 27 | class ELKBox(Algorithm): 28 | """https://www.eclipse.org/elk/reference/algorithms/org-eclipse-elk-box.html""" 29 | 30 | identifier = "org.eclipse.elk.box" 31 | metadata_provider = "core.options.CoreOptions" 32 | title = "ELK BoX" 33 | 34 | 35 | class ELKRadial(Algorithm): 36 | """https://www.eclipse.org/elk/reference/algorithms/org-eclipse-elk-radial.html""" 37 | 38 | identifier = "org.eclipse.elk.radial" 39 | metadata_provider = "options.RadialMetaDataProvider" 40 | title = "ELK Radial" 41 | 42 | 43 | class ELKLayered(Algorithm): 44 | """https://www.eclipse.org/elk/reference/algorithms/org-eclipse-elk-layered.html""" 45 | 46 | identifier = "org.eclipse.elk.layered" 47 | metadata_provider = "options.LayeredMetaDataProvider" 48 | title = "ELK Layered" 49 | 50 | 51 | class ELKRectanglePacking(Algorithm): 52 | """https://www.eclipse.org/elk/reference/algorithms/org-eclipse-elk-rectpacking.html""" 53 | 54 | identifier = "org.eclipse.elk.rectpacking" 55 | metadata_provider = "options.RectPackingMetaDataProvider" 56 | title = "Elk Rectangle Packing" 57 | 58 | 59 | ALGORITHM_OPTIONS = {_cls.identifier: _cls for _cls in Algorithm.__subclasses__()} 60 | 61 | 62 | class LayoutAlgorithm(LayoutOptionWidget): 63 | """Select a specific layout algorithm. 64 | 65 | https://www.eclipse.org/elk/reference/options/org-eclipse-elk-algorithm.html 66 | """ 67 | 68 | identifier = "org.eclipse.elk.algorithm" 69 | 70 | value = T.Enum( 71 | values=list(ALGORITHM_OPTIONS.keys()), default_value=ELKLayered.identifier 72 | ) 73 | metadata_provider = T.Unicode() 74 | applies_to = ["parents"] 75 | 76 | def _ui(self) -> List[W.Widget]: 77 | options = [ 78 | (_cls.title, identifier) for (identifier, _cls) in ALGORITHM_OPTIONS.items() 79 | ] 80 | dropdown = W.Dropdown(description="Layout Algorithm", options=options) 81 | 82 | T.link((self, "value"), (dropdown, "value")) 83 | 84 | return [dropdown] 85 | 86 | @T.default("metadata_provider") 87 | def _default_metadata_provider(self): 88 | """Default value for the current metadata provider""" 89 | return self._update_metadata_provider() 90 | 91 | @T.observe("value") 92 | def _update_metadata_provider(self, change: T.Bunch = None): 93 | """Change Handler to update the metadata provider based on current 94 | selected algorithm 95 | """ 96 | provider = ALGORITHM_OPTIONS[self.value].metadata_provider 97 | self.metadata_provider = provider 98 | return provider 99 | -------------------------------------------------------------------------------- /src/ipyelk/pipes/valid.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 ipyelk contributors. 2 | # Distributed under the terms of the Modified BSD License. 3 | from typing import Dict 4 | 5 | import traitlets as T 6 | from ipywidgets.widgets.trait_types import TypedTuple 7 | 8 | from ..elements import EdgeReport, IDReport, Node 9 | from . import flows as F 10 | from .base import Pipe 11 | from .marks import MarkIndex 12 | 13 | 14 | class ValidationPipe(Pipe): 15 | observes = TypedTuple(T.Unicode(), default_value=(F.New,)) 16 | reports = TypedTuple(T.Unicode(), default_value=(F.Layout,)) 17 | fix_null_id = T.Bool(default_value=True) 18 | fix_edge_owners = T.Bool(default_value=True) 19 | fix_orphans = T.Bool(default_value=True) 20 | id_report = T.Instance(IDReport, kw={}) 21 | edge_report = T.Instance(EdgeReport, kw={}) 22 | schema_report = T.Dict(kw={}) 23 | errors = T.Dict(kw={}) 24 | 25 | async def run(self): 26 | index: MarkIndex = self.inlet.build_index() 27 | with index.context: 28 | self.get_reports(index) 29 | self.errors = self.collect_errors() 30 | if self.errors: 31 | raise ValueError("Inlet value is not valid") 32 | value = self.apply_fixes(index) 33 | 34 | if value is self.outlet.value: 35 | # force refresh if same instance 36 | self.outlet._notify_trait("value", None, value) 37 | else: 38 | self.outlet.value = value 39 | self.get_reports(self.outlet.build_index()) 40 | self.errors = self.collect_errors() 41 | if self.errors: 42 | raise ValueError("Outlet value is not valid") 43 | 44 | def get_reports(self, index: MarkIndex): 45 | self.edge_report, self.id_report = index.elements.get_reports() 46 | 47 | def collect_errors(self) -> Dict: 48 | errors = {} 49 | if self.id_report.duplicated: 50 | errors["Nonunique Element Ids"] = self.id_report.duplicated 51 | 52 | if self.id_report.null_ids and not self.fix_null_id: 53 | errors["Null Id Elements"] = self.id_report.null_ids 54 | 55 | if self.edge_report.orphans and not self.fix_orphans: 56 | errors["Orphan Nodes"] = self.edge_report.orphans 57 | 58 | if self.edge_report.lca_mismatch and not self.fix_edge_owners: 59 | errors["Lowest Common Ancestor Mismatch"] = self.edge_report.lca_mismatch 60 | 61 | if self.schema_report: 62 | errors["Schema Error"] = self.schema_report 63 | return errors 64 | 65 | def apply_fixes(self, index: MarkIndex) -> Node: 66 | root = index.root 67 | if self.id_report.null_ids and self.fix_null_id: 68 | self.log.warning(f"fixing {len(self.id_report.null_ids)} ids") 69 | for el in self.id_report.null_ids: 70 | el.id = el.get_id() 71 | 72 | if self.edge_report.orphans and self.fix_orphans: 73 | for el in self.edge_report.orphans: 74 | root.add_child(el) 75 | 76 | if self.edge_report.lca_mismatch and self.fix_edge_owners: 77 | for edge, (old, new) in self.edge_report.lca_mismatch.items(): 78 | old.edges.remove(edge) 79 | if new is None: 80 | new = root 81 | new.edges.append(edge) 82 | return root 83 | -------------------------------------------------------------------------------- /examples/06_SVG_App_Exporter.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "id": "d899fb7b-18a9-4ac6-9862-2b3fbee61edc", 6 | "metadata": {}, 7 | "source": [ 8 | "# 🦌 SVG App Exporter 🥡\n", 9 | "\n", 10 | "Custom CSS created on an `Elk` app will also be exported.\n", 11 | "\n", 12 | "> Note: this requires a browser in the loop to do the rendering." 13 | ] 14 | }, 15 | { 16 | "cell_type": "code", 17 | "execution_count": null, 18 | "id": "9a3f5b11-3c7e-4c22-b530-86a14560d724", 19 | "metadata": {}, 20 | "outputs": [], 21 | "source": [ 22 | "if __name__ == \"__main__\":\n", 23 | " %pip install -q -r requirements.txt" 24 | ] 25 | }, 26 | { 27 | "cell_type": "code", 28 | "execution_count": null, 29 | "id": "0b719c7c-2092-4621-9e48-a46fe2b0b69e", 30 | "metadata": {}, 31 | "outputs": [], 32 | "source": [ 33 | "import importnb\n", 34 | "import ipywidgets\n", 35 | "from IPython.display import display" 36 | ] 37 | }, 38 | { 39 | "cell_type": "code", 40 | "execution_count": null, 41 | "id": "2153387d-1295-4639-9806-e394f5895923", 42 | "metadata": {}, 43 | "outputs": [], 44 | "source": [ 45 | "with importnb.Notebook():\n", 46 | " from __03_App import a_styled_elk_app_example\n", 47 | " from __05_SVG_Exporter import a_simple_elk_svg_export_example" 48 | ] 49 | }, 50 | { 51 | "cell_type": "code", 52 | "execution_count": null, 53 | "id": "c07285db-4086-44e3-a7ee-f1f0b238d672", 54 | "metadata": {}, 55 | "outputs": [], 56 | "source": [ 57 | "def a_stylish_elk_svg_export_example(filename=\"untitled_stylish_example.svg\"):\n", 58 | " box1, elk = a_styled_elk_app_example()\n", 59 | " box2, out, exporter, elk2 = a_simple_elk_svg_export_example(\n", 60 | " elk=elk, filename=filename\n", 61 | " )\n", 62 | " elk.layout.flex = out.layout.flex = \"1\"\n", 63 | " exporter.diagram = elk\n", 64 | " box = ipywidgets.HBox([elk, out], layout=dict(height=\"100%\", min_height=\"400px\"))\n", 65 | " return box, out, exporter, elk, elk2" 66 | ] 67 | }, 68 | { 69 | "cell_type": "code", 70 | "execution_count": null, 71 | "id": "57665acf-bb90-4642-9db7-d00b1af2471e", 72 | "metadata": {}, 73 | "outputs": [], 74 | "source": [ 75 | "if __name__ == \"__main__\":\n", 76 | " box, out, exporter, elk, elk2 = a_stylish_elk_svg_export_example()\n", 77 | " display(box)" 78 | ] 79 | }, 80 | { 81 | "cell_type": "markdown", 82 | "id": "cae5ecf6-8bc5-4eb5-aa00-30b2e248e003", 83 | "metadata": {}, 84 | "source": [ 85 | "## 🦌 Learn More 📖\n", 86 | "\n", 87 | "See the [other examples](./_index.ipynb)." 88 | ] 89 | } 90 | ], 91 | "metadata": { 92 | "kernelspec": { 93 | "display_name": "Python 3 (ipykernel)", 94 | "language": "python", 95 | "name": "python3" 96 | }, 97 | "language_info": { 98 | "codemirror_mode": { 99 | "name": "ipython", 100 | "version": 3 101 | }, 102 | "file_extension": ".py", 103 | "mimetype": "text/x-python", 104 | "name": "python", 105 | "nbconvert_exporter": "python", 106 | "pygments_lexer": "ipython3", 107 | "version": "3.10.6" 108 | } 109 | }, 110 | "nbformat": 4, 111 | "nbformat_minor": 5 112 | } 113 | -------------------------------------------------------------------------------- /src/ipyelk/elements/layout_options/wrapping_options.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 ipyelk contributors. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | from typing import List 5 | 6 | import ipywidgets as W 7 | import traitlets as T 8 | 9 | from .selection_widgets import LayoutOptionWidget, SpacingOptionWidget 10 | 11 | WRAPPING_STRATEGY_OPTIONS = { 12 | "Off": "OFF", 13 | "Single edge edge": "SINGLE_EDGE", 14 | "Multi Edge": "MULTI_EDGE", 15 | } 16 | 17 | 18 | class GraphWrappingStrategy(LayoutOptionWidget): 19 | """For certain graphs and certain prescribed drawing areas it may be 20 | desirable to split the laid out graph into chunks that are placed side by 21 | side. The edges that connect different chunks are ‘wrapped’ around from the 22 | end of one chunk to the start of the other chunk. The points between the 23 | chunks are referred to as ‘cuts’. 24 | 25 | https://www.eclipse.org/elk/reference/options/org-eclipse-elk-layered-wrapping-strategy.html 26 | """ 27 | 28 | identifier = "org.eclipse.elk.layered.wrapping.strategy" 29 | metadata_provider = "options.LayeredMetaDataProvider" 30 | applies_to = ["parents"] 31 | group = "wrapping" 32 | 33 | horizontal = T.Enum(values=["left", "center", "right"], default_value="left") 34 | value = T.Enum(value=WRAPPING_STRATEGY_OPTIONS.values(), default_value="OFF") 35 | 36 | def _ui(self) -> List[W.Widget]: 37 | dropdown = W.Dropdown(options=list(WRAPPING_STRATEGY_OPTIONS.items())) 38 | T.link((self, "value"), (dropdown, "value")) 39 | 40 | return [dropdown] 41 | 42 | 43 | class AdditionalWrappedEdgesSpacing(SpacingOptionWidget): 44 | """To visually separate edges that are wrapped from regularly routed edges 45 | an additional spacing value can be specified in form of this layout option. 46 | The spacing is added to the regular edgeNode spacing. 47 | 48 | https://www.eclipse.org/elk/reference/options/org-eclipse-elk-layered-wrapping-additionalEdgeSpacing.html 49 | """ 50 | 51 | identifier = "org.eclipse.elk.layered.wrapping.additionalEdgeSpacing" 52 | metadata_provider = "options.LayeredMetaDataProvider" 53 | applies_to = ["parents"] 54 | group = "wrapping" 55 | 56 | spacing = T.Float(default_value=10, min=0) 57 | dependencies = ( 58 | ("org.eclipse.elk.layered.wrapping.strategy", "SINGLE_EDGE"), 59 | ("org.eclipse.elk.layered.wrapping.strategy", "MULTI_EDGE"), 60 | ) 61 | _slider_description = "Additional Wrapped Edges Spacing" 62 | 63 | 64 | class CorrectionFactorForWrapping(SpacingOptionWidget): 65 | """At times and for certain types of graphs the executed wrapping may 66 | produce results that are consistently biased in the same fashion: either 67 | wrapping to often or to rarely. This factor can be used to correct the bias. 68 | Internally, it is simply multiplied with the ‘aspect ratio’ layout option. 69 | 70 | https://www.eclipse.org/elk/reference/options/org-eclipse-elk-layered-wrapping-correctionFactor.html 71 | """ 72 | 73 | identifier = "org.eclipse.elk.layered.wrapping.correctionFactor" 74 | metadata_provider = "options.LayeredMetaDataProvider" 75 | applies_to = ["parents"] 76 | group = "wrapping" 77 | dependencies = ( 78 | ("org.eclipse.elk.layered.wrapping.strategy", "SINGLE_EDGE"), 79 | ("org.eclipse.elk.layered.wrapping.strategy", "MULTI_EDGE"), 80 | ) 81 | -------------------------------------------------------------------------------- /src/ipyelk/contrib/library/block.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 ipyelk contributors. 2 | # Distributed under the terms of the Modified BSD License. 3 | from typing import Dict, Type 4 | 5 | from pydantic.v1 import Field 6 | 7 | from ...elements import ( 8 | Edge, 9 | EdgeProperties, 10 | Partition, 11 | Record, 12 | SymbolSpec, 13 | merge_excluded, 14 | ) 15 | from ...elements import layout_options as opt 16 | from ..molds import connectors 17 | 18 | content_label_opts = opt.OptionsWidget( 19 | options=[opt.NodeLabelPlacement(horizontal="left", vertical="center")] 20 | ).value 21 | 22 | top_center_label_opts = opt.OptionsWidget( 23 | options=[opt.NodeLabelPlacement(horizontal="center", vertical="top")] 24 | ).value 25 | 26 | center_label_opts = opt.OptionsWidget( 27 | options=[opt.NodeLabelPlacement(horizontal="center", vertical="center")] 28 | ).value 29 | 30 | bullet_opts = opt.OptionsWidget( 31 | options=[ 32 | opt.LabelSpacing(spacing=4), 33 | ] 34 | ).value 35 | 36 | compart_opts = opt.OptionsWidget( 37 | options=[ 38 | opt.NodeSizeConstraints(), 39 | ] 40 | ).value 41 | 42 | 43 | class Block(Record): 44 | pass 45 | 46 | 47 | class Composition(Edge): 48 | properties: EdgeProperties = EdgeProperties(shape={"start": "composition"}) 49 | 50 | 51 | class Aggregation(Edge): 52 | properties: EdgeProperties = EdgeProperties(shape={"start": "aggregation"}) 53 | 54 | 55 | class Containment(Edge): 56 | properties: EdgeProperties = EdgeProperties(shape={"start": "containment"}) 57 | 58 | 59 | class DirectedAssociation(Edge): 60 | properties: EdgeProperties = EdgeProperties(shape={"end": "directed_association"}) 61 | 62 | 63 | class Association(Edge): 64 | pass 65 | 66 | 67 | class Generalization(Edge): 68 | properties: EdgeProperties = EdgeProperties(shape={"start": "generalization"}) 69 | 70 | 71 | class BlockDiagram(Partition): 72 | # TODO flesh out ideas of encapsulating diagram defs / styles / elements 73 | class Config: 74 | copy_on_model_validation = "none" 75 | excluded = merge_excluded(Partition, "symbols", "style") 76 | 77 | symbols: SymbolSpec = SymbolSpec().add( 78 | connectors.Rhomb(identifier="composition", r=4), 79 | connectors.Rhomb(identifier="aggregation", r=4), 80 | connectors.Containment(identifier="containment", r=4), 81 | connectors.StraightArrow(identifier="directed_association", r=4), 82 | connectors.StraightArrow(identifier="generalization", r=4, closed=True), 83 | ) 84 | 85 | style: Dict[str, Dict] = { 86 | " .elklabel.compartment_title_1": { 87 | "font-weight": "bold", 88 | }, 89 | " .elklabel.heading, .elklabel.compartment_title_2": { 90 | "font-style": "italic", 91 | }, 92 | " .arrow.inheritance": { 93 | "fill": "none", 94 | }, 95 | " .arrow.containment": { 96 | "fill": "none", 97 | }, 98 | " .arrow.aggregation": { 99 | "fill": "none", 100 | }, 101 | " .arrow.directed_association": { 102 | "fill": "none", 103 | }, 104 | " .internal>.elknode": { 105 | "stroke": "transparent", 106 | "fill": "transparent", 107 | }, 108 | } 109 | default_edge: Type[Edge] = Field(default=Association) 110 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Test configurationb for ``ipyelk``.""" 2 | # Copyright (c) 2024 ipyelk contributors. 3 | # Distributed under the terms of the Modified BSD License. 4 | 5 | from __future__ import annotations 6 | 7 | import json 8 | import re 9 | from pathlib import Path 10 | from typing import Any 11 | 12 | import pytest 13 | 14 | UTF8 = {"encoding": "utf-8"} 15 | 16 | HERE = Path(__file__).parent 17 | ROOT = HERE.parent 18 | 19 | PIXI_TOML = ROOT / "pixi.toml" 20 | CI_YML = ROOT / ".github/workflows/ci.yml" 21 | RTD_YML = ROOT / "docs/rtd.yml" 22 | CONTRIB_MD = ROOT / "CONTRIBUTING.md" 23 | CHANGELOG_MD = ROOT / "CHANGELOG.md" 24 | PACKAGE_JSON = ROOT / "package.json" 25 | PYPROJECT_TOML = ROOT / "pyproject.toml" 26 | README_MD = ROOT / "README.md" 27 | 28 | PIXI_PATTERNS = { 29 | CI_YML: (5, r"pixi-version: v(.*)"), 30 | RTD_YML: (1, r"- pixi ==(.*)"), 31 | CONTRIB_MD: (1, r'"pixi==(.*)"'), 32 | } 33 | 34 | 35 | @pytest.fixture 36 | def the_pixi_toml() -> dict[str, Any]: 37 | """Provide the ``pixi.toml``""" 38 | if not PIXI_TOML.exists(): 39 | pytest.skip("Not in repo") 40 | 41 | try: 42 | import tomllib 43 | except ImportError: 44 | pytest.skip("Running on older python") 45 | return None 46 | 47 | return tomllib.loads(PIXI_TOML.read_text(**UTF8)) 48 | 49 | 50 | @pytest.fixture 51 | def the_pixi_version(the_pixi_toml: dict[str, Any]) -> str: 52 | """Provide the source-of-truth version of ``pixi``.""" 53 | return re.findall(r"/v([^/]+)/", the_pixi_toml["$schema"])[0] 54 | 55 | 56 | @pytest.fixture(params=[str(p.relative_to(ROOT)) for p in PIXI_PATTERNS]) 57 | def a_file_with_pixi_versions(request: pytest.FixtureRequest) -> Path: 58 | """Provide a file that should have ``pixi`` versions.""" 59 | return Path(ROOT / request.param) 60 | 61 | 62 | @pytest.fixture 63 | def pixi_versions_in_a_file(a_file_with_pixi_versions: Path) -> set[str]: 64 | """Provide the ``pixi`` versions found in a file.""" 65 | text = a_file_with_pixi_versions.read_text(**UTF8) 66 | count_pattern = PIXI_PATTERNS.get(Path(a_file_with_pixi_versions)) 67 | assert count_pattern 68 | count, pattern = count_pattern 69 | assert pattern 70 | matches = re.findall(pattern, text) 71 | assert len(matches) == count 72 | return set(matches) 73 | 74 | 75 | @pytest.fixture 76 | def the_changelog_text() -> str: 77 | """Provide the text of the changelog.""" 78 | if not CHANGELOG_MD.exists(): 79 | pytest.skip("Not in repo") 80 | return CHANGELOG_MD.read_text(**UTF8) 81 | 82 | 83 | @pytest.fixture 84 | def the_js_version() -> str: 85 | """Provide the source-of-truth data for the js extension.""" 86 | return json.loads(PACKAGE_JSON.read_text(**UTF8))["version"] 87 | 88 | 89 | @pytest.fixture 90 | def the_pyproject_data() -> dict[str, Any]: 91 | """Provide the python project data.""" 92 | try: 93 | import tomllib 94 | except ImportError: 95 | pytest.skip("Running on older python") 96 | return None 97 | 98 | return tomllib.loads(PYPROJECT_TOML.read_text(**UTF8)) 99 | 100 | 101 | @pytest.fixture 102 | def the_py_version(the_pyproject_data: dict[str, Any]) -> str: 103 | """Provide the source-of-truth python version.""" 104 | return the_pyproject_data["project"]["version"] 105 | 106 | 107 | @pytest.fixture 108 | def the_readme_text() -> str: 109 | if not README_MD.exists(): 110 | pytest.skip("Not in repo") 111 | return README_MD.read_text(**UTF8) 112 | -------------------------------------------------------------------------------- /atest/_resources/variables/Lab.robot: -------------------------------------------------------------------------------- 1 | *** Variables *** 2 | ${SPLASH} id:jupyterlab-splash 3 | ${CMD PALETTE INPUT} css:#command-palette .lm-CommandPalette-input 4 | ${CMD PALETTE ITEM ACTIVE} css:#command-palette .lm-CommandPalette-item.lm-mod-active 5 | ${JLAB XP TOP} //div[@id='jp-top-panel'] 6 | ${JLAB XP MENU ITEM LABEL} //div[@class='lm-Menu-itemLabel'] 7 | ${JLAB XP MENU LABEL} //div[@class='lm-MenuBar-itemLabel'] 8 | ${JLAB XP DOCK TAB} 9 | ... xpath://div[contains(@class, 'lm-DockPanel-tabBar')]//li[contains(@class, 'lm-TabBar-tab')] 10 | ${JLAB XP CODE CELLS} 11 | ... xpath://*[contains(@class, 'jp-NotebookPanel-notebook')]//*[contains(@class, 'jp-CodeCell')] 12 | ${JLAB CSS CELL} css:.jp-Cell 13 | ${JLAB XP CELLS} xpath://*[contains(@class, 'jp-NotebookPanel-notebook')]//*[contains(@class, 'jp-Cell')] 14 | ${JLAB CSS NOTEBOOK} css:.jp-NotebookPanel-notebook 15 | ${JLAB CSS NOTEBOOK SCROLL} ${JLAB CSS NOTEBOOK} .jp-WindowedPanel-outer 16 | ${JLAB CSS WINDOW SCROLL} css:.jp-mod-virtual-scrollbar .jp-WindowedPanel-scrollbar 17 | 18 | ${JLAB CSS WINDOW TOGGLE} css:.jp-NotebookPanel-toolbar [data-command='notebook:toggle-virtual-scrollbar'] 19 | ${JLAB XP LAST CODE CELL} ${JLAB XP CODE CELLS}\[last()] 20 | ${JLAB CSS NB FOOTER} css:.jp-Notebook-footer 21 | ${JLAB XP LAST CODE PROMPT} ${JLAB XP LAST CODE CELL}//*[contains(@class, 'jp-InputArea-prompt')] 22 | ${JLAB XP STDERR} xpath://*[@data-mime-type="application/vnd.jupyter.stderr"] 23 | ${JLAB XP KERNEL IDLE} xpath://div[contains(@id, 'jp-main-statusbar')]//span[contains(., "Idle")] 24 | ${JLAB CSS VERSION} css:.jp-About-version 25 | ${JLAB CSS CREATE OUTPUT} .lm-Menu-item[data-command="notebook:create-output-view"] 26 | ${JLAB CSS LINKED OUTPUT} .jp-LinkedOutputView 27 | ${JLAB CSS OUTPUT AREA} .jp-OutputArea-output 28 | ${CSS DIALOG OK} css:.jp-Dialog .jp-mod-accept 29 | ${MENU OPEN WITH} xpath://div[contains(@class, 'lm-Menu-itemLabel')][contains(text(), "Open With")] 30 | # R is missing on purpose (may need to use .) 31 | ${MENU RENAME} xpath://div[contains(@class, 'lm-Menu-itemLabel')][contains(., "ename")] 32 | # N is missing on purpose 33 | ${MENU NOTEBOOK} 34 | ... xpath://div[@id="jp-contextmenu-open-with"]//div[contains(@class, 'lm-Menu-itemLabel')][contains(., "otebook")] 35 | ${DIALOG WINDOW} css:.jp-Dialog 36 | ${DIALOG INPUT} css:.jp-Input-Dialog input 37 | ${DIALOG ACCEPT} css:button.jp-Dialog-button.jp-mod-accept 38 | ${STATUSBAR} css:div.lsp-statusbar-item 39 | ${MENU EDITOR} xpath://div[contains(@class, 'lm-Menu-itemLabel')][contains(., "Editor")] 40 | ${MENU JUMP} 41 | ... xpath://div[contains(@class, 'lm-Menu-itemLabel')][contains(text(), "Jump to definition")] 42 | ${MENU SETTINGS} xpath://div[contains(@class, 'lm-MenuBar-itemLabel')][contains(text(), "Settings")] 43 | ${MENU EDITOR THEME} 44 | ... xpath://div[contains(@class, 'lm-Menu-itemLabel')][contains(text(), "Text Editor Theme")] 45 | ${CM CURSOR} css:.CodeMirror-cursor 46 | ${CM CURSORS} css:.CodeMirror-cursors:not([style='visibility: hidden']) 47 | # settings 48 | ${CSS USER SETTINGS} .jp-SettingsRawEditor-user 49 | ${JLAB XP CLOSE SETTINGS} ${JLAB XP DOCK TAB}\[contains(., 'Settings')]/*[@data-icon='ui-components:close'] 50 | -------------------------------------------------------------------------------- /src/ipyelk/diagram/sprotty_viewer.py: -------------------------------------------------------------------------------- 1 | """Widget for interacting with ELK rendered using Sprotty""" 2 | 3 | # Copyright (c) 2024 ipyelk contributors. 4 | # Distributed under the terms of the Modified BSD License. 5 | 6 | from typing import List 7 | 8 | import traitlets as T 9 | from ipywidgets import DOMWidget 10 | 11 | from ..constants import EXTENSION_NAME, EXTENSION_SPEC_VERSION 12 | from ..elements import SymbolSpec, symbol_serialization 13 | from ..tools import CenterTool, FitTool 14 | from .viewer import Viewer 15 | 16 | # TODO reconnect schema check after adding edge type 17 | # from ..schema import ElkSchemaValidator 18 | # from ..trait_types import Schema 19 | 20 | 21 | class SprottyViewer(DOMWidget, Viewer): 22 | """Jupyterlab widget for displaying and interacting with views generated 23 | from ELK JSON. 24 | 25 | Setting the instance's `value` traitlet to valid `Eclipse Layout Kernel JSON 26 | `_ will call the `elkjs layout method 28 | `_ and display the returned `mark_layout` 29 | using `sprotty `_. 30 | 31 | """ 32 | 33 | _model_name = T.Unicode("ELKViewerModel").tag(sync=True) 34 | _model_module = T.Unicode(EXTENSION_NAME).tag(sync=True) 35 | _model_module_version = T.Unicode(EXTENSION_SPEC_VERSION).tag(sync=True) 36 | _view_name = T.Unicode("ELKViewerView").tag(sync=True) 37 | _view_module = T.Unicode(EXTENSION_NAME).tag(sync=True) 38 | _view_module_version = T.Unicode(EXTENSION_SPEC_VERSION).tag(sync=True) 39 | 40 | symbols: SymbolSpec = T.Instance(SymbolSpec, kw={}).tag( 41 | sync=True, **symbol_serialization 42 | ) 43 | 44 | def center( 45 | self, 46 | model_ids: List[str] = None, 47 | animate: bool = None, 48 | retain_zoom: bool = None, 49 | ): 50 | """Center Diagram View on specified model ids 51 | 52 | :param model_ids: list of elk model id strings, defaults to None 53 | :param animate: specify is the view animates to the given marks 54 | :param retain_zoom: specify if the current zoom level is maintained 55 | """ 56 | self.send({ 57 | "action": "center", 58 | "model_id": model_ids, 59 | "animate": True if animate is None else animate, 60 | "retain_zoom": False if retain_zoom is None else retain_zoom, 61 | }) 62 | 63 | def fit( 64 | self, 65 | model_ids: List[str] = None, 66 | animate: bool = None, 67 | max_zoom: float = None, 68 | padding: float = None, 69 | ): 70 | """Pan/Zoom the Diagram View to focus on particular model ids 71 | 72 | :param model_ids: list of elk model id strings, defaults to None 73 | :param animate: specify is the view animates to the given marks 74 | :param max_zoom: specify if the max zoom level 75 | :param padding: specify if the viewport padding around the marks 76 | """ 77 | self.send({ 78 | "action": "fit", 79 | "model_id": model_ids, 80 | "animate": True if animate is None else animate, 81 | "max_zoom": max_zoom, 82 | "padding": padding, 83 | }) 84 | 85 | @T.default("fit_tool") 86 | def _default_fit_tool(self) -> FitTool: 87 | return FitTool(handler=lambda *_: self.fit(model_ids=self.selection.ids)) 88 | 89 | @T.default("center_tool") 90 | def _default_center_tool(self) -> CenterTool: 91 | return CenterTool(handler=lambda *_: self.center(model_ids=self.selection.ids)) 92 | -------------------------------------------------------------------------------- /src/ipyelk/contrib/molds/connectors.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 ipyelk contributors. 2 | # Distributed under the terms of the Modified BSD License. 3 | from ...elements import Node, NodeProperties 4 | 5 | # from ...diagram.shapes.shapes import Circle, Path, Point 6 | from ...elements.shapes import Circle, Path, Point 7 | from ...elements.symbol import EndpointSymbol 8 | 9 | 10 | def Rhomb(identifier: str, r: float = 6) -> EndpointSymbol: 11 | return EndpointSymbol( 12 | identifier=identifier, 13 | element=Node( 14 | properties=NodeProperties( 15 | shape=Path.from_list( 16 | [ 17 | (0, 0), 18 | (r, r / 2), 19 | (2 * r, 0), 20 | (r, -r / 2), 21 | ], 22 | closed=True, 23 | ) 24 | ), 25 | ), 26 | symbol_offset=Point(x=-1, y=0), 27 | path_offset=Point(x=-2 * r, y=0), 28 | ) 29 | 30 | 31 | def Containment(identifier: str, r=6) -> EndpointSymbol: 32 | return EndpointSymbol( 33 | identifier=identifier, 34 | element=Node( 35 | children=[ 36 | Node( 37 | properties=NodeProperties( 38 | shape=Circle( 39 | radius=r, 40 | x=r, 41 | y=0, 42 | ) 43 | ) 44 | ), 45 | Node( 46 | properties=NodeProperties( 47 | shape=Path.from_list([(0, 0), (2 * r, 0)]), 48 | ) 49 | ), 50 | Node( 51 | properties=NodeProperties( 52 | shape=Path.from_list([(r, -r), (r, r)]), 53 | ) 54 | ), 55 | ] 56 | ), 57 | symbol_offset=Point(x=-1, y=0), 58 | path_offset=Point(x=-2 * r, y=0), 59 | ) 60 | 61 | 62 | def StraightArrow(identifier: str, r=6, closed=False) -> EndpointSymbol: 63 | return EndpointSymbol( 64 | identifier=identifier, 65 | element=Node( 66 | properties=NodeProperties( 67 | shape=Path.from_list([(r, -r), (0, 0), (r, r)], closed=closed), 68 | ) 69 | ), 70 | symbol_offset=Point(x=-1, y=0), 71 | path_offset=Point(x=-r - 1, y=0) if closed else Point(x=-1, y=0), 72 | ) 73 | 74 | 75 | def ThinArrow(identifier: str, r=6, closed=False) -> EndpointSymbol: 76 | return EndpointSymbol( 77 | identifier=identifier, 78 | element=Node( 79 | properties=NodeProperties( 80 | shape=Path.from_list([(r, -r / 2), (0, 0), (r, r / 2)], closed=closed), 81 | ) 82 | ), 83 | symbol_offset=Point(x=-1, y=0), 84 | path_offset=Point(x=-r, y=0) if closed else Point(x=-1, y=0), 85 | ) 86 | 87 | 88 | def FatArrow(identifier: str, r=6, closed=False) -> EndpointSymbol: 89 | return EndpointSymbol( 90 | identifier=identifier, 91 | element=Node( 92 | properties=NodeProperties( 93 | shape=Path.from_list([(r / 2, -r), (0, 0), (r / 2, r)], closed=closed), 94 | ) 95 | ), 96 | symbol_offset=Point(x=-1, y=0), 97 | path_offset=Point(x=-r / 2, y=0) if closed else Point(x=-1, y=0), 98 | ) 99 | 100 | 101 | def Space(identifier: str, r=6) -> EndpointSymbol: 102 | return EndpointSymbol( 103 | identifier=identifier, element=Node(), path_offset=Point(x=-r, y=0) 104 | ) 105 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | """documentation for ipyelk""" 2 | 3 | # Copyright (c) 2024 ipyelk contributors. 4 | # Distributed under the terms of the Modified BSD License. 5 | from __future__ import annotations 6 | 7 | import os 8 | import subprocess 9 | from pathlib import Path 10 | from typing import TYPE_CHECKING, Any 11 | 12 | if TYPE_CHECKING: 13 | from docutils import nodes 14 | from sphinx.application import Sphinx 15 | 16 | # our project data 17 | HERE = Path(__file__).parent 18 | ROOT = HERE.parent 19 | 20 | RTD = "READTHEDOCS" 21 | 22 | if os.getenv(RTD) == "True": 23 | # provide a fake root doc 24 | root_doc = "rtd" 25 | 26 | def setup(app: Sphinx) -> None: 27 | """Customize the sphinx build lifecycle in the outer RTD environment.""" 28 | 29 | def _run_pixi(*_args: Any) -> None: 30 | args = ["pixi", "run", "-v", "docs-rtd"] 31 | env = {k: v for k, v in os.environ.items() if k != RTD} 32 | subprocess.check_call(args, env=env, cwd=str(ROOT)) 33 | 34 | app.connect("build-finished", _run_pixi) 35 | 36 | else: 37 | import pypandoc 38 | import tomllib 39 | 40 | PY_PROJ = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8")) 41 | PROJ = PY_PROJ["project"] 42 | 43 | # extensions 44 | extensions = [ 45 | "myst_nb", 46 | # "autodoc_traits", # TODO investigate if can help streamline documentation writing 47 | "sphinx.ext.autosummary", 48 | "sphinx.ext.autodoc", 49 | "sphinx_autodoc_typehints", 50 | "sphinx-jsonschema", 51 | ] 52 | 53 | # meta 54 | project = PROJ["name"] 55 | author = PROJ["authors"][0]["name"] 56 | copyright = f"""2020 {author}""" 57 | release = PROJ["version"] 58 | 59 | # paths 60 | exclude_patterns = [ 61 | "_build", 62 | "Thumbs.db", 63 | ".DS_Store", 64 | ".ipynb_checkpoints", 65 | "rtd.rst", 66 | ] 67 | 68 | # content plugins 69 | autosummary_generate = True 70 | 71 | # theme 72 | html_theme = "pydata_sphinx_theme" 73 | html_logo = "_static/ipyelk.svg" 74 | html_favicon = "_static/favicon.ico" 75 | 76 | html_theme_options = { 77 | "github_url": PROJ["urls"]["Source"], 78 | "use_edit_page_button": True, 79 | "show_toc_level": 1, 80 | } 81 | html_context = { 82 | "github_user": "jupyrdf", 83 | "github_repo": "ipyelk", 84 | "github_version": "master", 85 | "doc_path": "docs", 86 | } 87 | html_static_path = ["_static", "../build/lite"] 88 | 89 | jsonschema_options = { 90 | "auto_reference": True, 91 | "lift_definitions": True, 92 | "lift_description": True, 93 | } 94 | 95 | def setup(app: Sphinx) -> None: 96 | """Customize the sphinx build lifecycle in the inner build environemnt.""" 97 | 98 | def _md_description( 99 | self, schema: dict[str, Any], container: nodes.Node | list[nodes.Node] 100 | ) -> None: 101 | """Convert (simple) markdown descriptions to (simple) rst.""" 102 | description = schema.pop("description", None) 103 | if not description: 104 | return 105 | rst = pypandoc.convert_text(description, "rst", format="md") 106 | if isinstance(container, list): 107 | container.append(self._linme(self._cell(rst))) 108 | else: 109 | self.state.nested_parse( 110 | self._convert_content(rst), self.lineno, container 111 | ) 112 | 113 | wf_cls = __import__("sphinx-jsonschema.wide_format").wide_format.WideFormat 114 | wf_cls._get_description = _md_description 115 | wf_cls._check_description = _md_description 116 | -------------------------------------------------------------------------------- /js/sprotty/update/update-model.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2024 ipyelk contributors. 3 | * Distributed under the terms of the Modified BSD License. 4 | * FIX BELOW FROM: 5 | */ 6 | 7 | /******************************************************************************** 8 | * Copyright (c) 2017-2020 TypeFox and others. 9 | * 10 | * This program and the accompanying materials are made available under the 11 | * terms of the Eclipse Public License v. 2.0 which is available at 12 | * http://www.eclipse.org/legal/epl-2.0. 13 | * 14 | * This Source Code may also be made available under the following Secondary 15 | * Licenses when the conditions for such availability set forth in the Eclipse 16 | * Public License v. 2.0 are satisfied: GNU General Public License, version 2 17 | * with the GNU Classpath Exception which is available at 18 | * https://www.gnu.org/software/classpath/license.html. 19 | * 20 | * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 21 | ********************************************************************************/ 22 | import { injectable } from 'inversify'; 23 | 24 | import { Fadeable } from 'sprotty-protocol'; 25 | 26 | import { 27 | Animation, 28 | CommandExecutionContext, 29 | CompoundAnimation, 30 | MatchResult, 31 | ResolvedElementFade, 32 | SChildElementImpl, 33 | SModelElementImpl, 34 | SModelRootImpl, 35 | SParentElementImpl, 36 | forEachMatch, 37 | isFadeable, 38 | } from 'sprotty'; 39 | import { UpdateAnimationData, UpdateModelCommand } from 'sprotty'; 40 | 41 | import { containsSome } from './smodel-utils'; 42 | 43 | @injectable() 44 | export class UpdateModelCommand2 extends UpdateModelCommand { 45 | protected computeAnimation( 46 | newRoot: SModelRootImpl, 47 | matchResult: MatchResult, 48 | context: CommandExecutionContext, 49 | ): SModelRootImpl | Animation { 50 | const animationData: UpdateAnimationData = { 51 | fades: [] as ResolvedElementFade[], 52 | }; 53 | forEachMatch(matchResult, (id, match) => { 54 | if (match.left != null && match.right != null) { 55 | // The element is still there, but may have been moved 56 | this.updateElement( 57 | match.left as SModelElementImpl, 58 | match.right as SModelElementImpl, 59 | animationData, 60 | ); 61 | } else if (match.right != null) { 62 | // An element has been added 63 | const right = match.right as SModelElementImpl; 64 | if (isFadeable(right)) { 65 | right.opacity = 0; 66 | animationData.fades.push({ 67 | element: right, 68 | type: 'in', 69 | }); 70 | } 71 | } else if (match.left instanceof SChildElementImpl) { 72 | // An element has been removed 73 | const left = match.left; 74 | if (isFadeable(left) && match.leftParentId != null) { 75 | if (!containsSome(newRoot, left)) { 76 | const parent = newRoot.index.getById(match.leftParentId); 77 | if (parent instanceof SParentElementImpl) { 78 | const leftCopy = context.modelFactory.createElement( 79 | left, 80 | ) as SChildElementImpl & Fadeable; 81 | parent.add(leftCopy); 82 | animationData.fades.push({ 83 | element: leftCopy, 84 | type: 'out', 85 | }); 86 | } 87 | } 88 | } 89 | } 90 | }); 91 | 92 | const animations = this.createAnimations(animationData, newRoot, context); 93 | if (animations.length >= 2) { 94 | return new CompoundAnimation(newRoot, context, animations); 95 | } else if (animations.length === 1) { 96 | return animations[0]; 97 | } else { 98 | return newRoot; 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /style/diagram.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2024 ipyelk contributors. 3 | * Distributed under the terms of the Modified BSD License. 4 | */ 5 | 6 | /* 7 | CSS for in-DOM or standalone viewing: all selectors should tolerate having 8 | `.jp-ElkView` stripped. 9 | */ 10 | :root { 11 | --jp-elk-stroke-width: 1; 12 | 13 | --jp-elk-node-fill: var(--jp-layout-color1); 14 | --jp-elk-node-stroke: var(--jp-border-color0); 15 | 16 | --jp-elk-edge-stroke: var(--jp-border-color0); 17 | 18 | --jp-elk-port-fill: var(--jp-layout-color1); 19 | --jp-elk-port-stroke: var(--jp-border-color0); 20 | 21 | --jp-elk-label-color: var(--jp-ui-font-color0); 22 | --jp-elk-label-font: var(--jp-content-font-family); 23 | --jp-elk-label-font-size: var(--jp-ui-font-size0); 24 | 25 | /* stable states */ 26 | --jp-elk-color-selected: var(--jp-brand-color2); 27 | --jp-elk-stroke-width-selected: 3; 28 | 29 | /* interactive states */ 30 | --jp-elk-stroke-hover: var(--jp-brand-color3); 31 | --jp-elk-stroke-width-hover: 2; 32 | 33 | --jp-elk-stroke-hover-selected: var(--jp-warn-color3); 34 | 35 | /* sugar */ 36 | --jp-elk-transition: 0.1s ease-in; 37 | } 38 | 39 | /* firefox doesnt apply style correctly with the addition of .jp-ElkView */ 40 | symbol.elksymbol { 41 | overflow: visible; 42 | } 43 | 44 | .jp-ElkView .elknode { 45 | stroke: var(--jp-elk-node-stroke); 46 | stroke-width: var(--jp-elk-stroke-width); 47 | fill: var(--jp-elk-node-fill); 48 | } 49 | 50 | .jp-ElkView .elkport { 51 | stroke: var(--jp-elk-port-stroke); 52 | stroke-width: var(--jp-elk-stroke-width); 53 | fill: var(--jp-elk-port-fill); 54 | } 55 | 56 | .jp-ElkView .elkedge { 57 | fill: none; 58 | stroke: var(--jp-elk-edge-stroke); 59 | stroke-width: var(--jp-elk-stroke-width); 60 | } 61 | 62 | .jp-ElkView .elklabel { 63 | stroke-width: 0; 64 | stroke: var(--jp-elk-label-color); 65 | fill: var(--jp-elk-label-color); 66 | font-family: var(--jp-elk-label-font); 67 | font-size: var(--jp-elk-label-font-size); 68 | dominant-baseline: hanging; 69 | } 70 | 71 | .jp-ElkView .elkjunction { 72 | stroke: none; 73 | fill: var(--jp-elk-edge-stroke); 74 | } 75 | 76 | /* stable states */ 77 | .jp-ElkView .elknode.selected, 78 | .jp-ElkView .elkport.selected, 79 | .jp-ElkView .elkedge.selected, 80 | .jp-ElkView .elkedge.selected .elkarrow { 81 | stroke: var(--jp-elk-color-selected); 82 | stroke-width: var(--jp-elk-stroke-width-selected); 83 | transition: stroke stroke-width var(--jp-elk-transition); 84 | } 85 | 86 | .jp-ElkView .elklabel.selected { 87 | fill: var(--jp-elk-color-selected); 88 | transition: fill var(--jp-elk-transition); 89 | } 90 | 91 | /* interactive states: elklabel does not have a mouseover selector/ancestor */ 92 | .jp-ElkView .elknode.mouseover, 93 | .jp-ElkView .elkport.mouseover, 94 | .jp-ElkView .elkedge.mouseover { 95 | stroke: var(--jp-elk-stroke-hover); 96 | stroke-width: var(--jp-elk-stroke-width-hover); 97 | transition: stroke stroke-width var(--jp-elk-transition); 98 | } 99 | 100 | .jp-ElkView .elklabel.mouseover { 101 | fill: var(--jp-elk-stroke-hover); 102 | transition: fill stroke var(--jp-elk-transition); 103 | } 104 | 105 | .jp-ElkView .elknode.selected.mouseover, 106 | .jp-ElkView .elkport.selected.mouseover, 107 | .jp-ElkView .elkedge.selected.mouseover, 108 | .jp-ElkView .elkedge.selected.mouseover .elkarrow { 109 | stroke-width: var(--jp-elk-stroke-width-hover); 110 | stroke: var(--jp-elk-stroke-hover-selected); 111 | transition: fill stroke var(--jp-elk-transition); 112 | } 113 | 114 | .jp-ElkView .elklabel.selected.mouseover { 115 | fill: var(--jp-elk-stroke-hover-selected); 116 | transition: fill stroke var(--jp-elk-transition); 117 | } 118 | 119 | .elkcontainer.widget { 120 | overflow: hidden; 121 | } 122 | 123 | .elkcontainer.widget .jupyter-widgets { 124 | transition: transform 0.5s; 125 | } 126 | -------------------------------------------------------------------------------- /js/sprotty/views/base.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx svg */ 2 | import { VNode } from 'snabbdom'; 3 | 4 | import { injectable } from 'inversify'; 5 | 6 | import { Bounds, Dimension, Hoverable, Selectable } from 'sprotty-protocol'; 7 | 8 | import { 9 | IView, 10 | IViewArgs, 11 | InternalBoundsAware, 12 | SChildElementImpl, 13 | SNodeImpl, 14 | SPortImpl, 15 | SShapeElementImpl, 16 | getAbsoluteBounds, 17 | svg, 18 | } from 'sprotty'; 19 | 20 | import { ElkModelRenderer } from '../renderer'; 21 | 22 | export function validCanvasBounds(bounds: Bounds): boolean { 23 | return bounds.width == 0 && bounds.height == 0; 24 | } 25 | 26 | @injectable() 27 | export abstract class ShapeView implements IView { 28 | /** 29 | * Check whether the given model element is in the current viewport. Use this method 30 | * in your `render` implementation to skip rendering in case the element is not visible. 31 | * This can greatly enhance performance for large models. 32 | */ 33 | isVisible( 34 | model: Readonly, 35 | context: ElkModelRenderer, 36 | ): boolean { 37 | if (context.targetKind === 'hidden') { 38 | // Don't hide any element for hidden rendering 39 | return true; 40 | } 41 | if (!Dimension.isValid(model.bounds)) { 42 | // We should hide only if we know the element's bounds 43 | return true; 44 | } 45 | 46 | const canvasBounds = model.root.canvasBounds; 47 | if (!validCanvasBounds(canvasBounds)) { 48 | // only hide if the canvas's size is set 49 | return true; 50 | } 51 | 52 | const ab = getAbsoluteBounds(model); 53 | return ( 54 | ab.x <= canvasBounds.width && 55 | ab.x + ab.width >= 0 && 56 | ab.y <= canvasBounds.height && 57 | ab.y + ab.height >= 0 58 | ); 59 | } 60 | 61 | abstract render( 62 | model: Readonly, 63 | context: ElkModelRenderer, 64 | args?: IViewArgs, 65 | ): VNode | undefined; 66 | } 67 | 68 | @injectable() 69 | export class CircularNodeView extends ShapeView { 70 | render( 71 | node: Readonly, 72 | context: ElkModelRenderer, 73 | args?: IViewArgs, 74 | ): VNode | undefined { 75 | if (!this.isVisible(node, context)) { 76 | return undefined; 77 | } 78 | const radius = this.getRadius(node); 79 | return ( 80 | 81 | 90 | {context.renderChildren(node)} 91 | 92 | ); 93 | } 94 | 95 | protected getRadius(node: SShapeElementImpl): number { 96 | const d = Math.min(node.size.width, node.size.height); 97 | return d > 0 ? d / 2 : 0; 98 | } 99 | } 100 | 101 | @injectable() 102 | export class RectangularNodeView extends ShapeView { 103 | render( 104 | node: Readonly, 105 | context: ElkModelRenderer, 106 | args?: IViewArgs, 107 | ): VNode | undefined { 108 | if (!this.isVisible(node, context)) { 109 | return undefined; 110 | } 111 | return ( 112 | 113 | 123 | {context.renderChildren(node)} 124 | 125 | ); 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/ipyelk/elements/mark_factory.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 ipyelk contributors. 2 | # Distributed under the terms of the Modified BSD License. 3 | from typing import Any, Optional 4 | 5 | import networkx as nx 6 | from pydantic.v1 import BaseModel, Field 7 | 8 | from .elements import BaseElement, Edge, Node 9 | from .registry import Registry 10 | 11 | 12 | class Mark(BaseModel): 13 | """Wrap the given node in another tuple so it can be used multiple times as 14 | a networkx node. 15 | 16 | :param node: Incoming Element to wrap 17 | :return: Tuple that describes this current node and context to be used in a 18 | networkx graph. 19 | """ 20 | 21 | element: BaseElement = Field(...) 22 | context: Registry = Field(...) 23 | selector: Optional[Any] = Field(None, exclude=True) 24 | 25 | def __hash__(self): 26 | return hash((id(self.element), id(self.context))) 27 | 28 | def __eq__(self, other): 29 | return hash(self) == hash(other) 30 | 31 | def dict(self, **kwargs): 32 | with self.context: 33 | return self.element.dict(**kwargs) 34 | 35 | def get_selector(self): 36 | if isinstance(self.element, Edge): 37 | if self.selector is None: 38 | raise ValueError("Edge Selector not set") 39 | return self.selector 40 | return self 41 | 42 | def set_edge_selector(self, u, v, key): 43 | self.selector = (u, v, key) 44 | 45 | def get_id(self): 46 | with self.context: 47 | return self.element.get_id() 48 | 49 | 50 | class MarkFactory(BaseModel): 51 | registry: Registry = Field(default_factory=Registry) 52 | 53 | def _add( 54 | self, node: Node, g: nx.Graph, tree: nx.DiGraph, follow_edges: bool 55 | ) -> Mark: 56 | context = self.registry 57 | with context: 58 | nx_node = Mark(element=node, context=context) 59 | if nx_node not in g: 60 | g.add_node( 61 | nx_node, 62 | mark=nx_node, 63 | elkjson=node.dict(exclude={"children", "edges", "parent"}), 64 | ) 65 | 66 | for child in get_children(node): 67 | nx_child = self._add(child, g, tree, follow_edges=follow_edges) 68 | tree.add_edge(nx_node, nx_child) 69 | 70 | for edge in node.edges: 71 | endpts = edge.points() 72 | nx_u, nx_v = map(lambda n: Mark(element=n, context=context), endpts) 73 | for nx_pt, pt in zip([nx_u, nx_v], endpts): 74 | if nx_pt not in g: 75 | if follow_edges: 76 | self._add(pt, g, tree, follow_edges=follow_edges) 77 | else: 78 | g.add_node( 79 | nx_pt, 80 | mark=nx_pt, 81 | elkjson=pt.dict( 82 | exclude={"children", "edges", "parent"} 83 | ), 84 | ) 85 | 86 | assert isinstance(edge, Edge), f"Expected Edge type not {type(edge)}" 87 | mark = Mark(element=edge, context=context) 88 | key = g.add_edge( 89 | nx_u, 90 | nx_v, 91 | mark=mark, 92 | elkjson=edge.dict(), 93 | ) 94 | mark.set_edge_selector(nx_u, nx_v, key) 95 | return nx_node 96 | 97 | def __call__(self, *nodes, follow_edges=True): 98 | g = nx.MultiDiGraph() 99 | tree = nx.DiGraph() 100 | for node in nodes: 101 | self._add(node, g, tree, follow_edges=follow_edges) 102 | return (g, tree) 103 | 104 | 105 | def get_children(node: Node) -> Node: 106 | return getattr(node, "children", []) 107 | --------------------------------------------------------------------------------