///|
fn boundary(point : Point, box : Rect, side : @model.Side) -> Bool {
match side {
Left =>
point.x == box.x && point.y >= box.y && point.y <= box.y + box.height
Right =>
point.x == box.x + box.width &&
point.y >= box.y &&
point.y <= box.y + box.height
Top => point.y == box.y && point.x >= box.x && point.x <= box.x + box.width
Bottom =>
point.y == box.y + box.height &&
point.x >= box.x &&
point.x <= box.x + box.width
}
}
///|
pub fn check(
geometry : Geometry,
palette~ : @theme.Palette = @theme.Theme::classic().light,
work_limit~ : Int32 = 1000000,
) -> ReadOnlyArray[Finding] {
let out : Array[Finding] = []
let mut work = 0
let shapes : Map[String, Shape] = Map([])
fn finding(code : String, message : String, element : String) {
out.push({ code, message, element, })
}
for shape in geometry.shapes {
work += 1 + shape.label.length()
if work > work_limit {
finding("limit", "Legibility work budget exhausted", geometry.model.id)
return ReadOnlyArray::from_array(out[:])
}
shapes[shape.id] = shape
if shape.font_size < 11 {
finding("small-text", "Text size is below 11 px", shape.id)
}
if shape.label != "" &&
measure(shape.label, size=shape.font_size) > shape.box.width - 24 {
finding("label-overflow", "Label exceeds measured shape bounds", shape.id)
}
if shape.box.width < 0 ||
shape.box.height < 0 ||
shape.box.x < 0 ||
shape.box.y < 0 ||
shape.box.x + shape.box.width > geometry.width ||
shape.box.y + shape.box.height > geometry.height {
finding("overflow", "Shape exceeds viewport", shape.id)
}
}
for shape in geometry.shapes {
if shape.parent is Some(parent) {
match shapes.get(parent) {
Some(group) =>
if !group.box.contains(shape.box) {
finding(
"containment",
"Shape is outside its parent group",
shape.id,
)
}
None => finding("containment", "Parent group has no geometry", shape.id)
}
}
}
fn ancestor(group : String, shape : Shape) -> Bool {
let mut parent = shape.parent
let mut depth = 0
while parent is Some(p) {
work += 1
if work > work_limit || depth >= geometry.shapes.length() {
return false
}
if p == group {
return true
}
parent = shapes.get(p).bind(s => s.parent)
depth += 1
}
false
}
for i, a in geometry.shapes {
for j = i + 1; j < geometry.shapes.length(); j = j + 1 {
work += 1
if work > work_limit {
finding("limit", "Legibility work budget exhausted", geometry.model.id)
return ReadOnlyArray::from_array(out[:])
}
let b = geometry.shapes[j]
if a.box.overlaps(b.box) && !ancestor(a.id, b) && !ancestor(b.id, a) {
finding("overlap", "Unrelated shape boxes overlap", a.id + "," + b.id)
}
}
}
for route in geometry.connections {
if route.points.length() < 2 {
finding("endpoint", "Connection has no path", route.connection.id)
continue
}
for shape in geometry.shapes {
let source = shapes.get(route.connection.source.id)
let target = shapes.get(route.connection.target.id)
let box = if shape.kind == Group &&
(
source.map(s => ancestor(shape.id, s)).unwrap_or(false) ||
target.map(s => ancestor(shape.id, s)).unwrap_or(false)
) {
{ ..shape.box, height: shape.header_height, }
} else {
shape.box
}
for i in 1.. work_limit {
finding(
"limit",
"Legibility work budget exhausted",
geometry.model.id,
)
return ReadOnlyArray::from_array(out[:])
}
if crosses(route.points[i - 1], route.points[i], box) {
finding(
"overlap",
"Connection crosses an unrelated shape or group title",
route.connection.id + "," + shape.id,
)
break
}
}
if route.label_box is Some(label) &&
label.overlaps(
if shape.kind == Group {
{ ..shape.box, height: shape.header_height, }
} else {
shape.box
},
) {
finding(
"label-overflow",
"Connection label overlaps a node",
route.connection.id,
)
}
}
for
(endpoint, point) in [
(route.connection.source, route.points[0]),
(route.connection.target, route.points[route.points.length() - 1]),
] {
match shapes.get(endpoint.id) {
Some(shape) => {
let box = if endpoint.group {
shape.parent
.bind(p => shapes.get(p))
.map(g => g.box)
.unwrap_or(shape.box)
} else {
shape.box
}
if !boundary(point, box, endpoint.side) {
finding(
"endpoint",
"Connection misses authored endpoint port",
route.connection.id,
)
}
}
None =>
finding(
"endpoint",
"Connection endpoint is absent",
route.connection.id,
)
}
}
}
if @theme.contrast(palette.text, palette.bg) < 4.5 {
finding("contrast", "Text contrast is below 4.5:1", geometry.model.id)
}
ReadOnlyArray::from_array(out[:])
}