Skip to main content

fission_charts/
chart.rs

1use crate::axis::{Axis, AxisType};
2use crate::components::{
3    AxisPointer, ChartGraphic, ChartGraphicKind, ChartTimeline, DataZoom, MarkArea, MarkLine,
4    MarkPoint, VisualMap,
5};
6use crate::grid::Grid;
7use crate::interaction::{ChartHit, ChartInteraction, ChartInteractionEvent, ChartInteractionKind};
8use crate::layout::math::{arc, catmull_rom_to_bezier, pie_slice};
9use crate::layout::scale::LinearScale;
10use crate::legend::Legend;
11use crate::model::{ChartModel, ResolvedBarSeries, ResolvedLineSeries, ResolvedSeries};
12use crate::series::graph::GraphEdge;
13use crate::series::Series;
14use crate::tooltip::Tooltip;
15use fission_core::event::{InputEvent, PointerEvent};
16use fission_core::internal::{
17    CustomEventResult, CustomHitResult, CustomRenderObject, InternalRenderNode,
18};
19use fission_core::op::Color;
20use fission_core::ui::{Container, Widget};
21use fission_core::{
22    Action, ActionEnvelope, AnimationPropertyId, AnimationRequest, AnimationStartValue,
23    EasingFunction, WidgetId,
24};
25use fission_ir::op::{Fill, LayoutOp, LineCap, LineJoin, PaintOp, Stroke};
26use fission_layout::{LayoutPoint, LayoutRect};
27use serde::{Deserialize, Serialize};
28use std::collections::{BTreeMap, HashMap};
29use std::sync::Arc;
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct Chart {
33    pub id: Option<WidgetId>,
34    pub width: Option<f32>,
35    pub height: Option<f32>,
36    pub title: Option<String>,
37    pub tooltip: Option<Tooltip>,
38    pub legend: Option<Legend>,
39    pub grid: Option<Grid>,
40    pub x_axis: Option<Axis>,
41    pub y_axis: Option<Axis>,
42    pub series: Vec<Series>,
43    pub dataset: Option<crate::dataset::Dataset>,
44    pub visual_map: Option<VisualMap>,
45    pub data_zoom: Option<DataZoom>,
46    pub axis_pointer: Option<AxisPointer>,
47    pub mark_points: Vec<MarkPoint>,
48    pub mark_lines: Vec<MarkLine>,
49    pub mark_areas: Vec<MarkArea>,
50    pub graphics: Vec<ChartGraphic>,
51    pub timeline: Option<ChartTimeline>,
52    pub theme: Option<ChartTheme>,
53    pub interaction: ChartInteraction,
54    pub animation: crate::animation::ChartAnimation,
55    pub animate: bool,
56}
57
58impl Default for Chart {
59    fn default() -> Self {
60        Self::new()
61    }
62}
63
64impl Chart {
65    pub fn new() -> Self {
66        Self {
67            id: None,
68            width: None,
69            height: None,
70            title: None,
71            tooltip: None,
72            legend: None,
73            grid: None,
74            x_axis: None,
75            y_axis: None,
76            series: Vec::new(),
77            dataset: None,
78            visual_map: None,
79            data_zoom: None,
80            axis_pointer: None,
81            mark_points: Vec::new(),
82            mark_lines: Vec::new(),
83            mark_areas: Vec::new(),
84            graphics: Vec::new(),
85            timeline: None,
86            theme: None,
87            interaction: ChartInteraction::default(),
88            animation: crate::animation::ChartAnimation::default(),
89            animate: false,
90        }
91    }
92
93    pub fn id(mut self, id: WidgetId) -> Self {
94        self.id = Some(id);
95        self
96    }
97
98    pub fn width(mut self, w: f32) -> Self {
99        self.width = Some(w);
100        self
101    }
102
103    pub fn height(mut self, h: f32) -> Self {
104        self.height = Some(h);
105        self
106    }
107
108    pub fn dataset(mut self, ds: crate::dataset::Dataset) -> Self {
109        self.dataset = Some(ds);
110        self
111    }
112
113    pub fn title(mut self, title: &str) -> Self {
114        self.title = Some(title.to_string());
115        self
116    }
117
118    pub fn tooltip(mut self, tooltip: Tooltip) -> Self {
119        self.tooltip = Some(tooltip);
120        self
121    }
122
123    pub fn legend(mut self, legend: Legend) -> Self {
124        self.legend = Some(legend);
125        self
126    }
127
128    pub fn x_axis(mut self, axis: Axis) -> Self {
129        self.x_axis = Some(axis);
130        self
131    }
132
133    pub fn y_axis(mut self, axis: Axis) -> Self {
134        self.y_axis = Some(axis);
135        self
136    }
137
138    pub fn series(mut self, series: Vec<Series>) -> Self {
139        self.series = series;
140        self
141    }
142
143    pub fn grid(mut self, grid: Grid) -> Self {
144        self.grid = Some(grid);
145        self
146    }
147
148    pub fn visual_map(mut self, visual_map: VisualMap) -> Self {
149        self.visual_map = Some(visual_map);
150        self
151    }
152
153    pub fn data_zoom(mut self, data_zoom: DataZoom) -> Self {
154        self.data_zoom = Some(data_zoom);
155        self
156    }
157
158    pub fn axis_pointer(mut self, axis_pointer: AxisPointer) -> Self {
159        self.axis_pointer = Some(axis_pointer);
160        self
161    }
162
163    pub fn mark_point(mut self, mark_point: MarkPoint) -> Self {
164        self.mark_points.push(mark_point);
165        self
166    }
167
168    pub fn mark_line(mut self, mark_line: MarkLine) -> Self {
169        self.mark_lines.push(mark_line);
170        self
171    }
172
173    pub fn mark_area(mut self, mark_area: MarkArea) -> Self {
174        self.mark_areas.push(mark_area);
175        self
176    }
177
178    pub fn graphic(mut self, graphic: ChartGraphic) -> Self {
179        self.graphics.push(graphic);
180        self
181    }
182
183    pub fn timeline(mut self, timeline: ChartTimeline) -> Self {
184        self.timeline = Some(timeline);
185        self
186    }
187
188    pub fn theme(mut self, theme: ChartTheme) -> Self {
189        self.theme = Some(theme);
190        self
191    }
192
193    pub fn animate(mut self, animate: bool) -> Self {
194        self.animate = animate;
195        self.animation.enabled = animate;
196        self
197    }
198
199    pub fn animation(mut self, animation: crate::animation::ChartAnimation) -> Self {
200        self.animate = animation.enabled;
201        self.animation = animation;
202        self
203    }
204
205    pub fn interaction(mut self, interaction: ChartInteraction) -> Self {
206        self.interaction = interaction;
207        self
208    }
209
210    pub fn emit_interaction_events(mut self, emit: bool) -> Self {
211        self.interaction = self.interaction.emit_events(emit);
212        self
213    }
214
215    pub fn hit_test(&self, width: f32, height: f32, point: LayoutPoint) -> Option<ChartHit> {
216        let model = ChartModel::from_chart(self);
217        let area = chart_area_for_size(self, width, height);
218        hit_test_chart(&model, &area, point)
219    }
220}
221
222impl From<Chart> for Widget {
223    fn from(component: Chart) -> Self {
224        let (ctx, _) = fission_core::build::current::<()>();
225        let this = &component;
226        if this.animation.enabled {
227            ctx.anim_for(this.animation_id()).request(AnimationRequest {
228                property: chart_animation_property(),
229                from: AnimationStartValue::Explicit(0.0),
230                to: 1.0,
231                duration_ms: this.animation.duration_ms,
232                repeat: this.animation.repeat,
233                delay_ms: this.animation.delay_ms,
234                frame_interval_ms: Some(16),
235                easing: chart_easing(this.animation.easing),
236            });
237        }
238
239        let render_object = if this.interaction.enabled {
240            Some(Arc::new(ChartRenderObject {
241                chart: this.clone(),
242            }) as Arc<dyn CustomRenderObject>)
243        } else {
244            None
245        };
246        let mut container = Container::new(fission_core::internal::custom_render_widget(
247            InternalRenderNode {
248                debug_tag: "fission_charts::Chart".into(),
249                lowerer: Some(Arc::new(ChartInternalLowerer {
250                    chart: this.clone(),
251                })),
252                render_object,
253            },
254        ));
255        if let Some(w) = this.width {
256            container = container.width(w);
257        } else {
258            container = container.flex_grow(1.0);
259        }
260        if let Some(h) = this.height {
261            container = container.height(h);
262        } else if this.width.is_none() {
263            container = container.flex_grow(1.0);
264        }
265        container.into()
266    }
267}
268
269impl Chart {
270    fn animation_id(&self) -> WidgetId {
271        self.id.unwrap_or_else(|| {
272            let title = self.title.as_deref().unwrap_or("untitled");
273            WidgetId::explicit(&format!("fission_charts::Chart::{title}"))
274        })
275    }
276}
277
278fn chart_animation_property() -> AnimationPropertyId {
279    AnimationPropertyId::custom("fission_charts::progress")
280}
281
282fn chart_easing(easing: crate::animation::ChartEasing) -> EasingFunction {
283    match easing {
284        crate::animation::ChartEasing::Linear => EasingFunction::Linear,
285        crate::animation::ChartEasing::EaseIn => EasingFunction::EaseIn,
286        crate::animation::ChartEasing::EaseOut => EasingFunction::EaseOut,
287        crate::animation::ChartEasing::EaseInOut => EasingFunction::EaseInOut,
288    }
289}
290
291#[derive(Debug)]
292pub struct ChartInternalLowerer {
293    pub chart: Chart,
294}
295
296#[derive(Debug)]
297struct ChartRenderObject {
298    chart: Chart,
299}
300
301impl CustomRenderObject for ChartRenderObject {
302    fn hit_test(&self, local_point: LayoutPoint, node_rect: LayoutRect) -> CustomHitResult {
303        if local_point.x >= 0.0
304            && local_point.y >= 0.0
305            && local_point.x < node_rect.width()
306            && local_point.y < node_rect.height()
307        {
308            CustomHitResult::inside(None)
309        } else {
310            CustomHitResult::miss()
311        }
312    }
313
314    fn handle_event(
315        &self,
316        node_id: fission_ir::WidgetId,
317        event: &InputEvent,
318        node_rect: LayoutRect,
319    ) -> CustomEventResult {
320        if !self.chart.interaction.emit_events {
321            return CustomEventResult::ignored();
322        }
323
324        let Some((kind, point, modifiers)) = chart_event_point(event) else {
325            return CustomEventResult::ignored();
326        };
327        let local = LayoutPoint::new(point.x - node_rect.x(), point.y - node_rect.y());
328        let hit = self
329            .chart
330            .hit_test(node_rect.width(), node_rect.height(), local);
331        let event = ChartInteractionEvent {
332            chart_id: self.chart.title.clone(),
333            kind,
334            local_x: local.x,
335            local_y: local.y,
336            modifiers,
337            hit,
338        };
339        let envelope = ActionEnvelope {
340            id: ChartInteractionEvent::static_id(),
341            payload: event.encode(),
342        };
343        CustomEventResult::consumed_with(vec![(node_id, envelope)])
344    }
345}
346
347#[derive(Debug, Clone, Copy)]
348struct ChartArea {
349    outer_w: f32,
350    outer_h: f32,
351    plot: LayoutRect,
352}
353
354#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
355pub struct ChartTheme {
356    pub background: Color,
357    pub plot_background: Color,
358    pub grid_line: Color,
359    pub axis_line: Color,
360    pub label: Color,
361    pub title: Color,
362    pub diagnostic: Color,
363    pub palette: Vec<Color>,
364}
365
366impl Default for ChartTheme {
367    fn default() -> Self {
368        Self::light()
369    }
370}
371
372impl ChartTheme {
373    pub fn light() -> Self {
374        Self {
375            background: color(255, 255, 255, 255),
376            plot_background: color(250, 252, 255, 255),
377            grid_line: color(226, 232, 240, 255),
378            axis_line: color(148, 163, 184, 255),
379            label: color(71, 85, 105, 255),
380            title: color(15, 23, 42, 255),
381            diagnostic: color(180, 83, 9, 255),
382            palette: vec![
383                color(84, 112, 198, 255),
384                color(145, 204, 117, 255),
385                color(250, 200, 88, 255),
386                color(238, 102, 102, 255),
387                color(115, 192, 222, 255),
388                color(154, 96, 180, 255),
389                color(234, 124, 204, 255),
390                color(59, 162, 114, 255),
391            ],
392        }
393    }
394
395    pub fn dark() -> Self {
396        Self {
397            background: color(15, 23, 42, 255),
398            plot_background: color(17, 24, 39, 255),
399            grid_line: color(51, 65, 85, 255),
400            axis_line: color(100, 116, 139, 255),
401            label: color(203, 213, 225, 255),
402            title: color(248, 250, 252, 255),
403            diagnostic: color(251, 191, 36, 255),
404            palette: vec![
405                color(96, 165, 250, 255),
406                color(45, 212, 191, 255),
407                color(251, 191, 36, 255),
408                color(248, 113, 113, 255),
409                color(56, 189, 248, 255),
410                color(192, 132, 252, 255),
411                color(244, 114, 182, 255),
412                color(74, 222, 128, 255),
413            ],
414        }
415    }
416
417    fn from_env(env: &fission_core::Env) -> Self {
418        let colors = &env.theme.tokens.colors;
419        let dark = color_luma(colors.background) < 128.0;
420        let mut theme = if dark { Self::dark() } else { Self::light() };
421        theme.background = colors.surface;
422        theme.plot_background = if dark {
423            mix_color(colors.surface, colors.background, 0.5)
424        } else {
425            mix_color(colors.surface, Color::WHITE, 0.55)
426        };
427        theme.grid_line = colors.border;
428        theme.axis_line = colors.text_secondary;
429        theme.label = colors.text_secondary;
430        theme.title = colors.text_primary;
431        if env.theme.tokens.data_visualization.palette.is_empty() {
432            theme.palette[0] = colors.primary;
433            theme.palette[1] = colors.secondary;
434        } else {
435            theme.palette = env.theme.tokens.data_visualization.palette.clone();
436        }
437        theme
438    }
439}
440
441#[cfg(test)]
442mod chart_theme_tests {
443    use super::*;
444
445    #[test]
446    fn chart_theme_uses_generated_data_visualization_palette() {
447        let mut env = fission_core::Env::default();
448        env.theme.tokens.data_visualization.palette = vec![
449            color(1, 2, 3, 255),
450            color(4, 5, 6, 255),
451            color(7, 8, 9, 255),
452        ];
453
454        let theme = ChartTheme::from_env(&env);
455
456        assert_eq!(theme.palette, env.theme.tokens.data_visualization.palette);
457    }
458}
459
460impl fission_core::internal::InternalLowerer for ChartInternalLowerer {
461    fn lower_dyn(
462        &self,
463        cx: &mut fission_core::internal::InternalLoweringCx,
464    ) -> fission_ir::WidgetId {
465        let model = ChartModel::from_chart(&self.chart);
466        let theme = self
467            .chart
468            .theme
469            .clone()
470            .unwrap_or_else(|| ChartTheme::from_env(cx.env));
471        let area = chart_area(&self.chart, cx);
472        let mut root = fission_core::internal::InternalIrBuilder::new(
473            cx.next_node_id(),
474            fission_ir::Op::Layout(LayoutOp::ZStack),
475        );
476
477        draw_background(cx, &mut root, &area, &theme);
478        draw_title(cx, &mut root, &model, &area, &theme);
479        if model.has_cartesian_series() {
480            draw_cartesian_axes(cx, &mut root, &model, &area, &theme);
481        }
482
483        draw_mark_areas(cx, &mut root, &model, &self.chart, &area);
484        render_series(cx, &mut root, &model, &self.chart, &area, &theme);
485        draw_mark_lines(cx, &mut root, &model, &self.chart, &area, &theme);
486        draw_mark_points(cx, &mut root, &model, &self.chart, &area, &theme);
487        draw_legend(cx, &mut root, &model, &self.chart, &area, &theme);
488        draw_visual_map(cx, &mut root, &self.chart, &area, &theme);
489        draw_data_zoom(cx, &mut root, &self.chart, &area, &theme);
490        draw_brush(cx, &mut root, &self.chart, &area, &theme);
491        draw_graphics(cx, &mut root, &self.chart, &area, &theme);
492        draw_timeline(cx, &mut root, &self.chart, &area, &theme);
493        draw_toolbox(cx, &mut root, &self.chart, &area, &theme);
494        draw_diagnostics(cx, &mut root, &model, &area, &theme);
495
496        root.build(cx)
497    }
498}
499
500fn chart_area(chart: &Chart, cx: &fission_core::internal::InternalLoweringCx) -> ChartArea {
501    let outer_w = chart.width.unwrap_or_else(|| {
502        let available_w = cx.env.viewport_size.width;
503        (available_w - 380.0).max(360.0)
504    });
505    let outer_h = chart.height.unwrap_or_else(|| {
506        let available_h = cx.env.viewport_size.height;
507        (available_h - 200.0).max(320.0)
508    });
509    chart_area_for_size(chart, outer_w, outer_h)
510}
511
512fn chart_area_for_size(chart: &Chart, outer_w: f32, outer_h: f32) -> ChartArea {
513    let grid = chart.grid.clone().unwrap_or_default();
514    let left = grid.left.unwrap_or(70.0);
515    let top = grid
516        .top
517        .unwrap_or(if chart.title.is_some() { 58.0 } else { 38.0 });
518    let right = grid
519        .right
520        .unwrap_or(if chart.legend.is_some() { 130.0 } else { 44.0 });
521    let bottom = grid.bottom.unwrap_or(if chart.data_zoom.is_some() {
522        78.0
523    } else {
524        54.0
525    });
526    ChartArea {
527        outer_w,
528        outer_h,
529        plot: LayoutRect::new(
530            left,
531            top,
532            (outer_w - left - right).max(1.0),
533            (outer_h - top - bottom).max(1.0),
534        ),
535    }
536}
537
538fn chart_event_point(event: &InputEvent) -> Option<(ChartInteractionKind, LayoutPoint, u8)> {
539    match event {
540        InputEvent::Pointer(PointerEvent::Move { point, modifiers }) => {
541            Some((ChartInteractionKind::Hover, *point, *modifiers))
542        }
543        InputEvent::Pointer(PointerEvent::Down {
544            point, modifiers, ..
545        }) => Some((ChartInteractionKind::Press, *point, *modifiers)),
546        InputEvent::Pointer(PointerEvent::Up {
547            point, modifiers, ..
548        }) => Some((ChartInteractionKind::Release, *point, *modifiers)),
549        InputEvent::Pointer(PointerEvent::Scroll {
550            point, modifiers, ..
551        }) => Some((ChartInteractionKind::Scroll, *point, *modifiers)),
552        _ => None,
553    }
554}
555
556#[derive(Debug, Clone, Copy)]
557struct ChartAnimationFrame {
558    enabled: bool,
559    progress: f32,
560    stagger_fraction: f32,
561}
562
563impl ChartAnimationFrame {
564    fn from_chart(chart: &Chart, cx: &fission_core::internal::InternalLoweringCx) -> Self {
565        if !chart.animation.enabled {
566            return Self::complete();
567        }
568
569        let progress = cx
570            .runtime_state
571            .animation
572            .values
573            .get(&(chart.animation_id(), chart_animation_property()))
574            .copied()
575            .unwrap_or(1.0)
576            .clamp(0.0, 1.0);
577        let duration = chart.animation.duration_ms.max(1) as f32;
578        let stagger_fraction = (chart.animation.stagger_ms as f32 / duration).clamp(0.0, 0.18);
579
580        Self {
581            enabled: true,
582            progress,
583            stagger_fraction,
584        }
585    }
586
587    fn complete() -> Self {
588        Self {
589            enabled: false,
590            progress: 1.0,
591            stagger_fraction: 0.0,
592        }
593    }
594
595    fn series_progress(self, series_index: usize) -> f32 {
596        if !self.enabled {
597            return 1.0;
598        }
599        self.staggered_progress(series_index, self.stagger_fraction)
600    }
601
602    fn item_progress(self, series_progress: f32, item_index: usize) -> f32 {
603        if !self.enabled {
604            return 1.0;
605        }
606        let item_stagger = (self.stagger_fraction * 0.55).min(0.08);
607        Self {
608            progress: series_progress,
609            ..self
610        }
611        .staggered_progress(item_index, item_stagger)
612    }
613
614    fn staggered_progress(self, index: usize, step: f32) -> f32 {
615        let delay = (index as f32 * step).min(0.86);
616        if self.progress <= delay {
617            0.0
618        } else {
619            ((self.progress - delay) / (1.0 - delay)).clamp(0.0, 1.0)
620        }
621    }
622}
623
624fn render_series(
625    cx: &mut fission_core::internal::InternalLoweringCx,
626    root: &mut fission_core::internal::InternalIrBuilder,
627    model: &ChartModel,
628    chart: &Chart,
629    area: &ChartArea,
630    theme: &ChartTheme,
631) {
632    let x_scale = LinearScale::nice(model.x_domain.0, model.x_domain.1, 6);
633    let y_scale = LinearScale::nice(model.y_domain.0, model.y_domain.1, 6);
634    let bar_groups = count_bar_groups(&model.series);
635    let mut bar_group_index = 0usize;
636    let mut bar_stacks: HashMap<(String, usize), f32> = HashMap::new();
637    let mut line_stacks: HashMap<(String, usize), f32> = HashMap::new();
638    let animation = ChartAnimationFrame::from_chart(chart, cx);
639
640    for (series_index, series) in model.series.iter().enumerate() {
641        match series {
642            ResolvedSeries::Bar(bar) => {
643                let group_index = if bar.source.stack.is_none() {
644                    let idx = bar_group_index;
645                    bar_group_index += 1;
646                    idx
647                } else {
648                    0
649                };
650                render_bar(
651                    cx,
652                    root,
653                    bar,
654                    &mut bar_stacks,
655                    model,
656                    area,
657                    &x_scale,
658                    &y_scale,
659                    theme,
660                    group_index,
661                    bar_groups,
662                    animation,
663                    series_index,
664                );
665            }
666            ResolvedSeries::Line(line) => render_line(
667                cx,
668                root,
669                line,
670                &mut line_stacks,
671                model,
672                area,
673                &x_scale,
674                &y_scale,
675                theme,
676                animation,
677                series_index,
678            ),
679            ResolvedSeries::Scatter(scatter) => render_scatter(
680                cx,
681                root,
682                &scatter.data,
683                scatter.color,
684                chart.visual_map.as_ref(),
685                area,
686                &x_scale,
687                &y_scale,
688                theme,
689                false,
690                animation,
691                series_index,
692            ),
693            ResolvedSeries::Bubble(bubble) => render_bubble(
694                cx,
695                root,
696                bubble,
697                chart.visual_map.as_ref(),
698                area,
699                &x_scale,
700                &y_scale,
701                animation,
702                series_index,
703            ),
704            ResolvedSeries::EffectScatter(effect) => render_scatter(
705                cx,
706                root,
707                &effect.data,
708                effect.color,
709                chart.visual_map.as_ref(),
710                area,
711                &x_scale,
712                &y_scale,
713                theme,
714                true,
715                animation,
716                series_index,
717            ),
718            ResolvedSeries::Pie(pie) => {
719                render_pie(cx, root, pie, area, theme, animation, series_index)
720            }
721            ResolvedSeries::Boxplot(boxplot) => render_boxplot(
722                cx,
723                root,
724                boxplot,
725                model,
726                area,
727                &y_scale,
728                theme,
729                animation,
730                series_index,
731            ),
732            ResolvedSeries::Candlestick(candle) => render_candlestick(
733                cx,
734                root,
735                candle,
736                model,
737                area,
738                &y_scale,
739                animation,
740                series_index,
741            ),
742            ResolvedSeries::Heatmap(heatmap) => render_heatmap(
743                cx,
744                root,
745                heatmap,
746                model,
747                chart.visual_map.as_ref(),
748                area,
749                theme,
750                animation,
751                series_index,
752            ),
753            ResolvedSeries::CalendarHeatmap(calendar) => render_calendar_heatmap(
754                cx,
755                root,
756                calendar,
757                chart.visual_map.as_ref(),
758                area,
759                theme,
760                animation,
761                series_index,
762            ),
763            ResolvedSeries::Lines(lines) => {
764                render_lines(cx, root, lines, area, theme, animation, series_index)
765            }
766            ResolvedSeries::Graph(graph) => {
767                render_graph(cx, root, graph, area, theme, animation, series_index)
768            }
769            ResolvedSeries::Tree(tree) => {
770                render_tree(cx, root, tree, area, theme, animation, series_index)
771            }
772            ResolvedSeries::Treemap(treemap) => {
773                render_treemap(cx, root, treemap, area, theme, animation, series_index)
774            }
775            ResolvedSeries::Radar(radar) => {
776                render_radar(cx, root, radar, area, theme, animation, series_index)
777            }
778            ResolvedSeries::Funnel(funnel) => {
779                render_funnel(cx, root, funnel, area, theme, animation, series_index)
780            }
781            ResolvedSeries::Gauge(gauge) => {
782                render_gauge(cx, root, gauge, area, theme, animation, series_index)
783            }
784            ResolvedSeries::Map(map) => render_map(
785                cx,
786                root,
787                map,
788                chart.visual_map.as_ref(),
789                area,
790                theme,
791                animation,
792                series_index,
793            ),
794            ResolvedSeries::Sankey(sankey) => {
795                render_sankey(cx, root, sankey, area, theme, animation, series_index)
796            }
797            ResolvedSeries::Parallel(parallel) => {
798                render_parallel(cx, root, parallel, area, theme, animation, series_index)
799            }
800            ResolvedSeries::Sunburst(sunburst) => {
801                render_sunburst(cx, root, sunburst, area, theme, animation, series_index)
802            }
803            ResolvedSeries::ThemeRiver(river) => {
804                render_theme_river(cx, root, river, area, theme, animation, series_index)
805            }
806            ResolvedSeries::PictorialBar(pic) => render_pictorial_bar(
807                cx,
808                root,
809                pic,
810                model,
811                area,
812                &y_scale,
813                theme,
814                animation,
815                series_index,
816            ),
817            ResolvedSeries::Liquidfill(liquid) => {
818                render_liquidfill(cx, root, liquid, area, theme, animation, series_index)
819            }
820            ResolvedSeries::Wordcloud(words) => {
821                render_wordcloud(cx, root, words, area, theme, animation, series_index)
822            }
823            ResolvedSeries::PolarBar(polar) => {
824                render_polar_bar(cx, root, polar, area, theme, animation, series_index)
825            }
826            ResolvedSeries::PolarLine(polar) => {
827                render_polar_line(cx, root, polar, area, theme, animation, series_index)
828            }
829            ResolvedSeries::SingleAxis(single_axis) => {
830                render_single_axis(cx, root, single_axis, area, theme, animation, series_index)
831            }
832        }
833    }
834}
835
836fn hit_test_chart(model: &ChartModel, area: &ChartArea, point: LayoutPoint) -> Option<ChartHit> {
837    if !area.plot.contains(point) {
838        return None;
839    }
840
841    let x_scale = LinearScale::nice(model.x_domain.0, model.x_domain.1, 6);
842    let y_scale = LinearScale::nice(model.y_domain.0, model.y_domain.1, 6);
843    let threshold = 10.0;
844    let bar_groups = count_bar_groups(&model.series);
845    let mut bar_group_index = 0usize;
846    let mut bar_stacks: HashMap<(String, usize), f32> = HashMap::new();
847    let mut line_stacks: HashMap<(String, usize), f32> = HashMap::new();
848    let mut direct_hit = None;
849
850    for (series_index, series) in model.series.iter().enumerate() {
851        match series {
852            ResolvedSeries::Bar(bar) => {
853                let group_index = if bar.source.stack.is_none() {
854                    let idx = bar_group_index;
855                    bar_group_index += 1;
856                    idx
857                } else {
858                    0
859                };
860                let band = band_width(model, area);
861                let group_count = bar_groups.max(1) as f32;
862                let bar_w = if bar.source.stack.is_some() {
863                    band * 0.64
864                } else {
865                    (band * 0.72 / group_count).max(2.0)
866                };
867                let group_offset = if bar.source.stack.is_some() {
868                    0.0
869                } else {
870                    (group_index as f32 - (group_count - 1.0) / 2.0) * bar_w
871                };
872
873                for (idx, value) in bar.values.iter().enumerate() {
874                    let base = stack_base(&bar_stacks, bar.source.stack.as_ref(), idx);
875                    let total = base + *value;
876                    if let Some(stack) = bar.source.stack.as_ref() {
877                        bar_stacks.insert((stack.clone(), idx), total);
878                    }
879                    let rect = if bar.source.orientation
880                        == crate::series::bar::BarOrientation::Horizontal
881                    {
882                        let band = category_band_width(
883                            model.y_categories.len().max(bar.values.len()),
884                            area.plot.height(),
885                        );
886                        let bar_h = if bar.source.stack.is_some() {
887                            band * 0.64
888                        } else {
889                            (band * 0.72 / group_count).max(2.0)
890                        };
891                        let group_offset_y = if bar.source.stack.is_some() {
892                            0.0
893                        } else {
894                            (group_index as f32 - (group_count - 1.0) / 2.0) * bar_h
895                        };
896                        let y = map_category_y(idx, model, area) + group_offset_y;
897                        let x0 = map_x(base, area, &x_scale);
898                        let x1 = map_x(total, area, &x_scale);
899                        LayoutRect::new(
900                            x0.min(x1),
901                            y - bar_h / 2.0,
902                            (x1 - x0).abs().max(1.0),
903                            bar_h,
904                        )
905                    } else {
906                        let x = map_category_x(idx, model, area) + group_offset;
907                        let y0 = map_y(base, area, &y_scale);
908                        let y1 = map_y(total, area, &y_scale);
909                        LayoutRect::new(
910                            x - bar_w / 2.0,
911                            y0.min(y1),
912                            bar_w,
913                            (y0 - y1).abs().max(1.0),
914                        )
915                    };
916                    if rect.contains(point) {
917                        direct_hit = Some(ChartHit::series_item(
918                            series_index,
919                            bar.source.name.clone(),
920                            idx,
921                            Some(idx as f32),
922                            Some(total),
923                        ));
924                    }
925                }
926            }
927            ResolvedSeries::Line(line) => {
928                for (idx, value) in line.values.iter().enumerate() {
929                    let base = stack_base(&line_stacks, line.source.stack.as_ref(), idx);
930                    let total = base + *value;
931                    if let Some(stack) = line.source.stack.as_ref() {
932                        line_stacks.insert((stack.clone(), idx), total);
933                    }
934                    let x = map_category_x(idx, model, area);
935                    let y = map_y(total, area, &y_scale);
936                    if distance(point, (x, y)) <= threshold {
937                        direct_hit = Some(ChartHit::series_item(
938                            series_index,
939                            line.source.name.clone(),
940                            idx,
941                            Some(idx as f32),
942                            Some(total),
943                        ));
944                    }
945                }
946            }
947            ResolvedSeries::Scatter(scatter) => {
948                if let Some(hit) = hit_test_points(
949                    series_index,
950                    &scatter.name,
951                    &scatter.data,
952                    area,
953                    &x_scale,
954                    &y_scale,
955                    point,
956                    threshold,
957                ) {
958                    direct_hit = Some(hit);
959                }
960            }
961            ResolvedSeries::Bubble(bubble) => {
962                let max_size = bubble
963                    .data
964                    .iter()
965                    .map(|(_, _, size)| *size)
966                    .fold(1.0_f32, f32::max);
967                for (idx, (xv, yv, size)) in bubble.data.iter().enumerate() {
968                    let x = map_x(*xv, area, &x_scale);
969                    let y = map_y(*yv, area, &y_scale);
970                    let t = (*size / max_size).clamp(0.0, 1.0).sqrt();
971                    let radius = bubble.min_radius + (bubble.max_radius - bubble.min_radius) * t;
972                    if distance(point, (x, y)) <= radius.max(threshold) {
973                        direct_hit = Some(ChartHit::series_item(
974                            series_index,
975                            bubble.name.clone(),
976                            idx,
977                            Some(*xv),
978                            Some(*yv),
979                        ));
980                    }
981                }
982            }
983            ResolvedSeries::EffectScatter(scatter) => {
984                if let Some(hit) = hit_test_points(
985                    series_index,
986                    &scatter.name,
987                    &scatter.data,
988                    area,
989                    &x_scale,
990                    &y_scale,
991                    point,
992                    threshold * 1.6,
993                ) {
994                    direct_hit = Some(hit);
995                }
996            }
997            ResolvedSeries::Pie(pie) => {
998                if let Some(hit) = hit_test_pie(series_index, pie, area, point) {
999                    direct_hit = Some(hit);
1000                }
1001            }
1002            ResolvedSeries::Heatmap(heatmap) => {
1003                let max_x = heatmap.data.iter().map(|d| d.0).max().unwrap_or(0) + 1;
1004                let max_y = heatmap.data.iter().map(|d| d.1).max().unwrap_or(0) + 1;
1005                let cell_w = area.plot.width() / max_x.max(1) as f32;
1006                let cell_h = area.plot.height() / max_y.max(1) as f32;
1007                for (idx, (x_idx, y_idx, value)) in heatmap.data.iter().enumerate() {
1008                    let rect = LayoutRect::new(
1009                        area.plot.x() + *x_idx as f32 * cell_w,
1010                        area.plot.bottom() - (*y_idx as f32 + 1.0) * cell_h,
1011                        cell_w,
1012                        cell_h,
1013                    );
1014                    if rect.contains(point) {
1015                        direct_hit = Some(ChartHit::series_item(
1016                            series_index,
1017                            heatmap.name.clone(),
1018                            idx,
1019                            Some(*x_idx as f32),
1020                            Some(*value),
1021                        ));
1022                    }
1023                }
1024            }
1025            _ => {}
1026        }
1027    }
1028
1029    direct_hit
1030        .or_else(|| nearest_cartesian_hit(model, area, point))
1031        .or_else(|| Some(ChartHit::plot_area()))
1032}
1033
1034fn draw_background(
1035    cx: &mut fission_core::internal::InternalLoweringCx,
1036    root: &mut fission_core::internal::InternalIrBuilder,
1037    area: &ChartArea,
1038    theme: &ChartTheme,
1039) {
1040    add_rect(
1041        cx,
1042        root,
1043        LayoutRect::new(0.0, 0.0, area.outer_w, area.outer_h),
1044        theme.background,
1045        None,
1046        14.0,
1047    );
1048    add_rect(
1049        cx,
1050        root,
1051        area.plot,
1052        theme.plot_background,
1053        Some(stroke(theme.grid_line, 1.0)),
1054        8.0,
1055    );
1056}
1057
1058fn draw_title(
1059    cx: &mut fission_core::internal::InternalLoweringCx,
1060    root: &mut fission_core::internal::InternalIrBuilder,
1061    model: &ChartModel,
1062    _area: &ChartArea,
1063    theme: &ChartTheme,
1064) {
1065    if let Some(title) = model.title.as_ref() {
1066        add_text(cx, root, title, 18.0, theme.title, 20.0, 18.0, 360.0, 28.0);
1067    }
1068}
1069
1070fn draw_cartesian_axes(
1071    cx: &mut fission_core::internal::InternalLoweringCx,
1072    root: &mut fission_core::internal::InternalIrBuilder,
1073    model: &ChartModel,
1074    area: &ChartArea,
1075    theme: &ChartTheme,
1076) {
1077    let y_scale = LinearScale::nice(model.y_domain.0, model.y_domain.1, 6);
1078    for tick in &y_scale.ticks {
1079        let y = map_y(*tick, area, &y_scale);
1080        if model.y_axis.split_line {
1081            add_path(
1082                cx,
1083                root,
1084                &format!("M {} {} L {} {}", area.plot.x(), y, area.plot.right(), y),
1085                None,
1086                Some(stroke(theme.grid_line, 1.0)),
1087            );
1088        }
1089        add_text(
1090            cx,
1091            root,
1092            &format_tick(*tick),
1093            11.0,
1094            theme.label,
1095            8.0,
1096            y - 7.0,
1097            area.plot.x() - 14.0,
1098            14.0,
1099        );
1100    }
1101
1102    add_path(
1103        cx,
1104        root,
1105        &format!(
1106            "M {} {} L {} {}",
1107            area.plot.x(),
1108            area.plot.bottom(),
1109            area.plot.right(),
1110            area.plot.bottom()
1111        ),
1112        None,
1113        Some(stroke(theme.axis_line, 1.0)),
1114    );
1115    add_path(
1116        cx,
1117        root,
1118        &format!(
1119            "M {} {} L {} {}",
1120            area.plot.x(),
1121            area.plot.y(),
1122            area.plot.x(),
1123            area.plot.bottom()
1124        ),
1125        None,
1126        Some(stroke(theme.axis_line, 1.0)),
1127    );
1128
1129    if model.x_axis.axis_type == AxisType::Category && !model.x_categories.is_empty() {
1130        let band = band_width(model, area);
1131        for (idx, label) in model.x_categories.iter().enumerate() {
1132            let x = map_category_x(idx, model, area);
1133            add_text(
1134                cx,
1135                root,
1136                label,
1137                11.0,
1138                theme.label,
1139                x - band / 2.0,
1140                area.plot.bottom() + 8.0,
1141                band,
1142                18.0,
1143            );
1144        }
1145    } else if model.y_axis.axis_type == AxisType::Category && !model.y_categories.is_empty() {
1146        let x_scale = LinearScale::nice(model.x_domain.0, model.x_domain.1, 6);
1147        for tick in &x_scale.ticks {
1148            let x = map_x(*tick, area, &x_scale);
1149            add_text(
1150                cx,
1151                root,
1152                &format_tick(*tick),
1153                11.0,
1154                theme.label,
1155                x - 24.0,
1156                area.plot.bottom() + 8.0,
1157                48.0,
1158                18.0,
1159            );
1160        }
1161        let band = category_band_width(model.y_categories.len(), area.plot.height());
1162        for (idx, label) in model.y_categories.iter().enumerate() {
1163            let y = map_category_y(idx, model, area);
1164            add_text(
1165                cx,
1166                root,
1167                label,
1168                11.0,
1169                theme.label,
1170                8.0,
1171                y - band / 2.0,
1172                area.plot.x() - 14.0,
1173                band.max(16.0),
1174            );
1175        }
1176    } else {
1177        let x_scale = LinearScale::nice(model.x_domain.0, model.x_domain.1, 6);
1178        for tick in &x_scale.ticks {
1179            let x = map_x(*tick, area, &x_scale);
1180            add_text(
1181                cx,
1182                root,
1183                &format_tick(*tick),
1184                11.0,
1185                theme.label,
1186                x - 24.0,
1187                area.plot.bottom() + 8.0,
1188                48.0,
1189                18.0,
1190            );
1191        }
1192    }
1193}
1194
1195fn render_bar(
1196    cx: &mut fission_core::internal::InternalLoweringCx,
1197    root: &mut fission_core::internal::InternalIrBuilder,
1198    bar: &ResolvedBarSeries,
1199    stacks: &mut HashMap<(String, usize), f32>,
1200    model: &ChartModel,
1201    area: &ChartArea,
1202    x_scale: &LinearScale,
1203    y_scale: &LinearScale,
1204    _theme: &ChartTheme,
1205    group_index: usize,
1206    group_count: usize,
1207    animation: ChartAnimationFrame,
1208    series_index: usize,
1209) {
1210    let series_progress = animation.series_progress(series_index);
1211    if bar.source.orientation == crate::series::bar::BarOrientation::Horizontal {
1212        render_horizontal_bar(
1213            cx,
1214            root,
1215            bar,
1216            stacks,
1217            model,
1218            area,
1219            x_scale,
1220            group_index,
1221            group_count,
1222            animation,
1223            series_progress,
1224        );
1225        return;
1226    }
1227
1228    let band = band_width(model, area);
1229    let group_count = group_count.max(1) as f32;
1230    let bar_w = if bar.source.stack.is_some() {
1231        band * 0.64
1232    } else {
1233        (band * 0.72 / group_count).max(2.0)
1234    };
1235    let group_offset = if bar.source.stack.is_some() {
1236        0.0
1237    } else {
1238        (group_index as f32 - (group_count - 1.0) / 2.0) * bar_w
1239    };
1240
1241    for (idx, value) in bar.values.iter().enumerate() {
1242        let item_progress = animation.item_progress(series_progress, idx);
1243        if item_progress <= f32::EPSILON {
1244            continue;
1245        }
1246        let base = stack_base(stacks, bar.source.stack.as_ref(), idx);
1247        let total = base + *value * item_progress;
1248        if bar.source.stack.is_some() {
1249            stacks.insert((bar.source.stack.clone().unwrap(), idx), total);
1250        }
1251        let x = map_category_x(idx, model, area) + group_offset;
1252        let y0 = map_y(base, area, y_scale);
1253        let y1 = map_y(total, area, y_scale);
1254        let top = y0.min(y1);
1255        let height = (y0 - y1).abs().max(1.0);
1256        if let Some(background) = bar.source.background {
1257            add_rect(
1258                cx,
1259                root,
1260                LayoutRect::new(x - bar_w / 2.0, area.plot.y(), bar_w, area.plot.height()),
1261                background,
1262                None,
1263                bar.source.border_radius.unwrap_or(4.0),
1264            );
1265        }
1266        add_rect(
1267            cx,
1268            root,
1269            LayoutRect::new(x - bar_w / 2.0, top, bar_w, height),
1270            bar.source.color,
1271            None,
1272            bar.source.border_radius.unwrap_or(4.0),
1273        );
1274    }
1275}
1276
1277#[allow(clippy::too_many_arguments)]
1278fn render_horizontal_bar(
1279    cx: &mut fission_core::internal::InternalLoweringCx,
1280    root: &mut fission_core::internal::InternalIrBuilder,
1281    bar: &ResolvedBarSeries,
1282    stacks: &mut HashMap<(String, usize), f32>,
1283    model: &ChartModel,
1284    area: &ChartArea,
1285    x_scale: &LinearScale,
1286    group_index: usize,
1287    group_count: usize,
1288    animation: ChartAnimationFrame,
1289    series_progress: f32,
1290) {
1291    let band = category_band_width(
1292        model.y_categories.len().max(bar.values.len()),
1293        area.plot.height(),
1294    );
1295    let group_count = group_count.max(1) as f32;
1296    let bar_h = if bar.source.stack.is_some() {
1297        band * 0.64
1298    } else {
1299        (band * 0.72 / group_count).max(2.0)
1300    };
1301    let group_offset = if bar.source.stack.is_some() {
1302        0.0
1303    } else {
1304        (group_index as f32 - (group_count - 1.0) / 2.0) * bar_h
1305    };
1306
1307    for (idx, value) in bar.values.iter().enumerate() {
1308        let item_progress = animation.item_progress(series_progress, idx);
1309        if item_progress <= f32::EPSILON {
1310            continue;
1311        }
1312        let base = stack_base(stacks, bar.source.stack.as_ref(), idx);
1313        let total = base + *value * item_progress;
1314        if bar.source.stack.is_some() {
1315            stacks.insert((bar.source.stack.clone().unwrap(), idx), total);
1316        }
1317        let y = map_category_y(idx, model, area) + group_offset;
1318        let x0 = map_x(base, area, x_scale);
1319        let x1 = map_x(total, area, x_scale);
1320        let left = x0.min(x1);
1321        let width = (x1 - x0).abs().max(1.0);
1322        if let Some(background) = bar.source.background {
1323            add_rect(
1324                cx,
1325                root,
1326                LayoutRect::new(area.plot.x(), y - bar_h / 2.0, area.plot.width(), bar_h),
1327                background,
1328                None,
1329                bar.source.border_radius.unwrap_or(4.0),
1330            );
1331        }
1332        add_rect(
1333            cx,
1334            root,
1335            LayoutRect::new(left, y - bar_h / 2.0, width, bar_h),
1336            bar.source.color,
1337            None,
1338            bar.source.border_radius.unwrap_or(4.0),
1339        );
1340    }
1341}
1342
1343fn render_line(
1344    cx: &mut fission_core::internal::InternalLoweringCx,
1345    root: &mut fission_core::internal::InternalIrBuilder,
1346    line: &ResolvedLineSeries,
1347    stacks: &mut HashMap<(String, usize), f32>,
1348    model: &ChartModel,
1349    area: &ChartArea,
1350    _x_scale: &LinearScale,
1351    y_scale: &LinearScale,
1352    _theme: &ChartTheme,
1353    animation: ChartAnimationFrame,
1354    series_index: usize,
1355) {
1356    if line.values.is_empty() {
1357        return;
1358    }
1359    let series_progress = animation.series_progress(series_index);
1360    if series_progress <= f32::EPSILON {
1361        return;
1362    }
1363    let mut points = Vec::new();
1364    let mut base_points = Vec::new();
1365    for (idx, value) in line.values.iter().enumerate() {
1366        let base = stack_base(stacks, line.source.stack.as_ref(), idx);
1367        let total = base + *value;
1368        if line.source.stack.is_some() {
1369            stacks.insert((line.source.stack.clone().unwrap(), idx), total);
1370        }
1371        let x = map_category_x(idx, model, area);
1372        points.push((x, map_y(total, area, y_scale)));
1373        base_points.push((x, map_y(base, area, y_scale)));
1374    }
1375
1376    let revealed_points = reveal_points(&points, series_progress);
1377    let revealed_base_points = reveal_points(&base_points, series_progress);
1378
1379    if let Some(area_color) = line.source.area_style {
1380        if revealed_points.len() > 1 && revealed_base_points.len() > 1 {
1381            let mut area_path = path_for_line(
1382                &revealed_points,
1383                line.source.smooth,
1384                line.source.step.as_deref(),
1385            );
1386            for (x, y) in revealed_base_points.iter().rev() {
1387                area_path.push_str(&format!(" L {} {}", x, y));
1388            }
1389            area_path.push_str(" Z");
1390            let fill = Fill::LinearGradient {
1391                start: (area.plot.x(), area.plot.y()),
1392                end: (area.plot.x(), area.plot.bottom()),
1393                stops: vec![(0.0, area_color), (1.0, area_color.with_alpha(16))],
1394            };
1395            add_path(cx, root, &area_path, Some(fill), None);
1396        }
1397    }
1398
1399    if revealed_points.len() > 1 {
1400        add_path(
1401            cx,
1402            root,
1403            &path_for_line(
1404                &revealed_points,
1405                line.source.smooth,
1406                line.source.step.as_deref(),
1407            ),
1408            None,
1409            Some(stroke(line.source.color, 2.4)),
1410        );
1411    }
1412    for (idx, (x, y)) in revealed_points.into_iter().enumerate() {
1413        let item_progress = animation.item_progress(series_progress, idx);
1414        if item_progress <= f32::EPSILON {
1415            continue;
1416        }
1417        let radius = 3.0 * item_progress.sqrt();
1418        add_rect(
1419            cx,
1420            root,
1421            LayoutRect::new(x - radius, y - radius, radius * 2.0, radius * 2.0),
1422            fade_color(line.source.color, item_progress),
1423            Some(stroke(Color::WHITE, 1.0)),
1424            radius,
1425        );
1426    }
1427}
1428
1429fn render_scatter(
1430    cx: &mut fission_core::internal::InternalLoweringCx,
1431    root: &mut fission_core::internal::InternalIrBuilder,
1432    data: &[(f32, f32)],
1433    color: Color,
1434    visual_map: Option<&VisualMap>,
1435    area: &ChartArea,
1436    x_scale: &LinearScale,
1437    y_scale: &LinearScale,
1438    _theme: &ChartTheme,
1439    effect: bool,
1440    animation: ChartAnimationFrame,
1441    series_index: usize,
1442) {
1443    let series_progress = animation.series_progress(series_index);
1444    if series_progress <= f32::EPSILON {
1445        return;
1446    }
1447
1448    for (idx, (xv, yv)) in data.iter().enumerate() {
1449        let item_progress = animation.item_progress(series_progress, idx);
1450        if item_progress <= f32::EPSILON {
1451            continue;
1452        }
1453        let x = map_x(*xv, area, x_scale);
1454        let y = map_y(*yv, area, y_scale);
1455        let fill = visual_map
1456            .map(|map| visual_color(map, *yv))
1457            .unwrap_or(color);
1458        if effect {
1459            for (scale, alpha) in [(2.2, 45), (1.55, 72), (1.0, 220)] {
1460                let r = 7.0 * scale * item_progress.sqrt();
1461                add_rect(
1462                    cx,
1463                    root,
1464                    LayoutRect::new(x - r, y - r, r * 2.0, r * 2.0),
1465                    fill.with_alpha(((alpha as f32) * item_progress).round() as u8),
1466                    None,
1467                    r,
1468                );
1469            }
1470        } else {
1471            let r = 5.5 * item_progress.sqrt();
1472            add_rect(
1473                cx,
1474                root,
1475                LayoutRect::new(x - r, y - r, r * 2.0, r * 2.0),
1476                fade_color(fill, item_progress),
1477                Some(stroke(Color::WHITE, 1.0)),
1478                r,
1479            );
1480        }
1481    }
1482}
1483
1484fn render_bubble(
1485    cx: &mut fission_core::internal::InternalLoweringCx,
1486    root: &mut fission_core::internal::InternalIrBuilder,
1487    bubble: &crate::series::bubble::BubbleSeries,
1488    visual_map: Option<&VisualMap>,
1489    area: &ChartArea,
1490    x_scale: &LinearScale,
1491    y_scale: &LinearScale,
1492    animation: ChartAnimationFrame,
1493    series_index: usize,
1494) {
1495    let max_size = bubble
1496        .data
1497        .iter()
1498        .map(|(_, _, size)| *size)
1499        .fold(1.0_f32, f32::max);
1500    let series_progress = animation.series_progress(series_index);
1501    if series_progress <= f32::EPSILON {
1502        return;
1503    }
1504
1505    for (idx, (xv, yv, size)) in bubble.data.iter().enumerate() {
1506        let item_progress = animation.item_progress(series_progress, idx);
1507        if item_progress <= f32::EPSILON {
1508            continue;
1509        }
1510        let x = map_x(*xv, area, x_scale);
1511        let y = map_y(*yv, area, y_scale);
1512        let t = (*size / max_size).clamp(0.0, 1.0).sqrt();
1513        let radius = (bubble.min_radius + (bubble.max_radius - bubble.min_radius) * t)
1514            * item_progress.sqrt();
1515        let fill = visual_map
1516            .map(|map| visual_color(map, *size))
1517            .unwrap_or_else(|| bubble.color.with_alpha(185));
1518        add_rect(
1519            cx,
1520            root,
1521            LayoutRect::new(x - radius, y - radius, radius * 2.0, radius * 2.0),
1522            fade_color(fill, item_progress),
1523            Some(stroke(Color::WHITE, 1.2)),
1524            radius,
1525        );
1526        if radius > 14.0 {
1527            add_text(
1528                cx,
1529                root,
1530                &(idx + 1).to_string(),
1531                10.0,
1532                Color::WHITE,
1533                x - 10.0,
1534                y - 6.0,
1535                20.0,
1536                12.0,
1537            );
1538        }
1539    }
1540}
1541
1542fn render_pie(
1543    cx: &mut fission_core::internal::InternalLoweringCx,
1544    root: &mut fission_core::internal::InternalIrBuilder,
1545    pie: &crate::series::pie::PieSeries,
1546    area: &ChartArea,
1547    theme: &ChartTheme,
1548    animation: ChartAnimationFrame,
1549    series_index: usize,
1550) {
1551    let total: f32 = pie.data.iter().map(|(_, value)| *value).sum();
1552    if total <= 0.0 {
1553        return;
1554    }
1555    let series_progress = animation.series_progress(series_index);
1556    if series_progress <= f32::EPSILON {
1557        return;
1558    }
1559    let cx_pie = area.plot.x() + area.plot.width() * 0.45;
1560    let cy_pie = area.plot.y() + area.plot.height() * 0.52;
1561    let max_r = area.plot.width().min(area.plot.height()) * 0.38;
1562    let inner = pie.inner_radius.max(0.0).min(max_r * 0.85);
1563    let max_value = pie
1564        .data
1565        .iter()
1566        .map(|(_, value)| *value)
1567        .fold(1.0_f32, f32::max);
1568    let mut angle = -std::f32::consts::PI / 2.0;
1569    let mut remaining_reveal = std::f32::consts::TAU * series_progress;
1570    for (idx, (label, value)) in pie.data.iter().enumerate() {
1571        let sweep = (*value / total) * std::f32::consts::TAU;
1572        let revealed_sweep = sweep.min(remaining_reveal.max(0.0));
1573        if revealed_sweep <= f32::EPSILON {
1574            break;
1575        }
1576        let end = angle + revealed_sweep;
1577        let mut outer = max_r;
1578        if let Some(rose_type) = pie.rose_type.as_deref() {
1579            let normalized = (*value / max_value).clamp(0.0, 1.0);
1580            outer = match rose_type {
1581                "area" => max_r * (0.42 + 0.58 * normalized.sqrt()),
1582                "radius" => max_r * (0.42 + 0.58 * normalized),
1583                _ => max_r,
1584            };
1585        }
1586        add_path(
1587            cx,
1588            root,
1589            &pie_slice(cx_pie, cy_pie, inner, outer, angle, end),
1590            Some(Fill::Solid(theme.palette[idx % theme.palette.len()])),
1591            Some(stroke(Color::WHITE, 1.2)),
1592        );
1593        let mid = angle + revealed_sweep / 2.0;
1594        let lx = cx_pie + (outer + 20.0) * mid.cos();
1595        let ly = cy_pie + (outer + 20.0) * mid.sin();
1596        if series_progress > 0.92 || revealed_sweep >= sweep * 0.92 {
1597            add_text(
1598                cx,
1599                root,
1600                label,
1601                11.0,
1602                theme.label,
1603                lx - 36.0,
1604                ly - 7.0,
1605                72.0,
1606                14.0,
1607            );
1608        }
1609        angle += sweep;
1610        remaining_reveal -= sweep;
1611    }
1612}
1613
1614fn render_boxplot(
1615    cx: &mut fission_core::internal::InternalLoweringCx,
1616    root: &mut fission_core::internal::InternalIrBuilder,
1617    boxplot: &crate::series::boxplot::BoxplotSeries,
1618    model: &ChartModel,
1619    area: &ChartArea,
1620    y_scale: &LinearScale,
1621    _theme: &ChartTheme,
1622    animation: ChartAnimationFrame,
1623    series_index: usize,
1624) {
1625    let series_progress = animation.series_progress(series_index);
1626    if series_progress <= f32::EPSILON {
1627        return;
1628    }
1629    let band = band_width(model, area);
1630    let box_w = band * 0.46;
1631    for (idx, row) in boxplot.data.iter().enumerate() {
1632        if row.len() < 5 {
1633            continue;
1634        }
1635        let item_progress = animation.item_progress(series_progress, idx);
1636        if item_progress <= f32::EPSILON {
1637            continue;
1638        }
1639        let x = map_category_x(idx, model, area);
1640        let median_anchor = map_y(row[2], area, y_scale);
1641        let min_y = interpolate(median_anchor, map_y(row[0], area, y_scale), item_progress);
1642        let q1_y = interpolate(median_anchor, map_y(row[1], area, y_scale), item_progress);
1643        let med_y = map_y(row[2], area, y_scale);
1644        let q3_y = interpolate(median_anchor, map_y(row[3], area, y_scale), item_progress);
1645        let max_y = interpolate(median_anchor, map_y(row[4], area, y_scale), item_progress);
1646        add_rect(
1647            cx,
1648            root,
1649            LayoutRect::new(
1650                x - box_w / 2.0,
1651                q3_y.min(q1_y),
1652                box_w,
1653                (q1_y - q3_y).abs().max(1.0),
1654            ),
1655            fade_color(boxplot.color.with_alpha(70), item_progress),
1656            Some(fade_stroke(stroke(boxplot.color, 1.5), item_progress)),
1657            1.0,
1658        );
1659        add_path(
1660            cx,
1661            root,
1662            &format!(
1663                "M {} {} L {} {} M {} {} L {} {} M {} {} L {} {} M {} {} L {} {}",
1664                x,
1665                min_y,
1666                x,
1667                q1_y.max(q3_y),
1668                x,
1669                max_y,
1670                x,
1671                q1_y.min(q3_y),
1672                x - box_w / 2.0,
1673                min_y,
1674                x + box_w / 2.0,
1675                min_y,
1676                x - box_w / 2.0,
1677                max_y,
1678                x + box_w / 2.0,
1679                max_y
1680            ),
1681            None,
1682            Some(fade_stroke(stroke(boxplot.color, 1.2), item_progress)),
1683        );
1684        add_path(
1685            cx,
1686            root,
1687            &format!(
1688                "M {} {} L {} {}",
1689                x - box_w / 2.0,
1690                med_y,
1691                x + box_w / 2.0,
1692                med_y
1693            ),
1694            None,
1695            Some(fade_stroke(stroke(boxplot.color, 2.0), item_progress)),
1696        );
1697    }
1698}
1699
1700fn render_candlestick(
1701    cx: &mut fission_core::internal::InternalLoweringCx,
1702    root: &mut fission_core::internal::InternalIrBuilder,
1703    candle: &crate::series::candlestick::CandlestickSeries,
1704    model: &ChartModel,
1705    area: &ChartArea,
1706    y_scale: &LinearScale,
1707    animation: ChartAnimationFrame,
1708    series_index: usize,
1709) {
1710    let series_progress = animation.series_progress(series_index);
1711    if series_progress <= f32::EPSILON {
1712        return;
1713    }
1714    let band = band_width(model, area);
1715    let box_w = band * 0.5;
1716    for (idx, row) in candle.data.iter().enumerate() {
1717        if row.len() < 4 {
1718            continue;
1719        }
1720        let item_progress = animation.item_progress(series_progress, idx);
1721        if item_progress <= f32::EPSILON {
1722            continue;
1723        }
1724        let open = row[0];
1725        let close = row[1];
1726        let low = row[2];
1727        let high = row[3];
1728        let up = close >= open;
1729        let color = if up {
1730            candle.color_up
1731        } else {
1732            candle.color_down
1733        };
1734        let x = map_category_x(idx, model, area);
1735        let center_y = map_y((open + close) / 2.0, area, y_scale);
1736        let open_y = interpolate(center_y, map_y(open, area, y_scale), item_progress);
1737        let close_y = interpolate(center_y, map_y(close, area, y_scale), item_progress);
1738        let high_y = interpolate(center_y, map_y(high, area, y_scale), item_progress);
1739        let low_y = interpolate(center_y, map_y(low, area, y_scale), item_progress);
1740        add_path(
1741            cx,
1742            root,
1743            &format!("M {} {} L {} {}", x, high_y, x, low_y),
1744            None,
1745            Some(fade_stroke(stroke(color, 1.4), item_progress)),
1746        );
1747        add_rect(
1748            cx,
1749            root,
1750            LayoutRect::new(
1751                x - box_w / 2.0,
1752                open_y.min(close_y),
1753                box_w,
1754                (open_y - close_y).abs().max(1.0),
1755            ),
1756            fade_color(if up { Color::WHITE } else { color }, item_progress),
1757            Some(fade_stroke(stroke(color, 1.4), item_progress)),
1758            0.0,
1759        );
1760    }
1761}
1762
1763fn render_heatmap(
1764    cx: &mut fission_core::internal::InternalLoweringCx,
1765    root: &mut fission_core::internal::InternalIrBuilder,
1766    heatmap: &crate::series::heatmap::HeatmapSeries,
1767    model: &ChartModel,
1768    visual_map: Option<&VisualMap>,
1769    area: &ChartArea,
1770    theme: &ChartTheme,
1771    animation: ChartAnimationFrame,
1772    series_index: usize,
1773) {
1774    let series_progress = animation.series_progress(series_index);
1775    if series_progress <= f32::EPSILON {
1776        return;
1777    }
1778    let max_x = heatmap.data.iter().map(|d| d.0).max().unwrap_or(0) + 1;
1779    let max_y = heatmap.data.iter().map(|d| d.1).max().unwrap_or(0) + 1;
1780    let cell_w = area.plot.width() / max_x.max(1) as f32;
1781    let cell_h = area.plot.height() / max_y.max(1) as f32;
1782    let max_val = heatmap.data.iter().map(|d| d.2).fold(1.0_f32, f32::max);
1783    for (idx, (x_idx, y_idx, val)) in heatmap.data.iter().enumerate() {
1784        let item_progress = animation.item_progress(series_progress, idx);
1785        if item_progress <= f32::EPSILON {
1786            continue;
1787        }
1788        let x = area.plot.x() + *x_idx as f32 * cell_w;
1789        let y = area.plot.bottom() - (*y_idx as f32 + 1.0) * cell_h;
1790        let fill = visual_map
1791            .map(|map| visual_color(map, *val))
1792            .unwrap_or_else(|| heat_color(*val / max_val));
1793        let rect = scale_rect_from_center(
1794            LayoutRect::new(x, y, cell_w, cell_h),
1795            0.82 + item_progress * 0.18,
1796        );
1797        add_rect(
1798            cx,
1799            root,
1800            rect,
1801            fade_color(fill, item_progress),
1802            Some(fade_stroke(stroke(Color::WHITE, 1.0), item_progress)),
1803            0.0,
1804        );
1805    }
1806    if model.x_axis.axis_type == AxisType::Category {
1807        for (idx, label) in model.x_axis.data.iter().enumerate() {
1808            add_text(
1809                cx,
1810                root,
1811                label,
1812                10.0,
1813                theme.label,
1814                area.plot.x() + idx as f32 * cell_w,
1815                area.plot.bottom() + 8.0,
1816                cell_w,
1817                14.0,
1818            );
1819        }
1820    }
1821}
1822
1823fn render_calendar_heatmap(
1824    cx: &mut fission_core::internal::InternalLoweringCx,
1825    root: &mut fission_core::internal::InternalIrBuilder,
1826    calendar: &crate::series::calendar_heatmap::CalendarHeatmapSeries,
1827    visual_map: Option<&VisualMap>,
1828    area: &ChartArea,
1829    theme: &ChartTheme,
1830    animation: ChartAnimationFrame,
1831    series_index: usize,
1832) {
1833    use chrono::{Datelike, Duration, NaiveDate};
1834
1835    let series_progress = animation.series_progress(series_index);
1836    if series_progress <= f32::EPSILON {
1837        return;
1838    }
1839
1840    let parsed: Vec<(NaiveDate, f32)> = calendar
1841        .data
1842        .iter()
1843        .filter_map(|(date, value)| {
1844            NaiveDate::parse_from_str(date, "%Y-%m-%d")
1845                .ok()
1846                .map(|date| (date, *value))
1847        })
1848        .collect();
1849    if parsed.is_empty() {
1850        return;
1851    }
1852
1853    let min_date = parsed.iter().map(|(date, _)| *date).min().unwrap();
1854    let max_date = parsed.iter().map(|(date, _)| *date).max().unwrap();
1855    let start = calendar
1856        .start
1857        .as_ref()
1858        .and_then(|date| NaiveDate::parse_from_str(date, "%Y-%m-%d").ok())
1859        .unwrap_or(min_date);
1860    let end = calendar
1861        .end
1862        .as_ref()
1863        .and_then(|date| NaiveDate::parse_from_str(date, "%Y-%m-%d").ok())
1864        .unwrap_or(max_date)
1865        .max(start);
1866
1867    let start_weekday = start.weekday().num_days_from_monday() as i64;
1868    let days = (end - start).num_days().max(0) + 1;
1869    let weeks = ((start_weekday + days + 6) / 7).max(1) as usize;
1870    let cell = (area.plot.width() / weeks as f32)
1871        .min(area.plot.height() / 7.0)
1872        .max(4.0);
1873    let x0 = area.plot.x();
1874    let y0 = area.plot.y() + (area.plot.height() - cell * 7.0) / 2.0;
1875    let values: HashMap<NaiveDate, f32> = parsed.into_iter().collect();
1876    let max_value = values.values().copied().fold(1.0_f32, f32::max);
1877
1878    let mut date = start;
1879    let mut idx = 0usize;
1880    while date <= end {
1881        let offset = (date - start).num_days() + start_weekday;
1882        let week = (offset / 7) as f32;
1883        let day = date.weekday().num_days_from_monday() as f32;
1884        let value = values.get(&date).copied().unwrap_or(0.0);
1885        let fill = visual_map
1886            .map(|map| visual_color(map, value))
1887            .unwrap_or_else(|| heat_color(value / max_value));
1888        let item_progress = animation.item_progress(series_progress, idx);
1889        let rect = scale_rect_from_center(
1890            LayoutRect::new(x0 + week * cell, y0 + day * cell, cell - 2.0, cell - 2.0),
1891            0.82 + item_progress * 0.18,
1892        );
1893        add_rect(
1894            cx,
1895            root,
1896            rect,
1897            fade_color(
1898                fill.with_alpha(if value > 0.0 { 230 } else { 55 }),
1899                item_progress,
1900            ),
1901            Some(fade_stroke(stroke(Color::WHITE, 0.8), item_progress)),
1902            2.0,
1903        );
1904        date += Duration::days(1);
1905        idx += 1;
1906    }
1907
1908    for (idx, label) in ["Mon", "Wed", "Fri", "Sun"].iter().enumerate() {
1909        let day = [0.0, 2.0, 4.0, 6.0][idx];
1910        add_text(
1911            cx,
1912            root,
1913            label,
1914            10.0,
1915            theme.label,
1916            x0 - 34.0,
1917            y0 + day * cell - 2.0,
1918            28.0,
1919            12.0,
1920        );
1921    }
1922    add_text(
1923        cx,
1924        root,
1925        &format!("{} to {}", start.format("%b %Y"), end.format("%b %Y")),
1926        11.0,
1927        theme.label,
1928        x0,
1929        y0 + cell * 7.0 + 8.0,
1930        area.plot.width(),
1931        16.0,
1932    );
1933}
1934
1935fn render_graph(
1936    cx: &mut fission_core::internal::InternalLoweringCx,
1937    root: &mut fission_core::internal::InternalIrBuilder,
1938    graph: &crate::series::graph::GraphSeries,
1939    area: &ChartArea,
1940    theme: &ChartTheme,
1941    animation: ChartAnimationFrame,
1942    series_index: usize,
1943) {
1944    let series_progress = animation.series_progress(series_index);
1945    if series_progress <= f32::EPSILON {
1946        return;
1947    }
1948    let positions = crate::layout::force_graph::ForceGraphLayout::compute_positions(
1949        &graph.nodes,
1950        &graph.edges,
1951        area.plot.width(),
1952        area.plot.height(),
1953        80,
1954    );
1955    render_edges(
1956        cx,
1957        root,
1958        &graph.edges,
1959        &positions,
1960        area,
1961        theme,
1962        animation,
1963        series_progress,
1964    );
1965    for (idx, node) in graph.nodes.iter().enumerate() {
1966        let item_progress = animation.item_progress(series_progress, idx + graph.edges.len());
1967        if item_progress <= f32::EPSILON {
1968            continue;
1969        }
1970        if let Some((x, y)) = positions.get(&node.id) {
1971            let r = (7.0 + node.value.sqrt().min(24.0)) * item_progress.sqrt();
1972            let px = area.plot.x() + *x;
1973            let py = area.plot.y() + *y;
1974            add_rect(
1975                cx,
1976                root,
1977                LayoutRect::new(px - r, py - r, r * 2.0, r * 2.0),
1978                fade_color(theme.palette[idx % theme.palette.len()], item_progress),
1979                Some(fade_stroke(stroke(Color::WHITE, 1.0), item_progress)),
1980                r,
1981            );
1982            if item_progress > 0.82 {
1983                add_text(
1984                    cx,
1985                    root,
1986                    &node.name,
1987                    10.0,
1988                    theme.label,
1989                    px + r + 4.0,
1990                    py - 7.0,
1991                    100.0,
1992                    14.0,
1993                );
1994            }
1995        }
1996    }
1997}
1998
1999fn render_lines(
2000    cx: &mut fission_core::internal::InternalLoweringCx,
2001    root: &mut fission_core::internal::InternalIrBuilder,
2002    lines: &crate::series::lines::LinesSeries,
2003    area: &ChartArea,
2004    theme: &ChartTheme,
2005    animation: ChartAnimationFrame,
2006    series_index: usize,
2007) {
2008    if lines.data.is_empty() {
2009        return;
2010    }
2011    let series_progress = animation.series_progress(series_index);
2012    if series_progress <= f32::EPSILON {
2013        return;
2014    }
2015
2016    let mut min_x = f32::MAX;
2017    let mut max_x = f32::MIN;
2018    let mut min_y = f32::MAX;
2019    let mut max_y = f32::MIN;
2020    let mut max_value = 1.0_f32;
2021    for segment in &lines.data {
2022        for (x, y) in [segment.from, segment.to] {
2023            min_x = min_x.min(x);
2024            max_x = max_x.max(x);
2025            min_y = min_y.min(y);
2026            max_y = max_y.max(y);
2027        }
2028        max_value = max_value.max(segment.value);
2029    }
2030    let (min_x, max_x) = normalize_bounds(min_x, max_x);
2031    let (min_y, max_y) = normalize_bounds(min_y, max_y);
2032
2033    for (idx, segment) in lines.data.iter().enumerate() {
2034        let item_progress = animation.item_progress(series_progress, idx);
2035        if item_progress <= f32::EPSILON {
2036            continue;
2037        }
2038        let from = map_lines_point(segment.from, min_x, max_x, min_y, max_y, area);
2039        let full_to = map_lines_point(segment.to, min_x, max_x, min_y, max_y, area);
2040        let to = interpolate_point(from, full_to, item_progress);
2041        let intensity = (segment.value / max_value).clamp(0.0, 1.0);
2042        let stroke_color = fade_color(
2043            mix_color(lines.color.with_alpha(110), lines.color, intensity),
2044            item_progress,
2045        );
2046        let control_x = (from.0 + to.0) / 2.0;
2047        let control_y = (from.1 + to.1) / 2.0 - 36.0 * intensity;
2048        let path = format!(
2049            "M {} {} C {} {} {} {} {} {}",
2050            from.0, from.1, control_x, control_y, control_x, control_y, to.0, to.1
2051        );
2052        add_path(
2053            cx,
2054            root,
2055            &path,
2056            None,
2057            Some(stroke(stroke_color, 1.6 + 2.2 * intensity)),
2058        );
2059        if item_progress > 0.72 {
2060            draw_arrow_head(cx, root, from, to, stroke_color);
2061        }
2062
2063        if lines.effect {
2064            let mid = quadratic_midpoint(from, (control_x, control_y), to);
2065            let radius = 4.0 + 5.0 * intensity;
2066            add_rect(
2067                cx,
2068                root,
2069                LayoutRect::new(mid.0 - radius, mid.1 - radius, radius * 2.0, radius * 2.0),
2070                stroke_color.with_alpha(130),
2071                Some(stroke(Color::WHITE.with_alpha(150), 1.0)),
2072                radius,
2073            );
2074        }
2075    }
2076
2077    add_text(
2078        cx,
2079        root,
2080        "lines",
2081        10.0,
2082        theme.label,
2083        area.plot.x() + 8.0,
2084        area.plot.y() + 8.0,
2085        56.0,
2086        14.0,
2087    );
2088}
2089
2090fn render_tree(
2091    cx: &mut fission_core::internal::InternalLoweringCx,
2092    root: &mut fission_core::internal::InternalIrBuilder,
2093    tree: &crate::series::tree::TreeSeries,
2094    area: &ChartArea,
2095    theme: &ChartTheme,
2096    animation: ChartAnimationFrame,
2097    series_index: usize,
2098) {
2099    if tree.data.is_empty() {
2100        return;
2101    }
2102    let series_progress = animation.series_progress(series_index);
2103    if series_progress <= f32::EPSILON {
2104        return;
2105    }
2106
2107    let leaf_count = tree.data.iter().map(tree_leaf_count).sum::<usize>().max(1);
2108    let depth = tree
2109        .data
2110        .iter()
2111        .map(treemap_depth)
2112        .max()
2113        .unwrap_or(1)
2114        .max(1);
2115    let mut next_leaf = 0usize;
2116    let mut nodes = Vec::<TreeRenderNode>::new();
2117    let mut edges = Vec::<((f32, f32), (f32, f32))>::new();
2118
2119    for root_node in &tree.data {
2120        if tree.radial {
2121            layout_radial_tree_node(
2122                root_node,
2123                0,
2124                depth,
2125                leaf_count,
2126                &mut next_leaf,
2127                area,
2128                &mut nodes,
2129                &mut edges,
2130            );
2131        } else {
2132            layout_tree_node(
2133                root_node,
2134                0,
2135                depth,
2136                leaf_count,
2137                &mut next_leaf,
2138                area,
2139                &mut nodes,
2140                &mut edges,
2141            );
2142        }
2143    }
2144
2145    for (idx, (from, to)) in edges.iter().enumerate() {
2146        let item_progress = animation.item_progress(series_progress, idx);
2147        if item_progress <= f32::EPSILON {
2148            continue;
2149        }
2150        let to = interpolate_point(*from, *to, item_progress);
2151        let path = if tree.radial {
2152            format!("M {} {} L {} {}", from.0, from.1, to.0, to.1)
2153        } else {
2154            let mid_x = (from.0 + to.0) / 2.0;
2155            format!(
2156                "M {} {} C {} {} {} {} {} {}",
2157                from.0, from.1, mid_x, from.1, mid_x, to.1, to.0, to.1
2158            )
2159        };
2160        add_path(
2161            cx,
2162            root,
2163            &path,
2164            None,
2165            Some(fade_stroke(
2166                stroke(theme.axis_line.with_alpha(150), 1.3),
2167                item_progress,
2168            )),
2169        );
2170    }
2171
2172    for (idx, node) in nodes.iter().enumerate() {
2173        let item_progress = animation.item_progress(series_progress, idx + edges.len());
2174        if item_progress <= f32::EPSILON {
2175            continue;
2176        }
2177        let radius = (if node.depth == 0 { 8.0 } else { 6.0 }) * item_progress.sqrt();
2178        let color = theme.palette[idx % theme.palette.len()];
2179        add_rect(
2180            cx,
2181            root,
2182            LayoutRect::new(node.x - radius, node.y - radius, radius * 2.0, radius * 2.0),
2183            fade_color(color, item_progress),
2184            Some(fade_stroke(stroke(Color::WHITE, 1.0), item_progress)),
2185            radius,
2186        );
2187        if item_progress > 0.82 && (!tree.radial || node.depth > 0) {
2188            add_text(
2189                cx,
2190                root,
2191                &node.name,
2192                10.0,
2193                theme.label,
2194                node.x + radius + 5.0,
2195                node.y - 7.0,
2196                110.0,
2197                14.0,
2198            );
2199        }
2200    }
2201}
2202
2203fn render_treemap(
2204    cx: &mut fission_core::internal::InternalLoweringCx,
2205    root: &mut fission_core::internal::InternalIrBuilder,
2206    treemap: &crate::series::treemap::TreemapSeries,
2207    area: &ChartArea,
2208    theme: &ChartTheme,
2209    animation: ChartAnimationFrame,
2210    series_index: usize,
2211) {
2212    let series_progress = animation.series_progress(series_index);
2213    if series_progress <= f32::EPSILON {
2214        return;
2215    }
2216    let layout = crate::layout::treemap::TreemapLayout::squarify(&treemap.data, area.plot);
2217    for (idx, (node, rect)) in layout.iter().enumerate() {
2218        let item_progress = animation.item_progress(series_progress, idx);
2219        if item_progress <= f32::EPSILON {
2220            continue;
2221        }
2222        let rect = scale_rect_from_center(*rect, 0.86 + item_progress * 0.14);
2223        add_rect(
2224            cx,
2225            root,
2226            rect,
2227            fade_color(theme.palette[idx % theme.palette.len()], item_progress),
2228            Some(fade_stroke(stroke(Color::WHITE, 2.0), item_progress)),
2229            3.0,
2230        );
2231        if item_progress > 0.82 && rect.width() > 58.0 && rect.height() > 24.0 {
2232            add_text(
2233                cx,
2234                root,
2235                &node.name,
2236                11.0,
2237                Color::WHITE,
2238                rect.x() + 6.0,
2239                rect.y() + 6.0,
2240                rect.width() - 12.0,
2241                16.0,
2242            );
2243        }
2244    }
2245}
2246
2247fn render_radar(
2248    cx: &mut fission_core::internal::InternalLoweringCx,
2249    root: &mut fission_core::internal::InternalIrBuilder,
2250    radar: &crate::series::radar::RadarSeries,
2251    area: &ChartArea,
2252    theme: &ChartTheme,
2253    animation: ChartAnimationFrame,
2254    series_index: usize,
2255) {
2256    let axes = radar.data.first().map(|data| data.len()).unwrap_or(0);
2257    if axes == 0 {
2258        return;
2259    }
2260    let series_progress = animation.series_progress(series_index);
2261    if series_progress <= f32::EPSILON {
2262        return;
2263    }
2264    let center = (
2265        area.plot.x() + area.plot.width() / 2.0,
2266        area.plot.y() + area.plot.height() / 2.0,
2267    );
2268    let r = area.plot.width().min(area.plot.height()) * 0.38;
2269    for ring in 1..=5 {
2270        let rr = r * ring as f32 / 5.0;
2271        let mut path = String::new();
2272        for axis in 0..axes {
2273            let angle = radar_angle(axis, axes);
2274            let x = center.0 + rr * angle.cos();
2275            let y = center.1 + rr * angle.sin();
2276            if axis == 0 {
2277                path.push_str(&format!("M {} {}", x, y));
2278            } else {
2279                path.push_str(&format!(" L {} {}", x, y));
2280            }
2281        }
2282        path.push_str(" Z");
2283        add_path(cx, root, &path, None, Some(stroke(theme.grid_line, 1.0)));
2284    }
2285    for axis in 0..axes {
2286        let angle = radar_angle(axis, axes);
2287        add_path(
2288            cx,
2289            root,
2290            &format!(
2291                "M {} {} L {} {}",
2292                center.0,
2293                center.1,
2294                center.0 + r * angle.cos(),
2295                center.1 + r * angle.sin()
2296            ),
2297            None,
2298            Some(stroke(theme.axis_line, 1.0)),
2299        );
2300    }
2301    for (idx, data) in radar.data.iter().enumerate() {
2302        let item_progress = animation.item_progress(series_progress, idx);
2303        if item_progress <= f32::EPSILON {
2304            continue;
2305        }
2306        let mut path = String::new();
2307        for (axis, value) in data.iter().enumerate() {
2308            let angle = radar_angle(axis, axes);
2309            let rr = r * (*value / 100.0).clamp(0.0, 1.0) * item_progress;
2310            let x = center.0 + rr * angle.cos();
2311            let y = center.1 + rr * angle.sin();
2312            if axis == 0 {
2313                path.push_str(&format!("M {} {}", x, y));
2314            } else {
2315                path.push_str(&format!(" L {} {}", x, y));
2316            }
2317        }
2318        path.push_str(" Z");
2319        let c = theme.palette[idx % theme.palette.len()];
2320        add_path(
2321            cx,
2322            root,
2323            &path,
2324            Some(Fill::Solid(fade_color(c.with_alpha(70), item_progress))),
2325            Some(fade_stroke(stroke(c, 2.0), item_progress)),
2326        );
2327    }
2328}
2329
2330fn render_polar_bar(
2331    cx: &mut fission_core::internal::InternalLoweringCx,
2332    root: &mut fission_core::internal::InternalIrBuilder,
2333    polar: &crate::series::polar::PolarBarSeries,
2334    area: &ChartArea,
2335    theme: &ChartTheme,
2336    animation: ChartAnimationFrame,
2337    series_index: usize,
2338) {
2339    if polar.data.is_empty() {
2340        return;
2341    }
2342    let series_progress = animation.series_progress(series_index);
2343    if series_progress <= f32::EPSILON {
2344        return;
2345    }
2346
2347    let center = (
2348        area.plot.x() + area.plot.width() / 2.0,
2349        area.plot.y() + area.plot.height() / 2.0,
2350    );
2351    let max_r = area.plot.width().min(area.plot.height()) * 0.43;
2352    let inner = polar.inner_radius.min(max_r * 0.72);
2353    let max_value = polar
2354        .data
2355        .iter()
2356        .map(|(_, value)| *value)
2357        .fold(1.0_f32, f32::max);
2358    let slot = std::f32::consts::TAU / polar.data.len() as f32;
2359
2360    for ring in 1..=4 {
2361        let r = inner + (max_r - inner) * ring as f32 / 4.0;
2362        add_path(
2363            cx,
2364            root,
2365            &circle_path(center.0, center.1, r),
2366            None,
2367            Some(stroke(theme.grid_line, 1.0)),
2368        );
2369    }
2370
2371    for (idx, (label, value)) in polar.data.iter().enumerate() {
2372        let item_progress = animation.item_progress(series_progress, idx);
2373        if item_progress <= f32::EPSILON {
2374            continue;
2375        }
2376        let start = -std::f32::consts::PI / 2.0 + idx as f32 * slot + slot * 0.10;
2377        let end = start + slot * 0.80 * item_progress;
2378        let outer = inner + (max_r - inner) * (*value / max_value).clamp(0.0, 1.0) * item_progress;
2379        let c = mix_color(
2380            polar.color.with_alpha(150),
2381            theme.palette[idx % theme.palette.len()],
2382            0.35,
2383        );
2384        add_path(
2385            cx,
2386            root,
2387            &pie_slice(center.0, center.1, inner, outer, start, end),
2388            Some(Fill::Solid(fade_color(c, item_progress))),
2389            Some(fade_stroke(stroke(Color::WHITE, 1.0), item_progress)),
2390        );
2391        let mid = (start + end) / 2.0;
2392        if item_progress > 0.86 {
2393            add_text(
2394                cx,
2395                root,
2396                label,
2397                10.0,
2398                theme.label,
2399                center.0 + (max_r + 16.0) * mid.cos() - 28.0,
2400                center.1 + (max_r + 16.0) * mid.sin() - 7.0,
2401                56.0,
2402                14.0,
2403            );
2404        }
2405    }
2406}
2407
2408fn render_polar_line(
2409    cx: &mut fission_core::internal::InternalLoweringCx,
2410    root: &mut fission_core::internal::InternalIrBuilder,
2411    polar: &crate::series::polar::PolarLineSeries,
2412    area: &ChartArea,
2413    theme: &ChartTheme,
2414    animation: ChartAnimationFrame,
2415    series_index: usize,
2416) {
2417    if polar.data.is_empty() {
2418        return;
2419    }
2420    let series_progress = animation.series_progress(series_index);
2421    if series_progress <= f32::EPSILON {
2422        return;
2423    }
2424
2425    let center = (
2426        area.plot.x() + area.plot.width() / 2.0,
2427        area.plot.y() + area.plot.height() / 2.0,
2428    );
2429    let max_r = area.plot.width().min(area.plot.height()) * 0.42;
2430    let max_value = polar
2431        .data
2432        .iter()
2433        .map(|(_, radius)| *radius)
2434        .fold(1.0_f32, f32::max);
2435    for ring in 1..=4 {
2436        let r = max_r * ring as f32 / 4.0;
2437        add_path(
2438            cx,
2439            root,
2440            &circle_path(center.0, center.1, r),
2441            None,
2442            Some(stroke(theme.grid_line, 1.0)),
2443        );
2444    }
2445    for axis in 0..8 {
2446        let angle = -std::f32::consts::PI / 2.0 + axis as f32 / 8.0 * std::f32::consts::TAU;
2447        add_path(
2448            cx,
2449            root,
2450            &format!(
2451                "M {} {} L {} {}",
2452                center.0,
2453                center.1,
2454                center.0 + max_r * angle.cos(),
2455                center.1 + max_r * angle.sin()
2456            ),
2457            None,
2458            Some(stroke(theme.grid_line, 0.8)),
2459        );
2460    }
2461
2462    let points: Vec<(f32, f32)> = polar
2463        .data
2464        .iter()
2465        .map(|(angle_degrees, radius)| {
2466            let angle = angle_degrees.to_radians() - std::f32::consts::PI / 2.0;
2467            let r = max_r * (*radius / max_value).clamp(0.0, 1.0);
2468            (center.0 + r * angle.cos(), center.1 + r * angle.sin())
2469        })
2470        .collect();
2471    let revealed_points = reveal_points(&points, series_progress);
2472    add_path(
2473        cx,
2474        root,
2475        &path_for_line(&revealed_points, polar.smooth, None),
2476        None,
2477        Some(fade_stroke(stroke(polar.color, 2.4), series_progress)),
2478    );
2479    for (idx, (x, y)) in revealed_points.into_iter().enumerate() {
2480        let item_progress = animation.item_progress(series_progress, idx);
2481        if item_progress <= f32::EPSILON {
2482            continue;
2483        }
2484        let r = 4.0 * item_progress.sqrt();
2485        add_rect(
2486            cx,
2487            root,
2488            LayoutRect::new(x - r, y - r, r * 2.0, r * 2.0),
2489            fade_color(polar.color, item_progress),
2490            Some(fade_stroke(stroke(Color::WHITE, 1.0), item_progress)),
2491            r,
2492        );
2493    }
2494}
2495
2496fn render_single_axis(
2497    cx: &mut fission_core::internal::InternalLoweringCx,
2498    root: &mut fission_core::internal::InternalIrBuilder,
2499    single_axis: &crate::series::single_axis::SingleAxisSeries,
2500    area: &ChartArea,
2501    theme: &ChartTheme,
2502    animation: ChartAnimationFrame,
2503    series_index: usize,
2504) {
2505    if single_axis.data.is_empty() {
2506        return;
2507    }
2508    let series_progress = animation.series_progress(series_index);
2509    if series_progress <= f32::EPSILON {
2510        return;
2511    }
2512
2513    let min = single_axis
2514        .data
2515        .iter()
2516        .map(|(value, _)| *value)
2517        .fold(f32::MAX, f32::min);
2518    let max = single_axis
2519        .data
2520        .iter()
2521        .map(|(value, _)| *value)
2522        .fold(f32::MIN, f32::max);
2523    let scale = LinearScale::nice(min, max, 6);
2524    let axis_y = area.plot.y() + area.plot.height() * 0.55;
2525    add_path(
2526        cx,
2527        root,
2528        &format!(
2529            "M {} {} L {} {}",
2530            area.plot.x(),
2531            axis_y,
2532            area.plot.right(),
2533            axis_y
2534        ),
2535        None,
2536        Some(stroke(theme.axis_line, 1.2)),
2537    );
2538    for tick in &scale.ticks {
2539        let x = map_x(*tick, area, &scale);
2540        add_path(
2541            cx,
2542            root,
2543            &format!("M {} {} L {} {}", x, axis_y - 5.0, x, axis_y + 5.0),
2544            None,
2545            Some(stroke(theme.axis_line, 1.0)),
2546        );
2547        add_text(
2548            cx,
2549            root,
2550            &format_tick(*tick),
2551            10.0,
2552            theme.label,
2553            x - 20.0,
2554            axis_y + 10.0,
2555            40.0,
2556            14.0,
2557        );
2558    }
2559    let max_size = single_axis
2560        .data
2561        .iter()
2562        .map(|(_, size)| *size)
2563        .fold(1.0_f32, f32::max);
2564    for (idx, (value, size)) in single_axis.data.iter().enumerate() {
2565        let item_progress = animation.item_progress(series_progress, idx);
2566        if item_progress <= f32::EPSILON {
2567            continue;
2568        }
2569        let x = map_x(*value, area, &scale);
2570        let lane = idx % 5;
2571        let y = axis_y - 32.0 + lane as f32 * 16.0;
2572        let r = (4.0 + 12.0 * (*size / max_size).clamp(0.0, 1.0).sqrt()) * item_progress.sqrt();
2573        add_rect(
2574            cx,
2575            root,
2576            LayoutRect::new(x - r, y - r, r * 2.0, r * 2.0),
2577            fade_color(single_axis.color.with_alpha(170), item_progress),
2578            Some(fade_stroke(stroke(Color::WHITE, 1.0), item_progress)),
2579            r,
2580        );
2581    }
2582}
2583
2584fn render_funnel(
2585    cx: &mut fission_core::internal::InternalLoweringCx,
2586    root: &mut fission_core::internal::InternalIrBuilder,
2587    funnel: &crate::series::funnel::FunnelSeries,
2588    area: &ChartArea,
2589    theme: &ChartTheme,
2590    animation: ChartAnimationFrame,
2591    series_index: usize,
2592) {
2593    if funnel.data.is_empty() {
2594        return;
2595    }
2596    let series_progress = animation.series_progress(series_index);
2597    if series_progress <= f32::EPSILON {
2598        return;
2599    }
2600    let max = funnel.data.iter().map(|(_, v)| *v).fold(1.0_f32, f32::max);
2601    let step_h = area.plot.height() / funnel.data.len() as f32;
2602    let cx_mid = area.plot.x() + area.plot.width() / 2.0;
2603    for (idx, (label, value)) in funnel.data.iter().enumerate() {
2604        let item_progress = animation.item_progress(series_progress, idx);
2605        if item_progress <= f32::EPSILON {
2606            continue;
2607        }
2608        let y = area.plot.y() + idx as f32 * step_h;
2609        let top_w = if idx == 0 {
2610            area.plot.width()
2611        } else {
2612            area.plot.width() * funnel.data[idx - 1].1 / max
2613        } * item_progress;
2614        let bot_w = area.plot.width() * *value / max * item_progress;
2615        let path = format!(
2616            "M {} {} L {} {} L {} {} L {} {} Z",
2617            cx_mid - top_w / 2.0,
2618            y,
2619            cx_mid + top_w / 2.0,
2620            y,
2621            cx_mid + bot_w / 2.0,
2622            y + step_h,
2623            cx_mid - bot_w / 2.0,
2624            y + step_h
2625        );
2626        add_path(
2627            cx,
2628            root,
2629            &path,
2630            Some(Fill::Solid(fade_color(
2631                theme.palette[idx % theme.palette.len()],
2632                item_progress,
2633            ))),
2634            Some(fade_stroke(stroke(Color::WHITE, 1.5), item_progress)),
2635        );
2636        if item_progress > 0.82 {
2637            add_text(
2638                cx,
2639                root,
2640                label,
2641                12.0,
2642                Color::WHITE,
2643                cx_mid - 50.0,
2644                y + step_h / 2.0 - 8.0,
2645                100.0,
2646                16.0,
2647            );
2648        }
2649    }
2650}
2651
2652fn render_gauge(
2653    cx: &mut fission_core::internal::InternalLoweringCx,
2654    root: &mut fission_core::internal::InternalIrBuilder,
2655    gauge: &crate::series::gauge::GaugeSeries,
2656    area: &ChartArea,
2657    theme: &ChartTheme,
2658    animation: ChartAnimationFrame,
2659    series_index: usize,
2660) {
2661    let center = (
2662        area.plot.x() + area.plot.width() / 2.0,
2663        area.plot.y() + area.plot.height() * 0.68,
2664    );
2665    let r = area.plot.width().min(area.plot.height()) * 0.42;
2666    add_path(
2667        cx,
2668        root,
2669        &arc(
2670            center.0,
2671            center.1,
2672            r,
2673            std::f32::consts::PI,
2674            std::f32::consts::TAU,
2675        ),
2676        None,
2677        Some(stroke(theme.grid_line, 18.0)),
2678    );
2679    if let Some((label, value)) = gauge.data.first() {
2680        let series_progress = animation.series_progress(series_index);
2681        if series_progress <= f32::EPSILON {
2682            return;
2683        }
2684        let pct = (*value / 100.0).clamp(0.0, 1.0);
2685        let angle = std::f32::consts::PI + pct * std::f32::consts::PI * series_progress;
2686        add_path(
2687            cx,
2688            root,
2689            &arc(center.0, center.1, r, std::f32::consts::PI, angle),
2690            None,
2691            Some(stroke(theme.palette[0], 18.0)),
2692        );
2693        add_path(
2694            cx,
2695            root,
2696            &format!(
2697                "M {} {} L {} {}",
2698                center.0,
2699                center.1,
2700                center.0 + r * 0.78 * angle.cos(),
2701                center.1 + r * 0.78 * angle.sin()
2702            ),
2703            None,
2704            Some(stroke(theme.title, 3.5)),
2705        );
2706        add_rect(
2707            cx,
2708            root,
2709            LayoutRect::new(center.0 - 7.0, center.1 - 7.0, 14.0, 14.0),
2710            theme.title,
2711            None,
2712            7.0,
2713        );
2714        add_text(
2715            cx,
2716            root,
2717            &format!("{} {:.0}", label, value),
2718            18.0,
2719            theme.title,
2720            center.0 - 70.0,
2721            center.1 + 20.0,
2722            140.0,
2723            24.0,
2724        );
2725    }
2726}
2727
2728fn render_map(
2729    cx: &mut fission_core::internal::InternalLoweringCx,
2730    root: &mut fission_core::internal::InternalIrBuilder,
2731    map: &crate::series::map::MapSeries,
2732    visual_map: Option<&VisualMap>,
2733    area: &ChartArea,
2734    theme: &ChartTheme,
2735    animation: ChartAnimationFrame,
2736    series_index: usize,
2737) {
2738    let series_progress = animation.series_progress(series_index);
2739    if series_progress <= f32::EPSILON {
2740        return;
2741    }
2742    let regions =
2743        crate::layout::map::MapLayout::compute_geojson(map, area.plot.width(), area.plot.height());
2744    if regions.is_empty() {
2745        return;
2746    }
2747    let values: Vec<f32> = regions.iter().filter_map(|region| region.value).collect();
2748    let min = values.iter().copied().fold(f32::MAX, f32::min);
2749    let max = values.iter().copied().fold(f32::MIN, f32::max);
2750    let denom = (max - min).max(f32::EPSILON);
2751
2752    for (idx, region) in regions.iter().enumerate() {
2753        let item_progress = animation.item_progress(series_progress, idx);
2754        if item_progress <= f32::EPSILON {
2755            continue;
2756        }
2757        let fill = if let Some(value) = region.value {
2758            visual_map
2759                .map(|map| visual_color(map, value))
2760                .unwrap_or_else(|| {
2761                    mix_color(
2762                        theme.palette[idx % theme.palette.len()].with_alpha(90),
2763                        theme.palette[idx % theme.palette.len()],
2764                        ((value - min) / denom).clamp(0.0, 1.0),
2765                    )
2766                })
2767        } else {
2768            color(226, 232, 240, 255)
2769        };
2770        let shifted = translate_path(&region.path, area.plot.x(), area.plot.y());
2771        add_path(
2772            cx,
2773            root,
2774            &shifted,
2775            Some(Fill::Solid(fade_color(fill, item_progress))),
2776            Some(fade_stroke(stroke(Color::WHITE, 1.4), item_progress)),
2777        );
2778        if let Some((x, y, width, height)) = path_bounds(&shifted) {
2779            if item_progress > 0.82 && width > 42.0 && height > 18.0 {
2780                add_text(
2781                    cx,
2782                    root,
2783                    &region.name,
2784                    10.0,
2785                    theme.title,
2786                    x + 4.0,
2787                    y + height / 2.0 - 7.0,
2788                    width - 8.0,
2789                    14.0,
2790                );
2791            }
2792        }
2793    }
2794}
2795
2796fn render_sankey(
2797    cx: &mut fission_core::internal::InternalLoweringCx,
2798    root: &mut fission_core::internal::InternalIrBuilder,
2799    sankey: &crate::series::sankey::SankeySeries,
2800    area: &ChartArea,
2801    theme: &ChartTheme,
2802    animation: ChartAnimationFrame,
2803    series_index: usize,
2804) {
2805    let series_progress = animation.series_progress(series_index);
2806    if series_progress <= f32::EPSILON {
2807        return;
2808    }
2809    let (rects, paths) = crate::layout::sankey::SankeyLayout::compute(
2810        &sankey.nodes,
2811        &sankey.edges,
2812        area.plot.width(),
2813        area.plot.height(),
2814    );
2815    for (idx, (_, _, path)) in paths.iter().enumerate() {
2816        let item_progress = animation.item_progress(series_progress, idx);
2817        if item_progress <= f32::EPSILON {
2818            continue;
2819        }
2820        add_path(
2821            cx,
2822            root,
2823            &translate_path(path, area.plot.x(), area.plot.y()),
2824            Some(Fill::Solid(fade_color(
2825                theme.palette[idx % theme.palette.len()].with_alpha(115),
2826                item_progress,
2827            ))),
2828            None,
2829        );
2830    }
2831    for (idx, node) in sankey.nodes.iter().enumerate() {
2832        let item_progress = animation.item_progress(series_progress, idx + paths.len());
2833        if item_progress <= f32::EPSILON {
2834            continue;
2835        }
2836        if let Some(rect) = rects.get(&node.id) {
2837            let shifted = scale_rect_from_center(
2838                LayoutRect::new(
2839                    area.plot.x() + rect.x(),
2840                    area.plot.y() + rect.y(),
2841                    rect.width(),
2842                    rect.height(),
2843                ),
2844                0.86 + item_progress * 0.14,
2845            );
2846            add_rect(
2847                cx,
2848                root,
2849                shifted,
2850                fade_color(theme.palette[idx % theme.palette.len()], item_progress),
2851                None,
2852                3.0,
2853            );
2854            if item_progress > 0.82 {
2855                add_text(
2856                    cx,
2857                    root,
2858                    &node.name,
2859                    11.0,
2860                    theme.label,
2861                    shifted.right() + 6.0,
2862                    shifted.y() + 4.0,
2863                    100.0,
2864                    14.0,
2865                );
2866            }
2867        }
2868    }
2869}
2870
2871fn render_sunburst(
2872    cx: &mut fission_core::internal::InternalLoweringCx,
2873    root: &mut fission_core::internal::InternalIrBuilder,
2874    sunburst: &crate::series::sunburst::SunburstSeries,
2875    area: &ChartArea,
2876    theme: &ChartTheme,
2877    animation: ChartAnimationFrame,
2878    series_index: usize,
2879) {
2880    if sunburst.data.is_empty() {
2881        return;
2882    }
2883    let series_progress = animation.series_progress(series_index);
2884    if series_progress <= f32::EPSILON {
2885        return;
2886    }
2887    let center = (
2888        area.plot.x() + area.plot.width() / 2.0,
2889        area.plot.y() + area.plot.height() / 2.0,
2890    );
2891    let depth = sunburst
2892        .data
2893        .iter()
2894        .map(treemap_depth)
2895        .max()
2896        .unwrap_or(1)
2897        .max(1);
2898    let radius = area.plot.width().min(area.plot.height()) * 0.44;
2899    let ring = radius / depth as f32;
2900    let total: f32 = sunburst.data.iter().map(treemap_weight).sum();
2901    if total <= 0.0 {
2902        return;
2903    }
2904    let mut angle = -std::f32::consts::PI / 2.0;
2905    let mut index = 0usize;
2906    for node in &sunburst.data {
2907        let sweep = treemap_weight(node) / total * std::f32::consts::TAU * series_progress;
2908        render_sunburst_node(
2909            cx,
2910            root,
2911            node,
2912            center,
2913            ring,
2914            0,
2915            angle,
2916            angle + sweep,
2917            theme,
2918            &mut index,
2919            animation,
2920            series_progress,
2921        );
2922        angle += sweep;
2923    }
2924}
2925
2926#[allow(clippy::too_many_arguments)]
2927fn render_sunburst_node(
2928    cx: &mut fission_core::internal::InternalLoweringCx,
2929    root: &mut fission_core::internal::InternalIrBuilder,
2930    node: &crate::series::treemap::TreemapNode,
2931    center: (f32, f32),
2932    ring: f32,
2933    depth: usize,
2934    start: f32,
2935    end: f32,
2936    theme: &ChartTheme,
2937    index: &mut usize,
2938    animation: ChartAnimationFrame,
2939    series_progress: f32,
2940) {
2941    if end <= start {
2942        return;
2943    }
2944    let item_index = *index;
2945    let item_progress = animation.item_progress(series_progress, item_index);
2946    if item_progress <= f32::EPSILON {
2947        *index += 1;
2948        return;
2949    }
2950    let inner = depth as f32 * ring;
2951    let outer = inner + ring * 0.94;
2952    let color = theme.palette[item_index % theme.palette.len()];
2953    *index += 1;
2954    add_path(
2955        cx,
2956        root,
2957        &pie_slice(center.0, center.1, inner, outer, start, end),
2958        Some(Fill::Solid(fade_color(
2959            color.with_alpha(215),
2960            item_progress,
2961        ))),
2962        Some(fade_stroke(stroke(Color::WHITE, 1.0), item_progress)),
2963    );
2964    if item_progress > 0.82 && end - start > 0.22 && outer > 28.0 {
2965        let mid = (start + end) / 2.0;
2966        let label_r = inner + (outer - inner) * 0.52;
2967        add_text(
2968            cx,
2969            root,
2970            &node.name,
2971            10.0,
2972            Color::WHITE,
2973            center.0 + label_r * mid.cos() - 30.0,
2974            center.1 + label_r * mid.sin() - 7.0,
2975            60.0,
2976            14.0,
2977        );
2978    }
2979    let child_total: f32 = node.children.iter().map(treemap_weight).sum();
2980    if child_total <= 0.0 {
2981        return;
2982    }
2983    let mut child_start = start;
2984    for child in &node.children {
2985        let child_sweep = treemap_weight(child) / child_total * (end - start);
2986        render_sunburst_node(
2987            cx,
2988            root,
2989            child,
2990            center,
2991            ring,
2992            depth + 1,
2993            child_start,
2994            child_start + child_sweep,
2995            theme,
2996            index,
2997            animation,
2998            series_progress,
2999        );
3000        child_start += child_sweep;
3001    }
3002}
3003
3004fn render_parallel(
3005    cx: &mut fission_core::internal::InternalLoweringCx,
3006    root: &mut fission_core::internal::InternalIrBuilder,
3007    parallel: &crate::series::parallel::ParallelSeries,
3008    area: &ChartArea,
3009    theme: &ChartTheme,
3010    animation: ChartAnimationFrame,
3011    series_index: usize,
3012) {
3013    let axes = parallel.data.first().map(|row| row.len()).unwrap_or(0);
3014    if axes < 2 {
3015        return;
3016    }
3017    let series_progress = animation.series_progress(series_index);
3018    if series_progress <= f32::EPSILON {
3019        return;
3020    }
3021    let step = area.plot.width() / (axes - 1) as f32;
3022    for axis in 0..axes {
3023        let x = area.plot.x() + axis as f32 * step;
3024        add_path(
3025            cx,
3026            root,
3027            &format!("M {} {} L {} {}", x, area.plot.y(), x, area.plot.bottom()),
3028            None,
3029            Some(stroke(theme.axis_line, 1.0)),
3030        );
3031    }
3032    for (idx, row) in parallel.data.iter().enumerate() {
3033        let item_progress = animation.item_progress(series_progress, idx);
3034        if item_progress <= f32::EPSILON {
3035            continue;
3036        }
3037        let points: Vec<(f32, f32)> = row
3038            .iter()
3039            .enumerate()
3040            .map(|(axis, value)| {
3041                let x = area.plot.x() + axis as f32 * step;
3042                let y = area.plot.bottom() - (*value / 100.0).clamp(0.0, 1.0) * area.plot.height();
3043                (x, y)
3044            })
3045            .collect();
3046        let path = path_for_points(&reveal_points(&points, item_progress));
3047        add_path(
3048            cx,
3049            root,
3050            &path,
3051            None,
3052            Some(fade_stroke(
3053                stroke(
3054                    theme.palette[idx % theme.palette.len()].with_alpha(170),
3055                    2.0,
3056                ),
3057                item_progress,
3058            )),
3059        );
3060    }
3061}
3062
3063fn render_theme_river(
3064    cx: &mut fission_core::internal::InternalLoweringCx,
3065    root: &mut fission_core::internal::InternalIrBuilder,
3066    river: &crate::series::theme_river::ThemeRiverSeries,
3067    area: &ChartArea,
3068    theme: &ChartTheme,
3069    animation: ChartAnimationFrame,
3070    series_index: usize,
3071) {
3072    if river.data.is_empty() {
3073        return;
3074    }
3075    let series_progress = animation.series_progress(series_index);
3076    if series_progress <= f32::EPSILON {
3077        return;
3078    }
3079    let mut by_time: BTreeMap<String, HashMap<String, f32>> = BTreeMap::new();
3080    let mut categories = Vec::<String>::new();
3081    for (time, value, category) in &river.data {
3082        by_time
3083            .entry(time.clone())
3084            .or_default()
3085            .insert(category.clone(), *value);
3086        if !categories.iter().any(|existing| existing == category) {
3087            categories.push(category.clone());
3088        }
3089    }
3090    let times: Vec<String> = by_time.keys().cloned().collect();
3091    if times.len() < 2 || categories.is_empty() {
3092        return;
3093    }
3094
3095    let totals: Vec<f32> = times
3096        .iter()
3097        .map(|time| by_time[time].values().sum::<f32>())
3098        .collect();
3099    let max_total = totals.iter().copied().fold(1.0_f32, f32::max);
3100    let scale = area.plot.height() * 0.72 / max_total.max(f32::EPSILON);
3101    let step = area.plot.width() / (times.len() - 1) as f32;
3102    let mut bases = vec![0.0_f32; times.len()];
3103
3104    add_path(
3105        cx,
3106        root,
3107        &format!(
3108            "M {} {} L {} {}",
3109            area.plot.x(),
3110            area.plot.y() + area.plot.height() / 2.0,
3111            area.plot.right(),
3112            area.plot.y() + area.plot.height() / 2.0
3113        ),
3114        None,
3115        Some(stroke(theme.grid_line, 1.0)),
3116    );
3117
3118    for (cat_idx, category) in categories.iter().enumerate() {
3119        let item_progress = animation.item_progress(series_progress, cat_idx);
3120        if item_progress <= f32::EPSILON {
3121            continue;
3122        }
3123        let mut top = Vec::new();
3124        let mut bottom = Vec::new();
3125        for (idx, time) in times.iter().enumerate() {
3126            let value = by_time[time].get(category).copied().unwrap_or(0.0).max(0.0);
3127            let total = totals[idx];
3128            let baseline = area.plot.y() + area.plot.height() / 2.0 + total * scale / 2.0;
3129            let x = area.plot.x() + idx as f32 * step;
3130            let y_top = baseline - (bases[idx] + value) * scale;
3131            let y_bottom = baseline - bases[idx] * scale;
3132            top.push((x, y_top));
3133            bottom.push((x, y_bottom));
3134            bases[idx] += value;
3135        }
3136        let top = reveal_points(&top, item_progress);
3137        let bottom = reveal_points(&bottom, item_progress);
3138        if top.len() < 2 || bottom.len() < 2 {
3139            continue;
3140        }
3141        let mut path = path_for_points(&top);
3142        for (x, y) in bottom.iter().rev() {
3143            path.push_str(&format!(" L {} {}", x, y));
3144        }
3145        path.push_str(" Z");
3146        let color = theme.palette[cat_idx % theme.palette.len()];
3147        add_path(
3148            cx,
3149            root,
3150            &path,
3151            Some(Fill::Solid(fade_color(
3152                color.with_alpha(150),
3153                item_progress,
3154            ))),
3155            Some(fade_stroke(stroke(color, 1.0), item_progress)),
3156        );
3157    }
3158
3159    for (idx, time) in times.iter().enumerate() {
3160        if idx % ((times.len() / 4).max(1)) == 0 {
3161            add_text(
3162                cx,
3163                root,
3164                time,
3165                10.0,
3166                theme.label,
3167                area.plot.x() + idx as f32 * step - 30.0,
3168                area.plot.bottom() + 8.0,
3169                60.0,
3170                14.0,
3171            );
3172        }
3173    }
3174}
3175
3176fn render_pictorial_bar(
3177    cx: &mut fission_core::internal::InternalLoweringCx,
3178    root: &mut fission_core::internal::InternalIrBuilder,
3179    pic: &crate::series::pictorial_bar::PictorialBarSeries,
3180    model: &ChartModel,
3181    area: &ChartArea,
3182    y_scale: &LinearScale,
3183    _theme: &ChartTheme,
3184    animation: ChartAnimationFrame,
3185    series_index: usize,
3186) {
3187    let series_progress = animation.series_progress(series_index);
3188    if series_progress <= f32::EPSILON {
3189        return;
3190    }
3191    for (idx, value) in pic.data.iter().enumerate() {
3192        let item_progress = animation.item_progress(series_progress, idx);
3193        if item_progress <= f32::EPSILON {
3194            continue;
3195        }
3196        let x = map_category_x(idx, model, area);
3197        let y0 = map_y(0.0, area, y_scale);
3198        let y1 = map_y(*value, area, y_scale);
3199        let count = ((*value).abs() / 20.0).ceil().max(1.0) as usize;
3200        let visible_units = (count as f32 * item_progress).ceil() as usize;
3201        let step = (y0 - y1) / count as f32;
3202        for unit in 0..visible_units.min(count) {
3203            let unit_progress = ((item_progress * count as f32) - unit as f32).clamp(0.0, 1.0);
3204            if unit_progress <= f32::EPSILON {
3205                continue;
3206            }
3207            let y = y0 - (unit as f32 + 0.5) * step;
3208            let half = 7.0 * unit_progress.sqrt();
3209            let top = 9.0 * unit_progress.sqrt();
3210            let bottom = 8.0 * unit_progress.sqrt();
3211            let path = if pic.symbol == "rect" {
3212                format!(
3213                    "M {} {} L {} {} L {} {} L {} {} Z",
3214                    x - half,
3215                    y - half,
3216                    x + half,
3217                    y - half,
3218                    x + half,
3219                    y + half,
3220                    x - half,
3221                    y + half
3222                )
3223            } else {
3224                format!(
3225                    "M {} {} L {} {} L {} {} Z",
3226                    x,
3227                    y - top,
3228                    x + bottom,
3229                    y + bottom,
3230                    x - bottom,
3231                    y + bottom
3232                )
3233            };
3234            add_path(
3235                cx,
3236                root,
3237                &path,
3238                Some(Fill::Solid(fade_color(pic.color, unit_progress))),
3239                None,
3240            );
3241        }
3242    }
3243}
3244
3245fn render_liquidfill(
3246    cx: &mut fission_core::internal::InternalLoweringCx,
3247    root: &mut fission_core::internal::InternalIrBuilder,
3248    liquid: &crate::series::liquidfill::LiquidfillSeries,
3249    area: &ChartArea,
3250    theme: &ChartTheme,
3251    animation: ChartAnimationFrame,
3252    series_index: usize,
3253) {
3254    let series_progress = animation.series_progress(series_index);
3255    let value = liquid.data.first().copied().unwrap_or(0.0).clamp(0.0, 1.0) * series_progress;
3256    let center = (
3257        area.plot.x() + area.plot.width() / 2.0,
3258        area.plot.y() + area.plot.height() / 2.0,
3259    );
3260    let r = area.plot.width().min(area.plot.height()) * 0.34;
3261    add_rect(
3262        cx,
3263        root,
3264        LayoutRect::new(center.0 - r, center.1 - r, r * 2.0, r * 2.0),
3265        color(232, 244, 255, 255),
3266        Some(stroke(liquid.color, 2.0)),
3267        r,
3268    );
3269    let water_y = center.1 + r - value * r * 2.0;
3270    let path = format!(
3271        "M {} {} C {} {} {} {} {} {} L {} {} L {} {} Z",
3272        center.0 - r,
3273        water_y,
3274        center.0 - r * 0.45,
3275        water_y - 16.0,
3276        center.0 + r * 0.45,
3277        water_y + 16.0,
3278        center.0 + r,
3279        water_y,
3280        center.0 + r,
3281        center.1 + r,
3282        center.0 - r,
3283        center.1 + r
3284    );
3285    add_path(
3286        cx,
3287        root,
3288        &path,
3289        Some(Fill::Solid(fade_color(
3290            liquid.color.with_alpha(190),
3291            series_progress,
3292        ))),
3293        None,
3294    );
3295    add_text(
3296        cx,
3297        root,
3298        &format!("{:.0}%", value * 100.0),
3299        24.0,
3300        theme.title,
3301        center.0 - 40.0,
3302        center.1 - 14.0,
3303        80.0,
3304        28.0,
3305    );
3306}
3307
3308fn render_wordcloud(
3309    cx: &mut fission_core::internal::InternalLoweringCx,
3310    root: &mut fission_core::internal::InternalIrBuilder,
3311    wordcloud: &crate::series::wordcloud::WordcloudSeries,
3312    area: &ChartArea,
3313    theme: &ChartTheme,
3314    animation: ChartAnimationFrame,
3315    series_index: usize,
3316) {
3317    let series_progress = animation.series_progress(series_index);
3318    if series_progress <= f32::EPSILON {
3319        return;
3320    }
3321    let layout = crate::layout::wordcloud::WordcloudLayout::compute(
3322        &wordcloud.data,
3323        area.plot.width(),
3324        area.plot.height(),
3325    );
3326    for (idx, (word, size, x, y)) in layout.iter().enumerate() {
3327        let item_progress = animation.item_progress(series_progress, idx);
3328        if item_progress <= f32::EPSILON {
3329            continue;
3330        }
3331        add_text(
3332            cx,
3333            root,
3334            word,
3335            (*size * (0.78 + item_progress * 0.22)).max(1.0),
3336            fade_color(theme.palette[idx % theme.palette.len()], item_progress),
3337            area.plot.x() + x + (*size * (1.0 - item_progress) * 0.08),
3338            area.plot.y() + y,
3339            180.0,
3340            size + 8.0,
3341        );
3342    }
3343}
3344
3345fn draw_legend(
3346    cx: &mut fission_core::internal::InternalLoweringCx,
3347    root: &mut fission_core::internal::InternalIrBuilder,
3348    model: &ChartModel,
3349    chart: &Chart,
3350    area: &ChartArea,
3351    theme: &ChartTheme,
3352) {
3353    if chart.legend.is_none() {
3354        return;
3355    }
3356    let mut y = area.plot.y();
3357    let x = area.plot.right() + 18.0;
3358    for (idx, name) in series_names(model).iter().enumerate() {
3359        add_rect(
3360            cx,
3361            root,
3362            LayoutRect::new(x, y + 3.0, 10.0, 10.0),
3363            theme.palette[idx % theme.palette.len()],
3364            None,
3365            2.0,
3366        );
3367        add_text(cx, root, name, 11.0, theme.label, x + 16.0, y, 110.0, 16.0);
3368        y += 20.0;
3369    }
3370}
3371
3372fn draw_mark_areas(
3373    cx: &mut fission_core::internal::InternalLoweringCx,
3374    root: &mut fission_core::internal::InternalIrBuilder,
3375    model: &ChartModel,
3376    chart: &Chart,
3377    area: &ChartArea,
3378) {
3379    if chart.mark_areas.is_empty() || !model.has_cartesian_series() {
3380        return;
3381    }
3382    let y_scale = LinearScale::nice(model.y_domain.0, model.y_domain.1, 6);
3383    for mark in &chart.mark_areas {
3384        let y0 = map_y(mark.y_min, area, &y_scale);
3385        let y1 = map_y(mark.y_max, area, &y_scale);
3386        add_rect(
3387            cx,
3388            root,
3389            LayoutRect::new(
3390                area.plot.x(),
3391                y0.min(y1),
3392                area.plot.width(),
3393                (y0 - y1).abs().max(1.0),
3394            ),
3395            mark.color,
3396            None,
3397            0.0,
3398        );
3399    }
3400}
3401
3402fn draw_mark_lines(
3403    cx: &mut fission_core::internal::InternalLoweringCx,
3404    root: &mut fission_core::internal::InternalIrBuilder,
3405    model: &ChartModel,
3406    chart: &Chart,
3407    area: &ChartArea,
3408    theme: &ChartTheme,
3409) {
3410    if chart.mark_lines.is_empty() || !model.has_cartesian_series() {
3411        return;
3412    }
3413    let y_scale = LinearScale::nice(model.y_domain.0, model.y_domain.1, 6);
3414    for mark in &chart.mark_lines {
3415        let y = map_y(mark.y, area, &y_scale);
3416        add_path(
3417            cx,
3418            root,
3419            &format!("M {} {} L {} {}", area.plot.x(), y, area.plot.right(), y),
3420            None,
3421            Some(stroke(mark.color, mark.width)),
3422        );
3423        add_text(
3424            cx,
3425            root,
3426            &mark.name,
3427            10.0,
3428            theme.label,
3429            area.plot.right() - 90.0,
3430            y - 16.0,
3431            86.0,
3432            14.0,
3433        );
3434    }
3435}
3436
3437fn draw_mark_points(
3438    cx: &mut fission_core::internal::InternalLoweringCx,
3439    root: &mut fission_core::internal::InternalIrBuilder,
3440    model: &ChartModel,
3441    chart: &Chart,
3442    area: &ChartArea,
3443    theme: &ChartTheme,
3444) {
3445    if chart.mark_points.is_empty() || !model.has_cartesian_series() {
3446        return;
3447    }
3448    let x_scale = LinearScale::nice(model.x_domain.0, model.x_domain.1, 6);
3449    let y_scale = LinearScale::nice(model.y_domain.0, model.y_domain.1, 6);
3450    for mark in &chart.mark_points {
3451        let x = if model.x_axis.axis_type == AxisType::Category {
3452            mark.x
3453                .map(|x| map_category_x(x.round().max(0.0) as usize, model, area))
3454                .unwrap_or(area.plot.x() + area.plot.width() / 2.0)
3455        } else {
3456            map_x(mark.x.unwrap_or(model.x_domain.0), area, &x_scale)
3457        };
3458        let y = map_y(mark.y, area, &y_scale);
3459        add_rect(
3460            cx,
3461            root,
3462            LayoutRect::new(x - 5.0, y - 5.0, 10.0, 10.0),
3463            mark.color,
3464            Some(stroke(Color::WHITE, 1.0)),
3465            5.0,
3466        );
3467        add_text(
3468            cx,
3469            root,
3470            &mark.name,
3471            10.0,
3472            theme.label,
3473            x + 8.0,
3474            y - 8.0,
3475            90.0,
3476            14.0,
3477        );
3478    }
3479}
3480
3481fn draw_visual_map(
3482    cx: &mut fission_core::internal::InternalLoweringCx,
3483    root: &mut fission_core::internal::InternalIrBuilder,
3484    chart: &Chart,
3485    area: &ChartArea,
3486    theme: &ChartTheme,
3487) {
3488    let Some(map) = chart.visual_map.as_ref() else {
3489        return;
3490    };
3491    let x = area.plot.right() + 24.0;
3492    let y = area.plot.bottom() - 110.0;
3493    let h = 90.0;
3494    add_rect(
3495        cx,
3496        root,
3497        LayoutRect::new(x, y, 12.0, h),
3498        color(255, 255, 255, 255),
3499        Some(stroke(theme.grid_line, 1.0)),
3500        2.0,
3501    );
3502    for i in 0..18 {
3503        let t = i as f32 / 17.0;
3504        add_rect(
3505            cx,
3506            root,
3507            LayoutRect::new(
3508                x + 1.0,
3509                y + h - (i as f32 + 1.0) * h / 18.0,
3510                10.0,
3511                h / 18.0 + 0.5,
3512            ),
3513            visual_color_at(map, t),
3514            None,
3515            0.0,
3516        );
3517    }
3518    add_text(
3519        cx,
3520        root,
3521        &format_tick(map.max),
3522        10.0,
3523        theme.label,
3524        x + 18.0,
3525        y - 2.0,
3526        70.0,
3527        14.0,
3528    );
3529    add_text(
3530        cx,
3531        root,
3532        &format_tick(map.min),
3533        10.0,
3534        theme.label,
3535        x + 18.0,
3536        y + h - 12.0,
3537        70.0,
3538        14.0,
3539    );
3540}
3541
3542fn draw_data_zoom(
3543    cx: &mut fission_core::internal::InternalLoweringCx,
3544    root: &mut fission_core::internal::InternalIrBuilder,
3545    chart: &Chart,
3546    area: &ChartArea,
3547    theme: &ChartTheme,
3548) {
3549    let Some(zoom) = chart.data_zoom.as_ref() else {
3550        return;
3551    };
3552    let x = area.plot.x();
3553    let y = area.plot.bottom() + 36.0;
3554    let w = area.plot.width();
3555    add_rect(
3556        cx,
3557        root,
3558        LayoutRect::new(x, y, w, 8.0),
3559        theme.grid_line,
3560        None,
3561        4.0,
3562    );
3563    let start = (zoom.start_percent / 100.0).clamp(0.0, 1.0);
3564    let end = (zoom.end_percent / 100.0).clamp(start, 1.0);
3565    add_rect(
3566        cx,
3567        root,
3568        LayoutRect::new(x + w * start, y - 2.0, w * (end - start), 12.0),
3569        theme.palette[0].with_alpha(180),
3570        None,
3571        6.0,
3572    );
3573}
3574
3575fn draw_brush(
3576    cx: &mut fission_core::internal::InternalLoweringCx,
3577    root: &mut fission_core::internal::InternalIrBuilder,
3578    chart: &Chart,
3579    area: &ChartArea,
3580    theme: &ChartTheme,
3581) {
3582    let Some(brush) = chart.interaction.brush.as_ref() else {
3583        return;
3584    };
3585    let Some((x, y, width, height)) = brush.preview_rect else {
3586        return;
3587    };
3588    let rect = LayoutRect::new(
3589        area.plot.x() + x * area.plot.width(),
3590        area.plot.y() + y * area.plot.height(),
3591        width * area.plot.width(),
3592        height * area.plot.height(),
3593    );
3594    add_rect(
3595        cx,
3596        root,
3597        rect,
3598        theme.palette[0].with_alpha(42),
3599        Some(stroke(theme.palette[0].with_alpha(190), 1.4)),
3600        3.0,
3601    );
3602}
3603
3604fn draw_graphics(
3605    cx: &mut fission_core::internal::InternalLoweringCx,
3606    root: &mut fission_core::internal::InternalIrBuilder,
3607    chart: &Chart,
3608    area: &ChartArea,
3609    theme: &ChartTheme,
3610) {
3611    for graphic in &chart.graphics {
3612        let x = area.plot.x() + graphic.x * area.plot.width();
3613        let y = area.plot.y() + graphic.y * area.plot.height();
3614        let width = graphic.width * area.plot.width();
3615        let height = graphic.height * area.plot.height();
3616        match graphic.kind {
3617            ChartGraphicKind::Rect => add_rect(
3618                cx,
3619                root,
3620                LayoutRect::new(x, y, width, height),
3621                graphic.color,
3622                graphic.stroke.map(|color| stroke(color, 1.0)),
3623                4.0,
3624            ),
3625            ChartGraphicKind::Circle => {
3626                let r = width.min(height) / 2.0;
3627                add_rect(
3628                    cx,
3629                    root,
3630                    LayoutRect::new(x - r, y - r, r * 2.0, r * 2.0),
3631                    graphic.color,
3632                    graphic.stroke.map(|color| stroke(color, 1.0)),
3633                    r,
3634                );
3635            }
3636            ChartGraphicKind::Text => {
3637                if let Some(text) = graphic.text.as_ref() {
3638                    add_text(cx, root, text, 12.0, graphic.color, x, y, width, height);
3639                }
3640            }
3641            ChartGraphicKind::Line => add_path(
3642                cx,
3643                root,
3644                &format!("M {} {} L {} {}", x, y, x + width, y + height),
3645                None,
3646                Some(stroke(graphic.color, 1.8)),
3647            ),
3648        }
3649    }
3650    if !chart.graphics.is_empty() {
3651        add_text(
3652            cx,
3653            root,
3654            "graphic layer",
3655            10.0,
3656            theme.label,
3657            area.plot.x() + 8.0,
3658            area.plot.y() + 8.0,
3659            110.0,
3660            14.0,
3661        );
3662    }
3663}
3664
3665fn draw_timeline(
3666    cx: &mut fission_core::internal::InternalLoweringCx,
3667    root: &mut fission_core::internal::InternalIrBuilder,
3668    chart: &Chart,
3669    area: &ChartArea,
3670    theme: &ChartTheme,
3671) {
3672    let Some(timeline) = chart.timeline.as_ref() else {
3673        return;
3674    };
3675    if timeline.labels.is_empty() {
3676        return;
3677    }
3678
3679    let x = area.plot.x();
3680    let y = area.outer_h - 30.0;
3681    let w = area.plot.width();
3682    add_path(
3683        cx,
3684        root,
3685        &format!("M {} {} L {} {}", x, y, x + w, y),
3686        None,
3687        Some(stroke(theme.grid_line, 2.0)),
3688    );
3689    let denom = timeline.labels.len().saturating_sub(1).max(1) as f32;
3690    for (idx, label) in timeline.labels.iter().enumerate() {
3691        let px = x + idx as f32 / denom * w;
3692        let active = idx == timeline.current_index.min(timeline.labels.len() - 1);
3693        let r = if active { 6.0 } else { 4.0 };
3694        add_rect(
3695            cx,
3696            root,
3697            LayoutRect::new(px - r, y - r, r * 2.0, r * 2.0),
3698            if active {
3699                theme.palette[0]
3700            } else {
3701                theme.axis_line
3702            },
3703            Some(stroke(Color::WHITE, 1.0)),
3704            r,
3705        );
3706        add_text(
3707            cx,
3708            root,
3709            label,
3710            10.0,
3711            theme.label,
3712            px - 28.0,
3713            y + 8.0,
3714            56.0,
3715            14.0,
3716        );
3717    }
3718}
3719
3720fn draw_toolbox(
3721    cx: &mut fission_core::internal::InternalLoweringCx,
3722    root: &mut fission_core::internal::InternalIrBuilder,
3723    chart: &Chart,
3724    area: &ChartArea,
3725    theme: &ChartTheme,
3726) {
3727    if chart.interaction.toolbox_actions.is_empty() {
3728        return;
3729    }
3730
3731    let mut x = area.plot.right() - chart.interaction.toolbox_actions.len() as f32 * 54.0;
3732    let y = 18.0;
3733    for action in &chart.interaction.toolbox_actions {
3734        let label = match action {
3735            crate::interaction::ChartToolAction::Restore => "reset",
3736            crate::interaction::ChartToolAction::SaveImage => "save",
3737            crate::interaction::ChartToolAction::DataZoom => "zoom",
3738            crate::interaction::ChartToolAction::Brush => "brush",
3739        };
3740        add_rect(
3741            cx,
3742            root,
3743            LayoutRect::new(x, y, 48.0, 22.0),
3744            theme.plot_background,
3745            Some(stroke(theme.grid_line, 1.0)),
3746            5.0,
3747        );
3748        add_text(
3749            cx,
3750            root,
3751            label,
3752            10.0,
3753            theme.label,
3754            x + 5.0,
3755            y + 4.0,
3756            38.0,
3757            14.0,
3758        );
3759        x += 54.0;
3760    }
3761}
3762
3763fn draw_diagnostics(
3764    cx: &mut fission_core::internal::InternalLoweringCx,
3765    root: &mut fission_core::internal::InternalIrBuilder,
3766    model: &ChartModel,
3767    area: &ChartArea,
3768    theme: &ChartTheme,
3769) {
3770    for (idx, diagnostic) in model.diagnostics.iter().enumerate() {
3771        let text = if let Some(name) = diagnostic.series_name.as_ref() {
3772            format!("{}: {}", name, diagnostic.message)
3773        } else {
3774            diagnostic.message.clone()
3775        };
3776        add_text(
3777            cx,
3778            root,
3779            &text,
3780            12.0,
3781            theme.diagnostic,
3782            area.plot.x() + 12.0,
3783            area.plot.y() + 16.0 + idx as f32 * 18.0,
3784            area.plot.width() - 24.0,
3785            16.0,
3786        );
3787    }
3788}
3789
3790fn count_bar_groups(series: &[ResolvedSeries]) -> usize {
3791    series
3792        .iter()
3793        .filter(|series| matches!(series, ResolvedSeries::Bar(bar) if bar.source.stack.is_none()))
3794        .count()
3795        .max(1)
3796}
3797
3798fn stack_base(stacks: &HashMap<(String, usize), f32>, stack: Option<&String>, idx: usize) -> f32 {
3799    stack
3800        .and_then(|name| stacks.get(&(name.clone(), idx)).copied())
3801        .unwrap_or(0.0)
3802}
3803
3804fn path_for_line(points: &[(f32, f32)], smooth: bool, step: Option<&str>) -> String {
3805    if points.is_empty() {
3806        return String::new();
3807    }
3808    if smooth {
3809        return catmull_rom_to_bezier(points);
3810    }
3811    let mut path = format!("M {} {}", points[0].0, points[0].1);
3812    for pair in points.windows(2) {
3813        let (px, py) = pair[0];
3814        let (x, y) = pair[1];
3815        match step {
3816            Some("start") => path.push_str(&format!(" L {} {} L {} {}", px, y, x, y)),
3817            Some("end") => path.push_str(&format!(" L {} {} L {} {}", x, py, x, y)),
3818            Some("middle") => {
3819                let mx = px + (x - px) / 2.0;
3820                path.push_str(&format!(" L {} {} L {} {} L {} {}", mx, py, mx, y, x, y));
3821            }
3822            _ => path.push_str(&format!(" L {} {}", x, y)),
3823        }
3824    }
3825    path
3826}
3827
3828fn reveal_points(points: &[(f32, f32)], progress: f32) -> Vec<(f32, f32)> {
3829    if points.is_empty() || progress <= f32::EPSILON {
3830        return Vec::new();
3831    }
3832    if progress >= 1.0 || points.len() == 1 {
3833        return points.to_vec();
3834    }
3835
3836    let span = progress.clamp(0.0, 1.0) * (points.len() - 1) as f32;
3837    let last_full = span.floor() as usize;
3838    let mut out = points[..=last_full].to_vec();
3839    if last_full + 1 < points.len() {
3840        let t = span - last_full as f32;
3841        let (ax, ay) = points[last_full];
3842        let (bx, by) = points[last_full + 1];
3843        out.push((ax + (bx - ax) * t, ay + (by - ay) * t));
3844    }
3845    out
3846}
3847
3848fn path_for_points(points: &[(f32, f32)]) -> String {
3849    if points.is_empty() {
3850        return String::new();
3851    }
3852    let mut path = format!("M {} {}", points[0].0, points[0].1);
3853    for (x, y) in points.iter().skip(1) {
3854        path.push_str(&format!(" L {} {}", x, y));
3855    }
3856    path
3857}
3858
3859fn circle_path(cx: f32, cy: f32, r: f32) -> String {
3860    format!(
3861        "M {} {} A {} {} 0 1 0 {} {} A {} {} 0 1 0 {} {}",
3862        cx + r,
3863        cy,
3864        r,
3865        r,
3866        cx - r,
3867        cy,
3868        r,
3869        r,
3870        cx + r,
3871        cy
3872    )
3873}
3874
3875fn treemap_weight(node: &crate::series::treemap::TreemapNode) -> f32 {
3876    let child_total: f32 = node.children.iter().map(treemap_weight).sum();
3877    if child_total > 0.0 {
3878        child_total
3879    } else {
3880        node.value.max(0.0)
3881    }
3882}
3883
3884fn treemap_depth(node: &crate::series::treemap::TreemapNode) -> usize {
3885    1 + node.children.iter().map(treemap_depth).max().unwrap_or(0)
3886}
3887
3888fn path_bounds(path: &str) -> Option<(f32, f32, f32, f32)> {
3889    let tokens: Vec<&str> = path.split_whitespace().collect();
3890    let mut min_x = f32::MAX;
3891    let mut max_x = f32::MIN;
3892    let mut min_y = f32::MAX;
3893    let mut max_y = f32::MIN;
3894    let mut idx = 0usize;
3895    while idx < tokens.len() {
3896        let token = tokens[idx];
3897        idx += 1;
3898        let coord_count = match token {
3899            "M" | "L" => 2,
3900            "C" => 6,
3901            "Z" => 0,
3902            _ => continue,
3903        };
3904        let mut coords = Vec::with_capacity(coord_count);
3905        for _ in 0..coord_count {
3906            if let Some(raw) = tokens.get(idx) {
3907                coords.push(raw.parse::<f32>().ok()?);
3908                idx += 1;
3909            }
3910        }
3911        for pair in coords.chunks(2) {
3912            if let [x, y] = pair {
3913                min_x = min_x.min(*x);
3914                max_x = max_x.max(*x);
3915                min_y = min_y.min(*y);
3916                max_y = max_y.max(*y);
3917            }
3918        }
3919    }
3920    if min_x == f32::MAX {
3921        None
3922    } else {
3923        Some((min_x, min_y, max_x - min_x, max_y - min_y))
3924    }
3925}
3926
3927fn hit_test_points(
3928    series_index: usize,
3929    series_name: &str,
3930    data: &[(f32, f32)],
3931    area: &ChartArea,
3932    x_scale: &LinearScale,
3933    y_scale: &LinearScale,
3934    point: LayoutPoint,
3935    threshold: f32,
3936) -> Option<ChartHit> {
3937    for (idx, (xv, yv)) in data.iter().enumerate() {
3938        let x = map_x(*xv, area, x_scale);
3939        let y = map_y(*yv, area, y_scale);
3940        if distance(point, (x, y)) <= threshold {
3941            return Some(ChartHit::series_item(
3942                series_index,
3943                series_name.to_string(),
3944                idx,
3945                Some(*xv),
3946                Some(*yv),
3947            ));
3948        }
3949    }
3950    None
3951}
3952
3953fn nearest_cartesian_hit(
3954    model: &ChartModel,
3955    area: &ChartArea,
3956    point: LayoutPoint,
3957) -> Option<ChartHit> {
3958    let y_scale = LinearScale::nice(model.y_domain.0, model.y_domain.1, 6);
3959    let mut best: Option<(f32, ChartHit)> = None;
3960
3961    for (series_index, series) in model.series.iter().enumerate() {
3962        match series {
3963            ResolvedSeries::Line(line) => {
3964                for (idx, value) in line.values.iter().enumerate() {
3965                    let x = map_category_x(idx, model, area);
3966                    let y = map_y(*value, area, &y_scale);
3967                    let dx = (point.x - x).abs();
3968                    let dy = (point.y - y).abs() * 0.25;
3969                    let score = dx + dy;
3970                    let hit = ChartHit::series_item(
3971                        series_index,
3972                        line.source.name.clone(),
3973                        idx,
3974                        Some(idx as f32),
3975                        Some(*value),
3976                    );
3977                    if best
3978                        .as_ref()
3979                        .map_or(true, |(best_score, _)| score < *best_score)
3980                    {
3981                        best = Some((score, hit));
3982                    }
3983                }
3984            }
3985            ResolvedSeries::Bar(bar) => {
3986                for (idx, value) in bar.values.iter().enumerate() {
3987                    let x = map_category_x(idx, model, area);
3988                    let score = (point.x - x).abs();
3989                    let hit = ChartHit::series_item(
3990                        series_index,
3991                        bar.source.name.clone(),
3992                        idx,
3993                        Some(idx as f32),
3994                        Some(*value),
3995                    );
3996                    if best
3997                        .as_ref()
3998                        .map_or(true, |(best_score, _)| score < *best_score)
3999                    {
4000                        best = Some((score, hit));
4001                    }
4002                }
4003            }
4004            _ => {}
4005        }
4006    }
4007
4008    best.and_then(|(score, hit)| {
4009        let max_distance = (band_width(model, area) * 0.55).max(16.0);
4010        if score <= max_distance {
4011            Some(hit)
4012        } else {
4013            None
4014        }
4015    })
4016}
4017
4018fn hit_test_pie(
4019    series_index: usize,
4020    pie: &crate::series::pie::PieSeries,
4021    area: &ChartArea,
4022    point: LayoutPoint,
4023) -> Option<ChartHit> {
4024    let total: f32 = pie.data.iter().map(|(_, value)| *value).sum();
4025    if total <= 0.0 {
4026        return None;
4027    }
4028
4029    let center = (
4030        area.plot.x() + area.plot.width() * 0.45,
4031        area.plot.y() + area.plot.height() * 0.52,
4032    );
4033    let max_r = area.plot.width().min(area.plot.height()) * 0.38;
4034    let dx = point.x - center.0;
4035    let dy = point.y - center.1;
4036    let radius = (dx * dx + dy * dy).sqrt();
4037    if radius > max_r {
4038        return None;
4039    }
4040    let inner = pie.inner_radius.max(0.0).min(max_r * 0.85);
4041    if radius < inner {
4042        return None;
4043    }
4044
4045    let mut angle = dy.atan2(dx);
4046    if angle < -std::f32::consts::PI / 2.0 {
4047        angle += std::f32::consts::TAU;
4048    }
4049    let mut start = -std::f32::consts::PI / 2.0;
4050    for (idx, (label, value)) in pie.data.iter().enumerate() {
4051        let sweep = (*value / total) * std::f32::consts::TAU;
4052        let end = start + sweep;
4053        if angle >= start && angle <= end {
4054            let _ = label;
4055            return Some(ChartHit::series_item(
4056                series_index,
4057                pie.name.clone(),
4058                idx,
4059                None,
4060                Some(*value),
4061            ));
4062        }
4063        start = end;
4064    }
4065    None
4066}
4067
4068fn distance(point: LayoutPoint, other: (f32, f32)) -> f32 {
4069    let dx = point.x - other.0;
4070    let dy = point.y - other.1;
4071    (dx * dx + dy * dy).sqrt()
4072}
4073
4074fn band_width(model: &ChartModel, area: &ChartArea) -> f32 {
4075    let count = model.x_categories.len().max(1) as f32;
4076    area.plot.width() / count
4077}
4078
4079fn category_band_width(count: usize, extent: f32) -> f32 {
4080    extent / count.max(1) as f32
4081}
4082
4083fn map_category_x(idx: usize, model: &ChartModel, area: &ChartArea) -> f32 {
4084    area.plot.x() + band_width(model, area) * (idx as f32 + 0.5)
4085}
4086
4087fn map_category_y(idx: usize, model: &ChartModel, area: &ChartArea) -> f32 {
4088    let count = model.y_categories.len().max(1);
4089    area.plot.y() + category_band_width(count, area.plot.height()) * (idx as f32 + 0.5)
4090}
4091
4092fn map_x(value: f32, area: &ChartArea, scale: &LinearScale) -> f32 {
4093    scale.map(value, area.plot.x(), area.plot.right())
4094}
4095
4096fn map_y(value: f32, area: &ChartArea, scale: &LinearScale) -> f32 {
4097    scale.map(value, area.plot.bottom(), area.plot.y())
4098}
4099
4100fn series_names(model: &ChartModel) -> Vec<String> {
4101    model
4102        .series
4103        .iter()
4104        .map(|series| match series {
4105            ResolvedSeries::Line(s) => s.source.name.clone(),
4106            ResolvedSeries::Bar(s) => s.source.name.clone(),
4107            ResolvedSeries::Scatter(s) => s.name.clone(),
4108            ResolvedSeries::Pie(s) => s.name.clone(),
4109            ResolvedSeries::Bubble(s) => s.name.clone(),
4110            ResolvedSeries::Boxplot(s) => s.name.clone(),
4111            ResolvedSeries::Candlestick(s) => s.name.clone(),
4112            ResolvedSeries::Heatmap(s) => s.name.clone(),
4113            ResolvedSeries::CalendarHeatmap(s) => s.name.clone(),
4114            ResolvedSeries::Lines(s) => s.name.clone(),
4115            ResolvedSeries::Graph(s) => s.name.clone(),
4116            ResolvedSeries::Tree(s) => s.name.clone(),
4117            ResolvedSeries::Treemap(s) => s.name.clone(),
4118            ResolvedSeries::Radar(s) => s.name.clone(),
4119            ResolvedSeries::Funnel(s) => s.name.clone(),
4120            ResolvedSeries::Gauge(s) => s.name.clone(),
4121            ResolvedSeries::Map(s) => s.name.clone(),
4122            ResolvedSeries::Sankey(s) => s.name.clone(),
4123            ResolvedSeries::Parallel(s) => s.name.clone(),
4124            ResolvedSeries::Sunburst(s) => s.name.clone(),
4125            ResolvedSeries::ThemeRiver(s) => s.name.clone(),
4126            ResolvedSeries::PictorialBar(s) => s.name.clone(),
4127            ResolvedSeries::EffectScatter(s) => s.name.clone(),
4128            ResolvedSeries::Liquidfill(s) => s.name.clone(),
4129            ResolvedSeries::Wordcloud(s) => s.name.clone(),
4130            ResolvedSeries::PolarBar(s) => s.name.clone(),
4131            ResolvedSeries::PolarLine(s) => s.name.clone(),
4132            ResolvedSeries::SingleAxis(s) => s.name.clone(),
4133        })
4134        .collect()
4135}
4136
4137fn render_edges(
4138    cx: &mut fission_core::internal::InternalLoweringCx,
4139    root: &mut fission_core::internal::InternalIrBuilder,
4140    edges: &[GraphEdge],
4141    positions: &HashMap<String, (f32, f32)>,
4142    area: &ChartArea,
4143    theme: &ChartTheme,
4144    animation: ChartAnimationFrame,
4145    series_progress: f32,
4146) {
4147    for (idx, edge) in edges.iter().enumerate() {
4148        let item_progress = animation.item_progress(series_progress, idx);
4149        if item_progress <= f32::EPSILON {
4150            continue;
4151        }
4152        if let (Some(a), Some(b)) = (positions.get(&edge.source), positions.get(&edge.target)) {
4153            let from = (area.plot.x() + a.0, area.plot.y() + a.1);
4154            let to = interpolate_point(
4155                from,
4156                (area.plot.x() + b.0, area.plot.y() + b.1),
4157                item_progress,
4158            );
4159            add_path(
4160                cx,
4161                root,
4162                &format!("M {} {} L {} {}", from.0, from.1, to.0, to.1),
4163                None,
4164                Some(fade_stroke(
4165                    stroke(theme.axis_line.with_alpha(140), 1.2),
4166                    item_progress,
4167                )),
4168            );
4169        }
4170    }
4171}
4172
4173fn radar_angle(axis: usize, axes: usize) -> f32 {
4174    axis as f32 / axes as f32 * std::f32::consts::TAU - std::f32::consts::PI / 2.0
4175}
4176
4177fn visual_color(map: &VisualMap, value: f32) -> Color {
4178    let denom = (map.max - map.min).max(f32::EPSILON);
4179    visual_color_at(map, ((value - map.min) / denom).clamp(0.0, 1.0))
4180}
4181
4182fn visual_color_at(map: &VisualMap, t: f32) -> Color {
4183    let colors = if map.in_range_colors.is_empty() {
4184        vec![
4185            color(49, 130, 206, 255),
4186            color(252, 211, 77, 255),
4187            color(220, 38, 38, 255),
4188        ]
4189    } else {
4190        map.in_range_colors.clone()
4191    };
4192    if colors.len() == 1 {
4193        return colors[0];
4194    }
4195    let scaled = t.clamp(0.0, 1.0) * (colors.len() - 1) as f32;
4196    let idx = scaled.floor() as usize;
4197    let next = (idx + 1).min(colors.len() - 1);
4198    let local = scaled - idx as f32;
4199    mix_color(colors[idx], colors[next], local)
4200}
4201
4202fn heat_color(t: f32) -> Color {
4203    mix_color(
4204        color(59, 130, 246, 255),
4205        color(239, 68, 68, 255),
4206        t.clamp(0.0, 1.0),
4207    )
4208}
4209
4210fn mix_color(a: Color, b: Color, t: f32) -> Color {
4211    let mix = |x: u8, y: u8| x as f32 + (y as f32 - x as f32) * t;
4212    color(
4213        mix(a.r, b.r) as u8,
4214        mix(a.g, b.g) as u8,
4215        mix(a.b, b.b) as u8,
4216        mix(a.a, b.a) as u8,
4217    )
4218}
4219
4220fn fade_color(color: Color, progress: f32) -> Color {
4221    color.with_alpha(((color.a as f32) * progress.clamp(0.0, 1.0)).round() as u8)
4222}
4223
4224fn fade_fill(fill: Fill, progress: f32) -> Fill {
4225    match fill {
4226        Fill::Solid(color) => Fill::Solid(fade_color(color, progress)),
4227        Fill::LinearGradient { start, end, stops } => Fill::LinearGradient {
4228            start,
4229            end,
4230            stops: stops
4231                .into_iter()
4232                .map(|(offset, color)| (offset, fade_color(color, progress)))
4233                .collect(),
4234        },
4235        Fill::RadialGradient {
4236            center,
4237            radius,
4238            stops,
4239        } => Fill::RadialGradient {
4240            center,
4241            radius,
4242            stops: stops
4243                .into_iter()
4244                .map(|(offset, color)| (offset, fade_color(color, progress)))
4245                .collect(),
4246        },
4247    }
4248}
4249
4250fn fade_stroke(mut stroke: Stroke, progress: f32) -> Stroke {
4251    stroke.fill = fade_fill(stroke.fill, progress);
4252    stroke
4253}
4254
4255fn interpolate(a: f32, b: f32, progress: f32) -> f32 {
4256    a + (b - a) * progress.clamp(0.0, 1.0)
4257}
4258
4259fn interpolate_point(from: (f32, f32), to: (f32, f32), progress: f32) -> (f32, f32) {
4260    (
4261        interpolate(from.0, to.0, progress),
4262        interpolate(from.1, to.1, progress),
4263    )
4264}
4265
4266fn scale_rect_from_center(rect: LayoutRect, progress: f32) -> LayoutRect {
4267    let progress = progress.clamp(0.0, 1.0);
4268    let width = (rect.width() * progress).max(1.0);
4269    let height = (rect.height() * progress).max(1.0);
4270    LayoutRect::new(
4271        rect.x() + (rect.width() - width) / 2.0,
4272        rect.y() + (rect.height() - height) / 2.0,
4273        width,
4274        height,
4275    )
4276}
4277
4278fn color_luma(color: Color) -> f32 {
4279    color.r as f32 * 0.2126 + color.g as f32 * 0.7152 + color.b as f32 * 0.0722
4280}
4281
4282fn translate_path(path: &str, dx: f32, dy: f32) -> String {
4283    if dx == 0.0 && dy == 0.0 {
4284        path.to_string()
4285    } else {
4286        // Sankey paths are relative to the plot origin and use M/C/L/Z commands.
4287        // Rebuild the coordinates with a simple command-aware parser.
4288        let tokens: Vec<&str> = path.split_whitespace().collect();
4289        let mut result = String::new();
4290        let mut idx = 0;
4291        while idx < tokens.len() {
4292            let cmd = tokens[idx];
4293            result.push_str(cmd);
4294            idx += 1;
4295            let coord_count = match cmd {
4296                "M" | "L" => 2,
4297                "C" => 6,
4298                "Z" => 0,
4299                _ => 0,
4300            };
4301            for coord_idx in 0..coord_count {
4302                if let Some(raw) = tokens.get(idx) {
4303                    let offset = if coord_idx % 2 == 0 { dx } else { dy };
4304                    let value = raw.parse::<f32>().unwrap_or(0.0) + offset;
4305                    result.push_str(&format!(" {}", value));
4306                    idx += 1;
4307                }
4308            }
4309            result.push(' ');
4310        }
4311        result
4312    }
4313}
4314
4315#[derive(Debug, Clone)]
4316struct TreeRenderNode {
4317    name: String,
4318    x: f32,
4319    y: f32,
4320    depth: usize,
4321}
4322
4323fn tree_leaf_count(node: &crate::series::treemap::TreemapNode) -> usize {
4324    if node.children.is_empty() {
4325        1
4326    } else {
4327        node.children.iter().map(tree_leaf_count).sum()
4328    }
4329}
4330
4331#[allow(clippy::too_many_arguments)]
4332fn layout_tree_node(
4333    node: &crate::series::treemap::TreemapNode,
4334    depth_index: usize,
4335    depth_count: usize,
4336    leaf_count: usize,
4337    next_leaf: &mut usize,
4338    area: &ChartArea,
4339    nodes: &mut Vec<TreeRenderNode>,
4340    edges: &mut Vec<((f32, f32), (f32, f32))>,
4341) -> (f32, f32) {
4342    let x_denom = depth_count.saturating_sub(1).max(1) as f32;
4343    let x = area.plot.x() + depth_index as f32 / x_denom * area.plot.width();
4344    let mut child_points = Vec::new();
4345    let y = if node.children.is_empty() {
4346        let y = area.plot.y() + (*next_leaf as f32 + 0.5) / leaf_count as f32 * area.plot.height();
4347        *next_leaf += 1;
4348        y
4349    } else {
4350        let mut sum = 0.0;
4351        for child in &node.children {
4352            let child_point = layout_tree_node(
4353                child,
4354                depth_index + 1,
4355                depth_count,
4356                leaf_count,
4357                next_leaf,
4358                area,
4359                nodes,
4360                edges,
4361            );
4362            child_points.push(child_point);
4363            let (_, child_y) = child_point;
4364            sum += child_y;
4365        }
4366        sum / node.children.len().max(1) as f32
4367    };
4368
4369    let point = (x, y);
4370    for child_point in child_points {
4371        edges.push((point, child_point));
4372    }
4373    nodes.push(TreeRenderNode {
4374        name: node.name.clone(),
4375        x,
4376        y,
4377        depth: depth_index,
4378    });
4379    point
4380}
4381
4382#[allow(clippy::too_many_arguments)]
4383fn layout_radial_tree_node(
4384    node: &crate::series::treemap::TreemapNode,
4385    depth_index: usize,
4386    depth_count: usize,
4387    leaf_count: usize,
4388    next_leaf: &mut usize,
4389    area: &ChartArea,
4390    nodes: &mut Vec<TreeRenderNode>,
4391    edges: &mut Vec<((f32, f32), (f32, f32))>,
4392) -> (f32, f32) {
4393    let center = (
4394        area.plot.x() + area.plot.width() / 2.0,
4395        area.plot.y() + area.plot.height() / 2.0,
4396    );
4397    let radius = area.plot.width().min(area.plot.height()) * 0.44;
4398    let mut child_points = Vec::new();
4399    let point = if node.children.is_empty() {
4400        let angle = -std::f32::consts::PI / 2.0
4401            + (*next_leaf as f32 + 0.5) / leaf_count as f32 * std::f32::consts::TAU;
4402        *next_leaf += 1;
4403        let r = depth_index as f32 / depth_count.saturating_sub(1).max(1) as f32 * radius;
4404        (center.0 + r * angle.cos(), center.1 + r * angle.sin())
4405    } else {
4406        let mut points = Vec::new();
4407        for child in &node.children {
4408            let child_point = layout_radial_tree_node(
4409                child,
4410                depth_index + 1,
4411                depth_count,
4412                leaf_count,
4413                next_leaf,
4414                area,
4415                nodes,
4416                edges,
4417            );
4418            points.push(child_point);
4419            child_points.push(child_point);
4420        }
4421        if depth_index == 0 {
4422            center
4423        } else {
4424            let avg_x = points.iter().map(|point| point.0).sum::<f32>() / points.len() as f32;
4425            let avg_y = points.iter().map(|point| point.1).sum::<f32>() / points.len() as f32;
4426            let angle = (avg_y - center.1).atan2(avg_x - center.0);
4427            let r = depth_index as f32 / depth_count.saturating_sub(1).max(1) as f32 * radius;
4428            (center.0 + r * angle.cos(), center.1 + r * angle.sin())
4429        }
4430    };
4431
4432    nodes.push(TreeRenderNode {
4433        name: node.name.clone(),
4434        x: point.0,
4435        y: point.1,
4436        depth: depth_index,
4437    });
4438    for child_point in child_points {
4439        edges.push((point, child_point));
4440    }
4441    point
4442}
4443
4444fn map_lines_point(
4445    point: (f32, f32),
4446    min_x: f32,
4447    max_x: f32,
4448    min_y: f32,
4449    max_y: f32,
4450    area: &ChartArea,
4451) -> (f32, f32) {
4452    let x_t = ((point.0 - min_x) / (max_x - min_x).max(f32::EPSILON)).clamp(0.0, 1.0);
4453    let y_t = ((point.1 - min_y) / (max_y - min_y).max(f32::EPSILON)).clamp(0.0, 1.0);
4454    (
4455        area.plot.x() + x_t * area.plot.width(),
4456        area.plot.bottom() - y_t * area.plot.height(),
4457    )
4458}
4459
4460fn quadratic_midpoint(from: (f32, f32), control: (f32, f32), to: (f32, f32)) -> (f32, f32) {
4461    (
4462        0.25 * from.0 + 0.5 * control.0 + 0.25 * to.0,
4463        0.25 * from.1 + 0.5 * control.1 + 0.25 * to.1,
4464    )
4465}
4466
4467fn draw_arrow_head(
4468    cx: &mut fission_core::internal::InternalLoweringCx,
4469    root: &mut fission_core::internal::InternalIrBuilder,
4470    from: (f32, f32),
4471    to: (f32, f32),
4472    fill: Color,
4473) {
4474    let angle = (to.1 - from.1).atan2(to.0 - from.0);
4475    let size = 8.0;
4476    let left = (
4477        to.0 - size * (angle - 0.45).cos(),
4478        to.1 - size * (angle - 0.45).sin(),
4479    );
4480    let right = (
4481        to.0 - size * (angle + 0.45).cos(),
4482        to.1 - size * (angle + 0.45).sin(),
4483    );
4484    let path = format!(
4485        "M {} {} L {} {} L {} {} Z",
4486        to.0, to.1, left.0, left.1, right.0, right.1
4487    );
4488    add_path(cx, root, &path, Some(Fill::Solid(fill)), None);
4489}
4490
4491fn normalize_bounds(min: f32, max: f32) -> (f32, f32) {
4492    if !min.is_finite() || !max.is_finite() {
4493        return (0.0, 1.0);
4494    }
4495    if (max - min).abs() < f32::EPSILON {
4496        (min - 1.0, max + 1.0)
4497    } else {
4498        (min, max)
4499    }
4500}
4501
4502fn add_rect(
4503    cx: &mut fission_core::internal::InternalLoweringCx,
4504    root: &mut fission_core::internal::InternalIrBuilder,
4505    rect: LayoutRect,
4506    fill: Color,
4507    stroke_value: Option<Stroke>,
4508    radius: f32,
4509) {
4510    add_positioned_paint(
4511        cx,
4512        root,
4513        rect,
4514        fission_ir::Op::Paint(PaintOp::DrawRect {
4515            fill: Some(Fill::Solid(fill)),
4516            stroke: stroke_value,
4517            corner_radius: radius,
4518            shadow: None,
4519        }),
4520    );
4521}
4522
4523fn add_text(
4524    cx: &mut fission_core::internal::InternalLoweringCx,
4525    root: &mut fission_core::internal::InternalIrBuilder,
4526    text: &str,
4527    size: f32,
4528    color: Color,
4529    left: f32,
4530    top: f32,
4531    width: f32,
4532    height: f32,
4533) {
4534    add_positioned_paint(
4535        cx,
4536        root,
4537        LayoutRect::new(left, top, width.max(1.0), height.max(1.0)),
4538        fission_ir::Op::Paint(PaintOp::DrawText {
4539            text: text.to_string(),
4540            size,
4541            color,
4542            underline: false,
4543            wrap: false,
4544            caret_index: None,
4545            caret_color: None,
4546            caret_width: None,
4547            caret_height: None,
4548            caret_radius: None,
4549            paragraph_style: None,
4550        }),
4551    );
4552}
4553
4554fn add_positioned_paint(
4555    cx: &mut fission_core::internal::InternalLoweringCx,
4556    root: &mut fission_core::internal::InternalIrBuilder,
4557    rect: LayoutRect,
4558    op: fission_ir::Op,
4559) {
4560    let paint_id = cx.next_node_id();
4561    let mut pos = fission_core::internal::InternalIrBuilder::new(
4562        cx.next_node_id(),
4563        fission_ir::Op::Layout(LayoutOp::Positioned {
4564            left: Some(rect.x()),
4565            top: Some(rect.y()),
4566            right: None,
4567            bottom: None,
4568            width: Some(rect.width()),
4569            height: Some(rect.height()),
4570        }),
4571    );
4572    pos.add_child(cx.insert_node(paint_id, op, vec![]));
4573    root.add_child(pos.build(cx));
4574}
4575
4576fn add_path(
4577    cx: &mut fission_core::internal::InternalLoweringCx,
4578    root: &mut fission_core::internal::InternalIrBuilder,
4579    path: &str,
4580    fill: Option<Fill>,
4581    stroke_value: Option<Stroke>,
4582) {
4583    let id = cx.next_node_id();
4584    root.add_child(cx.insert_node(
4585        id,
4586        fission_ir::Op::Paint(PaintOp::DrawPath {
4587            path: path.to_string(),
4588            fill,
4589            stroke: stroke_value,
4590        }),
4591        vec![],
4592    ));
4593}
4594
4595fn stroke(color: Color, width: f32) -> Stroke {
4596    Stroke {
4597        fill: Fill::Solid(color),
4598        width,
4599        dash_array: None,
4600        line_cap: LineCap::Round,
4601        line_join: LineJoin::Round,
4602    }
4603}
4604
4605fn format_tick(value: f32) -> String {
4606    if value.abs() >= 1000.0 {
4607        format!("{:.1}k", value / 1000.0)
4608    } else if value.fract().abs() < 0.001 {
4609        format!("{:.0}", value)
4610    } else {
4611        format!("{:.1}", value)
4612    }
4613}
4614
4615fn color(r: u8, g: u8, b: u8, a: u8) -> Color {
4616    Color { r, g, b, a }
4617}