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, 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 #[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#[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#[derive(Debug, Clone, Copy, Deserialize)]
128#[serde(deny_unknown_fields, default)]
129pub struct LabelLodSpec {
130 pub min_screen_radius_px: f32,
133 pub max_labels: usize,
137 pub edge_min_screen_length_px: f32,
140 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#[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 #[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
180fn 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 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 pub fn label_lod(&self) -> LabelLodSpec { self.labels }
275
276 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 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 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 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 #[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 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 #[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 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 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); assert_eq!(spec.node(&at(100.0), NodeRole::Normal).radius, 24.0); assert_eq!(spec.node(&at(-5.0), NodeRole::Normal).radius, 8.0); }
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 let spec = StyleSpec::default();
550 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 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 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 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 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 assert_eq!(spec.node(&Attrs::new(), NodeRole::Provisional).color, Rgba::from_hex("#5a6d8c").unwrap().0);
611
612 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 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}