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,35 @@
|
||||
# modules/pineal/ — Data-viz agnóstica (era lapaloma)
|
||||
|
||||
**Propósito.** Motor de gráficos con backends pluggables. Los crates
|
||||
de visualización no conocen `gpui` ni `wgpu`: hablan contra el trait
|
||||
`Canvas`, y producen un `RenderPlan` que cada backend ejecuta.
|
||||
|
||||
## Crates
|
||||
|
||||
| crate | tipo | rol |
|
||||
| ------------------ | ---- | ---------------------------------------------------- |
|
||||
| `pineal-core` | lib | Datos: Series, Point, Rect, Color, RingBuffer |
|
||||
| `pineal-render` | lib | Trait `Canvas` + `RenderPlan` + backend GPUI (feat) |
|
||||
| `pineal-cartesian` | lib | Axes + viewport + series sobre coord cartesiano |
|
||||
| `pineal-financial` | lib | Candlestick, OHLC sobre cartesian |
|
||||
| `pineal-stream` | lib | Streaming a 60Hz desde RingBuffer |
|
||||
| `pineal-phosphor` | lib | Trail CRT con alpha decay sobre stream |
|
||||
| `pineal-polar` | lib | Coord polar (stub) |
|
||||
| `pineal-heatmap` | lib | Grid 2D color-mapped (stub) |
|
||||
| `pineal-treemap` | lib | Rectangular treemap (stub) |
|
||||
| `pineal-flow` | lib | Sankey/flow diagrams (stub) |
|
||||
| `pineal-mesh` | lib | Triangle mesh + barycentric (stub) |
|
||||
| `pineal-export` | lib | SVG/PNG export del RenderPlan |
|
||||
| `pineal-umbrella` | lib | Re-export con features `core/render/cartesian/...` |
|
||||
|
||||
## Dependencias
|
||||
|
||||
- Todos ← `pineal-core`. Render-dependientes ← `pineal-render`.
|
||||
- Backend GPUI: feature gate `gpui` en pineal-render activa
|
||||
`gpui_backend.rs` (consumido por widgets en `apps/pineal-*-demo`).
|
||||
|
||||
## Estado
|
||||
|
||||
LOC ~3,900. 5 charts implementados (cartesian/financial/stream/phosphor
|
||||
/export). 5 stubs (<50 LOC c/u: polar, heatmap, treemap, flow, mesh).
|
||||
Roadmap: completar los 5 stubs en orden polar → heatmap → treemap.
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "pineal-core"
|
||||
version = { workspace = true }
|
||||
edition = { workspace = true }
|
||||
license = { workspace = true }
|
||||
authors = { workspace = true }
|
||||
publish = { workspace = true }
|
||||
description = "Lapaloma — primitivas agnósticas: DataBuffer interleaved, RingBuffer streaming, SpatialIndex, LTTB, escalas. Cero gpui, cero alloc en hot path."
|
||||
|
||||
[dependencies]
|
||||
|
||||
[dev-dependencies]
|
||||
@@ -0,0 +1,128 @@
|
||||
//! `DataBuffer` — buffer interleaved `[x0, y0, x1, y1, ...]` con
|
||||
//! revision counter para invalidación de cachés.
|
||||
//!
|
||||
//! Es la primitiva universal de Lapaloma: todo serie cartesiana,
|
||||
//! todo grafo de nodos, todo OHLC vive en uno de estos (o en una
|
||||
//! variante con stride distinto). El layout `f32` x `f32` es lo
|
||||
//! que el GPU consume sin transformación.
|
||||
|
||||
/// Buffer de coordenadas planas `[x, y]` empacadas.
|
||||
///
|
||||
/// La longitud lógica (número de puntos) es `coords.len() / 2`.
|
||||
/// Mutar in-place (`set_xy`, `push`) bumpea `revision` — los
|
||||
/// painters comparan su `last_seen_revision` para decidir si
|
||||
/// rebuilear su caché.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct DataBuffer {
|
||||
coords: Vec<f32>,
|
||||
revision: u64,
|
||||
}
|
||||
|
||||
impl DataBuffer {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Reserva espacio para `n` puntos sin agregarlos. Usalo al
|
||||
/// montar el widget para que `push` no realloque después.
|
||||
pub fn with_capacity(n: usize) -> Self {
|
||||
Self {
|
||||
coords: Vec::with_capacity(n * 2),
|
||||
revision: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Construye a partir de coords interleaved ya armadas.
|
||||
/// Útil en tests y carga inicial.
|
||||
pub fn from_interleaved(coords: Vec<f32>) -> Self {
|
||||
assert!(coords.len() % 2 == 0, "interleaved coords deben ser pares");
|
||||
Self {
|
||||
coords,
|
||||
revision: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push(&mut self, x: f32, y: f32) {
|
||||
self.coords.push(x);
|
||||
self.coords.push(y);
|
||||
self.revision = self.revision.wrapping_add(1);
|
||||
}
|
||||
|
||||
/// Sobrescribe un punto existente. `i` es el índice de punto
|
||||
/// (no de float), 0-based.
|
||||
pub fn set_xy(&mut self, i: usize, x: f32, y: f32) {
|
||||
self.coords[i * 2] = x;
|
||||
self.coords[i * 2 + 1] = y;
|
||||
self.revision = self.revision.wrapping_add(1);
|
||||
}
|
||||
|
||||
/// Pisa el contenido completo con la nueva slice.
|
||||
/// Útil para hidratar el buffer en un solo memcpy.
|
||||
pub fn replace_from(&mut self, src: &[f32]) {
|
||||
assert!(src.len() % 2 == 0);
|
||||
self.coords.clear();
|
||||
self.coords.extend_from_slice(src);
|
||||
self.revision = self.revision.wrapping_add(1);
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.coords.clear();
|
||||
self.revision = self.revision.wrapping_add(1);
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.coords.len() / 2
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.coords.is_empty()
|
||||
}
|
||||
|
||||
pub fn xy(&self, i: usize) -> (f32, f32) {
|
||||
(self.coords[i * 2], self.coords[i * 2 + 1])
|
||||
}
|
||||
|
||||
/// Slice plana lista para `drawRawPoints` / `wgpu::Buffer`
|
||||
/// / `<polyline points>`. No realiza copia.
|
||||
pub fn coords(&self) -> &[f32] {
|
||||
&self.coords
|
||||
}
|
||||
|
||||
pub fn revision(&self) -> u64 {
|
||||
self.revision
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn push_y_len() {
|
||||
let mut b = DataBuffer::with_capacity(4);
|
||||
b.push(0.0, 1.0);
|
||||
b.push(1.0, 2.0);
|
||||
assert_eq!(b.len(), 2);
|
||||
assert_eq!(b.xy(1), (1.0, 2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revision_bumps() {
|
||||
let mut b = DataBuffer::new();
|
||||
let r0 = b.revision();
|
||||
b.push(0.0, 0.0);
|
||||
let r1 = b.revision();
|
||||
b.set_xy(0, 1.0, 1.0);
|
||||
let r2 = b.revision();
|
||||
assert_ne!(r0, r1);
|
||||
assert_ne!(r1, r2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coords_slice_is_zero_copy() {
|
||||
let raw = vec![0.0, 0.0, 1.0, 1.0, 2.0, 2.0];
|
||||
let b = DataBuffer::from_interleaved(raw);
|
||||
assert_eq!(b.coords(), &[0.0, 0.0, 1.0, 1.0, 2.0, 2.0]);
|
||||
assert_eq!(b.len(), 3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
//! `pineal-core` — primitivas agnósticas de Lapaloma.
|
||||
//!
|
||||
//! Cero `gpui`, cero `wgpu`, cero I/O. Todo lo que vive acá puede
|
||||
//! correr en un test unitario, en un worker thread o en un export
|
||||
//! a SVG. Las tres reglas del documento de arquitectura aplican:
|
||||
//!
|
||||
//! - **P1 Zero boxing.** Los datos viven en `Vec<f32>` planos
|
||||
//! indexados, nunca como `Vec<Point2D>`. Cache L1 caliente y el
|
||||
//! compilador puede SIMD-loopearlo.
|
||||
//! - **P2 Zero alloc en hot path.** Buffers se reservan al construir,
|
||||
//! se mutan in-place para siempre. Helpers escriben a `&mut Vec`
|
||||
//! provistos por el caller, no devuelven `Vec` nuevos.
|
||||
//! - **P3 Una draw call por capa.** Acá no se dibuja; pero los
|
||||
//! tipos exponen slices contiguos listos para mandar al GPU
|
||||
//! sin copia.
|
||||
//!
|
||||
//! Convención de coordenadas: el buffer canónico es interleaved
|
||||
//! `[x0, y0, x1, y1, ...]`. Esto es el formato que `drawRawPoints`,
|
||||
//! `Vertices.raw`, `wgpu` vertex buffers y `<polyline points>` SVG
|
||||
//! consumen sin transformación.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod buffer;
|
||||
pub mod ring;
|
||||
pub mod spatial;
|
||||
pub mod lttb;
|
||||
pub mod scale;
|
||||
|
||||
// Algoritmos de layout — quedan como placeholders hasta que cada
|
||||
// módulo de visualización (mesh, treemap, flow) los demande.
|
||||
|
||||
/// Barnes-Hut quadtree para layouts force-directed.
|
||||
///
|
||||
/// Cuando se implemente: el quadtree es un `Vec<f32>` plano de
|
||||
/// stride 7 (cm_x, cm_y, mass, half_size, center_x, center_y,
|
||||
/// child_base), no un árbol de objetos. Rebuild O(n) por frame
|
||||
/// sin allocations.
|
||||
pub mod barnes_hut {}
|
||||
|
||||
/// Sugiyama-lite jerárquico: cycle-removal por DFS + Kahn layering
|
||||
/// + barycenter ordering con inversion-count crossings.
|
||||
pub mod sugiyama {}
|
||||
|
||||
/// Squarified treemap (Bruls / d3-hierarchy). Worst-aspect formula
|
||||
/// usa el lado *corto* del rectángulo restante.
|
||||
pub mod squarify {}
|
||||
|
||||
/// Subtree-width tree layout: BFS spanning + bottom-up width
|
||||
/// measurement + top-down placement. Simpler que Reingold-Tilford.
|
||||
pub mod tree_layout {}
|
||||
|
||||
/// Force-Directed Edge Bundling (FDEB-lite, single quadratic-bezier
|
||||
/// control point por edge).
|
||||
pub mod fdeb {}
|
||||
@@ -0,0 +1,177 @@
|
||||
//! LTTB (Largest-Triangle-Three-Buckets) — downsampling preservador
|
||||
//! de silueta para series cartesianas.
|
||||
//!
|
||||
//! Algoritmo: dividir `n` puntos en `k-2` buckets (los extremos se
|
||||
//! mantienen siempre). Por cada bucket, elegir el punto que forma
|
||||
//! el triángulo de área máxima con el último punto elegido y el
|
||||
//! centroide del bucket siguiente. Costo total O(n). Output ≤ k.
|
||||
//!
|
||||
//! Knob práctico: `target ≈ width_px × 3`. Tres vértices por pixel,
|
||||
//! el anti-aliasing rellena el resto.
|
||||
|
||||
/// Reduce `coords` (interleaved `[x,y,x,y,…]`) a a lo sumo `target`
|
||||
/// puntos, escribiendo los **índices originales** seleccionados en
|
||||
/// `out` (sin clearearlo: el caller decide).
|
||||
///
|
||||
/// Si `n <= target` o `target < 3`, devuelve todos los índices
|
||||
/// `[0..n)`.
|
||||
pub fn lttb_indices(coords: &[f32], target: usize, out: &mut Vec<usize>) {
|
||||
let n = coords.len() / 2;
|
||||
if n == 0 {
|
||||
return;
|
||||
}
|
||||
if n <= target || target < 3 {
|
||||
out.extend(0..n);
|
||||
return;
|
||||
}
|
||||
lttb_in_range_indices(coords, 0, n, target, out);
|
||||
}
|
||||
|
||||
/// Variante que opera sobre el rango `[start, end)` de un buffer
|
||||
/// más grande. Los índices devueltos son **absolutos** (relativos
|
||||
/// al `coords` original), no al sub-rango — esto le ahorra al caller
|
||||
/// la corrección de offset después de un `SpatialIndex::range`.
|
||||
pub fn lttb_in_range_indices(
|
||||
coords: &[f32],
|
||||
start: usize,
|
||||
end: usize,
|
||||
target: usize,
|
||||
out: &mut Vec<usize>,
|
||||
) {
|
||||
debug_assert!(coords.len() % 2 == 0);
|
||||
debug_assert!(start <= end && end <= coords.len() / 2);
|
||||
|
||||
let len = end - start;
|
||||
if len == 0 {
|
||||
return;
|
||||
}
|
||||
if len <= target || target < 3 {
|
||||
out.extend(start..end);
|
||||
return;
|
||||
}
|
||||
|
||||
// Primero el extremo izquierdo.
|
||||
out.push(start);
|
||||
|
||||
let bucket_size = (len - 2) as f64 / (target - 2) as f64;
|
||||
let mut a = start; // último punto elegido
|
||||
|
||||
for i in 0..target - 2 {
|
||||
// Bucket actual y siguiente, en índices absolutos.
|
||||
let cur_lo = start + 1 + (i as f64 * bucket_size).floor() as usize;
|
||||
let cur_hi = start + 1 + ((i + 1) as f64 * bucket_size).floor() as usize;
|
||||
let next_lo = cur_hi.min(end);
|
||||
let next_hi = (start + 1 + ((i + 2) as f64 * bucket_size).floor() as usize).min(end);
|
||||
|
||||
// Centroide del bucket siguiente. Si está vacío, fallback
|
||||
// al último punto.
|
||||
let (avg_x, avg_y) = if next_hi > next_lo {
|
||||
let span = (next_hi - next_lo) as f32;
|
||||
let mut sx = 0.0f32;
|
||||
let mut sy = 0.0f32;
|
||||
for j in next_lo..next_hi {
|
||||
sx += coords[j * 2];
|
||||
sy += coords[j * 2 + 1];
|
||||
}
|
||||
(sx / span, sy / span)
|
||||
} else {
|
||||
(coords[(end - 1) * 2], coords[(end - 1) * 2 + 1])
|
||||
};
|
||||
|
||||
let ax = coords[a * 2];
|
||||
let ay = coords[a * 2 + 1];
|
||||
|
||||
let mut max_area = -1.0f32;
|
||||
let mut max_idx = cur_lo;
|
||||
for j in cur_lo..cur_hi.min(end) {
|
||||
let bx = coords[j * 2];
|
||||
let by = coords[j * 2 + 1];
|
||||
// Área del triángulo (sin /2 porque comparamos relativos).
|
||||
let area = ((ax - avg_x) * (by - ay) - (ax - bx) * (avg_y - ay)).abs();
|
||||
if area > max_area {
|
||||
max_area = area;
|
||||
max_idx = j;
|
||||
}
|
||||
}
|
||||
out.push(max_idx);
|
||||
a = max_idx;
|
||||
}
|
||||
|
||||
// Extremo derecho.
|
||||
out.push(end - 1);
|
||||
}
|
||||
|
||||
/// Variante que materializa coords decimadas directamente — útil
|
||||
/// cuando el painter sólo quiere un slice listo para `drawRawPoints`
|
||||
/// y no necesita los índices.
|
||||
pub fn lttb_coords(coords: &[f32], target: usize, out: &mut Vec<f32>) {
|
||||
let mut idx_buf: Vec<usize> = Vec::with_capacity(target);
|
||||
lttb_indices(coords, target, &mut idx_buf);
|
||||
for i in idx_buf {
|
||||
out.push(coords[i * 2]);
|
||||
out.push(coords[i * 2 + 1]);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn no_decimate_si_n_menor_que_target() {
|
||||
let coords: Vec<f32> = (0..5).flat_map(|i| [i as f32, (i * i) as f32]).collect();
|
||||
let mut out = Vec::new();
|
||||
lttb_indices(&coords, 10, &mut out);
|
||||
assert_eq!(out, vec![0, 1, 2, 3, 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extremos_preservados() {
|
||||
let n = 100;
|
||||
let coords: Vec<f32> = (0..n).flat_map(|i| [i as f32, (i as f32).sin()]).collect();
|
||||
let mut out = Vec::new();
|
||||
lttb_indices(&coords, 10, &mut out);
|
||||
assert_eq!(out.first(), Some(&0));
|
||||
assert_eq!(out.last(), Some(&(n - 1)));
|
||||
assert!(out.len() <= 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indices_sorted_y_unicos() {
|
||||
let coords: Vec<f32> = (0..1000)
|
||||
.flat_map(|i| [i as f32, (i as f32 * 0.01).sin()])
|
||||
.collect();
|
||||
let mut out = Vec::new();
|
||||
lttb_indices(&coords, 50, &mut out);
|
||||
for w in out.windows(2) {
|
||||
assert!(w[0] < w[1], "indices deben ser estrictamente crecientes");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_range_indices_son_absolutos() {
|
||||
let n = 100;
|
||||
let coords: Vec<f32> = (0..n).flat_map(|i| [i as f32, i as f32]).collect();
|
||||
let mut out = Vec::new();
|
||||
lttb_in_range_indices(&coords, 20, 80, 10, &mut out);
|
||||
assert_eq!(out.first(), Some(&20));
|
||||
assert_eq!(out.last(), Some(&79));
|
||||
// ningún índice fuera del rango pedido
|
||||
for &i in &out {
|
||||
assert!(i >= 20 && i < 80);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserva_picos_extremos() {
|
||||
// Señal plana con un pico al medio: LTTB debe agarrar el pico.
|
||||
let mut coords: Vec<f32> = Vec::new();
|
||||
for i in 0..200 {
|
||||
coords.push(i as f32);
|
||||
coords.push(if i == 100 { 10.0 } else { 0.0 });
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
lttb_indices(&coords, 20, &mut out);
|
||||
assert!(out.contains(&100), "pico debe sobrevivir el downsample");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
//! `RingBuffer` — buffer circular de samples para streaming tipo
|
||||
//! osciloscopio.
|
||||
//!
|
||||
//! Capacidad fija. `push(v)` hace dos writes (uno a `values`, uno
|
||||
//! a `coords[head*2+1]`) y un increment de head + revision. El
|
||||
//! buffer **nunca se reasigna**; el painter consume slices del
|
||||
//! mismo backing memory frame tras frame.
|
||||
//!
|
||||
//! Convención: `x_norm` se pre-computa una vez en construcción
|
||||
//! (modo sweep). El painter aplica el escalado a píxeles via su
|
||||
//! propio transform — el buffer no rota X entre frames.
|
||||
//!
|
||||
//! ## Trampa del pre-fill (1.0.2 fix del Flutter)
|
||||
//!
|
||||
//! Antes que `count >= capacity`, los slots `[head, capacity)`
|
||||
//! contienen ceros iniciales. Si el painter dibuja toda la
|
||||
//! ringa, aparece una línea plana sobre la mitad derecha. La
|
||||
//! API expone [`RingBuffer::filled_len`] que devuelve `head` en
|
||||
//! ese caso, y `capacity` después — el painter clipea a eso.
|
||||
|
||||
/// Ring buffer en modo sweep (x_norm de cada slot es fijo).
|
||||
///
|
||||
/// Para modo scroll el painter aplica un translate adicional por
|
||||
/// frame; la estructura de datos es la misma.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RingBuffer {
|
||||
/// Sample raw por slot.
|
||||
values: Vec<f32>,
|
||||
/// `[x_norm, y_value]` por slot. `x_norm = slot / (cap - 1)`,
|
||||
/// fijo. `y_value` = `values[slot]`.
|
||||
coords: Vec<f32>,
|
||||
capacity: usize,
|
||||
/// Próximo slot a escribir.
|
||||
head: usize,
|
||||
/// Monotonic, sobrevive wraparound. Útil para anclar
|
||||
/// anotaciones por sample index absoluto.
|
||||
count: u64,
|
||||
revision: u64,
|
||||
}
|
||||
|
||||
impl RingBuffer {
|
||||
/// Asume `capacity >= 2` para que `x_norm` no divida por cero.
|
||||
pub fn new(capacity: usize) -> Self {
|
||||
assert!(capacity >= 2, "RingBuffer requiere capacity >= 2");
|
||||
let mut coords = vec![0.0; capacity * 2];
|
||||
let denom = (capacity - 1) as f32;
|
||||
for slot in 0..capacity {
|
||||
coords[slot * 2] = slot as f32 / denom;
|
||||
}
|
||||
Self {
|
||||
values: vec![0.0; capacity],
|
||||
coords,
|
||||
capacity,
|
||||
head: 0,
|
||||
count: 0,
|
||||
revision: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push(&mut self, v: f32) {
|
||||
self.values[self.head] = v;
|
||||
self.coords[self.head * 2 + 1] = v;
|
||||
self.head = (self.head + 1) % self.capacity;
|
||||
self.count = self.count.wrapping_add(1);
|
||||
self.revision = self.revision.wrapping_add(1);
|
||||
}
|
||||
|
||||
/// Inserción en batch con dos memcpys (cola + wrap-around).
|
||||
/// Para batches > capacity se queda con los últimos `capacity`
|
||||
/// samples (los anteriores se sobreescribirían igual).
|
||||
pub fn push_all(&mut self, batch: &[f32]) {
|
||||
if batch.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let cap = self.capacity;
|
||||
let src = if batch.len() > cap {
|
||||
&batch[batch.len() - cap..]
|
||||
} else {
|
||||
batch
|
||||
};
|
||||
|
||||
let tail = cap - self.head;
|
||||
if src.len() <= tail {
|
||||
self.values[self.head..self.head + src.len()].copy_from_slice(src);
|
||||
for (i, v) in src.iter().enumerate() {
|
||||
self.coords[(self.head + i) * 2 + 1] = *v;
|
||||
}
|
||||
self.head = (self.head + src.len()) % cap;
|
||||
} else {
|
||||
let (a, b) = src.split_at(tail);
|
||||
self.values[self.head..].copy_from_slice(a);
|
||||
for (i, v) in a.iter().enumerate() {
|
||||
self.coords[(self.head + i) * 2 + 1] = *v;
|
||||
}
|
||||
self.values[..b.len()].copy_from_slice(b);
|
||||
for (i, v) in b.iter().enumerate() {
|
||||
self.coords[i * 2 + 1] = *v;
|
||||
}
|
||||
self.head = b.len();
|
||||
}
|
||||
|
||||
self.count = self.count.wrapping_add(src.len() as u64);
|
||||
self.revision = self.revision.wrapping_add(1);
|
||||
}
|
||||
|
||||
pub fn capacity(&self) -> usize {
|
||||
self.capacity
|
||||
}
|
||||
|
||||
pub fn head(&self) -> usize {
|
||||
self.head
|
||||
}
|
||||
|
||||
pub fn count(&self) -> u64 {
|
||||
self.count
|
||||
}
|
||||
|
||||
pub fn revision(&self) -> u64 {
|
||||
self.revision
|
||||
}
|
||||
|
||||
pub fn is_full(&self) -> bool {
|
||||
self.count >= self.capacity as u64
|
||||
}
|
||||
|
||||
/// Cantidad de slots con datos reales. Antes del fill es
|
||||
/// `head`; después es `capacity`. El painter clipea a este
|
||||
/// valor para evitar el flicker del pre-fill.
|
||||
pub fn filled_len(&self) -> usize {
|
||||
if self.is_full() {
|
||||
self.capacity
|
||||
} else {
|
||||
self.head
|
||||
}
|
||||
}
|
||||
|
||||
/// Slice interleaved de `[x_norm, y]`. Para render en dos
|
||||
/// segmentos: `&coords()[..head*2]` y `&coords()[head*2..]`
|
||||
/// (cuando is_full).
|
||||
pub fn coords(&self) -> &[f32] {
|
||||
&self.coords
|
||||
}
|
||||
|
||||
/// Slice plana de samples raw — útil para downsample envelope
|
||||
/// min/max sin pasar por coords.
|
||||
pub fn values(&self) -> &[f32] {
|
||||
&self.values
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn x_norm_precomputado() {
|
||||
let r = RingBuffer::new(4);
|
||||
// x_norm en slots 0, 1, 2, 3 = 0.0, 1/3, 2/3, 1.0
|
||||
assert!((r.coords()[0] - 0.0).abs() < 1e-6);
|
||||
assert!((r.coords()[2] - 1.0 / 3.0).abs() < 1e-6);
|
||||
assert!((r.coords()[6] - 1.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_actualiza_y_no_x() {
|
||||
let mut r = RingBuffer::new(4);
|
||||
r.push(5.0);
|
||||
r.push(7.0);
|
||||
// slot 0 → y=5, slot 1 → y=7, x quedó igual
|
||||
assert_eq!(r.coords()[1], 5.0);
|
||||
assert_eq!(r.coords()[3], 7.0);
|
||||
assert!((r.coords()[2] - 1.0 / 3.0).abs() < 1e-6);
|
||||
assert_eq!(r.head(), 2);
|
||||
assert_eq!(r.count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filled_len_bloquea_prefill() {
|
||||
let mut r = RingBuffer::new(4);
|
||||
assert_eq!(r.filled_len(), 0);
|
||||
r.push(1.0);
|
||||
r.push(2.0);
|
||||
assert_eq!(r.filled_len(), 2);
|
||||
r.push(3.0);
|
||||
r.push(4.0);
|
||||
assert_eq!(r.filled_len(), 4);
|
||||
r.push(5.0); // wrap
|
||||
assert_eq!(r.filled_len(), 4);
|
||||
assert!(r.is_full());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_all_wrap_around() {
|
||||
let mut r = RingBuffer::new(4);
|
||||
r.push_all(&[1.0, 2.0, 3.0]); // head=3
|
||||
r.push_all(&[4.0, 5.0, 6.0]); // wrap: 4 en slot 3, 5 en slot 0, 6 en slot 1
|
||||
assert_eq!(r.values()[3], 4.0);
|
||||
assert_eq!(r.values()[0], 5.0);
|
||||
assert_eq!(r.values()[1], 6.0);
|
||||
assert_eq!(r.head(), 2);
|
||||
assert_eq!(r.count(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_all_oversized_se_queda_con_la_cola() {
|
||||
let mut r = RingBuffer::new(4);
|
||||
r.push_all(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0]);
|
||||
// Sólo los últimos 4 importan: [6,7,8,9]
|
||||
assert_eq!(r.values()[0], 6.0);
|
||||
assert_eq!(r.values()[1], 7.0);
|
||||
assert_eq!(r.values()[2], 8.0);
|
||||
assert_eq!(r.values()[3], 9.0);
|
||||
assert!(r.is_full());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
//! Escalas value→pixel para series cartesianas.
|
||||
//!
|
||||
//! La proyección no se aplica sobre los datos (eso rompería el
|
||||
//! P2 zero-alloc — habría que reescribir todo el buffer por frame).
|
||||
//! Las escalas devuelven el `(scale_x, scale_y, translate_x,
|
||||
//! translate_y)` que el painter mete en un transform GPU. Los
|
||||
//! datos quedan intactos.
|
||||
|
||||
/// Trait común a Linear / Log / Time. Cada implementación traduce
|
||||
/// un valor de dominio a posición normalizada `[0, 1]` (que luego
|
||||
/// el painter mapea al pixel range del plot).
|
||||
pub trait Scale {
|
||||
fn to_norm(&self, value: f64) -> f64;
|
||||
fn from_norm(&self, norm: f64) -> f64;
|
||||
fn domain(&self) -> (f64, f64);
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct LinearScale {
|
||||
min: f64,
|
||||
max: f64,
|
||||
}
|
||||
|
||||
impl LinearScale {
|
||||
pub fn new(min: f64, max: f64) -> Self {
|
||||
debug_assert!(max > min, "LinearScale: max debe ser > min");
|
||||
Self { min, max }
|
||||
}
|
||||
}
|
||||
|
||||
impl Scale for LinearScale {
|
||||
fn to_norm(&self, v: f64) -> f64 {
|
||||
(v - self.min) / (self.max - self.min)
|
||||
}
|
||||
fn from_norm(&self, n: f64) -> f64 {
|
||||
self.min + n * (self.max - self.min)
|
||||
}
|
||||
fn domain(&self) -> (f64, f64) {
|
||||
(self.min, self.max)
|
||||
}
|
||||
}
|
||||
|
||||
/// Escala logarítmica base e. `min` y `max` deben ser positivos.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct LogScale {
|
||||
log_min: f64,
|
||||
log_max: f64,
|
||||
min: f64,
|
||||
max: f64,
|
||||
}
|
||||
|
||||
impl LogScale {
|
||||
pub fn new(min: f64, max: f64) -> Self {
|
||||
debug_assert!(min > 0.0 && max > min, "LogScale: 0 < min < max");
|
||||
Self {
|
||||
log_min: min.ln(),
|
||||
log_max: max.ln(),
|
||||
min,
|
||||
max,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Scale for LogScale {
|
||||
fn to_norm(&self, v: f64) -> f64 {
|
||||
(v.ln() - self.log_min) / (self.log_max - self.log_min)
|
||||
}
|
||||
fn from_norm(&self, n: f64) -> f64 {
|
||||
(self.log_min + n * (self.log_max - self.log_min)).exp()
|
||||
}
|
||||
fn domain(&self) -> (f64, f64) {
|
||||
(self.min, self.max)
|
||||
}
|
||||
}
|
||||
|
||||
/// Escala temporal sobre epoch ms. Internamente lineal.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct TimeScale {
|
||||
inner: LinearScale,
|
||||
}
|
||||
|
||||
impl TimeScale {
|
||||
pub fn new(min_epoch_ms: f64, max_epoch_ms: f64) -> Self {
|
||||
Self {
|
||||
inner: LinearScale::new(min_epoch_ms, max_epoch_ms),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Scale for TimeScale {
|
||||
fn to_norm(&self, v: f64) -> f64 {
|
||||
self.inner.to_norm(v)
|
||||
}
|
||||
fn from_norm(&self, n: f64) -> f64 {
|
||||
self.inner.from_norm(n)
|
||||
}
|
||||
fn domain(&self) -> (f64, f64) {
|
||||
self.inner.domain()
|
||||
}
|
||||
}
|
||||
|
||||
/// Wilkinson "nice numbers" — devuelve el step ideal en `{1, 2, 5} × 10^k`
|
||||
/// para que un rango `[min, max]` tenga ~`target_ticks` divisiones.
|
||||
pub fn nice_step(min: f64, max: f64, target_ticks: usize) -> f64 {
|
||||
debug_assert!(max > min && target_ticks > 0);
|
||||
let raw = (max - min) / target_ticks as f64;
|
||||
let mag = 10f64.powf(raw.log10().floor());
|
||||
let norm = raw / mag;
|
||||
let nice = if norm < 1.5 {
|
||||
1.0
|
||||
} else if norm < 3.0 {
|
||||
2.0
|
||||
} else if norm < 7.0 {
|
||||
5.0
|
||||
} else {
|
||||
10.0
|
||||
};
|
||||
nice * mag
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn linear_roundtrip() {
|
||||
let s = LinearScale::new(10.0, 20.0);
|
||||
assert!((s.to_norm(15.0) - 0.5).abs() < 1e-9);
|
||||
assert!((s.from_norm(0.5) - 15.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn log_roundtrip() {
|
||||
let s = LogScale::new(1.0, 1000.0);
|
||||
// 10 está a 1/3 del camino en log10. ln(10)/ln(1000) = 1/3.
|
||||
assert!((s.to_norm(10.0) - 1.0 / 3.0).abs() < 1e-9);
|
||||
assert!((s.from_norm(2.0 / 3.0) - 100.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nice_step_es_potencia() {
|
||||
// 100/5 = 20 — exact match para el branch nice=2.0 · mag=10.
|
||||
assert!((nice_step(0.0, 100.0, 5) - 20.0).abs() < 1e-9);
|
||||
// 1.0/10 = 0.1 — branch nice=1.0 · mag=0.1.
|
||||
assert!((nice_step(0.0, 1.0, 10) - 0.1).abs() < 1e-9);
|
||||
// 14/5 = 2.8 — branch nice=2.0 (1.5 ≤ norm < 3) · mag=1.
|
||||
assert!((nice_step(0.0, 14.0, 5) - 2.0).abs() < 1e-9);
|
||||
// 7/5 = 1.4 — cae bajo 1.5 → snap a 1.0 · mag=1 = 1.0.
|
||||
assert!((nice_step(0.0, 7.0, 5) - 1.0).abs() < 1e-9);
|
||||
// 50/5 = 10 — branch nice=10 · mag=1 = 10. (Equivalente a 1·10.)
|
||||
assert!((nice_step(0.0, 50.0, 5) - 10.0).abs() < 1e-9);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
//! `SpatialIndex` — hit-testing sobre coords interleaved sorted-by-X.
|
||||
//!
|
||||
//! Cuando los puntos vienen ordenados por X (caso típico de series
|
||||
//! temporales) un binary search basta y es O(log n) sin estructuras
|
||||
//! auxiliares. Para nodos que se mueven cada frame (mesh graph)
|
||||
//! corresponde un spatial hash uniforme — ese va en `pineal-mesh`,
|
||||
//! no acá.
|
||||
|
||||
/// View sobre un buffer interleaved `[x0,y0,x1,y1,…]` sorted-asc por X.
|
||||
///
|
||||
/// El binary search asume invariante de ordenamiento. Si tu pipeline
|
||||
/// puede generar coords desordenadas, sortealas antes de construir
|
||||
/// el índice (no hay debug-assert porque sería O(n) en hot path).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct SpatialIndex<'a> {
|
||||
coords: &'a [f32],
|
||||
}
|
||||
|
||||
impl<'a> SpatialIndex<'a> {
|
||||
pub fn new(coords: &'a [f32]) -> Self {
|
||||
debug_assert!(coords.len() % 2 == 0);
|
||||
Self { coords }
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.coords.len() / 2
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.coords.is_empty()
|
||||
}
|
||||
|
||||
/// Índice del punto cuya X está más cerca de `target_x`.
|
||||
/// `None` si el buffer está vacío.
|
||||
pub fn nearest(&self, target_x: f32) -> Option<usize> {
|
||||
let n = self.len();
|
||||
if n == 0 {
|
||||
return None;
|
||||
}
|
||||
// Binary search sobre la columna X.
|
||||
let mut lo = 0usize;
|
||||
let mut hi = n;
|
||||
while lo < hi {
|
||||
let mid = (lo + hi) / 2;
|
||||
if self.coords[mid * 2] < target_x {
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
// `lo` es la primera X >= target_x. El más cercano es lo o lo-1.
|
||||
if lo == 0 {
|
||||
Some(0)
|
||||
} else if lo >= n {
|
||||
Some(n - 1)
|
||||
} else {
|
||||
let prev = lo - 1;
|
||||
let dx_prev = target_x - self.coords[prev * 2];
|
||||
let dx_next = self.coords[lo * 2] - target_x;
|
||||
if dx_prev <= dx_next {
|
||||
Some(prev)
|
||||
} else {
|
||||
Some(lo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rango `[start, end)` de puntos con X en `[x_min, x_max]`.
|
||||
/// Útil para clip-to-viewport antes de LTTB.
|
||||
pub fn range(&self, x_min: f32, x_max: f32) -> (usize, usize) {
|
||||
let n = self.len();
|
||||
if n == 0 {
|
||||
return (0, 0);
|
||||
}
|
||||
// lower bound: primer i con coords[i*2] >= x_min
|
||||
let start = {
|
||||
let mut lo = 0usize;
|
||||
let mut hi = n;
|
||||
while lo < hi {
|
||||
let mid = (lo + hi) / 2;
|
||||
if self.coords[mid * 2] < x_min {
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
lo
|
||||
};
|
||||
// upper bound: primer i con coords[i*2] > x_max
|
||||
let end = {
|
||||
let mut lo = start;
|
||||
let mut hi = n;
|
||||
while lo < hi {
|
||||
let mid = (lo + hi) / 2;
|
||||
if self.coords[mid * 2] <= x_max {
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
lo
|
||||
};
|
||||
(start, end)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn fixture() -> Vec<f32> {
|
||||
// x: 0, 1, 3, 5, 8 — y irrelevante.
|
||||
vec![0.0, 0.0, 1.0, 0.0, 3.0, 0.0, 5.0, 0.0, 8.0, 0.0]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nearest_dentro() {
|
||||
let c = fixture();
|
||||
let s = SpatialIndex::new(&c);
|
||||
assert_eq!(s.nearest(0.0), Some(0));
|
||||
assert_eq!(s.nearest(2.0), Some(1)); // 1 está más cerca que 3
|
||||
assert_eq!(s.nearest(2.5), Some(2)); // 3 está más cerca que 1
|
||||
assert_eq!(s.nearest(8.0), Some(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nearest_fuera_clamp() {
|
||||
let c = fixture();
|
||||
let s = SpatialIndex::new(&c);
|
||||
assert_eq!(s.nearest(-10.0), Some(0));
|
||||
assert_eq!(s.nearest(99.0), Some(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nearest_empty() {
|
||||
let empty: [f32; 0] = [];
|
||||
assert_eq!(SpatialIndex::new(&empty).nearest(0.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_clip() {
|
||||
let c = fixture();
|
||||
let s = SpatialIndex::new(&c);
|
||||
assert_eq!(s.range(1.0, 5.0), (1, 4)); // incluye x=1,3,5
|
||||
assert_eq!(s.range(2.0, 4.0), (2, 3)); // sólo x=3
|
||||
assert_eq!(s.range(-1.0, 100.0), (0, 5));
|
||||
assert_eq!(s.range(10.0, 20.0), (5, 5));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "pineal-export"
|
||||
version = { workspace = true }
|
||||
edition = { workspace = true }
|
||||
license = { workspace = true }
|
||||
authors = { workspace = true }
|
||||
publish = { workspace = true }
|
||||
description = "Lapaloma — exporters. SVG primero, PDF después. Decimación contextual por DPI: target = width_inches × dpi × vertices_per_pixel."
|
||||
|
||||
[dependencies]
|
||||
pineal-core = { path = "../core" }
|
||||
pineal-render = { path = "../render" }
|
||||
@@ -0,0 +1,23 @@
|
||||
//! `pineal-export` — exporters.
|
||||
//!
|
||||
//! Estrategia: implementar `pineal_render::Canvas` con un
|
||||
//! adapter que emite elementos SVG (o instrucciones PDF). El mismo
|
||||
//! painter que dibuja en pantalla escribe en el exporter — un sólo
|
||||
//! camino de código.
|
||||
//!
|
||||
//! Decimación contextual:
|
||||
//! ```text
|
||||
//! target = width_inches × dpi × vertices_per_pixel
|
||||
//! ```
|
||||
//! Print (300 dpi) saca ~3× más vértices que screen (96 dpi) del
|
||||
//! mismo source data (sección 3.10).
|
||||
//!
|
||||
//! - **`svg`** — exporter SVG.
|
||||
//! - **`pdf`** — placeholder; cuando se implemente, vía `printpdf`
|
||||
//! sobre el mismo `RenderPlan` que el SVG.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub mod svg {}
|
||||
pub mod pdf {}
|
||||
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "pineal-financial"
|
||||
version = { workspace = true }
|
||||
edition = { workspace = true }
|
||||
license = { workspace = true }
|
||||
authors = { workspace = true }
|
||||
publish = { workspace = true }
|
||||
description = "Lapaloma — gráficos financieros. OHLC / candlesticks con agregación que preserva volatilidad (no LTTB, time-bucketing con max/min de wicks)."
|
||||
|
||||
[dependencies]
|
||||
pineal-core = { path = "../core" }
|
||||
pineal-render = { path = "../render" }
|
||||
pineal-cartesian = { path = "../cartesian" }
|
||||
gpui = { workspace = true, optional = true }
|
||||
|
||||
[features]
|
||||
default = ["gpui"]
|
||||
gpui = ["dep:gpui", "pineal-render/gpui", "pineal-cartesian/gpui"]
|
||||
@@ -0,0 +1,165 @@
|
||||
//! Aggregation de OHLC por bucket de **tiempo** (no de índice).
|
||||
//!
|
||||
//! Bucket index = `floor((bar.t - t_start) / bucket_duration)`.
|
||||
//! Cuando cambia el bucket, commit del anterior:
|
||||
//!
|
||||
//! - `open` = primer `open` del bucket.
|
||||
//! - `close` = último `close` del bucket.
|
||||
//! - `high` = max(`high`) del bucket.
|
||||
//! - `low` = min(`low`) del bucket.
|
||||
//! - `volume` = sum(`volume`) del bucket.
|
||||
//! - `t` = timestamp del primer bar del bucket (canónico para
|
||||
//! ploteo; el doc original sugiere usar el inicio del bucket
|
||||
//! pero acá preferimos el sample real para no introducir bias).
|
||||
//!
|
||||
//! Buckets vacíos no se emiten — el length de salida es ≤ inputs.
|
||||
//! Fallback a index-bucketing si el span temporal es cero (todos
|
||||
//! los timestamps colapsados, e.g. tick-data).
|
||||
|
||||
use crate::ohlc_buffer::{Bar, OhlcBuffer, STRIDE};
|
||||
|
||||
/// Agrega `src` en buckets de duración `bucket_duration` (en
|
||||
/// unidades de `bar.t`). Escribe el output en `out` extendiéndolo
|
||||
/// (no se clearea; el caller decide).
|
||||
///
|
||||
/// Si `bucket_duration <= 0` o el span del input es cero, hace
|
||||
/// fallback a index-bucketing con `samples_per_bucket = 1` (es decir,
|
||||
/// copia el input tal cual). Esto evita panic con tick-data
|
||||
/// colapsado.
|
||||
pub fn aggregate_time_bucketed(src: &OhlcBuffer, bucket_duration: f32, out: &mut OhlcBuffer) {
|
||||
if src.is_empty() {
|
||||
return;
|
||||
}
|
||||
let n = src.len();
|
||||
let (t_first, t_last) = src.time_range().unwrap();
|
||||
|
||||
if bucket_duration <= 0.0 || (t_last - t_first).abs() < f32::EPSILON {
|
||||
// Fallback: copia tal cual.
|
||||
for i in 0..n {
|
||||
out.push_bar(src.bar(i));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let mut current_bucket = i64::MIN;
|
||||
let mut acc_t: f32 = 0.0;
|
||||
let mut acc_o: f32 = 0.0;
|
||||
let mut acc_h: f32 = f32::NEG_INFINITY;
|
||||
let mut acc_l: f32 = f32::INFINITY;
|
||||
let mut acc_c: f32 = 0.0;
|
||||
let mut acc_v: f32 = 0.0;
|
||||
let mut has_acc = false;
|
||||
|
||||
for i in 0..n {
|
||||
let b = src.bar(i);
|
||||
let bucket = ((b.t - t_first) / bucket_duration).floor() as i64;
|
||||
if bucket != current_bucket {
|
||||
if has_acc {
|
||||
out.push_bar(Bar {
|
||||
t: acc_t,
|
||||
o: acc_o,
|
||||
h: acc_h,
|
||||
l: acc_l,
|
||||
c: acc_c,
|
||||
v: acc_v,
|
||||
});
|
||||
}
|
||||
current_bucket = bucket;
|
||||
acc_t = b.t;
|
||||
acc_o = b.o;
|
||||
acc_h = b.h;
|
||||
acc_l = b.l;
|
||||
acc_c = b.c;
|
||||
acc_v = b.v;
|
||||
has_acc = true;
|
||||
} else {
|
||||
if b.h > acc_h {
|
||||
acc_h = b.h;
|
||||
}
|
||||
if b.l < acc_l {
|
||||
acc_l = b.l;
|
||||
}
|
||||
acc_c = b.c;
|
||||
acc_v += b.v;
|
||||
}
|
||||
}
|
||||
if has_acc {
|
||||
out.push_bar(Bar {
|
||||
t: acc_t,
|
||||
o: acc_o,
|
||||
h: acc_h,
|
||||
l: acc_l,
|
||||
c: acc_c,
|
||||
v: acc_v,
|
||||
});
|
||||
}
|
||||
|
||||
// Una métrica de cordura: el output nunca puede ser más largo
|
||||
// que el input.
|
||||
debug_assert!(out.bars().len() / STRIDE <= n);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn fixture() -> OhlcBuffer {
|
||||
// 10 bars con t en `[0, 9]`, valores deterministicos.
|
||||
let mut b = OhlcBuffer::with_capacity(10);
|
||||
for i in 0..10 {
|
||||
let t = i as f32;
|
||||
let base = 100.0 + (i as f32) * 0.5;
|
||||
b.push_values(t, base, base + 1.0, base - 1.0, base + 0.2, 10.0);
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bucket_de_3_agrega_a_4_bars() {
|
||||
// 10 inputs con t en `[0, 9]`, bucket 3 → buckets 0-2, 3-5, 6-8, 9.
|
||||
// = 4 buckets.
|
||||
let src = fixture();
|
||||
let mut out = OhlcBuffer::new();
|
||||
aggregate_time_bucketed(&src, 3.0, &mut out);
|
||||
assert_eq!(out.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aggregation_preserva_volatilidad() {
|
||||
// Inventamos un bucket donde un bar tiene spike alto y otro
|
||||
// spike bajo. El aggregate debe capturar AMBOS extremos.
|
||||
let mut src = OhlcBuffer::new();
|
||||
src.push_values(0.0, 10.0, 12.0, 9.0, 11.0, 5.0);
|
||||
src.push_values(0.5, 11.0, 20.0, 10.5, 11.5, 5.0); // spike up
|
||||
src.push_values(0.8, 11.5, 12.0, 2.0, 11.0, 5.0); // spike down
|
||||
let mut out = OhlcBuffer::new();
|
||||
aggregate_time_bucketed(&src, 1.0, &mut out);
|
||||
assert_eq!(out.len(), 1);
|
||||
let agg = out.bar(0);
|
||||
assert_eq!(agg.h, 20.0, "max H debe sobrevivir");
|
||||
assert_eq!(agg.l, 2.0, "min L debe sobrevivir");
|
||||
assert_eq!(agg.o, 10.0, "first open");
|
||||
assert_eq!(agg.c, 11.0, "last close");
|
||||
assert_eq!(agg.v, 15.0, "sum volumes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_a_index_si_span_cero() {
|
||||
// Todos los t iguales — fallback copia 1:1.
|
||||
let mut src = OhlcBuffer::new();
|
||||
src.push_values(7.0, 1.0, 2.0, 0.0, 1.5, 1.0);
|
||||
src.push_values(7.0, 1.5, 2.5, 1.0, 2.0, 1.0);
|
||||
src.push_values(7.0, 2.0, 3.0, 1.0, 1.0, 1.0);
|
||||
let mut out = OhlcBuffer::new();
|
||||
aggregate_time_bucketed(&src, 1.0, &mut out);
|
||||
assert_eq!(out.len(), 3, "span 0 ⇒ copy 1:1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_no_emite() {
|
||||
let src = OhlcBuffer::new();
|
||||
let mut out = OhlcBuffer::new();
|
||||
aggregate_time_bucketed(&src, 1.0, &mut out);
|
||||
assert_eq!(out.len(), 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
//! Render de candlesticks sobre cualquier `Canvas`.
|
||||
//!
|
||||
//! Por cada bar visible:
|
||||
//! - **Wick** = línea vertical de `(t, low)` a `(t, high)`.
|
||||
//! - **Body** = rect de `(t - body_w/2, open)` a `(t + body_w/2, close)`,
|
||||
//! relleno bull (close > open) / bear (close < open) / neutro.
|
||||
//!
|
||||
//! Esta función es agnóstica de gpui — habla contra el trait
|
||||
//! `Canvas`. El `Element` GPUI que la consume vive en `element.rs`.
|
||||
|
||||
use pineal_cartesian::CoordinateSystem;
|
||||
use pineal_render::{Canvas, Color, Point, Rect, StrokeStyle};
|
||||
|
||||
use crate::ohlc_buffer::OhlcBuffer;
|
||||
|
||||
/// Estilo visual de los candlesticks.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct CandlestickStyle {
|
||||
pub bull_color: Color,
|
||||
pub bear_color: Color,
|
||||
/// Color del body cuando open == close. Suele ser el axis color.
|
||||
pub neutral_color: Color,
|
||||
/// Ancho del wick (línea central). En píxeles.
|
||||
pub wick_width: f32,
|
||||
/// Ancho mínimo del body, en píxeles. Cuando el spacing entre
|
||||
/// bars cae por debajo, el body usa este floor.
|
||||
pub body_min_width: f32,
|
||||
/// Fracción del spacing entre bars consecutivas que ocupa el body.
|
||||
/// 0.7 deja un gap del 30% entre velas.
|
||||
pub body_width_ratio: f32,
|
||||
}
|
||||
|
||||
impl Default for CandlestickStyle {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
bull_color: Color::from_hex(0x88c08a),
|
||||
bear_color: Color::from_hex(0xbf616a),
|
||||
neutral_color: Color::rgba(0.7, 0.7, 0.75, 1.0),
|
||||
wick_width: 1.0,
|
||||
body_min_width: 2.0,
|
||||
body_width_ratio: 0.7,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Dibuja todas las velas del buffer visibles en el viewport del
|
||||
/// `CoordinateSystem`. Bars fuera de rango se skippean.
|
||||
pub fn paint_candlesticks(
|
||||
canvas: &mut dyn Canvas,
|
||||
cs: &CoordinateSystem,
|
||||
data: &OhlcBuffer,
|
||||
style: CandlestickStyle,
|
||||
) {
|
||||
let n = data.len();
|
||||
if n == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let plot = cs.plot;
|
||||
let viewport = cs.viewport;
|
||||
|
||||
// Spacing entre bars consecutivas en píxeles. Asume bars
|
||||
// aproximadamente equiespaciadas en X (caso típico OHLC
|
||||
// post-aggregation).
|
||||
let body_width = if n >= 2 {
|
||||
let first_t = data.bar(0).t as f64;
|
||||
let last_t = data.bar(n - 1).t as f64;
|
||||
let span_t = (last_t - first_t).max(f32::EPSILON as f64);
|
||||
let span_px = (span_t / viewport.x_span()) * plot.w as f64;
|
||||
let spacing = span_px / (n as f64 - 1.0);
|
||||
((spacing * style.body_width_ratio as f64) as f32).max(style.body_min_width)
|
||||
} else {
|
||||
style.body_min_width
|
||||
};
|
||||
let half_body = body_width * 0.5;
|
||||
|
||||
for i in 0..n {
|
||||
let bar = data.bar(i);
|
||||
|
||||
// Clip aproximado: si el bar entero queda fuera del viewport
|
||||
// X, lo saltamos.
|
||||
if (bar.t as f64) < viewport.x_min - viewport.x_span() * 0.05
|
||||
|| (bar.t as f64) > viewport.x_max + viewport.x_span() * 0.05
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let px_center = cs.data_to_pixel(bar.t as f64, bar.o as f64).x;
|
||||
let py_open = cs.data_to_pixel(bar.t as f64, bar.o as f64).y;
|
||||
let py_close = cs.data_to_pixel(bar.t as f64, bar.c as f64).y;
|
||||
let py_high = cs.data_to_pixel(bar.t as f64, bar.h as f64).y;
|
||||
let py_low = cs.data_to_pixel(bar.t as f64, bar.l as f64).y;
|
||||
|
||||
let color = if bar.is_bull() {
|
||||
style.bull_color
|
||||
} else if bar.is_bear() {
|
||||
style.bear_color
|
||||
} else {
|
||||
style.neutral_color
|
||||
};
|
||||
|
||||
// Wick: línea vertical de high a low.
|
||||
canvas.stroke_line(
|
||||
Point::new(px_center, py_high),
|
||||
Point::new(px_center, py_low),
|
||||
StrokeStyle::new(style.wick_width, color),
|
||||
);
|
||||
|
||||
// Body: rect entre open y close.
|
||||
let (y_top, y_bot) = if py_open < py_close {
|
||||
(py_open, py_close)
|
||||
} else {
|
||||
(py_close, py_open)
|
||||
};
|
||||
let body_h = (y_bot - y_top).max(1.0); // floor 1px para doji
|
||||
canvas.fill_rect(
|
||||
Rect::new(px_center - half_body, y_top, body_width, body_h),
|
||||
color,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
//! `LapalomaCandlestickElement` — Element GPUI para charts OHLC.
|
||||
//!
|
||||
//! Reusa `pineal_cartesian::axis::paint_axes` para los ejes y el
|
||||
//! `WindowCanvas` adapter de `pineal-render` para el output.
|
||||
//! El render mismo de las velas lo hace `paint_candlesticks` que
|
||||
//! es agnóstico de gpui — facilita futuros backends (SVG, wgpu).
|
||||
//!
|
||||
//! Sin cache pan-blit en v0.1: las velas se redibujan cada frame.
|
||||
//! Para hasta ~500 bars on-screen son sub-millisecond; con
|
||||
//! aggregation razonable (1 bar por columna de pixel) eso cubre
|
||||
//! cualquier caso humano. Si se necesita más, se replica el
|
||||
//! patrón de `LapalomaChartElement::with_cache`.
|
||||
|
||||
use std::panic;
|
||||
|
||||
use gpui::{
|
||||
App, Bounds, Element, ElementId, GlobalElementId, InspectorElementId, IntoElement, LayoutId,
|
||||
Pixels, Style, Window,
|
||||
};
|
||||
|
||||
use pineal_cartesian::axis::{self, AxisStyle};
|
||||
use pineal_cartesian::{ChartViewport, CoordinateSystem};
|
||||
use pineal_render::{Canvas, Color, Rect, WindowCanvas};
|
||||
|
||||
use crate::candlestick::{paint_candlesticks, CandlestickStyle};
|
||||
use crate::ohlc_buffer::OhlcBuffer;
|
||||
|
||||
const TARGET_TICKS_X: usize = 8;
|
||||
const TARGET_TICKS_Y: usize = 6;
|
||||
|
||||
pub struct LapalomaCandlestickElement {
|
||||
pub data: OhlcBuffer,
|
||||
pub viewport: ChartViewport,
|
||||
pub style: CandlestickStyle,
|
||||
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,
|
||||
}
|
||||
|
||||
impl LapalomaCandlestickElement {
|
||||
pub fn new(data: OhlcBuffer, viewport: ChartViewport) -> Self {
|
||||
Self {
|
||||
data,
|
||||
viewport,
|
||||
style: CandlestickStyle::default(),
|
||||
background: None,
|
||||
axis_color: Color::rgba(0.6, 0.6, 0.65, 0.8),
|
||||
axis_style: AxisStyle::default(),
|
||||
margin_bottom: 24.0,
|
||||
margin_left: 48.0,
|
||||
margin_top: 8.0,
|
||||
margin_right: 8.0,
|
||||
}
|
||||
}
|
||||
|
||||
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 style(mut self, style: CandlestickStyle) -> Self {
|
||||
self.style = style;
|
||||
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
|
||||
}
|
||||
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoElement for LapalomaCandlestickElement {
|
||||
type Element = Self;
|
||||
fn into_element(self) -> Self::Element {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for LapalomaCandlestickElement {
|
||||
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);
|
||||
}
|
||||
|
||||
axis::paint_axes(
|
||||
&mut canvas,
|
||||
&cs,
|
||||
&self.viewport,
|
||||
self.axis_color,
|
||||
self.axis_style,
|
||||
TARGET_TICKS_X,
|
||||
TARGET_TICKS_Y,
|
||||
);
|
||||
|
||||
paint_candlesticks(&mut canvas, &cs, &self.data, self.style);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn lapaloma_candlestick(
|
||||
data: OhlcBuffer,
|
||||
viewport: ChartViewport,
|
||||
) -> LapalomaCandlestickElement {
|
||||
LapalomaCandlestickElement::new(data, viewport)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
//! `pineal-financial` — OHLC y candlesticks.
|
||||
//!
|
||||
//! Layout del buffer: 6 floats por bar `[t, o, h, l, c, v]` (time,
|
||||
//! open, high, low, close, volume). Mismo principio P1 del doc
|
||||
//! canónico: array plano, sin objetos por bar.
|
||||
//!
|
||||
//! Aggregation (sección 3.2 del ARCHITECTURE.md):
|
||||
//! - **Time bucketing** (no index bucketing) para que weekends /
|
||||
//! holidays no colapsen la rate.
|
||||
//! - `open` = primero del bucket, `close` = último, `high` = max,
|
||||
//! `low` = min, `volume` = sum.
|
||||
//! - **Preserva volatilidad** — LTTB caería los wicks; estos los
|
||||
//! conserva por construcción.
|
||||
//!
|
||||
//! Render: dos batches separados — barras alcistas (close > open,
|
||||
//! verdes) y bajistas (close < open, rojas). v0.1 emite un quad
|
||||
//! por body + un line por wick (≈ 2 draw calls por bar; aceptable
|
||||
//! hasta ~500 bars on-screen). Optimización futura: agrupar
|
||||
//! N bodies en un solo PathBuilder fill.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub mod ohlc_buffer;
|
||||
pub mod aggregate;
|
||||
|
||||
#[cfg(feature = "gpui")]
|
||||
pub mod candlestick;
|
||||
|
||||
#[cfg(feature = "gpui")]
|
||||
pub mod element;
|
||||
|
||||
pub use ohlc_buffer::{Bar, OhlcBuffer};
|
||||
pub use aggregate::aggregate_time_bucketed;
|
||||
|
||||
#[cfg(feature = "gpui")]
|
||||
pub use candlestick::{paint_candlesticks, CandlestickStyle};
|
||||
|
||||
#[cfg(feature = "gpui")]
|
||||
pub use element::{lapaloma_candlestick, LapalomaCandlestickElement};
|
||||
@@ -0,0 +1,186 @@
|
||||
//! `OhlcBuffer` — buffer plano de bars con stride 6 `f32`.
|
||||
//!
|
||||
//! Memoria contigua: `[t0, o0, h0, l0, c0, v0, t1, o1, …]`.
|
||||
//! Acceso O(1) por índice; un memcpy completo para hidratar desde
|
||||
//! una fuente externa.
|
||||
|
||||
/// Una barra OHLC + volumen. Valor leído del buffer; no es la
|
||||
/// representación de almacenamiento (que vive como `[f32; 6]`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Bar {
|
||||
pub t: f32,
|
||||
pub o: f32,
|
||||
pub h: f32,
|
||||
pub l: f32,
|
||||
pub c: f32,
|
||||
pub v: f32,
|
||||
}
|
||||
|
||||
impl Bar {
|
||||
pub fn is_bull(self) -> bool {
|
||||
self.c > self.o
|
||||
}
|
||||
pub fn is_bear(self) -> bool {
|
||||
self.c < self.o
|
||||
}
|
||||
}
|
||||
|
||||
pub const STRIDE: usize = 6;
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct OhlcBuffer {
|
||||
bars: Vec<f32>,
|
||||
revision: u64,
|
||||
}
|
||||
|
||||
impl OhlcBuffer {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn with_capacity(n: usize) -> Self {
|
||||
Self {
|
||||
bars: Vec::with_capacity(n * STRIDE),
|
||||
revision: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_raw(bars: Vec<f32>) -> Self {
|
||||
assert!(bars.len() % STRIDE == 0, "OhlcBuffer: stride 6 required");
|
||||
Self { bars, revision: 0 }
|
||||
}
|
||||
|
||||
pub fn push_bar(&mut self, b: Bar) {
|
||||
self.bars.push(b.t);
|
||||
self.bars.push(b.o);
|
||||
self.bars.push(b.h);
|
||||
self.bars.push(b.l);
|
||||
self.bars.push(b.c);
|
||||
self.bars.push(b.v);
|
||||
self.revision = self.revision.wrapping_add(1);
|
||||
}
|
||||
|
||||
pub fn push_values(&mut self, t: f32, o: f32, h: f32, l: f32, c: f32, v: f32) {
|
||||
self.push_bar(Bar { t, o, h, l, c, v });
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.bars.len() / STRIDE
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.bars.is_empty()
|
||||
}
|
||||
|
||||
pub fn bar(&self, i: usize) -> Bar {
|
||||
let off = i * STRIDE;
|
||||
Bar {
|
||||
t: self.bars[off],
|
||||
o: self.bars[off + 1],
|
||||
h: self.bars[off + 2],
|
||||
l: self.bars[off + 3],
|
||||
c: self.bars[off + 4],
|
||||
v: self.bars[off + 5],
|
||||
}
|
||||
}
|
||||
|
||||
/// Slice plano del buffer subyacente.
|
||||
pub fn bars(&self) -> &[f32] {
|
||||
&self.bars
|
||||
}
|
||||
|
||||
pub fn revision(&self) -> u64 {
|
||||
self.revision
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.bars.clear();
|
||||
self.revision = self.revision.wrapping_add(1);
|
||||
}
|
||||
|
||||
/// Min/max de `low` y `high` sobre todo el buffer.
|
||||
/// Útil para autoscale del Y axis.
|
||||
pub fn price_range(&self) -> Option<(f32, f32)> {
|
||||
if self.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut lo = f32::INFINITY;
|
||||
let mut hi = f32::NEG_INFINITY;
|
||||
for i in 0..self.len() {
|
||||
let b = self.bar(i);
|
||||
if b.l < lo {
|
||||
lo = b.l;
|
||||
}
|
||||
if b.h > hi {
|
||||
hi = b.h;
|
||||
}
|
||||
}
|
||||
Some((lo, hi))
|
||||
}
|
||||
|
||||
/// Rango temporal `[t_min, t_max]`. None si vacío.
|
||||
pub fn time_range(&self) -> Option<(f32, f32)> {
|
||||
if self.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let first = self.bars[0];
|
||||
let last = self.bars[self.bars.len() - STRIDE];
|
||||
Some((first, last))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn push_y_lectura() {
|
||||
let mut b = OhlcBuffer::with_capacity(2);
|
||||
b.push_values(1.0, 10.0, 12.0, 9.0, 11.0, 100.0);
|
||||
b.push_values(2.0, 11.0, 13.0, 10.0, 10.5, 80.0);
|
||||
assert_eq!(b.len(), 2);
|
||||
assert_eq!(b.bar(0).c, 11.0);
|
||||
assert_eq!(b.bar(1).h, 13.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bull_y_bear() {
|
||||
let bull = Bar { t: 0.0, o: 10.0, h: 11.0, l: 9.0, c: 10.5, v: 0.0 };
|
||||
let bear = Bar { t: 0.0, o: 10.0, h: 11.0, l: 9.0, c: 9.5, v: 0.0 };
|
||||
assert!(bull.is_bull());
|
||||
assert!(!bull.is_bear());
|
||||
assert!(bear.is_bear());
|
||||
assert!(!bear.is_bull());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn price_range_correcto() {
|
||||
let mut b = OhlcBuffer::new();
|
||||
b.push_values(0.0, 10.0, 15.0, 8.0, 12.0, 0.0);
|
||||
b.push_values(1.0, 12.0, 14.0, 7.0, 9.0, 0.0);
|
||||
b.push_values(2.0, 9.0, 11.0, 9.0, 10.0, 0.0);
|
||||
let (lo, hi) = b.price_range().unwrap();
|
||||
assert_eq!(lo, 7.0);
|
||||
assert_eq!(hi, 15.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_range() {
|
||||
let mut b = OhlcBuffer::new();
|
||||
b.push_values(10.0, 0.0, 0.0, 0.0, 0.0, 0.0);
|
||||
b.push_values(50.0, 0.0, 0.0, 0.0, 0.0, 0.0);
|
||||
b.push_values(100.0, 0.0, 0.0, 0.0, 0.0, 0.0);
|
||||
assert_eq!(b.time_range(), Some((10.0, 100.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revision_bumps_en_push_y_clear() {
|
||||
let mut b = OhlcBuffer::new();
|
||||
let r0 = b.revision();
|
||||
b.push_values(0.0, 1.0, 1.0, 1.0, 1.0, 1.0);
|
||||
assert_ne!(r0, b.revision());
|
||||
let r1 = b.revision();
|
||||
b.clear();
|
||||
assert_ne!(r1, b.revision());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "pineal-flow"
|
||||
version = { workspace = true }
|
||||
edition = { workspace = true }
|
||||
license = { workspace = true }
|
||||
authors = { workspace = true }
|
||||
publish = { workspace = true }
|
||||
description = "Lapaloma — diagramas de flujo Sankey: columnas topológicas + barycenter ordering + ribbons como triangle strips de béziers."
|
||||
|
||||
[dependencies]
|
||||
pineal-core = { path = "../core" }
|
||||
pineal-render = { path = "../render" }
|
||||
gpui = { workspace = true }
|
||||
@@ -0,0 +1,16 @@
|
||||
//! `pineal-flow` — diagramas Sankey.
|
||||
//!
|
||||
//! Pipeline (sección 3.7 del ARCHITECTURE.md):
|
||||
//! 1. Columnas via longest-path en el DAG (back-edges drop).
|
||||
//! 2. Flow por nodo = max(in_value, out_value).
|
||||
//! 3. Barycenter ordering con inversion-count crossings.
|
||||
//! 4. Stripes por edge dentro de cada lado del nodo.
|
||||
//! 5. Ribbons como triangle-strip de béziers, un draw call por
|
||||
//! ribbon, color por vértice.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub mod layout {}
|
||||
pub mod ribbon {}
|
||||
pub mod element {}
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "pineal-heatmap"
|
||||
version = { workspace = true }
|
||||
edition = { workspace = true }
|
||||
license = { workspace = true }
|
||||
authors = { workspace = true }
|
||||
publish = { workspace = true }
|
||||
description = "Lapaloma — heatmap. Matriz [width × height] de f32 → imagen pre-encodeada que se rendea con un sólo drawImageRect."
|
||||
|
||||
[dependencies]
|
||||
pineal-core = { path = "../core" }
|
||||
pineal-render = { path = "../render" }
|
||||
gpui = { workspace = true }
|
||||
@@ -0,0 +1,21 @@
|
||||
//! `pineal-heatmap` — matriz `[width × height]` de `f32` → imagen.
|
||||
//!
|
||||
//! Para matrices grandes (4096² = 67 MB de pixels), encodear la
|
||||
//! imagen una vez al cambiar la data y renderear con un solo
|
||||
//! `drawImageRect` (o equivalente GPUI). Eso convierte el coste
|
||||
//! de cada frame en "blit de una textura", sub-millisecond.
|
||||
//!
|
||||
//! - **`matrix`** — `HeatmapMatrix { data: Vec<f32>, width, height,
|
||||
//! revision }`.
|
||||
//! - **`palette`** — color ramps (viridis, plasma, gray…).
|
||||
//! - **`encoder`** — convierte la matrix a un buffer ARGB para
|
||||
//! subir como textura.
|
||||
//! - **`element`** — `Element` GPUI.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub mod matrix {}
|
||||
pub mod palette {}
|
||||
pub mod encoder {}
|
||||
pub mod element {}
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "pineal-mesh"
|
||||
version = { workspace = true }
|
||||
edition = { workspace = true }
|
||||
license = { workspace = true }
|
||||
authors = { workspace = true }
|
||||
publish = { workspace = true }
|
||||
description = "Lapaloma — grafos. NodeBuffer / EdgeBuffer + layouts (force-directed con Barnes-Hut, Sugiyama-lite jerárquico, subtree-width)."
|
||||
|
||||
[dependencies]
|
||||
pineal-core = { path = "../core" }
|
||||
pineal-render = { path = "../render" }
|
||||
gpui = { workspace = true }
|
||||
@@ -0,0 +1,28 @@
|
||||
//! `pineal-mesh` — visualización de grafos.
|
||||
//!
|
||||
//! Módulos:
|
||||
//! - **`node_buffer`** / **`edge_buffer`** — `Vec<f32>` planos con
|
||||
//! stride fijo (3 floats por nodo: `[x, y, radius]`).
|
||||
//! - **`spatial_hash`** — uniform grid para hit-test de nodos
|
||||
//! móviles (sección 5.1).
|
||||
//! - **`force_directed`** — layout con Barnes-Hut delegado a
|
||||
//! `pineal_core::barnes_hut` (cuando se implemente).
|
||||
//! - **`hierarchical`** — Sugiyama-lite, delegado a
|
||||
//! `pineal_core::sugiyama`.
|
||||
//! - **`tree`** — subtree-width layout, delegado a
|
||||
//! `pineal_core::tree_layout`.
|
||||
//! - **`camera`** — pan/zoom con anchor-preserving zoom de la
|
||||
//! sección 5.3.
|
||||
//! - **`element`** — `Element` GPUI.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub mod node_buffer {}
|
||||
pub mod edge_buffer {}
|
||||
pub mod spatial_hash {}
|
||||
pub mod force_directed {}
|
||||
pub mod hierarchical {}
|
||||
pub mod tree {}
|
||||
pub mod camera {}
|
||||
pub mod element {}
|
||||
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "pineal-phosphor"
|
||||
version = { workspace = true }
|
||||
edition = { workspace = true }
|
||||
license = { workspace = true }
|
||||
authors = { workspace = true }
|
||||
publish = { workspace = true }
|
||||
description = "Lapaloma — decoración CRT sobre lapaloma-stream: trail con alpha decay por edad, ghost, anotaciones magnéticas ancladas a sample index."
|
||||
|
||||
[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,279 @@
|
||||
//! `LapalomaPhosphorElement` — Element GPUI con trail CRT.
|
||||
//!
|
||||
//! El render pinta el RingBuffer como N segmentos polilíneas con
|
||||
//! alpha decreciente del más nuevo al más viejo. Wraparound se
|
||||
//! parte en dos sub-polilíneas para no introducir la línea
|
||||
//! horizontal "del slot cap-1 al slot 0".
|
||||
|
||||
use std::panic;
|
||||
|
||||
use gpui::{
|
||||
App, Bounds, Element, ElementId, GlobalElementId, InspectorElementId, IntoElement, LayoutId,
|
||||
Pixels, Style, Window,
|
||||
};
|
||||
|
||||
use pineal_core::ring::RingBuffer;
|
||||
use pineal_render::{Canvas, Color, Rect, StrokeStyle, WindowCanvas};
|
||||
|
||||
/// Cantidad de tramos del trail. Más tramos = gradiente más suave,
|
||||
/// más draw calls. 16 cubre la mayoría de los casos sin ser caro.
|
||||
const DEFAULT_TRAIL_SEGMENTS: usize = 16;
|
||||
|
||||
pub struct LapalomaPhosphorElement {
|
||||
pub buffer: RingBuffer,
|
||||
/// Color y ancho base. El alpha se modula por tramo;
|
||||
/// `base_stroke.color.a` es el alpha máximo (cabeza del trail).
|
||||
pub base_stroke: StrokeStyle,
|
||||
pub background: Option<Color>,
|
||||
pub y_min: f32,
|
||||
pub y_max: f32,
|
||||
pub padding: f32,
|
||||
pub trail_segments: usize,
|
||||
/// Si > 0, se aplica una pasada adicional con `width × glow_width_mult`
|
||||
/// y `alpha × glow_alpha` debajo del trazo principal — efecto halo CRT.
|
||||
pub glow_width_mult: f32,
|
||||
pub glow_alpha: f32,
|
||||
scratch: Vec<f32>,
|
||||
}
|
||||
|
||||
impl LapalomaPhosphorElement {
|
||||
pub fn new(buffer: RingBuffer, base_stroke: StrokeStyle) -> Self {
|
||||
Self {
|
||||
buffer,
|
||||
base_stroke,
|
||||
background: None,
|
||||
y_min: -1.0,
|
||||
y_max: 1.0,
|
||||
padding: 8.0,
|
||||
trail_segments: DEFAULT_TRAIL_SEGMENTS,
|
||||
glow_width_mult: 3.0,
|
||||
glow_alpha: 0.25,
|
||||
scratch: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn background(mut self, color: Color) -> Self {
|
||||
self.background = Some(color);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn y_range(mut self, min: f32, max: f32) -> Self {
|
||||
debug_assert!(max > min);
|
||||
self.y_min = min;
|
||||
self.y_max = max;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn trail_segments(mut self, n: usize) -> Self {
|
||||
self.trail_segments = n.max(2);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn glow(mut self, width_mult: f32, alpha: f32) -> Self {
|
||||
self.glow_width_mult = width_mult;
|
||||
self.glow_alpha = alpha;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn no_glow(mut self) -> Self {
|
||||
self.glow_width_mult = 0.0;
|
||||
self.glow_alpha = 0.0;
|
||||
self
|
||||
}
|
||||
|
||||
fn plot_rect(&self, bounds: Rect) -> Rect {
|
||||
Rect::new(
|
||||
bounds.x + self.padding,
|
||||
bounds.y + self.padding,
|
||||
(bounds.w - self.padding * 2.0).max(1.0),
|
||||
(bounds.h - self.padding * 2.0).max(1.0),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoElement for LapalomaPhosphorElement {
|
||||
type Element = Self;
|
||||
fn into_element(self) -> Self::Element {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for LapalomaPhosphorElement {
|
||||
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 mut canvas = WindowCanvas::new(window);
|
||||
|
||||
if let Some(bg) = self.background {
|
||||
canvas.fill_rect(outer, bg);
|
||||
}
|
||||
|
||||
let filled = self.buffer.filled_len();
|
||||
if filled < 2 {
|
||||
return;
|
||||
}
|
||||
|
||||
let cap = self.buffer.capacity();
|
||||
let head = self.buffer.head();
|
||||
let coords = self.buffer.coords();
|
||||
|
||||
// El slot del sample temporalmente más viejo:
|
||||
// - is_full → `head` (el siguiente a sobrescribirse).
|
||||
// - !is_full → 0.
|
||||
let start_slot = if self.buffer.is_full() { head } else { 0 };
|
||||
|
||||
let n_segs = self.trail_segments.min(filled / 2).max(2);
|
||||
let base_per_seg = filled / n_segs;
|
||||
let glow_enabled = self.glow_alpha > 0.0 && self.glow_width_mult > 1.0;
|
||||
|
||||
for k in 0..n_segs {
|
||||
// Rango temporal del segmento. El segmento `k` cubre los
|
||||
// samples [k*base_per_seg, (k+1)*base_per_seg). El último
|
||||
// incluye el remainder.
|
||||
let t_lo = k * base_per_seg;
|
||||
let t_hi = if k == n_segs - 1 {
|
||||
filled
|
||||
} else {
|
||||
// +1 incluye el primer sample del siguiente segmento
|
||||
// para que las polilíneas se "toquen" sin gap visual.
|
||||
((k + 1) * base_per_seg) + 1
|
||||
};
|
||||
if t_hi <= t_lo + 1 {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Alpha decrece linealmente del más nuevo al más viejo.
|
||||
// k = n_segs - 1 → 1.0; k = 0 → 1/n_segs.
|
||||
let life = (k as f32 + 1.0) / n_segs as f32;
|
||||
let alpha = self.base_stroke.color.a * life;
|
||||
let mut color = self.base_stroke.color;
|
||||
color.a = alpha;
|
||||
let stroke = StrokeStyle::new(self.base_stroke.width, color);
|
||||
|
||||
// Glow underneath, mismo path con más ancho y menos alpha.
|
||||
let glow_stroke = if glow_enabled {
|
||||
let mut gc = color;
|
||||
gc.a *= self.glow_alpha;
|
||||
Some(StrokeStyle::new(
|
||||
self.base_stroke.width * self.glow_width_mult,
|
||||
gc,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Proyectar el rango temporal a slots físicos, partiendo
|
||||
// si cruzamos el final del buffer.
|
||||
let seg_len = t_hi - t_lo;
|
||||
let abs_start = (start_slot + t_lo) % cap;
|
||||
let contiguous_len = cap - abs_start;
|
||||
|
||||
if seg_len <= contiguous_len {
|
||||
let slice = &coords[abs_start * 2..(abs_start + seg_len) * 2];
|
||||
self.scratch.clear();
|
||||
project_segment(slice, plot, self.y_min, self.y_max, &mut self.scratch);
|
||||
if self.scratch.len() >= 4 {
|
||||
if let Some(gs) = glow_stroke {
|
||||
canvas.stroke_polyline(&self.scratch, gs);
|
||||
}
|
||||
canvas.stroke_polyline(&self.scratch, stroke);
|
||||
}
|
||||
} else {
|
||||
// Wraparound: dos sub-polilíneas separadas.
|
||||
let slice_a = &coords[abs_start * 2..];
|
||||
self.scratch.clear();
|
||||
project_segment(slice_a, plot, self.y_min, self.y_max, &mut self.scratch);
|
||||
if self.scratch.len() >= 4 {
|
||||
if let Some(gs) = glow_stroke {
|
||||
canvas.stroke_polyline(&self.scratch, gs);
|
||||
}
|
||||
canvas.stroke_polyline(&self.scratch, stroke);
|
||||
}
|
||||
|
||||
let remaining = seg_len - contiguous_len;
|
||||
let slice_b = &coords[..remaining * 2];
|
||||
self.scratch.clear();
|
||||
project_segment(slice_b, plot, self.y_min, self.y_max, &mut self.scratch);
|
||||
if self.scratch.len() >= 4 {
|
||||
if let Some(gs) = glow_stroke {
|
||||
canvas.stroke_polyline(&self.scratch, gs);
|
||||
}
|
||||
canvas.stroke_polyline(&self.scratch, stroke);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper builder.
|
||||
pub fn pineal_phosphor(
|
||||
buffer: RingBuffer,
|
||||
base_stroke: StrokeStyle,
|
||||
) -> LapalomaPhosphorElement {
|
||||
LapalomaPhosphorElement::new(buffer, base_stroke)
|
||||
}
|
||||
|
||||
/// Proyecta `[x_norm, y_value, …]` del ring a píxeles del plot.
|
||||
fn project_segment(segment: &[f32], plot: Rect, y_min: f32, y_max: f32, out: &mut Vec<f32>) {
|
||||
let y_span = y_max - y_min;
|
||||
if y_span.abs() < 1e-9 {
|
||||
return;
|
||||
}
|
||||
let inv = 1.0 / y_span;
|
||||
for chunk in segment.chunks_exact(2) {
|
||||
let xn = chunk[0];
|
||||
let yv = chunk[1];
|
||||
let py_norm = (yv - y_min) * inv;
|
||||
out.push(plot.x + xn * plot.w);
|
||||
out.push(plot.bottom() - py_norm * plot.h);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
//! `pineal-phosphor` — decoración CRT sobre `pineal_core::RingBuffer`.
|
||||
//!
|
||||
//! El "real" oscilloscope-trail effect de la sección 4.3 del
|
||||
//! ARCHITECTURE.md renderea cada sample como **2 vértices**
|
||||
//! (top y bottom, offset ±half_width) atados a un triangle strip
|
||||
//! con per-vertex color. GPUI 0.2 no expone triangle strips con
|
||||
//! atributos de vértice de forma directa.
|
||||
//!
|
||||
//! v0.1 implementa el efecto con un approach distinto pero
|
||||
//! visualmente similar: el trail se divide en N **segmentos**
|
||||
//! consecutivos del ring, cada uno se pinta como una `stroke_polyline`
|
||||
//! con alpha decreciente del más nuevo (1.0) al más viejo (≈ 0).
|
||||
//! Cada segmento incluye el primer sample del siguiente para no
|
||||
//! dejar gaps visibles entre tramos.
|
||||
//!
|
||||
//! Coste: N draw calls por frame en lugar de 2 del stream simple.
|
||||
//! Para N = 16 y ring cap = 512 son sub-millisecond en cualquier
|
||||
//! laptop moderna.
|
||||
//!
|
||||
//! Cuando GPUI/wgpu expongan triangle strip + per-vertex color,
|
||||
//! la siguiente fase reemplaza esta impl por la canónica.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub mod ghost {}
|
||||
pub mod magnetic_anchor {}
|
||||
|
||||
#[cfg(feature = "gpui")]
|
||||
pub mod element;
|
||||
|
||||
#[cfg(feature = "gpui")]
|
||||
pub use element::{pineal_phosphor, LapalomaPhosphorElement};
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "pineal-polar"
|
||||
version = { workspace = true }
|
||||
edition = { workspace = true }
|
||||
license = { workspace = true }
|
||||
authors = { workspace = true }
|
||||
publish = { workspace = true }
|
||||
description = "Lapaloma — gráficos polares: pie chart, donut, radar."
|
||||
|
||||
[dependencies]
|
||||
pineal-core = { path = "../core" }
|
||||
pineal-render = { path = "../render" }
|
||||
gpui = { workspace = true }
|
||||
@@ -0,0 +1,16 @@
|
||||
//! `pineal-polar` — gráficos en coordenadas polares.
|
||||
//!
|
||||
//! - **`pie`** — pie / donut chart.
|
||||
//! - **`radar`** — radar (spider) chart.
|
||||
//! - **`element`** — `Element` GPUI.
|
||||
//!
|
||||
//! No comparte mucho con cartesian; viewport y gestures van
|
||||
//! ad-hoc. El picture-cache de cartesian no aplica acá (las
|
||||
//! rotaciones lo invalidan).
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub mod pie {}
|
||||
pub mod radar {}
|
||||
pub mod element {}
|
||||
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "pineal-render"
|
||||
version = { workspace = true }
|
||||
edition = { workspace = true }
|
||||
license = { workspace = true }
|
||||
authors = { workspace = true }
|
||||
publish = { workspace = true }
|
||||
description = "Lapaloma — abstracción de painter: trait Canvas + RenderPlan + color helpers. Habilita backend CPU (gpui hoy) y GPU (wgpu mañana) sin tocar a los painters."
|
||||
|
||||
[dependencies]
|
||||
pineal-core = { path = "../core" }
|
||||
gpui = { workspace = true, optional = true }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
gpui = ["dep:gpui"]
|
||||
@@ -0,0 +1,53 @@
|
||||
//! El trait `Canvas` que todos los painters consumen.
|
||||
//!
|
||||
//! Mantenemos el set mínimo: line / polyline / rect (fill+stroke) /
|
||||
//! triangle strip. Cualquier visualización compleja (curvas
|
||||
//! bezier, gradients) se descompone en estos primitivos por el
|
||||
//! painter — el backend no necesita entender la semántica.
|
||||
//!
|
||||
//! Convención: coordenadas en píxeles del viewport, origen
|
||||
//! arriba-izquierda, +Y hacia abajo. La proyección de datos→pixel
|
||||
//! la hace el painter via las escalas de `pineal-core`.
|
||||
|
||||
use crate::{Color, Point, Rect};
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct StrokeStyle {
|
||||
pub width: f32,
|
||||
pub color: Color,
|
||||
}
|
||||
|
||||
impl StrokeStyle {
|
||||
pub const fn new(width: f32, color: Color) -> Self {
|
||||
Self { width, color }
|
||||
}
|
||||
}
|
||||
|
||||
pub trait Canvas {
|
||||
/// Clip subsiguiente al rect dado. Stack-discipline:
|
||||
/// `push_clip` + draw + `pop_clip`.
|
||||
fn push_clip(&mut self, rect: Rect);
|
||||
fn pop_clip(&mut self);
|
||||
|
||||
/// Rectángulo relleno (sin stroke).
|
||||
fn fill_rect(&mut self, rect: Rect, color: Color);
|
||||
|
||||
/// Rectángulo sólo stroke (sin fill).
|
||||
fn stroke_rect(&mut self, rect: Rect, stroke: StrokeStyle);
|
||||
|
||||
/// Línea de a→b.
|
||||
fn stroke_line(&mut self, a: Point, b: Point, stroke: StrokeStyle);
|
||||
|
||||
/// Polilínea sobre coords interleaved `[x0,y0,x1,y1,…]`.
|
||||
/// El backend la rendea como un solo draw call cuando puede.
|
||||
fn stroke_polyline(&mut self, coords: &[f32], stroke: StrokeStyle);
|
||||
|
||||
/// Triangle strip rellenado, con un color por vértice
|
||||
/// (longitudes deben coincidir: `coords.len()/2 == colors.len()`).
|
||||
/// Es lo que usa el phosphor trail y los ribbons Sankey.
|
||||
fn fill_triangle_strip(&mut self, coords: &[f32], colors: &[Color]);
|
||||
|
||||
/// Glyph de texto sencillo. El layout va a un text-cache
|
||||
/// dentro del backend; por ahora un trazo simple.
|
||||
fn draw_text(&mut self, p: Point, text: &str, color: Color, size_px: f32);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
//! Color RGBA en f32, agnóstico de backend.
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Color {
|
||||
pub r: f32,
|
||||
pub g: f32,
|
||||
pub b: f32,
|
||||
pub a: f32,
|
||||
}
|
||||
|
||||
impl Color {
|
||||
pub const TRANSPARENT: Self = Self::rgba(0.0, 0.0, 0.0, 0.0);
|
||||
pub const BLACK: Self = Self::rgb(0.0, 0.0, 0.0);
|
||||
pub const WHITE: Self = Self::rgb(1.0, 1.0, 1.0);
|
||||
|
||||
pub const fn rgb(r: f32, g: f32, b: f32) -> Self {
|
||||
Self { r, g, b, a: 1.0 }
|
||||
}
|
||||
pub const fn rgba(r: f32, g: f32, b: f32, a: f32) -> Self {
|
||||
Self { r, g, b, a }
|
||||
}
|
||||
|
||||
/// Construye desde 0xRRGGBB hex literal.
|
||||
pub fn from_hex(rgb: u32) -> Self {
|
||||
let r = ((rgb >> 16) & 0xff) as f32 / 255.0;
|
||||
let g = ((rgb >> 8) & 0xff) as f32 / 255.0;
|
||||
let b = (rgb & 0xff) as f32 / 255.0;
|
||||
Self::rgb(r, g, b)
|
||||
}
|
||||
|
||||
/// Multiplica el canal alpha — útil para fade del phosphor trail.
|
||||
pub fn with_alpha(self, a: f32) -> Self {
|
||||
Self { a, ..self }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//! Tipos geométricos mínimos en `f32`.
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Point {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
}
|
||||
|
||||
impl Point {
|
||||
pub const fn new(x: f32, y: f32) -> Self {
|
||||
Self { x, y }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Rect {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub w: f32,
|
||||
pub h: f32,
|
||||
}
|
||||
|
||||
impl Rect {
|
||||
pub const fn new(x: f32, y: f32, w: f32, h: f32) -> Self {
|
||||
Self { x, y, w, h }
|
||||
}
|
||||
pub fn right(&self) -> f32 {
|
||||
self.x + self.w
|
||||
}
|
||||
pub fn bottom(&self) -> f32 {
|
||||
self.y + self.h
|
||||
}
|
||||
pub fn contains(&self, p: Point) -> bool {
|
||||
p.x >= self.x && p.x <= self.right() && p.y >= self.y && p.y <= self.bottom()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
//! Backend CPU del trait [`crate::Canvas`] sobre `gpui::Window`.
|
||||
//!
|
||||
//! Bajo el feature `gpui`. Traduce los primitivos de Lapaloma a las
|
||||
//! llamadas nativas de GPUI 0.2 (`paint_quad`, `paint_path`). No
|
||||
//! introduce dependencia transitiva a gpui en los crates de
|
||||
//! visualización — éstos siguen hablando contra el trait abstracto;
|
||||
//! sólo el `Element` GPUI de cada widget importa este módulo.
|
||||
//!
|
||||
//! Limitaciones de la implementación CPU:
|
||||
//! - `push_clip` / `pop_clip` quedan como no-op por ahora — GPUI
|
||||
//! maneja content mask via builders de alto nivel; el chart se
|
||||
//! apoya en el bounds del Element para no pintar fuera.
|
||||
//! - `fill_triangle_strip` no implementado (lo necesitan phosphor
|
||||
//! y Sankey, que aún no están).
|
||||
//! - `draw_text` no implementado (axis labels lo necesitan; va con
|
||||
//! `WindowTextSystem` en una fase próxima).
|
||||
|
||||
use crate::{Canvas, Color, Point, Rect, StrokeStyle};
|
||||
use gpui::{
|
||||
fill, font, hsla, point as gpui_point, px, size as gpui_size, Bounds, Hsla, PathBuilder,
|
||||
SharedString, TextRun, Window,
|
||||
};
|
||||
|
||||
/// Adapter que pinta sobre un `&mut Window` de GPUI.
|
||||
///
|
||||
/// Vida útil del borrow del window iguala la de la pintura. Construir
|
||||
/// uno nuevo en cada `paint()` del Element.
|
||||
pub struct WindowCanvas<'a> {
|
||||
window: &'a mut Window,
|
||||
}
|
||||
|
||||
impl<'a> WindowCanvas<'a> {
|
||||
pub fn new(window: &'a mut Window) -> Self {
|
||||
Self { window }
|
||||
}
|
||||
}
|
||||
|
||||
/// Conversión RGB(a) → HSL(a). GPUI consume `Hsla` para casi todo
|
||||
/// el path. Linear, sin gamma — coincide con la convención del
|
||||
/// resto del codebase nahual.
|
||||
pub(crate) fn color_to_hsla(c: Color) -> Hsla {
|
||||
let (r, g, b, a) = (c.r, c.g, c.b, c.a);
|
||||
let max = r.max(g).max(b);
|
||||
let min = r.min(g).min(b);
|
||||
let l = (max + min) * 0.5;
|
||||
let delta = max - min;
|
||||
if delta.abs() < 1e-6 {
|
||||
return hsla(0.0, 0.0, l, a);
|
||||
}
|
||||
let s = if l < 0.5 { delta / (max + min) } else { delta / (2.0 - max - min) };
|
||||
let h = if max == r {
|
||||
((g - b) / delta).rem_euclid(6.0)
|
||||
} else if max == g {
|
||||
(b - r) / delta + 2.0
|
||||
} else {
|
||||
(r - g) / delta + 4.0
|
||||
};
|
||||
hsla(h / 6.0, s, l, a)
|
||||
}
|
||||
|
||||
fn to_bounds(r: Rect) -> Bounds<gpui::Pixels> {
|
||||
Bounds {
|
||||
origin: gpui_point(px(r.x), px(r.y)),
|
||||
size: gpui_size(px(r.w), px(r.h)),
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Canvas for WindowCanvas<'a> {
|
||||
fn push_clip(&mut self, _rect: Rect) {
|
||||
// Sin clip explícito por ahora. El Element pinta dentro
|
||||
// de sus bounds y los painters de pineal respetan el
|
||||
// plot_rect en sus proyecciones.
|
||||
}
|
||||
fn pop_clip(&mut self) {}
|
||||
|
||||
fn fill_rect(&mut self, rect: Rect, color: Color) {
|
||||
let hsla = color_to_hsla(color);
|
||||
self.window.paint_quad(fill(to_bounds(rect), hsla));
|
||||
}
|
||||
|
||||
fn stroke_rect(&mut self, rect: Rect, stroke: StrokeStyle) {
|
||||
// 4 line segments con PathBuilder en stroke mode.
|
||||
let mut pb = PathBuilder::stroke(px(stroke.width));
|
||||
pb.move_to(gpui_point(px(rect.x), px(rect.y)));
|
||||
pb.line_to(gpui_point(px(rect.right()), px(rect.y)));
|
||||
pb.line_to(gpui_point(px(rect.right()), px(rect.bottom())));
|
||||
pb.line_to(gpui_point(px(rect.x), px(rect.bottom())));
|
||||
pb.close();
|
||||
if let Ok(path) = pb.build() {
|
||||
self.window.paint_path(path, color_to_hsla(stroke.color));
|
||||
}
|
||||
}
|
||||
|
||||
fn stroke_line(&mut self, a: Point, b: Point, stroke: StrokeStyle) {
|
||||
let mut pb = PathBuilder::stroke(px(stroke.width));
|
||||
pb.move_to(gpui_point(px(a.x), px(a.y)));
|
||||
pb.line_to(gpui_point(px(b.x), px(b.y)));
|
||||
if let Ok(path) = pb.build() {
|
||||
self.window.paint_path(path, color_to_hsla(stroke.color));
|
||||
}
|
||||
}
|
||||
|
||||
fn stroke_polyline(&mut self, coords: &[f32], stroke: StrokeStyle) {
|
||||
if coords.len() < 4 {
|
||||
return; // <2 puntos → no hay segmento
|
||||
}
|
||||
let mut pb = PathBuilder::stroke(px(stroke.width));
|
||||
pb.move_to(gpui_point(px(coords[0]), px(coords[1])));
|
||||
let mut i = 2;
|
||||
while i + 1 < coords.len() {
|
||||
pb.line_to(gpui_point(px(coords[i]), px(coords[i + 1])));
|
||||
i += 2;
|
||||
}
|
||||
if let Ok(path) = pb.build() {
|
||||
self.window.paint_path(path, color_to_hsla(stroke.color));
|
||||
}
|
||||
}
|
||||
|
||||
fn fill_triangle_strip(&mut self, _coords: &[f32], _colors: &[Color]) {
|
||||
// TODO: cuando phosphor / Sankey lo necesiten. GPUI no
|
||||
// tiene API directa para triangle strips con per-vertex
|
||||
// color — habrá que descomponer en quads o subir un
|
||||
// vertex buffer wgpu.
|
||||
}
|
||||
|
||||
fn draw_text(&mut self, p: Point, text: &str, color: Color, size_px: f32) {
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
let hsla = color_to_hsla(color);
|
||||
let font_size = px(size_px);
|
||||
let text_str: SharedString = text.to_string().into();
|
||||
let runs = [TextRun {
|
||||
len: text.len(),
|
||||
font: font("Monospace"),
|
||||
color: hsla,
|
||||
background_color: None,
|
||||
underline: None,
|
||||
strikethrough: None,
|
||||
}];
|
||||
|
||||
let shaped = self
|
||||
.window
|
||||
.text_system()
|
||||
.shape_line(text_str, font_size, &runs, None);
|
||||
|
||||
// Iteramos glyphs vía `paint_glyph` para evitar la
|
||||
// dependencia con `&mut App` que pide `ShapedLine::paint`.
|
||||
// Eso encaja con el contrato actual del Canvas trait que
|
||||
// sólo expone `&mut Window`.
|
||||
let origin_x = px(p.x);
|
||||
let origin_y = px(p.y);
|
||||
for run in shaped.runs.iter() {
|
||||
for glyph in run.glyphs.iter() {
|
||||
let gx = origin_x + glyph.position.x;
|
||||
let gy = origin_y + glyph.position.y;
|
||||
let _ = self
|
||||
.window
|
||||
.paint_glyph(gpui_point(gx, gy), run.font_id, glyph.id, font_size, hsla);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rgb_a_hsla_grises() {
|
||||
// (0.5, 0.5, 0.5) → h=0, s=0, l=0.5
|
||||
let h = color_to_hsla(Color::rgb(0.5, 0.5, 0.5));
|
||||
assert!((h.s - 0.0).abs() < 1e-6);
|
||||
assert!((h.l - 0.5).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rgb_a_hsla_rojo_puro() {
|
||||
let h = color_to_hsla(Color::rgb(1.0, 0.0, 0.0));
|
||||
// Rojo: h=0, s=1, l=0.5
|
||||
assert!((h.h - 0.0).abs() < 1e-6);
|
||||
assert!((h.s - 1.0).abs() < 1e-6);
|
||||
assert!((h.l - 0.5).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rgb_a_hsla_alpha_pasa_directo() {
|
||||
let h = color_to_hsla(Color::rgba(0.0, 0.0, 1.0, 0.3));
|
||||
assert!((h.a - 0.3).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//! `pineal-render` — abstracción de painter.
|
||||
//!
|
||||
//! Los crates de visualización (cartesian, mesh, polar…) no
|
||||
//! conocen `gpui` ni `wgpu`. Hablan contra el trait [`Canvas`]
|
||||
//! definido acá. Eso permite:
|
||||
//!
|
||||
//! - **Backend CPU sobre gpui** — implementación por defecto;
|
||||
//! sirve para series de hasta ~50 k vértices a 60 FPS sin
|
||||
//! sudar.
|
||||
//! - **Backend GPU sobre wgpu** — placeholder hoy; cuando un
|
||||
//! módulo le pegue al wall (millones de puntos, force-sim
|
||||
//! pesada), se enchufa sin tocar la lógica de los painters.
|
||||
//! - **Backend SVG** — `pineal-export` implementa el mismo
|
||||
//! trait emitiendo elementos `<path>`, `<polyline>`, etc.
|
||||
//!
|
||||
//! Tipos primitivos (`Color`, `Point`, `Rect`) viven acá para
|
||||
//! no atarlos a `gpui::Rgba`/`gpui::Point` — los backends
|
||||
//! traducen al tipo nativo del runtime que les toca.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod color;
|
||||
pub mod geom;
|
||||
pub mod canvas;
|
||||
pub mod plan;
|
||||
|
||||
#[cfg(feature = "gpui")]
|
||||
pub mod gpui_backend;
|
||||
|
||||
pub use color::Color;
|
||||
pub use geom::{Point, Rect};
|
||||
pub use canvas::{Canvas, StrokeStyle};
|
||||
pub use plan::{RenderCmd, RenderPlan};
|
||||
|
||||
#[cfg(feature = "gpui")]
|
||||
pub use gpui_backend::WindowCanvas;
|
||||
@@ -0,0 +1,35 @@
|
||||
//! `RenderPlan` — comandos materializados para backends que no
|
||||
//! reciben llamadas en vivo (SVG export, snapshot testing).
|
||||
//!
|
||||
//! Un painter que escribe contra [`crate::Canvas`] puede ser
|
||||
//! capturado en un `RenderPlan` usando un `Canvas` adapter que
|
||||
//! empuja `RenderCmd`s en lugar de dibujar. El exporter consume
|
||||
//! el plan y emite `<polyline>` / `<rect>` / etc.
|
||||
|
||||
use crate::{Color, Point, Rect, StrokeStyle};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum RenderCmd {
|
||||
PushClip(Rect),
|
||||
PopClip,
|
||||
FillRect { rect: Rect, color: Color },
|
||||
StrokeRect { rect: Rect, stroke: StrokeStyle },
|
||||
StrokeLine { a: Point, b: Point, stroke: StrokeStyle },
|
||||
StrokePolyline { coords: Vec<f32>, stroke: StrokeStyle },
|
||||
FillTriangleStrip { coords: Vec<f32>, colors: Vec<Color> },
|
||||
DrawText { p: Point, text: String, color: Color, size_px: f32 },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct RenderPlan {
|
||||
pub cmds: Vec<RenderCmd>,
|
||||
}
|
||||
|
||||
impl RenderPlan {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
pub fn push(&mut self, cmd: RenderCmd) {
|
||||
self.cmds.push(cmd);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "pineal-stream"
|
||||
version = { workspace = true }
|
||||
edition = { workspace = true }
|
||||
license = { workspace = true }
|
||||
authors = { workspace = true }
|
||||
publish = { workspace = true }
|
||||
description = "Lapaloma — widget de telemetría tipo osciloscopio. Ring buffer + envelope min/max por columna + render en dos segmentos (split at head)."
|
||||
|
||||
[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,208 @@
|
||||
//! `LapalomaStreamElement` — Element GPUI para visualización de
|
||||
//! telemetría con RingBuffer.
|
||||
//!
|
||||
//! Modo **sweep** (canónico del osciloscopio):
|
||||
//! - Los slots `[0, capacity)` tienen `x_norm` fijo precomputado.
|
||||
//! - El `head` marca el slot donde se va a escribir el próximo
|
||||
//! sample → visualmente, el "cursor" que separa la traza vieja
|
||||
//! (a la derecha del head) de la nueva (a la izquierda).
|
||||
//! - Render en **dos segmentos** split-at-head para evitar la
|
||||
//! línea horizontal del wraparound. Antes del fill (count <
|
||||
//! capacity), sólo se pinta `[0, head)`.
|
||||
|
||||
use std::panic;
|
||||
|
||||
use gpui::{
|
||||
App, Bounds, Element, ElementId, GlobalElementId, InspectorElementId, IntoElement, LayoutId,
|
||||
Pixels, Style, Window,
|
||||
};
|
||||
|
||||
use pineal_core::ring::RingBuffer;
|
||||
use pineal_render::{Canvas, Color, Rect, StrokeStyle, WindowCanvas};
|
||||
|
||||
/// Element que pinta un `RingBuffer` en modo sweep.
|
||||
pub struct LapalomaStreamElement {
|
||||
pub buffer: RingBuffer,
|
||||
pub stroke: StrokeStyle,
|
||||
pub background: Option<Color>,
|
||||
/// Rango Y para proyectar los samples a píxeles. Default `-1..1`.
|
||||
pub y_min: f32,
|
||||
pub y_max: f32,
|
||||
pub padding: f32,
|
||||
/// Scratch reusable entre los dos segmentos del frame.
|
||||
scratch: Vec<f32>,
|
||||
}
|
||||
|
||||
impl LapalomaStreamElement {
|
||||
pub fn new(buffer: RingBuffer, stroke: StrokeStyle) -> Self {
|
||||
Self {
|
||||
buffer,
|
||||
stroke,
|
||||
background: None,
|
||||
y_min: -1.0,
|
||||
y_max: 1.0,
|
||||
padding: 8.0,
|
||||
scratch: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn background(mut self, color: Color) -> Self {
|
||||
self.background = Some(color);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn y_range(mut self, min: f32, max: f32) -> Self {
|
||||
debug_assert!(max > min);
|
||||
self.y_min = min;
|
||||
self.y_max = max;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn padding(mut self, px: f32) -> Self {
|
||||
self.padding = px;
|
||||
self
|
||||
}
|
||||
|
||||
fn plot_rect(&self, bounds: Rect) -> Rect {
|
||||
Rect::new(
|
||||
bounds.x + self.padding,
|
||||
bounds.y + self.padding,
|
||||
(bounds.w - self.padding * 2.0).max(1.0),
|
||||
(bounds.h - self.padding * 2.0).max(1.0),
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// Proyecta una slice de coords `[x_norm, y_value, …]` del
|
||||
/// RingBuffer al sistema de píxeles del plot. `out` se extiende
|
||||
/// (no se clearea acá; el caller decide).
|
||||
fn project_segment(segment: &[f32], plot: Rect, y_min: f32, y_max: f32, out: &mut Vec<f32>) {
|
||||
let y_span = y_max - y_min;
|
||||
if y_span.abs() < 1e-9 {
|
||||
return;
|
||||
}
|
||||
let inv_y_span = 1.0 / y_span;
|
||||
for chunk in segment.chunks_exact(2) {
|
||||
let xn = chunk[0];
|
||||
let yv = chunk[1];
|
||||
let py_norm = (yv - y_min) * inv_y_span;
|
||||
let px = plot.x + xn * plot.w;
|
||||
let py = plot.bottom() - py_norm * plot.h;
|
||||
out.push(px);
|
||||
out.push(py);
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoElement for LapalomaStreamElement {
|
||||
type Element = Self;
|
||||
fn into_element(self) -> Self::Element {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for LapalomaStreamElement {
|
||||
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 mut canvas = WindowCanvas::new(window);
|
||||
|
||||
if let Some(bg) = self.background {
|
||||
canvas.fill_rect(outer, bg);
|
||||
}
|
||||
|
||||
let coords = self.buffer.coords();
|
||||
let head = self.buffer.head();
|
||||
let cap = self.buffer.capacity();
|
||||
|
||||
if !self.buffer.is_full() {
|
||||
// Pre-fill: sólo [0, head). Evita la línea plana del
|
||||
// 1.0.2 fix del Flutter doc.
|
||||
let filled = head;
|
||||
if filled >= 2 {
|
||||
let slice = &coords[..filled * 2];
|
||||
self.scratch.clear();
|
||||
project_segment(slice, plot, self.y_min, self.y_max, &mut self.scratch);
|
||||
canvas.stroke_polyline(&self.scratch, self.stroke);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Ya filled — dos segmentos split-at-head.
|
||||
let split = head * 2;
|
||||
// Segmento "viejo": [head*2 .. cap*2) — temporalmente más antiguo.
|
||||
if split < cap * 2 {
|
||||
let seg1 = &coords[split..];
|
||||
if seg1.len() >= 4 {
|
||||
self.scratch.clear();
|
||||
project_segment(seg1, plot, self.y_min, self.y_max, &mut self.scratch);
|
||||
canvas.stroke_polyline(&self.scratch, self.stroke);
|
||||
}
|
||||
}
|
||||
// Segmento "nuevo": [0 .. head*2) — más reciente, dibujado a
|
||||
// la izquierda del cursor.
|
||||
if split > 0 {
|
||||
let seg2 = &coords[..split];
|
||||
if seg2.len() >= 4 {
|
||||
self.scratch.clear();
|
||||
project_segment(seg2, plot, self.y_min, self.y_max, &mut self.scratch);
|
||||
canvas.stroke_polyline(&self.scratch, self.stroke);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper builder-style.
|
||||
pub fn pineal_stream(buffer: RingBuffer, stroke: StrokeStyle) -> LapalomaStreamElement {
|
||||
LapalomaStreamElement::new(buffer, stroke)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
//! `pineal-stream` — telemetría streaming tipo osciloscopio.
|
||||
//!
|
||||
//! Núcleo: `pineal_core::ring::RingBuffer` + render en dos
|
||||
//! segmentos split-at-head (modo sweep). El emisor de samples vive
|
||||
//! afuera del Element — típicamente en el `Render` host con un
|
||||
//! timer `cx.background_executor().timer(...)` que llama a
|
||||
//! `buffer.push(value)` y `cx.notify()` cada N ms.
|
||||
//!
|
||||
//! El Element clona el RingBuffer por frame (para cap = 512 son
|
||||
//! 4 KB, irrelevante). Para capacidades grandes (100k+) la siguiente
|
||||
//! optimización es pasar `Arc<RingBuffer>` con shared read y
|
||||
//! mutación interna via `Mutex`/`AtomicU64` para el head.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub mod envelope {}
|
||||
|
||||
#[cfg(feature = "gpui")]
|
||||
pub mod element;
|
||||
|
||||
#[cfg(feature = "gpui")]
|
||||
pub use element::{pineal_stream, LapalomaStreamElement};
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "pineal-treemap"
|
||||
version = { workspace = true }
|
||||
edition = { workspace = true }
|
||||
license = { workspace = true }
|
||||
authors = { workspace = true }
|
||||
publish = { workspace = true }
|
||||
description = "Lapaloma — treemap con algoritmo squarified (Bruls / d3-hierarchy formulation)."
|
||||
|
||||
[dependencies]
|
||||
pineal-core = { path = "../core" }
|
||||
pineal-render = { path = "../render" }
|
||||
gpui = { workspace = true }
|
||||
@@ -0,0 +1,12 @@
|
||||
//! `pineal-treemap` — treemap squarified.
|
||||
//!
|
||||
//! Algoritmo en `pineal_core::squarify` (placeholder); el `Element`
|
||||
//! sólo se encarga de iterar las tiles resultantes y dibujarlas.
|
||||
//! Pre-scaling de valores al area total del rect es clave para
|
||||
//! estabilidad numérica con rangos amplios.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub mod tile {}
|
||||
pub mod element {}
|
||||
@@ -0,0 +1,41 @@
|
||||
[package]
|
||||
name = "pineal"
|
||||
version = { workspace = true }
|
||||
edition = { workspace = true }
|
||||
license = { workspace = true }
|
||||
authors = { workspace = true }
|
||||
publish = { workspace = true }
|
||||
description = "Lapaloma — paraguas: re-exporta los módulos para prototipos. En producción importar los crates hoja directamente para que tree-shaking descarte lo no usado."
|
||||
|
||||
[dependencies]
|
||||
pineal-core = { path = "../core", optional = true }
|
||||
pineal-render = { path = "../render", optional = true }
|
||||
pineal-cartesian = { path = "../cartesian", optional = true }
|
||||
pineal-stream = { path = "../stream", optional = true }
|
||||
pineal-mesh = { path = "../mesh", optional = true }
|
||||
pineal-financial = { path = "../financial", optional = true }
|
||||
pineal-polar = { path = "../polar", optional = true }
|
||||
pineal-heatmap = { path = "../heatmap", optional = true }
|
||||
pineal-treemap = { path = "../treemap", optional = true }
|
||||
pineal-flow = { path = "../flow", optional = true }
|
||||
pineal-phosphor = { path = "../phosphor", optional = true }
|
||||
pineal-export = { path = "../export", optional = true }
|
||||
|
||||
[features]
|
||||
default = ["full"]
|
||||
full = [
|
||||
"core", "render", "cartesian", "stream", "mesh", "financial",
|
||||
"polar", "heatmap", "treemap", "flow", "phosphor", "export",
|
||||
]
|
||||
core = ["dep:pineal-core"]
|
||||
render = ["dep:pineal-render", "core"]
|
||||
cartesian = ["dep:pineal-cartesian", "render"]
|
||||
stream = ["dep:pineal-stream", "render"]
|
||||
mesh = ["dep:pineal-mesh", "render"]
|
||||
financial = ["dep:pineal-financial", "cartesian"]
|
||||
polar = ["dep:pineal-polar", "render"]
|
||||
heatmap = ["dep:pineal-heatmap", "render"]
|
||||
treemap = ["dep:pineal-treemap", "render"]
|
||||
flow = ["dep:pineal-flow", "render"]
|
||||
phosphor = ["dep:pineal-phosphor", "stream"]
|
||||
export = ["dep:pineal-export", "render"]
|
||||
@@ -0,0 +1,53 @@
|
||||
//! `pineal` — paraguas re-export.
|
||||
//!
|
||||
//! Para **prototipos** que quieren probar varios módulos a la vez
|
||||
//! sin agregar 8 dependencias a `Cargo.toml`. En producción
|
||||
//! preferir importar directamente los crates hoja (`pineal-core`,
|
||||
//! `pineal-cartesian`, …) para que el linker descarte lo no
|
||||
//! usado y los tiempos de compilación bajen.
|
||||
//!
|
||||
//! Las features mapean 1:1 a cada sub-crate:
|
||||
//!
|
||||
//! ```toml
|
||||
//! [dependencies]
|
||||
//! pineal = { workspace = true, default-features = false,
|
||||
//! features = ["cartesian", "stream"] }
|
||||
//! ```
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
#[cfg(feature = "core")]
|
||||
pub use pineal_core as core;
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
pub use pineal_render as render;
|
||||
|
||||
#[cfg(feature = "cartesian")]
|
||||
pub use pineal_cartesian as cartesian;
|
||||
|
||||
#[cfg(feature = "stream")]
|
||||
pub use pineal_stream as stream;
|
||||
|
||||
#[cfg(feature = "mesh")]
|
||||
pub use pineal_mesh as mesh;
|
||||
|
||||
#[cfg(feature = "financial")]
|
||||
pub use pineal_financial as financial;
|
||||
|
||||
#[cfg(feature = "polar")]
|
||||
pub use pineal_polar as polar;
|
||||
|
||||
#[cfg(feature = "heatmap")]
|
||||
pub use pineal_heatmap as heatmap;
|
||||
|
||||
#[cfg(feature = "treemap")]
|
||||
pub use pineal_treemap as treemap;
|
||||
|
||||
#[cfg(feature = "flow")]
|
||||
pub use pineal_flow as flow;
|
||||
|
||||
#[cfg(feature = "phosphor")]
|
||||
pub use pineal_phosphor as phosphor;
|
||||
|
||||
#[cfg(feature = "export")]
|
||||
pub use pineal_export as export;
|
||||
Reference in New Issue
Block a user