1use std::cell::{Cell, RefCell};
6use std::rc::Rc;
7use std::sync::Arc;
8
9use ravenrs_core::{
10 ColorStop, CursorIcon, Effect, Key, PathElement, PathFill, PathPaint, Point, Quad, Rect, Role, Size,
11};
12use ravenrs_graphics::chart::{ChartLabel, LabelAlign};
13use ravenrs_graphics::sankey::{self, SankeyHit, SankeyLayout, SankeyLink, SankeyStyle};
14use ravenrs_layout::{Align, Axis, LayoutStyle};
15use ravenrs_reactive::State;
16use ravenrs_style::{Semantic, TextStyle, Theme, Weight};
17use ravenrs_text::TextLayout;
18
19pub use ravenrs_graphics::sankey::SankeyAlign;
20
21use crate::chart::{self, ChartTooltip, KeyRow, TooltipPanel, ValueFormat};
22use crate::context::{BuildCx, NodeId, Paint, PaintCx, Placement, Semantics};
23use crate::motion::Motion;
24use crate::{View, util};
25
26const FLOW_ALPHA: f32 = 0.38;
28const FLOW_FOCUS_ALPHA: f32 = 0.7;
30const FOCUS_DIM: f32 = 0.7;
32const NODE_RADIUS: f32 = 2.0;
33const LABEL_GAP: f32 = 6.0;
34const SPOKEN_FLOWS: usize = 16;
36const TOOLTIP_FLOWS: usize = 8;
37
38type Item = usize;
40
41#[derive(PartialEq)]
43struct Laid {
44 size: Size,
45 links: Vec<SankeyLink>,
46 names: Vec<String>,
47 text_size: f32,
48 align: SankeyAlign,
49}
50
51#[derive(Default)]
53struct Picture {
54 key: Option<Laid>,
55 layout: SankeyLayout,
56 paths: Vec<Arc<[PathElement]>>,
57 labels: Vec<(TextLayout, Point, ravenrs_core::Color)>,
58}
59
60#[cfg_attr(feature = "live", ravenrs_live::expose(part = "part1"))]
61impl Picture {
62 fn refresh(&mut self, chart: &SankeyChart, size: Size, ink: ravenrs_core::Color, cx: &mut PaintCx<'_>) {
63 let key = Laid {
64 size,
65 links: chart.links.clone(),
66 names: chart.names.clone(),
67 text_size: chart.text_size,
68 align: chart.align,
69 };
70 if self.key.as_ref() == Some(&key) {
71 return;
72 }
73 let mut style = SankeyStyle::default();
74 style.align = chart.align;
75 let area = Rect::new(0.0, 0.0, size.width, size.height);
76 let layout = sankey::layout(chart.names.len(), &chart.links, area, &style).unwrap_or_default();
77 self.paths = layout.flows.iter().map(|flow| chart::path_elements(&flow.path()).into()).collect();
78 let height = chart.text_size * 1.3;
79 self.labels = layout
80 .nodes
81 .iter()
82 .zip(&chart.names)
83 .map(|(node, name)| {
84 let last = node.column + 1 == layout.columns && layout.columns > 1;
86 let (x, align) = if last {
87 (node.rect.x() - LABEL_GAP - size.width, LabelAlign::Right)
88 } else {
89 (node.rect.max_x() + LABEL_GAP, LabelAlign::Left)
90 };
91 let middle = node.rect.center().y;
92 let label = ChartLabel {
93 text: name.clone(),
94 rect: Rect::new(x, middle - height / 2.0, size.width, height),
95 align,
96 size: chart.text_size,
97 bold: false,
98 color: ink,
99 };
100 chart::typeset(&label, cx)
101 })
102 .collect();
103 self.layout = layout;
104 self.key = Some(key);
105 }
106}
107
108#[derive(Clone, Copy)]
110struct Focus {
111 item: Item,
112 strength: f32,
114}
115
116#[cfg_attr(feature = "live", ravenrs_live::expose)]
117impl Focus {
118 fn touches_flow(&self, layout: &SankeyLayout, flow: usize) -> bool {
120 let nodes = layout.nodes.len();
121 if self.item >= nodes {
122 return self.item - nodes == flow;
123 }
124 layout.flows.get(flow).is_some_and(|f| f.source == self.item || f.target == self.item)
125 }
126
127 fn touches_node(&self, layout: &SankeyLayout, node: usize) -> bool {
128 let nodes = layout.nodes.len();
129 if self.item >= nodes {
130 return layout.flows.get(self.item - nodes).is_some_and(|f| f.source == node || f.target == node);
131 }
132 self.item == node
133 || layout.flows.iter().any(|f| {
134 (f.source == self.item && f.target == node) || (f.target == self.item && f.source == node)
135 })
136 }
137}
138
139#[derive(Clone)]
159#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
160pub struct SankeyChart {
161 names: Vec<String>,
162 links: Vec<SankeyLink>,
163 colors: Vec<(String, Semantic)>,
164 title: String,
165 alt: Option<String>,
166 height: f32,
167 width: Option<f32>,
168 align: SankeyAlign,
169 format: ValueFormat,
170 gradient: bool,
171 text_size: f32,
172 no_tooltip: bool,
173 appear: bool,
174 appear_key: u64,
175}
176
177impl std::fmt::Debug for SankeyChart {
178 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
179 f.debug_struct("SankeyChart")
180 .field("title", &self.title)
181 .field("nodes", &self.names.len())
182 .field("flows", &self.links.len())
183 .finish_non_exhaustive()
184 }
185}
186
187impl Default for SankeyChart {
188 fn default() -> Self {
189 Self::new()
190 }
191}
192
193#[cfg_attr(feature = "live", ravenrs_live::expose)]
194impl SankeyChart {
195 pub fn new() -> Self {
197 Self {
198 names: Vec::new(),
199 links: Vec::new(),
200 colors: Vec::new(),
201 title: String::new(),
202 alt: None,
203 height: 300.0,
204 width: None,
205 align: SankeyAlign::default(),
206 format: Rc::new(chart::plain_number),
207 gradient: true,
208 text_size: 12.0,
209 no_tooltip: false,
210 appear: true,
211 appear_key: 0,
212 }
213 }
214
215 fn node_of(&mut self, name: String) -> usize {
216 if let Some(found) = self.names.iter().position(|known| *known == name) {
217 return found;
218 }
219 self.names.push(name);
220 self.names.len() - 1
221 }
222
223 pub fn flow(mut self, source: impl Into<String>, target: impl Into<String>, value: f64) -> Self {
225 let source = self.node_of(source.into());
226 let target = self.node_of(target.into());
227 self.links.push(SankeyLink { source, target, value });
228 self
229 }
230
231 pub fn title(mut self, title: impl Into<String>) -> Self {
233 self.title = title.into();
234 self
235 }
236
237 pub fn alt(mut self, text: impl Into<String>) -> Self {
240 self.alt = Some(text.into());
241 self
242 }
243
244 pub fn height(mut self, height: f32) -> Self {
246 self.height = height;
247 self
248 }
249
250 pub fn size(mut self, width: f32, height: f32) -> Self {
252 self.width = Some(width);
253 self.height = height;
254 self
255 }
256
257 pub fn align(mut self, align: SankeyAlign) -> Self {
259 self.align = align;
260 self
261 }
262
263 pub fn format(mut self, format: impl Fn(f64) -> String + 'static) -> Self {
265 self.format = Rc::new(format);
266 self
267 }
268
269 pub fn node_color(mut self, name: impl Into<String>, color: Semantic) -> Self {
271 self.colors.push((name.into(), color));
272 self
273 }
274
275 pub fn no_gradient(mut self) -> Self {
277 self.gradient = false;
278 self
279 }
280
281 pub fn hide_tooltip(mut self) -> Self {
283 self.no_tooltip = true;
284 self
285 }
286
287 pub fn no_appear(mut self) -> Self {
290 self.appear = false;
291 self
292 }
293
294 pub fn appear_key(mut self, key: impl std::hash::Hash) -> Self {
296 self.appear_key = chart::appear_key_of(key);
297 self
298 }
299
300 fn color_of(&self, node: usize, colors: &[ravenrs_core::Color], theme: &Theme) -> ravenrs_core::Color {
301 let name = &self.names[node];
302 match self.colors.iter().find(|(known, _)| known == name) {
303 Some((_, role)) => theme.color(*role),
304 None => colors[node % colors.len()],
305 }
306 }
307
308 fn usable(&self) -> impl Iterator<Item = (usize, &SankeyLink)> {
309 self.links.iter().filter(|link| link.value.is_finite() && link.value > 0.0).enumerate()
310 }
311
312 fn spoken(&self) -> String {
314 let mut spoken: Vec<String> = self
315 .usable()
316 .take(SPOKEN_FLOWS)
317 .map(|(_, link)| {
318 let (from, to) = (&self.names[link.source], &self.names[link.target]);
319 format!("{from} → {to}: {}", (self.format)(link.value))
320 })
321 .collect();
322 if self.usable().count() > SPOKEN_FLOWS {
323 spoken.push("…".to_owned());
324 }
325 spoken.join("; ")
326 }
327
328 fn panel(&self, item: Item, colors: &[ravenrs_core::Color], theme: &Theme) -> TooltipPanel {
330 let nodes = self.names.len();
331 let flows: Vec<&SankeyLink> = self.usable().map(|(_, link)| link).collect();
332 let row = |name: String, node: usize, value: f64| {
333 KeyRow::named(name, self.color_of(node, colors, theme), None, Some(value))
334 };
335 let (title, rows, total) = if item < nodes {
336 let incoming = flows.iter().filter(|l| l.target == item);
337 let outgoing = flows.iter().filter(|l| l.source == item);
338 let coming: f64 = incoming.clone().map(|l| l.value).sum();
339 let going: f64 = outgoing.clone().map(|l| l.value).sum();
340 let rows: Vec<KeyRow> = incoming
341 .map(|l| row(format!("← {}", self.names[l.source]), l.source, l.value))
342 .chain(outgoing.map(|l| row(format!("→ {}", self.names[l.target]), l.target, l.value)))
343 .take(TOOLTIP_FLOWS)
344 .collect();
345 let name = self.names[item].clone();
346 (name.clone(), rows, Some((name, coming.max(going))))
347 } else {
348 let link = flows.get(item - nodes).copied();
349 let title = link
350 .map_or(String::new(), |l| format!("{} → {}", self.names[l.source], self.names[l.target]));
351 let rows =
352 link.map_or_else(Vec::new, |l| vec![row(self.names[l.target].clone(), l.target, l.value)]);
353 (title, rows, None)
354 };
355 TooltipPanel {
356 title: Some(title.clone()),
357 rows,
358 total,
359 tooltip: ChartTooltip::new(),
360 format: self.format.clone(),
361 index: item,
362 category: title,
363 }
364 }
365
366 fn anchor(layout: &SankeyLayout, item: Item) -> Option<Point> {
368 let nodes = layout.nodes.len();
369 if item < nodes {
370 let rect = layout.nodes[item].rect;
371 return Some(Point::new(rect.center().x, rect.center().y));
372 }
373 let flow = layout.flows.get(item - nodes)?;
374 Some(Point::new((flow.from.x + flow.to.x) / 2.0, (flow.from.y + flow.to.y) / 2.0))
375 }
376}
377
378fn flow_paint(
380 from: ravenrs_core::Color,
381 to: ravenrs_core::Color,
382 flow: &sankey::SankeyFlow,
383 alpha: f32,
384) -> PathPaint {
385 PathPaint::Linear {
386 start: flow.from,
387 end: flow.to,
388 stops: Arc::from(
389 [
390 ColorStop { offset: 0.0, color: from.with_alpha(alpha) },
391 ColorStop { offset: 1.0, color: to.with_alpha(alpha) },
392 ]
393 .as_slice(),
394 ),
395 }
396}
397
398impl View for SankeyChart {
399 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
400 let theme = *cx.theme();
401 if let Err(problem) = sankey::check(self.names.len(), &self.links) {
402 return self.problem(cx, &problem, &theme);
403 }
404 let colors = chart::palette(&theme);
405 let picture = cx.use_state(|| Rc::new(RefCell::new(Picture::default()))).peek().clone();
406 let active: State<Option<Item>> = cx.use_state(|| None::<Item>);
407 let hovered = cx.is_hover_within();
408 let focused = cx.is_focused();
409 let draw_in = if self.appear { theme.motion.draw_in } else { ravenrs_animation::Transition::Instant };
412 let progress = cx.appear(self.appear_key, draw_in);
413 let shown = active.get().filter(|_| (hovered || focused) && !self.no_tooltip && progress >= 1.0);
414
415 let held = cx.use_state(|| Rc::new(Cell::new(None::<Item>))).peek().clone();
417 if shown.is_some() {
418 held.set(shown);
419 }
420 let m = theme.motion;
421 let strength = cx.animated(if shown.is_some() { 1.0_f32 } else { 0.0 }, m.base);
422 let focus = held.get().filter(|_| strength > 0.001).map(|item| Focus { item, strength });
423
424 let mut children = Vec::new();
425 let spot = shown.and_then(|item| {
426 let picture = picture.borrow();
427 let at = Self::anchor(&picture.layout, item)?;
428 let past = at.x > picture.key.as_ref().map_or(0.0, |key| key.size.width) / 2.0;
429 Some((at, if past { Placement::LeftCenter } else { Placement::RightCenter }))
430 });
431 let anchor = spot.map(|(at, _)| {
432 cx.container(
433 LayoutStyle {
434 absolute: Some((at.x, at.y)),
435 width: Some(0.0),
436 height: Some(0.0),
437 ..LayoutStyle::leaf()
438 },
439 &[],
440 Paint::None,
441 )
442 });
443 children.extend(anchor);
444
445 let mut own = self.clone();
446 own.text_size = theme.text(TextStyle::Caption).size;
447 let (ink, radius) = (theme.color(Semantic::Ink), NODE_RADIUS);
448 let node_colors: Vec<ravenrs_core::Color> =
449 (0..self.names.len()).map(|node| self.color_of(node, &colors, &theme)).collect();
450 let (seen, gradient) = (picture.clone(), self.gradient);
451 let node = cx.container(
452 LayoutStyle { grow: 1.0, align_self: Some(Align::Stretch), ..LayoutStyle::leaf() },
453 &children,
454 Paint::Rich(Rc::new(move |frame: Rect, paint: &mut PaintCx<'_>| {
455 if frame.is_empty() {
456 return;
457 }
458 let mut picture = seen.borrow_mut();
459 picture.refresh(&own, frame.size, ink, paint);
460 let drawing_in = progress < 1.0;
461 if drawing_in {
462 let whole = Rect::new(0.0, 0.0, frame.width(), frame.height());
463 paint.scene.push_clip(chart::revealed(frame, whole, progress.max(0.0)));
464 }
465 let layout = &picture.layout;
466 for (index, flow) in layout.flows.iter().enumerate() {
467 let on = focus.is_some_and(|f| f.touches_flow(layout, index));
468 let dim = focus.map_or(0.0, |f| if on { 0.0 } else { FOCUS_DIM * f.strength });
469 let lift = focus.map_or(0.0, |f| if on { f.strength } else { 0.0 });
470 let alpha = (FLOW_ALPHA + (FLOW_FOCUS_ALPHA - FLOW_ALPHA) * lift) * (1.0 - dim);
471 let from = node_colors[flow.source];
472 let to = if gradient { node_colors[flow.target] } else { from };
473 let Some(elements) = picture.paths.get(index) else { continue };
474 paint.scene.push_path(PathFill {
475 elements: elements.clone(),
476 paint: flow_paint(from, to, flow, alpha),
477 transform: [1.0, 0.0, 0.0, 1.0, frame.x(), frame.y()],
478 ..PathFill::solid(elements.clone(), from)
479 });
480 }
481 for (index, node) in layout.nodes.iter().enumerate() {
482 let on = focus.is_none_or(|f| f.touches_node(layout, index));
483 let dim = focus.map_or(0.0, |f| if on { 0.0 } else { FOCUS_DIM * f.strength });
484 let rect = Rect::new(
485 frame.x() + node.rect.x(),
486 frame.y() + node.rect.y(),
487 node.rect.width(),
488 node.rect.height(),
489 );
490 let color = node_colors[index];
491 paint
492 .scene
493 .push_quad(Quad::new(rect, color.with_alpha(color.a * (1.0 - dim))).radius(radius));
494 }
495 for (layout, at, color) in &picture.labels {
496 let origin = Point::new(frame.x() + at.x, frame.y() + at.y);
497 layout.paint(origin, *color, paint.scene);
498 }
499 if drawing_in {
500 paint.scene.pop_clip();
501 }
502 })),
503 );
504
505 if !self.no_tooltip {
506 let (write, seen) = (active.clone(), picture.clone());
507 cx.on_pointer_move(
508 node,
509 Rc::new(move |position, frame| {
510 let local = Point::new(position.x - frame.x(), position.y - frame.y());
511 let picture = seen.borrow();
512 let hit = picture.layout.hit(local).map(|hit| match hit {
513 SankeyHit::Node(node) => node,
514 SankeyHit::Flow(flow) => picture.layout.nodes.len() + flow,
515 });
516 if write.get() != hit {
517 write.set(hit);
518 }
519 }),
520 );
521 cx.cursor(node, CursorIcon::Default);
522 let (write, seen) = (active.clone(), picture);
523 cx.on_key(
524 node,
525 Rc::new(move |key, _| {
526 let picture = seen.borrow();
527 let count = picture.layout.nodes.len() + picture.layout.flows.len();
528 if count == 0 {
529 return false;
530 }
531 let last = count - 1;
532 let now = write.get();
533 let next = match key {
534 Key::ArrowRight | Key::ArrowDown => now.map_or(0, |i| (i + 1).min(last)),
535 Key::ArrowLeft | Key::ArrowUp => now.map_or(last, |i| i.saturating_sub(1)),
536 Key::Home => 0,
537 Key::End => last,
538 Key::Escape if now.is_some() => {
539 write.set(None);
540 return true;
541 }
542 _ => return false,
543 };
544 write.set(Some(next));
545 true
546 }),
547 );
548 cx.focusable(node, theme.radii.sm);
549 }
550
551 if let (Some(item), Some((_, placement)), Some(anchor)) = (shown, spot, anchor) {
552 cx.overlay(anchor, placement, &self.panel(item, &colors, &theme), None);
553 }
554
555 let name = self.alt.as_deref().unwrap_or(&self.title);
556 let values = self.spoken();
557 let mut semantics = if name.is_empty() {
558 Semantics::new(Role::Image).name(values)
559 } else {
560 Semantics::new(Role::Image).name(name.to_owned()).description(values)
561 };
562 if let Some(item) = shown {
563 semantics = semantics.value(self.panel(item, &colors, &theme).spoken());
564 }
565 cx.semantics(node, semantics);
566 if !self.appear {
567 cx.motion(node, Motion::new().enter(Effect::hidden(), m.base));
568 }
569
570 let mut parts = Vec::new();
571 if !self.title.is_empty() {
572 let ink = theme.color(Semantic::Ink);
573 parts.push(util::label(cx, &self.title, TextStyle::Callout, Some(Weight::SEMIBOLD), ink));
574 }
575 parts.push(node);
576 cx.container(
577 LayoutStyle {
578 width: self.width,
579 height: Some(self.height),
580 spacing: 8.0,
581 align: Align::Stretch,
582 align_self: self.width.is_none().then_some(Align::Stretch),
583 ..LayoutStyle::stack(Axis::Vertical)
584 },
585 &parts,
586 Paint::None,
587 )
588 }
589}
590
591#[cfg_attr(feature = "live", ravenrs_live::expose(part = "part1"))]
592impl SankeyChart {
593 fn problem(&self, cx: &mut BuildCx<'_>, problem: &sankey::SankeyError, theme: &Theme) -> NodeId {
595 let names = |nodes: &[usize]| {
596 let names: Vec<&str> =
597 nodes.iter().filter_map(|n| self.names.get(*n)).map(String::as_str).collect();
598 names.join(" → ")
599 };
600 let text = match problem {
601 sankey::SankeyError::Cycle(nodes) => format!("{}: {}", cx.labels().sankey_cycle, names(nodes)),
602 other => other.to_string(),
603 };
604 let node = util::label(cx, &text, TextStyle::Caption, None, theme.color(Semantic::Danger));
605 cx.semantics(node, Semantics::new(Role::Label).name(text));
606 node
607 }
608}