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,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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user