refactor(monorepo): reorganización lógica + renames + SDDs + split CHANGELOG
Reorganización física de crates/: - core/ (mezclaba 6 propósitos) se divide en protocol/, init/, runtime/, compat/ - shared/ (3 crates) se redistribuye en protocol/ e init/ - lapaloma (sub-módulo de ui_engine) se promueve a modules/pineal/ Renames de proyectos: - shipote → shuma (runtime de sandboxes) - nouser → akasha (explorador de Mónadas) - yahweh → nahual (motor GPUI, antes ui_engine/) - lapaloma → pineal (data-viz agnóstica) Fraccionamiento UI → core agnóstico: - vista-core (DeckState + snap, 175 LOC, 5 tests verdes) - barra-core (Task + render_html + sanitize, 90 LOC, 5 tests verdes) - vista-web y barra-web ahora son thin DOM bindings Documentación nueva: - 16 SDDs por subdirectorio (≤80 LOC c/u): protocol/init/runtime/compat + 10 módulos + apps/ - docs/STATUS.md con cifras reales por proyecto - docs/ROADMAP.md con plan a finalización (6 hitos, ~6-8 semanas) - CHANGELOG.md particionado en docs/changelog/<proyecto>.md (7 buckets) Automatización: - scripts/reorg.py — script idempotente que: git mv directorios, renombra package names, recomputa path = refs, reescribe imports rust, actualiza workspace Cargo.toml. Soporta --dry-run. - scripts/split-changelog.py — particiona CHANGELOG por componente. Validación: - cargo check --workspace pasa (124 crates + 2 nuevos cores). - 10 tests adicionales (5 en vista-core + 5 en barra-core) verdes. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "pineal-cartesian"
|
||||
version = { workspace = true }
|
||||
edition = { workspace = true }
|
||||
license = { workspace = true }
|
||||
authors = { workspace = true }
|
||||
publish = { workspace = true }
|
||||
description = "Lapaloma — gráficos cartesianos: LineSeries / BarSeries / AreaSeries, viewport con pan/zoom, picture cache, ejes con decimación, tooltips."
|
||||
|
||||
[dependencies]
|
||||
pineal-core = { path = "../core" }
|
||||
pineal-render = { path = "../render" }
|
||||
gpui = { workspace = true, optional = true }
|
||||
|
||||
[features]
|
||||
default = ["gpui"]
|
||||
gpui = ["dep:gpui", "pineal-render/gpui"]
|
||||
@@ -0,0 +1,310 @@
|
||||
//! Generación y decimación de ticks para ejes cartesianos.
|
||||
//!
|
||||
//! Toda esta lógica es agnóstica de backend: produce listas de
|
||||
//! valores (ticks en dominio + posiciones en pixel + strings de
|
||||
//! label). El `Element` GPUI los itera para emitir línea base,
|
||||
//! segmentos de tick y `draw_text` de cada label.
|
||||
//!
|
||||
//! Pipeline canónico:
|
||||
//! 1. [`ticks_nice`] — Wilkinson nice numbers en el rango del eje.
|
||||
//! 2. Proyección dominio → pixel via [`crate::CoordinateSystem`].
|
||||
//! 3. [`decimate_labels`] — descarta labels que se solaparían con
|
||||
//! el anterior dado un `min_spacing_px`. Los **ticks** sí
|
||||
//! siempre se dibujan (delgados, no estorban); sólo el texto
|
||||
//! se decima (sección 4.7 del ARCHITECTURE.md).
|
||||
//!
|
||||
//! `format_tick` es heurístico: si `step >= 1`, sin decimales; si
|
||||
//! no, tantos decimales como hagan falta para distinguir ticks
|
||||
//! adyacentes. Para escalas temporales el caller pasa su propio
|
||||
//! formato (epoch ms → "HH:MM:SS"), `format_tick` no entiende
|
||||
//! semántica.
|
||||
|
||||
use pineal_core::scale::nice_step;
|
||||
use pineal_render::{Canvas, Color, Point, StrokeStyle};
|
||||
|
||||
use crate::coord_system::CoordinateSystem;
|
||||
use crate::viewport::ChartViewport;
|
||||
|
||||
/// Lado del plot donde vive el eje.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AxisSide {
|
||||
Bottom,
|
||||
Left,
|
||||
Top,
|
||||
Right,
|
||||
}
|
||||
|
||||
impl AxisSide {
|
||||
pub fn is_horizontal(self) -> bool {
|
||||
matches!(self, AxisSide::Bottom | AxisSide::Top)
|
||||
}
|
||||
}
|
||||
|
||||
/// Genera ticks "lindos" para un rango y cantidad objetivo.
|
||||
///
|
||||
/// El step es Wilkinson nice (`{1, 2, 5} × 10^k`); los ticks
|
||||
/// resultantes son múltiplos del step alineados a 0.
|
||||
/// Garantiza inclusión de bordes que caigan exactamente en
|
||||
/// múltiplos; ticks fuera del rango se descartan.
|
||||
pub fn ticks_nice(min: f64, max: f64, target_ticks: usize) -> Vec<f64> {
|
||||
debug_assert!(max > min && target_ticks > 0);
|
||||
let step = nice_step(min, max, target_ticks);
|
||||
let mut t = (min / step).ceil() * step;
|
||||
let mut out = Vec::with_capacity(target_ticks + 2);
|
||||
// Tolerancia para incluir el borde derecho cuando cae justo
|
||||
// por epsilon arriba del max.
|
||||
let epsilon = step * 1e-9;
|
||||
while t <= max + epsilon {
|
||||
out.push(t);
|
||||
t += step;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Filtra una lista de `(pixel_pos, label)` para que los labels
|
||||
/// no se solapen. Devuelve los **índices** que sobreviven (los
|
||||
/// del input). Asume input ordenado por `pixel_pos`.
|
||||
///
|
||||
/// `min_spacing_px` es la distancia mínima entre el borde
|
||||
/// derecho de un label aprobado y el borde izquierdo del
|
||||
/// siguiente. Si no tenés el ancho del label, pasá un valor
|
||||
/// conservador (≈ 48 px del Flutter doc).
|
||||
pub fn decimate_labels(
|
||||
positions_px: &[f32],
|
||||
label_widths_px: &[f32],
|
||||
min_spacing_px: f32,
|
||||
) -> Vec<usize> {
|
||||
debug_assert_eq!(positions_px.len(), label_widths_px.len());
|
||||
if positions_px.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut out = Vec::with_capacity(positions_px.len());
|
||||
// Primero (más a la izquierda) siempre va.
|
||||
out.push(0);
|
||||
let mut last_right = positions_px[0] + label_widths_px[0] * 0.5;
|
||||
|
||||
for i in 1..positions_px.len() {
|
||||
let half_w = label_widths_px[i] * 0.5;
|
||||
let my_left = positions_px[i] - half_w;
|
||||
if my_left - last_right >= min_spacing_px {
|
||||
out.push(i);
|
||||
last_right = positions_px[i] + half_w;
|
||||
}
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
/// Formateo numérico básico con decimales dependientes del step.
|
||||
///
|
||||
/// - `step >= 1` → sin decimales: "1", "20", "300".
|
||||
/// - `0 < step < 1` → decimales suficientes para distinguir step
|
||||
/// de step + step (típicamente `-floor(log10(step))`).
|
||||
/// - Valores absolutos muy chicos quedan en "0".
|
||||
pub fn format_tick(value: f64, step: f64) -> String {
|
||||
if step >= 1.0 {
|
||||
format!("{}", value.round() as i64)
|
||||
} else if step <= 0.0 {
|
||||
format!("{}", value)
|
||||
} else {
|
||||
let decimals = (-step.log10().floor()) as i32;
|
||||
let decimals = decimals.clamp(1, 9) as usize;
|
||||
format!("{:.*}", decimals, value)
|
||||
}
|
||||
}
|
||||
|
||||
/// Estilo visual del eje. Lo consume el Element en `paint()`.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct AxisStyle {
|
||||
pub tick_length_px: f32,
|
||||
pub tick_width_px: f32,
|
||||
pub axis_line_width_px: f32,
|
||||
pub label_size_px: f32,
|
||||
pub label_offset_px: f32,
|
||||
/// Min spacing entre labels después de decimar.
|
||||
pub label_min_spacing_px: f32,
|
||||
}
|
||||
|
||||
impl Default for AxisStyle {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
tick_length_px: 4.0,
|
||||
tick_width_px: 1.0,
|
||||
axis_line_width_px: 1.0,
|
||||
label_size_px: 10.0,
|
||||
label_offset_px: 4.0,
|
||||
label_min_spacing_px: 8.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const MONO_GLYPH_RATIO: f32 = 0.55;
|
||||
|
||||
/// Pinta las dos líneas base (X y Y), los tick marks y los labels
|
||||
/// decimados de ambos ejes. Función reusable entre crates de
|
||||
/// visualización (cartesian, financial, etc.) — recibe todo por
|
||||
/// args para no atarse al state de un Element específico.
|
||||
pub fn paint_axes(
|
||||
canvas: &mut dyn Canvas,
|
||||
cs: &CoordinateSystem,
|
||||
viewport: &ChartViewport,
|
||||
color: Color,
|
||||
style: AxisStyle,
|
||||
target_ticks_x: usize,
|
||||
target_ticks_y: usize,
|
||||
) {
|
||||
let plot = cs.plot;
|
||||
let axis_stroke = StrokeStyle::new(style.axis_line_width_px, color);
|
||||
let tick_stroke = StrokeStyle::new(style.tick_width_px, color);
|
||||
let tlen = style.tick_length_px;
|
||||
|
||||
canvas.stroke_line(
|
||||
Point::new(plot.x, plot.bottom()),
|
||||
Point::new(plot.right(), plot.bottom()),
|
||||
axis_stroke,
|
||||
);
|
||||
canvas.stroke_line(
|
||||
Point::new(plot.x, plot.y),
|
||||
Point::new(plot.x, plot.bottom()),
|
||||
axis_stroke,
|
||||
);
|
||||
|
||||
// X axis ticks + labels.
|
||||
let x_ticks = ticks_nice(viewport.x_min, viewport.x_max, target_ticks_x);
|
||||
let x_step = nice_step(viewport.x_min, viewport.x_max, target_ticks_x);
|
||||
let mut x_pos: Vec<f32> = Vec::with_capacity(x_ticks.len());
|
||||
let mut x_lbl: Vec<String> = Vec::with_capacity(x_ticks.len());
|
||||
let mut x_widths: Vec<f32> = Vec::with_capacity(x_ticks.len());
|
||||
for v in &x_ticks {
|
||||
let pixel = cs.data_to_pixel(*v, viewport.y_min).x;
|
||||
if pixel < plot.x - 0.5 || pixel > plot.right() + 0.5 {
|
||||
continue;
|
||||
}
|
||||
canvas.stroke_line(
|
||||
Point::new(pixel, plot.bottom()),
|
||||
Point::new(pixel, plot.bottom() + tlen),
|
||||
tick_stroke,
|
||||
);
|
||||
let lbl = format_tick(*v, x_step);
|
||||
let w = lbl.len() as f32 * style.label_size_px * MONO_GLYPH_RATIO;
|
||||
x_pos.push(pixel);
|
||||
x_widths.push(w);
|
||||
x_lbl.push(lbl);
|
||||
}
|
||||
let keep_x = decimate_labels(&x_pos, &x_widths, style.label_min_spacing_px);
|
||||
for i in keep_x {
|
||||
let half = x_widths[i] * 0.5;
|
||||
canvas.draw_text(
|
||||
Point::new(
|
||||
x_pos[i] - half,
|
||||
plot.bottom() + tlen + style.label_offset_px,
|
||||
),
|
||||
&x_lbl[i],
|
||||
color,
|
||||
style.label_size_px,
|
||||
);
|
||||
}
|
||||
|
||||
// Y axis ticks + labels con decimación vertical.
|
||||
let y_ticks = ticks_nice(viewport.y_min, viewport.y_max, target_ticks_y);
|
||||
let y_step = nice_step(viewport.y_min, viewport.y_max, target_ticks_y);
|
||||
let y_label_pitch = style.label_size_px + style.label_min_spacing_px;
|
||||
let mut prev_py: Option<f32> = None;
|
||||
|
||||
for v in &y_ticks {
|
||||
let py = cs.data_to_pixel(viewport.x_min, *v).y;
|
||||
if py < plot.y - 0.5 || py > plot.bottom() + 0.5 {
|
||||
continue;
|
||||
}
|
||||
canvas.stroke_line(
|
||||
Point::new(plot.x - tlen, py),
|
||||
Point::new(plot.x, py),
|
||||
tick_stroke,
|
||||
);
|
||||
let label_ok = match prev_py {
|
||||
None => true,
|
||||
Some(p) => (py - p).abs() >= y_label_pitch,
|
||||
};
|
||||
if !label_ok {
|
||||
continue;
|
||||
}
|
||||
let lbl = format_tick(*v, y_step);
|
||||
let w = lbl.len() as f32 * style.label_size_px * MONO_GLYPH_RATIO;
|
||||
canvas.draw_text(
|
||||
Point::new(
|
||||
plot.x - tlen - style.label_offset_px - w,
|
||||
py - style.label_size_px * 0.5,
|
||||
),
|
||||
&lbl,
|
||||
color,
|
||||
style.label_size_px,
|
||||
);
|
||||
prev_py = Some(py);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ticks_nice_genera_alineados_a_step() {
|
||||
let t = ticks_nice(0.0, 10.0, 5);
|
||||
assert_eq!(t, vec![0.0, 2.0, 4.0, 6.0, 8.0, 10.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticks_nice_clipea_fuera_de_rango() {
|
||||
let t = ticks_nice(0.3, 9.8, 5);
|
||||
// step = 2; ticks dentro [0.3, 9.8] son 2,4,6,8.
|
||||
assert_eq!(t, vec![2.0, 4.0, 6.0, 8.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticks_nice_rango_fraccional() {
|
||||
let t = ticks_nice(0.0, 1.0, 5);
|
||||
// step = 0.2 → 0, 0.2, 0.4, 0.6, 0.8, 1.0
|
||||
assert_eq!(t.len(), 6);
|
||||
for (i, v) in t.iter().enumerate() {
|
||||
assert!((v - (i as f64 * 0.2)).abs() < 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decimate_preserva_primero() {
|
||||
let pos = vec![0.0, 5.0, 10.0, 100.0];
|
||||
let w = vec![20.0; 4];
|
||||
// min_spacing 10 px. 0 va; 5 está a 5-10=-5 del borde der → no
|
||||
// entra; 10 está a 10-10=0 → no entra; 100 sí.
|
||||
let keep = decimate_labels(&pos, &w, 10.0);
|
||||
assert_eq!(keep, vec![0, 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decimate_vacio() {
|
||||
let keep = decimate_labels(&[], &[], 10.0);
|
||||
assert!(keep.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decimate_pasa_todo_cuando_hay_lugar() {
|
||||
let pos = vec![0.0, 50.0, 100.0];
|
||||
let w = vec![10.0, 10.0, 10.0];
|
||||
let keep = decimate_labels(&pos, &w, 5.0);
|
||||
assert_eq!(keep, vec![0, 1, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_tick_integer() {
|
||||
assert_eq!(format_tick(42.0, 1.0), "42");
|
||||
assert_eq!(format_tick(0.0, 5.0), "0");
|
||||
assert_eq!(format_tick(1000.0, 100.0), "1000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_tick_fraccional() {
|
||||
assert_eq!(format_tick(0.5, 0.1), "0.5");
|
||||
assert_eq!(format_tick(0.05, 0.01), "0.05");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
//! `CoordinateSystem` — proyección dominio ↔ pixel.
|
||||
//!
|
||||
//! Compone `ChartViewport` (qué se ve) + `plot_rect` (dónde, en
|
||||
//! píxeles) en una transformación afín. La invocación es
|
||||
//! pointwise; no toca los buffers de datos.
|
||||
//!
|
||||
//! Convención Y: +Y de pantalla apunta abajo; +Y de datos arriba.
|
||||
//! La proyección invierte Y para que un valor alto quede arriba.
|
||||
|
||||
use crate::viewport::ChartViewport;
|
||||
use pineal_render::{Point, Rect};
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct CoordinateSystem {
|
||||
pub viewport: ChartViewport,
|
||||
pub plot: Rect,
|
||||
}
|
||||
|
||||
impl CoordinateSystem {
|
||||
pub fn new(viewport: ChartViewport, plot: Rect) -> Self {
|
||||
Self { viewport, plot }
|
||||
}
|
||||
|
||||
/// `(value_x, value_y)` → `(pixel_x, pixel_y)`.
|
||||
pub fn data_to_pixel(&self, x: f64, y: f64) -> Point {
|
||||
let nx = (x - self.viewport.x_min) / self.viewport.x_span();
|
||||
let ny = (y - self.viewport.y_min) / self.viewport.y_span();
|
||||
let px = self.plot.x + nx as f32 * self.plot.w;
|
||||
// +Y de datos = arriba → restar de bottom.
|
||||
let py = self.plot.bottom() - ny as f32 * self.plot.h;
|
||||
Point::new(px, py)
|
||||
}
|
||||
|
||||
/// `(pixel_x, pixel_y)` → `(value_x, value_y)`.
|
||||
/// Usado para hit-test y tooltip-on-hover.
|
||||
pub fn pixel_to_data(&self, p: Point) -> (f64, f64) {
|
||||
let nx = ((p.x - self.plot.x) / self.plot.w) as f64;
|
||||
let ny = ((self.plot.bottom() - p.y) / self.plot.h) as f64;
|
||||
let x = self.viewport.x_min + nx * self.viewport.x_span();
|
||||
let y = self.viewport.y_min + ny * self.viewport.y_span();
|
||||
(x, y)
|
||||
}
|
||||
|
||||
/// Proyecta un buffer entero de coords interleaved
|
||||
/// `[x, y, x, y, …]` (en dominio) a `[px, py, px, py, …]`
|
||||
/// (en píxeles), escribiendo a `out` sin allocar.
|
||||
///
|
||||
/// El caller debe hacer `out.clear()` previo si quiere reuso
|
||||
/// del buffer; este método sólo extiende.
|
||||
pub fn project_buffer(&self, data: &[f32], out: &mut Vec<f32>) {
|
||||
debug_assert!(data.len() % 2 == 0);
|
||||
// Factorizamos para evitar la división por iteración.
|
||||
let sx = self.plot.w / self.viewport.x_span() as f32;
|
||||
let sy = self.plot.h / self.viewport.y_span() as f32;
|
||||
let tx = self.plot.x - self.viewport.x_min as f32 * sx;
|
||||
let ty = self.plot.bottom() + self.viewport.y_min as f32 * sy;
|
||||
|
||||
out.reserve(data.len());
|
||||
let mut i = 0;
|
||||
while i < data.len() {
|
||||
let px = data[i] * sx + tx;
|
||||
let py = ty - data[i + 1] * sy;
|
||||
out.push(px);
|
||||
out.push(py);
|
||||
i += 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn fixture() -> CoordinateSystem {
|
||||
// Viewport [0..10, 0..10] sobre plot de 100×100 en (0, 0).
|
||||
CoordinateSystem::new(
|
||||
ChartViewport::new(0.0, 10.0, 0.0, 10.0),
|
||||
Rect::new(0.0, 0.0, 100.0, 100.0),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_to_pixel_origen() {
|
||||
let cs = fixture();
|
||||
// (0,0) data → (0, 100) pixel (bottom-left del plot).
|
||||
let p = cs.data_to_pixel(0.0, 0.0);
|
||||
assert!((p.x - 0.0).abs() < 1e-6);
|
||||
assert!((p.y - 100.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_to_pixel_centro() {
|
||||
let cs = fixture();
|
||||
// (5, 5) → (50, 50)
|
||||
let p = cs.data_to_pixel(5.0, 5.0);
|
||||
assert!((p.x - 50.0).abs() < 1e-6);
|
||||
assert!((p.y - 50.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_to_pixel_top_right() {
|
||||
let cs = fixture();
|
||||
// (10, 10) → (100, 0)
|
||||
let p = cs.data_to_pixel(10.0, 10.0);
|
||||
assert!((p.x - 100.0).abs() < 1e-6);
|
||||
assert!((p.y - 0.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pixel_to_data_roundtrip() {
|
||||
let cs = fixture();
|
||||
for (x, y) in [(2.5_f64, 7.5), (0.0, 0.0), (10.0, 10.0), (3.14, 1.59)] {
|
||||
let p = cs.data_to_pixel(x, y);
|
||||
let (x2, y2) = cs.pixel_to_data(p);
|
||||
assert!((x - x2).abs() < 1e-4, "x roundtrip: {} vs {}", x, x2);
|
||||
assert!((y - y2).abs() < 1e-4, "y roundtrip: {} vs {}", y, y2);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_buffer_consistente_con_pointwise() {
|
||||
let cs = fixture();
|
||||
let data: Vec<f32> = vec![0.0, 0.0, 5.0, 5.0, 10.0, 10.0];
|
||||
let mut out = Vec::new();
|
||||
cs.project_buffer(&data, &mut out);
|
||||
assert_eq!(out.len(), data.len());
|
||||
for i in 0..3 {
|
||||
let expected = cs.data_to_pixel(data[i * 2] as f64, data[i * 2 + 1] as f64);
|
||||
assert!((out[i * 2] - expected.x).abs() < 1e-4);
|
||||
assert!((out[i * 2 + 1] - expected.y).abs() < 1e-4);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
//! `LapalomaChartElement` — el `Element` GPUI que envuelve el
|
||||
//! pipeline cartesian.
|
||||
//!
|
||||
//! ## Picture cache pan-blit
|
||||
//!
|
||||
//! En GPUI cada frame se construye un Element nuevo (el árbol se
|
||||
//! recrea), así que el cache no puede vivir en el Element. El
|
||||
//! caller crea un `ChartCacheHandle` (Arc<Mutex<ChartCache>>) una
|
||||
//! vez y se lo pasa a cada frame.
|
||||
//!
|
||||
//! Algoritmo (sección 4.4 del ARCHITECTURE.md adaptada a GPUI):
|
||||
//! - Hash estructural = plot rect + span (no x_min/y_min) + por
|
||||
//! serie: revision + len + stroke.
|
||||
//! - Si hash igual al cached: **pan puro** → emitimos las coords
|
||||
//! cacheadas con un offset `(dx_px, dy_px)` calculado del
|
||||
//! diff `viewport.x_min - cached.x_min`. Saltea LTTB +
|
||||
//! projection.
|
||||
//! - Si hash distinto: full rebuild. Re-corre LTTB + project,
|
||||
//! pisa el cache, actualiza el snapshot del viewport.
|
||||
//!
|
||||
//! Sin cache, el Element funciona igual: cada frame rebuild
|
||||
//! completo. Útil para tests/smoke.
|
||||
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::panic;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use gpui::{
|
||||
App, Bounds, Element, ElementId, GlobalElementId, InspectorElementId, IntoElement, LayoutId,
|
||||
Pixels, Style, Window,
|
||||
};
|
||||
|
||||
use pineal_core::buffer::DataBuffer;
|
||||
use pineal_render::{Canvas, Color, Rect, StrokeStyle, WindowCanvas};
|
||||
|
||||
use crate::axis::{self, AxisStyle};
|
||||
use crate::coord_system::CoordinateSystem;
|
||||
use crate::series::LineSeries;
|
||||
use crate::viewport::ChartViewport;
|
||||
|
||||
const TARGET_TICKS_X: usize = 8;
|
||||
const TARGET_TICKS_Y: usize = 6;
|
||||
|
||||
/// Cache de coords proyectadas para reuso entre frames. Es lo
|
||||
/// que habilita el pan-blit: el caller lo crea una vez y lo
|
||||
/// pasa por handle.
|
||||
#[derive(Default, Debug)]
|
||||
pub struct ChartCache {
|
||||
/// Coords proyectadas por serie. `projected.len()` debe coincidir
|
||||
/// con la cantidad de series del Element.
|
||||
projected: Vec<Vec<f32>>,
|
||||
/// Hash de la geometría + identidades de data. Si cambia,
|
||||
/// invalidamos.
|
||||
structural_hash: u64,
|
||||
/// `viewport.x_min` con el que se proyectaron las coords.
|
||||
cached_x_min: f64,
|
||||
cached_y_min: f64,
|
||||
/// Estadística informativa: cuántos pan-blits desde el último
|
||||
/// rebuild. Útil para debugging y para mostrar en demos.
|
||||
pan_blits: u64,
|
||||
/// Estadística informativa: cuántos rebuilds totales.
|
||||
rebuilds: u64,
|
||||
has_valid_cache: bool,
|
||||
}
|
||||
|
||||
impl ChartCache {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
pub fn pan_blits(&self) -> u64 {
|
||||
self.pan_blits
|
||||
}
|
||||
pub fn rebuilds(&self) -> u64 {
|
||||
self.rebuilds
|
||||
}
|
||||
pub fn invalidate(&mut self) {
|
||||
*self = Self::default();
|
||||
}
|
||||
}
|
||||
|
||||
pub type ChartCacheHandle = Arc<Mutex<ChartCache>>;
|
||||
|
||||
/// Atajo para crear un cache compartido. El caller lo guarda en
|
||||
/// su `Render` host y le pasa el clone al Element en cada frame.
|
||||
pub fn chart_cache() -> ChartCacheHandle {
|
||||
Arc::new(Mutex::new(ChartCache::new()))
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ChartSeriesItem {
|
||||
pub data: DataBuffer,
|
||||
pub stroke: StrokeStyle,
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
impl ChartSeriesItem {
|
||||
pub fn new(data: DataBuffer, stroke: StrokeStyle) -> Self {
|
||||
Self { data, stroke, name: None }
|
||||
}
|
||||
pub fn named(data: DataBuffer, stroke: StrokeStyle, name: impl Into<String>) -> Self {
|
||||
Self { data, stroke, name: Some(name.into()) }
|
||||
}
|
||||
}
|
||||
|
||||
pub struct LapalomaChartElement {
|
||||
pub series: Vec<ChartSeriesItem>,
|
||||
pub viewport: ChartViewport,
|
||||
pub background: Option<Color>,
|
||||
pub axis_color: Color,
|
||||
pub axis_style: AxisStyle,
|
||||
pub margin_bottom: f32,
|
||||
pub margin_left: f32,
|
||||
pub margin_top: f32,
|
||||
pub margin_right: f32,
|
||||
/// Cache opcional compartido con el `Render` host. Si está
|
||||
/// presente, habilita pan-blit.
|
||||
pub cache: Option<ChartCacheHandle>,
|
||||
scratch: Vec<f32>,
|
||||
}
|
||||
|
||||
impl LapalomaChartElement {
|
||||
pub fn new(viewport: ChartViewport) -> Self {
|
||||
Self {
|
||||
series: Vec::new(),
|
||||
viewport,
|
||||
background: None,
|
||||
axis_color: Color::rgba(0.6, 0.6, 0.65, 0.8),
|
||||
axis_style: AxisStyle::default(),
|
||||
margin_bottom: 24.0,
|
||||
margin_left: 32.0,
|
||||
margin_top: 8.0,
|
||||
margin_right: 8.0,
|
||||
cache: None,
|
||||
scratch: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_series(mut self, data: DataBuffer, stroke: StrokeStyle) -> Self {
|
||||
self.series.push(ChartSeriesItem::new(data, stroke));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn add_series_named(
|
||||
mut self,
|
||||
data: DataBuffer,
|
||||
stroke: StrokeStyle,
|
||||
name: impl Into<String>,
|
||||
) -> Self {
|
||||
self.series.push(ChartSeriesItem::named(data, stroke, name));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn background(mut self, color: Color) -> Self {
|
||||
self.background = Some(color);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn axis_color(mut self, color: Color) -> Self {
|
||||
self.axis_color = color;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn margins(mut self, top: f32, right: f32, bottom: f32, left: f32) -> Self {
|
||||
self.margin_top = top;
|
||||
self.margin_right = right;
|
||||
self.margin_bottom = bottom;
|
||||
self.margin_left = left;
|
||||
self
|
||||
}
|
||||
|
||||
/// Enchufa un cache compartido. Sin esto, cada frame es rebuild
|
||||
/// completo (correcto pero sin la optimización pan-blit).
|
||||
pub fn with_cache(mut self, cache: ChartCacheHandle) -> Self {
|
||||
self.cache = Some(cache);
|
||||
self
|
||||
}
|
||||
|
||||
fn plot_rect(&self, bounds: Rect) -> Rect {
|
||||
Rect::new(
|
||||
bounds.x + self.margin_left,
|
||||
bounds.y + self.margin_top,
|
||||
(bounds.w - self.margin_left - self.margin_right).max(1.0),
|
||||
(bounds.h - self.margin_top - self.margin_bottom).max(1.0),
|
||||
)
|
||||
}
|
||||
|
||||
fn paint_axes(&self, canvas: &mut dyn Canvas, cs: &CoordinateSystem) {
|
||||
axis::paint_axes(
|
||||
canvas,
|
||||
cs,
|
||||
&self.viewport,
|
||||
self.axis_color,
|
||||
self.axis_style,
|
||||
TARGET_TICKS_X,
|
||||
TARGET_TICKS_Y,
|
||||
);
|
||||
}
|
||||
|
||||
/// Rebuild full: LTTB + projection por serie. Pinta directo desde
|
||||
/// el cache si está enchufado (para no copiar dos veces).
|
||||
fn rebuild_and_paint(&mut self, cs: &CoordinateSystem, canvas: &mut dyn Canvas) {
|
||||
if let Some(handle) = self.cache.clone() {
|
||||
let mut cache = handle.lock().unwrap();
|
||||
cache.projected.clear();
|
||||
cache.projected.resize_with(self.series.len(), Vec::new);
|
||||
for (i, item) in self.series.iter().enumerate() {
|
||||
let series = LineSeries::new(&item.data, item.stroke);
|
||||
series.compute_projected(cs, &mut cache.projected[i]);
|
||||
if cache.projected[i].len() >= 4 {
|
||||
canvas.stroke_polyline(&cache.projected[i], item.stroke);
|
||||
}
|
||||
}
|
||||
cache.structural_hash = structural_hash(
|
||||
cs.plot,
|
||||
self.viewport.x_span(),
|
||||
self.viewport.y_span(),
|
||||
&self.series,
|
||||
);
|
||||
cache.cached_x_min = self.viewport.x_min;
|
||||
cache.cached_y_min = self.viewport.y_min;
|
||||
cache.has_valid_cache = true;
|
||||
cache.pan_blits = 0;
|
||||
cache.rebuilds = cache.rebuilds.wrapping_add(1);
|
||||
} else {
|
||||
// Sin cache: usamos el scratch local.
|
||||
for item in &self.series {
|
||||
let series = LineSeries::new(&item.data, item.stroke);
|
||||
series.compute_projected(cs, &mut self.scratch);
|
||||
if self.scratch.len() >= 4 {
|
||||
canvas.stroke_polyline(&self.scratch, item.stroke);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Emite las coords cacheadas con un offset en pixel space.
|
||||
/// Se usa cuando detectamos pan puro (mismo hash estructural).
|
||||
fn pan_blit_paint(&mut self, plot: Rect, canvas: &mut dyn Canvas) {
|
||||
let Some(handle) = self.cache.clone() else {
|
||||
return;
|
||||
};
|
||||
let mut cache = handle.lock().unwrap();
|
||||
let dx_px = ((cache.cached_x_min - self.viewport.x_min) * plot.w as f64
|
||||
/ self.viewport.x_span()) as f32;
|
||||
let dy_px = ((self.viewport.y_min - cache.cached_y_min) * plot.h as f64
|
||||
/ self.viewport.y_span()) as f32;
|
||||
|
||||
for (i, item) in self.series.iter().enumerate() {
|
||||
let cached = &cache.projected[i];
|
||||
if cached.len() < 4 {
|
||||
continue;
|
||||
}
|
||||
self.scratch.clear();
|
||||
self.scratch.reserve(cached.len());
|
||||
let mut k = 0;
|
||||
while k + 1 < cached.len() {
|
||||
self.scratch.push(cached[k] + dx_px);
|
||||
self.scratch.push(cached[k + 1] + dy_px);
|
||||
k += 2;
|
||||
}
|
||||
canvas.stroke_polyline(&self.scratch, item.stroke);
|
||||
}
|
||||
cache.pan_blits = cache.pan_blits.wrapping_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoElement for LapalomaChartElement {
|
||||
type Element = Self;
|
||||
fn into_element(self) -> Self::Element {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for LapalomaChartElement {
|
||||
type RequestLayoutState = ();
|
||||
type PrepaintState = ();
|
||||
|
||||
fn id(&self) -> Option<ElementId> {
|
||||
None
|
||||
}
|
||||
fn source_location(&self) -> Option<&'static panic::Location<'static>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn request_layout(
|
||||
&mut self,
|
||||
_id: Option<&GlobalElementId>,
|
||||
_inspector_id: Option<&InspectorElementId>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> (LayoutId, Self::RequestLayoutState) {
|
||||
let mut style = Style::default();
|
||||
style.size.width = gpui::Length::Definite(gpui::DefiniteLength::Fraction(1.0));
|
||||
style.size.height = gpui::Length::Definite(gpui::DefiniteLength::Fraction(1.0));
|
||||
let id = window.request_layout(style, [], cx);
|
||||
(id, ())
|
||||
}
|
||||
|
||||
fn prepaint(
|
||||
&mut self,
|
||||
_id: Option<&GlobalElementId>,
|
||||
_inspector_id: Option<&InspectorElementId>,
|
||||
_bounds: Bounds<Pixels>,
|
||||
_request_layout: &mut Self::RequestLayoutState,
|
||||
_window: &mut Window,
|
||||
_cx: &mut App,
|
||||
) -> Self::PrepaintState {
|
||||
}
|
||||
|
||||
fn paint(
|
||||
&mut self,
|
||||
_id: Option<&GlobalElementId>,
|
||||
_inspector_id: Option<&InspectorElementId>,
|
||||
bounds: Bounds<Pixels>,
|
||||
_request_layout: &mut Self::RequestLayoutState,
|
||||
_prepaint: &mut Self::PrepaintState,
|
||||
window: &mut Window,
|
||||
_cx: &mut App,
|
||||
) {
|
||||
let ox: f32 = bounds.origin.x.into();
|
||||
let oy: f32 = bounds.origin.y.into();
|
||||
let w: f32 = bounds.size.width.into();
|
||||
let h: f32 = bounds.size.height.into();
|
||||
let outer = Rect::new(ox, oy, w, h);
|
||||
let plot = self.plot_rect(outer);
|
||||
|
||||
let cs = CoordinateSystem::new(self.viewport, plot);
|
||||
let mut canvas = WindowCanvas::new(window);
|
||||
|
||||
if let Some(bg) = self.background {
|
||||
canvas.fill_rect(outer, bg);
|
||||
}
|
||||
|
||||
self.paint_axes(&mut canvas, &cs);
|
||||
|
||||
// Decide rebuild vs pan-blit.
|
||||
let current_hash = structural_hash(
|
||||
plot,
|
||||
self.viewport.x_span(),
|
||||
self.viewport.y_span(),
|
||||
&self.series,
|
||||
);
|
||||
let pan_only = self
|
||||
.cache
|
||||
.as_ref()
|
||||
.map(|h| {
|
||||
let c = h.lock().unwrap();
|
||||
c.has_valid_cache
|
||||
&& c.structural_hash == current_hash
|
||||
&& c.projected.len() == self.series.len()
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
if pan_only {
|
||||
self.pan_blit_paint(plot, &mut canvas);
|
||||
} else {
|
||||
self.rebuild_and_paint(&cs, &mut canvas);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Hash de la geometría + identidades de data. Lo que NO va acá:
|
||||
/// `viewport.x_min` y `y_min` (el pan los mueve sin invalidar).
|
||||
fn structural_hash(
|
||||
plot: Rect,
|
||||
x_span: f64,
|
||||
y_span: f64,
|
||||
series: &[ChartSeriesItem],
|
||||
) -> u64 {
|
||||
let mut h = DefaultHasher::new();
|
||||
plot.x.to_bits().hash(&mut h);
|
||||
plot.y.to_bits().hash(&mut h);
|
||||
plot.w.to_bits().hash(&mut h);
|
||||
plot.h.to_bits().hash(&mut h);
|
||||
x_span.to_bits().hash(&mut h);
|
||||
y_span.to_bits().hash(&mut h);
|
||||
(series.len() as u64).hash(&mut h);
|
||||
for s in series {
|
||||
s.data.revision().hash(&mut h);
|
||||
(s.data.len() as u64).hash(&mut h);
|
||||
s.stroke.width.to_bits().hash(&mut h);
|
||||
s.stroke.color.r.to_bits().hash(&mut h);
|
||||
s.stroke.color.g.to_bits().hash(&mut h);
|
||||
s.stroke.color.b.to_bits().hash(&mut h);
|
||||
s.stroke.color.a.to_bits().hash(&mut h);
|
||||
}
|
||||
h.finish()
|
||||
}
|
||||
|
||||
pub fn lapaloma_chart(
|
||||
data: DataBuffer,
|
||||
viewport: ChartViewport,
|
||||
stroke: StrokeStyle,
|
||||
) -> LapalomaChartElement {
|
||||
LapalomaChartElement::new(viewport).add_series(data, stroke)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn structural_hash_estable_para_mismo_estado() {
|
||||
let mut data = DataBuffer::with_capacity(10);
|
||||
for i in 0..10 {
|
||||
data.push(i as f32, (i as f32).sin());
|
||||
}
|
||||
let series = vec![ChartSeriesItem::new(
|
||||
data,
|
||||
StrokeStyle::new(2.0, Color::rgb(1.0, 0.0, 0.0)),
|
||||
)];
|
||||
let plot = Rect::new(0.0, 0.0, 100.0, 100.0);
|
||||
let a = structural_hash(plot, 10.0, 2.0, &series);
|
||||
let b = structural_hash(plot, 10.0, 2.0, &series);
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structural_hash_pan_no_cambia() {
|
||||
// Mismo span pero distinto x_min/y_min — hash igual.
|
||||
let mut data = DataBuffer::with_capacity(10);
|
||||
for i in 0..10 {
|
||||
data.push(i as f32, (i as f32).sin());
|
||||
}
|
||||
let series = vec![ChartSeriesItem::new(
|
||||
data,
|
||||
StrokeStyle::new(2.0, Color::rgb(1.0, 0.0, 0.0)),
|
||||
)];
|
||||
let plot = Rect::new(0.0, 0.0, 100.0, 100.0);
|
||||
let a = structural_hash(plot, 10.0, 2.0, &series);
|
||||
// Pan implícito: x_span/y_span no cambiaron → hash igual.
|
||||
let b = structural_hash(plot, 10.0, 2.0, &series);
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structural_hash_zoom_invalida() {
|
||||
let series = vec![ChartSeriesItem::new(
|
||||
DataBuffer::new(),
|
||||
StrokeStyle::new(2.0, Color::WHITE),
|
||||
)];
|
||||
let plot = Rect::new(0.0, 0.0, 100.0, 100.0);
|
||||
let a = structural_hash(plot, 10.0, 2.0, &series);
|
||||
let b = structural_hash(plot, 5.0, 2.0, &series); // zoom in X
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structural_hash_data_revision_invalida() {
|
||||
let mut data = DataBuffer::with_capacity(2);
|
||||
data.push(0.0, 0.0);
|
||||
let series0 = vec![ChartSeriesItem::new(
|
||||
data.clone(),
|
||||
StrokeStyle::new(2.0, Color::WHITE),
|
||||
)];
|
||||
let plot = Rect::new(0.0, 0.0, 100.0, 100.0);
|
||||
let a = structural_hash(plot, 1.0, 1.0, &series0);
|
||||
|
||||
data.push(1.0, 1.0); // bump revision
|
||||
let series1 = vec![ChartSeriesItem::new(
|
||||
data,
|
||||
StrokeStyle::new(2.0, Color::WHITE),
|
||||
)];
|
||||
let b = structural_hash(plot, 1.0, 1.0, &series1);
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structural_hash_plot_rect_invalida() {
|
||||
let series = vec![ChartSeriesItem::new(
|
||||
DataBuffer::new(),
|
||||
StrokeStyle::new(2.0, Color::WHITE),
|
||||
)];
|
||||
let a = structural_hash(Rect::new(0.0, 0.0, 100.0, 100.0), 1.0, 1.0, &series);
|
||||
let b = structural_hash(Rect::new(0.0, 0.0, 200.0, 100.0), 1.0, 1.0, &series);
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
//! `pineal-cartesian` — gráficos cartesianos.
|
||||
//!
|
||||
//! Este crate trae:
|
||||
//!
|
||||
//! - **`viewport`** — `ChartViewport` con `(x_min, x_max, y_min, y_max)`
|
||||
//! y helpers de pan/zoom anchor-preserving.
|
||||
//! - **`coord_system`** — proyecta valores de dominio → pixeles del
|
||||
//! plot usando las escalas de `pineal-core::scale`.
|
||||
//! - **`series`** — trait `Series` + impls `LineSeries`, `BarSeries`,
|
||||
//! `AreaSeries`. Cada serie decide LTTB vs raw según densidad.
|
||||
//! - **`axis`** — ejes con nice-ticks (Wilkinson) y decimación de
|
||||
//! etiquetas que no overlappean.
|
||||
//! - **`picture_cache`** — translate-only pan-blit con hash de
|
||||
//! invalidación. Clipea el outer canvas antes del translate
|
||||
//! (bug 0.3.0 del Flutter).
|
||||
//! - **`element`** — el `Element` GPUI que envuelve todo lo de
|
||||
//! arriba y se inserta en un layout nahual.
|
||||
//!
|
||||
//! Hoy todos los módulos están como placeholders; la primera
|
||||
//! impl real va a ser `LineSeries` + `element` end-to-end para
|
||||
//! validar la cadena `core → render → cartesian → gpui`.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub mod viewport;
|
||||
pub mod coord_system;
|
||||
pub mod series;
|
||||
pub mod axis;
|
||||
|
||||
#[cfg(feature = "gpui")]
|
||||
pub mod element;
|
||||
|
||||
// Pendientes — siguen como placeholders hasta su fase.
|
||||
pub mod picture_cache {}
|
||||
|
||||
pub use viewport::ChartViewport;
|
||||
pub use coord_system::CoordinateSystem;
|
||||
pub use series::{LineSeries, PaintCtx, RenderMode, Series};
|
||||
|
||||
#[cfg(feature = "gpui")]
|
||||
pub use element::{
|
||||
chart_cache, lapaloma_chart, ChartCache, ChartCacheHandle, ChartSeriesItem,
|
||||
LapalomaChartElement,
|
||||
};
|
||||
@@ -0,0 +1,283 @@
|
||||
//! `Series` — trait que abstrae cualquier dataset visualizable
|
||||
//! sobre coordenadas cartesianas, + impl [`LineSeries`].
|
||||
//!
|
||||
//! La firma es agnóstica de `gpui`: el painter dibuja contra
|
||||
//! `pineal_render::Canvas`. El Element GPUI envuelve esto y
|
||||
//! pasa un adaptador del Canvas trait sobre el PaintContext nativo.
|
||||
|
||||
use pineal_core::buffer::DataBuffer;
|
||||
use pineal_core::lttb;
|
||||
use pineal_render::{Canvas, StrokeStyle};
|
||||
|
||||
use crate::coord_system::CoordinateSystem;
|
||||
|
||||
/// Hint para la serie sobre el nivel de detalle. A alta densidad
|
||||
/// (muchos más puntos que pixeles) el painter saltea decoraciones
|
||||
/// y aplica decimación; a baja densidad pinta marcadores y todo.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RenderMode {
|
||||
HighDensity,
|
||||
UiRich,
|
||||
}
|
||||
|
||||
/// Contexto que la serie recibe en cada `paint`.
|
||||
///
|
||||
/// Lleva el coord system + el modo + un buffer scratch que el
|
||||
/// caller mantiene entre frames para evitar allocations.
|
||||
pub struct PaintCtx<'a> {
|
||||
pub cs: CoordinateSystem,
|
||||
pub mode: RenderMode,
|
||||
/// Buffer scratch reusable (compartido entre series del mismo
|
||||
/// chart). El caller hace `clear()` antes de cada serie.
|
||||
pub scratch: &'a mut Vec<f32>,
|
||||
}
|
||||
|
||||
pub trait Series {
|
||||
fn paint(&self, ctx: &mut PaintCtx<'_>, canvas: &mut dyn Canvas);
|
||||
|
||||
/// Devuelve `Some(point_index)` del sample más cercano al
|
||||
/// pixel pasado, si está dentro del threshold de hit (default
|
||||
/// 8 px). El default impl asume que el data buffer expuesto
|
||||
/// por la serie está sorted por X (caso de [`LineSeries`]).
|
||||
fn hit_test(&self, _pixel: pineal_render::Point, _cs: &CoordinateSystem) -> Option<usize> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Serie de polilínea simple. Decimación LTTB cuando
|
||||
/// `data.len() > 3 × plot_width_px`.
|
||||
pub struct LineSeries<'a> {
|
||||
pub data: &'a DataBuffer,
|
||||
pub stroke: StrokeStyle,
|
||||
/// Si `None`, se usa heurística `target = plot_width × 3`.
|
||||
pub lttb_target: Option<usize>,
|
||||
}
|
||||
|
||||
impl<'a> LineSeries<'a> {
|
||||
pub fn new(data: &'a DataBuffer, stroke: StrokeStyle) -> Self {
|
||||
Self { data, stroke, lttb_target: None }
|
||||
}
|
||||
|
||||
pub fn effective_target(&self, plot_w: f32) -> usize {
|
||||
self.lttb_target.unwrap_or_else(|| (plot_w as usize).saturating_mul(3))
|
||||
}
|
||||
|
||||
/// Materializa las coords proyectadas a pixel space en `out`,
|
||||
/// aplicando LTTB cuando densidad > target. `out` se clearea.
|
||||
///
|
||||
/// Útil para callers que necesitan cachear el resultado
|
||||
/// (picture cache pan-blit) sin pasar por `paint()`.
|
||||
pub fn compute_projected(&self, cs: &CoordinateSystem, out: &mut Vec<f32>) {
|
||||
out.clear();
|
||||
if self.data.len() < 2 {
|
||||
return;
|
||||
}
|
||||
let target = self.effective_target(cs.plot.w);
|
||||
if self.data.len() > target {
|
||||
let mut idx: Vec<usize> = Vec::with_capacity(target);
|
||||
lttb::lttb_indices(self.data.coords(), target, &mut idx);
|
||||
let mut decimated: Vec<f32> = Vec::with_capacity(idx.len() * 2);
|
||||
for i in idx {
|
||||
decimated.push(self.data.coords()[i * 2]);
|
||||
decimated.push(self.data.coords()[i * 2 + 1]);
|
||||
}
|
||||
cs.project_buffer(&decimated, out);
|
||||
} else {
|
||||
cs.project_buffer(self.data.coords(), out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Series for LineSeries<'a> {
|
||||
fn paint(&self, ctx: &mut PaintCtx<'_>, canvas: &mut dyn Canvas) {
|
||||
self.compute_projected(&ctx.cs, ctx.scratch);
|
||||
if ctx.scratch.len() < 4 {
|
||||
return;
|
||||
}
|
||||
canvas.stroke_polyline(ctx.scratch, self.stroke);
|
||||
}
|
||||
|
||||
fn hit_test(&self, pixel: pineal_render::Point, cs: &CoordinateSystem) -> Option<usize> {
|
||||
let (target_x, _) = cs.pixel_to_data(pixel);
|
||||
let idx = pineal_core::spatial::SpatialIndex::new(self.data.coords())
|
||||
.nearest(target_x as f32)?;
|
||||
// Threshold de 8px sobre la distancia en pixeles real,
|
||||
// no sólo la X — evita match cuando el punto está lejos
|
||||
// verticalmente.
|
||||
let (dx, dy) = self.data.xy(idx);
|
||||
let p = cs.data_to_pixel(dx as f64, dy as f64);
|
||||
let dist2 = (p.x - pixel.x).powi(2) + (p.y - pixel.y).powi(2);
|
||||
if dist2 <= 64.0 { Some(idx) } else { None }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::viewport::ChartViewport;
|
||||
use pineal_render::{Color, Point, Rect, RenderCmd, RenderPlan};
|
||||
|
||||
/// Canvas mock que captura comandos en un `RenderPlan`.
|
||||
struct Capture {
|
||||
plan: RenderPlan,
|
||||
}
|
||||
impl Capture {
|
||||
fn new() -> Self {
|
||||
Self { plan: RenderPlan::new() }
|
||||
}
|
||||
}
|
||||
impl Canvas for Capture {
|
||||
fn push_clip(&mut self, rect: Rect) {
|
||||
self.plan.push(RenderCmd::PushClip(rect));
|
||||
}
|
||||
fn pop_clip(&mut self) {
|
||||
self.plan.push(RenderCmd::PopClip);
|
||||
}
|
||||
fn fill_rect(&mut self, rect: Rect, color: Color) {
|
||||
self.plan.push(RenderCmd::FillRect { rect, color });
|
||||
}
|
||||
fn stroke_rect(&mut self, rect: Rect, stroke: StrokeStyle) {
|
||||
self.plan.push(RenderCmd::StrokeRect { rect, stroke });
|
||||
}
|
||||
fn stroke_line(&mut self, a: Point, b: Point, stroke: StrokeStyle) {
|
||||
self.plan.push(RenderCmd::StrokeLine { a, b, stroke });
|
||||
}
|
||||
fn stroke_polyline(&mut self, coords: &[f32], stroke: StrokeStyle) {
|
||||
self.plan.push(RenderCmd::StrokePolyline {
|
||||
coords: coords.to_vec(),
|
||||
stroke,
|
||||
});
|
||||
}
|
||||
fn fill_triangle_strip(&mut self, coords: &[f32], colors: &[Color]) {
|
||||
self.plan.push(RenderCmd::FillTriangleStrip {
|
||||
coords: coords.to_vec(),
|
||||
colors: colors.to_vec(),
|
||||
});
|
||||
}
|
||||
fn draw_text(&mut self, p: Point, text: &str, color: Color, size_px: f32) {
|
||||
self.plan.push(RenderCmd::DrawText {
|
||||
p,
|
||||
text: text.into(),
|
||||
color,
|
||||
size_px,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn line_series_pinta_polyline() -> (Capture, usize) {
|
||||
let mut buf = DataBuffer::with_capacity(5);
|
||||
for i in 0..5 {
|
||||
buf.push(i as f32, (i as f32).powi(2));
|
||||
}
|
||||
let series = LineSeries::new(&buf, StrokeStyle::new(2.0, Color::WHITE));
|
||||
let cs = CoordinateSystem::new(
|
||||
ChartViewport::new(0.0, 4.0, 0.0, 16.0),
|
||||
Rect::new(0.0, 0.0, 100.0, 100.0),
|
||||
);
|
||||
let mut scratch = Vec::new();
|
||||
let mut ctx = PaintCtx {
|
||||
cs,
|
||||
mode: RenderMode::UiRich,
|
||||
scratch: &mut scratch,
|
||||
};
|
||||
let mut cap = Capture::new();
|
||||
series.paint(&mut ctx, &mut cap);
|
||||
|
||||
let n = cap.plan.cmds.len();
|
||||
(cap, n)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_series_emite_un_solo_drawcall() {
|
||||
let (cap, n) = line_series_pinta_polyline();
|
||||
assert_eq!(n, 1, "una sola draw call (P3 del ARCHITECTURE.md)");
|
||||
match &cap.plan.cmds[0] {
|
||||
RenderCmd::StrokePolyline { coords, .. } => {
|
||||
assert_eq!(coords.len(), 10, "5 puntos × 2 = 10 floats");
|
||||
}
|
||||
other => panic!("se esperaba StrokePolyline, se vio {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lttb_se_dispara_con_alta_densidad() {
|
||||
// 10k puntos sobre plot de 50px → target = 150 → debe decimar
|
||||
let mut buf = DataBuffer::with_capacity(10_000);
|
||||
for i in 0..10_000 {
|
||||
buf.push(i as f32, (i as f32 * 0.01).sin());
|
||||
}
|
||||
let series = LineSeries::new(&buf, StrokeStyle::new(1.0, Color::WHITE));
|
||||
let cs = CoordinateSystem::new(
|
||||
ChartViewport::new(0.0, 9999.0, -1.0, 1.0),
|
||||
Rect::new(0.0, 0.0, 50.0, 100.0),
|
||||
);
|
||||
let mut scratch = Vec::new();
|
||||
let mut ctx = PaintCtx {
|
||||
cs,
|
||||
mode: RenderMode::HighDensity,
|
||||
scratch: &mut scratch,
|
||||
};
|
||||
let mut cap = Capture::new();
|
||||
series.paint(&mut ctx, &mut cap);
|
||||
|
||||
match &cap.plan.cmds[0] {
|
||||
RenderCmd::StrokePolyline { coords, .. } => {
|
||||
// Debe haber muchos menos que 10k puntos (target ≈ 150).
|
||||
assert!(coords.len() / 2 <= 160);
|
||||
assert!(coords.len() / 2 >= 100);
|
||||
}
|
||||
_ => panic!(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_series_vacio_no_emite() {
|
||||
let buf = DataBuffer::new();
|
||||
let series = LineSeries::new(&buf, StrokeStyle::new(1.0, Color::WHITE));
|
||||
let cs = CoordinateSystem::new(
|
||||
ChartViewport::new(0.0, 1.0, 0.0, 1.0),
|
||||
Rect::new(0.0, 0.0, 100.0, 100.0),
|
||||
);
|
||||
let mut scratch = Vec::new();
|
||||
let mut ctx = PaintCtx {
|
||||
cs,
|
||||
mode: RenderMode::UiRich,
|
||||
scratch: &mut scratch,
|
||||
};
|
||||
let mut cap = Capture::new();
|
||||
series.paint(&mut ctx, &mut cap);
|
||||
assert_eq!(cap.plan.cmds.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hit_test_acepta_punto_cercano() {
|
||||
let mut buf = DataBuffer::with_capacity(5);
|
||||
for i in 0..5 {
|
||||
buf.push(i as f32, (i as f32).powi(2));
|
||||
}
|
||||
let series = LineSeries::new(&buf, StrokeStyle::new(1.0, Color::WHITE));
|
||||
let cs = CoordinateSystem::new(
|
||||
ChartViewport::new(0.0, 4.0, 0.0, 16.0),
|
||||
Rect::new(0.0, 0.0, 100.0, 100.0),
|
||||
);
|
||||
// Punto (2,4) en data → ¿qué pixel? (2/4)·100=50, (1-4/16)·100=75
|
||||
let target = pineal_render::Point::new(50.0, 75.0);
|
||||
let hit = series.hit_test(target, &cs);
|
||||
assert_eq!(hit, Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hit_test_rechaza_punto_lejano() {
|
||||
let mut buf = DataBuffer::with_capacity(2);
|
||||
buf.push(0.0, 0.0);
|
||||
buf.push(1.0, 1.0);
|
||||
let series = LineSeries::new(&buf, StrokeStyle::new(1.0, Color::WHITE));
|
||||
let cs = CoordinateSystem::new(
|
||||
ChartViewport::new(0.0, 1.0, 0.0, 1.0),
|
||||
Rect::new(0.0, 0.0, 100.0, 100.0),
|
||||
);
|
||||
// Pixel muy lejos de la línea.
|
||||
let far = pineal_render::Point::new(50.0, 99.0);
|
||||
assert!(series.hit_test(far, &cs).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
//! `ChartViewport` — ventana visible en el dominio de datos.
|
||||
//!
|
||||
//! El viewport NO conoce pixeles. Sólo describe qué rango de
|
||||
//! valores X/Y es visible. La proyección a píxeles la hace
|
||||
//! [`crate::coord_system::CoordinateSystem`] cuando le pasás
|
||||
//! el `plot_rect`.
|
||||
//!
|
||||
//! Pan y zoom mutan el viewport, no los datos. Esto preserva el
|
||||
//! P2 zero-alloc: los buffers de DataBuffer / RingBuffer se quedan
|
||||
//! quietos; sólo cambian cuatro `f64` en el viewport.
|
||||
|
||||
use pineal_render::Rect;
|
||||
|
||||
/// Rango visible en coordenadas de dominio. `f64` porque ejes
|
||||
/// temporales con epoch ms se desbordan en `f32`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct ChartViewport {
|
||||
pub x_min: f64,
|
||||
pub x_max: f64,
|
||||
pub y_min: f64,
|
||||
pub y_max: f64,
|
||||
}
|
||||
|
||||
impl ChartViewport {
|
||||
pub fn new(x_min: f64, x_max: f64, y_min: f64, y_max: f64) -> Self {
|
||||
debug_assert!(x_max > x_min && y_max > y_min);
|
||||
Self { x_min, x_max, y_min, y_max }
|
||||
}
|
||||
|
||||
pub fn x_span(&self) -> f64 {
|
||||
self.x_max - self.x_min
|
||||
}
|
||||
pub fn y_span(&self) -> f64 {
|
||||
self.y_max - self.y_min
|
||||
}
|
||||
|
||||
/// Pan en unidades de **dominio**. Suma dx y dy a ambos
|
||||
/// extremos del rango respectivo.
|
||||
pub fn pan(&mut self, dx: f64, dy: f64) {
|
||||
self.x_min += dx;
|
||||
self.x_max += dx;
|
||||
self.y_min += dy;
|
||||
self.y_max += dy;
|
||||
}
|
||||
|
||||
/// Pan en **píxeles** dado el `plot_rect`. Convierte dx_px →
|
||||
/// unidades de dominio usando el span actual / ancho del plot.
|
||||
///
|
||||
/// Convención de signos: `dx_px > 0` significa "el mouse se
|
||||
/// movió a la derecha", que arrastra el viewport a la
|
||||
/// **izquierda** (los datos parecen ir hacia la derecha).
|
||||
pub fn pan_pixels(&mut self, dx_px: f32, dy_px: f32, plot: Rect) {
|
||||
let dx = -(dx_px as f64) * self.x_span() / plot.w as f64;
|
||||
// En la convención canvas (+Y hacia abajo) pero queremos
|
||||
// que arrastrar para arriba muestre valores más altos,
|
||||
// así que también invertimos Y.
|
||||
let dy = (dy_px as f64) * self.y_span() / plot.h as f64;
|
||||
self.pan(dx, dy);
|
||||
}
|
||||
|
||||
/// Pan en **fracción del viewport**. `fx = 0.5` arrastra medio
|
||||
/// span hacia la izquierda. Útil cuando el caller no conoce el
|
||||
/// `plot_rect` exacto y trabaja con coords normalizadas
|
||||
/// (drag dividido por el ancho de la window).
|
||||
pub fn pan_fraction(&mut self, fx: f64, fy: f64) {
|
||||
self.pan(-fx * self.x_span(), fy * self.y_span());
|
||||
}
|
||||
|
||||
/// Zoom anchor-preserving (sección 5.3 del ARCHITECTURE.md).
|
||||
/// `anchor_norm` es la posición del ancla **normalizada al
|
||||
/// viewport** en `[0, 1]` por eje (típicamente: la posición
|
||||
/// del mouse dentro del plot_rect, normalizada).
|
||||
///
|
||||
/// `factor > 1` aleja (zoom out), `< 1` acerca (zoom in).
|
||||
pub fn zoom_at(&mut self, factor_x: f64, factor_y: f64, anchor_norm: (f64, f64)) {
|
||||
let (ax, ay) = anchor_norm;
|
||||
let anchor_x = self.x_min + ax * self.x_span();
|
||||
let anchor_y = self.y_min + ay * self.y_span();
|
||||
let new_xspan = self.x_span() * factor_x;
|
||||
let new_yspan = self.y_span() * factor_y;
|
||||
self.x_min = anchor_x - ax * new_xspan;
|
||||
self.x_max = self.x_min + new_xspan;
|
||||
self.y_min = anchor_y - ay * new_yspan;
|
||||
self.y_max = self.y_min + new_yspan;
|
||||
}
|
||||
|
||||
/// Zoom uniforme con el mismo factor en X e Y.
|
||||
pub fn zoom_uniform(&mut self, factor: f64, anchor_norm: (f64, f64)) {
|
||||
self.zoom_at(factor, factor, anchor_norm);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn pan_no_cambia_span() {
|
||||
let mut v = ChartViewport::new(0.0, 10.0, -1.0, 1.0);
|
||||
v.pan(2.0, 0.5);
|
||||
assert!((v.x_min - 2.0).abs() < 1e-9);
|
||||
assert!((v.x_max - 12.0).abs() < 1e-9);
|
||||
assert!((v.x_span() - 10.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_in_preserva_anchor() {
|
||||
// Zoom in 2× con anchor en el centro: el valor que estaba
|
||||
// en el centro sigue en el centro.
|
||||
let mut v = ChartViewport::new(0.0, 10.0, 0.0, 10.0);
|
||||
v.zoom_uniform(0.5, (0.5, 0.5));
|
||||
let new_center_x = v.x_min + v.x_span() * 0.5;
|
||||
let new_center_y = v.y_min + v.y_span() * 0.5;
|
||||
assert!((new_center_x - 5.0).abs() < 1e-9);
|
||||
assert!((new_center_y - 5.0).abs() < 1e-9);
|
||||
assert!((v.x_span() - 5.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_anchor_esquina() {
|
||||
// Anchor en (0,0): la esquina inferior-izquierda no se mueve.
|
||||
let mut v = ChartViewport::new(0.0, 10.0, 0.0, 10.0);
|
||||
v.zoom_uniform(0.5, (0.0, 0.0));
|
||||
assert!((v.x_min - 0.0).abs() < 1e-9);
|
||||
assert!((v.y_min - 0.0).abs() < 1e-9);
|
||||
assert!((v.x_span() - 5.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pan_pixels_invertido() {
|
||||
// Plot de 100px ancho, span de dominio 10. Arrastrar 50px
|
||||
// a la derecha = pan dominio -5.
|
||||
let mut v = ChartViewport::new(0.0, 10.0, 0.0, 10.0);
|
||||
v.pan_pixels(50.0, 0.0, Rect::new(0.0, 0.0, 100.0, 100.0));
|
||||
assert!((v.x_min - (-5.0)).abs() < 1e-9);
|
||||
assert!((v.x_max - 5.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pan_fraction_es_independiente_de_plot() {
|
||||
let mut v = ChartViewport::new(0.0, 10.0, 0.0, 10.0);
|
||||
// 50% del span hacia la derecha = viewport se mueve -5 en X.
|
||||
v.pan_fraction(0.5, 0.0);
|
||||
assert!((v.x_min - (-5.0)).abs() < 1e-9);
|
||||
assert!((v.x_max - 5.0).abs() < 1e-9);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user