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, pub label_visible: bool, pub label_text: Option<String> }
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    #[serde(default)] pub label_visible: Option<bool>,
80    /// Overrides whatever `edge_base.label_attr` supplied for this edge —
81    /// prettify a raw relationship type, or name an edge the data does not.
82    #[serde(default)] pub label_text: Option<String>,
83}
84
85#[derive(Debug, Clone, Default, Deserialize)]
86#[serde(deny_unknown_fields)]
87pub struct Selector {
88    #[serde(default)] pub role: Option<String>,
89    #[serde(default)] pub attr: Option<String>,
90    #[serde(default)] pub equals: Option<Value>,
91    #[serde(default)] pub one_of: Option<Vec<Value>>,
92}
93
94#[derive(Debug, Clone, Copy, PartialEq, Deserialize)]
95#[serde(rename_all = "lowercase")]
96pub enum ScalableNodeProp { Radius, Opacity }
97#[derive(Debug, Clone, Copy, PartialEq, Deserialize)]
98#[serde(rename_all = "lowercase")]
99pub enum ScalableEdgeProp { Width, Opacity }
100
101#[derive(Debug, Clone, Deserialize)]
102#[serde(deny_unknown_fields)]
103pub struct NodeScale { pub by: String, pub property: ScalableNodeProp, pub domain: [f32;2], pub range: [f32;2] }
104#[derive(Debug, Clone, Deserialize)]
105#[serde(deny_unknown_fields)]
106pub struct EdgeScale { pub by: String, pub property: ScalableEdgeProp, pub domain: [f32;2], pub range: [f32;2] }
107
108/// A rule is a `when` gate plus a `set` (categorical override) and/or a `scale`.
109#[derive(Debug, Clone, Default, Deserialize)]
110#[serde(deny_unknown_fields)]
111pub struct NodeRule { #[serde(default)] pub when: Option<Selector>, #[serde(default)] pub set: Option<PartialNodeStyle>, #[serde(default)] pub scale: Option<NodeScale> }
112#[derive(Debug, Clone, Default, Deserialize)]
113#[serde(deny_unknown_fields)]
114pub struct EdgeRule { #[serde(default)] pub when: Option<Selector>, #[serde(default)] pub set: Option<PartialEdgeStyle>, #[serde(default)] pub scale: Option<EdgeScale> }
115
116#[derive(Debug, Clone, Deserialize)]
117#[serde(deny_unknown_fields)]
118pub struct StyleSpec {
119    pub node_base: NodeBaseSpec,
120    #[serde(default)] pub node_rules: Vec<NodeRule>,
121    pub edge_base: EdgeBaseSpec,
122    #[serde(default)] pub edge_rules: Vec<EdgeRule>,
123    #[serde(default)] pub labels: LabelLodSpec,
124}
125
126/// Label level-of-detail. Both knobs are host-tunable via `set_style`.
127#[derive(Debug, Clone, Copy, Deserialize)]
128#[serde(deny_unknown_fields, default)]
129pub struct LabelLodSpec {
130    /// Zoom gate: a label is a candidate only when its node's on-screen
131    /// radius (world radius × zoom) is at least this many pixels.
132    pub min_screen_radius_px: f32,
133    /// Importance cap: at most this many labels per frame; survivors are the
134    /// top-N by importance = degree, with the current node and its neighbors
135    /// boosted so the user's context never loses its labels.
136    pub max_labels: usize,
137    /// Zoom gate for EDGE labels: an edge shorter than this on screen has no
138    /// room to print a relationship name along it.
139    pub edge_min_screen_length_px: f32,
140    /// Importance cap for edge labels, counted separately from `max_labels`
141    /// so turning edge labels on cannot silently evict node labels.
142    pub max_edge_labels: usize,
143}
144impl Default for LabelLodSpec {
145    fn default() -> Self {
146        Self {
147            min_screen_radius_px: 8.0,
148            max_labels: 200,
149            edge_min_screen_length_px: 60.0,
150            max_edge_labels: 100,
151        }
152    }
153}
154
155// Base specs deserialize from JSON with defaults, then convert into the resolved *Style.
156#[derive(Debug, Clone, Deserialize)]
157#[serde(deny_unknown_fields)]
158pub 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 }
159#[derive(Debug, Clone, Deserialize)]
160#[serde(deny_unknown_fields)]
161pub struct EdgeBaseSpec {
162    pub color: Rgba,
163    #[serde(default = "one")] pub opacity: f32,
164    pub width: f32,
165    /// Name an edge attr to draw as the edge's label — `"rel"`, `"type"`,
166    /// whatever the dataset uses. Unset (the default) means edges carry no
167    /// labels at all, so adding this field changes nothing for a spec that
168    /// does not ask for it.
169    ///
170    /// An attr reference rather than a literal because relationship types are
171    /// generally not known when the spec is written: a graph read from a
172    /// database has whatever types the database has. Per-rule `label_text`
173    /// overrides this for the types a host *does* want to name.
174    #[serde(default)] pub label_attr: Option<String>,
175    #[serde(default = "yes")] pub label_visible: bool,
176}
177fn one() -> f32 { 1.0 }
178fn yes() -> bool { true }
179
180/// Render an attr value as label text, or `None` if it is not a scalar.
181///
182/// Strings render bare (no JSON quotes); numbers and booleans render as
183/// written. Arrays, objects and null render nothing — there is no sensible
184/// one-line form, and `"[object Object]"` on an edge is worse than no label.
185/// This is the same scalar/non-scalar line `graph-explorer-proxy` already
186/// draws when deciding what survives into `attrs`.
187fn scalar_text(v: &Value) -> Option<String> {
188    match v {
189        Value::String(s) => Some(s.clone()),
190        Value::Number(n) => Some(n.to_string()),
191        Value::Bool(b) => Some(b.to_string()),
192        _ => None,
193    }
194}
195
196impl Selector {
197    fn matches(&self, attrs: &Attrs, role_str: &str) -> bool {
198        if let Some(r) = &self.role { if r != role_str { return false; } }
199        if let Some(a) = &self.attr {
200            let v = attrs.get(a);
201            if let Some(eq) = &self.equals { if v != Some(eq) { return false; } }
202            if let Some(list) = &self.one_of { if !v.is_some_and(|v| list.contains(v)) { return false; } }
203            if self.equals.is_none() && self.one_of.is_none() && v.is_none() { return false; }
204        }
205        true
206    }
207}
208
209fn lerp_clamp(x: f32, d: [f32;2], r: [f32;2]) -> f32 {
210    let span = d[1] - d[0];
211    if span == 0.0 { return r[0]; }
212    let t = ((x - d[0]) / span).clamp(0.0, 1.0);
213    r[0] + t * (r[1] - r[0])
214}
215
216impl StyleSpec {
217    pub fn node(&self, attrs: &Attrs, role: NodeRole) -> NodeStyle {
218        let b = &self.node_base;
219        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 };
220        let role_str = role.as_str();
221        for rule in &self.node_rules {
222            if let Some(sel) = &rule.when { if !sel.matches(attrs, role_str) { continue; } }
223            if let Some(set) = &rule.set {
224                if let Some(c) = &set.color { s.color = c.0; }
225                if let Some(v) = set.radius { s.radius = v; }
226                if let Some(v) = set.opacity { s.opacity = v; }
227                if let Some(v) = set.shape { s.shape = v; }
228                if let Some(v) = set.label_visible { s.label_visible = v; }
229                if let Some(v) = &set.label_text { s.label_text = Some(v.clone()); }
230            }
231            if let Some(sc) = &rule.scale {
232                if let Some(x) = attrs.get(&sc.by).and_then(|v| v.as_f64()) {
233                    let mapped = lerp_clamp(x as f32, sc.domain, sc.range);
234                    match sc.property { ScalableNodeProp::Radius => s.radius = mapped, ScalableNodeProp::Opacity => s.opacity = mapped }
235                }
236            }
237        }
238        s
239    }
240
241    pub fn edge(&self, attrs: &Attrs, role: EdgeRole) -> EdgeStyle {
242        let b = &self.edge_base;
243        // The attr-derived default is resolved BEFORE the rule loop, so a
244        // rule's `label_text` overrides it by the same last-write-wins path
245        // every other property uses.
246        let mut s = EdgeStyle {
247            color: b.color.0,
248            opacity: b.opacity,
249            width: b.width,
250            label_visible: b.label_visible,
251            label_text: b.label_attr.as_ref().and_then(|k| attrs.get(k)).and_then(scalar_text),
252        };
253        let role_str = role.as_str();
254        for rule in &self.edge_rules {
255            if let Some(sel) = &rule.when { if !sel.matches(attrs, role_str) { continue; } }
256            if let Some(set) = &rule.set {
257                if let Some(c) = &set.color { s.color = c.0; }
258                if let Some(v) = set.opacity { s.opacity = v; }
259                if let Some(v) = set.width { s.width = v; }
260                if let Some(v) = set.label_visible { s.label_visible = v; }
261                if let Some(v) = &set.label_text { s.label_text = Some(v.clone()); }
262            }
263            if let Some(sc) = &rule.scale {
264                if let Some(x) = attrs.get(&sc.by).and_then(|v| v.as_f64()) {
265                    let mapped = lerp_clamp(x as f32, sc.domain, sc.range);
266                    match sc.property { ScalableEdgeProp::Width => s.width = mapped, ScalableEdgeProp::Opacity => s.opacity = mapped }
267                }
268            }
269        }
270        s
271    }
272
273    /// The active label level-of-detail config (host-tunable via `set_style`).
274    pub fn label_lod(&self) -> LabelLodSpec { self.labels }
275
276    /// Whether any edge could carry a label. Lets the scene builder skip the
277    /// whole edge-label path — including interning — for the overwhelmingly
278    /// common case of a spec that never asked for edge labels.
279    pub fn edge_labels_possible(&self) -> bool {
280        self.edge_base.label_attr.is_some()
281            || self.edge_rules.iter().any(|r| r.set.as_ref().is_some_and(|s| s.label_text.is_some()))
282    }
283}
284
285impl Default for StyleSpec {
286    fn default() -> Self {
287        let json = r##"{
288          "node_base": { "color": "#1f70eb", "radius": 14, "opacity": 1, "shape": "circle", "label_visible": true },
289          "node_rules": [
290            { "when": { "role": "focus" },    "set": { "color": "#e3b341", "radius": 20 } },
291            { "when": { "role": "selected" }, "set": { "color": "#3399ff", "radius": 18 } },
292            { "when": { "role": "history" },  "set": { "color": "#59667a", "radius": 12 } },
293            { "when": { "role": "provisional" }, "set": { "color": "#5a6d8c", "radius": 11, "opacity": 0.45 } }
294          ],
295          "edge_base": { "color": "#3a4252", "opacity": 1, "width": 1.5 },
296          "edge_rules": [
297            { "when": { "role": "provisional" }, "set": { "color": "#2b3444", "opacity": 0.35, "width": 1.0 } }
298          ]
299        }"##;
300        serde_json::from_str(json).expect("built-in default spec must parse")
301    }
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307
308    #[test]
309    fn rgba_parses_hex() {
310        assert_eq!(Rgba::from_hex("#1f6feb").unwrap().0, [0.12156863, 0.43529412, 0.92156863, 1.0]);
311        assert_eq!(Rgba::from_hex("#00000080").unwrap().0[3], 128.0 / 255.0);
312        assert!(Rgba::from_hex("nope").is_err());
313    }
314
315    #[test]
316    fn stylespec_deserializes_from_json() {
317        let json = r##"{
318          "node_base": { "color": "#1f6feb", "radius": 14, "opacity": 1, "shape": "circle", "label_visible": true },
319          "node_rules": [
320            { "when": { "role": "focus" }, "set": { "color": "#e3b341", "radius": 20 } },
321            { "scale": { "by": "degree", "property": "radius", "domain": [1, 10], "range": [8, 24] } }
322          ],
323          "edge_base": { "color": "#3a4252", "opacity": 1, "width": 1.5 },
324          "edge_rules": []
325        }"##;
326        let spec: StyleSpec = serde_json::from_str(json).unwrap();
327        assert_eq!(spec.node_rules.len(), 2);
328        assert_eq!(spec.node_base.radius, 14.0);
329    }
330
331    fn spec_json(rules: &str) -> StyleSpec {
332        let json = format!(r##"{{
333          "node_base": {{ "color": "#1f6feb", "radius": 14 }},
334          "node_rules": [{rules}],
335          "edge_base": {{ "color": "#3a4252", "width": 1.5 }},
336          "edge_rules": []
337        }}"##);
338        serde_json::from_str(&json).unwrap()
339    }
340
341    /// Build a spec whose edges carry `edge_base` fields `base` and rules `rules`.
342    fn edge_spec(base: &str, rules: &str) -> StyleSpec {
343        let json = format!(r##"{{
344          "node_base": {{ "color": "#1f6feb", "radius": 14 }},
345          "node_rules": [],
346          "edge_base": {{ "color": "#3a4252", "width": 1.5 {base} }},
347          "edge_rules": [{rules}]
348        }}"##);
349        serde_json::from_str(&json).unwrap()
350    }
351
352    fn edge_attrs(pairs: &[(&str, serde_json::Value)]) -> Attrs {
353        pairs.iter().map(|(k, v)| (k.to_string(), v.clone())).collect()
354    }
355
356    #[test]
357    fn label_attr_supplies_edge_label_text() {
358        let spec = edge_spec(r##", "label_attr": "rel""##, "");
359        let a = edge_attrs(&[("rel", serde_json::json!("written_by"))]);
360        assert_eq!(spec.edge(&a, EdgeRole::Normal).label_text.as_deref(), Some("written_by"));
361    }
362
363    #[test]
364    fn a_rule_label_text_overrides_the_attr_derived_default() {
365        let spec = edge_spec(
366            r##", "label_attr": "rel""##,
367            r##"{ "when": { "attr": "rel", "equals": "written_by" }, "set": { "label_text": "written by" } }"##,
368        );
369        let a = edge_attrs(&[("rel", serde_json::json!("written_by"))]);
370        assert_eq!(spec.edge(&a, EdgeRole::Normal).label_text.as_deref(), Some("written by"));
371        // A type with no rule keeps the raw attr value.
372        let b = edge_attrs(&[("rel", serde_json::json!("features"))]);
373        assert_eq!(spec.edge(&b, EdgeRole::Normal).label_text.as_deref(), Some("features"));
374    }
375
376    #[test]
377    fn label_visible_false_suppresses_a_label_the_attr_would_supply() {
378        let spec = edge_spec(
379            r##", "label_attr": "rel""##,
380            r##"{ "when": { "attr": "rel", "equals": "in_genre" }, "set": { "label_visible": false } }"##,
381        );
382        let a = edge_attrs(&[("rel", serde_json::json!("in_genre"))]);
383        let s = spec.edge(&a, EdgeRole::Normal);
384        assert!(!s.label_visible);
385        // The text is still resolved; visibility is the renderer's gate. What
386        // matters is that the two are independent.
387        assert_eq!(s.label_text.as_deref(), Some("in_genre"));
388    }
389
390    #[test]
391    fn an_edge_missing_the_named_attr_gets_no_label() {
392        let spec = edge_spec(r##", "label_attr": "rel""##, "");
393        let a = edge_attrs(&[("weight", serde_json::json!(3))]);
394        assert_eq!(spec.edge(&a, EdgeRole::Normal).label_text, None);
395    }
396
397    /// Non-scalars have no sensible one-line form. Rendering `[object Object]`
398    /// on an edge is worse than rendering nothing.
399    #[test]
400    fn non_scalar_attr_values_produce_no_label() {
401        for v in [serde_json::json!([1, 2]), serde_json::json!({"a": 1}), serde_json::json!(null)] {
402            let spec = edge_spec(r##", "label_attr": "rel""##, "");
403            let a = edge_attrs(&[("rel", v.clone())]);
404            assert_eq!(spec.edge(&a, EdgeRole::Normal).label_text, None, "for {v}");
405        }
406    }
407
408    #[test]
409    fn numbers_and_bools_render_as_written() {
410        let spec = edge_spec(r##", "label_attr": "w""##, "");
411        assert_eq!(
412            spec.edge(&edge_attrs(&[("w", serde_json::json!(42))]), EdgeRole::Normal).label_text.as_deref(),
413            Some("42"),
414        );
415        assert_eq!(
416            spec.edge(&edge_attrs(&[("w", serde_json::json!(true))]), EdgeRole::Normal).label_text.as_deref(),
417            Some("true"),
418        );
419        // A string renders bare, without the quotes `Value::to_string` adds.
420        assert_eq!(
421            spec.edge(&edge_attrs(&[("w", serde_json::json!("hi"))]), EdgeRole::Normal).label_text.as_deref(),
422            Some("hi"),
423        );
424    }
425
426    /// The no-behaviour-change guarantee: a spec that never mentions edge
427    /// labels gets none, and the scene builder can skip the path entirely.
428    #[test]
429    fn edge_labels_are_off_unless_the_spec_asks_for_them() {
430        let spec = edge_spec("", "");
431        let a = edge_attrs(&[("rel", serde_json::json!("written_by"))]);
432        assert_eq!(spec.edge(&a, EdgeRole::Normal).label_text, None);
433        assert!(!spec.edge_labels_possible());
434
435        assert!(edge_spec(r##", "label_attr": "rel""##, "").edge_labels_possible());
436        assert!(edge_spec("", r##"{ "set": { "label_text": "x" } }"##).edge_labels_possible());
437        assert!(!StyleSpec::default().edge_labels_possible(), "the built-in spec must stay label-free");
438    }
439
440    #[test]
441    fn categorical_rule_matches_role_and_attr() {
442        let spec = spec_json(r##"
443          { "when": { "role": "focus" }, "set": { "color": "#e3b341", "radius": 20 } },
444          { "when": { "attr": "group", "equals": "input" }, "set": { "shape": "square" } }
445        "##);
446        let mut attrs = Attrs::new();
447        attrs.insert("group".into(), serde_json::json!("input"));
448        // focus role -> gold+20, and group=input -> square
449        let s = spec.node(&attrs, NodeRole::Focus);
450        assert_eq!(s.color, [0.8901961, 0.7019608, 0.25490198, 1.0]);
451        assert_eq!(s.radius, 20.0);
452        assert_eq!(s.shape, Shape::Square);
453        // normal role, no group -> base blue/14/circle
454        let base = spec.node(&Attrs::new(), NodeRole::Normal);
455        assert_eq!(base.radius, 14.0);
456        assert_eq!(base.shape, Shape::Circle);
457    }
458
459    #[test]
460    fn scale_rule_maps_and_clamps() {
461        let spec = spec_json(r##"{ "scale": { "by": "deg", "property": "radius", "domain": [1, 10], "range": [8, 24] } }"##);
462        let at = |v: f64| { let mut a = Attrs::new(); a.insert("deg".into(), serde_json::json!(v)); a };
463        assert_eq!(spec.node(&at(1.0), NodeRole::Normal).radius, 8.0);
464        assert_eq!(spec.node(&at(10.0), NodeRole::Normal).radius, 24.0);
465        assert_eq!(spec.node(&at(5.5), NodeRole::Normal).radius, 16.0); // midpoint
466        assert_eq!(spec.node(&at(100.0), NodeRole::Normal).radius, 24.0); // clamped high
467        assert_eq!(spec.node(&at(-5.0), NodeRole::Normal).radius, 8.0);   // clamped low
468    }
469
470    #[test]
471    fn later_rules_win() {
472        let spec = spec_json(r##"
473          { "set": { "color": "#111111" } },
474          { "set": { "color": "#222222" } }
475        "##);
476        assert_eq!(spec.node(&Attrs::new(), NodeRole::Normal).color, Rgba::from_hex("#222222").unwrap().0);
477    }
478
479    fn approx(a: [f32;4], b: [f32;4]) -> bool { a.iter().zip(b).all(|(x,y)| (x-y).abs() < 0.02) }
480
481    #[test]
482    fn default_spec_matches_current_look() {
483        let spec = StyleSpec::default();
484        let normal = spec.node(&Attrs::new(), NodeRole::Normal);
485        assert!(approx(normal.color, [0.12, 0.44, 0.92, 1.0]));
486        assert_eq!(normal.radius, 14.0);
487        assert_eq!(normal.shape, Shape::Circle);
488        let focus = spec.node(&Attrs::new(), NodeRole::Focus);
489        assert!(approx(focus.color, [0.89, 0.70, 0.25, 1.0]));
490        assert_eq!(focus.radius, 20.0);
491        let hist = spec.node(&Attrs::new(), NodeRole::History);
492        assert_eq!(hist.radius, 12.0);
493        let e = spec.edge(&Attrs::new(), EdgeRole::Normal);
494        assert_eq!(e.width, 1.5);
495    }
496
497    #[test]
498    fn from_hex_rejects_non_ascii_without_panicking() {
499        assert!(Rgba::from_hex("#1f6f€b").is_err());
500    }
501
502    #[test]
503    fn scale_with_degenerate_domain_is_finite() {
504        let spec: StyleSpec = serde_json::from_str(r##"{
505          "node_base": { "color": "#1f6feb", "radius": 14 },
506          "node_rules": [ { "scale": { "by": "d", "property": "radius", "domain": [5,5], "range": [10,20] } } ],
507          "edge_base": { "color": "#3a4252", "width": 1.5 }, "edge_rules": []
508        }"##).unwrap();
509        let mut a = Attrs::new(); a.insert("d".into(), serde_json::json!(5.0));
510        let r = spec.node(&a, NodeRole::Normal).radius;
511        assert!(r.is_finite());
512        assert_eq!(r, 10.0);
513    }
514
515    #[test]
516    fn unknown_selector_key_is_rejected() {
517        let bad = r##"{
518          "node_base": { "color": "#1f6feb", "radius": 14 },
519          "node_rules": [ { "when": { "attrs": "group" }, "set": { "shape": "square" } } ],
520          "edge_base": { "color": "#3a4252", "width": 1.5 }, "edge_rules": []
521        }"##;
522        assert!(serde_json::from_str::<StyleSpec>(bad).is_err());
523    }
524
525    #[test]
526    fn provisional_roles_stringify_for_selectors() {
527        assert_eq!(NodeRole::Provisional.as_str(), "provisional");
528        assert_eq!(EdgeRole::Provisional.as_str(), "provisional");
529    }
530
531    #[test]
532    fn default_spec_ghosts_provisional_nodes_and_edges() {
533        let spec = StyleSpec::default();
534        let n = spec.node(&Attrs::new(), NodeRole::Provisional);
535        assert_eq!(n.color, Rgba::from_hex("#5a6d8c").unwrap().0);
536        assert_eq!(n.radius, 11.0);
537        assert_eq!(n.opacity, 0.45);
538
539        let e = spec.edge(&Attrs::new(), EdgeRole::Provisional);
540        assert_eq!(e.color, Rgba::from_hex("#2b3444").unwrap().0);
541        assert_eq!(e.opacity, 0.35);
542        assert_eq!(e.width, 1.0);
543    }
544
545    #[test]
546    fn provisional_ghosting_stays_clickable() {
547        // graph-explorer-render's hit test ignores targets below 0.05 opacity, and
548        // clicking a ghost is how you walk into it.
549        let spec = StyleSpec::default();
550        // keep in sync with graph-explorer-render's MIN_HITTABLE_OPACITY
551        assert!(spec.node(&Attrs::new(), NodeRole::Provisional).opacity > 0.05);
552    }
553
554    #[test]
555    fn a_host_rule_overrides_the_built_in_provisional_rule_selectively() {
556        // Layering on the SHIPPED default, not a hand-copied spec: this fails if
557        // anyone removes or reorders the built-in provisional rule, which a
558        // freshly-constructed spec would not notice.
559        let mut spec = StyleSpec::default();
560        spec.node_rules.push(serde_json::from_str(
561            r##"{ "when": { "role": "provisional" }, "set": { "color": "#ff00aa" } }"##).unwrap());
562        let n = spec.node(&Attrs::new(), NodeRole::Provisional);
563        assert_eq!(n.color, Rgba::from_hex("#ff00aa").unwrap().0);
564        assert_eq!(n.radius, 11.0, "the built-in radius survives a colour-only override");
565        assert_eq!(n.opacity, 0.45);
566    }
567
568    #[test]
569    fn a_host_edge_rule_overrides_the_built_in_provisional_edge_rule_selectively() {
570        // Edge side of the same guarantee: layering on the shipped default,
571        // not a hand-copied spec.
572        let mut spec = StyleSpec::default();
573        spec.edge_rules.push(serde_json::from_str(
574            r##"{ "when": { "role": "provisional" }, "set": { "width": 4.0 } }"##).unwrap());
575        let e = spec.edge(&Attrs::new(), EdgeRole::Provisional);
576        assert_eq!(e.width, 4.0);
577        assert_eq!(e.opacity, 0.35, "the built-in opacity survives a width-only override");
578    }
579
580    #[test]
581    fn a_replacement_spec_without_provisional_rules_falls_back_to_base() {
582        // Wholesale replacement drops the built-in ghosting, same as it already
583        // does for focus/history. It must not error.
584        let json = r##"{
585          "node_base": { "color": "#1f70eb", "radius": 14 },
586          "edge_base": { "color": "#3a4252", "width": 1.5 }
587        }"##;
588        let spec: StyleSpec = serde_json::from_str(json).unwrap();
589        let n = spec.node(&Attrs::new(), NodeRole::Provisional);
590        assert_eq!(n.color, Rgba::from_hex("#1f70eb").unwrap().0);
591        assert_eq!(n.opacity, 1.0);
592    }
593
594    #[test]
595    fn provisional_composes_with_an_attribute_selector() {
596        // Layering on the shipped default, not a hand-copied spec, so this
597        // fails if the built-in provisional rule is removed or reordered.
598        let mut spec = StyleSpec::default();
599        spec.node_rules.push(serde_json::from_str(
600            r##"{ "when": { "role": "provisional", "attr": "kind", "equals": "Person" },
601                  "set": { "color": "#ff00aa" } }"##).unwrap());
602        let mut person = Attrs::new();
603        person.insert("kind".into(), serde_json::json!("Person"));
604
605        let p = spec.node(&person, NodeRole::Provisional);
606        assert_eq!(p.color, Rgba::from_hex("#ff00aa").unwrap().0, "matching attr overrides the ghost colour");
607        assert_eq!(p.opacity, 0.45, "the built-in opacity survives a colour-only override");
608
609        // Non-Person ghost keeps the built-in ghost colour, not the base blue.
610        assert_eq!(spec.node(&Attrs::new(), NodeRole::Provisional).color, Rgba::from_hex("#5a6d8c").unwrap().0);
611
612        // A Person that isn't provisional is unaffected.
613        assert_eq!(spec.node(&person, NodeRole::Normal).color, Rgba::from_hex("#1f70eb").unwrap().0);
614    }
615
616    #[test]
617    fn label_lod_defaults_and_parses() {
618        let s: StyleSpec = serde_json::from_str(
619            r##"{ "node_base": {"color":"#ffffff","radius":8}, "edge_base": {"color":"#888888","width":1} }"##).unwrap();
620        assert_eq!(s.labels.min_screen_radius_px, 8.0);
621        assert_eq!(s.labels.max_labels, 200);
622        let s: StyleSpec = serde_json::from_str(
623            r##"{ "node_base": {"color":"#ffffff","radius":8}, "edge_base": {"color":"#888888","width":1},
624                 "labels": { "max_labels": 50 } }"##).unwrap();
625        assert_eq!(s.labels.max_labels, 50);
626        assert_eq!(s.labels.min_screen_radius_px, 8.0, "partial keeps other defaults");
627    }
628
629    #[test]
630    fn edge_label_lod_defaults_and_parses() {
631        let s: StyleSpec = serde_json::from_str(
632            r##"{ "node_base": {"color":"#ffffff","radius":8}, "edge_base": {"color":"#888888","width":1} }"##).unwrap();
633        assert_eq!(s.labels.edge_min_screen_length_px, 60.0);
634        assert_eq!(s.labels.max_edge_labels, 100);
635        let s: StyleSpec = serde_json::from_str(
636            r##"{ "node_base": {"color":"#ffffff","radius":8}, "edge_base": {"color":"#888888","width":1},
637                 "labels": { "edge_min_screen_length_px": 20, "max_edge_labels": 5 } }"##).unwrap();
638        assert_eq!(s.labels.edge_min_screen_length_px, 20.0);
639        assert_eq!(s.labels.max_edge_labels, 5);
640        // The edge caps are counted separately, so setting them must not
641        // disturb the node caps.
642        assert_eq!(s.labels.max_labels, 200);
643    }
644
645    #[test]
646    fn label_lod_rejects_unknown_fields() {
647        assert!(serde_json::from_str::<StyleSpec>(
648            r##"{ "node_base": {"color":"#ffffff","radius":8}, "edge_base": {"color":"#888888","width":1},
649                 "labels": { "wobble": 1 } }"##).is_err());
650    }
651}