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:
@@ -1,16 +1,15 @@
|
|||||||
//! `pineal-polar` — gráficos en coordenadas polares.
|
//! `pineal-polar` — gráficos en coordenadas polares.
|
||||||
//!
|
//!
|
||||||
//! - **`pie`** — pie / donut chart.
|
//! Painters agnósticos (hablan contra `Canvas`): el `Canvas` no tiene
|
||||||
//! - **`radar`** — radar (spider) chart.
|
//! primitiva de arco, así que cada forma se tesela en triangle strips.
|
||||||
//! - **`element`** — `Element` GPUI.
|
|
||||||
//!
|
//!
|
||||||
//! No comparte mucho con cartesian; viewport y gestures van
|
//! - [`pie`] — pie / donut chart.
|
||||||
//! ad-hoc. El picture-cache de cartesian no aplica acá (las
|
//! - [`radar`] — radar (spider) chart.
|
||||||
//! rotaciones lo invalidan).
|
|
||||||
|
|
||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
#![allow(dead_code)]
|
|
||||||
|
|
||||||
pub mod pie {}
|
pub mod pie;
|
||||||
pub mod radar {}
|
pub mod radar;
|
||||||
pub mod element {}
|
|
||||||
|
pub use pie::{paint_pie, Slice};
|
||||||
|
pub use radar::paint_radar;
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
//! Pie / donut chart.
|
||||||
|
//!
|
||||||
|
//! Las porciones arrancan a las 12 en punto (-90°) y avanzan en sentido
|
||||||
|
//! horario. Como el `Canvas` no tiene primitiva de arco, cada cuña se
|
||||||
|
//! tesela en un triangle strip; la calidad del arco escala con el ángulo.
|
||||||
|
|
||||||
|
use pineal_render::{Canvas, Color, Point};
|
||||||
|
use std::f32::consts::{FRAC_PI_2, TAU};
|
||||||
|
|
||||||
|
/// Una porción del pie: un valor (peso) y su color.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct Slice {
|
||||||
|
pub value: f32,
|
||||||
|
pub color: Color,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Slice {
|
||||||
|
pub fn new(value: f32, color: Color) -> Self {
|
||||||
|
Self { value, color }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Segmentos de arco por vuelta completa — controla la suavidad.
|
||||||
|
const ARC_SEGMENTS_PER_TURN: f32 = 96.0;
|
||||||
|
|
||||||
|
/// Dibuja un pie centrado en `center`. Si `inner_radius > 0` es un donut.
|
||||||
|
/// Los valores negativos se tratan como 0.
|
||||||
|
pub fn paint_pie(
|
||||||
|
slices: &[Slice],
|
||||||
|
center: Point,
|
||||||
|
radius: f32,
|
||||||
|
inner_radius: f32,
|
||||||
|
canvas: &mut dyn Canvas,
|
||||||
|
) {
|
||||||
|
let total: f32 = slices.iter().map(|s| s.value.max(0.0)).sum();
|
||||||
|
if total <= 0.0 || radius <= 0.0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut angle = -FRAC_PI_2;
|
||||||
|
for s in slices {
|
||||||
|
let sweep = (s.value.max(0.0) / total) * TAU;
|
||||||
|
if sweep > 0.0 {
|
||||||
|
paint_wedge(center, radius, inner_radius.max(0.0), angle, angle + sweep, s.color, canvas);
|
||||||
|
}
|
||||||
|
angle += sweep;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn arc_point(center: Point, r: f32, angle: f32) -> Point {
|
||||||
|
Point::new(center.x + r * angle.cos(), center.y + r * angle.sin())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn paint_wedge(
|
||||||
|
center: Point,
|
||||||
|
r_out: f32,
|
||||||
|
r_in: f32,
|
||||||
|
a0: f32,
|
||||||
|
a1: f32,
|
||||||
|
color: Color,
|
||||||
|
canvas: &mut dyn Canvas,
|
||||||
|
) {
|
||||||
|
let segs = ((a1 - a0).abs() / TAU * ARC_SEGMENTS_PER_TURN).ceil() as usize;
|
||||||
|
let segs = segs.max(1);
|
||||||
|
let mut coords = Vec::with_capacity((segs + 1) * 4);
|
||||||
|
let mut colors = Vec::with_capacity((segs + 1) * 2);
|
||||||
|
for i in 0..=segs {
|
||||||
|
let t = a0 + (a1 - a0) * (i as f32 / segs as f32);
|
||||||
|
// Borde interno: el centro (pie) o el arco interno (donut).
|
||||||
|
let inner = if r_in <= 0.0 {
|
||||||
|
center
|
||||||
|
} else {
|
||||||
|
arc_point(center, r_in, t)
|
||||||
|
};
|
||||||
|
let outer = arc_point(center, r_out, t);
|
||||||
|
coords.push(inner.x);
|
||||||
|
coords.push(inner.y);
|
||||||
|
coords.push(outer.x);
|
||||||
|
coords.push(outer.y);
|
||||||
|
colors.push(color);
|
||||||
|
colors.push(color);
|
||||||
|
}
|
||||||
|
// Strip [in0,out0,in1,out1,…]: cada par de triángulos cubre un segmento.
|
||||||
|
canvas.fill_triangle_strip(&coords, &colors);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use pineal_render::{PlanRecorder, RenderCmd};
|
||||||
|
|
||||||
|
fn count_strips(cmds: &[RenderCmd]) -> usize {
|
||||||
|
cmds.iter()
|
||||||
|
.filter(|c| matches!(c, RenderCmd::FillTriangleStrip { .. }))
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn one_strip_per_nonzero_slice() {
|
||||||
|
let slices = [
|
||||||
|
Slice::new(1.0, Color::WHITE),
|
||||||
|
Slice::new(1.0, Color::BLACK),
|
||||||
|
Slice::new(2.0, Color::from_hex(0xff0000)),
|
||||||
|
];
|
||||||
|
let mut rec = PlanRecorder::new();
|
||||||
|
paint_pie(&slices, Point::new(50.0, 50.0), 40.0, 0.0, &mut rec);
|
||||||
|
assert_eq!(count_strips(&rec.into_plan().cmds), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zero_total_draws_nothing() {
|
||||||
|
let slices = [Slice::new(0.0, Color::WHITE)];
|
||||||
|
let mut rec = PlanRecorder::new();
|
||||||
|
paint_pie(&slices, Point::new(50.0, 50.0), 40.0, 0.0, &mut rec);
|
||||||
|
assert!(rec.into_plan().cmds.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn donut_also_emits_strips() {
|
||||||
|
let slices = [Slice::new(1.0, Color::WHITE), Slice::new(1.0, Color::BLACK)];
|
||||||
|
let mut rec = PlanRecorder::new();
|
||||||
|
paint_pie(&slices, Point::new(50.0, 50.0), 40.0, 20.0, &mut rec);
|
||||||
|
assert_eq!(count_strips(&rec.into_plan().cmds), 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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