use std::collections::HashMap;
use super::text::{
BackgroundStyle, TextRenderStyle, font_attrs_for_style, render_text_centered,
render_text_centered_with_wrap,
};
use super::{GraphSvgPalette, Point, dynamic_css_attrs};
use crate::graph::geometry::{EdgeLabelGeometry, GraphGeometry};
use crate::graph::measure::{GraphTextStyleKey, TextMetricsProvider, edge_text_style_key};
use crate::graph::routing::compute_end_label_positions;
use crate::graph::{Edge, Graph, Stroke};
use crate::render::svg::SvgWriter;
const LABEL_ANCHOR_REVALIDATION_MAX_DISTANCE: f64 = 2.0;
const LABEL_POINT_EPS: f64 = 0.000_001;
pub(super) struct ResolvedEdgeLabel<'a> {
pub(super) center: Point,
pub(super) geometry: Option<&'a EdgeLabelGeometry>,
}
fn revalidate_svg_label_anchor(candidate: Point, rendered_path: Option<&[Point]>) -> Point {
let Some(path) = rendered_path else {
return candidate;
};
if path.is_empty() {
return candidate;
}
let drift = distance_point_to_svg_path(candidate, path);
if drift <= LABEL_ANCHOR_REVALIDATION_MAX_DISTANCE {
return candidate;
}
svg_path_midpoint(path).unwrap_or(candidate)
}
fn point_distance_svg(a: Point, b: Point) -> f64 {
((a.x - b.x).powi(2) + (a.y - b.y).powi(2)).sqrt()
}
fn distance_point_to_svg_segment(point: Point, a: Point, b: Point) -> f64 {
let dx = b.x - a.x;
let dy = b.y - a.y;
let seg_len_sq = dx * dx + dy * dy;
if seg_len_sq <= LABEL_POINT_EPS {
return point_distance_svg(point, a);
}
let projection = ((point.x - a.x) * dx + (point.y - a.y) * dy) / seg_len_sq;
let t = projection.clamp(0.0, 1.0);
let closest = Point {
x: a.x + t * dx,
y: a.y + t * dy,
};
point_distance_svg(point, closest)
}
fn distance_point_to_svg_path(point: Point, path: &[Point]) -> f64 {
if path.is_empty() {
return f64::INFINITY;
}
if path.len() == 1 {
return point_distance_svg(point, path[0]);
}
path.windows(2)
.map(|segment| distance_point_to_svg_segment(point, segment[0], segment[1]))
.fold(f64::INFINITY, f64::min)
}
fn svg_path_midpoint(path: &[Point]) -> Option<Point> {
if path.is_empty() {
return None;
}
if path.len() == 1 {
return path.first().copied();
}
let total_len: f64 = path
.windows(2)
.map(|segment| point_distance_svg(segment[0], segment[1]))
.sum();
if total_len <= LABEL_POINT_EPS {
return path.get(path.len() / 2).copied();
}
let target = total_len / 2.0;
let mut traversed = 0.0;
for segment in path.windows(2) {
let a = segment[0];
let b = segment[1];
let seg_len = point_distance_svg(a, b);
if seg_len <= LABEL_POINT_EPS {
continue;
}
if traversed + seg_len >= target {
let t = (target - traversed) / seg_len;
return Some(Point {
x: a.x + (b.x - a.x) * t,
y: a.y + (b.y - a.y) * t,
});
}
traversed += seg_len;
}
path.last().copied()
}
#[allow(clippy::too_many_arguments)]
pub(super) fn render_edge_labels(
writer: &mut SvgWriter,
diagram: &Graph,
geom: &GraphGeometry,
self_edge_paths: &HashMap<usize, Vec<Point>>,
rendered_edge_paths: &HashMap<usize, Vec<Point>>,
override_nodes: &HashMap<String, String>,
metrics: &dyn TextMetricsProvider,
default_text_style: &GraphTextStyleKey,
scale: f64,
palette: &GraphSvgPalette,
) {
let dynamic_attrs = dynamic_css_attrs(
palette.dynamic_css,
"graph-edge-text",
&["fill:var(--_text);"],
);
let bg_dynamic_attrs = dynamic_css_attrs(
palette.dynamic_css,
"graph-edge-label-bg",
&["fill:var(--bg);"],
);
let bg_style = BackgroundStyle {
fill: &palette.edge_label_background,
extra_attrs: bg_dynamic_attrs.as_str(),
size: None,
};
writer.start_group("edgeLabels");
for edge in diagram.edges.iter() {
if edge.stroke == Stroke::Invisible {
continue;
}
let Some(label) = edge.label.as_ref() else {
continue;
};
let Some(resolved_label) = resolve_edge_label(
diagram,
edge,
geom,
self_edge_paths,
rendered_edge_paths,
override_nodes,
) else {
continue;
};
let layout_edge = geom.edges.iter().find(|e| e.index == edge.index);
let wrap_lines = layout_edge
.and_then(|e| e.effective_wrapped_lines.as_deref())
.or(edge.wrapped_label_lines.as_deref());
let text_style = edge_text_style_key(metrics, edge);
let text_attrs =
dynamic_attrs.clone() + &font_attrs_for_style(default_text_style, &text_style);
render_text_centered_with_wrap(
writer,
Point {
x: resolved_label.center.x * scale,
y: resolved_label.center.y * scale,
},
label,
wrap_lines,
metrics,
scale,
TextRenderStyle {
color: &palette.edge_label_text,
extra_attrs: text_attrs.as_str(),
text_style: Some(&text_style),
background: Some(BackgroundStyle {
fill: bg_style.fill,
extra_attrs: bg_style.extra_attrs,
size: resolved_label
.geometry
.map(|g| (g.rect.width, g.rect.height)),
}),
},
);
}
for edge in diagram.edges.iter() {
if edge.head_label.is_none() && edge.tail_label.is_none() {
continue;
}
let path: Vec<Point> = geom
.edges
.iter()
.find(|e| e.index == edge.index)
.and_then(|e| e.layout_path_hint.clone())
.unwrap_or_default();
if path.len() < 2 {
continue;
}
let (head_pos, tail_pos) = compute_end_label_positions(&path);
let text_style = edge_text_style_key(metrics, edge);
let text_attrs =
dynamic_attrs.clone() + &font_attrs_for_style(default_text_style, &text_style);
if let (Some(label), Some(pos)) = (&edge.head_label, head_pos) {
render_text_centered(
writer,
Point {
x: pos.x * scale,
y: pos.y * scale,
},
label,
metrics,
scale,
TextRenderStyle {
color: &palette.edge_label_text,
extra_attrs: text_attrs.as_str(),
text_style: Some(&text_style),
background: Some(BackgroundStyle {
fill: bg_style.fill,
extra_attrs: bg_style.extra_attrs,
size: None,
}),
},
);
}
if let (Some(label), Some(pos)) = (&edge.tail_label, tail_pos) {
render_text_centered(
writer,
Point {
x: pos.x * scale,
y: pos.y * scale,
},
label,
metrics,
scale,
TextRenderStyle {
color: &palette.edge_label_text,
extra_attrs: text_attrs.as_str(),
text_style: Some(&text_style),
background: Some(BackgroundStyle {
fill: bg_style.fill,
extra_attrs: bg_style.extra_attrs,
size: None,
}),
},
);
}
}
writer.end_group();
}
pub(super) fn resolve_edge_label<'a>(
diagram: &Graph,
edge: &Edge,
geom: &'a GraphGeometry,
self_edge_paths: &HashMap<usize, Vec<Point>>,
rendered_edge_paths: &HashMap<usize, Vec<Point>>,
override_nodes: &HashMap<String, String>,
) -> Option<ResolvedEdgeLabel<'a>> {
let edge_idx = edge.index;
let cross_boundary = if edge.from_subgraph.is_none() && edge.to_subgraph.is_none() {
let from_override = override_nodes.get(&edge.from);
let to_override = override_nodes.get(&edge.to);
matches!(
(from_override, to_override),
(Some(a), Some(b)) if a != b
) || matches!(
(from_override, to_override),
(Some(_), None) | (None, Some(_))
)
} else {
false
};
let use_precomputed =
edge.from_subgraph.is_none() && edge.to_subgraph.is_none() && !cross_boundary;
let layout_edge = geom.edges.iter().find(|e| e.index == edge_idx);
let label_geom = layout_edge.and_then(|e| e.label_geometry.as_ref());
let sibling_compound_center = label_geom
.filter(|_| edge_crosses_sibling_subgraphs(diagram, geom, edge))
.map(|g| g.center);
let lane_shifted_center = label_geom
.filter(|g| (g.track != 0 || g.compartment_size > 1) && use_precomputed)
.map(|g| g.center);
if let Some(center) = sibling_compound_center.or(lane_shifted_center) {
return Some(ResolvedEdgeLabel {
center,
geometry: label_geom,
});
}
let candidate = if use_precomputed {
label_geom
.map(|g| g.center)
.or_else(|| layout_edge.and_then(|e| e.label_position))
} else {
None
}
.or_else(|| fallback_label_position(geom, edge_idx, self_edge_paths, rendered_edge_paths));
let center = candidate.map(|candidate| {
revalidate_svg_label_anchor(
candidate,
rendered_edge_paths
.get(&edge_idx)
.map(|path| path.as_slice()),
)
})?;
Some(ResolvedEdgeLabel {
center,
geometry: label_geom,
})
}
fn edge_crosses_sibling_subgraphs(
diagram: &Graph,
geom: &GraphGeometry,
edge: &crate::graph::Edge,
) -> bool {
let Some(from_parent) = node_parent_id(diagram, geom, &edge.from) else {
return false;
};
let Some(to_parent) = node_parent_id(diagram, geom, &edge.to) else {
return false;
};
if from_parent == to_parent {
return false;
}
let from_grandparent = diagram
.subgraphs
.get(&from_parent)
.and_then(|sg| sg.parent.as_deref());
let to_grandparent = diagram
.subgraphs
.get(&to_parent)
.and_then(|sg| sg.parent.as_deref());
from_grandparent.is_some() && from_grandparent == to_grandparent
}
fn node_parent_id(diagram: &Graph, geom: &GraphGeometry, node_id: &str) -> Option<String> {
if let Some(parent) = geom
.nodes
.get(node_id)
.and_then(|node| node.parent.as_deref())
{
return Some(parent.to_string());
}
diagram
.subgraphs
.values()
.find(|subgraph| subgraph.nodes.iter().any(|member| member == node_id))
.map(|subgraph| subgraph.id.clone())
}
pub(super) fn fallback_label_position(
geom: &GraphGeometry,
edge_index: usize,
self_edge_paths: &HashMap<usize, Vec<Point>>,
rendered_edge_paths: &HashMap<usize, Vec<Point>>,
) -> Option<Point> {
if let Some(points) = self_edge_paths.get(&edge_index) {
return svg_path_midpoint(points).or_else(|| points.get(points.len() / 2).copied());
}
if let Some(layout_edge) = geom.edges.iter().find(|e| e.index == edge_index)
&& let Some(path) = &layout_edge.layout_path_hint
{
return path.get(path.len() / 2).copied();
}
if let Some(se) = geom.self_edges.iter().find(|e| e.edge_index == edge_index) {
return se.points.get(se.points.len() / 2).copied();
}
if let Some(points) = rendered_edge_paths.get(&edge_index) {
return svg_path_midpoint(points).or_else(|| points.get(points.len() / 2).copied());
}
None
}
#[cfg(test)]
mod tests {
use std::collections::{HashMap, HashSet};
use super::{Point, revalidate_svg_label_anchor, svg_path_midpoint};
use crate::graph::geometry::{EdgeLabelGeometry, EdgeLabelSide, GraphGeometry, LayoutEdge};
use crate::graph::space::{FPoint, FRect};
use crate::graph::{Direction, Edge, Graph};
#[test]
fn revalidate_svg_label_anchor_keeps_nearby_anchor() {
let candidate = Point { x: 5.0, y: 1.0 };
let path = [Point { x: 0.0, y: 0.0 }, Point { x: 10.0, y: 0.0 }];
assert_eq!(
revalidate_svg_label_anchor(candidate, Some(&path)),
candidate
);
}
#[test]
fn revalidate_svg_label_anchor_falls_back_to_path_midpoint_when_drifted() {
let candidate = Point { x: 50.0, y: 25.0 };
let path = [Point { x: 0.0, y: 0.0 }, Point { x: 10.0, y: 0.0 }];
assert_eq!(
revalidate_svg_label_anchor(candidate, Some(&path)),
Point { x: 5.0, y: 0.0 }
);
}
#[test]
fn svg_path_midpoint_handles_multi_segment_paths_by_distance() {
let path = [
Point { x: 0.0, y: 0.0 },
Point { x: 6.0, y: 0.0 },
Point { x: 6.0, y: 6.0 },
];
assert_eq!(svg_path_midpoint(&path), Some(Point { x: 6.0, y: 0.0 }));
}
fn minimal_geom_with_labeled_edge() -> GraphGeometry {
GraphGeometry {
nodes: HashMap::new(),
edges: vec![LayoutEdge {
index: 0,
from: "A".into(),
to: "B".into(),
waypoints: vec![],
label_position: Some(FPoint::new(50.0, 50.0)),
label_side: None,
from_subgraph: None,
to_subgraph: None,
layout_path_hint: Some(vec![FPoint::new(50.0, 0.0), FPoint::new(50.0, 100.0)]),
preserve_orthogonal_topology: false,
label_geometry: None,
effective_wrapped_lines: None,
}],
subgraphs: HashMap::new(),
self_edges: vec![],
direction: Direction::TopDown,
node_directions: HashMap::new(),
bounds: FRect::new(0.0, 0.0, 100.0, 100.0),
reversed_edges: vec![],
engine_hints: None,
grid_projection: None,
rerouted_edges: HashSet::new(),
enhanced_backward_routing: false,
}
}
fn minimal_labeled_edge() -> Edge {
let mut edge = Edge::new("A", "B").with_label("yes");
edge.index = 0;
edge
}
#[test]
fn svg_labels_uses_label_geometry_center_when_present() {
let mut geom = minimal_geom_with_labeled_edge();
let empty_map: HashMap<usize, Vec<Point>> = HashMap::new();
geom.edges[0].label_geometry = Some(EdgeLabelGeometry {
center: FPoint::new(123.0, 456.0),
rect: FRect::new(113.0, 451.0, 20.0, 10.0),
padding: (4.0, 2.0),
side: EdgeLabelSide::Above,
track: 0,
compartment_size: 1,
});
let empty_overrides = HashMap::new();
let center = super::resolve_edge_label(
&Graph::new(Direction::TopDown),
&minimal_labeled_edge(),
&geom,
&empty_map,
&empty_map,
&empty_overrides,
)
.map(|label| label.center);
assert_eq!(
center,
Some(FPoint::new(123.0, 456.0)),
"must use label_geometry.center when no rendered path can revalidate it"
);
}
#[test]
fn svg_labels_falls_back_to_label_position_when_no_label_geometry() {
let geom = minimal_geom_with_labeled_edge();
let empty_map: HashMap<usize, Vec<Point>> = HashMap::new();
let empty_overrides = HashMap::new();
let center = super::resolve_edge_label(
&Graph::new(Direction::TopDown),
&minimal_labeled_edge(),
&geom,
&empty_map,
&empty_map,
&empty_overrides,
)
.map(|label| label.center);
assert_eq!(
center,
Some(FPoint::new(50.0, 50.0)),
"must fall back to label_position when label_geometry is None"
);
}
}