Skip to content

Commit

Permalink
Controlling SVG output (#9)
Browse files Browse the repository at this point in the history
* simplifying paths -> halving rough svg sizes

* better placement for simplifying function

* default directory + caching

* add control over output style

* add colors + leave default as is

* amended README.md to reflect all changes
  • Loading branch information
sirocchj authored Dec 5, 2019
1 parent 4b37317 commit 153286e
Show file tree
Hide file tree
Showing 34 changed files with 1,898 additions and 693 deletions.
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,12 +118,18 @@ That's it (_potentially_)! Eisner is capable of scanning your classes searching
- fields or zero-arg methods that return `java.util.function.Supplier[org.apache.kafka.streams.Topology]`

SVGs will be created for each, named after the class(+field/method) that defines the Topology.
Currently, files are generated in `target/streams/_global/eisner/_global/streams/`.
Files are generated by default in `target/eisner`.
As long as topologies are not changed between subsequent runs of `eisner` (without `clean`), SVGs will not be regenerated.

## Tweaking with it

Currently, Eisner supports a handful of options, namely:

- `eisnerColorSink`: a `String` representing a [valid graphviz color](https://www.graphviz.org/doc/info/colors.html) used to draw sinks, defaults to `black`
- `eisnerColorSubtopology`: a `String` representing a [valid graphviz color](https://www.graphviz.org/doc/info/colors.html) used to frame sub topologies, defaults to `lightgrey`
- `eisnerColorTopic`: a `String` representing a [valid graphviz color](https://www.graphviz.org/doc/info/colors.html) used to draw topics, defaults to `black`
- `eisnerRoughSVG`: a `Boolean` indicating whether Eisner should produce SVGs that look like hand drawings, defaults to `true`
- `eisnerTargetDirectory`: a `File` which represents the directory where all SVGs will be saved into, defaults to `[current project]/target/eisner`
- `eisnerTopologies`: a `Seq[String]` representing the fully qualified names of classes implementing `org.apache.kafka.streams.Topology`. This is useful in all cases where you need to control which SVGs get generated
- `eisnerTopologiesSnippet`: a `Option[String]` representing a Scala snippet (inclusive of all necessary imports) that evaluates to a `Seq[(String, org.apache.kafka.streams.Topology)]`, where the `String` represents the `package.name` you want to give to the target file (dots will be converted to path separators). This is useful in all cases where automatic classloader scanning would not work, e.g. because you define your topologies in non zero-args methods or in fields that return `scala.Function1`. See [here](https://raw.githubusercontent.com/laserdisc-io/eisner/master/src/sbt-test/sbt-eisner/snippet/src/main/scala/snippet/EisnerTopology.scala) for a practical example.

Expand Down
1 change: 1 addition & 0 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ lazy val eisner = project
Compile / unmanagedSourceDirectories += (Compile / sourceDirectory).value / (if (isJDK9Plus) "scala-jdk9+" else "scala-jdk8-"),
scalaVersion := `scala 2.12`,
libraryDependencies ++= Seq(
"com.chuusai" %% "shapeless" % "2.3.3",
"io.circe" %% "circe-generic-extras" % "0.12.2",
"io.circe" %% "circe-parser" % "0.12.3",
"io.dylemma" %% "xml-spac" % "0.7",
Expand Down
3 changes: 3 additions & 0 deletions src/main/scala/eisner/Config.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package eisner

final case class Config(subgraphColor: String, topicColor: String, storeColor: String)
54 changes: 42 additions & 12 deletions src/main/scala/eisner/EisnerPlugin.scala
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,31 @@ import scala.concurrent.ExecutionContext.Implicits.global

object EisnerPlugin extends AutoPlugin with ReflectionSupport with SnippetSupport {
object autoImport {
val eisnerTopologies = settingKey[Seq[String]]("The fully qualified names of classes implementing org.apache.kafka.streams.Topology")
val eisnerColorSink = settingKey[String]("The color used to represent sinks, see https://www.graphviz.org/doc/info/colors.html")
val eisnerColorSubtopology = settingKey[String]("The color used to frame subtopologies, see https://www.graphviz.org/doc/info/colors.html")
val eisnerColorTopic = settingKey[String]("The color used to represent topics, see https://www.graphviz.org/doc/info/colors.html")
val eisnerRoughSVG = settingKey[Boolean]("The flag that controls whether to create SVG using pseudo hand-drawing")
val eisnerTargetDirectory = settingKey[File]("The directory where to store the generated topologies")
val eisnerTopologies = settingKey[Seq[String]]("The fully qualified names of classes implementing org.apache.kafka.streams.Topology")
val eisnerTopologiesSnippet =
settingKey[Option[String]]("A scala snippet (including all imports) that evaluates to a Seq[(String, org.apache.kafka.streams.Topology)]")
val eisner = taskKey[Seq[File]]("Generates one SVG for each org.apache.kafka.streams.Topology")
val eisner = taskKey[Set[File]]("Generates one SVG for each org.apache.kafka.streams.Topology")
}

import autoImport._

override final def projectSettings: Seq[Setting[_]] = Seq(
eisnerColorSink := "black",
eisnerColorSubtopology := "lightgrey",
eisnerColorTopic := "black",
eisnerRoughSVG := true,
eisnerTargetDirectory := (Compile / target).value / "eisner",
eisnerTopologies := Seq.empty,
eisnerTopologiesSnippet := None,
eisner := generate.dependsOn(Compile / compile).value
)

private[this] final def generate: Def.Initialize[Task[Seq[File]]] = Def.taskDyn {
private[this] final def generate: Def.Initialize[Task[Set[File]]] = Def.taskDyn {
val log = streams.value.log
val cacheDir = streams.value.cacheDirectory

Expand Down Expand Up @@ -59,18 +69,38 @@ object EisnerPlugin extends AutoPlugin with ReflectionSupport with SnippetSuppor
Thread.currentThread.setContextClassLoader(classOf[PromiseException].getClassLoader)

if (topologyDescriptions.nonEmpty) {
val fs = Future.traverse(topologyDescriptions) {
case (topologyName, topology) =>
topology.toSVG.map { svg =>
val f = new File(s"$cacheDir/${dotsToSlashes(topologyName)}.svg")
IO.write(f, svg)
f
}
val config = Config(eisnerColorSubtopology.value, eisnerColorTopic.value, eisnerColorSink.value)
val topologiesWithDots = topologyDescriptions
.map {
case (n, td) => (n, td, td.toDot(config))
}
.collect {
case (n, td, Right(d)) => (n, td, d)
}
val inputs = topologiesWithDots.map {
case (name, _, dot) =>
val f = new File(s"$cacheDir/${dotsToSlashes(name)}.dot")
IO.write(f, dot)
f
}.toSet
val cachedFun = FileFunction.cached(cacheDir, FileInfo.hash) { _ =>
val fs = Future.traverse(topologiesWithDots) {
case (topologyName, topology, _) =>
val svg = if (eisnerRoughSVG.value) topology.toSimplifiedRoughSVG(config) else topology.toSVG(config)
svg.map { svg =>
val filename = s"${eisnerTargetDirectory.value.getAbsolutePath}/${dotsToSlashes(topologyName)}.svg"
log.info(s"Eisner - saving $filename")
val f = new File(filename)
IO.write(f, svg)
f
}
}
Await.result(fs, 60.seconds).toSet
}
Await.result(fs, 60.seconds)
cachedFun(inputs)
} else {
log.warn("Eisner - No topology found!")
Seq.empty
Set.empty
}
}
}
Expand Down
14 changes: 7 additions & 7 deletions src/main/scala/eisner/dot/dot.scala
Original file line number Diff line number Diff line change
Expand Up @@ -17,21 +17,21 @@ package object dot {
final def unapplySeq(s: String): Option[Seq[String]] = Some(s.split(',').flatMap(Clean.unapply).filter(_ != "none"))
}

final def toDot(s: String): TopologyParserError | DiGraph = {
final def toDot(c: Config, s: String): TopologyParserError | DiGraph = {
if (!s.startsWith("Topolog"))
Left(TopologyParserError(s"Supplied string does not appear to be a valid topology (${s.substring(0, 10)}...)"))
else {
val (g, _) = s.split('\n').foldLeft(DiGraph.empty -> (None: Option[String])) {
case ((DiGraph(sgs, es, ts, ss), maybeN), SubTopology(Clean(sc), id)) =>
DiGraph(SubGraph.empty(id, sc) :: sgs, es, ts, ss) -> maybeN
DiGraph(SubGraph.empty(id, sc, c.subgraphColor) :: sgs, es, ts, ss) -> maybeN
case ((DiGraph(sgs, es, ts, ss), _), Source(Clean(n), Links(ls @ _*))) =>
DiGraph(sgs, es ++ ls.map(Edge(_, n)), ts ++ ls.map(Topic(_)), ss) -> Some(n)
DiGraph(sgs, es ++ ls.map(Edge(_, n)), ts ++ ls.map(Topic(_, c.topicColor)), ss) -> Some(n)
case ((DiGraph(sgs, es, ts, ss), _), Processor(Clean(n), Links(ls @ _*))) =>
DiGraph(sgs, es ++ ls.map(Edge(n, _)), ts, ss ++ ls.map(Store(_))) -> Some(n)
DiGraph(sgs, es ++ ls.map(Edge(n, _)), ts, ss ++ ls.map(Store(_, c.storeColor))) -> Some(n)
case ((DiGraph(sgs, es, ts, ss), _), Sink(Clean(n), Links(l))) =>
DiGraph(sgs, es :+ Edge(n, l), ts + Topic(l), ss) -> Some(n)
case ((DiGraph(SubGraph(id, la, sges) :: sgs, es, ts, ss), Some(n)), RightArrow(Links(ls @ _*))) =>
DiGraph(SubGraph(id, la, sges ++ ls.map(Edge(n, _))) :: sgs, es, ts, ss) -> Some(n)
DiGraph(sgs, es :+ Edge(n, l), ts + Topic(l, c.topicColor), ss) -> Some(n)
case ((DiGraph(SubGraph(id, la, sges, color) :: sgs, es, ts, ss), Some(n)), RightArrow(Links(ls @ _*))) =>
DiGraph(SubGraph(id, la, sges ++ ls.map(Edge(n, _)), color) :: sgs, es, ts, ss) -> Some(n)
case (acc, _) =>
acc
}
Expand Down
18 changes: 10 additions & 8 deletions src/main/scala/eisner/dot/model.scala
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,16 @@ final object DiGraph {
}
}

final case class SubGraph(id: String, label: String, edges: Vector[Edge])
final case class SubGraph(id: String, label: String, edges: Vector[Edge], color: String)
final object SubGraph {
final def empty(id: String, label: String): SubGraph = SubGraph(id, label, Vector.empty)
final def empty(id: String, label: String, color: String): SubGraph = SubGraph(id, label, Vector.empty, color)

implicit final val subgraphWriter: Writer[SubGraph] = Writer.instance {
case (SubGraph(id, l, es), i) =>
case (SubGraph(id, l, es, c), i) =>
s"${i.tabs}subgraph cluster_$id {" ::
s"""${(i + 1).tabs}label = "$l";""" ::
s"${(i + 1).tabs}style = filled;" ::
s"${(i + 1).tabs}color = lightgrey;" ::
s"${(i + 1).tabs}color = $c;" ::
s"${(i + 1).tabs}node [style = filled, color = white];" ::
Writer[Vector[Edge]].write(es, i + 1) :::
s"${i.tabs}}" ::
Expand All @@ -40,12 +40,14 @@ final object Edge {
implicit final val edgeWriter: Writer[Edge] = Writer.instance { case (Edge(f, t), i) => s"""${i.tabs}"$f" -> "$t";""" :: Nil }
}

final case class Topic(name: String) extends AnyVal
final case class Topic(name: String, color: String)
final object Topic {
implicit final val topicWriter: Writer[Topic] = Writer.instance { case (Topic(t), i) => s"""${i.tabs}"$t" [shape = rect];""" :: Nil }
implicit final val topicWriter: Writer[Topic] = Writer.instance { case (Topic(t, c), i) => s"""${i.tabs}"$t" [shape = rect; color = $c];""" :: Nil }
}

final case class Store(name: String) extends AnyVal
final case class Store(name: String, color: String)
final object Store {
implicit final val storeWriter: Writer[Store] = Writer.instance { case (Store(s), i) => s"""${i.tabs}"$s" [shape = cylinder];""" :: Nil }
implicit final val storeWriter: Writer[Store] = Writer.instance {
case (Store(s, c), i) => s"""${i.tabs}"$s" [shape = cylinder; color = $c];""" :: Nil
}
}
22 changes: 17 additions & 5 deletions src/main/scala/eisner/eisner.scala
Original file line number Diff line number Diff line change
Expand Up @@ -9,25 +9,36 @@ package object eisner {
private[eisner] final val Topic = dot.Topic
private[eisner] final val Store = dot.Store

private[this] final val decimalsPattern = """\.(\d{2})\d+(\D?)""".r
private[this] object trimDoubles extends shapeless.poly.->((s: String) => decimalsPattern.replaceAllIn(s, ".$1$2"))
private[this] final def simplify(svg: SVG): SVG = {
val simplified = shapeless.everywhere(trimDoubles)(svg)
simplified
}

implicit final class IntOps(private val i: Int) extends AnyVal {
final def tabs: String = "\t" * i
}
implicit final class StringOps(private val s: String) extends AnyVal {
import io.circe.parser.decode
import scala.concurrent.{ExecutionContext, Future}

private[eisner] final def toDiGraph: TopologyParserError | DiGraph = dot.toDot(s)
private[eisner] final def decodeSVG: SVGParserError | SVG = decode[SVG](s).left.map(e => SVGParserError(e.getLocalizedMessage()))
private[eisner] final def toDiGraph(c: Config): TopologyParserError | DiGraph = dot.toDot(c, s)
private[eisner] final def decodeSVG: SVGParserError | SVG = decode[SVG](s).left.map(e => SVGParserError(e.getLocalizedMessage()))

final def toDot: TopologyParserError | String = toDiGraph.map(_.dot)
final def toSVG(implicit ec: ExecutionContext): Future[String] = toDiGraph match {
final def toDot(c: Config): TopologyParserError | String = toDiGraph(c).map(_.dot)
final def toSVG(c: Config)(implicit ec: ExecutionContext): Future[String] = toDiGraph(c) match {
case Left(tpe) => Future.failed(tpe)
case Right(dg) => dg.simpleSVG.map(_.xml)
}
final def toRoughSVG(implicit ec: ExecutionContext): Future[String] = toDiGraph match {
final def toRoughSVG(c: Config)(implicit ec: ExecutionContext): Future[String] = toDiGraph(c) match {
case Left(tpe) => Future.failed(tpe)
case Right(dg) => dg.simpleSVG.flatMap(_.roughSVG.fold(Future.failed, svg => Future.successful(svg.xml)))
}
final def toSimplifiedRoughSVG(c: Config)(implicit ec: ExecutionContext): Future[String] = toDiGraph(c) match {
case Left(tpe) => Future.failed(tpe)
case Right(dg) => dg.simpleSVG.flatMap(_.roughSVG.fold(Future.failed, svg => Future.successful(svg.simplified.xml)))
}
}
private[eisner] implicit final class DiGraphOps(private val dg: DiGraph) extends AnyVal {
import scala.concurrent.{ExecutionContext, Future}
Expand All @@ -40,6 +51,7 @@ package object eisner {
private[eisner] implicit final class SVGOps(private val svg: SVG) extends AnyVal {
import io.circe.Encoder

final def simplified: SVG = simplify(svg)
final def json: String = Encoder[SVG].apply(svg).noSpaces
final def xml: String = Writer[SVG].write(svg, 0).mkString("\n")
final def roughSVG: SVGParserError | SVG = js.svgToRoughSVG(json).decodeSVG
Expand Down
2 changes: 1 addition & 1 deletion src/sbt-test/sbt-eisner/auto-field/test
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# invoke SVG creation
> eisner
# check that the SVG is created
$ exists target/streams/_global/eisner/_global/streams/auto/field/InnerTopology#someField.svg
$ exists target/eisner/auto/field/InnerTopology#someField.svg
2 changes: 1 addition & 1 deletion src/sbt-test/sbt-eisner/auto-fun0field/test
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# invoke SVG creation
> eisner
# check that the SVG is created
$ exists target/streams/_global/eisner/_global/streams/auto/fun0field/InnerTopology#someField.svg
$ exists target/eisner/auto/fun0field/InnerTopology#someField.svg
2 changes: 1 addition & 1 deletion src/sbt-test/sbt-eisner/auto-fun0method/test
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# invoke SVG creation
> eisner
# check that the SVG is created
$ exists target/streams/_global/eisner/_global/streams/auto/fun0method/InnerTopology$someMethod.svg
$ exists target/eisner/auto/fun0method/InnerTopology$someMethod.svg
2 changes: 1 addition & 1 deletion src/sbt-test/sbt-eisner/auto-inheritance/test
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# invoke SVG creation
> eisner
# check that the SVG is created
$ exists target/streams/_global/eisner/_global/streams/auto/inheritance/EisnerTopology.svg
$ exists target/eisner/auto/inheritance/EisnerTopology.svg
2 changes: 1 addition & 1 deletion src/sbt-test/sbt-eisner/auto-method/test
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# invoke SVG creation
> eisner
# check that the SVG is created
$ exists target/streams/_global/eisner/_global/streams/auto/method/InnerTopology$someMethod.svg
$ exists target/eisner/auto/method/InnerTopology$someMethod.svg
2 changes: 1 addition & 1 deletion src/sbt-test/sbt-eisner/auto-supplierfield/test
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# invoke SVG creation
> eisner
# check that the SVG is created
$ exists target/streams/_global/eisner/_global/streams/auto/supplierfield/InnerTopology#someField.svg
$ exists target/eisner/auto/supplierfield/InnerTopology#someField.svg
2 changes: 1 addition & 1 deletion src/sbt-test/sbt-eisner/auto-suppliermethod/test
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# invoke SVG creation
> eisner
# check that the SVG is created
$ exists target/streams/_global/eisner/_global/streams/auto/suppliermethod/InnerTopology$someMethod.svg
$ exists target/eisner/auto/suppliermethod/InnerTopology$someMethod.svg
2 changes: 1 addition & 1 deletion src/sbt-test/sbt-eisner/empty/test
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# invoke SVG creation
> eisner
# check that the SVG is not created (topology is empty)
$ absent target/streams/_global/eisner/_global/streams/EmptyTopology.svg
$ absent target/eisner/EmptyTopology.svg
2 changes: 1 addition & 1 deletion src/sbt-test/sbt-eisner/explicit-inheritance/test
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# invoke SVG creation
> eisner
# check that the SVG is created
$ exists target/streams/_global/eisner/_global/streams/explicit/inheritance/EisnerTopology.svg
$ exists target/eisner/explicit/inheritance/EisnerTopology.svg
2 changes: 1 addition & 1 deletion src/sbt-test/sbt-eisner/snippet/test
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# invoke SVG creation
> eisner
# check that the SVG is created
$ exists target/streams/_global/eisner/_global/streams/snippet/EisnerTopology#myTopology.svg
$ exists target/eisner/snippet/EisnerTopology#myTopology.svg
154 changes: 75 additions & 79 deletions src/test/resources/topology1-rough.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
157 changes: 157 additions & 0 deletions src/test/resources/topology1-simplified-rough.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 5 additions & 5 deletions src/test/resources/topology1.dot
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@ digraph G {
"count-\nresolved-\nrepartition" -> "KSTREAM-\nSOURCE-\n0000000006";
"KSTREAM-\nAGGREGATE-\n0000000003" -> "count-\nresolved";
"KSTREAM-\nSINK-\n0000000008" -> "streams-\ncount-\nresolved";
"conversation-\nmeta" [shape = rect];
"count-\nresolved-\nrepartition" [shape = rect];
"streams-\ncount-\nresolved" [shape = rect];
"conversation-\nmeta-\nstate" [shape = cylinder];
"count-\nresolved" [shape = cylinder];
"conversation-\nmeta" [shape = rect; color = black];
"count-\nresolved-\nrepartition" [shape = rect; color = black];
"streams-\ncount-\nresolved" [shape = rect; color = black];
"conversation-\nmeta-\nstate" [shape = cylinder; color = black];
"count-\nresolved" [shape = cylinder; color = black];
}
94 changes: 45 additions & 49 deletions src/test/resources/topology2-rough.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
97 changes: 97 additions & 0 deletions src/test/resources/topology2-simplified-rough.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 4 additions & 4 deletions src/test/resources/topology2.dot
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ digraph G {
"KSTREAM-\nSINK-\n0000000004" -> "skus-\nwith-\ntaxcode";
"taxcodes" -> "KTABLE-\nSOURCE-\n0000000000";
"KTABLE-\nSOURCE-\n0000000001" -> "tax-\ncodes-\nstore";
"skus" [shape = rect];
"skus-\nwith-\ntaxcode" [shape = rect];
"taxcodes" [shape = rect];
"tax-\ncodes-\nstore" [shape = cylinder];
"skus" [shape = rect; color = black];
"skus-\nwith-\ntaxcode" [shape = rect; color = black];
"taxcodes" [shape = rect; color = black];
"tax-\ncodes-\nstore" [shape = cylinder; color = black];
}
204 changes: 100 additions & 104 deletions src/test/resources/topology3-rough.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
206 changes: 206 additions & 0 deletions src/test/resources/topology3-simplified-rough.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 6 additions & 6 deletions src/test/resources/topology3.dot
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,10 @@ digraph G {
"KSTREAM-\nAGGREGATE-\n0000000010" -> "windowed-\ncount-\nstore";
"KSTREAM-\nSINK-\n0000000009" -> "count-\ntopic";
"KSTREAM-\nSINK-\n0000000017" -> "windowed-\ncount";
"streams-\nplaintext-\ninput" [shape = rect];
"count-\nstore-\nrepartition" [shape = rect];
"count-\ntopic" [shape = rect];
"windowed-\ncount" [shape = rect];
"count-\nstore" [shape = cylinder];
"windowed-\ncount-\nstore" [shape = cylinder];
"streams-\nplaintext-\ninput" [shape = rect; color = black];
"count-\nstore-\nrepartition" [shape = rect; color = black];
"count-\ntopic" [shape = rect; color = black];
"windowed-\ncount" [shape = rect; color = black];
"count-\nstore" [shape = cylinder; color = black];
"windowed-\ncount-\nstore" [shape = cylinder; color = black];
}
680 changes: 338 additions & 342 deletions src/test/resources/topology4-rough.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
694 changes: 694 additions & 0 deletions src/test/resources/topology4-simplified-rough.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading

0 comments on commit 153286e

Please sign in to comment.