Skip to main content

graph_explorer_style/
lib.rs

1use serde::Deserialize;
2use serde_json::Value;
3use std::collections::HashMap;
4
5pub type Attrs = HashMap<String, Value>;
6
7pub mod anim;
8pub mod interner;
9pub mod scene;
10pub use anim::*;
11pub use interner::{Interner, LabelInterner, NodeIndex, LabelId, IdKind, EMPTY_LABEL};
12pub use scene::*;
13
14/// RGBA in 0..1. Deserializes from a hex string "#rrggbb" or "#rrggbbaa".
15#[derive(Debug, Clone, Copy, PartialEq)]
16pub struct Rgba(pub [f32; 4]);
17
18impl Rgba {
19    pub fn from_hex(s: &str) -> Result<Rgba, String> {
20        let h = s.strip_prefix('#').ok_or_else(|| format!("missing #: {s}"))?;
21        if !h.is_ascii() { return Err(format!("hex must be ASCII: {s}")); }
22        let n = |i: usize| u8::from_str_radix(&h[i..i + 2], 16).map(|v| v as f32 / 255.0);
23        let px = |i: usize| n(i).map_err(|e| format!("bad hex {s}: {e}"));
24        match h.len() {
25            6 => Ok(Rgba([px(0)?, px(2)?, px(4)?, 1.0])),
26            8 => Ok(Rgba([px(0)?, px(2)?, px(4)?, px(6)?])),
27            _ => Err(format!("hex must be 6 or 8 digits: {s}")),
28        }
29    }
30}
31
32impl<'de> Deserialize<'de> for Rgba {
33    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
34        let s = String::deserialize(d)?;
35        Rgba::from_hex(&s).map_err(serde::de::Error::custom)
36    }
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Default, Deserialize)]
40#[serde(rename_all = "lowercase")]
41pub enum Shape { #[default] Circle, Square, Diamond }
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum NodeRole { Normal, Focus, Selected, History, Candidate, Provisional }
45impl NodeRole {
46    pub fn as_str(&self) -> &'static str {
47        match self { NodeRole::Normal=>"normal", NodeRole::Focus=>"focus", NodeRole::Selected=>"selected", NodeRole::History=>"history", NodeRole::Candidate=>"candidate", NodeRole::Provisional=>"provisional" }
48    }
49}
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum EdgeRole { Normal, HistoryLink, CandidateLink, Provisional }
52impl EdgeRole {
53    pub fn as_str(&self) -> &'static str {
54        match self { EdgeRole::Normal=>"normal", EdgeRole::HistoryLink=>"history", EdgeRole::CandidateLink=>"candidate", EdgeRole::Provisional=>"provisional" }
55    }
56}
57
58#[derive(Debug, Clone, PartialEq)]
59pub struct NodeStyle { pub color: [f32;4], pub radius: f32, pub opacity: f32, pub shape: Shape, pub label_visible: bool, pub label_text: Option<String> }
60#[derive(Debug, Clone, PartialEq)]
61pub struct EdgeStyle { pub color: [f32;4], pub opacity: f32, pub width: f32 }
62
63#[derive(Debug, Clone, Default, Deserialize)]
64#[serde(deny_unknown_fields)]
65pub struct PartialNodeStyle {
66    #[serde(default)] pub color: Option<Rgba>,
67    #[serde(default)] pub radius: Option<f32>,
68    #[serde(default)] pub opacity: Option<f32>,
69    #[serde(default)] pub shape: Option<Shape>,
70    #[serde(default)] pub label_visible: Option<bool>,
71    #[serde(default)] pub label_text: Option<String>,
72}
73#[derive(Debug, Clone, Default, Deserialize)]
74#[serde(deny_unknown_fields)]
75pub struct PartialEdgeStyle {
76    #[serde(default)] pub color: Option<Rgba>,
77    #[serde(default)] pub opacity: Option<f32>,
78    #[serde(default)] pub width: Option<f32>,
79}
80
81#[derive(Debug, Clone, Default, Deserialize)]
82#[serde(deny_unknown_fields)]
83pub struct Selector {
84    #[serde(default)] pub role: Option<String>,
85    #[serde(default)] pub attr: Option<String>,
86    #[serde(default)] pub equals: Option<Value>,
87    #[serde(default)] pub one_of: Option<Vec<Value>>,
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Deserialize)]
91#[serde(rename_all = "lowercase")]
92pub enum ScalableNodeProp { Radius, Opacity }
93#[derive(Debug, Clone, Copy, PartialEq, Deserialize)]
94#[serde(rename_all = "lowercase")]
95pub enum ScalableEdgeProp { Width, Opacity }
96
97#[derive(Debug, Clone, Deserialize)]
98#[serde(deny_unknown_fields)]
99pub struct NodeScale { pub by: String, pub property: ScalableNodeProp, pub domain: [f32;2], pub range: [f32;2] }
100#[derive(Debug, Clone, Deserialize)]
101#[serde(deny_unknown_fields)]
102pub struct EdgeScale { pub by: String, pub property: ScalableEdgeProp, pub domain: [f32;2], pub range: [f32;2] }
103
104/// A rule is a `when` gate plus a `set` (categorical override) and/or a `scale`.
105#[derive(Debug, Clone, Default, Deserialize)]
106#[serde(deny_unknown_fields)]
107pub struct NodeRule { #[serde(default)] pub when: Option<Selector>, #[serde(default)] pub set: Option<PartialNodeStyle>, #[serde(default)] pub scale: Option<NodeScale> }
108#[derive(Debug, Clone, Default, Deserialize)]
109#[serde(deny_unknown_fields)]
110pub struct EdgeRule { #[serde(default)] pub when: Option<Selector>, #[serde(default)] pub set: Option<PartialEdgeStyle>, #[serde(default)] pub scale: Option<EdgeScale> }
111
112#[derive(Debug, Clone, Deserialize)]
113#[serde(deny_unknown_fields)]
114pub struct StyleSpec {
115    pub node_base: NodeBaseSpec,
116    #[serde(default)] pub node_rules: Vec<NodeRule>,
117    pub edge_base: EdgeBaseSpec,
118    #[serde(default)] pub edge_rules: Vec<EdgeRule>,
119    #[serde(default)] pub labels: LabelLodSpec,
120}
121
122/// Label level-of-detail. Both knobs are host-tunable via `set_style`.
123#[derive(Debug, Clone, Copy, Deserialize)]
124#[serde(deny_unknown_fields, default)]
125pub struct LabelLodSpec {
126    /// Zoom gate: a label is a candidate only when its node's on-screen
127    /// radius (world radius × zoom) is at least this many pixels.
128    pub min_screen_radius_px: f32,
129    /// Importance cap: at most this many labels per frame; survivors are the
130    /// top-N by importance = degree, with the current node and its neighbors
131    /// boosted so the user's context never loses its labels.
132    pub max_labels: usize,
133}
134impl Default for LabelLodSpec {
135    fn default() -> Self { Self { min_screen_radius_px: 8.0, max_labels: 200 } }
136}
137
138// Base specs deserialize from JSON with defaults, then convert into the resolved *Style.
139#[derive(Debug, Clone, Deserialize)]
140#[serde(deny_unknown_fields)]
141pub struct NodeBaseSpec { pub color: Rgba, pub radius: f32, #[serde(default = "one")] pub opacity: f32, #[serde(default)] pub shape: Shape, #[serde(default = "yes")] pub label_visible: bool }
142#[derive(Debug, Clone, Deserialize)]
143#[serde(deny_unknown_fields)]
144pub struct EdgeBaseSpec { pub color: Rgba, #[serde(default = "one")] pub opacity: f32, pub width: f32 }
145fn one() -> f32 { 1.0 }
146fn yes() -> bool { true }
147
148impl Selector {
149    fn matches(&self, attrs: &Attrs, role_str: &str) -> bool {
150        if let Some(r) = &self.role { if r != role_str { return false; } }
151        if let Some(a) = &self.attr {
152            let v = attrs.get(a);
153            if let Some(eq) = &self.equals { if v != Some(eq) { return false; } }
154            if let Some(list) = &self.one_of { if !v.is_some_and(|v| list.contains(v)) { return false; } }
155            if self.equals.is_none() && self.one_of.is_none() && v.is_none() { return false; }
156        }
157        true
158    }
159}
160
161fn lerp_clamp(x: f32, d: [f32;2], r: [f32;2]) -> f32 {
162    let span = d[1] - d[0];
163    if span == 0.0 { return r[0]; }
164    let t = ((x - d[0]) / span).clamp(0.0, 1.0);
165    r[0] + t * (r[1] - r[0])
166}
167
168impl StyleSpec {
169    pub fn node(&self, attrs: &Attrs, role: NodeRole) -> NodeStyle {
170        let b = &self.node_base;
171        let mut s = NodeStyle { color: b.color.0, radius: b.radius, opacity: b.opacity, shape: b.shape, label_visible: b.label_visible, label_text: None };
172        let role_str = role.as_str();
173        for rule in &self.node_rules {
174            if let Some(sel) = &rule.when { if !sel.matches(attrs, role_str) { continue; } }
175            if let Some(set) = &rule.set {
176                if let Some(c) = &set.color { s.color = c.0; }
177                if let Some(v) = set.radius { s.radius = v; }
178                if let Some(v) = set.opacity { s.opacity = v; }
179                if let Some(v) = set.shape { s.shape = v; }
180                if let Some(v) = set.label_visible { s.label_visible = v; }
181                if let Some(v) = &set.label_text { s.label_text = Some(v.clone()); }
182            }
183            if let Some(sc) = &rule.scale {
184                if let Some(x) = attrs.get(&sc.by).and_then(|v| v.as_f64()) {
185                    let mapped = lerp_clamp(x as f32, sc.domain, sc.range);
186                    match sc.property { ScalableNodeProp::Radius => s.radius = mapped, ScalableNodeProp::Opacity => s.opacity = mapped }
187                }
188            }
189        }
190        s
191    }
192
193    pub fn edge(&self, attrs: &Attrs, role: EdgeRole) -> EdgeStyle {
194        let b = &self.edge_base;
195        let mut s = EdgeStyle { color: b.color.0, opacity: b.opacity, width: b.width };
196        let role_str = role.as_str();
197        for rule in &self.edge_rules {
198            if let Some(sel) = &rule.when { if !sel.matches(attrs, role_str) { continue; } }
199            if let Some(set) = &rule.set {
200                if let Some(c) = &set.color { s.color = c.0; }
201                if let Some(v) = set.opacity { s.opacity = v; }
202                if let Some(v) = set.width { s.width = v; }
203            }
204            if let Some(sc) = &rule.scale {
205                if let Some(x) = attrs.get(&sc.by).and_then(|v| v.as_f64()) {
206                    let mapped = lerp_clamp(x as f32, sc.domain, sc.range);
207                    match sc.property { ScalableEdgeProp::Width => s.width = mapped, ScalableEdgeProp::Opacity => s.opacity = mapped }
208                }
209            }
210        }
211        s
212    }
213
214    /// The active label level-of-detail config (host-tunable via `set_style`).
215    pub fn label_lod(&self) -> LabelLodSpec { self.labels }
216}
217
218impl Default for StyleSpec {
219    fn default() -> Self {
220        let json = r##"{
221          "node_base": { "color": "#1f70eb", "radius": 14, "opacity": 1, "shape": "circle", "label_visible": true },
222          "node_rules": [
223            { "when": { "role": "focus" },    "set": { "color": "#e3b341", "radius": 20 } },
224            { "when": { "role": "selected" }, "set": { "color": "#3399ff", "radius": 18 } },
225            { "when": { "role": "history" },  "set": { "color": "#59667a", "radius": 12 } },
226            { "when": { "role": "provisional" }, "set": { "color": "#5a6d8c", "radius": 11, "opacity": 0.45 } }
227          ],
228          "edge_base": { "color": "#3a4252", "opacity": 1, "width": 1.5 },
229          "edge_rules": [
230            { "when": { "role": "provisional" }, "set": { "color": "#2b3444", "opacity": 0.35, "width": 1.0 } }
231          ]
232        }"##;
233        serde_json::from_str(json).expect("built-in default spec must parse")
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    #[test]
242    fn rgba_parses_hex() {
243        assert_eq!(Rgba::from_hex("#1f6feb").unwrap().0, [0.12156863, 0.43529412, 0.92156863, 1.0]);
244        assert_eq!(Rgba::from_hex("#00000080").unwrap().0[3], 128.0 / 255.0);
245        assert!(Rgba::from_hex("nope").is_err());
246    }
247
248    #[test]
249    fn stylespec_deserializes_from_json() {
250        let json = r##"{
251          "node_base": { "color": "#1f6feb", "radius": 14, "opacity": 1, "shape": "circle", "label_visible": true },
252          "node_rules": [
253            { "when": { "role": "focus" }, "set": { "color": "#e3b341", "radius": 20 } },
254            { "scale": { "by": "degree", "property": "radius", "domain": [1, 10], "range": [8, 24] } }
255          ],
256          "edge_base": { "color": "#3a4252", "opacity": 1, "width": 1.5 },
257          "edge_rules": []
258        }"##;
259        let spec: StyleSpec = serde_json::from_str(json).unwrap();
260        assert_eq!(spec.node_rules.len(), 2);
261        assert_eq!(spec.node_base.radius, 14.0);
262    }
263
264    fn spec_json(rules: &str) -> StyleSpec {
265        let json = format!(r##"{{
266          "node_base": {{ "color": "#1f6feb", "radius": 14 }},
267          "node_rules": [{rules}],
268          "edge_base": {{ "color": "#3a4252", "width": 1.5 }},
269          "edge_rules": []
270        }}"##);
271        serde_json::from_str(&json).unwrap()
272    }
273
274    #[test]
275    fn categorical_rule_matches_role_and_attr() {
276        let spec = spec_json(r##"
277          { "when": { "role": "focus" }, "set": { "color": "#e3b341", "radius": 20 } },
278          { "when": { "attr": "group", "equals": "input" }, "set": { "shape": "square" } }
279        "##);
280        let mut attrs = Attrs::new();
281        attrs.insert("group".into(), serde_json::json!("input"));
282        // focus role -> gold+20, and group=input -> square
283        let s = spec.node(&attrs, NodeRole::Focus);
284        assert_eq!(s.color, [0.8901961, 0.7019608, 0.25490198, 1.0]);
285        assert_eq!(s.radius, 20.0);
286        assert_eq!(s.shape, Shape::Square);
287        // normal role, no group -> base blue/14/circle
288        let base = spec.node(&Attrs::new(), NodeRole::Normal);
289        assert_eq!(base.radius, 14.0);
290        assert_eq!(base.shape, Shape::Circle);
291    }
292
293    #[test]
294    fn scale_rule_maps_and_clamps() {
295        let spec = spec_json(r##"{ "scale": { "by": "deg", "property": "radius", "domain": [1, 10], "range": [8, 24] } }"##);
296        let at = |v: f64| { let mut a = Attrs::new(); a.insert("deg".into(), serde_json::json!(v)); a };
297        assert_eq!(spec.node(&at(1.0), NodeRole::Normal).radius, 8.0);
298        assert_eq!(spec.node(&at(10.0), NodeRole::Normal).radius, 24.0);
299        assert_eq!(spec.node(&at(5.5), NodeRole::Normal).radius, 16.0); // midpoint
300        assert_eq!(spec.node(&at(100.0), NodeRole::Normal).radius, 24.0); // clamped high
301        assert_eq!(spec.node(&at(-5.0), NodeRole::Normal).radius, 8.0);   // clamped low
302    }
303
304    #[test]
305    fn later_rules_win() {
306        let spec = spec_json(r##"
307          { "set": { "color": "#111111" } },
308          { "set": { "color": "#222222" } }
309        "##);
310        assert_eq!(spec.node(&Attrs::new(), NodeRole::Normal).color, Rgba::from_hex("#222222").unwrap().0);
311    }
312
313    fn approx(a: [f32;4], b: [f32;4]) -> bool { a.iter().zip(b).all(|(x,y)| (x-y).abs() < 0.02) }
314
315    #[test]
316    fn default_spec_matches_current_look() {
317        let spec = StyleSpec::default();
318        let normal = spec.node(&Attrs::new(), NodeRole::Normal);
319        assert!(approx(normal.color, [0.12, 0.44, 0.92, 1.0]));
320        assert_eq!(normal.radius, 14.0);
321        assert_eq!(normal.shape, Shape::Circle);
322        let focus = spec.node(&Attrs::new(), NodeRole::Focus);
323        assert!(approx(focus.color, [0.89, 0.70, 0.25, 1.0]));
324        assert_eq!(focus.radius, 20.0);
325        let hist = spec.node(&Attrs::new(), NodeRole::History);
326        assert_eq!(hist.radius, 12.0);
327        let e = spec.edge(&Attrs::new(), EdgeRole::Normal);
328        assert_eq!(e.width, 1.5);
329    }
330
331    #[test]
332    fn from_hex_rejects_non_ascii_without_panicking() {
333        assert!(Rgba::from_hex("#1f6f€b").is_err());
334    }
335
336    #[test]
337    fn scale_with_degenerate_domain_is_finite() {
338        let spec: StyleSpec = serde_json::from_str(r##"{
339          "node_base": { "color": "#1f6feb", "radius": 14 },
340          "node_rules": [ { "scale": { "by": "d", "property": "radius", "domain": [5,5], "range": [10,20] } } ],
341          "edge_base": { "color": "#3a4252", "width": 1.5 }, "edge_rules": []
342        }"##).unwrap();
343        let mut a = Attrs::new(); a.insert("d".into(), serde_json::json!(5.0));
344        let r = spec.node(&a, NodeRole::Normal).radius;
345        assert!(r.is_finite());
346        assert_eq!(r, 10.0);
347    }
348
349    #[test]
350    fn unknown_selector_key_is_rejected() {
351        let bad = r##"{
352          "node_base": { "color": "#1f6feb", "radius": 14 },
353          "node_rules": [ { "when": { "attrs": "group" }, "set": { "shape": "square" } } ],
354          "edge_base": { "color": "#3a4252", "width": 1.5 }, "edge_rules": []
355        }"##;
356        assert!(serde_json::from_str::<StyleSpec>(bad).is_err());
357    }
358
359    #[test]
360    fn provisional_roles_stringify_for_selectors() {
361        assert_eq!(NodeRole::Provisional.as_str(), "provisional");
362        assert_eq!(EdgeRole::Provisional.as_str(), "provisional");
363    }
364
365    #[test]
366    fn default_spec_ghosts_provisional_nodes_and_edges() {
367        let spec = StyleSpec::default();
368        let n = spec.node(&Attrs::new(), NodeRole::Provisional);
369        assert_eq!(n.color, Rgba::from_hex("#5a6d8c").unwrap().0);
370        assert_eq!(n.radius, 11.0);
371        assert_eq!(n.opacity, 0.45);
372
373        let e = spec.edge(&Attrs::new(), EdgeRole::Provisional);
374        assert_eq!(e.color, Rgba::from_hex("#2b3444").unwrap().0);
375        assert_eq!(e.opacity, 0.35);
376        assert_eq!(e.width, 1.0);
377    }
378
379    #[test]
380    fn provisional_ghosting_stays_clickable() {
381        // graph-explorer-render's hit test ignores targets below 0.05 opacity, and
382        // clicking a ghost is how you walk into it.
383        let spec = StyleSpec::default();
384        // keep in sync with graph-explorer-render's MIN_HITTABLE_OPACITY
385        assert!(spec.node(&Attrs::new(), NodeRole::Provisional).opacity > 0.05);
386    }
387
388    #[test]
389    fn a_host_rule_overrides_the_built_in_provisional_rule_selectively() {
390        // Layering on the SHIPPED default, not a hand-copied spec: this fails if
391        // anyone removes or reorders the built-in provisional rule, which a
392        // freshly-constructed spec would not notice.
393        let mut spec = StyleSpec::default();
394        spec.node_rules.push(serde_json::from_str(
395            r##"{ "when": { "role": "provisional" }, "set": { "color": "#ff00aa" } }"##).unwrap());
396        let n = spec.node(&Attrs::new(), NodeRole::Provisional);
397        assert_eq!(n.color, Rgba::from_hex("#ff00aa").unwrap().0);
398        assert_eq!(n.radius, 11.0, "the built-in radius survives a colour-only override");
399        assert_eq!(n.opacity, 0.45);
400    }
401
402    #[test]
403    fn a_host_edge_rule_overrides_the_built_in_provisional_edge_rule_selectively() {
404        // Edge side of the same guarantee: layering on the shipped default,
405        // not a hand-copied spec.
406        let mut spec = StyleSpec::default();
407        spec.edge_rules.push(serde_json::from_str(
408            r##"{ "when": { "role": "provisional" }, "set": { "width": 4.0 } }"##).unwrap());
409        let e = spec.edge(&Attrs::new(), EdgeRole::Provisional);
410        assert_eq!(e.width, 4.0);
411        assert_eq!(e.opacity, 0.35, "the built-in opacity survives a width-only override");
412    }
413
414    #[test]
415    fn a_replacement_spec_without_provisional_rules_falls_back_to_base() {
416        // Wholesale replacement drops the built-in ghosting, same as it already
417        // does for focus/history. It must not error.
418        let json = r##"{
419          "node_base": { "color": "#1f70eb", "radius": 14 },
420          "edge_base": { "color": "#3a4252", "width": 1.5 }
421        }"##;
422        let spec: StyleSpec = serde_json::from_str(json).unwrap();
423        let n = spec.node(&Attrs::new(), NodeRole::Provisional);
424        assert_eq!(n.color, Rgba::from_hex("#1f70eb").unwrap().0);
425        assert_eq!(n.opacity, 1.0);
426    }
427
428    #[test]
429    fn provisional_composes_with_an_attribute_selector() {
430        // Layering on the shipped default, not a hand-copied spec, so this
431        // fails if the built-in provisional rule is removed or reordered.
432        let mut spec = StyleSpec::default();
433        spec.node_rules.push(serde_json::from_str(
434            r##"{ "when": { "role": "provisional", "attr": "kind", "equals": "Person" },
435                  "set": { "color": "#ff00aa" } }"##).unwrap());
436        let mut person = Attrs::new();
437        person.insert("kind".into(), serde_json::json!("Person"));
438
439        let p = spec.node(&person, NodeRole::Provisional);
440        assert_eq!(p.color, Rgba::from_hex("#ff00aa").unwrap().0, "matching attr overrides the ghost colour");
441        assert_eq!(p.opacity, 0.45, "the built-in opacity survives a colour-only override");
442
443        // Non-Person ghost keeps the built-in ghost colour, not the base blue.
444        assert_eq!(spec.node(&Attrs::new(), NodeRole::Provisional).color, Rgba::from_hex("#5a6d8c").unwrap().0);
445
446        // A Person that isn't provisional is unaffected.
447        assert_eq!(spec.node(&person, NodeRole::Normal).color, Rgba::from_hex("#1f70eb").unwrap().0);
448    }
449
450    #[test]
451    fn label_lod_defaults_and_parses() {
452        let s: StyleSpec = serde_json::from_str(
453            r##"{ "node_base": {"color":"#ffffff","radius":8}, "edge_base": {"color":"#888888","width":1} }"##).unwrap();
454        assert_eq!(s.labels.min_screen_radius_px, 8.0);
455        assert_eq!(s.labels.max_labels, 200);
456        let s: StyleSpec = serde_json::from_str(
457            r##"{ "node_base": {"color":"#ffffff","radius":8}, "edge_base": {"color":"#888888","width":1},
458                 "labels": { "max_labels": 50 } }"##).unwrap();
459        assert_eq!(s.labels.max_labels, 50);
460        assert_eq!(s.labels.min_screen_radius_px, 8.0, "partial keeps other defaults");
461    }
462
463    #[test]
464    fn label_lod_rejects_unknown_fields() {
465        assert!(serde_json::from_str::<StyleSpec>(
466            r##"{ "node_base": {"color":"#ffffff","radius":8}, "edge_base": {"color":"#888888","width":1},
467                 "labels": { "wobble": 1 } }"##).is_err());
468    }
469}