feat(pineal): cierra stub polar — pie/donut + radar
Fase F: tercer stub de pineal cerrado. - pie — paint_pie: pie y donut (inner_radius > 0). Porciones desde las 12 en punto, horario; valores negativos → 0. Cada cuña se tesela en un triangle strip [in,out,in,out,…] con segmentos de arco escalados al ángulo. - radar — paint_radar: M ejes equiespaciados, valores proyectados a distancia proporcional; relleno (fan) + contorno (polilínea cerrada). Painters 100% agnósticos (trait Canvas). 5 tests verdes. cargo check --workspace verde. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
//! Radar (spider) chart.
|
||||
//!
|
||||
//! `M` ejes equiespaciados desde las 12 en punto. Cada valor se proyecta
|
||||
//! a una distancia del centro proporcional a `value / max_value`. El
|
||||
//! polígono resultante se rellena (triangle fan) y se contornea.
|
||||
|
||||
use pineal_render::{Canvas, Color, Point, StrokeStyle};
|
||||
use std::f32::consts::{FRAC_PI_2, TAU};
|
||||
|
||||
/// Dibuja un radar de `values.len()` ejes. `max_value` define el borde.
|
||||
/// Rellena con `fill` y contornea con `stroke`.
|
||||
pub fn paint_radar(
|
||||
values: &[f32],
|
||||
max_value: f32,
|
||||
center: Point,
|
||||
radius: f32,
|
||||
fill: Color,
|
||||
stroke: StrokeStyle,
|
||||
canvas: &mut dyn Canvas,
|
||||
) {
|
||||
let m = values.len();
|
||||
if m < 3 || max_value <= 0.0 || radius <= 0.0 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Punto de cada eje, en orden.
|
||||
let verts: Vec<Point> = values
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &v)| {
|
||||
let angle = -FRAC_PI_2 + (i as f32 / m as f32) * TAU;
|
||||
let dist = (v / max_value).clamp(0.0, 1.0) * radius;
|
||||
Point::new(center.x + dist * angle.cos(), center.y + dist * angle.sin())
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Relleno: fan como strip [c, v0, c, v1, …, c, v0] (cierra el polígono).
|
||||
let mut coords = Vec::with_capacity((m + 1) * 4);
|
||||
let mut colors = Vec::with_capacity((m + 1) * 2);
|
||||
for i in 0..=m {
|
||||
let v = verts[i % m];
|
||||
coords.push(center.x);
|
||||
coords.push(center.y);
|
||||
coords.push(v.x);
|
||||
coords.push(v.y);
|
||||
colors.push(fill);
|
||||
colors.push(fill);
|
||||
}
|
||||
canvas.fill_triangle_strip(&coords, &colors);
|
||||
|
||||
// Contorno: polilínea cerrada.
|
||||
let mut outline = Vec::with_capacity((m + 1) * 2);
|
||||
for i in 0..=m {
|
||||
let v = verts[i % m];
|
||||
outline.push(v.x);
|
||||
outline.push(v.y);
|
||||
}
|
||||
canvas.stroke_polyline(&outline, stroke);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pineal_render::{PlanRecorder, RenderCmd};
|
||||
|
||||
#[test]
|
||||
fn emits_fill_strip_and_outline() {
|
||||
let mut rec = PlanRecorder::new();
|
||||
paint_radar(
|
||||
&[1.0, 2.0, 3.0, 2.0, 1.0],
|
||||
3.0,
|
||||
Point::new(50.0, 50.0),
|
||||
40.0,
|
||||
Color::WHITE,
|
||||
StrokeStyle::new(1.5, Color::BLACK),
|
||||
&mut rec,
|
||||
);
|
||||
let cmds = rec.into_plan().cmds;
|
||||
assert!(cmds.iter().any(|c| matches!(c, RenderCmd::FillTriangleStrip { .. })));
|
||||
assert!(cmds.iter().any(|c| matches!(c, RenderCmd::StrokePolyline { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn too_few_axes_draws_nothing() {
|
||||
let mut rec = PlanRecorder::new();
|
||||
paint_radar(
|
||||
&[1.0, 2.0],
|
||||
3.0,
|
||||
Point::new(0.0, 0.0),
|
||||
10.0,
|
||||
Color::WHITE,
|
||||
StrokeStyle::new(1.0, Color::BLACK),
|
||||
&mut rec,
|
||||
);
|
||||
assert!(rec.into_plan().cmds.is_empty());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user