feat: pineal standalone — catálogo de painters GPU sobre Llimphi (front-door liviano)

Visualización backend-agnóstica (cartesian/polar/mesh/treemap/phosphor/flow/
heatmap/stream/financial/hexbin/contour/bars) sobre el trait Canvas. Front-door
liviano: única dep externa es Llimphi (git-dep a llimphi.git, app-bus incluido)
— sin clonar el monorepo. 16 crates lib + 14 demos (galería + GPU 1M primitivas).
cargo check --workspace pasa (0 errores).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-04 11:47:26 +00:00
commit 514f17ba57
149 changed files with 18852 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
/target
**/*.rs.bk
*.pdb
+127
View File
@@ -0,0 +1,127 @@
# pineal
> Visualización agnóstica del backend. El "tercer ojo" del monorepo.
Pineal es un catálogo de painters especializados — cartesiano, polar,
mesh, treemap, phosphor, flow, heatmap, stream, financial, hexbin,
contour y bars — sobre una **única abstracción de pintura**, el trait
`Canvas`. Cualquier dominio del workspace puede empujar formas a un
pineal y obtener pixels sin cargar pipeline gráfica propia. Toda la
cadena `core → render → painter` es agnóstica del backend: el mismo
painter dibuja sobre vello/llimphi en pantalla, sobre PNG/SVG/PDF
software offline, o directo a la GPU para millones de primitivas.
Pineal **no calcula** — sólo dibuja. La simulación vive en `cosmos`,
`dominium`, `tinkuy`, `chasqui`, etc.; pineal recibe el output y lo
materializa en pixels. Es el martillo, no el carpintero.
> Estado (2026-06-01): **cerrado**. El catálogo cubre las familias de
> gráficos comunes; no tiene roadmap propio pendiente. El diseño
> autoritativo está en [`SDD.md`](SDD.md).
## Arranque rápido — la galería
La forma más rápida de ver todo es la galería, que muestra 11 painters en
una sola ventana:
```sh
cargo run --release -p pineal-galeria-demo
```
El camino GPU denso tiene su propio showcase — un starfield 3D que empuja
hasta **1 M de primitivas por frame** en una sola draw call instanciada:
```sh
cargo run --release -p pineal-gpu-demo # D = densidad 50K→1M · espacio = pausa
```
## Todos los demos
```sh
cargo run --release -p pineal-galeria-demo # galería: 11 painters en una ventana
cargo run --release -p pineal-gpu-demo # starfield warp 3D — GPU directo (1M)
cargo run --release -p pineal-demo # cartesiano multi-serie (zoom a cursor)
cargo run --release -p pineal-bars-demo # columnas · horizontales · agrupadas · apiladas · histograma
cargo run --release -p pineal-polar-demo # pie/donut + radar
cargo run --release -p pineal-treemap-demo # treemap squarified
cargo run --release -p pineal-heatmap-demo # heatmap 48×32 viviente (Viridis)
cargo run --release -p pineal-hexbin-demo # scatter density hexbin
cargo run --release -p pineal-contour-demo # marching squares + heatmap base
cargo run --release -p pineal-flow-demo # Sankey
cargo run --release -p pineal-mesh-demo # grafo force-directed
cargo run --release -p pineal-phosphor-demo # trail tipo CRT (fósforo)
cargo run --release -p pineal-stream-demo # osciloscopio sintético (zero-alloc)
cargo run --release -p pineal-financial-demo # candlesticks OHLC
```
## Las tres reglas (invariantes)
1. **Zero boxing.** Los datos viven en `Vec<f32>` planos interleaved
(`[x0, y0, x1, y1, …]`), nunca `Vec<Point>`. Calientes en L1,
SIMD-loopables, listos para vertex buffer sin transformación.
2. **Zero alloc en el hot path.** Los buffers se reservan al construir y
se mutan in-place para siempre. Los helpers escriben a un `&mut Vec`
provisto por el caller, no devuelven uno nuevo. El `RingBuffer` del
stream lo demuestra: `push(v)` son 2 escrituras + 2 increments.
3. **Una draw call por capa.** Los painters teselan en un solo
polyline / triangle-strip / batch instanciado por serie. El backend
pinta cada uno como una draw call cuando puede.
## Backends
| Backend | Dónde | Notas |
|---|---|---|
| **vello / llimphi** (`SceneCanvas`) | `pineal-render::llimphi_backend` | En pantalla. Pinta en `View::paint_with`. ~100 K primitivas. |
| **GPU directo wgpu** (`GpuSceneCanvas`) | `pineal-render::gpu_canvas` | 0.110 M primitivas en un batch instanciado. Pinta en `View::gpu_paint_with`. Sin texto, sin AA fino (por diseño). |
| **SVG vectorial** (`to_svg`) | `pineal-export::svg` | Vector real `<rect>`/`<polyline>`/`<polygon>`. |
| **PNG raster** (`to_png`) | `pineal-export::png` | Rasterizador software propio, AA 2×2. Sin `tiny-skia`/`cairo`. |
| **PDF** (`to_pdf`) | `pineal-export::pdf` | Writer propio (sin `printpdf`), 1 página, operadores PDF-1.4. Con decimación LTTB contextual. |
El texto se omite en los caminos GPU y PNG a propósito — usar SVG (o una
pasada vello hermana) cuando hagan falta etiquetas.
## Compatibilidad
- **Linux / macOS / Windows** — render Llimphi (vello/wgpu).
- **Wawa bare-metal** — Llimphi corre directo sobre el framebuffer; mismo
árbol gráfico, mismos painters.
## Crates
### Núcleo, render y export
| Crate | Rol |
|---|---|
| [`pineal-core`](pineal-core/) | Algoritmos: buffers planos, ring, índice espacial, decimación LTTB, escalas. Sin gráficos. |
| [`pineal-render`](pineal-render/) | El trait `Canvas` + `SceneCanvas` (vello), `GpuSceneCanvas` (wgpu) y `PlanRecorder` (replay diferido). |
| [`pineal-export`](pineal-export/) | `RenderPlan` → SVG + PNG + PDF. |
| [`pineal-umbrella`](pineal-umbrella/) (crate `pineal`) | Re-export del catálogo entero bajo features. Cómodo en prototipos; en producción importar los crates hoja para que tree-shaking descarte lo demás. |
### Painters
| Crate | Painter | Algoritmo clave |
|---|---|---|
| [`pineal-cartesian`](pineal-cartesian/) | `ChartView` | Ticks log/lin, viewport con zoom anclado, cache de panning. |
| [`pineal-polar`](pineal-polar/) | `paint_pie`, `paint_radar` | Wedge teselado a 96 segs/vuelta, fan para radar. |
| [`pineal-mesh`](pineal-mesh/) | `paint_graph`, `tree_layout`, `ForceLayout`, `bundle` | Fruchterman-Reingold O(n²) + Barnes-Hut O(n log n), Sugiyama-lite, bundling FDEB. |
| [`pineal-treemap`](pineal-treemap/) | `paint_treemap` | Squarified (Bruls / d3-hierarchy). |
| [`pineal-phosphor`](pineal-phosphor/) | trail tipo CRT | Triangle strip con alpha decay + glow. |
| [`pineal-flow`](pineal-flow/) | `paint_sankey` | Longest-path + barycenter + ribbons smoothstep. |
| [`pineal-heatmap`](pineal-heatmap/) | `paint`, `encode_argb` | Ramp Viridis + textura para matrices grandes. |
| [`pineal-stream`](pineal-stream/) | `pineal_stream_view` | Sweep oscilloscope split-at-head. |
| [`pineal-financial`](pineal-financial/) | `paint_candles` | OHLC + agregación por bucket temporal. |
| [`pineal-hexbin`](pineal-hexbin/) | `paint_hexbin` | Bineado hexagonal pointy-top + ramp Viridis. |
| [`pineal-contour`](pineal-contour/) | `paint_contours` | Marching squares (16 casos) → polilíneas por nivel. |
| [`pineal-bars`](pineal-bars/) | `paint_bars`, `paint_grouped`, `paint_stacked` | Columnas/barras vertical u horizontal, agrupadas y apiladas, baseline con negativos; `Histogram` binea `&[f32]` → barras. |
## Consideraciones
- pineal **no calcula** — sólo dibuja. Para correr una simulación hablás
con `dominium`, `tinkuy`, `cosmos`, etc., y le pasás el resultado.
- El export a SVG es vector real (no captura de píxeles). El PNG es un
rasterizador software diminuto — sin stack gráfico nativo. El texto se
omite en PNG y en el camino GPU (usar SVG o una pasada vello hermana).
- El único techo es el del motor: vello satura cerca de 1 M de primitivas
por frame. El camino GPU directo (`GpuSceneCanvas`) es justamente la vía
para superarlo — y es un asunto horizontal de `llimphi-raster`, no de
pineal. Ver [`02_ruway/llimphi/SDD.md`](../../02_ruway/llimphi/SDD.md).
+136
View File
@@ -0,0 +1,136 @@
# pineal
> Backend-agnostic visualization. The "third eye" of the monorepo.
Pineal is a catalog of specialized painters — cartesian, polar, mesh,
treemap, phosphor, flow, heatmap, stream, financial, hexbin, contour and
bars — over a **single painter abstraction**, the `Canvas` trait. Any
domain in the workspace can push shapes to a pineal and get pixels
without carrying its own graphics stack. The whole chain
`core → render → painter` is agnostic of the graphics backend: the same
painter draws to vello/llimphi on screen, to a software PNG/SVG/PDF
offline, or straight to the GPU for millions of primitives.
Pineal **does not compute** — it only draws. Simulation lives in
`cosmos`, `dominium`, `tinkuy`, `chasqui`, etc.; pineal takes the output
and turns it into pixels. It's the hammer, not the carpenter.
> Status (2026-06-01): **closed**. The catalog covers the common chart
> families; there is no pending roadmap of its own. See
> [`SDD.md`](SDD.md) for the authoritative design.
## Quick start — the gallery
The fastest way to see everything is the gallery, which tiles 11 painters
in one window:
```sh
cargo run --release -p pineal-galeria-demo
```
The dense GPU path has its own showcase — a 3D starfield that pushes up
to **1 M primitives per frame** through a single instanced draw call:
```sh
cargo run --release -p pineal-gpu-demo # D = densidad 50K→1M · espacio = pausa
```
## All demos
```sh
cargo run --release -p pineal-galeria-demo # galería: 11 painters en una ventana
cargo run --release -p pineal-gpu-demo # starfield warp 3D — GPU directo (1M)
cargo run --release -p pineal-demo # cartesian multi-series (zoom a cursor)
cargo run --release -p pineal-bars-demo # columnas · horizontales · agrupadas · apiladas · histograma
cargo run --release -p pineal-polar-demo # pie/donut + radar
cargo run --release -p pineal-treemap-demo # treemap squarified
cargo run --release -p pineal-heatmap-demo # heatmap 48×32 viviente (Viridis)
cargo run --release -p pineal-hexbin-demo # scatter density hexbin
cargo run --release -p pineal-contour-demo # marching squares + heatmap base
cargo run --release -p pineal-flow-demo # Sankey
cargo run --release -p pineal-mesh-demo # grafo force-directed
cargo run --release -p pineal-phosphor-demo # trail tipo CRT (fósforo)
cargo run --release -p pineal-stream-demo # osciloscopio sintético (zero-alloc)
cargo run --release -p pineal-financial-demo # candlesticks OHLC
```
## The three rules (invariants)
1. **Zero boxing.** Data lives in flat interleaved `Vec<f32>`
(`[x0, y0, x1, y1, …]`), never `Vec<Point>`. Hot in L1, SIMD-loopable,
ready for a vertex buffer with no transformation.
2. **Zero alloc on the hot path.** Buffers are reserved at construction
and mutated in place forever. Helpers write into a caller-provided
`&mut Vec`, they don't return fresh ones. The stream `RingBuffer`
proves it: `push(v)` is two writes plus two increments.
3. **One draw call per layer.** Painters tessellate into a single
polyline / triangle-strip / instanced batch per series. The backend
draws each as one call when it can.
## Backends
| Backend | Where | Notes |
|---|---|---|
| **vello / llimphi** (`SceneCanvas`) | `pineal-render::llimphi_backend` | On-screen. Paints inside `View::paint_with`. ~100 K primitives. |
| **GPU direct wgpu** (`GpuSceneCanvas`) | `pineal-render::gpu_canvas` | 0.110 M primitives via one instanced batch. Paints inside `View::gpu_paint_with`. No text, no fine AA (by design). |
| **SVG vector** (`to_svg`) | `pineal-export::svg` | True vector `<rect>`/`<polyline>`/`<polygon>`. |
| **PNG raster** (`to_png`) | `pineal-export::png` | Own software rasterizer, 2×2 AA. No `tiny-skia`/`cairo`. |
| **PDF** (`to_pdf`) | `pineal-export::pdf` | Own writer (no `printpdf`), one page, PDF-1.4 operators. With LTTB contextual decimation. |
Text is omitted on the GPU and PNG paths on purpose — use SVG (or a
sibling vello pass) when you need labels.
## Compatibility
- **Linux / macOS / Windows** — Llimphi (vello/wgpu) rendering.
- **Wawa bare-metal** — Llimphi runs straight on the framebuffer; same
scene tree, same painters.
## Crates
### Core, render & export
| Crate | Role |
|---|---|
| [`pineal-core`](pineal-core/) | Algorithms: flat buffers, ring, spatial index, LTTB decimation, scales. No graphics. |
| [`pineal-render`](pineal-render/) | The `Canvas` trait + `SceneCanvas` (vello), `GpuSceneCanvas` (wgpu) and `PlanRecorder` (deferred replay). |
| [`pineal-export`](pineal-export/) | `RenderPlan` → SVG + PNG + PDF. |
| [`pineal-umbrella`](pineal-umbrella/) (crate `pineal`) | Feature-gated re-export of the whole catalog. Handy in prototypes; in production import the leaf crates so tree-shaking drops the rest. |
### Painters
| Crate | Painter | Key algorithm |
|---|---|---|
| [`pineal-cartesian`](pineal-cartesian/) | `ChartView` | Log/lin ticks, zoom-anchored viewport, panning cache. |
| [`pineal-polar`](pineal-polar/) | `paint_pie`, `paint_radar` | Wedge tessellation at 96 segs/turn, radar fan. |
| [`pineal-mesh`](pineal-mesh/) | `paint_graph`, `tree_layout`, `ForceLayout`, `bundle` | Fruchterman-Reingold O(n²) + Barnes-Hut O(n log n), Sugiyama-lite, FDEB bundling. |
| [`pineal-treemap`](pineal-treemap/) | `paint_treemap` | Squarified (Bruls / d3-hierarchy). |
| [`pineal-phosphor`](pineal-phosphor/) | CRT-style trail | Triangle strip with alpha decay + glow. |
| [`pineal-flow`](pineal-flow/) | `paint_sankey` | Longest-path + barycenter + smoothstep ribbons. |
| [`pineal-heatmap`](pineal-heatmap/) | `paint`, `encode_argb` | Viridis ramp + texture for large matrices. |
| [`pineal-stream`](pineal-stream/) | `pineal_stream_view` | Split-at-head sweep oscilloscope. |
| [`pineal-financial`](pineal-financial/) | `paint_candles` | OHLC + time-bucket aggregation. |
| [`pineal-hexbin`](pineal-hexbin/) | `paint_hexbin` | Pointy-top hexagonal binning + Viridis ramp. |
| [`pineal-contour`](pineal-contour/) | `paint_contours` | Marching squares (16 cases) → per-level polylines. |
| [`pineal-bars`](pineal-bars/) | `paint_bars`, `paint_grouped`, `paint_stacked` | Columns/bars vertical or horizontal, grouped and stacked, baseline with negatives; `Histogram` bins `&[f32]` → bars. |
## Considerations
- pineal **doesn't compute** — only draws. To run a simulation talk to
[`dominium`](../../01_yachay/dominium/), [`tinkuy`](../../01_yachay/tinkuy/),
[`cosmos`](../../01_yachay/cosmos/), etc., and feed it the result.
- SVG export is true vector (not a pixel capture). PNG export is a tiny
software rasterizer — no native graphics stack. Text is skipped in PNG
and on the GPU path (use SVG or a sibling vello pass for labels).
- The only ceiling is the engine's: vello tops out around 1 M primitives
per frame. The GPU-direct path (`GpuSceneCanvas`) is exactly the way
past it — and it's a horizontal concern of `llimphi-raster`, not of
pineal. See [`02_ruway/llimphi/SDD.md`](../../02_ruway/llimphi/SDD.md).
## Tests
`cargo test -p <crate>`. 140+ green across the catalog: `pineal-core`
(buffers, ring, spatial, LTTB, scale), `pineal-render` (color + recorder
roundtrip), `pineal-mesh` (Barnes-Hut vs naïve, Sugiyama), `pineal-bars`
(simple/grouped/stacked + histogram), `pineal-export` (SVG/PNG byte
validation), and 413 per painter via `PlanRecorder`.
+30
View File
@@ -0,0 +1,30 @@
<!-- Quechua (Cusco/Collao). Revisión bienvenida. -->
# pineal
> Backend mana hap'iq rikuchikuy. Monorepuq "kimsa-ñawin".
Canvas tukuylla (cartesiano · polar · mesh · treemap · phosphor · flow · heatmap · stream · financial · umbrella), huk Llimphi `SceneCanvas` patapi. Ima willay kawsasqakunapas (cosmos, dominium, nakui, tinkuy, chasqui) pineal-man k'antinkunata haywariy atin, sapan grafica pipeline mana kanchu.
## Churay
```sh
cargo run --release -p pineal-demo
cargo run --release -p pineal-financial-demo
cargo run --release -p pineal-phosphor-demo
cargo run --release -p pineal-stream-demo
```
## Tinkuy
- **Linux / macOS / Windows** — Llimphi (vello/wgpu) renderiy.
- **Wawa bare-metal** — Llimphi framebuffer patapi; kikin escena sach'a.
## Crateskuna
Tukuy crates yachachiyninwan iskay versionkunapi: [`pineal-core`](pineal-core/README.md) escena modelo, [`pineal-render`](pineal-render/README.md) Llimphi rikuchikuy, [`pineal-{cartesian,polar,mesh,treemap,phosphor,flow,heatmap,stream,financial,umbrella}`](pineal-cartesian/README.md) sapanka layakuna, [`pineal-export`](pineal-export/README.md) PNG/SVG/GIF qatinapaq, [`pineal-{demo,financial-demo,phosphor-demo,stream-demo}`](pineal-demo/README.md) muestrakuna.
## Yuyaykunaq
- pineal **manan yupanchu** — siq'illanmi. Simulación ruwananpaqqa [`dominium`](../../01_yachay/dominium/README.md), [`tinkuy`](../../01_yachay/tinkuy/README.md), [`cosmos`](../../01_yachay/cosmos/README.md), chaykunawan rimay, hinaspa kutichiyninta pineal-man qun.
- SVG qatinaqa cheqaq vector kanmi (mana píxeles laqasqachu).
+244
View File
@@ -0,0 +1,244 @@
# SDD — pineal
> Backend-agnostic visualization. El "tercer ojo" del monorepo.
Pineal es un catálogo de canvases especializados (cartesian, polar, mesh,
treemap, phosphor, flow, heatmap, stream, financial) sobre una única
abstracción de painter. Cualquier dominio del workspace puede empujar
formas a un pineal y obtener pintura sin cargar con su propio stack
gráfico.
## 0. Posición en el monorepo
Cuadrante: `00_unanchay/` (PERCIBIR). Pineal **no computa** — sólo dibuja.
La simulación vive en `cosmos`, `dominium`, `tinkuy`, `chasqui`, etc.;
pineal recibe el output y lo materializa en pixels.
## 1. Invariantes (las tres reglas)
**P1 — Zero boxing.** Los datos viven en `Vec<f32>` planos
interleaved `[x0, y0, x1, y1, ...]`, nunca como `Vec<Point2D>`. Hot
en cache L1, SIMD-loopable por el compilador, listo para vertex buffer
sin transformación. Aplica a `DataBuffer`, `RingBuffer`, `NodeBuffer`.
**P2 — Zero alloc en hot path.** Buffers se reservan al construir y se
mutan in-place para siempre. Helpers escriben a `&mut Vec` provistos por
el caller, no devuelven `Vec` nuevos. El `RingBuffer` del stream
demuestra esto: `push(v)` son 2 escrituras + 2 increments.
**P3 — Una draw call por capa.** Los painters tesselan en un solo
`polyline` / `triangle_strip` por serie. El backend pinta cada uno como
un draw call cuando puede.
## 2. Topología de crates
```
pineal-core ─┬─ buffer, ring, spatial, lttb, scale
└─ (algoritmos puros — sin gráficos)
pineal-render ──── trait Canvas
├── SceneCanvas (backend vello/llimphi)
└── PlanRecorder (replay diferido)
pineal-{cartesian, polar, mesh, treemap, phosphor, flow,
heatmap, stream, financial, hexbin, contour, bars} ── painters
pineal-export ── consume RenderPlan → SVG + PNG
pineal-umbrella ── re-exports todo bajo features (cómodo en prototipos)
```
Regla dura: los painters hablan **únicamente** contra el trait `Canvas`
de `pineal-render`. No conocen el runtime UI. Esto deja toda la cadena
`core → render → painter` agnóstica del backend gráfico.
## 3. El trait `Canvas`
Set mínimo deliberado — cualquier viz compleja se descompone en estos
primitivos por el painter, no por el backend:
```rust
trait Canvas {
fn push_clip(&mut self, rect: Rect);
fn pop_clip(&mut self);
fn fill_rect(&mut self, rect: Rect, color: Color);
fn stroke_rect(&mut self, rect: Rect, stroke: StrokeStyle);
fn stroke_line(&mut self, a: Point, b: Point, stroke: StrokeStyle);
fn stroke_polyline(&mut self, coords: &[f32], stroke: StrokeStyle);
fn fill_triangle_strip(&mut self, coords: &[f32], colors: &[Color]);
fn draw_text(&mut self, p: Point, text: &str, color: Color, size_px: f32);
}
```
Convención de coordenadas: pixels absolutos del scene, origen
arriba-izquierda, +Y hacia abajo. La proyección datos→pixel la hace el
painter vía las escalas de `pineal-core`.
## 4. Backends activos
| Backend | Status | Ubicación |
|---|---|---|
| **vello/llimphi** (`SceneCanvas`) | Producción. Pinta en `View::paint_with`. | `pineal-render::llimphi_backend` |
| **SVG vectorial** (`to_svg`) | Producción. Emite `<rect>`/`<polyline>`/`<polygon>`. | `pineal-export::svg` |
| **PNG raster** (`to_png`) | Producción. Software rasterizer propio con AA 2×2. | `pineal-export::png` |
| **PDF** (`to_pdf`) | Producción. Writer propio (sin `printpdf`), 1 página, operadores PDF-1.4. | `pineal-export::pdf` |
| **GPU directo `wgpu`** (`GpuSceneCanvas`) | Producción. Para 0.110 M primitivas. Pinta en `View::gpu_paint_with`, sin texto y sin AA fino. | `pineal-render::gpu_canvas` |
El rasterizador PNG es propio para no depender de `tiny-skia`/`cairo`/etc.
Texto se omite a propósito — para labels usar SVG. Coverage 2×2 (4
samples por pixel) da AA suficiente para reportes y dashboards.
## 5. Canvases (painters)
| Crate | Painter | Algoritmo clave |
|---|---|---|
| `pineal-cartesian` | `ChartView` | Ticks por escala log/lin, viewport con zoom anclado, cache de panning |
| `pineal-polar` | `paint_pie`, `paint_radar` | Wedge teselado a 96 segs/vuelta, fan para radar |
| `pineal-mesh` | `paint_graph`, `tree_layout`, `ForceLayout`, `bundle` | Fruchterman-Reingold O(n²) y Barnes-Hut O(n log n), Sugiyama-lite layered, FDEB-lite para bundling |
| `pineal-treemap` | `paint_treemap` | Squarified (Bruls / d3-hierarchy) |
| `pineal-phosphor` | trail tipo CRT | Triangle strip con alpha decay |
| `pineal-flow` | `paint_sankey` | Longest-path + barycenter + ribbons smoothstep |
| `pineal-heatmap` | `paint`, `encode_argb` | Ramp Viridis + textura para matrices grandes |
| `pineal-stream` | `pineal_stream_view` | Sweep oscilloscope split-at-head |
| `pineal-financial` | `paint_candles` | OHLC + agregación por bucket temporal |
| `pineal-hexbin` | `paint_hexbin` | Bineado hexagonal pointy-top + ramp Viridis |
| `pineal-contour` | `paint_contours` | Marching squares 16 casos → polilíneas por nivel |
| `pineal-bars` | `paint_bars`, `paint_grouped`, `paint_stacked` | Columnas/barras vertical u horizontal, agrupadas y apiladas, baseline con negativos; `Histogram` binea `&[f32]` → barras |
### 5.1 Barnes-Hut (added 2026-05-28)
`pineal-mesh::barnes_hut::Quadtree` aproxima la fuerza repulsiva con
criterio MAC (`s/d < theta`). Usar `ForceLayout::step_bh(theta=0.5)`
para grafos > ~1 K nodos. Para grafos chicos `step()` naïve es más
rápido en práctica (sin overhead del árbol).
### 5.2 Sugiyama (added 2026-05-28)
`pineal-mesh::hierarchical::sugiyama_layout` produce layout layered en
3 pasadas: DFS para romper ciclos → Kahn longest-path para capas →
barycenter en 2 pasadas (down + up) para reducir cruces. Devuelve
posiciones + agrupación por capa.
### 5.3 FDEB (added 2026-05-28)
`pineal-mesh::fdeb::bundle` aplica Force-Directed Edge Bundling: cada
arista se subdivide en N puntos intermedios que se atraen a puntos
correspondientes de aristas compatibles (paralelas + cerca + similar
escala). Endpoints fijos. Útil para grafos densos donde el spaghetti
oculta el flujo macroscópico.
### 5.4 PDF con decimación contextual (added 2026-05-28)
`pineal-export::to_pdf_decimated(plan, w_pts, h_pts, dpi)` aplica LTTB
a cada polyline antes de emitir el PDF, con
`target = width_inches × dpi × 3 vértices/px`. Output PDF mucho más
chico sin sacrificar la silueta visible al DPI destino.
## 6. Decisión: AA por defecto en PNG, no en pantalla
- **PNG**: AA 2×2 supersample siempre. PNG es output offline; el costo
no importa, el resultado vive para siempre.
- **Vello/llimphi**: AA del compositor (lo que vello hace nativamente).
No agregamos nada encima.
- **SVG**: el renderer destino decide. Pineal no tiene rasterizado allí.
## 7. Tests
Cobertura por crate:
- `pineal-core` — 23 unit tests (buffers, ring, spatial, lttb, scale).
- `pineal-render` — 4 (color conversion + recorder roundtrip).
- `pineal-mesh` — 25 (incluye Barnes-Hut vs naïve dentro del 30 %,
Sugiyama chain/fan/cycle).
- `pineal-export` — 9 (SVG + PNG, validación de bytes magic + roundtrip
decode/check pixel).
- `pineal-bars` — 10 (simple/agrupado/apilado en ambas orientaciones,
baseline con negativos, histograma: suma de conteos, último bin, rango
degenerado).
- Cada painter trae 413 tests propios usando `PlanRecorder`.
Total al 2026-06-01: **140+ tests verdes**.
## 8. Decisiones explícitas
| Decisión | Razón |
|---|---|
| `fill_triangle_strip` con color promedio por triángulo | Vello no expone mesh con per-vertex color trivial. Sankey/radar/wedge usan colores uniformes igualmente. |
| No mock GPUI ni Skia ni cairo | El catálogo entero está sobre vello/llimphi en pantalla y software-puro en PNG. Cero deps gráficas externas. |
| `RingBuffer` con `revision` u64 | Permite a los backends invalidar texturas cacheadas sin diff por valor. Mismo patrón en `HeatmapMatrix`. |
| Painters dibujan ABSOLUTO en pixels del scene | Composición vía `View::paint_with`: el caller pasa `PaintRect` y el painter no necesita conocer transformations. |
## Estado (2026-06-01)
### Hecho
- Catálogo: 15 crates viz/render/export (core, render, cartesian,
polar, mesh, treemap, phosphor, flow, heatmap, stream, financial,
hexbin, contour, bars, umbrella) + export SVG/PNG/PDF + 14 demos
ejecutables (incluyendo `pineal-galeria-demo`, que muestra 11 painters
en una sola ventana, y `pineal-bars-demo`).
- **bars** (added 2026-06-01, a pedido): el gráfico común que faltaba.
`paint_bars`/`paint_grouped`/`paint_stacked` (vertical u horizontal,
baseline con negativos) + `Histogram` para bineado de muestras. 10
tests propios con `PlanRecorder`.
- Algoritmos de grafo: Fruchterman-Reingold + Barnes-Hut O(n log n),
Sugiyama-lite layered, FDEB-lite para bundling.
- Backends en producción: vello/llimphi (`SceneCanvas`), SVG vectorial,
PNG raster con AA 2×2, PDF writer propio (+ decimación LTTB contextual).
- **GPU directo wgpu** (`GpuSceneCanvas`, Fase 4): mismo trait `Canvas`
sobre `llimphi-raster::GpuBatch`; pinta en `View::gpu_paint_with`.
Validado en Iris Xe (11.76×@1M, 141 fps).
- **Primer consumidor real del camino GPU** (`pineal-gpu-demo`,
2026-06-01): starfield warp 3D que proyecta perspectiva en CPU y emite
hasta **1 M `fill_rect`** por frame contra el trait `Canvas` — detrás,
`GpuSceneCanvas` los colapsa en **una sola draw call instanciada**.
Densidad cicable 50K→200K→500K→1M (tecla `D` / menú), pausa con
`espacio`. Cierra el pendiente que el SDD arrastraba: el camino GPU ya
no es sólo teoría + tests, hay una app que lo ejercita end-to-end.
- Menú principal + contextual cableados en las demos.
- 140+ tests verdes (los 10 de bars incluidos).
### Pendiente
- Texto y AA fino en el camino GPU (limitación documentada del backend;
los labels del starfield van por la pasada vello hermana, como prescribe
el módulo `gpu_canvas`).
- El techo de >1M primitivos por frame en vello es horizontal de
`llimphi-raster`, no de pineal. El camino GPU directo (ya ejercitado)
es justamente la vía para superarlo.
## 9. Roadmap
El catálogo de pineal cubre 15 crates de viz/render/export
+ 14 demos ejecutables + SDD propio. **Pineal no tiene roadmap propio
pendiente.** El catálogo no está congelado por dogma: cuando aparece un
tipo de gráfico genuinamente común que falta (como `bars`, agregado el
2026-06-01), entra — siempre que respete las tres reglas y hable sólo
contra el trait `Canvas`.
El único techo es el del motor (vello no aguanta >1M primitivos por
frame), y ése es un asunto horizontal de `llimphi-raster`, no de pineal
— afecta por igual a cosmos, tinkuy, nakui y cualquier app sobre
llimphi. Ver `02_ruway/llimphi/SDD.md §"GPU directo wgpu"`.
**Hecho 2026-05-28**: `pineal-render::gpu_canvas::GpuSceneCanvas` ya
existe — implementa el mismo trait `Canvas` apoyándose en
`llimphi_raster::GpuBatch`. Los painters no cambian. Lo enchufa la app
desde un `View::gpu_paint_with` (en lugar de `paint_with`). Trade-offs
(sin texto, una sola `line_width` por flush, sin AA fino) documentados
en el módulo.
**Hecho 2026-06-01**: `pineal-gpu-demo` es la primera visualización densa
que lo ejercita — un starfield warp 3D que empuja hasta 1 M `fill_rect`
por frame por el camino GPU directo. El SDD daba "cosmos starfield" como
candidata; se hizo un starfield autónomo dentro de pineal para no acoplar
el demo a la maquinaria de cosmos (que computa, y pineal no computa).
cosmos puede ahora copiar el patrón cuando quiera su propio cielo GPU.
## 10. Lo que NO va a pineal
- Lógica de dominio (queda en `cosmos`, `dominium`, etc.).
- Persistencia (queda en `pluma-store`, `nakui-store`, etc.).
- Input / event handling (queda en `llimphi-ui`).
- Decisión de qué pintar (queda en el caller — pineal es el martillo,
no el carpintero).
@@ -0,0 +1,17 @@
[package]
name = "pineal-bars-demo"
version = { workspace = true }
edition = { workspace = true }
license = { workspace = true }
authors = { workspace = true }
publish = { workspace = true }
description = "Lapaloma — demo de barras: columnas simples, horizontales, agrupadas, apiladas e histograma, todo en una grilla estática."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
llimphi-widget-menubar = { workspace = true }
llimphi-widget-context-menu = { workspace = true }
app-bus = { workspace = true }
pineal-render = { path = "../../pineal-render" }
pineal-bars = { path = "../../pineal-bars" }
@@ -0,0 +1,348 @@
//! `pineal-bars-demo` — el painter de barras del catálogo, en sus cinco
//! modos, sobre una grilla estática:
//!
//! - **Columnas** (vertical simple, con un valor negativo para mostrar el
//! baseline).
//! - **Barras** (horizontal simple).
//! - **Agrupadas** (dos series clustered por categoría).
//! - **Apiladas** (segmentos sobre un baseline común).
//! - **Histograma** (muestra gaussiana sintética bineada).
//!
//! Datos 100 % deterministas (sin `Date::now`/random): la muestra del
//! histograma sale de un LCG sembrado con constante. Es un showcase, sin
//! animación. Las etiquetas de eje no las pinta el painter (regla del
//! SDD: el texto va aparte) — acá los títulos viven en Views hermanas.
use std::sync::Arc;
use llimphi_theme::Theme;
use llimphi_ui::llimphi_layout::taffy::prelude::{length, percent, FlexDirection, Size, Style};
use llimphi_ui::llimphi_layout::taffy::Rect as PadRect;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::{App, Handle, View};
use llimphi_widget_context_menu::{
context_menu_view, ContextMenuItem, ContextMenuPalette, ContextMenuSpec,
};
use llimphi_widget_menubar::{menubar_overlay, menubar_view, MenuBarSpec, DEFAULT_HEIGHT as MENU_H};
use app_bus::{AppMenu, Menu, MenuItem};
use pineal_bars::{paint_bars, paint_grouped, paint_stacked, Bar, BarStyle, Histogram};
use pineal_render::{Canvas as _, Color, Rect, SceneCanvas};
// Paleta nórdica, consistente con el resto de los demos de pineal.
const C_AZUL: u32 = 0x88c0d0;
const C_NARANJA: u32 = 0xd08770;
const C_VERDE: u32 = 0xa3be8c;
const C_AMARILLO: u32 = 0xebcb8b;
const C_LILA: u32 = 0xb48ead;
#[derive(Clone)]
enum Msg {
MenuOpen(Option<usize>),
MenuCommand(String),
CloseMenus,
CycleTheme,
ContextMenuOpen(f32, f32),
}
struct Model {
theme: Theme,
menu_open: Option<usize>,
context_menu: Option<(f32, f32)>,
}
struct BarsDemo;
impl App for BarsDemo {
type Model = Model;
type Msg = Msg;
fn title() -> &'static str {
"pineal — barras (columnas · agrupadas · apiladas · histograma)"
}
fn initial_size() -> (u32, u32) {
(1100, 720)
}
fn init(_handle: &Handle<Msg>) -> Model {
Model {
theme: Theme::dark(),
menu_open: None,
context_menu: None,
}
}
fn update(mut model: Model, msg: Msg, handle: &Handle<Msg>) -> Model {
match msg {
Msg::MenuOpen(which) => {
model.menu_open = which;
model.context_menu = None;
}
Msg::MenuCommand(cmd) => {
model.menu_open = None;
return handle_menu_command(model, &cmd, handle);
}
Msg::CloseMenus => {
model.menu_open = None;
model.context_menu = None;
}
Msg::CycleTheme => model.theme = Theme::next_after(model.theme.name),
Msg::ContextMenuOpen(x, y) => {
model.menu_open = None;
model.context_menu = Some((x, y));
}
}
model
}
fn view(model: &Model) -> View<Msg> {
let theme = &model.theme;
let menu = app_menu();
let menubar = menubar_view(&menubar_spec(&menu, model));
let row_top = View::new(row_style())
.children(vec![tile_columnas(theme), tile_agrupadas(theme), tile_apiladas(theme)]);
let row_bot = View::new(row_style())
.children(vec![tile_horizontal(theme), tile_histograma(theme)]);
let body = View::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
flex_grow: 1.0,
padding: PadRect {
left: length(14.0_f32),
right: length(14.0_f32),
top: length(12.0_f32),
bottom: length(12.0_f32),
},
gap: Size { width: length(0.0_f32), height: length(12.0_f32) },
..Default::default()
})
.fill(theme.bg_app)
.children(vec![row_top, row_bot]);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
..Default::default()
})
.fill(theme.bg_app)
.on_right_click_at(|x, y, _w, _h| Some(Msg::ContextMenuOpen(x, y)))
.children(vec![menubar, body])
}
fn view_overlay(model: &Model) -> Option<View<Msg>> {
if let Some((x, y)) = model.context_menu {
let items = vec![ContextMenuItem::action("Cambiar tema")];
let on_pick: Arc<dyn Fn(usize) -> Msg + Send + Sync> = Arc::new(|_i| Msg::CycleTheme);
return Some(context_menu_view(ContextMenuSpec {
anchor: (x, y),
viewport: viewport_of(model),
header: Some("Barras".to_string()),
items,
active: usize::MAX,
on_pick,
on_dismiss: Msg::CloseMenus,
palette: ContextMenuPalette::from_theme(&model.theme),
}));
}
let menu = app_menu();
menubar_overlay(&menubar_spec(&menu, model))
}
}
fn row_style() -> Style {
Style {
flex_direction: FlexDirection::Row,
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
flex_grow: 1.0,
gap: Size { width: length(12.0_f32), height: length(0.0_f32) },
..Default::default()
}
}
/// Envuelve un canvas en una celda con título arriba.
fn tile(name: &str, theme: &Theme, canvas: View<Msg>) -> View<Msg> {
let title = View::new(Style {
size: Size { width: percent(1.0_f32), height: length(20.0_f32) },
..Default::default()
})
.text_aligned(name.to_string(), 13.0, theme.fg_text, Alignment::Start);
let plot = View::new(Style {
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
flex_grow: 1.0,
..Default::default()
})
.clip(true)
.children(vec![canvas]);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
flex_grow: 1.0,
gap: Size { width: length(0.0_f32), height: length(4.0_f32) },
..Default::default()
})
.children(vec![title, plot])
}
const PLOT_BG: Color = Color::rgba(0.05, 0.06, 0.08, 1.0);
fn canvas_style() -> Style {
Style {
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
flex_grow: 1.0,
..Default::default()
}
}
/// Margen interior para que las barras no toquen el borde del tile.
fn inset(rect: &llimphi_ui::PaintRect, m: f32) -> Rect {
Rect::new(rect.x + m, rect.y + m, rect.w - 2.0 * m, rect.h - 2.0 * m)
}
fn tile_columnas(theme: &Theme) -> View<Msg> {
let bars = vec![
Bar::new(4.0, Color::from_hex(C_AZUL)),
Bar::new(7.0, Color::from_hex(C_AZUL)),
Bar::new(2.0, Color::from_hex(C_AZUL)),
Bar::new(-3.0, Color::from_hex(C_NARANJA)),
Bar::new(5.0, Color::from_hex(C_AZUL)),
Bar::new(6.5, Color::from_hex(C_AZUL)),
];
let canvas = View::new(canvas_style()).clip(true).paint_with(move |scene, ts, rect| {
let outer = Rect::new(rect.x, rect.y, rect.w, rect.h);
let mut c = SceneCanvas::new(scene, ts);
c.fill_rect(outer, PLOT_BG);
paint_bars(&bars, inset(&rect, 10.0), &BarStyle::vertical(), &mut c);
});
tile("Columnas (con negativo)", theme, canvas)
}
fn tile_horizontal(theme: &Theme) -> View<Msg> {
let bars = vec![
Bar::new(8.0, Color::from_hex(C_VERDE)),
Bar::new(5.0, Color::from_hex(C_VERDE)),
Bar::new(11.0, Color::from_hex(C_VERDE)),
Bar::new(3.0, Color::from_hex(C_VERDE)),
];
let canvas = View::new(canvas_style()).clip(true).paint_with(move |scene, ts, rect| {
let outer = Rect::new(rect.x, rect.y, rect.w, rect.h);
let mut c = SceneCanvas::new(scene, ts);
c.fill_rect(outer, PLOT_BG);
paint_bars(&bars, inset(&rect, 10.0), &BarStyle::horizontal(), &mut c);
});
tile("Barras horizontales", theme, canvas)
}
fn tile_agrupadas(theme: &Theme) -> View<Msg> {
let serie_a = vec![
Bar::new(4.0, Color::from_hex(C_AZUL)),
Bar::new(6.0, Color::from_hex(C_AZUL)),
Bar::new(3.0, Color::from_hex(C_AZUL)),
Bar::new(7.0, Color::from_hex(C_AZUL)),
];
let serie_b = vec![
Bar::new(5.0, Color::from_hex(C_NARANJA)),
Bar::new(2.0, Color::from_hex(C_NARANJA)),
Bar::new(6.0, Color::from_hex(C_NARANJA)),
Bar::new(4.0, Color::from_hex(C_NARANJA)),
];
let canvas = View::new(canvas_style()).clip(true).paint_with(move |scene, ts, rect| {
let outer = Rect::new(rect.x, rect.y, rect.w, rect.h);
let mut c = SceneCanvas::new(scene, ts);
c.fill_rect(outer, PLOT_BG);
let series: [&[Bar]; 2] = [&serie_a, &serie_b];
paint_grouped(&series, inset(&rect, 10.0), &BarStyle::vertical().with_gap(0.25), &mut c);
});
tile("Agrupadas (2 series)", theme, canvas)
}
fn tile_apiladas(theme: &Theme) -> View<Msg> {
let mk = |a: f64, b: f64, d: f64| {
vec![
Bar::new(a, Color::from_hex(C_AZUL)),
Bar::new(b, Color::from_hex(C_VERDE)),
Bar::new(d, Color::from_hex(C_AMARILLO)),
]
};
let s0 = mk(3.0, 4.0, 2.0);
let s1 = mk(5.0, 2.0, 3.0);
let s2 = mk(2.0, 6.0, 1.0);
let s3 = mk(4.0, 3.0, 4.0);
let canvas = View::new(canvas_style()).clip(true).paint_with(move |scene, ts, rect| {
let outer = Rect::new(rect.x, rect.y, rect.w, rect.h);
let mut c = SceneCanvas::new(scene, ts);
c.fill_rect(outer, PLOT_BG);
let stacks: [&[Bar]; 4] = [&s0, &s1, &s2, &s3];
paint_stacked(&stacks, inset(&rect, 10.0), &BarStyle::vertical().with_gap(0.3), &mut c);
});
tile("Apiladas (3 segmentos)", theme, canvas)
}
fn tile_histograma(theme: &Theme) -> View<Msg> {
// Muestra ~gaussiana: suma de 6 uniformes (teorema central del
// límite) de un LCG sembrado con constante. Determinista.
let mut rng: u32 = 0x1234_5678;
let mut next = || {
rng = rng.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
(rng >> 8) as f32 / (1u32 << 24) as f32 // [0,1)
};
let n = 4000;
let mut sample = Vec::with_capacity(n);
for _ in 0..n {
let g: f32 = (0..6).map(|_| next()).sum::<f32>() / 6.0; // media 0.5
sample.push((g - 0.5) * 6.0); // centrado, escala ~[-3,3]
}
let hist = Histogram::new(&sample, 28);
let bars = hist.to_bars(Color::from_hex(C_LILA));
let canvas = View::new(canvas_style()).clip(true).paint_with(move |scene, ts, rect| {
let outer = Rect::new(rect.x, rect.y, rect.w, rect.h);
let mut c = SceneCanvas::new(scene, ts);
c.fill_rect(outer, PLOT_BG);
// Histograma: barras pegadas (gap 0) para la silueta continua.
paint_bars(&bars, inset(&rect, 10.0), &BarStyle::vertical().with_gap(0.05), &mut c);
});
tile("Histograma (4000 muestras, 28 bins)", theme, canvas)
}
fn viewport_of(_model: &Model) -> (f32, f32) {
let (w, h) = BarsDemo::initial_size();
(w as f32, h as f32)
}
fn menubar_spec<'a>(menu: &'a AppMenu, model: &'a Model) -> MenuBarSpec<'a, Msg> {
MenuBarSpec {
menu,
open: model.menu_open,
theme: &model.theme,
viewport: viewport_of(model),
height: MENU_H,
on_open: Arc::new(Msg::MenuOpen),
on_command: Arc::new(|c: &str| Msg::MenuCommand(c.to_string())),
}
}
fn app_menu() -> AppMenu {
AppMenu::new()
.menu(Menu::new("Archivo").item(MenuItem::new("Salir", "file.quit").shortcut("Ctrl+Q")))
.menu(Menu::new("Ver").item(MenuItem::new("Cambiar tema", "view.theme")))
.menu(Menu::new("Ayuda").item(MenuItem::new("Acerca de", "help.about")))
}
fn handle_menu_command(model: Model, cmd: &str, handle: &Handle<Msg>) -> Model {
match cmd {
"file.quit" => std::process::exit(0),
"view.theme" => {
handle.dispatch(Msg::CycleTheme);
model
}
_ => model,
}
}
fn main() {
llimphi_ui::run::<BarsDemo>();
}
@@ -0,0 +1,19 @@
[package]
name = "pineal-contour-demo"
version = { workspace = true }
edition = { workspace = true }
license = { workspace = true }
authors = { workspace = true }
publish = { workspace = true }
description = "Lapaloma — demo contour: campo f(x,y)=sin(x)+cos(y) con 8 isolíneas."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
pineal-render = { path = "../../pineal-render" }
pineal-contour = { path = "../../pineal-contour" }
pineal-heatmap = { path = "../../pineal-heatmap" }
llimphi-widget-menubar = { workspace = true }
llimphi-widget-context-menu = { workspace = true }
app-bus = { workspace = true }
@@ -0,0 +1,307 @@
//! `pineal-contour-demo` — campo escalar con 8 isolíneas + heatmap base.
//!
//! Renderiza primero el heatmap Viridis del campo (para contexto), y
//! encima 8 isolíneas extraídas por marching squares con gradiente
//! azul→rojo. Matriz 64×48; el campo es
//! `sin(x · 0.4 - t · 0.1) + cos(y · 0.4 + t · 0.07)` con un tick lento
//! para que se vea la deformación.
use std::sync::{Arc, Mutex};
use std::time::Duration;
use llimphi_theme::Theme;
use llimphi_ui::llimphi_layout::taffy::prelude::{length, percent, FlexDirection, Size, Style};
use llimphi_ui::llimphi_layout::taffy::Rect as TaffyRect;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::{App, Handle, View};
use app_bus::{AppMenu, Menu, MenuItem};
use llimphi_widget_context_menu::{
context_menu_view, ContextMenuItem, ContextMenuPalette, ContextMenuSpec,
};
use llimphi_widget_menubar::{menubar_overlay, menubar_view, MenuBarSpec, DEFAULT_HEIGHT as MENU_H};
use pineal_contour::paint_contours;
use pineal_heatmap::{paint as paint_heatmap, HeatmapMatrix, Ramp};
use pineal_render::{Canvas as _, Color, Rect, SceneCanvas};
const W: usize = 64;
const H: usize = 48;
const TICK: Duration = Duration::from_millis(80);
#[derive(Clone)]
enum Msg {
Tick,
/// Pausa/reanuda la animación del campo (el tick sigue llegando,
/// pero el campo sólo avanza si no está en pausa).
TogglePause,
/// Reinicia el campo al estado inicial (t = 0).
Reset,
/// Cicla el preset de tema (viste la barra de menú y overlays).
CycleTheme,
/// Barra de menú principal: abrir/cerrar un menú raíz (`None` cierra).
MenuOpen(Option<usize>),
/// Comando elegido en la barra o el contextual → `Msg` real.
MenuCommand(String),
/// Cierra cualquier menú abierto (click-fuera / Esc).
CloseMenus,
/// Right-click sobre el plot → abre el contextual anclado en `(x, y)`.
ContextMenuOpen(f32, f32),
}
struct Model {
matrix: Arc<Mutex<HeatmapMatrix>>,
t: u64,
paused: bool,
theme: Theme,
menu_open: Option<usize>,
context_menu: Option<(f32, f32)>,
}
struct ContourDemo;
impl App for ContourDemo {
type Model = Model;
type Msg = Msg;
fn title() -> &'static str {
"Lapaloma — contour (campo + 8 isolíneas)"
}
fn initial_size() -> (u32, u32) {
(960, 640)
}
fn init(handle: &Handle<Msg>) -> Model {
handle.spawn_periodic(TICK, || Msg::Tick);
let mut m = HeatmapMatrix::new(W, H);
fill(&mut m, 0);
Model {
matrix: Arc::new(Mutex::new(m)),
t: 0,
paused: false,
theme: Theme::dark(),
menu_open: None,
context_menu: None,
}
}
fn update(mut model: Model, msg: Msg, handle: &Handle<Msg>) -> Model {
match msg {
Msg::Tick => {
if !model.paused {
model.t = model.t.wrapping_add(1);
if let Ok(mut m) = model.matrix.lock() {
fill(&mut m, model.t);
}
}
}
Msg::TogglePause => {
model.paused = !model.paused;
}
Msg::Reset => {
model.t = 0;
if let Ok(mut m) = model.matrix.lock() {
fill(&mut m, 0);
}
}
Msg::CycleTheme => {
model.theme = Theme::next_after(model.theme.name);
}
Msg::MenuOpen(which) => {
model.menu_open = which;
model.context_menu = None;
}
Msg::CloseMenus => {
model.menu_open = None;
model.context_menu = None;
}
Msg::ContextMenuOpen(x, y) => {
model.menu_open = None;
model.context_menu = Some((x, y));
}
Msg::MenuCommand(cmd) => {
model.menu_open = None;
model.context_menu = None;
handle_menu_command(&cmd, handle);
}
}
model
}
fn view(model: &Model) -> View<Msg> {
let theme = &model.theme;
let plot_bg = Color::rgba(0.05, 0.06, 0.09, 1.0);
let matrix = model.matrix.clone();
let menu = app_menu(model);
let menubar = menubar_view(&menubar_spec(&menu, model));
let header = View::new(Style {
size: Size { width: percent(1.0_f32), height: length(28.0_f32) },
..Default::default()
})
.text_aligned(
"Lapaloma — contour".to_string(),
18.0,
theme.fg_text,
Alignment::Start,
);
let legend = View::new(Style {
size: Size { width: percent(1.0_f32), height: length(20.0_f32) },
..Default::default()
})
.text_aligned(
format!("campo {}×{} · 8 isolíneas · marching squares · tick = {}", W, H, model.t),
11.0,
theme.fg_muted,
Alignment::Start,
);
let panel = View::new(Style {
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
flex_grow: 1.0,
..Default::default()
})
.clip(true)
.paint_with(move |scene, ts, rect| {
let outer = Rect::new(rect.x, rect.y, rect.w, rect.h);
let mut canvas = SceneCanvas::new(scene, ts);
canvas.fill_rect(outer, plot_bg);
if let Ok(m) = matrix.lock() {
paint_heatmap(&m, Ramp::Viridis, outer, &mut canvas);
paint_contours(
&m,
8,
outer,
Color::rgba(0.4, 0.6, 1.0, 0.9),
Color::rgba(1.0, 0.4, 0.3, 0.95),
1.2,
&mut canvas,
);
}
});
let body = View::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
flex_grow: 1.0,
padding: TaffyRect {
left: length(16.0_f32),
right: length(16.0_f32),
top: length(16.0_f32),
bottom: length(16.0_f32),
},
gap: Size { width: length(0.0_f32), height: length(10.0_f32) },
..Default::default()
})
.fill(theme.bg_app)
.children(vec![header, legend, panel]);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
..Default::default()
})
.fill(theme.bg_app)
.on_right_click_at(|x, y, _w, _h| Some(Msg::ContextMenuOpen(x, y)))
.children(vec![menubar, body])
}
fn view_overlay(model: &Model) -> Option<View<Msg>> {
if let Some((x, y)) = model.context_menu {
return Some(context_menu_for_plot(model, x, y));
}
let menu = app_menu(model);
menubar_overlay(&menubar_spec(&menu, model))
}
}
// =====================================================================
// Menú principal + contextual del plot
// =====================================================================
fn viewport_of(_model: &Model) -> (f32, f32) {
let (w, h) = ContourDemo::initial_size();
(w as f32, h as f32)
}
fn menubar_spec<'a>(menu: &'a AppMenu, model: &'a Model) -> MenuBarSpec<'a, Msg> {
MenuBarSpec {
menu,
open: model.menu_open,
theme: &model.theme,
viewport: viewport_of(model),
height: MENU_H,
on_open: Arc::new(Msg::MenuOpen),
on_command: Arc::new(|c: &str| Msg::MenuCommand(c.to_string())),
}
}
fn app_menu(model: &Model) -> AppMenu {
let pause_label = if model.paused { "Reanudar" } else { "Pausar" };
AppMenu::new()
.menu(Menu::new("Archivo").item(MenuItem::new("Salir", "app.quit").shortcut("Esc")))
.menu(
Menu::new("Ver")
.item(MenuItem::new("Reiniciar campo", "view.reset"))
.item(MenuItem::new(pause_label, "view.pause"))
.item(MenuItem::new("Cambiar tema", "view.theme").separated()),
)
.menu(Menu::new("Ayuda").item(MenuItem::new("Acerca de", "help.about")))
}
fn handle_menu_command(cmd: &str, handle: &Handle<Msg>) {
let msg = match cmd {
"app.quit" => {
std::process::exit(0);
}
"view.reset" => Some(Msg::Reset),
"view.pause" => Some(Msg::TogglePause),
"view.theme" => Some(Msg::CycleTheme),
_ => None,
};
if let Some(msg) = msg {
handle.dispatch(msg);
}
}
fn context_menu_for_plot(model: &Model, x: f32, y: f32) -> View<Msg> {
let pause_label = if model.paused { "Reanudar" } else { "Pausar" };
let items = vec![
ContextMenuItem::action("Reiniciar campo"),
ContextMenuItem::action(pause_label),
];
let cmds: Vec<&'static str> = vec!["view.reset", "view.pause"];
let on_pick: Arc<dyn Fn(usize) -> Msg + Send + Sync> = Arc::new(move |i: usize| {
Msg::MenuCommand(cmds.get(i).copied().unwrap_or("").to_string())
});
context_menu_view(ContextMenuSpec {
anchor: (x, y),
viewport: viewport_of(model),
header: Some("campo".to_string()),
items,
active: usize::MAX,
on_pick,
on_dismiss: Msg::CloseMenus,
palette: ContextMenuPalette::from_theme(&model.theme),
})
}
fn fill(m: &mut HeatmapMatrix, t: u64) {
let phase = t as f32 * 0.10;
let phase2 = t as f32 * 0.07;
let mut data = Vec::with_capacity(W * H);
for y in 0..H {
for x in 0..W {
let v = (x as f32 * 0.4 - phase).sin() + (y as f32 * 0.4 + phase2).cos();
data.push(v);
}
}
m.replace_data(data);
}
fn main() {
llimphi_ui::run::<ContourDemo>();
}
@@ -0,0 +1,19 @@
[package]
name = "pineal-demo"
version = { workspace = true }
edition = { workspace = true }
license = { workspace = true }
authors = { workspace = true }
publish = { workspace = true }
description = "Lapaloma — demo app: una serie sin(x) sobre ChartViewport rendereada con LapalomaChartElement. Valida la cadena core → render → cartesian → llimphi en vivo."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
pineal-core = { path = "../../pineal-core" }
pineal-render = { path = "../../pineal-render" }
pineal-cartesian = { path = "../../pineal-cartesian" }
llimphi-widget-menubar = { workspace = true }
llimphi-widget-context-menu = { workspace = true }
app-bus = { workspace = true }
@@ -0,0 +1,16 @@
# pineal-demo
> Demo gallery del catálogo de [pineal](../README.md).
Binario que muestra todos los backends (`cartesian`, `polar`, `mesh`, `treemap`, `flow`, `heatmap`, `umbrella`) en una grid Llimphi. Click en una demo abre la vista completa. Sirve como **regression visual**: si rompés un backend, la demo lo grita.
## Uso
```sh
cargo run --release -p pineal-demo
```
## Deps
- Todos los backends de pineal
- [`llimphi-ui`](../../../02_ruway/llimphi/)
@@ -0,0 +1,16 @@
# pineal-demo
> Catalog demo gallery for [pineal](../README.md).
Binary that shows all backends (`cartesian`, `polar`, `mesh`, `treemap`, `flow`, `heatmap`, `umbrella`) in a Llimphi grid. Click on a demo opens the full view. Works as **visual regression**: if you break a backend, the demo screams.
## Usage
```sh
cargo run --release -p pineal-demo
```
## Deps
- All pineal backends
- [`llimphi-ui`](../../../02_ruway/llimphi/)
@@ -0,0 +1,363 @@
//! `pineal-demo` — demo cartesian multi-series sobre Llimphi.
//!
//! Ventana 900×560 con un chart cartesiano de **3 series** sobre 1024
//! muestras:
//!
//! - `sin(x · 0.04)` — azul nórdico
//! - `cos(x · 0.04)` — naranja
//! - `0.5·sin(x · 0.02) + 0.5·cos(x · 0.08)` — verde
//!
//! Interacción: wheel = zoom (uniforme alrededor del cursor),
//! click = reset viewport. El pan por drag requiere callbacks
//! mouse_move/down/up que llimphi-ui aún no expone — pendiente
//! para una pasada futura cuando esos hooks estén.
use std::sync::Arc;
use llimphi_theme::Theme;
use llimphi_ui::llimphi_layout::taffy::prelude::{length, percent, FlexDirection, Size, Style};
use llimphi_ui::llimphi_layout::taffy::Rect;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::{App, Handle, Modifiers, View, WheelDelta};
use app_bus::{AppMenu, Menu, MenuItem};
use llimphi_widget_context_menu::{
context_menu_view, ContextMenuItem, ContextMenuPalette, ContextMenuSpec,
};
use llimphi_widget_menubar::{menubar_overlay, menubar_view, MenuBarSpec, DEFAULT_HEIGHT as MENU_H};
use pineal_cartesian::view::{chart_cache, ChartCacheHandle};
use pineal_cartesian::{ChartView, ChartViewport};
use pineal_core::buffer::DataBuffer;
use pineal_render::{Color, StrokeStyle};
const N_SAMPLES: usize = 1024;
const WHEEL_SENSITIVITY: f64 = 0.04;
const COLOR_SIN: (u8, u8, u8) = (0x88, 0xc0, 0xd0); // azul nórdico
const COLOR_COS: (u8, u8, u8) = (0xd0, 0x87, 0x70); // naranja
const COLOR_MIX: (u8, u8, u8) = (0xa3, 0xbe, 0x8c); // verde
fn color_rgb(c: (u8, u8, u8)) -> Color {
Color::rgb(c.0 as f32 / 255.0, c.1 as f32 / 255.0, c.2 as f32 / 255.0)
}
#[derive(Clone)]
enum Msg {
/// Zoom uniforme alrededor del cursor (en fracciones [0,1] del plot).
Zoom { factor: f64, anchor_x: f64, anchor_y: f64 },
/// Zoom de paso fijo alrededor del centro del plot — el que disparan
/// los menús (sin cursor ancla). `factor < 1` acerca, `> 1` aleja.
ZoomStep(f64),
/// Click sobre el chart → reset al viewport inicial.
Reset,
/// Cicla el preset de tema (sólo viste la barra de menú y overlays).
CycleTheme,
/// Barra de menú principal: abrir/cerrar un menú raíz (`None` cierra).
MenuOpen(Option<usize>),
/// Comando elegido en la barra o el contextual — se traduce al `Msg` real.
MenuCommand(String),
/// Cierra cualquier menú abierto (click-fuera / Esc).
CloseMenus,
/// Right-click sobre el plot → abre el menú contextual anclado en `(x, y)`.
ContextMenuOpen(f32, f32),
}
struct Model {
series_sin: DataBuffer,
series_cos: DataBuffer,
series_mix: DataBuffer,
viewport: ChartViewport,
initial_viewport: ChartViewport,
chart_cache: ChartCacheHandle,
/// Tamaño actual de la ventana — necesario para mapear cursor
/// absoluto a fracciones del plot en el handler de wheel.
win_w: f32,
win_h: f32,
/// Tema activo — viste la barra de menú y los overlays.
theme: Theme,
/// Barra de menú principal: índice del menú raíz abierto (`None` cerrado).
menu_open: Option<usize>,
/// Menú contextual del plot: ancla `(x, y)` en coords de ventana.
context_menu: Option<(f32, f32)>,
}
struct Demo;
impl App for Demo {
type Model = Model;
type Msg = Msg;
fn title() -> &'static str {
"Lapaloma — multi-series (wheel = zoom, click = reset)"
}
fn initial_size() -> (u32, u32) {
(900, 560)
}
fn init(_: &Handle<Msg>) -> Model {
let mut sin = DataBuffer::with_capacity(N_SAMPLES);
let mut cos = DataBuffer::with_capacity(N_SAMPLES);
let mut mix = DataBuffer::with_capacity(N_SAMPLES);
for i in 0..N_SAMPLES {
let x = i as f32;
sin.push(x, (x * 0.04).sin());
cos.push(x, (x * 0.04).cos());
mix.push(x, 0.5 * (x * 0.02).sin() + 0.5 * (x * 0.08).cos());
}
let viewport = ChartViewport::new(0.0, (N_SAMPLES - 1) as f64, -1.3, 1.3);
Model {
series_sin: sin,
series_cos: cos,
series_mix: mix,
viewport,
initial_viewport: viewport,
chart_cache: chart_cache(),
win_w: 900.0,
win_h: 560.0,
theme: Theme::dark(),
menu_open: None,
context_menu: None,
}
}
fn update(mut model: Model, msg: Msg, handle: &Handle<Msg>) -> Model {
match msg {
Msg::Zoom { factor, anchor_x, anchor_y } => {
model.viewport.zoom_uniform(factor, (anchor_x, anchor_y));
}
Msg::ZoomStep(factor) => {
model.viewport.zoom_uniform(factor, (0.5, 0.5));
}
Msg::Reset => {
model.viewport = model.initial_viewport;
model.chart_cache.lock().unwrap().invalidate();
}
Msg::CycleTheme => {
model.theme = Theme::next_after(model.theme.name);
}
Msg::MenuOpen(which) => {
model.menu_open = which;
model.context_menu = None;
}
Msg::CloseMenus => {
model.menu_open = None;
model.context_menu = None;
}
Msg::ContextMenuOpen(x, y) => {
model.menu_open = None;
model.context_menu = Some((x, y));
}
Msg::MenuCommand(cmd) => {
model.menu_open = None;
model.context_menu = None;
handle_menu_command(&cmd, handle);
}
}
model
}
fn on_wheel(
model: &Model,
delta: WheelDelta,
cursor: (f32, f32),
_mods: Modifiers,
) -> Option<Msg> {
if model.win_w <= 0.0 || model.win_h <= 0.0 {
return None;
}
let factor = (-delta.y as f64 * WHEEL_SENSITIVITY).exp();
let ax = (cursor.0 / model.win_w).clamp(0.0, 1.0) as f64;
// Llimphi reporta cursor con +Y hacia abajo; el viewport quiere
// +Y hacia arriba (anchor a fondo de plot = 0).
let ay = (1.0 - cursor.1 / model.win_h).clamp(0.0, 1.0) as f64;
Some(Msg::Zoom { factor, anchor_x: ax, anchor_y: ay })
}
fn view(model: &Model) -> View<Msg> {
let theme = &model.theme;
let plot_bg = Color::rgba(0.10, 0.12, 0.16, 1.0);
let menu = app_menu();
let menubar = menubar_view(&menubar_spec(&menu, model));
let chart = ChartView::new(model.viewport)
.background(plot_bg)
.with_cache(model.chart_cache.clone())
.add_series_named(
model.series_sin.clone(),
StrokeStyle::new(2.0, color_rgb(COLOR_SIN)),
"sin",
)
.add_series_named(
model.series_cos.clone(),
StrokeStyle::new(2.0, color_rgb(COLOR_COS)),
"cos",
)
.add_series_named(
model.series_mix.clone(),
StrokeStyle::new(2.0, color_rgb(COLOR_MIX)),
"mix",
)
.view::<Msg>();
let (pan_blits, rebuilds) = {
let c = model.chart_cache.lock().unwrap();
(c.pan_blits(), c.rebuilds())
};
let header = View::new(Style {
size: Size { width: percent(1.0_f32), height: length(28.0_f32) },
..Default::default()
})
.text_aligned(
"Lapaloma — demo cartesian multi-series".to_string(),
18.0,
theme.fg_text,
Alignment::Start,
);
let legend = format!(
"sin(x · 0.04) cos(x · 0.04) ½·sin(x · 0.02) + ½·cos(x · 0.08) \
cache: {} pan-blits / {} rebuilds",
pan_blits, rebuilds,
);
let legend_row = View::new(Style {
size: Size { width: percent(1.0_f32), height: length(20.0_f32) },
..Default::default()
})
.text_aligned(legend, 11.0, theme.fg_muted, Alignment::Start);
let plot_panel = View::new(Style {
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
flex_grow: 1.0,
..Default::default()
})
.clip(true)
.children(vec![chart])
.on_click(Msg::Reset);
let body = View::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
flex_grow: 1.0,
padding: Rect {
left: length(16.0_f32),
right: length(16.0_f32),
top: length(16.0_f32),
bottom: length(16.0_f32),
},
gap: Size { width: length(0.0_f32), height: length(12.0_f32) },
..Default::default()
})
.fill(theme.bg_app)
.children(vec![header, legend_row, plot_panel]);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
..Default::default()
})
.fill(theme.bg_app)
.on_right_click_at(|x, y, _w, _h| Some(Msg::ContextMenuOpen(x, y)))
.children(vec![menubar, body])
}
fn view_overlay(model: &Model) -> Option<View<Msg>> {
// Prioridad: menú contextual del plot.
if let Some((x, y)) = model.context_menu {
return Some(context_menu_for_plot(model, x, y));
}
// Si no, el dropdown del menú principal.
let menu = app_menu();
menubar_overlay(&menubar_spec(&menu, model))
}
}
// =====================================================================
// Menú principal + contextual del plot
// =====================================================================
/// Viewport para clampear overlays — coords de ventana actual.
fn viewport_of(model: &Model) -> (f32, f32) {
(model.win_w, model.win_h)
}
/// Arma el `MenuBarSpec` compartido por `menubar_view` y `menubar_overlay`.
fn menubar_spec<'a>(menu: &'a AppMenu, model: &'a Model) -> MenuBarSpec<'a, Msg> {
MenuBarSpec {
menu,
open: model.menu_open,
theme: &model.theme,
viewport: viewport_of(model),
height: MENU_H,
on_open: Arc::new(Msg::MenuOpen),
on_command: Arc::new(|c: &str| Msg::MenuCommand(c.to_string())),
}
}
/// Menú principal. Archivo / Ver / Ayuda — sólo comandos que mapean a
/// `Msg` reales. No hay "Editar": es un canvas de chart, sin texto.
fn app_menu() -> AppMenu {
AppMenu::new()
.menu(Menu::new("Archivo").item(MenuItem::new("Salir", "app.quit").shortcut("Esc")))
.menu(
Menu::new("Ver")
.item(MenuItem::new("Reiniciar vista", "view.reset"))
.item(MenuItem::new("Acercar", "view.zoom_in").shortcut("+").separated())
.item(MenuItem::new("Alejar", "view.zoom_out").shortcut("-"))
.item(MenuItem::new("Cambiar tema", "view.theme").separated()),
)
.menu(Menu::new("Ayuda").item(MenuItem::new("Acerca de", "help.about")))
}
const ZOOM_IN_FACTOR: f64 = 0.8;
const ZOOM_OUT_FACTOR: f64 = 1.25;
/// Traduce un command id (barra o contextual) al `Msg` real y lo dispatcha.
fn handle_menu_command(cmd: &str, handle: &Handle<Msg>) {
let msg = match cmd {
"app.quit" => {
std::process::exit(0);
}
"view.reset" => Some(Msg::Reset),
"view.zoom_in" => Some(Msg::ZoomStep(ZOOM_IN_FACTOR)),
"view.zoom_out" => Some(Msg::ZoomStep(ZOOM_OUT_FACTOR)),
"view.theme" => Some(Msg::CycleTheme),
// "help.about" y desconocidos: no-op (sin diálogo todavía).
_ => None,
};
if let Some(msg) = msg {
handle.dispatch(msg);
}
}
/// Menú contextual del plot — expone las acciones de vista del chart.
fn context_menu_for_plot(model: &Model, x: f32, y: f32) -> View<Msg> {
let items = vec![
ContextMenuItem::action("Reiniciar vista"),
ContextMenuItem::separator(),
ContextMenuItem::action("Acercar").with_shortcut("+"),
ContextMenuItem::action("Alejar").with_shortcut("-"),
];
// Mapeo índice de item → command id de `handle_menu_command`.
let cmds: Vec<&'static str> = vec!["view.reset", "", "view.zoom_in", "view.zoom_out"];
let on_pick: Arc<dyn Fn(usize) -> Msg + Send + Sync> = Arc::new(move |i: usize| {
Msg::MenuCommand(cmds.get(i).copied().unwrap_or("").to_string())
});
context_menu_view(ContextMenuSpec {
anchor: (x, y),
viewport: viewport_of(model),
header: Some("vista".to_string()),
items,
active: usize::MAX,
on_pick,
on_dismiss: Msg::CloseMenus,
palette: ContextMenuPalette::from_theme(&model.theme),
})
}
fn main() {
llimphi_ui::run::<Demo>();
}
@@ -0,0 +1,19 @@
[package]
name = "pineal-financial-demo"
version = { workspace = true }
edition = { workspace = true }
license = { workspace = true }
authors = { workspace = true }
publish = { workspace = true }
description = "Lapaloma — demo de candlesticks OHLC. Random walk sintético de 120 días con pan + zoom."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
pineal-render = { path = "../../pineal-render" }
pineal-cartesian = { path = "../../pineal-cartesian" }
pineal-financial = { path = "../../pineal-financial" }
llimphi-widget-menubar = { workspace = true }
llimphi-widget-context-menu = { workspace = true }
app-bus = { workspace = true }
@@ -0,0 +1,16 @@
# pineal-financial-demo
> Demo del backend financiero de [pineal](../README.md).
Carga bars OHLCV sintéticos (o de un CSV pasado por CLI), aplica overlays (SMA, EMA, Bollinger) y los muestra. Si pasás un símbolo y tenés conexión, intenta cargar bars desde un endpoint público — pero por defecto opera offline con datos generados.
## Uso
```sh
cargo run --release -p pineal-financial-demo
cargo run --release -p pineal-financial-demo -- --csv bars.csv
```
## Deps
- [`pineal-financial`](../pineal-financial/README.md), [`pineal-render`](../pineal-render/README.md)
@@ -0,0 +1,16 @@
# pineal-financial-demo
> Financial backend demo for [pineal](../README.md).
Loads synthetic OHLCV bars (or from a CSV passed via CLI), applies overlays (SMA, EMA, Bollinger) and renders them. If you pass a symbol with connectivity, it tries to load bars from a public endpoint — but by default it works offline with generated data.
## Usage
```sh
cargo run --release -p pineal-financial-demo
cargo run --release -p pineal-financial-demo -- --csv bars.csv
```
## Deps
- [`pineal-financial`](../pineal-financial/README.md), [`pineal-render`](../pineal-render/README.md)
@@ -0,0 +1,360 @@
//! `pineal-financial-demo` — chart OHLC con random walk sobre Llimphi.
//!
//! Genera 120 "días" de bars con un random walk determinístico (sin RNG
//! runtime — derivado de un seed fijo + xorshift32 inline) y los pinta
//! con `CandlestickView`. Wheel = zoom uniforme alrededor del cursor,
//! click = reset al viewport inicial.
//!
//! Pan por drag pendiente: requiere callbacks mouse_move/down/up que
//! llimphi-ui aún no expone.
use std::sync::Arc;
use llimphi_theme::Theme;
use llimphi_ui::llimphi_layout::taffy::prelude::{length, percent, FlexDirection, Size, Style};
use llimphi_ui::llimphi_layout::taffy::Rect;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::{App, Handle, Modifiers, View, WheelDelta};
use app_bus::{AppMenu, Menu, MenuItem};
use llimphi_widget_context_menu::{
context_menu_view, ContextMenuItem, ContextMenuPalette, ContextMenuSpec,
};
use llimphi_widget_menubar::{menubar_overlay, menubar_view, MenuBarSpec, DEFAULT_HEIGHT as MENU_H};
use pineal_cartesian::ChartViewport;
use pineal_financial::{lapaloma_candlestick_view, Bar, CandlestickStyle, OhlcBuffer};
use pineal_render::Color;
const N_BARS: usize = 120;
const WHEEL_SENSITIVITY: f64 = 0.04;
#[derive(Clone)]
enum Msg {
Zoom { factor: f64, anchor_x: f64, anchor_y: f64 },
/// Zoom de paso fijo alrededor del centro — el que disparan los menús.
ZoomStep(f64),
Reset,
/// Cicla el preset de tema (sólo viste la barra de menú y overlays).
CycleTheme,
/// Barra de menú principal: abrir/cerrar un menú raíz (`None` cierra).
MenuOpen(Option<usize>),
/// Comando elegido en la barra o el contextual → `Msg` real.
MenuCommand(String),
/// Cierra cualquier menú abierto (click-fuera / Esc).
CloseMenus,
/// Right-click sobre el plot → abre el contextual anclado en `(x, y)`.
ContextMenuOpen(f32, f32),
}
struct Model {
data: OhlcBuffer,
viewport: ChartViewport,
initial_viewport: ChartViewport,
win_w: f32,
win_h: f32,
theme: Theme,
menu_open: Option<usize>,
context_menu: Option<(f32, f32)>,
}
struct FinancialDemo;
impl App for FinancialDemo {
type Model = Model;
type Msg = Msg;
fn title() -> &'static str {
"Lapaloma — candlesticks (wheel = zoom, click = reset)"
}
fn initial_size() -> (u32, u32) {
(960, 560)
}
fn init(_: &Handle<Msg>) -> Model {
let data = synth_random_walk(N_BARS, 100.0, 0xc0ffee);
let (lo, hi) = data.price_range().unwrap_or((0.0, 1.0));
let pad = (hi - lo) * 0.08;
let viewport = ChartViewport::new(
-0.5,
N_BARS as f64 - 0.5,
(lo - pad) as f64,
(hi + pad) as f64,
);
Model {
data,
viewport,
initial_viewport: viewport,
win_w: 960.0,
win_h: 560.0,
theme: Theme::dark(),
menu_open: None,
context_menu: None,
}
}
fn update(mut model: Model, msg: Msg, handle: &Handle<Msg>) -> Model {
match msg {
Msg::Zoom { factor, anchor_x, anchor_y } => {
model.viewport.zoom_uniform(factor, (anchor_x, anchor_y));
}
Msg::ZoomStep(factor) => {
model.viewport.zoom_uniform(factor, (0.5, 0.5));
}
Msg::Reset => {
model.viewport = model.initial_viewport;
}
Msg::CycleTheme => {
model.theme = Theme::next_after(model.theme.name);
}
Msg::MenuOpen(which) => {
model.menu_open = which;
model.context_menu = None;
}
Msg::CloseMenus => {
model.menu_open = None;
model.context_menu = None;
}
Msg::ContextMenuOpen(x, y) => {
model.menu_open = None;
model.context_menu = Some((x, y));
}
Msg::MenuCommand(cmd) => {
model.menu_open = None;
model.context_menu = None;
handle_menu_command(&cmd, handle);
}
}
model
}
fn on_wheel(
model: &Model,
delta: WheelDelta,
cursor: (f32, f32),
_mods: Modifiers,
) -> Option<Msg> {
if model.win_w <= 0.0 || model.win_h <= 0.0 {
return None;
}
let factor = (-delta.y as f64 * WHEEL_SENSITIVITY).exp();
let ax = (cursor.0 / model.win_w).clamp(0.0, 1.0) as f64;
let ay = (1.0 - cursor.1 / model.win_h).clamp(0.0, 1.0) as f64;
Some(Msg::Zoom { factor, anchor_x: ax, anchor_y: ay })
}
fn view(model: &Model) -> View<Msg> {
let theme = &model.theme;
let plot_bg = Color::rgba(0.06, 0.08, 0.10, 1.0);
let menu = app_menu();
let menubar = menubar_view(&menubar_spec(&menu, model));
let style = CandlestickStyle {
bull_color: Color::rgb(0.639, 0.745, 0.549),
bear_color: Color::rgb(0.749, 0.380, 0.416),
..CandlestickStyle::default()
};
let chart = lapaloma_candlestick_view(model.data.clone(), model.viewport)
.background(plot_bg)
.style(style)
.view::<Msg>();
let (lo, hi) = model.data.price_range().unwrap_or((0.0, 0.0));
let header = View::new(Style {
size: Size { width: percent(1.0_f32), height: length(28.0_f32) },
..Default::default()
})
.text_aligned(
"Lapaloma — candlesticks".to_string(),
18.0,
theme.fg_text,
Alignment::Start,
);
let stats = format!(
"{} bars (random walk) price [{:.2}, {:.2}] wheel = zoom, click = reset",
N_BARS, lo, hi,
);
let stats_row = View::new(Style {
size: Size { width: percent(1.0_f32), height: length(20.0_f32) },
..Default::default()
})
.text_aligned(stats, 11.0, theme.fg_muted, Alignment::Start);
let plot_panel = View::new(Style {
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
flex_grow: 1.0,
..Default::default()
})
.clip(true)
.children(vec![chart])
.on_click(Msg::Reset);
let body = View::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
flex_grow: 1.0,
padding: Rect {
left: length(16.0_f32),
right: length(16.0_f32),
top: length(16.0_f32),
bottom: length(16.0_f32),
},
gap: Size { width: length(0.0_f32), height: length(10.0_f32) },
..Default::default()
})
.fill(theme.bg_app)
.children(vec![header, stats_row, plot_panel]);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
..Default::default()
})
.fill(theme.bg_app)
.on_right_click_at(|x, y, _w, _h| Some(Msg::ContextMenuOpen(x, y)))
.children(vec![menubar, body])
}
fn view_overlay(model: &Model) -> Option<View<Msg>> {
if let Some((x, y)) = model.context_menu {
return Some(context_menu_for_plot(model, x, y));
}
let menu = app_menu();
menubar_overlay(&menubar_spec(&menu, model))
}
}
// =====================================================================
// Menú principal + contextual del plot
// =====================================================================
fn viewport_of(model: &Model) -> (f32, f32) {
(model.win_w, model.win_h)
}
fn menubar_spec<'a>(menu: &'a AppMenu, model: &'a Model) -> MenuBarSpec<'a, Msg> {
MenuBarSpec {
menu,
open: model.menu_open,
theme: &model.theme,
viewport: viewport_of(model),
height: MENU_H,
on_open: Arc::new(Msg::MenuOpen),
on_command: Arc::new(|c: &str| Msg::MenuCommand(c.to_string())),
}
}
fn app_menu() -> AppMenu {
AppMenu::new()
.menu(Menu::new("Archivo").item(MenuItem::new("Salir", "app.quit").shortcut("Esc")))
.menu(
Menu::new("Ver")
.item(MenuItem::new("Reiniciar vista", "view.reset"))
.item(MenuItem::new("Acercar", "view.zoom_in").shortcut("+").separated())
.item(MenuItem::new("Alejar", "view.zoom_out").shortcut("-"))
.item(MenuItem::new("Cambiar tema", "view.theme").separated()),
)
.menu(Menu::new("Ayuda").item(MenuItem::new("Acerca de", "help.about")))
}
const ZOOM_IN_FACTOR: f64 = 0.8;
const ZOOM_OUT_FACTOR: f64 = 1.25;
fn handle_menu_command(cmd: &str, handle: &Handle<Msg>) {
let msg = match cmd {
"app.quit" => {
std::process::exit(0);
}
"view.reset" => Some(Msg::Reset),
"view.zoom_in" => Some(Msg::ZoomStep(ZOOM_IN_FACTOR)),
"view.zoom_out" => Some(Msg::ZoomStep(ZOOM_OUT_FACTOR)),
"view.theme" => Some(Msg::CycleTheme),
_ => None,
};
if let Some(msg) = msg {
handle.dispatch(msg);
}
}
fn context_menu_for_plot(model: &Model, x: f32, y: f32) -> View<Msg> {
let items = vec![
ContextMenuItem::action("Reiniciar vista"),
ContextMenuItem::separator(),
ContextMenuItem::action("Acercar").with_shortcut("+"),
ContextMenuItem::action("Alejar").with_shortcut("-"),
];
let cmds: Vec<&'static str> = vec!["view.reset", "", "view.zoom_in", "view.zoom_out"];
let on_pick: Arc<dyn Fn(usize) -> Msg + Send + Sync> = Arc::new(move |i: usize| {
Msg::MenuCommand(cmds.get(i).copied().unwrap_or("").to_string())
});
context_menu_view(ContextMenuSpec {
anchor: (x, y),
viewport: viewport_of(model),
header: Some("vista".to_string()),
items,
active: usize::MAX,
on_pick,
on_dismiss: Msg::CloseMenus,
palette: ContextMenuPalette::from_theme(&model.theme),
})
}
/// xorshift32 inline — RNG determinístico mínimo. No criptográfico,
/// pero perfecto para series sintéticas reproducibles.
fn xorshift32(state: &mut u32) -> u32 {
let mut x = *state;
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
*state = x;
x
}
fn rand_f32(state: &mut u32) -> f32 {
xorshift32(state) as f32 / u32::MAX as f32
}
fn synth_random_walk(n: usize, start_price: f32, seed: u32) -> OhlcBuffer {
let mut rng = seed.max(1);
let mut buf = OhlcBuffer::with_capacity(n);
let mut close = start_price;
let drift = 0.05;
let vol = 1.2;
for i in 0..n {
let r1 = rand_f32(&mut rng) - 0.5;
let r2 = rand_f32(&mut rng) - 0.5;
let r3 = rand_f32(&mut rng) - 0.5;
let r4 = rand_f32(&mut rng) - 0.5;
let open = close;
let move_close = drift + r1 * vol * 2.0;
let new_close = (open + move_close).max(1.0);
let body_hi = open.max(new_close);
let body_lo = open.min(new_close);
let wick_up = (r2.abs() * vol * 1.2).max(0.05);
let wick_dn = (r3.abs() * vol * 1.2).max(0.05);
let high = body_hi + wick_up;
let low = (body_lo - wick_dn).max(0.1);
let volume = 1000.0 + r4.abs() * 8000.0;
buf.push_bar(Bar {
t: i as f32,
o: open,
h: high,
l: low,
c: new_close,
v: volume,
});
close = new_close;
}
buf
}
fn main() {
llimphi_ui::run::<FinancialDemo>();
}
@@ -0,0 +1,18 @@
[package]
name = "pineal-flow-demo"
version = { workspace = true }
edition = { workspace = true }
license = { workspace = true }
authors = { workspace = true }
publish = { workspace = true }
description = "Lapaloma — demo Sankey: presupuesto familiar (ingresos → categorías → ahorro)."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
pineal-render = { path = "../../pineal-render" }
pineal-flow = { path = "../../pineal-flow" }
llimphi-widget-menubar = { workspace = true }
llimphi-widget-context-menu = { workspace = true }
app-bus = { workspace = true }
@@ -0,0 +1,267 @@
//! `pineal-flow-demo` — Sankey de presupuesto familiar.
//!
//! 4 fuentes de ingreso → 5 categorías de gasto → 1 nodo de ahorro.
//! El algoritmo de layout (longest-path + barycenter) ubica los
//! nodos en columnas y minimiza cruces; las bandas se tesselan con
//! curva S (smoothstep) y se rendean como triangle strips.
use std::sync::Arc;
use llimphi_theme::Theme;
use llimphi_ui::llimphi_layout::taffy::prelude::{length, percent, FlexDirection, Size, Style};
use llimphi_ui::llimphi_layout::taffy::Rect as TaffyRect;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::{App, Handle, View};
use app_bus::{AppMenu, Menu, MenuItem};
use llimphi_widget_context_menu::{
context_menu_view, ContextMenuItem, ContextMenuPalette, ContextMenuSpec,
};
use llimphi_widget_menubar::{menubar_overlay, menubar_view, MenuBarSpec, DEFAULT_HEIGHT as MENU_H};
use pineal_flow::{compute_layout, paint_sankey, SankeyLink, SankeyNode};
use pineal_render::{Canvas as _, Color, Rect, SceneCanvas};
#[derive(Clone)]
enum Msg {
/// Cicla el preset de tema (viste la barra de menú y overlays).
CycleTheme,
/// Barra de menú principal: abrir/cerrar un menú raíz (`None` cierra).
MenuOpen(Option<usize>),
/// Comando elegido en la barra o el contextual → `Msg` real.
MenuCommand(String),
/// Cierra cualquier menú abierto (click-fuera / Esc).
CloseMenus,
/// Right-click sobre el plot → abre el contextual anclado en `(x, y)`.
ContextMenuOpen(f32, f32),
}
struct Model {
theme: Theme,
menu_open: Option<usize>,
context_menu: Option<(f32, f32)>,
}
struct FlowDemo;
impl App for FlowDemo {
type Model = Model;
type Msg = Msg;
fn title() -> &'static str {
"Lapaloma — Sankey (presupuesto)"
}
fn initial_size() -> (u32, u32) {
(1080, 620)
}
fn init(_: &Handle<Msg>) -> Model {
Model { theme: Theme::dark(), menu_open: None, context_menu: None }
}
fn update(mut model: Model, msg: Msg, handle: &Handle<Msg>) -> Model {
match msg {
Msg::CycleTheme => {
model.theme = Theme::next_after(model.theme.name);
}
Msg::MenuOpen(which) => {
model.menu_open = which;
model.context_menu = None;
}
Msg::CloseMenus => {
model.menu_open = None;
model.context_menu = None;
}
Msg::ContextMenuOpen(x, y) => {
model.menu_open = None;
model.context_menu = Some((x, y));
}
Msg::MenuCommand(cmd) => {
model.menu_open = None;
model.context_menu = None;
handle_menu_command(&cmd, handle);
}
}
model
}
fn view(model: &Model) -> View<Msg> {
let theme = &model.theme;
let plot_bg = Color::rgba(0.08, 0.10, 0.13, 1.0);
let menu = app_menu();
let menubar = menubar_view(&menubar_spec(&menu, model));
// 0..4: ingresos · 5..9: categorías de gasto · 10: ahorro.
let nodes: Vec<SankeyNode> = [
"Sueldo", "Freelance", "Renta", "Dividendos",
"Vivienda", "Comida", "Transporte", "Ocio", "Salud",
"Ahorro",
]
.iter()
.map(|n| SankeyNode::new(*n))
.collect();
let links: Vec<SankeyLink> = vec![
// Sueldo → todo
SankeyLink { source: 0, target: 4, value: 1200.0 },
SankeyLink { source: 0, target: 5, value: 600.0 },
SankeyLink { source: 0, target: 6, value: 250.0 },
SankeyLink { source: 0, target: 9, value: 950.0 },
// Freelance
SankeyLink { source: 1, target: 5, value: 200.0 },
SankeyLink { source: 1, target: 7, value: 300.0 },
SankeyLink { source: 1, target: 9, value: 400.0 },
// Renta
SankeyLink { source: 2, target: 4, value: 400.0 },
SankeyLink { source: 2, target: 8, value: 150.0 },
// Dividendos
SankeyLink { source: 3, target: 9, value: 350.0 },
SankeyLink { source: 3, target: 7, value: 80.0 },
];
let header = View::new(Style {
size: Size { width: percent(1.0_f32), height: length(28.0_f32) },
..Default::default()
})
.text_aligned(
"Lapaloma — Sankey".to_string(),
18.0,
theme.fg_text,
Alignment::Start,
);
let legend = View::new(Style {
size: Size { width: percent(1.0_f32), height: length(20.0_f32) },
..Default::default()
})
.text_aligned(
"4 ingresos → 5 categorías + ahorro · longest-path + barycenter + ribbons smoothstep"
.to_string(),
11.0,
theme.fg_muted,
Alignment::Start,
);
let panel = View::new(Style {
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
flex_grow: 1.0,
..Default::default()
})
.clip(true)
.paint_with(move |scene, ts, rect| {
let outer = Rect::new(rect.x, rect.y, rect.w, rect.h);
let mut canvas = SceneCanvas::new(scene, ts);
canvas.fill_rect(outer, plot_bg);
// 20 px de margen interior para el cómputo del layout.
let area = Rect::new(outer.x + 20.0, outer.y + 20.0, outer.w - 40.0, outer.h - 40.0);
let layout = compute_layout(&nodes, &links, area, 18.0, 8.0);
paint_sankey(
&layout,
Color::from_hex(0xe5e9f0),
Color::rgba(0.533, 0.753, 0.816, 0.45),
&mut canvas,
);
});
let body = View::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
flex_grow: 1.0,
padding: TaffyRect {
left: length(16.0_f32),
right: length(16.0_f32),
top: length(16.0_f32),
bottom: length(16.0_f32),
},
gap: Size { width: length(0.0_f32), height: length(10.0_f32) },
..Default::default()
})
.fill(theme.bg_app)
.children(vec![header, legend, panel]);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
..Default::default()
})
.fill(theme.bg_app)
.on_right_click_at(|x, y, _w, _h| Some(Msg::ContextMenuOpen(x, y)))
.children(vec![menubar, body])
}
fn view_overlay(model: &Model) -> Option<View<Msg>> {
if let Some((x, y)) = model.context_menu {
return Some(context_menu_for_plot(model, x, y));
}
let menu = app_menu();
menubar_overlay(&menubar_spec(&menu, model))
}
}
// =====================================================================
// Menú principal + contextual del plot
// =====================================================================
fn viewport_of(_model: &Model) -> (f32, f32) {
let (w, h) = FlowDemo::initial_size();
(w as f32, h as f32)
}
fn menubar_spec<'a>(menu: &'a AppMenu, model: &'a Model) -> MenuBarSpec<'a, Msg> {
MenuBarSpec {
menu,
open: model.menu_open,
theme: &model.theme,
viewport: viewport_of(model),
height: MENU_H,
on_open: Arc::new(Msg::MenuOpen),
on_command: Arc::new(|c: &str| Msg::MenuCommand(c.to_string())),
}
}
/// Menú principal. El Sankey es un diagrama estático (sin zoom/pan), así
/// que "Ver" sólo ofrece cambio de tema; no hay reset de vista que mapear.
fn app_menu() -> AppMenu {
AppMenu::new()
.menu(Menu::new("Archivo").item(MenuItem::new("Salir", "app.quit").shortcut("Esc")))
.menu(Menu::new("Ver").item(MenuItem::new("Cambiar tema", "view.theme")))
.menu(Menu::new("Ayuda").item(MenuItem::new("Acerca de", "help.about")))
}
fn handle_menu_command(cmd: &str, handle: &Handle<Msg>) {
let msg = match cmd {
"app.quit" => {
std::process::exit(0);
}
"view.theme" => Some(Msg::CycleTheme),
_ => None,
};
if let Some(msg) = msg {
handle.dispatch(msg);
}
}
fn context_menu_for_plot(model: &Model, x: f32, y: f32) -> View<Msg> {
let items = vec![ContextMenuItem::action("Cambiar tema")];
let cmds: Vec<&'static str> = vec!["view.theme"];
let on_pick: Arc<dyn Fn(usize) -> Msg + Send + Sync> = Arc::new(move |i: usize| {
Msg::MenuCommand(cmds.get(i).copied().unwrap_or("").to_string())
});
context_menu_view(ContextMenuSpec {
anchor: (x, y),
viewport: viewport_of(model),
header: Some("Sankey".to_string()),
items,
active: usize::MAX,
on_pick,
on_dismiss: Msg::CloseMenus,
palette: ContextMenuPalette::from_theme(&model.theme),
})
}
fn main() {
llimphi_ui::run::<FlowDemo>();
}
@@ -0,0 +1,23 @@
[package]
name = "pineal-galeria-demo"
version = { workspace = true }
edition = { workspace = true }
license = { workspace = true }
authors = { workspace = true }
publish = { workspace = true }
description = "Galería estática de pineal — una grilla de tiles, cada uno un painter distinto del catálogo (cartesian, polar, treemap, heatmap, hexbin, contour, flow, mesh) con datos sintéticos deterministas."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
llimphi-widget-menubar = { workspace = true }
app-bus = { workspace = true }
pineal-render = { path = "../../pineal-render" }
pineal-polar = { path = "../../pineal-polar" }
pineal-treemap = { path = "../../pineal-treemap" }
pineal-heatmap = { path = "../../pineal-heatmap" }
pineal-hexbin = { path = "../../pineal-hexbin" }
pineal-contour = { path = "../../pineal-contour" }
pineal-flow = { path = "../../pineal-flow" }
pineal-mesh = { path = "../../pineal-mesh" }
pineal-bars = { path = "../../pineal-bars" }
@@ -0,0 +1,633 @@
//! `pineal-galeria-demo` — galería estática de TODO el catálogo de painters.
//!
//! Una sola ventana con una grilla 3×3 de tiles; cada tile pinta un
//! painter distinto de pineal con datos sintéticos deterministas (sin
//! timers, sin RNG de sistema): cartesian (sinusoide a mano), polar
//! (pie/donut y radar), treemap, heatmap, hexbin, contour, flow (Sankey)
//! y mesh (grafo force-directed pre-relajado).
//!
//! Los painters animados (phosphor, stream) y el financial tienen su
//! propio demo en vivo y quedan FUERA — la galería es un showcase
//! estático. El tile #9 es un cartesiano extra (segunda señal) para
//! completar la grilla.
//!
//! Cada tile reusa la construcción de datos de su demo suelto en
//! `pineal-<painter>-demo`, así que las firmas son las reales. Cada
//! closure de `paint_with` captura SUS datos por `move`.
//!
//! Cableado de UI: barra de menú principal mínima (Archivo / Ver). Los
//! tiles son canvas estáticos sin edición ni clipboard.
use std::sync::Arc;
use llimphi_theme::Theme;
use llimphi_ui::llimphi_layout::taffy::prelude::{length, percent, FlexDirection, Size, Style};
use llimphi_ui::llimphi_layout::taffy::Rect as TaffyRect;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::{App, Handle, View};
use app_bus::{AppMenu, Menu, MenuItem};
use llimphi_widget_menubar::{menubar_overlay, menubar_view, MenuBarSpec, DEFAULT_HEIGHT as MENU_H};
use pineal_bars::{paint_bars, Bar, BarStyle, Histogram};
use pineal_contour::paint_contours;
use pineal_flow::{compute_layout, paint_sankey, SankeyLink, SankeyNode};
use pineal_heatmap::{paint as paint_heatmap, HeatmapMatrix, Ramp};
use pineal_hexbin::{paint_hexbin, HexGrid};
use pineal_mesh::{EdgeBuffer, ForceLayout, ForceParams, NodeBuffer};
use pineal_polar::{paint_pie, paint_radar, Slice};
use pineal_render::{Canvas as _, Color, Point, Rect, SceneCanvas, StrokeStyle};
use pineal_treemap::{paint_treemap, Tile};
// =====================================================================
// Modelo y mensajes
// =====================================================================
#[derive(Clone)]
enum Msg {
/// Barra de menú principal: abrir/cerrar un menú raíz (`None` cierra).
MenuOpen(Option<usize>),
/// Comando elegido en la barra → se traduce al `Msg` real.
MenuCommand(String),
/// Cicla el preset de tema.
CycleTheme,
}
struct Model {
theme: Theme,
menu_open: Option<usize>,
/// Grafo del tile mesh, ya relajado en el init (posiciones fijas).
graph: Arc<Graph>,
/// Hexbin pre-construido (5 000 puntos gaussianos deterministas).
hex: HexGrid,
/// Campo escalar 48×32 para heatmap y contour (estático, t = 0).
field: Arc<HeatmapMatrix>,
}
struct GaleriaDemo;
impl App for GaleriaDemo {
type Model = Model;
type Msg = Msg;
fn title() -> &'static str {
"pineal — galería de painters (grilla 3×3)"
}
fn initial_size() -> (u32, u32) {
(1280, 860)
}
fn init(_: &Handle<Msg>) -> Model {
Model {
theme: Theme::dark(),
menu_open: None,
graph: Arc::new(Graph::relaxed()),
hex: build_hexgrid(),
field: Arc::new(build_field()),
}
}
fn update(mut model: Model, msg: Msg, handle: &Handle<Msg>) -> Model {
match msg {
Msg::MenuOpen(which) => model.menu_open = which,
Msg::CycleTheme => {
model.theme = Theme::next_after(model.theme.name);
}
Msg::MenuCommand(cmd) => {
model.menu_open = None;
handle_menu_command(&cmd, handle);
}
}
model
}
fn view(model: &Model) -> View<Msg> {
let theme = &model.theme;
let menu = app_menu();
let menubar = menubar_view(&menubar_spec(&menu, model));
let header = View::new(Style {
size: Size { width: percent(1.0_f32), height: length(28.0_f32) },
..Default::default()
})
.text_aligned(
"pineal — galería de painters".to_string(),
18.0,
theme.fg_text,
Alignment::Start,
);
let legend = View::new(Style {
size: Size { width: percent(1.0_f32), height: length(20.0_f32) },
..Default::default()
})
.text_aligned(
"11 tiles · cartesian · pie · radar · treemap · heatmap · hexbin · contour · sankey · mesh · bars · histograma".to_string(),
11.0,
theme.fg_muted,
Alignment::Start,
);
// Tres filas de tres tiles cada una.
let row0 = grid_row(theme, vec![
tile("cartesian (sin)", theme, cartesian_tile(0.0)),
tile("polar · pie/donut", theme, pie_tile()),
tile("polar · radar", theme, radar_tile()),
]);
let row1 = grid_row(theme, vec![
tile("treemap", theme, treemap_tile()),
tile("heatmap (Viridis)", theme, heatmap_tile(model.field.clone())),
tile("hexbin (Viridis)", theme, hexbin_tile(model.hex.clone())),
]);
let row2 = grid_row(theme, vec![
tile("contour (8 niveles)", theme, contour_tile(model.field.clone())),
tile("flow · sankey", theme, sankey_tile()),
tile("mesh (force-directed)", theme, mesh_tile(model.graph.clone())),
]);
let row3 = grid_row(theme, vec![
tile("bars (con negativo)", theme, bars_tile()),
tile("histograma", theme, histogram_tile()),
]);
let grid = View::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
flex_grow: 1.0,
gap: Size { width: length(0.0_f32), height: length(10.0_f32) },
..Default::default()
})
.children(vec![row0, row1, row2, row3]);
let body = View::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
flex_grow: 1.0,
padding: TaffyRect {
left: length(16.0_f32),
right: length(16.0_f32),
top: length(16.0_f32),
bottom: length(16.0_f32),
},
gap: Size { width: length(0.0_f32), height: length(10.0_f32) },
..Default::default()
})
.fill(theme.bg_app)
.children(vec![header, legend, grid]);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
..Default::default()
})
.fill(theme.bg_app)
.children(vec![menubar, body])
}
fn view_overlay(model: &Model) -> Option<View<Msg>> {
let menu = app_menu();
menubar_overlay(&menubar_spec(&menu, model))
}
}
// =====================================================================
// Estructura de la grilla: fila de tiles + tile (label + canvas)
// =====================================================================
/// Una fila horizontal de tiles, cada uno con `flex_grow: 1.0`.
fn grid_row(_theme: &Theme, tiles: Vec<View<Msg>>) -> View<Msg> {
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
flex_grow: 1.0,
gap: Size { width: length(10.0_f32), height: length(0.0_f32) },
..Default::default()
})
.children(tiles)
}
/// Un tile: label arriba + el canvas del painter abajo (que ya viene
/// como una `View` con `paint_with`).
fn tile(name: &str, theme: &Theme, canvas: View<Msg>) -> View<Msg> {
let label = View::new(Style {
size: Size { width: percent(1.0_f32), height: length(16.0_f32) },
..Default::default()
})
.text_aligned(name.to_string(), 11.0, theme.fg_text, Alignment::Start);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
flex_grow: 1.0,
flex_basis: length(0.0_f32),
gap: Size { width: length(0.0_f32), height: length(4.0_f32) },
..Default::default()
})
.children(vec![label, canvas])
}
/// Plantilla del canvas de un tile: una `View` con clip + `paint_with`
/// que rellena el fondo y delega al painter.
fn canvas_view<F>(painter: F) -> View<Msg>
where
F: Fn(&mut SceneCanvas<'_>, Rect) + Send + Sync + 'static,
{
let plot_bg = Color::rgba(0.06, 0.08, 0.10, 1.0);
View::new(Style {
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
flex_grow: 1.0,
..Default::default()
})
.clip(true)
.paint_with(move |scene, ts, rect| {
let outer = Rect::new(rect.x, rect.y, rect.w, rect.h);
let mut canvas = SceneCanvas::new(scene, ts);
canvas.fill_rect(outer, plot_bg);
painter(&mut canvas, outer);
})
}
// =====================================================================
// Tiles — un painter por función. Cada uno captura SUS datos por move.
// =====================================================================
/// Cartesian: una sinusoide a mano. Construimos `[x0,y0,x1,y1,…]`
/// mapeados al rect y los pintamos con `stroke_polyline` directo (sin
/// ChartView / viewport / caché).
fn cartesian_tile(phase: f32) -> View<Msg> {
canvas_view(move |canvas, outer| {
const N: usize = 240;
let pad = 6.0_f32;
let x0 = outer.x + pad;
let w = (outer.w - 2.0 * pad).max(1.0);
let cy = outer.y + outer.h * 0.5;
let amp = (outer.h * 0.5 - pad).max(1.0);
let mut coords: Vec<f32> = Vec::with_capacity(N * 2);
for i in 0..N {
let t = i as f32 / (N - 1) as f32; // 0..1
let px = x0 + t * w;
// Suma de dos armónicos para que se vea algo más que un seno.
let v = (t * std::f32::consts::TAU * 3.0 + phase).sin() * 0.7
+ (t * std::f32::consts::TAU * 7.0 + phase).sin() * 0.25;
let py = cy - v * amp;
coords.push(px);
coords.push(py);
}
canvas.stroke_polyline(&coords, StrokeStyle::new(1.8, Color::from_hex(0x88c0d0)));
})
}
/// Barras — una serie de columnas con un valor negativo para mostrar el
/// baseline.
fn bars_tile() -> View<Msg> {
canvas_view(move |canvas, outer| {
let bars = [
Bar::new(4.0, Color::from_hex(0x88c0d0)),
Bar::new(7.0, Color::from_hex(0x88c0d0)),
Bar::new(2.0, Color::from_hex(0x88c0d0)),
Bar::new(-3.0, Color::from_hex(0xd08770)),
Bar::new(5.0, Color::from_hex(0x88c0d0)),
Bar::new(6.5, Color::from_hex(0x88c0d0)),
];
let area = Rect::new(outer.x + 8.0, outer.y + 8.0, outer.w - 16.0, outer.h - 16.0);
paint_bars(&bars, area, &BarStyle::vertical(), canvas);
})
}
/// Histograma — muestra ~gaussiana (suma de uniformes de un LCG
/// sembrado) bineada en 24 bins.
fn histogram_tile() -> View<Msg> {
canvas_view(move |canvas, outer| {
let mut rng: u32 = 0x0BAD_F00D;
let mut next = || {
rng = rng.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
(rng >> 8) as f32 / (1u32 << 24) as f32
};
let mut sample = Vec::with_capacity(3000);
for _ in 0..3000 {
let g: f32 = (0..6).map(|_| next()).sum::<f32>() / 6.0;
sample.push((g - 0.5) * 6.0);
}
let bars = Histogram::new(&sample, 24).to_bars(Color::from_hex(0xb48ead));
let area = Rect::new(outer.x + 8.0, outer.y + 8.0, outer.w - 16.0, outer.h - 16.0);
paint_bars(&bars, area, &BarStyle::vertical().with_gap(0.05), canvas);
})
}
/// Polar — pie/donut. 6 porciones de un presupuesto sintético.
fn pie_tile() -> View<Msg> {
canvas_view(move |canvas, outer| {
let cx = outer.x + outer.w * 0.5;
let cy = outer.y + outer.h * 0.5;
let r_out = (outer.w.min(outer.h) * 0.42).max(20.0);
let r_in = r_out * 0.45;
let slices = [
Slice::new(28.0, Color::from_hex(0x88c0d0)),
Slice::new(18.0, Color::from_hex(0xd08770)),
Slice::new(14.0, Color::from_hex(0xa3be8c)),
Slice::new(12.0, Color::from_hex(0xebcb8b)),
Slice::new(10.0, Color::from_hex(0xb48ead)),
Slice::new(8.0, Color::from_hex(0x5e81ac)),
];
paint_pie(&slices, Point::new(cx, cy), r_out, r_in, canvas);
})
}
/// Polar — radar (spider). 6 ejes, círculos guía + polígono.
fn radar_tile() -> View<Msg> {
canvas_view(move |canvas, outer| {
let cx = outer.x + outer.w * 0.5;
let cy = outer.y + outer.h * 0.5;
let r = (outer.w.min(outer.h) * 0.42).max(20.0);
// Ejes guía: 4 círculos concéntricos cada 25 % del radio.
for step in 1..=4 {
let t = step as f32 / 4.0;
let ring: Vec<f32> = (0..=72)
.flat_map(|i| {
let a = (i as f32 / 72.0) * std::f32::consts::TAU
- std::f32::consts::FRAC_PI_2;
[cx + (r * t) * a.cos(), cy + (r * t) * a.sin()]
})
.collect();
canvas.stroke_polyline(
&ring,
StrokeStyle::new(0.6, Color::rgba(0.55, 0.6, 0.7, 0.35)),
);
}
let values = [8.0_f32, 6.5, 9.0, 4.0, 7.0, 5.5];
paint_radar(
&values,
10.0,
Point::new(cx, cy),
r,
Color::rgba(0.639, 0.745, 0.549, 0.35),
StrokeStyle::new(1.6, Color::from_hex(0xa3be8c)),
canvas,
);
})
}
/// Treemap squarified: 12 tiles con pesos a mano.
fn treemap_tile() -> View<Msg> {
let palette = [
0x88c0d0, 0xd08770, 0xa3be8c, 0xebcb8b, 0xb48ead, 0x5e81ac, 0x81a1c1, 0xbf616a,
0x8fbcbb, 0xd8dee9, 0xa3be8c, 0xebcb8b,
];
let weights = [40.0, 28.0, 22.0, 18.0, 14.0, 10.0, 8.0, 6.0, 5.0, 4.0, 3.0, 2.0];
let tiles: Vec<Tile> = weights
.iter()
.zip(palette.iter())
.map(|(&w, &c)| Tile::new(w, Color::from_hex(c)))
.collect();
canvas_view(move |canvas, outer| {
paint_treemap(&tiles, outer, 2.0, canvas);
})
}
/// Heatmap: campo 48×32 Viridis (estático, t = 0).
fn heatmap_tile(field: Arc<HeatmapMatrix>) -> View<Msg> {
canvas_view(move |canvas, outer| {
paint_heatmap(&field, Ramp::Viridis, outer, canvas);
})
}
/// Hexbin: 5 000 puntos gaussianos deterministas, bineados Viridis.
fn hexbin_tile(grid: HexGrid) -> View<Msg> {
canvas_view(move |canvas, outer| {
paint_hexbin(&grid, Ramp::Viridis, (outer.x, outer.y), canvas);
})
}
/// Contour: heatmap base + 8 isolíneas (marching squares) del mismo
/// campo escalar que el tile heatmap.
fn contour_tile(field: Arc<HeatmapMatrix>) -> View<Msg> {
canvas_view(move |canvas, outer| {
paint_heatmap(&field, Ramp::Viridis, outer, canvas);
paint_contours(
&field,
8,
outer,
Color::rgba(0.4, 0.6, 1.0, 0.9),
Color::rgba(1.0, 0.4, 0.3, 0.95),
1.2,
canvas,
);
})
}
/// Flow — Sankey de presupuesto familiar (mismos nodos/links que el
/// `pineal-flow-demo`). El layout se computa dentro del closure a partir
/// del rect del tile.
fn sankey_tile() -> View<Msg> {
let nodes: Vec<SankeyNode> = [
"Sueldo", "Freelance", "Renta", "Dividendos", "Vivienda", "Comida", "Transporte",
"Ocio", "Salud", "Ahorro",
]
.iter()
.map(|n| SankeyNode::new(*n))
.collect();
let links: Vec<SankeyLink> = vec![
SankeyLink { source: 0, target: 4, value: 1200.0 },
SankeyLink { source: 0, target: 5, value: 600.0 },
SankeyLink { source: 0, target: 6, value: 250.0 },
SankeyLink { source: 0, target: 9, value: 950.0 },
SankeyLink { source: 1, target: 5, value: 200.0 },
SankeyLink { source: 1, target: 7, value: 300.0 },
SankeyLink { source: 1, target: 9, value: 400.0 },
SankeyLink { source: 2, target: 4, value: 400.0 },
SankeyLink { source: 2, target: 8, value: 150.0 },
SankeyLink { source: 3, target: 9, value: 350.0 },
SankeyLink { source: 3, target: 7, value: 80.0 },
];
canvas_view(move |canvas, outer| {
let area = Rect::new(outer.x + 12.0, outer.y + 12.0, outer.w - 24.0, outer.h - 24.0);
let layout = compute_layout(&nodes, &links, area, 14.0, 6.0);
paint_sankey(
&layout,
Color::from_hex(0xe5e9f0),
Color::rgba(0.533, 0.753, 0.816, 0.45),
canvas,
);
})
}
/// Mesh — grafo force-directed ya relajado (posiciones fijas calculadas
/// en el init). Replica el `paint_graph` local del `pineal-mesh-demo`
/// (pineal-mesh no exporta un painter de grafo).
fn mesh_tile(graph: Arc<Graph>) -> View<Msg> {
canvas_view(move |canvas, outer| {
paint_graph(canvas, &graph, outer);
})
}
// =====================================================================
// Datos sintéticos deterministas
// =====================================================================
/// Campo escalar 48×32 compartido por heatmap y contour (estático t = 0).
fn build_field() -> HeatmapMatrix {
const W: usize = 48;
const H: usize = 32;
let mut m = HeatmapMatrix::new(W, H);
let mut data = Vec::with_capacity(W * H);
for y in 0..H {
for x in 0..W {
// Mismo perfil que el heatmap-demo en t = 0.
let v = (x as f32 * 0.25).sin() + (y as f32 * 0.30).cos();
data.push(v);
}
}
m.replace_data(data);
m
}
/// HexGrid con 5 000 puntos gaussianos deterministas (LCG sembrado).
fn build_hexgrid() -> HexGrid {
const N_POINTS: usize = 5000;
const HEX_RADIUS: f32 = 7.0;
let mut g = HexGrid::new(HEX_RADIUS);
let mut state: u64 = 0xC0FFEE;
let mut rng = || -> f32 {
state = state
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
((state >> 32) as f32) / (u32::MAX as f32)
};
let mut gauss = || -> (f32, f32) {
let u1 = (rng()).max(1e-9);
let u2 = rng();
let r = (-2.0 * u1.ln()).sqrt();
let theta = 2.0 * std::f32::consts::PI * u2;
(r * theta.cos(), r * theta.sin())
};
for i in 0..N_POINTS {
let (g0, g1) = gauss();
if i % 3 == 0 {
g.push(300.0 + g0 * 50.0, 300.0 + g1 * 50.0);
} else {
g.push(520.0 + g0 * 80.0, 380.0 + g1 * 80.0);
}
}
g
}
// =====================================================================
// Grafo del tile mesh — mismo armado que el pineal-mesh-demo, pero
// relajado en el init (corremos N pasos de force layout una vez).
// =====================================================================
const N_RING: usize = 12;
const N_SAT: usize = 12;
struct Graph {
nodes: NodeBuffer,
edges: EdgeBuffer,
}
impl Graph {
/// Construye la topología y la deja relajada tras unos pasos de
/// Fruchterman-Reingold (sin timers: todo el cómputo en el init).
fn relaxed() -> Self {
let mut nodes = NodeBuffer::new();
for i in 0..N_RING {
let a = (i as f32 / N_RING as f32) * std::f32::consts::TAU;
nodes.push(20.0 * a.cos(), 20.0 * a.sin(), 6.0);
}
for i in 0..N_SAT {
let a = (i as f32 / N_SAT as f32) * std::f32::consts::TAU + 0.13;
nodes.push(60.0 * a.cos(), 60.0 * a.sin(), 4.5);
}
let mut edges = EdgeBuffer::new();
for i in 0..N_RING {
edges.push(i, (i + 1) % N_RING);
}
for i in 0..N_RING {
edges.push(i, (i + 3) % N_RING);
}
for i in 0..N_SAT {
edges.push(i, N_RING + i);
}
let mut sim = ForceLayout::new(ForceParams { k: 38.0, temperature: 60.0, cooling: 0.985 });
// Relajación estática: corremos pasos hasta que se enfríe.
for _ in 0..400 {
let _ = sim.step(&mut nodes, &edges);
}
Self { nodes, edges }
}
}
/// Pinta el grafo dentro de `area` (centrado). Replica el painter local
/// del `pineal-mesh-demo`: aristas grises + nodos como rect rellenos.
fn paint_graph(canvas: &mut SceneCanvas<'_>, g: &Graph, area: Rect) {
let n = g.nodes.len();
if n == 0 {
return;
}
let cx = area.x + area.w * 0.5;
let cy = area.y + area.h * 0.5;
let edge_stroke = StrokeStyle::new(1.0, Color::rgba(0.6, 0.65, 0.7, 0.45));
for (u, v) in g.edges.iter() {
let (xu, yu) = g.nodes.pos(u);
let (xv, yv) = g.nodes.pos(v);
canvas.stroke_line(
Point::new(cx + xu, cy + yu),
Point::new(cx + xv, cy + yv),
edge_stroke,
);
}
for i in 0..n {
let (x, y) = g.nodes.pos(i);
let r = g.nodes.radius(i);
let color = if i < N_RING {
Color::from_hex(0x88c0d0)
} else {
Color::from_hex(0xa3be8c)
};
let rect = Rect::new(cx + x - r, cy + y - r, r * 2.0, r * 2.0);
canvas.fill_rect(rect, color);
}
}
// =====================================================================
// Menú principal
// =====================================================================
fn viewport_of(_model: &Model) -> (f32, f32) {
let (w, h) = GaleriaDemo::initial_size();
(w as f32, h as f32)
}
fn menubar_spec<'a>(menu: &'a AppMenu, model: &'a Model) -> MenuBarSpec<'a, Msg> {
MenuBarSpec {
menu,
open: model.menu_open,
theme: &model.theme,
viewport: viewport_of(model),
height: MENU_H,
on_open: Arc::new(Msg::MenuOpen),
on_command: Arc::new(|c: &str| Msg::MenuCommand(c.to_string())),
}
}
fn app_menu() -> AppMenu {
AppMenu::new()
.menu(Menu::new("Archivo").item(MenuItem::new("Salir", "file.quit").shortcut("Esc")))
.menu(Menu::new("Ver").item(MenuItem::new("Cambiar tema", "view.theme")))
}
fn handle_menu_command(cmd: &str, handle: &Handle<Msg>) {
match cmd {
"file.quit" => std::process::exit(0),
"view.theme" => handle.dispatch(Msg::CycleTheme),
_ => {}
}
}
fn main() {
llimphi_ui::run::<GaleriaDemo>();
}
@@ -0,0 +1,17 @@
[package]
name = "pineal-gpu-demo"
version = { workspace = true }
edition = { workspace = true }
license = { workspace = true }
authors = { workspace = true }
publish = { workspace = true }
description = "Starfield warp 3D denso (hasta 1M estrellas) — primer consumidor real de GpuSceneCanvas: pinta el trait Canvas sobre GpuBatch desde View::gpu_paint_with. Ejercita el camino GPU directo (Fase 4) que el resto del catálogo pineal sólo tenía documentado."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
llimphi-widget-menubar = { workspace = true }
llimphi-widget-context-menu = { workspace = true }
app-bus = { workspace = true }
wgpu = { workspace = true }
pineal-render = { path = "../../pineal-render" }
@@ -0,0 +1,448 @@
//! `pineal-gpu-demo` — starfield warp 3D sobre el camino GPU directo.
//!
//! Es el **primer consumidor real de `GpuSceneCanvas`** (Fase 4 del SDD
//! de pineal). El resto del catálogo sólo tenía el backend GPU
//! documentado y testeado; aquí se pinta de verdad: el demo emite hasta
//! 1 M de `fill_rect` por frame contra el trait `Canvas`, pero detrás
//! del trait está `GpuSceneCanvas` empujando a un `GpuBatch` que despacha
//! todo en **una sola draw call instanciada** (P3 del SDD).
//!
//! Reparto de responsabilidades (Elm + el split CPU/GPU del SDD):
//!
//! - `update(Tick)` avanza la simulación en CPU: cada estrella vuela
//! hacia el observador (`z -= speed`) y se recicla al fondo al cruzar
//! el plano cercano. Estado en un `Vec<f32>` plano interleaved
//! `[x,y,z, ...]`, mutado in-place (P1 + P2: zero boxing, zero alloc
//! en hot path).
//! - `gpu_paint_with(...)` proyecta perspectiva y dibuja: lee el campo
//! de estrellas, calcula el pixel de cada una contra el rect del nodo
//! y llama `canvas.fill_rect(...)`. Ni el demo ni el painter saben de
//! `wgpu` — sólo hablan el trait `Canvas`.
//!
//! El campo se comparte entre `update` (escribe) y el painter (lee) por
//! `Arc<Mutex<StarField>>`. Ambos corren en el hilo de UI, así que el
//! mutex nunca se disputa; el `Arc` existe sólo porque la closure GPU
//! debe ser `'static` y no puede tomar prestado el `Model`.
//!
//! Las `GpuPipelines` (shaders + render pipelines) se compilan una vez y
//! se cachean en un `OnceLock` que vive en el `Model`, no en `view()` —
//! recompilarlas por frame mataría el framerate.
use std::sync::{Arc, Mutex, OnceLock};
use std::time::Duration;
use llimphi_theme::Theme;
use llimphi_ui::llimphi_layout::taffy::prelude::{length, percent, FlexDirection, Size, Style};
use llimphi_ui::llimphi_layout::taffy::Rect as PadRect;
use llimphi_ui::llimphi_raster::peniko::Color as PenikoColor;
use llimphi_ui::llimphi_raster::{GpuBatch, GpuPipelines};
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::{App, Handle, KeyEvent, KeyState, View};
use llimphi_widget_context_menu::{
context_menu_view, ContextMenuItem, ContextMenuPalette, ContextMenuSpec,
};
use llimphi_widget_menubar::{menubar_overlay, menubar_view, MenuBarSpec, DEFAULT_HEIGHT as MENU_H};
use app_bus::{AppMenu, Menu, MenuItem};
use pineal_render::{Canvas, Color, GpuSceneCanvas, Rect};
/// La intermediate del frame es `Rgba8Unorm` (ver
/// `llimphi-compositor::GpuPaintFn`). Las pipelines deben coincidir.
const TARGET_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
const FRAME_PERIOD: Duration = Duration::from_millis(16);
const NEAR: f32 = 0.06;
const FAR: f32 = 1.0;
const WARP_SPEED: f32 = 0.012;
/// Densidades cicladas desde el menú. La última (1 M) es la que el SDD
/// daba como techo del camino GPU directo.
const DENSITIES: [usize; 4] = [50_000, 200_000, 500_000, 1_000_000];
#[derive(Clone)]
enum Msg {
Tick,
/// Cicla a la siguiente densidad de estrellas y reconstruye el campo.
CycleDensity,
/// Pausa/reanuda el warp (las estrellas quedan congeladas).
TogglePause,
MenuOpen(Option<usize>),
MenuCommand(String),
CloseMenus,
CycleTheme,
ContextMenuOpen(f32, f32),
}
/// Campo de estrellas en coordenadas de cámara. `xyz` interleaved
/// `[x0,y0,z0, x1,y1,z1, ...]`; x,y ∈ [-1,1], z ∈ (NEAR, FAR].
struct StarField {
xyz: Vec<f32>,
count: usize,
rng: u32,
}
impl StarField {
fn new(count: usize) -> Self {
let mut field = StarField {
xyz: vec![0.0; count * 3],
count,
rng: 0x9E37_79B9,
};
for i in 0..count {
field.respawn(i, true);
}
field
}
/// xorshift32 → f32 en [-1, 1).
fn unit(&mut self) -> f32 {
let mut x = self.rng;
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
self.rng = x;
(x as f32 / u32::MAX as f32) * 2.0 - 1.0
}
/// Reposiciona la estrella `i`. Con `fresh` la siembra a una `z`
/// aleatoria en todo el rango (arranque); si no, la manda al fondo
/// (reciclaje cuando cruzó el plano cercano).
fn respawn(&mut self, i: usize, fresh: bool) {
let x = self.unit();
let y = self.unit();
let z = if fresh {
// Aleatoria en (NEAR, FAR] para no arrancar todas en fila.
NEAR + (self.unit() * 0.5 + 0.5) * (FAR - NEAR)
} else {
FAR
};
let b = i * 3;
self.xyz[b] = x;
self.xyz[b + 1] = y;
self.xyz[b + 2] = z;
}
/// Un paso de simulación: todas vuelan hacia el observador; las que
/// cruzan el plano cercano se reciclan al fondo. Zero alloc.
fn advance(&mut self) {
for i in 0..self.count {
let zi = i * 3 + 2;
let z = self.xyz[zi] - WARP_SPEED;
if z <= NEAR {
self.respawn(i, false);
} else {
self.xyz[zi] = z;
}
}
}
}
struct Model {
field: Arc<Mutex<StarField>>,
pipelines: Arc<OnceLock<GpuPipelines>>,
density_idx: usize,
paused: bool,
frame: u64,
theme: Theme,
menu_open: Option<usize>,
context_menu: Option<(f32, f32)>,
}
struct GpuDemo;
impl App for GpuDemo {
type Model = Model;
type Msg = Msg;
fn title() -> &'static str {
"pineal — starfield warp (GPU directo)"
}
fn initial_size() -> (u32, u32) {
(1100, 700)
}
fn init(handle: &Handle<Msg>) -> Model {
handle.spawn_periodic(FRAME_PERIOD, || Msg::Tick);
let density_idx = 1; // 200 K por defecto
Model {
field: Arc::new(Mutex::new(StarField::new(DENSITIES[density_idx]))),
pipelines: Arc::new(OnceLock::new()),
density_idx,
paused: false,
frame: 0,
theme: Theme::dark(),
menu_open: None,
context_menu: None,
}
}
fn update(mut model: Model, msg: Msg, handle: &Handle<Msg>) -> Model {
match msg {
Msg::Tick => {
if !model.paused {
model.field.lock().unwrap().advance();
model.frame = model.frame.wrapping_add(1);
}
}
Msg::CycleDensity => {
model.density_idx = (model.density_idx + 1) % DENSITIES.len();
let n = DENSITIES[model.density_idx];
model.field = Arc::new(Mutex::new(StarField::new(n)));
}
Msg::TogglePause => model.paused = !model.paused,
Msg::MenuOpen(which) => {
model.menu_open = which;
model.context_menu = None;
}
Msg::MenuCommand(cmd) => {
model.menu_open = None;
return handle_menu_command(model, &cmd, handle);
}
Msg::CloseMenus => {
model.menu_open = None;
model.context_menu = None;
}
Msg::CycleTheme => model.theme = Theme::next_after(model.theme.name),
Msg::ContextMenuOpen(x, y) => {
model.menu_open = None;
model.context_menu = Some((x, y));
}
}
model
}
fn view(model: &Model) -> View<Msg> {
let theme = &model.theme;
let menu = app_menu();
let menubar = menubar_view(&menubar_spec(&menu, model));
let header = View::new(Style {
size: Size { width: percent(1.0_f32), height: length(26.0_f32) },
..Default::default()
})
.text_aligned(
"pineal — starfield warp · GpuSceneCanvas → 1 draw call instanciada".to_string(),
18.0,
theme.fg_text,
Alignment::Start,
);
let stats = format!(
"estrellas = {} {} frame = {} backend = GpuBatch (wgpu, Rgba8Unorm) \
[D] densidad · [espacio] pausa · click-derecho menú",
fmt_count(DENSITIES[model.density_idx]),
if model.paused { "PAUSA" } else { "warp" },
model.frame,
);
let stats_row = View::new(Style {
size: Size { width: percent(1.0_f32), height: length(18.0_f32) },
..Default::default()
})
.text_aligned(stats, 11.0, theme.fg_muted, Alignment::Start);
// El panel del cielo: fondo negro por vello + estrellas por GPU
// directo encima (LoadOp::Load preserva el fondo).
let field = model.field.clone();
let pipelines = model.pipelines.clone();
let sky = View::new(Style {
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
flex_grow: 1.0,
..Default::default()
})
.clip(true)
.fill(PenikoColor::from_rgba8(2, 3, 9, 255))
.gpu_paint_with(move |device, queue, encoder, target, rect, viewport| {
let pipes = pipelines.get_or_init(|| GpuPipelines::new(device, TARGET_FORMAT));
let mut batch = GpuBatch::new(pipes);
// Proyección perspectiva contra el rect del nodo (pixels
// absolutos del scene). Todo lo que sigue es el caller
// decidiendo "qué pintar"; el "cómo" es del backend.
{
let mut canvas = GpuSceneCanvas::new(&mut batch);
let cx = rect.x + rect.w * 0.5;
let cy = rect.y + rect.h * 0.5;
let kx = rect.w * 0.05;
let ky = rect.h * 0.05;
let f = model_field_snapshot(&field);
let xyz = &f.xyz;
for i in 0..f.count {
let b = i * 3;
let (x, y, z) = (xyz[b], xyz[b + 1], xyz[b + 2]);
let inv = 1.0 / z;
let sx = cx + x * inv * kx;
let sy = cy + y * inv * ky;
// Cull fuera del rect (barato; evita rects enormes).
if sx < rect.x || sx > rect.x + rect.w || sy < rect.y || sy > rect.y + rect.h {
continue;
}
// Cerca = grande y brillante; lejos = punto tenue.
let bright = ((FAR - z) / (FAR - NEAR)).clamp(0.0, 1.0);
let size = 0.6 + bright * bright * 2.6;
let half = size * 0.5;
let a = 0.25 + bright * 0.75;
// Tinte ligeramente frío hacia el azul-blanco.
let col = Color::rgba(0.78 + bright * 0.22, 0.84 + bright * 0.16, 1.0, a);
canvas.fill_rect(
Rect { x: sx - half, y: sy - half, w: size, h: size },
col,
);
}
}
batch.flush(
device,
queue,
encoder,
target,
(viewport.0 as f32, viewport.1 as f32),
wgpu::LoadOp::Load,
);
});
let sky_panel = View::new(Style {
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
flex_grow: 1.0,
..Default::default()
})
.clip(true)
.children(vec![sky]);
let body = View::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
flex_grow: 1.0,
padding: PadRect {
left: length(16.0_f32),
right: length(16.0_f32),
top: length(14.0_f32),
bottom: length(14.0_f32),
},
gap: Size { width: length(0.0_f32), height: length(8.0_f32) },
..Default::default()
})
.fill(theme.bg_app)
.children(vec![header, stats_row, sky_panel]);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
..Default::default()
})
.fill(theme.bg_app)
.on_right_click_at(|x, y, _w, _h| Some(Msg::ContextMenuOpen(x, y)))
.children(vec![menubar, body])
}
fn on_key(_model: &Model, event: &KeyEvent) -> Option<Msg> {
if event.state != KeyState::Pressed {
return None;
}
match event.text.as_deref() {
Some("d") | Some("D") => Some(Msg::CycleDensity),
Some(" ") => Some(Msg::TogglePause),
_ => None,
}
}
fn view_overlay(model: &Model) -> Option<View<Msg>> {
if let Some((x, y)) = model.context_menu {
let items = vec![
ContextMenuItem::action("Más estrellas"),
ContextMenuItem::action("Pausa / reanudar"),
ContextMenuItem::action("Cambiar tema"),
];
let on_pick: Arc<dyn Fn(usize) -> Msg + Send + Sync> = Arc::new(|i: usize| match i {
0 => Msg::CycleDensity,
1 => Msg::TogglePause,
_ => Msg::CycleTheme,
});
return Some(context_menu_view(ContextMenuSpec {
anchor: (x, y),
viewport: viewport_of(model),
header: Some("Starfield".to_string()),
items,
active: usize::MAX,
on_pick,
on_dismiss: Msg::CloseMenus,
palette: ContextMenuPalette::from_theme(&model.theme),
}));
}
let menu = app_menu();
menubar_overlay(&menubar_spec(&menu, model))
}
}
/// Clona el snapshot del campo bajo el lock (libera el mutex antes de
/// la proyección). El campo es `Vec<f32>` plano: el clone es un memcpy
/// contiguo, sin punteros ni boxing (P1).
fn model_field_snapshot(field: &Arc<Mutex<StarField>>) -> FieldSnapshot {
let g = field.lock().unwrap();
FieldSnapshot { xyz: g.xyz.clone(), count: g.count }
}
struct FieldSnapshot {
xyz: Vec<f32>,
count: usize,
}
fn fmt_count(n: usize) -> String {
if n >= 1_000_000 {
format!("{} M", n / 1_000_000)
} else {
format!("{} K", n / 1_000)
}
}
fn viewport_of(_model: &Model) -> (f32, f32) {
let (w, h) = GpuDemo::initial_size();
(w as f32, h as f32)
}
fn menubar_spec<'a>(menu: &'a AppMenu, model: &'a Model) -> MenuBarSpec<'a, Msg> {
MenuBarSpec {
menu,
open: model.menu_open,
theme: &model.theme,
viewport: viewport_of(model),
height: MENU_H,
on_open: Arc::new(Msg::MenuOpen),
on_command: Arc::new(|c: &str| Msg::MenuCommand(c.to_string())),
}
}
fn app_menu() -> AppMenu {
AppMenu::new()
.menu(Menu::new("Archivo").item(MenuItem::new("Salir", "file.quit").shortcut("Ctrl+Q")))
.menu(
Menu::new("Ver")
.item(MenuItem::new("Más estrellas", "view.density"))
.item(MenuItem::new("Pausa / reanudar", "view.pause"))
.item(MenuItem::new("Cambiar tema", "view.theme").separated()),
)
.menu(Menu::new("Ayuda").item(MenuItem::new("Acerca de", "help.about")))
}
fn handle_menu_command(model: Model, cmd: &str, handle: &Handle<Msg>) -> Model {
match cmd {
"file.quit" => std::process::exit(0),
"view.density" => {
handle.dispatch(Msg::CycleDensity);
model
}
"view.pause" => {
handle.dispatch(Msg::TogglePause);
model
}
"view.theme" => {
handle.dispatch(Msg::CycleTheme);
model
}
_ => model,
}
}
fn main() {
llimphi_ui::run::<GpuDemo>();
}
@@ -0,0 +1,17 @@
[package]
name = "pineal-heatmap-demo"
version = { workspace = true }
edition = { workspace = true }
license = { workspace = true }
authors = { workspace = true }
publish = { workspace = true }
description = "Lapaloma — demo heatmap: campo 48×32 con onda viajera (Viridis)."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
llimphi-widget-menubar = { workspace = true }
llimphi-widget-context-menu = { workspace = true }
app-bus = { workspace = true }
pineal-render = { path = "../../pineal-render" }
pineal-heatmap = { path = "../../pineal-heatmap" }
@@ -0,0 +1,275 @@
//! `pineal-heatmap-demo` — campo 2D con onda viajera.
//!
//! Matriz 48×32 que se reescribe cada 33 ms (≈ 30 Hz). El valor de
//! cada celda combina dos sinusoides con desplazamiento de fase
//! ligado al tick: `sin(x·0.25 - t·0.1) + cos(y·0.30 + t·0.07)`.
//! El ramp Viridis mapea `[min, max]` de la matriz a color.
//!
//! Cableado de UI: barra de menú principal (Archivo / Ver / Ayuda) +
//! menú contextual sobre el plot (right-click). Como es un canvas sin
//! texto editable no hay menú Editar ni clipboard.
use std::sync::{Arc, Mutex};
use std::time::Duration;
use llimphi_theme::Theme;
use llimphi_ui::llimphi_layout::taffy::prelude::{length, percent, FlexDirection, Size, Style};
use llimphi_ui::llimphi_layout::taffy::Rect as TaffyRect;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::{App, Handle, View};
use llimphi_widget_context_menu::{
context_menu_view, ContextMenuItem, ContextMenuPalette, ContextMenuSpec,
};
use llimphi_widget_menubar::{menubar_overlay, menubar_view, MenuBarSpec, DEFAULT_HEIGHT as MENU_H};
use app_bus::{AppMenu, Menu, MenuItem};
use pineal_heatmap::{paint, HeatmapMatrix, Ramp};
use pineal_render::{Canvas as _, Color, Rect, SceneCanvas};
const W: usize = 48;
const H: usize = 32;
const TICK_PERIOD: Duration = Duration::from_millis(33);
#[derive(Clone)]
enum Msg {
Tick,
/// Reinicia el campo al tick 0 (la "vista" del heatmap).
Reset,
/// Barra de menú principal: abrir/cerrar un menú raíz (`None` cerrar).
MenuOpen(Option<usize>),
/// Comando elegido en el menú principal — se traduce al `Msg` real.
MenuCommand(String),
/// Cierra cualquier menú abierto (click-fuera / Esc).
CloseMenus,
/// Cicla el tema claro/oscuro.
CycleTheme,
/// Right-click en la raíz → abre el menú contextual en `(x, y)`.
ContextMenuOpen(f32, f32),
}
struct Model {
matrix: Arc<Mutex<HeatmapMatrix>>,
t: u64,
theme: Theme,
menu_open: Option<usize>,
context_menu: Option<(f32, f32)>,
}
struct HeatmapDemo;
impl App for HeatmapDemo {
type Model = Model;
type Msg = Msg;
fn title() -> &'static str {
"Lapaloma — heatmap 48×32 (Viridis · onda viajera)"
}
fn initial_size() -> (u32, u32) {
(960, 600)
}
fn init(handle: &Handle<Msg>) -> Model {
handle.spawn_periodic(TICK_PERIOD, || Msg::Tick);
let mut m = HeatmapMatrix::new(W, H);
fill(&mut m, 0);
Model {
matrix: Arc::new(Mutex::new(m)),
t: 0,
theme: Theme::dark(),
menu_open: None,
context_menu: None,
}
}
fn update(mut model: Model, msg: Msg, handle: &Handle<Msg>) -> Model {
match msg {
Msg::Tick => {
model.t = model.t.wrapping_add(1);
if let Ok(mut m) = model.matrix.lock() {
fill(&mut m, model.t);
}
}
Msg::Reset => {
model.t = 0;
if let Ok(mut m) = model.matrix.lock() {
fill(&mut m, 0);
}
}
Msg::MenuOpen(which) => {
model.menu_open = which;
model.context_menu = None;
}
Msg::MenuCommand(cmd) => {
model.menu_open = None;
return handle_menu_command(model, &cmd, handle);
}
Msg::CloseMenus => {
model.menu_open = None;
model.context_menu = None;
}
Msg::CycleTheme => {
model.theme = Theme::next_after(model.theme.name);
}
Msg::ContextMenuOpen(x, y) => {
model.menu_open = None;
model.context_menu = Some((x, y));
}
}
model
}
fn view(model: &Model) -> View<Msg> {
let theme = &model.theme;
let plot_bg = Color::rgba(0.06, 0.07, 0.10, 1.0);
let matrix = model.matrix.clone();
let menu = app_menu();
let menubar = menubar_view(&menubar_spec(&menu, model));
let header = View::new(Style {
size: Size { width: percent(1.0_f32), height: length(28.0_f32) },
..Default::default()
})
.text_aligned(
"Lapaloma — heatmap".to_string(),
18.0,
theme.fg_text,
Alignment::Start,
);
let stats = format!("matriz {}×{} · tick = {} · ramp = Viridis", W, H, model.t);
let legend = View::new(Style {
size: Size { width: percent(1.0_f32), height: length(20.0_f32) },
..Default::default()
})
.text_aligned(stats, 11.0, theme.fg_muted, Alignment::Start);
let panel = View::new(Style {
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
flex_grow: 1.0,
..Default::default()
})
.clip(true)
.paint_with(move |scene, ts, rect| {
let outer = Rect::new(rect.x, rect.y, rect.w, rect.h);
let mut canvas = SceneCanvas::new(scene, ts);
canvas.fill_rect(outer, plot_bg);
if let Ok(m) = matrix.lock() {
paint(&m, Ramp::Viridis, outer, &mut canvas);
}
});
let body = View::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
flex_grow: 1.0,
padding: TaffyRect {
left: length(16.0_f32),
right: length(16.0_f32),
top: length(16.0_f32),
bottom: length(16.0_f32),
},
gap: Size { width: length(0.0_f32), height: length(10.0_f32) },
..Default::default()
})
.fill(theme.bg_app)
.children(vec![header, legend, panel]);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
..Default::default()
})
.fill(theme.bg_app)
.on_right_click_at(|x, y, _w, _h| Some(Msg::ContextMenuOpen(x, y)))
.children(vec![menubar, body])
}
fn view_overlay(model: &Model) -> Option<View<Msg>> {
if let Some((x, y)) = model.context_menu {
let items = vec![
ContextMenuItem::action("Reiniciar vista"),
ContextMenuItem::action("Cambiar tema"),
];
let on_pick: Arc<dyn Fn(usize) -> Msg + Send + Sync> =
Arc::new(|i: usize| match i {
0 => Msg::Reset,
_ => Msg::CycleTheme,
});
return Some(context_menu_view(ContextMenuSpec {
anchor: (x, y),
viewport: viewport_of(model),
header: Some("Heatmap".to_string()),
items,
active: usize::MAX,
on_pick,
on_dismiss: Msg::CloseMenus,
palette: ContextMenuPalette::from_theme(&model.theme),
}));
}
let menu = app_menu();
menubar_overlay(&menubar_spec(&menu, model))
}
}
fn viewport_of(_model: &Model) -> (f32, f32) {
let (w, h) = HeatmapDemo::initial_size();
(w as f32, h as f32)
}
fn menubar_spec<'a>(menu: &'a AppMenu, model: &'a Model) -> MenuBarSpec<'a, Msg> {
MenuBarSpec {
menu,
open: model.menu_open,
theme: &model.theme,
viewport: viewport_of(model),
height: MENU_H,
on_open: Arc::new(Msg::MenuOpen),
on_command: Arc::new(|c: &str| Msg::MenuCommand(c.to_string())),
}
}
fn app_menu() -> AppMenu {
AppMenu::new()
.menu(Menu::new("Archivo").item(MenuItem::new("Salir", "file.quit").shortcut("Ctrl+Q")))
.menu(
Menu::new("Ver")
.item(MenuItem::new("Reiniciar vista", "view.reset"))
.item(MenuItem::new("Cambiar tema", "view.theme").separated()),
)
.menu(Menu::new("Ayuda").item(MenuItem::new("Acerca de", "help.about")))
}
fn handle_menu_command(model: Model, cmd: &str, handle: &Handle<Msg>) -> Model {
match cmd {
"file.quit" => std::process::exit(0),
"view.reset" => {
handle.dispatch(Msg::Reset);
model
}
"view.theme" => {
handle.dispatch(Msg::CycleTheme);
model
}
// "help.about" y desconocidos: no-op (sin diálogo todavía).
_ => model,
}
}
fn fill(m: &mut HeatmapMatrix, t: u64) {
let phase = t as f32 * 0.1;
let phase2 = t as f32 * 0.07;
let mut data = Vec::with_capacity(W * H);
for y in 0..H {
for x in 0..W {
let v = (x as f32 * 0.25 - phase).sin() + (y as f32 * 0.30 + phase2).cos();
data.push(v);
}
}
m.replace_data(data);
}
fn main() {
llimphi_ui::run::<HeatmapDemo>();
}
@@ -0,0 +1,18 @@
[package]
name = "pineal-hexbin-demo"
version = { workspace = true }
edition = { workspace = true }
license = { workspace = true }
authors = { workspace = true }
publish = { workspace = true }
description = "Lapaloma — demo hexbin: 5000 puntos gaussianos sintéticos bineados a Viridis."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
llimphi-widget-menubar = { workspace = true }
llimphi-widget-context-menu = { workspace = true }
app-bus = { workspace = true }
pineal-render = { path = "../../pineal-render" }
pineal-hexbin = { path = "../../pineal-hexbin" }
pineal-heatmap = { path = "../../pineal-heatmap" }
@@ -0,0 +1,286 @@
//! `pineal-hexbin-demo` — 5 000 puntos sintéticos bineados.
//!
//! Generador determinista (LCG sobre `t`) que produce dos clusters
//! gaussianos solapados. El hexbin revela la densidad — cada celda se
//! colorea con Viridis según count. Sin animación: el chart se computa
//! una vez al iniciar.
//!
//! Cableado de UI: barra de menú principal (Archivo / Ver / Ayuda) +
//! menú contextual sobre el plot (right-click). Como es un canvas sin
//! texto editable no hay menú Editar ni clipboard.
use std::sync::Arc;
use llimphi_theme::Theme;
use llimphi_ui::llimphi_layout::taffy::prelude::{length, percent, FlexDirection, Size, Style};
use llimphi_ui::llimphi_layout::taffy::Rect as TaffyRect;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::{App, Handle, View};
use llimphi_widget_context_menu::{
context_menu_view, ContextMenuItem, ContextMenuPalette, ContextMenuSpec,
};
use llimphi_widget_menubar::{menubar_overlay, menubar_view, MenuBarSpec, DEFAULT_HEIGHT as MENU_H};
use app_bus::{AppMenu, Menu, MenuItem};
use pineal_heatmap::Ramp;
use pineal_hexbin::{paint_hexbin, HexGrid};
use pineal_render::{Canvas as _, Color, Rect, SceneCanvas};
const N_POINTS: usize = 5000;
const HEX_RADIUS: f32 = 9.0;
#[derive(Clone)]
enum Msg {
/// Recalcula el hexbin desde cero (la "vista" del chart).
Reset,
/// Barra de menú principal: abrir/cerrar un menú raíz (`None` cerrar).
MenuOpen(Option<usize>),
/// Comando elegido en el menú principal — se traduce al `Msg` real.
MenuCommand(String),
/// Cierra cualquier menú abierto (click-fuera / Esc).
CloseMenus,
/// Cicla el tema claro/oscuro.
CycleTheme,
/// Right-click en la raíz → abre el menú contextual en `(x, y)`.
ContextMenuOpen(f32, f32),
}
struct Model {
grid: HexGrid,
theme: Theme,
menu_open: Option<usize>,
context_menu: Option<(f32, f32)>,
}
struct HexbinDemo;
impl App for HexbinDemo {
type Model = Model;
type Msg = Msg;
fn title() -> &'static str {
"Lapaloma — hexbin (5 000 puntos gaussianos)"
}
fn initial_size() -> (u32, u32) {
(900, 620)
}
fn init(_: &Handle<Msg>) -> Model {
Model {
grid: build_grid(),
theme: Theme::dark(),
menu_open: None,
context_menu: None,
}
}
fn update(mut model: Model, msg: Msg, handle: &Handle<Msg>) -> Model {
match msg {
Msg::Reset => {
model.grid = build_grid();
}
Msg::MenuOpen(which) => {
model.menu_open = which;
model.context_menu = None;
}
Msg::MenuCommand(cmd) => {
model.menu_open = None;
return handle_menu_command(model, &cmd, handle);
}
Msg::CloseMenus => {
model.menu_open = None;
model.context_menu = None;
}
Msg::CycleTheme => {
model.theme = Theme::next_after(model.theme.name);
}
Msg::ContextMenuOpen(x, y) => {
model.menu_open = None;
model.context_menu = Some((x, y));
}
}
model
}
fn view(model: &Model) -> View<Msg> {
let theme = &model.theme;
let plot_bg = Color::rgba(0.06, 0.08, 0.10, 1.0);
let grid = &model.grid;
let snapshot = grid.clone();
let (min, max) = grid.min_max();
let menu = app_menu();
let menubar = menubar_view(&menubar_spec(&menu, model));
let header = View::new(Style {
size: Size { width: percent(1.0_f32), height: length(28.0_f32) },
..Default::default()
})
.text_aligned(
"Lapaloma — hexbin".to_string(),
18.0,
theme.fg_text,
Alignment::Start,
);
let legend = View::new(Style {
size: Size { width: percent(1.0_f32), height: length(20.0_f32) },
..Default::default()
})
.text_aligned(
format!(
"{} pts · radio {} px · {} bines · count ∈ [{}, {}] · Viridis",
N_POINTS,
HEX_RADIUS as i32,
grid.cells().count(),
min,
max,
),
11.0,
theme.fg_muted,
Alignment::Start,
);
let panel = View::new(Style {
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
flex_grow: 1.0,
..Default::default()
})
.clip(true)
.paint_with(move |scene, ts, rect| {
let outer = Rect::new(rect.x, rect.y, rect.w, rect.h);
let mut canvas = SceneCanvas::new(scene, ts);
canvas.fill_rect(outer, plot_bg);
paint_hexbin(&snapshot, Ramp::Viridis, (outer.x, outer.y), &mut canvas);
});
let body = View::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
flex_grow: 1.0,
padding: TaffyRect {
left: length(16.0_f32),
right: length(16.0_f32),
top: length(16.0_f32),
bottom: length(16.0_f32),
},
gap: Size { width: length(0.0_f32), height: length(10.0_f32) },
..Default::default()
})
.fill(theme.bg_app)
.children(vec![header, legend, panel]);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
..Default::default()
})
.fill(theme.bg_app)
.on_right_click_at(|x, y, _w, _h| Some(Msg::ContextMenuOpen(x, y)))
.children(vec![menubar, body])
}
fn view_overlay(model: &Model) -> Option<View<Msg>> {
if let Some((x, y)) = model.context_menu {
let items = vec![
ContextMenuItem::action("Recalcular"),
ContextMenuItem::action("Cambiar tema"),
];
let on_pick: Arc<dyn Fn(usize) -> Msg + Send + Sync> =
Arc::new(|i: usize| match i {
0 => Msg::Reset,
_ => Msg::CycleTheme,
});
return Some(context_menu_view(ContextMenuSpec {
anchor: (x, y),
viewport: viewport_of(model),
header: Some("Hexbin".to_string()),
items,
active: usize::MAX,
on_pick,
on_dismiss: Msg::CloseMenus,
palette: ContextMenuPalette::from_theme(&model.theme),
}));
}
let menu = app_menu();
menubar_overlay(&menubar_spec(&menu, model))
}
}
fn viewport_of(_model: &Model) -> (f32, f32) {
let (w, h) = HexbinDemo::initial_size();
(w as f32, h as f32)
}
fn menubar_spec<'a>(menu: &'a AppMenu, model: &'a Model) -> MenuBarSpec<'a, Msg> {
MenuBarSpec {
menu,
open: model.menu_open,
theme: &model.theme,
viewport: viewport_of(model),
height: MENU_H,
on_open: Arc::new(Msg::MenuOpen),
on_command: Arc::new(|c: &str| Msg::MenuCommand(c.to_string())),
}
}
fn app_menu() -> AppMenu {
AppMenu::new()
.menu(Menu::new("Archivo").item(MenuItem::new("Salir", "file.quit").shortcut("Ctrl+Q")))
.menu(
Menu::new("Ver")
.item(MenuItem::new("Recalcular", "view.reset"))
.item(MenuItem::new("Cambiar tema", "view.theme").separated()),
)
.menu(Menu::new("Ayuda").item(MenuItem::new("Acerca de", "help.about")))
}
fn handle_menu_command(model: Model, cmd: &str, handle: &Handle<Msg>) -> Model {
match cmd {
"file.quit" => std::process::exit(0),
"view.reset" => {
handle.dispatch(Msg::Reset);
model
}
"view.theme" => {
handle.dispatch(Msg::CycleTheme);
model
}
// "help.about" y desconocidos: no-op (sin diálogo todavía).
_ => model,
}
}
/// Construye el HexGrid con los 5 000 puntos gaussianos deterministas.
fn build_grid() -> HexGrid {
let mut g = HexGrid::new(HEX_RADIUS);
// LCG determinista — no agrega dep para randomness.
let mut state: u64 = 0xC0FFEE;
let mut rng = || -> f32 {
state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
((state >> 32) as f32) / (u32::MAX as f32)
};
let mut gauss = || -> (f32, f32) {
// Box-Muller.
let u1 = (rng()).max(1e-9);
let u2 = rng();
let r = (-2.0 * u1.ln()).sqrt();
let theta = 2.0 * std::f32::consts::PI * u2;
(r * theta.cos(), r * theta.sin())
};
// Cluster A: centro (300, 300), sigma 50. Cluster B: (520, 380), sigma 80.
for i in 0..N_POINTS {
let (g0, g1) = gauss();
if i % 3 == 0 {
g.push(300.0 + g0 * 50.0, 300.0 + g1 * 50.0);
} else {
g.push(520.0 + g0 * 80.0, 380.0 + g1 * 80.0);
}
}
g
}
fn main() {
llimphi_ui::run::<HexbinDemo>();
}
@@ -0,0 +1,17 @@
[package]
name = "pineal-mesh-demo"
version = { workspace = true }
edition = { workspace = true }
license = { workspace = true }
authors = { workspace = true }
publish = { workspace = true }
description = "Lapaloma — demo grafo: layout force-directed que converge frame a frame."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
llimphi-widget-menubar = { workspace = true }
llimphi-widget-context-menu = { workspace = true }
app-bus = { workspace = true }
pineal-render = { path = "../../pineal-render" }
pineal-mesh = { path = "../../pineal-mesh" }
@@ -0,0 +1,350 @@
//! `pineal-mesh-demo` — grafo de 24 nodos relajándose en vivo.
//!
//! Topología: ciclo de 12 nodos + cordales aleatorios + 12 nodos
//! satélites enganchados al exterior. La simulación Fruchterman-Reingold
//! corre un paso por tick (≈ 60 Hz) hasta enfriarse; el panel muestra
//! aristas (gris) y nodos (círculos de discos rellenos) sobre fondo
//! oscuro. Cuando la temperatura cae bajo el umbral, el sistema queda
//! estacionario hasta que un reset lo recalienta.
//!
//! Cableado de UI: barra de menú principal (Archivo / Ver / Ayuda) +
//! menú contextual sobre el plot (right-click). Como es un canvas sin
//! texto editable no hay menú Editar ni clipboard.
use std::sync::{Arc, Mutex};
use std::time::Duration;
use llimphi_theme::Theme;
use llimphi_ui::llimphi_layout::taffy::prelude::{length, percent, FlexDirection, Size, Style};
use llimphi_ui::llimphi_layout::taffy::Rect as TaffyRect;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::{App, Handle, View};
use llimphi_widget_context_menu::{
context_menu_view, ContextMenuItem, ContextMenuPalette, ContextMenuSpec,
};
use llimphi_widget_menubar::{menubar_overlay, menubar_view, MenuBarSpec, DEFAULT_HEIGHT as MENU_H};
use app_bus::{AppMenu, Menu, MenuItem};
use pineal_mesh::{EdgeBuffer, ForceLayout, ForceParams, NodeBuffer};
use pineal_render::{Canvas as _, Color, Point, Rect, SceneCanvas, StrokeStyle};
const N_RING: usize = 12;
const N_SAT: usize = 12;
const TICK_PERIOD: Duration = Duration::from_millis(16);
#[derive(Clone)]
enum Msg {
Step,
Reset,
/// Barra de menú principal: abrir/cerrar un menú raíz (`None` cerrar).
MenuOpen(Option<usize>),
/// Comando elegido en el menú principal — se traduce al `Msg` real.
MenuCommand(String),
/// Cierra cualquier menú abierto (click-fuera / Esc).
CloseMenus,
/// Cicla el tema claro/oscuro.
CycleTheme,
/// Right-click en la raíz → abre el menú contextual en `(x, y)`.
ContextMenuOpen(f32, f32),
}
struct Graph {
nodes: NodeBuffer,
edges: EdgeBuffer,
sim: ForceLayout,
}
impl Graph {
fn new() -> Self {
let mut nodes = NodeBuffer::new();
// Anillo principal centrado en el origen, radios chicos para que
// la fuerza repulsiva los separe.
for i in 0..N_RING {
let a = (i as f32 / N_RING as f32) * std::f32::consts::TAU;
nodes.push(20.0 * a.cos(), 20.0 * a.sin(), 6.0);
}
// Satélites: cada uno colgado de un nodo del anillo, levemente
// desplazado.
for i in 0..N_SAT {
let a = (i as f32 / N_SAT as f32) * std::f32::consts::TAU + 0.13;
nodes.push(60.0 * a.cos(), 60.0 * a.sin(), 4.5);
}
let mut edges = EdgeBuffer::new();
// Anillo.
for i in 0..N_RING {
edges.push(i, (i + 1) % N_RING);
}
// Cordales (i ↔ i+3).
for i in 0..N_RING {
edges.push(i, (i + 3) % N_RING);
}
// Satélites enganchados a su nodo de anillo correspondiente.
for i in 0..N_SAT {
edges.push(i, N_RING + i);
}
let sim = ForceLayout::new(ForceParams { k: 38.0, temperature: 60.0, cooling: 0.985 });
Self { nodes, edges, sim }
}
}
struct Model {
graph: Arc<Mutex<Graph>>,
steps: u64,
theme: Theme,
menu_open: Option<usize>,
context_menu: Option<(f32, f32)>,
}
struct MeshDemo;
impl App for MeshDemo {
type Model = Model;
type Msg = Msg;
fn title() -> &'static str {
"Lapaloma — mesh (force-directed, 24 nodos)"
}
fn initial_size() -> (u32, u32) {
(900, 700)
}
fn init(handle: &Handle<Msg>) -> Model {
handle.spawn_periodic(TICK_PERIOD, || Msg::Step);
Model {
graph: Arc::new(Mutex::new(Graph::new())),
steps: 0,
theme: Theme::dark(),
menu_open: None,
context_menu: None,
}
}
fn update(mut model: Model, msg: Msg, handle: &Handle<Msg>) -> Model {
match msg {
Msg::Step => {
if let Ok(mut g) = model.graph.lock() {
// Split-borrow legítimo de campos distintos del struct.
let Graph { nodes, edges, sim } = &mut *g;
let _ = sim.step(nodes, edges);
}
model.steps = model.steps.wrapping_add(1);
}
Msg::Reset => {
if let Ok(mut g) = model.graph.lock() {
*g = Graph::new();
}
model.steps = 0;
}
Msg::MenuOpen(which) => {
model.menu_open = which;
model.context_menu = None;
}
Msg::MenuCommand(cmd) => {
model.menu_open = None;
return handle_menu_command(model, &cmd, handle);
}
Msg::CloseMenus => {
model.menu_open = None;
model.context_menu = None;
}
Msg::CycleTheme => {
model.theme = Theme::next_after(model.theme.name);
}
Msg::ContextMenuOpen(x, y) => {
model.menu_open = None;
model.context_menu = Some((x, y));
}
}
model
}
fn view(model: &Model) -> View<Msg> {
let theme = &model.theme;
let plot_bg = Color::rgba(0.06, 0.08, 0.10, 1.0);
let graph = model.graph.clone();
let menu = app_menu();
let menubar = menubar_view(&menubar_spec(&menu, model));
let header = View::new(Style {
size: Size { width: percent(1.0_f32), height: length(28.0_f32) },
..Default::default()
})
.text_aligned(
"Lapaloma — mesh".to_string(),
18.0,
theme.fg_text,
Alignment::Start,
);
let temp = model
.graph
.lock()
.map(|g| g.sim.temperature())
.unwrap_or(0.0);
let legend = View::new(Style {
size: Size { width: percent(1.0_f32), height: length(20.0_f32) },
..Default::default()
})
.text_aligned(
format!(
"24 nodos · 24 aristas · pasos = {} · T = {:.2} · click = reset",
model.steps, temp,
),
11.0,
theme.fg_muted,
Alignment::Start,
);
let panel = View::new(Style {
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
flex_grow: 1.0,
..Default::default()
})
.clip(true)
.on_click(Msg::Reset)
.paint_with(move |scene, ts, rect| {
let outer = Rect::new(rect.x, rect.y, rect.w, rect.h);
let mut canvas = SceneCanvas::new(scene, ts);
canvas.fill_rect(outer, plot_bg);
if let Ok(g) = graph.lock() {
paint_graph(&mut canvas, &g, outer);
}
});
let body = View::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
flex_grow: 1.0,
padding: TaffyRect {
left: length(16.0_f32),
right: length(16.0_f32),
top: length(16.0_f32),
bottom: length(16.0_f32),
},
gap: Size { width: length(0.0_f32), height: length(10.0_f32) },
..Default::default()
})
.fill(theme.bg_app)
.children(vec![header, legend, panel]);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
..Default::default()
})
.fill(theme.bg_app)
.on_right_click_at(|x, y, _w, _h| Some(Msg::ContextMenuOpen(x, y)))
.children(vec![menubar, body])
}
fn view_overlay(model: &Model) -> Option<View<Msg>> {
if let Some((x, y)) = model.context_menu {
let items = vec![
ContextMenuItem::action("Reiniciar (recalentar)"),
ContextMenuItem::action("Cambiar tema"),
];
let on_pick: Arc<dyn Fn(usize) -> Msg + Send + Sync> =
Arc::new(|i: usize| match i {
0 => Msg::Reset,
_ => Msg::CycleTheme,
});
return Some(context_menu_view(ContextMenuSpec {
anchor: (x, y),
viewport: viewport_of(model),
header: Some("Mesh".to_string()),
items,
active: usize::MAX,
on_pick,
on_dismiss: Msg::CloseMenus,
palette: ContextMenuPalette::from_theme(&model.theme),
}));
}
let menu = app_menu();
menubar_overlay(&menubar_spec(&menu, model))
}
}
fn viewport_of(_model: &Model) -> (f32, f32) {
let (w, h) = MeshDemo::initial_size();
(w as f32, h as f32)
}
fn menubar_spec<'a>(menu: &'a AppMenu, model: &'a Model) -> MenuBarSpec<'a, Msg> {
MenuBarSpec {
menu,
open: model.menu_open,
theme: &model.theme,
viewport: viewport_of(model),
height: MENU_H,
on_open: Arc::new(Msg::MenuOpen),
on_command: Arc::new(|c: &str| Msg::MenuCommand(c.to_string())),
}
}
fn app_menu() -> AppMenu {
AppMenu::new()
.menu(Menu::new("Archivo").item(MenuItem::new("Salir", "file.quit").shortcut("Ctrl+Q")))
.menu(
Menu::new("Ver")
.item(MenuItem::new("Reiniciar (recalentar)", "view.reset"))
.item(MenuItem::new("Cambiar tema", "view.theme").separated()),
)
.menu(Menu::new("Ayuda").item(MenuItem::new("Acerca de", "help.about")))
}
fn handle_menu_command(model: Model, cmd: &str, handle: &Handle<Msg>) -> Model {
match cmd {
"file.quit" => std::process::exit(0),
"view.reset" => {
handle.dispatch(Msg::Reset);
model
}
"view.theme" => {
handle.dispatch(Msg::CycleTheme);
model
}
// "help.about" y desconocidos: no-op (sin diálogo todavía).
_ => model,
}
}
fn paint_graph(canvas: &mut SceneCanvas<'_>, g: &Graph, area: Rect) {
let n = g.nodes.len();
if n == 0 {
return;
}
let cx = area.x + area.w * 0.5;
let cy = area.y + area.h * 0.5;
// Aristas en gris.
let edge_stroke = StrokeStyle::new(1.0, Color::rgba(0.6, 0.65, 0.7, 0.45));
for (u, v) in g.edges.iter() {
let (xu, yu) = g.nodes.pos(u);
let (xv, yv) = g.nodes.pos(v);
canvas.stroke_line(
Point::new(cx + xu, cy + yu),
Point::new(cx + xv, cy + yv),
edge_stroke,
);
}
// Nodos como rectángulos rellenos (quad chico aproxima un disco para
// el `Canvas` mínimo). Anillo + satélites con colores distintos.
for i in 0..n {
let (x, y) = g.nodes.pos(i);
let r = g.nodes.radius(i);
let color = if i < N_RING {
Color::from_hex(0x88c0d0)
} else {
Color::from_hex(0xa3be8c)
};
let rect = Rect::new(cx + x - r, cy + y - r, r * 2.0, r * 2.0);
canvas.fill_rect(rect, color);
}
}
fn main() {
llimphi_ui::run::<MeshDemo>();
}
@@ -0,0 +1,18 @@
[package]
name = "pineal-phosphor-demo"
version = { workspace = true }
edition = { workspace = true }
license = { workspace = true }
authors = { workspace = true }
publish = { workspace = true }
description = "Lapaloma — demo del trail CRT (phosphor) sobre un RingBuffer streaming a 60Hz. Compará con lapaloma-stream-demo para ver el contraste."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
llimphi-widget-menubar = { workspace = true }
llimphi-widget-context-menu = { workspace = true }
app-bus = { workspace = true }
pineal-core = { path = "../../pineal-core" }
pineal-render = { path = "../../pineal-render" }
pineal-phosphor = { path = "../../pineal-phosphor" }
@@ -0,0 +1,15 @@
# pineal-phosphor-demo
> Demo del backend fósforo de [pineal](../README.md).
Genera waveforms (seno, lissajous, ruido) y los pasa por [`pineal-phosphor`](../pineal-phosphor/README.md) con distintos `decay`. Slider para ajustar decay en vivo. Útil para entender qué hace el parámetro antes de usarlo en una app real.
## Uso
```sh
cargo run --release -p pineal-phosphor-demo
```
## Deps
- [`pineal-phosphor`](../pineal-phosphor/README.md), [`pineal-render`](../pineal-render/README.md)
@@ -0,0 +1,15 @@
# pineal-phosphor-demo
> Phosphor backend demo for [pineal](../README.md).
Generates waveforms (sine, lissajous, noise) and pipes them through [`pineal-phosphor`](../pineal-phosphor/README.md) with various `decay` values. Slider to tune decay live. Useful to understand the parameter before using it in a real app.
## Usage
```sh
cargo run --release -p pineal-phosphor-demo
```
## Deps
- [`pineal-phosphor`](../pineal-phosphor/README.md), [`pineal-render`](../pineal-render/README.md)
@@ -0,0 +1,264 @@
//! `pineal-phosphor-demo` — osciloscopio con trail CRT sobre Llimphi.
//!
//! Mismo setup que `pineal-stream-demo` (RingBuffer 512 + timer 60 Hz)
//! pero el render usa `PhosphorView`: el trail decae en alpha del cursor
//! hacia atrás y arrastra un halo (glow). Visualmente queda como un
//! osciloscopio analógico con fósforo persistente.
//!
//! Cableado de UI: barra de menú principal (Archivo / Ver / Ayuda) +
//! menú contextual sobre el plot (right-click). Como es un canvas sin
//! texto editable no hay menú Editar ni clipboard.
use std::sync::Arc;
use std::time::Duration;
use llimphi_theme::Theme;
use llimphi_ui::llimphi_layout::taffy::prelude::{length, percent, FlexDirection, Size, Style};
use llimphi_ui::llimphi_layout::taffy::Rect;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::{App, Handle, View};
use llimphi_widget_context_menu::{
context_menu_view, ContextMenuItem, ContextMenuPalette, ContextMenuSpec,
};
use llimphi_widget_menubar::{menubar_overlay, menubar_view, MenuBarSpec, DEFAULT_HEIGHT as MENU_H};
use app_bus::{AppMenu, Menu, MenuItem};
use pineal_core::ring::RingBuffer;
use pineal_phosphor::pineal_phosphor_view;
use pineal_render::{Color, StrokeStyle};
const RING_CAPACITY: usize = 512;
const SAMPLE_PERIOD: Duration = Duration::from_millis(16);
#[derive(Clone)]
enum Msg {
Tick,
/// Limpia el trail y reinicia el tiempo (la "vista" del osciloscopio).
Reset,
/// Barra de menú principal: abrir/cerrar un menú raíz (`None` cerrar).
MenuOpen(Option<usize>),
/// Comando elegido en el menú principal — se traduce al `Msg` real.
MenuCommand(String),
/// Cierra cualquier menú abierto (click-fuera / Esc).
CloseMenus,
/// Cicla el tema claro/oscuro.
CycleTheme,
/// Right-click en la raíz → abre el menú contextual en `(x, y)`.
ContextMenuOpen(f32, f32),
}
struct Model {
buffer: RingBuffer,
t: u64,
theme: Theme,
menu_open: Option<usize>,
context_menu: Option<(f32, f32)>,
}
struct PhosphorDemo;
impl App for PhosphorDemo {
type Model = Model;
type Msg = Msg;
fn title() -> &'static str {
"Lapaloma — phosphor trail (CRT 60 Hz)"
}
fn initial_size() -> (u32, u32) {
(900, 480)
}
fn init(handle: &Handle<Msg>) -> Model {
handle.spawn_periodic(SAMPLE_PERIOD, || Msg::Tick);
Model {
buffer: RingBuffer::new(RING_CAPACITY),
t: 0,
theme: Theme::dark(),
menu_open: None,
context_menu: None,
}
}
fn update(mut model: Model, msg: Msg, handle: &Handle<Msg>) -> Model {
match msg {
Msg::Tick => {
let v = synthesize(model.t);
model.buffer.push(v);
model.t = model.t.wrapping_add(1);
}
Msg::Reset => {
model.buffer = RingBuffer::new(RING_CAPACITY);
model.t = 0;
}
Msg::MenuOpen(which) => {
model.menu_open = which;
model.context_menu = None;
}
Msg::MenuCommand(cmd) => {
model.menu_open = None;
return handle_menu_command(model, &cmd, handle);
}
Msg::CloseMenus => {
model.menu_open = None;
model.context_menu = None;
}
Msg::CycleTheme => {
model.theme = Theme::next_after(model.theme.name);
}
Msg::ContextMenuOpen(x, y) => {
model.menu_open = None;
model.context_menu = Some((x, y));
}
}
model
}
fn view(model: &Model) -> View<Msg> {
let theme = &model.theme;
let plot_bg = Color::rgba(0.03, 0.05, 0.04, 1.0);
let trace = StrokeStyle::new(1.6, Color::rgb(0.608, 1.0, 0.549));
let menu = app_menu();
let menubar = menubar_view(&menubar_spec(&menu, model));
let header = View::new(Style {
size: Size { width: percent(1.0_f32), height: length(28.0_f32) },
..Default::default()
})
.text_aligned("Lapaloma — phosphor".to_string(), 18.0, theme.fg_text, Alignment::Start);
let stats = format!(
"cap = {} head = {} trail = 24 segs glow = 4× / α 0.18 t = {}",
RING_CAPACITY,
model.buffer.head(),
model.t,
);
let stats_row = View::new(Style {
size: Size { width: percent(1.0_f32), height: length(20.0_f32) },
..Default::default()
})
.text_aligned(stats, 11.0, theme.fg_muted, Alignment::Start);
let phosphor = pineal_phosphor_view(model.buffer.clone(), trace)
.background(plot_bg)
.y_range(-1.2, 1.2)
.trail_segments(24)
.glow(4.0, 0.18)
.view::<Msg>();
let plot_panel = View::new(Style {
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
flex_grow: 1.0,
..Default::default()
})
.clip(true)
.children(vec![phosphor]);
let body = View::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
flex_grow: 1.0,
padding: Rect {
left: length(16.0_f32),
right: length(16.0_f32),
top: length(16.0_f32),
bottom: length(16.0_f32),
},
gap: Size { width: length(0.0_f32), height: length(10.0_f32) },
..Default::default()
})
.fill(theme.bg_app)
.children(vec![header, stats_row, plot_panel]);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
..Default::default()
})
.fill(theme.bg_app)
.on_right_click_at(|x, y, _w, _h| Some(Msg::ContextMenuOpen(x, y)))
.children(vec![menubar, body])
}
fn view_overlay(model: &Model) -> Option<View<Msg>> {
if let Some((x, y)) = model.context_menu {
let items = vec![
ContextMenuItem::action("Limpiar trail"),
ContextMenuItem::action("Cambiar tema"),
];
let on_pick: Arc<dyn Fn(usize) -> Msg + Send + Sync> =
Arc::new(|i: usize| match i {
0 => Msg::Reset,
_ => Msg::CycleTheme,
});
return Some(context_menu_view(ContextMenuSpec {
anchor: (x, y),
viewport: viewport_of(model),
header: Some("Phosphor".to_string()),
items,
active: usize::MAX,
on_pick,
on_dismiss: Msg::CloseMenus,
palette: ContextMenuPalette::from_theme(&model.theme),
}));
}
let menu = app_menu();
menubar_overlay(&menubar_spec(&menu, model))
}
}
fn viewport_of(_model: &Model) -> (f32, f32) {
let (w, h) = PhosphorDemo::initial_size();
(w as f32, h as f32)
}
fn menubar_spec<'a>(menu: &'a AppMenu, model: &'a Model) -> MenuBarSpec<'a, Msg> {
MenuBarSpec {
menu,
open: model.menu_open,
theme: &model.theme,
viewport: viewport_of(model),
height: MENU_H,
on_open: Arc::new(Msg::MenuOpen),
on_command: Arc::new(|c: &str| Msg::MenuCommand(c.to_string())),
}
}
fn app_menu() -> AppMenu {
AppMenu::new()
.menu(Menu::new("Archivo").item(MenuItem::new("Salir", "file.quit").shortcut("Ctrl+Q")))
.menu(
Menu::new("Ver")
.item(MenuItem::new("Limpiar trail", "view.reset"))
.item(MenuItem::new("Cambiar tema", "view.theme").separated()),
)
.menu(Menu::new("Ayuda").item(MenuItem::new("Acerca de", "help.about")))
}
fn handle_menu_command(model: Model, cmd: &str, handle: &Handle<Msg>) -> Model {
match cmd {
"file.quit" => std::process::exit(0),
"view.reset" => {
handle.dispatch(Msg::Reset);
model
}
"view.theme" => {
handle.dispatch(Msg::CycleTheme);
model
}
// "help.about" y desconocidos: no-op (sin diálogo todavía).
_ => model,
}
}
fn synthesize(t: u64) -> f32 {
let phase = t as f32;
let signal = (phase * 0.07).sin() * 0.75 + (phase * 0.19).sin() * 0.22;
let jitter = ((phase * 37.0).sin() * 1000.0).fract() * 0.04;
signal + jitter
}
fn main() {
llimphi_ui::run::<PhosphorDemo>();
}
@@ -0,0 +1,17 @@
[package]
name = "pineal-polar-demo"
version = { workspace = true }
edition = { workspace = true }
license = { workspace = true }
authors = { workspace = true }
publish = { workspace = true }
description = "Lapaloma — demo polar: pie/donut a la izquierda, radar a la derecha."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
llimphi-widget-menubar = { workspace = true }
llimphi-widget-context-menu = { workspace = true }
app-bus = { workspace = true }
pineal-render = { path = "../../pineal-render" }
pineal-polar = { path = "../../pineal-polar" }
@@ -0,0 +1,297 @@
//! `pineal-polar-demo` — pie/donut + radar sobre Llimphi.
//!
//! Dos paneles uno al lado del otro:
//! - **Pie chart (donut)** — 6 porciones de un presupuesto sintético.
//! - **Radar (spider)** — perfil de 6 atributos contra un máximo común.
//!
//! Ambos se ajustan al rect del panel: el centro y el radio se calculan
//! en el closure de `paint_with` a partir del `PaintRect` recibido.
//!
//! Lleva barra de menú principal (Archivo/Ver/Ayuda) + un menú
//! contextual sobre los plots. Son canvas estáticos sin edición ni
//! clipboard — el contextual sólo ofrece cambiar tema.
use std::sync::Arc;
use llimphi_theme::Theme;
use llimphi_ui::llimphi_layout::taffy::prelude::{length, percent, FlexDirection, Size, Style};
use llimphi_ui::llimphi_layout::taffy::Rect as TaffyRect;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::{App, Handle, View};
use app_bus::{AppMenu, Menu, MenuItem};
use llimphi_widget_context_menu::{
context_menu_view, ContextMenuItem, ContextMenuPalette, ContextMenuSpec,
};
use llimphi_widget_menubar::{menubar_overlay, menubar_view, MenuBarSpec, DEFAULT_HEIGHT as MENU_H};
use pineal_polar::{paint_pie, paint_radar, Slice};
use pineal_render::{Canvas as _, Color, Point, Rect, SceneCanvas, StrokeStyle};
#[derive(Clone)]
enum Msg {
/// Barra de menú principal: abrir/cerrar un menú raíz (`None` cierra).
MenuOpen(Option<usize>),
/// Comando elegido en la barra o en el contextual.
MenuCommand(String),
/// Cierra cualquier menú abierto (click-fuera / Esc).
CloseMenus,
/// Right-click sobre el plot → abre el contextual en `(x, y)` de ventana.
ContextMenuOpen(f32, f32),
/// Cicla el preset de tema.
CycleTheme,
}
struct Model {
theme: Theme,
menu_open: Option<usize>,
context_menu: Option<(f32, f32)>,
}
struct PolarDemo;
impl App for PolarDemo {
type Model = Model;
type Msg = Msg;
fn title() -> &'static str {
"Lapaloma — polar (pie · donut · radar)"
}
fn initial_size() -> (u32, u32) {
(1000, 520)
}
fn init(_: &Handle<Msg>) -> Model {
Model { theme: Theme::dark(), menu_open: None, context_menu: None }
}
fn update(mut model: Model, msg: Msg, handle: &Handle<Msg>) -> Model {
match msg {
Msg::MenuOpen(which) => {
model.menu_open = which;
model.context_menu = None;
}
Msg::CloseMenus => {
model.menu_open = None;
model.context_menu = None;
}
Msg::ContextMenuOpen(x, y) => {
model.menu_open = None;
model.context_menu = Some((x, y));
}
Msg::CycleTheme => {
model.theme = Theme::next_after(model.theme.name);
}
Msg::MenuCommand(cmd) => {
model.menu_open = None;
model.context_menu = None;
handle_menu_command(&cmd, handle);
}
}
model
}
fn view(model: &Model) -> View<Msg> {
let theme = &model.theme;
let plot_bg = Color::rgba(0.10, 0.12, 0.16, 1.0);
let menu = app_menu();
let menubar = menubar_view(&menubar_spec(&menu, model));
let header = View::new(Style {
size: Size { width: percent(1.0_f32), height: length(28.0_f32) },
..Default::default()
})
.text_aligned(
"Lapaloma — polar".to_string(),
18.0,
theme.fg_text,
Alignment::Start,
);
let legend = View::new(Style {
size: Size { width: percent(1.0_f32), height: length(20.0_f32) },
..Default::default()
})
.text_aligned(
"izq: donut (6 porciones) · der: radar (6 ejes, max=10)".to_string(),
11.0,
theme.fg_muted,
Alignment::Start,
);
let pie_panel = View::new(Style {
size: Size { width: percent(0.5_f32), height: percent(1.0_f32) },
flex_grow: 1.0,
..Default::default()
})
.clip(true)
.paint_with(move |scene, ts, rect| {
let outer = Rect::new(rect.x, rect.y, rect.w, rect.h);
let mut canvas = SceneCanvas::new(scene, ts);
canvas.fill_rect(outer, plot_bg);
let cx = outer.x + outer.w * 0.5;
let cy = outer.y + outer.h * 0.5;
let r_out = (outer.w.min(outer.h) * 0.42).max(20.0);
let r_in = r_out * 0.45;
let slices = [
Slice::new(28.0, Color::from_hex(0x88c0d0)),
Slice::new(18.0, Color::from_hex(0xd08770)),
Slice::new(14.0, Color::from_hex(0xa3be8c)),
Slice::new(12.0, Color::from_hex(0xebcb8b)),
Slice::new(10.0, Color::from_hex(0xb48ead)),
Slice::new(8.0, Color::from_hex(0x5e81ac)),
];
paint_pie(&slices, Point::new(cx, cy), r_out, r_in, &mut canvas);
});
let radar_panel = View::new(Style {
size: Size { width: percent(0.5_f32), height: percent(1.0_f32) },
flex_grow: 1.0,
..Default::default()
})
.clip(true)
.paint_with(move |scene, ts, rect| {
let outer = Rect::new(rect.x, rect.y, rect.w, rect.h);
let mut canvas = SceneCanvas::new(scene, ts);
canvas.fill_rect(outer, plot_bg);
let cx = outer.x + outer.w * 0.5;
let cy = outer.y + outer.h * 0.5;
let r = (outer.w.min(outer.h) * 0.42).max(20.0);
// Ejes guía: 4 círculos concéntricos cada 25% del radio.
for step in 1..=4 {
let t = step as f32 / 4.0;
let ring: Vec<f32> = (0..=72)
.flat_map(|i| {
let a = (i as f32 / 72.0) * std::f32::consts::TAU
- std::f32::consts::FRAC_PI_2;
[cx + (r * t) * a.cos(), cy + (r * t) * a.sin()]
})
.collect();
canvas.stroke_polyline(
&ring,
StrokeStyle::new(0.6, Color::rgba(0.55, 0.6, 0.7, 0.35)),
);
}
let values = [8.0_f32, 6.5, 9.0, 4.0, 7.0, 5.5];
paint_radar(
&values,
10.0,
Point::new(cx, cy),
r,
Color::rgba(0.639, 0.745, 0.549, 0.35),
StrokeStyle::new(1.6, Color::from_hex(0xa3be8c)),
&mut canvas,
);
});
let row = View::new(Style {
flex_direction: FlexDirection::Row,
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
flex_grow: 1.0,
gap: Size { width: length(12.0_f32), height: length(0.0_f32) },
..Default::default()
})
.children(vec![pie_panel, radar_panel]);
let body = View::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
flex_grow: 1.0,
padding: TaffyRect {
left: length(16.0_f32),
right: length(16.0_f32),
top: length(16.0_f32),
bottom: length(16.0_f32),
},
gap: Size { width: length(0.0_f32), height: length(10.0_f32) },
..Default::default()
})
.children(vec![header, legend, row]);
// Right-click en cualquier punto de la raíz abre el contextual;
// origen (0,0) ⇒ coords locales == coords de ventana.
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
..Default::default()
})
.fill(theme.bg_app)
.on_right_click_at(|x, y, _, _| Some(Msg::ContextMenuOpen(x, y)))
.children(vec![menubar, body])
}
fn view_overlay(model: &Model) -> Option<View<Msg>> {
if let Some((x, y)) = model.context_menu {
return Some(context_menu_for_plot(model, x, y));
}
let menu = app_menu();
menubar_overlay(&menubar_spec(&menu, model))
}
}
// =====================================================================
// Menú principal + contextual del plot
// =====================================================================
fn viewport_of(_model: &Model) -> (f32, f32) {
let (w, h) = PolarDemo::initial_size();
(w as f32, h as f32)
}
fn menubar_spec<'a>(menu: &'a AppMenu, model: &'a Model) -> MenuBarSpec<'a, Msg> {
MenuBarSpec {
menu,
open: model.menu_open,
theme: &model.theme,
viewport: viewport_of(model),
height: MENU_H,
on_open: Arc::new(Msg::MenuOpen),
on_command: Arc::new(|c: &str| Msg::MenuCommand(c.to_string())),
}
}
fn app_menu() -> AppMenu {
AppMenu::new()
.menu(Menu::new("Archivo").item(MenuItem::new("Salir", "file.quit").shortcut("Esc")))
.menu(Menu::new("Ver").item(MenuItem::new("Cambiar tema", "view.theme")))
.menu(Menu::new("Ayuda").item(MenuItem::new("Acerca de", "help.about")))
}
fn handle_menu_command(cmd: &str, handle: &Handle<Msg>) {
match cmd {
"file.quit" => std::process::exit(0),
"view.theme" => handle.dispatch(Msg::CycleTheme),
_ => {}
}
}
/// Menú contextual del plot. Canvas estáticos sin objetos
/// seleccionables ni edición — sólo ofrece cambiar tema.
fn context_menu_for_plot(model: &Model, x: f32, y: f32) -> View<Msg> {
let items = vec![ContextMenuItem::action("Cambiar tema")];
let cmds: Vec<&'static str> = vec!["view.theme"];
let on_pick: Arc<dyn Fn(usize) -> Msg + Send + Sync> = Arc::new(move |i: usize| {
Msg::MenuCommand(cmds.get(i).copied().unwrap_or("").to_string())
});
context_menu_view(ContextMenuSpec {
anchor: (x, y),
viewport: viewport_of(model),
header: Some("polar".to_string()),
items,
active: usize::MAX,
on_pick,
on_dismiss: Msg::CloseMenus,
palette: ContextMenuPalette::from_theme(&model.theme),
})
}
fn main() {
llimphi_ui::run::<PolarDemo>();
}
@@ -0,0 +1,18 @@
[package]
name = "pineal-stream-demo"
version = { workspace = true }
edition = { workspace = true }
license = { workspace = true }
authors = { workspace = true }
publish = { workspace = true }
description = "Lapaloma — demo de streaming: RingBuffer + timer 60 Hz + sweep render. Showcase del zero-alloc en hot path."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
llimphi-widget-menubar = { workspace = true }
llimphi-widget-context-menu = { workspace = true }
app-bus = { workspace = true }
pineal-core = { path = "../../pineal-core" }
pineal-render = { path = "../../pineal-render" }
pineal-stream = { path = "../../pineal-stream" }
@@ -0,0 +1,15 @@
# pineal-stream-demo
> Demo del backend streaming de [pineal](../README.md).
Genera muestras a 60 Hz (varias series simultáneas) y las pushea a [`pineal-stream`](../pineal-stream/README.md). Demuestra el scroll suave y la composición de múltiples series sobre el mismo eje de tiempo.
## Uso
```sh
cargo run --release -p pineal-stream-demo
```
## Deps
- [`pineal-stream`](../pineal-stream/README.md), [`pineal-render`](../pineal-render/README.md)
@@ -0,0 +1,15 @@
# pineal-stream-demo
> Streaming backend demo for [pineal](../README.md).
Generates samples at 60 Hz (several simultaneous series) and pushes them to [`pineal-stream`](../pineal-stream/README.md). Demonstrates smooth scrolling and composition of multiple series on the same time axis.
## Usage
```sh
cargo run --release -p pineal-stream-demo
```
## Deps
- [`pineal-stream`](../pineal-stream/README.md), [`pineal-render`](../pineal-render/README.md)
@@ -0,0 +1,290 @@
//! `pineal-stream-demo` — osciloscopio sintético sobre Llimphi.
//!
//! Ventana con un `StreamView` montado sobre un `RingBuffer` de 512
//! slots. Un thread periódico empuja un sample cada **16 ms** (≈ 60 Hz)
//! vía `Handle::spawn_periodic` y dispatcha `Msg::Tick` al update.
//!
//! El efecto visual: la traza barre la ventana como en un osciloscopio
//! CRT — split-at-head deja un "cursor" donde arranca la traza fresca,
//! la traza vieja se mantiene a la derecha hasta que el cursor la
//! sobrescriba.
//!
//! Showcase del **P2 zero-alloc en hot path**: el `push(v)` del
//! RingBuffer son 2 escrituras + 2 increments. Cero allocations por
//! frame, ningún `Vec` se reasigna en el sampler.
//!
//! Lleva barra de menú principal (Archivo/Ver/Ayuda) + un menú
//! contextual sobre el plot. Sin edición ni clipboard — el contextual
//! ofrece "Reiniciar vista" (vacía el buffer) y cambiar tema.
use std::sync::Arc;
use std::time::Duration;
use llimphi_theme::Theme;
use llimphi_ui::llimphi_layout::taffy::prelude::{length, percent, FlexDirection, Size, Style};
use llimphi_ui::llimphi_layout::taffy::Rect;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::{App, Handle, View};
use app_bus::{AppMenu, Menu, MenuItem};
use llimphi_widget_context_menu::{
context_menu_view, ContextMenuItem, ContextMenuPalette, ContextMenuSpec,
};
use llimphi_widget_menubar::{menubar_overlay, menubar_view, MenuBarSpec, DEFAULT_HEIGHT as MENU_H};
use pineal_core::ring::RingBuffer;
use pineal_render::{Color, StrokeStyle};
use pineal_stream::pineal_stream_view;
const RING_CAPACITY: usize = 512;
const SAMPLE_PERIOD: Duration = Duration::from_millis(16);
#[derive(Clone)]
enum Msg {
Tick,
/// Reinicia la vista: vacía el RingBuffer y resetea la fase.
Reset,
/// Barra de menú principal: abrir/cerrar un menú raíz (`None` cierra).
MenuOpen(Option<usize>),
/// Comando elegido en la barra o en el contextual.
MenuCommand(String),
/// Cierra cualquier menú abierto (click-fuera / Esc).
CloseMenus,
/// Right-click sobre el plot → abre el contextual en `(x, y)` de ventana.
ContextMenuOpen(f32, f32),
/// Cicla el preset de tema.
CycleTheme,
}
struct Model {
buffer: RingBuffer,
/// Tick count global. Sirve de fase para la señal sintética y se
/// muestra en el header para verificar que el timer corre.
t: u64,
theme: Theme,
menu_open: Option<usize>,
context_menu: Option<(f32, f32)>,
}
struct StreamDemo;
impl App for StreamDemo {
type Model = Model;
type Msg = Msg;
fn title() -> &'static str {
"Lapaloma — stream (osciloscopio sintético 60 Hz)"
}
fn initial_size() -> (u32, u32) {
(900, 480)
}
fn init(handle: &Handle<Msg>) -> Model {
handle.spawn_periodic(SAMPLE_PERIOD, || Msg::Tick);
Model {
buffer: RingBuffer::new(RING_CAPACITY),
t: 0,
theme: Theme::dark(),
menu_open: None,
context_menu: None,
}
}
fn update(mut model: Model, msg: Msg, handle: &Handle<Msg>) -> Model {
match msg {
Msg::Tick => {
let v = synthesize(model.t);
model.buffer.push(v);
model.t = model.t.wrapping_add(1);
}
Msg::Reset => {
model.buffer = RingBuffer::new(RING_CAPACITY);
model.t = 0;
}
Msg::MenuOpen(which) => {
model.menu_open = which;
model.context_menu = None;
}
Msg::CloseMenus => {
model.menu_open = None;
model.context_menu = None;
}
Msg::ContextMenuOpen(x, y) => {
model.menu_open = None;
model.context_menu = Some((x, y));
}
Msg::CycleTheme => {
model.theme = Theme::next_after(model.theme.name);
}
Msg::MenuCommand(cmd) => {
model.menu_open = None;
model.context_menu = None;
handle_menu_command(&cmd, handle);
}
}
model
}
fn view(model: &Model) -> View<Msg> {
let theme = &model.theme;
let plot_bg = Color::rgba(0.08, 0.10, 0.13, 1.0);
let stroke = StrokeStyle::new(1.8, Color::rgb(0.639, 0.745, 0.549));
let menu = app_menu();
let menubar = menubar_view(&menubar_spec(&menu, model));
let header = View::new(Style {
size: Size { width: percent(1.0_f32), height: length(28.0_f32) },
padding: Rect {
left: length(2.0_f32),
right: length(2.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
..Default::default()
})
.text_aligned("Lapaloma — stream".to_string(), 18.0, theme.fg_text, Alignment::Start);
let fill_pct = (model.buffer.filled_len() * 100) / RING_CAPACITY;
let stats = format!(
"cap = {} head = {} filled = {}% t = {} rev = {}",
RING_CAPACITY,
model.buffer.head(),
fill_pct,
model.t,
model.buffer.revision(),
);
let stats_row = View::new(Style {
size: Size { width: percent(1.0_f32), height: length(20.0_f32) },
..Default::default()
})
.text_aligned(stats, 11.0, theme.fg_muted, Alignment::Start);
let stream = pineal_stream_view(model.buffer.clone(), stroke)
.background(plot_bg)
.y_range(-1.2, 1.2)
.view::<Msg>();
let plot_panel = View::new(Style {
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
flex_grow: 1.0,
..Default::default()
})
.clip(true)
.children(vec![stream]);
let body = View::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
flex_grow: 1.0,
padding: Rect {
left: length(16.0_f32),
right: length(16.0_f32),
top: length(16.0_f32),
bottom: length(16.0_f32),
},
gap: Size { width: length(0.0_f32), height: length(10.0_f32) },
..Default::default()
})
.children(vec![header, stats_row, plot_panel]);
// Right-click en cualquier punto de la raíz abre el contextual;
// origen (0,0) ⇒ coords locales == coords de ventana.
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
..Default::default()
})
.fill(theme.bg_app)
.on_right_click_at(|x, y, _, _| Some(Msg::ContextMenuOpen(x, y)))
.children(vec![menubar, body])
}
fn view_overlay(model: &Model) -> Option<View<Msg>> {
if let Some((x, y)) = model.context_menu {
return Some(context_menu_for_plot(model, x, y));
}
let menu = app_menu();
menubar_overlay(&menubar_spec(&menu, model))
}
}
// =====================================================================
// Menú principal + contextual del plot
// =====================================================================
fn viewport_of(_model: &Model) -> (f32, f32) {
let (w, h) = StreamDemo::initial_size();
(w as f32, h as f32)
}
fn menubar_spec<'a>(menu: &'a AppMenu, model: &'a Model) -> MenuBarSpec<'a, Msg> {
MenuBarSpec {
menu,
open: model.menu_open,
theme: &model.theme,
viewport: viewport_of(model),
height: MENU_H,
on_open: Arc::new(Msg::MenuOpen),
on_command: Arc::new(|c: &str| Msg::MenuCommand(c.to_string())),
}
}
fn app_menu() -> AppMenu {
AppMenu::new()
.menu(Menu::new("Archivo").item(MenuItem::new("Salir", "file.quit").shortcut("Esc")))
.menu(
Menu::new("Ver")
.item(MenuItem::new("Reiniciar vista", "view.reset"))
.item(MenuItem::new("Cambiar tema", "view.theme")),
)
.menu(Menu::new("Ayuda").item(MenuItem::new("Acerca de", "help.about")))
}
fn handle_menu_command(cmd: &str, handle: &Handle<Msg>) {
match cmd {
"file.quit" => std::process::exit(0),
"view.reset" => handle.dispatch(Msg::Reset),
"view.theme" => handle.dispatch(Msg::CycleTheme),
_ => {}
}
}
/// Menú contextual del plot. El osciloscopio no tiene objetos
/// seleccionables ni edición — ofrece reiniciar la vista y cambiar tema.
fn context_menu_for_plot(model: &Model, x: f32, y: f32) -> View<Msg> {
let items = vec![
ContextMenuItem::action("Reiniciar vista"),
ContextMenuItem::separator(),
ContextMenuItem::action("Cambiar tema"),
];
let cmds: Vec<&'static str> = vec!["view.reset", "", "view.theme"];
let on_pick: Arc<dyn Fn(usize) -> Msg + Send + Sync> = Arc::new(move |i: usize| {
Msg::MenuCommand(cmds.get(i).copied().unwrap_or("").to_string())
});
context_menu_view(ContextMenuSpec {
anchor: (x, y),
viewport: viewport_of(model),
header: Some(format!("stream · t = {}", model.t)),
items,
active: usize::MAX,
on_pick,
on_dismiss: Msg::CloseMenus,
palette: ContextMenuPalette::from_theme(&model.theme),
})
}
/// Señal sintética: suma de dos sinusoides + jitter determinístico. El
/// rango efectivo queda en `[-1, 1]` aproximadamente.
fn synthesize(t: u64) -> f32 {
let phase = t as f32;
let signal = (phase * 0.07).sin() * 0.75 + (phase * 0.19).sin() * 0.22;
let jitter = ((phase * 37.0).sin() * 1000.0).fract() * 0.04;
signal + jitter
}
fn main() {
llimphi_ui::run::<StreamDemo>();
}
@@ -0,0 +1,17 @@
[package]
name = "pineal-treemap-demo"
version = { workspace = true }
edition = { workspace = true }
license = { workspace = true }
authors = { workspace = true }
publish = { workspace = true }
description = "Lapaloma — demo treemap squarified: 12 tiles con pesos heterogéneos."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
llimphi-widget-menubar = { workspace = true }
llimphi-widget-context-menu = { workspace = true }
app-bus = { workspace = true }
pineal-render = { path = "../../pineal-render" }
pineal-treemap = { path = "../../pineal-treemap" }
@@ -0,0 +1,244 @@
//! `pineal-treemap-demo` — treemap squarified con 12 tiles.
//!
//! Pesos escogidos a mano para mostrar el algoritmo cuando hay
//! mezcla de tiles grandes y chicas. El squarified minimiza el
//! peor aspect ratio en cada fila/columna; las tiles chicas
//! quedan amontonadas en una banda angosta.
//!
//! Lleva barra de menú principal (Archivo/Ver/Ayuda) + un menú
//! contextual sobre el plot. No hay edición ni clipboard — el treemap
//! es un canvas estático, así que el contextual sólo ofrece cambiar
//! tema.
use std::sync::Arc;
use llimphi_theme::Theme;
use llimphi_ui::llimphi_layout::taffy::prelude::{length, percent, FlexDirection, Size, Style};
use llimphi_ui::llimphi_layout::taffy::Rect as TaffyRect;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::{App, Handle, View};
use app_bus::{AppMenu, Menu, MenuItem};
use llimphi_widget_context_menu::{
context_menu_view, ContextMenuItem, ContextMenuPalette, ContextMenuSpec,
};
use llimphi_widget_menubar::{menubar_overlay, menubar_view, MenuBarSpec, DEFAULT_HEIGHT as MENU_H};
use pineal_render::{Canvas as _, Color, Rect, SceneCanvas};
use pineal_treemap::{paint_treemap, Tile};
#[derive(Clone)]
enum Msg {
/// Barra de menú principal: abrir/cerrar un menú raíz (`None` cierra).
MenuOpen(Option<usize>),
/// Comando elegido en la barra o en el contextual — se traduce al
/// `Msg` real existente.
MenuCommand(String),
/// Cierra cualquier menú abierto (click-fuera / Esc).
CloseMenus,
/// Right-click sobre el plot → abre el menú contextual anclado en
/// `(x, y)` de ventana.
ContextMenuOpen(f32, f32),
/// Cicla el preset de tema.
CycleTheme,
}
struct Model {
theme: Theme,
menu_open: Option<usize>,
context_menu: Option<(f32, f32)>,
}
struct TreemapDemo;
impl App for TreemapDemo {
type Model = Model;
type Msg = Msg;
fn title() -> &'static str {
"Lapaloma — treemap (squarified)"
}
fn initial_size() -> (u32, u32) {
(1000, 620)
}
fn init(_: &Handle<Msg>) -> Model {
Model { theme: Theme::dark(), menu_open: None, context_menu: None }
}
fn update(mut model: Model, msg: Msg, handle: &Handle<Msg>) -> Model {
match msg {
Msg::MenuOpen(which) => {
model.menu_open = which;
model.context_menu = None;
}
Msg::CloseMenus => {
model.menu_open = None;
model.context_menu = None;
}
Msg::ContextMenuOpen(x, y) => {
model.menu_open = None;
model.context_menu = Some((x, y));
}
Msg::CycleTheme => {
model.theme = Theme::next_after(model.theme.name);
}
Msg::MenuCommand(cmd) => {
model.menu_open = None;
model.context_menu = None;
handle_menu_command(&cmd, handle);
}
}
model
}
fn view(model: &Model) -> View<Msg> {
let theme = &model.theme;
let plot_bg = Color::rgba(0.08, 0.10, 0.13, 1.0);
let palette = [
0x88c0d0, 0xd08770, 0xa3be8c, 0xebcb8b, 0xb48ead, 0x5e81ac,
0x81a1c1, 0xbf616a, 0x8fbcbb, 0xd8dee9, 0xa3be8c, 0xebcb8b,
];
let weights = [40.0, 28.0, 22.0, 18.0, 14.0, 10.0, 8.0, 6.0, 5.0, 4.0, 3.0, 2.0];
let tiles: Vec<Tile> = weights
.iter()
.zip(palette.iter())
.map(|(&w, &c)| Tile::new(w, Color::from_hex(c)))
.collect();
let menu = app_menu();
let menubar = menubar_view(&menubar_spec(&menu, model));
let header = View::new(Style {
size: Size { width: percent(1.0_f32), height: length(28.0_f32) },
..Default::default()
})
.text_aligned(
"Lapaloma — treemap".to_string(),
18.0,
theme.fg_text,
Alignment::Start,
);
let legend = View::new(Style {
size: Size { width: percent(1.0_f32), height: length(20.0_f32) },
..Default::default()
})
.text_aligned(
"12 tiles · pesos 40, 28, 22, 18, 14, 10, 8, 6, 5, 4, 3, 2 · gap 2 px".to_string(),
11.0,
theme.fg_muted,
Alignment::Start,
);
let panel = View::new(Style {
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
flex_grow: 1.0,
..Default::default()
})
.clip(true)
.paint_with(move |scene, ts, rect| {
let outer = Rect::new(rect.x, rect.y, rect.w, rect.h);
let mut canvas = SceneCanvas::new(scene, ts);
canvas.fill_rect(outer, plot_bg);
paint_treemap(&tiles, outer, 2.0, &mut canvas);
});
let body = View::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
flex_grow: 1.0,
padding: TaffyRect {
left: length(16.0_f32),
right: length(16.0_f32),
top: length(16.0_f32),
bottom: length(16.0_f32),
},
gap: Size { width: length(0.0_f32), height: length(10.0_f32) },
..Default::default()
})
.children(vec![header, legend, panel]);
// Right-click en cualquier punto de la raíz abre el contextual;
// origen (0,0) ⇒ coords locales == coords de ventana.
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
..Default::default()
})
.fill(theme.bg_app)
.on_right_click_at(|x, y, _, _| Some(Msg::ContextMenuOpen(x, y)))
.children(vec![menubar, body])
}
fn view_overlay(model: &Model) -> Option<View<Msg>> {
if let Some((x, y)) = model.context_menu {
return Some(context_menu_for_plot(model, x, y));
}
let menu = app_menu();
menubar_overlay(&menubar_spec(&menu, model))
}
}
// =====================================================================
// Menú principal + contextual del plot
// =====================================================================
fn viewport_of(_model: &Model) -> (f32, f32) {
let (w, h) = TreemapDemo::initial_size();
(w as f32, h as f32)
}
fn menubar_spec<'a>(menu: &'a AppMenu, model: &'a Model) -> MenuBarSpec<'a, Msg> {
MenuBarSpec {
menu,
open: model.menu_open,
theme: &model.theme,
viewport: viewport_of(model),
height: MENU_H,
on_open: Arc::new(Msg::MenuOpen),
on_command: Arc::new(|c: &str| Msg::MenuCommand(c.to_string())),
}
}
fn app_menu() -> AppMenu {
AppMenu::new()
.menu(Menu::new("Archivo").item(MenuItem::new("Salir", "file.quit").shortcut("Esc")))
.menu(Menu::new("Ver").item(MenuItem::new("Cambiar tema", "view.theme")))
.menu(Menu::new("Ayuda").item(MenuItem::new("Acerca de", "help.about")))
}
fn handle_menu_command(cmd: &str, handle: &Handle<Msg>) {
match cmd {
"file.quit" => std::process::exit(0),
"view.theme" => handle.dispatch(Msg::CycleTheme),
// "help.about" y desconocidos: no-op (sin diálogo todavía).
_ => {}
}
}
/// Menú contextual del plot. El treemap es un canvas estático sin
/// objetos seleccionables ni edición — sólo ofrece cambiar tema.
fn context_menu_for_plot(model: &Model, x: f32, y: f32) -> View<Msg> {
let items = vec![ContextMenuItem::action("Cambiar tema")];
let cmds: Vec<&'static str> = vec!["view.theme"];
let on_pick: Arc<dyn Fn(usize) -> Msg + Send + Sync> = Arc::new(move |i: usize| {
Msg::MenuCommand(cmds.get(i).copied().unwrap_or("").to_string())
});
context_menu_view(ContextMenuSpec {
anchor: (x, y),
viewport: viewport_of(model),
header: Some("treemap".to_string()),
items,
active: usize::MAX,
on_pick,
on_dismiss: Msg::CloseMenus,
palette: ContextMenuPalette::from_theme(&model.theme),
})
}
fn main() {
llimphi_ui::run::<TreemapDemo>();
}
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "pineal-bars"
version = { workspace = true }
edition = { workspace = true }
license = { workspace = true }
authors = { workspace = true }
publish = { workspace = true }
description = "Lapaloma — barras: columnas/barras simples, agrupadas y apiladas (vertical u horizontal) + histograma. Painter agnóstico sobre el trait Canvas."
[dependencies]
pineal-core = { path = "../pineal-core" }
pineal-render = { path = "../pineal-render" }
@@ -0,0 +1,102 @@
//! Bineado de una muestra en conteos, para alimentar [`paint_bars`] como
//! histograma. Zero-boxing: la entrada es un `&[f32]` plano (P1 del SDD).
use crate::paint::Bar;
use pineal_render::Color;
/// Conteos por bin de una muestra sobre `[lo, hi]`.
#[derive(Debug, Clone)]
pub struct Histogram {
/// Conteo por bin (longitud = nº de bins).
pub counts: Vec<f64>,
pub lo: f32,
pub hi: f32,
/// Ancho de cada bin en unidades de dato.
pub bin_width: f32,
}
impl Histogram {
/// Binea `values` en `n_bins` sobre su propio min..max. Valores
/// fuera de rango (NaN) se ignoran. Con `n_bins == 0` o muestra
/// vacía devuelve un histograma vacío.
pub fn new(values: &[f32], n_bins: usize) -> Self {
if n_bins == 0 || values.is_empty() {
return Self { counts: Vec::new(), lo: 0.0, hi: 0.0, bin_width: 0.0 };
}
let mut lo = f32::INFINITY;
let mut hi = f32::NEG_INFINITY;
for &v in values {
if v.is_nan() {
continue;
}
lo = lo.min(v);
hi = hi.max(v);
}
if !lo.is_finite() || !hi.is_finite() {
return Self { counts: vec![0.0; n_bins], lo: 0.0, hi: 0.0, bin_width: 0.0 };
}
// Rango degenerado (todos iguales): ensancha para no dividir por 0.
if (hi - lo).abs() < f32::EPSILON {
hi = lo + 1.0;
}
let bin_width = (hi - lo) / n_bins as f32;
let mut counts = vec![0.0_f64; n_bins];
for &v in values {
if v.is_nan() {
continue;
}
let mut idx = ((v - lo) / bin_width) as usize;
if idx >= n_bins {
idx = n_bins - 1; // el máximo cae en el último bin
}
counts[idx] += 1.0;
}
Self { counts, lo, hi, bin_width }
}
/// Convierte los conteos en barras de un mismo color, listas para
/// [`crate::paint_bars`].
pub fn to_bars(&self, color: Color) -> Vec<Bar> {
self.counts.iter().map(|&c| Bar::new(c, color)).collect()
}
/// Conteo del bin más poblado (útil para fijar el rango del eje).
pub fn max_count(&self) -> f64 {
self.counts.iter().copied().fold(0.0, f64::max)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn counts_sum_to_sample_size() {
let v = [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0];
let h = Histogram::new(&v, 5);
let total: f64 = h.counts.iter().sum();
assert_eq!(total, 10.0);
assert_eq!(h.counts.len(), 5);
}
#[test]
fn max_value_lands_in_last_bin() {
let v = [0.0, 10.0];
let h = Histogram::new(&v, 4);
assert_eq!(h.counts[3], 1.0, "el máximo cae en el último bin");
assert_eq!(h.counts[0], 1.0);
}
#[test]
fn empty_and_zero_bins_are_safe() {
assert!(Histogram::new(&[], 5).counts.iter().all(|&c| c == 0.0) || Histogram::new(&[], 5).counts.is_empty());
assert!(Histogram::new(&[1.0, 2.0], 0).counts.is_empty());
}
#[test]
fn degenerate_range_does_not_panic() {
let v = [3.0, 3.0, 3.0];
let h = Histogram::new(&v, 4);
assert_eq!(h.counts.iter().sum::<f64>(), 3.0);
}
}
+26
View File
@@ -0,0 +1,26 @@
//! `pineal-bars` — el painter de barras del catálogo.
//!
//! El gráfico más común que faltaba: columnas/barras. Como todo en
//! pineal, es agnóstico del backend — sólo emite `fill_rect` contra el
//! trait [`pineal_render::Canvas`], así que sirve igual sobre
//! vello/llimphi, PNG, SVG, PDF o el camino GPU directo.
//!
//! - [`paint_bars`] — una serie: barras desde un baseline, vertical u
//! horizontal, con soporte para valores negativos.
//! - [`paint_grouped`] — varias series por categoría, agrupadas
//! (clustered) lado a lado.
//! - [`paint_stacked`] — segmentos apilados sobre un baseline común.
//! - [`Histogram`] — bineado de una muestra `&[f32]` en conteos, listo
//! para pasar a `paint_bars` como histograma.
//!
//! El eje, los ticks y las etiquetas no son responsabilidad del painter
//! (regla del SDD: el texto va por una pasada vello hermana). Acá sólo
//! viven los rectángulos.
#![forbid(unsafe_code)]
pub mod histogram;
pub mod paint;
pub use histogram::Histogram;
pub use paint::{paint_bars, paint_grouped, paint_stacked, Bar, BarStyle, Orientation};
+330
View File
@@ -0,0 +1,330 @@
//! Painters de barras agnósticos: categorías → `fill_rect` contra un
//! `Canvas`. Una unidad geométrica común ([`Axis`]) unifica orientación
//! vertical/horizontal y los tres modos (simple, agrupado, apilado).
use pineal_render::{Canvas, Color, Rect};
/// Una barra: su valor (puede ser negativo) y su color.
#[derive(Debug, Clone, Copy)]
pub struct Bar {
pub value: f64,
pub color: Color,
}
impl Bar {
pub fn new(value: f64, color: Color) -> Self {
Self { value, color }
}
}
/// Hacia dónde crecen las barras.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Orientation {
/// Columnas: el eje de valor es vertical, crecen hacia arriba.
Vertical,
/// Barras: el eje de valor es horizontal, crecen hacia la derecha.
Horizontal,
}
/// Estilo del gráfico de barras.
#[derive(Debug, Clone, Copy)]
pub struct BarStyle {
pub orientation: Orientation,
/// Fracción del slot de cada categoría que va a separación, en
/// `[0, 1)`. `0.0` = barras pegadas; `0.2` = 20 % de aire.
pub gap_ratio: f32,
/// Valor del cero. Las barras nacen acá; valores por debajo crecen
/// en sentido contrario. Normalmente `0.0`.
pub baseline: f64,
/// Override del rango de valor. `None` = automático a partir de los
/// datos (incluyendo siempre el baseline).
pub range: Option<(f64, f64)>,
}
impl Default for BarStyle {
fn default() -> Self {
Self {
orientation: Orientation::Vertical,
gap_ratio: 0.18,
baseline: 0.0,
range: None,
}
}
}
impl BarStyle {
pub fn vertical() -> Self {
Self::default()
}
pub fn horizontal() -> Self {
Self {
orientation: Orientation::Horizontal,
..Self::default()
}
}
pub fn with_gap(mut self, gap_ratio: f32) -> Self {
self.gap_ratio = gap_ratio.clamp(0.0, 0.95);
self
}
pub fn with_baseline(mut self, baseline: f64) -> Self {
self.baseline = baseline;
self
}
pub fn with_range(mut self, lo: f64, hi: f64) -> Self {
self.range = Some((lo, hi));
self
}
}
/// Geometría compartida: mapea valor → pixel sobre el eje de valor y
/// arma el rect de una barra que ocupa el segmento `[cat_lo, cat_hi]`
/// del eje de categoría y va de `v_from` a `v_to` en el eje de valor.
struct Axis {
area: Rect,
vmin: f64,
vmax: f64,
orientation: Orientation,
}
impl Axis {
fn new(area: Rect, vmin: f64, vmax: f64, orientation: Orientation) -> Self {
// Evita división por cero cuando todos los valores coinciden.
let (vmin, vmax) = if (vmax - vmin).abs() < f64::EPSILON {
(vmin - 0.5, vmax + 0.5)
} else {
(vmin, vmax)
};
Self { area, vmin, vmax, orientation }
}
/// Extensión del eje de categoría (donde se reparten los slots).
fn cat_span(&self) -> (f32, f32) {
match self.orientation {
Orientation::Vertical => (self.area.x, self.area.x + self.area.w),
Orientation::Horizontal => (self.area.y, self.area.y + self.area.h),
}
}
/// Pixel del eje de valor para `v`. En vertical, +valor = arriba
/// (y menor); en horizontal, +valor = derecha (x mayor).
fn value_px(&self, v: f64) -> f32 {
let t = ((v - self.vmin) / (self.vmax - self.vmin)) as f32;
match self.orientation {
Orientation::Vertical => self.area.y + self.area.h * (1.0 - t),
Orientation::Horizontal => self.area.x + self.area.w * t,
}
}
/// Rect de una barra: ocupa `[cat_lo, cat_hi]` en categoría y el
/// tramo de valor `[v_from, v_to]` (orden indistinto).
fn bar_rect(&self, cat_lo: f32, cat_hi: f32, v_from: f64, v_to: f64) -> Rect {
let p0 = self.value_px(v_from);
let p1 = self.value_px(v_to);
let (lo, hi) = (p0.min(p1), p0.max(p1));
match self.orientation {
Orientation::Vertical => Rect::new(cat_lo, lo, cat_hi - cat_lo, hi - lo),
Orientation::Horizontal => Rect::new(lo, cat_lo, hi - lo, cat_hi - cat_lo),
}
}
}
/// Reparte `n` slots iguales sobre `[span0, span1]` y devuelve el
/// sub-rango `[lo, hi]` del slot `i` ya descontado el gap.
fn slot(span0: f32, span1: f32, n: usize, i: usize, gap_ratio: f32) -> (f32, f32) {
let total = span1 - span0;
let w = total / n as f32;
let pad = w * gap_ratio * 0.5;
let lo = span0 + w * i as f32 + pad;
let hi = span0 + w * (i + 1) as f32 - pad;
(lo, hi)
}
fn auto_range(values: impl Iterator<Item = f64>, baseline: f64) -> (f64, f64) {
let mut lo = baseline;
let mut hi = baseline;
for v in values {
if v < lo {
lo = v;
}
if v > hi {
hi = v;
}
}
(lo, hi)
}
/// Dibuja una serie de barras dentro de `area`. Una `fill_rect` por
/// barra; valores negativos crecen al otro lado del baseline.
pub fn paint_bars(bars: &[Bar], area: Rect, style: &BarStyle, canvas: &mut dyn Canvas) {
if bars.is_empty() {
return;
}
let (vmin, vmax) = style
.range
.unwrap_or_else(|| auto_range(bars.iter().map(|b| b.value), style.baseline));
let axis = Axis::new(area, vmin, vmax, style.orientation);
let (s0, s1) = axis.cat_span();
for (i, bar) in bars.iter().enumerate() {
let (lo, hi) = slot(s0, s1, bars.len(), i, style.gap_ratio);
let r = axis.bar_rect(lo, hi, style.baseline, bar.value);
if r.w > 0.0 && r.h > 0.0 {
canvas.fill_rect(r, bar.color);
}
}
}
/// Dibuja varias series agrupadas (clustered): cada categoría es un
/// slot que se subdivide entre las `series.len()` series. `series[k]`
/// debe tener un valor por categoría; series más cortas se rellenan
/// hasta la categoría que tengan.
pub fn paint_grouped(series: &[&[Bar]], area: Rect, style: &BarStyle, canvas: &mut dyn Canvas) {
let n_series = series.len();
if n_series == 0 {
return;
}
let n_cats = series.iter().map(|s| s.len()).max().unwrap_or(0);
if n_cats == 0 {
return;
}
let all = series.iter().flat_map(|s| s.iter().map(|b| b.value));
let (vmin, vmax) = style.range.unwrap_or_else(|| auto_range(all, style.baseline));
let axis = Axis::new(area, vmin, vmax, style.orientation);
let (s0, s1) = axis.cat_span();
for cat in 0..n_cats {
// Slot de la categoría (sin gap: el gap se aplica adentro, entre
// las barras del cluster).
let (clo, chi) = slot(s0, s1, n_cats, cat, 0.0);
for (k, serie) in series.iter().enumerate() {
let Some(bar) = serie.get(cat) else { continue };
let (lo, hi) = slot(clo, chi, n_series, k, style.gap_ratio);
let r = axis.bar_rect(lo, hi, style.baseline, bar.value);
if r.w > 0.0 && r.h > 0.0 {
canvas.fill_rect(r, bar.color);
}
}
}
}
/// Dibuja barras apiladas: `stacks[c]` son los segmentos de la
/// categoría `c`, acumulados desde el baseline. Pensado para segmentos
/// del mismo signo (lo habitual en stacked bars).
pub fn paint_stacked(stacks: &[&[Bar]], area: Rect, style: &BarStyle, canvas: &mut dyn Canvas) {
if stacks.is_empty() {
return;
}
// Rango: del baseline al mínimo/máximo acumulado de cada pila.
let mut lo = style.baseline;
let mut hi = style.baseline;
for stack in stacks {
let mut acc = style.baseline;
for seg in stack.iter() {
acc += seg.value;
lo = lo.min(acc);
hi = hi.max(acc);
}
}
let (vmin, vmax) = style.range.unwrap_or((lo, hi));
let axis = Axis::new(area, vmin, vmax, style.orientation);
let (s0, s1) = axis.cat_span();
for (c, stack) in stacks.iter().enumerate() {
let (clo, chi) = slot(s0, s1, stacks.len(), c, style.gap_ratio);
let mut acc = style.baseline;
for seg in stack.iter() {
let from = acc;
acc += seg.value;
let r = axis.bar_rect(clo, chi, from, acc);
if r.w > 0.0 && r.h > 0.0 {
canvas.fill_rect(r, seg.color);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use pineal_render::{PlanRecorder, RenderCmd};
fn fill_rects(rec: PlanRecorder) -> Vec<Rect> {
rec.into_plan()
.cmds
.into_iter()
.filter_map(|c| match c {
RenderCmd::FillRect { rect, .. } => Some(rect),
_ => None,
})
.collect()
}
#[test]
fn one_rect_per_bar() {
let bars = [
Bar::new(3.0, Color::WHITE),
Bar::new(5.0, Color::BLACK),
Bar::new(1.0, Color::from_hex(0x00ff00)),
];
let mut rec = PlanRecorder::new();
paint_bars(&bars, Rect::new(0.0, 0.0, 300.0, 200.0), &BarStyle::vertical(), &mut rec);
assert_eq!(fill_rects(rec).len(), 3);
}
#[test]
fn taller_value_taller_bar() {
let bars = [Bar::new(1.0, Color::WHITE), Bar::new(4.0, Color::WHITE)];
let mut rec = PlanRecorder::new();
paint_bars(&bars, Rect::new(0.0, 0.0, 200.0, 100.0), &BarStyle::vertical(), &mut rec);
let rects = fill_rects(rec);
assert!(rects[1].h > rects[0].h, "la barra de mayor valor debe ser más alta");
}
#[test]
fn negative_grows_below_baseline() {
// Con baseline 0 y rango simétrico, un valor negativo debe quedar
// por debajo (y mayor) que uno positivo.
let bars = [Bar::new(2.0, Color::WHITE), Bar::new(-2.0, Color::WHITE)];
let style = BarStyle::vertical().with_range(-3.0, 3.0);
let mut rec = PlanRecorder::new();
paint_bars(&bars, Rect::new(0.0, 0.0, 200.0, 100.0), &style, &mut rec);
let rects = fill_rects(rec);
// baseline (v=0) está en el medio (y=50). El positivo arranca
// arriba del baseline; el negativo abajo.
assert!(rects[0].y < 50.0, "positivo arriba del baseline");
assert!(rects[1].y >= 50.0 - f32::EPSILON, "negativo en/abajo del baseline");
}
#[test]
fn horizontal_swaps_axes() {
let bars = [Bar::new(1.0, Color::WHITE), Bar::new(4.0, Color::WHITE)];
let mut rec = PlanRecorder::new();
paint_bars(&bars, Rect::new(0.0, 0.0, 200.0, 100.0), &BarStyle::horizontal(), &mut rec);
let rects = fill_rects(rec);
// En horizontal el largo es el ancho (w), no la altura.
assert!(rects[1].w > rects[0].w, "mayor valor = barra más larga (w)");
}
#[test]
fn grouped_emits_all_bars() {
let a = [Bar::new(1.0, Color::WHITE), Bar::new(2.0, Color::WHITE)];
let b = [Bar::new(3.0, Color::BLACK), Bar::new(4.0, Color::BLACK)];
let series: [&[Bar]; 2] = [&a, &b];
let mut rec = PlanRecorder::new();
paint_grouped(&series, Rect::new(0.0, 0.0, 400.0, 200.0), &BarStyle::vertical(), &mut rec);
assert_eq!(fill_rects(rec).len(), 4);
}
#[test]
fn stacked_segments_dont_overlap() {
let s0 = [Bar::new(2.0, Color::WHITE), Bar::new(3.0, Color::BLACK)];
let stacks: [&[Bar]; 1] = [&s0];
let mut rec = PlanRecorder::new();
paint_stacked(&stacks, Rect::new(0.0, 0.0, 100.0, 100.0), &BarStyle::vertical(), &mut rec);
let rects = fill_rects(rec);
assert_eq!(rects.len(), 2);
// Apilados verticalmente: el primer segmento (baseline→2) queda
// debajo del segundo (2→5). Sin solape ⇒ el de abajo empieza
// donde termina el de arriba (con tolerancia de borde).
let bottom0 = rects[0].y + rects[0].h;
let bottom1 = rects[1].y + rects[1].h;
assert!(bottom1 <= bottom0 + 0.01 && rects[1].y < rects[0].y);
}
}
@@ -0,0 +1,13 @@
[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 = "../pineal-core" }
pineal-render = { path = "../pineal-render" }
llimphi-ui = { workspace = true }
@@ -0,0 +1,20 @@
# pineal-cartesian
> Canvas cartesiano para [pineal](../README.md): ejes, grid, ticks, labels.
Backend para gráficos x/y clásicos. Auto-scaling de ejes, ticks con espaciado smart (1/2/5 × 10^n), labels formateables, grid opcional. Soporta múltiples series sobre el mismo viewport. Datos en cualquier escala (lineal, log, time).
## API
```rust
use pineal_cartesian::{Cartesian, Series};
let plot = Cartesian::new()
.x_range(0.0..100.0)
.y_auto()
.series(Series::line(&xs, &ys).color(theme.accent));
```
## Deps
- [`pineal-core`](../pineal-core/README.md)
@@ -0,0 +1,20 @@
# pineal-cartesian
> Cartesian canvas for [pineal](../README.md): axes, grid, ticks, labels.
Backend for classic x/y graphics. Axis auto-scaling, smart tick spacing (1/2/5 × 10^n), formattable labels, optional grid. Supports multiple series over the same viewport. Data on any scale (linear, log, time).
## API
```rust
use pineal_cartesian::{Cartesian, Series};
let plot = Cartesian::new()
.x_range(0.0..100.0)
.y_auto()
.series(Series::line(&xs, &ys).color(theme.accent));
```
## Deps
- [`pineal-core`](../pineal-core/README.md)
@@ -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
//! format (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,32 @@
//! `pineal-cartesian` — gráficos cartesianos.
//!
//! Componentes:
//!
//! - [`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` + `LineSeries`. Cada serie decide
//! LTTB vs raw según densidad.
//! - [`axis`] — ejes con nice-ticks (Wilkinson) y decimación de etiquetas
//! que no overlappean.
//! - [`view`] — `ChartView` que se inserta como `View<Msg>` declarativo en
//! un árbol llimphi-ui. Incluye `ChartCache` para translate-only pan-blit
//! con hash de invalidación.
#![forbid(unsafe_code)]
#![allow(dead_code)]
pub mod viewport;
pub mod coord_system;
pub mod series;
pub mod axis;
pub mod view;
pub use viewport::ChartViewport;
pub use coord_system::CoordinateSystem;
pub use series::{LineSeries, PaintCtx, RenderMode, Series};
pub use view::{
chart_cache, lapaloma_chart_view, ChartCache, ChartCacheHandle, ChartSeriesItem, ChartView,
};
@@ -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,340 @@
//! Vista Llimphi del gráfico cartesiano.
//!
//! Paralelo del Element GPUI (`element.rs`). Misma lógica conceptual
//! — viewport, axes, series con LTTB, picture-cache pan-blit —
//! pero el `View<Msg>` declarativo de Llimphi reconstruye el árbol
//! por frame. El estado que debe persistir entre frames (el cache de
//! coords proyectadas para pan-blit) vive afuera del View, en el Model
//! del host, vía un `ChartCacheHandle = Arc<Mutex<ChartCache>>` que el
//! caller crea una vez y le pasa el clone a cada `chart_view(...)`.
//!
//! `scratch` del Element original era estado mutable del Element. En el
//! View es un `Vec<f32>` local del closure: por frame se asigna y se
//! drop al terminar el paint. Para series de hasta 50 K puntos el costo
//! del alloc/dealloc es despreciable comparado con la proyección y el
//! shaping de glifos.
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::sync::{Arc, Mutex};
use llimphi_ui::llimphi_layout::taffy::prelude::{percent, Size, Style};
use llimphi_ui::View;
use pineal_core::buffer::DataBuffer;
use pineal_render::{Canvas as _, Color, Rect, SceneCanvas, StrokeStyle};
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. Lo que habilita
/// el pan-blit: el caller lo crea una vez en su Model y le pasa el
/// `Arc<Mutex<_>>` clonado a cada frame.
#[derive(Default, Debug)]
pub struct ChartCache {
projected: Vec<Vec<f32>>,
structural_hash: u64,
cached_x_min: f64,
cached_y_min: f64,
pan_blits: u64,
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: cache compartido listo para enchufar en el host.
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()) }
}
}
/// Configuración del chart. Builder estilo Element. `view::<Msg>()`
/// materializa el `View<Msg>`.
pub struct ChartView {
series: Vec<ChartSeriesItem>,
viewport: ChartViewport,
background: Option<Color>,
axis_color: Color,
axis_style: AxisStyle,
margin_top: f32,
margin_right: f32,
margin_bottom: f32,
margin_left: f32,
cache: Option<ChartCacheHandle>,
}
impl ChartView {
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_top: 8.0,
margin_right: 8.0,
margin_bottom: 24.0,
margin_left: 32.0,
cache: None,
}
}
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
}
pub fn with_cache(mut self, cache: ChartCacheHandle) -> Self {
self.cache = Some(cache);
self
}
/// Materializa el `View<Msg>`. Devuelve un nodo que ocupa 100% del
/// padre y pinta el chart dentro de su rect.
pub fn view<Msg: Clone + 'static>(self) -> View<Msg> {
let ChartView {
series,
viewport,
background,
axis_color,
axis_style,
margin_top,
margin_right,
margin_bottom,
margin_left,
cache,
} = self;
View::new(Style {
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
..Default::default()
})
.paint_with(move |scene, typesetter, rect| {
let outer = Rect::new(rect.x, rect.y, rect.w, rect.h);
let plot = Rect::new(
outer.x + margin_left,
outer.y + margin_top,
(outer.w - margin_left - margin_right).max(1.0),
(outer.h - margin_top - margin_bottom).max(1.0),
);
let cs = CoordinateSystem::new(viewport, plot);
let mut canvas = SceneCanvas::new(scene, typesetter);
if let Some(bg) = background {
canvas.fill_rect(outer, bg);
}
axis::paint_axes(
&mut canvas,
&cs,
&viewport,
axis_color,
axis_style,
TARGET_TICKS_X,
TARGET_TICKS_Y,
);
let current_hash =
structural_hash(plot, viewport.x_span(), viewport.y_span(), &series);
let pan_only = cache
.as_ref()
.map(|h| {
let c = h.lock().unwrap();
c.has_valid_cache
&& c.structural_hash == current_hash
&& c.projected.len() == series.len()
})
.unwrap_or(false);
if pan_only {
pan_blit_paint(&series, plot, viewport, cache.as_ref().unwrap(), &mut canvas);
} else {
rebuild_and_paint(
&series,
&cs,
plot,
viewport,
current_hash,
cache.as_ref(),
&mut canvas,
);
}
})
}
}
/// Rebuild full: LTTB + projection por serie. Pinta directo del cache
/// (si está enchufado) para no copiar dos veces.
fn rebuild_and_paint(
series: &[ChartSeriesItem],
cs: &CoordinateSystem,
_plot: Rect,
viewport: ChartViewport,
current_hash: u64,
cache: Option<&ChartCacheHandle>,
canvas: &mut SceneCanvas<'_>,
) {
if let Some(handle) = cache {
let mut cached = handle.lock().unwrap();
cached.projected.clear();
cached.projected.resize_with(series.len(), Vec::new);
for (i, item) in series.iter().enumerate() {
let s = LineSeries::new(&item.data, item.stroke);
s.compute_projected(cs, &mut cached.projected[i]);
if cached.projected[i].len() >= 4 {
canvas.stroke_polyline(&cached.projected[i], item.stroke);
}
}
cached.structural_hash = current_hash;
cached.cached_x_min = viewport.x_min;
cached.cached_y_min = viewport.y_min;
cached.has_valid_cache = true;
cached.pan_blits = 0;
cached.rebuilds = cached.rebuilds.wrapping_add(1);
} else {
let mut scratch = Vec::new();
for item in series {
let s = LineSeries::new(&item.data, item.stroke);
s.compute_projected(cs, &mut scratch);
if scratch.len() >= 4 {
canvas.stroke_polyline(&scratch, item.stroke);
}
}
}
}
/// Emite las coords cacheadas con un offset en pixel space. Se usa
/// cuando el hash estructural coincide — solo cambió el origen del
/// viewport (pan puro).
fn pan_blit_paint(
series: &[ChartSeriesItem],
plot: Rect,
viewport: ChartViewport,
cache: &ChartCacheHandle,
canvas: &mut SceneCanvas<'_>,
) {
let mut cached = cache.lock().unwrap();
let dx_px =
((cached.cached_x_min - viewport.x_min) * plot.w as f64 / viewport.x_span()) as f32;
let dy_px =
((viewport.y_min - cached.cached_y_min) * plot.h as f64 / viewport.y_span()) as f32;
let mut scratch = Vec::new();
for (i, item) in series.iter().enumerate() {
let projected = &cached.projected[i];
if projected.len() < 4 {
continue;
}
scratch.clear();
scratch.reserve(projected.len());
let mut k = 0;
while k + 1 < projected.len() {
scratch.push(projected[k] + dx_px);
scratch.push(projected[k + 1] + dy_px);
k += 2;
}
canvas.stroke_polyline(&scratch, item.stroke);
}
cached.pan_blits = cached.pan_blits.wrapping_add(1);
}
/// Hash de la geometría + identidades de data. NO incluye `x_min`/`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()
}
/// Helper builder-style — paralelo al `lapaloma_chart(...)` del Element GPUI.
pub fn lapaloma_chart_view(
data: DataBuffer,
viewport: ChartViewport,
stroke: StrokeStyle,
) -> ChartView {
ChartView::new(viewport).add_series(data, stroke)
}
@@ -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-contour"
version = { workspace = true }
edition = { workspace = true }
license = { workspace = true }
authors = { workspace = true }
publish = { workspace = true }
description = "Lapaloma — contour: marching squares sobre matriz escalar → polilíneas por isolínea."
[dependencies]
pineal-render = { path = "../pineal-render" }
pineal-heatmap = { path = "../pineal-heatmap" }
@@ -0,0 +1,266 @@
//! `pineal-contour` — isolíneas vía marching squares.
//!
//! Dada una `HeatmapMatrix` y un nivel `iso`, extrae los segmentos
//! donde la función vale exactamente `iso` aproximada por interpolación
//! lineal en los bordes de cada celda. Compone con `pineal-cartesian`
//! para mapas topográficos, líneas de presión, equipotenciales, etc.
//!
//! - [`extract_contour`] — un nivel → `Vec` de segmentos `[x0,y0,x1,y1]`.
//! - [`extract_contours`] — N niveles equiespaciados.
//! - [`paint_contours`] — pinta cada nivel como polilínea contra `Canvas`.
//!
//! Implementación: marching squares clásico — para cada celda 2×2 se
//! computa un índice 4-bit a partir del signo de cada esquina respecto
//! a `iso`; un lookup table de 16 casos da los 0/1/2 segmentos. Sin
//! ambigüedad resolution (los casos 5 y 10 saddle se rompen siempre
//! del mismo lado — suficiente para charts).
#![forbid(unsafe_code)]
use pineal_heatmap::HeatmapMatrix;
use pineal_render::{Canvas, Color, Rect, StrokeStyle};
/// Un segmento de contorno: 4 floats interleaved `[x0, y0, x1, y1]`.
pub type Segment = [f32; 4];
/// Extrae los segmentos del nivel `iso` sobre `matrix`. Los segmentos
/// están en coords de la matriz (celda `(x, y)` ocupa el cuadrado
/// `[x..x+1] × [y..y+1]`). El caller mapea a pixels con un
/// [`pineal-cartesian`] viewport o equivalente.
pub fn extract_contour(matrix: &HeatmapMatrix, iso: f32) -> Vec<Segment> {
let w = matrix.width();
let h = matrix.height();
if w < 2 || h < 2 {
return Vec::new();
}
let mut segs = Vec::new();
for y in 0..h - 1 {
for x in 0..w - 1 {
// Esquinas: nw=00, ne=10, se=11, sw=01 (clockwise desde top-left).
let nw = matrix.get(x, y);
let ne = matrix.get(x + 1, y);
let se = matrix.get(x + 1, y + 1);
let sw = matrix.get(x, y + 1);
let mut idx = 0u8;
if nw >= iso { idx |= 1; }
if ne >= iso { idx |= 2; }
if se >= iso { idx |= 4; }
if sw >= iso { idx |= 8; }
if idx == 0 || idx == 15 {
continue;
}
// Interpolación lineal sobre cada borde activo.
let xf = x as f32;
let yf = y as f32;
let top = (xf + lerp_t(nw, ne, iso), yf);
let right = (xf + 1.0, yf + lerp_t(ne, se, iso));
let bot = (xf + lerp_t(sw, se, iso), yf + 1.0);
let left = (xf, yf + lerp_t(nw, sw, iso));
// Lookup table de marching squares — 16 casos.
match idx {
1 | 14 => push(&mut segs, top, left),
2 | 13 => push(&mut segs, top, right),
3 | 12 => push(&mut segs, left, right),
4 | 11 => push(&mut segs, right, bot),
5 => {
push(&mut segs, top, left);
push(&mut segs, right, bot);
}
6 | 9 => push(&mut segs, top, bot),
7 | 8 => push(&mut segs, left, bot),
10 => {
push(&mut segs, top, right);
push(&mut segs, left, bot);
}
_ => {}
}
}
}
segs
}
fn lerp_t(a: f32, b: f32, iso: f32) -> f32 {
let d = b - a;
if d.abs() < 1e-9 {
return 0.5;
}
((iso - a) / d).clamp(0.0, 1.0)
}
fn push(segs: &mut Vec<Segment>, a: (f32, f32), b: (f32, f32)) {
segs.push([a.0, a.1, b.0, b.1]);
}
/// Extrae N niveles equiespaciados entre `min` y `max` de la matriz
/// (exclusivos en ambos extremos: el primer nivel está en `min + step`,
/// el último en `max - step`).
pub fn extract_contours(matrix: &HeatmapMatrix, n_levels: usize) -> Vec<(f32, Vec<Segment>)> {
if n_levels == 0 {
return Vec::new();
}
let (min, max) = matrix.min_max();
if (max - min).abs() < 1e-9 {
return Vec::new();
}
let step = (max - min) / (n_levels + 1) as f32;
(1..=n_levels)
.map(|i| {
let iso = min + step * i as f32;
(iso, extract_contour(matrix, iso))
})
.collect()
}
/// Pinta los segmentos en `coords` de matriz mapeados al `area` destino.
/// Cada nivel se dibuja con `stroke`.
pub fn paint_contour(
segments: &[Segment],
matrix_w: usize,
matrix_h: usize,
area: Rect,
stroke: StrokeStyle,
canvas: &mut dyn Canvas,
) {
if matrix_w < 2 || matrix_h < 2 {
return;
}
let cell_w = area.w / (matrix_w - 1) as f32;
let cell_h = area.h / (matrix_h - 1) as f32;
for seg in segments {
let coords = [
area.x + seg[0] * cell_w,
area.y + seg[1] * cell_h,
area.x + seg[2] * cell_w,
area.y + seg[3] * cell_h,
];
canvas.stroke_polyline(&coords, stroke);
}
}
/// Atajo: pinta N niveles cada uno con su propio color (gradiente
/// lineal entre `color_low` y `color_high` por isolínea).
pub fn paint_contours(
matrix: &HeatmapMatrix,
n_levels: usize,
area: Rect,
color_low: Color,
color_high: Color,
line_width: f32,
canvas: &mut dyn Canvas,
) {
let levels = extract_contours(matrix, n_levels);
if levels.is_empty() {
return;
}
let n = levels.len().max(1) as f32;
for (i, (_iso, segs)) in levels.iter().enumerate() {
let t = if n > 1.0 { i as f32 / (n - 1.0) } else { 0.5 };
let c = lerp_color(color_low, color_high, t);
paint_contour(segs, matrix.width(), matrix.height(), area, StrokeStyle::new(line_width, c), canvas);
}
}
fn lerp_color(a: Color, b: Color, t: f32) -> Color {
Color::rgba(
a.r + (b.r - a.r) * t,
a.g + (b.g - a.g) * t,
a.b + (b.b - a.b) * t,
a.a + (b.a - a.a) * t,
)
}
#[cfg(test)]
mod tests {
use super::*;
use pineal_render::{PlanRecorder, RenderCmd};
fn ramp(w: usize, h: usize) -> HeatmapMatrix {
let data: Vec<f32> = (0..w * h).map(|i| (i / w) as f32).collect();
HeatmapMatrix::from_data(data, w, h).unwrap()
}
#[test]
fn empty_matrix_no_segments() {
let m = HeatmapMatrix::new(0, 0);
assert!(extract_contour(&m, 0.5).is_empty());
}
#[test]
fn ramp_at_mid_level_yields_horizontal_segments() {
// ramp 4x4: rows = 0,1,2,3. iso=1.5 cae entre row 1 y row 2 → línea horizontal.
let m = ramp(4, 4);
let segs = extract_contour(&m, 1.5);
assert!(!segs.is_empty());
// Todos los segmentos deberían ser ~horizontales (y0 ≈ y1).
for s in &segs {
assert!((s[1] - s[3]).abs() < 0.1, "no-horizontal: {s:?}");
}
}
#[test]
fn out_of_range_iso_no_segments() {
let m = ramp(4, 4);
assert!(extract_contour(&m, -5.0).is_empty());
assert!(extract_contour(&m, 100.0).is_empty());
}
#[test]
fn extract_contours_produces_n_levels() {
let m = ramp(4, 4);
let levels = extract_contours(&m, 3);
assert_eq!(levels.len(), 3);
// Niveles ordenados.
assert!(levels[0].0 < levels[1].0 && levels[1].0 < levels[2].0);
}
#[test]
fn paint_contour_emits_one_polyline_per_segment() {
let m = ramp(4, 4);
let segs = extract_contour(&m, 1.5);
let mut rec = PlanRecorder::new();
paint_contour(
&segs,
m.width(),
m.height(),
Rect::new(0.0, 0.0, 300.0, 200.0),
StrokeStyle::new(1.0, Color::BLACK),
&mut rec,
);
let n = rec
.into_plan()
.cmds
.iter()
.filter(|c| matches!(c, RenderCmd::StrokePolyline { .. }))
.count();
assert_eq!(n, segs.len());
}
#[test]
fn paint_contours_emits_per_level() {
let m = ramp(8, 8);
let mut rec = PlanRecorder::new();
paint_contours(
&m,
4,
Rect::new(0.0, 0.0, 200.0, 200.0),
Color::from_hex(0x000080),
Color::from_hex(0xff0000),
1.0,
&mut rec,
);
let n = rec
.into_plan()
.cmds
.iter()
.filter(|c| matches!(c, RenderCmd::StrokePolyline { .. }))
.count();
assert!(n > 0);
}
#[test]
fn flat_matrix_no_contours() {
let m = HeatmapMatrix::from_data(vec![5.0; 16], 4, 4).unwrap();
let levels = extract_contours(&m, 3);
assert!(levels.is_empty(), "matriz constante no debería tener isolíneas");
}
}
+12
View File
@@ -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 deps de UI, cero alloc en hot path."
[dependencies]
[dev-dependencies]
+19
View File
@@ -0,0 +1,19 @@
# pineal-core
> Modelo de escena de [pineal](../README.md): shapes, transforms, capas.
Tipos sin dependencia gráfica: `Shape`, `Path`, `Transform`, `Layer`, `Scene`. Los backends ([cartesian](../pineal-cartesian/README.md), [polar](../pineal-polar/README.md), etc.) construyen `Scene`; [`pineal-render`](../pineal-render/README.md) la dibuja.
## API
```rust
use pineal_core::{Scene, Shape, Layer};
let mut scene = Scene::new();
scene.layer("data").add(Shape::line(p1, p2));
```
## Deps
- `serde`, `glam` (vec/mat)
- Cero deps gráficas
+19
View File
@@ -0,0 +1,19 @@
# pineal-core
> Scene model of [pineal](../README.md): shapes, transforms, layers.
Graphics-free types: `Shape`, `Path`, `Transform`, `Layer`, `Scene`. Backends ([cartesian](../pineal-cartesian/README.md), [polar](../pineal-polar/README.md), etc.) build `Scene`; [`pineal-render`](../pineal-render/README.md) draws it.
## API
```rust
use pineal_core::{Scene, Shape, Layer};
let mut scene = Scene::new();
scene.layer("data").add(Shape::line(p1, p2));
```
## Deps
- `serde`, `glam` (vec/mat)
- Zero graphics deps
@@ -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);
}
}
+37
View File
@@ -0,0 +1,37 @@
//! `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 format 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: cada uno vive en el crate de la viz que lo
// usa, no acá. Esto es deliberado — `pineal-core` no debe arrastrar
// dependencias de visualización.
//
// - Barnes-Hut + Sugiyama + tree layout: `pineal-mesh`.
// - Squarified treemap: `pineal-treemap`.
// - Sankey layered: `pineal-flow`.
// - FDEB (Force-Directed Edge Bundling): roadmap, vendrá a `pineal-mesh`.
+177
View File
@@ -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");
}
}
+216
View File
@@ -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());
}
}
+153
View File
@@ -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,13 @@
[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 = "../pineal-core" }
pineal-render = { path = "../pineal-render" }
png = { workspace = true }
+19
View File
@@ -0,0 +1,19 @@
# pineal-export
> Exportador de [pineal](../README.md) a PNG / SVG / GIF.
Toma una `Scene` de [`pineal-core`](../pineal-core/README.md) y la serializa al formato pedido. SVG es **vector real** (no pixel-capture): cada shape se traduce a su elemento SVG correspondiente. PNG usa rasterizado vello en buffer offscreen. GIF para animaciones (encadenar frames).
## API
```rust
use pineal_export::{export, Format};
let bytes = export(&scene, Format::Svg)?;
fs::write("plot.svg", &bytes)?;
```
## Deps
- [`pineal-core`](../pineal-core/README.md), [`pineal-render`](../pineal-render/README.md)
- `image` (PNG, GIF), `svg` crate
@@ -0,0 +1,19 @@
# pineal-export
> [pineal](../README.md) exporter to PNG / SVG / GIF.
Takes a `Scene` from [`pineal-core`](../pineal-core/README.md) and serializes it to the requested format. SVG is **true vector** (not pixel-capture): each shape translates to its corresponding SVG element. PNG uses vello rasterization in an offscreen buffer. GIF for animations (chained frames).
## API
```rust
use pineal_export::{export, Format};
let bytes = export(&scene, Format::Svg)?;
fs::write("plot.svg", &bytes)?;
```
## Deps
- [`pineal-core`](../pineal-core/README.md), [`pineal-render`](../pineal-render/README.md)
- `image` (PNG, GIF), `svg` crate
@@ -0,0 +1,22 @@
//! `pineal-export` — exporters de `RenderPlan`.
//!
//! Estrategia: el painter dibuja contra el trait `Canvas`; un
//! `PlanRecorder` (en `pineal-render`) lo graba como `RenderPlan`; este
//! crate consume el plan y emite el format destino. Un solo camino de
//! código para screen y export.
//!
//! - [`svg`] — exporter SVG vectorial (`<path>`/`<rect>`/`<polygon>`…).
//! - [`png`] — exporter PNG raster, rasterizador software propio que
//! replayea cada `RenderCmd` sobre un buffer RGBA8. Sin deps nativas.
//! - [`pdf`] — exporter PDF mínimo, writer propio (sin `printpdf`).
//! 1 página, content stream con operadores básicos PDF-1.4.
#![forbid(unsafe_code)]
pub mod svg;
pub mod png;
pub mod pdf;
pub use svg::to_svg;
pub use crate::png::to_png;
pub use crate::pdf::{to_pdf, to_pdf_decimated};
+414
View File
@@ -0,0 +1,414 @@
//! Exporter PDF mínimo — un `RenderPlan` → documento PDF de 1 página.
//!
//! Estrategia: PDF es texto + binary stream. Para los primitivos que
//! produce pineal (rect, polyline, polygon de triangle-strip) basta con
//! emitir un content stream con operadores básicos:
//!
//! - `r g b rg` — set fill color (RGB, 0..1).
//! - `r g b RG` — set stroke color.
//! - `w w` — set line width.
//! - `x y w h re` — append rectangle to path.
//! - `x y m` — moveto.
//! - `x y l` — lineto.
//! - `f` — fill (non-zero).
//! - `S` — stroke.
//! - `q` / `Q` — save / restore graphics state (para clip).
//! - `W n` — clip non-zero + no-op end-path.
//!
//! PDF tiene origen en bottom-left con +Y hacia arriba; pineal trabaja
//! en screen-space (top-left, +Y abajo). Convertimos en el writer:
//! `pdf_y = page_height - y`. El alto/ancho del rect quedan iguales.
//!
//! Texto se omite a propósito (igual que el PNG exporter) — para labels
//! vectoriales usar SVG, que sí emite `<text>`. PDF embedeable de texto
//! requiere font subsetting, complejo y no es lo que se necesita para
//! reportes de chart.
//!
//! Sin compresión flate / sin streams comprimidos: cada export queda
//! human-readable y debugeable. Tamaño aceptable para los volúmenes
//! que un chart produce.
use pineal_core::lttb::lttb_indices;
use pineal_render::{Color, Rect, RenderCmd, RenderPlan};
use std::fmt::Write;
/// Convierte un `RenderPlan` a un PDF de página única `width × height`
/// (en puntos PDF, 72 dpi). Devuelve los bytes del documento.
pub fn to_pdf(plan: &RenderPlan, width: f32, height: f32) -> Vec<u8> {
let content = build_content(plan, height);
assemble(width, height, &content)
}
/// Igual que [`to_pdf`] pero aplica decimación LTTB a los polylines
/// antes de emitirlos. El target se computa según el DPI destino:
///
/// ```text
/// vertices_target = width_inches × dpi × vertices_per_pixel
/// ```
///
/// `vertices_per_pixel` es 3 (regla práctica del LTTB: tres vértices por
/// pixel — el rasterizado/AA del visor rellena el resto). Para un PDF
/// US-letter a 300 DPI: `target = 8.5 × 300 × 3 = 7 650` vértices máx
/// por polyline. Rects, líneas, triangle-strips y texto pasan intactos.
pub fn to_pdf_decimated(
plan: &RenderPlan,
width: f32,
height: f32,
dpi: f32,
) -> Vec<u8> {
let width_inches = width / 72.0;
let target = (width_inches * dpi * 3.0).max(16.0) as usize;
let decimated = decimate_polylines(plan, target);
to_pdf(&decimated, width, height)
}
/// Aplica LTTB a cada `StrokePolyline` del plan, dejando el resto
/// intacto. Si la polyline ya está por debajo del target, queda igual.
fn decimate_polylines(plan: &RenderPlan, target: usize) -> RenderPlan {
let mut out = RenderPlan::new();
for cmd in &plan.cmds {
match cmd {
RenderCmd::StrokePolyline { coords, stroke } => {
let n = coords.len() / 2;
if n <= target {
out.push(RenderCmd::StrokePolyline {
coords: coords.clone(),
stroke: *stroke,
});
continue;
}
let mut indices = Vec::with_capacity(target);
lttb_indices(coords, target, &mut indices);
let mut new_coords = Vec::with_capacity(indices.len() * 2);
for &i in &indices {
new_coords.push(coords[i * 2]);
new_coords.push(coords[i * 2 + 1]);
}
out.push(RenderCmd::StrokePolyline {
coords: new_coords,
stroke: *stroke,
});
}
other => out.push(other.clone()),
}
}
out
}
fn build_content(plan: &RenderPlan, page_h: f32) -> String {
let mut s = String::with_capacity(plan.cmds.len() * 64 + 128);
// Línea por default.
let mut current_fill = Color::TRANSPARENT;
let mut current_stroke = Color::TRANSPARENT;
let mut current_width = -1.0f32;
for cmd in &plan.cmds {
match cmd {
RenderCmd::PushClip(r) => {
let (x, y, w, h) = rect_to_pdf(*r, page_h);
let _ = writeln!(s, "q");
let _ = writeln!(s, "{} {} {} {} re W n", x, y, w, h);
}
RenderCmd::PopClip => {
let _ = writeln!(s, "Q");
}
RenderCmd::FillRect { rect, color } => {
set_fill(&mut s, *color, &mut current_fill);
let (x, y, w, h) = rect_to_pdf(*rect, page_h);
let _ = writeln!(s, "{} {} {} {} re f", x, y, w, h);
}
RenderCmd::StrokeRect { rect, stroke } => {
set_stroke(&mut s, stroke.color, &mut current_stroke);
set_width(&mut s, stroke.width, &mut current_width);
let (x, y, w, h) = rect_to_pdf(*rect, page_h);
let _ = writeln!(s, "{} {} {} {} re S", x, y, w, h);
}
RenderCmd::StrokeLine { a, b, stroke } => {
set_stroke(&mut s, stroke.color, &mut current_stroke);
set_width(&mut s, stroke.width, &mut current_width);
let _ = writeln!(s, "{} {} m {} {} l S", a.x, page_h - a.y, b.x, page_h - b.y);
}
RenderCmd::StrokePolyline { coords, stroke } => {
if coords.len() < 4 {
continue;
}
set_stroke(&mut s, stroke.color, &mut current_stroke);
set_width(&mut s, stroke.width, &mut current_width);
let _ = writeln!(s, "{} {} m", coords[0], page_h - coords[1]);
let mut i = 2;
while i + 1 < coords.len() {
let _ = writeln!(s, "{} {} l", coords[i], page_h - coords[i + 1]);
i += 2;
}
let _ = writeln!(s, "S");
}
RenderCmd::FillTriangleStrip { coords, colors } => {
let n = coords.len() / 2;
if n < 3 {
continue;
}
for t in 0..n - 2 {
let avg = avg_color(&[
colors.get(t).copied(),
colors.get(t + 1).copied(),
colors.get(t + 2).copied(),
]);
set_fill(&mut s, avg, &mut current_fill);
let p0 = (coords[t * 2], coords[t * 2 + 1]);
let p1 = (coords[(t + 1) * 2], coords[(t + 1) * 2 + 1]);
let p2 = (coords[(t + 2) * 2], coords[(t + 2) * 2 + 1]);
let _ = writeln!(
s,
"{} {} m {} {} l {} {} l h f",
p0.0,
page_h - p0.1,
p1.0,
page_h - p1.1,
p2.0,
page_h - p2.1
);
}
}
// DrawText: skip a propósito. SVG cubre vectorial-con-texto.
RenderCmd::DrawText { .. } => {}
}
}
s
}
fn rect_to_pdf(r: Rect, page_h: f32) -> (f32, f32, f32, f32) {
// PDF rect: x, y, w, h donde (x, y) es la esquina inferior-izquierda.
let pdf_y = page_h - r.y - r.h;
(r.x, pdf_y, r.w, r.h)
}
fn set_fill(s: &mut String, c: Color, current: &mut Color) {
if !same_color(*current, c) {
let _ = writeln!(s, "{} {} {} rg", c.r.clamp(0.0, 1.0), c.g.clamp(0.0, 1.0), c.b.clamp(0.0, 1.0));
*current = c;
}
}
fn set_stroke(s: &mut String, c: Color, current: &mut Color) {
if !same_color(*current, c) {
let _ = writeln!(s, "{} {} {} RG", c.r.clamp(0.0, 1.0), c.g.clamp(0.0, 1.0), c.b.clamp(0.0, 1.0));
*current = c;
}
}
fn set_width(s: &mut String, w: f32, current: &mut f32) {
let w = w.max(0.0);
if (*current - w).abs() > 1e-4 {
let _ = writeln!(s, "{} w", w);
*current = w;
}
}
fn same_color(a: Color, b: Color) -> bool {
(a.r - b.r).abs() < 1e-4
&& (a.g - b.g).abs() < 1e-4
&& (a.b - b.b).abs() < 1e-4
&& (a.a - b.a).abs() < 1e-4
}
fn avg_color(cs: &[Option<Color>]) -> Color {
let mut acc = Color::rgba(0.0, 0.0, 0.0, 0.0);
let mut n = 0.0;
for c in cs.iter().flatten() {
acc.r += c.r;
acc.g += c.g;
acc.b += c.b;
acc.a += c.a;
n += 1.0;
}
if n == 0.0 {
return Color::TRANSPARENT;
}
Color::rgba(acc.r / n, acc.g / n, acc.b / n, acc.a / n)
}
/// Ensambla el documento PDF: header, 4 objetos (catalog, pages, page,
/// content stream), xref y trailer. Cada offset se va anotando para el
/// xref final.
fn assemble(width: f32, height: f32, content: &str) -> Vec<u8> {
let mut buf: Vec<u8> = Vec::with_capacity(content.len() + 512);
let mut offsets: Vec<usize> = Vec::with_capacity(5);
buf.extend_from_slice(b"%PDF-1.4\n");
// Comentario binario obligatorio para PDFs que mezclan binario.
buf.extend_from_slice(b"%\xE2\xE3\xCF\xD3\n");
offsets.push(buf.len());
buf.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
offsets.push(buf.len());
buf.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n");
offsets.push(buf.len());
let page = format!(
"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {w} {h}] /Contents 4 0 R \
/Resources << >> >>\nendobj\n",
w = width,
h = height,
);
buf.extend_from_slice(page.as_bytes());
offsets.push(buf.len());
let content_bytes = content.as_bytes();
let stream_header = format!("4 0 obj\n<< /Length {} >>\nstream\n", content_bytes.len());
buf.extend_from_slice(stream_header.as_bytes());
buf.extend_from_slice(content_bytes);
buf.extend_from_slice(b"\nendstream\nendobj\n");
// xref.
let xref_offset = buf.len();
buf.extend_from_slice(b"xref\n0 5\n");
buf.extend_from_slice(b"0000000000 65535 f \n");
for off in &offsets {
let line = format!("{:010} 00000 n \n", off);
buf.extend_from_slice(line.as_bytes());
}
// Trailer.
let trailer = format!(
"trailer\n<< /Size 5 /Root 1 0 R >>\nstartxref\n{}\n%%EOF\n",
xref_offset
);
buf.extend_from_slice(trailer.as_bytes());
buf
}
#[cfg(test)]
mod tests {
use super::*;
use pineal_render::{Canvas, Color, Point, StrokeStyle};
fn sample_plan() -> RenderPlan {
let mut rec = pineal_render::PlanRecorder::new();
rec.fill_rect(Rect::new(10.0, 20.0, 100.0, 60.0), Color::from_hex(0xff0000));
rec.stroke_line(
Point::new(0.0, 0.0),
Point::new(200.0, 100.0),
StrokeStyle::new(2.0, Color::BLACK),
);
rec.into_plan()
}
#[test]
fn pdf_starts_with_header() {
let pdf = to_pdf(&sample_plan(), 300.0, 200.0);
assert!(pdf.starts_with(b"%PDF-1.4"));
}
#[test]
fn pdf_ends_with_eof() {
let pdf = to_pdf(&sample_plan(), 300.0, 200.0);
let tail = &pdf[pdf.len().saturating_sub(8)..];
assert!(tail.windows(5).any(|w| w == b"%%EOF"));
}
#[test]
fn pdf_contains_required_objects() {
let pdf = to_pdf(&sample_plan(), 300.0, 200.0);
let s = String::from_utf8_lossy(&pdf);
assert!(s.contains("/Type /Catalog"));
assert!(s.contains("/Type /Pages"));
assert!(s.contains("/Type /Page "));
assert!(s.contains("/MediaBox [0 0 300 200]"));
assert!(s.contains("xref"));
assert!(s.contains("startxref"));
}
#[test]
fn fill_rect_emits_re_f() {
let pdf = to_pdf(&sample_plan(), 300.0, 200.0);
let s = String::from_utf8_lossy(&pdf);
// rg para fill, re y f para rect+fill.
assert!(s.contains("1 0 0 rg"));
assert!(s.contains("re f"));
}
#[test]
fn stroke_line_emits_m_l_s() {
let pdf = to_pdf(&sample_plan(), 300.0, 200.0);
let s = String::from_utf8_lossy(&pdf);
assert!(s.contains(" m"));
assert!(s.contains(" l"));
assert!(s.contains("S\n") || s.contains("S\r"));
}
#[test]
fn empty_plan_still_well_formed() {
let pdf = to_pdf(&RenderPlan::default(), 100.0, 50.0);
assert!(pdf.starts_with(b"%PDF"));
let s = String::from_utf8_lossy(&pdf);
assert!(s.contains("%%EOF"));
}
#[test]
fn triangle_strip_emits_polygon_per_triangle() {
let mut rec = pineal_render::PlanRecorder::new();
rec.fill_triangle_strip(
&[0.0, 0.0, 10.0, 0.0, 5.0, 10.0, 15.0, 10.0],
&[Color::WHITE; 4],
);
let pdf = to_pdf(&rec.into_plan(), 50.0, 50.0);
let s = String::from_utf8_lossy(&pdf);
// 4 vértices → 2 triángulos → 2 polígonos (m + 2 l + h + f).
assert_eq!(s.matches(" h f").count(), 2);
}
#[test]
fn decimated_polyline_shrinks_to_target() {
let mut rec = pineal_render::PlanRecorder::new();
// 1000 vértices en una polyline (datos sintéticos).
let coords: Vec<f32> = (0..1000)
.flat_map(|i| {
let x = i as f32;
let y = (x * 0.1).sin() * 50.0 + 100.0;
[x, y]
})
.collect();
rec.stroke_polyline(&coords, StrokeStyle::new(1.0, Color::BLACK));
let plan = rec.into_plan();
// PDF de 200×100 pts (≈ 2.78 inches) a 72 DPI →
// target = 2.78 × 72 × 3 ≈ 600.
let pdf = to_pdf_decimated(&plan, 200.0, 100.0, 72.0);
let s = String::from_utf8_lossy(&pdf);
let moveto_count = s.matches(" m\n").count() + s.matches(" m ").count();
let lineto_count = s.matches(" l\n").count();
// 1 moveto + (N-1) linetos. Target < 1000 → debería ser menos
// que el original.
assert!(moveto_count >= 1);
assert!(
lineto_count < 999,
"esperaba decimación, hubo {lineto_count} linetos"
);
}
#[test]
fn decimation_preserves_other_primitives() {
let mut rec = pineal_render::PlanRecorder::new();
rec.fill_rect(Rect::new(0.0, 0.0, 50.0, 50.0), Color::WHITE);
let coords: Vec<f32> = (0..200).flat_map(|i| [i as f32, 0.0]).collect();
rec.stroke_polyline(&coords, StrokeStyle::new(1.0, Color::BLACK));
let pdf = to_pdf_decimated(&rec.into_plan(), 100.0, 100.0, 300.0);
let s = String::from_utf8_lossy(&pdf);
// Rect intacto.
assert!(s.contains("re f"));
}
#[test]
fn clip_uses_q_and_Q_blocks() {
let mut rec = pineal_render::PlanRecorder::new();
rec.push_clip(Rect::new(0.0, 0.0, 50.0, 50.0));
rec.fill_rect(Rect::new(10.0, 10.0, 20.0, 20.0), Color::BLACK);
rec.pop_clip();
let pdf = to_pdf(&rec.into_plan(), 100.0, 100.0);
let s = String::from_utf8_lossy(&pdf);
// q al inicio del clip + W n para fijarlo + Q al cerrar.
assert!(s.contains("q\n"));
assert!(s.contains(" W n"));
assert!(s.contains("Q\n"));
}
}
+411
View File
@@ -0,0 +1,411 @@
//! Exporter PNG: rasteriza un [`RenderPlan`] sobre un buffer RGBA y lo
//! codifica como PNG.
//!
//! La estrategia es la misma que `svg.rs`: el painter grabó cada comando
//! en un plan; acá lo replayeamos contra un rasterizador software
//! propio. No depende de `tiny-skia` ni `cairo` — sólo `png` (ya en el
//! workspace) y aritmética f32. Esto mantiene la cadena
//! `core → render → export` libre de stack gráfico nativo.
//!
//! Cobertura:
//! - `FillRect`, `StrokeRect` — clip al canvas, blast directo.
//! - `StrokeLine` / `StrokePolyline` — línea gruesa por expansión
//! perpendicular + scanline fill del rectángulo orientado.
//! - `FillTriangleStrip` — `N-2` triángulos, scanline fill con color
//! promedio por triángulo (mismo trade-off que el exporter SVG).
//! - `DrawText` — *no-op*. PNG export es para gráficos densos
//! (heatmaps, treemaps, traces); el texto se mete después en
//! composición. SVG sí emite `<text>` cuando se quiere vectorial.
//! - `PushClip` / `PopClip` — stack de clip-rect activo, AND con el
//! bound del buffer al escribir.
//!
//! Composición: source-over premultiplicado. Suficiente para overlays
//! semitransparentes (radar fill, palette ramps).
use pineal_render::{Color, Rect, RenderCmd, RenderPlan};
use std::io::Cursor;
/// Convierte un `RenderPlan` a PNG (bytes). `bg` es el color con que se
/// inicializa el canvas (alpha incluido — `Color::TRANSPARENT` da fondo
/// transparente). Devuelve `Err` si la codificación PNG falla.
pub fn to_png(
plan: &RenderPlan,
width: u32,
height: u32,
bg: Color,
) -> Result<Vec<u8>, png::EncodingError> {
let mut buf = RasterBuffer::new(width, height, bg);
let mut clip_stack: Vec<Rect> = Vec::new();
for cmd in &plan.cmds {
replay(cmd, &mut buf, &mut clip_stack);
}
encode_png(&buf)
}
/// Buffer RGBA8 row-major, +Y hacia abajo (igual que `RenderPlan`).
struct RasterBuffer {
pixels: Vec<u8>,
w: u32,
h: u32,
}
impl RasterBuffer {
fn new(w: u32, h: u32, bg: Color) -> Self {
let r = (bg.r.clamp(0.0, 1.0) * 255.0).round() as u8;
let g = (bg.g.clamp(0.0, 1.0) * 255.0).round() as u8;
let b = (bg.b.clamp(0.0, 1.0) * 255.0).round() as u8;
let a = (bg.a.clamp(0.0, 1.0) * 255.0).round() as u8;
let mut pixels = Vec::with_capacity((w * h * 4) as usize);
for _ in 0..(w * h) {
pixels.extend_from_slice(&[r, g, b, a]);
}
Self { pixels, w, h }
}
/// Source-over con premultiplicación. `x`/`y` ya deben caer dentro.
#[inline]
fn blend(&mut self, x: u32, y: u32, c: Color) {
let idx = ((y * self.w + x) * 4) as usize;
let sa = c.a.clamp(0.0, 1.0);
if sa <= 0.0 {
return;
}
let sr = c.r.clamp(0.0, 1.0) * sa;
let sg = c.g.clamp(0.0, 1.0) * sa;
let sb = c.b.clamp(0.0, 1.0) * sa;
let inv = 1.0 - sa;
let dr = self.pixels[idx] as f32 / 255.0;
let dg = self.pixels[idx + 1] as f32 / 255.0;
let db = self.pixels[idx + 2] as f32 / 255.0;
let da = self.pixels[idx + 3] as f32 / 255.0;
let or = sr + dr * inv;
let og = sg + dg * inv;
let ob = sb + db * inv;
let oa = sa + da * inv;
self.pixels[idx] = (or.clamp(0.0, 1.0) * 255.0).round() as u8;
self.pixels[idx + 1] = (og.clamp(0.0, 1.0) * 255.0).round() as u8;
self.pixels[idx + 2] = (ob.clamp(0.0, 1.0) * 255.0).round() as u8;
self.pixels[idx + 3] = (oa.clamp(0.0, 1.0) * 255.0).round() as u8;
}
/// Llena `rect` (en pixels) con `color`, intersectándolo primero con
/// el clip actual y el bound del buffer.
fn fill_rect(&mut self, rect: Rect, color: Color, clip: Option<Rect>) {
let bound = self.bound();
let mut r = intersect(rect, bound);
if let Some(c) = clip {
r = intersect(r, c);
}
if r.w <= 0.0 || r.h <= 0.0 {
return;
}
let x0 = r.x.floor() as u32;
let y0 = r.y.floor() as u32;
let x1 = (r.x + r.w).ceil() as u32;
let y1 = (r.y + r.h).ceil() as u32;
for y in y0..x1_clamped(y1, self.h) {
for x in x0..x1_clamped(x1, self.w) {
self.blend(x, y, color);
}
}
}
fn bound(&self) -> Rect {
Rect::new(0.0, 0.0, self.w as f32, self.h as f32)
}
}
fn x1_clamped(v: u32, max: u32) -> u32 {
if v > max {
max
} else {
v
}
}
fn intersect(a: Rect, b: Rect) -> Rect {
let x0 = a.x.max(b.x);
let y0 = a.y.max(b.y);
let x1 = (a.x + a.w).min(b.x + b.w);
let y1 = (a.y + a.h).min(b.y + b.h);
Rect::new(x0, y0, (x1 - x0).max(0.0), (y1 - y0).max(0.0))
}
fn replay(cmd: &RenderCmd, buf: &mut RasterBuffer, clip: &mut Vec<Rect>) {
let active_clip = clip.last().copied();
match cmd {
RenderCmd::PushClip(r) => {
// Push el AND con el clip anterior.
let new_clip = match active_clip {
Some(prev) => intersect(*r, prev),
None => *r,
};
clip.push(new_clip);
}
RenderCmd::PopClip => {
clip.pop();
}
RenderCmd::FillRect { rect, color } => {
buf.fill_rect(*rect, *color, active_clip);
}
RenderCmd::StrokeRect { rect, stroke } => {
let w = stroke.width.max(1.0);
// Top, bottom, left, right como rects rellenos.
buf.fill_rect(Rect::new(rect.x, rect.y, rect.w, w), stroke.color, active_clip);
buf.fill_rect(
Rect::new(rect.x, rect.y + rect.h - w, rect.w, w),
stroke.color,
active_clip,
);
buf.fill_rect(Rect::new(rect.x, rect.y, w, rect.h), stroke.color, active_clip);
buf.fill_rect(
Rect::new(rect.x + rect.w - w, rect.y, w, rect.h),
stroke.color,
active_clip,
);
}
RenderCmd::StrokeLine { a, b, stroke } => {
stroke_segment(buf, (a.x, a.y), (b.x, b.y), stroke.color, stroke.width, active_clip);
}
RenderCmd::StrokePolyline { coords, stroke } => {
for w in coords.chunks_exact(2).collect::<Vec<_>>().windows(2) {
stroke_segment(
buf,
(w[0][0], w[0][1]),
(w[1][0], w[1][1]),
stroke.color,
stroke.width,
active_clip,
);
}
}
RenderCmd::FillTriangleStrip { coords, colors } => {
let n = coords.len() / 2;
if n < 3 {
return;
}
for t in 0..n - 2 {
let p0 = (coords[t * 2], coords[t * 2 + 1]);
let p1 = (coords[(t + 1) * 2], coords[(t + 1) * 2 + 1]);
let p2 = (coords[(t + 2) * 2], coords[(t + 2) * 2 + 1]);
let avg = avg_color(&[
colors.get(t).copied(),
colors.get(t + 1).copied(),
colors.get(t + 2).copied(),
]);
fill_triangle(buf, p0, p1, p2, avg, active_clip);
}
}
// DrawText: PNG export deliberadamente skipea texto. Ver doc del módulo.
RenderCmd::DrawText { .. } => {}
}
}
fn avg_color(cs: &[Option<Color>]) -> Color {
let mut acc = Color::rgba(0.0, 0.0, 0.0, 0.0);
let mut n = 0.0;
for c in cs.iter().flatten() {
acc.r += c.r;
acc.g += c.g;
acc.b += c.b;
acc.a += c.a;
n += 1.0;
}
if n == 0.0 {
return Color::TRANSPARENT;
}
Color::rgba(acc.r / n, acc.g / n, acc.b / n, acc.a / n)
}
/// Segmento expandido a un quad orientado (perpendicular ± width/2),
/// rasterizado como dos triángulos.
fn stroke_segment(
buf: &mut RasterBuffer,
a: (f32, f32),
b: (f32, f32),
color: Color,
width: f32,
clip: Option<Rect>,
) {
let dx = b.0 - a.0;
let dy = b.1 - a.1;
let len = (dx * dx + dy * dy).sqrt();
if len < 1e-6 {
return;
}
let half = width.max(1.0) * 0.5;
// Perpendicular unitario.
let nx = -dy / len * half;
let ny = dx / len * half;
let p0 = (a.0 + nx, a.1 + ny);
let p1 = (a.0 - nx, a.1 - ny);
let p2 = (b.0 + nx, b.1 + ny);
let p3 = (b.0 - nx, b.1 - ny);
fill_triangle(buf, p0, p1, p2, color, clip);
fill_triangle(buf, p1, p3, p2, color, clip);
}
/// Scanline fill de un triángulo con AA por supersampling 2×2 (4 samples
/// por pixel → coverage ∈ {0, ¼, ½, ¾, 1}). El color final se blendea
/// multiplicando alpha por coverage. Edge ratio razonable: ~4× más
/// cómputo que sample-único pero los bordes dejan de tener escalera.
fn fill_triangle(
buf: &mut RasterBuffer,
a: (f32, f32),
b: (f32, f32),
c: (f32, f32),
color: Color,
clip: Option<Rect>,
) {
let bound = buf.bound();
let active = match clip {
Some(c) => intersect(c, bound),
None => bound,
};
let min_x = a.0.min(b.0).min(c.0).max(active.x).floor() as i32;
let max_x = a.0.max(b.0).max(c.0).min(active.x + active.w).ceil() as i32;
let min_y = a.1.min(b.1).min(c.1).max(active.y).floor() as i32;
let max_y = a.1.max(b.1).max(c.1).min(active.y + active.h).ceil() as i32;
if max_x <= min_x || max_y <= min_y {
return;
}
let area = edge(a, b, c);
if area.abs() < 1e-6 {
return;
}
let sign = area.signum();
const SAMPLES: [(f32, f32); 4] = [(0.25, 0.25), (0.75, 0.25), (0.25, 0.75), (0.75, 0.75)];
for y in min_y..max_y {
for x in min_x..max_x {
let mut hits = 0u32;
for (sx, sy) in SAMPLES.iter() {
let p = (x as f32 + *sx, y as f32 + *sy);
let w0 = edge(b, c, p) * sign;
let w1 = edge(c, a, p) * sign;
let w2 = edge(a, b, p) * sign;
if w0 >= 0.0 && w1 >= 0.0 && w2 >= 0.0 {
hits += 1;
}
}
if hits == 0 {
continue;
}
let cov = hits as f32 / SAMPLES.len() as f32;
let blended = Color { a: color.a * cov, ..color };
buf.blend(x as u32, y as u32, blended);
}
}
}
#[inline]
fn edge(a: (f32, f32), b: (f32, f32), c: (f32, f32)) -> f32 {
(b.0 - a.0) * (c.1 - a.1) - (b.1 - a.1) * (c.0 - a.0)
}
fn encode_png(buf: &RasterBuffer) -> Result<Vec<u8>, png::EncodingError> {
let mut out = Vec::with_capacity(buf.pixels.len() / 4);
{
let mut encoder = png::Encoder::new(Cursor::new(&mut out), buf.w, buf.h);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header()?;
writer.write_image_data(&buf.pixels)?;
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use pineal_render::{Canvas, Color, Point, StrokeStyle};
fn record_one_rect() -> RenderPlan {
let mut rec = pineal_render::PlanRecorder::new();
rec.fill_rect(Rect::new(2.0, 2.0, 6.0, 6.0), Color::from_hex(0xff0000));
rec.into_plan()
}
#[test]
fn png_starts_with_magic() {
let bytes = to_png(&record_one_rect(), 16, 16, Color::WHITE).unwrap();
assert_eq!(&bytes[..8], b"\x89PNG\r\n\x1a\n");
}
#[test]
fn rect_is_painted_red_on_white_background() {
let bytes = to_png(&record_one_rect(), 16, 16, Color::WHITE).unwrap();
// Decodificar de vuelta y verificar un pixel dentro del rect (≈ 255,0,0)
// y otro fuera (255,255,255).
let decoder = png::Decoder::new(Cursor::new(bytes));
let mut reader = decoder.read_info().unwrap();
let mut img = vec![0; reader.output_buffer_size().unwrap()];
let info = reader.next_frame(&mut img).unwrap();
assert_eq!(info.color_type, png::ColorType::Rgba);
let idx_in = ((4u32 * 16 + 4) * 4) as usize;
let idx_out = ((0u32 * 16 + 0) * 4) as usize;
assert_eq!(img[idx_in], 255);
assert!(img[idx_in + 1] < 5);
assert!(img[idx_in + 2] < 5);
assert_eq!(img[idx_out], 255);
assert_eq!(img[idx_out + 1], 255);
assert_eq!(img[idx_out + 2], 255);
}
#[test]
fn stroke_line_writes_pixels_along_segment() {
let mut rec = pineal_render::PlanRecorder::new();
rec.stroke_line(
Point::new(2.0, 2.0),
Point::new(13.0, 13.0),
StrokeStyle::new(2.0, Color::BLACK),
);
let bytes = to_png(&rec.into_plan(), 16, 16, Color::WHITE).unwrap();
let decoder = png::Decoder::new(Cursor::new(bytes));
let mut reader = decoder.read_info().unwrap();
let mut img = vec![0; reader.output_buffer_size().unwrap()];
reader.next_frame(&mut img).unwrap();
// Algún pixel cerca de la diagonal debería estar marcado (con AA
// los bordes tienen coverage parcial — basta con que sea
// visiblemente más oscuro que el fondo blanco).
let idx = ((7u32 * 16 + 7) * 4) as usize;
assert!(img[idx] < 150, "esperaba pixel marcado en (7,7), got R={}", img[idx]);
}
#[test]
fn triangle_strip_fills_pixels() {
let mut rec = pineal_render::PlanRecorder::new();
rec.fill_triangle_strip(
&[0.0, 0.0, 16.0, 0.0, 0.0, 16.0, 16.0, 16.0],
&[Color::BLACK; 4],
);
let bytes = to_png(&rec.into_plan(), 16, 16, Color::WHITE).unwrap();
let decoder = png::Decoder::new(Cursor::new(bytes));
let mut reader = decoder.read_info().unwrap();
let mut img = vec![0; reader.output_buffer_size().unwrap()];
reader.next_frame(&mut img).unwrap();
// Casi todos los pixels deberían ser negros (1 strip cubre el cuadrado).
let black = img
.chunks_exact(4)
.filter(|p| p[0] < 30 && p[1] < 30 && p[2] < 30)
.count();
assert!(black > 240, "se esperaban >240 pixels negros, hubo {black}");
}
#[test]
fn clip_blocks_writes_outside_rect() {
let mut rec = pineal_render::PlanRecorder::new();
rec.push_clip(Rect::new(0.0, 0.0, 8.0, 16.0));
rec.fill_rect(Rect::new(0.0, 0.0, 16.0, 16.0), Color::BLACK);
rec.pop_clip();
let bytes = to_png(&rec.into_plan(), 16, 16, Color::WHITE).unwrap();
let decoder = png::Decoder::new(Cursor::new(bytes));
let mut reader = decoder.read_info().unwrap();
let mut img = vec![0; reader.output_buffer_size().unwrap()];
reader.next_frame(&mut img).unwrap();
// Lado izquierdo (x=2) negro; lado derecho (x=12) blanco.
let idx_l = ((8u32 * 16 + 2) * 4) as usize;
let idx_r = ((8u32 * 16 + 12) * 4) as usize;
assert!(img[idx_l] < 30);
assert!(img[idx_r] > 230);
}
}
+223
View File
@@ -0,0 +1,223 @@
//! Exporter SVG: un [`RenderPlan`] → documento SVG completo.
//!
//! El mismo painter que dibuja en pantalla (vía el trait `Canvas`) se
//! graba con un `PlanRecorder` y el plan resultante se vuelca acá. Un
//! solo camino de código para screen y export.
//!
//! v1: los comandos de clip (`PushClip`/`PopClip`) se ignoran — el
//! recorte no es crítico para la mayoría de exports y SVG `clipPath`
//! agrega complejidad de IDs. Se puede agregar después sin romper API.
use pineal_render::{Color, RenderCmd, RenderPlan};
use std::fmt::Write;
/// Convierte un `RenderPlan` a un documento SVG de `width × height`.
pub fn to_svg(plan: &RenderPlan, width: f32, height: f32) -> String {
let mut s = String::with_capacity(256 + plan.cmds.len() * 80);
let _ = write!(
s,
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{width}\" \
height=\"{height}\" viewBox=\"0 0 {width} {height}\">"
);
for cmd in &plan.cmds {
emit_cmd(&mut s, cmd);
}
s.push_str("</svg>");
s
}
fn emit_cmd(s: &mut String, cmd: &RenderCmd) {
match cmd {
// v1: clips ignorados (ver doc del módulo).
RenderCmd::PushClip(_) | RenderCmd::PopClip => {}
RenderCmd::FillRect { rect, color } => {
let (c, a) = svg_color(*color);
let _ = write!(
s,
"<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" \
fill=\"{c}\" fill-opacity=\"{a}\"/>",
rect.x, rect.y, rect.w, rect.h
);
}
RenderCmd::StrokeRect { rect, stroke } => {
let (c, a) = svg_color(stroke.color);
let _ = write!(
s,
"<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" \
fill=\"none\" stroke=\"{c}\" stroke-opacity=\"{a}\" \
stroke-width=\"{}\"/>",
rect.x, rect.y, rect.w, rect.h, stroke.width
);
}
RenderCmd::StrokeLine { a: p0, b: p1, stroke } => {
let (c, alpha) = svg_color(stroke.color);
let _ = write!(
s,
"<line x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" \
stroke=\"{c}\" stroke-opacity=\"{alpha}\" stroke-width=\"{}\"/>",
p0.x, p0.y, p1.x, p1.y, stroke.width
);
}
RenderCmd::StrokePolyline { coords, stroke } => {
let (c, alpha) = svg_color(stroke.color);
s.push_str("<polyline points=\"");
emit_points(s, coords);
let _ = write!(
s,
"\" fill=\"none\" stroke=\"{c}\" stroke-opacity=\"{alpha}\" \
stroke-width=\"{}\"/>",
stroke.width
);
}
RenderCmd::FillTriangleStrip { coords, colors } => {
emit_triangle_strip(s, coords, colors);
}
RenderCmd::DrawText { p, text, color, size_px } => {
let (c, a) = svg_color(*color);
let _ = write!(
s,
"<text x=\"{}\" y=\"{}\" fill=\"{c}\" fill-opacity=\"{a}\" \
font-size=\"{size_px}\">{}</text>",
p.x, p.y, escape_xml(text)
);
}
}
}
/// Emite `x0,y0 x1,y1 …` para el atributo `points` de polyline/polygon.
fn emit_points(s: &mut String, coords: &[f32]) {
for (i, pair) in coords.chunks_exact(2).enumerate() {
if i > 0 {
s.push(' ');
}
let _ = write!(s, "{},{}", pair[0], pair[1]);
}
}
/// Un triangle strip de N vértices = N-2 triángulos. Cada triángulo se
/// emite como `<polygon>` con el color promedio de sus 3 vértices (SVG
/// no tiene gradient por-vértice trivial).
fn emit_triangle_strip(s: &mut String, coords: &[f32], colors: &[Color]) {
let n = coords.len() / 2;
if n < 3 {
return;
}
for t in 0..n - 2 {
let (i0, i1, i2) = (t, t + 1, t + 2);
let avg = avg_color(&[
colors.get(i0).copied(),
colors.get(i1).copied(),
colors.get(i2).copied(),
]);
let (c, a) = svg_color(avg);
let _ = write!(
s,
"<polygon points=\"{},{} {},{} {},{}\" fill=\"{c}\" fill-opacity=\"{a}\"/>",
coords[i0 * 2], coords[i0 * 2 + 1],
coords[i1 * 2], coords[i1 * 2 + 1],
coords[i2 * 2], coords[i2 * 2 + 1],
);
}
}
fn avg_color(cs: &[Option<Color>]) -> Color {
let mut acc = Color::rgba(0.0, 0.0, 0.0, 0.0);
let mut n = 0.0;
for c in cs.iter().flatten() {
acc.r += c.r;
acc.g += c.g;
acc.b += c.b;
acc.a += c.a;
n += 1.0;
}
if n == 0.0 {
return Color::TRANSPARENT;
}
Color::rgba(acc.r / n, acc.g / n, acc.b / n, acc.a / n)
}
/// `Color` f32 → (`rgb(R,G,B)` con enteros 0-255, alpha 0-1).
fn svg_color(c: Color) -> (String, f32) {
let to255 = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
(
format!("rgb({},{},{})", to255(c.r), to255(c.g), to255(c.b)),
c.a.clamp(0.0, 1.0),
)
}
/// Escapa los 5 caracteres especiales de XML en contenido de texto.
fn escape_xml(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for ch in text.chars() {
match ch {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
'\'' => out.push_str("&apos;"),
c => out.push(c),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use pineal_render::{Canvas, Point, Rect, StrokeStyle};
fn sample_plan() -> RenderPlan {
let mut rec = pineal_render::PlanRecorder::new();
rec.fill_rect(Rect::new(1.0, 2.0, 30.0, 40.0), Color::from_hex(0xff0000));
rec.stroke_line(
Point::new(0.0, 0.0),
Point::new(100.0, 50.0),
StrokeStyle::new(2.0, Color::BLACK),
);
rec.draw_text(Point::new(5.0, 10.0), "a<b&c", Color::WHITE, 12.0);
rec.into_plan()
}
#[test]
fn emits_well_formed_svg() {
let svg = to_svg(&sample_plan(), 200.0, 100.0);
assert!(svg.starts_with("<svg xmlns=\"http://www.w3.org/2000/svg\""));
assert!(svg.ends_with("</svg>"));
assert!(svg.contains("width=\"200\""));
assert!(svg.contains("viewBox=\"0 0 200 100\""));
}
#[test]
fn emits_each_primitive() {
let svg = to_svg(&sample_plan(), 200.0, 100.0);
assert!(svg.contains("<rect "));
assert!(svg.contains("fill=\"rgb(255,0,0)\""));
assert!(svg.contains("<line "));
assert!(svg.contains("<text "));
}
#[test]
fn escapes_xml_in_text() {
let svg = to_svg(&sample_plan(), 200.0, 100.0);
assert!(svg.contains("a&lt;b&amp;c"));
assert!(!svg.contains("a<b&c"));
}
#[test]
fn triangle_strip_becomes_polygons() {
let mut rec = pineal_render::PlanRecorder::new();
rec.fill_triangle_strip(
&[0.0, 0.0, 10.0, 0.0, 5.0, 10.0, 15.0, 10.0],
&[Color::WHITE, Color::WHITE, Color::WHITE, Color::WHITE],
);
let svg = to_svg(&rec.into_plan(), 20.0, 20.0);
// 4 vértices → 2 triángulos → 2 polígonos.
assert_eq!(svg.matches("<polygon").count(), 2);
}
}
@@ -0,0 +1,14 @@
[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 = "../pineal-core" }
pineal-render = { path = "../pineal-render" }
pineal-cartesian = { path = "../pineal-cartesian" }
llimphi-ui = { workspace = true }
@@ -0,0 +1,19 @@
# pineal-financial
> Canvas financiero para [pineal](../README.md): velas, volúmenes, overlays técnicos.
OHLCV (Open/High/Low/Close/Volume) candles con estilo configurable (color up/down, wick width). Overlays técnicos: SMA, EMA, Bollinger, RSI, MACD. Pensado para inspección manual, no para HFT — el render es prioridad antes que latencia.
## API
```rust
use pineal_financial::{Chart, Bar, Overlay};
let chart = Chart::new(&bars)
.overlay(Overlay::sma(20))
.overlay(Overlay::bollinger(20, 2.0));
```
## Deps
- [`pineal-core`](../pineal-core/README.md)
@@ -0,0 +1,19 @@
# pineal-financial
> Financial canvas for [pineal](../README.md): candles, volumes, technical overlays.
OHLCV (Open/High/Low/Close/Volume) candles with configurable style (up/down color, wick width). Technical overlays: SMA, EMA, Bollinger, RSI, MACD. Aimed at manual inspection, not HFT — render is prioritized over latency.
## API
```rust
use pineal_financial::{Chart, Bar, Overlay};
let chart = Chart::new(&bars)
.overlay(Overlay::sma(20))
.overlay(Overlay::bollinger(20, 2.0));
```
## Deps
- [`pineal-core`](../pineal-core/README.md)
@@ -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,33 @@
//! `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;
pub mod candlestick;
pub mod view;
pub use ohlc_buffer::{Bar, OhlcBuffer};
pub use aggregate::aggregate_time_bucketed;
pub use candlestick::{paint_candlesticks, CandlestickStyle};
pub use view::{lapaloma_candlestick_view, CandlestickView};
@@ -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,124 @@
//! Vista Llimphi del chart OHLC / candlesticks.
//!
//! Paralelo de `element.rs` para el bucle de Llimphi. Reusa
//! `pineal_cartesian::axis::paint_axes` para los ejes y la función
//! agnóstica `paint_candlesticks` de `candlestick.rs` — sólo cambia
//! el backend (`SceneCanvas` en lugar de `WindowCanvas`).
use llimphi_ui::llimphi_layout::taffy::prelude::{percent, Size, Style};
use llimphi_ui::View;
use pineal_cartesian::axis::{self, AxisStyle};
use pineal_cartesian::{ChartViewport, CoordinateSystem};
use pineal_render::{Canvas as _, Color, Rect, SceneCanvas};
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 CandlestickView {
data: OhlcBuffer,
viewport: ChartViewport,
style: CandlestickStyle,
background: Option<Color>,
axis_color: Color,
axis_style: AxisStyle,
margin_top: f32,
margin_right: f32,
margin_bottom: f32,
margin_left: f32,
}
impl CandlestickView {
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_top: 8.0,
margin_right: 8.0,
margin_bottom: 24.0,
margin_left: 48.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
}
/// Materializa el `View<Msg>`. Pinta velas + ejes dentro del rect.
pub fn view<Msg: Clone + 'static>(self) -> View<Msg> {
let CandlestickView {
data,
viewport,
style,
background,
axis_color,
axis_style,
margin_top,
margin_right,
margin_bottom,
margin_left,
} = self;
View::new(Style {
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
..Default::default()
})
.paint_with(move |scene, typesetter, rect| {
let outer = Rect::new(rect.x, rect.y, rect.w, rect.h);
let plot = Rect::new(
outer.x + margin_left,
outer.y + margin_top,
(outer.w - margin_left - margin_right).max(1.0),
(outer.h - margin_top - margin_bottom).max(1.0),
);
let cs = CoordinateSystem::new(viewport, plot);
let mut canvas = SceneCanvas::new(scene, typesetter);
if let Some(bg) = background {
canvas.fill_rect(outer, bg);
}
axis::paint_axes(
&mut canvas,
&cs,
&viewport,
axis_color,
axis_style,
TARGET_TICKS_X,
TARGET_TICKS_Y,
);
paint_candlesticks(&mut canvas, &cs, &data, style);
})
}
}
/// Helper builder-style — paralelo al `lapaloma_candlestick(...)` GPUI.
pub fn lapaloma_candlestick_view(
data: OhlcBuffer,
viewport: ChartViewport,
) -> CandlestickView {
CandlestickView::new(data, viewport)
}
+12
View File
@@ -0,0 +1,12 @@
[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 = "../pineal-core" }
pineal-render = { path = "../pineal-render" }
+19
View File
@@ -0,0 +1,19 @@
# pineal-flow
> Canvas de campos vectoriales para [pineal](../README.md).
Dado un campo `(x, y) → (vx, vy)`, dibuja streamlines: trazas integradas a través del campo. RK4 para integración, longitud configurable, densidad adaptativa. Útil para visualizar flujos de [`dominium`](../../../01_yachay/dominium/README.md) o cualquier simulador con campos vectoriales.
## API
```rust
use pineal_flow::{Flow, Field};
let flow = Flow::new(field)
.density(0.05)
.line_length(40);
```
## Deps
- [`pineal-core`](../pineal-core/README.md)
+19
View File
@@ -0,0 +1,19 @@
# pineal-flow
> Vector-field canvas for [pineal](../README.md).
Given a field `(x, y) → (vx, vy)`, draws streamlines: integrated traces through the field. RK4 integration, configurable length, adaptive density. Useful to visualize flows from [`dominium`](../../../01_yachay/dominium/README.md) or any vector-field simulator.
## API
```rust
use pineal_flow::{Flow, Field};
let flow = Flow::new(field)
.density(0.05)
.line_length(40);
```
## Deps
- [`pineal-core`](../pineal-core/README.md)
@@ -0,0 +1,300 @@
//! Layout de un diagrama Sankey.
//!
//! Pipeline: columnas por longest-path en el DAG (back-edges descartadas)
//! → valor de nodo = max(entrada, salida) → apilado vertical por columna
//! con una pasada de barycenter para reducir cruces → anclas de cada
//! banda (link) en los bordes de sus nodos.
use pineal_render::{Point, Rect};
/// Un nodo del Sankey.
#[derive(Debug, Clone)]
pub struct SankeyNode {
pub label: String,
}
impl SankeyNode {
pub fn new(label: impl Into<String>) -> Self {
Self { label: label.into() }
}
}
/// Un flujo dirigido `source → target` con un caudal `value`.
#[derive(Debug, Clone, Copy)]
pub struct SankeyLink {
pub source: usize,
pub target: usize,
pub value: f64,
}
/// Caja de un nodo ya ubicada en el lienzo.
#[derive(Debug, Clone)]
pub struct NodeBox {
pub rect: Rect,
pub column: usize,
}
/// Banda de un link: cuatro anclas (arriba/abajo en origen y destino).
#[derive(Debug, Clone, Copy)]
pub struct LinkBand {
pub link: usize,
pub src_top: Point,
pub src_bot: Point,
pub dst_top: Point,
pub dst_bot: Point,
}
/// Layout completo: cajas de nodos + bandas de links.
#[derive(Debug, Clone, Default)]
pub struct SankeyLayout {
pub nodes: Vec<NodeBox>,
pub links: Vec<LinkBand>,
}
/// Calcula el layout de un Sankey dentro de `area`.
pub fn compute_layout(
nodes: &[SankeyNode],
links: &[SankeyLink],
area: Rect,
node_width: f32,
node_gap: f32,
) -> SankeyLayout {
let n = nodes.len();
if n == 0 || area.w <= 0.0 || area.h <= 0.0 {
return SankeyLayout::default();
}
let valid: Vec<&SankeyLink> = links
.iter()
.filter(|l| l.source < n && l.target < n && l.source != l.target && l.value > 0.0)
.collect();
let columns = assign_columns(n, &valid);
let n_cols = columns.iter().copied().max().unwrap_or(0) + 1;
// Valor de cada nodo = max(suma entrante, suma saliente).
let mut in_sum = vec![0.0f64; n];
let mut out_sum = vec![0.0f64; n];
for l in &valid {
in_sum[l.target] += l.value;
out_sum[l.source] += l.value;
}
let node_value: Vec<f64> = (0..n).map(|i| in_sum[i].max(out_sum[i]).max(0.0)).collect();
// Nodos por columna.
let mut by_col: Vec<Vec<usize>> = vec![Vec::new(); n_cols];
for (i, &c) in columns.iter().enumerate() {
by_col[c].push(i);
}
// Escala vertical: la columna más cargada llena `area.h` (con gaps).
let max_col_value = by_col
.iter()
.map(|col| col.iter().map(|&i| node_value[i]).sum::<f64>())
.fold(0.0f64, f64::max)
.max(1e-9);
let max_col_count = by_col.iter().map(|c| c.len()).max().unwrap_or(1).max(1);
let usable_h = (area.h - node_gap * (max_col_count.saturating_sub(1)) as f32).max(1.0);
let v_scale = usable_h as f64 / max_col_value;
// Una pasada de barycenter para ordenar cada columna.
barycenter_pass(&mut by_col, &valid, &columns);
// Geometría de cada nodo.
let col_step = if n_cols > 1 {
(area.w - node_width) / (n_cols - 1) as f32
} else {
0.0
};
let mut boxes = vec![
NodeBox { rect: Rect::new(0.0, 0.0, 0.0, 0.0), column: 0 };
n
];
for (c, col) in by_col.iter().enumerate() {
let mut y = area.y;
for &i in col {
let h = (node_value[i] * v_scale) as f32;
let x = area.x + c as f32 * col_step;
boxes[i] = NodeBox {
rect: Rect::new(x, y, node_width, h.max(1.0)),
column: c,
};
y += h + node_gap;
}
}
// Bandas de links: apiladas en el borde derecho del origen y el
// borde izquierdo del destino, en el orden de aparición.
let mut src_cursor = vec![0.0f32; n];
let mut dst_cursor = vec![0.0f32; n];
let mut bands = Vec::with_capacity(valid.len());
for (vi, l) in valid.iter().enumerate() {
let sb = &boxes[l.source].rect;
let tb = &boxes[l.target].rect;
let thick_s = (l.value * v_scale) as f32;
let s_y0 = sb.y + src_cursor[l.source];
let t_y0 = tb.y + dst_cursor[l.target];
src_cursor[l.source] += thick_s;
dst_cursor[l.target] += thick_s;
// El índice real del link en el slice original.
let link_idx = links
.iter()
.position(|x| {
x.source == l.source && x.target == l.target && x.value == l.value
})
.unwrap_or(vi);
bands.push(LinkBand {
link: link_idx,
src_top: Point::new(sb.right(), s_y0),
src_bot: Point::new(sb.right(), s_y0 + thick_s),
dst_top: Point::new(tb.x, t_y0),
dst_bot: Point::new(tb.x, t_y0 + thick_s),
});
}
SankeyLayout { nodes: boxes, links: bands }
}
/// Columna de cada nodo = longest-path desde una fuente. Las back-edges
/// (detectadas por DFS) se descartan para romper ciclos.
fn assign_columns(n: usize, links: &[&SankeyLink]) -> Vec<usize> {
let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
for l in links {
adj[l.source].push(l.target);
}
// DFS marcando back-edges (destino en la pila actual).
let mut state = vec![0u8; n]; // 0=blanco 1=en-pila 2=hecho
let mut back: Vec<(usize, usize)> = Vec::new();
for s in 0..n {
if state[s] != 0 {
continue;
}
let mut stack = vec![(s, 0usize)];
state[s] = 1;
while let Some(&mut (u, ref mut i)) = stack.last_mut() {
if *i < adj[u].len() {
let v = adj[u][*i];
*i += 1;
match state[v] {
0 => {
state[v] = 1;
stack.push((v, 0));
}
1 => back.push((u, v)),
_ => {}
}
} else {
state[u] = 2;
stack.pop();
}
}
}
// Longest-path en el DAG (sin back-edges) vía relajación topológica.
let mut indeg = vec![0usize; n];
let mut dag: Vec<Vec<usize>> = vec![Vec::new(); n];
for l in links {
if !back.contains(&(l.source, l.target)) {
dag[l.source].push(l.target);
indeg[l.target] += 1;
}
}
let mut col = vec![0usize; n];
let mut queue: Vec<usize> = (0..n).filter(|&i| indeg[i] == 0).collect();
let mut head = 0;
while head < queue.len() {
let u = queue[head];
head += 1;
for &v in &dag[u] {
col[v] = col[v].max(col[u] + 1);
indeg[v] -= 1;
if indeg[v] == 0 {
queue.push(v);
}
}
}
col
}
/// Reordena los nodos de cada columna por el promedio de las posiciones
/// de sus vecinos (barycenter heuristic), una pasada izquierda→derecha.
fn barycenter_pass(by_col: &mut [Vec<usize>], links: &[&SankeyLink], columns: &[usize]) {
let n = columns.len();
let mut order_in_col = vec![0usize; n];
for col in by_col.iter() {
for (pos, &i) in col.iter().enumerate() {
order_in_col[i] = pos;
}
}
for c in 1..by_col.len() {
let bary: Vec<(usize, f64)> = by_col[c]
.iter()
.map(|&node| {
let mut sum = 0.0;
let mut cnt = 0.0;
for l in links {
if l.target == node && columns[l.source] == c - 1 {
sum += order_in_col[l.source] as f64;
cnt += 1.0;
}
}
(node, if cnt > 0.0 { sum / cnt } else { f64::MAX })
})
.collect();
let mut sorted = bary;
sorted.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
by_col[c] = sorted.into_iter().map(|(node, _)| node).collect();
for (pos, &i) in by_col[c].iter().enumerate() {
order_in_col[i] = pos;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn nodes(n: usize) -> Vec<SankeyNode> {
(0..n).map(|i| SankeyNode::new(format!("n{i}"))).collect()
}
#[test]
fn empty_input() {
let l = compute_layout(&[], &[], Rect::new(0.0, 0.0, 100.0, 100.0), 20.0, 4.0);
assert!(l.nodes.is_empty());
}
#[test]
fn chain_assigns_increasing_columns() {
// 0 → 1 → 2
let links = [
SankeyLink { source: 0, target: 1, value: 5.0 },
SankeyLink { source: 1, target: 2, value: 5.0 },
];
let l = compute_layout(&nodes(3), &links, Rect::new(0.0, 0.0, 300.0, 100.0), 20.0, 4.0);
assert_eq!(l.nodes[0].column, 0);
assert_eq!(l.nodes[1].column, 1);
assert_eq!(l.nodes[2].column, 2);
assert_eq!(l.links.len(), 2);
}
#[test]
fn back_edge_does_not_loop_forever() {
// ciclo 0 → 1 → 0 ; debe terminar y no panickear.
let links = [
SankeyLink { source: 0, target: 1, value: 3.0 },
SankeyLink { source: 1, target: 0, value: 1.0 },
];
let l = compute_layout(&nodes(2), &links, Rect::new(0.0, 0.0, 200.0, 100.0), 20.0, 4.0);
assert_eq!(l.nodes.len(), 2);
}
#[test]
fn node_height_proportional_to_flow() {
// 0 manda 10 a 1 y 1 a 2 ; nodo 0 más "grueso" que nodo 2.
let links = [
SankeyLink { source: 0, target: 1, value: 10.0 },
SankeyLink { source: 0, target: 2, value: 2.0 },
];
let l = compute_layout(&nodes(3), &links, Rect::new(0.0, 0.0, 200.0, 200.0), 20.0, 4.0);
assert!(l.nodes[0].rect.h > l.nodes[2].rect.h);
}
}
+18
View File
@@ -0,0 +1,18 @@
//! `pineal-flow` — diagramas Sankey.
//!
//! Pipeline:
//! 1. Columnas por longest-path en el DAG (back-edges descartadas).
//! 2. Valor de nodo = max(caudal entrante, caudal saliente).
//! 3. Apilado vertical por columna + una pasada de barycenter.
//! 4. Bandas (ribbons) como triangle-strips con curva S (`smoothstep`).
//!
//! - [`layout`] — cómputo del layout (agnóstico).
//! - [`ribbon`] — teselado + painters contra `Canvas`.
#![forbid(unsafe_code)]
pub mod layout;
pub mod ribbon;
pub use layout::{compute_layout, LinkBand, NodeBox, SankeyLayout, SankeyLink, SankeyNode};
pub use ribbon::{paint_ribbon, paint_sankey, ribbon_strip};
@@ -0,0 +1,105 @@
//! Tesela y dibuja las bandas (ribbons) de un Sankey.
//!
//! Cada banda es una franja con curva S: `x` avanza lineal entre nodos y
//! `y` interpola con `smoothstep`, lo que da tangentes horizontales en
//! ambos extremos (el look clásico de Sankey). Se emite como un triangle
//! strip `[top0,bot0,top1,bot1,…]`, un draw call por ribbon.
use crate::layout::{LinkBand, SankeyLayout};
use pineal_render::{Canvas, Color};
/// Segmentos por ribbon — controla la suavidad de la curva.
const RIBBON_SEGMENTS: usize = 24;
fn smoothstep(t: f32) -> f32 {
t * t * (3.0 - 2.0 * t)
}
/// Tesela una banda en coords interleaved `[x,y,…]` de un triangle strip.
pub fn ribbon_strip(band: &LinkBand) -> Vec<f32> {
let mut coords = Vec::with_capacity((RIBBON_SEGMENTS + 1) * 4);
for i in 0..=RIBBON_SEGMENTS {
let t = i as f32 / RIBBON_SEGMENTS as f32;
let e = smoothstep(t);
let x_top = band.src_top.x + (band.dst_top.x - band.src_top.x) * t;
let y_top = band.src_top.y + (band.dst_top.y - band.src_top.y) * e;
let x_bot = band.src_bot.x + (band.dst_bot.x - band.src_bot.x) * t;
let y_bot = band.src_bot.y + (band.dst_bot.y - band.src_bot.y) * e;
coords.push(x_top);
coords.push(y_top);
coords.push(x_bot);
coords.push(y_bot);
}
coords
}
/// Dibuja una sola banda con el color dado.
pub fn paint_ribbon(band: &LinkBand, color: Color, canvas: &mut dyn Canvas) {
let coords = ribbon_strip(band);
let colors = vec![color; coords.len() / 2];
canvas.fill_triangle_strip(&coords, &colors);
}
/// Dibuja un Sankey completo: ribbons primero (al fondo), nodos encima.
pub fn paint_sankey(
layout: &SankeyLayout,
node_color: Color,
link_color: Color,
canvas: &mut dyn Canvas,
) {
for band in &layout.links {
paint_ribbon(band, link_color, canvas);
}
for nb in &layout.nodes {
if nb.rect.w > 0.0 && nb.rect.h > 0.0 {
canvas.fill_rect(nb.rect, node_color);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::layout::{compute_layout, SankeyLink, SankeyNode};
use pineal_render::{PlanRecorder, Rect, RenderCmd};
#[test]
fn ribbon_strip_has_expected_vertex_count() {
let band = LinkBand {
link: 0,
src_top: pineal_render::Point::new(0.0, 0.0),
src_bot: pineal_render::Point::new(0.0, 10.0),
dst_top: pineal_render::Point::new(100.0, 50.0),
dst_bot: pineal_render::Point::new(100.0, 60.0),
};
let coords = ribbon_strip(&band);
assert_eq!(coords.len(), (RIBBON_SEGMENTS + 1) * 4);
}
#[test]
fn paint_sankey_emits_nodes_and_ribbons() {
let nodes = vec![
SankeyNode::new("a"),
SankeyNode::new("b"),
SankeyNode::new("c"),
];
let links = [
SankeyLink { source: 0, target: 1, value: 5.0 },
SankeyLink { source: 1, target: 2, value: 3.0 },
];
let layout = compute_layout(
&nodes,
&links,
Rect::new(0.0, 0.0, 300.0, 150.0),
18.0,
6.0,
);
let mut rec = PlanRecorder::new();
paint_sankey(&layout, Color::from_hex(0x335577), Color::from_hex(0x88aacc), &mut rec);
let cmds = rec.into_plan().cmds;
let rects = cmds.iter().filter(|c| matches!(c, RenderCmd::FillRect { .. })).count();
let strips = cmds.iter().filter(|c| matches!(c, RenderCmd::FillTriangleStrip { .. })).count();
assert_eq!(rects, 3, "un fill_rect por nodo");
assert_eq!(strips, 2, "un triangle strip por link");
}
}
@@ -0,0 +1,12 @@
[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 = "../pineal-core" }
pineal-render = { path = "../pineal-render" }
@@ -0,0 +1,20 @@
# pineal-heatmap
> Canvas de heatmap denso 2D para [pineal](../README.md).
Toma una matriz `Array2<f32>` y la pinta como una grilla coloreada según un colormap (viridis, magma, plasma, ...). Soporta NaN como pixel transparente. Útil para resultados de [`dominium`](../../../01_yachay/dominium/README.md), correlation maps, raster cosmology.
## API
```rust
use pineal_heatmap::{Heatmap, Colormap};
let hm = Heatmap::new(&data)
.colormap(Colormap::Viridis)
.range(0.0..1.0);
```
## Deps
- [`pineal-core`](../pineal-core/README.md)
- `ndarray` opcional para integración con simuladores
@@ -0,0 +1,20 @@
# pineal-heatmap
> Dense 2D heatmap canvas for [pineal](../README.md).
Takes an `Array2<f32>` matrix and paints it as a colored grid via a colormap (viridis, magma, plasma, ...). Supports NaN as transparent pixel. Useful for [`dominium`](../../../01_yachay/dominium/README.md) outputs, correlation maps, raster cosmology.
## API
```rust
use pineal_heatmap::{Heatmap, Colormap};
let hm = Heatmap::new(&data)
.colormap(Colormap::Viridis)
.range(0.0..1.0);
```
## Deps
- [`pineal-core`](../pineal-core/README.md)
- `ndarray` optional for simulator integration
@@ -0,0 +1,57 @@
//! Encoder: `HeatmapMatrix` → buffer ARGB para subir como textura.
use crate::matrix::HeatmapMatrix;
use crate::palette::Ramp;
/// Normaliza cada celda a `[0,1]` por min/max, la mapea por la rampa y
/// empaqueta el resultado como `u32` ARGB (0xAARRGGBB), fila por fila.
///
/// El backend GPUI sube este buffer como una textura y la rendea con un
/// solo `drawImageRect`, en vez de N draw calls.
pub fn encode_argb(matrix: &HeatmapMatrix, ramp: Ramp) -> Vec<u32> {
let (min, max) = matrix.min_max();
let span = max - min;
let mut out = Vec::with_capacity(matrix.width() * matrix.height());
for &v in matrix.data() {
let t = if span > 0.0 { (v - min) / span } else { 0.0 };
let c = ramp.sample(t);
out.push(pack_argb(c.a, c.r, c.g, c.b));
}
out
}
/// Empaqueta 4 canales `f32` `[0,1]` en `0xAARRGGBB`.
fn pack_argb(a: f32, r: f32, g: f32, b: f32) -> u32 {
let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u32;
(q(a) << 24) | (q(r) << 16) | (q(g) << 8) | q(b)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encodes_one_pixel_per_cell() {
let m = HeatmapMatrix::from_data(vec![0.0, 1.0, 2.0, 3.0], 2, 2).unwrap();
let buf = encode_argb(&m, Ramp::Grayscale);
assert_eq!(buf.len(), 4);
}
#[test]
fn normalizes_min_to_ramp_start() {
// Grayscale: min → negro (0x000000), max → blanco (0xffffff).
let m = HeatmapMatrix::from_data(vec![10.0, 20.0], 2, 1).unwrap();
let buf = encode_argb(&m, Ramp::Grayscale);
assert_eq!(buf[0] & 0x00ff_ffff, 0x0000_0000); // min
assert_eq!(buf[1] & 0x00ff_ffff, 0x00ff_ffff); // max
assert_eq!(buf[0] >> 24, 0xff); // alpha opaco
}
#[test]
fn flat_matrix_does_not_divide_by_zero() {
let m = HeatmapMatrix::from_data(vec![5.0; 4], 2, 2).unwrap();
let buf = encode_argb(&m, Ramp::Viridis);
assert_eq!(buf.len(), 4);
assert!(buf.iter().all(|&p| p == buf[0]));
}
}
@@ -0,0 +1,23 @@
//! `pineal-heatmap` — matriz `width × height` de `f32` → visualización.
//!
//! Dos caminos de render:
//! - [`paint`] — agnóstico, un `fill_rect` por celda contra un `Canvas`.
//! Apto para matrices chicas y export SVG.
//! - [`encoder::encode_argb`] — empaqueta la matriz como buffer ARGB para
//! que un backend lo suba como textura y la rendee con un solo blit.
//! Apto para matrices grandes (4096² sin sudar).
//!
//! - [`matrix`] — `HeatmapMatrix` con `revision` para invalidación.
//! - [`palette`] — color ramps (Viridis, Grayscale).
#![forbid(unsafe_code)]
pub mod matrix;
pub mod palette;
pub mod encoder;
pub mod paint;
pub use encoder::encode_argb;
pub use matrix::HeatmapMatrix;
pub use paint::paint;
pub use palette::Ramp;
@@ -0,0 +1,93 @@
//! `HeatmapMatrix` — matriz densa `width × height` de `f32`.
/// Matriz de valores para un heatmap. `revision` se incrementa en cada
/// mutación — los backends lo usan para invalidar la textura cacheada.
#[derive(Debug, Clone)]
pub struct HeatmapMatrix {
data: Vec<f32>,
width: usize,
height: usize,
revision: u64,
}
impl HeatmapMatrix {
/// Matriz de ceros de `width × height`.
pub fn new(width: usize, height: usize) -> Self {
Self { data: vec![0.0; width * height], width, height, revision: 0 }
}
/// Construye desde datos crudos. `None` si `data.len() != width*height`.
pub fn from_data(data: Vec<f32>, width: usize, height: usize) -> Option<Self> {
if data.len() != width * height {
return None;
}
Some(Self { data, width, height, revision: 0 })
}
pub fn width(&self) -> usize { self.width }
pub fn height(&self) -> usize { self.height }
pub fn revision(&self) -> u64 { self.revision }
pub fn data(&self) -> &[f32] { &self.data }
/// Valor en `(x, y)`. `0.0` si está fuera de rango.
pub fn get(&self, x: usize, y: usize) -> f32 {
if x >= self.width || y >= self.height {
return 0.0;
}
self.data[y * self.width + x]
}
/// Fija el valor en `(x, y)` e incrementa `revision`. No-op si está
/// fuera de rango.
pub fn set(&mut self, x: usize, y: usize, v: f32) {
if x >= self.width || y >= self.height {
return;
}
self.data[y * self.width + x] = v;
self.revision += 1;
}
/// Reemplaza todos los datos (mismas dimensiones) e incrementa
/// `revision`. No-op si la longitud no coincide.
pub fn replace_data(&mut self, data: Vec<f32>) {
if data.len() == self.width * self.height {
self.data = data;
self.revision += 1;
}
}
/// `(min, max)` de los valores. `(0.0, 0.0)` si la matriz está vacía.
pub fn min_max(&self) -> (f32, f32) {
let mut it = self.data.iter().copied();
let Some(first) = it.next() else { return (0.0, 0.0) };
it.fold((first, first), |(lo, hi), v| (lo.min(v), hi.max(v)))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn from_data_checks_length() {
assert!(HeatmapMatrix::from_data(vec![1.0; 6], 2, 3).is_some());
assert!(HeatmapMatrix::from_data(vec![1.0; 5], 2, 3).is_none());
}
#[test]
fn get_set_and_revision() {
let mut m = HeatmapMatrix::new(3, 2);
assert_eq!(m.revision(), 0);
m.set(1, 1, 4.5);
assert_eq!(m.get(1, 1), 4.5);
assert_eq!(m.revision(), 1);
m.set(99, 99, 1.0); // fuera de rango → no-op
assert_eq!(m.revision(), 1);
}
#[test]
fn min_max_over_values() {
let m = HeatmapMatrix::from_data(vec![3.0, -1.0, 7.0, 2.0], 2, 2).unwrap();
assert_eq!(m.min_max(), (-1.0, 7.0));
}
}
@@ -0,0 +1,61 @@
//! Painter agnóstico: dibuja una `HeatmapMatrix` contra un `Canvas`.
//!
//! Emite un `fill_rect` por celda. Apto para matrices chicas y para
//! export SVG. Para matrices grandes el backend GPUI usa
//! [`crate::encoder`] + textura en vez de este camino.
use crate::matrix::HeatmapMatrix;
use crate::palette::Ramp;
use pineal_render::{Canvas, Rect};
/// Dibuja `matrix` dentro de `area`, una celda = un `fill_rect`.
/// Los valores se normalizan por min/max de la matriz.
pub fn paint(matrix: &HeatmapMatrix, ramp: Ramp, area: Rect, canvas: &mut dyn Canvas) {
let (w, h) = (matrix.width(), matrix.height());
if w == 0 || h == 0 {
return;
}
let (min, max) = matrix.min_max();
let span = max - min;
let cell_w = area.w / w as f32;
let cell_h = area.h / h as f32;
for y in 0..h {
for x in 0..w {
let v = matrix.get(x, y);
let t = if span > 0.0 { (v - min) / span } else { 0.0 };
let color = ramp.sample(t);
let rect = Rect::new(
area.x + x as f32 * cell_w,
area.y + y as f32 * cell_h,
cell_w,
cell_h,
);
canvas.fill_rect(rect, color);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use pineal_render::{PlanRecorder, RenderCmd};
#[test]
fn emits_one_fill_rect_per_cell() {
let m = HeatmapMatrix::from_data(vec![0.0, 1.0, 2.0, 3.0, 4.0, 5.0], 3, 2).unwrap();
let mut rec = PlanRecorder::new();
paint(&m, Ramp::Viridis, Rect::new(0.0, 0.0, 300.0, 200.0), &mut rec);
let plan = rec.into_plan();
assert_eq!(plan.cmds.len(), 6);
assert!(plan.cmds.iter().all(|c| matches!(c, RenderCmd::FillRect { .. })));
}
#[test]
fn empty_matrix_emits_nothing() {
let m = HeatmapMatrix::new(0, 0);
let mut rec = PlanRecorder::new();
paint(&m, Ramp::Viridis, Rect::new(0.0, 0.0, 10.0, 10.0), &mut rec);
assert!(rec.into_plan().cmds.is_empty());
}
}
@@ -0,0 +1,94 @@
//! Color ramps para heatmaps. Interpolación lineal entre control points.
use pineal_render::Color;
/// Rampa de color. `sample(t)` mapea `t ∈ [0,1]` a un `Color`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Ramp {
/// Viridis — perceptualmente uniforme, dark-purple → yellow.
Viridis,
/// Escala de grises lineal, negro → blanco.
Grayscale,
}
impl Ramp {
/// Mapea `t` (se clampa a `[0,1]`) a un color de la rampa.
pub fn sample(&self, t: f32) -> Color {
let t = t.clamp(0.0, 1.0);
match self {
Ramp::Grayscale => Color::rgb(t, t, t),
Ramp::Viridis => lerp_stops(t, VIRIDIS),
}
}
}
/// Control points de Viridis (aproximación de 5 stops del colormap real).
const VIRIDIS: &[(f32, u32)] = &[
(0.00, 0x440154),
(0.25, 0x3b528b),
(0.50, 0x21918c),
(0.75, 0x5ec962),
(1.00, 0xfde725),
];
/// Interpola linealmente entre los stops `(pos, hex)` ordenados por `pos`.
fn lerp_stops(t: f32, stops: &[(f32, u32)]) -> Color {
if stops.is_empty() {
return Color::BLACK;
}
if t <= stops[0].0 {
return Color::from_hex(stops[0].1);
}
let last = stops[stops.len() - 1];
if t >= last.0 {
return Color::from_hex(last.1);
}
for w in stops.windows(2) {
let (p0, c0) = w[0];
let (p1, c1) = w[1];
if t >= p0 && t <= p1 {
let local = (t - p0) / (p1 - p0);
return lerp_color(Color::from_hex(c0), Color::from_hex(c1), local);
}
}
Color::from_hex(last.1)
}
fn lerp_color(a: Color, b: Color, t: f32) -> Color {
Color::rgba(
a.r + (b.r - a.r) * t,
a.g + (b.g - a.g) * t,
a.b + (b.b - a.b) * t,
a.a + (b.a - a.a) * t,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn grayscale_endpoints() {
assert_eq!(Ramp::Grayscale.sample(0.0), Color::BLACK);
assert_eq!(Ramp::Grayscale.sample(1.0), Color::WHITE);
}
#[test]
fn viridis_endpoints_match_control_points() {
assert_eq!(Ramp::Viridis.sample(0.0), Color::from_hex(0x440154));
assert_eq!(Ramp::Viridis.sample(1.0), Color::from_hex(0xfde725));
}
#[test]
fn sample_clamps_out_of_range() {
assert_eq!(Ramp::Viridis.sample(-5.0), Ramp::Viridis.sample(0.0));
assert_eq!(Ramp::Viridis.sample(5.0), Ramp::Viridis.sample(1.0));
}
#[test]
fn viridis_midpoint_is_between() {
let mid = Ramp::Viridis.sample(0.5);
// El stop de 0.5 es 0x21918c.
assert_eq!(mid, Color::from_hex(0x21918c));
}
}
@@ -0,0 +1,12 @@
[package]
name = "pineal-hexbin"
version = { workspace = true }
edition = { workspace = true }
license = { workspace = true }
authors = { workspace = true }
publish = { workspace = true }
description = "Lapaloma — hexbin: bineado hexagonal pointy-top de un dataset 2D + painter."
[dependencies]
pineal-render = { path = "../pineal-render" }
pineal-heatmap = { path = "../pineal-heatmap" }
+249
View File
@@ -0,0 +1,249 @@
//! `pineal-hexbin` — bineado hexagonal de scatter densos.
//!
//! Cuando un scatter tiene tantos puntos que la nube tapa el patrón,
//! binear sobre una rejilla hexagonal pointy-top y colorear por
//! densidad es la forma estándar de revelar la distribución. La
//! topología hexagonal evita los "estrías" rectangulares de un binning
//! cuadrado y respeta mejor la isotropía de la nube.
//!
//! - [`HexGrid`] — calcula el bin de cada `(x, y)` con la rejilla
//! pointy-top (hex con dos vértices arriba/abajo). Cell size = radio
//! del circumcírculo.
//! - [`paint_hexbin`] — pinta los bins no vacíos como hexágonos
//! rellenos coloreados por densidad vía `pineal_heatmap::Ramp`.
#![forbid(unsafe_code)]
use pineal_heatmap::Ramp;
use pineal_render::{Canvas, Color, Rect};
use std::collections::HashMap;
/// Rejilla hexagonal pointy-top con bines indexados por `(col, row)`
/// según el offset-coord clásico ("odd-r" en la nomenclatura de Red
/// Blob Games). El bineo es determinista y O(n) sobre los puntos.
#[derive(Debug, Clone)]
pub struct HexGrid {
radius: f32,
counts: HashMap<(i32, i32), u32>,
}
impl HexGrid {
/// Construye una rejilla vacía con celda de `radius` pixels.
pub fn new(radius: f32) -> Self {
Self { radius: radius.max(1e-3), counts: HashMap::new() }
}
/// Agrega un punto. Incrementa el bin correspondiente.
pub fn push(&mut self, x: f32, y: f32) {
let cell = pixel_to_oddr(x, y, self.radius);
*self.counts.entry(cell).or_insert(0) += 1;
}
/// Construye y popula a partir de un slice interleaved `[x0,y0,x1,y1…]`.
pub fn from_xy(radius: f32, xy: &[f32]) -> Self {
let mut g = Self::new(radius);
for chunk in xy.chunks_exact(2) {
g.push(chunk[0], chunk[1]);
}
g
}
pub fn radius(&self) -> f32 {
self.radius
}
pub fn cells(&self) -> impl Iterator<Item = ((i32, i32), u32)> + '_ {
self.counts.iter().map(|(k, v)| (*k, *v))
}
pub fn is_empty(&self) -> bool {
self.counts.is_empty()
}
/// `(min, max)` de cuentas. `(0, 0)` si está vacía.
pub fn min_max(&self) -> (u32, u32) {
let mut it = self.counts.values().copied();
let Some(first) = it.next() else { return (0, 0) };
it.fold((first, first), |(lo, hi), v| (lo.min(v), hi.max(v)))
}
}
/// Convierte pixel `(x, y)` a coords offset `(col, row)` odd-r.
/// Algoritmo: pasar a axial (q, r) por pixel→axial pointy-top, luego
/// redondear vía cube-coordinate-rounding, luego axial→oddr.
fn pixel_to_oddr(x: f32, y: f32, radius: f32) -> (i32, i32) {
let sqrt3 = 3.0_f32.sqrt();
let q = (sqrt3 / 3.0 * x - y / 3.0) / radius;
let r = (2.0 / 3.0 * y) / radius;
let (qr, rr) = cube_round(q, r);
let col = qr + (rr - (rr & 1)) / 2;
(col, rr)
}
fn cube_round(q: f32, r: f32) -> (i32, i32) {
let x = q;
let z = r;
let y = -x - z;
let mut rx = x.round();
let ry = y.round();
let mut rz = z.round();
let dx = (rx - x).abs();
let dy = (ry - y).abs();
let dz = (rz - z).abs();
if dx > dy && dx > dz {
rx = -ry - rz;
} else if dz > dy {
rz = -rx - ry;
}
// En el caso restante (dy es el peor), el output sólo usa rx y rz —
// no hace falta ajustar ry. La invariante rx+ry+rz=0 no nos importa
// porque ry no se devuelve.
(rx as i32, rz as i32)
}
/// Convierte offset `(col, row)` odd-r a centro pixel.
pub fn oddr_to_pixel(col: i32, row: i32, radius: f32) -> (f32, f32) {
let sqrt3 = 3.0_f32.sqrt();
let x = radius * sqrt3 * (col as f32 + 0.5 * (row & 1) as f32);
let y = radius * 3.0 / 2.0 * row as f32;
(x, y)
}
/// Dibuja todos los bines de `grid` sobre `canvas`, mapeando densidad a
/// color con `ramp`. `offset` desplaza la grilla (se suma al centro de
/// cada hex). El alto y ancho del rect destino los decide el caller —
/// los hexes se dibujan en sus coords absolutas, no se reescalan.
pub fn paint_hexbin(
grid: &HexGrid,
ramp: Ramp,
offset: (f32, f32),
canvas: &mut dyn Canvas,
) {
if grid.is_empty() {
return;
}
let (min, max) = grid.min_max();
let span = (max - min) as f32;
let r = grid.radius();
for ((col, row), count) in grid.cells() {
let (cx, cy) = oddr_to_pixel(col, row, r);
let cx = cx + offset.0;
let cy = cy + offset.1;
let t = if span > 0.0 {
(count - min) as f32 / span
} else {
0.0
};
let color = ramp.sample(t);
paint_hex(cx, cy, r, color, canvas);
}
}
/// Hexágono pointy-top centrado en `(cx, cy)` con circumcircle `r`.
/// Se tesselan los 6 triángulos del fan compartiendo el centro.
fn paint_hex(cx: f32, cy: f32, r: f32, color: Color, canvas: &mut dyn Canvas) {
use std::f32::consts::{FRAC_PI_3, FRAC_PI_2};
// Fan: [center, v0, center, v1, ..., center, v0].
let mut coords = Vec::with_capacity(14 * 2);
let mut colors = Vec::with_capacity(14);
for i in 0..=6 {
let a = -FRAC_PI_2 + i as f32 * FRAC_PI_3;
coords.push(cx);
coords.push(cy);
coords.push(cx + r * a.cos());
coords.push(cy + r * a.sin());
colors.push(color);
colors.push(color);
}
canvas.fill_triangle_strip(&coords, &colors);
}
/// `Rect` con todos los hexes — útil para que el caller dimensione el
/// viewport antes de pintar.
pub fn bounds(grid: &HexGrid) -> Option<Rect> {
if grid.is_empty() {
return None;
}
let r = grid.radius();
let mut min_x = f32::MAX;
let mut min_y = f32::MAX;
let mut max_x = f32::MIN;
let mut max_y = f32::MIN;
for ((col, row), _) in grid.cells() {
let (cx, cy) = oddr_to_pixel(col, row, r);
min_x = min_x.min(cx - r);
max_x = max_x.max(cx + r);
min_y = min_y.min(cy - r);
max_y = max_y.max(cy + r);
}
Some(Rect::new(min_x, min_y, max_x - min_x, max_y - min_y))
}
#[cfg(test)]
mod tests {
use super::*;
use pineal_render::{PlanRecorder, RenderCmd};
#[test]
fn empty_grid_is_noop() {
let g = HexGrid::new(10.0);
let mut rec = PlanRecorder::new();
paint_hexbin(&g, Ramp::Viridis, (0.0, 0.0), &mut rec);
assert!(rec.into_plan().cmds.is_empty());
}
#[test]
fn coincident_points_bin_together() {
let mut g = HexGrid::new(5.0);
g.push(10.0, 10.0);
g.push(10.0, 10.0);
g.push(10.0, 10.0);
assert_eq!(g.cells().count(), 1);
let (_, count) = g.cells().next().unwrap();
assert_eq!(count, 3);
}
#[test]
fn far_points_bin_separately() {
let mut g = HexGrid::new(5.0);
g.push(0.0, 0.0);
g.push(1000.0, 1000.0);
assert_eq!(g.cells().count(), 2);
}
#[test]
fn paint_emits_one_strip_per_cell() {
let mut g = HexGrid::new(8.0);
g.push(0.0, 0.0);
g.push(100.0, 100.0);
g.push(50.0, 50.0);
let mut rec = PlanRecorder::new();
paint_hexbin(&g, Ramp::Viridis, (0.0, 0.0), &mut rec);
let strips = rec
.into_plan()
.cmds
.iter()
.filter(|c| matches!(c, RenderCmd::FillTriangleStrip { .. }))
.count();
assert_eq!(strips, 3);
}
#[test]
fn from_xy_populates_correctly() {
let xy = [0.0, 0.0, 100.0, 100.0, 100.0, 100.0];
let g = HexGrid::from_xy(5.0, &xy);
assert_eq!(g.cells().count(), 2);
let (_, max) = g.min_max();
assert_eq!(max, 2);
}
#[test]
fn bounds_covers_extremes() {
let mut g = HexGrid::new(5.0);
g.push(0.0, 0.0);
g.push(100.0, 50.0);
let b = bounds(&g).unwrap();
assert!(b.w > 0.0 && b.h > 0.0);
}
}
+12
View File
@@ -0,0 +1,12 @@
[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 = "../pineal-core" }
pineal-render = { path = "../pineal-render" }
+18
View File
@@ -0,0 +1,18 @@
# pineal-mesh
> Canvas de triángulos arbitrarios para [pineal](../README.md).
Backend para geometría libre: tomá una malla `(vertices, indices, colors)` y se dibuja con shader-like flexibility. Útil para terrenos, geometría 2D compleja, ilustraciones generativas.
## API
```rust
use pineal_mesh::{Mesh, Vertex};
let mesh = Mesh::new(vertices, indices)
.vertex_colors(colors);
```
## Deps
- [`pineal-core`](../pineal-core/README.md)
+18
View File
@@ -0,0 +1,18 @@
# pineal-mesh
> Arbitrary-triangle canvas for [pineal](../README.md).
Backend for free geometry: pass a mesh `(vertices, indices, colors)` and draw it with shader-like flexibility. Useful for terrains, complex 2D geometry, generative illustration.
## API
```rust
use pineal_mesh::{Mesh, Vertex};
let mesh = Mesh::new(vertices, indices)
.vertex_colors(colors);
```
## Deps
- [`pineal-core`](../pineal-core/README.md)
@@ -0,0 +1,294 @@
//! Quadtree Barnes-Hut para aproximar la fuerza repulsiva del
//! force-directed en O(n log n) en lugar de O(n²).
//!
//! La idea: si un nodo lejano y un grupo de nodos cumplen
//! `half_size / dist < theta`, el grupo se aproxima por su centro de masa.
//! Theta típico: 0.5 — más bajo = más preciso, más lento.
//!
//! Representación: `Vec<QuadNode>` indexado. El doc original de
//! `pineal-core::barnes_hut` propone stride 7 plano; acá optamos por la
//! versión idiomática — clara y suficiente para los rangos en que el
//! force-directed corre interactivo (hasta ~50 K nodos).
/// Un nodo del árbol — puede ser hoja con una partícula, hoja vacía, o
/// nodo interno con hasta 4 hijos.
#[derive(Debug, Clone, Copy)]
struct QuadNode {
/// Centro del cuadrante (no es el center of mass; eso vive en `cm`).
cx: f32,
cy: f32,
/// Medio-lado del cuadrante.
half: f32,
/// Center of mass acumulado.
cm_x: f32,
cm_y: f32,
/// Masa total (suma de pesos — acá usamos 1.0 por nodo).
mass: f32,
/// Hoja con exactamente 1 cuerpo: índice del cuerpo en `positions`.
body: Option<usize>,
/// Índices a los 4 hijos (NW, NE, SW, SE) en `Quadtree::nodes`.
children: [Option<usize>; 4],
}
impl QuadNode {
fn new(cx: f32, cy: f32, half: f32) -> Self {
Self {
cx,
cy,
half,
cm_x: 0.0,
cm_y: 0.0,
mass: 0.0,
body: None,
children: [None; 4],
}
}
fn is_internal(&self) -> bool {
self.children.iter().any(Option::is_some)
}
/// Cuadrante (0..=3) al que cae `(x, y)` respecto al centro.
/// 0 = NW (x<cx, y<cy), 1 = NE (x>=cx, y<cy), 2 = SW, 3 = SE.
fn quadrant_of(&self, x: f32, y: f32) -> usize {
let east = x >= self.cx;
let south = y >= self.cy;
match (east, south) {
(false, false) => 0,
(true, false) => 1,
(false, true) => 2,
(true, true) => 3,
}
}
/// Centro del cuadrante hijo `q`.
fn child_center(&self, q: usize) -> (f32, f32) {
let h = self.half * 0.5;
match q {
0 => (self.cx - h, self.cy - h),
1 => (self.cx + h, self.cy - h),
2 => (self.cx - h, self.cy + h),
3 => (self.cx + h, self.cy + h),
_ => unreachable!(),
}
}
}
/// Quadtree construido sobre un set fijo de posiciones.
pub struct Quadtree {
nodes: Vec<QuadNode>,
root: Option<usize>,
}
impl Quadtree {
/// Construye el árbol cubriendo todas las posiciones con una raíz
/// cuadrada ajustada al bounding box (con margen de seguridad).
pub fn build(positions: &[(f32, f32)]) -> Self {
if positions.is_empty() {
return Self { nodes: Vec::new(), root: None };
}
let (mut min_x, mut min_y) = positions[0];
let (mut max_x, mut max_y) = positions[0];
for &(x, y) in &positions[1..] {
if x < min_x {
min_x = x;
}
if x > max_x {
max_x = x;
}
if y < min_y {
min_y = y;
}
if y > max_y {
max_y = y;
}
}
let cx = (min_x + max_x) * 0.5;
let cy = (min_y + max_y) * 0.5;
// `half` cubre la dimensión más grande con margen — evita que un
// body recién en el borde se pierda por error de redondeo.
let half = ((max_x - min_x).max(max_y - min_y) * 0.5).max(1e-3) + 1.0;
let mut tree = Self { nodes: Vec::with_capacity(positions.len() * 4), root: None };
tree.nodes.push(QuadNode::new(cx, cy, half));
tree.root = Some(0);
for (i, &p) in positions.iter().enumerate() {
tree.insert(0, i, p, 0);
}
tree
}
/// Inserta el body `body_idx` en el subárbol enraizado en `node_idx`.
/// `depth` es un guardabarros: tras un umbral abandonamos la
/// subdivisión (bodies degenerados muy próximos sólo se ven en
/// patológicos — el integrador con jitter ya los separa).
fn insert(&mut self, node_idx: usize, body_idx: usize, p: (f32, f32), depth: u32) {
const MAX_DEPTH: u32 = 32;
let (mass, cm) = (self.nodes[node_idx].mass, (self.nodes[node_idx].cm_x, self.nodes[node_idx].cm_y));
// Actualiza COM acumulado: weighted mean.
let new_mass = mass + 1.0;
let cm_x = (cm.0 * mass + p.0) / new_mass;
let cm_y = (cm.1 * mass + p.1) / new_mass;
self.nodes[node_idx].mass = new_mass;
self.nodes[node_idx].cm_x = cm_x;
self.nodes[node_idx].cm_y = cm_y;
// Hoja vacía: ocupar.
if self.nodes[node_idx].body.is_none() && !self.nodes[node_idx].is_internal() {
self.nodes[node_idx].body = Some(body_idx);
return;
}
// Hoja con body previo: hay que subdividir.
if let Some(prev_body) = self.nodes[node_idx].body.take() {
// Reubicar el body previo (necesitamos su posición — la
// recuperamos vía las componentes que metimos en cm).
// Para evitar recomputar todo, asumimos que el caller no
// muta `positions` entre inserts y nos basta la posición
// que reconstruimos del cm previo (mass == 1 antes del
// update significa que cm == posición del previo body).
let prev_pos = if mass == 1.0 { cm } else { (cm.0, cm.1) };
if depth >= MAX_DEPTH {
// Bodies coincidentes en profundidad máxima: dejamos el
// body re-asignado al mismo nodo. El cm sigue siendo
// válido, sólo perdemos la separación. Ralo en practice.
self.nodes[node_idx].body = Some(prev_body);
return;
}
self.descend_and_insert(node_idx, prev_body, prev_pos, depth);
}
// Insertar el nuevo body en el hijo apropiado.
self.descend_and_insert(node_idx, body_idx, p, depth);
}
fn descend_and_insert(&mut self, node_idx: usize, body_idx: usize, p: (f32, f32), depth: u32) {
let q = self.nodes[node_idx].quadrant_of(p.0, p.1);
let child_idx = match self.nodes[node_idx].children[q] {
Some(ix) => ix,
None => {
let (ccx, ccy) = self.nodes[node_idx].child_center(q);
let ch = QuadNode::new(ccx, ccy, self.nodes[node_idx].half * 0.5);
let new_idx = self.nodes.len();
self.nodes.push(ch);
self.nodes[node_idx].children[q] = Some(new_idx);
new_idx
}
};
self.insert(child_idx, body_idx, p, depth + 1);
}
/// Fuerza repulsiva `f_r = k² / d` sobre la partícula en `target_pos`
/// (con índice `target_idx`, para que no se atraiga a sí misma).
/// Aproxima clusters lejanos usando `theta`.
pub fn force_on(&self, target_pos: (f32, f32), target_idx: usize, k: f32, theta: f32) -> (f32, f32) {
let Some(root) = self.root else { return (0.0, 0.0) };
let mut acc = (0.0f32, 0.0f32);
self.accumulate(root, target_pos, target_idx, k, theta, &mut acc);
acc
}
fn accumulate(
&self,
node_idx: usize,
p: (f32, f32),
target_idx: usize,
k: f32,
theta: f32,
acc: &mut (f32, f32),
) {
let n = &self.nodes[node_idx];
if n.mass <= 0.0 {
return;
}
// Si es hoja con el mismo body, skip.
if let Some(body) = n.body {
if body == target_idx {
return;
}
}
let dx = p.0 - n.cm_x;
let dy = p.1 - n.cm_y;
let dist2 = dx * dx + dy * dy;
let dist = dist2.sqrt().max(1e-3);
let s = n.half * 2.0; // lado completo del cuadrante
// Criterio MAC: hoja, o lejano enough → aproximamos.
if n.body.is_some() || (s / dist) < theta {
let f = k * k * n.mass / dist;
acc.0 += (dx / dist) * f;
acc.1 += (dy / dist) * f;
return;
}
for c in n.children.iter().flatten() {
self.accumulate(*c, p, target_idx, k, theta, acc);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_tree_has_no_force() {
let qt = Quadtree::build(&[]);
assert_eq!(qt.force_on((0.0, 0.0), 0, 10.0, 0.5), (0.0, 0.0));
}
#[test]
fn single_body_self_skipped() {
let qt = Quadtree::build(&[(0.0, 0.0)]);
assert_eq!(qt.force_on((0.0, 0.0), 0, 10.0, 0.5), (0.0, 0.0));
}
#[test]
fn two_bodies_repel_along_axis() {
let qt = Quadtree::build(&[(0.0, 0.0), (100.0, 0.0)]);
let f = qt.force_on((0.0, 0.0), 0, 10.0, 0.5);
// Body en (100,0): empuja a body 0 hacia -x.
assert!(f.0 < 0.0, "esperaba fuerza hacia -x, got {f:?}");
assert!(f.1.abs() < 1e-3);
}
#[test]
fn bh_approximates_naive_for_distant_clusters() {
// Cluster lejano de 50 nodos vs un body cercano. BH con theta
// generoso debería dar fuerza similar a la suma naïve.
let mut positions = Vec::with_capacity(51);
positions.push((0.0, 0.0)); // target
for i in 0..50 {
let a = (i as f32 / 50.0) * std::f32::consts::TAU;
positions.push((500.0 + 5.0 * a.cos(), 500.0 + 5.0 * a.sin()));
}
let qt = Quadtree::build(&positions);
let f_bh = qt.force_on(positions[0], 0, 10.0, 0.7);
// Naïve para comparar.
let k = 10.0;
let mut f_n = (0.0f32, 0.0);
for (i, &p) in positions.iter().enumerate() {
if i == 0 {
continue;
}
let dx = positions[0].0 - p.0;
let dy = positions[0].1 - p.1;
let dist = (dx * dx + dy * dy).sqrt().max(1e-3);
let f = k * k / dist;
f_n.0 += (dx / dist) * f;
f_n.1 += (dy / dist) * f;
}
// Comparación: misma dirección y magnitud dentro del 30%.
assert!(f_bh.0 * f_n.0 > 0.0, "signo discrepa: bh={f_bh:?} naive={f_n:?}");
let mag_bh = (f_bh.0 * f_bh.0 + f_bh.1 * f_bh.1).sqrt();
let mag_n = (f_n.0 * f_n.0 + f_n.1 * f_n.1).sqrt();
assert!(
(mag_bh - mag_n).abs() / mag_n < 0.30,
"magnitud lejos: bh={mag_bh}, naive={mag_n}"
);
}
#[test]
fn coincident_bodies_do_not_explode() {
let qt = Quadtree::build(&[(10.0, 10.0), (10.0, 10.0), (10.0, 10.0)]);
let f = qt.force_on((10.0, 10.0), 0, 10.0, 0.5);
assert!(f.0.is_finite() && f.1.is_finite());
}
}

Some files were not shown because too many files have changed in this diff Show More