Skip to content

Commit

Permalink
Add d2 animation support (#8)
Browse files Browse the repository at this point in the history
Add d2 animation support
  • Loading branch information
goto1134 authored May 7, 2023
1 parent 21a90a4 commit 227cae0
Show file tree
Hide file tree
Showing 54 changed files with 4,484 additions and 256 deletions.
81 changes: 79 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Structurizr D2 Exporter

The [D2Exporter](lib/src/main/java/io/github/goto1134/structurizr/export/d2/D2Exporter.java) class provides a way
The [D2Exporter](/lib/src/main/kotlin/io/github/goto1134/structurizr/export/d2/D2Exporter.kt) class provides a way
to export Structurizr views to diagram definitions that are compatible with [D2](https://d2lang.com).

This library is developed to be included in the [Structurizr CLI](https://github.com/structurizr/cli),
Expand All @@ -9,5 +9,82 @@ and is available on Maven Central, for inclusion in your own Java applications:
- groupId: `io.github.goto1134`
- artifactId: `structurizr-d2-exporter`

## Example
![amazon.png](examples/amazon.png)

# Table of Content
<!-- TOC -->
* [Customization](#customization)
* [`d2.title_position`](#d2titleposition)
* [`d2.animation`](#d2animation)
* [`d2.animated`](#d2animated)
* [`d2.fill_pattern`](#d2fillpattern)
<!-- TOC -->


# Customization

## `d2.title_position`

* Entity: [`views`, `view`](https://github.com/structurizr/dsl/blob/master/docs/language-reference.md#views)
*

Values: `top-left`, `top-center`, `top-right`, `center-left`, `center-right`, `bottom-left`, `bottom-center`, `bottom-right`

* Default: `top-center`

Specifies diagram title position. For more details, see [d2 near](https://d2lang.com/tour/positions/#near).

### Example:

Source: [title-position/workspace.dsl](lib/src/test/resources/title-position/workspace.dsl)

`bottom-left` title:
![title-position.png](examples/title-position.png)

## `d2.animation`

* Entity: [`views`, `view`](https://github.com/structurizr/dsl/blob/master/docs/language-reference.md#views)
* Values: `d2`, `frames`, `no`
* Default: `d2`

Specifies animation variant for [animated structurizr views](https://github.com/structurizr/dsl/blob/master/docs/language-reference.md#animation).

* `d2` is for [d2 steps animation](https://d2lang.com/tour/steps) that allows you
to produce animated images
* `frames` is for structurizr default frame animation.
Unfortunately, it is not exportable yet.
* `no` can be used in case you have animation steps, but do not want the animation.

### Example:

![amazon-animated.svg](examples/amazon-animated.svg)

## `d2.animated`

* Entity: [`relationship` style](https://github.com/structurizr/dsl/blob/master/docs/language-reference.md#relationship-style)

* Values: `true`, `false`
* Default: `false`

### Example

Source: [animated-relation/workspace.dsl](lib/src/test/resources/animated-relation/workspace.dsl)

![animated-relation.svg](examples/animated-relation.svg)

**Hint:** Do not forget to provide [`--animate-interval` flag](https://d2lang.com/tour/composition-formats/) when
producing SVG to see the animation

## `d2.fill_pattern`
* Entity: [`views`, `view`](https://github.com/structurizr/dsl/blob/master/docs/language-reference.md#views) ,[`element` style](https://github.com/structurizr/dsl/blob/master/docs/language-reference.md#element-style)
* Values: `dots`, `lines`, `grain`
* Default: –

When set on `views` or `view`, adds a [fill pattern](https://d2lang.com/tour/style/#fill-pattern) to the background.
When set on an `element` style, adds [fill pattern](https://d2lang.com/tour/style/#fill-pattern) to it's body.

### Example

Source: [fill-pattern/workspace.dsl](lib/src/test/resources/fill-pattern/workspace.dsl)

![fill-pattern.png](examples/fill-pattern.png)
1,010 changes: 1,010 additions & 0 deletions examples/amazon-animated.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
892 changes: 892 additions & 0 deletions examples/animated-relation.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added examples/fill-pattern.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added examples/title-position.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package io.github.goto1134.structurizr.export.d2

enum class AnimationType {
NO,
D2,
FRAMES;

companion object {
fun get(value: String?) = AnimationType.values().firstOrNull {
it.name.equals(value, ignoreCase = true)
}

fun getOrDefault(value: String?, default: AnimationType) = get(value) ?: default
}
}
Original file line number Diff line number Diff line change
@@ -1,26 +1,189 @@
package io.github.goto1134.structurizr.export.d2

import com.structurizr.export.AbstractDiagramExporter
import com.structurizr.export.Diagram
import com.structurizr.export.IndentingWriter
import com.structurizr.model.*
import com.structurizr.view.*
import io.github.goto1134.structurizr.export.d2.model.D2Object
import io.github.goto1134.structurizr.export.d2.model.D2TextObject
import io.github.goto1134.structurizr.export.d2.model.GlobalObject
import io.github.goto1134.structurizr.export.d2.model.NamedObject
import io.github.goto1134.structurizr.export.d2.model.TextObject

open class D2Exporter : AbstractDiagramExporter() {

companion object {
const val D2_TITLE_POSITION = "d2.title_position"
const val D2_ANIMATION = "d2.animation"
const val D2_CONNECTION_ANIMATED = "d2.animated"
const val D2_FILL_PATTERN = "d2.fill_pattern"
const val STRUCTURIZR_INCLUDE_SOFTWARE_SYSTEM_BOUNDARIES = "structurizr.softwareSystemBoundaries"
}

override fun createDiagram(view: ModelView, definition: String) = D2Diagram(view, definition)

override fun export(view: CustomView): Diagram {
if (view.useD2StepsAnimation(view.animations)) {
val diagram = exportD2Steps(view, view.animations)
diagram.legend = createLegend(view)
return diagram
}
return super.export(view)
}

override fun export(view: SystemLandscapeView): Diagram {
if (view.useD2StepsAnimation(view.animations)) {
val diagram = exportD2Steps(view, view.animations)
diagram.legend = createLegend(view)
return diagram
}
return super.export(view)
}

override fun export(view: SystemContextView): Diagram {
if (view.useD2StepsAnimation(view.animations)) {
val diagram = exportD2Steps(view, view.animations)
diagram.legend = createLegend(view)
return diagram
}
return super.export(view)
}

override fun export(view: ContainerView): Diagram {
if (view.useD2StepsAnimation(view.animations)) {
val diagram = exportD2Steps(view, view.animations)
diagram.legend = createLegend(view)
return diagram
}
return super.export(view)
}

override fun export(view: ComponentView): Diagram {
if (view.useD2StepsAnimation(view.animations)) {
val diagram = exportD2Steps(view, view.animations)
diagram.legend = createLegend(view)
return diagram
}
return super.export(view)
}

override fun export(view: DeploymentView): Diagram {
if (view.useD2StepsAnimation(view.animations)) {
val diagram = exportD2Steps(view, view.animations)
diagram.legend = createLegend(view)
return diagram
}
return super.export(view)
}

private fun ModelView.useD2StepsAnimation(animations: List<Animation>): Boolean {
return animations.isNotEmpty() && animationType == AnimationType.D2
}

protected fun exportD2Steps(view: ModelView, animations: List<Animation>): Diagram {
val writer = IndentingWriter()
writeHeader(view, writer)
NamedObject.build("steps").writeObject(writer) {
writeAnimations(view, animations, it)
}
writeFooter(view, writer)
return createDiagram(view, writer.toString())
}

private fun writeAnimations(view: ModelView, animations: List<Animation>, writer: IndentingWriter) {
val stepsAnimationState = StepsAnimationState()
val elements = view.elements.associateBy { it.id }
val relationships = view.relationships.associateBy { it.id }

for (animation in animations.sortedBy { it.order }) {
NamedObject.build(animation.order.toString()).writeObject(writer) {
writeAnimationElements(
view,
animation.elements
.mapNotNull { elements[it]?.element }
.filterIsInstance<GroupableElement>()
.sortedBy { it.id },
stepsAnimationState,
writer
)
writer.writeLine()
writeAnimationRelationships(
view,
animation.relationships.mapNotNull { relationships[it] }.sortedBy { it.id },
writer
)
}
}
}

private fun writeAnimationRelationships(
view: ModelView,
relationships: List<RelationshipView>,
writer: IndentingWriter
) {
for (relationshipView in relationships) {
writeRelationship(view, relationshipView, writer)
}
}

private fun writeAnimationElements(
view: ModelView,
elementsInStep: List<GroupableElement>,
stepsAnimationState: StepsAnimationState,
writer: IndentingWriter
) {
if (view is ContainerView) {
elementsInStep.filterIsInstance<Container>().map { it.softwareSystem }.sortedBy { it.id }.forEach {
stepsAnimationState.ifNewElement(it) {
startSoftwareSystemBoundary(view, it, writer)
}
}
}
if (view is ComponentView) {
val containers = elementsInStep.filterIsInstance<Component>().map { it.container }
.sortedBy { it.id }
if (view.includeSoftwareSystemBoundaries) {
containers.map { it.softwareSystem }.sortedBy { it.id }.forEach {
stepsAnimationState.ifNewElement(it) {
startSoftwareSystemBoundary(view, it, writer)
}
}
}
containers.forEach {
stepsAnimationState.ifNewElement(it) {
startContainerBoundary(view, it, writer)
}
}
}
elementsInStep.forEach {
writeElementWithGroup(view, writer, it, stepsAnimationState)
}
}

private fun writeElementWithGroup(
view: ModelView,
writer: IndentingWriter,
element: GroupableElement,
stepsAnimationState: StepsAnimationState
) {
stepsAnimationState.ifNewElement(element) {
writeElement(view, element, writer)
}
stepsAnimationState.ifNewGroup(element.group) {
writeGroup(view, element, writer)
}
}

override fun isAnimationSupported(view: ModelView) = view.animationType == AnimationType.FRAMES

override fun writeHeader(view: ModelView, writer: IndentingWriter) {
D2TextObject.build("title", "md", "# ${view.d2Title}") {
TextObject.build("title", "md", "# ${view.d2Title}") {
near(view.d2TitlePosition)
}.writeObject(writer)
val d2Direction = view.automaticLayout?.getD2Direction()
d2Direction?.toD2Property()?.write(writer)

GlobalObject.build {
direction(view.d2Direction)
fillPattern(view.d2FillPattern)
}.writeObject(writer)
}

override fun writeElements(view: ModelView, elements: List<GroupableElement>, writer: IndentingWriter) {
Expand All @@ -33,7 +196,7 @@ open class D2Exporter : AbstractDiagramExporter() {

protected fun writeGroup(view: ModelView, element: GroupableElement, writer: IndentingWriter) {
val groupAbsolutePathInView = element.groupAbsolutePathInView(view) ?: return
D2Object.build(groupAbsolutePathInView) {
NamedObject.build(groupAbsolutePathInView) {
label(element.group)
withGroupStyle()
}.writeObject(writer)
Expand Down Expand Up @@ -77,9 +240,10 @@ open class D2Exporter : AbstractDiagramExporter() {

override fun writeRelationship(view: ModelView, relationshipView: RelationshipView, writer: IndentingWriter) {
val relationshipStyle = findRelationshipStyle(view, relationshipView.relationship)
D2Object.build(relationshipView.relationshipNameInView(view)) {
NamedObject.build(relationshipView.relationshipNameInView(view)) {
animated(relationshipStyle.d2Animated)
label(relationshipView.d2LabelInView(view))
opacity(relationshipStyle.d2Opacity())
opacity(relationshipStyle.d2Opacity)
stroke(relationshipStyle.color)
fontSize(relationshipStyle.fontSize)
when (relationshipStyle.style) {
Expand All @@ -91,21 +255,19 @@ open class D2Exporter : AbstractDiagramExporter() {
}.writeObject(writer)
}

private fun Element.writeD2Object(view: ModelView, writer: IndentingWriter) =
d2ObjectInView(view).writeObject(writer)

private fun Element.d2ObjectInView(view: ModelView): D2Object {
private fun Element.writeD2Object(view: ModelView, writer: IndentingWriter) {
val style = findElementStyle(view, this)
return D2Object.build(absolutePathInView(view)) {
NamedObject.build(absolutePathInView(view)) {
label(d2Label(view))
shape(style.shape.d2Shape())
shape(style.d2Shape)
icon(style.icon)
link(url)
tooltip(description)
fill(style.background)
fillPattern(style.d2FillPattern)
stroke(style.stroke)
strokeWidth(style.strokeWidth)
opacity(style.d2Opacity())
opacity(style.d2Opacity)
when (style.border) {
Border.Dashed -> dashed()
Border.Dotted -> dotted()
Expand All @@ -114,7 +276,7 @@ open class D2Exporter : AbstractDiagramExporter() {
multiple(hasMultipleInstances)
fontColor(style.color)
fontSize(style.fontSize)
}
}.writeObject(writer)
}

private fun Element.d2Label(view: ModelView): String = buildString {
Expand All @@ -126,4 +288,20 @@ open class D2Exporter : AbstractDiagramExporter() {

private fun Element.typeOfOrNull(view: ModelView, includeMetadataSymbols: Boolean = true): String? =
typeOf(view, this, includeMetadataSymbols).takeUnless(String::isBlank)
}

class StepsAnimationState {
private val metElements: MutableSet<String> = mutableSetOf()
private val metGroups: MutableSet<String> = mutableSetOf()

fun addElement(element: Element): Boolean = metElements.add(element.id)
fun addGroup(group: String?): Boolean = !group.isNullOrEmpty() && metGroups.add(group)

fun ifNewElement(element: Element, block: () -> Unit) {
if (addElement(element)) block()
}

fun ifNewGroup(group: String?, block: () -> Unit) {
if (addGroup(group)) block()
}
}
}
Loading

0 comments on commit 227cae0

Please sign in to comment.