├── .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/.*/' -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 | [](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 | 
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------