Skip to main content

facett_graphview/
model.rs

1//! The **domain-agnostic graph model** — the same shape nornir's
2//! `src/viz/graph_render.rs::draw_graph` accepts, so this engine is a future
3//! drop-in for that egui routine. Nodes are laid out in *world space* by the
4//! caller (each domain owns its own layout/columns); edges reference nodes by
5//! id. [`Decorations`] is the **caller-supplied** overlay — facett mustn't know
6//! what a "release gate" is, so rings/badges/emphasis-edges are opaque here.
7//!
8//! Colours are a tiny POD [`Color`] (premultiply-free sRGBA8) with a `From`
9//! conversion off egui's `Color32`, so a caller already speaking egui (exactly
10//! nornir's `draw_graph`) maps straight across with zero glue.
11
12use serde::{Deserialize, Serialize};
13
14/// A straight sRGBA8 colour (NOT premultiplied). The render backends convert to
15/// their own colour type at the seam.
16#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
17pub struct Color {
18    pub r: u8,
19    pub g: u8,
20    pub b: u8,
21    pub a: u8,
22}
23
24impl Color {
25    pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
26        Self { r, g, b, a: 255 }
27    }
28    pub const fn rgba(r: u8, g: u8, b: u8, a: u8) -> Self {
29        Self { r, g, b, a }
30    }
31    pub const TRANSPARENT: Color = Color::rgba(0, 0, 0, 0);
32    pub const WHITE: Color = Color::rgb(255, 255, 255);
33    pub const GRAY: Color = Color::rgb(128, 128, 128);
34
35    /// Dim toward transparency (the off-trace fade — mirrors `graph_render::dim`).
36    pub fn dim(self, k: f32) -> Color {
37        Color::rgba(self.r, self.g, self.b, (self.a as f32 * k) as u8)
38    }
39}
40
41impl From<egui::Color32> for Color {
42    fn from(c: egui::Color32) -> Self {
43        let [r, g, b, a] = c.to_srgba_unmultiplied();
44        Color { r, g, b, a }
45    }
46}
47
48impl From<Color> for egui::Color32 {
49    fn from(c: Color) -> Self {
50        egui::Color32::from_rgba_unmultiplied(c.r, c.g, c.b, c.a)
51    }
52}
53
54/// A 2D point in world space (caller-owned layout coordinates).
55#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
56pub struct Pos {
57    pub x: f32,
58    pub y: f32,
59}
60
61impl Pos {
62    pub const fn new(x: f32, y: f32) -> Self {
63        Self { x, y }
64    }
65}
66
67/// Default node chip size in world units (matches nornir's arch board:
68/// `BOX_W`/`BOX_H`), before any pan/zoom transform.
69pub const BOX_W: f32 = 184.0;
70pub const BOX_H: f32 = 34.0;
71
72/// One laid-out node: stable id, label, chip fill + stroke (the *kind* colour,
73/// caller's choice), and its world-space centre.
74#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
75pub struct GraphNode {
76    pub id: String,
77    pub label: String,
78    pub fill: Color,
79    pub stroke: Color,
80    pub pos: Pos,
81}
82
83/// One edge: endpoints by node id, colour, a `dashed` flag, and an optional
84/// mid-edge label (a cut rationale).
85#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
86pub struct GraphEdge {
87    pub from: String,
88    pub to: String,
89    pub color: Color,
90    pub dashed: bool,
91    pub label: Option<String>,
92}
93
94/// The full graph to paint — nodes (laid out) + edges.
95#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
96pub struct GraphModel {
97    pub nodes: Vec<GraphNode>,
98    pub edges: Vec<GraphEdge>,
99}
100
101impl GraphModel {
102    /// World-space bounding box of all node chips `(min_x, min_y, max_x, max_y)`,
103    /// chip extents included. Used to fit the camera. `None` if empty.
104    pub fn world_bounds(&self) -> Option<(f32, f32, f32, f32)> {
105        let mut it = self.nodes.iter();
106        let first = it.next()?;
107        let (hw, hh) = (BOX_W * 0.5, BOX_H * 0.5);
108        let mut b = (first.pos.x - hw, first.pos.y - hh, first.pos.x + hw, first.pos.y + hh);
109        for n in it {
110            b.0 = b.0.min(n.pos.x - hw);
111            b.1 = b.1.min(n.pos.y - hh);
112            b.2 = b.2.max(n.pos.x + hw);
113            b.3 = b.3.max(n.pos.y + hh);
114        }
115        Some(b)
116    }
117}
118
119/// A per-node decoration layered over the base chip: an optional status **ring**
120/// colour (drawn outside the kind stroke), an optional **badge** (a short glyph/text
121/// at the chip's top-right corner), and an optional **scale** — a multiplier on the
122/// node's marker radius so a caller can size nodes by an importance metric
123/// (population, degree, …) without the engine learning what the metric means. The
124/// L0 lowering reads `scale` (default `1.0`) when it sizes the node marker.
125#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
126pub struct NodeDecoration {
127    pub ring: Option<Color>,
128    pub badge: Option<String>,
129    pub badge_color: Option<Color>,
130    /// Marker-radius multiplier (`None`/`1.0` = the default chip size). Domain-
131    /// agnostic node sizing (e.g. kommun population, node degree).
132    pub scale: Option<f32>,
133}
134
135/// The **caller-supplied** overlay layer (domain-agnostic). Per-node rings/badges
136/// keyed by node id, plus extra emphasis edges painted on top (e.g. a cycle's
137/// suggested cut). Empty = no overlay.
138#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
139pub struct Decorations {
140    /// node id -> its ring/badge overlay.
141    pub nodes: std::collections::HashMap<String, NodeDecoration>,
142    /// Extra emphasis edges painted ON TOP of the base edges (the cut lines).
143    pub edges: Vec<GraphEdge>,
144}
145
146/// The camera transform: pan + zoom over the world. World point `p` projects to
147/// screen as `center + pan + p * zoom` (the same affine nornir's `draw_graph`
148/// applies). Owned by the caller so navigation survives across frames.
149#[derive(Clone, Copy, Debug)]
150pub struct Camera {
151    pub pan_x: f32,
152    pub pan_y: f32,
153    pub zoom: f32,
154}
155
156impl Default for Camera {
157    fn default() -> Self {
158        Self { pan_x: 0.0, pan_y: 0.0, zoom: 1.0 }
159    }
160}
161
162impl Camera {
163    /// A camera that fits `model`'s world bounds into a `w × h` viewport with a
164    /// margin. The "⊙ fit" button, computed once instead of by trial pan/zoom.
165    pub fn fit(model: &GraphModel, w: f32, h: f32, margin: f32) -> Camera {
166        let Some((min_x, min_y, max_x, max_y)) = model.world_bounds() else {
167            return Camera::default();
168        };
169        let (bw, bh) = ((max_x - min_x).max(1.0), (max_y - min_y).max(1.0));
170        let zoom = ((w - 2.0 * margin) / bw).min((h - 2.0 * margin) / bh).clamp(0.05, 4.0);
171        // Pan so the world-bounds centre lands at the viewport centre.
172        let (cx, cy) = ((min_x + max_x) * 0.5, (min_y + max_y) * 0.5);
173        Camera { pan_x: w * 0.5 - cx * zoom, pan_y: h * 0.5 - cy * zoom, zoom }
174    }
175
176    /// Project a world point to screen pixels.
177    #[inline]
178    pub fn project(&self, p: Pos) -> (f32, f32) {
179        (p.x * self.zoom + self.pan_x, p.y * self.zoom + self.pan_y)
180    }
181}