第二十七章:数据可视化与图表集成

博客v1.0系列教程(Dioxus)博客 v1.0 系列教程 (Dioxus)

第二十七章:数据可视化与图表集成

1. 方案对比

| 方案 | 实现方式 | 优点 | 缺点 | |------|---------|------|------| | 纯 SVG 图表 | Dioxus 直接生成 SVG 元素 | 无依赖,响应式,SSR 友好 | 复杂图表代码量大 | | Chart.js | web-sys + JS 库 | 功能丰富,社区成熟 | 需要 JS 互操作 | | Plotly | WASM 编译 | 交互性强 | 体积大 | | Canvas | <canvas> 元素 | 高性能,大数据量 | 无事件绑定,需手动绘制 |

选择建议

简单统计卡片     → 纯 SVG(Dioxus 原生渲染)
文章阅读量趋势   → Chart.js(折线图)
标签分布         → 纯 SVG 环形图
大数据量         → Canvas

2. 纯 SVG 图表

2.1 条形图

#[component]
fn BarChart(
    data: Vec<(String, f64)>,
    width: u32,
    height: u32,
) -> Element {
    let max_val = data.iter().map(|(_, v)| v).cloned().fold(0.0_f64, f64::max);
    let bar_width = width as f64 / data.len() as f64 * 0.7;
    let gap = width as f64 / data.len() as f64 * 0.3;
    let padding = 40.0;

    let chart_w = width as f64;
    let chart_h = height as f64;
    let draw_w = chart_w - padding * 2.0;
    let draw_h = chart_h - padding * 2.0;

    rsx! {
        div { class: "chart-container",
            svg {
                class: "w-full",
                view_box: "0 0 {width} {height}",
                xmlns: "http://www.w3.org/2000/svg",

                // Y 轴网格线
                for i in 0..=4 {
                    let y = padding + draw_h * (1.0 - i as f64 / 4.0);
                    let val = max_val * i as f64 / 4.0;
                    line {
                        x1: "{padding}", y1: "{y}",
                        x2: "{chart_w - padding}", y2: "{y}",
                        stroke: "var(--border)",
                        stroke_width: "1",
                    }
                    text {
                        x: "{padding - 8}", y: "{y + 4}",
                        text_anchor: "end",
                        class: "text-[10px]",
                        style: "fill: var(--tertiary);",
                        "{val:.0}"
                    }
                }

                // 柱子
                for (i, (label, value)) in data.iter().enumerate() {
                    let x = padding + i as f64 * (bar_width + gap) + gap / 2.0;
                    let bar_h = if max_val > 0.0 {
                        draw_h * value / max_val
                    } else { 0.0 };
                    let y = padding + draw_h - bar_h;

                    rect {
                        x: "{x}", y: "{y}",
                        width: "{bar_width}", height: "{bar_h}",
                        rx: "4",
                        style: "fill: var(--primary); transition: height 0.3s;",
                    }
                    text {
                        x: "{x + bar_width / 2.0}", y: "{chart_h - 8}",
                        text_anchor: "middle",
                        class: "text-[10px]",
                        style: "fill: var(--tertiary);",
                        "{label}"
                    }
                }
            }
        }
    }
}

2.2 折线图

#[component]
fn LineChart(
    data: Vec<(String, f64)>,
    width: u32,
    height: u32,
) -> Element {
    let padding = 40.0;
    let draw_w = width as f64 - padding * 2.0;
    let draw_h = height as f64 - padding * 2.0;
    let max_val = data.iter().map(|(_, v)| v).cloned().fold(0.0_f64, f64::max);

    // 计算折线路径
    let points: Vec<(f64, f64)> = data.iter().enumerate().map(|(i, (_, v))| {
        let x = padding + i as f64 * draw_w / (data.len() - 1).max(1) as f64;
        let y = padding + draw_h * (1.0 - v / max_val);
        (x, y)
    }).collect();

    let line_path = {
        let mut d = String::new();
        for (i, (x, y)) in points.iter().enumerate() {
            if i == 0 {
                d.push_str(&format!("M {x:.1} {y:.1}"));
            } else {
                d.push_str(&format!(" L {x:.1} {y:.1}"));
            }
        }
        d
    };

    // 面积填充
    let area_path = {
        let last_x = points.last().map(|(x, _)| x).unwrap_or(&0.0);
        let first_x = points.first().map(|(x, _)| x).unwrap_or(&0.0);
        let bottom_y = padding + draw_h;
        format!("{line_path} L {last_x} {bottom_y} L {first_x} {bottom_y} Z")
    };

    rsx! {
        svg {
            view_box: "0 0 {width} {height}",
            // 面积填充
            path {
                d: "{area_path}",
                style: "fill: var(--primary); opacity: 0.1;",
            }
            // 折线
            path {
                d: "{line_path}",
                style: "fill: none; stroke: var(--primary); stroke-width: 2;",
            }
            // 数据点
            for (x, y) in &points {
                circle {
                    cx: "{x}", cy: "{y}",
                    r: "4",
                    style: "fill: var(--primary); stroke: white; stroke-width: 2;",
                }
            }
        }
    }
}

