├── .github └── workflows │ ├── ci.yml │ └── clean.yml ├── .gitignore ├── .jvmopts ├── .sbtopts ├── .scalafmt.conf ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── RELEASE.md ├── TODO.md ├── build.sbt ├── cats ├── jvm │ └── src │ │ └── test │ │ └── scala │ │ └── com │ │ └── flowtick │ │ └── graphs │ │ └── cat │ │ └── GraphCatsSpec.scala └── shared │ └── src │ └── main │ └── scala │ └── com │ └── flowtick │ └── graphs │ └── cat │ └── package.scala ├── core ├── jvm │ └── src │ │ └── test │ │ └── scala │ │ └── com │ │ └── flowtick │ │ └── graphs │ │ ├── GraphGen.scala │ │ ├── GraphSpec.scala │ │ └── algorithm │ │ ├── BreadthFirstSearchSpec.scala │ │ ├── DepthFirstSearchSpec.scala │ │ ├── DijkstraShortestPathSpec.scala │ │ └── TopologicalSortSpec.scala └── shared │ └── src │ └── main │ └── scala │ └── com │ └── flowtick │ └── graphs │ ├── Graph.scala │ ├── algorithm │ ├── BreadthFirstTraversal.scala │ ├── DepthFirstTraversal.scala │ ├── DijkstraShortestPath.scala │ ├── TopologicalSort.scala │ ├── Traversal.scala │ └── package.scala │ ├── defaults │ └── package.scala │ └── util │ └── MathUtil.scala ├── docs └── src │ └── main │ └── paradox │ ├── algorithms.md │ ├── cats.md │ ├── creating-graphs.md │ ├── editor.md │ ├── graphml.md │ ├── index.md │ ├── json.md │ ├── layout.md │ ├── monoid-example.graphml │ ├── monoid-example.png │ └── setup.md ├── editor ├── dist │ ├── config.json │ └── editor.html ├── js │ └── src │ │ └── main │ │ └── scala │ │ └── com │ │ └── flowtick │ │ └── graphs │ │ └── editor │ │ ├── EditorInstanceJs.scala │ │ ├── EditorMainJs.scala │ │ ├── EditorMenuJs.scala │ │ ├── EditorPageJs.scala │ │ ├── EditorPaletteJs.scala │ │ ├── EditorPropertiesHtml.scala │ │ ├── EditorPropertiesJs.scala │ │ ├── EditorViewJs.scala │ │ ├── vendor │ │ ├── Ace.scala │ │ ├── Mousetrap.scala │ │ └── SVGUtil.scala │ │ └── view │ │ └── SVGRendererJs.scala ├── jvm │ └── src │ │ ├── main │ │ ├── resources │ │ │ ├── 2B1C_color.png │ │ │ ├── log4j2.xml │ │ │ └── style.css │ │ └── scala │ │ │ └── com │ │ │ └── flowtick │ │ │ └── graphs │ │ │ └── editor │ │ │ ├── EditorGraphNodeFx.scala │ │ │ ├── EditorGraphPane.scala │ │ │ ├── EditorMainJvm.scala │ │ │ ├── EditorMenuJavaFx.scala │ │ │ ├── EditorPaletteJavaFx.scala │ │ │ ├── EditorPropertiesJavaFx.scala │ │ │ ├── EditorViewJavaFx.scala │ │ │ ├── ImageLoaderFx.scala │ │ │ └── util │ │ │ └── DragResizer.scala │ │ └── test │ │ ├── resources │ │ └── log4j2.xml │ │ └── scala │ │ └── com │ │ └── flowtick │ │ └── graphs │ │ ├── EditorBaseSpec.scala │ │ ├── EditorPropertiesSpec.scala │ │ ├── EditorViewSpec.scala │ │ ├── MathUtilSpec.scala │ │ ├── ModelUpdateFeatureSpec.scala │ │ └── RoutingFeatureSpec.scala └── shared │ └── src │ └── main │ └── scala │ └── com │ └── flowtick │ └── graphs │ └── editor │ ├── EditorCommand.scala │ ├── EditorConfiguration.scala │ ├── EditorGraph.scala │ ├── EditorGraphJsonFormat.scala │ ├── EditorImageLoader.scala │ ├── EditorMain.scala │ ├── EditorMenuSpec.scala │ ├── EditorMessageBus.scala │ ├── EditorModel.scala │ ├── EditorPalette.scala │ ├── EditorProperties.scala │ ├── EditorSchema.scala │ ├── EditorView.scala │ ├── ImageLoader.scala │ └── feature │ ├── ModelUpdateFeature.scala │ ├── PaletteFeature.scala │ ├── RoutingFeature.scala │ └── UndoFeature.scala ├── examples ├── html │ └── examples.html ├── js │ └── src │ │ └── main │ │ └── scala │ │ └── examples │ │ └── ExamplesJs.scala ├── jvm │ └── src │ │ └── main │ │ └── scala │ │ └── ExamplesJvm.scala └── shared │ └── src │ └── main │ └── scala │ └── examples │ ├── BfsExample.scala │ ├── CatsExample.scala │ ├── CustomGraphExample.scala │ ├── DfsExample.scala │ ├── DijkstraExample.scala │ ├── GraphMLExample.scala │ ├── JsonExample.scala │ ├── LayoutExample.scala │ ├── SimpleGraphExample.scala │ └── TopologicalSortingExample.scala ├── graphml ├── js │ └── src │ │ └── test │ │ └── scala │ │ └── com │ │ └── flowtick │ │ └── graphs │ │ └── JsGraphMLSerializationSpec.scala ├── jvm │ └── src │ │ └── test │ │ ├── resources │ │ ├── image_node.graphml │ │ ├── test.graphml │ │ └── yed-cities.graphml │ │ └── scala │ │ └── com │ │ └── flowtick │ │ └── graphs │ │ └── graphml │ │ ├── GraphMLDatatypeSpec.scala │ │ └── GraphMLNodeDatatypeSpec.scala └── shared │ └── src │ └── main │ └── scala │ └── com │ └── flowtick │ └── graphs │ └── graphml │ ├── GraphMLDatatype.scala │ ├── GraphMLEdgeDatatype.scala │ ├── GraphMLGraph.scala │ ├── GraphMLNodeDatatype.scala │ └── package.scala ├── json ├── jvm │ └── src │ │ └── test │ │ └── scala │ │ └── com │ │ └── flowtick │ │ └── graphs │ │ └── json │ │ ├── JsonExporterSpec.scala │ │ ├── JsonSpec.scala │ │ └── schema │ │ └── JsonSchemaSpec.scala └── shared │ └── src │ └── main │ └── scala │ └── com │ └── flowtick │ └── graphs │ └── json │ ├── JsonExporter.scala │ ├── package.scala │ └── schema │ └── JsonSchema.scala ├── layout-elk ├── js │ └── src │ │ └── main │ │ └── scala │ │ └── com │ │ └── flowtick │ │ └── graphs │ │ └── layout │ │ └── elk │ │ ├── ELK.scala │ │ └── ELkLayoutOpsJS.scala └── jvm │ └── src │ ├── main │ └── scala │ │ └── com │ │ └── flowtick │ │ └── graphs │ │ └── layout │ │ └── elk │ │ └── ELkLayoutJVM.scala │ └── test │ └── scala │ └── com │ └── flowtick │ └── graphs │ └── layout │ └── elk │ └── ELkLayoutJVMSpec.scala ├── layout ├── jvm │ └── src │ │ └── test │ │ └── scala │ │ └── com │ │ └── flowtick │ │ └── graphs │ │ └── layout │ │ └── ForceDirectedLayoutJVMSpec.scala └── shared │ └── src │ └── main │ └── scala │ └── com │ └── flowtick │ └── graphs │ └── layout │ ├── ForceDirectedLayout.scala │ └── GraphLayoutOps.scala ├── project ├── build.properties └── plugins.sbt ├── style └── shared │ └── src │ └── main │ └── scala │ └── com │ └── flowtick │ └── graphs │ └── style │ ├── StyleSheet.scala │ └── package.scala └── view ├── jvm └── src │ ├── main │ └── scala │ │ └── com │ │ └── flowtick │ │ └── graphs │ │ └── view │ │ ├── SVGRendererJvm.scala │ │ └── SVGTranscoder.scala │ └── test │ └── scala │ └── com │ └── flowtick │ └── graphs │ └── view │ └── SVGRendererJvmSpec.scala └── shared └── src └── main └── scala └── com └── flowtick └── graphs └── view ├── GraphView.scala ├── SVGPage.scala ├── SVGRenderer.scala ├── ViewComponent.scala └── util └── DrawUtil.scala /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # This file was automatically generated by sbt-github-actions using the 2 | # githubWorkflowGenerate task. You should add and commit this file to 3 | # your git repository. It goes without saying that you shouldn't edit 4 | # this file by hand! Instead, if you wish to make changes, you should 5 | # change your sbt build configuration to revise the workflow description 6 | # to meet your needs, then regenerate this file. 7 | 8 | name: Continuous Integration 9 | 10 | on: 11 | pull_request: 12 | branches: ['**'] 13 | push: 14 | branches: ['**'] 15 | tags: [v*] 16 | 17 | env: 18 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 19 | 20 | jobs: 21 | build: 22 | name: Build and Test 23 | strategy: 24 | matrix: 25 | os: [ubuntu-latest] 26 | scala: [2.13.6, 2.12.14] 27 | java: [adopt@1.15.0-2] 28 | runs-on: ${{ matrix.os }} 29 | steps: 30 | - name: Checkout current branch (full) 31 | uses: actions/checkout@v2 32 | with: 33 | fetch-depth: 0 34 | 35 | - name: Setup Java and Scala 36 | uses: olafurpg/setup-scala@v13 37 | with: 38 | java-version: ${{ matrix.java }} 39 | 40 | - name: Cache sbt 41 | uses: actions/cache@v2 42 | with: 43 | path: | 44 | ~/.sbt 45 | ~/.ivy2/cache 46 | ~/.coursier/cache/v1 47 | ~/.cache/coursier/v1 48 | ~/AppData/Local/Coursier/Cache/v1 49 | ~/Library/Caches/Coursier/v1 50 | key: ${{ runner.os }}-sbt-cache-v2-${{ hashFiles('**/*.sbt') }}-${{ hashFiles('project/build.properties') }} 51 | 52 | - name: Check that workflows are up to date 53 | run: sbt ++${{ matrix.scala }} githubWorkflowCheck 54 | 55 | - run: sbt ++${{ matrix.scala }} scalafmtCheckAll test docs/makeSite 56 | 57 | - name: Compress target directories 58 | run: tar cf targets.tar layout/jvm/target view/jvm/target editor/jvm/target docs/target layout/js/target json/jvm/target core/js/target examples/js/target graphml/jvm/target style/js/target editor/js/target style/jvm/target layout-elk/js/target core/jvm/target examples/jvm/target view/js/target graphml/js/target layout-elk/jvm/target json/js/target cats/js/target cats/jvm/target target project/target 59 | 60 | - name: Upload target directories 61 | uses: actions/upload-artifact@v2 62 | with: 63 | name: target-${{ matrix.os }}-${{ matrix.scala }}-${{ matrix.java }} 64 | path: targets.tar 65 | 66 | publish: 67 | name: Publish Artifacts 68 | needs: [build] 69 | if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v')) 70 | strategy: 71 | matrix: 72 | os: [ubuntu-latest] 73 | scala: [2.13.6] 74 | java: [adopt@1.15.0-2] 75 | runs-on: ${{ matrix.os }} 76 | steps: 77 | - name: Checkout current branch (full) 78 | uses: actions/checkout@v2 79 | with: 80 | fetch-depth: 0 81 | 82 | - name: Setup Java and Scala 83 | uses: olafurpg/setup-scala@v13 84 | with: 85 | java-version: ${{ matrix.java }} 86 | 87 | - name: Cache sbt 88 | uses: actions/cache@v2 89 | with: 90 | path: | 91 | ~/.sbt 92 | ~/.ivy2/cache 93 | ~/.coursier/cache/v1 94 | ~/.cache/coursier/v1 95 | ~/AppData/Local/Coursier/Cache/v1 96 | ~/Library/Caches/Coursier/v1 97 | key: ${{ runner.os }}-sbt-cache-v2-${{ hashFiles('**/*.sbt') }}-${{ hashFiles('project/build.properties') }} 98 | 99 | - name: Download target directories (2.13.6) 100 | uses: actions/download-artifact@v2 101 | with: 102 | name: target-${{ matrix.os }}-2.13.6-${{ matrix.java }} 103 | 104 | - name: Inflate target directories (2.13.6) 105 | run: | 106 | tar xf targets.tar 107 | rm targets.tar 108 | 109 | - name: Download target directories (2.12.14) 110 | uses: actions/download-artifact@v2 111 | with: 112 | name: target-${{ matrix.os }}-2.12.14-${{ matrix.java }} 113 | 114 | - name: Inflate target directories (2.12.14) 115 | run: | 116 | tar xf targets.tar 117 | rm targets.tar 118 | 119 | - env: 120 | PGP_PASSPHRASE: ${{ secrets.PGP_PASSPHRASE }} 121 | PGP_SECRET: ${{ secrets.PGP_SECRET }} 122 | SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }} 123 | SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }} 124 | run: sbt ++${{ matrix.scala }} ci-release 125 | -------------------------------------------------------------------------------- /.github/workflows/clean.yml: -------------------------------------------------------------------------------- 1 | # This file was automatically generated by sbt-github-actions using the 2 | # githubWorkflowGenerate task. You should add and commit this file to 3 | # your git repository. It goes without saying that you shouldn't edit 4 | # this file by hand! Instead, if you wish to make changes, you should 5 | # change your sbt build configuration to revise the workflow description 6 | # to meet your needs, then regenerate this file. 7 | 8 | name: Clean 9 | 10 | on: push 11 | 12 | jobs: 13 | delete-artifacts: 14 | name: Delete Artifacts 15 | runs-on: ubuntu-latest 16 | env: 17 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 18 | steps: 19 | - name: Delete artifacts 20 | run: | 21 | # Customize those three lines with your repository and credentials: 22 | REPO=${GITHUB_API_URL}/repos/${{ github.repository }} 23 | 24 | # A shortcut to call GitHub API. 25 | ghapi() { curl --silent --location --user _:$GITHUB_TOKEN "$@"; } 26 | 27 | # A temporary file which receives HTTP response headers. 28 | TMPFILE=/tmp/tmp.$$ 29 | 30 | # An associative array, key: artifact name, value: number of artifacts of that name. 31 | declare -A ARTCOUNT 32 | 33 | # Process all artifacts on this repository, loop on returned "pages". 34 | URL=$REPO/actions/artifacts 35 | while [[ -n "$URL" ]]; do 36 | 37 | # Get current page, get response headers in a temporary file. 38 | JSON=$(ghapi --dump-header $TMPFILE "$URL") 39 | 40 | # Get URL of next page. Will be empty if we are at the last page. 41 | URL=$(grep '^Link:' "$TMPFILE" | tr ',' '\n' | grep 'rel="next"' | head -1 | sed -e 's/.*.*//') 42 | rm -f $TMPFILE 43 | 44 | # Number of artifacts on this page: 45 | COUNT=$(( $(jq <<<$JSON -r '.artifacts | length') )) 46 | 47 | # Loop on all artifacts on this page. 48 | for ((i=0; $i < $COUNT; i++)); do 49 | 50 | # Get name of artifact and count instances of this name. 51 | name=$(jq <<<$JSON -r ".artifacts[$i].name?") 52 | ARTCOUNT[$name]=$(( $(( ${ARTCOUNT[$name]} )) + 1)) 53 | 54 | id=$(jq <<<$JSON -r ".artifacts[$i].id?") 55 | size=$(( $(jq <<<$JSON -r ".artifacts[$i].size_in_bytes?") )) 56 | printf "Deleting '%s' #%d, %'d bytes\n" $name ${ARTCOUNT[$name]} $size 57 | ghapi -X DELETE $REPO/actions/artifacts/$id 58 | done 59 | done 60 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | *.iml 3 | .idea 4 | .ensime 5 | .ensime_cache 6 | .bloop 7 | .metals 8 | ensime-langserver.log 9 | pc.stdout.log 10 | metals.sbt 11 | /editor/dist/app.js 12 | /editor/dist/app.js.map 13 | /.vscode/ 14 | /.bsp/sbt.json 15 | -------------------------------------------------------------------------------- /.jvmopts: -------------------------------------------------------------------------------- 1 | -Xss8M 2 | -Xmx2G 3 | -XX:+UseG1GC -------------------------------------------------------------------------------- /.sbtopts: -------------------------------------------------------------------------------- 1 | -J-Xmx4G 2 | -J-XX:MaxMetaspaceSize=1G 3 | -J-XX:MaxPermSize=1G 4 | -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | version = 3.0.3 2 | maxColumn = 100 3 | runner.dialect = "scala213" 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.watcherExclude": { 3 | "**/target": true 4 | } 5 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![latest release](https://img.shields.io/maven-central/v/com.flowtick/graphs-core_2.12.svg?label=Maven%20Central)](https://search.maven.org/search?q=g:com.flowtick%20AND%20a:graphs*) 2 | 3 | # ℹ️ graphs has moved to https://codeberg.org/unkonstant/graphs under a new license ℹ️ 4 | 5 | # graphs 6 | 7 | `graphs` is a simple [graph](https://en.wikipedia.org/wiki/Graph_theory) library for Scala 8 | 9 | # Example 10 | 11 | @@snip [SimpleGraphApp.scala](examples/shared/src/main/scala/examples/SimpleGraphExample.scala) { #simple_graph } 12 | 13 | # Documentation 14 | 15 | Please check the [guide](https://flowtick.github.io/graphs) and the 16 | [API docs](https://flowtick.github.io/graphs/latest/api/com/flowtick/graphs) 17 | 18 | # Features 19 | * Simple graph creation API 20 | * Depth-first traversal / search 21 | * Breadth-first traversal / search 22 | * Topological sorting 23 | * Dijkstras algorithm for shortest paths 24 | * GraphML import / export (experimental) 25 | * force based layout (planned) 26 | * cross compiled for Scala 2.12, 2.13, Scala.js 27 | 28 | # Motivation 29 | 30 | `graphs` was created to explore different type encoding for graphs and implement well-known algorithms 31 | 32 | ## Goals 33 | * Support [Scala.js](https://www.scala-js.org) 34 | * Support [GraphML](https://de.wikipedia.org/wiki/GraphML) 35 | * Usages of the library and the core interfaces should be intuitive 36 | * The codebase should follow current idioms and integrate with mainstream libraries for Scala 37 | 38 | ## Non-Goals 39 | * Support all possible graph types / scenarios 40 | * Provide a purely functional library 41 | 42 | ## Alternatives 43 | 44 | [Graph for Scala](http://scala-graph.org) is probably the most established graph library for Scala and supports many kinds of graphs 45 | explicitly (custom syntax etc.) with a big variety of algorithms and extensions (json, dot support). 46 | 47 | Its still being worked on and recently added support for Scala.js. It might have a steeper learning curve but is more 48 | battle-tested and powerful then `graphs`. 49 | 50 | [quiver](https://github.com/Verizon/quiver) follows [Martin Erwigs Functions Graph Library](http://web.engr.oregonstate.edu/~erwig/fgl/haskell) 51 | and appears to be more or less abandoned. Its less focused on algorithms but provides a more functional perspective 52 | on graphs. 53 | 54 | `graphs` is inspired and influenced by both libraries. Please check them out to see if they fit your use case 55 | and preferences better. 56 | 57 | # License 58 | 59 | graphs is published under the terms of the Apache 2.0 License. See the [LICENSE](LICENSE) file. 60 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | How to cut a release 2 | ==================== 3 | 4 | graphs is using sbt-ci-release to publish artifacts. 5 | To create a new release simply create a tag and push it. 6 | 7 | ## Web 8 | 9 | Create a new tag/release here: https://github.com/flowtick/graphs/releases/new 10 | 11 | ## Git 12 | 13 | ``` 14 | git tag -a v0.1.0 -m "v0.1.0" 15 | git push origin v0.1.0 16 | ``` 17 | 18 | ## Manually 19 | 20 | Run 21 | 22 | sbt "+release" 23 | 24 | This needs valid credentials for the sonatype plugin: 25 | 26 | ``` 27 | $HOME/.sbt/(sbt-version 0.13 or 1.0)/sonatype.sbt 28 | ``` 29 | 30 | should contain 31 | 32 | ``` 33 | credentials += Credentials("Sonatype Nexus Repository Manager", 34 | "oss.sonatype.org", 35 | "(Sonatype user name)", 36 | "(Sonatype password)") 37 | ``` 38 | 39 | The cross release will sometimes enter a loop, just exist after the push. 40 | 41 | Update Docs 42 | =========== 43 | 44 | ``` 45 | git checkout v # not needed if your branch is even with the tag 46 | sbt editorJS/fullOptJS # to create the editor js app in 'editor/dist' 47 | sbt docs/ghpagesPushSite 48 | ``` 49 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | Ideas 2 | ===== 3 | 4 | - [x] XML value serialization using the approach from https://stackoverflow.com/questions/29757464/how-to-shapeless-case-classes-with-attributes-and-typeclasses 5 | - [x] JSON support via circe 6 | - [ ] Editor 7 | - [x] Basic Edge routing 8 | - [x] Delete Nodes 9 | - [x] Selectable Edges 10 | - [x] Delete Edges 11 | - [x] JSON export / import 12 | - [x] Basic Palette 13 | - [x] Set Node data json in properties 14 | - [x] Basic Schema support for properties 15 | - [x] Render External Labels 16 | - [x] Basic Label Edit 17 | - [x] Render Edge Label (simple free model) 18 | - [x] Editor hints in schema 19 | - [ ] Duplicate Selection 20 | - [ ] Undo 21 | - [ ] multi-select 22 | - [ ] Containers 23 | - [ ] Layout using elkjs 24 | - [ ] Better Label placement 25 | - [ ] More style properties 26 | - [ ] Snap to elements 27 | - [ ] copy and paste 28 | - [ ] Manhattan Routing 29 | - [ ] Better Palette (search, layout) 30 | 31 | Issues 32 | ====== 33 | 34 | from JavaFX: https://bugs.openjdk.java.net/browse/JDK-8251240 35 | workaround for that: `-Djdk.gtk.version=2` 36 | -------------------------------------------------------------------------------- /cats/jvm/src/test/scala/com/flowtick/graphs/cat/GraphCatsSpec.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs.cat 2 | 3 | import com.flowtick.graphs._ 4 | import com.flowtick.graphs.defaults._ 5 | import cats.implicits._ 6 | import org.scalatest.diagrams.Diagrams 7 | import org.scalatest.flatspec.AnyFlatSpec 8 | import org.scalatest.matchers.should.Matchers 9 | 10 | class GraphCatsSpec extends AnyFlatSpec with Matchers with Diagrams { 11 | import com.flowtick.graphs.cat.instances._ 12 | 13 | "Graph Monoid" should "combine graphs" in { 14 | type NumberNodeGraph = Graph[Unit, Int] 15 | import com.flowtick.graphs.defaults.id._ 16 | 17 | val graphA: NumberNodeGraph = 18 | Graph.fromEdges(Set(1 --> 2, 2 --> 3)).withNode(Node.of(10)) 19 | 20 | val graphB: NumberNodeGraph = 21 | Graph.fromEdges(Set(2 --> 3, 4 --> 3, 4 --> 5, 5 --> 1)) 22 | 23 | val combined = graphA |+| graphB 24 | 25 | combined.edges should contain theSameElementsAs Seq( 26 | 1 --> 2, 27 | 2 --> 3, 28 | 4 --> 3, 29 | 4 --> 5, 30 | 5 --> 1 31 | ).flatMap(_.toEdges) 32 | 33 | combined.nodes.map(_.value) should contain theSameElementsAs Seq( 34 | 1, 2, 3, 4, 5, 10 35 | ) 36 | } 37 | 38 | "Graph Applicative" should "map over nodes" in { 39 | val addOne = (x: Int) => x + 1 40 | val timesTwo = (x: Int) => x * 2 41 | 42 | implicit val id: Identifiable[Any] = { 43 | case f if f == addOne => "addOne" 44 | case f if f == timesTwo => "timesTwo" 45 | case other => other.toString 46 | } 47 | 48 | val functionGraph: Graph[Unit, Int => Int] = Graph.fromEdges( 49 | Set(addOne --> timesTwo) 50 | ) 51 | 52 | val numberGraph: Graph[Unit, Int] = Graph.fromEdges[Unit, Int](Set(1 --> 2, 2 --> 3)) 53 | 54 | val applied = GraphApplicative[Unit].ap(functionGraph)(numberGraph) 55 | 56 | val expectedApplied = Graph.fromEdges[Unit, Int]( 57 | Set(2 --> 3, 3 --> 4) ++ Set(2 --> 4, 4 --> 6) 58 | ) 59 | 60 | applied should be(expectedApplied) 61 | 62 | val timesTenGraph = Graph.fromEdges[Unit, Int]( 63 | Set( 64 | 10 --> 20, 65 | 20 --> 30 66 | ) 67 | ) 68 | 69 | numberGraph.map(_ * 10) should be(timesTenGraph) 70 | } 71 | 72 | "Graph Traverse" should "traverse" in { 73 | import com.flowtick.graphs.defaults.id.identifyAny 74 | 75 | val numberGraph: Graph[Unit, Option[Int]] = 76 | Graph.fromEdges[Unit, Int](Set(1 --> 2)).map(value => Some(value * 3)) 77 | 78 | GraphNodeTraverse[Unit].sequence(numberGraph) should be( 79 | Some(Graph.fromEdges[Unit, Int](Set(3 --> 6))) 80 | ) 81 | } 82 | 83 | } 84 | -------------------------------------------------------------------------------- /cats/shared/src/main/scala/com/flowtick/graphs/cat/package.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs 2 | 3 | import cats.{Applicative, Eval, Monoid, Traverse} 4 | 5 | package object cat { 6 | 7 | /** A monoid to combine graphs. 8 | * @param nodeId 9 | * the id for the new nodes 10 | * @tparam E 11 | * edge type 12 | * @tparam N 13 | * node type 14 | */ 15 | class GraphMonoid[E, N](nodeId: Identifiable[N], edgeId: Identifiable[E]) 16 | extends Monoid[Graph[E, N]] { 17 | override def empty: Graph[E, N] = Graph.empty[E, N](nodeId, edgeId) 18 | 19 | override def combine(x: Graph[E, N], y: Graph[E, N]): Graph[E, N] = 20 | (x.edges ++ y.edges) 21 | .foldLeft(Graph.empty[E, N](nodeId, edgeId))(_ withEdge _) 22 | .withNodes(x.nodes ++ y.nodes) 23 | } 24 | 25 | object GraphMonoid { 26 | def apply[E, N](implicit nodeId: Identifiable[N], edgeId: Identifiable[E]) = 27 | new GraphMonoid[E, N](nodeId, edgeId) 28 | } 29 | 30 | class GraphNodeApplicative[E](implicit id: Identifiable[Any]) extends Applicative[Graph[E, *]] { 31 | override def pure[A](x: A): Graph[E, A] = Graph(nodes = Set(Node.of(x)(id))) 32 | 33 | override def ap[A, B](functionGraph: Graph[E, A => B])(graph: Graph[E, A]): Graph[E, B] = 34 | GraphMonoid[E, B].combineAll(graph.nodes.map(node => { 35 | functionGraph.nodes.foldLeft(Graph.empty[E, B]) { case (acc, f) => 36 | graph.outgoing(node.id).foldLeft(acc) { case (result, edge) => 37 | val fromNode = f.value(node.value) 38 | graph 39 | .findNode(edge.to) 40 | .map(existingToNode => { 41 | val toNode = f.value(existingToNode.value) 42 | 43 | result 44 | .addNode(fromNode) 45 | .addNode(toNode) 46 | .withEdgeValue(edge.value, id(fromNode), id(toNode)) 47 | }) 48 | .getOrElse(result) 49 | } 50 | } 51 | })) 52 | } 53 | 54 | object GraphApplicative { 55 | def apply[E](implicit id: Identifiable[Any]): Applicative[Graph[E, *]] = 56 | new GraphNodeApplicative[E] 57 | } 58 | 59 | class GraphNodeTraverse[E](implicit id: Identifiable[Any]) extends Traverse[Graph[E, *]] { 60 | private implicit val ga: Applicative[Graph[E, *]] = GraphApplicative[E] 61 | import cats.implicits._ 62 | 63 | override def traverse[G[_], A, B]( 64 | fa: Graph[E, A] 65 | )(f: A => G[B])(implicit ap: Applicative[G]): G[Graph[E, B]] = { 66 | val applied: Graph[E, G[(String, B)]] = fa.map(a => { 67 | f(a).map(b => (fa.nodeId(a), b)) 68 | }) 69 | 70 | Traverse[List].sequence(applied.nodes.map(_.value).toList).map { nodeListWithId => 71 | { 72 | val nodeMap = nodeListWithId.toMap 73 | Graph 74 | .empty[E, B] 75 | .addNodes(nodeMap.values) 76 | .withEdges(fa.edges.flatMap(edge => { 77 | for { 78 | fromValue <- nodeMap.get(edge.from) 79 | toValue <- nodeMap.get(edge.to) 80 | } yield Edge.of(edge.value, id(fromValue), id(toValue)) 81 | })) 82 | } 83 | } 84 | } 85 | 86 | override def foldLeft[A, B](fa: Graph[E, A], b: B)(f: (B, A) => B): B = 87 | Traverse[List].foldLeft(fa.nodes.map(_.value).toList, b)(f) 88 | override def foldRight[A, B](fa: Graph[E, A], lb: Eval[B])( 89 | f: (A, Eval[B]) => Eval[B] 90 | ): Eval[B] = 91 | Traverse[List].foldRight(fa.nodes.map(_.value).toList, lb)(f) 92 | } 93 | 94 | object GraphNodeTraverse { 95 | def apply[E](implicit id: Identifiable[Any]): Traverse[Graph[E, *]] = new GraphNodeTraverse[E] 96 | } 97 | 98 | trait GraphInstances { 99 | implicit def graphMonoid[E, N](implicit 100 | nodeId: Identifiable[N], 101 | edgeId: Identifiable[E] 102 | ): Monoid[Graph[E, N]] = 103 | GraphMonoid[E, N] 104 | implicit def graphNodeApplicative[E](implicit id: Identifiable[Any]): Applicative[Graph[E, *]] = 105 | GraphApplicative[E] 106 | } 107 | 108 | object instances extends GraphInstances 109 | } 110 | -------------------------------------------------------------------------------- /core/jvm/src/test/scala/com/flowtick/graphs/GraphGen.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs 2 | 3 | import org.scalacheck.Gen 4 | import defaults.id._ 5 | 6 | object GraphGen { 7 | def edgesGen[E, N](implicit 8 | valueGen: Gen[E], 9 | nodeGen: Gen[N] 10 | ): Gen[List[Edge[E]]] = for { 11 | lefts <- Gen.listOf(nodeGen) 12 | rights <- Gen.listOfN(lefts.size, nodeGen) 13 | values <- Gen.listOfN(lefts.size, valueGen) 14 | } yield lefts.zip(rights).zip(values).map { case ((left, right), value) => 15 | Edge.of(value, left.toString, right.toString) 16 | } 17 | 18 | def graphGen[M, E, N](implicit 19 | edgeGen: Gen[E], 20 | nodeGen: Gen[N], 21 | nodesGen: Gen[List[N]], 22 | metaGen: Gen[M] 23 | ): Gen[Graph[E, N]] = for { 24 | nodes <- nodesGen 25 | edges <- edgesGen[E, N] 26 | } yield Graph(edges = edges, nodes = nodes.map(Node.of)) 27 | } 28 | -------------------------------------------------------------------------------- /core/jvm/src/test/scala/com/flowtick/graphs/GraphSpec.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs 2 | 3 | import com.flowtick.graphs.defaults._ 4 | 5 | import org.scalatest.flatspec.AnyFlatSpec 6 | import org.scalatest.matchers.should.Matchers 7 | 8 | class GraphSpec extends AnyFlatSpec with Matchers { 9 | 10 | val testGraph = Graph.fromEdges[Unit, String]( 11 | Seq( 12 | "A" --> "B", 13 | "B" --> "C", 14 | "C" --> "D", 15 | "D" --> "A", 16 | "A" --> "C", 17 | "B" --> "D" 18 | ) 19 | ) 20 | 21 | "Graph" should "provide incoming edges for nodes" in { 22 | val incomingA = testGraph.incoming("A") 23 | incomingA should contain theSameElementsAs List(Edge.unit("D", "A")) 24 | 25 | val incomingB = testGraph.incoming("B") 26 | incomingB should contain theSameElementsAs List(Edge.unit("A", "B")) 27 | 28 | testGraph.incoming("C") should contain theSameElementsAs List( 29 | Edge.unit("B", "C"), 30 | Edge.unit("A", "C") 31 | ) 32 | 33 | testGraph.incoming("D") should contain theSameElementsAs List( 34 | Edge.unit("C", "D"), 35 | Edge.unit("B", "D") 36 | ) 37 | } 38 | 39 | it should "provide outgoing edges for nodes" in { 40 | testGraph.outgoing("A") should contain theSameElementsAs List( 41 | Edge.unit("A", "B"), 42 | Edge.unit("A", "C") 43 | ) 44 | 45 | testGraph.outgoing("B") should contain theSameElementsAs List( 46 | Edge.unit("B", "C"), 47 | Edge.unit("B", "D") 48 | ) 49 | 50 | testGraph.outgoing("C") should contain theSameElementsAs List( 51 | Edge.unit("C", "D") 52 | ) 53 | 54 | testGraph.outgoing("D") should contain theSameElementsAs List( 55 | Edge.unit("D", "A") 56 | ) 57 | } 58 | 59 | it should "get the predecessors for a node" in { 60 | testGraph.predecessors("A").toSet should be(Set(Node.of("D"))) 61 | } 62 | 63 | it should "get the successors for a node" in { 64 | testGraph.successors("A").toSet should be(Set(Node.of("B"), Node.of("C"))) 65 | } 66 | 67 | it should "return the all the nodes of a graph" in { 68 | testGraph.nodes should contain theSameElementsAs (List( 69 | Node.of("A"), 70 | Node.of("B"), 71 | Node.of("C"), 72 | Node.of("D") 73 | )) 74 | } 75 | 76 | it should "return the all the edges of a graph" in { 77 | testGraph.edges should contain theSameElementsAs (List( 78 | "A" --> "B", 79 | "B" --> "C", 80 | "C" --> "D", 81 | "D" --> "A", 82 | "A" --> "C", 83 | "B" --> "D" 84 | ).flatMap(_.toEdges)) 85 | } 86 | 87 | it should "return empty iterable for an empty graph" in { 88 | val emptyGraph = Graph.empty[Unit, Int] 89 | emptyGraph.edges should be(empty) 90 | } 91 | 92 | it should "have nodes after adding an edge" in { 93 | val intGraph = 94 | Graph.empty[Option[Unit], Int].addEdge(None, 1, 2) 95 | intGraph.nodes should contain theSameElementsAs List( 96 | Node("1", 1), 97 | Node("2", 2) 98 | ) 99 | } 100 | 101 | it should "remove nodes" in { 102 | val node1 = 1 103 | val node2 = 2 104 | val node3 = 3 105 | 106 | val intGraph = Graph 107 | .empty[Unit, Int] 108 | .addEdge((), node1, node2) 109 | .addNode(node3) 110 | 111 | intGraph.removeNodeValue(node3) should be( 112 | Graph.empty[Unit, Int].addEdge((), 1, 2) 113 | ) 114 | 115 | val expected = Graph 116 | .empty[Unit, Int] 117 | .withNode(Node.of(2)) 118 | .withNode(Node.of(3)) 119 | 120 | intGraph.removeNodeValue(node1) should be(expected) 121 | } 122 | 123 | it should "remove edges" in { 124 | val intGraph = Graph 125 | .empty[Unit, Int] 126 | .addEdge((), 1, 2) 127 | .addNode(3) 128 | 129 | val expected = Graph 130 | .empty[Unit, Int] 131 | .withNode(Node.of(1)) 132 | .withNode(Node.of(2)) 133 | .withNode(Node.of(3)) 134 | 135 | intGraph.edges.headOption match { 136 | case Some(edge) => intGraph.removeEdge(edge) should be(expected) 137 | case None => fail("edge was not in the graph") 138 | } 139 | } 140 | 141 | } 142 | -------------------------------------------------------------------------------- /core/jvm/src/test/scala/com/flowtick/graphs/algorithm/BreadthFirstSearchSpec.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs.algorithm 2 | 3 | import com.flowtick.graphs.algorithm.Traversal.Step 4 | import com.flowtick.graphs.{Edge, Graph, Node} 5 | import com.flowtick.graphs.defaults._ 6 | import org.scalatest.flatspec.AnyFlatSpec 7 | import org.scalatest.matchers.should.Matchers 8 | 9 | class BreadthFirstSearchSpec extends AnyFlatSpec with Matchers { 10 | "Bfs" should "traverse in breadth first manner" in { 11 | 12 | val graph: Graph[Unit, String] = Graph.fromEdges( 13 | Seq( 14 | "1" --> "2", 15 | "1" --> "3", 16 | "2" --> "4", 17 | "2" --> "5", 18 | "3" --> "6", 19 | "3" --> "7" 20 | ) 21 | ) 22 | 23 | val traversal = graph.bfs("1").run 24 | 25 | val values: Iterable[String] = traversal.collect { case Completed(step, _) => 26 | step.node.value 27 | } 28 | 29 | values should be(List("1", "2", "3", "4", "5", "6", "7")) 30 | 31 | val expected = List( 32 | Visited(Step(Node.of("1"), None, Some(0))), 33 | Visited(Step(Node.of("2"), Some(Edge.unit("1", "2")), Some(1))), 34 | Visited(Step(Node.of("3"), Some(Edge.unit("1", "3")), Some(1))), 35 | Completed(Step(Node.of("1"), None, Some(0))), 36 | Visited(Step(Node.of("4"), Some(Edge.unit("2", "4")), Some(2))), 37 | Visited(Step(Node.of("5"), Some(Edge.unit("2", "5")), Some(2))), 38 | Completed(Step(Node.of("2"), Some(Edge.unit("1", "2")), Some(1))), 39 | Visited(Step(Node.of("6"), Some(Edge.unit("3", "6")), Some(2))), 40 | Visited(Step(Node.of("7"), Some(Edge.unit("3", "7")), Some(2))), 41 | Completed(Step(Node.of("3"), Some(Edge.unit("1", "3")), Some(1))), 42 | Completed(Step(Node.of("4"), Some(Edge.unit("2", "4")), Some(2))), 43 | Completed(Step(Node.of("5"), Some(Edge.unit("2", "5")), Some(2))), 44 | Completed(Step(Node.of("6"), Some(Edge.unit("3", "6")), Some(2))), 45 | Completed(Step(Node.of("7"), Some(Edge.unit("3", "7")), Some(2))) 46 | ) 47 | 48 | traversal.toList should be(expected) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /core/jvm/src/test/scala/com/flowtick/graphs/algorithm/DepthFirstSearchSpec.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs.algorithm 2 | 3 | import com.flowtick.graphs.algorithm.Traversal.Step 4 | import com.flowtick.graphs.defaults._ 5 | import com.flowtick.graphs.{Edge, Graph, Node} 6 | import org.scalatest.flatspec.AnyFlatSpec 7 | import org.scalatest.matchers.should.Matchers 8 | 9 | class DepthFirstSearchSpec extends AnyFlatSpec with Matchers { 10 | val graph: Graph[Unit, String] = Graph.fromEdges( 11 | Seq( 12 | "1" --> "2", 13 | "1" --> "3", 14 | "2" --> "4", 15 | "2" --> "5", 16 | "3" --> "6", 17 | "3" --> "7" 18 | ) 19 | ) 20 | 21 | "Dfs" should "traverse in depth first manner" in { 22 | val traversal = graph.dfs("1").run 23 | 24 | val values: Iterable[String] = traversal.collect { case Visited(step) => 25 | step.node.value 26 | } 27 | 28 | values should be(List("1", "3", "7", "6", "2", "5", "4")) 29 | 30 | val expected = List( 31 | Visited(Step(Node.of("1"), None, Some(0))), 32 | Visited(Step(Node.of("3"), Some(Edge.unit("1", "3")), Some(1))), 33 | Visited(Step(Node.of("7"), Some(Edge.unit("3", "7")), Some(2))), 34 | Completed(Step(Node.of("7"), Some(Edge.unit("3", "7")), Some(2))), 35 | Visited(Step(Node.of("6"), Some(Edge.unit("3", "6")), Some(2))), 36 | Completed(Step(Node.of("6"), Some(Edge.unit("3", "6")), Some(2))), 37 | Completed(Step(Node.of("3"), Some(Edge.unit("1", "3")), Some(1))), 38 | Visited(Step(Node.of("2"), Some(Edge.unit("1", "2")), Some(1))), 39 | Visited(Step(Node.of("5"), Some(Edge.unit("2", "5")), Some(2))), 40 | Completed(Step(Node.of("5"), Some(Edge.unit("2", "5")), Some(2))), 41 | Visited(Step(Node.of("4"), Some(Edge.unit("2", "4")), Some(2))), 42 | Completed(Step(Node.of("4"), Some(Edge.unit("2", "4")), Some(2))), 43 | Completed(Step(Node.of("2"), Some(Edge.unit("1", "2")), Some(1))), 44 | Completed(Step(Node.of("1"), None, Some(0))) 45 | ) 46 | 47 | traversal.toList should be(expected) 48 | } 49 | 50 | it should "traverse graphs with loops" in { 51 | val graph = Graph.fromEdges(Seq("1" ~~~ "2")) 52 | 53 | val traversal = graph.dfs("1").run 54 | 55 | traversal.toList should be( 56 | List( 57 | Visited(Step(Node.of("1"), None, Some(0))), 58 | Visited(Step(Node.of("2"), Some(Edge.unit("1", "2")), Some(1))), 59 | Completed(Step(Node.of("2"), Some(Edge.unit("1", "2")), Some(1))), 60 | Completed( 61 | Step(Node.of("1"), None, Some(0)), 62 | backtrack = Some(Step(Node.of("2"), Some(Edge.unit("2", "1")), Some(1))) 63 | ) 64 | ) 65 | ) 66 | } 67 | 68 | it should "retrieve all paths" in { 69 | graph.paths("1").map(_.steps.map(_.node.id)) should be( 70 | List( 71 | List("1"), 72 | List("1", "2"), 73 | List("1", "2", "4"), 74 | List("1", "2", "5"), 75 | List("1", "3"), 76 | List("1", "3", "6"), 77 | List("1", "3", "7") 78 | ) 79 | ) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /core/jvm/src/test/scala/com/flowtick/graphs/algorithm/DijkstraShortestPathSpec.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs.algorithm 2 | 3 | import com.flowtick.graphs._ 4 | import com.flowtick.graphs.defaults._ 5 | import org.scalatest.flatspec.AnyFlatSpec 6 | import org.scalatest.matchers.should.Matchers 7 | 8 | class DijkstraShortestPathSpec extends AnyFlatSpec with Matchers { 9 | "Dijkstras algorithm" should "get the shortest path" in { 10 | 11 | // example taken from https://de.wikipedia.org/wiki/Dijkstra-Algorithmus 12 | val g = Graph.fromEdges( 13 | Seq( 14 | "Frankfurt" --> (85, "Mannheim"), 15 | "Frankfurt" --> (217, "Wuerzburg"), 16 | "Frankfurt" --> (173, "Kassel"), 17 | "Mannheim" --> (80, "Karlsruhe"), 18 | "Wuerzburg" --> (186, "Erfurt"), 19 | "Wuerzburg" --> (103, "Nuernberg"), 20 | "Stuttgart" --> (183, "Nuernberg"), 21 | "Kassel" --> (502, "Muenchen"), 22 | "Nuernberg" --> (167, "Muenchen"), 23 | "Karlsruhe" --> (250, "Augsburg"), 24 | "Augsburg" --> (84, "Muenchen") 25 | ) 26 | ) 27 | 28 | val pathFrankfurtMuenchen = g.dijkstra.shortestPath("Frankfurt", "Muenchen") 29 | pathFrankfurtMuenchen.headOption match { 30 | case Some(firstEdge) => firstEdge.from should be("Frankfurt") 31 | case None => fail("there should be path") 32 | } 33 | pathFrankfurtMuenchen.map(_.to) should be( 34 | List("Wuerzburg", "Nuernberg", "Muenchen") 35 | ) 36 | 37 | val pathFrankfurtErfurt = g.dijkstra.shortestPath("Frankfurt", "Erfurt") 38 | pathFrankfurtErfurt.headOption match { 39 | case Some(firstEdge) => firstEdge.from should be("Frankfurt") 40 | case None => fail("there should be path") 41 | } 42 | pathFrankfurtErfurt.map(_.to) should be(List("Wuerzburg", "Erfurt")) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /core/jvm/src/test/scala/com/flowtick/graphs/algorithm/TopologicalSortSpec.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs.algorithm 2 | 3 | import com.flowtick.graphs.Graph 4 | import com.flowtick.graphs.defaults._ 5 | import org.scalatest.flatspec.AnyFlatSpec 6 | import org.scalatest.matchers.should.Matchers 7 | 8 | class TopologicalSortSpec extends AnyFlatSpec with Matchers { 9 | "Topological sort" should "sort dependent nodes" in { 10 | // https://de.wikipedia.org/wiki/Topologische_Sortierung#Beispiel:_Anziehreihenfolge_von_Kleidungsst.C3.BCcken 11 | val clothes = Graph.fromEdges( 12 | Seq( 13 | "Unterhose" --> "Hose", 14 | "Hose" --> "Mantel", 15 | "Pullover" --> "Mantel", 16 | "Unterhemd" --> "Pullover", 17 | "Hose" --> "Schuhe", 18 | "Socken" --> "Schuhe" 19 | ) 20 | ) 21 | 22 | val sorted = clothes.topologicalSort.map(_.node.id) 23 | 24 | clothes.edges.forall { edge => 25 | sorted.indexOf(edge.from) < sorted.indexOf(edge.to) 26 | } should be(true) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/com/flowtick/graphs/algorithm/BreadthFirstTraversal.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs.algorithm 2 | 3 | import com.flowtick.graphs.Graph 4 | import com.flowtick.graphs.algorithm.Traversal.Step 5 | 6 | class BreadthFirstTraversal[E, N]( 7 | initialNodes: Iterable[String], 8 | graph: Graph[E, N] 9 | ) extends StepTraversal[E, N] { 10 | override def run: Iterable[TraversalEvent[Step[E, N]]] = 11 | Traversal.nodes(graph)(initialNodes.view.flatMap(graph.findNode))( 12 | TraversalState.queue 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/com/flowtick/graphs/algorithm/DepthFirstTraversal.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs.algorithm 2 | 3 | import com.flowtick.graphs.Graph 4 | import com.flowtick.graphs.algorithm.Traversal.Step 5 | 6 | class DepthFirstTraversal[E, N]( 7 | initialNodes: Iterable[String], 8 | graph: Graph[E, N] 9 | ) extends StepTraversal[E, N] { 10 | override def run: Iterable[TraversalEvent[Step[E, N]]] = 11 | Traversal.nodes(graph)(initialNodes.view.flatMap(graph.findNode))( 12 | TraversalState.stack 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/com/flowtick/graphs/algorithm/DijkstraShortestPath.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs.algorithm 2 | 3 | import com.flowtick.graphs._ 4 | 5 | import scala.collection.mutable 6 | import scala.collection.mutable.ListBuffer 7 | 8 | class DijkstraShortestPath[M, E, N](graph: Graph[E, N])(implicit 9 | label: Labeled[Edge[E], E], 10 | numeric: Numeric[E] 11 | ) { 12 | 13 | /** determine the shortest path from start to end, works only for positive weight values 14 | * 15 | * @param start 16 | * the start node 17 | * @param end 18 | * the end node 19 | * @return 20 | * Some list of node ids with the shortest path, None if there is no path from start to end 21 | */ 22 | def shortestPath(start: String, end: String): Iterable[Edge[E]] = { 23 | val distanceMap = mutable.Map.empty[String, Double] 24 | val predecessorMap = mutable.Map.empty[String, (Node[N], Edge[E])] 25 | 26 | implicit val nodePriority: Ordering[Node[N]] = new Ordering[Node[N]] { 27 | override def compare(x: Node[N], y: Node[N]): Int = { 28 | distanceMap(x.id).compareTo(distanceMap(y.id)) 29 | } 30 | }.reverse 31 | 32 | val queue = mutable.PriorityQueue.empty[Node[N]] 33 | 34 | graph.nodes.foreach { node => 35 | if (node.id == start) { 36 | distanceMap.put(start, 0) 37 | } else { 38 | distanceMap.put(node.id, Double.PositiveInfinity) 39 | } 40 | 41 | queue.enqueue(node) 42 | } 43 | 44 | while (queue.nonEmpty) { 45 | val current = queue.dequeue() 46 | val currentDistance: Double = distanceMap(current.id) 47 | if (currentDistance != Double.NaN) { 48 | graph.outgoing(current.id).foreach { edge => 49 | val weight = numeric.toDouble(label(edge)) 50 | val newDist = currentDistance + weight 51 | 52 | if (newDist < distanceMap(edge.to)) { 53 | distanceMap.put(edge.to, newDist) 54 | predecessorMap.put(edge.to, (current, edge)) 55 | graph.findNode(edge.to).foreach(queue.enqueue(_)) 56 | } 57 | 58 | /** since we can not update the distance in queue, we mark the current one as done 59 | */ 60 | distanceMap.put(current.id, Double.NaN) 61 | } 62 | 63 | } 64 | } 65 | 66 | if (predecessorMap.contains(end)) { 67 | val predecessors = mutable.Stack[(Node[N], Edge[E])]() 68 | val predecessorList = ListBuffer.empty[Edge[E]] 69 | predecessorMap.get(end).foreach(predecessors.push) 70 | 71 | while (predecessors.nonEmpty) { 72 | val currentPredecessor = predecessors.pop() 73 | predecessorList.prepend(currentPredecessor._2) 74 | predecessorMap.get(currentPredecessor._1.id).foreach(predecessors.push) 75 | } 76 | 77 | predecessorList 78 | } else List.empty 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/com/flowtick/graphs/algorithm/TopologicalSort.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs.algorithm 2 | 3 | import com.flowtick.graphs.Graph 4 | import com.flowtick.graphs.algorithm.Traversal.Step 5 | 6 | class TopologicalSort[E, N](graph: Graph[E, N]) { 7 | def sort: List[Step[E, N]] = 8 | new DepthFirstTraversal[E, N](graph.nodeIds, graph).completed 9 | .foldLeft(List.empty[Step[E, N]]) { case (acc, next) => 10 | next :: acc // reverse the completed 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/com/flowtick/graphs/algorithm/package.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs 2 | 3 | import com.flowtick.graphs.algorithm.Traversal.Step 4 | 5 | package object algorithm { 6 | final case class Path[E, N](steps: List[Step[E, N]]) 7 | 8 | implicit class GraphOps[M, E, N](graph: Graph[E, N]) { 9 | def bfs(startNode: String): StepTraversal[E, N] = 10 | new BreadthFirstTraversal[E, N](Seq(startNode), graph) 11 | def dfs(startNode: String): StepTraversal[E, N] = 12 | new DepthFirstTraversal[E, N](Seq(startNode), graph) 13 | def topologicalSort: List[Step[E, N]] = 14 | new TopologicalSort[E, N](graph).sort 15 | def dijkstra(implicit 16 | numeric: Numeric[E], 17 | label: Labeled[Edge[E], E] 18 | ): DijkstraShortestPath[M, E, N] = new DijkstraShortestPath[M, E, N](graph) 19 | 20 | def paths(startNode: String): List[Path[E, N]] = { 21 | new DepthFirstTraversal[E, N](Seq(startNode), graph).run 22 | .foldLeft(List.empty[Path[E, N]]) { 23 | case (Nil, Visited(step)) => 24 | Path(steps = List(step)) :: Nil 25 | case (path :: tail, Visited(step)) => 26 | path.copy(steps = path.steps :+ step) :: tail 27 | case (path :: tail, Completed(_, _)) => 28 | if (path.steps.size == 1) path :: tail // skip empty list 29 | else Path(path.steps.dropRight(1)) :: path :: tail 30 | case (paths, _) => paths 31 | } 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/com/flowtick/graphs/defaults/package.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs 2 | 3 | package object defaults { 4 | implicit val identifiableString: Identifiable[String] = 5 | Identifiable.identify(identity) 6 | implicit val identifiableUnit: Identifiable[Unit] = 7 | Identifiable.identify(_ => "()") 8 | implicit val identifiableInt: Identifiable[Int] = 9 | Identifiable.identify(int => int.toString) 10 | 11 | private final case class IdentifiableOption[T](id: Identifiable[T]) 12 | extends Identifiable[Option[T]] { 13 | override def apply(value: Option[T]): String = value.map(id(_)).getOrElse("none") 14 | } 15 | 16 | implicit def identifiableOption[T](implicit id: Identifiable[T]): Identifiable[Option[T]] = 17 | IdentifiableOption(id) 18 | 19 | object id { 20 | implicit val identifyAny: Identifiable[Any] = (value: Any) => value.toString 21 | } 22 | 23 | object label { 24 | implicit val unitLabel: Labeled[Unit, String] = _ => "()" 25 | implicit val intOptLabel: Labeled[Int, Option[String]] = number => Some(number.toString) 26 | implicit val intLabel: Labeled[Int, String] = number => number.toString 27 | 28 | implicit def labeledEdgeString[E, N](implicit 29 | labeled: Labeled[E, String] 30 | ): Labeled[Edge[E], String] = 31 | Labeled.label[Edge[E], String](edge => labeled(edge.value)) 32 | 33 | implicit def labeledNodeString[N](implicit 34 | labeled: Labeled[N, String] 35 | ): Labeled[Node[N], String] = 36 | Labeled.label[Node[N], String](node => labeled(node.value)) 37 | 38 | implicit val stringOptLabel: Labeled[String, Option[String]] = string => Some(string) 39 | 40 | implicit def edgeStringOptLabel[E, N](implicit 41 | edgeLabel: Labeled[Edge[E], String] 42 | ): Labeled[Edge[E], Option[String]] = 43 | edge => Some(edgeLabel(edge)) 44 | 45 | implicit def nodeStringOptLabel[N](implicit 46 | nodeLabel: Labeled[Node[N], String] 47 | ): Labeled[Node[N], Option[String]] = 48 | node => Some(nodeLabel(node)) 49 | } 50 | 51 | implicit class DefaultRelationBuilder[N](from: N) { 52 | 53 | /** create a relation 54 | * 55 | * @param value 56 | * value of the relation 57 | * @param to 58 | * target / co-domain / image of `from` 59 | * @param nodeId 60 | * identity to use 61 | * @return 62 | * new directed relation between `to` and `from` 63 | */ 64 | def -->[E](value: E, to: N)(implicit 65 | nodeId: Identifiable[N] 66 | ): Relation[E, N] = 67 | Relation(value, Node(nodeId(from), from), Node(nodeId(to), to)) 68 | def -->(to: N)(implicit nodeId: Identifiable[N]): Relation[Unit, N] = 69 | Relation((), Node(nodeId(from), from), Node(nodeId(to), to)) 70 | 71 | /** create a symmetric relation 72 | * 73 | * @param value 74 | * value of the relation 75 | * @param to 76 | * related node 77 | * @param nodeId 78 | * identity to use 79 | * @return 80 | * new symmetric relation between `to` and `from` 81 | */ 82 | def ~~~[E](value: E, to: N)(implicit 83 | nodeId: Identifiable[N] 84 | ): Relation[E, N] = Relation( 85 | value, 86 | Node(nodeId(from), from), 87 | Node(nodeId(to), to), 88 | symmetric = true 89 | ) 90 | def ~~~(to: N)(implicit nodeId: Identifiable[N]): Relation[Unit, N] = 91 | Relation( 92 | (), 93 | Node(nodeId(from), from), 94 | Node(nodeId(to), to), 95 | symmetric = true 96 | ) 97 | } 98 | 99 | implicit val stringLabel: Labeled[String, String] = (string: String) => string 100 | 101 | implicit def numericEdgeLabel[E, N](implicit 102 | numeric: Numeric[E] 103 | ): Labeled[Edge[E], E] = new Labeled[Edge[E], E] { 104 | override def apply(edge: Edge[E]): E = edge.value 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/com/flowtick/graphs/util/MathUtil.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs.util 2 | 3 | import scala.util.Random 4 | 5 | /** line segment intersection adapted from 6 | * https://www.codeproject.com/tips/862988/find-the-intersection-point-of-two-line-segments 7 | */ 8 | object MathUtil { 9 | final case class Vector2(x: Double, y: Double) { 10 | def -(other: Vector2): Vector2 = copy(x = x - other.x, y = y - other.y) 11 | 12 | def +(other: Vector2): Vector2 = copy(x = x + other.x, y = y + other.y) 13 | 14 | def *(other: Vector2): Double = x * other.x + y * other.y 15 | 16 | def /(n: Double): Vector2 = if (n == 0.0) Vector2(0, 0) else Vector2(x / n, y / n) 17 | 18 | def length: Double = Math.sqrt(x * x + y * y) 19 | 20 | def normal: Vector2 = Vector2(-y, x) 21 | 22 | def normalise: Vector2 = this / length 23 | 24 | def times(factor: Double): Vector2 = copy(x = x * factor, y = y * factor) 25 | 26 | def cross(other: Vector2): Double = x * other.y - y * other.x 27 | 28 | def same(other: Vector2): Boolean = 29 | MathUtil.isZero(x - other.x) && MathUtil.isZero(y - other.y) 30 | } 31 | 32 | object Vector2 { 33 | val zero: Vector2 = Vector2(0.0, 0.0) 34 | def random(generator: scala.util.Random = new Random()): Vector2 = 35 | Vector2(10.0 * (generator.nextDouble() - 0.5), 10.0 * (generator.nextDouble() - 0.5)) 36 | } 37 | 38 | final case class LineSegment(start: Vector2, end: Vector2) 39 | 40 | final case class Rectangle(topLeft: Vector2, bottomRight: Vector2) { 41 | def top: LineSegment = LineSegment(topLeft, topLeft.copy(x = bottomRight.x)) 42 | 43 | def left: LineSegment = 44 | LineSegment(topLeft, topLeft.copy(y = bottomRight.y)) 45 | 46 | def bottom: LineSegment = 47 | LineSegment(bottomRight, bottomRight.copy(x = topLeft.x)) 48 | 49 | def right: LineSegment = 50 | LineSegment(bottomRight, bottomRight.copy(y = topLeft.y)) 51 | } 52 | 53 | private val Epsilon: Double = 1e-10 54 | 55 | def isZero(d: Double): Boolean = Math.abs(d) < Epsilon 56 | 57 | def segmentIntersect( 58 | a: LineSegment, 59 | b: LineSegment, 60 | considerCollinearOverlapAsIntersect: Boolean = false 61 | ): Option[Vector2] = { 62 | val p = a.start 63 | val p2 = a.end 64 | val q = b.start 65 | val q2 = b.end 66 | 67 | val r = p2 - p 68 | val s = q2 - q 69 | val rxs = r cross s 70 | val qpxr = (q - p) cross r 71 | 72 | // If r x s = 0 and (q - p) x r = 0, then the two lines are collinear. 73 | if (isZero(rxs) && isZero(qpxr)) { 74 | // 1. If either 0 <= (q - p) * r <= r * r or 0 <= (p - q) * s <= * s 75 | // then the two lines are overlapping, 76 | if ( 77 | considerCollinearOverlapAsIntersect && ((0 <= (q - p) * r && (q - p) * r <= r * r) || (0 <= (p - q) * s && (p - q) * s <= s * s)) 78 | ) { 79 | // note: on line overlap there will be two intersection points, the choice here is random 80 | Some(b.start + ((a.end - a.start) - (b.end - b.start))) 81 | } else None 82 | // 2. If neither 0 <= (q - p) * r = r * r nor 0 <= (p - q) * s <= s * s 83 | // then the two lines are collinear but disjoint. 84 | } 85 | // 3. If r x s = 0 and (q - p) x r != 0, then the two lines are parallel and non-intersecting. 86 | else if (isZero(rxs) && !isZero(qpxr)) { 87 | None 88 | } else { 89 | // t = (q - p) x s / (r x s) 90 | val t = ((q - p) cross s) / rxs 91 | 92 | // u = (q - p) x r / (r x s) 93 | val u = ((q - p) cross r) / rxs 94 | 95 | // 4. If r x s != 0 and 0 <= t <= 1 and 0 <= u <= 1 96 | // the two line segments meet at the point p + t r = q + u s. 97 | if (!isZero(rxs) && (0 <= t && t <= 1) && (0 <= u && u <= 1)) { 98 | // We can calculate the intersection point using either t or u. 99 | Some(p + (r times t)) 100 | } else None 101 | // 5. Otherwise, the two line segments are not parallel but do not intersect. 102 | } 103 | } 104 | 105 | def pointInRect(point: Vector2, rect: Rectangle): Boolean = 106 | rect.topLeft.x < point.x && 107 | point.x < rect.bottomRight.x && 108 | rect.topLeft.y < point.y && 109 | point.y < rect.bottomRight.y 110 | 111 | def rectIntersect(segment: LineSegment, rect: Rectangle): Option[Vector2] = { 112 | // both outside? 113 | val bothOut = 114 | !pointInRect(segment.start, rect) && !pointInRect(segment.end, rect) 115 | val bothIn = 116 | pointInRect(segment.start, rect) && pointInRect(segment.end, rect) 117 | 118 | if (bothIn || bothOut) { 119 | None 120 | } else 121 | segmentIntersect(segment, rect.top) 122 | .orElse(segmentIntersect(segment, rect.bottom)) 123 | .orElse(segmentIntersect(segment, rect.left)) 124 | .orElse(segmentIntersect(segment, rect.right)) 125 | } 126 | 127 | } 128 | -------------------------------------------------------------------------------- /docs/src/main/paradox/algorithms.md: -------------------------------------------------------------------------------- 1 | # Algorithms 2 | 3 | `graphs` allows ad-hoc extension of graph instances so that you can execute some common operations and algorithms on it. 4 | 5 | Currently, the following algorithms are supported: 6 | 7 | ## Depth-first traversal 8 | 9 | A depth first traversal visits every node _per branch / path_ which means that for every node the first child will 10 | be visited and the search will be continued there, before going to its siblings. 11 | 12 | Every node will be visited. 13 | 14 | @@snip [DfsExample.scala](../../../../examples/shared/src/main/scala/examples/DfsExample.scala) 15 | 16 | ## Breadth-first traversal 17 | 18 | A breadth first traversal visits every node _per layer_, which means that first all child nodes 19 | will be visited before continuing with their children. 20 | 21 | Every node will be visited. 22 | 23 | @@snip [BfsExample.scala](../../../../examples/shared/src/main/scala/examples/BfsExample.scala) 24 | 25 | ## Topological sorting using a depth-first approach 26 | 27 |
28 | https://en.wikipedia.org/wiki/Topological_sorting: 29 | 30 | "topological ordering of a directed graph is a linear ordering of its vertices such that for every 31 | directed edge uv from vertex u to vertex v, u comes before v in the ordering. 32 | For instance, the vertices of the graph may represent tasks to be performed, and the edges may represent 33 | constraints that one task must be performed before another" 34 |
35 | 36 | 37 | @@snip [TopologicalSortingExample.scala](../../../../examples/shared/src/main/scala/examples/TopologicalSortingExample.scala) 38 | 39 | ## Dijkstras algorithm for shortest paths 40 | 41 |
42 | https://en.wikipedia.org/wiki/Dijkstra%27s_algorithm: 43 | 44 | For a given source node in the graph, the algorithm finds the shortest path between that node and every other. 45 | It can also be used for finding the shortest paths from a single node to a single destination node by stopping 46 | the algorithm once the shortest path to the destination node has been determined. 47 |
48 | 49 | @@snip [DijkstraExample.scala](../../../../examples/shared/src/main/scala/examples/DijkstraExample.scala) -------------------------------------------------------------------------------- /docs/src/main/paradox/cats.md: -------------------------------------------------------------------------------- 1 | ## cats support 2 | 3 | `graphs` implements some typeclass instances to allow basic operations like combining graphs. 4 | 5 | You need to add the `graphs-cats` dependency to use that: 6 | 7 | @@@vars 8 | ```scala 9 | libraryDependencies += "com.flowtick" %% "graphs-cats" % "$version$" 10 | ``` 11 | @@@ 12 | 13 | @@snip [CatsExample.scala](../../../../examples/shared/src/main/scala/examples/CatsExample.scala) 14 | 15 | Which computes the following combination of graphs: 16 | 17 | ![monoid example](monoid-example.png) -------------------------------------------------------------------------------- /docs/src/main/paradox/creating-graphs.md: -------------------------------------------------------------------------------- 1 | # Creating graphs 2 | 3 | A _graph_ consists of _nodes_ which represent some objects or values and _edges_ which express a 4 | relation between this objects. 5 | 6 | `graphs` has a default builder `-->` that you can use with arbitrary node types. 7 | By importing this builder, you can instantly start creating simple graphs: 8 | 9 | @@snip [SimpleGraphApp.scala](../../../../examples/shared/src/main/scala/examples/SimpleGraphExample.scala){ #simple_graph } 10 | 11 | Edges can also have values associated with them, for example a 12 | distance between nodes that represent locations. 13 | 14 | Numeric edge values can be used in algorithms like Dijkstras 15 | algorithm to find the shortest path between two nodes. 16 | 17 | @@snip [DijkstraExample.scala](../../../../examples/shared/src/main/scala/examples/DijkstraExample.scala){ #cities } 18 | 19 | ## Core 20 | 21 | In `graphs` the core type is the `Graph` type. 22 | 23 | It is parametrized over three types: 24 | 25 | * the value type of the edges (named `E`) 26 | * the value type of the node (named `N`) 27 | 28 | The meta value allows carrying additional information on the graph itself like a description or groups / layers of nodes. 29 | In `graphs` transformations are defined on nodes, which is why the node type is on the right side of the type parameter 30 | list. 31 | 32 | A value of a graph instance of type `Graph[Double, String]` would be described as 33 | > a graph with edges of type `Double` value connecting nodes of type `String` 34 | 35 | `Graph` has common methods to work with graph instances: 36 | 37 | @@snip [Graph.scala](../../../../core/shared/src/main/scala/com/flowtick/graphs/Graph.scala){ #graph } 38 | 39 | ## Identity 40 | 41 | Nodes and Edges have an `id` field of type `String`. Most of the graph API is built around this `id`. 42 | During graph creation, the `id` is derived from the node / edge value via the `Identifiable` type: 43 | 44 | @@snip [Graph.scala](../../../../core/shared/src/main/scala/com/flowtick/graphs/Graph.scala){ #identifiable } 45 | 46 | The defaults contain some default identities for common primitive types, for complex custom types you 47 | need to provide a corresponding instance. 48 | 49 | ## Custom Graph Types 50 | 51 | Since the graph type itself is parametrized, you can just plug in your types: 52 | 53 | @@snip [CustomGraphApp.scala](../../../../examples/shared/src/main/scala/examples/CustomGraphExample.scala){#custom_graph} 54 | 55 | 56 | -------------------------------------------------------------------------------- /docs/src/main/paradox/editor.md: -------------------------------------------------------------------------------- 1 | # Editor 2 | 3 | `graphs` includes an experimental editor, which is usable in the browser via Scala.js. 4 | 5 | [Try it here](editor/index.html) (contains links to [OpenMoji](https://openmoji.org/) images) -------------------------------------------------------------------------------- /docs/src/main/paradox/graphml.md: -------------------------------------------------------------------------------- 1 | ## GraphML support 2 | 3 | `graphs` supports exporting and loading of graphs to GraphML XML. 4 | This format is used by the [yed editor](https://www.yworks.com/products/yed), so graphs can be edited and 5 | layouted there. 6 | 7 | You need to add the `graphs-graphml` dependency to use it: 8 | 9 | @@@vars 10 | ```scala 11 | libraryDependencies += "com.flowtick" %% "graphs-graphml" % "$version$" 12 | ``` 13 | @@@ 14 | 15 | ### Conversion to GraphML 16 | 17 | This creates a default graph and converts it to a GraphML graph. 18 | 19 | @@snip [GraphMLExample.scala](../../../../examples/shared/src/main/scala/examples/GraphMLExample.scala){ #simple-graphml } 20 | 21 | ### Custom Node Types 22 | 23 | Its possible to create GraphML graphs directly using the `ml` edge builder and serialize your own node types, 24 | this is implemeted using shapeless: 25 | 26 | @@snip [GraphMLExample.scala](../../../../examples/shared/src/main/scala/examples/GraphMLExample.scala){ #custom-node-graphml } 27 | -------------------------------------------------------------------------------- /docs/src/main/paradox/index.md: -------------------------------------------------------------------------------- 1 | @@include[README](../../../../README.md) 2 | 3 | @@@ index 4 | 5 | * [Setup](setup.md) 6 | * [Creating Graphs](creating-graphs.md) 7 | * [Algorithms](algorithms.md) 8 | * [JSON support](json.md) 9 | * [GraphML support](graphml.md) 10 | * [Layout support](layout.md) 11 | * [cats support](cats.md) 12 | * [Editor](editor.md) 13 | 14 | @@@ -------------------------------------------------------------------------------- /docs/src/main/paradox/json.md: -------------------------------------------------------------------------------- 1 | ## JSON support 2 | 3 | `graphs` supports the serialization of graphs as via [circe](https://github.com/circe/circe). 4 | 5 | You need to add the `graphs-json` dependency to use it: 6 | 7 | @@@vars 8 | ```scala 9 | libraryDependencies += "com.flowtick" %% "graphs-json" % "$version$" 10 | ``` 11 | @@@ 12 | 13 | For the conversion you need to have an `Identifiable` instance for the node type: 14 | 15 | #### Example: primitive types with defaults 16 | 17 | @@snip [JsonExample.scala](../../../../examples/shared/src/main/scala/examples/JsonExample.scala){ #json_simple } 18 | 19 | #### Example: providing the ID for a custom type 20 | 21 | @@snip [JsonExample.scala](../../../../examples/shared/src/main/scala/examples/JsonExample.scala){ #json_custom } 22 | 23 | Note that this example is also importing to option to treat unit as `null` in the JSON representation. 24 | -------------------------------------------------------------------------------- /docs/src/main/paradox/layout.md: -------------------------------------------------------------------------------- 1 | ## Layout 2 | 3 | `graphs` provides an integration with [ELK](https://www.eclipse.org/elk/) to provide 4 | layout for graphs: 5 | 6 | @@snip [LayoutExample.scala](../../../../examples/shared/src/main/scala/examples/LayoutExample.scala){ #layout_simple } 7 | -------------------------------------------------------------------------------- /docs/src/main/paradox/monoid-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flowtick/graphs/71c5d0498a41139c54cf0d8811fdffa68b9b14ac/docs/src/main/paradox/monoid-example.png -------------------------------------------------------------------------------- /docs/src/main/paradox/setup.md: -------------------------------------------------------------------------------- 1 | # Setup 2 | 3 | Add the dependency to your build: 4 | 5 | @@@vars 6 | ```scala 7 | libraryDependencies += "com.flowtick" %% "graphs-core" % "$version$" 8 | ``` 9 | @@@ 10 | 11 | Using the [ammonite REPL](http://ammonite.io) you can quickly play around with graphs: 12 | 13 | @@@vars 14 | ```scala 15 | import $ivy.`com.flowtick:graphs-core_2.13:$version$` 16 | ``` 17 | @@@ 18 | 19 | @@snip [SimpleGraphApp.scala](../../../../examples/shared/src/main/scala/examples/SimpleGraphExample.scala){ #simple_graph } 20 | -------------------------------------------------------------------------------- /editor/js/src/main/scala/com/flowtick/graphs/editor/EditorInstanceJs.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs.editor 2 | 3 | import scala.scalajs.js.annotation.JSExport 4 | import cats.effect.unsafe.implicits.global 5 | 6 | class EditorInstanceJs(val messageBus: EditorMessageBus) { 7 | @JSExport 8 | def execute(commandJson: String): Unit = 9 | io.circe.parser.decode[EditorCommand](commandJson) match { 10 | case Right(command) => messageBus.publish(command).unsafeToFuture() 11 | case Left(error) => 12 | println("unable to execute command:", error) 13 | } 14 | 15 | } 16 | -------------------------------------------------------------------------------- /editor/js/src/main/scala/com/flowtick/graphs/editor/EditorMainJs.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs.editor 2 | 3 | import cats.effect.IO 4 | import cats.effect.unsafe.implicits.global 5 | 6 | import org.scalajs.dom.Event 7 | 8 | import scala.scalajs.js 9 | import scala.scalajs.js.{JSON, undefined} 10 | import scala.scalajs.js.annotation.{JSExport, JSExportTopLevel} 11 | import scala.concurrent.Future 12 | 13 | @JSExportTopLevel("graphs") 14 | object EditorMainJs extends EditorMain { 15 | 16 | @JSExport 17 | def createEditor( 18 | containerElementId: String, 19 | optionsObj: js.UndefOr[js.Object], 20 | menuContainerId: js.UndefOr[String] = undefined, 21 | paletteContainerId: js.UndefOr[String] = undefined 22 | ): Future[EditorInstanceJs] = (for { 23 | options <- optionsObj.toOption 24 | .map(obj => IO.fromEither(EditorConfiguration.decode(obj.toString))) 25 | .getOrElse(IO.pure(EditorConfiguration())) 26 | 27 | editor <- createEditor(bus => 28 | List( 29 | Some(new EditorPropertiesJs(containerElementId)(bus)), 30 | Some(new EditorViewJs(containerElementId)(bus)), 31 | paletteContainerId.map(new EditorPaletteJs(_)(bus)).toOption, 32 | menuContainerId.map(new EditorMenuJs(_)(bus)).toOption 33 | ).flatten 34 | )(options) 35 | // we use right click for panning, prevent context menu 36 | _ <- IO( 37 | org.scalajs.dom.window.document.addEventListener( 38 | "contextmenu", 39 | (event: Event) => { 40 | event.preventDefault() 41 | }, 42 | false 43 | ) 44 | ) 45 | } yield new EditorInstanceJs(editor.bus)) 46 | .redeemWith( 47 | error => 48 | IO(println(s"error while creating editor $error")) *> IO.raiseError( 49 | error 50 | ), 51 | IO.pure 52 | ) 53 | .unsafeToFuture() 54 | 55 | def main(args: Array[String]): Unit = { 56 | println("graphs loaded...") 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /editor/js/src/main/scala/com/flowtick/graphs/editor/EditorMenuJs.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs.editor 2 | 3 | import cats.effect.IO 4 | import com.flowtick.graphs._ 5 | import com.flowtick.graphs.editor.vendor.Mousetrap 6 | import org.scalajs.dom.html 7 | import org.scalajs.dom.html.{Div, Input} 8 | import org.scalajs.dom.raw._ 9 | import scalatags.JsDom 10 | import scalatags.JsDom.all._ 11 | 12 | import scala.scalajs.js 13 | 14 | class EditorMenuJs(menuContainerId: String)(val messageBus: EditorMessageBus) extends EditorMenu { 15 | import cats.effect.unsafe.implicits.global 16 | 17 | override def order: Double = 0.6 18 | 19 | lazy val menuContainer = 20 | org.scalajs.dom.window.document.getElementById(menuContainerId) 21 | 22 | lazy val downloadLink = a( 23 | id := "download_link", 24 | href := "", 25 | "Click here to download", 26 | display := "none" 27 | ).render 28 | 29 | lazy val fileInput: Input = 30 | input(`type` := "file", id := "file", onchange := readFile).render 31 | 32 | lazy val fileInputGroup: Div = 33 | div( 34 | cls := "input-group col-2 d-none", 35 | div( 36 | cls := "custom-file", 37 | fileInput, 38 | label(cls := "custom-file-label", `for` := "file", "Choose file") 39 | ) 40 | ).render 41 | 42 | def iconTag(icon: String): JsDom.TypedTag[html.Element] = i( 43 | cls := s"fa fa-$icon" 44 | ) 45 | 46 | val navbar: Div = div( 47 | cls := "collapse navbar-collapse", 48 | id := "menuNavbarCollapse", 49 | ul( 50 | cls := "navbar-nav mr-auto" 51 | ).apply(editorMenus.toArray.map { 52 | case EditorMenuSpec(menuTitle, Toolbar, actions, _) => 53 | div( 54 | cls := "btn-toolbar", 55 | role := "toolbar", 56 | aria.label := menuTitle, 57 | div( 58 | cls := "btn-group mr-2", 59 | role := "group" 60 | ).apply( 61 | actions.toArray.map(action => 62 | button( 63 | `type` := "button", 64 | cls := "btn btn-secondary", 65 | data("toggle") := "tooltip", 66 | data("placement") := "top", 67 | title := action.fullTitle, 68 | onclick := action.handler, 69 | action.icon 70 | .map(iconTag) 71 | .getOrElse(action.title) 72 | ) 73 | ) 74 | ) 75 | ) 76 | case EditorMenuSpec(menuTitle, DropUp, actions, icon) => 77 | li( 78 | cls := "nav-item dropup", 79 | a( 80 | cls := "nav-link dropdown-toggle", 81 | href := "#", 82 | id := s"menu-$name", 83 | data("toggle") := "dropdown", 84 | aria.haspopup := "true", 85 | aria.expanded := "false", 86 | icon.map(iconTag).getOrElse(menuTitle) 87 | ), 88 | div( 89 | cls := "dropdown-menu", 90 | aria.labelledby := s"menu-$name" 91 | ).apply( 92 | actions.toArray.map(action => 93 | a( 94 | cls := "dropdown-item", 95 | href := "#", 96 | onclick := action.handler, 97 | action.icon.map(iconTag).getOrElse(""), 98 | " ", 99 | action.fullTitle 100 | ) 101 | ) 102 | ) 103 | ) 104 | }) 105 | ).render 106 | 107 | override def bindShortcut(action: Action): IO[Unit] = IO { 108 | Mousetrap.bind(action.shortCut, action.handler) 109 | } 110 | 111 | override def handleExported(exported: ExportedGraph): IO[Unit] = 112 | showLink(exported.name, exported.value, exported.format) 113 | 114 | def readFile: DragEvent => Unit = (event: DragEvent) => { 115 | val file = event.target.asInstanceOf[HTMLInputElement].files.item(0) 116 | val format = if (file.name.endsWith(".json")) JsonFormat else GraphMLFormat 117 | 118 | val reader = new FileReader 119 | reader.onload = (_: Event) => 120 | messageBus 121 | .publish(Load(reader.result.toString, format)) 122 | .unsafeToFuture() 123 | reader.readAsText(file) 124 | } 125 | 126 | def showLink(name: String, data: String, format: FileFormat): IO[Unit] = IO { 127 | val blob = new Blob(js.Array.apply(data), BlobPropertyBag("text/plain")); 128 | val url = URL.createObjectURL(blob) 129 | 130 | val suffix = format match { 131 | case GraphMLFormat => ".xml" 132 | case JsonFormat => ".json" 133 | } 134 | 135 | downloadLink.asInstanceOf[js.Dynamic].download = s"$name$suffix" 136 | downloadLink.href = url; 137 | downloadLink.click() 138 | } 139 | 140 | override def triggerFileOpen: Any => Unit = { 141 | case _: Event => 142 | org.scalajs.dom.document 143 | .getElementById(fileInput.id) 144 | .asInstanceOf[HTMLInputElement] 145 | .click() 146 | case _ => 147 | } 148 | 149 | override def initMenus: IO[Unit] = IO { 150 | menuContainer.appendChild(navbar) 151 | menuContainer.appendChild(fileInputGroup) 152 | } 153 | 154 | } 155 | -------------------------------------------------------------------------------- /editor/js/src/main/scala/com/flowtick/graphs/editor/EditorPageJs.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs.editor 2 | 3 | import cats.effect.IO 4 | import org.scalajs.dom 5 | import org.scalajs.dom.raw._ 6 | import cats.effect.unsafe.implicits.global 7 | import com.flowtick.graphs.view._ 8 | 9 | object EditorDomEventLike extends EventLike[Event, dom.Element] { 10 | override def target(event: Event): dom.Element = 11 | event.target.asInstanceOf[Element] 12 | override def preventDefault(event: Event): Unit = event.preventDefault() 13 | override def data(event: Event): EventData = { 14 | event match { 15 | case wheel: WheelEvent => 16 | EditorWheelEvent(wheel.clientX, wheel.clientY, wheel.deltaY) 17 | case mouseEvent: MouseEvent => 18 | EditorMouseEvent( 19 | mouseEvent.clientX, 20 | mouseEvent.clientY, 21 | mouseEvent.button 22 | ) 23 | case _ => EditorAnyEvent 24 | } 25 | } 26 | } 27 | 28 | object EditorPageJs { 29 | def apply( 30 | handleSelect: ElementRef => Boolean => IO[Unit], 31 | handleDrag: Option[DragStart[dom.Element]] => IO[Unit], 32 | handleDoubleClick: Event => IO[Unit] 33 | )( 34 | renderer: SVGRenderer[dom.Element, dom.Element, dom.Node, SVGMatrix], 35 | eventLike: EventLike[Event, dom.Element] 36 | ): Page[dom.Element, Event, EditorGraphNode, EditorGraphEdge, EditorModel] = { 37 | val page = 38 | new SVGPage[ 39 | dom.Element, 40 | dom.Element, 41 | dom.Node, 42 | Event, 43 | SVGMatrix, 44 | EditorGraphNode, 45 | EditorGraphEdge, 46 | EditorModel 47 | ]( 48 | renderer, 49 | eventLike 50 | ) { 51 | override def clientWidth: Double = renderer.graphSVG.root.clientWidth 52 | override def clientHeight: Double = renderer.graphSVG.root.clientHeight 53 | override def scrollSpeed: Double = if (dom.window.navigator.userAgent.contains("Firefox")) 54 | 0.03 55 | else 0.003 56 | } 57 | 58 | renderer.graphSVG.panZoomRect.foreach( 59 | _.addEventListener( 60 | "mousedown", 61 | (e: MouseEvent) => { 62 | page.startPan(e) 63 | } 64 | ) 65 | ) 66 | 67 | renderer.graphSVG.panZoomRect.foreach( 68 | _.addEventListener("mouseup", (e: Event) => page.stopPan(e).unsafeToFuture()) 69 | ) 70 | 71 | renderer.graphSVG.panZoomRect.foreach( 72 | _.addEventListener( 73 | "mousemove", 74 | (e: MouseEvent) => { 75 | page.pan(e) 76 | } 77 | ) 78 | ) 79 | 80 | renderer.graphSVG.root.addEventListener("wheel", page.zoom _) 81 | 82 | renderer.graphSVG.root.addEventListener( 83 | "mousedown", 84 | (e: MouseEvent) => { 85 | page 86 | .startDrag(e) 87 | .flatMap { 88 | case Some(_) => IO.unit // we already have a selection 89 | case None => 90 | page.click(e).flatMap { 91 | case Some(clicked) => handleSelect(clicked)(e.ctrlKey) 92 | case None => IO.unit 93 | } 94 | } 95 | .unsafeToFuture() 96 | } 97 | ) 98 | 99 | renderer.graphSVG.root 100 | .addEventListener("mousemove", (e: Event) => page.drag(e).unsafeToFuture()) 101 | renderer.graphSVG.root.addEventListener( 102 | "mouseup", 103 | (e: MouseEvent) => 104 | { 105 | for { 106 | drag <- page.endDrag(e) 107 | _ <- handleDrag(drag).attempt 108 | result <- drag match { 109 | case Some(drag) if Math.abs(drag.deltaX) < 2 && Math.abs(drag.deltaY) < 2 => 110 | page.click(e).flatMap { 111 | case Some(element) => handleSelect(element)(false) 112 | case None => IO.unit 113 | } 114 | case _ => IO.unit 115 | } 116 | } yield result 117 | }.unsafeToFuture() 118 | ) 119 | 120 | renderer.graphSVG.root.addEventListener( 121 | "mouseleave", 122 | (e: MouseEvent) => 123 | { 124 | for { 125 | _ <- page.stopPan(e) 126 | drag <- page.endDrag(e) 127 | result <- handleDrag(drag) 128 | } yield result 129 | }.unsafeToFuture() 130 | ) 131 | 132 | renderer.graphSVG.root.addEventListener( 133 | "dblclick", 134 | (e: MouseEvent) => { 135 | handleDoubleClick(e).unsafeToFuture() 136 | } 137 | ) 138 | 139 | page 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /editor/js/src/main/scala/com/flowtick/graphs/editor/EditorPaletteJs.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs.editor 2 | 3 | import cats.effect.IO 4 | import com.flowtick.graphs.editor.feature.PaletteFeature 5 | import com.flowtick.graphs.style.ImageSpec 6 | import org.scalajs.dom.html.{Button, Div, UList} 7 | import org.scalajs.dom.raw.{Event, HTMLElement} 8 | import scalatags.JsDom.all._ 9 | import cats.effect.unsafe.implicits.global 10 | 11 | class EditorPaletteJs(paletteElementId: String)( 12 | val messageBus: EditorMessageBus 13 | ) extends PaletteFeature { 14 | lazy val paletteContainer: HTMLElement = org.scalajs.dom.window.document 15 | .getElementById(paletteElementId) 16 | .asInstanceOf[HTMLElement] 17 | 18 | def stencilNavList(palette: Palette): UList = ul( 19 | cls := "nav" 20 | ).apply( 21 | palette.stencils.toArray.map(group => 22 | div( 23 | cls := "btn-toolbar", 24 | role := "toolbar", 25 | aria.label := group.title, 26 | div( 27 | cls := "btn-group mr-2", 28 | role := "group" 29 | ).apply( 30 | group.items.toArray.map(item => 31 | button( 32 | `type` := "button", 33 | cls := "btn btn-secondary", 34 | data("toggle") := "tooltip", 35 | data("placement") := "top", 36 | title := item.title, 37 | onclick := ((_: Event) => selectPaletteItem(item)), 38 | ondblclick := ((_: Event) => createFromStencil(item)), 39 | imageOrTitle(item.image, item.title) 40 | ) 41 | ) 42 | ) 43 | ) 44 | ) 45 | ).render 46 | 47 | def connectorNavList(palette: Palette): UList = ul( 48 | cls := "nav" 49 | ).apply( 50 | palette.connectors.toArray.map(group => 51 | div( 52 | cls := "btn-toolbar", 53 | role := "toolbar", 54 | aria.label := group.title, 55 | div( 56 | cls := "btn-group mr-2", 57 | role := "group" 58 | ).apply(group.items.toArray.map(item => { 59 | button( 60 | `type` := "button", 61 | cls := "btn btn-secondary", 62 | data("toggle") := "tooltip", 63 | data("placement") := "top", 64 | title := item.title, 65 | onclick := ((_: Event) => selectConnectorItem(item)), 66 | imageOrTitle(item.image, item.title) 67 | ) 68 | })) 69 | ) 70 | ) 71 | ).render 72 | 73 | private def imageOrTitle( 74 | imageSpec: Option[ImageSpec], 75 | title: String 76 | ): Modifier = 77 | imageSpec 78 | .map { 79 | case ImageSpec(data, "dataUrl", _, heightOpt) => 80 | img(src := data, height := heightOpt.getOrElse(32.0).toInt) 81 | case ImageSpec(url, "url", _, heightOpt) => 82 | img(src := url, height := heightOpt.getOrElse(32.0).toInt) 83 | case ImageSpec(url, _, _, heightOpt) => 84 | img(src := url, height := heightOpt.getOrElse(32.0).toInt) 85 | } 86 | .getOrElse(title) 87 | 88 | def stencilsElement(palette: Palette): Div = { 89 | div( 90 | cls := "collapse navbar-collapse", 91 | id := "paletteNavbarCollapse", 92 | stencilNavList(palette) 93 | ) 94 | }.render 95 | 96 | def connectorsElement(palette: Palette): Div = { 97 | div( 98 | cls := "collapse navbar-collapse", 99 | id := "connectorNavbarCollapse", 100 | connectorNavList(palette) 101 | ) 102 | }.render 103 | 104 | override def toggleView(enabled: Boolean): IO[Boolean] = IO { 105 | if (enabled) { 106 | paletteContainer.classList.remove("d-none") 107 | } else { 108 | paletteContainer.classList.add("d-none") 109 | } 110 | enabled 111 | } 112 | 113 | lazy val closeButton: Button = 114 | button( 115 | cls := "close float-right", 116 | `type` := "button", 117 | data("dismiss") := "modal", 118 | aria.label := "Close", 119 | span(aria.hidden := "true", "×"), 120 | onclick := ((_: Event) => 121 | messageBus 122 | .publish(EditorToggle(EditorToggle.paletteKey, Some(false))) 123 | .unsafeToFuture() 124 | ) 125 | ).render 126 | 127 | override def initPalette(model: EditorModel): IO[Unit] = for { 128 | newPaletteElements <- IO.pure( 129 | model.palette.palettes.map(palette => stencilNavList(palette)) 130 | ) 131 | newConnectorsElements <- IO.pure( 132 | model.palette.palettes.map(palette => connectorNavList(palette)) 133 | ) 134 | _ <- IO { 135 | newPaletteElements.foreach(paletteContainer.appendChild) 136 | newConnectorsElements.foreach(paletteContainer.appendChild) 137 | } 138 | _ <- IO(paletteContainer.appendChild(closeButton)) 139 | } yield () 140 | } 141 | -------------------------------------------------------------------------------- /editor/js/src/main/scala/com/flowtick/graphs/editor/EditorPropertiesJs.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs.editor 2 | 3 | import cats.effect.IO 4 | 5 | class EditorPropertiesJs(containerId: String)(val messageBus: EditorMessageBus) 6 | extends EditorProperties { 7 | 8 | lazy val container = 9 | org.scalajs.dom.window.document.getElementById(containerId) 10 | 11 | override def initEditor(model: EditorModel): IO[Unit] = IO { 12 | container.parentNode.appendChild(EditorPropertiesHtml.propertiesPanel) 13 | container.parentNode.appendChild(EditorPropertiesHtml.propertiesToggle) 14 | } 15 | 16 | override def toggleEdit(enabled: Boolean): IO[Boolean] = 17 | IO(EditorPropertiesHtml.propertiesToggle.click()).attempt.map(_ => true) 18 | 19 | override def createPropertyControls( 20 | properties: List[PropertySpec], 21 | values: EditorPropertiesValues 22 | ): IO[List[PropertyControl]] = for { 23 | newForm <- EditorPropertiesHtml.createPropertyForm(properties) 24 | _ <- IO { 25 | 26 | if (EditorPropertiesHtml.propertiesBody.children.length == 0) { 27 | EditorPropertiesHtml.propertiesBody.appendChild(newForm.html) 28 | } else { 29 | EditorPropertiesHtml.propertiesBody.replaceChild( 30 | newForm.html, 31 | EditorPropertiesHtml.propertiesBody.firstChild 32 | ) 33 | } 34 | } 35 | } yield newForm.controls 36 | } 37 | -------------------------------------------------------------------------------- /editor/js/src/main/scala/com/flowtick/graphs/editor/EditorViewJs.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs.editor 2 | 3 | import cats.effect.IO 4 | import com.flowtick.graphs.editor.view.SVGRendererJs 5 | import com.flowtick.graphs.view.SVGRendererOptions 6 | import org.scalajs.dom.raw.{Element, Event} 7 | 8 | class EditorViewJs(containerElementId: String)(messageBus: EditorMessageBus) 9 | extends EditorView[Element, Event](messageBus) { 10 | lazy val container = 11 | org.scalajs.dom.window.document.getElementById(containerElementId) 12 | 13 | def createPage: IO[PageType] = for { 14 | newPage <- IO.pure( 15 | EditorPageJs(handleSelect, handleDrag, handleDoubleClick)( 16 | SVGRendererJs(SVGRendererOptions(showOrigin = true)), 17 | EditorDomEventLike 18 | ) 19 | ) 20 | currentPage <- pageRef.get 21 | _ <- currentPage match { 22 | case None => 23 | IO(container.insertBefore(newPage.root, container.firstChild)) 24 | case Some(existing) => 25 | IO(container.replaceChild(newPage.root, existing.root)) 26 | } 27 | } yield newPage 28 | } 29 | -------------------------------------------------------------------------------- /editor/js/src/main/scala/com/flowtick/graphs/editor/vendor/Ace.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs.editor.vendor 2 | 3 | import scala.scalajs.js 4 | import scala.scalajs.js.annotation.JSGlobal 5 | 6 | @js.native 7 | trait AceSession extends js.Any { 8 | def setMode(mode: String): Unit = js.native 9 | def setValue(value: String): Unit = js.native 10 | def on( 11 | event: String, 12 | handler: js.Function1[js.UndefOr[js.Object], Unit] 13 | ): Unit = js.native 14 | } 15 | 16 | @js.native 17 | trait AceConfig extends js.Any { 18 | def set(key: String, value: String): js.native 19 | } 20 | 21 | @js.native 22 | trait AceEditor extends js.Any { 23 | def setTheme(theme: String): Unit = js.native 24 | def session: AceSession = js.native 25 | def on( 26 | event: String, 27 | handler: js.Function1[js.UndefOr[js.Object], Unit] 28 | ): Unit = js.native 29 | def getValue(): String = js.native 30 | } 31 | 32 | @js.native 33 | @JSGlobal("ace") 34 | object Ace extends js.Any { 35 | def edit(element: js.Any): AceEditor = js.native 36 | def config: AceConfig = js.native 37 | } 38 | -------------------------------------------------------------------------------- /editor/js/src/main/scala/com/flowtick/graphs/editor/vendor/Mousetrap.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs.editor.vendor 2 | 3 | import org.scalajs.dom.Event 4 | 5 | import scala.scalajs.js 6 | import scala.scalajs.js.annotation.JSGlobal 7 | 8 | @js.native 9 | @JSGlobal("Mousetrap") 10 | object Mousetrap extends js.Any { 11 | def bind(sequence: String, handler: js.Function1[Event, Unit]): Unit = 12 | js.native 13 | } 14 | -------------------------------------------------------------------------------- /editor/js/src/main/scala/com/flowtick/graphs/editor/vendor/SVGUtil.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs.editor.vendor 2 | 3 | import org.scalajs.dom.raw.{SVGElement, SVGMatrix} 4 | 5 | object SVGUtil { 6 | def setMatrix(elem: SVGElement, matrix: SVGMatrix): Unit = 7 | setTransform( 8 | elem, 9 | "matrix(" + matrix.a + "," + 10 | matrix.b + "," + 11 | matrix.c + "," + 12 | matrix.d + "," + 13 | matrix.e + "," + 14 | matrix.f + ")" 15 | ) 16 | 17 | def setTransform(elem: SVGElement, transform: String): Unit = { 18 | elem.setAttributeNS( 19 | null, 20 | "transform", 21 | transform 22 | ) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /editor/jvm/src/main/resources/2B1C_color.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flowtick/graphs/71c5d0498a41139c54cf0d8811fdffa68b9b14ac/editor/jvm/src/main/resources/2B1C_color.png -------------------------------------------------------------------------------- /editor/jvm/src/main/resources/log4j2.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | %d %p %C{1.} [%t] %m%n 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /editor/jvm/src/main/resources/style.css: -------------------------------------------------------------------------------- 1 | .root { 2 | -fx-base: rgb(50, 50, 50); 3 | -fx-background: rgb(50, 50, 50); 4 | -fx-control-inner-background: rgb(50, 50, 50); 5 | } 6 | 7 | .menu-bar { 8 | -fx-background-color: derive(-fx-base, 25%); 9 | } 10 | 11 | .menu-button:hover, .menu-button:showing { 12 | -fx-background-color: derive(-fx-base, 50%); 13 | } 14 | 15 | .menu-item { 16 | -fx-background-color: derive(-fx-base, 10%); 17 | } 18 | 19 | .menu-item:hover { 20 | -fx-background-color: derive(-fx-base, 50%); 21 | } 22 | 23 | .button { 24 | -fx-background-color: transparent; 25 | } 26 | 27 | .button:hover { 28 | -fx-background-color: -fx-shadow-highlight-color, -fx-outer-border, -fx-inner-border, -fx-body-color; 29 | -fx-color: -fx-hover-base; 30 | } 31 | -------------------------------------------------------------------------------- /editor/jvm/src/main/scala/com/flowtick/graphs/editor/EditorMainJvm.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs.editor 2 | 3 | import cats.effect.IO 4 | import cats.effect.unsafe.implicits.global 5 | import org.apache.logging.log4j.{LogManager, Logger} 6 | import scalafx.application.JFXApp 7 | import scalafx.scene.image.Image 8 | import scalafx.scene.layout.BorderPane 9 | import scalafx.scene.{Group, Scene} 10 | 11 | import java.io.File 12 | import java.net.URL 13 | 14 | object EditorMainJvm extends JFXApp with EditorMain { 15 | protected val log: Logger = LogManager.getLogger(getClass) 16 | 17 | lazy val pageRoot = new Group 18 | 19 | lazy val editorLayout = new BorderPane { 20 | center = pageRoot 21 | style = s"""-fx-background: #DDD;""".stripMargin 22 | } 23 | 24 | lazy val editorScene = new Scene(editorLayout) { 25 | val stylePath = sys.env.getOrElse( 26 | "GRAPHS_THEME", 27 | getClass.getClassLoader.getResource("style.css").toURI.toString 28 | ) 29 | stylesheets.add(stylePath) 30 | } 31 | 32 | stage = new JFXApp.PrimaryStage { 33 | title.value = "graphs editor" 34 | width = 600 35 | height = 450 36 | scene = editorScene 37 | } 38 | 39 | def initEditor: IO[EditorInstance] = for { 40 | home <- sys.props 41 | .get("user.home") 42 | .map { userHome => 43 | IO(new File(userHome)) 44 | } 45 | .getOrElse( 46 | IO.raiseError(new IllegalStateException("could not find user home")) 47 | ) 48 | 49 | configFile <- IO { 50 | sys.env.get("GRAPHS_CONFIG").map(new File(_)).getOrElse { 51 | val graphsDir = new File(home, ".graphs") 52 | graphsDir.mkdir() 53 | new File(graphsDir, "config.json") 54 | } 55 | } 56 | 57 | config <- 58 | if (configFile.exists()) { 59 | IO(scala.io.Source.fromFile(configFile)).bracket { configSource => 60 | val configContent = configSource.getLines().mkString("\n") 61 | IO.fromEither(EditorConfiguration.decode(configContent)) 62 | .attempt 63 | .flatMap { 64 | case Right(config) => IO.pure(config) 65 | case Left(error) => 66 | IO(log.error(s"unable to load config: $configContent")) *> IO 67 | .raiseError(error) 68 | } 69 | }(source => IO(source.close())) 70 | } else 71 | IO(log.error(s"config not found: $configFile")) *> IO.pure( 72 | EditorConfiguration() 73 | ) 74 | 75 | editor <- createEditor(bus => 76 | List( 77 | new EditorMenuJavaFx(bus, editorLayout, stage), 78 | new EditorViewJavaFx(bus, editorLayout), 79 | new EditorPaletteJavaFx(bus, editorLayout), 80 | new EditorPropertiesJavaFx(bus, editorLayout), 81 | new EditorImageLoader[Image](ImageLoaderFx) 82 | ) 83 | )(config) 84 | _ <- parameters.raw.headOption match { 85 | case Some(firstArg) => 86 | for { 87 | fileUrl <- IO(new URL(firstArg)).redeemWith( 88 | _ => IO(new URL(s"file://${new File(firstArg).getAbsolutePath}")), 89 | IO.pure 90 | ) 91 | _ <- IO(scala.io.Source.fromURL(fileUrl)).bracket { source => 92 | val format = 93 | if (fileUrl.getFile.endsWith(".json")) JsonFormat 94 | else GraphMLFormat 95 | editor.bus.publish(Load(source.getLines().mkString("\n"), format)) 96 | }(source => IO(source.close())) 97 | } yield () 98 | case None => IO.unit 99 | } 100 | } yield editor 101 | 102 | initEditor.attempt.unsafeRunSync().foreach(println) 103 | } 104 | -------------------------------------------------------------------------------- /editor/jvm/src/main/scala/com/flowtick/graphs/editor/EditorMenuJavaFx.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs.editor 2 | 3 | import java.io.{File, FileInputStream, FileOutputStream} 4 | import cats.effect.IO 5 | import cats.effect.unsafe.implicits.global 6 | import com.flowtick.graphs._ 7 | import javafx.event.EventHandler 8 | import javafx.scene.input.KeyEvent 9 | import scalafx.application.Platform 10 | import scalafx.scene.control.{Menu, MenuBar, MenuItem} 11 | import scalafx.scene.input.{KeyCode, KeyCodeCombination, KeyCombination} 12 | import scalafx.scene.layout.BorderPane 13 | import scalafx.stage.{FileChooser, Stage} 14 | 15 | class EditorMenuJavaFx( 16 | val messageBus: EditorMessageBus, 17 | layout: BorderPane, 18 | stage: Stage 19 | ) extends EditorMenu { 20 | 21 | lazy val menu = { 22 | val bar = new MenuBar { 23 | menus = editorMenus.map { menuSpec => 24 | new Menu(menuSpec.title) { 25 | items = menuSpec.actions.map(action => { 26 | new MenuItem(action.fullTitle) { 27 | onAction = event => { 28 | event.consume() 29 | action.handler(event) 30 | } 31 | } 32 | }) 33 | } 34 | } 35 | } 36 | bar.setViewOrder(-1.0) // lowest comes first, always shows menu 37 | bar 38 | } 39 | 40 | def openFile: IO[Unit] = IO 41 | .async_ { (cb: Either[Throwable, Option[File]] => Unit) => 42 | val fileChooser = new FileChooser() 43 | fileChooser.setTitle("Open File") 44 | 45 | val graphmlFilter = 46 | new FileChooser.ExtensionFilter("graphml files (*.graphml)", "*.graphml") 47 | val jsonFilter = 48 | new FileChooser.ExtensionFilter("JSON files (*.json)", "*.json") 49 | 50 | fileChooser.getExtensionFilters.add(graphmlFilter) 51 | fileChooser.getExtensionFilters.add(jsonFilter) 52 | 53 | Platform.runLater { 54 | cb(Right(Option(fileChooser.showOpenDialog(stage)))) 55 | } 56 | } 57 | .attempt 58 | .flatMap { 59 | case Right(Some(file)) => { 60 | val format = file.getAbsolutePath.split("\\.").lastOption match { 61 | case Some("json") => JsonFormat 62 | case _ => GraphMLFormat 63 | } 64 | loadGraph(file.getAbsolutePath, format) 65 | } 66 | case Right(None) => IO(println("no file selected")) 67 | case Left(error) => IO.raiseError(error) 68 | } 69 | 70 | case class ShortCut( 71 | keyCode: Option[KeyCode] = None, 72 | modifiers: List[KeyCombination.Modifier] = List.empty 73 | ) { 74 | def toCombination: Option[KeyCodeCombination] = 75 | keyCode.map(code => new KeyCodeCombination(code, modifiers.map(_.delegate): _*)) 76 | } 77 | 78 | override def bindShortcut(action: Action): IO[Unit] = IO { 79 | val matchingShortCut = 80 | action.shortCut.split("\\+").foldLeft[ShortCut](ShortCut()) { case (shortcut, nextPart) => 81 | nextPart match { 82 | case "alt" => 83 | shortcut 84 | .copy(modifiers = shortcut.modifiers.::(KeyCombination.AltDown)) 85 | case "ctrl" => 86 | shortcut.copy(modifiers = shortcut.modifiers.::(KeyCombination.ControlDown)) 87 | case "shift" => 88 | shortcut.copy(modifiers = shortcut.modifiers.::(KeyCombination.ShiftDown)) 89 | case "ins" => shortcut.copy(keyCode = Some(KeyCode.Insert)) 90 | case "del" => shortcut.copy(keyCode = Some(KeyCode.Delete)) 91 | case other => 92 | shortcut.copy(keyCode = KeyCode.values.find(_.name.toLowerCase == other.toLowerCase)) 93 | } 94 | } 95 | 96 | matchingShortCut.toCombination.foreach { combination => 97 | val currentHandler = 98 | Option[EventHandler[_ >: KeyEvent]](stage.scene.value.getOnKeyReleased) 99 | stage.scene.value.setOnKeyReleased((event) => { 100 | if (combination.`match`(event)) { 101 | event.consume() 102 | action.handler(event) 103 | } else { 104 | currentHandler.foreach(_.handle(event)) 105 | } 106 | }) 107 | } 108 | } 109 | 110 | override def triggerFileOpen: Any => Unit = _ => { 111 | openFile.unsafeToFuture() 112 | } 113 | 114 | override def handleExported(exported: ExportedGraph): IO[Unit] = for { 115 | file <- IO 116 | .async_ { (callback: Either[Throwable, File] => Unit) => 117 | Platform.runLater { 118 | callback(Right(new FileChooser() { 119 | title = s"Save as ${exported.format.`extension`}" 120 | }.showSaveDialog(stage))) 121 | } 122 | } 123 | .map(Option(_)) 124 | _ <- file match { 125 | case Some(path) => 126 | IO { 127 | val out = new FileOutputStream(path) 128 | out.write(exported.value.getBytes("UTF-8")) 129 | out.flush() 130 | out.close() 131 | } 132 | case None => IO.unit 133 | } 134 | } yield () 135 | 136 | override def initMenus: IO[Unit] = IO { 137 | layout.setTop(menu) 138 | } 139 | 140 | def loadGraph(path: String, format: FileFormat): IO[Unit] = for { 141 | fileContent <- IO { 142 | scala.io.Source 143 | .fromInputStream(new FileInputStream(path)) 144 | .getLines() 145 | .mkString("\n") 146 | } 147 | _ <- messageBus.publish(Load(fileContent, format)) 148 | } yield () 149 | 150 | } 151 | -------------------------------------------------------------------------------- /editor/jvm/src/main/scala/com/flowtick/graphs/editor/EditorPaletteJavaFx.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs.editor 2 | 3 | import cats.effect.IO 4 | 5 | import com.flowtick.graphs.editor.feature.PaletteFeature 6 | import scalafx.geometry.Insets 7 | import scalafx.scene.control.{Accordion, TitledPane, Tooltip} 8 | import scalafx.scene.image.{Image, ImageView} 9 | import scalafx.scene.layout._ 10 | import scalafx.scene.paint.Color 11 | 12 | class EditorPaletteJavaFx(val messageBus: EditorMessageBus, layout: BorderPane) 13 | extends PaletteFeature { 14 | val pane = new BorderPane() { 15 | visible = false 16 | background = new Background( 17 | Array( 18 | new BackgroundFill(Color.LightGray, new CornerRadii(0), Insets.Empty) 19 | ) 20 | ) 21 | 22 | viewOrder_(-10) 23 | 24 | minWidth = 200 25 | } 26 | 27 | override def toggleView(enabled: Boolean): IO[Boolean] = IO { 28 | pane.visible = enabled 29 | enabled 30 | } 31 | 32 | override def initPalette(model: EditorModel): IO[Unit] = for { 33 | _ <- IO { 34 | layout.left = pane 35 | 36 | lazy val fallBackImage = IO( 37 | new Image( 38 | getClass.getClassLoader.getResourceAsStream("2B1C_color.png"), 39 | 50, 40 | 50, 41 | true, 42 | true 43 | ) 44 | ) 45 | 46 | model.palette.stencilGroups.zipWithIndex.flatMap { case (group, index) => 47 | val accordion = new Accordion() 48 | pane.center = accordion 49 | 50 | val groupPane = new TitledPane() 51 | groupPane.setText(group.title) 52 | accordion.panes.add(groupPane) 53 | 54 | if (index == 0) { 55 | accordion.expandedPane = groupPane 56 | } 57 | 58 | val groupContent = new FlowPane() { 59 | padding = Insets.apply(10, 10, 10, 10) 60 | hgap = 10 61 | } 62 | groupPane.content = groupContent 63 | 64 | group.items.map { stencil => 65 | val stencilImage = stencil.image 66 | .map(imageSpec => 67 | ImageLoaderFx 68 | .registerImage(s"graphs:palette:${stencil.id}", imageSpec) 69 | ) 70 | .getOrElse(fallBackImage) 71 | 72 | val stencilView = stencilImage.map(theImage => 73 | new ImageView { 74 | image = theImage 75 | fitWidth = 32 76 | fitHeight = 32 77 | } 78 | ) 79 | 80 | val tooltip = new Tooltip { 81 | text = stencil.title 82 | } 83 | 84 | stencilView.map { view => 85 | Tooltip.install(view, tooltip) 86 | 87 | val stencilGroup = new VBox { 88 | maxWidth = 50 89 | maxHeight = 50 90 | 91 | children.add(view) 92 | 93 | onMouseClicked = e => { 94 | selectPaletteItem(stencil) 95 | 96 | if (e.getClickCount == 2) { 97 | createFromStencil(stencil) 98 | } 99 | } 100 | } 101 | groupContent.children.add(stencilGroup) 102 | } 103 | } 104 | } 105 | } 106 | } yield () 107 | } 108 | -------------------------------------------------------------------------------- /editor/jvm/src/main/scala/com/flowtick/graphs/editor/EditorViewJavaFx.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs.editor 2 | 3 | import cats.effect.IO 4 | import javafx.scene.input.MouseEvent 5 | import scalafx.application.Platform 6 | import scalafx.scene.Node 7 | import scalafx.scene.layout.BorderPane 8 | 9 | class EditorViewJavaFx(bus: EditorMessageBus, layout: BorderPane) 10 | extends EditorView[Node, MouseEvent](bus) 11 | with EditorComponent { 12 | override def createPage: IO[PageType] = IO( 13 | new EditorGraphPane(layout)(handleSelect, handleDrag, handleDoubleClick) 14 | ).flatTap(pane => IO(Platform.runLater(layout.setCenter(pane)))) 15 | } 16 | -------------------------------------------------------------------------------- /editor/jvm/src/main/scala/com/flowtick/graphs/editor/util/DragResizer.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs.editor.util 2 | 3 | import javafx.event.EventHandler 4 | import javafx.scene.Cursor 5 | import javafx.scene.input.MouseEvent 6 | import javafx.scene.layout.Region 7 | 8 | /** adapted from 9 | * https://gist.githubusercontent.com/cannibalsticky/a3057d6e9c1f029d99e6bc95f9b3340e/raw/b216499cf78322746709310079974d89ce52b885/DragResizerXY.java 10 | */ 11 | class DragResizerXY(region: Region) { 12 | 13 | /** The margin around the control that a user can click in to start resizing the region. 14 | */ 15 | val RESIZE_MARGIN = 10 16 | 17 | var x: Double = 0.0 18 | var y: Double = 0.0 19 | 20 | var initMinHeight: Boolean = false 21 | var initMinWidth: Boolean = false 22 | 23 | var draggableZoneX, draggableZoneY: Boolean = false 24 | var dragging: Boolean = false 25 | 26 | def mouseReleased(event: MouseEvent): Unit = { 27 | dragging = false 28 | region.setCursor(Cursor.DEFAULT) 29 | } 30 | 31 | def mouseOver(event: MouseEvent): Unit = { 32 | if (isInDraggableZone(event) || dragging) { 33 | if (draggableZoneY) { 34 | region.setCursor(Cursor.S_RESIZE) 35 | } 36 | 37 | if (draggableZoneX) { 38 | region.setCursor(Cursor.E_RESIZE) 39 | } 40 | 41 | } else { 42 | region.setCursor(Cursor.DEFAULT) 43 | } 44 | } 45 | 46 | //had to use 2 variables for the controll, tried without, had unexpected behaviour (going big was ok, going small nope.) 47 | def isInDraggableZone(event: MouseEvent): Boolean = { 48 | draggableZoneY = event.getY() > (region.getHeight() - RESIZE_MARGIN) 49 | draggableZoneX = event.getX() > (region.getWidth() - RESIZE_MARGIN) 50 | draggableZoneY || draggableZoneX 51 | } 52 | 53 | def mouseDragged(event: MouseEvent) { 54 | if (!dragging) { 55 | return 56 | } 57 | 58 | if (draggableZoneY) { 59 | val mousey = event.getY() 60 | 61 | val newHeight = region.getMinHeight() + (mousey - y) 62 | 63 | region.setMinHeight(newHeight) 64 | 65 | y = mousey 66 | } 67 | 68 | if (draggableZoneX) { 69 | val mousex = event.getX() 70 | 71 | val newWidth = region.getMinWidth() + (mousex - x) 72 | 73 | region.setMinWidth(newWidth) 74 | 75 | x = mousex 76 | } 77 | 78 | } 79 | 80 | def mousePressed(event: MouseEvent) { 81 | 82 | // ignore clicks outside of the draggable margin 83 | if (!isInDraggableZone(event)) { 84 | return 85 | } 86 | 87 | dragging = true 88 | 89 | // make sure that the minimum height is set to the current height once, 90 | // setting a min height that is smaller than the current height will 91 | // have no effect 92 | if (!initMinHeight) { 93 | region.setMinHeight(region.getHeight()) 94 | initMinHeight = true 95 | } 96 | 97 | y = event.getY() 98 | 99 | if (!initMinWidth) { 100 | region.setMinWidth(region.getWidth()) 101 | initMinWidth = true 102 | } 103 | 104 | x = event.getX() 105 | } 106 | } 107 | 108 | object DragResizer { 109 | def makeResizable(region: Region) = { 110 | val resizer = new DragResizerXY(region) 111 | 112 | region.setOnMousePressed(new EventHandler[MouseEvent]() { 113 | def handle(event: MouseEvent) { 114 | resizer.mousePressed(event) 115 | } 116 | }) 117 | region.setOnMouseDragged(new EventHandler[MouseEvent]() { 118 | def handle(event: MouseEvent) { 119 | resizer.mouseDragged(event) 120 | } 121 | }) 122 | region.setOnMouseMoved(new EventHandler[MouseEvent]() { 123 | def handle(event: MouseEvent) { 124 | resizer.mouseOver(event) 125 | } 126 | }) 127 | region.setOnMouseReleased(new EventHandler[MouseEvent]() { 128 | def handle(event: MouseEvent) { 129 | resizer.mouseReleased(event) 130 | } 131 | }) 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /editor/jvm/src/test/resources/log4j2.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /editor/jvm/src/test/scala/com/flowtick/graphs/EditorBaseSpec.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs 2 | 3 | import cats.effect.IO 4 | import cats.effect.kernel.Ref 5 | import cats.effect.unsafe.implicits.global 6 | import com.flowtick.graphs.editor._ 7 | import com.flowtick.graphs.json.schema.Schema 8 | import com.flowtick.graphs.style.StyleSheet 9 | import com.flowtick.graphs.view.SVGRendererJvm 10 | import org.apache.logging.log4j.{LogManager, Logger} 11 | import org.scalatest.concurrent.{IntegrationPatience, ScalaFutures} 12 | import org.scalatest.flatspec.AnyFlatSpec 13 | import org.scalatest.matchers.should.Matchers 14 | 15 | import java.io.FileOutputStream 16 | import scala.collection.mutable.ListBuffer 17 | 18 | trait EditorBaseSpec 19 | extends AnyFlatSpec 20 | with Matchers 21 | with EditorMain 22 | with ScalaFutures 23 | with IntegrationPatience { self => 24 | protected val log: Logger = LogManager.getLogger(getClass) 25 | protected val events: ListBuffer[EditorEvent] = ListBuffer() 26 | protected val lastExported: Ref[IO, Option[ExportedGraph]] = Ref.unsafe(None) 27 | 28 | def shouldRenderImage: Boolean = false 29 | 30 | def testComponents(bus: EditorMessageBus): List[EditorComponent] = List.empty 31 | 32 | def testSchema: Schema[EditorSchemaHints] = Schema( 33 | `type` = Some(Right("object")), 34 | properties = Some( 35 | Map( 36 | "text" -> Schema( 37 | `type` = Some(Right("string")) 38 | ) 39 | ) 40 | ) 41 | ) 42 | 43 | def testPalette: Palette = Palette( 44 | stencils = List( 45 | StencilGroup( 46 | "test", 47 | List(Stencil("test", "A Test Stencil", schemaRef = Some("#/$defs/test-schema"))) 48 | ) 49 | ), 50 | styleSheet = StyleSheet(), 51 | schema = Schema( 52 | definitions = Some( 53 | Map("test-schema" -> testSchema) 54 | ) 55 | ) 56 | ) 57 | 58 | def palettes: Option[List[Palette]] = Some(List(testPalette)) 59 | 60 | def editorConfiguration: EditorConfiguration = EditorConfiguration(palettes) 61 | 62 | def withEditor[T](f: EditorInstance => T): T = 63 | createEditor(bus => 64 | List( 65 | testView 66 | ) ++ testComponents(bus) 67 | )(editorConfiguration).flatMap(editor => IO(f(editor))).unsafeToFuture().futureValue 68 | 69 | protected def writeToFile(fileName: String)(content: String): IO[Unit] = IO { 70 | val out = new FileOutputStream(fileName) 71 | out.write(content.getBytes("UTF-8")) 72 | out.flush() 73 | out.close() 74 | } 75 | 76 | protected def testView: EditorComponent = 77 | new EditorComponent { 78 | override def init(model: EditorModel): IO[Unit] = IO.unit 79 | 80 | override def eval: Eval = ctx => 81 | ctx.effect(this) { 82 | case Reset => 83 | IO { 84 | ctx.model.graph.edges.foreach(log.debug) 85 | } 86 | 87 | case export @ ExportedGraph(name, value, format) => 88 | val fileName = 89 | s"target/${self.getClass.getName}_last_exported_$name" 90 | 91 | lastExported.update(_ => Some(export)) *> 92 | writeToFile(fileName + format.extension)(value) *> 93 | (if (shouldRenderImage) { 94 | SVGRendererJvm() 95 | .renderGraph(ctx.model) 96 | .flatMap(_.toXmlString) 97 | .flatMap(writeToFile(fileName + ".svg")) 98 | } else IO.unit) 99 | 100 | case event => 101 | IO { 102 | events += event 103 | log.debug(event) 104 | } 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /editor/jvm/src/test/scala/com/flowtick/graphs/EditorPropertiesSpec.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs 2 | import cats.implicits._ 3 | import cats.effect.IO 4 | import cats.effect.kernel.Ref 5 | import cats.effect.unsafe.implicits.global 6 | import com.flowtick.graphs.editor._ 7 | import com.flowtick.graphs.view.{ElementRef, NodeElementType} 8 | import io.circe.Json 9 | import org.apache.logging.log4j.{LogManager, Logger} 10 | 11 | final case class TestControl(property: PropertySpec, value: Ref[IO, Option[Json]], init: IO[Unit]) 12 | extends PropertyControl { 13 | protected val log: Logger = LogManager.getLogger(getClass) 14 | 15 | override def set: Json => IO[Unit] = json => 16 | IO( 17 | log.info(s"test: setting ${property.key.flatMap(_.name).getOrElse(property.title)} to $json") 18 | ) *> 19 | value.set(Some(json)) 20 | } 21 | 22 | class TestProperties(bus: EditorMessageBus) extends EditorProperties { 23 | protected val log: Logger = LogManager.getLogger(getClass) 24 | 25 | override def messageBus: EditorMessageBus = bus 26 | override def initEditor(editorModel: EditorModel): IO[Unit] = IO.unit 27 | override def toggleEdit(enabled: Boolean): IO[Boolean] = 28 | IO(log.debug(s"toggle edit $enabled")) *> IO.pure(enabled) 29 | 30 | override protected def createPropertyControls( 31 | properties: List[PropertySpec], 32 | values: EditorPropertiesValues 33 | ): IO[List[PropertyControl]] = 34 | IO(properties.map { prop => 35 | TestControl( 36 | prop, 37 | value = Ref.unsafe(None), 38 | init = IO(log.info(s"test: init $prop")) 39 | ) 40 | }) 41 | } 42 | 43 | class EditorPropertiesSpec extends EditorBaseSpec { 44 | override def testComponents(bus: EditorMessageBus): List[EditorComponent] = List( 45 | new TestProperties(bus) 46 | ) 47 | 48 | "Editor Properties" should "set values" in withEditor { editor => 49 | def getControls: IO[List[PropertyControl]] = editor.components 50 | .traverse { 51 | case properties: TestProperties => properties.currentControls.get 52 | case _ => IO.pure(List.empty) 53 | } 54 | .map(_.flatten) 55 | 56 | (for { 57 | _ <- editor.bus.publish(Reset) 58 | _ <- editor.bus.publish( 59 | AddNode("1", stencilRef = Some("test"), Some(100.0), Some(100.0)) 60 | ) 61 | elementRef = ElementRef("1", NodeElementType) 62 | controlsBeforeSelect <- getControls 63 | _ = controlsBeforeSelect should be(empty) 64 | 65 | _ <- editor.bus.publish(Select(Set(elementRef))) 66 | 67 | controlsAfterSelect <- getControls 68 | 69 | textControl <- IO.fromOption(controlsAfterSelect.find(_.property.inputType == TextInput))( 70 | new IllegalStateException("text control should exist") 71 | ) 72 | _ <- editor.bus.publish(SetJson(elementRef, _ => Json.fromString("new Text"))) 73 | textValue <- textControl match { 74 | case testControl: TestControl => testControl.value.get 75 | case _ => 76 | IO.raiseError(new IllegalStateException("only test controls expected during test")) 77 | } 78 | _ = textValue should be(Some(Json.fromString("new Text"))) 79 | } yield ()).unsafeToFuture().futureValue 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /editor/jvm/src/test/scala/com/flowtick/graphs/EditorViewSpec.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs 2 | 3 | import cats.effect.IO 4 | import com.flowtick.graphs.editor.feature.ModelUpdateFeature 5 | import com.flowtick.graphs.editor.{EditorController, EditorView} 6 | 7 | class EditorViewSpec extends EditorBaseSpec { 8 | "Editor View" should "have higher order than model update" in { 9 | val viewOrder = new EditorView[Unit, Unit](EditorController()) { 10 | override def createPage: IO[PageType] = IO.raiseError(new UnsupportedOperationException) 11 | }.order 12 | 13 | (viewOrder > new ModelUpdateFeature().order) should be(true) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /editor/jvm/src/test/scala/com/flowtick/graphs/MathUtilSpec.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs 2 | 3 | import com.flowtick.graphs.util.MathUtil 4 | import com.flowtick.graphs.util.MathUtil.{LineSegment, Vector2} 5 | import org.scalatest.flatspec.AnyFlatSpec 6 | import org.scalatest.matchers.should.Matchers 7 | 8 | class MathUtilSpec extends AnyFlatSpec with Matchers { 9 | "MathUtil" should "find intersection of line segments" in { 10 | val a = LineSegment(Vector2(0, 0), Vector2(5, 5)) 11 | val b = LineSegment(Vector2(0, 5), Vector2(5, 0)) 12 | 13 | MathUtil.segmentIntersect(a, b) should be( 14 | Some( 15 | Vector2(2.5, 2.5) 16 | ) 17 | ) 18 | 19 | // no intersection 20 | val c = LineSegment(Vector2(3, 0), Vector2(3, 4)) 21 | val d = LineSegment(Vector2(0, 5), Vector2(5, 5)) 22 | 23 | MathUtil.segmentIntersect(c, d) should be(None) 24 | 25 | // collinear / overlapping 26 | val e = LineSegment(Vector2(0, 0), Vector2(2, 0)) 27 | val f = LineSegment(Vector2(1, 0), Vector2(3, 0)) 28 | 29 | MathUtil.segmentIntersect(e, f) should be(None) 30 | MathUtil.segmentIntersect( 31 | e, 32 | f, 33 | considerCollinearOverlapAsIntersect = true 34 | ) should be(Some(Vector2(1, 0))) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /editor/jvm/src/test/scala/com/flowtick/graphs/ModelUpdateFeatureSpec.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs 2 | 3 | import cats.effect.IO 4 | import cats.effect.unsafe.implicits.global 5 | import com.flowtick.graphs.editor._ 6 | import com.flowtick.graphs.layout.{DefaultGeometry, EdgePath} 7 | import com.flowtick.graphs.view.{ElementRef, NodeElementType} 8 | 9 | class ModelUpdateFeatureSpec extends EditorBaseSpec { 10 | override def shouldRenderImage: Boolean = true 11 | 12 | "Editor Model Update" should "add nodes and edges" in withEditor { editor => 13 | val (firstNodeId, secondNodeId) = ("1", "2") 14 | val edgeId = "e1" 15 | 16 | val loaded = (for { 17 | _ <- editor.bus.publish(Reset) 18 | _ <- editor.bus.publish( 19 | AddNode(firstNodeId, None, Some(100.0), Some(100.0)) 20 | ) 21 | _ <- editor.bus.publish( 22 | AddNode(secondNodeId, None, Some(200.0), Some(200.0)) 23 | ) 24 | _ <- editor.bus.publish(SetLabel(ElementRef(firstNodeId, NodeElementType), "1")) 25 | _ <- editor.bus.publish(SetLabel(ElementRef(secondNodeId, NodeElementType), "2")) 26 | _ <- editor.bus.publish(AddEdge(edgeId, firstNodeId, secondNodeId, None)) 27 | _ <- editor.bus.publish(Export(JsonFormat)) 28 | exported <- lastExported.get.flatMap( 29 | IO.fromOption(_)(new IllegalStateException("exported graph not set")) 30 | ) 31 | loaded <- editor.bus.publish(Load(exported.value, JsonFormat)) 32 | } yield loaded).unsafeToFuture().futureValue 33 | 34 | loaded.model.graph.nodes should have size (2) 35 | loaded.model.graph.edges should have size (1) 36 | 37 | loaded.model.layout.nodeGeometry(firstNodeId) should be( 38 | Some(DefaultGeometry(100.0, 100.0, 50.0, 50.0)) 39 | ) 40 | loaded.model.layout.edgePath(edgeId) should be( 41 | Some(EdgePath(25.0, 25.0, -25.0, -25.0, List.empty)) 42 | ) 43 | } 44 | 45 | it should "update the color of a node" in withEditor { editor => 46 | val firstNodeId = "1" 47 | 48 | val color = "#ccc" 49 | 50 | val loaded = (for { 51 | _ <- editor.bus.publish(Reset) 52 | _ <- editor.bus.publish( 53 | AddNode(firstNodeId, None, Some(100.0), Some(100.0)) 54 | ) 55 | _ <- editor.bus.publish(SetColor(ElementRef("1", NodeElementType), color)) 56 | _ <- editor.bus.publish(Export(JsonFormat)) 57 | exported <- lastExported.get.flatMap( 58 | IO.fromOption(_)(new IllegalStateException("exported graph not set")) 59 | ) 60 | loaded <- editor.bus.publish(Load(exported.value, JsonFormat)) 61 | } yield loaded).unsafeToFuture().futureValue 62 | 63 | loaded.model.styleSheet 64 | .getNodeStyle(Some("1")) 65 | .flatMap(_.fill) 66 | .flatMap(_.color) should be(Some(color)) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /editor/jvm/src/test/scala/com/flowtick/graphs/RoutingFeatureSpec.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs 2 | 3 | import cats.effect.unsafe.implicits.global 4 | import com.flowtick.graphs.editor._ 5 | import com.flowtick.graphs.view.{ElementRef, NodeElementType} 6 | import org.scalatest.concurrent.ScalaFutures 7 | 8 | class RoutingFeatureSpec extends EditorBaseSpec with ScalaFutures { 9 | "Routing" should "update edges" in withEditor { editor => 10 | val (firstNodeId, secondNodeId) = ("1", "2") 11 | val edgeId = "e1" 12 | 13 | val (added, moved) = (for { 14 | _ <- editor.bus.publish(Reset) 15 | _ <- editor.bus.publish( 16 | AddNode(firstNodeId, None, Some(100.0), Some(100.0)) 17 | ) 18 | _ <- editor.bus.publish( 19 | AddNode(secondNodeId, None, Some(200.0), Some(200.0)) 20 | ) 21 | added <- editor.bus.publish( 22 | AddEdge(edgeId, firstNodeId, secondNodeId, None) 23 | ) 24 | moved <- editor.bus.publish( 25 | MoveTo(ElementRef(firstNodeId, NodeElementType), 110.0, 110.0) 26 | ) 27 | _ <- editor.bus.publish(Export(JsonFormat)) 28 | } yield (added, moved)).unsafeToFuture().futureValue 29 | 30 | moved.model.graph.edgeIds should have size (1) 31 | 32 | added.model.layout.nodeGeometry(firstNodeId) match { 33 | case Some(posAfterAdd) => 34 | posAfterAdd.x should be(100.0) 35 | posAfterAdd.y should be(100.0) 36 | case None => fail() 37 | } 38 | 39 | moved.model.layout.nodeGeometry(firstNodeId) match { 40 | case Some(posAfterMove) => 41 | posAfterMove.x should be(110.0) 42 | posAfterMove.y should be(110.0) 43 | case None => fail() 44 | } 45 | 46 | val edgeAfterMove = moved.model.graph.findEdge(edgeId) 47 | edgeAfterMove.isDefined should be(true) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /editor/shared/src/main/scala/com/flowtick/graphs/editor/EditorCommand.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs.editor 2 | 3 | import com.flowtick.graphs.layout.EdgePath 4 | import com.flowtick.graphs.view.ElementRef 5 | import io.circe.Decoder.Result 6 | import io.circe.{Decoder, DecodingFailure, HCursor, Json} 7 | 8 | case class AddNode( 9 | id: String, 10 | stencilRef: Option[String] = None, 11 | x: Option[Double] = None, 12 | y: Option[Double] = None 13 | ) extends EditorCommand 14 | 15 | case class SetLabel(elementRef: ElementRef, label: String) extends EditorCommand 16 | case class SetColor(elementRef: ElementRef, color: String) extends EditorCommand 17 | case class SetJsonString(elementRef: ElementRef, json: String) extends EditorCommand 18 | case class SetJson(elementRef: ElementRef, json: Json => Json) extends EditorCommand 19 | 20 | case class Load(value: String, format: FileFormat) extends EditorCommand 21 | case object Reset extends EditorCommand 22 | case class SetModel(model: EditorModel) extends EditorCommand 23 | 24 | sealed trait FileFormat { 25 | def extension: String 26 | } 27 | 28 | case object GraphMLFormat extends FileFormat { 29 | override def `extension`: String = ".graphml" 30 | } 31 | case object JsonFormat extends FileFormat { 32 | override def `extension`: String = ".json" 33 | } 34 | 35 | case class Export(format: FileFormat) extends EditorCommand 36 | case class ExportedGraph(name: String, value: String, format: FileFormat) extends EditorEvent 37 | 38 | case class MoveTo(ref: ElementRef, x: Double, y: Double) extends EditorCommand 39 | case class MoveBy(deltaX: Double, deltaY: Double) extends EditorCommand 40 | 41 | case class AddEdge( 42 | id: String, 43 | from: String, 44 | to: String, 45 | connectorRef: Option[String] = None, 46 | path: Option[EdgePath] = None 47 | ) extends EditorCommand 48 | 49 | case class ElementUpdated( 50 | element: ElementRef, 51 | update: UpdateType = Changed, 52 | causedBy: Option[EditorEvent] = None 53 | ) extends EditorEvent 54 | 55 | case object ResetTransformation extends EditorCommand 56 | case class Select(selection: Set[ElementRef], append: Boolean = false) extends EditorCommand 57 | case class Selected(elements: Set[ElementRef], oldSelection: Set[ElementRef]) extends EditorEvent 58 | 59 | case object DeleteSelection extends EditorCommand 60 | case class EditorToggle(key: String, value: Option[Boolean]) extends EditorCommand 61 | case object Undo extends EditorCommand 62 | case object SelectAll extends EditorCommand 63 | 64 | case class EditorErrorMessage(message: String) extends EditorEvent 65 | 66 | sealed trait EditorEvent 67 | sealed trait EditorCommand extends EditorEvent 68 | 69 | sealed trait UpdateType 70 | case object Created extends UpdateType 71 | case object Changed extends UpdateType 72 | case object Deleted extends UpdateType 73 | 74 | // updates that are only used for internal communication between components 75 | // these should not be considered for undo etc. 76 | // TODO: maybe have more specialized events to trigger wanted behaviour like redraw? 77 | case object Internal extends UpdateType 78 | 79 | object EditorToggle { 80 | val connectKey = "connect" 81 | val editKey = "edit" 82 | val paletteKey = "palette" 83 | } 84 | 85 | object EditorCommand { 86 | implicit val decoder = new Decoder[EditorCommand] { 87 | import io.circe.generic.auto._ 88 | 89 | def convert[A](c: HCursor)(implicit decoder: Decoder[A]) = 90 | c.downField("name").delete.as[A] 91 | 92 | override def apply(c: HCursor): Result[EditorCommand] = c 93 | .downField("name") 94 | .as[String] match { 95 | case Right("add-node") => convert[AddNode](c) 96 | case _ => Left(DecodingFailure("unknown command name", List.empty)) 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /editor/shared/src/main/scala/com/flowtick/graphs/editor/EditorConfiguration.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs.editor 2 | 3 | import com.flowtick.graphs.json.schema.Schema 4 | import com.flowtick.graphs.style.StyleSheet 5 | import io.circe 6 | import io.circe.Decoder.Result 7 | import io.circe.generic.semiauto._ 8 | import io.circe.{Codec, Decoder, HCursor} 9 | 10 | final case class EditorConfiguration(palettes: Option[List[Palette]] = None) { 11 | lazy val styleSheets: List[StyleSheet] = 12 | palettes.getOrElse(List.empty).map(_.styleSheet) 13 | lazy val schemas: List[Schema[EditorSchemaHints]] = 14 | palettes.getOrElse(List.empty).map(_.schema) 15 | } 16 | 17 | object EditorConfiguration { 18 | implicit val schemaHintsCodec: Codec[EditorSchemaHints] = 19 | deriveCodec[EditorSchemaHints] 20 | 21 | implicit val configDecoder: Decoder[EditorConfiguration] = 22 | new Decoder[EditorConfiguration] { 23 | override def apply(c: HCursor): Result[EditorConfiguration] = for { 24 | palettes <- { 25 | import io.circe.generic.auto._ 26 | implicit val schemaDecoder: Decoder[Schema[EditorSchemaHints]] = 27 | com.flowtick.graphs.json.schema.JsonSchema.jsonSchemaDecoder 28 | c.downField("palettes").as[Option[List[Palette]]] 29 | } 30 | } yield EditorConfiguration(palettes) 31 | } 32 | 33 | def decode(schemasJson: String): Either[circe.Error, EditorConfiguration] = { 34 | io.circe.parser.decode[EditorConfiguration](schemasJson) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /editor/shared/src/main/scala/com/flowtick/graphs/editor/EditorGraph.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs.editor 2 | 3 | import com.flowtick.graphs.layout.GraphLayoutLike 4 | import com.flowtick.graphs.style.{StyleRef, StyleSheet} 5 | import com.flowtick.graphs.{Edge, Graph, Identifiable, Labeled, Node} 6 | import io.circe.Json 7 | 8 | trait EditorGraphElement { 9 | def id: String 10 | def data: Json 11 | def label: Option[String] 12 | def schemaRef: Option[String] 13 | } 14 | 15 | final case class EditorGraphNode( 16 | id: String, 17 | data: Json, 18 | stencil: Option[String], 19 | schemaRef: Option[String], 20 | label: Option[String] = None 21 | ) extends EditorGraphElement 22 | 23 | final case class EditorGraphEdge( 24 | id: String, 25 | data: Json, 26 | connector: Option[String], 27 | schemaRef: Option[String], 28 | label: Option[String] = None 29 | ) extends EditorGraphElement 30 | 31 | final case class EditorGraph( 32 | graph: Graph[EditorGraphEdge, EditorGraphNode], 33 | styleSheets: List[Either[String, StyleSheet]], 34 | layouts: List[Either[String, GraphLayoutLike]], 35 | schemas: List[Either[String, EditorModel.EditorSchema]] 36 | ) 37 | 38 | object EditorGraphNode { 39 | implicit val identifiableEditorNode = new Identifiable[EditorGraphNode] { 40 | override def apply(value: EditorGraphNode): String = value.id 41 | } 42 | 43 | implicit val editorNodeStyleRef: StyleRef[Node[EditorGraphNode]] = 44 | new StyleRef[Node[EditorGraphNode]] { 45 | override def id(element: Node[EditorGraphNode]): Option[String] = Some( 46 | element.id 47 | ) 48 | override def classList(element: Node[EditorGraphNode]): List[String] = 49 | element.value.stencil.toList 50 | } 51 | 52 | implicit val editorNodeLabel: Labeled[EditorGraphNode, String] = 53 | new Labeled[EditorGraphNode, String] { 54 | override def apply(node: EditorGraphNode): String = 55 | node.label.getOrElse("") 56 | } 57 | } 58 | 59 | object EditorGraphEdge { 60 | implicit val identifiableEditorEdge = new Identifiable[EditorGraphEdge] { 61 | override def apply(value: EditorGraphEdge): String = value.id 62 | } 63 | 64 | implicit val editorEdgeStyleRef: StyleRef[Edge[EditorGraphEdge]] = 65 | new StyleRef[Edge[EditorGraphEdge]] { 66 | override def id(element: Edge[EditorGraphEdge]): Option[String] = Some( 67 | element.id 68 | ) 69 | override def classList(element: Edge[EditorGraphEdge]): List[String] = 70 | element.value.connector.toList 71 | } 72 | 73 | implicit val editorEdgeLabel: Labeled[EditorGraphEdge, String] = 74 | new Labeled[EditorGraphEdge, String] { 75 | override def apply(edge: EditorGraphEdge): String = 76 | edge.label.getOrElse("") 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /editor/shared/src/main/scala/com/flowtick/graphs/editor/EditorGraphJsonFormat.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs.editor 2 | 3 | import com.flowtick.graphs.{Edge, Graph, Node} 4 | import io.circe.Decoder.Result 5 | import io.circe.{Decoder, Encoder, HCursor, Json, _} 6 | 7 | import scala.collection.mutable.ListBuffer 8 | import io.circe.generic.auto._ 9 | import com.flowtick.graphs.json.format.default._ 10 | import com.flowtick.graphs.layout.{DefaultGeometry, Geometry, GraphLayout, GraphLayoutLike} 11 | import com.flowtick.graphs.style.StyleSheet 12 | 13 | object EditorGraphJsonFormat { 14 | implicit def decodeEitherStringA[A](implicit 15 | decoderA: Decoder[A], 16 | decoderB: Decoder[String] 17 | ): Decoder[Either[String, A]] = decoderB.either(decoderA) 18 | 19 | implicit def encodeEitherStringA[A](implicit 20 | encoderA: Encoder[A], 21 | encoderB: Encoder[String] 22 | ): Encoder[Either[String, A]] = new Encoder[Either[String, A]] { 23 | override def apply(a: Either[String, A]): Json = a match { 24 | case Right(a) => encoderA(a) 25 | case Left(b) => encoderB(b) 26 | } 27 | } 28 | 29 | implicit val geometryCode = new Codec[Geometry] { 30 | override def apply(a: Geometry): Json = Json.obj( 31 | "x" -> Json.fromDoubleOrNull(a.x), 32 | "y" -> Json.fromDoubleOrNull(a.y), 33 | "height" -> Json.fromDoubleOrNull(a.height), 34 | "width" -> Json.fromDoubleOrNull(a.width) 35 | ) 36 | 37 | override def apply(c: HCursor): Result[Geometry] = for { 38 | x <- c.downField("x").as[Double] 39 | y <- c.downField("y").as[Double] 40 | width <- c.downField("width").as[Double] 41 | height <- c.downField("height").as[Double] 42 | } yield DefaultGeometry(x, y, width, height) 43 | } 44 | 45 | implicit val defaultEditorGraphEncoder: Encoder[EditorGraph] = 46 | new Encoder[EditorGraph] { 47 | import io.circe.syntax._ 48 | 49 | override def apply(editorGraph: EditorGraph): Json = { 50 | val fields = new ListBuffer[(String, Json)] 51 | fields.append( 52 | "graph" -> Json.obj( 53 | "nodes" -> editorGraph.graph.nodes.asJson, 54 | "edges" -> editorGraph.graph.edges.asJson 55 | ), 56 | "styleSheets" -> editorGraph.styleSheets.asJson, 57 | "layouts" -> editorGraph.layouts 58 | .flatMap(_.toOption) 59 | .flatMap(_.toGraphLayouts) 60 | .asJson, 61 | "schemas" -> editorGraph.schemas.asJson 62 | ) 63 | 64 | Json.fromFields(fields) 65 | } 66 | } 67 | 68 | implicit val defaultEditorGraphDecoder: Decoder[EditorGraph] = 69 | new Decoder[EditorGraph] { 70 | override def apply(json: HCursor): Result[EditorGraph] = for { 71 | nodes <- json 72 | .downField("graph") 73 | .downField("nodes") 74 | .as[List[Node[EditorGraphNode]]] 75 | 76 | edges <- json 77 | .downField("graph") 78 | .downField("edges") 79 | .as[Option[List[Edge[EditorGraphEdge]]]] 80 | 81 | styleSheets <- json 82 | .downField("styleSheets") 83 | .as[List[Either[String, StyleSheet]]] 84 | layouts <- json 85 | .downField("layouts") 86 | .as[List[Either[String, GraphLayout]]] 87 | schemas <- json 88 | .downField("schemas") 89 | .as[List[Either[String, EditorModel.EditorSchema]]] 90 | } yield EditorGraph( 91 | Graph 92 | .empty[EditorGraphEdge, EditorGraphNode] 93 | .withNodes(nodes) 94 | .withEdges(edges.getOrElse(List.empty)), 95 | styleSheets, 96 | layouts, 97 | schemas 98 | ) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /editor/shared/src/main/scala/com/flowtick/graphs/editor/EditorImageLoader.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs.editor 2 | 3 | import cats.effect.IO 4 | import cats.implicits._ 5 | import com.flowtick.graphs.style.StyleSheetLike 6 | 7 | class EditorImageLoader[Image](imageLoader: ImageLoader[Image]) extends EditorComponent { 8 | override def order: Double = 0.45 9 | 10 | override def init(model: EditorModel): IO[Unit] = { 11 | registerStyleSheetImages(model.styleSheet).void 12 | } 13 | 14 | override def eval: Eval = ctx => 15 | ctx.effect(this) { case Reset => 16 | registerStyleSheetImages(ctx.model.styleSheet).void 17 | } 18 | 19 | def registerStyleSheetImages( 20 | styleSheet: StyleSheetLike 21 | ): IO[List[Either[Throwable, Image]]] = 22 | styleSheet.images 23 | .map { case (key, imageSpec) => 24 | imageLoader.registerImage(key, imageSpec).attempt 25 | } 26 | .toList 27 | .sequence 28 | } 29 | -------------------------------------------------------------------------------- /editor/shared/src/main/scala/com/flowtick/graphs/editor/EditorMain.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs.editor 2 | 3 | import cats.effect.IO 4 | import cats.effect.kernel.Ref 5 | import cats.implicits._ 6 | import com.flowtick.graphs.editor.feature.{ModelUpdateFeature, RoutingFeature, UndoFeature} 7 | 8 | final case class EditorInstance( 9 | bus: EditorMessageBus, 10 | components: List[EditorComponent] 11 | ) 12 | 13 | trait EditorMain { 14 | def createEditor( 15 | additionalComponents: EditorMessageBus => List[EditorComponent] 16 | )(configuration: EditorConfiguration): IO[EditorInstance] = for { 17 | listeners <- Ref.of[IO, List[EditorComponent]](List.empty) 18 | log <- Ref.of[IO, List[EditorEvent]](List.empty) 19 | 20 | messageBus <- IO.pure( 21 | new EditorController( 22 | log, 23 | listeners, 24 | initial = EditorModel.fromConfig(configuration) 25 | ) 26 | ) 27 | 28 | components = List( 29 | new ModelUpdateFeature, 30 | new RoutingFeature, 31 | new UndoFeature 32 | ) ++ additionalComponents(messageBus) 33 | 34 | subscribed <- components 35 | .sortBy(_.order) 36 | .map(component => { 37 | messageBus.subscribe(component) *> IO(component) 38 | }) 39 | .sequence 40 | } yield EditorInstance(messageBus, subscribed) 41 | } 42 | -------------------------------------------------------------------------------- /editor/shared/src/main/scala/com/flowtick/graphs/editor/EditorMenuSpec.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs.editor 2 | 3 | import java.util.UUID 4 | import cats.effect.IO 5 | import cats.effect.unsafe.implicits.global 6 | import cats.implicits._ 7 | import com.flowtick.graphs.graphml.{GraphML, GraphMLGraph} 8 | import com.flowtick.graphs.view.ViewComponent 9 | 10 | final case class Action( 11 | title: String, 12 | shortCut: String, 13 | handler: Any => Unit, 14 | icon: Option[String] = None 15 | ) { 16 | def fullTitle = s"$title [$shortCut]" 17 | } 18 | 19 | sealed trait MenuType 20 | case object DropUp extends MenuType 21 | case object Toolbar extends MenuType 22 | 23 | final case class EditorMenuSpec( 24 | title: String, 25 | menuType: MenuType, 26 | actions: List[Action], 27 | icon: Option[String] = None 28 | ) 29 | 30 | trait EditorMenu extends EditorComponent { 31 | def messageBus: EditorMessageBus 32 | 33 | lazy val editorMenus: List[EditorMenuSpec] = List( 34 | EditorMenuSpec( 35 | "File", 36 | Toolbar, 37 | actions = List( 38 | Action("New File", "ctrl+n", triggerFileNew, Some("file")), 39 | Action("Open File", "ctrl+o", triggerFileOpen, Some("upload")), 40 | Action( 41 | "Export JSON", 42 | "ctrl+shift+e", 43 | triggerExportJson, 44 | icon = Some("file-download") 45 | ), 46 | Action("Export XML", "ctrl+e", triggerExportXML, Some("file-code")) 47 | ), 48 | icon = Some("file") 49 | ), 50 | EditorMenuSpec( 51 | "Edit", 52 | Toolbar, 53 | actions = List( 54 | Action("Undo", "ctrl+z", triggerUndo, icon = Some("undo")), 55 | Action("Insert Element", "ins", triggerAdd, icon = Some("plus-circle")), 56 | Action( 57 | "Select All", 58 | "alt+a", 59 | triggerSelectAll, 60 | icon = Some("object-group") 61 | ), 62 | Action( 63 | "Unselect", 64 | "alt+shift+a", 65 | triggerUnselect, 66 | icon = Some("object-ungroup") 67 | ), 68 | Action("Delete Selection", "del", triggerDelete, icon = Some("trash")), 69 | Action( 70 | "Connect Selection", 71 | "alt+c", 72 | toggleConnect, 73 | icon = Some("code-branch") 74 | ) 75 | ) 76 | ), 77 | EditorMenuSpec( 78 | "View", 79 | Toolbar, 80 | actions = List( 81 | Action( 82 | "Reset View", 83 | "ctrl+0", 84 | triggerResetView, 85 | icon = Some("search-location") 86 | ), 87 | Action("Show Palette", "f4", togglePalette, icon = Some("palette")), 88 | Action("Show Properties", "f2", toggleEdit, icon = Some("edit")) 89 | ) 90 | ) 91 | ) 92 | 93 | def initMenus: IO[Unit] 94 | def handleExported(exported: ExportedGraph): IO[Unit] 95 | def bindShortcut(action: Action): IO[Unit] 96 | def triggerFileOpen: Any => Unit 97 | 98 | override lazy val eval: Eval = ctx => 99 | ctx.effect(this) { case exported: ExportedGraph => 100 | handleExported(exported) 101 | } 102 | 103 | override def init(model: EditorModel): IO[Unit] = for { 104 | _ <- initMenus 105 | tryBindings <- bindShortcuts.attempt 106 | _ <- tryBindings match { 107 | case Right(_) => IO.unit 108 | case Left(error) => 109 | IO(println(s"unable to bind shortcuts? $error", error)) 110 | } 111 | } yield () 112 | 113 | def bindShortcuts: IO[Unit] = 114 | editorMenus.flatMap(_.actions).map(bindShortcut).sequence.void 115 | 116 | def toggleConnect: Any => Unit = _ => { 117 | messageBus 118 | .publish(EditorToggle(EditorToggle.connectKey, Some(true))) 119 | .unsafeToFuture() 120 | } 121 | 122 | def triggerFileNew: Any => Unit = _ => { 123 | messageBus.publish(Reset).unsafeToFuture() 124 | } 125 | 126 | def togglePalette: Any => Unit = _ => { 127 | messageBus 128 | .publish(EditorToggle(EditorToggle.paletteKey, None)) 129 | .unsafeToFuture() 130 | } 131 | 132 | def toggleEdit: Any => Unit = _ => { 133 | messageBus.publish(EditorToggle(EditorToggle.editKey, None)).unsafeToFuture() 134 | } 135 | 136 | def triggerUnselect: Any => Unit = _ => { 137 | messageBus.publish(Select(Set.empty)).unsafeToFuture() 138 | } 139 | 140 | def triggerResetView: Any => Unit = _ => { 141 | messageBus.publish(ResetTransformation).unsafeToFuture() 142 | } 143 | 144 | def triggerExportXML: Any => Unit = _ => { 145 | messageBus.publish(Export(GraphMLFormat)).unsafeToFuture() 146 | } 147 | 148 | def triggerExportJson: Any => Unit = _ => { 149 | messageBus.publish(Export(JsonFormat)).unsafeToFuture() 150 | } 151 | 152 | def triggerDelete: Any => Unit = _ => messageBus.publish(DeleteSelection).unsafeToFuture() 153 | def triggerUndo: Any => Unit = _ => messageBus.publish(Undo).unsafeToFuture() 154 | def triggerSelectAll: Any => Unit = _ => messageBus.publish(SelectAll).unsafeToFuture() 155 | 156 | def triggerAdd: Any => Unit = _ => { 157 | val id = UUID.randomUUID().toString 158 | messageBus 159 | .publish(AddNode(id, None)) 160 | .unsafeToFuture() 161 | } 162 | 163 | } 164 | -------------------------------------------------------------------------------- /editor/shared/src/main/scala/com/flowtick/graphs/editor/EditorMessageBus.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs.editor 2 | 3 | import cats.effect.IO 4 | import cats.implicits._ 5 | import cats.effect.kernel.Ref 6 | import com.flowtick.graphs.{Graph, Identifiable} 7 | import com.flowtick.graphs.view.MessageBus 8 | 9 | trait EditorMessageBus 10 | extends MessageBus[EditorComponent, EditorCommand, EditorEvent, EditorContext] 11 | 12 | final case class Notification(source: EditorComponent, event: EditorEvent) 13 | final case class EditorEffect(source: EditorComponent, effect: IO[Unit]) 14 | 15 | final case class EditorContext( 16 | event: EditorEvent, 17 | model: EditorModel, 18 | notifications: Vector[Notification] = Vector.empty, 19 | commands: Vector[EditorCommand] = Vector.empty, 20 | effects: Vector[EditorEffect] = Vector.empty 21 | ) { 22 | def transform(f: PartialFunction[EditorEvent, EditorContext]): EditorContext = 23 | if (f.isDefinedAt(event)) f(event) else this 24 | 25 | def transformIO( 26 | f: PartialFunction[EditorEvent, IO[EditorContext]] 27 | ): IO[EditorContext] = 28 | if (f.isDefinedAt(event)) f(event) else IO.pure(this) 29 | 30 | def effect( 31 | source: EditorComponent 32 | )(f: PartialFunction[EditorEvent, IO[Unit]]): IO[EditorContext] = 33 | if (f.isDefinedAt(event)) 34 | IO.pure(copy(effects = effects.:+(EditorEffect(source, f(event))))) 35 | else IO.pure(this) 36 | 37 | def addError(source: EditorComponent, error: Throwable): EditorContext = 38 | copy(effects = effects.:+(EditorEffect(source, IO.raiseError(error)))) 39 | 40 | def addNotification( 41 | source: EditorComponent, 42 | event: EditorEvent 43 | ): EditorContext = copy(notifications = { 44 | val notification = Notification(source, event) 45 | 46 | if (!notifications.contains(notification)) { 47 | notifications.:+(notification) 48 | } else notifications 49 | }) 50 | 51 | def addCommand(command: EditorCommand): EditorContext = 52 | copy(commands = commands.:+(command)) 53 | 54 | def updateModel(update: EditorModel => EditorModel): EditorContext = 55 | copy(model = update(model).copy(version = model.version + 1)) 56 | } 57 | 58 | class EditorController( 59 | logRef: Ref[IO, List[EditorEvent]], 60 | listenersRef: Ref[IO, List[EditorComponent]], 61 | initial: EditorModel 62 | ) extends EditorMessageBus { 63 | lazy val modelRef: Ref[IO, EditorModel] = Ref.unsafe[IO, EditorModel](initial) 64 | 65 | override def subscribe(component: EditorComponent): IO[EditorComponent] = 66 | for { 67 | _ <- listenersRef.getAndUpdate(component :: _) 68 | model <- modelRef.get 69 | _ <- component.init(model) 70 | } yield component 71 | 72 | override def notifyEvent( 73 | source: EditorComponent, 74 | event: EditorEvent 75 | ): IO[EditorContext] = 76 | listenersRef.get 77 | .map(_.filter(_ != source)) 78 | .flatMap(notifyListeners(event, _)) 79 | 80 | private def notifyListeners( 81 | event: EditorEvent, 82 | listeners: List[EditorComponent] 83 | ): IO[EditorContext] = 84 | (for { 85 | _ <- logRef.getAndUpdate(event :: _) 86 | graph <- modelRef.get 87 | context <- listeners.foldLeft(IO.pure(EditorContext(event, graph))) { case (current, next) => 88 | current 89 | .flatMap(next.eval) 90 | .redeemWith( 91 | error => 92 | IO.raiseError( 93 | new RuntimeException( 94 | s"unable to evaluate event $event in $next", 95 | error 96 | ) 97 | ), 98 | IO.pure 99 | ) 100 | } 101 | _ <- modelRef.set(context.model) 102 | _ <- context.effects 103 | .map(editorEffect => 104 | editorEffect.effect.redeemWith( 105 | error => 106 | IO.raiseError( 107 | new RuntimeException( 108 | s"unable to evaluate effect in ${editorEffect.source}", 109 | error 110 | ) 111 | ), 112 | IO.pure 113 | ) 114 | ) 115 | .sequence 116 | _ <- 117 | context.notifications 118 | .map(notification => notifyEvent(notification.source, notification.event)) 119 | .sequence 120 | _ <- 121 | if (context.commands.nonEmpty) publishAll(context.commands) else IO.unit 122 | } yield context).redeemWith( 123 | error => 124 | IO { 125 | println(s"error $error during notify") 126 | error.printStackTrace() 127 | } *> IO.raiseError(new RuntimeException(s"error during notify", error)), 128 | IO.pure 129 | ) 130 | 131 | override def publish(command: EditorCommand): IO[EditorContext] = for { 132 | listeners <- listenersRef.get 133 | notifyContext <- notifyListeners(command, listeners) 134 | } yield notifyContext 135 | 136 | override def publishAll( 137 | commands: Vector[EditorCommand] 138 | ): IO[Vector[EditorContext]] = for { 139 | listeners <- listenersRef.get 140 | contexts <- commands.map(notifyListeners(_, listeners)).sequence 141 | } yield contexts 142 | } 143 | 144 | object EditorController { 145 | def apply() = new EditorController( 146 | Ref.unsafe(List.empty), 147 | Ref.unsafe(List.empty), 148 | EditorModel(Graph.empty[EditorGraphEdge, EditorGraphNode]) 149 | ) 150 | } 151 | -------------------------------------------------------------------------------- /editor/shared/src/main/scala/com/flowtick/graphs/editor/EditorModel.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs.editor 2 | 3 | import cats.data.Validated.{Valid, _} 4 | import cats.data.ValidatedNel 5 | import com.flowtick.graphs.{Edge, Graph, Labeled, Node} 6 | import com.flowtick.graphs.graphml.{Datatype, GraphMLKey} 7 | import com.flowtick.graphs.json.schema.Schema 8 | import com.flowtick.graphs.layout.{GraphLayoutLike, GraphLayouts} 9 | import com.flowtick.graphs.style._ 10 | import com.flowtick.graphs.view.{ElementRef, ViewComponent, ViewContextLike} 11 | import io.circe.Json 12 | 13 | import scala.xml.NodeSeq 14 | 15 | final case class EditorModel( 16 | graph: Graph[EditorGraphEdge, EditorGraphNode], 17 | selection: Set[ElementRef] = Set.empty, 18 | connectSelection: Boolean = false, 19 | schema: EditorSchemaLike = EditorSchemas(), 20 | palette: EditorPaletteLike = EditorPalettes(), 21 | layout: GraphLayoutLike = GraphLayouts(), 22 | styleSheet: StyleSheetLike = StyleSheets(), 23 | version: Long = 0, 24 | config: EditorConfiguration = EditorConfiguration() 25 | )(implicit 26 | val nodeLabel: Labeled[EditorGraphNode, String], 27 | val nodeStyleRef: StyleRef[Node[EditorGraphNode]], 28 | val edgeLabel: Labeled[EditorGraphEdge, String], 29 | val edgeStyleRef: StyleRef[Edge[EditorGraphEdge]] 30 | ) extends ViewContextLike[EditorGraphEdge, EditorGraphNode] { 31 | def updateGraph( 32 | updated: Graph[EditorGraphEdge, EditorGraphNode] => Graph[ 33 | EditorGraphEdge, 34 | EditorGraphNode 35 | ] 36 | ): EditorModel = 37 | copy(graph = updated(graph)) 38 | 39 | def updateLayout(updated: GraphLayoutLike => GraphLayoutLike): EditorModel = 40 | copy(layout = updated(layout)) 41 | 42 | def updateStyleSheet(updated: StyleSheetLike => StyleSheetLike): EditorModel = 43 | copy(styleSheet = updated(styleSheet)) 44 | 45 | def updateSchema(updated: EditorSchemaLike => EditorSchemaLike): EditorModel = 46 | copy(schema = updated(schema)) 47 | 48 | override def toString: String = { 49 | s"""version = $version, connect = $connectSelection, selection = $selection""" 50 | } 51 | } 52 | 53 | final case class EditorSchemaHints( 54 | copyToLabel: Option[Boolean] = None, 55 | hideLabelProperty: Option[Boolean] = None, 56 | highlight: Option[String] = None, 57 | showJsonProperty: Option[Boolean] = None 58 | ) 59 | 60 | object EditorModel { 61 | def fromConfig(config: EditorConfiguration): EditorModel = 62 | EditorModel( 63 | graph = Graph.empty, 64 | schema = EditorSchemas(config.palettes.getOrElse(List.empty).map(_.schema)), 65 | layout = GraphLayouts(), 66 | styleSheet = StyleSheets(config.palettes.getOrElse(List.empty).map(_.styleSheet)), 67 | palette = EditorPalettes(config.palettes.getOrElse(List.empty)), 68 | config = config 69 | ) 70 | 71 | type EditorSchema = Schema[EditorSchemaHints] 72 | 73 | def jsonDataType(emptyOnError: Boolean)(implicit 74 | stringDatatype: Datatype[String] 75 | ): Datatype[Json] = new Datatype[Json] { 76 | override def serialize(value: Json, targetHint: Option[String]): NodeSeq = 77 | stringDatatype.serialize(value.noSpaces, targetHint) 78 | override def deserialize( 79 | from: NodeSeq, 80 | graphKeys: collection.Map[String, GraphMLKey], 81 | targetHint: Option[String] 82 | ): ValidatedNel[Throwable, Json] = 83 | stringDatatype 84 | .deserialize(from, graphKeys, targetHint) 85 | .map(io.circe.parser.decode[Json]) match { 86 | case Valid(Right(json)) => valid(json) 87 | case Valid(Left(error)) => 88 | if (emptyOnError) validNel(Json.obj()) 89 | else 90 | invalidNel( 91 | new IllegalArgumentException("could not parse json", error) 92 | ) 93 | case Invalid(errors) => 94 | if (emptyOnError) validNel(Json.obj()) 95 | else 96 | invalid( 97 | errors.prepend( 98 | new IllegalArgumentException("unable to parse xml node") 99 | ) 100 | ) 101 | } 102 | 103 | override def keys(targetHint: Option[String]): Seq[GraphMLKey] = 104 | stringDatatype.keys(targetHint) 105 | } 106 | } 107 | 108 | trait EditorComponent extends ViewComponent[EditorContext, EditorModel] 109 | -------------------------------------------------------------------------------- /editor/shared/src/main/scala/com/flowtick/graphs/editor/EditorPalette.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs.editor 2 | 3 | import com.flowtick.graphs.json.schema.Schema 4 | import com.flowtick.graphs.style.{ImageSpec, StyleSheet} 5 | 6 | /** @param id 7 | * the id of the stencil, this will be used as a reference to the style sheet 8 | * @param title 9 | * title to show 10 | * @param image 11 | * an image spec to be used in the palette, this is not referencing the stylesheet images because 12 | * the stencil style might actually not be an image, making a preview hard to reproduce 13 | * @param schemaRef 14 | * reference to the schema definition, not the full path, only the fragment 15 | */ 16 | final case class Stencil( 17 | id: String, 18 | title: String, 19 | image: Option[ImageSpec] = None, 20 | schemaRef: Option[String] = None 21 | ) 22 | 23 | final case class Connector( 24 | id: String, 25 | title: String, 26 | image: Option[ImageSpec] = None, 27 | schemaRef: Option[String] = None 28 | ) 29 | 30 | final case class ConnectorGroup(title: String, items: List[Connector]) 31 | final case class StencilGroup(title: String, items: List[Stencil]) 32 | 33 | final case class Palette( 34 | stencils: List[StencilGroup] = List.empty, 35 | connectors: List[ConnectorGroup] = List.empty, 36 | styleSheet: StyleSheet, 37 | schema: Schema[EditorSchemaHints] 38 | ) 39 | 40 | trait EditorPaletteLike { 41 | private lazy val stencilById: Map[String, Stencil] = 42 | palettes 43 | .flatMap(_.stencils) 44 | .flatMap(_.items) 45 | .map(stencil => (stencil.id, stencil)) 46 | .toMap 47 | 48 | private lazy val connectorsById: Map[String, Connector] = 49 | palettes 50 | .flatMap(_.connectors) 51 | .flatMap(_.items) 52 | .map(connector => (connector.id, connector)) 53 | .toMap 54 | 55 | def palettes: List[Palette] 56 | 57 | def stencils: Iterable[Stencil] = stencilById.values 58 | 59 | def connectors: Iterable[Connector] = connectorsById.values 60 | 61 | def findStencil(id: String): Option[Stencil] = stencilById.get(id) 62 | 63 | def findConnector(id: String): Option[Connector] = connectorsById.get(id) 64 | 65 | def stencilGroups: Iterable[StencilGroup] = palettes.view.flatMap(_.stencils) 66 | 67 | def connectorGroups: Iterable[ConnectorGroup] = 68 | palettes.view.flatMap(_.connectors) 69 | } 70 | 71 | final case class EditorPalettes(palettes: List[Palette] = List.empty) extends EditorPaletteLike 72 | -------------------------------------------------------------------------------- /editor/shared/src/main/scala/com/flowtick/graphs/editor/EditorSchema.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs.editor 2 | 3 | import com.flowtick.graphs.json.schema.Schema 4 | 5 | trait EditorSchemaLike { 6 | def schemas: List[Schema[EditorSchemaHints]] 7 | 8 | def merge(other: List[Schema[EditorSchemaHints]]): EditorSchemaLike 9 | 10 | lazy val definitions: Map[String, Schema[EditorSchemaHints]] = 11 | schemas.view 12 | .flatMap(_.definitionsCompat) 13 | .foldRight(Map.empty[String, Schema[EditorSchemaHints]])(_ ++ _) 14 | } 15 | 16 | final case class EditorSchemas(schemas: List[Schema[EditorSchemaHints]] = List.empty) 17 | extends EditorSchemaLike { 18 | override def merge(other: List[Schema[EditorSchemaHints]]): EditorSchemaLike = 19 | copy( 20 | schemas ++ other.filterNot(_.$id.exists(schemas.flatMap(_.$id).contains)) 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /editor/shared/src/main/scala/com/flowtick/graphs/editor/EditorView.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs.editor 2 | 3 | import cats.effect.IO 4 | import com.flowtick.graphs.view._ 5 | 6 | abstract class EditorView[T, E](messageBus: EditorMessageBus) 7 | extends GraphView[T, E, EditorGraphNode, EditorGraphEdge, EditorModel] 8 | with EditorComponent { 9 | override def handleSelect(element: ElementRef): Boolean => IO[Unit] = append => 10 | for { 11 | vm <- viewElements.get 12 | newSelections = 13 | if (vm.elements.contains(element)) Set(element) 14 | else Set.empty[ElementRef] 15 | _ <- messageBus.notifyEvent(this, Select(newSelections, append)) 16 | } yield () 17 | 18 | def handleDoubleClick: Any => IO[Unit] = (_: Any) => { 19 | messageBus.publish(EditorToggle(EditorToggle.editKey, Some(true))).void 20 | } 21 | 22 | def handleDrag(drag: Option[DragStart[T]]): IO[Unit] = (for { 23 | dragEvent <- drag.filter(value => Math.abs(value.deltaY) > 0 || Math.abs(value.deltaX) > 0) 24 | } yield messageBus.publish(MoveBy(dragEvent.deltaX, dragEvent.deltaY)).void).getOrElse(IO.unit) 25 | 26 | def handleEvents: Eval = ctx => 27 | ctx 28 | .effect(this) { 29 | case ResetTransformation => handleResetTransformation 30 | 31 | case Reset => init(ctx.model).void 32 | 33 | case setModel: SetModel => renderGraph(setModel.model) 34 | 35 | case selected: Selected => 36 | updateSelections(selected.oldSelection, selected.elements) 37 | 38 | case ElementUpdated(element, Deleted, _) => deleteElementRef(element) 39 | 40 | case ElementUpdated(ElementRef(id, EdgeElementType), _, _) => 41 | updateEdge(id, ctx.model).void 42 | case ElementUpdated(ElementRef(id, NodeElementType), _, _) => 43 | updateNode(id, ctx.model).void 44 | } 45 | .flatMap(handleCreateNode) 46 | 47 | override lazy val eval: Eval = handleEvents 48 | 49 | override val order: Double = 0.2 50 | 51 | def handleCreateNode: Eval = ctx => 52 | ctx.transformIO { case create: AddNode => 53 | for { 54 | p <- requirePage 55 | center = p.pageCenter 56 | } yield { 57 | ctx 58 | .copy(event = 59 | create.copy( 60 | x = create.x.orElse(Some(center.x)), 61 | y = create.y.orElse(Some(center.y)) 62 | ) 63 | ) 64 | } 65 | } 66 | 67 | override def init(model: EditorModel): IO[Unit] = createAndRender(model) 68 | } 69 | -------------------------------------------------------------------------------- /editor/shared/src/main/scala/com/flowtick/graphs/editor/ImageLoader.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs.editor 2 | 3 | import cats.effect.IO 4 | import com.flowtick.graphs.style.ImageSpec 5 | 6 | trait ImageLoader[T] { 7 | def registerImage(ref: String, imageSpec: ImageSpec): IO[T] 8 | def getImage(ref: String): Option[T] 9 | } 10 | 11 | object ImageLoader { 12 | def unescapeXml(xml: String): String = xml 13 | .replaceAll(">", ">") 14 | .replaceAll("<", "<") 15 | .replaceAll(" ", "") 16 | } 17 | -------------------------------------------------------------------------------- /editor/shared/src/main/scala/com/flowtick/graphs/editor/feature/PaletteFeature.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs.editor.feature 2 | 3 | import cats.effect.IO 4 | import cats.effect.unsafe.implicits.global 5 | import com.flowtick.graphs.editor._ 6 | 7 | import java.util.UUID 8 | import cats.effect.kernel.Ref 9 | import com.flowtick.graphs.view.ViewComponent 10 | 11 | trait PaletteFeature extends EditorComponent { 12 | def messageBus: EditorMessageBus 13 | def toggleView(enabled: Boolean): IO[Boolean] 14 | def initPalette(model: EditorModel): IO[Unit] 15 | 16 | override def order: Double = 0.5 17 | 18 | val currentStencilItemRef: Ref[IO, Option[Stencil]] = Ref.unsafe(None) 19 | val currentConnectorItemRef: Ref[IO, Option[Connector]] = Ref.unsafe(None) 20 | val visibleRef: Ref[IO, Option[Boolean]] = Ref.unsafe(None) 21 | 22 | override def eval: Eval = ctx => 23 | ctx 24 | .effect(this) { case EditorToggle(EditorToggle.paletteKey, enabled) => 25 | val show = if (enabled.isEmpty) { 26 | visibleRef.get 27 | .flatMap(visible => toggleView(visible.forall(state => !state))) 28 | } else toggleView(enabled.getOrElse(true)) 29 | 30 | show.flatMap(visible => visibleRef.set(Some(visible))) 31 | } 32 | .flatMap(_.transformIO { 33 | case addNode: AddNode => 34 | for { 35 | current <- currentStencilItemRef.get 36 | } yield current match { 37 | case Some(item) => 38 | ctx.copy(event = addNode.copy(stencilRef = Some(item.id))) 39 | case None => ctx 40 | } 41 | case addEdge: AddEdge => 42 | for { 43 | current <- currentConnectorItemRef.get 44 | } yield current match { 45 | case Some(item) => 46 | ctx.copy(event = addEdge.copy(connectorRef = Some(item.id))) 47 | case None => ctx 48 | } 49 | }) 50 | 51 | override def init(model: EditorModel): IO[Unit] = for { 52 | _ <- IO(toggleView(false)) 53 | _ <- initPalette(model) 54 | } yield () 55 | 56 | def selectPaletteItem(paletteItem: Stencil): Unit = { 57 | currentStencilItemRef.set(Some(paletteItem)).attempt.unsafeToFuture() 58 | } 59 | 60 | def selectConnectorItem(connectorItem: Connector): Unit = { 61 | currentConnectorItemRef.set(Some(connectorItem)).attempt.unsafeToFuture() 62 | } 63 | 64 | def createFromStencil(paletteItem: Stencil): Unit = { 65 | messageBus 66 | .publish(AddNode(UUID.randomUUID().toString, Some(paletteItem.id))) 67 | .attempt 68 | .unsafeToFuture() 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /editor/shared/src/main/scala/com/flowtick/graphs/editor/feature/RoutingFeature.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs.editor.feature 2 | 3 | import cats.effect.IO 4 | import com.flowtick.graphs.editor._ 5 | import com.flowtick.graphs.{Edge, Node} 6 | import com.flowtick.graphs.util.MathUtil.{LineSegment, Rectangle, Vector2} 7 | import com.flowtick.graphs.layout.{EdgePath, GraphLayoutLike} 8 | import com.flowtick.graphs.util.MathUtil 9 | import com.flowtick.graphs.view._ 10 | 11 | class RoutingFeature extends EditorComponent { 12 | 13 | override def order: Double = 0.2 14 | 15 | def edgePath(fromNode: Node[EditorGraphNode], toNode: Node[EditorGraphNode])( 16 | layout: GraphLayoutLike 17 | ): Option[EdgePath] = 18 | for { 19 | from <- layout.nodeGeometry(fromNode.id) 20 | to <- layout.nodeGeometry(toNode.id) 21 | } yield { 22 | val fromCenterX = from.x + from.width / 2 23 | val fromCenterY = from.y + from.height / 2 24 | 25 | val toCenterX = to.x + to.width / 2 26 | val toCenterY = to.y + to.height / 2 27 | 28 | val sourceCenter = Vector2(fromCenterX, fromCenterY) 29 | val targetCenter = Vector2(toCenterX, toCenterY) 30 | val edgeSegment = LineSegment(sourceCenter, targetCenter) 31 | 32 | val sourceRect = Rectangle( 33 | Vector2(from.x, from.y), 34 | Vector2(from.x + from.width, from.y + from.height) 35 | ) 36 | val targetRect = Rectangle( 37 | Vector2(to.x, to.y), 38 | Vector2(to.x + to.width, to.y + to.height) 39 | ) 40 | 41 | val sourcePort = 42 | MathUtil.rectIntersect(edgeSegment, sourceRect).map(_ - sourceCenter) 43 | val targetPort = 44 | MathUtil.rectIntersect(edgeSegment, targetRect).map(_ - targetCenter) 45 | 46 | EdgePath( 47 | sourcePort.map(_.x).getOrElse(0.0), 48 | sourcePort.map(_.y).getOrElse(0.0), 49 | targetPort.map(_.x).getOrElse(0.0), 50 | targetPort.map(_.y).getOrElse(0.0), 51 | List.empty 52 | ) 53 | } 54 | 55 | private def updateRouting( 56 | ctx: EditorContext, 57 | edge: Edge[EditorGraphEdge] 58 | ): EditorContext = { 59 | val fromNode = ctx.model.graph.findNode(edge.from) 60 | val toNode = ctx.model.graph.findNode(edge.to) 61 | 62 | val newLayout = for { 63 | from <- fromNode 64 | to <- toNode 65 | path <- edgePath(from, to)(ctx.model.layout) 66 | } yield ctx.model.layout.setEdgePath(edge.id, path) 67 | 68 | ctx 69 | .updateModel(_.updateLayout(current => newLayout.getOrElse(current))) 70 | .addNotification( 71 | this, 72 | ElementUpdated(ElementRef(edge.id, EdgeElementType), Internal) 73 | ) 74 | } 75 | 76 | override def eval: Eval = ctx => 77 | IO(ctx.transform { 78 | case ElementUpdated(ElementRef(id, EdgeElementType), Created, _) => 79 | val newEdge = ctx.model.graph.findEdge(id) 80 | newEdge.map(updateRouting(ctx, _)).getOrElse(ctx) 81 | 82 | case ElementUpdated(ElementRef(id, NodeElementType), _, _) => 83 | val edges = ctx.model.graph.incoming(id) ++ ctx.model.graph.outgoing(id) 84 | 85 | edges.foldLeft(ctx) { case (current, edge) => 86 | updateRouting(current, edge) 87 | } 88 | }) 89 | } 90 | -------------------------------------------------------------------------------- /editor/shared/src/main/scala/com/flowtick/graphs/editor/feature/UndoFeature.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs.editor.feature 2 | 3 | import cats.effect.IO 4 | import com.flowtick.graphs.editor._ 5 | import com.flowtick.graphs.view.ElementRef 6 | 7 | import scala.collection.mutable.ListBuffer 8 | 9 | class UndoFeature extends EditorComponent { 10 | val modelHistory = ListBuffer.empty[EditorModel] 11 | 12 | override def eval: Eval = ctx => 13 | ctx 14 | .effect(this) { 15 | case Reset => 16 | IO { 17 | modelHistory.prepend(ctx.model) 18 | } 19 | 20 | case ElementUpdated(ElementRef(_, _), updateType, _) if updateType != Internal => 21 | IO { 22 | if ( 23 | modelHistory.isEmpty || modelHistory.headOption 24 | .exists(lastModel => lastModel.version < ctx.model.version) 25 | ) { 26 | modelHistory.prepend(ctx.model) 27 | } 28 | } 29 | } 30 | .flatMap(_.transformIO { case Undo => 31 | IO { 32 | if (modelHistory.length > 1) { 33 | modelHistory.remove(0) 34 | modelHistory.headOption match { 35 | case Some(previousModel) => 36 | ctx.addCommand( 37 | SetModel(previousModel.copy(selection = Set.empty)) 38 | ) 39 | case None => ctx 40 | } 41 | 42 | } else ctx 43 | } 44 | }) 45 | } 46 | -------------------------------------------------------------------------------- /examples/html/examples.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | graphs examples 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /examples/js/src/main/scala/examples/ExamplesJs.scala: -------------------------------------------------------------------------------- 1 | package examples 2 | 3 | import com.flowtick.graphs.layout.GraphLayoutOps 4 | import com.flowtick.graphs.layout.elk.{ELKImport, ELkLayoutOpsJS} 5 | 6 | import scala.scalajs.js.annotation.{JSExport, JSExportTopLevel} 7 | 8 | trait ExampleApp { 9 | @JSExport 10 | def main(args: Array[String]): Unit = println("finished example.") 11 | } 12 | 13 | @JSExportTopLevel("dijkstra") 14 | object DijkstraExampleApp extends DijkstraExample with ExampleApp 15 | 16 | @JSExportTopLevel("bfs") 17 | object BfsExampleApp extends BfsExample with ExampleApp 18 | 19 | @JSExportTopLevel("cats") 20 | object CatsExampleApp extends CatsExample with ExampleApp 21 | 22 | @JSExportTopLevel("customGraph") 23 | object CustomGraphExampleApp extends CustomGraphExample with ExampleApp 24 | 25 | @JSExportTopLevel("dfs") 26 | object DfsExampleApp extends DfsExample with ExampleApp 27 | 28 | @JSExportTopLevel("graphml") 29 | object GraphMLRendererExampleApp extends GraphMLExample with ExampleApp 30 | 31 | @JSExportTopLevel("simple") 32 | object SimpleGraphExampleApp extends SimpleGraphExample with ExampleApp 33 | 34 | @JSExportTopLevel("topologicalSort") 35 | object TopologicalSortingExampleApp extends TopologicalSortingExample with ExampleApp 36 | 37 | @JSExportTopLevel("json") 38 | object JsonExampleApp extends JsonExample with ExampleApp 39 | 40 | @JSExportTopLevel("layout") 41 | object LayoutExampleApp extends LayoutExample with ExampleApp { 42 | override def layoutOps: GraphLayoutOps = new ELkLayoutOpsJS(new ELKImport) 43 | } 44 | -------------------------------------------------------------------------------- /examples/jvm/src/main/scala/ExamplesJvm.scala: -------------------------------------------------------------------------------- 1 | import cats.effect.{ExitCode, IO, IOApp} 2 | import com.flowtick.graphs.layout.{ForceDirectedLayout, GraphLayoutOps} 3 | import com.flowtick.graphs.style._ 4 | import com.flowtick.graphs.view.{SVGRendererJvm, SVGRendererOptions, SVGTranscoder, ViewContext} 5 | import examples._ 6 | 7 | import java.io.FileOutputStream 8 | 9 | object DijkstraExampleApp extends DijkstraExample with App 10 | object BfsExampleApp extends BfsExample with App 11 | object CatsExampleApp extends CatsExample with App 12 | object CustomGraphExampleApp extends CustomGraphExample with App 13 | object DfsExampleApp extends DfsExample with App 14 | object GraphMLExampleApp extends GraphMLExample with App 15 | object SimpleGraphExampleApp extends SimpleGraphExample with App 16 | object TopologicalSortingExampleApp extends TopologicalSortingExample with App 17 | object JsonExampleApp extends JsonExample with App 18 | 19 | object LayoutExampleApp extends LayoutExample with IOApp { 20 | import com.flowtick.graphs.defaults._ 21 | import com.flowtick.graphs.defaults.label._ 22 | import com.flowtick.graphs.style.defaults._ 23 | 24 | override def layoutOps: GraphLayoutOps = ForceDirectedLayout 25 | 26 | def writeToFile(path: String, content: Array[Byte]): IO[Unit] = IO { 27 | val out = new FileOutputStream(path) 28 | out.write(content) 29 | out.flush() 30 | out.close() 31 | } 32 | 33 | val renderer = 34 | SVGRendererJvm(options = SVGRendererOptions(padding = Some(100))) 35 | 36 | val nodeShape = NodeShape( 37 | fill = Some(Fill(color = Some("#aaa"))), 38 | shapeType = Some(ShapeType.RoundRectangle), 39 | image = Some("city"), 40 | labelStyle = Some( 41 | NodeLabel( 42 | textColor = Some("#ccc"), 43 | fontSize = Some("16"), 44 | border = Some(BorderStyle(color = "#222", width = Some(0.6))) 45 | ) 46 | ) 47 | ) 48 | 49 | val edgeShape = EdgeShape( 50 | arrows = Some(Arrows(source = Some("circle"), target = Some("standard"))) 51 | ) 52 | 53 | val styleSheet = StyleSheet() 54 | .withNodeDefault(nodeShape) 55 | .withEdgeDefault(edgeShape) 56 | .withImage( 57 | "city", 58 | ImageSpec( 59 | "https://openmoji.org/data/color/svg/1F3D9.svg", 60 | imageType = ImageType.url 61 | ) 62 | ) 63 | 64 | val renderImages = for { 65 | layoutResult <- IO.fromFuture(IO(layout)) 66 | svgString <- renderer 67 | .translateAndScaleView(0, 0, 2.0) 68 | .renderGraph(ViewContext(graph, layoutResult)) 69 | .flatMap(_.toXmlString) 70 | _ <- writeToFile( 71 | "target/layout_example.svg", 72 | svgString.getBytes("UTF-8") 73 | ) 74 | pngData <- SVGTranscoder.svgXmlToPng(svgString, None, None) 75 | _ <- writeToFile("target/layout_example.png", pngData) 76 | } yield () 77 | 78 | override def run(args: List[String]): IO[ExitCode] = for { 79 | _ <- renderImages 80 | } yield ExitCode.Success 81 | } 82 | -------------------------------------------------------------------------------- /examples/shared/src/main/scala/examples/BfsExample.scala: -------------------------------------------------------------------------------- 1 | package examples 2 | 3 | import com.flowtick.graphs._ 4 | import com.flowtick.graphs.algorithm._ 5 | import com.flowtick.graphs.defaults._ 6 | 7 | trait BfsExample { 8 | val graph = Graph.fromEdges( 9 | Set( 10 | "A" --> "D", 11 | "A" --> "C", 12 | "A" --> "B", 13 | "B" --> "E", 14 | "B" --> "F", 15 | "B" --> "G", 16 | "E" --> "H" 17 | ) 18 | ) 19 | 20 | println(graph.bfs("A").steps.map(_.node.id)) 21 | // List(A, B, C, D, E, F, G, H) 22 | } 23 | -------------------------------------------------------------------------------- /examples/shared/src/main/scala/examples/CatsExample.scala: -------------------------------------------------------------------------------- 1 | package examples 2 | 3 | import cats.kernel.Monoid 4 | import com.flowtick.graphs.Graph 5 | import com.flowtick.graphs.cat.instances._ 6 | import com.flowtick.graphs.defaults._ 7 | 8 | trait CatsExample { 9 | implicit val monoid = Monoid[Graph[Unit, String]] 10 | 11 | val someGraph: Graph[Unit, String] = 12 | Graph.fromEdges(Set("1" --> "2", "2" --> "3", "2" --> "4")) 13 | 14 | val anotherGraph: Graph[Unit, String] = 15 | Graph.fromEdges(Set("2" --> "3", "4" --> "3", "4" --> "5")) 16 | 17 | val combined: Graph[Unit, String] = monoid.combine(someGraph, anotherGraph) 18 | 19 | println(combined.edges) 20 | // Set(4 --> 3[()], 4 --> 5[()], 1 --> 2[()], 2 --> 3[()], 2 --> 4[()]) 21 | } 22 | -------------------------------------------------------------------------------- /examples/shared/src/main/scala/examples/CustomGraphExample.scala: -------------------------------------------------------------------------------- 1 | package examples 2 | 3 | import com.flowtick.graphs.defaults._ 4 | import com.flowtick.graphs.{Graph, Identifiable} 5 | 6 | trait CustomGraphExample { 7 | // #custom_graph 8 | final case class MyNode(id: String, someCustomProperty: String) 9 | 10 | implicit val myNodeId: Identifiable[MyNode] = new Identifiable[MyNode] { 11 | override def apply(value: MyNode): String = value.id 12 | } 13 | 14 | val graph = Graph.fromEdges( 15 | Set( 16 | MyNode("first_node", "My first node") --> MyNode( 17 | "second_node", 18 | "My second node" 19 | ) 20 | ) 21 | ) 22 | 23 | println(graph.edges) 24 | // #custom_graph 25 | } 26 | -------------------------------------------------------------------------------- /examples/shared/src/main/scala/examples/DfsExample.scala: -------------------------------------------------------------------------------- 1 | package examples 2 | 3 | import com.flowtick.graphs._ 4 | import com.flowtick.graphs.algorithm._ 5 | import com.flowtick.graphs.defaults._ 6 | 7 | trait DfsExample { 8 | val graph = Graph.fromEdges( 9 | Set( 10 | "1" --> "2", 11 | "1" --> "9", 12 | "2" --> "6", 13 | "2" --> "3", 14 | "3" --> "5", 15 | "3" --> "4", 16 | "6" --> "7", 17 | "6" --> "8" 18 | ) 19 | ) 20 | 21 | println(graph.dfs("1").steps.map(_.node.id)) 22 | // List(1, 9, 2, 6, 8, 7, 3, 5, 4) 23 | } 24 | -------------------------------------------------------------------------------- /examples/shared/src/main/scala/examples/DijkstraExample.scala: -------------------------------------------------------------------------------- 1 | package examples 2 | 3 | import com.flowtick.graphs.Graph 4 | import com.flowtick.graphs.algorithm._ 5 | import com.flowtick.graphs.defaults._ 6 | 7 | object DijkstraGraph { 8 | // example taken from https://de.wikipedia.org/wiki/Dijkstra-Algorithmus 9 | // #cities 10 | val cities: Graph[Int, String] = Graph.fromEdges( 11 | Set( 12 | "Frankfurt" --> (85, "Mannheim"), 13 | "Frankfurt" --> (217, "Wuerzburg"), 14 | "Frankfurt" --> (173, "Kassel"), 15 | "Mannheim" --> (80, "Karlsruhe"), 16 | "Wuerzburg" --> (186, "Erfurt"), 17 | "Wuerzburg" --> (103, "Nuernberg"), 18 | "Stuttgart" --> (183, "Nuernberg"), 19 | "Kassel" --> (502, "Muenchen"), 20 | "Nuernberg" --> (167, "Muenchen"), 21 | "Karlsruhe" --> (250, "Augsburg"), 22 | "Augsburg" --> (84, "Muenchen") 23 | ) 24 | ) 25 | // #cities 26 | } 27 | 28 | trait DijkstraExample { 29 | println(DijkstraGraph.cities.dijkstra.shortestPath("Frankfurt", "Muenchen")) 30 | // ListBuffer(Frankfurt --> Wuerzburg[217], Wuerzburg --> Nuernberg[103], Nuernberg --> Muenchen[167]) 31 | } 32 | -------------------------------------------------------------------------------- /examples/shared/src/main/scala/examples/GraphMLExample.scala: -------------------------------------------------------------------------------- 1 | package examples 2 | 3 | trait GraphMLExample { 4 | { 5 | // #simple-graphml 6 | import com.flowtick.graphs.Graph 7 | import com.flowtick.graphs.defaults._ 8 | import com.flowtick.graphs.defaults.label._ 9 | import com.flowtick.graphs.graphml._ 10 | import com.flowtick.graphs.graphml.generic._ 11 | 12 | val simple: Graph[Unit, String] = 13 | Graph.fromEdges(Set("A" --> "B", "B" --> "C", "D" --> "A")) 14 | 15 | val graphML = simple.asGraphML().xml 16 | val loaded = FromGraphML[Int, String](graphML.toString) 17 | // #simple-graphml 18 | } 19 | 20 | { 21 | // #custom-node-graphml 22 | import com.flowtick.graphs.graphml._ 23 | import com.flowtick.graphs.graphml.generic._ 24 | import scala.xml.NodeSeq 25 | 26 | final case class MyNode(value: Int) 27 | 28 | val customGraph: GraphMLGraph[Unit, MyNode] = 29 | GraphML.fromEdges( 30 | Set( 31 | ml(MyNode(1), id = Some("one")) --> ml(MyNode(2), id = Some("two")) 32 | ) 33 | ) 34 | 35 | val xml: NodeSeq = ToGraphML[Unit, MyNode](customGraph) 36 | println(xml) 37 | val loaded = FromGraphML[Unit, MyNode](xml.toString) 38 | println(loaded) 39 | // #custom-node-graphml 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /examples/shared/src/main/scala/examples/JsonExample.scala: -------------------------------------------------------------------------------- 1 | package examples 2 | 3 | trait JsonExample { 4 | { 5 | // #json_simple 6 | import com.flowtick.graphs._ 7 | import com.flowtick.graphs.defaults._ 8 | import com.flowtick.graphs.json.format.default._ 9 | import io.circe._ 10 | import io.circe.syntax._ 11 | import io.circe.parser._ 12 | 13 | val graph: Graph[Unit, String] = Graph.fromEdges( 14 | Set( 15 | "A" --> "D", 16 | "A" --> "C", 17 | "A" --> "B", 18 | "B" --> "E", 19 | "B" --> "F", 20 | "B" --> "G", 21 | "E" --> "H" 22 | ) 23 | ) 24 | 25 | val json: Json = graph.asJson 26 | val parsed: Either[Error, Graph[Unit, String]] = decode[Graph[Unit, String]](json.noSpaces) 27 | 28 | require(parsed == Right(graph)) 29 | // #json_simple 30 | println(json.noSpaces) 31 | } 32 | 33 | { 34 | // #json_custom 35 | import com.flowtick.graphs._ 36 | import com.flowtick.graphs.defaults._ 37 | import com.flowtick.graphs.json.format.default._ 38 | import io.circe._ 39 | import io.circe.generic.auto._ 40 | import io.circe.syntax._ 41 | import io.circe.parser._ 42 | 43 | case class MyNode(id: String, value: Double) 44 | 45 | implicit val myNodeId: Identifiable[MyNode] = (value: MyNode) => value.id 46 | 47 | val graph: Graph[Unit, MyNode] = Graph.fromEdges( 48 | Set( 49 | MyNode("1", 42) --> MyNode("2", 43) 50 | ) 51 | ) 52 | 53 | val json: Json = graph.asJson 54 | val parsed = decode[Graph[Unit, MyNode]](json.noSpaces) 55 | 56 | require(parsed == Right(graph)) 57 | // #json_custom 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /examples/shared/src/main/scala/examples/LayoutExample.scala: -------------------------------------------------------------------------------- 1 | package examples 2 | 3 | import com.flowtick.graphs.layout.{GraphLayoutLike, GraphLayoutOps} 4 | 5 | import scala.concurrent.ExecutionContext.Implicits.global 6 | import scala.concurrent.Future 7 | 8 | trait LayoutExample { 9 | def layoutOps: GraphLayoutOps 10 | 11 | // #layout_simple 12 | import com.flowtick.graphs._ 13 | import com.flowtick.graphs.defaults.label._ 14 | 15 | lazy val graph: Graph[Int, String] = DijkstraGraph.cities 16 | lazy val layout: Future[GraphLayoutLike] = layoutOps.layout(graph) 17 | 18 | layout.onComplete(println) 19 | // #layout_simple 20 | } 21 | -------------------------------------------------------------------------------- /examples/shared/src/main/scala/examples/SimpleGraphExample.scala: -------------------------------------------------------------------------------- 1 | package examples 2 | 3 | trait SimpleGraphExample { 4 | // #simple_graph 5 | import com.flowtick.graphs._ 6 | import com.flowtick.graphs.defaults._ 7 | 8 | val graph: Graph[Unit, String] = 9 | Graph.fromEdges(Set("A" --> "B", "B" --> "C", "D" --> "A")) 10 | 11 | println(graph.edges) 12 | // #simple_graph 13 | } 14 | -------------------------------------------------------------------------------- /examples/shared/src/main/scala/examples/TopologicalSortingExample.scala: -------------------------------------------------------------------------------- 1 | package examples 2 | 3 | import com.flowtick.graphs._ 4 | import com.flowtick.graphs.algorithm._ 5 | import com.flowtick.graphs.defaults._ 6 | 7 | trait TopologicalSortingExample { 8 | lazy val graph = Graph.fromEdges(Set("A" --> "B", "B" --> "C", "D" --> "A")) 9 | 10 | lazy val clothingDependencies = Graph.fromEdges( 11 | Set( 12 | "Underpants" --> "Pants", 13 | "Pants" --> "Coat", 14 | "Pullover" --> "Coat", 15 | "Undershirt" --> "Pullover", 16 | "Pants" --> "Shoes", 17 | "Socks" --> "Shoes" 18 | ) 19 | ) 20 | 21 | println(graph.topologicalSort) 22 | // List(D, A, B, C) 23 | 24 | println(clothingDependencies.topologicalSort) 25 | // List(Undershirt, Pullover, Underpants, Pants, Coat, Socks, Shoes) 26 | } 27 | -------------------------------------------------------------------------------- /graphml/js/src/test/scala/com/flowtick/graphs/JsGraphMLSerializationSpec.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs 2 | 3 | import com.flowtick.graphs.graphml._ 4 | import com.flowtick.graphs.graphml.generic._ 5 | 6 | import org.scalatest.flatspec.AnyFlatSpec 7 | import org.scalatest.matchers.should.Matchers 8 | 9 | class JsGraphMLSerializationSpec extends AnyFlatSpec with Matchers { 10 | val testGraph: GraphMLGraph[Unit, Unit] = 11 | GraphML.fromEdges(Set(ml((), Some("A")) --> ml((), Some("B")))) 12 | 13 | val testDataType = GraphMLDatatype[Unit, Unit] 14 | 15 | "GraphRenderer" should "render default graph" in { 16 | testDataType.serialize(testGraph, None).headOption match { 17 | case Some(xml) => 18 | xml.headOption shouldBe defined 19 | xml.headOption.foreach(_.label should be("graphml")) 20 | xml.descendant.count(_.label == "node") should be(2) 21 | xml.descendant.count(_.label == "edge") should be(1) 22 | 23 | xml.descendant 24 | .filter(_.label == "node") 25 | .flatMap( 26 | _.attribute("id").map(_.text) 27 | ) should contain theSameElementsAs List("B", "A") 28 | xml.descendant 29 | .filter(_.label == "edge") 30 | .flatMap(_.attribute("id").map(_.text)) should be(List("A-B")) 31 | case None => fail 32 | } 33 | } 34 | 35 | it should "import graph from XML" in { 36 | testDataType.serialize(testGraph, None).headOption match { 37 | case Some(xml) => 38 | val loaded = FromGraphML[Unit, Unit](xml.toString()).getOrElse(fail()) 39 | loaded.graph.edges.size should be(1) 40 | loaded.graph.nodes.size should be(2) 41 | case None => fail 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /graphml/jvm/src/test/resources/image_node.graphml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAADeElEQVR42tWaWUhUURjHjy1otlhZ 32 | ECRRvVRQD0UPFbRAFEQPUU+99hiFKNRLED0VFEUl5DAuqZiJSJtlklmZe66jg5m2ibmko1buy8z5 33 | 9525NjTJXGfu3Nudc+DAwHDP/H/nft935vu+w9jMQAILh5XFOi2s2pnIRriVIZQm6Zp0WVgdaYxH 34 | Gotgfw8ksxiXlTWGmmhfk7TahWbPzssk3gNhYU1COxNmI5v4P5O0xzFh87ICkOXUslB02AAce5wZ 35 | +iMZq2lGGwqhP8CD7eA1F4HeKoC7aDqBnlLwqvPgOZtDECA1EvzFMaAlCRjpxJzjZxvQeAP86QHw 36 | 5AUmAdxfD152FugoAJzj0DwmBoGPWeBFJ8HTogwESJoP/mQvYLsKDDbDkOGaArpegVfEgWdv1Bng 37 | 8R7gQyow7oDhY8AOXntJCQC6m5B4E2S7sN8Ghjv0E+2oA6++QE6+SUcTKj4FnruVPof5XuDhTqDh 38 | CvCjJUDFnKJUJXjlOfKnDeoihW8Ihw8UAP22majRCtRfpvC4Qx0mZ4t7F9FX40MzhdSeEvDyWPB7 39 | MeqixfdlZ4BvhYpvOGq1ADTMFjH0lULgdfKJ3WRO83wLyFrnForuYqCzCLz0NHjmGnXRuduU84PE 40 | zjYxLQCOenUrEDHfngCet1/xjUBDoHiGnkXTTdqYL3P4iCaAOv9NeqwXeG8Fzz9Mh9PCOQ6940Br 41 | OkW0/gCcXAuAL1v253AigV4mRicu2vOA6TGNUUoTQHVQkdHrb0LqoiDDrBaA3neyA1TpCBBpBkCl 42 | 5ADfK3QEWGwGQLnkAD1l+gHcXWIGQKnsACU6Aiw1AaD7rX4AacvMACiWHKDrjY4AUWYAvJYcgBIR 43 | /QCWmwHwUnIAkY/qBZC+wgSAT9mhA0CbqaEuFOauRMB2DRhuDxJgZeCiR7so576lFBBUqiF+FrZo 44 | gUe7lARcLGwUwHgf0Jw4UyiYZ1BxVyyct49+6I6SzPsDkBGtnkO3pIDnH9JU3QiuvC5EPjuolNYn 45 | BvwHmBoC2jLBC46qVzH+a4NDCHl+RCmZTP76B2AVMD0KfM4BLzwBnhIRwh0aMVPCvR1PmAYlNYa0 46 | mEQHXOomn2jfSwtgZTWi0R0vcaM7lomLE+LugYRNbpv7qoHnsoeFNUkl3sLWet9YoTch7h6I9r1w 47 | jlB0WGHzwmw8O0/jN3Tos4D5mzPuAAAAAElFTkSuQmCC 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /graphml/jvm/src/test/scala/com/flowtick/graphs/graphml/GraphMLNodeDatatypeSpec.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs.graphml 2 | 3 | import cats.data.Validated.Valid 4 | import cats.data.ValidatedNel 5 | import com.flowtick.graphs.layout.DefaultGeometry 6 | import com.flowtick.graphs.style.{BorderStyle, Fill, NodeLabel, NodeShape} 7 | import org.scalatest.flatspec.AnyFlatSpec 8 | import org.scalatest.matchers.should.Matchers 9 | 10 | import scala.xml.NodeSeq 11 | import generic._ 12 | 13 | case class SomeNodeValue(one: String, two: String) 14 | 15 | class GraphMLNodeDatatypeSpec extends AnyFlatSpec with Matchers { 16 | def prettyXml(xml: scala.xml.Node) = 17 | new scala.xml.PrettyPrinter(80, 4).format(xml) 18 | 19 | it should "serialize a generic GraphML node" in { 20 | val fooDataType = GraphMLNodeDatatype[SomeNodeValue] 21 | 22 | val targetHint = Some("node") 23 | val nodeShape = NodeShape( 24 | fill = Some(Fill(color = Some("#FFFFFF"), transparent = Some(false))), 25 | borderStyle = Some(BorderStyle("#000000", Some("line"), Some(1.0))), 26 | labelStyle = Some(NodeLabel()), 27 | shapeType = Some("rectangle") 28 | ) 29 | 30 | val serialized: NodeSeq = fooDataType.serialize( 31 | GraphMLNode( 32 | id = "test", 33 | value = SomeNodeValue("foo", "bar"), 34 | shape = Some(nodeShape), 35 | geometry = Some(DefaultGeometry(0.0, 0.0, 30.0, 30.0)), 36 | labelValue = Some("test") 37 | ), 38 | targetHint 39 | ) 40 | 41 | val expectedXml = 42 | foo 43 | bar 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | serialized.headOption match { 61 | case Some(node) => prettyXml(node) should be(prettyXml(expectedXml)) 62 | case _ => fail() 63 | } 64 | 65 | val deserialized: ValidatedNel[Throwable, GraphMLNode[SomeNodeValue]] = 66 | fooDataType.deserialize( 67 | serialized, 68 | fooDataType.keys(targetHint).map(key => (key.id, key)).toMap, 69 | targetHint 70 | ) 71 | deserialized.map(_.value) should be(Valid(SomeNodeValue("foo", "bar"))) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /graphml/shared/src/main/scala/com/flowtick/graphs/graphml/GraphMLGraph.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs.graphml 2 | 3 | import com.flowtick.graphs.layout.{DefaultGeometry, EdgePath, Geometry} 4 | import com.flowtick.graphs.{Edge, Graph, Identifiable, Node, Relation} 5 | import com.flowtick.graphs.style._ 6 | 7 | final case class GraphMLKey( 8 | id: String, 9 | name: Option[String] = None, 10 | typeHint: Option[String] = None, 11 | targetHint: Option[String] = None, 12 | yfilesType: Option[String] = None, 13 | graphsType: Option[String] = None 14 | ) {} 15 | 16 | final case class GraphMLResource( 17 | id: String, 18 | value: String, 19 | typeHint: Option[String] 20 | ) 21 | 22 | sealed trait GraphMLElement[V] { 23 | def id: String 24 | def value: V 25 | def label: Option[LabelStyle] 26 | def fill: Option[FillLike] 27 | } 28 | 29 | final case class GraphMLNode[N]( 30 | id: String, 31 | value: N, 32 | shape: Option[NodeShape] = None, 33 | geometry: Option[Geometry] = None, 34 | labelValue: Option[String] 35 | ) extends GraphMLElement[N] { 36 | def updateNodeGeometry( 37 | fx: Double => Double, 38 | fy: Double => Double 39 | ): GraphMLNode[N] = 40 | copy(geometry = for { 41 | geo <- geometry 42 | } yield DefaultGeometry(fx(geo.x), fy(geo.y), geo.width, geo.height)) 43 | 44 | def updateNodeLabel(textValue: String): GraphMLNode[N] = 45 | copy(labelValue = Some(textValue)) 46 | 47 | def updateNodeColor(color: String): GraphMLNode[N] = 48 | copy(shape = for { 49 | shape <- shape 50 | } yield shape.copy(fill = shape.fill.map(_.copy(color = Some(color))))) 51 | 52 | def updateValue(update: N => N): GraphMLNode[N] = 53 | copy(value = update(value)) 54 | 55 | override def label: Option[LabelStyle] = shape.flatMap(_.labelStyle) 56 | override def fill: Option[FillLike] = shape.flatMap(_.fill) 57 | } 58 | 59 | final case class GraphMLEdge[V]( 60 | id: String, 61 | value: V, 62 | source: Option[String], 63 | target: Option[String], 64 | shape: Option[EdgeShape] = None, 65 | schemaRef: Option[String] = None, 66 | labelValue: Option[String] = None, 67 | path: Option[EdgePath] = None 68 | ) extends GraphMLElement[V] { 69 | override def label: Option[LabelStyle] = shape.flatMap(_.labelStyle) 70 | override def fill: Option[FillLike] = 71 | shape.map(shape => Fill(shape.edgeStyle.map(_.color))) 72 | 73 | def updateEdgeLabel(textValue: String): GraphMLEdge[V] = 74 | copy(labelValue = Some(textValue)) 75 | 76 | def updateValue(update: V => V): GraphMLEdge[V] = 77 | copy(value = update(value)) 78 | } 79 | 80 | final case class GraphMLGraph[E, N]( 81 | graph: Graph[GraphMLEdge[E], GraphMLNode[N]], 82 | meta: GraphMLMeta 83 | ) { 84 | def addResource(resource: GraphMLResource): GraphMLGraph[E, N] = 85 | copy(meta = meta.copy(resources = meta.resources :+ resource)) 86 | 87 | lazy val resourcesById: Map[String, GraphMLResource] = meta.resources.map { resource => 88 | resource.id -> resource 89 | }.toMap 90 | } 91 | 92 | final case class GraphMLMeta( 93 | id: Option[String] = None, 94 | keys: Seq[GraphMLKey] = Seq.empty, 95 | resources: Seq[GraphMLResource] = Seq.empty 96 | ) 97 | 98 | object GraphML { 99 | def empty[E, N](implicit 100 | nodeId: Identifiable[GraphMLNode[N]], 101 | edgeId: Identifiable[GraphMLEdge[E]] 102 | ): GraphMLGraph[E, N] = GraphMLGraph[E, N]( 103 | Graph.empty[GraphMLEdge[E], GraphMLNode[N]], 104 | GraphMLMeta() 105 | ) 106 | 107 | def apply[E, N]( 108 | id: String, 109 | edges: Iterable[Edge[GraphMLEdge[E]]], 110 | nodes: Iterable[Node[GraphMLNode[N]]] = Iterable.empty, 111 | keys: Seq[GraphMLKey] = Seq.empty 112 | )(implicit 113 | nodeId: Identifiable[GraphMLNode[N]], 114 | edgeId: Identifiable[GraphMLEdge[E]] 115 | ): GraphMLGraph[E, N] = { 116 | GraphMLGraph(Graph(edges = edges, nodes = nodes), GraphMLMeta(keys = keys)) 117 | } 118 | 119 | def fromEdges[E, N]( 120 | edges: Iterable[Relation[GraphMLEdge[E], GraphMLNode[N]]] 121 | )(implicit 122 | nodeId: Identifiable[GraphMLNode[N]], 123 | edgeId: Identifiable[GraphMLEdge[E]] 124 | ): GraphMLGraph[E, N] = 125 | GraphMLGraph(Graph.fromEdges(edges), GraphMLMeta()) 126 | } 127 | -------------------------------------------------------------------------------- /json/jvm/src/test/scala/com/flowtick/graphs/json/JsonExporterSpec.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs.json 2 | 3 | import cats.data.Validated.Valid 4 | import com.flowtick.graphs.json.schema.Schema 5 | import com.flowtick.graphs.{Graph, Node} 6 | import io.circe.Json 7 | import org.scalatest.flatspec.AnyFlatSpec 8 | import org.scalatest.matchers.should.Matchers 9 | 10 | class JsonExporterSpec extends AnyFlatSpec with Matchers { 11 | import com.flowtick.graphs.defaults._ 12 | 13 | type EdgeType = Unit 14 | type SchemaType = Unit 15 | 16 | val numberGraph: Graph[EdgeType, Int] = 17 | Graph.fromEdges(Set(1 --> 2)).withNode(Node.of(3)) 18 | 19 | "JSON export" should "export graph with schema" in { 20 | implicit val nodeJson = new JsonWithSchema[Int, SchemaType] { 21 | override def json(value: Int): Json = 22 | Json.obj("value" -> Json.fromInt(value)) 23 | override def schema(value: Int): Schema[Unit] = 24 | if (value == 1) Schema($id = Some("root")) 25 | else Schema($id = Some("number")) 26 | } 27 | 28 | implicit val reference = new Reference[Unit] { 29 | override def name(relation: EdgeType): Option[String] = Some("child") 30 | } 31 | 32 | val exported = JsonExporter.exportJson[EdgeType, Int, SchemaType]( 33 | numberGraph, 34 | Schema($id = Some("root")) 35 | ) 36 | 37 | exported should be( 38 | Valid( 39 | Json.obj( 40 | "value" -> Json.fromInt(1), 41 | "child" -> Json.obj( 42 | "value" -> Json.fromInt(2) 43 | ) 44 | ) 45 | ) 46 | ) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /json/jvm/src/test/scala/com/flowtick/graphs/json/JsonSpec.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs.json 2 | 3 | import com.flowtick.graphs.defaults._ 4 | import com.flowtick.graphs.{Graph, Node} 5 | import io.circe._ 6 | import io.circe.syntax._ 7 | import io.circe.parser._ 8 | import org.scalatest.diagrams.Diagrams 9 | import org.scalatest.flatspec.AnyFlatSpec 10 | import org.scalatest.matchers.should.Matchers 11 | 12 | class JsonSpec extends AnyFlatSpec with Matchers with Diagrams { 13 | val numberGraph: Graph[Unit, Int] = 14 | Graph.fromEdges(Set(1 --> 2)).withNode(Node.of(3)) 15 | 16 | "JSON support" should "encode and decode int graph" in { 17 | import com.flowtick.graphs.json.format.default._ 18 | 19 | val graphJson: Json = numberGraph.asJson 20 | val parsed: Either[Error, Graph[Unit, Int]] = decode[Graph[Unit, Int]](graphJson.noSpaces) 21 | 22 | parsed should be(Right(numberGraph)) 23 | } 24 | 25 | it should "parse json without meta" in { 26 | val numberGraphJson = s""" 27 | |{ 28 | | "nodes" : [ 29 | | { 30 | | "id" : "1", 31 | | "value" : 1 32 | | }, 33 | | { 34 | | "id" : "2", 35 | | "value" : 2 36 | | }, 37 | | { 38 | | "id" : "3", 39 | | "value" : 3 40 | | } 41 | | ], 42 | | "edges" : [ 43 | | { 44 | | "id" : "1-()-2", 45 | | "value" : null, 46 | | "from" : "1", 47 | | "to" : "2" 48 | | } 49 | | ] 50 | |} 51 | | 52 | |""".stripMargin 53 | 54 | import com.flowtick.graphs.json.format.default._ 55 | 56 | val parsed = decode[Graph[Unit, Int]](numberGraphJson) 57 | parsed should be(Right(numberGraph)) 58 | } 59 | 60 | it should "parse empty graph" in { 61 | val emptyGraph = s""" 62 | |{ 63 | | "meta": null, 64 | | "nodes": [], 65 | | "edges": [] 66 | |} 67 | |""".stripMargin 68 | 69 | import com.flowtick.graphs.json.format.default._ 70 | 71 | val parsed = decode[Graph[Unit, Int]](emptyGraph) 72 | parsed should be(Right(Graph.empty[Unit, Int])) 73 | } 74 | 75 | it should "treat unit as null if option is imported" in { 76 | import com.flowtick.graphs.json.format.default._ 77 | 78 | val parsed = 79 | Graph.empty[Unit, Int].addEdge((), 1, 2).asJson 80 | 81 | val edgesJson = parsed.hcursor 82 | .downField("edges") 83 | .as[List[Json]] 84 | 85 | edgesJson 86 | .getOrElse(fail("edges cant be parsed")) 87 | .headOption 88 | .flatMap(_.asObject) 89 | .flatMap(_("value")) should be(Some(Json.Null)) 90 | } 91 | 92 | it should "parse embedded graph" in { 93 | val emptyGraph = s""" 94 | |{ 95 | | "nodes": [1,2], 96 | | "edges": [{ "id": "1-none-2", "from": "1", "to": "2"}] 97 | |} 98 | |""".stripMargin 99 | import com.flowtick.graphs.json.format.embedded._ 100 | 101 | val expected = Graph.empty[Option[Unit], Int].addEdge(None, 1, 2) 102 | 103 | decode[Graph[Option[Unit], Int]](emptyGraph) match { 104 | case Right(parsed) => 105 | parsed.edgeId should be(expected.edgeId) 106 | parsed should equal(expected) 107 | case Left(error) => fail(error) 108 | } 109 | } 110 | 111 | it should "create embedded graph json" in { 112 | val expectedGraph = s""" 113 | |{ 114 | | "nodes": [1,2], 115 | | "edges": [{ "id": "1-none-2", "from": "1", "to": "2", "value": null}] 116 | |} 117 | |""".stripMargin 118 | import com.flowtick.graphs.json.format.embedded._ 119 | 120 | val json = Graph.empty[Option[Unit], Int].addEdge(None, 1, 2).asJson 121 | 122 | io.circe.parser.decode[Json](expectedGraph) match { 123 | case Right(expectedJson) => expectedJson.spaces2 should be(json.spaces2) 124 | case Left(error) => fail(error) 125 | } 126 | } 127 | 128 | } 129 | -------------------------------------------------------------------------------- /json/jvm/src/test/scala/com/flowtick/graphs/json/schema/JsonSchemaSpec.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs.json.schema 2 | 3 | import org.scalatest.flatspec.AnyFlatSpec 4 | import org.scalatest.matchers.should.Matchers 5 | 6 | class JsonSchemaSpec extends AnyFlatSpec with Matchers { 7 | "JSOM Schema" should "be parsed" in { 8 | val schemaString = s""" 9 | |{ 10 | | "$$id": "https://example.net/root.json", 11 | | "$$schema": "http://json-schema.org/draft/2019-09/schema#", 12 | | "$$defs": { 13 | | "node": { 14 | | "type": "object", 15 | | "properties": { 16 | | "text": { 17 | | "title": "Some Text", 18 | | "type": "string", 19 | | "extension": "ext" 20 | | }, 21 | | "someInt": { 22 | | "title": "Some Integer", 23 | | "type": "integer" 24 | | }, 25 | | "someBool": { 26 | | "title": "Some Boolean", 27 | | "type": "boolean" 28 | | }, 29 | | "someFloat": { 30 | | "title": "Some Float", 31 | | "type": "number" 32 | | } 33 | | } 34 | | }, 35 | | "textOnly": { 36 | | "title": "Text", 37 | | "type": "string" 38 | | } 39 | | } 40 | |}""".stripMargin 41 | 42 | JsonSchema.parse[String](schemaString) match { 43 | case Right(schemaJson) => succeed 44 | case Left(error) => fail(error) 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /json/shared/src/main/scala/com/flowtick/graphs/json/JsonExporter.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs.json 2 | 3 | import cats.data.Validated 4 | import cats.data.Validated._ 5 | import com.flowtick.graphs.{Graph, Node} 6 | import com.flowtick.graphs.json.schema.Schema 7 | import io.circe.Json 8 | 9 | trait JsonWithSchema[T, S] { 10 | def json(value: T): Json 11 | def schema(value: T): Schema[S] 12 | } 13 | 14 | trait Reference[E] { 15 | def name(relation: E): Option[String] 16 | def transform: Json => Json = identity 17 | } 18 | 19 | object JsonExporter { 20 | def exportNode[E, N, S](node: Node[N], graph: Graph[E, N])(implicit 21 | nodeJson: JsonWithSchema[N, S], 22 | reference: Reference[E] 23 | ): Validated[IllegalArgumentException, Json] = { 24 | val json = nodeJson.json(node.value) 25 | 26 | graph 27 | .outgoing(node.id) 28 | .foldLeft[Validated[IllegalArgumentException, Json]](Valid(json)) { case (json, edge) => 29 | reference.name(edge.value) match { 30 | case Some(field) => 31 | // FIXME: not stack-safe 32 | graph 33 | .findNode(edge.to) 34 | .map(exportNode(_, graph)) 35 | .getOrElse(json) 36 | .andThen { referencedJson => 37 | json.map( 38 | _.mapObject(obj => obj.add(field, reference.transform(referencedJson))) 39 | ) 40 | } 41 | 42 | case None => json 43 | } 44 | } 45 | } 46 | 47 | def exportJson[E: Reference, N, S](graph: Graph[E, N], schema: Schema[S])(implicit 48 | nodeJson: JsonWithSchema[N, S] 49 | ): Validated[IllegalArgumentException, Json] = { 50 | val exported = for { 51 | rootNode <- schema.$id.flatMap(rootSchemaId => 52 | graph.nodes.find(node => nodeJson.schema(node.value).$id.contains(rootSchemaId)) 53 | ) 54 | } yield exportNode(rootNode, graph) 55 | exported.getOrElse( 56 | Invalid( 57 | new IllegalArgumentException( 58 | s"could not find root node matching the schema id: ${schema.$id}" 59 | ) 60 | ) 61 | ) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /json/shared/src/main/scala/com/flowtick/graphs/json/package.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs 2 | 3 | import io.circe.Decoder.Result 4 | import io.circe.syntax._ 5 | import io.circe._ 6 | 7 | import scala.collection.mutable.ListBuffer 8 | 9 | package object json { 10 | 11 | object format { 12 | implicit object default { 13 | implicit val unitAsNull: Codec[Unit] = new Codec[Unit] { 14 | override def apply(a: Unit): Json = Json.Null 15 | override def apply(c: HCursor): Result[Unit] = Right(()) 16 | } 17 | 18 | import io.circe.generic.semiauto._ 19 | 20 | implicit def wrappedNodeEncoder[N](implicit 21 | nodeEncoder: Encoder[N] 22 | ): Encoder[Node[N]] = deriveEncoder[Node[N]] 23 | implicit def wrappedNodeDecoder[N](implicit 24 | nodeDecoder: Decoder[N] 25 | ): Decoder[Node[N]] = deriveDecoder[Node[N]] 26 | 27 | implicit def wrappedEdgeEncoder[E, N](implicit 28 | edgeEncoder: Encoder[E] 29 | ): Encoder[Edge[E]] = graphsEdgeEncoder[E, N] 30 | implicit def wrappedEdgeDecoder[E, N](implicit 31 | edgeDecoder: Decoder[E] 32 | ): Decoder[Edge[E]] = deriveDecoder[Edge[E]] 33 | 34 | implicit def defaultGraphEncoder[E, N](implicit 35 | nodeEncoder: Encoder[Node[N]], 36 | edgeEncoder: Encoder[Edge[E]] 37 | ): Encoder[Graph[E, N]] = new Encoder[Graph[E, N]] { 38 | override def apply(a: Graph[E, N]): Json = { 39 | val fields = new ListBuffer[(String, Json)] 40 | fields.append("nodes" -> a.nodes.asJson) 41 | fields.append("edges" -> a.edges.asJson) 42 | Json.fromFields(fields) 43 | } 44 | } 45 | 46 | implicit def defaultGraphDecoder[E, N](implicit 47 | nodeDecoder: Decoder[Node[N]], 48 | edgeDecoder: Decoder[Edge[E]], 49 | nodeId: Identifiable[N], 50 | edgeId: Identifiable[E] 51 | ): Decoder[Graph[E, N]] = new Decoder[Graph[E, N]] { 52 | override def apply(c: HCursor): Result[Graph[E, N]] = for { 53 | nodes <- c 54 | .downField("nodes") 55 | .as[List[Node[N]]] 56 | edges <- c.downField("edges").as[Option[List[Edge[E]]]] 57 | } yield Graph 58 | .empty[E, N] 59 | .withNodes(nodes) 60 | .withEdges(edges.getOrElse(List.empty)) 61 | } 62 | } 63 | 64 | private def graphsEdgeEncoder[E, N](implicit 65 | edgeEncoder: Encoder[E] 66 | ): Encoder[Edge[E]] = new Encoder[Edge[E]] { 67 | override def apply(a: Edge[E]): Json = { 68 | val encodedValue = edgeEncoder(a.value) 69 | val fields = new ListBuffer[(String, Json)] 70 | fields.append("id" -> Json.fromString(a.id)) 71 | fields.append("from" -> Json.fromString(a.from)) 72 | fields.append("to" -> Json.fromString(a.to)) 73 | fields.append("value" -> encodedValue) 74 | 75 | Json.fromFields(fields) 76 | } 77 | } 78 | 79 | /** format that uses the node values instead of the wrapper 80 | */ 81 | object embedded { 82 | import io.circe.generic.semiauto._ 83 | 84 | implicit def nodeEncoder[N](implicit 85 | nodeEncoder: Encoder[N] 86 | ): Encoder[Node[N]] = deriveEncoder[Node[N]] 87 | implicit def nodeDecoder[N](implicit 88 | nodeDecoder: Decoder[N] 89 | ): Decoder[Node[N]] = deriveDecoder[Node[N]] 90 | 91 | implicit def wrappedEdgeEncoder[E, N](implicit 92 | edgeEncoder: Encoder[E] 93 | ): Encoder[Edge[E]] = graphsEdgeEncoder[E, N] 94 | 95 | implicit def wrappedEdgeDecoder[E, N](implicit 96 | edgeDecoder: Decoder[E] 97 | ): Decoder[Edge[E]] = deriveDecoder[Edge[E]] 98 | 99 | implicit def embeddedGraphEncoder[E, N](implicit 100 | nodeEncoder: Encoder[N], 101 | edgeEncoder: Encoder[E] 102 | ): Encoder[Graph[E, N]] = new Encoder[Graph[E, N]] { 103 | override def apply(a: Graph[E, N]): Json = Json 104 | .obj( 105 | "nodes" -> a.nodes.map(_.value).asJson, 106 | "edges" -> a.edges.asJson.dropNullValues 107 | ) 108 | .asJson 109 | } 110 | 111 | implicit def embeddedGraphDecoder[E, N](implicit 112 | nodeDecoder: Decoder[N], 113 | edgeDecoder: Decoder[E], 114 | nodeId: Identifiable[N], 115 | edgeId: Identifiable[E] 116 | ): Decoder[Graph[E, N]] = new Decoder[Graph[E, N]] { 117 | override def apply(c: HCursor): Result[Graph[E, N]] = for { 118 | nodes <- c.downField("nodes").as[List[N]] 119 | edges <- c.downField("edges").as[Option[List[Edge[E]]]] 120 | } yield Graph 121 | .empty[E, N] 122 | .addNodes(nodes) 123 | .withEdges(edges.getOrElse(List.empty)) 124 | } 125 | } 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /json/shared/src/main/scala/com/flowtick/graphs/json/schema/JsonSchema.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs.json.schema 2 | 3 | import com.flowtick.graphs.json.schema.JsonSchema.SingleOrList 4 | import io.circe 5 | import io.circe.syntax._ 6 | import io.circe.{Decoder, Encoder, Json} 7 | 8 | sealed trait JsonSchemaEnum 9 | 10 | final case class Schema[E]( 11 | $id: Option[String] = None, 12 | $schema: Option[String] = None, 13 | title: Option[String] = None, 14 | description: Option[String] = None, 15 | `type`: Option[SingleOrList[String]] = None, 16 | required: Option[List[String]] = None, 17 | $ref: Option[String] = None, 18 | additionalProperties: Option[Either[Boolean, Schema[E]]] = None, 19 | enum: Option[ 20 | Either[List[Boolean], List[Either[Double, String]]] 21 | ] = None, 22 | properties: Option[Map[String, Schema[E]]] = None, 23 | items: Option[SingleOrList[Schema[E]]] = None, 24 | definitions: Option[Map[String, Schema[E]]] = None, 25 | $defs: Option[Map[String, Schema[E]]] = None, 26 | anyOf: Option[List[Schema[E]]] = None, 27 | oneOf: Option[List[Schema[E]]] = None, 28 | minItems: Option[Int] = None, 29 | maxItems: Option[Int] = None, 30 | minimum: Option[Int] = None, 31 | maximum: Option[Int] = None, 32 | minLength: Option[Int] = None, 33 | maxLength: Option[Int] = None, 34 | pattern: Option[String] = None, 35 | format: Option[String] = None, 36 | extension: Option[E] = None 37 | ) { 38 | def definitionsCompat: Option[Map[String, Schema[E]]] = 39 | definitions.orElse($defs) 40 | } 41 | 42 | object JsonSchema { 43 | import io.circe.generic.semiauto._ 44 | 45 | type SingleOrList[A] = Either[List[A], A] 46 | 47 | implicit def decodeSingleOrList[A](implicit 48 | singleDecoder: Decoder[A], 49 | listDecoder: Decoder[List[A]] 50 | ): Decoder[SingleOrList[A]] = listDecoder.either(singleDecoder) 51 | 52 | implicit def encodeSingleOrList[A](implicit 53 | singleEncoder: Encoder[A], 54 | listEncoder: Encoder[List[A]] 55 | ): Encoder[SingleOrList[A]] = { 56 | case Right(single) => singleEncoder(single) 57 | case Left(list) => listEncoder(list) 58 | } 59 | 60 | implicit def decodeEitherBA[B, A](implicit 61 | decoderA: Decoder[A], 62 | decoderB: Decoder[B] 63 | ): Decoder[Either[B, A]] = decoderB.either(decoderA) 64 | 65 | implicit def encodeEitherBA[B, A](implicit 66 | encoderA: Encoder[A], 67 | encoderB: Encoder[B] 68 | ): Encoder[Either[B, A]] = new Encoder[Either[B, A]] { 69 | override def apply(a: Either[B, A]): Json = a match { 70 | case Right(a) => encoderA(a) 71 | case Left(b) => encoderB(b) 72 | } 73 | } 74 | 75 | implicit def jsonSchemaDecoder[E](implicit 76 | decoder: Decoder[E] 77 | ): Decoder[Schema[E]] = 78 | deriveDecoder[Schema[E]] 79 | 80 | implicit def jsonSchemaEncoder[E](implicit 81 | encoder: Encoder[E] 82 | ): Encoder[Schema[E]] = 83 | deriveEncoder[Schema[E]] 84 | 85 | def parse[E](schemaJson: String)(implicit 86 | decoder: Decoder[E] 87 | ): Either[circe.Error, Schema[E]] = 88 | io.circe.parser.decode[Schema[E]](schemaJson) 89 | 90 | def toJson[E](schema: Schema[E])(implicit encoder: Encoder[E]): Json = 91 | schema.asJson 92 | } 93 | -------------------------------------------------------------------------------- /layout-elk/js/src/main/scala/com/flowtick/graphs/layout/elk/ELK.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs.layout.elk 2 | 3 | import scala.scalajs.js 4 | import scala.scalajs.js.annotation.{JSExportTopLevel, JSGlobal, JSImport} 5 | import scala.scalajs.js.{Promise, UndefOr} 6 | 7 | @JSExportTopLevel("ElkLayoutNode") 8 | class ElkLayoutNode( 9 | val id: String, 10 | val x: Double = 0.0, 11 | val y: Double = 0.0, 12 | val width: Double, 13 | val height: Double, 14 | val children: js.Array[ElkLayoutNode] = js.Array.apply() 15 | ) extends js.Object 16 | 17 | @JSExportTopLevel("ElkLayoutPoint") 18 | class ElkLayoutPoint(val x: Double, val y: Double) extends js.Object 19 | 20 | @JSExportTopLevel("ElkLayoutEdgeSection") 21 | class ElkLayoutEdgeSection( 22 | val id: String, 23 | val incomingShape: String, 24 | val outgoingShape: String, 25 | val startPoint: ElkLayoutPoint, 26 | val endPoint: ElkLayoutPoint, 27 | val bendPoints: js.UndefOr[js.Array[ElkLayoutPoint]] 28 | ) extends js.Object 29 | 30 | @JSExportTopLevel("ElkLayoutEdge") 31 | class ElkLayoutEdge( 32 | val id: String, 33 | val sources: js.Array[String] = js.Array.apply(), 34 | val targets: js.Array[String] = js.Array.apply(), 35 | val sections: js.Array[ElkLayoutEdgeSection] = js.Array.apply() 36 | ) extends js.Object 37 | 38 | @JSExportTopLevel("ElkLayoutGraph") 39 | class ElkLayoutGraph( 40 | val id: String, 41 | val layoutOptions: js.Dictionary[js.Any] = js.Dictionary.apply( 42 | "elk.algorithm" -> "layered" 43 | ), 44 | val children: js.Array[ElkLayoutNode] = js.Array(), 45 | val edges: js.Array[ElkLayoutEdge] = js.Array() 46 | ) extends js.Object { 47 | var width: UndefOr[Double] = 0.0 48 | var height: UndefOr[Double] = 0.0 49 | } 50 | 51 | @js.native 52 | trait ELKJS extends js.Any { 53 | def layout(graph: ElkLayoutGraph): Promise[ElkLayoutGraph] = js.native 54 | } 55 | 56 | @JSImport("elkjs", JSImport.Namespace) 57 | @js.native 58 | class ELKImport extends ELKJS 59 | 60 | @JSGlobal("ELK") 61 | @js.native 62 | class ELKGlobal extends ELKJS 63 | -------------------------------------------------------------------------------- /layout-elk/js/src/main/scala/com/flowtick/graphs/layout/elk/ELkLayoutOpsJS.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs.layout.elk 2 | 3 | import com.flowtick.graphs.layout._ 4 | import com.flowtick.graphs.{Edge, Graph, Labeled} 5 | 6 | import scala.concurrent.Future 7 | import scala.scalajs.js 8 | import scala.scalajs.js.{Promise, Thenable, |} 9 | import scala.util.Try 10 | 11 | class ELkLayoutOpsJS(elkjs: ELKJS) extends GraphLayoutOps { 12 | private val handleError: js.Function1[Any, GraphLayoutLike | Thenable[GraphLayoutLike]] = 13 | (error: Any) => Promise.reject(s"unable to layout graph with ELKJS: $error") 14 | 15 | private def elkEdgeToPath( 16 | elkEdge: ElkLayoutEdge 17 | ): Option[(String, EdgePath)] = 18 | elkEdge.sections.headOption 19 | .map(section => 20 | elkEdge.id -> EdgePath( 21 | section.startPoint.x, 22 | section.startPoint.y, 23 | section.endPoint.x, 24 | section.endPoint.y, 25 | points = section.bendPoints 26 | .map(points => points.map(point => PointSpec(point.x, point.y)).toList) 27 | .getOrElse(List.empty) 28 | ) 29 | ) 30 | 31 | private val elkResultToGraphLayout: js.Function1[ElkLayoutGraph, GraphLayoutLike | Thenable[ 32 | GraphLayoutLike 33 | ]] = (result: ElkLayoutGraph) => { 34 | Try(org.scalajs.dom.window.asInstanceOf[js.Dynamic].lastLayout = result) 35 | GraphLayout( 36 | width = result.width.toOption, 37 | height = result.height.toOption, 38 | nodes = result.children 39 | .map(elkNode => 40 | elkNode.id -> DefaultGeometry( 41 | elkNode.x, 42 | elkNode.y, 43 | elkNode.width, 44 | elkNode.height 45 | ) 46 | ) 47 | .toMap, 48 | edges = result.edges 49 | .flatMap(elkEdgeToPath) 50 | .toMap 51 | ) 52 | } 53 | 54 | override def layout[E, N]( 55 | g: Graph[E, N], 56 | layoutConfiguration: GraphLayoutConfiguration 57 | )(implicit edgeLabel: Labeled[Edge[E], String]): Future[GraphLayoutLike] = { 58 | val graph = new ElkLayoutGraph( 59 | "graph", 60 | children = g.nodes.foldLeft(js.Array.apply[ElkLayoutNode]()) { case (acc, next) => 61 | acc :+ new ElkLayoutNode( 62 | id = next.id, 63 | width = layoutConfiguration.nodeWidth, 64 | height = layoutConfiguration.nodeHeight 65 | ) 66 | }, 67 | edges = g.edges.foldLeft(js.Array.apply[ElkLayoutEdge]()) { case (acc, next) => 68 | acc :+ new ElkLayoutEdge( 69 | next.id, 70 | js.Array(next.from), 71 | js.Array(next.to) 72 | ) 73 | } 74 | ) 75 | elkjs 76 | .layout(graph) 77 | .`then`[GraphLayoutLike]( 78 | onFulfilled = elkResultToGraphLayout, 79 | onRejected = handleError 80 | ) 81 | .toFuture 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /layout-elk/jvm/src/test/scala/com/flowtick/graphs/layout/elk/ELkLayoutJVMSpec.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs.layout.elk 2 | 3 | import com.flowtick.graphs.Graph 4 | import com.flowtick.graphs.defaults._ 5 | import com.flowtick.graphs.defaults.label._ 6 | import org.scalatest.concurrent.ScalaFutures 7 | import org.scalatest.flatspec.AnyFlatSpec 8 | import org.scalatest.matchers.should.Matchers 9 | 10 | class ELkLayoutJVMSpec extends AnyFlatSpec with Matchers with ScalaFutures { 11 | "Graph layout" should "layout simple graph" in { 12 | val graph = 13 | Graph.fromEdges[Unit, String](Set("A" --> "B", "B" --> "C", "D" --> "A")) 14 | println(ELkLayoutJVM.layout(graph).futureValue) 15 | } 16 | 17 | it should "layout city graph" in { 18 | val cities = Graph.fromEdges[Int, String]( 19 | Set( 20 | "Frankfurt" --> (85, "Mannheim"), 21 | "Frankfurt" --> (217, "Wuerzburg"), 22 | "Frankfurt" --> (173, "Kassel"), 23 | "Mannheim" --> (80, "Karlsruhe"), 24 | "Wuerzburg" --> (186, "Erfurt"), 25 | "Wuerzburg" --> (103, "Nuernberg"), 26 | "Stuttgart" --> (183, "Nuernberg"), 27 | "Kassel" --> (502, "Muenchen"), 28 | "Nuernberg" --> (167, "Muenchen"), 29 | "Karlsruhe" --> (250, "Augsburg"), 30 | "Augsburg" --> (84, "Muenchen") 31 | ) 32 | ) 33 | ELkLayoutJVM.layout(cities).futureValue 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /layout/jvm/src/test/scala/com/flowtick/graphs/layout/ForceDirectedLayoutJVMSpec.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs.layout 2 | 3 | import com.flowtick.graphs.Graph 4 | import com.flowtick.graphs.defaults._ 5 | import com.flowtick.graphs.defaults.label._ 6 | import org.scalatest.concurrent.ScalaFutures 7 | import org.scalatest.flatspec.AnyFlatSpec 8 | import org.scalatest.matchers.should.Matchers 9 | 10 | class ForceDirectedLayoutJVMSpec extends AnyFlatSpec with Matchers with ScalaFutures { 11 | "Force Directed layout" should "layout plants graph" in { 12 | val plants = Graph.fromEdges[Unit, String]( 13 | Set( 14 | "Norway Spruce" --> "Sicilian Fir", 15 | "Sicilian Fir" --> "Sumatran Pine", 16 | "Sicilian Fir" --> "Japanese Larch", 17 | "Norway Spruce" --> "Japanese Larch", 18 | "Norway Spruce" --> "Giant Sequoia" 19 | ) 20 | ) 21 | 22 | val config = GraphLayoutConfiguration(seed = Some(42)) 23 | ForceDirectedLayout.layout(plants, config).futureValue should be( 24 | GraphLayout( 25 | nodes = Map( 26 | "Sumatran Pine" -> DefaultGeometry(29.0, 178.0, 80.0, 40.0), 27 | "Giant Sequoia" -> DefaultGeometry(198.0, 85.0, 80.0, 40.0), 28 | "Sicilian Fir" -> DefaultGeometry(118.0, 0.0, 80.0, 40.0), 29 | "Norway Spruce" -> DefaultGeometry(170.0, 204.0, 80.0, 40.0), 30 | "Japanese Larch" -> DefaultGeometry(0.0, 34.0, 80.0, 40.0) 31 | ), 32 | edges = Map() 33 | ) 34 | ) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.5.5 2 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("com.geirsson" % "sbt-ci-release" % "1.5.7") 2 | addSbtPlugin("com.typesafe.sbt" % "sbt-site" % "1.4.1") 3 | addSbtPlugin("com.typesafe.sbt" % "sbt-ghpages" % "0.6.3") 4 | addSbtPlugin("com.eed3si9n" % "sbt-unidoc" % "0.4.3") 5 | addSbtPlugin("com.lightbend.paradox" % "sbt-paradox" % "0.9.2") 6 | addSbtPlugin("io.github.jonas" % "sbt-paradox-material-theme" % "0.6.0") 7 | addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject" % "1.1.0") 8 | addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.7.0") 9 | addSbtPlugin("ch.epfl.scala" % "sbt-scalajs-bundler" % "0.20.0") 10 | addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.4.3") 11 | addSbtPlugin("com.codecommit" % "sbt-github-actions" % "0.13.0") -------------------------------------------------------------------------------- /style/shared/src/main/scala/com/flowtick/graphs/style/package.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs 2 | 3 | package object style { 4 | object ImageType { 5 | val url = "url" 6 | val svg = "svg" 7 | val dataUrl = "dataUrl" 8 | } 9 | 10 | final case class ImageSpec( 11 | data: String, 12 | imageType: String, 13 | width: Option[Double] = None, 14 | height: Option[Double] = None 15 | ) 16 | 17 | final case class Fill( 18 | color: Option[String], 19 | transparent: Option[Boolean] = None 20 | ) extends FillLike 21 | 22 | sealed trait LabelModel 23 | case object Custom extends LabelModel 24 | case object Free extends LabelModel 25 | 26 | sealed trait LabelStyle { 27 | def textColor: Option[String] 28 | def fontSize: Option[String] 29 | def fontFamily: Option[String] 30 | def model: Option[LabelModel] 31 | def position: Option[StylePos] 32 | def border: Option[BorderStyle] 33 | } 34 | 35 | sealed trait FillLike { 36 | def color: Option[String] 37 | } 38 | 39 | final case class NodeLabel( 40 | textColor: Option[String] = None, 41 | fontSize: Option[String] = None, 42 | fontFamily: Option[String] = None, 43 | modelName: Option[String] = None, 44 | position: Option[StylePos] = None, 45 | border: Option[BorderStyle] = None 46 | ) extends LabelStyle { 47 | override def model: Option[LabelModel] = modelName.map { 48 | case "custom" => Custom 49 | case _ => Free 50 | } 51 | 52 | def withTextColor(color: String): NodeLabel = copy(textColor = Some(color)) 53 | } 54 | 55 | final case class BorderStyle( 56 | color: String, 57 | styleType: Option[String] = None, 58 | width: Option[Double] = None 59 | ) 60 | final case class SVGContent(refId: String) 61 | 62 | object ShapeType { 63 | val Rectangle = "rectangle" 64 | val RoundRectangle = "roundrectangle" 65 | val Ellipse = "ellipse" 66 | } 67 | 68 | final case class NodeShape( 69 | fill: Option[Fill] = None, 70 | labelStyle: Option[NodeLabel] = None, 71 | shapeType: Option[String] = None, 72 | borderStyle: Option[BorderStyle] = None, 73 | image: Option[String] = None, 74 | svgContent: Option[SVGContent] = None 75 | ) { 76 | def updateFill(update: Option[Fill] => Option[Fill]): NodeShape = 77 | copy(fill = update(fill)) 78 | 79 | def updateLabelStyle( 80 | update: Option[NodeLabel] => Option[NodeLabel] 81 | ): NodeShape = 82 | copy(labelStyle = update(labelStyle)) 83 | 84 | def updateBorderStyle( 85 | update: Option[BorderStyle] => Option[BorderStyle] 86 | ): NodeShape = 87 | copy(borderStyle = update(borderStyle)) 88 | } 89 | 90 | final case class Arrows(source: Option[String], target: Option[String]) { 91 | def withSource(source: String): Arrows = copy(source = Some(source)) 92 | def withTarget(target: String): Arrows = copy(target = Some(target)) 93 | } 94 | 95 | final case class EdgeStyle(color: String, width: Option[Double] = None) 96 | final case class EdgeLabel( 97 | textColor: Option[String] = None, 98 | fontSize: Option[String] = None, 99 | fontFamily: Option[String] = None, 100 | model: Option[LabelModel] = Some(Free), 101 | position: Option[StylePos] = None, 102 | border: Option[BorderStyle] = None 103 | ) extends LabelStyle 104 | 105 | final case class StylePos(x: Double, y: Double) 106 | 107 | final case class EdgeShape( 108 | labelStyle: Option[EdgeLabel] = None, 109 | edgeStyle: Option[EdgeStyle] = None, 110 | arrows: Option[Arrows] = None 111 | ) 112 | 113 | object defaults { 114 | implicit def generalNodeStyleRef[T]: StyleRef[Node[T]] = 115 | new StyleRef[Node[T]] { 116 | override def id(node: Node[T]): Option[String] = Some(node.id) 117 | override def classList(element: Node[T]): List[String] = List.empty 118 | } 119 | 120 | implicit def generalEdgeStyleRef[T]: StyleRef[Edge[T]] = 121 | new StyleRef[Edge[T]] { 122 | override def id(edge: Edge[T]): Option[String] = Some(edge.id) 123 | override def classList(element: Edge[T]): List[String] = List.empty 124 | } 125 | } 126 | 127 | } 128 | -------------------------------------------------------------------------------- /view/jvm/src/main/scala/com/flowtick/graphs/view/SVGTranscoder.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs.view 2 | 3 | import cats.effect.IO 4 | import org.apache.batik.transcoder.image.PNGTranscoder 5 | import org.apache.batik.transcoder.{SVGAbstractTranscoder, TranscoderInput, TranscoderOutput} 6 | 7 | import java.io.{ByteArrayInputStream, ByteArrayOutputStream, InputStream} 8 | 9 | object SVGTranscoder { 10 | def svgXmlToPng( 11 | svgXml: String, 12 | scaleWidth: Option[Double], 13 | scaleHeight: Option[Double] 14 | ): IO[Array[Byte]] = 15 | transcodeSvgToPng( 16 | new ByteArrayInputStream(svgXml.getBytes("UTF-8")), 17 | scaleWidth, 18 | scaleHeight 19 | ) 20 | 21 | def transcodeSvgToPng( 22 | input: InputStream, 23 | scaleWidth: Option[Double], 24 | scaleHeight: Option[Double] 25 | ): IO[Array[Byte]] = 26 | IO(input).bracket(data => 27 | IO { 28 | val dpi = 100 29 | val pixelUnitToMillimeter = (2.54f / dpi) * 10 30 | val pngTranscoder = new PNGTranscoder() 31 | pngTranscoder.addTranscodingHint( 32 | SVGAbstractTranscoder.KEY_PIXEL_UNIT_TO_MILLIMETER, 33 | pixelUnitToMillimeter 34 | ) 35 | 36 | scaleWidth.foreach(width => 37 | pngTranscoder.addTranscodingHint( 38 | SVGAbstractTranscoder.KEY_WIDTH, 39 | width.toFloat 40 | ) 41 | ) 42 | scaleHeight.foreach(height => 43 | pngTranscoder.addTranscodingHint( 44 | SVGAbstractTranscoder.KEY_HEIGHT, 45 | height.toFloat 46 | ) 47 | ) 48 | 49 | val input = new TranscoderInput(data) 50 | val outputStream = new ByteArrayOutputStream() 51 | val output = new TranscoderOutput(outputStream) 52 | pngTranscoder.transcode(input, output) 53 | outputStream.toByteArray 54 | } 55 | )(data => IO(data.close())) 56 | 57 | } 58 | -------------------------------------------------------------------------------- /view/jvm/src/test/scala/com/flowtick/graphs/view/SVGRendererJvmSpec.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs.view 2 | 3 | import cats.effect.IO 4 | import cats.effect.unsafe.implicits.global 5 | import com.flowtick.graphs.Graph 6 | import com.flowtick.graphs.defaults._ 7 | import com.flowtick.graphs.defaults.label._ 8 | import com.flowtick.graphs.layout.ForceDirectedLayout 9 | import com.flowtick.graphs.style.StyleSheet 10 | import com.flowtick.graphs.style.defaults._ 11 | import org.scalatest.flatspec.AnyFlatSpec 12 | import org.scalatest.matchers.should.Matchers 13 | 14 | import java.io.FileOutputStream 15 | 16 | class SVGRendererJvmSpec extends AnyFlatSpec with Matchers { 17 | "Editor Renderer" should "render node" in { 18 | val graph: Graph[Unit, String] = Graph.fromEdges(Set("A" --> "B")) 19 | 20 | val renderer = SVGRendererJvm() 21 | val styleSheet = StyleSheet() 22 | 23 | val renderedSvg = for { 24 | layout <- IO.fromFuture(IO(ForceDirectedLayout.layout(graph))) 25 | xmlString <- renderer 26 | .translateAndScaleView(0, 0, 2) 27 | .renderGraph(ViewContext(graph, layout, styleSheet)) 28 | .flatMap(_.toXmlString) 29 | _ <- IO { 30 | val fileOut = new FileOutputStream("target/test_simple.svg") 31 | fileOut.write(xmlString.getBytes("UTF-8")) 32 | fileOut.flush() 33 | fileOut.close() 34 | } 35 | } yield renderer.graphSVG 36 | 37 | renderedSvg.unsafeRunSync() 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /view/shared/src/main/scala/com/flowtick/graphs/view/ViewComponent.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs.view 2 | 3 | import cats.effect.IO 4 | 5 | trait ViewComponent[Context, Model] { 6 | type Eval = Context => IO[Context] 7 | type Transform = Context => Context 8 | 9 | // TODO: an explicit dependency approach would be less error prone 10 | def order: Double = 0.0 11 | 12 | def init(model: Model): IO[Unit] = IO.unit 13 | 14 | def eval: Eval 15 | } 16 | -------------------------------------------------------------------------------- /view/shared/src/main/scala/com/flowtick/graphs/view/util/DrawUtil.scala: -------------------------------------------------------------------------------- 1 | package com.flowtick.graphs.view.util 2 | 3 | import com.flowtick.graphs.layout.{EdgePath, GraphLayoutLike, PointSpec} 4 | import com.flowtick.graphs.{Edge, Graph} 5 | 6 | object DrawUtil { 7 | def getLinePoints[E, N]( 8 | edge: Edge[E], 9 | graph: Graph[E, N], 10 | layout: GraphLayoutLike 11 | ): Option[Iterator[PointSpec]] = for { 12 | fromNode <- graph.findNode(edge.from) 13 | toNode <- graph.findNode(edge.to) 14 | from <- layout.nodeGeometry(fromNode.id) 15 | to <- layout.nodeGeometry(toNode.id) 16 | 17 | fromCenterX = from.x + from.width / 2 18 | fromCenterY = from.y + from.height / 2 19 | 20 | toCenterX = to.x + to.width / 2 21 | toCenterY = to.y + to.height / 2 22 | 23 | path = layout.edgePath(edge.id).getOrElse(EdgePath()) 24 | start = PointSpec(fromCenterX + path.sourceX, fromCenterY + path.sourceY) 25 | end = PointSpec(toCenterX + path.targetX, toCenterY + path.targetY) 26 | points = Iterator(start) ++ path.points ++ Iterator(end) 27 | } yield points 28 | } 29 | --------------------------------------------------------------------------------