Skip to main content

rustmotion_components/
tag_cloud.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use skia_safe::Canvas;
4
5use rustmotion_core::css::CssStyle;
6use rustmotion_core::engine::animator::AnimatedProperties;
7use rustmotion_core::engine::layout_pass::BoxLayout;
8use rustmotion_core::engine::renderer::{
9    draw_text_with_fallback, emoji_typeface, measure_text_with_fallback, paint_from_hex,
10    parse_hex_color, typeface_with_fallback,
11};
12use rustmotion_core::schema::TimelineStep;
13use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};
14
15const DEFAULT_PALETTE: &[&str] = &[
16    "#3B82F6", "#EF4444", "#22C55E", "#F59E0B", "#8B5CF6", "#EC4899", "#06B6D4", "#F97316",
17];
18
19fn default_min_font_size() -> f32 {
20    14.0
21}
22
23fn default_max_font_size() -> f32 {
24    64.0
25}
26
27fn default_animated() -> bool {
28    true
29}
30
31fn default_animation_duration() -> f64 {
32    1.5
33}
34
35#[derive(Debug, Serialize, Deserialize, JsonSchema)]
36pub struct TagItem {
37    pub text: String,
38    #[serde(default)]
39    pub weight: f64,
40    #[serde(default)]
41    pub color: Option<String>,
42}
43
44#[derive(Debug, Serialize, Deserialize, JsonSchema)]
45pub struct TagCloud {
46    pub tags: Vec<TagItem>,
47    #[serde(default = "default_min_font_size")]
48    pub min_font_size: f32,
49    #[serde(default = "default_max_font_size")]
50    pub max_font_size: f32,
51    #[serde(default)]
52    pub colors: Option<Vec<String>>,
53    #[serde(default = "default_animated")]
54    pub animated: bool,
55    #[serde(default = "default_animation_duration")]
56    pub animation_duration: f64,
57    #[serde(flatten)]
58    pub timing: TimingConfig,
59    #[serde(default)]
60    pub style: CssStyle,
61    #[serde(default)]
62    pub timeline: Vec<TimelineStep>,
63    #[serde(default)]
64    pub stagger: Option<f32>,
65}
66
67rustmotion_core::impl_traits!(TagCloud {
68    Animatable => animation,
69    Timed => timing,
70    Styled => style,
71});
72
73impl TagCloud {
74    fn progress_at(&self, time: f64) -> f32 {
75        if !self.animated {
76            return 1.0;
77        }
78        let p = (time / self.animation_duration).clamp(0.0, 1.0) as f32;
79        1.0 - (1.0 - p).powi(3)
80    }
81
82    /// Never empty: callers index into it with `%`, and `"colors": []` — which a
83    /// generator emits to mean "no custom palette" — otherwise divides by zero.
84    fn palette(&self) -> Vec<&str> {
85        match &self.colors {
86            Some(colors) if !colors.is_empty() => colors.iter().map(|s| s.as_str()).collect(),
87            _ => DEFAULT_PALETTE.to_vec(),
88        }
89    }
90
91    fn font_size_for_weight(&self, weight: f64, min_weight: f64, max_weight: f64) -> f32 {
92        let range = (max_weight - min_weight).max(0.001);
93        let normalized = ((weight - min_weight) / range) as f32;
94        self.min_font_size + normalized * (self.max_font_size - self.min_font_size)
95    }
96}
97
98impl TagCloud {
99    fn paint(&self, canvas: &Canvas, layout_w: f32, layout_h: f32, time: f64) {
100        let w = layout_w;
101        let h = layout_h;
102
103        if self.tags.is_empty() {
104            return;
105        }
106
107        let progress = self.progress_at(time);
108        let palette = self.palette();
109
110        // Collect weights
111        let min_weight = self.tags.iter().map(|t| t.weight).fold(f64::MAX, f64::min);
112        let max_weight = self.tags.iter().map(|t| t.weight).fold(f64::MIN, f64::max);
113
114        // Sort tags by weight descending (indices for stagger)
115        let mut sorted_indices: Vec<usize> = (0..self.tags.len()).collect();
116        sorted_indices.sort_by(|&a, &b| {
117            self.tags[b]
118                .weight
119                .partial_cmp(&self.tags[a].weight)
120                .unwrap_or(std::cmp::Ordering::Equal)
121        });
122
123        let tag_count = self.tags.len();
124        let h_gap = 12.0_f32;
125        let v_gap = 8.0_f32;
126
127        // Pre-compute tag metrics for flow layout
128        struct TagMetrics {
129            index: usize,
130            font_size: f32,
131            text_width: f32,
132            ascent: f32,
133            height: f32,
134        }
135
136        let mut metrics_list: Vec<TagMetrics> = Vec::with_capacity(tag_count);
137        for &idx in &sorted_indices {
138            let tag = &self.tags[idx];
139            let font_size = self.font_size_for_weight(tag.weight, min_weight, max_weight);
140            let font_style = skia_safe::FontStyle::bold();
141            let Ok(typeface) = typeface_with_fallback("Inter", font_style) else {
142                continue;
143            };
144            let font = skia_safe::Font::from_typeface(typeface, font_size);
145            let emoji_font =
146                emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, font_size));
147
148            let text_width = measure_text_with_fallback(&tag.text, &font, &emoji_font, 0.0);
149            let (_, fm_metrics) = font.metrics();
150            let ascent = -fm_metrics.ascent;
151            let height = ascent + fm_metrics.descent;
152
153            metrics_list.push(TagMetrics {
154                index: idx,
155                font_size,
156                text_width,
157                ascent,
158                height,
159            });
160        }
161
162        // Flow layout: place tags left-to-right, wrap when exceeding width
163        struct PlacedTag {
164            index: usize,
165            x: f32,
166            y: f32,
167            font_size: f32,
168            ascent: f32,
169            line_height: f32,
170        }
171
172        let mut placed: Vec<PlacedTag> = Vec::with_capacity(tag_count);
173        let mut cursor_x = 0.0_f32;
174        let mut cursor_y = 0.0_f32;
175        let mut line_max_height = 0.0_f32;
176
177        for m in &metrics_list {
178            let item_width = m.text_width + h_gap;
179
180            if cursor_x + m.text_width > w && cursor_x > 0.0 {
181                // Wrap to next line
182                cursor_y += line_max_height + v_gap;
183                cursor_x = 0.0;
184                line_max_height = 0.0;
185            }
186
187            placed.push(PlacedTag {
188                index: m.index,
189                x: cursor_x,
190                y: cursor_y,
191                font_size: m.font_size,
192                ascent: m.ascent,
193                line_height: m.height,
194            });
195
196            if m.height > line_max_height {
197                line_max_height = m.height;
198            }
199
200            cursor_x += item_width;
201        }
202
203        // Compute total content height for vertical centering
204        let total_height = if let Some(last) = placed.last() {
205            last.y
206                + placed.iter().fold(0.0_f32, |max_h, p| {
207                    if (p.y - last.y).abs() < 0.01 {
208                        max_h.max(p.line_height)
209                    } else {
210                        max_h
211                    }
212                })
213        } else {
214            0.0
215        };
216        let y_offset = ((h - total_height) / 2.0).max(0.0);
217
218        // Render each tag
219        for (draw_order, pt) in placed.iter().enumerate() {
220            let tag = &self.tags[pt.index];
221
222            // Staggered opacity animation
223            let tag_alpha = if self.animated {
224                let stagger_delay = (draw_order as f64 / tag_count as f64) * 0.6;
225                let tag_progress = ((time - stagger_delay) / (self.animation_duration * 0.4))
226                    .clamp(0.0, 1.0) as f32;
227                tag_progress * progress
228            } else {
229                1.0
230            };
231
232            if tag_alpha <= 0.0 {
233                continue;
234            }
235
236            // Pick color
237            let color_str = tag
238                .color
239                .as_deref()
240                .unwrap_or(palette[pt.index % palette.len()]);
241
242            let (_r, _g, _b, _) = parse_hex_color(color_str);
243            let mut text_paint = paint_from_hex(color_str);
244            text_paint.set_anti_alias(true);
245            text_paint.set_alpha((tag_alpha * 255.0) as u8);
246
247            let font_style = skia_safe::FontStyle::bold();
248            let Ok(typeface) = typeface_with_fallback("Inter", font_style) else {
249                continue;
250            };
251            let font = skia_safe::Font::from_typeface(typeface, pt.font_size);
252            let emoji_font =
253                emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, pt.font_size));
254
255            let text_x = pt.x;
256            let text_y = y_offset + pt.y + pt.ascent;
257
258            draw_text_with_fallback(
259                canvas,
260                &tag.text,
261                &font,
262                &emoji_font,
263                0.0,
264                text_x,
265                text_y,
266                &text_paint,
267            );
268        }
269    }
270}
271
272impl Painter for TagCloud {
273    fn paint_content(
274        &self,
275        canvas: &Canvas,
276        layout: &BoxLayout,
277        _props: &AnimatedProperties,
278        ctx: &PaintCtx,
279    ) {
280        self.paint(canvas, layout.width, layout.height, ctx.time);
281    }
282}