第二十七章:数据可视化与图表集成
博客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-sys和wasm-bindgen集成 - Canvas 适合高性能绘图和大量数据点
use_interval定时刷新实现实时数据可视化- 仪表盘组合 StatCard + 图表形成数据展示页面
- 为图表添加 ARIA 标签和数据表格,确保可访问性
dioxuschartvisualizationcanvassvgcharting