2.3 环形进度图

#[component]
fn RingChart(
    value: f64,        // 0.0 ~ 1.0
    size: u32,
    stroke_width: u32,
    color: String,
) -> Element {
    let radius = (size as f64 - stroke_width as f64) / 2.0;
    let circumference = 2.0 * std::f64::consts::PI * radius;
    let offset = circumference * (1.0 - value);
    let center = size as f64 / 2.0;

    rsx! {
        div { class: "relative inline-flex items-center justify-center",
            svg {
                class: "transform -rotate-90",
                width: "{size}", height: "{size}",
                // 背景环
                circle {
                    cx: "{center}", cy: "{center}",
                    r: "{radius}",
                    style: "fill: none; stroke: var(--border); stroke-width: {stroke_width};",
                }
                // 进度环
                circle {
                    cx: "{center}", cy: "{center}",
                    r: "{radius}",
                    style: "
                        fill: none;
                        stroke: {color};
                        stroke-width: {stroke_width};
                        stroke-dasharray: {circumference};
                        stroke-dashoffset: {offset};
                        stroke-linecap: round;
                        transition: stroke-dashoffset 0.5s ease;
                    ",
                }
            }
            // 中间文字
            div { class: "absolute inset-0 flex items-center justify-center",
                span { class: "text-lg font-bold",
                    style: "color: var(--text);",
                    "{value * 100.0:.0}%"
                }
            }
        }
    }
}

// 使用
RingChart {
    value: 0.75,
    size: 120,
    stroke_width: 8,
    color: "var(--primary)",
}

3. Chart.js 集成

3.1 基础绑定

use wasm_bindgen::prelude::*;

#[wasm_bindgen(module = "chart.js")]
extern "C" {
    type Chart;

    #[wasm_bindgen(js_name = "default")]
    fn new_chart(ctx: &JsValue, config: &JsValue) -> Chart;

    #[wasm_bindgen(method)]
    fn update(this: &Chart, mode: &str);

    #[wasm_bindgen(method)]
    fn destroy(this: &Chart);
}

#[component]
fn ChartJsLine(labels: Vec<String>, values: Vec<f64>) -> Element {
    let canvas_ref = use_node_ref();
    let chart_ref = use_signal(|| Option::<Chart>::None);

    use_effect(move || {
        // 销毁旧图表
        if let Some(ref c) = *chart_ref.read() {
            c.destroy();
        }

        if let Some(canvas) = canvas_ref.get() {
            let ctx = canvas.cast::<web_sys::HtmlCanvasElement>()
                .unwrap()
                .get_context("2d")
                .unwrap()
                .unwrap();

            let config = serde_wasm_bindgen::to_value(&serde_json::json!({
                "type": "line",
                "data": {
                    "labels": labels,
                    "datasets": [{
                        "label": "阅读量",
                        "data": values,
                        "borderColor": "#1e66f5",
                        "backgroundColor": "rgba(30, 102, 245, 0.1)",
                        "fill": true,
                        "tension": 0.3,
                    }]
                },
                "options": {
                    "responsive": true,
                    "plugins": {
                        "legend": { "display": false }
                    },
                    "scales": {
                        "y": { "beginAtZero": true }
                    }
                }
            })).unwrap();

            let chart = Chart::new(&ctx, &config);
            chart_ref.set(Some(chart));
        }
    });

    rsx! {
        canvas {
            class: "w-full h-64",
            onmounted: move |_| {},
        }
    }
}

3.2 响应式更新

#[component]
fn LiveChart() -> Element {
    let data_points = use_signal(|| Vec::<(String, f64)>::new());

    // 定时添加新数据
    use_interval(2000, move || {
        let mut d = data_points.write();
        d.push((
            chrono::Local::now().format("%H:%M:%S").to_string(),
            rand::random::<f64>() * 100.0,
        ));
        if d.len() > 20 { d.remove(0); }
    });

    rsx! {
        div { class: "space-y-4",
            h3 { class: "font-semibold", "实时数据" }
            BarChart {
                data: data_points(),
                width: 600,
                height: 250,
            }
        }
    }
}

4. Canvas 绘图

4.1 基础 Canvas

