///|
/// A budgeted SVG builder for data views. It escapes text, refuses
/// characters that XML forbids and stops at the output limit.
priv struct DataPaint {
paint : Paint
diagnostics : Array[@diagrams.Diagnostic]
limit : Int32
mut used : Int32
mut failed : Bool
}
///|
fn DataPaint::reserve(self : DataPaint, units : Int32) -> Bool {
if units < 0 || units > self.limit - self.used {
if !self.failed {
self.diagnostics.push(
@diagrams.Diagnostic::error(
"data-tree/render-limit", "Data-tree SVG output limit exceeded",
),
)
}
self.failed = true
return false
}
self.used += units
true
}
///|
fn DataPaint::invalid(self : DataPaint, message : String) -> Bool {
if !self.failed {
self.diagnostics.push(
@diagrams.Diagnostic::error("data-tree/invalid-xml-character", message),
)
}
self.failed = true
false
}
///|
fn DataPaint::escaped(
self : DataPaint,
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 {
return self.invalid("Data-tree text contains an unpaired surrogate")
}
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 {
return self.invalid(
"Data-tree text contains a character that XML forbids",
)
}
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 DataPaint::element(
self : DataPaint,
name : String,
attrs~ : ArrayView[(String, String)] = [],
children~ : ArrayView[@writer.Node] = [],
) -> @writer.Node {
if !self.reserve(name.length() * 2 + 8) {
return @writer.element("g")
}
for pair in attrs {
let (key, value) = pair
if !self.reserve(key.length() + 4) || !self.escaped(value, true) {
return @writer.element("g")
}
}
@writer.element(name, attrs~, children~)
}
///|
fn DataPaint::text(self : DataPaint, value : String) -> @writer.Node {
if !self.escaped(value, false) {
return @writer.text("")
}
@writer.text(value)
}
///|
fn data_number(value : Double) -> String {
@data_tree_layout.svg_number(value)
}
///|
fn DataPaint::rect(
self : DataPaint,
class : String,
r : @data_tree_layout.Rect,
fill : String,
stroke : String,
width : String,
radius? : String,
) -> @writer.Node {
let attrs = [
("class", class),
("x", data_number(r.x)),
("y", data_number(r.y)),
("width", data_number(r.width)),
("height", data_number(r.height)),
]
if radius is Some(rx) {
attrs.push(("rx", rx))
}
attrs.push(("fill", fill))
attrs.push(("stroke", stroke))
attrs.push(("stroke-width", width))
self.element("rect", attrs~)
}
///|
/// One label with a line for each wrapped line. Spaces are kept.
fn DataPaint::label(
self : DataPaint,
class : String,
label : @data_tree_layout.Label,
) -> @writer.Node {
let step = if label.size >= 16.0 { 21.0 } else { 17.0 }
let x = data_number(label.box.x)
let lines = []
for i, line in label.lines {
lines.push(
self.element(
"tspan",
attrs=[
("x", x),
(
"y",
data_number(label.box.y + label.size + i.to_double() * step - 2.0),
),
// Keep repeated and outer spaces of a value, and only of the value.
("xml:space", "preserve"),
],
children=[self.text(line)],
),
)
}
self.element(
"text",
attrs=[
("class", class),
("font-family", font_stack),
("font-size", data_number(label.size)),
("font-weight", label.weight.to_string()),
("fill", self.paint.color(label.ink.token())),
],
children=lines,
)
}
///|
fn DataPaint::row(
self : DataPaint,
row : @data_tree_layout.Row,
) -> @writer.Node {
let attrs = [
("class", "data-row"),
("data-member", row.entry),
("data-node", row.node),
("data-type", row.kind.name()),
]
if row.highlighted {
attrs.push(("data-highlight", "true"))
}
let children = [
self.rect(
"data-row-bounds",
row.box,
self.paint.color(if row.highlighted { "cloud-fill" } else { "mask" }),
self.paint.color(
if row.highlighted {
"cloud-stroke"
} else {
"external-stroke"
},
),
if row.highlighted {
"1.5"
} else {
"0.75"
},
),
]
if row.key is Some(key) {
children.push(self.label("data-key", key))
}
if row.value is Some(value) {
children.push(self.label("data-value", value))
}
self.element("g", attrs~, children~)
}
///|
fn DataPaint::box(
self : DataPaint,
box : @data_tree_layout.Box,
) -> @writer.Node {
let attrs = [
("class", "data-box"),
("data-node", box.node),
("data-type", box.kind.name()),
]
let container = box.kind is (Object | Array)
if container && box.rows.is_empty() {
attrs.push(("data-empty", "true"))
}
if box.highlighted {
attrs.push(("data-highlight", "true"))
}
let children = [
self.rect(
"data-box-frame",
box.box,
self.paint.color("mask"),
self.paint.color(
if box.highlighted {
"cloud-stroke"
} else {
"external-stroke"
},
),
if box.highlighted {
"2.5"
} else {
"1.5"
},
radius="4",
),
]
if box.label is Some(label) {
children.push(self.label("data-value", label))
}
for row in box.rows {
if self.failed {
break
}
children.push(self.row(row))
}
if box.divider is Some(x) {
children.push(
self.element("path", attrs=[
("class", "data-divider"),
(
"d",
"M\{data_number(x)} \{data_number(box.box.y)} V\{data_number(box.box.bottom())}",
),
("fill", "none"),
("stroke", self.paint.color("external-stroke")),
("stroke-width", "0.75"),
]),
)
}
self.element("g", attrs~, children~)
}
///|
/// The painted connector path of a geometry connector.
fn data_connector_path(connector : @data_tree_layout.Connector) -> String {
connector.path()
}
///|
fn data_theme(theme : @theme.Theme) -> @theme.Theme {
{
dark: @data_tree_layout.palette(theme.dark),
light: @data_tree_layout.palette(theme.light),
}
}
///|
fn paint_data_tree(
view : @data_tree.DataTree,
theme : @theme.Theme,
mode : Mode,
id_prefix : String?,
limits : @data_tree_layout.Limits,
precomputed? : @data_tree_layout.Result,
connectors~ : ConnectorOptions = ConnectorOptions::new(),
) -> @render.Rendered {
if !connector_options_valid(connectors) {
return @render.Rendered::failed("image/svg+xml", [
@diagrams.Diagnostic::error(
"connector/invalid-option", "Invalid connector options",
),
])
}
let limits = limits.with_minimum_approach(connectors.minimum_approach)
let result = match precomputed {
Some(result) => result
None => @data_tree_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 = data_theme(theme)
let c : DataPaint = {
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))]))
}
defs.push(
c.element(
"marker",
attrs=[
("id", "dt-arrow"),
("viewBox", "-10 -6 12 12"),
("refX", "0"),
("refY", "0"),
("markerWidth", "12"),
("markerHeight", "12"),
("markerUnits", "userSpaceOnUse"),
("orient", "auto"),
],
children=[
c.element("path", attrs=[
("d", "M-9 -4 L0 0 L-9 4 Z"),
("fill", c.paint.color("arrow")),
]),
],
),
)
let connector_nodes = []
for connector in geometry.connectors {
if c.failed {
break
}
connector_nodes.push(
c.element("path", attrs=[
("class", "data-connector"),
("data-member", connector.entry),
("data-source", connector.source),
("data-target", connector.target),
("d", data_connector_path(connector)),
("fill", "none"),
("stroke", c.paint.color("arrow")),
("stroke-width", "1.5"),
("marker-end", "url(#dt-arrow)"),
]),
)
}
let boxes = []
for box in geometry.boxes {
if c.failed {
break
}
boxes.push(c.box(box))
}
let containers = geometry.boxes
.iter()
.filter(b => b.kind is (Object | Array))
.count()
let title = view.accessibility_title
.map(value => Some(value))
.unwrap_or(view.title)
.unwrap_or(view.id)
let description = view.accessibility_description.unwrap_or(
"Structured data diagram with \{view.nodes.length()} values in \{containers} containers. Rows show members in source order and arrays show positions; connectors join nested containers.",
)
let body = [
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")),
]),
]
if geometry.title is Some(label) {
body.push(c.label("data-title", label))
}
body.push(
c.element(
"g",
attrs=[("class", "data-connectors")],
children=connector_nodes,
),
)
body.push(c.element("g", attrs=[("class", "data-boxes")], children=boxes))
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", "data-tree"),
],
children=body,
)
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(
"data-tree/render-limit", "Scoped SVG identifiers exceed the output limit",
),
])
}
let obstacles : Array[ConnectorObstacle] = []
for box in geometry.boxes {
let r = box.box
obstacles.push({
owner: box.node,
x: r.x,
y: r.y,
width: r.width,
height: r.height,
})
}
guard finalize_connectors(
root,
connectors,
diagnostics,
obstacles=obstacles[:],
)
is Some(root) else {
return @render.Rendered::failed("image/svg+xml", diagnostics)
}
let original = @writer.to_string(root)
if original.length() > limits.output_units - 128 {
return prefix_failure()
}
// The scoped style sheet repeats the prefix four times; the marker and its
// two references use it too.
let occurrences = (if mode == Adaptive { 4 } else { 0 }) +
2 +
connector_nodes.length()
let prefix_limit = (limits.output_units - original.length() - 128) /
occurrences
let prefix = match id_prefix {
Some(raw) => {
let out = StringBuilder()
out.write_string("dt-u-")
for ch in raw {
out.write_string(ch.to_int().to_string(radix=16) + "-")
if out.to_string().length() > prefix_limit {
return prefix_failure()
}
}
out.to_string()
}
None => {
// FNV-1a over UTF-16 units keeps IDs deterministic on every backend.
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(
"data-tree/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 pair in palettes {
let (name, palette) = pair
for
finding in @data_tree_layout.check(
geometry,
palette,
work_limit=limits.work,
) {
if finding.rule == Limit {
diagnostics.push(
@diagrams.Diagnostic::error("data-tree/layout-limit", finding.message),
)
return @render.Rendered::failed("image/svg+xml", diagnostics)
}
diagnostics.push(
@diagrams.Diagnostic::warning(
"data-tree/legibility",
name + ": " + finding.message,
),
)
}
}
@render.Rendered::ok(output, "image/svg+xml", diagnostics~)
}
///|
/// Render one standalone data-tree SVG with the same caller-supplied options
/// as `SvgRenderer`. Keys and values are escaped text; a stereotype of a
/// highlight is never style. Highlights use the theme colours.
pub fn render_data_tree(
view : @data_tree.DataTree,
theme~ : @theme.Theme = @theme.Theme::classic(),
mode~ : Mode = Adaptive,
id_prefix? : String,
limits~ : @data_tree_layout.Limits = @data_tree_layout.Limits::new(),
connectors~ : ConnectorOptions = ConnectorOptions::new(),
) -> @render.Rendered {
paint_data_tree(view, theme, mode, id_prefix, limits, connectors~)
}
///|
/// Scene geometry for a data tree: the canonical render, the connector paths
/// and the box/row bindings, all from one measured layout.
fn data_tree_scene(
renderer : SvgRenderer,
view : @data_tree.DataTree,
catalog : @animation.Catalog,
output_units : Int32,
) -> Result[
(@render.Rendered, Array[(@animation.Occurrence, String)], Array[NodeBinding]),
ReadOnlyArray[@diagrams.Diagnostic],
] {
let limits = renderer.data_tree_limits.with_minimum_approach(
renderer.connectors.minimum_approach,
)
let result = @data_tree_layout.layout(view, limits~)
let rendered = paint_data_tree(
view,
renderer.theme,
renderer.mode,
renderer.id_prefix,
limits,
connectors=renderer.connectors,
precomputed=result,
)
guard result.geometry is Some(geometry) && rendered.output is Some(_) else {
return Err(rendered.diagnostics)
}
fn mismatch(
message : String,
) -> Result[
(
@render.Rendered,
Array[(@animation.Occurrence, String)],
Array[NodeBinding],
),
ReadOnlyArray[@diagrams.Diagnostic],
] {
Err([@diagrams.Diagnostic::error("animation/binding-mismatch", message)])
}
let connectors : Map[String, @data_tree_layout.Connector] = Map([])
for connector in geometry.connectors {
connectors[connector.entry] = connector
}
let boxes : Map[String, Bool] = Map([])
let rows : Map[String, Bool] = Map([])
for box in geometry.boxes {
boxes[box.node] = true
for row in box.rows {
rows[row.entry] = true
}
}
let paths = []
let nodes = []
let mut units = 0
for occurrence in catalog.occurrences {
match occurrence.kind {
DataConnector => {
guard occurrence.data_member is Some(entry) &&
connectors.get(entry.id) is Some(connector) else {
return mismatch("A data connector occurrence has no drawn path")
}
let path = data_connector_path(connector)
if path.length() > output_units - units {
return Err([
@diagrams.Diagnostic::error(
"animation/scene-limit", "Data-tree scene paths exceed their output limit",
),
])
}
units += path.length()
paths.push((occurrence, path))
}
Visit(DataBox) => {
guard occurrence.data_node is Some(node) && boxes.contains(node.id) else {
return mismatch("A data box occurrence has no drawn box")
}
nodes.push({ endpoint: occurrence.key, kind: DataBox(node.id), })
}
Visit(DataRow) => {
guard occurrence.data_member is Some(entry) && rows.contains(entry.id) else {
return mismatch("A data row occurrence has no drawn row")
}
nodes.push({ endpoint: occurrence.key, kind: DataRow(entry.id), })
}
_ =>
return mismatch(
"A data-tree catalog has a target that is not a box, row or connector",
)
}
}
if paths.length() != geometry.connectors.length() {
return mismatch("The drawn connectors and the catalog connectors differ")
}
Ok((rendered, paths, nodes))
}