use serde::Deserialize;
use serde_json::Value;
use std::collections::HashMap;
pub type Attrs = HashMap<String, Value>;
pub mod anim;
pub mod interner;
pub mod scene;
pub use anim::*;
pub use interner::{Interner, LabelInterner, NodeIndex, LabelId, IdKind, EMPTY_LABEL};
pub use scene::*;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Rgba(pub [f32; 4]);
impl Rgba {
pub fn from_hex(s: &str) -> Result<Rgba, String> {
let h = s.strip_prefix('#').ok_or_else(|| format!("missing #: {s}"))?;
if !h.is_ascii() { return Err(format!("hex must be ASCII: {s}")); }
let n = |i: usize| u8::from_str_radix(&h[i..i + 2], 16).map(|v| v as f32 / 255.0);
let px = |i: usize| n(i).map_err(|e| format!("bad hex {s}: {e}"));
match h.len() {
6 => Ok(Rgba([px(0)?, px(2)?, px(4)?, 1.0])),
8 => Ok(Rgba([px(0)?, px(2)?, px(4)?, px(6)?])),
_ => Err(format!("hex must be 6 or 8 digits: {s}")),
}
}
}
impl<'de> Deserialize<'de> for Rgba {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let s = String::deserialize(d)?;
Rgba::from_hex(&s).map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Default, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Shape { #[default] Circle, Square, Diamond }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NodeRole { Normal, Focus, Selected, History, Candidate, Provisional }
impl NodeRole {
pub fn as_str(&self) -> &'static str {
match self { NodeRole::Normal=>"normal", NodeRole::Focus=>"focus", NodeRole::Selected=>"selected", NodeRole::History=>"history", NodeRole::Candidate=>"candidate", NodeRole::Provisional=>"provisional" }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EdgeRole { Normal, HistoryLink, CandidateLink, Provisional }
impl EdgeRole {
pub fn as_str(&self) -> &'static str {
match self { EdgeRole::Normal=>"normal", EdgeRole::HistoryLink=>"history", EdgeRole::CandidateLink=>"candidate", EdgeRole::Provisional=>"provisional" }
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct NodeStyle { pub color: [f32;4], pub radius: f32, pub opacity: f32, pub shape: Shape, pub label_visible: bool, pub label_text: Option<String> }
#[derive(Debug, Clone, PartialEq)]
pub struct EdgeStyle { pub color: [f32;4], pub opacity: f32, pub width: f32 }
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PartialNodeStyle {
#[serde(default)] pub color: Option<Rgba>,
#[serde(default)] pub radius: Option<f32>,
#[serde(default)] pub opacity: Option<f32>,
#[serde(default)] pub shape: Option<Shape>,
#[serde(default)] pub label_visible: Option<bool>,
#[serde(default)] pub label_text: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PartialEdgeStyle {
#[serde(default)] pub color: Option<Rgba>,
#[serde(default)] pub opacity: Option<f32>,
#[serde(default)] pub width: Option<f32>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Selector {
#[serde(default)] pub role: Option<String>,
#[serde(default)] pub attr: Option<String>,
#[serde(default)] pub equals: Option<Value>,
#[serde(default)] pub one_of: Option<Vec<Value>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ScalableNodeProp { Radius, Opacity }
#[derive(Debug, Clone, Copy, PartialEq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ScalableEdgeProp { Width, Opacity }
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct NodeScale { pub by: String, pub property: ScalableNodeProp, pub domain: [f32;2], pub range: [f32;2] }
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EdgeScale { pub by: String, pub property: ScalableEdgeProp, pub domain: [f32;2], pub range: [f32;2] }
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct NodeRule { #[serde(default)] pub when: Option<Selector>, #[serde(default)] pub set: Option<PartialNodeStyle>, #[serde(default)] pub scale: Option<NodeScale> }
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EdgeRule { #[serde(default)] pub when: Option<Selector>, #[serde(default)] pub set: Option<PartialEdgeStyle>, #[serde(default)] pub scale: Option<EdgeScale> }
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct StyleSpec {
pub node_base: NodeBaseSpec,
#[serde(default)] pub node_rules: Vec<NodeRule>,
pub edge_base: EdgeBaseSpec,
#[serde(default)] pub edge_rules: Vec<EdgeRule>,
#[serde(default)] pub labels: LabelLodSpec,
}
#[derive(Debug, Clone, Copy, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct LabelLodSpec {
pub min_screen_radius_px: f32,
pub max_labels: usize,
}
impl Default for LabelLodSpec {
fn default() -> Self { Self { min_screen_radius_px: 8.0, max_labels: 200 } }
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub 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 }
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EdgeBaseSpec { pub color: Rgba, #[serde(default = "one")] pub opacity: f32, pub width: f32 }
fn one() -> f32 { 1.0 }
fn yes() -> bool { true }
impl Selector {
fn matches(&self, attrs: &Attrs, role_str: &str) -> bool {
if let Some(r) = &self.role { if r != role_str { return false; } }
if let Some(a) = &self.attr {
let v = attrs.get(a);
if let Some(eq) = &self.equals { if v != Some(eq) { return false; } }
if let Some(list) = &self.one_of { if !v.is_some_and(|v| list.contains(v)) { return false; } }
if self.equals.is_none() && self.one_of.is_none() && v.is_none() { return false; }
}
true
}
}
fn lerp_clamp(x: f32, d: [f32;2], r: [f32;2]) -> f32 {
let span = d[1] - d[0];
if span == 0.0 { return r[0]; }
let t = ((x - d[0]) / span).clamp(0.0, 1.0);
r[0] + t * (r[1] - r[0])
}
impl StyleSpec {
pub fn node(&self, attrs: &Attrs, role: NodeRole) -> NodeStyle {
let b = &self.node_base;
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 };
let role_str = role.as_str();
for rule in &self.node_rules {
if let Some(sel) = &rule.when { if !sel.matches(attrs, role_str) { continue; } }
if let Some(set) = &rule.set {
if let Some(c) = &set.color { s.color = c.0; }
if let Some(v) = set.radius { s.radius = v; }
if let Some(v) = set.opacity { s.opacity = v; }
if let Some(v) = set.shape { s.shape = v; }
if let Some(v) = set.label_visible { s.label_visible = v; }
if let Some(v) = &set.label_text { s.label_text = Some(v.clone()); }
}
if let Some(sc) = &rule.scale {
if let Some(x) = attrs.get(&sc.by).and_then(|v| v.as_f64()) {
let mapped = lerp_clamp(x as f32, sc.domain, sc.range);
match sc.property { ScalableNodeProp::Radius => s.radius = mapped, ScalableNodeProp::Opacity => s.opacity = mapped }
}
}
}
s
}
pub fn edge(&self, attrs: &Attrs, role: EdgeRole) -> EdgeStyle {
let b = &self.edge_base;
let mut s = EdgeStyle { color: b.color.0, opacity: b.opacity, width: b.width };
let role_str = role.as_str();
for rule in &self.edge_rules {
if let Some(sel) = &rule.when { if !sel.matches(attrs, role_str) { continue; } }
if let Some(set) = &rule.set {
if let Some(c) = &set.color { s.color = c.0; }
if let Some(v) = set.opacity { s.opacity = v; }
if let Some(v) = set.width { s.width = v; }
}
if let Some(sc) = &rule.scale {
if let Some(x) = attrs.get(&sc.by).and_then(|v| v.as_f64()) {
let mapped = lerp_clamp(x as f32, sc.domain, sc.range);
match sc.property { ScalableEdgeProp::Width => s.width = mapped, ScalableEdgeProp::Opacity => s.opacity = mapped }
}
}
}
s
}
pub fn label_lod(&self) -> LabelLodSpec { self.labels }
}
impl Default for StyleSpec {
fn default() -> Self {
let json = r##"{
"node_base": { "color": "#1f70eb", "radius": 14, "opacity": 1, "shape": "circle", "label_visible": true },
"node_rules": [
{ "when": { "role": "focus" }, "set": { "color": "#e3b341", "radius": 20 } },
{ "when": { "role": "selected" }, "set": { "color": "#3399ff", "radius": 18 } },
{ "when": { "role": "history" }, "set": { "color": "#59667a", "radius": 12 } },
{ "when": { "role": "provisional" }, "set": { "color": "#5a6d8c", "radius": 11, "opacity": 0.45 } }
],
"edge_base": { "color": "#3a4252", "opacity": 1, "width": 1.5 },
"edge_rules": [
{ "when": { "role": "provisional" }, "set": { "color": "#2b3444", "opacity": 0.35, "width": 1.0 } }
]
}"##;
serde_json::from_str(json).expect("built-in default spec must parse")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rgba_parses_hex() {
assert_eq!(Rgba::from_hex("#1f6feb").unwrap().0, [0.12156863, 0.43529412, 0.92156863, 1.0]);
assert_eq!(Rgba::from_hex("#00000080").unwrap().0[3], 128.0 / 255.0);
assert!(Rgba::from_hex("nope").is_err());
}
#[test]
fn stylespec_deserializes_from_json() {
let json = r##"{
"node_base": { "color": "#1f6feb", "radius": 14, "opacity": 1, "shape": "circle", "label_visible": true },
"node_rules": [
{ "when": { "role": "focus" }, "set": { "color": "#e3b341", "radius": 20 } },
{ "scale": { "by": "degree", "property": "radius", "domain": [1, 10], "range": [8, 24] } }
],
"edge_base": { "color": "#3a4252", "opacity": 1, "width": 1.5 },
"edge_rules": []
}"##;
let spec: StyleSpec = serde_json::from_str(json).unwrap();
assert_eq!(spec.node_rules.len(), 2);
assert_eq!(spec.node_base.radius, 14.0);
}
fn spec_json(rules: &str) -> StyleSpec {
let json = format!(r##"{{
"node_base": {{ "color": "#1f6feb", "radius": 14 }},
"node_rules": [{rules}],
"edge_base": {{ "color": "#3a4252", "width": 1.5 }},
"edge_rules": []
}}"##);
serde_json::from_str(&json).unwrap()
}
#[test]
fn categorical_rule_matches_role_and_attr() {
let spec = spec_json(r##"
{ "when": { "role": "focus" }, "set": { "color": "#e3b341", "radius": 20 } },
{ "when": { "attr": "group", "equals": "input" }, "set": { "shape": "square" } }
"##);
let mut attrs = Attrs::new();
attrs.insert("group".into(), serde_json::json!("input"));
let s = spec.node(&attrs, NodeRole::Focus);
assert_eq!(s.color, [0.8901961, 0.7019608, 0.25490198, 1.0]);
assert_eq!(s.radius, 20.0);
assert_eq!(s.shape, Shape::Square);
let base = spec.node(&Attrs::new(), NodeRole::Normal);
assert_eq!(base.radius, 14.0);
assert_eq!(base.shape, Shape::Circle);
}
#[test]
fn scale_rule_maps_and_clamps() {
let spec = spec_json(r##"{ "scale": { "by": "deg", "property": "radius", "domain": [1, 10], "range": [8, 24] } }"##);
let at = |v: f64| { let mut a = Attrs::new(); a.insert("deg".into(), serde_json::json!(v)); a };
assert_eq!(spec.node(&at(1.0), NodeRole::Normal).radius, 8.0);
assert_eq!(spec.node(&at(10.0), NodeRole::Normal).radius, 24.0);
assert_eq!(spec.node(&at(5.5), NodeRole::Normal).radius, 16.0); assert_eq!(spec.node(&at(100.0), NodeRole::Normal).radius, 24.0); assert_eq!(spec.node(&at(-5.0), NodeRole::Normal).radius, 8.0); }
#[test]
fn later_rules_win() {
let spec = spec_json(r##"
{ "set": { "color": "#111111" } },
{ "set": { "color": "#222222" } }
"##);
assert_eq!(spec.node(&Attrs::new(), NodeRole::Normal).color, Rgba::from_hex("#222222").unwrap().0);
}
fn approx(a: [f32;4], b: [f32;4]) -> bool { a.iter().zip(b).all(|(x,y)| (x-y).abs() < 0.02) }
#[test]
fn default_spec_matches_current_look() {
let spec = StyleSpec::default();
let normal = spec.node(&Attrs::new(), NodeRole::Normal);
assert!(approx(normal.color, [0.12, 0.44, 0.92, 1.0]));
assert_eq!(normal.radius, 14.0);
assert_eq!(normal.shape, Shape::Circle);
let focus = spec.node(&Attrs::new(), NodeRole::Focus);
assert!(approx(focus.color, [0.89, 0.70, 0.25, 1.0]));
assert_eq!(focus.radius, 20.0);
let hist = spec.node(&Attrs::new(), NodeRole::History);
assert_eq!(hist.radius, 12.0);
let e = spec.edge(&Attrs::new(), EdgeRole::Normal);
assert_eq!(e.width, 1.5);
}
#[test]
fn from_hex_rejects_non_ascii_without_panicking() {
assert!(Rgba::from_hex("#1f6f€b").is_err());
}
#[test]
fn scale_with_degenerate_domain_is_finite() {
let spec: StyleSpec = serde_json::from_str(r##"{
"node_base": { "color": "#1f6feb", "radius": 14 },
"node_rules": [ { "scale": { "by": "d", "property": "radius", "domain": [5,5], "range": [10,20] } } ],
"edge_base": { "color": "#3a4252", "width": 1.5 }, "edge_rules": []
}"##).unwrap();
let mut a = Attrs::new(); a.insert("d".into(), serde_json::json!(5.0));
let r = spec.node(&a, NodeRole::Normal).radius;
assert!(r.is_finite());
assert_eq!(r, 10.0);
}
#[test]
fn unknown_selector_key_is_rejected() {
let bad = r##"{
"node_base": { "color": "#1f6feb", "radius": 14 },
"node_rules": [ { "when": { "attrs": "group" }, "set": { "shape": "square" } } ],
"edge_base": { "color": "#3a4252", "width": 1.5 }, "edge_rules": []
}"##;
assert!(serde_json::from_str::<StyleSpec>(bad).is_err());
}
#[test]
fn provisional_roles_stringify_for_selectors() {
assert_eq!(NodeRole::Provisional.as_str(), "provisional");
assert_eq!(EdgeRole::Provisional.as_str(), "provisional");
}
#[test]
fn default_spec_ghosts_provisional_nodes_and_edges() {
let spec = StyleSpec::default();
let n = spec.node(&Attrs::new(), NodeRole::Provisional);
assert_eq!(n.color, Rgba::from_hex("#5a6d8c").unwrap().0);
assert_eq!(n.radius, 11.0);
assert_eq!(n.opacity, 0.45);
let e = spec.edge(&Attrs::new(), EdgeRole::Provisional);
assert_eq!(e.color, Rgba::from_hex("#2b3444").unwrap().0);
assert_eq!(e.opacity, 0.35);
assert_eq!(e.width, 1.0);
}
#[test]
fn provisional_ghosting_stays_clickable() {
let spec = StyleSpec::default();
assert!(spec.node(&Attrs::new(), NodeRole::Provisional).opacity > 0.05);
}
#[test]
fn a_host_rule_overrides_the_built_in_provisional_rule_selectively() {
let mut spec = StyleSpec::default();
spec.node_rules.push(serde_json::from_str(
r##"{ "when": { "role": "provisional" }, "set": { "color": "#ff00aa" } }"##).unwrap());
let n = spec.node(&Attrs::new(), NodeRole::Provisional);
assert_eq!(n.color, Rgba::from_hex("#ff00aa").unwrap().0);
assert_eq!(n.radius, 11.0, "the built-in radius survives a colour-only override");
assert_eq!(n.opacity, 0.45);
}
#[test]
fn a_host_edge_rule_overrides_the_built_in_provisional_edge_rule_selectively() {
let mut spec = StyleSpec::default();
spec.edge_rules.push(serde_json::from_str(
r##"{ "when": { "role": "provisional" }, "set": { "width": 4.0 } }"##).unwrap());
let e = spec.edge(&Attrs::new(), EdgeRole::Provisional);
assert_eq!(e.width, 4.0);
assert_eq!(e.opacity, 0.35, "the built-in opacity survives a width-only override");
}
#[test]
fn a_replacement_spec_without_provisional_rules_falls_back_to_base() {
let json = r##"{
"node_base": { "color": "#1f70eb", "radius": 14 },
"edge_base": { "color": "#3a4252", "width": 1.5 }
}"##;
let spec: StyleSpec = serde_json::from_str(json).unwrap();
let n = spec.node(&Attrs::new(), NodeRole::Provisional);
assert_eq!(n.color, Rgba::from_hex("#1f70eb").unwrap().0);
assert_eq!(n.opacity, 1.0);
}
#[test]
fn provisional_composes_with_an_attribute_selector() {
let mut spec = StyleSpec::default();
spec.node_rules.push(serde_json::from_str(
r##"{ "when": { "role": "provisional", "attr": "kind", "equals": "Person" },
"set": { "color": "#ff00aa" } }"##).unwrap());
let mut person = Attrs::new();
person.insert("kind".into(), serde_json::json!("Person"));
let p = spec.node(&person, NodeRole::Provisional);
assert_eq!(p.color, Rgba::from_hex("#ff00aa").unwrap().0, "matching attr overrides the ghost colour");
assert_eq!(p.opacity, 0.45, "the built-in opacity survives a colour-only override");
assert_eq!(spec.node(&Attrs::new(), NodeRole::Provisional).color, Rgba::from_hex("#5a6d8c").unwrap().0);
assert_eq!(spec.node(&person, NodeRole::Normal).color, Rgba::from_hex("#1f70eb").unwrap().0);
}
#[test]
fn label_lod_defaults_and_parses() {
let s: StyleSpec = serde_json::from_str(
r##"{ "node_base": {"color":"#ffffff","radius":8}, "edge_base": {"color":"#888888","width":1} }"##).unwrap();
assert_eq!(s.labels.min_screen_radius_px, 8.0);
assert_eq!(s.labels.max_labels, 200);
let s: StyleSpec = serde_json::from_str(
r##"{ "node_base": {"color":"#ffffff","radius":8}, "edge_base": {"color":"#888888","width":1},
"labels": { "max_labels": 50 } }"##).unwrap();
assert_eq!(s.labels.max_labels, 50);
assert_eq!(s.labels.min_screen_radius_px, 8.0, "partial keeps other defaults");
}
#[test]
fn label_lod_rejects_unknown_fields() {
assert!(serde_json::from_str::<StyleSpec>(
r##"{ "node_base": {"color":"#ffffff","radius":8}, "edge_base": {"color":"#888888","width":1},
"labels": { "wobble": 1 } }"##).is_err());
}
}