diff --git a/crates/pseudoscript-emit/src/c4_render.rs b/crates/pseudoscript-emit/src/c4_render.rs index 1ca9fcb..8908a24 100644 --- a/crates/pseudoscript-emit/src/c4_render.rs +++ b/crates/pseudoscript-emit/src/c4_render.rs @@ -36,9 +36,10 @@ use layout::topo::layout::VisualGraph; use std::panic::{AssertUnwindSafe, catch_unwind}; use pseudoscript_model::NodeKind; +use serde::{Deserialize, Serialize}; use crate::render::pal; -use crate::scene::{C4EdgeKind, C4Scene, PlacedNode, RoutedEdge}; +use crate::scene::{C4EdgeKind, C4Scene, PlacedNode, Rect, RoutedEdge}; // All SVG colours come from the active theme palette (crate::render::pal); the // hand-written emitters bind their roles as locals at the top of each function. @@ -98,6 +99,363 @@ pub(crate) fn render_c4(scene: &C4Scene) -> String { } } +// --- C4 layout export ------------------------------------------------------- + +/// A fully positioned C4 view — absolute renderer coordinates a consumer draws +/// verbatim, the same geometry [`render_c4`] turns into SVG. Serde-serializable +/// so it crosses the wasm boundary unchanged (the web IDE's interactive canvas +/// renders it directly, as `FlowTimeline` renders a positioned sequence +/// `Layout`). Produced by [`layout_c4_scene`]. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct C4Layout { + /// Total canvas width. + pub width: i32, + /// Total canvas height. + pub height: i32, + /// Placed node cards. + pub nodes: Vec, + /// Routed edges between cards. + pub edges: Vec, + /// The enclosing frame of a container/component view; `None` for context. + pub boundary: Option, +} + +/// A node card placed by the layout engine: its content (for the card chrome) +/// and its rectangle (engine placement + content-derived [`card_size`]). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LaidOutNode { + /// The node's fully-qualified name. + pub fqn: String, + /// The node's C4 kind, for the card's accent and eyebrow. + pub kind: NodeKind, + /// The display label (simple name). + pub label: String, + /// The node's `///` summary, rendered as the card's dimmed description. + #[serde(default)] + pub summary: Option, + /// The card rectangle. + pub rect: Rect, +} + +/// A routed edge: the engine's polyline through `points` (a straight +/// card-centre-to-centre line when the routed path could not be matched), plus +/// the relationship it expresses. `points` always has at least two entries. +/// `dashed` marks a `from`-provenance edge, matching the SVG. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LaidOutEdge { + /// Source endpoint FQN. + pub from: String, + /// Target endpoint FQN. + pub to: String, + /// The relationship kind. + pub kind: C4EdgeKind, + /// Edge label (the method name for a call, else empty). + pub label: String, + /// The routed polyline (at least two points). + pub points: Vec, + /// The engine's label position, when a matching label was found. + #[serde(default)] + pub label_pos: Option, + /// Dashed (a `from`-provenance edge), matching the SVG. + pub dashed: bool, +} + +/// The enclosing frame of a container/component view: its rectangle and the +/// boundary node's title and kind (for the frame's accent). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BoundaryFrame { + /// The boundary node's display label. + pub title: String, + /// The boundary node's C4 kind (system for a container view, container for a + /// component view), for the frame's accent colour. + pub kind: NodeKind, + /// The frame rectangle (padded child bounding box). + pub rect: Rect, +} + +/// An integer point on the canvas (a rounded layout coordinate). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct PointI { + /// Horizontal coordinate. + pub x: i32, + /// Vertical coordinate. + pub y: i32, +} + +/// Positions a [`C4Scene`] into a [`C4Layout`] — the same layout-rs Sugiyama +/// pass [`render_c4`] runs, returned as structured geometry instead of SVG. +/// Wrapped exactly like [`render_c4`]: an empty view or a layout-engine panic +/// falls back to the scene's own simple coordinates ([`fallback_layout`]), so +/// no input can panic the caller. +#[must_use] +pub fn layout_c4_scene(scene: &C4Scene) -> C4Layout { + let boundary = scene.of.as_deref(); + let laid_out = scene.nodes.iter().any(|n| Some(n.fqn.as_str()) != boundary); + if !laid_out { + return fallback_layout(scene, boundary); + } + + match catch_unwind(AssertUnwindSafe(|| layout_capture(scene, boundary))) { + Ok(capture) => capture_to_layout(&capture, scene, boundary), + Err(_) => fallback_layout(scene, boundary), + } +} + +/// Reads the captured geometry into a [`C4Layout`]: cards from the rects whose +/// `properties` name a scene node (the SVG card filter), edges from each +/// laid-out scene edge matched to its routed arrow by endpoint proximity +/// (robust to the engine's draw order and to connector arrows), and the +/// boundary frame and extent from the shared [`frame_and_extent`]. +fn capture_to_layout(capture: &Capture, scene: &C4Scene, boundary: Option<&str>) -> C4Layout { + let by_fqn: HashMap<&str, &PlacedNode> = + scene.nodes.iter().map(|n| (n.fqn.as_str(), n)).collect(); + let (frame, width, height) = frame_and_extent(capture, scene, boundary); + + let mut centre: HashMap<&str, Point> = HashMap::new(); + let mut nodes = Vec::new(); + for rect in &capture.rects { + let Some((fqn, node)) = rect + .properties + .as_deref() + .and_then(|fqn| Some((fqn, *by_fqn.get(fqn)?))) + else { + continue; + }; + centre.insert( + fqn, + Point::new(rect.xy.x + rect.size.x / 2.0, rect.xy.y + rect.size.y / 2.0), + ); + nodes.push(laid_out_node(node, rect_of(rect.xy, rect.size))); + } + + let mut used_arrow = vec![false; capture.arrows.len()]; + let mut used_text = vec![false; capture.texts.len()]; + let mut edges = Vec::new(); + for edge in acyclic_edges(scene, boundary) { + let (Some(&from_c), Some(&to_c)) = + (centre.get(edge.from.as_str()), centre.get(edge.to.as_str())) + else { + continue; + }; + let (points, dashed, label_pos) = + match nearest_arrow(&capture.arrows, &used_arrow, from_c, to_c) { + Some(i) => { + used_arrow[i] = true; + let arrow = &capture.arrows[i]; + let label_pos = + nearest_label(&capture.texts, &mut used_text, &edge.label, arrow); + ( + arrow.points.iter().map(point_i).collect(), + arrow.dashed, + label_pos, + ) + } + None => ( + // No routed arrow matched: a straight line between card centres, + // so every edge always carries a drawable polyline. + vec![point_i(&from_c), point_i(&to_c)], + matches!(edge.kind, C4EdgeKind::Provenance), + None, + ), + }; + edges.push(LaidOutEdge { + from: edge.from.clone(), + to: edge.to.clone(), + kind: edge.kind, + label: edge.label.clone(), + points, + label_pos, + dashed, + }); + } + + C4Layout { + width, + height, + nodes, + edges, + boundary: frame.and_then(|(min, max, _title)| { + boundary_frame(scene, boundary?, rect_corners(min, max)) + }), + } +} + +/// The unused arrow whose ends sit closest to `from`/`to` (the source and target +/// card centres), or `None` when none is left. Endpoint proximity, not draw +/// order, so connector arrows and reordering never mismatch an edge. +fn nearest_arrow(arrows: &[CapturedArrow], used: &[bool], from: Point, to: Point) -> Option { + arrows + .iter() + .enumerate() + .filter(|(i, a)| !used[*i] && a.points.len() >= 2) + .map(|(i, a)| { + let first = a.points[0]; + let last = a.points[a.points.len() - 1]; + (i, dist2(first, from) + dist2(last, to)) + }) + .min_by(|(_, x), (_, y)| x.total_cmp(y)) + .map(|(i, _)| i) +} + +/// The unused captured edge label matching `label` nearest the arrow's midpoint, +/// marking it consumed so parallel same-label edges take distinct labels. `None` +/// for an empty label or when none matches. +fn nearest_label( + texts: &[CapturedText], + used: &mut [bool], + label: &str, + arrow: &CapturedArrow, +) -> Option { + if label.is_empty() { + return None; + } + let mid = arrow_midpoint(arrow); + let (j, text) = texts + .iter() + .enumerate() + .filter(|(j, t)| !used[*j] && t.text == label) + .min_by(|(_, a), (_, b)| dist2(a.xy, mid).total_cmp(&dist2(b.xy, mid)))?; + used[j] = true; + Some(point_i(&text.xy)) +} + +/// The arithmetic mean of an arrow's polyline points (its rough centre). +fn arrow_midpoint(arrow: &CapturedArrow) -> Point { + let n = f64::from(u32::try_from(arrow.points.len().max(1)).unwrap_or(u32::MAX)); + let sum = arrow + .points + .iter() + .fold(Point::zero(), |acc, p| Point::new(acc.x + p.x, acc.y + p.y)); + Point::new(sum.x / n, sum.y / n) +} + +/// Squared Euclidean distance (avoids a `sqrt` — only the ordering matters). +fn dist2(a: Point, b: Point) -> f64 { + let (dx, dy) = (a.x - b.x, a.y - b.y); + dx * dx + dy * dy +} + +/// A captured top-left + size as an integer [`Rect`]. +fn rect_of(xy: Point, size: Point) -> Rect { + Rect { + x: round(xy.x), + y: round(xy.y), + w: round(size.x), + h: round(size.y), + } +} + +/// A min/max corner pair as an integer [`Rect`] (the boundary frame box). +fn rect_corners(min: Point, max: Point) -> Rect { + Rect { + x: round(min.x), + y: round(min.y), + w: round(max.x - min.x), + h: round(max.y - min.y), + } +} + +/// A rounded [`PointI`] from a layout [`Point`]. +fn point_i(p: &Point) -> PointI { + PointI { + x: round(p.x), + y: round(p.y), + } +} + +/// The centre of an integer [`Rect`]. +fn rect_centre(r: &Rect) -> PointI { + PointI { + x: r.x + r.w / 2, + y: r.y + r.h / 2, + } +} + +/// A scene node as a [`LaidOutNode`] at `rect` (the engine placement or, in the +/// fallback, the node's own rect). +fn laid_out_node(node: &PlacedNode, rect: Rect) -> LaidOutNode { + LaidOutNode { + fqn: node.fqn.clone(), + kind: node.kind, + label: node.label.clone(), + summary: node.summary.clone(), + rect, + } +} + +/// The boundary node's [`BoundaryFrame`] at `rect` (its title and kind from the +/// node), or `None` when `of` names no scene node. +fn boundary_frame(scene: &C4Scene, of: &str, rect: Rect) -> Option { + let node = scene.nodes.iter().find(|n| n.fqn == of)?; + Some(BoundaryFrame { + title: node.label.clone(), + kind: node.kind, + rect, + }) +} + +/// The two endpoint nodes of an in-view edge, or `None` when the edge touches +/// the framed boundary or an endpoint is absent. Shared by the SVG fallback and +/// the layout fallback so the two agree on which edges a fallback draws. +fn view_edge_endpoints<'a>( + scene: &'a C4Scene, + edge: &RoutedEdge, + boundary: Option<&str>, +) -> Option<(&'a PlacedNode, &'a PlacedNode)> { + if Some(edge.from.as_str()) == boundary || Some(edge.to.as_str()) == boundary { + return None; + } + let from = scene.nodes.iter().find(|n| n.fqn == edge.from)?; + let to = scene.nodes.iter().find(|n| n.fqn == edge.to)?; + Some((from, to)) +} + +/// A panic-proof fallback mirroring [`fallback_svg`]: each node at its own +/// scene-assigned rect, straight centre-to-centre edges, and the boundary frame +/// from the boundary node's rect. Used for an empty view or a layout panic. +fn fallback_layout(scene: &C4Scene, boundary: Option<&str>) -> C4Layout { + let pad = 20; + let extent = + |f: fn(&Rect) -> i32| scene.nodes.iter().map(|n| f(&n.rect)).max().unwrap_or(0) + pad; + let width = extent(|r| r.x + r.w).max(pad); + let height = extent(|r| r.y + r.h).max(pad); + + let nodes = scene + .nodes + .iter() + .filter(|n| Some(n.fqn.as_str()) != boundary) + .map(|n| laid_out_node(n, n.rect)) + .collect(); + + let edges = scene + .edges + .iter() + .filter_map(|e| { + let (from, to) = view_edge_endpoints(scene, e, boundary)?; + Some(LaidOutEdge { + from: e.from.clone(), + to: e.to.clone(), + kind: e.kind, + label: e.label.clone(), + points: vec![rect_centre(&from.rect), rect_centre(&to.rect)], + label_pos: None, + dashed: matches!(e.kind, C4EdgeKind::Provenance), + }) + }) + .collect(); + + let boundary = boundary + .and_then(|of| boundary_frame(scene, of, scene.nodes.iter().find(|n| n.fqn == of)?.rect)); + + C4Layout { + width, + height, + nodes, + edges, + boundary, + } +} + /// Builds the layout graph (boundary framed out, cycles broken) and captures the /// engine's placement + edge routing. Each node box carries an empty engine /// label (so the engine draws no centred text) and its FQN as `properties` (so @@ -252,14 +610,9 @@ fn fallback_svg(scene: &C4Scene, boundary: Option<&str>) -> String { ); } for edge in &scene.edges { - let from = scene.nodes.iter().find(|n| n.fqn == edge.from); - let to = scene.nodes.iter().find(|n| n.fqn == edge.to); - let (Some(from), Some(to)) = (from, to) else { + let Some((from, to)) = view_edge_endpoints(scene, edge, boundary) else { continue; }; - if Some(from.fqn.as_str()) == boundary || Some(to.fqn.as_str()) == boundary { - continue; - } let _ = write!( out, ") -> String { - let by_fqn: HashMap<&str, &PlacedNode> = - scene.nodes.iter().map(|n| (n.fqn.as_str(), n)).collect(); - +/// The boundary frame (padded child bbox + title) and the document extent +/// (`w`, `h`) for a captured layout — the geometry both the SVG emitter and the +/// [`C4Layout`] export derive identically, so the two never drift. The extent +/// covers the captured content plus any boundary frame. +fn frame_and_extent<'a>( + capture: &Capture, + scene: &'a C4Scene, + boundary: Option<&str>, +) -> (Option<(Point, Point, &'a str)>, i32, i32) { let boundary_frame = boundary.and_then(|of| { let title = boundary_title(scene, of)?; // Frame only the boundary's own children, never the external actors the @@ -548,14 +904,21 @@ fn emit_svg(capture: &Capture, scene: &C4Scene, boundary: Option<&str>) -> Strin )) }); - // The document extent covers the content plus any boundary frame. let (_, mut max) = content_bbox(capture); if let Some((_, frame_max, _)) = &boundary_frame { max.x = max.x.max(frame_max.x); max.y = max.y.max(frame_max.y); } - let w = round(max.x + MARGIN); - let h = round(max.y + MARGIN); + (boundary_frame, round(max.x + MARGIN), round(max.y + MARGIN)) +} + +/// Re-emits captured geometry as a self-contained SVG, framing the boundary +/// children when the view has an `of`. +fn emit_svg(capture: &Capture, scene: &C4Scene, boundary: Option<&str>) -> String { + let by_fqn: HashMap<&str, &PlacedNode> = + scene.nodes.iter().map(|n| (n.fqn.as_str(), n)).collect(); + + let (boundary_frame, w, h) = frame_and_extent(capture, scene, boundary); let mut out = String::new(); svg_open(&mut out, w, h); @@ -984,4 +1347,110 @@ mod tests { let lines = wrap_summary("a short note", 300); assert_eq!(lines, vec!["a short note".to_owned()]); } + + // --- layout_c4_scene -------------------------------------------------- + + /// A container view: a system boundary with two component-style children and + /// a call between them. + fn container_scene() -> C4Scene { + let child = |fqn: &str, label: &str| PlacedNode { + boundary: Some("m::Sys".to_owned()), + ..placed(fqn, NodeKind::Container, label, None) + }; + C4Scene { + view: C4View::Container, + of: Some("m::Sys".to_owned()), + nodes: vec![ + placed("m::Sys", NodeKind::System, "Sys", None), + child("m::Sys::Web", "Web"), + child("m::Sys::Api", "Api"), + ], + edges: vec![RoutedEdge { + from: "m::Sys::Web".to_owned(), + to: "m::Sys::Api".to_owned(), + kind: C4EdgeKind::Call, + label: "calls".to_owned(), + }], + } + } + + #[test] + fn layout_c4_scene_positions_every_node() { + let layout = layout_c4_scene(&context_scene()); + assert_eq!(layout.nodes.len(), 2, "both nodes placed: {layout:?}"); + assert!(layout.width > 0 && layout.height > 0, "canvas sized"); + for node in &layout.nodes { + assert!(node.rect.w > 0 && node.rect.h > 0, "card sized: {node:?}"); + } + } + + #[test] + fn layout_c4_scene_is_deterministic() { + let scene = context_scene(); + assert_eq!(layout_c4_scene(&scene), layout_c4_scene(&scene)); + } + + #[test] + fn layout_c4_scene_edges_carry_points_and_kind() { + let layout = layout_c4_scene(&context_scene()); + let edge = layout.edges.first().expect("the A->B edge is laid out"); + assert_eq!((edge.from.as_str(), edge.to.as_str()), ("m::A", "m::B")); + assert_eq!(edge.kind, C4EdgeKind::Call); + assert_eq!(edge.label, "uses"); + assert!(edge.points.len() >= 2, "routed polyline: {edge:?}"); + } + + #[test] + fn layout_c4_scene_frames_a_container_view() { + let layout = layout_c4_scene(&container_scene()); + let frame = layout + .boundary + .expect("container view has a boundary frame"); + assert_eq!(frame.title, "Sys"); + // The frame encloses the two children, which are the only laid-out cards. + assert_eq!(layout.nodes.len(), 2, "boundary itself is not a card"); + for node in &layout.nodes { + assert!( + node.rect.x >= frame.rect.x, + "child inside frame x: {node:?}" + ); + assert!( + node.rect.y >= frame.rect.y, + "child inside frame y: {node:?}" + ); + } + } + + #[test] + fn layout_c4_scene_cyclic_graph_no_panic() { + let mut scene = context_scene(); + scene.edges.push(RoutedEdge { + from: "m::B".to_owned(), + to: "m::A".to_owned(), + kind: C4EdgeKind::Call, + label: "back".to_owned(), + }); + let layout = layout_c4_scene(&scene); + assert_eq!(layout.nodes.len(), 2); + assert_eq!(layout_c4_scene(&scene), layout); + } + + /// An empty view (only the framed boundary, no children) falls back without + /// panicking and produces no cards. + #[test] + fn layout_c4_scene_empty_view_falls_back() { + let scene = C4Scene { + view: C4View::Container, + of: Some("m::Sys".to_owned()), + nodes: vec![placed("m::Sys", NodeKind::System, "Sys", None)], + edges: Vec::new(), + }; + let layout = layout_c4_scene(&scene); + assert!(layout.nodes.is_empty(), "no children to draw"); + assert_eq!( + layout.boundary.map(|b| b.title), + Some("Sys".to_owned()), + "fallback still frames the boundary" + ); + } } diff --git a/crates/pseudoscript-emit/src/lib.rs b/crates/pseudoscript-emit/src/lib.rs index 9fe291a..24a9f35 100644 --- a/crates/pseudoscript-emit/src/lib.rs +++ b/crates/pseudoscript-emit/src/lib.rs @@ -40,6 +40,7 @@ mod project; mod render; mod scene; +pub use c4_render::{BoundaryFrame, C4Layout, LaidOutEdge, LaidOutNode, PointI, layout_c4_scene}; pub use project::{EmitError, View, project, project_symbol}; pub use render::{Theme, layout_sequence_scene, render_svg, render_svg_themed}; pub use scene::{ diff --git a/crates/pseudoscript-emit/src/scene.rs b/crates/pseudoscript-emit/src/scene.rs index 1d6455e..8ab4f0f 100644 --- a/crates/pseudoscript-emit/src/scene.rs +++ b/crates/pseudoscript-emit/src/scene.rs @@ -49,7 +49,10 @@ impl C4View { /// A laid-out C4 view: an ordered set of placed nodes and routed edges. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct C4Scene { - /// Which C4 view this is. + /// Which C4 view this is. Serialised as `c4view`, not `view`: the [`Scene`] + /// enum is internally tagged on `view` (`c4`/`sequence`), so the inner field + /// must not reuse that key or a round-trip hits a duplicate-`view` error. + #[serde(rename = "c4view")] pub view: C4View, /// The view's boundary node FQN (`of`): the system for a container view, the /// container for a component view. `None` for context. diff --git a/crates/pseudoscript-wasm/src/lib.rs b/crates/pseudoscript-wasm/src/lib.rs index d375d73..d15e86f 100644 --- a/crates/pseudoscript-wasm/src/lib.rs +++ b/crates/pseudoscript-wasm/src/lib.rs @@ -40,7 +40,8 @@ use pseudoscript_doc::{ try_render_site_with, }; use pseudoscript_emit::{ - Scene, View, graph_of_source, layout_sequence_scene, project, project_symbol, render_svg, + Scene, View, graph_of_source, layout_c4_scene, layout_sequence_scene, project, project_symbol, + render_svg, }; use pseudoscript_format::format as format_source; use pseudoscript_model::{ @@ -329,14 +330,16 @@ pub fn symbol_svg(modules_json: &str, fqn: &str) -> Result { symbol_svg_impl(modules_json, fqn).map_err(|e| JsError::new(&e)) } -/// Positions a sequence [`Scene`] (as JSON) into absolute coordinates, returning -/// the layout as JSON. The host collapses the scene to a chosen depth first, -/// then hands it here; the layout engine owns all geometry. A non-sequence scene -/// is an error. +/// Positions a [`Scene`] (as JSON) into absolute coordinates, returning the +/// layout as JSON. The layout engine owns all geometry: a sequence scene yields +/// a positioned sequence layout (the host collapses it to a chosen depth first); +/// a C4 scene yields a [`pseudoscript_emit::C4Layout`] (placed cards + routed +/// edges + boundary frame), the same geometry the SVG draws. The two layout +/// shapes are distinguishable by their fields (`participants` vs `nodes`). /// /// # Errors /// -/// Returns an error for invalid JSON or a non-sequence scene. +/// Returns an error for invalid JSON. #[wasm_bindgen] pub fn layout_scene(scene_json: &str) -> Result { layout_scene_impl(scene_json).map_err(|e| JsError::new(&e)) @@ -752,7 +755,7 @@ fn layout_scene_impl(scene_json: &str) -> Result { let scene: Scene = serde_json::from_str(scene_json).map_err(|e| e.to_string())?; match scene { Scene::Sequence(seq) => Ok(to_json(&layout_sequence_scene(&seq))), - Scene::C4(_) => Err("layout_scene expects a sequence scene".to_owned()), + Scene::C4(c4) => Ok(to_json(&layout_c4_scene(&c4))), } } @@ -1283,6 +1286,18 @@ mod tests { assert!(out.contains("m::F"), "{out}"); } + #[test] + fn layout_scene_lays_out_a_c4_scene() { + // The exact IDE path: a projected C4 scene serialised to JSON + // (`emit_scene_modules`) and handed back to `layout_scene`. It must + // positionally lay out (placed cards), not error as it once did. + let scene = project_view("//! m\npublic person P;\npublic system S;", "context", "") + .expect("context view projects"); + let out = layout_scene_impl(&to_json(&scene)).expect("c4 scene lays out"); + assert!(out.contains(r#""nodes""#), "C4 layout carries nodes: {out}"); + assert!(out.contains("m::P") && out.contains("m::S"), "{out}"); + } + #[test] fn layout_scene_rejects_a_scene_missing_the_view_tag() { // A scene object without the `view` discriminant cannot deserialize into diff --git a/web-ide/package-lock.json b/web-ide/package-lock.json index b15b92a..59c70bb 100644 --- a/web-ide/package-lock.json +++ b/web-ide/package-lock.json @@ -18,7 +18,6 @@ "@codemirror/search": "^6.7.0", "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.43.0", - "@dagrejs/dagre": "3.0.0", "@lezer/highlight": "^1.2.3", "@lezer/markdown": "^1.6.4", "@xyflow/svelte": "1.5.2", @@ -853,21 +852,6 @@ "node": ">=20.19.0" } }, - "node_modules/@dagrejs/dagre": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-3.0.0.tgz", - "integrity": "sha512-ZzhnTy1rfuoew9Ez3EIw4L2znPGnYYhfn8vc9c4oB8iw6QAsszbiU0vRhlxWPFnmmNSFAkrYeF1PhM5m4lAN0Q==", - "license": "MIT", - "dependencies": { - "@dagrejs/graphlib": "4.0.1" - } - }, - "node_modules/@dagrejs/graphlib": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@dagrejs/graphlib/-/graphlib-4.0.1.tgz", - "integrity": "sha512-IvcV6FduIIAmLwnH+yun+QtV36SC7mERqa86aClNqmMN09WhmPPYU8ckHrZBozErf+UvHPWOTJYaGYiIcs0DgA==", - "license": "MIT" - }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", diff --git a/web-ide/package.json b/web-ide/package.json index 03de90f..1fd3e83 100644 --- a/web-ide/package.json +++ b/web-ide/package.json @@ -53,7 +53,6 @@ "@codemirror/search": "^6.7.0", "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.43.0", - "@dagrejs/dagre": "3.0.0", "@lezer/highlight": "^1.2.3", "@lezer/markdown": "^1.6.4", "@xyflow/svelte": "1.5.2", diff --git a/web-ide/src/app.css b/web-ide/src/app.css index 016345e..bcf69c3 100644 --- a/web-ide/src/app.css +++ b/web-ide/src/app.css @@ -337,9 +337,14 @@ button { font: inherit; cursor: pointer; color: inherit; } .svelte-flow__edge-text { fill: var(--ink-soft); font-family: var(--font-mono); font-size: 10px; } .svelte-flow__edge-textbg { fill: var(--surface); } -/* kind-coloured card nodes (shared by C4 graph + timeline) */ +/* kind-coloured card nodes (shared by C4 graph + timeline). The C4 graph sizes + each card to the layout engine's rect (width/height set on the node), so the + box honours those dimensions and clips overflow; the timeline's lifeline head + cards (width unset) size to content. */ .svelte-flow__node.c4-node { width: auto; + box-sizing: border-box; + overflow: hidden; padding: 0.55rem 0.9rem; font-family: var(--font-mono); font-size: 0.8rem; diff --git a/web-ide/src/lib/components/C4Flow.svelte b/web-ide/src/lib/components/C4Flow.svelte index 37c6f9b..f99f0b5 100644 --- a/web-ide/src/lib/components/C4Flow.svelte +++ b/web-ide/src/lib/components/C4Flow.svelte @@ -1,50 +1,45 @@ - - -{#snippet row(id: string, label: string, value: string, options: readonly { id: string; label: string }[], onpick: (v: string) => void, disabled = false)} -
- - -
-{/snippet} - - - - - - - Customise diagram - Layout and edges. Saved across sessions. - - -
- {@render row("cs-algo", "Algorithm", canvasPrefs.algorithm, ALGORITHMS, (v) => canvasPrefs.setAlgorithm(v as LayoutAlgo))} - {@render row("cs-dir", "Direction", canvasPrefs.layout, LAYOUTS, (v) => canvasPrefs.setLayout(v as LayoutDir), !directional)} - {@render row("cs-edge", "Lines", canvasPrefs.edge, EDGE_STYLES, (v) => canvasPrefs.setEdge(v as EdgeStyle))} -
-
-
- - diff --git a/web-ide/src/lib/components/DiagramPane.svelte b/web-ide/src/lib/components/DiagramPane.svelte index 9f44ee1..285ddb3 100644 --- a/web-ide/src/lib/components/DiagramPane.svelte +++ b/web-ide/src/lib/components/DiagramPane.svelte @@ -58,8 +58,9 @@ const hasC4 = $derived(!!scene && Array.isArray(scene.nodes) && scene.nodes.length > 0); const hasFlow = $derived(isFlow && (scene?.participants?.length ?? 0) > 0); const ready = $derived(isFlow ? hasFlow : hasC4); - // Remount the flow when the rendered content changes so the view resets. - const sig = $derived(isFlow ? JSON.stringify(layout) : scene ? JSON.stringify(scene) : ""); + // Remount the flow when the rendered content changes so the view resets. Both + // kinds are now positioned by the layout engine, so key off the layout. + const sig = $derived(layout ? JSON.stringify(layout) : scene ? JSON.stringify(scene) : "");
@@ -77,7 +78,7 @@
{/if} {#key sig} - {#if isFlow}["scene"]} layout={layout as ComponentProps["layout"]} {onusages} {onsource} {typeFqn} />{:else}["scene"]} {onpick} {onup} {flows} {onsource} {onusages} />{/if} + {#if isFlow}["scene"]} layout={layout as ComponentProps["layout"]} {onusages} {onsource} {typeFqn} />{:else}["scene"]} layout={layout as ComponentProps["layout"]} {onpick} {onup} {flows} {onsource} {onusages} />{/if} {/key} {:else}
diff --git a/web-ide/src/lib/components/FloatingEdge.svelte b/web-ide/src/lib/components/FloatingEdge.svelte deleted file mode 100644 index 6d615fd..0000000 --- a/web-ide/src/lib/components/FloatingEdge.svelte +++ /dev/null @@ -1,43 +0,0 @@ - - -{#if geom} - -{/if} diff --git a/web-ide/src/lib/components/PolylineEdge.svelte b/web-ide/src/lib/components/PolylineEdge.svelte new file mode 100644 index 0000000..d3252c1 --- /dev/null +++ b/web-ide/src/lib/components/PolylineEdge.svelte @@ -0,0 +1,35 @@ + + +{#if geom} + +{/if} diff --git a/web-ide/src/lib/core/canvas.test.ts b/web-ide/src/lib/core/canvas.test.ts index 118c059..e15cf13 100644 --- a/web-ide/src/lib/core/canvas.test.ts +++ b/web-ide/src/lib/core/canvas.test.ts @@ -26,10 +26,11 @@ describe("projectCanvas", () => { expect((r.layout as { laid?: boolean })?.laid).toBe(true); }); - it("projects the context overview with no selection (no layout)", () => { + it("projects and lays out the context overview with no selection", () => { const r = projectCanvas({ selected: null, seqDepth: "component", modules: [], index: idx, wasm: wasm(), onError: () => {} }); expect((r.scene as { nodes: unknown[] }).nodes).toHaveLength(1); - expect(r.layout).toBeNull(); + // The C4 context is positioned by the layout engine too (not just sequences). + expect((r.layout as { laid?: boolean })?.laid).toBe(true); }); it("falls back to a lifeline when a selected sequence is empty", () => { diff --git a/web-ide/src/lib/core/canvas.ts b/web-ide/src/lib/core/canvas.ts index 492783f..a1d9f78 100644 --- a/web-ide/src/lib/core/canvas.ts +++ b/web-ide/src/lib/core/canvas.ts @@ -28,10 +28,10 @@ export type ProjectCanvasArgs = { /** * Project the canvas scene + layout. A selected symbol projects its fitting view; - * no selection projects the context overview. A sequence is collapsed to `seqDepth` - * and positioned by the layout engine; C4 stays as-is. An empty or unprojectable - * selected symbol falls back to its single lifeline; an unprojectable context is an - * error. Both error paths report via `onError`. + * no selection projects the context overview. A sequence is collapsed to `seqDepth`; + * both kinds are then positioned by the Rust layout engine. An empty or + * unprojectable selected symbol falls back to its single lifeline; an + * unprojectable context is an error. Both error paths report via `onError`. */ export function projectCanvas(args: ProjectCanvasArgs): Canvas { const { selected, seqDepth, modules, index, wasm, onError } = args; @@ -47,7 +47,10 @@ export function projectCanvas(args: ProjectCanvasArgs): Canvas { ? !(shown?.participants as unknown[] | undefined)?.length : !(shown?.nodes as unknown[] | undefined)?.length; if (isEmpty && selected) return lifelineFallback(); - const layout = isSeq && shown ? wasm.layoutScene(shown) : null; + // Both kinds are positioned by the Rust layout engine: a sequence yields a + // positioned timeline, a C4 scene yields placed cards + routed edges (the + // same geometry the static SVG draws). + const layout = shown ? wasm.layoutScene(shown) : null; return { scene: shown, layout, error: "" }; } catch (e) { const detail = String((e as Error)?.message ?? e); diff --git a/web-ide/src/lib/floating-edge.ts b/web-ide/src/lib/floating-edge.ts deleted file mode 100644 index 9b9b987..0000000 --- a/web-ide/src/lib/floating-edge.ts +++ /dev/null @@ -1,67 +0,0 @@ -// Floating-edge geometry: anchor an edge at the point where the straight line -// between two node centres crosses each node's border, rather than at a fixed -// handle. This gives the shortest visible connection and lets edges leave a card -// from whichever side faces the other node. Ported from Svelte Flow's floating- -// edges example, adapted to absolute node positions (parent offsets included). - -import { Position, type InternalNode } from "@xyflow/svelte"; - -type Rect = { x: number; y: number; w: number; h: number }; - -function rect(node: InternalNode): Rect { - return { - x: node.internals.positionAbsolute.x, - y: node.internals.positionAbsolute.y, - w: node.measured.width ?? 0, - h: node.measured.height ?? 0, - }; -} - -// Where the segment from `node`'s centre toward `other`'s centre meets node's -// border. Projects the centre-to-centre direction into the rectangle's -// normalised diagonal space, clamps it to the unit diamond (so it lands on an -// edge), then maps back — Svelte Flow's floating-edges intersection. -function intersection(node: Rect, other: Rect): { x: number; y: number } { - const halfW = node.w / 2; - const halfH = node.h / 2; - const cx = node.x + halfW; - const cy = node.y + halfH; - const ox = other.x + other.w / 2; - const oy = other.y + other.h / 2; - - // Direction to the other centre, rotated into the rectangle's diagonal axes. - const diagA = (ox - cx) / (2 * halfW) - (oy - cy) / (2 * halfH); - const diagB = (ox - cx) / (2 * halfW) + (oy - cy) / (2 * halfH); - const clamp = 1 / (Math.abs(diagA) + Math.abs(diagB) || 1); - const unitA = clamp * diagA; - const unitB = clamp * diagB; - return { x: halfW * (unitA + unitB) + cx, y: halfH * (-unitA + unitB) + cy }; -} - -// Which border the intersection sits on — drives the path's entry/exit direction. -function side(node: Rect, p: { x: number; y: number }): Position { - const nx = Math.round(node.x); - const ny = Math.round(node.y); - const px = Math.round(p.x); - const py = Math.round(p.y); - if (px <= nx + 1) return Position.Left; - if (px >= nx + node.w - 1) return Position.Right; - if (py <= ny + 1) return Position.Top; - return Position.Bottom; -} - -// The floating endpoints + their borders for a source→target pair. -export function getEdgeParams(source: InternalNode, target: InternalNode) { - const s = rect(source); - const t = rect(target); - const sp = intersection(s, t); - const tp = intersection(t, s); - return { - sx: sp.x, - sy: sp.y, - tx: tp.x, - ty: tp.y, - sourcePos: side(s, sp), - targetPos: side(t, tp), - }; -} diff --git a/web-ide/src/lib/pds-wasm/pseudoscript_wasm.d.ts b/web-ide/src/lib/pds-wasm/pseudoscript_wasm.d.ts index abca1b6..d47e7c4 100644 --- a/web-ide/src/lib/pds-wasm/pseudoscript_wasm.d.ts +++ b/web-ide/src/lib/pds-wasm/pseudoscript_wasm.d.ts @@ -141,14 +141,16 @@ export function format(source: string): string; export function hover(modules_json: string, module_fqn: string, offset: number): string; /** - * Positions a sequence [`Scene`] (as JSON) into absolute coordinates, returning - * the layout as JSON. The host collapses the scene to a chosen depth first, - * then hands it here; the layout engine owns all geometry. A non-sequence scene - * is an error. + * Positions a [`Scene`] (as JSON) into absolute coordinates, returning the + * layout as JSON. The layout engine owns all geometry: a sequence scene yields + * a positioned sequence layout (the host collapses it to a chosen depth first); + * a C4 scene yields a [`pseudoscript_emit::C4Layout`] (placed cards + routed + * edges + boundary frame), the same geometry the SVG draws. The two layout + * shapes are distinguishable by their fields (`participants` vs `nodes`). * * # Errors * - * Returns an error for invalid JSON or a non-sequence scene. + * Returns an error for invalid JSON. */ export function layout_scene(scene_json: string): string; diff --git a/web-ide/src/lib/pds-wasm/pseudoscript_wasm.js b/web-ide/src/lib/pds-wasm/pseudoscript_wasm.js index f97ab8c..c4c3b56 100644 --- a/web-ide/src/lib/pds-wasm/pseudoscript_wasm.js +++ b/web-ide/src/lib/pds-wasm/pseudoscript_wasm.js @@ -401,14 +401,16 @@ export function hover(modules_json, module_fqn, offset) { } /** - * Positions a sequence [`Scene`] (as JSON) into absolute coordinates, returning - * the layout as JSON. The host collapses the scene to a chosen depth first, - * then hands it here; the layout engine owns all geometry. A non-sequence scene - * is an error. + * Positions a [`Scene`] (as JSON) into absolute coordinates, returning the + * layout as JSON. The layout engine owns all geometry: a sequence scene yields + * a positioned sequence layout (the host collapses it to a chosen depth first); + * a C4 scene yields a [`pseudoscript_emit::C4Layout`] (placed cards + routed + * edges + boundary frame), the same geometry the SVG draws. The two layout + * shapes are distinguishable by their fields (`participants` vs `nodes`). * * # Errors * - * Returns an error for invalid JSON or a non-sequence scene. + * Returns an error for invalid JSON. * @param {string} scene_json * @returns {string} */ diff --git a/web-ide/src/lib/pds-wasm/pseudoscript_wasm_bg.wasm b/web-ide/src/lib/pds-wasm/pseudoscript_wasm_bg.wasm index 7e8b21b..ab72555 100644 Binary files a/web-ide/src/lib/pds-wasm/pseudoscript_wasm_bg.wasm and b/web-ide/src/lib/pds-wasm/pseudoscript_wasm_bg.wasm differ diff --git a/web-ide/src/lib/stores/canvas-prefs.svelte.ts b/web-ide/src/lib/stores/canvas-prefs.svelte.ts deleted file mode 100644 index 80d753a..0000000 --- a/web-ide/src/lib/stores/canvas-prefs.svelte.ts +++ /dev/null @@ -1,98 +0,0 @@ -// Canvas rendering preferences — a reactive rune store, persisted to localStorage. -// -// Owns the C4 diagram's layout algorithm, its flow direction, and its edge line -// style. All three are saved across sessions and edited from the canvas's -// "Customise" modal. C4Flow reads these and re-projects when any of them change. - -// The placement algorithm for the flat (peer) view. "layered" is dagre's -// hierarchical layout (the only one that honours `direction`); the others are -// geometric and ignore direction. -export type LayoutAlgo = "layered" | "grid" | "circular" | "radial"; -// The dagre rank direction a layered graph flows along. -export type LayoutDir = "TB" | "LR" | "BT" | "RL"; -// The edge routing style (mapped to a Svelte Flow built-in edge type). -export type EdgeStyle = "smoothstep" | "bezier" | "straight" | "step"; - -// Algorithm options, in menu order. `directional` flags the ones that use the -// direction setting, so the modal can disable the direction control otherwise. -export const ALGORITHMS: readonly { id: LayoutAlgo; label: string; directional: boolean }[] = [ - { id: "layered", label: "Layered", directional: true }, - { id: "grid", label: "Grid", directional: false }, - { id: "circular", label: "Circular", directional: false }, - { id: "radial", label: "Radial", directional: false }, -]; -// Direction options, in menu order. -export const LAYOUTS: readonly { id: LayoutDir; label: string }[] = [ - { id: "TB", label: "Top → bottom" }, - { id: "LR", label: "Left → right" }, - { id: "BT", label: "Bottom → top" }, - { id: "RL", label: "Right → left" }, -]; -// Line-style options, in menu order. -export const EDGE_STYLES: readonly { id: EdgeStyle; label: string }[] = [ - { id: "smoothstep", label: "Smooth step" }, - { id: "bezier", label: "Bezier" }, - { id: "straight", label: "Straight" }, - { id: "step", label: "Step" }, -]; - -const ALGO_KEY = "pds-canvas-algo"; -const LAYOUT_KEY = "pds-canvas-layout"; -const EDGE_KEY = "pds-canvas-edge"; - -const isAlgo = (v: string | null): v is LayoutAlgo => ALGORITHMS.some((a) => a.id === v); -const isLayout = (v: string | null): v is LayoutDir => LAYOUTS.some((l) => l.id === v); -const isEdge = (v: string | null): v is EdgeStyle => EDGE_STYLES.some((e) => e.id === v); - -function load(key: string, guard: (v: string | null) => v is T, fallback: T): T { - try { - const v = localStorage.getItem(key); - return guard(v) ? v : fallback; - } catch { - return fallback; - } -} - -function save(key: string, value: string): void { - try { - localStorage.setItem(key, value); - } catch { - /* private mode / quota — applies this session only */ - } -} - -/** Whether an algorithm honours the direction setting. */ -export function isDirectional(algo: LayoutAlgo): boolean { - return ALGORITHMS.find((a) => a.id === algo)?.directional ?? false; -} - -class CanvasPrefs { - // The flat-view placement algorithm (persisted). - algorithm = $state(load(ALGO_KEY, isAlgo, "layered")); - // The layered graph's flow direction (persisted). - layout = $state(load(LAYOUT_KEY, isLayout, "TB")); - // The edge line style (persisted). - edge = $state(load(EDGE_KEY, isEdge, "smoothstep")); - - /** The Svelte Flow edge `type` for the chosen style — bezier is the built-in "default". */ - get edgeType(): string { - return this.edge === "bezier" ? "default" : this.edge; - } - - setAlgorithm(algo: LayoutAlgo): void { - this.algorithm = algo; - save(ALGO_KEY, algo); - } - - setLayout(dir: LayoutDir): void { - this.layout = dir; - save(LAYOUT_KEY, dir); - } - - setEdge(style: EdgeStyle): void { - this.edge = style; - save(EDGE_KEY, style); - } -} - -export const canvasPrefs = new CanvasPrefs();