use std::collections::HashMap;
use abstracttui::base::{Point, Rect, Rgba};
use abstracttui::canvas::DotCanvas;
use abstracttui::text::truncate_ellipsis;
use crate::desc::GraphDesc;
use crate::layout::Layout;
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub(crate) enum StrokeKind {
Solid,
Dotted,
Thick,
}
pub(crate) struct PlannedEdge {
pub points: Vec<Point>,
pub bow: f64,
pub kind: StrokeKind,
pub broken: bool,
pub arrow: Option<(Point, char)>,
pub label: Option<(Point, String)>,
}
const LABEL_BUDGET: i32 = 16;
pub(crate) fn plan_edges(desc: &GraphDesc, layout: &Layout) -> Vec<PlannedEdge> {
let rect_of: HashMap<&str, Rect> = layout
.nodes
.iter()
.map(|n| (n.id.as_str(), n.rect))
.collect();
let mut pair_total: HashMap<(String, String), usize> = HashMap::new();
for e in &layout.edges {
if e.waypoints.len() == 2 && e.from != e.to {
*pair_total.entry(pair_key(&e.from, &e.to)).or_insert(0) += 1;
}
}
let mut pair_seen: HashMap<(String, String), usize> = HashMap::new();
let mut planned = Vec::with_capacity(layout.edges.len());
for e in &layout.edges {
if e.waypoints.is_empty() {
continue;
}
let meta = desc.edges.get(e.desc_index);
let hint = meta.and_then(|m| m.style.as_deref()).unwrap_or("");
let kind = if e.broken || hint.contains("dotted") || hint.contains("dashed") {
StrokeKind::Dotted
} else if hint.contains("thick") || hint.contains("bold") {
StrokeKind::Thick
} else {
StrokeKind::Solid
};
let open = hint.contains("open");
let bow = if e.waypoints.len() == 2 && e.from != e.to {
let key = pair_key(&e.from, &e.to);
let total = pair_total[&key];
if total > 1 {
let slot = pair_seen.entry(key).or_insert(0);
let k = *slot;
*slot += 1;
let centered = 2.0 * (k as f64) - (total as f64 - 1.0);
if canonical_forward(&e.from, &e.to) {
centered
} else {
-centered
}
} else {
0.0
}
} else {
0.0
};
let arrow = if open {
None
} else {
plan_arrow(&e.waypoints, rect_of.get(e.to.as_str()).copied())
};
let label = meta.and_then(|m| m.label.as_deref()).and_then(|text| {
let mid = polyline_mid(&e.waypoints);
let t = truncate_ellipsis(text, LABEL_BUDGET);
if t.is_empty() {
return None;
}
let w = abstracttui::text::width(&t);
let fits = |x: i32, y: i32| {
let run = Rect::new(x, y, w, 1);
layout
.nodes
.iter()
.all(|n| n.rect.intersect(run).is_empty())
};
let centred = mid.x - (w - 1) / 2;
for nudge in [0, -1, 1, -2, 2, -3, 3] {
for row in [mid.y, mid.y - 1, mid.y + 1] {
if fits(centred + nudge, row) {
return Some((Point::new(centred + nudge, row), t));
}
}
}
None
});
planned.push(PlannedEdge {
points: e.waypoints.clone(),
bow,
kind,
broken: e.broken,
arrow,
label,
});
}
planned
}
fn polyline_mid(points: &[Point]) -> Point {
let n = points.len();
if n.is_multiple_of(2) {
let (a, b) = (points[n / 2 - 1], points[n / 2]);
Point::new((a.x + b.x) / 2, (a.y + b.y) / 2)
} else {
points[n / 2]
}
}
fn pair_key(a: &str, b: &str) -> (String, String) {
if a <= b {
(a.to_string(), b.to_string())
} else {
(b.to_string(), a.to_string())
}
}
fn canonical_forward(from: &str, to: &str) -> bool {
from <= to
}
fn plan_arrow(points: &[Point], to_rect: Option<Rect>) -> Option<(Point, char)> {
let last = *points.last()?;
let mut dir: Option<(i32, i32)> = None;
for p in points.iter().rev().skip(1) {
let d = (last.x - p.x, last.y - p.y);
if d != (0, 0) {
dir = Some(d);
break;
}
}
if dir.is_none() {
if let Some(r) = to_rect {
let c = (r.x + r.w / 2, r.y + r.h / 2);
let d = (c.0 - last.x, c.1 - last.y);
if d != (0, 0) {
dir = Some(d);
}
}
}
let (dx, dy) = dir?;
let glyph = if dx.abs() >= dy.abs() {
if dx > 0 {
'▶'
} else {
'◀'
}
} else if dy > 0 {
'▼'
} else {
'▲'
};
Some((last, glyph))
}
fn dot(p: Point) -> (f32, f32) {
(2.0 * p.x as f32 + 1.0, 4.0 * p.y as f32 + 2.0)
}
pub(crate) fn draw_edges<C: abstracttui::ui::StyledCanvas + ?Sized>(
canvas: &mut C,
origin: Point,
size: (i32, i32),
plan: &[PlannedEdge],
edge_ink: Rgba,
broken_ink: Rgba,
label_ink: Rgba,
) {
let mut normal = DotCanvas::braille(size.0, size.1);
let mut broken = DotCanvas::braille(size.0, size.1);
for e in plan {
let grid = if e.broken { &mut broken } else { &mut normal };
stroke(grid, e);
}
normal.blit(canvas, origin, edge_ink);
broken.blit(canvas, origin, broken_ink);
for e in plan {
if let Some((cell, glyph)) = e.arrow {
let ink = if e.broken { broken_ink } else { edge_ink };
canvas.put(
Point::new(origin.x + cell.x, origin.y + cell.y),
glyph,
ink,
Rgba::TRANSPARENT,
);
}
if let Some((cell, text)) = &e.label {
canvas.print(
Point::new(origin.x + cell.x, origin.y + cell.y),
text,
label_ink,
Rgba::TRANSPARENT,
);
}
}
}
fn stroke(grid: &mut DotCanvas, e: &PlannedEdge) {
match e.kind {
StrokeKind::Solid => stroke_path(grid, e, (0, 0)),
StrokeKind::Thick => {
for off in [(0, 0), (1, 0), (0, 1)] {
stroke_path(grid, e, off);
}
}
StrokeKind::Dotted => stroke_dotted(grid, e),
}
}
fn stroke_path(grid: &mut DotCanvas, e: &PlannedEdge, off: (i32, i32)) {
let pts = &e.points;
let d = |p: Point| {
let (x, y) = dot(p);
(x + off.0 as f32, y + off.1 as f32)
};
let di = |p: Point| {
let (x, y) = d(p);
(x.round() as i32, y.round() as i32)
};
match pts.len() {
0 => {}
1 => {
let p = di(pts[0]);
grid.set(p.0, p.1);
}
2 => {
if e.bow != 0.0 {
let (p0, p1) = (d(pts[0]), d(pts[1]));
let c = clamp_control(grid, bow_control(p0, p1, e.bow));
grid.bezier_quad(p0, c, p1, 0.25);
} else {
grid.line(di(pts[0]), di(pts[1]));
}
}
_ => {
let mut cur = d(pts[0]);
for i in 1..pts.len() - 1 {
let ctrl = d(pts[i]);
let next = d(pts[i + 1]);
let m = (0.5 * (ctrl.0 + next.0), 0.5 * (ctrl.1 + next.1));
grid.bezier_quad(cur, ctrl, m, 0.25);
cur = m;
}
let last = di(*pts.last().expect("len >= 3"));
grid.line((cur.0.round() as i32, cur.1.round() as i32), last);
}
}
}
fn clamp_control(grid: &DotCanvas, c: (f32, f32)) -> (f32, f32) {
(
c.0.clamp(0.0, (grid.dots_w() - 1).max(0) as f32),
c.1.clamp(0.0, (grid.dots_h() - 1).max(0) as f32),
)
}
fn bow_control(p0: (f32, f32), p1: (f32, f32), bow: f64) -> (f32, f32) {
let (mx, my) = (0.5 * (p0.0 + p1.0), 0.5 * (p0.1 + p1.1));
let (ex, ey) = (p1.0 - p0.0, p1.1 - p0.1);
let len = (ex * ex + ey * ey).sqrt();
if len <= f32::EPSILON {
return (mx, my);
}
let (px, py) = (-ey / len, ex / len);
let amp = (bow as f32) * 2.0 * 3.0;
(mx + px * amp, my + py * amp)
}
fn stroke_dotted(grid: &mut DotCanvas, e: &PlannedEdge) {
let pts = &e.points;
if pts.is_empty() {
return;
}
let mut phase = 0u32;
if pts.len() == 2 && e.bow != 0.0 {
let (p0, p1) = (dot(pts[0]), dot(pts[1]));
let c = clamp_control(grid, bow_control(p0, p1, e.bow));
sample_quad(grid, p0, c, p1, &mut phase);
return;
}
if pts.len() >= 3 {
let mut cur = dot(pts[0]);
for i in 1..pts.len() - 1 {
let ctrl = dot(pts[i]);
let next = dot(pts[i + 1]);
let m = (0.5 * (ctrl.0 + next.0), 0.5 * (ctrl.1 + next.1));
sample_quad(grid, cur, ctrl, m, &mut phase);
cur = m;
}
sample_segment(grid, cur, dot(pts[pts.len() - 1]), &mut phase);
return;
}
sample_segment(
grid,
dot(pts[0]),
dot(*pts.last().expect("non-empty")),
&mut phase,
);
}
fn sample_segment(grid: &mut DotCanvas, a: (f32, f32), b: (f32, f32), phase: &mut u32) {
let (ex, ey) = (b.0 - a.0, b.1 - a.1);
let len = (ex * ex + ey * ey).sqrt();
let steps = len.ceil().max(1.0) as i32;
for i in 0..=steps {
let t = i as f32 / steps as f32;
if phase.is_multiple_of(3) {
let (x, y) = (a.0 + ex * t, a.1 + ey * t);
grid.set(x.round() as i32, y.round() as i32);
}
*phase += 1;
}
}
fn sample_quad(
grid: &mut DotCanvas,
p0: (f32, f32),
c: (f32, f32),
p1: (f32, f32),
phase: &mut u32,
) {
let poly = dist(p0, c) + dist(c, p1);
let steps = poly.ceil().clamp(1.0, 512.0) as i32;
for i in 0..=steps {
let t = i as f32 / steps as f32;
let u = 1.0 - t;
if phase.is_multiple_of(3) {
let x = u * u * p0.0 + 2.0 * u * t * c.0 + t * t * p1.0;
let y = u * u * p0.1 + 2.0 * u * t * c.1 + t * t * p1.1;
grid.set(x.round() as i32, y.round() as i32);
}
*phase += 1;
}
}
fn dist(a: (f32, f32), b: (f32, f32)) -> f32 {
let (ex, ey) = (b.0 - a.0, b.1 - a.1);
(ex * ex + ey * ey).sqrt()
}