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:
@@ -0,0 +1,3 @@
|
|||||||
|
/target
|
||||||
|
**/*.rs.bk
|
||||||
|
*.pdb
|
||||||
@@ -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.1–10 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).
|
||||||
@@ -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.1–10 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 4–13 per painter via `PlanRecorder`.
|
||||||
@@ -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).
|
||||||
@@ -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.1–10 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 4–13 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>();
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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};
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
@@ -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
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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`.
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
//! LTTB (Largest-Triangle-Three-Buckets) — downsampling preservador
|
||||||
|
//! de silueta para series cartesianas.
|
||||||
|
//!
|
||||||
|
//! Algoritmo: dividir `n` puntos en `k-2` buckets (los extremos se
|
||||||
|
//! mantienen siempre). Por cada bucket, elegir el punto que forma
|
||||||
|
//! el triángulo de área máxima con el último punto elegido y el
|
||||||
|
//! centroide del bucket siguiente. Costo total O(n). Output ≤ k.
|
||||||
|
//!
|
||||||
|
//! Knob práctico: `target ≈ width_px × 3`. Tres vértices por pixel,
|
||||||
|
//! el anti-aliasing rellena el resto.
|
||||||
|
|
||||||
|
/// Reduce `coords` (interleaved `[x,y,x,y,…]`) a a lo sumo `target`
|
||||||
|
/// puntos, escribiendo los **índices originales** seleccionados en
|
||||||
|
/// `out` (sin clearearlo: el caller decide).
|
||||||
|
///
|
||||||
|
/// Si `n <= target` o `target < 3`, devuelve todos los índices
|
||||||
|
/// `[0..n)`.
|
||||||
|
pub fn lttb_indices(coords: &[f32], target: usize, out: &mut Vec<usize>) {
|
||||||
|
let n = coords.len() / 2;
|
||||||
|
if n == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if n <= target || target < 3 {
|
||||||
|
out.extend(0..n);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lttb_in_range_indices(coords, 0, n, target, out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Variante que opera sobre el rango `[start, end)` de un buffer
|
||||||
|
/// más grande. Los índices devueltos son **absolutos** (relativos
|
||||||
|
/// al `coords` original), no al sub-rango — esto le ahorra al caller
|
||||||
|
/// la corrección de offset después de un `SpatialIndex::range`.
|
||||||
|
pub fn lttb_in_range_indices(
|
||||||
|
coords: &[f32],
|
||||||
|
start: usize,
|
||||||
|
end: usize,
|
||||||
|
target: usize,
|
||||||
|
out: &mut Vec<usize>,
|
||||||
|
) {
|
||||||
|
debug_assert!(coords.len() % 2 == 0);
|
||||||
|
debug_assert!(start <= end && end <= coords.len() / 2);
|
||||||
|
|
||||||
|
let len = end - start;
|
||||||
|
if len == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if len <= target || target < 3 {
|
||||||
|
out.extend(start..end);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Primero el extremo izquierdo.
|
||||||
|
out.push(start);
|
||||||
|
|
||||||
|
let bucket_size = (len - 2) as f64 / (target - 2) as f64;
|
||||||
|
let mut a = start; // último punto elegido
|
||||||
|
|
||||||
|
for i in 0..target - 2 {
|
||||||
|
// Bucket actual y siguiente, en índices absolutos.
|
||||||
|
let cur_lo = start + 1 + (i as f64 * bucket_size).floor() as usize;
|
||||||
|
let cur_hi = start + 1 + ((i + 1) as f64 * bucket_size).floor() as usize;
|
||||||
|
let next_lo = cur_hi.min(end);
|
||||||
|
let next_hi = (start + 1 + ((i + 2) as f64 * bucket_size).floor() as usize).min(end);
|
||||||
|
|
||||||
|
// Centroide del bucket siguiente. Si está vacío, fallback
|
||||||
|
// al último punto.
|
||||||
|
let (avg_x, avg_y) = if next_hi > next_lo {
|
||||||
|
let span = (next_hi - next_lo) as f32;
|
||||||
|
let mut sx = 0.0f32;
|
||||||
|
let mut sy = 0.0f32;
|
||||||
|
for j in next_lo..next_hi {
|
||||||
|
sx += coords[j * 2];
|
||||||
|
sy += coords[j * 2 + 1];
|
||||||
|
}
|
||||||
|
(sx / span, sy / span)
|
||||||
|
} else {
|
||||||
|
(coords[(end - 1) * 2], coords[(end - 1) * 2 + 1])
|
||||||
|
};
|
||||||
|
|
||||||
|
let ax = coords[a * 2];
|
||||||
|
let ay = coords[a * 2 + 1];
|
||||||
|
|
||||||
|
let mut max_area = -1.0f32;
|
||||||
|
let mut max_idx = cur_lo;
|
||||||
|
for j in cur_lo..cur_hi.min(end) {
|
||||||
|
let bx = coords[j * 2];
|
||||||
|
let by = coords[j * 2 + 1];
|
||||||
|
// Área del triángulo (sin /2 porque comparamos relativos).
|
||||||
|
let area = ((ax - avg_x) * (by - ay) - (ax - bx) * (avg_y - ay)).abs();
|
||||||
|
if area > max_area {
|
||||||
|
max_area = area;
|
||||||
|
max_idx = j;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push(max_idx);
|
||||||
|
a = max_idx;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extremo derecho.
|
||||||
|
out.push(end - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Variante que materializa coords decimadas directamente — útil
|
||||||
|
/// cuando el painter sólo quiere un slice listo para `drawRawPoints`
|
||||||
|
/// y no necesita los índices.
|
||||||
|
pub fn lttb_coords(coords: &[f32], target: usize, out: &mut Vec<f32>) {
|
||||||
|
let mut idx_buf: Vec<usize> = Vec::with_capacity(target);
|
||||||
|
lttb_indices(coords, target, &mut idx_buf);
|
||||||
|
for i in idx_buf {
|
||||||
|
out.push(coords[i * 2]);
|
||||||
|
out.push(coords[i * 2 + 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_decimate_si_n_menor_que_target() {
|
||||||
|
let coords: Vec<f32> = (0..5).flat_map(|i| [i as f32, (i * i) as f32]).collect();
|
||||||
|
let mut out = Vec::new();
|
||||||
|
lttb_indices(&coords, 10, &mut out);
|
||||||
|
assert_eq!(out, vec![0, 1, 2, 3, 4]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extremos_preservados() {
|
||||||
|
let n = 100;
|
||||||
|
let coords: Vec<f32> = (0..n).flat_map(|i| [i as f32, (i as f32).sin()]).collect();
|
||||||
|
let mut out = Vec::new();
|
||||||
|
lttb_indices(&coords, 10, &mut out);
|
||||||
|
assert_eq!(out.first(), Some(&0));
|
||||||
|
assert_eq!(out.last(), Some(&(n - 1)));
|
||||||
|
assert!(out.len() <= 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn indices_sorted_y_unicos() {
|
||||||
|
let coords: Vec<f32> = (0..1000)
|
||||||
|
.flat_map(|i| [i as f32, (i as f32 * 0.01).sin()])
|
||||||
|
.collect();
|
||||||
|
let mut out = Vec::new();
|
||||||
|
lttb_indices(&coords, 50, &mut out);
|
||||||
|
for w in out.windows(2) {
|
||||||
|
assert!(w[0] < w[1], "indices deben ser estrictamente crecientes");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn in_range_indices_son_absolutos() {
|
||||||
|
let n = 100;
|
||||||
|
let coords: Vec<f32> = (0..n).flat_map(|i| [i as f32, i as f32]).collect();
|
||||||
|
let mut out = Vec::new();
|
||||||
|
lttb_in_range_indices(&coords, 20, 80, 10, &mut out);
|
||||||
|
assert_eq!(out.first(), Some(&20));
|
||||||
|
assert_eq!(out.last(), Some(&79));
|
||||||
|
// ningún índice fuera del rango pedido
|
||||||
|
for &i in &out {
|
||||||
|
assert!(i >= 20 && i < 80);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preserva_picos_extremos() {
|
||||||
|
// Señal plana con un pico al medio: LTTB debe agarrar el pico.
|
||||||
|
let mut coords: Vec<f32> = Vec::new();
|
||||||
|
for i in 0..200 {
|
||||||
|
coords.push(i as f32);
|
||||||
|
coords.push(if i == 100 { 10.0 } else { 0.0 });
|
||||||
|
}
|
||||||
|
let mut out = Vec::new();
|
||||||
|
lttb_indices(&coords, 20, &mut out);
|
||||||
|
assert!(out.contains(&100), "pico debe sobrevivir el downsample");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
//! `RingBuffer` — buffer circular de samples para streaming tipo
|
||||||
|
//! osciloscopio.
|
||||||
|
//!
|
||||||
|
//! Capacidad fija. `push(v)` hace dos writes (uno a `values`, uno
|
||||||
|
//! a `coords[head*2+1]`) y un increment de head + revision. El
|
||||||
|
//! buffer **nunca se reasigna**; el painter consume slices del
|
||||||
|
//! mismo backing memory frame tras frame.
|
||||||
|
//!
|
||||||
|
//! Convención: `x_norm` se pre-computa una vez en construcción
|
||||||
|
//! (modo sweep). El painter aplica el escalado a píxeles via su
|
||||||
|
//! propio transform — el buffer no rota X entre frames.
|
||||||
|
//!
|
||||||
|
//! ## Trampa del pre-fill (1.0.2 fix del Flutter)
|
||||||
|
//!
|
||||||
|
//! Antes que `count >= capacity`, los slots `[head, capacity)`
|
||||||
|
//! contienen ceros iniciales. Si el painter dibuja toda la
|
||||||
|
//! ringa, aparece una línea plana sobre la mitad derecha. La
|
||||||
|
//! API expone [`RingBuffer::filled_len`] que devuelve `head` en
|
||||||
|
//! ese caso, y `capacity` después — el painter clipea a eso.
|
||||||
|
|
||||||
|
/// Ring buffer en modo sweep (x_norm de cada slot es fijo).
|
||||||
|
///
|
||||||
|
/// Para modo scroll el painter aplica un translate adicional por
|
||||||
|
/// frame; la estructura de datos es la misma.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct RingBuffer {
|
||||||
|
/// Sample raw por slot.
|
||||||
|
values: Vec<f32>,
|
||||||
|
/// `[x_norm, y_value]` por slot. `x_norm = slot / (cap - 1)`,
|
||||||
|
/// fijo. `y_value` = `values[slot]`.
|
||||||
|
coords: Vec<f32>,
|
||||||
|
capacity: usize,
|
||||||
|
/// Próximo slot a escribir.
|
||||||
|
head: usize,
|
||||||
|
/// Monotonic, sobrevive wraparound. Útil para anclar
|
||||||
|
/// anotaciones por sample index absoluto.
|
||||||
|
count: u64,
|
||||||
|
revision: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RingBuffer {
|
||||||
|
/// Asume `capacity >= 2` para que `x_norm` no divida por cero.
|
||||||
|
pub fn new(capacity: usize) -> Self {
|
||||||
|
assert!(capacity >= 2, "RingBuffer requiere capacity >= 2");
|
||||||
|
let mut coords = vec![0.0; capacity * 2];
|
||||||
|
let denom = (capacity - 1) as f32;
|
||||||
|
for slot in 0..capacity {
|
||||||
|
coords[slot * 2] = slot as f32 / denom;
|
||||||
|
}
|
||||||
|
Self {
|
||||||
|
values: vec![0.0; capacity],
|
||||||
|
coords,
|
||||||
|
capacity,
|
||||||
|
head: 0,
|
||||||
|
count: 0,
|
||||||
|
revision: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push(&mut self, v: f32) {
|
||||||
|
self.values[self.head] = v;
|
||||||
|
self.coords[self.head * 2 + 1] = v;
|
||||||
|
self.head = (self.head + 1) % self.capacity;
|
||||||
|
self.count = self.count.wrapping_add(1);
|
||||||
|
self.revision = self.revision.wrapping_add(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inserción en batch con dos memcpys (cola + wrap-around).
|
||||||
|
/// Para batches > capacity se queda con los últimos `capacity`
|
||||||
|
/// samples (los anteriores se sobreescribirían igual).
|
||||||
|
pub fn push_all(&mut self, batch: &[f32]) {
|
||||||
|
if batch.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cap = self.capacity;
|
||||||
|
let src = if batch.len() > cap {
|
||||||
|
&batch[batch.len() - cap..]
|
||||||
|
} else {
|
||||||
|
batch
|
||||||
|
};
|
||||||
|
|
||||||
|
let tail = cap - self.head;
|
||||||
|
if src.len() <= tail {
|
||||||
|
self.values[self.head..self.head + src.len()].copy_from_slice(src);
|
||||||
|
for (i, v) in src.iter().enumerate() {
|
||||||
|
self.coords[(self.head + i) * 2 + 1] = *v;
|
||||||
|
}
|
||||||
|
self.head = (self.head + src.len()) % cap;
|
||||||
|
} else {
|
||||||
|
let (a, b) = src.split_at(tail);
|
||||||
|
self.values[self.head..].copy_from_slice(a);
|
||||||
|
for (i, v) in a.iter().enumerate() {
|
||||||
|
self.coords[(self.head + i) * 2 + 1] = *v;
|
||||||
|
}
|
||||||
|
self.values[..b.len()].copy_from_slice(b);
|
||||||
|
for (i, v) in b.iter().enumerate() {
|
||||||
|
self.coords[i * 2 + 1] = *v;
|
||||||
|
}
|
||||||
|
self.head = b.len();
|
||||||
|
}
|
||||||
|
|
||||||
|
self.count = self.count.wrapping_add(src.len() as u64);
|
||||||
|
self.revision = self.revision.wrapping_add(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn capacity(&self) -> usize {
|
||||||
|
self.capacity
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn head(&self) -> usize {
|
||||||
|
self.head
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn count(&self) -> u64 {
|
||||||
|
self.count
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn revision(&self) -> u64 {
|
||||||
|
self.revision
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_full(&self) -> bool {
|
||||||
|
self.count >= self.capacity as u64
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cantidad de slots con datos reales. Antes del fill es
|
||||||
|
/// `head`; después es `capacity`. El painter clipea a este
|
||||||
|
/// valor para evitar el flicker del pre-fill.
|
||||||
|
pub fn filled_len(&self) -> usize {
|
||||||
|
if self.is_full() {
|
||||||
|
self.capacity
|
||||||
|
} else {
|
||||||
|
self.head
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Slice interleaved de `[x_norm, y]`. Para render en dos
|
||||||
|
/// segmentos: `&coords()[..head*2]` y `&coords()[head*2..]`
|
||||||
|
/// (cuando is_full).
|
||||||
|
pub fn coords(&self) -> &[f32] {
|
||||||
|
&self.coords
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Slice plana de samples raw — útil para downsample envelope
|
||||||
|
/// min/max sin pasar por coords.
|
||||||
|
pub fn values(&self) -> &[f32] {
|
||||||
|
&self.values
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn x_norm_precomputado() {
|
||||||
|
let r = RingBuffer::new(4);
|
||||||
|
// x_norm en slots 0, 1, 2, 3 = 0.0, 1/3, 2/3, 1.0
|
||||||
|
assert!((r.coords()[0] - 0.0).abs() < 1e-6);
|
||||||
|
assert!((r.coords()[2] - 1.0 / 3.0).abs() < 1e-6);
|
||||||
|
assert!((r.coords()[6] - 1.0).abs() < 1e-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn push_actualiza_y_no_x() {
|
||||||
|
let mut r = RingBuffer::new(4);
|
||||||
|
r.push(5.0);
|
||||||
|
r.push(7.0);
|
||||||
|
// slot 0 → y=5, slot 1 → y=7, x quedó igual
|
||||||
|
assert_eq!(r.coords()[1], 5.0);
|
||||||
|
assert_eq!(r.coords()[3], 7.0);
|
||||||
|
assert!((r.coords()[2] - 1.0 / 3.0).abs() < 1e-6);
|
||||||
|
assert_eq!(r.head(), 2);
|
||||||
|
assert_eq!(r.count(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filled_len_bloquea_prefill() {
|
||||||
|
let mut r = RingBuffer::new(4);
|
||||||
|
assert_eq!(r.filled_len(), 0);
|
||||||
|
r.push(1.0);
|
||||||
|
r.push(2.0);
|
||||||
|
assert_eq!(r.filled_len(), 2);
|
||||||
|
r.push(3.0);
|
||||||
|
r.push(4.0);
|
||||||
|
assert_eq!(r.filled_len(), 4);
|
||||||
|
r.push(5.0); // wrap
|
||||||
|
assert_eq!(r.filled_len(), 4);
|
||||||
|
assert!(r.is_full());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn push_all_wrap_around() {
|
||||||
|
let mut r = RingBuffer::new(4);
|
||||||
|
r.push_all(&[1.0, 2.0, 3.0]); // head=3
|
||||||
|
r.push_all(&[4.0, 5.0, 6.0]); // wrap: 4 en slot 3, 5 en slot 0, 6 en slot 1
|
||||||
|
assert_eq!(r.values()[3], 4.0);
|
||||||
|
assert_eq!(r.values()[0], 5.0);
|
||||||
|
assert_eq!(r.values()[1], 6.0);
|
||||||
|
assert_eq!(r.head(), 2);
|
||||||
|
assert_eq!(r.count(), 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn push_all_oversized_se_queda_con_la_cola() {
|
||||||
|
let mut r = RingBuffer::new(4);
|
||||||
|
r.push_all(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0]);
|
||||||
|
// Sólo los últimos 4 importan: [6,7,8,9]
|
||||||
|
assert_eq!(r.values()[0], 6.0);
|
||||||
|
assert_eq!(r.values()[1], 7.0);
|
||||||
|
assert_eq!(r.values()[2], 8.0);
|
||||||
|
assert_eq!(r.values()[3], 9.0);
|
||||||
|
assert!(r.is_full());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
//! Escalas value→pixel para series cartesianas.
|
||||||
|
//!
|
||||||
|
//! La proyección no se aplica sobre los datos (eso rompería el
|
||||||
|
//! P2 zero-alloc — habría que reescribir todo el buffer por frame).
|
||||||
|
//! Las escalas devuelven el `(scale_x, scale_y, translate_x,
|
||||||
|
//! translate_y)` que el painter mete en un transform GPU. Los
|
||||||
|
//! datos quedan intactos.
|
||||||
|
|
||||||
|
/// Trait común a Linear / Log / Time. Cada implementación traduce
|
||||||
|
/// un valor de dominio a posición normalizada `[0, 1]` (que luego
|
||||||
|
/// el painter mapea al pixel range del plot).
|
||||||
|
pub trait Scale {
|
||||||
|
fn to_norm(&self, value: f64) -> f64;
|
||||||
|
fn from_norm(&self, norm: f64) -> f64;
|
||||||
|
fn domain(&self) -> (f64, f64);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct LinearScale {
|
||||||
|
min: f64,
|
||||||
|
max: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LinearScale {
|
||||||
|
pub fn new(min: f64, max: f64) -> Self {
|
||||||
|
debug_assert!(max > min, "LinearScale: max debe ser > min");
|
||||||
|
Self { min, max }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Scale for LinearScale {
|
||||||
|
fn to_norm(&self, v: f64) -> f64 {
|
||||||
|
(v - self.min) / (self.max - self.min)
|
||||||
|
}
|
||||||
|
fn from_norm(&self, n: f64) -> f64 {
|
||||||
|
self.min + n * (self.max - self.min)
|
||||||
|
}
|
||||||
|
fn domain(&self) -> (f64, f64) {
|
||||||
|
(self.min, self.max)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Escala logarítmica base e. `min` y `max` deben ser positivos.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct LogScale {
|
||||||
|
log_min: f64,
|
||||||
|
log_max: f64,
|
||||||
|
min: f64,
|
||||||
|
max: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LogScale {
|
||||||
|
pub fn new(min: f64, max: f64) -> Self {
|
||||||
|
debug_assert!(min > 0.0 && max > min, "LogScale: 0 < min < max");
|
||||||
|
Self {
|
||||||
|
log_min: min.ln(),
|
||||||
|
log_max: max.ln(),
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Scale for LogScale {
|
||||||
|
fn to_norm(&self, v: f64) -> f64 {
|
||||||
|
(v.ln() - self.log_min) / (self.log_max - self.log_min)
|
||||||
|
}
|
||||||
|
fn from_norm(&self, n: f64) -> f64 {
|
||||||
|
(self.log_min + n * (self.log_max - self.log_min)).exp()
|
||||||
|
}
|
||||||
|
fn domain(&self) -> (f64, f64) {
|
||||||
|
(self.min, self.max)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Escala temporal sobre epoch ms. Internamente lineal.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct TimeScale {
|
||||||
|
inner: LinearScale,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TimeScale {
|
||||||
|
pub fn new(min_epoch_ms: f64, max_epoch_ms: f64) -> Self {
|
||||||
|
Self {
|
||||||
|
inner: LinearScale::new(min_epoch_ms, max_epoch_ms),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Scale for TimeScale {
|
||||||
|
fn to_norm(&self, v: f64) -> f64 {
|
||||||
|
self.inner.to_norm(v)
|
||||||
|
}
|
||||||
|
fn from_norm(&self, n: f64) -> f64 {
|
||||||
|
self.inner.from_norm(n)
|
||||||
|
}
|
||||||
|
fn domain(&self) -> (f64, f64) {
|
||||||
|
self.inner.domain()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wilkinson "nice numbers" — devuelve el step ideal en `{1, 2, 5} × 10^k`
|
||||||
|
/// para que un rango `[min, max]` tenga ~`target_ticks` divisiones.
|
||||||
|
pub fn nice_step(min: f64, max: f64, target_ticks: usize) -> f64 {
|
||||||
|
debug_assert!(max > min && target_ticks > 0);
|
||||||
|
let raw = (max - min) / target_ticks as f64;
|
||||||
|
let mag = 10f64.powf(raw.log10().floor());
|
||||||
|
let norm = raw / mag;
|
||||||
|
let nice = if norm < 1.5 {
|
||||||
|
1.0
|
||||||
|
} else if norm < 3.0 {
|
||||||
|
2.0
|
||||||
|
} else if norm < 7.0 {
|
||||||
|
5.0
|
||||||
|
} else {
|
||||||
|
10.0
|
||||||
|
};
|
||||||
|
nice * mag
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn linear_roundtrip() {
|
||||||
|
let s = LinearScale::new(10.0, 20.0);
|
||||||
|
assert!((s.to_norm(15.0) - 0.5).abs() < 1e-9);
|
||||||
|
assert!((s.from_norm(0.5) - 15.0).abs() < 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn log_roundtrip() {
|
||||||
|
let s = LogScale::new(1.0, 1000.0);
|
||||||
|
// 10 está a 1/3 del camino en log10. ln(10)/ln(1000) = 1/3.
|
||||||
|
assert!((s.to_norm(10.0) - 1.0 / 3.0).abs() < 1e-9);
|
||||||
|
assert!((s.from_norm(2.0 / 3.0) - 100.0).abs() < 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nice_step_es_potencia() {
|
||||||
|
// 100/5 = 20 — exact match para el branch nice=2.0 · mag=10.
|
||||||
|
assert!((nice_step(0.0, 100.0, 5) - 20.0).abs() < 1e-9);
|
||||||
|
// 1.0/10 = 0.1 — branch nice=1.0 · mag=0.1.
|
||||||
|
assert!((nice_step(0.0, 1.0, 10) - 0.1).abs() < 1e-9);
|
||||||
|
// 14/5 = 2.8 — branch nice=2.0 (1.5 ≤ norm < 3) · mag=1.
|
||||||
|
assert!((nice_step(0.0, 14.0, 5) - 2.0).abs() < 1e-9);
|
||||||
|
// 7/5 = 1.4 — cae bajo 1.5 → snap a 1.0 · mag=1 = 1.0.
|
||||||
|
assert!((nice_step(0.0, 7.0, 5) - 1.0).abs() < 1e-9);
|
||||||
|
// 50/5 = 10 — branch nice=10 · mag=1 = 10. (Equivalente a 1·10.)
|
||||||
|
assert!((nice_step(0.0, 50.0, 5) - 10.0).abs() < 1e-9);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
//! `SpatialIndex` — hit-testing sobre coords interleaved sorted-by-X.
|
||||||
|
//!
|
||||||
|
//! Cuando los puntos vienen ordenados por X (caso típico de series
|
||||||
|
//! temporales) un binary search basta y es O(log n) sin estructuras
|
||||||
|
//! auxiliares. Para nodos que se mueven cada frame (mesh graph)
|
||||||
|
//! corresponde un spatial hash uniforme — ese va en `pineal-mesh`,
|
||||||
|
//! no acá.
|
||||||
|
|
||||||
|
/// View sobre un buffer interleaved `[x0,y0,x1,y1,…]` sorted-asc por X.
|
||||||
|
///
|
||||||
|
/// El binary search asume invariante de ordenamiento. Si tu pipeline
|
||||||
|
/// puede generar coords desordenadas, sortealas antes de construir
|
||||||
|
/// el índice (no hay debug-assert porque sería O(n) en hot path).
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct SpatialIndex<'a> {
|
||||||
|
coords: &'a [f32],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> SpatialIndex<'a> {
|
||||||
|
pub fn new(coords: &'a [f32]) -> Self {
|
||||||
|
debug_assert!(coords.len() % 2 == 0);
|
||||||
|
Self { coords }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.coords.len() / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.coords.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Índice del punto cuya X está más cerca de `target_x`.
|
||||||
|
/// `None` si el buffer está vacío.
|
||||||
|
pub fn nearest(&self, target_x: f32) -> Option<usize> {
|
||||||
|
let n = self.len();
|
||||||
|
if n == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// Binary search sobre la columna X.
|
||||||
|
let mut lo = 0usize;
|
||||||
|
let mut hi = n;
|
||||||
|
while lo < hi {
|
||||||
|
let mid = (lo + hi) / 2;
|
||||||
|
if self.coords[mid * 2] < target_x {
|
||||||
|
lo = mid + 1;
|
||||||
|
} else {
|
||||||
|
hi = mid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// `lo` es la primera X >= target_x. El más cercano es lo o lo-1.
|
||||||
|
if lo == 0 {
|
||||||
|
Some(0)
|
||||||
|
} else if lo >= n {
|
||||||
|
Some(n - 1)
|
||||||
|
} else {
|
||||||
|
let prev = lo - 1;
|
||||||
|
let dx_prev = target_x - self.coords[prev * 2];
|
||||||
|
let dx_next = self.coords[lo * 2] - target_x;
|
||||||
|
if dx_prev <= dx_next {
|
||||||
|
Some(prev)
|
||||||
|
} else {
|
||||||
|
Some(lo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rango `[start, end)` de puntos con X en `[x_min, x_max]`.
|
||||||
|
/// Útil para clip-to-viewport antes de LTTB.
|
||||||
|
pub fn range(&self, x_min: f32, x_max: f32) -> (usize, usize) {
|
||||||
|
let n = self.len();
|
||||||
|
if n == 0 {
|
||||||
|
return (0, 0);
|
||||||
|
}
|
||||||
|
// lower bound: primer i con coords[i*2] >= x_min
|
||||||
|
let start = {
|
||||||
|
let mut lo = 0usize;
|
||||||
|
let mut hi = n;
|
||||||
|
while lo < hi {
|
||||||
|
let mid = (lo + hi) / 2;
|
||||||
|
if self.coords[mid * 2] < x_min {
|
||||||
|
lo = mid + 1;
|
||||||
|
} else {
|
||||||
|
hi = mid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lo
|
||||||
|
};
|
||||||
|
// upper bound: primer i con coords[i*2] > x_max
|
||||||
|
let end = {
|
||||||
|
let mut lo = start;
|
||||||
|
let mut hi = n;
|
||||||
|
while lo < hi {
|
||||||
|
let mid = (lo + hi) / 2;
|
||||||
|
if self.coords[mid * 2] <= x_max {
|
||||||
|
lo = mid + 1;
|
||||||
|
} else {
|
||||||
|
hi = mid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lo
|
||||||
|
};
|
||||||
|
(start, end)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn fixture() -> Vec<f32> {
|
||||||
|
// x: 0, 1, 3, 5, 8 — y irrelevante.
|
||||||
|
vec![0.0, 0.0, 1.0, 0.0, 3.0, 0.0, 5.0, 0.0, 8.0, 0.0]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nearest_dentro() {
|
||||||
|
let c = fixture();
|
||||||
|
let s = SpatialIndex::new(&c);
|
||||||
|
assert_eq!(s.nearest(0.0), Some(0));
|
||||||
|
assert_eq!(s.nearest(2.0), Some(1)); // 1 está más cerca que 3
|
||||||
|
assert_eq!(s.nearest(2.5), Some(2)); // 3 está más cerca que 1
|
||||||
|
assert_eq!(s.nearest(8.0), Some(4));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nearest_fuera_clamp() {
|
||||||
|
let c = fixture();
|
||||||
|
let s = SpatialIndex::new(&c);
|
||||||
|
assert_eq!(s.nearest(-10.0), Some(0));
|
||||||
|
assert_eq!(s.nearest(99.0), Some(4));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nearest_empty() {
|
||||||
|
let empty: [f32; 0] = [];
|
||||||
|
assert_eq!(SpatialIndex::new(&empty).nearest(0.0), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn range_clip() {
|
||||||
|
let c = fixture();
|
||||||
|
let s = SpatialIndex::new(&c);
|
||||||
|
assert_eq!(s.range(1.0, 5.0), (1, 4)); // incluye x=1,3,5
|
||||||
|
assert_eq!(s.range(2.0, 4.0), (2, 3)); // sólo x=3
|
||||||
|
assert_eq!(s.range(-1.0, 100.0), (0, 5));
|
||||||
|
assert_eq!(s.range(10.0, 20.0), (5, 5));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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 }
|
||||||
@@ -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};
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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("&"),
|
||||||
|
'<' => out.push_str("<"),
|
||||||
|
'>' => out.push_str(">"),
|
||||||
|
'"' => out.push_str("""),
|
||||||
|
'\'' => out.push_str("'"),
|
||||||
|
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<b&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)
|
||||||
|
}
|
||||||
@@ -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" }
|
||||||
@@ -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)
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" }
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" }
|
||||||
@@ -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)
|
||||||
@@ -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
Reference in New Issue
Block a user