#[component]
fn CanvasDraw() -> Element {
    let canvas_ref = use_node_ref();

    use_effect(move || {
        if let Some(el) = canvas_ref.get() {
            let canvas = el.cast::<web_sys::HtmlCanvasElement>().unwrap();
            let ctx = canvas.get_context("2d").unwrap().unwrap();

            // 绘制
            ctx.set_fill_style(&JsValue::from_str("var(--primary)"));
            ctx.fill_rect(10.0, 10.0, 100.0, 100.0);

            ctx.set_font("16px sans-serif");
            ctx.set_fill_style(&JsValue::from_str("var(--text)"));
            ctx.fill_text("Hello Canvas", 10.0, 150.0).unwrap();
        }
    });

    rsx! {
        canvas {
            class: "border rounded-lg",
            width: "400",
            height: "200",
        }
    }
}

4.2 词云标签

#[component]
fn TagCloud(tags: Vec<(String, usize)>) -> Element {
    let max_count = tags.iter().map(|(_, c)| *c).max().unwrap_or(1);

    rsx! {
        div { class: "flex flex-wrap gap-2 justify-center p-4",
            for (tag, count) in &tags {
                let size = 0.8 + (count as f64 / max_count as f64) * 1.2;
                span {
                    class: "inline-block px-3 py-1 rounded-full transition-all hover:scale-110",
                    style: "
                        font-size: {size}rem;
                        background: var(--hover);
                        color: var(--secondary);
                        cursor: default;
                    ",
                    "{tag}"
                }
            }
        }
    }
}

5. 仪表盘布局

#[component]
fn Dashboard() -> Element {
    let stats = use_resource(|| async move {
        fetch_stats().await
    });

    rsx! {
        div { class: "space-y-6",
            // 统计卡片
            div { class: "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4",
                StatCard { label: "总文章", value: "128", icon: "📄" }
                StatCard { label: "总评论", value: "1,024", icon: "💬" }
                StatCard { label: "总访问", value: "58.2K", icon: "👁" }
                StatCard { label: "订阅者", value: "256", icon: "📧" }
            }

            // 图表行
            div { class: "grid grid-cols-1 lg:grid-cols-2 gap-6",
                div { class: "rounded-lg border p-4",
                    style: "background: var(--card); border-color: var(--border);",
                    h3 { class: "font-semibold mb-4", "月度阅读量" }
                    LineChart {
                        data: vec![
                            ("1月".into(), 1200.0),
                            ("2月".into(), 1900.0),
                            ("3月".into(), 1600.0),
                            ("4月".into(), 2100.0),
                            ("5月".into(), 1800.0),
                            ("6月".into(), 2400.0),
                        ],
                        width: 500,
                        height: 250,
                    }
                }
                div { class: "rounded-lg border p-4",
                    style: "background: var(--card); border-color: var(--border);",
                    h3 { class: "font-semibold mb-4", "标签分布" }
                    TagCloud {
                        tags: vec![
                            ("Rust".into(), 15),
                            ("Dioxus".into(), 12),
                            ("React".into(), 8),
                            ("TypeScript".into(), 7),
                            ("CSS".into(), 6),
                            ("Python".into(), 5),
                        ],
                    }
                }
            }
        }
    }
}

#[component]
fn StatCard(label: String, value: String, icon: String) -> Element {
    rsx! {
        div {
            class: "rounded-lg border p-4 transition-all hover:shadow-md",
            style: "background: var(--card); border-color: var(--border);",
            div { class: "flex items-center gap-3",
                span { class: "text-2xl", "{icon}" }
                div {
                    p { class: "text-2xl font-bold", style: "color: var(--text);", "{value}" }
                    p { class: "text-xs", style: "color: var(--tertiary);", "{label}" }
                }
            }
        }
    }
}

6. 无障碍图表

// 为 SVG 图表添加 ARIA 属性
#[component]
fn AccessibleChart(data: Vec<(String, f64)>) -> Element {
    rsx! {
        div {
            role: "img",
            aria_label: "文章阅读量统计图,包含 {data.len()} 个数据点",
            svg {
                role: "presentation",
                // ...
            }
            // 隐藏的表格数据供屏幕阅读器
            div { class: "sr-only",
                table {
                    caption { "月度阅读量统计" }
                    tr {
                        th { "月份" }
                        th { "阅读量" }
                    }
                    for (label, value) in &data {
                        tr {
                            td { "{label}" }
                            td { "{value}" }
                        }
                    }
                }
            }
        }
    }
}

7. 小结

  • 纯 SVG 图表无外部依赖,适合简单图表,SSR 友好
  • SVG 条形图、折线图、环形图可直接用 Dioxus 元素渲染
  • Chart.js 等 JS 图表库通过 web-syswasm-bindgen 集成
  • Canvas 适合高性能绘图和大量数据点
  • use_interval 定时刷新实现实时数据可视化
  • 仪表盘组合 StatCard + 图表形成数据展示页面
  • 为图表添加 ARIA 标签和数据表格,确保可访问性
dioxuschartvisualizationcanvassvgcharting