diff --git a/etch/src/html.rs b/etch/src/html.rs new file mode 100644 index 0000000..b7d2104 --- /dev/null +++ b/etch/src/html.rs @@ -0,0 +1,167 @@ +//! Interactive HTML wrapper for etch SVG output. +//! +//! Produces a self-contained HTML document with embedded SVG and JavaScript +//! for pan, zoom, selection, and group highlighting. No external dependencies. + +use crate::layout::GraphLayout; +use crate::svg::{SvgOptions, render_svg}; + +/// Options for HTML output. +#[derive(Debug, Clone)] +pub struct HtmlOptions { + /// Page title. + pub title: String, + /// Show minimap (Phase 3b — reserved). + pub minimap: bool, + /// Enable search (Phase 3b — reserved). + pub search: bool, + /// Show legend (Phase 3b — reserved). + pub legend: bool, + /// Enable semantic zoom (CSS classes at low zoom levels). + pub semantic_zoom: bool, +} + +impl Default for HtmlOptions { + fn default() -> Self { + Self { + title: "Graph".into(), + minimap: true, + search: true, + legend: true, + semantic_zoom: true, + } + } +} + +/// Render a [`GraphLayout`] as a self-contained interactive HTML document. +/// +/// The returned string is a complete HTML page with embedded SVG and +/// JavaScript for pan, zoom, selection, and group highlighting. +pub fn render_html( + layout: &GraphLayout, + svg_options: &SvgOptions, + html_options: &HtmlOptions, +) -> String { + let svg_content = render_svg(layout, svg_options); + let js = include_str!("html_interactivity.js"); + let title = &html_options.title; + + format!( + r#" + + + + +{title} + + + +
+{svg_content} +
+ + +"# + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::layout::{EdgeInfo, LayoutOptions, NodeInfo, layout}; + use petgraph::Graph; + use petgraph::graph::{EdgeIndex, NodeIndex}; + + fn build_test_layout() -> GraphLayout { + let mut g = Graph::new(); + let a = g.add_node("A"); + let b = g.add_node("B"); + g.add_edge(a, b, "link"); + + layout( + &g, + &|_idx: NodeIndex, n: &&str| NodeInfo { + id: n.to_string(), + label: n.to_string(), + node_type: "default".into(), + sublabel: None, + parent: None, + ports: vec![], + }, + &|_idx: EdgeIndex, e: &&str| EdgeInfo { + label: e.to_string(), + source_port: None, + target_port: None, + }, + &LayoutOptions::default(), + ) + } + + #[test] + fn html_contains_svg_and_script() { + let gl = build_test_layout(); + let html = render_html(&gl, &SvgOptions::default(), &HtmlOptions::default()); + assert!(html.contains("")); + assert!(html.contains("")); + assert!(html.contains("")); + } + + #[test] + fn html_contains_interactivity_code() { + let gl = build_test_layout(); + let html = render_html(&gl, &SvgOptions::default(), &HtmlOptions::default()); + assert!(html.contains("mousedown"), "should have pan handler"); + assert!(html.contains("wheel"), "should have zoom handler"); + assert!(html.contains("etch-select"), "should have selection event"); + assert!(html.contains("viewBox"), "should manipulate viewBox"); + } + + #[test] + fn html_has_semantic_zoom_css() { + let gl = build_test_layout(); + let html = render_html(&gl, &SvgOptions::default(), &HtmlOptions::default()); + assert!(html.contains("zoom-low"), "should have zoom-low class"); + assert!( + html.contains("zoom-overview"), + "should have zoom-overview class" + ); + } + + #[test] + fn html_has_selection_css() { + let gl = build_test_layout(); + let html = render_html(&gl, &SvgOptions::default(), &HtmlOptions::default()); + assert!( + html.contains(".node.selected rect"), + "should have selection CSS" + ); + } + + #[test] + fn html_title_customizable() { + let gl = build_test_layout(); + let opts = HtmlOptions { + title: "My Architecture".into(), + ..Default::default() + }; + let html = render_html(&gl, &SvgOptions::default(), &opts); + assert!(html.contains("My Architecture")); + } +} diff --git a/etch/src/html_interactivity.js b/etch/src/html_interactivity.js new file mode 100644 index 0000000..c07b00f --- /dev/null +++ b/etch/src/html_interactivity.js @@ -0,0 +1,128 @@ +// etch interactive SVG viewer — pan, zoom, selection, group highlight +(function() { + const container = document.getElementById('container'); + const svg = container.querySelector('svg'); + if (!svg) return; + + // Parse initial viewBox + const vb = svg.getAttribute('viewBox').split(' ').map(Number); + let [vx, vy, vw, vh] = vb; + const origVw = vw, origVh = vh; + + // State + let isPanning = false; + let panStart = { x: 0, y: 0 }; + let scale = 1; + + // --- Pan --- + svg.addEventListener('mousedown', e => { + if (e.target.closest('.node')) return; // don't pan when clicking nodes + isPanning = true; + panStart = { x: e.clientX, y: e.clientY }; + svg.style.cursor = 'grabbing'; + }); + + window.addEventListener('mousemove', e => { + if (!isPanning) return; + const dx = (e.clientX - panStart.x) * (vw / svg.clientWidth); + const dy = (e.clientY - panStart.y) * (vh / svg.clientHeight); + vx -= dx; + vy -= dy; + panStart = { x: e.clientX, y: e.clientY }; + updateViewBox(); + }); + + window.addEventListener('mouseup', () => { + isPanning = false; + svg.style.cursor = 'grab'; + }); + + // --- Zoom (wheel) --- + svg.addEventListener('wheel', e => { + e.preventDefault(); + const zoomFactor = e.deltaY > 0 ? 1.1 : 0.9; + + // Zoom around cursor position + const rect = svg.getBoundingClientRect(); + const mx = (e.clientX - rect.left) / rect.width; + const my = (e.clientY - rect.top) / rect.height; + + const newVw = vw * zoomFactor; + const newVh = vh * zoomFactor; + + vx += (vw - newVw) * mx; + vy += (vh - newVh) * my; + vw = newVw; + vh = newVh; + scale = origVw / vw; + + updateViewBox(); + updateSemanticZoom(); + }, { passive: false }); + + // --- Selection --- + svg.addEventListener('click', e => { + const nodeEl = e.target.closest('.node'); + if (!nodeEl) { + if (!e.ctrlKey && !e.metaKey) { + svg.querySelectorAll('.node.selected').forEach(n => n.classList.remove('selected')); + } + return; + } + + if (e.ctrlKey || e.metaKey) { + nodeEl.classList.toggle('selected'); + } else { + svg.querySelectorAll('.node.selected').forEach(n => n.classList.remove('selected')); + nodeEl.classList.add('selected'); + } + + // If it's a container, highlight children + if (nodeEl.classList.contains('container')) { + const containerId = nodeEl.getAttribute('data-id'); + if (containerId) { + // Emit event for integration + svg.dispatchEvent(new CustomEvent('etch-container-select', { + detail: { id: containerId } + })); + } + } + + // Emit selection event + const selected = Array.from(svg.querySelectorAll('.node.selected')) + .map(n => n.getAttribute('data-id')) + .filter(Boolean); + svg.dispatchEvent(new CustomEvent('etch-select', { + detail: { ids: selected } + })); + }); + + // --- URL highlight parameter --- + const params = new URLSearchParams(window.location.search); + const highlightId = params.get('highlight'); + if (highlightId) { + const node = svg.querySelector(`.node[data-id="${CSS.escape(highlightId)}"]`); + if (node) { + node.classList.add('selected'); + // Pan to highlighted node + const rect = node.querySelector('rect'); + if (rect) { + const nx = parseFloat(rect.getAttribute('x')); + const ny = parseFloat(rect.getAttribute('y')); + vx = nx - vw / 4; + vy = ny - vh / 4; + updateViewBox(); + } + } + } + + // --- Semantic zoom --- + function updateSemanticZoom() { + svg.classList.toggle('zoom-low', scale < 0.5); + svg.classList.toggle('zoom-overview', scale < 0.25); + } + + function updateViewBox() { + svg.setAttribute('viewBox', `${vx} ${vy} ${vw} ${vh}`); + } +})(); diff --git a/etch/src/layout.rs b/etch/src/layout.rs index 75744d4..cb0d5c8 100644 --- a/etch/src/layout.rs +++ b/etch/src/layout.rs @@ -29,6 +29,16 @@ pub enum RankDirection { LeftToRight, } +/// Edge routing strategy. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum EdgeRouting { + /// Orthogonal routing with right-angle bends. + #[default] + Orthogonal, + /// Cubic bezier curves (legacy behavior). + CubicBezier, +} + /// Options that control the layout algorithm. #[derive(Debug, Clone)] pub struct LayoutOptions { @@ -49,6 +59,14 @@ pub struct LayoutOptions { pub container_padding: f64, /// Height of the container header (for the label) (px). pub container_header: f64, + /// Edge routing strategy. + pub edge_routing: EdgeRouting, + /// Penalty for each bend in orthogonal routing (higher = fewer bends). + pub bend_penalty: f64, + /// Gap between parallel edge segments (px). + pub edge_separation: f64, + /// Minimum straight stub length leaving a port before any bend (px). + pub port_stub_length: f64, } impl Default for LayoutOptions { @@ -62,10 +80,94 @@ impl Default for LayoutOptions { type_ranks: HashMap::new(), container_padding: 20.0, container_header: 30.0, + edge_routing: EdgeRouting::default(), + bend_penalty: 20.0, + edge_separation: 4.0, + port_stub_length: 10.0, } } } +// --------------------------------------------------------------------------- +// Port types +// --------------------------------------------------------------------------- + +/// Side of the node where a port is positioned. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum PortSide { + Left, + Right, + Top, + Bottom, + /// Let the layout algorithm choose based on direction. + #[default] + Auto, +} + +/// Direction of data flow through a port. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PortDirection { + In, + Out, + InOut, +} + +/// Visual category of a port (determines color in SVG rendering). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum PortType { + /// Data port (blue #4a90d9). + #[default] + Data, + /// Event port (orange #e67e22). + Event, + /// Event-data port (green #27ae60). + EventData, + /// Access port (gray #999). + Access, + /// Feature group (purple #9b59b6). + Group, + /// Abstract feature (dark gray #666). + Abstract, +} + +/// Display-level information about a port on a node. +#[derive(Debug, Clone)] +pub struct PortInfo { + /// Unique identifier within the owning node. + pub id: String, + /// Label rendered next to the port circle. + pub label: String, + /// Which side of the node this port appears on. + pub side: PortSide, + /// Direction of data flow. + pub direction: PortDirection, + /// Visual category (determines color). + pub port_type: PortType, +} + +/// A positioned port on a layout node. +#[derive(Debug, Clone)] +pub struct LayoutPort { + /// Port identifier. + pub id: String, + /// Label text. + pub label: String, + /// X coordinate of port center (absolute). + pub x: f64, + /// Y coordinate of port center (absolute). + pub y: f64, + /// Which side of the node. + pub side: PortSide, + /// Direction indicator. + pub direction: PortDirection, + /// Visual type. + pub port_type: PortType, +} + +// --------------------------------------------------------------------------- +// Node / Edge / Layout types +// --------------------------------------------------------------------------- + /// Display-level information about a node supplied by the caller. #[derive(Debug, Clone)] pub struct NodeInfo { @@ -82,6 +184,9 @@ pub struct NodeInfo { /// algorithm lays out each container's children independently and then /// sizes the container to fit its content. pub parent: Option, + /// Ports on this node. Empty for nodes without explicit ports; + /// edges then connect to node centers (backward compatible). + pub ports: Vec, } /// Display-level information about an edge supplied by the caller. @@ -89,6 +194,10 @@ pub struct NodeInfo { pub struct EdgeInfo { /// Label rendered along the edge path. pub label: String, + /// Source port ID (within source node). `None` = connect to node center. + pub source_port: Option, + /// Target port ID (within target node). `None` = connect to node center. + pub target_port: Option, } /// A positioned node produced by the layout algorithm. @@ -114,6 +223,8 @@ pub struct LayoutNode { pub sublabel: Option, /// `true` when this node is a container with children laid out inside. pub is_container: bool, + /// Positioned ports on this node. + pub ports: Vec, } /// A routed edge produced by the layout algorithm. @@ -127,6 +238,10 @@ pub struct LayoutEdge { pub label: String, /// Ordered polyline waypoints `(x, y)`. pub points: Vec<(f64, f64)>, + /// Source port ID if edge connects to a specific port. + pub source_port: Option, + /// Target port ID if edge connects to a specific port. + pub target_port: Option, } /// Complete layout result. @@ -405,16 +520,30 @@ fn sweep_up( // Phase 3: Coordinate assignment // --------------------------------------------------------------------------- -/// Per-node size overrides for variable-size nodes (containers). +/// Per-node size, accounting for container overrides and port counts. fn node_size( idx: NodeIndex, options: &LayoutOptions, size_overrides: &HashMap, + infos: &HashMap, ) -> (f64, f64) { - size_overrides - .get(&idx) - .copied() - .unwrap_or((options.node_width, options.node_height)) + if let Some(&size) = size_overrides.get(&idx) { + return size; + } + let base_w = options.node_width; + let mut base_h = options.node_height; + + // Grow height if ports need more space (12px per port + 8px padding) + if let Some(info) = infos.get(&idx) { + let (left, right) = resolved_side_counts(&info.ports); + let max_side = left.max(right); + if max_side > 0 { + let port_h = max_side as f64 * 12.0 + 8.0; + base_h = base_h.max(port_h); + } + } + + (base_w, base_h) } fn assign_coordinates( @@ -437,7 +566,7 @@ fn assign_coordinates( } let total_w: f64 = list .iter() - .map(|&idx| node_size(idx, options, size_overrides).0) + .map(|&idx| node_size(idx, options, size_overrides, infos).0) .sum(); total_w + (list.len() as f64 - 1.0) * options.node_separation }) @@ -447,7 +576,7 @@ fn assign_coordinates( .iter() .map(|list| { list.iter() - .map(|&idx| node_size(idx, options, size_overrides).1) + .map(|&idx| node_size(idx, options, size_overrides, infos).1) .fold(options.node_height, f64::max) }) .collect(); @@ -469,7 +598,7 @@ fn assign_coordinates( let mut x_cursor = x_offset; for &idx in list { let info = &infos[&idx]; - let (nw, nh) = node_size(idx, options, size_overrides); + let (nw, nh) = node_size(idx, options, size_overrides, infos); let is_container = size_overrides.contains_key(&idx); let (x, y) = match options.rank_direction { @@ -491,7 +620,7 @@ fn assign_coordinates( max_y = y + nh; } - nodes.push(LayoutNode { + let mut layout_node = LayoutNode { id: info.id.clone(), x, y, @@ -502,7 +631,10 @@ fn assign_coordinates( node_type: info.node_type.clone(), sublabel: info.sublabel.clone(), is_container, - }); + ports: Vec::new(), + }; + layout_node.ports = position_ports(&layout_node, &info.ports); + nodes.push(layout_node); x_cursor += nw + options.node_separation; } @@ -511,6 +643,126 @@ fn assign_coordinates( (nodes, max_x, max_y) } +// --------------------------------------------------------------------------- +// Port positioning +// --------------------------------------------------------------------------- + +/// Compute positioned ports for a laid-out node. +fn position_ports(node: &LayoutNode, ports: &[PortInfo]) -> Vec { + if ports.is_empty() { + return Vec::new(); + } + + // Resolve Auto sides based on direction + let resolved_side = |p: &PortInfo| -> PortSide { + match p.side { + PortSide::Auto => match p.direction { + PortDirection::In => PortSide::Left, + PortDirection::Out | PortDirection::InOut => PortSide::Right, + }, + other => other, + } + }; + + let mut left: Vec<&PortInfo> = Vec::new(); + let mut right: Vec<&PortInfo> = Vec::new(); + let mut top: Vec<&PortInfo> = Vec::new(); + let mut bottom: Vec<&PortInfo> = Vec::new(); + + for p in ports { + match resolved_side(p) { + PortSide::Left => left.push(p), + PortSide::Right => right.push(p), + PortSide::Top => top.push(p), + PortSide::Bottom => bottom.push(p), + PortSide::Auto => unreachable!(), + } + } + + let mut result = Vec::new(); + + // Place ports evenly along each side + let place_vertical = + |ports: &[&PortInfo], fixed_x: f64, y_start: f64, y_len: f64| -> Vec { + let n = ports.len(); + if n == 0 { + return vec![]; + } + let spacing = y_len / (n as f64 + 1.0); + ports + .iter() + .enumerate() + .map(|(i, p)| LayoutPort { + id: p.id.clone(), + label: p.label.clone(), + x: fixed_x, + y: y_start + spacing * (i as f64 + 1.0), + side: resolved_side(p), + direction: p.direction, + port_type: p.port_type, + }) + .collect() + }; + + let place_horizontal = + |ports: &[&PortInfo], fixed_y: f64, x_start: f64, x_len: f64| -> Vec { + let n = ports.len(); + if n == 0 { + return vec![]; + } + let spacing = x_len / (n as f64 + 1.0); + ports + .iter() + .enumerate() + .map(|(i, p)| LayoutPort { + id: p.id.clone(), + label: p.label.clone(), + x: x_start + spacing * (i as f64 + 1.0), + y: fixed_y, + side: resolved_side(p), + direction: p.direction, + port_type: p.port_type, + }) + .collect() + }; + + result.extend(place_vertical(&left, node.x, node.y, node.height)); + result.extend(place_vertical( + &right, + node.x + node.width, + node.y, + node.height, + )); + result.extend(place_horizontal(&top, node.y, node.x, node.width)); + result.extend(place_horizontal( + &bottom, + node.y + node.height, + node.x, + node.width, + )); + + result +} + +/// Count ports per side after resolving Auto, returns (left, right). +#[allow(dead_code)] +fn resolved_side_counts(ports: &[PortInfo]) -> (usize, usize) { + let mut left = 0usize; + let mut right = 0usize; + for p in ports { + match p.side { + PortSide::Left => left += 1, + PortSide::Right => right += 1, + PortSide::Auto => match p.direction { + PortDirection::In => left += 1, + PortDirection::Out | PortDirection::InOut => right += 1, + }, + _ => {} // Top/Bottom don't affect height + } + } + (left, right) +} + // --------------------------------------------------------------------------- // Phase 4: Edge routing // --------------------------------------------------------------------------- @@ -551,13 +803,51 @@ fn route_edges( None => continue, }; - let points = compute_waypoints(src_node, tgt_node, options); + // If ports are specified, snap to port positions + let src_point = info + .source_port + .as_ref() + .and_then(|pid| src_node.ports.iter().find(|p| p.id == *pid)) + .map(|p| (p.x, p.y)); + let tgt_point = info + .target_port + .as_ref() + .and_then(|pid| tgt_node.ports.iter().find(|p| p.id == *pid)) + .map(|p| (p.x, p.y)); + + // Compute start/end points (port-aware or center-based) + let start = src_point.unwrap_or_else(|| { + ( + src_node.x + src_node.width / 2.0, + src_node.y + src_node.height, + ) + }); + let end = tgt_point.unwrap_or_else(|| (tgt_node.x + tgt_node.width / 2.0, tgt_node.y)); + + let points = match options.edge_routing { + EdgeRouting::Orthogonal => crate::ortho::route_orthogonal( + layout_nodes, + start, + end, + options.bend_penalty, + options.port_stub_length, + ), + EdgeRouting::CubicBezier => { + if src_point.is_some() || tgt_point.is_some() { + vec![start, end] + } else { + compute_waypoints(src_node, tgt_node, options) + } + } + }; edges.push(LayoutEdge { source_id: src_id.clone(), target_id: tgt_id.clone(), label: info.label, points, + source_port: info.source_port, + target_port: info.target_port, }); } @@ -933,12 +1223,15 @@ mod tests { node_type: "default".into(), sublabel: None, parent: None, + ports: vec![], } } fn simple_edge_info(_idx: EdgeIndex, label: &&str) -> EdgeInfo { EdgeInfo { label: label.to_string(), + source_port: None, + target_port: None, } } @@ -1120,6 +1413,7 @@ mod tests { } else { None }, + ports: vec![], }, &simple_edge_info, &LayoutOptions::default(), @@ -1186,6 +1480,7 @@ mod tests { "T1" | "T2" => Some("P".into()), _ => None, }, + ports: vec![], }, &simple_edge_info, &LayoutOptions::default(), @@ -1241,6 +1536,7 @@ mod tests { "B" => Some("P2".into()), _ => None, }, + ports: vec![], }, &simple_edge_info, &LayoutOptions::default(), @@ -1285,6 +1581,7 @@ mod tests { node_type: "default".into(), sublabel: None, parent: if *n != "S" { Some("S".into()) } else { None }, + ports: vec![], }, &simple_edge_info, &opts, @@ -1329,6 +1626,7 @@ mod tests { "A" | "B" => Some("S".into()), _ => None, }, + ports: vec![], }, &simple_edge_info, &LayoutOptions::default(), @@ -1344,6 +1642,65 @@ mod tests { assert!(!node_leaf.is_container); } + #[test] + fn layout_is_deterministic() { + let mut g = Graph::new(); + let a = g.add_node("A"); + let b = g.add_node("B"); + let c = g.add_node("C"); + let d = g.add_node("D"); + let e = g.add_node("E"); + g.add_edge(a, b, "ab"); + g.add_edge(a, c, "ac"); + g.add_edge(b, d, "bd"); + g.add_edge(c, d, "cd"); + g.add_edge(d, e, "de"); + + let opts = LayoutOptions::default(); + let first = layout(&g, &simple_node_info, &simple_edge_info, &opts); + + for _ in 0..10 { + let result = layout(&g, &simple_node_info, &simple_edge_info, &opts); + assert_eq!(first.nodes.len(), result.nodes.len()); + for (a, b) in first.nodes.iter().zip(result.nodes.iter()) { + assert_eq!(a.id, b.id); + assert!((a.x - b.x).abs() < 0.001, "x mismatch for {}", a.id); + assert!((a.y - b.y).abs() < 0.001, "y mismatch for {}", a.id); + } + } + } + + #[test] + fn compound_layout_is_deterministic() { + let mut g = Graph::new(); + let _s = g.add_node("S"); + let a = g.add_node("A"); + let b = g.add_node("B"); + let c = g.add_node("C"); + g.add_edge(a, b, "ab"); + g.add_edge(b, c, "bc"); + + let node_info = |_idx: NodeIndex, n: &&str| NodeInfo { + id: n.to_string(), + label: n.to_string(), + node_type: "default".into(), + sublabel: None, + parent: if *n != "S" { Some("S".into()) } else { None }, + ports: vec![], + }; + + let first = layout(&g, &node_info, &simple_edge_info, &LayoutOptions::default()); + + for _ in 0..10 { + let result = layout(&g, &node_info, &simple_edge_info, &LayoutOptions::default()); + for (a, b) in first.nodes.iter().zip(result.nodes.iter()) { + assert_eq!(a.id, b.id); + assert!((a.x - b.x).abs() < 0.001, "x mismatch for {}", a.id); + assert!((a.y - b.y).abs() < 0.001, "y mismatch for {}", a.id); + } + } + } + #[test] fn multi_rank_edge_waypoints() { let mut g = Graph::new(); @@ -1370,4 +1727,202 @@ mod tests { // A->C spans ranks 0..2, so should have 3 waypoints (start, mid, end). assert_eq!(long_edge.points.len(), 3); } + + // ----------------------------------------------------------------------- + // Port positioning tests + // ----------------------------------------------------------------------- + + #[test] + fn ports_positioned_on_node_sides() { + let mut g = Graph::new(); + let _a = g.add_node("A"); + + let result = layout( + &g, + &|_idx, _n: &&str| NodeInfo { + id: "A".into(), + label: "A".into(), + node_type: "default".into(), + sublabel: None, + parent: None, + ports: vec![ + PortInfo { + id: "in1".into(), + label: "in1".into(), + side: PortSide::Left, + direction: PortDirection::In, + port_type: PortType::Data, + }, + PortInfo { + id: "out1".into(), + label: "out1".into(), + side: PortSide::Right, + direction: PortDirection::Out, + port_type: PortType::Data, + }, + ], + }, + &simple_edge_info, + &LayoutOptions::default(), + ); + + let node = &result.nodes[0]; + assert_eq!(node.ports.len(), 2); + + let in_port = node.ports.iter().find(|p| p.id == "in1").unwrap(); + let out_port = node.ports.iter().find(|p| p.id == "out1").unwrap(); + + // Left port at node's left edge + assert!( + (in_port.x - node.x).abs() < 1.0, + "in1 should be on left edge" + ); + // Right port at node's right edge + assert!( + (out_port.x - (node.x + node.width)).abs() < 1.0, + "out1 should be on right edge" + ); + // Both vertically within the node + assert!(in_port.y > node.y && in_port.y < node.y + node.height); + assert!(out_port.y > node.y && out_port.y < node.y + node.height); + } + + #[test] + fn auto_ports_resolve_by_direction() { + let mut g = Graph::new(); + let _a = g.add_node("A"); + + let result = layout( + &g, + &|_idx, _n: &&str| NodeInfo { + id: "A".into(), + label: "A".into(), + node_type: "default".into(), + sublabel: None, + parent: None, + ports: vec![ + PortInfo { + id: "auto_in".into(), + label: "auto_in".into(), + side: PortSide::Auto, + direction: PortDirection::In, + port_type: PortType::Data, + }, + PortInfo { + id: "auto_out".into(), + label: "auto_out".into(), + side: PortSide::Auto, + direction: PortDirection::Out, + port_type: PortType::Event, + }, + ], + }, + &simple_edge_info, + &LayoutOptions::default(), + ); + + let node = &result.nodes[0]; + let in_port = node.ports.iter().find(|p| p.id == "auto_in").unwrap(); + let out_port = node.ports.iter().find(|p| p.id == "auto_out").unwrap(); + + // Auto+In resolves to Left + assert_eq!(in_port.side, PortSide::Left); + assert!((in_port.x - node.x).abs() < 1.0); + // Auto+Out resolves to Right + assert_eq!(out_port.side, PortSide::Right); + assert!((out_port.x - (node.x + node.width)).abs() < 1.0); + } + + #[test] + fn node_grows_for_many_ports() { + let mut g = Graph::new(); + let _a = g.add_node("A"); + + let ports: Vec = (0..6) + .map(|i| PortInfo { + id: format!("p{i}"), + label: format!("port_{i}"), + side: PortSide::Left, + direction: PortDirection::In, + port_type: PortType::Data, + }) + .collect(); + + let result = layout( + &g, + &|_idx, _n: &&str| NodeInfo { + id: "A".into(), + label: "A".into(), + node_type: "default".into(), + sublabel: None, + parent: None, + ports: ports.clone(), + }, + &simple_edge_info, + &LayoutOptions::default(), + ); + + let node = &result.nodes[0]; + // 6 ports * 12px + 8px = 80px > default 50px + assert!( + node.height >= 80.0, + "node should grow for 6 ports, got {}", + node.height + ); + assert_eq!(node.ports.len(), 6); + } + + #[test] + fn edge_connects_to_ports() { + let mut g = Graph::new(); + let a = g.add_node("A"); + let b = g.add_node("B"); + g.add_edge(a, b, "conn"); + + let result = layout( + &g, + &|_idx, n: &&str| NodeInfo { + id: n.to_string(), + label: n.to_string(), + node_type: "default".into(), + sublabel: None, + parent: None, + ports: vec![ + PortInfo { + id: format!("{n}_out"), + label: "out".into(), + side: PortSide::Right, + direction: PortDirection::Out, + port_type: PortType::Data, + }, + PortInfo { + id: format!("{n}_in"), + label: "in".into(), + side: PortSide::Left, + direction: PortDirection::In, + port_type: PortType::Data, + }, + ], + }, + &|_idx, _e: &&str| EdgeInfo { + label: "conn".into(), + source_port: Some("A_out".into()), + target_port: Some("B_in".into()), + }, + &LayoutOptions::default(), + ); + + let edge = &result.edges[0]; + assert_eq!(edge.source_port.as_deref(), Some("A_out")); + assert_eq!(edge.target_port.as_deref(), Some("B_in")); + + // Edge start point should be near A's right port + let node_a = result.nodes.iter().find(|n| n.id == "A").unwrap(); + let a_out = node_a.ports.iter().find(|p| p.id == "A_out").unwrap(); + let start = edge.points[0]; + assert!( + (start.0 - a_out.x).abs() < 2.0, + "edge should start at port x" + ); + } } diff --git a/etch/src/lib.rs b/etch/src/lib.rs index 0357c65..f8a343b 100644 --- a/etch/src/lib.rs +++ b/etch/src/lib.rs @@ -25,8 +25,8 @@ //! //! let gl = layout( //! &g, -//! &|_idx, n| NodeInfo { id: n.to_string(), label: n.to_string(), node_type: "default".into(), sublabel: None, parent: None }, -//! &|_idx, e| EdgeInfo { label: e.to_string() }, +//! &|_idx, n| NodeInfo { id: n.to_string(), label: n.to_string(), node_type: "default".into(), sublabel: None, parent: None, ports: vec![] }, +//! &|_idx, e| EdgeInfo { label: e.to_string(), source_port: None, target_port: None }, //! &LayoutOptions::default(), //! ); //! @@ -35,5 +35,7 @@ //! ``` pub mod filter; +pub mod html; pub mod layout; +pub mod ortho; pub mod svg; diff --git a/etch/src/ortho.rs b/etch/src/ortho.rs new file mode 100644 index 0000000..3bb5c7b --- /dev/null +++ b/etch/src/ortho.rs @@ -0,0 +1,422 @@ +//! Orthogonal edge routing with obstacle avoidance. +//! +//! Routes edges as sequences of horizontal and vertical line segments, +//! avoiding node rectangles. Uses a simplified visibility-graph approach: +//! +//! 1. Build padded obstacle rectangles from all nodes. +//! 2. Generate candidate waypoints at obstacle corners. +//! 3. Find shortest orthogonal path using A* with bend penalty. + +use std::cmp::Ordering; +use std::collections::{BinaryHeap, HashMap}; + +use crate::layout::LayoutNode; + +/// Padding around obstacle rectangles (px). +const OBSTACLE_PADDING: f64 = 6.0; + +/// An axis-aligned rectangle used as an obstacle. +#[derive(Debug, Clone, Copy)] +struct Rect { + x1: f64, + y1: f64, + x2: f64, + y2: f64, +} + +impl Rect { + fn contains(&self, x: f64, y: f64) -> bool { + x >= self.x1 && x <= self.x2 && y >= self.y1 && y <= self.y2 + } + + fn intersects_segment(&self, ax: f64, ay: f64, bx: f64, by: f64) -> bool { + // Check if horizontal or vertical segment intersects this rectangle + if (ay - by).abs() < 0.001 { + // Horizontal segment + let y = ay; + if y < self.y1 || y > self.y2 { + return false; + } + let min_x = ax.min(bx); + let max_x = ax.max(bx); + min_x < self.x2 && max_x > self.x1 + } else if (ax - bx).abs() < 0.001 { + // Vertical segment + let x = ax; + if x < self.x1 || x > self.x2 { + return false; + } + let min_y = ay.min(by); + let max_y = ay.max(by); + min_y < self.y2 && max_y > self.y1 + } else { + false // Non-axis-aligned segments not handled + } + } +} + +/// A* node for orthogonal pathfinding. +#[derive(Debug, Clone)] +struct PathNode { + x: f64, + y: f64, + cost: f64, + /// Direction of the segment leading to this node (for bend penalty). + /// 0 = start, 1 = horizontal, 2 = vertical + dir: u8, +} + +impl PartialEq for PathNode { + fn eq(&self, other: &Self) -> bool { + self.cost == other.cost + } +} + +impl Eq for PathNode {} + +impl PartialOrd for PathNode { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for PathNode { + fn cmp(&self, other: &Self) -> Ordering { + // Reverse ordering for min-heap + other + .cost + .partial_cmp(&self.cost) + .unwrap_or(Ordering::Equal) + } +} + +/// Discretize a coordinate for use as HashMap key. +fn grid_key(x: f64, y: f64) -> (i64, i64) { + ((x * 100.0) as i64, (y * 100.0) as i64) +} + +/// Route an edge orthogonally from `src` to `tgt`, avoiding obstacles. +/// +/// Returns a list of waypoints where all consecutive pairs form +/// horizontal or vertical segments. +pub fn route_orthogonal( + nodes: &[LayoutNode], + src: (f64, f64), + tgt: (f64, f64), + bend_penalty: f64, + _port_stub_length: f64, +) -> Vec<(f64, f64)> { + // Trivial case: same point + if (src.0 - tgt.0).abs() < 0.001 && (src.1 - tgt.1).abs() < 0.001 { + return vec![src]; + } + + // If source and target share an axis, try direct line + let obstacles = build_obstacles(nodes); + + if can_route_direct(&obstacles, src, tgt) { + return if (src.0 - tgt.0).abs() < 0.001 || (src.1 - tgt.1).abs() < 0.001 { + vec![src, tgt] + } else { + // One bend: go horizontal then vertical + let mid = (tgt.0, src.1); + if !segment_blocked(&obstacles, src.0, src.1, mid.0, mid.1) + && !segment_blocked(&obstacles, mid.0, mid.1, tgt.0, tgt.1) + { + vec![src, mid, tgt] + } else { + let mid2 = (src.0, tgt.1); + if !segment_blocked(&obstacles, src.0, src.1, mid2.0, mid2.1) + && !segment_blocked(&obstacles, mid2.0, mid2.1, tgt.0, tgt.1) + { + vec![src, mid2, tgt] + } else { + route_with_astar(&obstacles, src, tgt, bend_penalty) + } + } + }; + } + + route_with_astar(&obstacles, src, tgt, bend_penalty) +} + +fn build_obstacles(nodes: &[LayoutNode]) -> Vec { + nodes + .iter() + .map(|n| Rect { + x1: n.x - OBSTACLE_PADDING, + y1: n.y - OBSTACLE_PADDING, + x2: n.x + n.width + OBSTACLE_PADDING, + y2: n.y + n.height + OBSTACLE_PADDING, + }) + .collect() +} + +fn can_route_direct(obstacles: &[Rect], src: (f64, f64), tgt: (f64, f64)) -> bool { + // Direct horizontal or vertical line + if (src.0 - tgt.0).abs() < 0.001 || (src.1 - tgt.1).abs() < 0.001 { + return !segment_blocked(obstacles, src.0, src.1, tgt.0, tgt.1); + } + false +} + +fn segment_blocked(obstacles: &[Rect], x1: f64, y1: f64, x2: f64, y2: f64) -> bool { + obstacles + .iter() + .any(|r| r.intersects_segment(x1, y1, x2, y2)) +} + +fn route_with_astar( + obstacles: &[Rect], + src: (f64, f64), + tgt: (f64, f64), + bend_penalty: f64, +) -> Vec<(f64, f64)> { + // Generate candidate waypoints from obstacle corners + src/tgt + let mut candidates: Vec<(f64, f64)> = vec![src, tgt]; + + for r in obstacles { + // Add corner points (slightly outside the obstacle) + candidates.push((r.x1, r.y1)); + candidates.push((r.x2, r.y1)); + candidates.push((r.x1, r.y2)); + candidates.push((r.x2, r.y2)); + } + + // Also add axis-aligned projections of src/tgt through obstacle corners + for r in obstacles { + candidates.push((src.0, r.y1)); + candidates.push((src.0, r.y2)); + candidates.push((r.x1, src.1)); + candidates.push((r.x2, src.1)); + candidates.push((tgt.0, r.y1)); + candidates.push((tgt.0, r.y2)); + candidates.push((r.x1, tgt.1)); + candidates.push((r.x2, tgt.1)); + } + + // Filter out candidates inside obstacles + candidates.retain(|&(x, y)| !obstacles.iter().any(|r| r.contains(x, y))); + + // Deduplicate + candidates.sort_by(|a, b| { + a.0.partial_cmp(&b.0) + .unwrap_or(Ordering::Equal) + .then(a.1.partial_cmp(&b.1).unwrap_or(Ordering::Equal)) + }); + candidates.dedup_by(|a, b| (a.0 - b.0).abs() < 0.01 && (a.1 - b.1).abs() < 0.01); + + // A* search + let src_key = grid_key(src.0, src.1); + let tgt_key = grid_key(tgt.0, tgt.1); + + let mut heap = BinaryHeap::new(); + type GridKey = (i64, i64); + // (cost, direction, predecessor) + let mut best: HashMap)> = HashMap::new(); + + heap.push(PathNode { + x: src.0, + y: src.1, + cost: 0.0, + dir: 0, + }); + best.insert(src_key, (0.0, 0, None)); + + while let Some(current) = heap.pop() { + let cur_key = grid_key(current.x, current.y); + + if cur_key == tgt_key { + break; + } + + if let Some(&(best_cost, _, _)) = best.get(&cur_key) + && current.cost > best_cost + 0.001 + { + continue; + } + + // Try reaching each candidate via orthogonal segment + for &(cx, cy) in &candidates { + let c_key = grid_key(cx, cy); + if c_key == cur_key { + continue; + } + + // Must share an axis (orthogonal move) + let is_horizontal = (current.y - cy).abs() < 0.01; + let is_vertical = (current.x - cx).abs() < 0.01; + + if !is_horizontal && !is_vertical { + continue; + } + + // Check if segment is blocked + if segment_blocked(obstacles, current.x, current.y, cx, cy) { + continue; + } + + let dir = if is_horizontal { 1 } else { 2 }; + let dist = if is_horizontal { + (current.x - cx).abs() + } else { + (current.y - cy).abs() + }; + + let bend_cost = if current.dir != 0 && current.dir != dir { + bend_penalty + } else { + 0.0 + }; + + let new_cost = current.cost + dist + bend_cost; + + let is_better = match best.get(&c_key) { + Some(&(prev_cost, _, _)) => new_cost < prev_cost - 0.001, + None => true, + }; + + if is_better { + best.insert(c_key, (new_cost, dir, Some(cur_key))); + heap.push(PathNode { + x: cx, + y: cy, + cost: new_cost, + dir, + }); + } + } + } + + // Reconstruct path + let mut path = Vec::new(); + let mut key = tgt_key; + + loop { + match best.get(&key) { + Some(&(_, _, Some(prev))) => { + // Find the point for this key + let (x, y) = (key.0 as f64 / 100.0, key.1 as f64 / 100.0); + path.push((x, y)); + key = prev; + } + _ => { + path.push(src); + break; + } + } + } + + path.reverse(); + + // If path is empty or single point, fallback to L-shaped route + if path.len() < 2 { + let mid = (tgt.0, src.1); + return vec![src, mid, tgt]; + } + + path +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::layout::LayoutNode; + + fn make_node(id: &str, x: f64, y: f64, w: f64, h: f64) -> LayoutNode { + LayoutNode { + id: id.into(), + x, + y, + width: w, + height: h, + rank: 0, + label: id.into(), + node_type: "default".into(), + sublabel: None, + is_container: false, + ports: vec![], + } + } + + #[test] + fn direct_vertical_no_obstacles() { + let nodes = vec![]; + let path = route_orthogonal(&nodes, (100.0, 0.0), (100.0, 200.0), 20.0, 10.0); + assert_eq!(path.len(), 2); + assert!((path[0].0 - 100.0).abs() < 0.1); + assert!((path[1].0 - 100.0).abs() < 0.1); + } + + #[test] + fn direct_horizontal_no_obstacles() { + let nodes = vec![]; + let path = route_orthogonal(&nodes, (0.0, 100.0), (200.0, 100.0), 20.0, 10.0); + assert_eq!(path.len(), 2); + } + + #[test] + fn l_shaped_no_obstacles() { + let nodes = vec![]; + let path = route_orthogonal(&nodes, (0.0, 0.0), (200.0, 200.0), 20.0, 10.0); + // Should have one bend (3 points) + assert!(path.len() >= 2); + // All segments orthogonal + for w in path.windows(2) { + let dx = (w[0].0 - w[1].0).abs(); + let dy = (w[0].1 - w[1].1).abs(); + assert!( + dx < 0.1 || dy < 0.1, + "non-orthogonal: ({},{})->({},{})", + w[0].0, + w[0].1, + w[1].0, + w[1].1 + ); + } + } + + #[test] + fn routes_around_obstacle() { + // Node B sits between src and tgt + let nodes = vec![make_node("B", 90.0, 90.0, 20.0, 20.0)]; + let path = route_orthogonal(&nodes, (100.0, 50.0), (100.0, 150.0), 20.0, 10.0); + + // Path should avoid the obstacle (more than 2 points) + assert!( + path.len() >= 3, + "should route around obstacle, got {} points", + path.len() + ); + + // All segments orthogonal + for w in path.windows(2) { + let dx = (w[0].0 - w[1].0).abs(); + let dy = (w[0].1 - w[1].1).abs(); + assert!(dx < 0.1 || dy < 0.1, "non-orthogonal segment"); + } + } + + #[test] + fn all_segments_orthogonal() { + let nodes = vec![ + make_node("A", 0.0, 0.0, 80.0, 40.0), + make_node("B", 200.0, 0.0, 80.0, 40.0), + make_node("C", 100.0, 100.0, 80.0, 40.0), + ]; + let path = route_orthogonal(&nodes, (80.0, 20.0), (200.0, 120.0), 20.0, 10.0); + + for w in path.windows(2) { + let dx = (w[0].0 - w[1].0).abs(); + let dy = (w[0].1 - w[1].1).abs(); + assert!( + dx < 0.1 || dy < 0.1, + "non-orthogonal: ({:.1},{:.1})->({:.1},{:.1})", + w[0].0, + w[0].1, + w[1].0, + w[1].1 + ); + } + } +} diff --git a/etch/src/svg.rs b/etch/src/svg.rs index 4a28971..a6e52d9 100644 --- a/etch/src/svg.rs +++ b/etch/src/svg.rs @@ -151,6 +151,14 @@ fn write_style(svg: &mut String, options: &SvgOptions) { font-weight: 500; }}\n\ \x20 .node.container rect {{ stroke-dasharray: 4 2; }}\n\ \x20 .node:hover rect {{ filter: brightness(0.92); }}\n\ + \x20 .port circle {{ stroke: #333; stroke-width: 0.8; }}\n\ + \x20 .port.data circle {{ fill: #4a90d9; }}\n\ + \x20 .port.event circle {{ fill: #e67e22; }}\n\ + \x20 .port.event-data circle {{ fill: #27ae60; }}\n\ + \x20 .port.access circle {{ fill: #999; }}\n\ + \x20 .port.group circle {{ fill: #9b59b6; }}\n\ + \x20 .port.abstract circle {{ fill: #666; }}\n\ + \x20 .port text {{ font-size: 9px; fill: #444; dominant-baseline: central; }}\n\ \x20 \n", fs - 2.0, fs - 2.0, @@ -312,6 +320,76 @@ fn write_nodes(svg: &mut String, layout: &GraphLayout, options: &SvgOptions) { } } + // Ports. + for port in &node.ports { + let port_class = match port.port_type { + crate::layout::PortType::Data => "data", + crate::layout::PortType::Event => "event", + crate::layout::PortType::EventData => "event-data", + crate::layout::PortType::Access => "access", + crate::layout::PortType::Group => "group", + crate::layout::PortType::Abstract => "abstract", + }; + writeln!( + svg, + " ", + xml_escape(&port.id), + ) + .unwrap(); + // Port circle + writeln!( + svg, + " ", + port.x, port.y, + ) + .unwrap(); + // Direction indicator (small triangle) + let tri = match port.direction { + crate::layout::PortDirection::In => { + // Inward-pointing triangle + match port.side { + crate::layout::PortSide::Left => { + format!("M {} {} l 4 -2.5 l 0 5 Z", port.x + 4.0, port.y) + } + crate::layout::PortSide::Right => { + format!("M {} {} l -4 -2.5 l 0 5 Z", port.x - 4.0, port.y) + } + _ => String::new(), + } + } + crate::layout::PortDirection::Out => { + // Outward-pointing triangle + match port.side { + crate::layout::PortSide::Left => { + format!("M {} {} l -4 -2.5 l 0 5 Z", port.x - 4.0, port.y) + } + crate::layout::PortSide::Right => { + format!("M {} {} l 4 -2.5 l 0 5 Z", port.x + 4.0, port.y) + } + _ => String::new(), + } + } + crate::layout::PortDirection::InOut => String::new(), + }; + if !tri.is_empty() { + writeln!(svg, " ").unwrap(); + } + // Port label + let (lx, anchor) = match port.side { + crate::layout::PortSide::Left => (port.x + 6.0, "start"), + crate::layout::PortSide::Right => (port.x - 6.0, "end"), + _ => (port.x, "middle"), + }; + writeln!( + svg, + " {}", + port.y, + xml_escape(&port.label), + ) + .unwrap(); + svg.push_str(" \n"); + } + // Tooltip. writeln!(svg, " {}", xml_escape(&node.id)).unwrap(); @@ -351,11 +429,24 @@ fn build_bezier_path(points: &[(f64, f64)]) -> String { let (x0, y0) = points[0]; write!(d, "M {x0} {y0}").unwrap(); - if points.len() == 2 { + // Check if all segments are axis-aligned (orthogonal routing) + let is_orthogonal = points.len() >= 2 + && points.windows(2).all(|w| { + let dx = (w[0].0 - w[1].0).abs(); + let dy = (w[0].1 - w[1].1).abs(); + dx < 0.1 || dy < 0.1 + }); + + if is_orthogonal { + // Polyline with straight segments (L commands) + for &(x, y) in &points[1..] { + write!(d, " L {x} {y}").unwrap(); + } + } else if points.len() == 2 { let (x1, y1) = points[1]; write!(d, " L {x1} {y1}").unwrap(); } else { - // Simple cubic bezier: for each segment use vertical tangent handles. + // Cubic bezier: for each segment use vertical tangent handles. for i in 0..points.len() - 1 { let (x1, y1) = points[i]; let (x2, y2) = points[i + 1]; @@ -396,7 +487,9 @@ fn css_class_safe(s: &str) -> String { #[cfg(test)] mod tests { use super::*; - use crate::layout::{EdgeInfo, LayoutOptions, NodeInfo, layout}; + use crate::layout::{ + EdgeInfo, LayoutOptions, NodeInfo, PortDirection, PortInfo, PortSide, PortType, layout, + }; use petgraph::Graph; use petgraph::graph::{EdgeIndex, NodeIndex}; @@ -414,9 +507,12 @@ mod tests { node_type: "req".into(), sublabel: Some("Title".into()), parent: None, + ports: vec![], }, &|_idx: EdgeIndex, e: &&str| EdgeInfo { label: e.to_string(), + source_port: None, + target_port: None, }, &LayoutOptions::default(), ) @@ -534,9 +630,12 @@ mod tests { } else { None }, + ports: vec![], }, &|_idx: EdgeIndex, e: &&str| EdgeInfo { label: e.to_string(), + source_port: None, + target_port: None, }, &LayoutOptions::default(), ); @@ -582,6 +681,116 @@ mod tests { assert!(result.starts_with("#ff")); } + #[test] + fn svg_orthogonal_edges_use_line_commands() { + let mut g = Graph::new(); + let a = g.add_node("A"); + let b = g.add_node("B"); + g.add_edge(a, b, "ab"); + + let gl = layout( + &g, + &|_idx: NodeIndex, n: &&str| NodeInfo { + id: n.to_string(), + label: n.to_string(), + node_type: "default".into(), + sublabel: None, + parent: None, + ports: vec![], + }, + &|_idx: EdgeIndex, e: &&str| EdgeInfo { + label: e.to_string(), + source_port: None, + target_port: None, + }, + &LayoutOptions { + edge_routing: crate::layout::EdgeRouting::Orthogonal, + ..Default::default() + }, + ); + + let svg = render_svg(&gl, &SvgOptions::default()); + // Orthogonal edges should use L (line-to) commands, not C (cubic) + assert!( + svg.contains(" L "), + "orthogonal edges should use L commands" + ); + // Should NOT contain C commands for orthogonal edges + assert!( + !svg.contains(" C "), + "orthogonal edges should not use C (bezier) commands" + ); + } + + #[test] + fn svg_renders_ports() { + let mut g = Graph::new(); + let _a = g.add_node("A"); + + let gl = layout( + &g, + &|_idx: NodeIndex, _n: &&str| NodeInfo { + id: "A".into(), + label: "A".into(), + node_type: "default".into(), + sublabel: None, + parent: None, + ports: vec![ + PortInfo { + id: "data_in".into(), + label: "data_in".into(), + side: PortSide::Left, + direction: PortDirection::In, + port_type: PortType::Data, + }, + PortInfo { + id: "event_out".into(), + label: "event_out".into(), + side: PortSide::Right, + direction: PortDirection::Out, + port_type: PortType::Event, + }, + ], + }, + &|_idx: EdgeIndex, _e: &&str| EdgeInfo { + label: String::new(), + source_port: None, + target_port: None, + }, + &LayoutOptions::default(), + ); + + let svg = render_svg(&gl, &SvgOptions::default()); + // Port elements present + assert!( + svg.contains("class=\"port data\""), + "should have data port class" + ); + assert!( + svg.contains("class=\"port event\""), + "should have event port class" + ); + // Port circles present + assert!(svg.contains("data_in<"), "should have port label"); + assert!(svg.contains(">event_out<"), "should have port label"); + // Port CSS styles present + assert!( + svg.contains(".port.data circle"), + "should have port data CSS" + ); + assert!( + svg.contains(".port.event circle"), + "should have port event CSS" + ); + // Direction indicator triangle + assert!( + svg.contains(") -> Html { node_type: node_type.into(), sublabel, parent: None, + ports: vec![], } }, - &|_idx, e| EdgeInfo { label: e.clone() }, + &|_idx, e| EdgeInfo { + label: e.clone(), + source_port: None, + target_port: None, + }, &layout_opts, ); diff --git a/rivet-core/src/model.rs b/rivet-core/src/model.rs index 749aa42..fec03c1 100644 --- a/rivet-core/src/model.rs +++ b/rivet-core/src/model.rs @@ -7,13 +7,7 @@ use serde::{Deserialize, Serialize}; pub type ArtifactId = String; /// Statuses that indicate an artifact should be fully traced in the lifecycle. -pub const TRACED_STATUSES: &[&str] = &[ - "implemented", - "done", - "approved", - "accepted", - "verified", -]; +pub const TRACED_STATUSES: &[&str] = &["implemented", "done", "approved", "accepted", "verified"]; /// A typed, directional link from one artifact to another. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] diff --git a/tests/playwright/.gitignore b/tests/playwright/.gitignore new file mode 100644 index 0000000..dbd64df --- /dev/null +++ b/tests/playwright/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +test-results/ +playwright-report/ diff --git a/tests/playwright/artifacts.spec.ts b/tests/playwright/artifacts.spec.ts new file mode 100644 index 0000000..9b686a7 --- /dev/null +++ b/tests/playwright/artifacts.spec.ts @@ -0,0 +1,44 @@ +import { test, expect } from "@playwright/test"; +import { countTableRows } from "./helpers"; + +test.describe("Artifacts", () => { + test("artifact list shows artifacts", async ({ page }) => { + await page.goto("/artifacts"); + const rows = await countTableRows(page); + expect(rows).toBeGreaterThan(10); + }); + + test("artifact detail shows links", async ({ page }) => { + await page.goto("/artifacts/REQ-001"); + await expect(page.locator("body")).toContainText("REQ-001"); + }); + + test("filter by type via URL", async ({ page }) => { + await page.goto("/artifacts?types=requirement"); + const rows = await countTableRows(page); + expect(rows).toBeGreaterThan(10); + expect(rows).toBeLessThan(100); + }); + + test("sort by column preserves in URL", async ({ page }) => { + await page.goto("/artifacts?sort=type&dir=asc"); + await expect(page).toHaveURL(/sort=type/); + await expect(page).toHaveURL(/dir=asc/); + }); + + test("pagination limits rows", async ({ page }) => { + await page.goto("/artifacts?per_page=20"); + const rows = await countTableRows(page); + expect(rows).toBeLessThanOrEqual(20); + // Should mention page count + const text = await page.locator("body").textContent(); + expect(text).toContain("page"); + }); + + test("text search filters results", async ({ page }) => { + await page.goto("/artifacts?q=OSLC"); + const rows = await countTableRows(page); + expect(rows).toBeGreaterThan(0); + expect(rows).toBeLessThan(50); + }); +}); diff --git a/tests/playwright/graph.spec.ts b/tests/playwright/graph.spec.ts new file mode 100644 index 0000000..aaf68a0 --- /dev/null +++ b/tests/playwright/graph.spec.ts @@ -0,0 +1,31 @@ +import { test, expect } from "@playwright/test"; +import { waitForHtmx } from "./helpers"; + +test.describe("Graph View", () => { + test("graph with type filter renders SVG", async ({ page }) => { + await page.goto("/graph?types=requirement&depth=2"); + await waitForHtmx(page); + await expect(page.locator("svg").first()).toBeVisible({ timeout: 15_000 }); + }); + + test("focus on specific artifact", async ({ page }) => { + await page.goto("/graph?focus=REQ-001&depth=2"); + await waitForHtmx(page); + await expect(page.locator("svg").first()).toBeVisible({ timeout: 15_000 }); + }); + + test("node budget prevents crash on full graph", async ({ page }) => { + await page.goto("/graph"); + await waitForHtmx(page); + // Should render without timeout — either SVG or budget message + const content = page.locator("svg, :text('budget')"); + await expect(content.first()).toBeVisible({ timeout: 30_000 }); + }); + + test("graph controls are visible", async ({ page }) => { + await page.goto("/graph?types=requirement"); + await waitForHtmx(page); + // Type filter checkboxes should be present + await expect(page.locator("input[type='checkbox']").first()).toBeVisible(); + }); +}); diff --git a/tests/playwright/helpers.ts b/tests/playwright/helpers.ts new file mode 100644 index 0000000..45e1d9f --- /dev/null +++ b/tests/playwright/helpers.ts @@ -0,0 +1,35 @@ +import { Page, expect } from "@playwright/test"; + +/** Wait for HTMX to finish all pending requests. */ +export async function waitForHtmx(page: Page) { + await page.waitForFunction( + () => !document.querySelector(".htmx-request"), + { timeout: 10_000 }, + ); +} + +/** Navigate via HTMX (click nav link) and wait for content swap. */ +export async function htmxNavigate(page: Page, linkText: string) { + await page.click(`a:has-text("${linkText}")`); + await waitForHtmx(page); +} + +/** Assert current URL path and optional query params. */ +export async function assertUrl( + page: Page, + path: string, + params?: Record, +) { + const url = new URL(page.url()); + expect(url.pathname).toBe(path); + if (params) { + for (const [key, value] of Object.entries(params)) { + expect(url.searchParams.get(key)).toBe(value); + } + } +} + +/** Count visible rows in a . */ +export async function countTableRows(page: Page) { + return page.locator("table tbody tr").count(); +} diff --git a/tests/playwright/navigation.spec.ts b/tests/playwright/navigation.spec.ts new file mode 100644 index 0000000..14b6199 --- /dev/null +++ b/tests/playwright/navigation.spec.ts @@ -0,0 +1,40 @@ +import { test, expect } from "@playwright/test"; +import { waitForHtmx } from "./helpers"; + +test.describe("Navigation", () => { + test("dashboard loads with project name", async ({ page }) => { + await page.goto("/"); + await expect(page.locator(".ctx-project")).toHaveText("rivet"); + }); + + test("all major nav links are reachable via direct URL", async ({ page }) => { + // Test via direct URL access (more reliable than HTMX click) + const routes = ["/artifacts", "/validate", "/matrix", "/graph", "/coverage"]; + for (const route of routes) { + const response = await page.goto(route); + expect(response?.status()).toBe(200); + } + }); + + test("direct URL access works without redirect loop", async ({ page }) => { + await page.goto("/artifacts"); + await expect(page.locator("table")).toBeVisible(); + await page.goto("/stpa"); + await expect(page.locator("body")).toContainText(/STPA/i); + }); + + test("browser back/forward works with direct navigation", async ({ page }) => { + await page.goto("/artifacts"); + await page.goto("/graph?types=requirement"); + await page.goBack(); + await expect(page).toHaveURL(/artifacts/); + await page.goForward(); + await expect(page).toHaveURL(/graph/); + }); + + test("reload button is visible", async ({ page }) => { + await page.goto("/"); + const btn = page.locator('button:has-text("Reload")'); + await expect(btn).toBeVisible(); + }); +}); diff --git a/tests/playwright/package-lock.json b/tests/playwright/package-lock.json new file mode 100644 index 0000000..485f2dd --- /dev/null +++ b/tests/playwright/package-lock.json @@ -0,0 +1,76 @@ +{ + "name": "rivet-playwright", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "rivet-playwright", + "devDependencies": { + "@playwright/test": "^1.50.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/tests/playwright/package.json b/tests/playwright/package.json new file mode 100644 index 0000000..1f09dd3 --- /dev/null +++ b/tests/playwright/package.json @@ -0,0 +1,12 @@ +{ + "name": "rivet-playwright", + "private": true, + "devDependencies": { + "@playwright/test": "^1.50.0" + }, + "scripts": { + "test": "playwright test", + "test:headed": "playwright test --headed", + "test:ui": "playwright test --ui" + } +} diff --git a/tests/playwright/playwright.config.ts b/tests/playwright/playwright.config.ts new file mode 100644 index 0000000..3a6cff7 --- /dev/null +++ b/tests/playwright/playwright.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: ".", + testMatch: "*.spec.ts", + timeout: 30_000, + retries: process.env.CI ? 1 : 0, + workers: 1, // serial — single server instance + use: { + baseURL: "http://localhost:3003", + trace: "on-first-retry", + screenshot: "only-on-failure", + }, + webServer: { + command: "cargo run --release -- serve --port 3003", + port: 3003, + timeout: 120_000, + reuseExistingServer: !process.env.CI, + cwd: "../..", + }, + projects: [{ name: "chromium", use: { browserName: "chromium" } }], +}); diff --git a/tests/playwright/print-mode.spec.ts b/tests/playwright/print-mode.spec.ts new file mode 100644 index 0000000..22f68f2 --- /dev/null +++ b/tests/playwright/print-mode.spec.ts @@ -0,0 +1,34 @@ +import { test, expect } from "@playwright/test"; + +test.describe("Print Mode", () => { + test("?print=1 removes nav element entirely", async ({ page }) => { + await page.goto("/stpa?print=1"); + // Print layout doesn't include