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#[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#[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#[derive(Debug, Clone, Copy, Deserialize)]
124#[serde(deny_unknown_fields, default)]
125pub struct LabelLodSpec {
126 pub min_screen_radius_px: f32,
129 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#[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 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 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 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); assert_eq!(spec.node(&at(100.0), NodeRole::Normal).radius, 24.0); assert_eq!(spec.node(&at(-5.0), NodeRole::Normal).radius, 8.0); }
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 let spec = StyleSpec::default();
384 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 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 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 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 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 assert_eq!(spec.node(&Attrs::new(), NodeRole::Provisional).color, Rgba::from_hex("#5a6d8c").unwrap().0);
445
446 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}