fixes
Some checks failed
CI / rust (push) Failing after 36s
CI / frontend (push) Failing after 1m53s
CI / wasm (push) Successful in 1m46s
CI / publish-crates (push) Has been skipped
CI / publish-npm (push) Has been skipped

This commit is contained in:
2026-04-09 02:16:27 +03:00
parent 58a59f2609
commit 92583141c9
24 changed files with 586 additions and 40 deletions

View File

@@ -129,10 +129,23 @@ pub struct LineChartLayout {
pub show_labels: bool,
pub label_font: f64,
pub label_color: String,
pub smooth: bool,
/// X axis line endpoints
pub x_axis_y: f64,
pub x_axis_x1: f64,
pub x_axis_x2: f64,
/// Vertical reference lines
pub ref_lines: Vec<RefLineLayout>,
}
pub struct RefLineLayout {
pub x: f64,
pub y1: f64,
pub y2: f64,
pub color: String,
pub width: f64,
pub dash: bool,
pub label: Option<String>,
}
pub struct PieSlice {
@@ -223,6 +236,10 @@ pub trait ChartDataSource {
fn inner_radius(&self) -> Option<f64>;
fn show_points(&self) -> Option<bool>;
fn line_width(&self) -> Option<f64>;
fn curve_type(&self) -> Option<&str>;
fn reference_lines(&self) -> &[dreport_core::models::ChartReferenceLine];
fn show_vertical_grid(&self) -> bool;
fn vertical_grid_color(&self) -> Option<&str>;
}
// ---------------------------------------------------------------------------
@@ -314,6 +331,18 @@ impl ChartDataSource for crate::data_resolve::ResolvedChartData {
fn line_width(&self) -> Option<f64> {
self.style.line_width
}
fn curve_type(&self) -> Option<&str> {
self.style.curve_type.as_deref()
}
fn reference_lines(&self) -> &[dreport_core::models::ChartReferenceLine] {
self.axis.as_ref().map_or(&[], |a| &a.reference_lines)
}
fn show_vertical_grid(&self) -> bool {
self.axis.as_ref().and_then(|a| a.show_vertical_grid).unwrap_or(true)
}
fn vertical_grid_color(&self) -> Option<&str> {
self.axis.as_ref().and_then(|a| a.vertical_grid_color.as_deref())
}
}
// ---------------------------------------------------------------------------
@@ -403,6 +432,18 @@ impl ChartDataSource for crate::ChartRenderData {
fn line_width(&self) -> Option<f64> {
self.line_width
}
fn curve_type(&self) -> Option<&str> {
self.curve_type.as_deref()
}
fn reference_lines(&self) -> &[dreport_core::models::ChartReferenceLine] {
&self.reference_lines
}
fn show_vertical_grid(&self) -> bool {
self.show_vertical_grid
}
fn vertical_grid_color(&self) -> Option<&str> {
self.vertical_grid_color.as_deref()
}
}
// ---------------------------------------------------------------------------
@@ -693,22 +734,14 @@ pub fn compute_x_labels_line(
rotate_angle: 0.0,
};
}
let spacing = if n_cats == 1 {
pw
} else {
pw / (n_cats - 1) as f64
};
let step = pw / n_cats as f64;
let max_label_len = categories.iter().map(|c| c.len()).max().unwrap_or(0);
let rotate_angle = compute_label_rotation(max_label_len, spacing);
let rotate_angle = compute_label_rotation(max_label_len, step);
let labels = categories
.iter()
.enumerate()
.map(|(ci, cat)| {
let x = if n_cats == 1 {
px + pw / 2.0
} else {
px + ci as f64 * pw / (n_cats - 1) as f64
};
let x = px + step / 2.0 + ci as f64 * step;
XLabel {
text: cat.clone(),
x,
@@ -833,6 +866,11 @@ pub fn compute_line_layout(data: &dyn ChartDataSource, cl: &ChartLayout) -> Line
let show_labels = data.show_labels();
let label_font = data.label_font_size().unwrap_or(2.2);
let label_color = data.label_color().unwrap_or("#333").to_string();
let smooth = data.curve_type() == Some("smooth");
// Slot-based positioning: each category gets a slot, point centered in slot
// This adds padding on left/right so first/last points don't touch axes
let step = if n_cats > 0 { pw / n_cats as f64 } else { pw };
let series = (0..data.series_count())
.map(|si| {
@@ -841,11 +879,7 @@ pub fn compute_line_layout(data: &dyn ChartDataSource, cl: &ChartLayout) -> Line
.iter()
.enumerate()
.map(|(ci, val)| {
let x = if n_cats == 1 {
px + pw / 2.0
} else {
px + ci as f64 * pw / (n_cats - 1) as f64
};
let x = px + step / 2.0 + ci as f64 * step;
let y = py + ph - ((val - min_val) / range) * ph;
LinePoint { x, y, value: *val }
})
@@ -859,6 +893,42 @@ pub fn compute_line_layout(data: &dyn ChartDataSource, cl: &ChartLayout) -> Line
let x_labels = compute_x_labels_line(data.categories(), px, py + ph, pw);
// Vertical grid lines at each category
let vgrid_color = data.vertical_grid_color().unwrap_or("#E5E7EB").to_string();
let mut ref_lines: Vec<RefLineLayout> = if data.show_vertical_grid() {
(0..n_cats).map(|ci| {
let x = px + step / 2.0 + ci as f64 * step;
RefLineLayout {
x,
y1: py,
y2: py + ph,
color: vgrid_color.clone(),
width: 0.15,
dash: false,
label: None,
}
}).collect()
} else {
vec![]
};
// Explicit reference lines (overlay on top of grid)
for rl in data.reference_lines() {
if rl.category_index >= n_cats {
continue;
}
let x = px + step / 2.0 + rl.category_index as f64 * step;
ref_lines.push(RefLineLayout {
x,
y1: py,
y2: py + ph,
color: rl.color.clone().unwrap_or_else(|| "#9CA3AF".to_string()),
width: rl.width.unwrap_or(0.3),
dash: rl.dash.unwrap_or(true),
label: rl.label.clone(),
});
}
LineChartLayout {
min_val,
max_val,
@@ -870,9 +940,11 @@ pub fn compute_line_layout(data: &dyn ChartDataSource, cl: &ChartLayout) -> Line
show_labels,
label_font,
label_color,
smooth,
x_axis_y: py + ph,
x_axis_x1: px,
x_axis_x2: px + pw,
ref_lines,
}
}

View File

@@ -121,14 +121,13 @@ fn render_line(svg: &mut String, data: &ResolvedChartData, cl: &ChartLayout) {
// Y axis
render_y_axis_svg(svg, &ll.y_axis);
let mut label_texts = String::new();
for series_layout in &ll.series {
let color = color_at(&cl.palette, series_layout.color_idx);
let mut points = String::new();
let mut point_circles = String::new();
for pt in &series_layout.points {
write!(points, "{:.2},{:.2} ", pt.x, pt.y).unwrap();
if ll.show_points {
write!(
point_circles,
@@ -139,19 +138,75 @@ fn render_line(svg: &mut String, data: &ResolvedChartData, cl: &ChartLayout) {
}
if ll.show_labels {
svg_text(svg, pt.x, pt.y - 1.5, ll.label_font, &ll.label_color, SvgAnchor::Middle, &format_value(pt.value));
svg_text(&mut label_texts, pt.x, pt.y - 1.5, ll.label_font, &ll.label_color, SvgAnchor::Middle, &format_value(pt.value));
}
}
write!(
svg,
r##"<polyline points="{}" fill="none" stroke="{}" stroke-width="{:.2}" stroke-linejoin="round" stroke-linecap="round"/>"##,
points.trim(), color, ll.line_width
)
.unwrap();
if ll.smooth && series_layout.points.len() >= 2 {
// Catmull-Rom → cubic bezier smooth curve
let pts = &series_layout.points;
let mut d = format!("M{:.2},{:.2}", pts[0].x, pts[0].y);
for i in 0..pts.len() - 1 {
let p0 = if i > 0 { &pts[i - 1] } else { &pts[i] };
let p1 = &pts[i];
let p2 = &pts[i + 1];
let p3 = if i + 2 < pts.len() { &pts[i + 2] } else { &pts[i + 1] };
let cp1x = p1.x + (p2.x - p0.x) / 6.0;
let cp1y = p1.y + (p2.y - p0.y) / 6.0;
let cp2x = p2.x - (p3.x - p1.x) / 6.0;
let cp2y = p2.y - (p3.y - p1.y) / 6.0;
write!(d, " C{:.2},{:.2} {:.2},{:.2} {:.2},{:.2}",
cp1x, cp1y, cp2x, cp2y, p2.x, p2.y
).unwrap();
}
write!(
svg,
r##"<path d="{}" fill="none" stroke="{}" stroke-width="{:.2}" stroke-linejoin="round" stroke-linecap="round"/>"##,
d, color, ll.line_width
)
.unwrap();
} else {
let mut points = String::new();
for pt in &series_layout.points {
write!(points, "{:.2},{:.2} ", pt.x, pt.y).unwrap();
}
write!(
svg,
r##"<polyline points="{}" fill="none" stroke="{}" stroke-width="{:.2}" stroke-linejoin="round" stroke-linecap="round"/>"##,
points.trim(), color, ll.line_width
)
.unwrap();
}
svg.push_str(&point_circles);
}
// Data labels (rendered after lines/points so they appear on top)
svg.push_str(&label_texts);
// Reference lines (vertical)
for rl in &ll.ref_lines {
if rl.dash {
write!(
svg,
r##"<line x1="{:.2}" y1="{:.2}" x2="{:.2}" y2="{:.2}" stroke="{}" stroke-width="{:.2}" stroke-dasharray="1.5,1"/>"##,
rl.x, rl.y1, rl.x, rl.y2, rl.color, rl.width
)
.unwrap();
} else {
write!(
svg,
r##"<line x1="{:.2}" y1="{:.2}" x2="{:.2}" y2="{:.2}" stroke="{}" stroke-width="{:.2}"/>"##,
rl.x, rl.y1, rl.x, rl.y2, rl.color, rl.width
)
.unwrap();
}
if let Some(ref label) = rl.label {
svg_text(svg, rl.x, rl.y1 - 1.0, 2.0, &rl.color, SvgAnchor::Middle, label);
}
}
// X axis labels
render_x_labels_svg(svg, &ll.x_labels);
@@ -560,6 +615,9 @@ mod tests {
y_label: Some("Revenue".to_string()),
show_grid: None,
grid_color: None,
show_vertical_grid: None,
vertical_grid_color: None,
reference_lines: vec![],
});
let svg = render_svg(&data, 100.0, 60.0);
@@ -575,6 +633,9 @@ mod tests {
y_label: Some("Y Label".to_string()),
show_grid: None,
grid_color: None,
show_vertical_grid: None,
vertical_grid_color: None,
reference_lines: vec![],
});
let svg = render_svg(&data, 80.0, 80.0);

View File

@@ -161,8 +161,21 @@ pub struct ChartRenderData {
// Title align
#[serde(default)]
pub title_align: Option<String>,
// Curve type for line charts
#[serde(default)]
pub curve_type: Option<String>,
// Vertical reference lines
#[serde(default)]
pub reference_lines: Vec<dreport_core::models::ChartReferenceLine>,
// Vertical grid
#[serde(default = "default_true")]
pub show_vertical_grid: bool,
#[serde(default)]
pub vertical_grid_color: Option<String>,
}
fn default_true() -> bool { true }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChartSeriesData {
pub name: String,
@@ -340,6 +353,10 @@ impl From<&data_resolve::ResolvedChartData> for ChartRenderData {
legend_font_size: cd.legend.as_ref().and_then(|l| l.font_size),
x_label: cd.axis.as_ref().and_then(|a| a.x_label.clone()),
y_label: cd.axis.as_ref().and_then(|a| a.y_label.clone()),
curve_type: cd.style.curve_type.clone(),
reference_lines: cd.axis.as_ref().map_or_else(Vec::new, |a| a.reference_lines.clone()),
show_vertical_grid: cd.axis.as_ref().and_then(|a| a.show_vertical_grid).unwrap_or(true),
vertical_grid_color: cd.axis.as_ref().and_then(|a| a.vertical_grid_color.clone()),
}
}
}

View File

@@ -1015,11 +1015,33 @@ fn render_chart(
}));
let path = {
let mut pb = PathBuilder::new();
for (i, (lx, ly)) in points.iter().enumerate() {
if i == 0 {
pb.move_to(mm(*lx), mm(*ly));
} else {
pb.line_to(mm(*lx), mm(*ly));
if ll.smooth && points.len() >= 2 {
let pts = &series_layout.points;
pb.move_to(mm(pts[0].x), mm(pts[0].y));
for i in 0..pts.len() - 1 {
let p0 = if i > 0 { &pts[i - 1] } else { &pts[i] };
let p1 = &pts[i];
let p2 = &pts[i + 1];
let p3 = if i + 2 < pts.len() { &pts[i + 2] } else { &pts[i + 1] };
let cp1x = p1.x + (p2.x - p0.x) / 6.0;
let cp1y = p1.y + (p2.y - p0.y) / 6.0;
let cp2x = p2.x - (p3.x - p1.x) / 6.0;
let cp2y = p2.y - (p3.y - p1.y) / 6.0;
pb.cubic_to(
mm(cp1x), mm(cp1y),
mm(cp2x), mm(cp2y),
mm(p2.x), mm(p2.y),
);
}
} else {
for (i, (lx, ly)) in points.iter().enumerate() {
if i == 0 {
pb.move_to(mm(*lx), mm(*ly));
} else {
pb.line_to(mm(*lx), mm(*ly));
}
}
}
pb.finish()
@@ -1055,6 +1077,32 @@ fn render_chart(
}
}
}
// Reference lines (vertical)
for rl in &ll.ref_lines {
let rl_color = parse_color(&rl.color);
chart_line_seg(
surface,
rl.x,
rl.y1,
rl.x,
rl.y2,
rl_color,
(rl.width * 2.5) as f32,
);
if let Some(ref label) = rl.label {
chart_text(
surface,
rl.x,
rl.y1 - 1.0,
label,
2.0,
&rl.color,
ChartTextAlign::Center,
fonts,
measurer,
);
}
}
render_chart_x_labels(surface, &ll.x_labels, fonts, measurer);
let ac = parse_color("#9CA3AF");
chart_line_seg(

File diff suppressed because one or more lines are too long