///|
priv struct SwimlanePaint {
paint : Paint
diagnostics : Array[@diagrams.Diagnostic]
limit : Int32
mut used : Int32
mut failed : Bool
}
///|
fn SwimlanePaint::reserve(self : SwimlanePaint, units : Int32) -> Bool {
if units < 0 || units > self.limit - self.used {
if !self.failed {
self.diagnostics.push(
@diagrams.Diagnostic::error(
"swimlane/render-limit", "Swimlane SVG output limit exceeded",
),
)
}
self.failed = true
return false
}
self.used += units
true
}
///|
fn SwimlanePaint::escaped(
self : SwimlanePaint,
value : String,
attribute : Bool,
) -> Bool {
let mut i = 0
while i < value.length() {
let u = value.code_unit_at(i).to_int()
i += 1
if u >= 0xd800 && u <= 0xdbff {
if i >= value.length() ||
value.code_unit_at(i).to_int() < 0xdc00 ||
value.code_unit_at(i).to_int() > 0xdfff {
self.diagnostics.push(
@diagrams.Diagnostic::error(
"swimlane/invalid-xml-character", "Swimlane text contains an unpaired surrogate",
),
)
self.failed = true
return false
}
i += 1
if !self.reserve(2) {
return false
}
continue
}
if (u < 32 && u != 9 && u != 10 && u != 13) ||
(u >= 0xdc00 && u <= 0xdfff) ||
u == 0xfffe ||
u == 0xffff {
self.diagnostics.push(
@diagrams.Diagnostic::error(
"swimlane/invalid-xml-character", "Swimlane text contains a forbidden XML character",
),
)
self.failed = true
return false
}
let units = if u == 38 {
5
} else if u == 60 || u == 62 {
4
} else if u == 13 || (attribute && (u == 9 || u == 10)) {
5
} else if attribute && u == 34 {
6
} else {
1
}
if !self.reserve(units) {
return false
}
}
true
}
///|
fn SwimlanePaint::element(
self : SwimlanePaint,
name : String,
attrs~ : ArrayView[(String, String)] = [],
children~ : ArrayView[@writer.Node] = [],
) -> @writer.Node {
if !self.reserve(name.length() * 2 + 8) {
return @writer.element("g")
}
for (key, value) in attrs {
if !self.reserve(key.length() + 4) || !self.escaped(value, true) {
return @writer.element("g")
}
}
@writer.element(name, attrs~, children~)
}
///|
fn SwimlanePaint::text(self : SwimlanePaint, value : String) -> @writer.Node {
if !self.escaped(value, false) {
return @writer.text("")
}
@writer.text(value)
}
///|
fn swimlane_number(value : Double) -> String {
@swimlane_layout.svg_number(value)
}
///|
pub fn swimlane_path(edge : @swimlane_layout.Connection) -> String {
let b = StringBuilder()
for i, p in edge.line {
b.write_string(
"\{if i == 0 { "M" } else { " L" }}\{swimlane_number(p.x)} \{swimlane_number(p.y)}",
)
}
b.to_string()
}
///|
fn swimlane_outline(shape : @swimlane_layout.Shape) -> String {
if shape.shape == "datastore" {
let r = shape.outline_box
let n = swimlane_number
return "M\{n(r.x)} \{n(r.y)} H\{n(r.x+r.width)} M\{n(r.x)} \{n(r.y+r.height)} H\{n(r.x+r.width)}"
}
fn boundary(kind : String) -> String {
let b = StringBuilder()
for i, p in @swimlane_layout.outline(kind, shape.outline_box) {
b.write_string(
"\{if i == 0 { "M" } else { " L" }}\{swimlane_number(p.x)} \{swimlane_number(p.y)}",
)
}
b.write_string(" Z")
b.to_string()
}
if shape.shape == "braces" {
boundary("brace") + " " + boundary("brace-r")
} else {
boundary(shape.shape)
}
}
///|
fn SwimlanePaint::line(
self : SwimlanePaint,
d : String,
color : String,
width~ : String = "1.5",
) -> @writer.Node {
self.element("path", attrs=[
("d", d),
("fill", "none"),
("stroke", color),
("stroke-width", width),
])
}
///|
fn SwimlanePaint::label(
self : SwimlanePaint,
label : @swimlane_layout.Label,
inverted~ : Bool = false,
) -> @writer.Node {
let color = if inverted {
self.paint.color("bg")
} else {
label.family
.map(f => self.paint.color(f.name() + "-ink"))
.unwrap_or(self.paint.color("text"))
}
let children = []
for i, line in label.text.split("\n") {
children.push(
self.element(
"tspan",
attrs=[
("x", swimlane_number(label.box.x + label.box.width / 2.0)),
(
"y",
swimlane_number(
label.box.y + label.size + i.to_double() * (label.size + 4.0),
),
),
],
children=[self.text(line.to_owned())],
),
)
}
self.element(
"text",
attrs=[
("class", "swimlane-label"),
("data-label-owner", label.owner),
("x", swimlane_number(label.box.x + label.box.width / 2.0)),
("y", swimlane_number(label.box.y + label.size)),
("text-anchor", "middle"),
("font-family", font_stack),
("font-size", swimlane_number(label.size)),
("font-weight", label.weight.to_string()),
("fill", color),
],
children~,
)
}
///|
fn SwimlanePaint::decorations(
self : SwimlanePaint,
shape : @swimlane_layout.Shape,
stroke : String,
) -> Array[@writer.Node] {
let r = shape.outline_box
let x = r.x
let y = r.y
let w = r.width
let h = r.height
let n = swimlane_number
let result = []
fn line(d : String) {
result.push(self.line(d, stroke))
}
match shape.shape {
"subroutine" => {
line("M\{n(x+10.0)} \{n(y)} V\{n(y+h)}")
line("M\{n(x+w-10.0)} \{n(y)} V\{n(y+h)}")
}
"lin-rect" | "lin-doc" => line("M\{n(x+12.0)} \{n(y+8.0)} V\{n(y+h-8.0)}")
"div-rect" | "composite" => line("M\{n(x)} \{n(y+14.0)} H\{n(x+w)}")
"win-pane" => {
line("M\{n(x+14.0)} \{n(y)} V\{n(y+h)}")
line("M\{n(x)} \{n(y+14.0)} H\{n(x+w)}")
}
"doublecircle" | "fr-circ" | "cross-circ" => {
result.push(
self.element("ellipse", attrs=[
("cx", n(x + w / 2.0)),
("cy", n(y + h / 2.0)),
("rx", n(w / 2.0 - 6.0)),
("ry", n(h / 2.0 - 6.0)),
(
"fill",
if shape.shape == "fr-circ" {
self.paint.color("messagebus-ink")
} else {
"none"
},
),
("stroke", stroke),
("stroke-width", "1.5"),
]),
)
if shape.shape == "cross-circ" {
line(
"M\{n(x+w*0.15)} \{n(y+h*0.15)} L\{n(x+w*0.85)} \{n(y+h*0.85)} M\{n(x+w*0.85)} \{n(y+h*0.15)} L\{n(x+w*0.15)} \{n(y+h*0.85)}",
)
}
}
"cylinder" | "lin-cyl" => {
result.push(
self.element("ellipse", attrs=[
("cx", n(x + w / 2.0)),
("cy", n(y + h * 0.15)),
("rx", n(w / 2.0)),
("ry", n(h * 0.15)),
("fill", "none"),
("stroke", stroke),
("stroke-width", "1.5"),
]),
)
if shape.shape == "lin-cyl" {
for offset in [0.24, 0.3] {
line(
"M\{n(x)} \{n(y+h*offset)} Q\{n(x+w/2.0)} \{n(y+h*(offset+0.25))} \{n(x+w)} \{n(y+h*offset)}",
)
}
}
}
"h-cyl" =>
result.push(
self.element("ellipse", attrs=[
("cx", n(x + w * 0.12)),
("cy", n(y + h / 2.0)),
("rx", n(w * 0.12)),
("ry", n(h / 2.0)),
("fill", "none"),
("stroke", stroke),
("stroke-width", "1.5"),
]),
)
"tag-doc" | "tag-rect" | "note" =>
line("M\{n(x+w-18.0)} \{n(y)} V\{n(y+18.0)} H\{n(x+w)}")
"st-rect" | "docs" => {
line("M\{n(x+6.0)} \{n(y+6.0)} H\{n(x+w-6.0)} V\{n(y+h-6.0)}")
line("M\{n(x+12.0)} \{n(y+12.0)} H\{n(x+w-12.0)}")
}
"fork" => ()
"browser" | "console" => {
line("M\{n(x)} \{n(y+18.0)} H\{n(x+w)}")
if shape.shape == "console" {
line(
"M\{n(x+8.0)} \{n(y+7.0)} L\{n(x+13.0)} \{n(y+10.0)} L\{n(x+8.0)} \{n(y+13.0)}",
)
} else {
for px in [8.0, 15.0, 22.0] {
result.push(
self.element("circle", attrs=[
("cx", n(x + px)),
("cy", n(y + 9.0)),
("r", "2"),
("fill", stroke),
]),
)
}
}
}
"bucket" =>
line(
"M\{n(x+w*0.15)} \{n(y+8.0)} Q\{n(x+w/2.0)} \{n(y-8.0)} \{n(x+w*0.85)} \{n(y+8.0)}",
)
"person" =>
result.push(
self.element("circle", attrs=[
("cx", n(x + w / 2.0)),
("cy", n(y + h * 0.17)),
("r", n(h * 0.15)),
("fill", self.paint.color("backend-fill")),
("stroke", stroke),
]),
)
"bang" => {
line("M\{n(x+w/2.0)} \{n(y+8.0)} V\{n(y+22.0)}")
result.push(
self.element("circle", attrs=[
("cx", n(x + w / 2.0)),
("cy", n(y + 27.0)),
("r", "2"),
("fill", stroke),
]),
)
}
_ => ()
}
result
}
///|
fn SwimlanePaint::shape(
self : SwimlanePaint,
shape : @swimlane_layout.Shape,
node : @swimlane.Node?,
) -> @writer.Node {
let family = @swimlane_layout.family(shape.shape)
let stroke = if shape.kind == Node {
self.paint.color(family.name() + "-stroke")
} else {
self.paint.color("muted")
}
let filled = ["f-circ", "sm-circ", "anchor", "fork"].contains(shape.shape)
let fill = if filled {
self.paint.color(family.name() + "-ink")
} else if shape.kind == Node {
self.paint.color(family.name() + "-fill")
} else {
self.paint.color("bg")
}
let empty = ["text"].contains(shape.shape)
let attrs = [
(
"class",
"swimlane-\{match shape.kind { Lane => "lane"; Group => "group"; Node => "node" }}",
),
("data-swimlane-id", shape.id),
("data-shape", shape.shape),
("data-expanded", if shape.expanded { "true" } else { "false" }),
]
if shape.lane is Some(lane) {
attrs.push(("data-lane", lane))
}
if shape.parent is Some(parent) {
attrs.push(("data-parent", parent))
}
if node.bind(n => n.properties) is Some(properties) {
if properties.link is Some(link) {
attrs.push(("data-link", link))
}
for i, (key, value) in properties.metadata {
attrs.push(
(
"data-property-" +
i.to_string() +
"-" +
key.to_array().map(ch => ch.to_int().to_string(radix=16)).join("-"),
value,
),
)
}
}
let outline_attrs = [
("class", "swimlane-outline"),
("d", swimlane_outline(shape)),
("fill", if empty { "none" } else { fill }),
("stroke", if empty { "none" } else { stroke }),
("stroke-width", if shape.kind == Lane { "2" } else { "1.5" }),
]
if shape.kind == Group {
outline_attrs.push(("stroke-dasharray", "6 4"))
}
let box = shape.box
let children = [
self.element("rect", attrs=[
("class", "swimlane-bounds"),
("x", swimlane_number(box.x)),
("y", swimlane_number(box.y)),
("width", swimlane_number(box.width)),
("height", swimlane_number(box.height)),
("fill", "none"),
("stroke", "none"),
("pointer-events", "none"),
("aria-hidden", "true"),
]),
self.element("path", attrs=outline_attrs),
]
children.append(self.decorations(shape, stroke))
if shape.label is Some(label) {
children.push(
self.label(
label,
inverted=filled && shape.outline_box.contains(label.box),
),
)
}
self.element("g", attrs~, children~)
}
///|
fn swimlane_marker_name(marker : @swimlane.Marker) -> String? {
match marker {
None => None
Arrow => Some("sl-arrow")
Circle => Some("sl-circle")
Cross => Some("sl-cross")
}
}
///|
fn SwimlanePaint::connection(
self : SwimlanePaint,
edge : @swimlane_layout.Connection,
) -> @writer.Node {
let flow = edge.connection
let attrs = [
("d", swimlane_path(edge)),
("fill", "none"),
("stroke", self.paint.color("arrow")),
("stroke-width", if flow.style == Thick { "3" } else { "1.5" }),
]
if flow.style == Dotted {
attrs.push(("stroke-dasharray", "4 4"))
}
if swimlane_marker_name(flow.source_marker) is Some(id) {
attrs.push(("marker-start", "url(#" + id + ")"))
}
if swimlane_marker_name(flow.target_marker) is Some(id) {
attrs.push(("marker-end", "url(#" + id + ")"))
}
let children = [self.element("path", attrs~)]
if edge.label is Some(label) {
let r = label.box
children.push(
self.element("rect", attrs=[
("x", swimlane_number(r.x - 4.0)),
("y", swimlane_number(r.y - 2.0)),
("width", swimlane_number(r.width + 8.0)),
("height", swimlane_number(r.height + 4.0)),
("rx", "3"),
("fill", self.paint.color("bg")),
]),
)
children.push(self.label(label))
}
self.element(
"g",
attrs=[
("class", "swimlane-connection"),
("data-swimlane-id", flow.id),
("data-source", flow.source),
("data-target", flow.target),
("data-display-source", edge.source),
("data-display-target", edge.target),
],
children~,
)
}
///|
fn paint_swimlane(
view : @swimlane.Swimlane,
theme : @theme.Theme,
mode : Mode,
id_prefix : String?,
limits : @swimlane_layout.Limits,
precomputed? : @swimlane_layout.Result,
) -> @render.Rendered {
let result = precomputed.unwrap_or_else(() => {
@swimlane_layout.layout(view, limits~)
})
let diagnostics = result.diagnostics.iter().to_array()
guard result.geometry is Some(geometry) else {
return @render.Rendered::failed("image/svg+xml", diagnostics)
}
let theme = @theme.Theme::{
dark: @swimlane_layout.palette(theme.dark),
light: @swimlane_layout.palette(theme.light),
}
let c = SwimlanePaint::{
paint: Paint::{ theme, mode, },
diagnostics,
limit: limits.output_units,
used: 0,
failed: false,
}
let defs = []
if mode == Adaptive {
defs.push(c.element("style", children=[c.text(style_sheet(theme))]))
}
let color = c.paint.color("arrow")
for
(id, shape) in [
("sl-arrow", "M-9 -4 L0 0 L-9 4 Z"),
("sl-circle", "M-4 0 A4 4 0 1 0 4 0 A4 4 0 1 0 -4 0 Z"),
("sl-cross", "M-4 -4 L4 4 M-4 4 L4 -4"),
] {
defs.push(
c.element(
"marker",
attrs=[
("id", id),
("viewBox", "-10 -6 16 12"),
("refX", "0"),
("refY", "0"),
("markerWidth", "16"),
("markerHeight", "12"),
("markerUnits", "userSpaceOnUse"),
("orient", "auto-start-reverse"),
],
children=[
c.element("path", attrs=[
("d", shape),
("fill", if id == "sl-cross" { "none" } else { color }),
("stroke", color),
("stroke-width", "1.5"),
]),
],
),
)
}
let node_map = Map([])
for node in view.nodes {
node_map[node.id] = node
}
let backgrounds = []
let nodes = []
let connections = []
for shape in geometry.shapes {
if c.failed {
break
}
if shape.kind == Node {
nodes.push(c.shape(shape, node_map.get(shape.id)))
}
}
// Larger ancestors paint first, independently of authored declaration order.
let ordered = geometry.shapes.iter().filter(s => s.kind != Node).to_array()
ordered.sort_by((a, b) => {
let area = (b.box.width * b.box.height).compare(a.box.width * a.box.height)
if area == 0 {
a.index.compare(b.index)
} else {
area
}
})
for shape in ordered {
if c.failed {
break
}
backgrounds.push(c.shape(shape, None))
}
for edge in geometry.connections {
if c.failed {
break
}
if edge.connection.style != Invisible {
connections.push(c.connection(edge))
}
}
let title = view.accessibility_title
.map(value => Some(value))
.unwrap_or(view.title)
.unwrap_or(view.id)
let description = view.accessibility_description.unwrap_or(
"Swimlane diagram with \{view.lanes.length()} responsibility lanes and \{view.connections.length()} structural connections. Playback illustrates structure and does not execute a process.",
)
let root = c.element(
"svg",
attrs=[
("xmlns", "http://www.w3.org/2000/svg"),
("viewBox", "0 0 \{geometry.width} \{geometry.height}"),
("width", geometry.width.to_string()),
("height", geometry.height.to_string()),
("role", "img"),
("data-diagram", "swimlane"),
],
children=[
c.element("title", children=[c.text(title)]),
c.element("desc", children=[c.text(description)]),
c.element("defs", children=defs),
c.element("rect", attrs=[
("class", "bg"),
("x", "0"),
("y", "0"),
("width", geometry.width.to_string()),
("height", geometry.height.to_string()),
("fill", c.paint.color("bg")),
]),
c.element(
"g",
attrs=[("class", "swimlane-backgrounds")],
children=backgrounds,
),
c.element(
"g",
attrs=[("class", "swimlane-connections")],
children=connections,
),
c.element("g", attrs=[("class", "swimlane-nodes")], children=nodes),
],
)
if c.failed {
return @render.Rendered::failed("image/svg+xml", diagnostics)
}
fn prefix_failure() -> @render.Rendered {
@render.Rendered::failed("image/svg+xml", [
@diagrams.Diagnostic::error(
"swimlane/render-limit", "Scoped SVG identifiers exceed the output limit",
),
])
}
let original = @writer.to_string(root)
if original.length() > limits.output_units - 128 {
return prefix_failure()
}
let stack = [root]
let mut occurrences = 1
while stack.pop() is Some(node) {
match node {
Text(_) => ()
Element(name, attrs, children) => {
if name == "style" {
occurrences += 4
}
for (key, _) in attrs {
if key == "id" || key == "marker-start" || key == "marker-end" {
occurrences += 1
}
}
stack.append(children)
}
}
}
let prefix_limit = (limits.output_units - original.length() - 128) /
occurrences
let prefix = match id_prefix {
Some(raw) => {
if raw.length() > prefix_limit / 2 {
return prefix_failure()
}
let out = StringBuilder()
let mut units = 5
out.write_string("sl-u-")
for ch in raw {
let part = ch.to_int().to_string(radix=16) + "-"
if part.length() > prefix_limit - units {
return prefix_failure()
}
units += part.length()
out.write_string(part)
}
out.to_string()
}
None => {
let mut hash : UInt64 = 14695981039346656037
for i in 0.. prefix_limit {
return prefix_failure()
}
let output = @writer.to_string(scope_svg(root, prefix, theme))
if output.length() > limits.output_units {
return @render.Rendered::failed("image/svg+xml", [
@diagrams.Diagnostic::error(
"swimlane/render-limit", "Scoped SVG exceeds its output limit",
),
])
}
let palettes = match mode {
Adaptive => [("dark", theme.dark), ("light", theme.light)]
Fixed(Dark) => [("dark", theme.dark)]
Fixed(Light) => [("light", theme.light)]
}
for (name, palette) in palettes {
for
finding in @swimlane_layout.check(
geometry,
palette,
work_limit=limits.work,
) {
if finding.rule == Limit {
diagnostics.push(
@diagrams.Diagnostic::error("swimlane/layout-limit", finding.message),
)
return @render.Rendered::failed("image/svg+xml", diagnostics)
}
diagnostics.push(
@diagrams.Diagnostic::warning(
"swimlane/legibility",
name + ": " + finding.message,
),
)
}
}
@render.Rendered::ok(output, "image/svg+xml", diagnostics~)
}
///|
/// Render a standalone swimlane with the same caller-supplied options as SvgRenderer.
pub fn render_swimlane(
view : @swimlane.Swimlane,
theme~ : @theme.Theme = @theme.Theme::classic(),
mode~ : Mode = Adaptive,
id_prefix? : String,
limits~ : @swimlane_layout.Limits = @swimlane_layout.Limits::new(),
) -> @render.Rendered {
paint_swimlane(view, theme, mode, id_prefix, limits)
}