commit 514f17ba57e75d7e5db3a54d703d47377ca10fe6 Author: Sergio Date: Thu Jun 4 11:47:26 2026 +0000 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) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b7141ea --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/target +**/*.rs.bk +*.pdb diff --git a/00_unanchay/pineal/LEEME.md b/00_unanchay/pineal/LEEME.md new file mode 100644 index 0000000..2b68415 --- /dev/null +++ b/00_unanchay/pineal/LEEME.md @@ -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` planos interleaved + (`[x0, y0, x1, y1, …]`), nunca `Vec`. 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 ``/``/``. | +| **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). diff --git a/00_unanchay/pineal/README.md b/00_unanchay/pineal/README.md new file mode 100644 index 0000000..553b8bb --- /dev/null +++ b/00_unanchay/pineal/README.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` + (`[x0, y0, x1, y1, …]`), never `Vec`. 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 ``/``/``. | +| **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 `. 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`. diff --git a/00_unanchay/pineal/README.qu.md b/00_unanchay/pineal/README.qu.md new file mode 100644 index 0000000..9f1658a --- /dev/null +++ b/00_unanchay/pineal/README.qu.md @@ -0,0 +1,30 @@ + + +# 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). diff --git a/00_unanchay/pineal/SDD.md b/00_unanchay/pineal/SDD.md new file mode 100644 index 0000000..683c119 --- /dev/null +++ b/00_unanchay/pineal/SDD.md @@ -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` planos +interleaved `[x0, y0, x1, y1, ...]`, nunca como `Vec`. 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 ``/``/``. | `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). diff --git a/00_unanchay/pineal/demos/pineal-bars-demo/Cargo.toml b/00_unanchay/pineal/demos/pineal-bars-demo/Cargo.toml new file mode 100644 index 0000000..f15ce0d --- /dev/null +++ b/00_unanchay/pineal/demos/pineal-bars-demo/Cargo.toml @@ -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" } diff --git a/00_unanchay/pineal/demos/pineal-bars-demo/src/main.rs b/00_unanchay/pineal/demos/pineal-bars-demo/src/main.rs new file mode 100644 index 0000000..7fb8a98 --- /dev/null +++ b/00_unanchay/pineal/demos/pineal-bars-demo/src/main.rs @@ -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), + MenuCommand(String), + CloseMenus, + CycleTheme, + ContextMenuOpen(f32, f32), +} + +struct Model { + theme: Theme, + menu_open: Option, + 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) -> Model { + Model { + theme: Theme::dark(), + menu_open: None, + context_menu: None, + } + } + + fn update(mut model: Model, msg: Msg, handle: &Handle) -> 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 { + 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> { + if let Some((x, y)) = model.context_menu { + let items = vec![ContextMenuItem::action("Cambiar tema")]; + let on_pick: Arc 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) -> View { + 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 { + 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 { + 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 { + 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 { + 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 { + // 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::() / 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) -> Model { + match cmd { + "file.quit" => std::process::exit(0), + "view.theme" => { + handle.dispatch(Msg::CycleTheme); + model + } + _ => model, + } +} + +fn main() { + llimphi_ui::run::(); +} diff --git a/00_unanchay/pineal/demos/pineal-contour-demo/Cargo.toml b/00_unanchay/pineal/demos/pineal-contour-demo/Cargo.toml new file mode 100644 index 0000000..1f3ae63 --- /dev/null +++ b/00_unanchay/pineal/demos/pineal-contour-demo/Cargo.toml @@ -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 } diff --git a/00_unanchay/pineal/demos/pineal-contour-demo/src/main.rs b/00_unanchay/pineal/demos/pineal-contour-demo/src/main.rs new file mode 100644 index 0000000..86a9fa4 --- /dev/null +++ b/00_unanchay/pineal/demos/pineal-contour-demo/src/main.rs @@ -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), + /// 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>, + t: u64, + paused: bool, + theme: Theme, + menu_open: Option, + 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) -> 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) -> 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 { + 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> { + 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) { + 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 { + 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 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::(); +} diff --git a/00_unanchay/pineal/demos/pineal-demo/Cargo.toml b/00_unanchay/pineal/demos/pineal-demo/Cargo.toml new file mode 100644 index 0000000..d07e0c7 --- /dev/null +++ b/00_unanchay/pineal/demos/pineal-demo/Cargo.toml @@ -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 } diff --git a/00_unanchay/pineal/demos/pineal-demo/LEEME.md b/00_unanchay/pineal/demos/pineal-demo/LEEME.md new file mode 100644 index 0000000..2abfcf7 --- /dev/null +++ b/00_unanchay/pineal/demos/pineal-demo/LEEME.md @@ -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/) diff --git a/00_unanchay/pineal/demos/pineal-demo/README.md b/00_unanchay/pineal/demos/pineal-demo/README.md new file mode 100644 index 0000000..7dc0fc3 --- /dev/null +++ b/00_unanchay/pineal/demos/pineal-demo/README.md @@ -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/) diff --git a/00_unanchay/pineal/demos/pineal-demo/src/main.rs b/00_unanchay/pineal/demos/pineal-demo/src/main.rs new file mode 100644 index 0000000..46daf70 --- /dev/null +++ b/00_unanchay/pineal/demos/pineal-demo/src/main.rs @@ -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), + /// 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, + /// 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) -> 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) -> 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 { + 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 { + 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::(); + + 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> { + // 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) { + 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 { + 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 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::(); +} diff --git a/00_unanchay/pineal/demos/pineal-financial-demo/Cargo.toml b/00_unanchay/pineal/demos/pineal-financial-demo/Cargo.toml new file mode 100644 index 0000000..2b356c8 --- /dev/null +++ b/00_unanchay/pineal/demos/pineal-financial-demo/Cargo.toml @@ -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 } diff --git a/00_unanchay/pineal/demos/pineal-financial-demo/LEEME.md b/00_unanchay/pineal/demos/pineal-financial-demo/LEEME.md new file mode 100644 index 0000000..0b3098b --- /dev/null +++ b/00_unanchay/pineal/demos/pineal-financial-demo/LEEME.md @@ -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) diff --git a/00_unanchay/pineal/demos/pineal-financial-demo/README.md b/00_unanchay/pineal/demos/pineal-financial-demo/README.md new file mode 100644 index 0000000..91619e0 --- /dev/null +++ b/00_unanchay/pineal/demos/pineal-financial-demo/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) diff --git a/00_unanchay/pineal/demos/pineal-financial-demo/src/main.rs b/00_unanchay/pineal/demos/pineal-financial-demo/src/main.rs new file mode 100644 index 0000000..7c4c148 --- /dev/null +++ b/00_unanchay/pineal/demos/pineal-financial-demo/src/main.rs @@ -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), + /// 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, + 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) -> 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) -> 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 { + 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 { + 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::(); + + 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> { + 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) { + 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 { + 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 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::(); +} diff --git a/00_unanchay/pineal/demos/pineal-flow-demo/Cargo.toml b/00_unanchay/pineal/demos/pineal-flow-demo/Cargo.toml new file mode 100644 index 0000000..6ba3b24 --- /dev/null +++ b/00_unanchay/pineal/demos/pineal-flow-demo/Cargo.toml @@ -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 } diff --git a/00_unanchay/pineal/demos/pineal-flow-demo/src/main.rs b/00_unanchay/pineal/demos/pineal-flow-demo/src/main.rs new file mode 100644 index 0000000..8654b3d --- /dev/null +++ b/00_unanchay/pineal/demos/pineal-flow-demo/src/main.rs @@ -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), + /// 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, + 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) -> Model { + Model { theme: Theme::dark(), menu_open: None, context_menu: None } + } + + fn update(mut model: Model, msg: Msg, handle: &Handle) -> 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 { + 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 = [ + "Sueldo", "Freelance", "Renta", "Dividendos", + "Vivienda", "Comida", "Transporte", "Ocio", "Salud", + "Ahorro", + ] + .iter() + .map(|n| SankeyNode::new(*n)) + .collect(); + + let links: Vec = 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> { + 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) { + 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 { + let items = vec![ContextMenuItem::action("Cambiar tema")]; + let cmds: Vec<&'static str> = vec!["view.theme"]; + let on_pick: Arc 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::(); +} diff --git a/00_unanchay/pineal/demos/pineal-galeria-demo/Cargo.toml b/00_unanchay/pineal/demos/pineal-galeria-demo/Cargo.toml new file mode 100644 index 0000000..5648b7b --- /dev/null +++ b/00_unanchay/pineal/demos/pineal-galeria-demo/Cargo.toml @@ -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" } diff --git a/00_unanchay/pineal/demos/pineal-galeria-demo/src/main.rs b/00_unanchay/pineal/demos/pineal-galeria-demo/src/main.rs new file mode 100644 index 0000000..aff5954 --- /dev/null +++ b/00_unanchay/pineal/demos/pineal-galeria-demo/src/main.rs @@ -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--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), + /// 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, + /// Grafo del tile mesh, ya relajado en el init (posiciones fijas). + graph: Arc, + /// Hexbin pre-construido (5 000 puntos gaussianos deterministas). + hex: HexGrid, + /// Campo escalar 48×32 para heatmap y contour (estático, t = 0). + field: Arc, +} + +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) -> 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) -> 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 { + 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> { + 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 { + 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) -> View { + 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(painter: F) -> View +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 { + 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 = 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 { + 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 { + 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::() / 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 { + 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 { + 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 = (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 { + 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 = 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) -> View { + 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 { + 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) -> View { + 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 { + let nodes: Vec = [ + "Sueldo", "Freelance", "Renta", "Dividendos", "Vivienda", "Comida", "Transporte", + "Ocio", "Salud", "Ahorro", + ] + .iter() + .map(|n| SankeyNode::new(*n)) + .collect(); + + let links: Vec = 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) -> View { + 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) { + match cmd { + "file.quit" => std::process::exit(0), + "view.theme" => handle.dispatch(Msg::CycleTheme), + _ => {} + } +} + +fn main() { + llimphi_ui::run::(); +} diff --git a/00_unanchay/pineal/demos/pineal-gpu-demo/Cargo.toml b/00_unanchay/pineal/demos/pineal-gpu-demo/Cargo.toml new file mode 100644 index 0000000..8b1d57a --- /dev/null +++ b/00_unanchay/pineal/demos/pineal-gpu-demo/Cargo.toml @@ -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" } diff --git a/00_unanchay/pineal/demos/pineal-gpu-demo/src/main.rs b/00_unanchay/pineal/demos/pineal-gpu-demo/src/main.rs new file mode 100644 index 0000000..2cd22f8 --- /dev/null +++ b/00_unanchay/pineal/demos/pineal-gpu-demo/src/main.rs @@ -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` 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>`. 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), + 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, + 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>, + pipelines: Arc>, + density_idx: usize, + paused: bool, + frame: u64, + theme: Theme, + menu_open: Option, + 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) -> 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) -> 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 { + 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 { + 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> { + 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 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` plano: el clone es un memcpy +/// contiguo, sin punteros ni boxing (P1). +fn model_field_snapshot(field: &Arc>) -> FieldSnapshot { + let g = field.lock().unwrap(); + FieldSnapshot { xyz: g.xyz.clone(), count: g.count } +} + +struct FieldSnapshot { + xyz: Vec, + 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) -> 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::(); +} diff --git a/00_unanchay/pineal/demos/pineal-heatmap-demo/Cargo.toml b/00_unanchay/pineal/demos/pineal-heatmap-demo/Cargo.toml new file mode 100644 index 0000000..e96ab2d --- /dev/null +++ b/00_unanchay/pineal/demos/pineal-heatmap-demo/Cargo.toml @@ -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" } diff --git a/00_unanchay/pineal/demos/pineal-heatmap-demo/src/main.rs b/00_unanchay/pineal/demos/pineal-heatmap-demo/src/main.rs new file mode 100644 index 0000000..21ed56c --- /dev/null +++ b/00_unanchay/pineal/demos/pineal-heatmap-demo/src/main.rs @@ -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), + /// 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>, + t: u64, + theme: Theme, + menu_open: Option, + 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) -> 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) -> 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 { + 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> { + if let Some((x, y)) = model.context_menu { + let items = vec![ + ContextMenuItem::action("Reiniciar vista"), + ContextMenuItem::action("Cambiar tema"), + ]; + let on_pick: Arc 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) -> 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::(); +} diff --git a/00_unanchay/pineal/demos/pineal-hexbin-demo/Cargo.toml b/00_unanchay/pineal/demos/pineal-hexbin-demo/Cargo.toml new file mode 100644 index 0000000..3d1b29b --- /dev/null +++ b/00_unanchay/pineal/demos/pineal-hexbin-demo/Cargo.toml @@ -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" } diff --git a/00_unanchay/pineal/demos/pineal-hexbin-demo/src/main.rs b/00_unanchay/pineal/demos/pineal-hexbin-demo/src/main.rs new file mode 100644 index 0000000..d23da1c --- /dev/null +++ b/00_unanchay/pineal/demos/pineal-hexbin-demo/src/main.rs @@ -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), + /// 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, + 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) -> Model { + Model { + grid: build_grid(), + theme: Theme::dark(), + menu_open: None, + context_menu: None, + } + } + + fn update(mut model: Model, msg: Msg, handle: &Handle) -> 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 { + 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> { + if let Some((x, y)) = model.context_menu { + let items = vec![ + ContextMenuItem::action("Recalcular"), + ContextMenuItem::action("Cambiar tema"), + ]; + let on_pick: Arc 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) -> 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::(); +} diff --git a/00_unanchay/pineal/demos/pineal-mesh-demo/Cargo.toml b/00_unanchay/pineal/demos/pineal-mesh-demo/Cargo.toml new file mode 100644 index 0000000..a5eca47 --- /dev/null +++ b/00_unanchay/pineal/demos/pineal-mesh-demo/Cargo.toml @@ -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" } diff --git a/00_unanchay/pineal/demos/pineal-mesh-demo/src/main.rs b/00_unanchay/pineal/demos/pineal-mesh-demo/src/main.rs new file mode 100644 index 0000000..11c9f10 --- /dev/null +++ b/00_unanchay/pineal/demos/pineal-mesh-demo/src/main.rs @@ -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), + /// 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>, + steps: u64, + theme: Theme, + menu_open: Option, + 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) -> 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) -> 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 { + 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> { + if let Some((x, y)) = model.context_menu { + let items = vec![ + ContextMenuItem::action("Reiniciar (recalentar)"), + ContextMenuItem::action("Cambiar tema"), + ]; + let on_pick: Arc 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) -> 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::(); +} diff --git a/00_unanchay/pineal/demos/pineal-phosphor-demo/Cargo.toml b/00_unanchay/pineal/demos/pineal-phosphor-demo/Cargo.toml new file mode 100644 index 0000000..c7ee475 --- /dev/null +++ b/00_unanchay/pineal/demos/pineal-phosphor-demo/Cargo.toml @@ -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" } diff --git a/00_unanchay/pineal/demos/pineal-phosphor-demo/LEEME.md b/00_unanchay/pineal/demos/pineal-phosphor-demo/LEEME.md new file mode 100644 index 0000000..e933a9b --- /dev/null +++ b/00_unanchay/pineal/demos/pineal-phosphor-demo/LEEME.md @@ -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) diff --git a/00_unanchay/pineal/demos/pineal-phosphor-demo/README.md b/00_unanchay/pineal/demos/pineal-phosphor-demo/README.md new file mode 100644 index 0000000..666d885 --- /dev/null +++ b/00_unanchay/pineal/demos/pineal-phosphor-demo/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) diff --git a/00_unanchay/pineal/demos/pineal-phosphor-demo/src/main.rs b/00_unanchay/pineal/demos/pineal-phosphor-demo/src/main.rs new file mode 100644 index 0000000..16cc185 --- /dev/null +++ b/00_unanchay/pineal/demos/pineal-phosphor-demo/src/main.rs @@ -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), + /// 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, + 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) -> 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) -> 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 { + 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::(); + + 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> { + if let Some((x, y)) = model.context_menu { + let items = vec![ + ContextMenuItem::action("Limpiar trail"), + ContextMenuItem::action("Cambiar tema"), + ]; + let on_pick: Arc 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) -> 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::(); +} diff --git a/00_unanchay/pineal/demos/pineal-polar-demo/Cargo.toml b/00_unanchay/pineal/demos/pineal-polar-demo/Cargo.toml new file mode 100644 index 0000000..a1e4404 --- /dev/null +++ b/00_unanchay/pineal/demos/pineal-polar-demo/Cargo.toml @@ -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" } diff --git a/00_unanchay/pineal/demos/pineal-polar-demo/src/main.rs b/00_unanchay/pineal/demos/pineal-polar-demo/src/main.rs new file mode 100644 index 0000000..d2c03eb --- /dev/null +++ b/00_unanchay/pineal/demos/pineal-polar-demo/src/main.rs @@ -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), + /// 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, + 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) -> Model { + Model { theme: Theme::dark(), menu_open: None, context_menu: None } + } + + fn update(mut model: Model, msg: Msg, handle: &Handle) -> 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 { + 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 = (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> { + 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) { + 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 { + let items = vec![ContextMenuItem::action("Cambiar tema")]; + let cmds: Vec<&'static str> = vec!["view.theme"]; + let on_pick: Arc 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::(); +} diff --git a/00_unanchay/pineal/demos/pineal-stream-demo/Cargo.toml b/00_unanchay/pineal/demos/pineal-stream-demo/Cargo.toml new file mode 100644 index 0000000..81bd07e --- /dev/null +++ b/00_unanchay/pineal/demos/pineal-stream-demo/Cargo.toml @@ -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" } diff --git a/00_unanchay/pineal/demos/pineal-stream-demo/LEEME.md b/00_unanchay/pineal/demos/pineal-stream-demo/LEEME.md new file mode 100644 index 0000000..db2dac2 --- /dev/null +++ b/00_unanchay/pineal/demos/pineal-stream-demo/LEEME.md @@ -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) diff --git a/00_unanchay/pineal/demos/pineal-stream-demo/README.md b/00_unanchay/pineal/demos/pineal-stream-demo/README.md new file mode 100644 index 0000000..e81e3f8 --- /dev/null +++ b/00_unanchay/pineal/demos/pineal-stream-demo/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) diff --git a/00_unanchay/pineal/demos/pineal-stream-demo/src/main.rs b/00_unanchay/pineal/demos/pineal-stream-demo/src/main.rs new file mode 100644 index 0000000..efb2cc3 --- /dev/null +++ b/00_unanchay/pineal/demos/pineal-stream-demo/src/main.rs @@ -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), + /// 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, + 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) -> 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) -> 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 { + 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::(); + + 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> { + 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) { + 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 { + 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 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::(); +} diff --git a/00_unanchay/pineal/demos/pineal-treemap-demo/Cargo.toml b/00_unanchay/pineal/demos/pineal-treemap-demo/Cargo.toml new file mode 100644 index 0000000..cc197a3 --- /dev/null +++ b/00_unanchay/pineal/demos/pineal-treemap-demo/Cargo.toml @@ -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" } diff --git a/00_unanchay/pineal/demos/pineal-treemap-demo/src/main.rs b/00_unanchay/pineal/demos/pineal-treemap-demo/src/main.rs new file mode 100644 index 0000000..dc58c9f --- /dev/null +++ b/00_unanchay/pineal/demos/pineal-treemap-demo/src/main.rs @@ -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), + /// 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, + 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) -> Model { + Model { theme: Theme::dark(), menu_open: None, context_menu: None } + } + + fn update(mut model: Model, msg: Msg, handle: &Handle) -> 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 { + 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 = 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> { + 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) { + 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 { + let items = vec![ContextMenuItem::action("Cambiar tema")]; + let cmds: Vec<&'static str> = vec!["view.theme"]; + let on_pick: Arc 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::(); +} diff --git a/00_unanchay/pineal/pineal-bars/Cargo.toml b/00_unanchay/pineal/pineal-bars/Cargo.toml new file mode 100644 index 0000000..97cd70a --- /dev/null +++ b/00_unanchay/pineal/pineal-bars/Cargo.toml @@ -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" } diff --git a/00_unanchay/pineal/pineal-bars/src/histogram.rs b/00_unanchay/pineal/pineal-bars/src/histogram.rs new file mode 100644 index 0000000..5a074dc --- /dev/null +++ b/00_unanchay/pineal/pineal-bars/src/histogram.rs @@ -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, + 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 { + 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::(), 3.0); + } +} diff --git a/00_unanchay/pineal/pineal-bars/src/lib.rs b/00_unanchay/pineal/pineal-bars/src/lib.rs new file mode 100644 index 0000000..58bea63 --- /dev/null +++ b/00_unanchay/pineal/pineal-bars/src/lib.rs @@ -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}; diff --git a/00_unanchay/pineal/pineal-bars/src/paint.rs b/00_unanchay/pineal/pineal-bars/src/paint.rs new file mode 100644 index 0000000..3bfb32a --- /dev/null +++ b/00_unanchay/pineal/pineal-bars/src/paint.rs @@ -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, 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 { + 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); + } +} diff --git a/00_unanchay/pineal/pineal-cartesian/Cargo.toml b/00_unanchay/pineal/pineal-cartesian/Cargo.toml new file mode 100644 index 0000000..9f0c30b --- /dev/null +++ b/00_unanchay/pineal/pineal-cartesian/Cargo.toml @@ -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 } diff --git a/00_unanchay/pineal/pineal-cartesian/LEEME.md b/00_unanchay/pineal/pineal-cartesian/LEEME.md new file mode 100644 index 0000000..1d010c6 --- /dev/null +++ b/00_unanchay/pineal/pineal-cartesian/LEEME.md @@ -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) diff --git a/00_unanchay/pineal/pineal-cartesian/README.md b/00_unanchay/pineal/pineal-cartesian/README.md new file mode 100644 index 0000000..13bc445 --- /dev/null +++ b/00_unanchay/pineal/pineal-cartesian/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) diff --git a/00_unanchay/pineal/pineal-cartesian/src/axis.rs b/00_unanchay/pineal/pineal-cartesian/src/axis.rs new file mode 100644 index 0000000..ed1775d --- /dev/null +++ b/00_unanchay/pineal/pineal-cartesian/src/axis.rs @@ -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 { + 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 { + 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 = Vec::with_capacity(x_ticks.len()); + let mut x_lbl: Vec = Vec::with_capacity(x_ticks.len()); + let mut x_widths: Vec = 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 = 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"); + } +} diff --git a/00_unanchay/pineal/pineal-cartesian/src/coord_system.rs b/00_unanchay/pineal/pineal-cartesian/src/coord_system.rs new file mode 100644 index 0000000..1cdf44b --- /dev/null +++ b/00_unanchay/pineal/pineal-cartesian/src/coord_system.rs @@ -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) { + 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 = 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); + } + } +} diff --git a/00_unanchay/pineal/pineal-cartesian/src/lib.rs b/00_unanchay/pineal/pineal-cartesian/src/lib.rs new file mode 100644 index 0000000..f112432 --- /dev/null +++ b/00_unanchay/pineal/pineal-cartesian/src/lib.rs @@ -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` 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, +}; diff --git a/00_unanchay/pineal/pineal-cartesian/src/series.rs b/00_unanchay/pineal/pineal-cartesian/src/series.rs new file mode 100644 index 0000000..dc4d1cf --- /dev/null +++ b/00_unanchay/pineal/pineal-cartesian/src/series.rs @@ -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, +} + +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 { + 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, +} + +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) { + 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 = Vec::with_capacity(target); + lttb::lttb_indices(self.data.coords(), target, &mut idx); + let mut decimated: Vec = 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 { + 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()); + } +} diff --git a/00_unanchay/pineal/pineal-cartesian/src/view.rs b/00_unanchay/pineal/pineal-cartesian/src/view.rs new file mode 100644 index 0000000..02e6eb9 --- /dev/null +++ b/00_unanchay/pineal/pineal-cartesian/src/view.rs @@ -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` 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>` 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` 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>` clonado a cada frame. +#[derive(Default, Debug)] +pub struct ChartCache { + projected: Vec>, + 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>; + +/// 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, +} + +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) -> Self { + Self { data, stroke, name: Some(name.into()) } + } +} + +/// Configuración del chart. Builder estilo Element. `view::()` +/// materializa el `View`. +pub struct ChartView { + series: Vec, + viewport: ChartViewport, + background: Option, + axis_color: Color, + axis_style: AxisStyle, + margin_top: f32, + margin_right: f32, + margin_bottom: f32, + margin_left: f32, + cache: Option, +} + +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, + ) -> 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`. Devuelve un nodo que ocupa 100% del + /// padre y pinta el chart dentro de su rect. + pub fn view(self) -> View { + 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) +} diff --git a/00_unanchay/pineal/pineal-cartesian/src/viewport.rs b/00_unanchay/pineal/pineal-cartesian/src/viewport.rs new file mode 100644 index 0000000..63fbe96 --- /dev/null +++ b/00_unanchay/pineal/pineal-cartesian/src/viewport.rs @@ -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); + } +} diff --git a/00_unanchay/pineal/pineal-contour/Cargo.toml b/00_unanchay/pineal/pineal-contour/Cargo.toml new file mode 100644 index 0000000..1dc73f1 --- /dev/null +++ b/00_unanchay/pineal/pineal-contour/Cargo.toml @@ -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" } diff --git a/00_unanchay/pineal/pineal-contour/src/lib.rs b/00_unanchay/pineal/pineal-contour/src/lib.rs new file mode 100644 index 0000000..5757fa0 --- /dev/null +++ b/00_unanchay/pineal/pineal-contour/src/lib.rs @@ -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 { + 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, 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)> { + 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 = (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"); + } +} diff --git a/00_unanchay/pineal/pineal-core/Cargo.toml b/00_unanchay/pineal/pineal-core/Cargo.toml new file mode 100644 index 0000000..bdab6ad --- /dev/null +++ b/00_unanchay/pineal/pineal-core/Cargo.toml @@ -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] diff --git a/00_unanchay/pineal/pineal-core/LEEME.md b/00_unanchay/pineal/pineal-core/LEEME.md new file mode 100644 index 0000000..4820ca6 --- /dev/null +++ b/00_unanchay/pineal/pineal-core/LEEME.md @@ -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 diff --git a/00_unanchay/pineal/pineal-core/README.md b/00_unanchay/pineal/pineal-core/README.md new file mode 100644 index 0000000..97b1203 --- /dev/null +++ b/00_unanchay/pineal/pineal-core/README.md @@ -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 diff --git a/00_unanchay/pineal/pineal-core/src/buffer.rs b/00_unanchay/pineal/pineal-core/src/buffer.rs new file mode 100644 index 0000000..a80469a --- /dev/null +++ b/00_unanchay/pineal/pineal-core/src/buffer.rs @@ -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, + 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) -> 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` + /// / ``. 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); + } +} diff --git a/00_unanchay/pineal/pineal-core/src/lib.rs b/00_unanchay/pineal/pineal-core/src/lib.rs new file mode 100644 index 0000000..0ea74f9 --- /dev/null +++ b/00_unanchay/pineal/pineal-core/src/lib.rs @@ -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` planos +//! indexados, nunca como `Vec`. 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 `` 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`. diff --git a/00_unanchay/pineal/pineal-core/src/lttb.rs b/00_unanchay/pineal/pineal-core/src/lttb.rs new file mode 100644 index 0000000..31466c4 --- /dev/null +++ b/00_unanchay/pineal/pineal-core/src/lttb.rs @@ -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) { + 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, +) { + 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) { + let mut idx_buf: Vec = 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 = (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 = (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 = (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 = (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 = 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"); + } +} diff --git a/00_unanchay/pineal/pineal-core/src/ring.rs b/00_unanchay/pineal/pineal-core/src/ring.rs new file mode 100644 index 0000000..2f5fa55 --- /dev/null +++ b/00_unanchay/pineal/pineal-core/src/ring.rs @@ -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, + /// `[x_norm, y_value]` por slot. `x_norm = slot / (cap - 1)`, + /// fijo. `y_value` = `values[slot]`. + coords: Vec, + 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()); + } +} diff --git a/00_unanchay/pineal/pineal-core/src/scale.rs b/00_unanchay/pineal/pineal-core/src/scale.rs new file mode 100644 index 0000000..da18d4f --- /dev/null +++ b/00_unanchay/pineal/pineal-core/src/scale.rs @@ -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); + } +} diff --git a/00_unanchay/pineal/pineal-core/src/spatial.rs b/00_unanchay/pineal/pineal-core/src/spatial.rs new file mode 100644 index 0000000..630778e --- /dev/null +++ b/00_unanchay/pineal/pineal-core/src/spatial.rs @@ -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 { + 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 { + // 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)); + } +} diff --git a/00_unanchay/pineal/pineal-export/Cargo.toml b/00_unanchay/pineal/pineal-export/Cargo.toml new file mode 100644 index 0000000..644a770 --- /dev/null +++ b/00_unanchay/pineal/pineal-export/Cargo.toml @@ -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 } diff --git a/00_unanchay/pineal/pineal-export/LEEME.md b/00_unanchay/pineal/pineal-export/LEEME.md new file mode 100644 index 0000000..c547514 --- /dev/null +++ b/00_unanchay/pineal/pineal-export/LEEME.md @@ -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 diff --git a/00_unanchay/pineal/pineal-export/README.md b/00_unanchay/pineal/pineal-export/README.md new file mode 100644 index 0000000..0a45333 --- /dev/null +++ b/00_unanchay/pineal/pineal-export/README.md @@ -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 diff --git a/00_unanchay/pineal/pineal-export/src/lib.rs b/00_unanchay/pineal/pineal-export/src/lib.rs new file mode 100644 index 0000000..907b90b --- /dev/null +++ b/00_unanchay/pineal/pineal-export/src/lib.rs @@ -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 (``/``/``…). +//! - [`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}; diff --git a/00_unanchay/pineal/pineal-export/src/pdf.rs b/00_unanchay/pineal/pineal-export/src/pdf.rs new file mode 100644 index 0000000..d361b59 --- /dev/null +++ b/00_unanchay/pineal/pineal-export/src/pdf.rs @@ -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 ``. 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 { + 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 { + 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 { + 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 { + let mut buf: Vec = Vec::with_capacity(content.len() + 512); + let mut offsets: Vec = 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 = (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 = (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")); + } +} diff --git a/00_unanchay/pineal/pineal-export/src/png.rs b/00_unanchay/pineal/pineal-export/src/png.rs new file mode 100644 index 0000000..45b66eb --- /dev/null +++ b/00_unanchay/pineal/pineal-export/src/png.rs @@ -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 `` 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, png::EncodingError> { + let mut buf = RasterBuffer::new(width, height, bg); + let mut clip_stack: Vec = 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, + 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) { + 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) { + 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::>().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 { + 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, +) { + 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, +) { + 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, 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); + } +} diff --git a/00_unanchay/pineal/pineal-export/src/svg.rs b/00_unanchay/pineal/pineal-export/src/svg.rs new file mode 100644 index 0000000..93f418b --- /dev/null +++ b/00_unanchay/pineal/pineal-export/src/svg.rs @@ -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, + "" + ); + for cmd in &plan.cmds { + emit_cmd(&mut s, cmd); + } + s.push_str(""); + 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, rect.y, rect.w, rect.h + ); + } + + RenderCmd::StrokeRect { rect, stroke } => { + let (c, a) = svg_color(stroke.color); + let _ = write!( + s, + "", + 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, + "", + p0.x, p0.y, p1.x, p1.y, stroke.width + ); + } + + RenderCmd::StrokePolyline { coords, stroke } => { + let (c, alpha) = svg_color(stroke.color); + s.push_str("", + 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, + "{}", + 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 `` 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, + "", + 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 { + 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")); + 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(" 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) diff --git a/00_unanchay/pineal/pineal-financial/README.md b/00_unanchay/pineal/pineal-financial/README.md new file mode 100644 index 0000000..808b98c --- /dev/null +++ b/00_unanchay/pineal/pineal-financial/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) diff --git a/00_unanchay/pineal/pineal-financial/src/aggregate.rs b/00_unanchay/pineal/pineal-financial/src/aggregate.rs new file mode 100644 index 0000000..cfe117a --- /dev/null +++ b/00_unanchay/pineal/pineal-financial/src/aggregate.rs @@ -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); + } +} diff --git a/00_unanchay/pineal/pineal-financial/src/candlestick.rs b/00_unanchay/pineal/pineal-financial/src/candlestick.rs new file mode 100644 index 0000000..b66e138 --- /dev/null +++ b/00_unanchay/pineal/pineal-financial/src/candlestick.rs @@ -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, + ); + } +} diff --git a/00_unanchay/pineal/pineal-financial/src/lib.rs b/00_unanchay/pineal/pineal-financial/src/lib.rs new file mode 100644 index 0000000..facb7e2 --- /dev/null +++ b/00_unanchay/pineal/pineal-financial/src/lib.rs @@ -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}; diff --git a/00_unanchay/pineal/pineal-financial/src/ohlc_buffer.rs b/00_unanchay/pineal/pineal-financial/src/ohlc_buffer.rs new file mode 100644 index 0000000..8838450 --- /dev/null +++ b/00_unanchay/pineal/pineal-financial/src/ohlc_buffer.rs @@ -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, + 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) -> 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()); + } +} diff --git a/00_unanchay/pineal/pineal-financial/src/view.rs b/00_unanchay/pineal/pineal-financial/src/view.rs new file mode 100644 index 0000000..ce0600c --- /dev/null +++ b/00_unanchay/pineal/pineal-financial/src/view.rs @@ -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, + 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`. Pinta velas + ejes dentro del rect. + pub fn view(self) -> View { + 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) +} diff --git a/00_unanchay/pineal/pineal-flow/Cargo.toml b/00_unanchay/pineal/pineal-flow/Cargo.toml new file mode 100644 index 0000000..6fa6d98 --- /dev/null +++ b/00_unanchay/pineal/pineal-flow/Cargo.toml @@ -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" } diff --git a/00_unanchay/pineal/pineal-flow/LEEME.md b/00_unanchay/pineal/pineal-flow/LEEME.md new file mode 100644 index 0000000..a59e936 --- /dev/null +++ b/00_unanchay/pineal/pineal-flow/LEEME.md @@ -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) diff --git a/00_unanchay/pineal/pineal-flow/README.md b/00_unanchay/pineal/pineal-flow/README.md new file mode 100644 index 0000000..db7c02b --- /dev/null +++ b/00_unanchay/pineal/pineal-flow/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) diff --git a/00_unanchay/pineal/pineal-flow/src/layout.rs b/00_unanchay/pineal/pineal-flow/src/layout.rs new file mode 100644 index 0000000..e051776 --- /dev/null +++ b/00_unanchay/pineal/pineal-flow/src/layout.rs @@ -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) -> 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, + pub links: Vec, +} + +/// 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 = (0..n).map(|i| in_sum[i].max(out_sum[i]).max(0.0)).collect(); + + // Nodos por columna. + let mut by_col: Vec> = 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::()) + .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 { + let mut adj: Vec> = 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![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 = (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], 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 { + (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); + } +} diff --git a/00_unanchay/pineal/pineal-flow/src/lib.rs b/00_unanchay/pineal/pineal-flow/src/lib.rs new file mode 100644 index 0000000..6f55d68 --- /dev/null +++ b/00_unanchay/pineal/pineal-flow/src/lib.rs @@ -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}; diff --git a/00_unanchay/pineal/pineal-flow/src/ribbon.rs b/00_unanchay/pineal/pineal-flow/src/ribbon.rs new file mode 100644 index 0000000..33d7c7c --- /dev/null +++ b/00_unanchay/pineal/pineal-flow/src/ribbon.rs @@ -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 { + 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"); + } +} diff --git a/00_unanchay/pineal/pineal-heatmap/Cargo.toml b/00_unanchay/pineal/pineal-heatmap/Cargo.toml new file mode 100644 index 0000000..b6f1979 --- /dev/null +++ b/00_unanchay/pineal/pineal-heatmap/Cargo.toml @@ -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" } diff --git a/00_unanchay/pineal/pineal-heatmap/LEEME.md b/00_unanchay/pineal/pineal-heatmap/LEEME.md new file mode 100644 index 0000000..196221b --- /dev/null +++ b/00_unanchay/pineal/pineal-heatmap/LEEME.md @@ -0,0 +1,20 @@ +# pineal-heatmap + +> Canvas de heatmap denso 2D para [pineal](../README.md). + +Toma una matriz `Array2` 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 diff --git a/00_unanchay/pineal/pineal-heatmap/README.md b/00_unanchay/pineal/pineal-heatmap/README.md new file mode 100644 index 0000000..5dc8a8b --- /dev/null +++ b/00_unanchay/pineal/pineal-heatmap/README.md @@ -0,0 +1,20 @@ +# pineal-heatmap + +> Dense 2D heatmap canvas for [pineal](../README.md). + +Takes an `Array2` 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 diff --git a/00_unanchay/pineal/pineal-heatmap/src/encoder.rs b/00_unanchay/pineal/pineal-heatmap/src/encoder.rs new file mode 100644 index 0000000..67bcc44 --- /dev/null +++ b/00_unanchay/pineal/pineal-heatmap/src/encoder.rs @@ -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 { + 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])); + } +} diff --git a/00_unanchay/pineal/pineal-heatmap/src/lib.rs b/00_unanchay/pineal/pineal-heatmap/src/lib.rs new file mode 100644 index 0000000..4179864 --- /dev/null +++ b/00_unanchay/pineal/pineal-heatmap/src/lib.rs @@ -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; diff --git a/00_unanchay/pineal/pineal-heatmap/src/matrix.rs b/00_unanchay/pineal/pineal-heatmap/src/matrix.rs new file mode 100644 index 0000000..acaeba0 --- /dev/null +++ b/00_unanchay/pineal/pineal-heatmap/src/matrix.rs @@ -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, + 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, width: usize, height: usize) -> Option { + 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) { + 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)); + } +} diff --git a/00_unanchay/pineal/pineal-heatmap/src/paint.rs b/00_unanchay/pineal/pineal-heatmap/src/paint.rs new file mode 100644 index 0000000..4d0f909 --- /dev/null +++ b/00_unanchay/pineal/pineal-heatmap/src/paint.rs @@ -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()); + } +} diff --git a/00_unanchay/pineal/pineal-heatmap/src/palette.rs b/00_unanchay/pineal/pineal-heatmap/src/palette.rs new file mode 100644 index 0000000..14b3ef1 --- /dev/null +++ b/00_unanchay/pineal/pineal-heatmap/src/palette.rs @@ -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)); + } +} diff --git a/00_unanchay/pineal/pineal-hexbin/Cargo.toml b/00_unanchay/pineal/pineal-hexbin/Cargo.toml new file mode 100644 index 0000000..4a4b58d --- /dev/null +++ b/00_unanchay/pineal/pineal-hexbin/Cargo.toml @@ -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" } diff --git a/00_unanchay/pineal/pineal-hexbin/src/lib.rs b/00_unanchay/pineal/pineal-hexbin/src/lib.rs new file mode 100644 index 0000000..9a88695 --- /dev/null +++ b/00_unanchay/pineal/pineal-hexbin/src/lib.rs @@ -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 + '_ { + 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 { + 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); + } +} diff --git a/00_unanchay/pineal/pineal-mesh/Cargo.toml b/00_unanchay/pineal/pineal-mesh/Cargo.toml new file mode 100644 index 0000000..80a7135 --- /dev/null +++ b/00_unanchay/pineal/pineal-mesh/Cargo.toml @@ -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" } diff --git a/00_unanchay/pineal/pineal-mesh/LEEME.md b/00_unanchay/pineal/pineal-mesh/LEEME.md new file mode 100644 index 0000000..2ac9f96 --- /dev/null +++ b/00_unanchay/pineal/pineal-mesh/LEEME.md @@ -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) diff --git a/00_unanchay/pineal/pineal-mesh/README.md b/00_unanchay/pineal/pineal-mesh/README.md new file mode 100644 index 0000000..6e65100 --- /dev/null +++ b/00_unanchay/pineal/pineal-mesh/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) diff --git a/00_unanchay/pineal/pineal-mesh/src/barnes_hut.rs b/00_unanchay/pineal/pineal-mesh/src/barnes_hut.rs new file mode 100644 index 0000000..18e33b6 --- /dev/null +++ b/00_unanchay/pineal/pineal-mesh/src/barnes_hut.rs @@ -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` 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, + /// Índices a los 4 hijos (NW, NE, SW, SE) en `Quadtree::nodes`. + children: [Option; 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 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, + root: Option, +} + +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()); + } +} diff --git a/00_unanchay/pineal/pineal-mesh/src/buffers.rs b/00_unanchay/pineal/pineal-mesh/src/buffers.rs new file mode 100644 index 0000000..251a652 --- /dev/null +++ b/00_unanchay/pineal/pineal-mesh/src/buffers.rs @@ -0,0 +1,111 @@ +//! Buffers planos de nodos y aristas — `Vec` contiguos con stride fijo. + +/// Nodos: stride 3 = `[x, y, radius]` por nodo. +#[derive(Debug, Clone, Default)] +pub struct NodeBuffer { + data: Vec, +} + +impl NodeBuffer { + pub fn new() -> Self { + Self::default() + } + + pub fn with_capacity(n: usize) -> Self { + Self { data: Vec::with_capacity(n * 3) } + } + + /// Agrega un nodo y devuelve su índice. + pub fn push(&mut self, x: f32, y: f32, radius: f32) -> usize { + let idx = self.len(); + self.data.extend_from_slice(&[x, y, radius]); + idx + } + + pub fn len(&self) -> usize { + self.data.len() / 3 + } + + pub fn is_empty(&self) -> bool { + self.data.is_empty() + } + + pub fn pos(&self, i: usize) -> (f32, f32) { + (self.data[i * 3], self.data[i * 3 + 1]) + } + + pub fn radius(&self, i: usize) -> f32 { + self.data[i * 3 + 2] + } + + pub fn set_pos(&mut self, i: usize, x: f32, y: f32) { + self.data[i * 3] = x; + self.data[i * 3 + 1] = y; + } + + /// Acceso crudo al `Vec` interleaved — para subir como buffer GPU. + pub fn raw(&self) -> &[f32] { + &self.data + } +} + +/// Aristas: stride 2 = `[from, to]` (índices de nodo). +#[derive(Debug, Clone, Default)] +pub struct EdgeBuffer { + data: Vec, +} + +impl EdgeBuffer { + pub fn new() -> Self { + Self::default() + } + + pub fn push(&mut self, from: usize, to: usize) { + self.data.push(from as u32); + self.data.push(to as u32); + } + + pub fn len(&self) -> usize { + self.data.len() / 2 + } + + pub fn is_empty(&self) -> bool { + self.data.is_empty() + } + + pub fn edge(&self, i: usize) -> (usize, usize) { + (self.data[i * 2] as usize, self.data[i * 2 + 1] as usize) + } + + pub fn iter(&self) -> impl Iterator + '_ { + (0..self.len()).map(move |i| self.edge(i)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn node_buffer_push_and_access() { + let mut nb = NodeBuffer::new(); + let a = nb.push(1.0, 2.0, 5.0); + let b = nb.push(3.0, 4.0, 6.0); + assert_eq!((a, b), (0, 1)); + assert_eq!(nb.len(), 2); + assert_eq!(nb.pos(1), (3.0, 4.0)); + assert_eq!(nb.radius(0), 5.0); + nb.set_pos(0, 9.0, 9.0); + assert_eq!(nb.pos(0), (9.0, 9.0)); + } + + #[test] + fn edge_buffer_roundtrip() { + let mut eb = EdgeBuffer::new(); + eb.push(0, 1); + eb.push(1, 2); + assert_eq!(eb.len(), 2); + assert_eq!(eb.edge(1), (1, 2)); + assert_eq!(eb.iter().collect::>(), vec![(0, 1), (1, 2)]); + } +} diff --git a/00_unanchay/pineal/pineal-mesh/src/camera.rs b/00_unanchay/pineal/pineal-mesh/src/camera.rs new file mode 100644 index 0000000..0f890f6 --- /dev/null +++ b/00_unanchay/pineal/pineal-mesh/src/camera.rs @@ -0,0 +1,72 @@ +//! Cámara 2D: pan + zoom con zoom anclado a un punto de pantalla. + +/// Transformación world↔screen. `screen = (world - pan) * zoom`. +#[derive(Debug, Clone, Copy)] +pub struct Camera { + pub pan: (f32, f32), + pub zoom: f32, +} + +impl Default for Camera { + fn default() -> Self { + Self { pan: (0.0, 0.0), zoom: 1.0 } + } +} + +impl Camera { + pub fn new() -> Self { + Self::default() + } + + pub fn world_to_screen(&self, w: (f32, f32)) -> (f32, f32) { + ((w.0 - self.pan.0) * self.zoom, (w.1 - self.pan.1) * self.zoom) + } + + pub fn screen_to_world(&self, s: (f32, f32)) -> (f32, f32) { + (s.0 / self.zoom + self.pan.0, s.1 / self.zoom + self.pan.1) + } + + /// Desplaza la cámara `delta` pixels de pantalla. + pub fn pan_by(&mut self, dx: f32, dy: f32) { + self.pan.0 -= dx / self.zoom; + self.pan.1 -= dy / self.zoom; + } + + /// Zoom multiplicando por `factor`, manteniendo fijo el punto de + /// pantalla `anchor` (el world-point bajo el cursor no se mueve). + pub fn zoom_at(&mut self, anchor: (f32, f32), factor: f32) { + let before = self.screen_to_world(anchor); + self.zoom = (self.zoom * factor).clamp(0.01, 1000.0); + let after = self.screen_to_world(anchor); + self.pan.0 += before.0 - after.0; + self.pan.1 += before.1 - after.1; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn close(a: (f32, f32), b: (f32, f32)) -> bool { + (a.0 - b.0).abs() < 1e-3 && (a.1 - b.1).abs() < 1e-3 + } + + #[test] + fn world_screen_roundtrip() { + let mut cam = Camera::new(); + cam.pan = (10.0, 20.0); + cam.zoom = 2.0; + let w = (33.0, 44.0); + assert!(close(cam.screen_to_world(cam.world_to_screen(w)), w)); + } + + #[test] + fn zoom_at_keeps_anchor_world_point_fixed() { + let mut cam = Camera::new(); + let anchor = (100.0, 80.0); + let before = cam.screen_to_world(anchor); + cam.zoom_at(anchor, 2.5); + let after = cam.screen_to_world(anchor); + assert!(close(before, after), "el punto bajo el cursor no se mueve"); + } +} diff --git a/00_unanchay/pineal/pineal-mesh/src/fdeb.rs b/00_unanchay/pineal/pineal-mesh/src/fdeb.rs new file mode 100644 index 0000000..9af2c1a --- /dev/null +++ b/00_unanchay/pineal/pineal-mesh/src/fdeb.rs @@ -0,0 +1,262 @@ +//! FDEB-lite — Force-Directed Edge Bundling (Holten & van Wijk 2009, +//! versión simplificada). +//! +//! Cuando un grafo tiene muchas aristas paralelas o casi-paralelas el +//! resultado del force-directed se ve como "spaghetti". FDEB agrupa +//! las aristas compatibles en haces curvos que comparten trayectoria, +//! revelando la estructura macroscópica del flujo (mismo principio que +//! los mapas de migración o las rutas aéreas). +//! +//! Pipeline: +//! 1. Cada arista se subdivide en `subdivisions` puntos intermedios +//! (incluyendo los endpoints que quedan fijos). +//! 2. Para cada par de aristas se calcula una *compatibility* en +//! `[0, 1]` (combinación de paralelismo + escala + cercanía). +//! 3. En cada iteración los puntos intermedios se mueven por: +//! - Spring force a sus vecinos dentro de la misma arista (mantiene +//! la integridad del path). +//! - Electric force atrayendo a puntos correspondientes de aristas +//! compatibles (bundling). +//! 4. La step size baja en cada iteración (cooling). +//! +//! Los endpoints se mantienen fijos (los nodos no se mueven). El output +//! son los paths como `Vec>`, listos para `stroke_polyline`. + +use crate::buffers::{EdgeBuffer, NodeBuffer}; + +/// Parámetros del bundling. Defaults razonables para grafos medianos. +#[derive(Debug, Clone, Copy)] +pub struct FdebParams { + /// Puntos intermedios por edge (sin contar endpoints). + pub subdivisions: usize, + /// Iteraciones totales. + pub iterations: usize, + /// Step size inicial (px por iteración). Decae linealmente a 0. + pub step: f32, + /// Rigidez del spring intra-edge. + pub spring_k: f32, + /// Fuerza eléctrica entre edges compatibles. + pub electric_k: f32, + /// Umbral de compatibility a partir del cual dos edges interactúan. + pub compat_threshold: f32, +} + +impl Default for FdebParams { + fn default() -> Self { + Self { + subdivisions: 8, + iterations: 30, + step: 4.0, + spring_k: 0.1, + electric_k: 0.05, + compat_threshold: 0.6, + } + } +} + +/// Path bundleado: secuencia de puntos `(x, y)` desde el endpoint +/// origen al destino, incluyendo ambos. +pub type Path = Vec<(f32, f32)>; + +/// Genera paths bundleados para cada arista de `edges`. Endpoints fijos +/// (las posiciones de los nodos no se modifican). +pub fn bundle(nodes: &NodeBuffer, edges: &EdgeBuffer, params: FdebParams) -> Vec { + let n_edges = edges.len(); + if n_edges == 0 { + return Vec::new(); + } + let subs = params.subdivisions.max(1); + + // Inicializa cada path como la línea recta entre los endpoints, + // muestreada en `subs + 2` puntos. + let mut paths: Vec = Vec::with_capacity(n_edges); + let endpoints: Vec<((f32, f32), (f32, f32))> = (0..n_edges) + .map(|i| { + let (u, v) = edges.edge(i); + let pu = if u < nodes.len() { nodes.pos(u) } else { (0.0, 0.0) }; + let pv = if v < nodes.len() { nodes.pos(v) } else { (0.0, 0.0) }; + (pu, pv) + }) + .collect(); + for (pu, pv) in &endpoints { + let mut path = Vec::with_capacity(subs + 2); + for i in 0..=(subs + 1) { + let t = i as f32 / (subs + 1) as f32; + path.push((pu.0 + (pv.0 - pu.0) * t, pu.1 + (pv.1 - pu.1) * t)); + } + paths.push(path); + } + + // Matriz de compatibilidad — sparse: sólo guardamos pares con + // compat ≥ threshold para acelerar el lazo interno. + let mut compat: Vec> = vec![Vec::new(); n_edges]; + for i in 0..n_edges { + let ei = endpoints[i]; + for j in (i + 1)..n_edges { + let ej = endpoints[j]; + let c = compatibility(ei, ej); + if c >= params.compat_threshold { + compat[i].push((j, c)); + compat[j].push((i, c)); + } + } + } + + // Iteraciones con cooling lineal. + for it in 0..params.iterations { + let cool = 1.0 - (it as f32 / params.iterations as f32); + let step = params.step * cool.max(0.05); + let snapshot = paths.clone(); + + for e in 0..n_edges { + // Endpoints fijos: i = 0 y i = subs+1. + for i in 1..=subs { + let p = snapshot[e][i]; + let prev = snapshot[e][i - 1]; + let next = snapshot[e][i + 1]; + + // Spring: tira hacia el promedio de los vecinos. + let target = ((prev.0 + next.0) * 0.5, (prev.1 + next.1) * 0.5); + let dx_sp = (target.0 - p.0) * params.spring_k; + let dy_sp = (target.1 - p.1) * params.spring_k; + + // Electric: atrae al punto i de cada edge compatible. + let mut dx_el = 0.0_f32; + let mut dy_el = 0.0_f32; + for &(other, c) in &compat[e] { + let q = snapshot[other][i]; + let dx = q.0 - p.0; + let dy = q.1 - p.1; + let d2 = dx * dx + dy * dy + 1.0; + let inv_d = 1.0 / d2.sqrt(); + let f = params.electric_k * c * inv_d; + dx_el += dx * f; + dy_el += dy * f; + } + + let total_dx = dx_sp + dx_el; + let total_dy = dy_sp + dy_el; + let len = (total_dx * total_dx + total_dy * total_dy).sqrt(); + let (mx, my) = if len > step { + (total_dx / len * step, total_dy / len * step) + } else { + (total_dx, total_dy) + }; + paths[e][i].0 += mx; + paths[e][i].1 += my; + } + } + } + + paths +} + +/// Compatibility entre dos edges (Holten-lite): producto de angle, +/// scale y position. Devuelve `[0, 1]`. +fn compatibility(a: ((f32, f32), (f32, f32)), b: ((f32, f32), (f32, f32))) -> f32 { + let va = (a.1 .0 - a.0 .0, a.1 .1 - a.0 .1); + let vb = (b.1 .0 - b.0 .0, b.1 .1 - b.0 .1); + let len_a = (va.0 * va.0 + va.1 * va.1).sqrt().max(1e-3); + let len_b = (vb.0 * vb.0 + vb.1 * vb.1).sqrt().max(1e-3); + + // Angle compat: |cos(theta)|. Edges anti-paralelas también son compatibles. + let cos_t = (va.0 * vb.0 + va.1 * vb.1) / (len_a * len_b); + let angle_c = cos_t.abs(); + + // Scale compat: 2 * min(la, lb) / ((la + lb) * max(la, lb) / min) — simplificado. + let lmin = len_a.min(len_b); + let lmax = len_a.max(len_b); + let l_avg = (len_a + len_b) * 0.5; + let scale_c = 2.0 / (l_avg / lmin + lmax / l_avg); + + // Position compat: cercanía de midpoints relativa a length promedio. + let mid_a = ((a.0 .0 + a.1 .0) * 0.5, (a.0 .1 + a.1 .1) * 0.5); + let mid_b = ((b.0 .0 + b.1 .0) * 0.5, (b.0 .1 + b.1 .1) * 0.5); + let d_mid = + ((mid_a.0 - mid_b.0).powi(2) + (mid_a.1 - mid_b.1).powi(2)).sqrt(); + let position_c = l_avg / (l_avg + d_mid); + + angle_c * scale_c * position_c +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_edges(positions: &[(f32, f32)], pairs: &[(usize, usize)]) -> (NodeBuffer, EdgeBuffer) { + let mut nb = NodeBuffer::new(); + for &(x, y) in positions { + nb.push(x, y, 3.0); + } + let mut eb = EdgeBuffer::new(); + for &(u, v) in pairs { + eb.push(u, v); + } + (nb, eb) + } + + #[test] + fn empty_input_returns_empty() { + let nb = NodeBuffer::new(); + let eb = EdgeBuffer::new(); + let paths = bundle(&nb, &eb, FdebParams::default()); + assert!(paths.is_empty()); + } + + #[test] + fn endpoints_stay_fixed_after_bundle() { + // 4 nodos, 2 edges paralelas (0→1, 2→3). + let (nb, eb) = make_edges( + &[(0.0, 0.0), (100.0, 0.0), (0.0, 20.0), (100.0, 20.0)], + &[(0, 1), (2, 3)], + ); + let paths = bundle(&nb, &eb, FdebParams::default()); + // Endpoints intactos. + assert!((paths[0][0].0 - 0.0).abs() < 1e-3 && (paths[0][0].1 - 0.0).abs() < 1e-3); + let last0 = *paths[0].last().unwrap(); + assert!((last0.0 - 100.0).abs() < 1e-3); + } + + #[test] + fn parallel_edges_get_pulled_together() { + // Dos edges horizontales separadas verticalmente: tras bundle + // los midpoints deberían acercarse. + let (nb, eb) = make_edges( + &[(0.0, 0.0), (200.0, 0.0), (0.0, 40.0), (200.0, 40.0)], + &[(0, 1), (2, 3)], + ); + let mut params = FdebParams::default(); + params.iterations = 60; + params.electric_k = 0.2; + let paths = bundle(&nb, &eb, params); + let mid_idx = paths[0].len() / 2; + let m0 = paths[0][mid_idx]; + let m1 = paths[1][mid_idx]; + let dy = (m0.1 - m1.1).abs(); + assert!(dy < 40.0, "esperaba bundling, midpoints siguen a dy={dy}"); + } + + #[test] + fn orthogonal_edges_do_not_bundle() { + // Edge horizontal + edge vertical: compat angular ≈ 0. + let (nb, eb) = make_edges( + &[(0.0, 0.0), (200.0, 0.0), (100.0, -100.0), (100.0, 100.0)], + &[(0, 1), (2, 3)], + ); + let paths = bundle(&nb, &eb, FdebParams::default()); + // El path original era recto y debería seguir cerca de recto. + let mid = paths[0][paths[0].len() / 2]; + // Midpoint del path original recto: (100, 0). Si bundleó con el + // vertical, se habría movido lejos. Tolerancia generosa. + assert!(mid.1.abs() < 5.0, "edge horizontal se torció: {mid:?}"); + } + + #[test] + fn path_has_subdivisions_plus_two_points() { + let (nb, eb) = make_edges(&[(0.0, 0.0), (100.0, 0.0)], &[(0, 1)]); + let mut params = FdebParams::default(); + params.subdivisions = 10; + let paths = bundle(&nb, &eb, params); + assert_eq!(paths[0].len(), 12); + } +} diff --git a/00_unanchay/pineal/pineal-mesh/src/force.rs b/00_unanchay/pineal/pineal-mesh/src/force.rs new file mode 100644 index 0000000..7b9c90a --- /dev/null +++ b/00_unanchay/pineal/pineal-mesh/src/force.rs @@ -0,0 +1,264 @@ +//! Layout force-directed (Fruchterman-Reingold). +//! +//! Repulsión entre todo par de nodos + atracción a lo largo de las +//! aristas, integrado con cooling. Dos variantes del paso: +//! +//! - [`ForceLayout::step`] — O(n²) naïve. Hasta ~1 K nodos, exacto. +//! - [`ForceLayout::step_bh`] — O(n log n) Barnes-Hut, con parámetro +//! `theta` (típico 0.5). Apta para 10⁴–10⁵ nodos. + +use crate::barnes_hut::Quadtree; +use crate::buffers::{EdgeBuffer, NodeBuffer}; + +/// Parámetros de la simulación. +#[derive(Debug, Clone, Copy)] +pub struct ForceParams { + /// Distancia ideal entre nodos conectados. + pub k: f32, + /// Desplazamiento máximo inicial por paso (se enfría). + pub temperature: f32, + /// Factor de enfriamiento aplicado cada paso (`0 < cooling < 1`). + pub cooling: f32, +} + +impl Default for ForceParams { + fn default() -> Self { + Self { k: 50.0, temperature: 50.0, cooling: 0.95 } + } +} + +/// Estado de una simulación force-directed. +pub struct ForceLayout { + params: ForceParams, + temp: f32, +} + +impl ForceLayout { + pub fn new(params: ForceParams) -> Self { + let temp = params.temperature; + Self { params, temp } + } + + /// Temperatura actual (baja con cada paso — útil para detectar fin). + pub fn temperature(&self) -> f32 { + self.temp + } + + /// Un paso de simulación. Muta las posiciones de `nodes`. Devuelve el + /// desplazamiento total aplicado (converge hacia 0). + pub fn step(&mut self, nodes: &mut NodeBuffer, edges: &EdgeBuffer) -> f32 { + let n = nodes.len(); + if n == 0 { + return 0.0; + } + let k = self.params.k.max(1e-3); + let mut disp = vec![(0.0f32, 0.0f32); n]; + + // Repulsión: todo par. f_r = k² / d. + for i in 0..n { + let (xi, yi) = nodes.pos(i); + for j in (i + 1)..n { + let (xj, yj) = nodes.pos(j); + let mut dx = xi - xj; + let mut dy = yi - yj; + let mut dist = (dx * dx + dy * dy).sqrt(); + if dist < 1e-3 { + // Jitter determinista para despegar nodos coincidentes. + dx = ((i as f32) - (j as f32)) * 0.01 + 0.01; + dy = 0.01; + dist = (dx * dx + dy * dy).sqrt(); + } + let f = k * k / dist; + let (ux, uy) = (dx / dist, dy / dist); + disp[i].0 += ux * f; + disp[i].1 += uy * f; + disp[j].0 -= ux * f; + disp[j].1 -= uy * f; + } + } + + // Atracción: a lo largo de cada arista. f_a = d² / k. + for (u, v) in edges.iter() { + if u >= n || v >= n || u == v { + continue; + } + let (xu, yu) = nodes.pos(u); + let (xv, yv) = nodes.pos(v); + let dx = xu - xv; + let dy = yu - yv; + let dist = (dx * dx + dy * dy).sqrt().max(1e-3); + let f = dist * dist / k; + let (ux, uy) = (dx / dist, dy / dist); + disp[u].0 -= ux * f; + disp[u].1 -= uy * f; + disp[v].0 += ux * f; + disp[v].1 += uy * f; + } + + // Integración con cap de temperatura. + let mut total = 0.0f32; + for i in 0..n { + let (dx, dy) = disp[i]; + let len = (dx * dx + dy * dy).sqrt(); + if len < 1e-6 { + continue; + } + let capped = len.min(self.temp); + let (mx, my) = (dx / len * capped, dy / len * capped); + let (x, y) = nodes.pos(i); + nodes.set_pos(i, x + mx, y + my); + total += capped; + } + self.temp *= self.params.cooling; + total + } + + /// Igual semántica que [`Self::step`] pero la repulsión se + /// aproxima con un quadtree Barnes-Hut. `theta` controla la + /// agresividad de la aproximación (0.0 = exacto/lento, ~0.5 + /// = balance, 1.0 = grosero). La atracción y la integración + /// quedan iguales — Barnes-Hut sólo aplica al lazo de pares. + pub fn step_bh(&mut self, nodes: &mut NodeBuffer, edges: &EdgeBuffer, theta: f32) -> f32 { + let n = nodes.len(); + if n == 0 { + return 0.0; + } + let k = self.params.k.max(1e-3); + let mut disp = vec![(0.0f32, 0.0f32); n]; + + // Repulsión vía Barnes-Hut. + let positions: Vec<(f32, f32)> = (0..n).map(|i| nodes.pos(i)).collect(); + let qt = Quadtree::build(&positions); + for i in 0..n { + let f = qt.force_on(positions[i], i, k, theta.max(0.0)); + disp[i].0 += f.0; + disp[i].1 += f.1; + } + + // Atracción a lo largo de aristas, idem `step`. + for (u, v) in edges.iter() { + if u >= n || v >= n || u == v { + continue; + } + let (xu, yu) = nodes.pos(u); + let (xv, yv) = nodes.pos(v); + let dx = xu - xv; + let dy = yu - yv; + let dist = (dx * dx + dy * dy).sqrt().max(1e-3); + let f = dist * dist / k; + let (ux, uy) = (dx / dist, dy / dist); + disp[u].0 -= ux * f; + disp[u].1 -= uy * f; + disp[v].0 += ux * f; + disp[v].1 += uy * f; + } + + // Integración + cooling, idéntico a `step`. + let mut total = 0.0f32; + for i in 0..n { + let (dx, dy) = disp[i]; + let len = (dx * dx + dy * dy).sqrt(); + if len < 1e-6 { + continue; + } + let capped = len.min(self.temp); + let (mx, my) = (dx / len * capped, dy / len * capped); + let (x, y) = nodes.pos(i); + nodes.set_pos(i, x + mx, y + my); + total += capped; + } + self.temp *= self.params.cooling; + total + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn two_connected_nodes_settle_near_k() { + let mut nb = NodeBuffer::new(); + nb.push(0.0, 0.0, 5.0); + nb.push(500.0, 0.0, 5.0); // arrancan muy lejos + let mut eb = EdgeBuffer::new(); + eb.push(0, 1); + let mut fl = ForceLayout::new(ForceParams::default()); + for _ in 0..400 { + fl.step(&mut nb, &eb); + } + let (x0, y0) = nb.pos(0); + let (x1, y1) = nb.pos(1); + let dist = ((x1 - x0).powi(2) + (y1 - y0).powi(2)).sqrt(); + // No deberían quedar ni pegados ni a 500 de distancia. + assert!(dist > 5.0 && dist < 300.0, "dist tras converger = {dist}"); + } + + #[test] + fn coincident_nodes_do_not_nan() { + let mut nb = NodeBuffer::new(); + nb.push(10.0, 10.0, 5.0); + nb.push(10.0, 10.0, 5.0); + let eb = EdgeBuffer::new(); + let mut fl = ForceLayout::new(ForceParams::default()); + fl.step(&mut nb, &eb); + let (x, y) = nb.pos(0); + assert!(x.is_finite() && y.is_finite()); + } + + #[test] + fn empty_graph_is_noop() { + let mut nb = NodeBuffer::new(); + let eb = EdgeBuffer::new(); + let mut fl = ForceLayout::new(ForceParams::default()); + assert_eq!(fl.step(&mut nb, &eb), 0.0); + } + + #[test] + fn step_bh_converges_to_similar_layout_as_naive() { + // 12 nodos en ciclo. Naïve y Barnes-Hut deberían converger a + // tamaños similares (dentro del 30%). + fn build() -> (NodeBuffer, EdgeBuffer) { + let mut nb = NodeBuffer::new(); + for i in 0..12 { + let a = (i as f32 / 12.0) * std::f32::consts::TAU; + nb.push(80.0 * a.cos(), 80.0 * a.sin(), 4.0); + } + let mut eb = EdgeBuffer::new(); + for i in 0..12 { + eb.push(i, (i + 1) % 12); + } + (nb, eb) + } + let (mut nb_n, eb_n) = build(); + let mut fl_n = ForceLayout::new(ForceParams::default()); + for _ in 0..200 { + fl_n.step(&mut nb_n, &eb_n); + } + let (mut nb_b, eb_b) = build(); + let mut fl_b = ForceLayout::new(ForceParams::default()); + for _ in 0..200 { + fl_b.step_bh(&mut nb_b, &eb_b, 0.5); + } + let radius = |nb: &NodeBuffer| -> f32 { + let mut rmax = 0.0f32; + for i in 0..nb.len() { + let (x, y) = nb.pos(i); + rmax = rmax.max((x * x + y * y).sqrt()); + } + rmax + }; + let r_n = radius(&nb_n); + let r_b = radius(&nb_b); + let ratio = (r_b - r_n).abs() / r_n.max(1.0); + assert!(ratio < 0.30, "naive r={r_n}, BH r={r_b} (>30% off)"); + } + + #[test] + fn step_bh_empty_is_noop() { + let mut nb = NodeBuffer::new(); + let eb = EdgeBuffer::new(); + let mut fl = ForceLayout::new(ForceParams::default()); + assert_eq!(fl.step_bh(&mut nb, &eb, 0.5), 0.0); + } +} diff --git a/00_unanchay/pineal/pineal-mesh/src/hierarchical.rs b/00_unanchay/pineal/pineal-mesh/src/hierarchical.rs new file mode 100644 index 0000000..60d806d --- /dev/null +++ b/00_unanchay/pineal/pineal-mesh/src/hierarchical.rs @@ -0,0 +1,229 @@ +//! Sugiyama-lite — layout layered para DAGs (o casi-DAGs). +//! +//! Pipeline en tres etapas, todas O(V + E): +//! 1. **Cycle removal** (DFS): cualquier back-edge se invierte +//! lógicamente para producir un DAG con el que trabajar. +//! 2. **Layering** (Kahn longest-path): asigna a cada nodo una capa +//! `0..L` tal que cada arista va de capa `i` a capa `j > i`. +//! 3. **Barycenter ordering** (2 pasadas): minimiza cruces sumando +//! la posición promedio de los vecinos de la capa anterior y +//! re-ordenando. +//! +//! Devuelve la posición final `(x, y)` de cada nodo en pixels, +//! contenida en el `Rect` provisto. La integración con `NodeBuffer` +//! la hace el caller (`set_pos` por nodo). + +use pineal_render::Rect; + +/// Resultado del layout: posición por nodo (mismo índice que la +/// entrada) + información de capas para el caller que quiera dibujar +/// guías o agrupaciones. +#[derive(Debug, Clone)] +pub struct HierarchicalLayout { + pub positions: Vec<(f32, f32)>, + pub layers: Vec>, +} + +/// Calcula el layout. `edges` son pares `(from, to)`; los nodos +/// existen para todos los índices `0..n`. `area` es el rectángulo +/// destino; nodos se distribuyen uniformemente dentro. +pub fn sugiyama_layout(n: usize, edges: &[(usize, usize)], area: Rect) -> HierarchicalLayout { + if n == 0 { + return HierarchicalLayout { positions: Vec::new(), layers: Vec::new() }; + } + + // === 1. Cycle removal por DFS — marca back-edges. + let mut adj: Vec> = vec![Vec::new(); n]; + for &(u, v) in edges { + if u < n && v < n && u != v { + adj[u].push(v); + } + } + 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<(usize, usize)> = vec![(s, 0)]; + 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(); + } + } + } + + // === 2. Longest-path layering (Kahn) sobre el DAG (sin back-edges). + let mut dag: Vec> = vec![Vec::new(); n]; + let mut indeg = vec![0usize; n]; + for &(u, v) in edges { + if u < n && v < n && u != v && !back.contains(&(u, v)) { + dag[u].push(v); + indeg[v] += 1; + } + } + let mut layer = vec![0usize; n]; + let mut queue: Vec = (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] { + layer[v] = layer[v].max(layer[u] + 1); + indeg[v] -= 1; + if indeg[v] == 0 { + queue.push(v); + } + } + } + let n_layers = layer.iter().copied().max().unwrap_or(0) + 1; + let mut by_layer: Vec> = vec![Vec::new(); n_layers]; + for (i, &l) in layer.iter().enumerate() { + by_layer[l].push(i); + } + + // === 3. Barycenter — dos pasadas (down + up) para reducir cruces. + let mut order = vec![0usize; n]; + for col in by_layer.iter() { + for (pos, &node) in col.iter().enumerate() { + order[node] = pos; + } + } + for _ in 0..2 { + // Down (capa i mira a sus padres en capa i-1). + for c in 1..n_layers { + let mut paired: Vec<(usize, f64)> = by_layer[c] + .iter() + .map(|&node| { + let mut sum = 0.0; + let mut cnt = 0.0; + for &(u, v) in edges { + if v == node && u < n && layer[u] + 1 == c { + sum += order[u] as f64; + cnt += 1.0; + } + } + (node, if cnt > 0.0 { sum / cnt } else { f64::MAX }) + }) + .collect(); + paired.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)); + by_layer[c] = paired.into_iter().map(|(n, _)| n).collect(); + for (pos, &i) in by_layer[c].iter().enumerate() { + order[i] = pos; + } + } + // Up (capa i mira a sus hijos en capa i+1). + for c in (0..n_layers.saturating_sub(1)).rev() { + let mut paired: Vec<(usize, f64)> = by_layer[c] + .iter() + .map(|&node| { + let mut sum = 0.0; + let mut cnt = 0.0; + for &(u, v) in edges { + if u == node && v < n && layer[v] == c + 1 { + sum += order[v] as f64; + cnt += 1.0; + } + } + (node, if cnt > 0.0 { sum / cnt } else { f64::MAX }) + }) + .collect(); + paired.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)); + by_layer[c] = paired.into_iter().map(|(n, _)| n).collect(); + for (pos, &i) in by_layer[c].iter().enumerate() { + order[i] = pos; + } + } + } + + // === 4. Asignar posiciones — `x` por capa, `y` por orden dentro. + let mut positions = vec![(0.0f32, 0.0); n]; + let dx = if n_layers > 1 { + area.w / (n_layers - 1) as f32 + } else { + 0.0 + }; + for (c, col) in by_layer.iter().enumerate() { + let dy = if col.len() > 1 { + area.h / (col.len() - 1) as f32 + } else { + 0.0 + }; + let cx = area.x + c as f32 * dx; + for (pos, &node) in col.iter().enumerate() { + let cy = if col.len() == 1 { + area.y + area.h * 0.5 + } else { + area.y + pos as f32 * dy + }; + positions[node] = (cx, cy); + } + } + + HierarchicalLayout { positions, layers: by_layer } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_input_returns_empty() { + let l = sugiyama_layout(0, &[], Rect::new(0.0, 0.0, 100.0, 100.0)); + assert!(l.positions.is_empty()); + } + + #[test] + fn chain_assigns_increasing_x() { + // 0 → 1 → 2 → 3 + let edges = [(0, 1), (1, 2), (2, 3)]; + let l = sugiyama_layout(4, &edges, Rect::new(0.0, 0.0, 300.0, 100.0)); + assert_eq!(l.layers.len(), 4); + let xs: Vec = l.positions.iter().map(|p| p.0).collect(); + assert!(xs[0] < xs[1] && xs[1] < xs[2] && xs[2] < xs[3]); + } + + #[test] + fn cycle_does_not_loop() { + // 0 → 1 → 0 + let edges = [(0, 1), (1, 0)]; + let l = sugiyama_layout(2, &edges, Rect::new(0.0, 0.0, 100.0, 100.0)); + assert_eq!(l.positions.len(), 2); + assert!(l.positions[0].0.is_finite() && l.positions[0].1.is_finite()); + } + + #[test] + fn fan_out_distributes_children_vertically() { + // 0 → {1, 2, 3, 4} + let edges = [(0, 1), (0, 2), (0, 3), (0, 4)]; + let l = sugiyama_layout(5, &edges, Rect::new(0.0, 0.0, 200.0, 200.0)); + let ys: Vec = (1..=4).map(|i| l.positions[i].1).collect(); + let unique_ys: std::collections::HashSet = + ys.iter().map(|y| (*y * 10.0) as i32).collect(); + assert!(unique_ys.len() >= 3, "ys deberían ser distintos: {ys:?}"); + } + + #[test] + fn positions_fall_within_area() { + let edges = [(0, 1), (1, 2), (0, 3), (3, 4)]; + let area = Rect::new(50.0, 100.0, 400.0, 300.0); + let l = sugiyama_layout(5, &edges, area); + for &(x, y) in &l.positions { + assert!(x >= area.x && x <= area.x + area.w + 1.0); + assert!(y >= area.y && y <= area.y + area.h + 1.0); + } + } +} diff --git a/00_unanchay/pineal/pineal-mesh/src/lib.rs b/00_unanchay/pineal/pineal-mesh/src/lib.rs new file mode 100644 index 0000000..399d66a --- /dev/null +++ b/00_unanchay/pineal/pineal-mesh/src/lib.rs @@ -0,0 +1,33 @@ +//! `pineal-mesh` — visualización de grafos. +//! +//! - [`buffers`] — `NodeBuffer` / `EdgeBuffer`: `Vec` planos con stride +//! fijo (3 floats por nodo `[x,y,radius]`, 2 por arista). +//! - [`spatial_hash`] — uniform grid para hit-test de nodos móviles. +//! - [`force`] — layout force-directed (Fruchterman-Reingold), naïve +//! O(n²) y Barnes-Hut O(n log n) según el método llamado. +//! - [`barnes_hut`] — quadtree de aproximación para fuerza repulsiva. +//! - [`fdeb`] — Force-Directed Edge Bundling (lite) para grafos con +//! muchas aristas casi-paralelas. +//! - [`tree`] — layout de árbol por ancho de subárbol. +//! - [`hierarchical`] — Sugiyama-lite (DAGs por capas, mínimo cruce). +//! - [`camera`] — pan/zoom con zoom anclado al cursor. + +#![forbid(unsafe_code)] + +pub mod barnes_hut; +pub mod buffers; +pub mod camera; +pub mod fdeb; +pub mod force; +pub mod hierarchical; +pub mod spatial_hash; +pub mod tree; + +pub use barnes_hut::Quadtree; +pub use buffers::{EdgeBuffer, NodeBuffer}; +pub use camera::Camera; +pub use fdeb::{bundle, FdebParams, Path}; +pub use force::{ForceLayout, ForceParams}; +pub use hierarchical::{sugiyama_layout, HierarchicalLayout}; +pub use spatial_hash::SpatialHash; +pub use tree::tree_layout; diff --git a/00_unanchay/pineal/pineal-mesh/src/spatial_hash.rs b/00_unanchay/pineal/pineal-mesh/src/spatial_hash.rs new file mode 100644 index 0000000..aade27e --- /dev/null +++ b/00_unanchay/pineal/pineal-mesh/src/spatial_hash.rs @@ -0,0 +1,80 @@ +//! Uniform grid para hit-test de nodos móviles. + +use crate::buffers::NodeBuffer; +use std::collections::HashMap; + +/// Grid de celdas cuadradas. `rebuild` lo repuebla; `query` busca el +/// nodo cuyo radio cubre un punto. +pub struct SpatialHash { + cell: f32, + map: HashMap<(i32, i32), Vec>, +} + +impl SpatialHash { + /// `cell_size` conviene ~2× el radio típico de nodo. + pub fn new(cell_size: f32) -> Self { + Self { cell: cell_size.max(1.0), map: HashMap::new() } + } + + fn cell_of(&self, x: f32, y: f32) -> (i32, i32) { + ((x / self.cell).floor() as i32, (y / self.cell).floor() as i32) + } + + /// Repuebla el grid con las posiciones actuales de los nodos. + pub fn rebuild(&mut self, nodes: &NodeBuffer) { + self.map.clear(); + for i in 0..nodes.len() { + let (x, y) = nodes.pos(i); + self.map.entry(self.cell_of(x, y)).or_default().push(i); + } + } + + /// Devuelve el nodo más cercano a `(x,y)` cuyo radio lo cubre, o + /// `None`. Revisa la celda del punto y sus 8 vecinas. + pub fn query(&self, nodes: &NodeBuffer, x: f32, y: f32) -> Option { + let (cx, cy) = self.cell_of(x, y); + let mut best: Option<(usize, f32)> = None; + for dy in -1..=1 { + for dx in -1..=1 { + if let Some(bucket) = self.map.get(&(cx + dx, cy + dy)) { + for &i in bucket { + let (nx, ny) = nodes.pos(i); + let r = nodes.radius(i); + let d2 = (nx - x).powi(2) + (ny - y).powi(2); + if d2 <= r * r && best.map(|(_, bd)| d2 < bd).unwrap_or(true) { + best = Some((i, d2)); + } + } + } + } + } + best.map(|(i, _)| i) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn query_hits_node_under_point() { + let mut nb = NodeBuffer::new(); + nb.push(10.0, 10.0, 5.0); + nb.push(100.0, 100.0, 8.0); + let mut sh = SpatialHash::new(20.0); + sh.rebuild(&nb); + assert_eq!(sh.query(&nb, 12.0, 11.0), Some(0)); + assert_eq!(sh.query(&nb, 103.0, 98.0), Some(1)); + } + + #[test] + fn query_misses_empty_space() { + let mut nb = NodeBuffer::new(); + nb.push(10.0, 10.0, 5.0); + let mut sh = SpatialHash::new(20.0); + sh.rebuild(&nb); + assert_eq!(sh.query(&nb, 500.0, 500.0), None); + // fuera del radio pero misma celda + assert_eq!(sh.query(&nb, 18.0, 18.0), None); + } +} diff --git a/00_unanchay/pineal/pineal-mesh/src/tree.rs b/00_unanchay/pineal/pineal-mesh/src/tree.rs new file mode 100644 index 0000000..4241b29 --- /dev/null +++ b/00_unanchay/pineal/pineal-mesh/src/tree.rs @@ -0,0 +1,105 @@ +//! Layout de árbol por ancho de subárbol. +//! +//! Post-order: las hojas se ubican en columnas consecutivas; cada nodo +//! interno se centra sobre sus hijos. `y` es la profundidad. Soporta +//! bosque (múltiples raíces). Ciclos en los punteros `parent` se ignoran +//! con gracia (esos nodos quedan en el origen). + +/// Calcula `(x, y)` por nodo. `parent[i] = None` marca una raíz. +pub fn tree_layout(parent: &[Option], x_gap: f32, y_gap: f32) -> Vec<(f32, f32)> { + let n = parent.len(); + let mut pos = vec![(0.0f32, 0.0f32); n]; + if n == 0 { + return pos; + } + + let mut children: Vec> = vec![Vec::new(); n]; + let mut roots: Vec = Vec::new(); + for i in 0..n { + match parent[i] { + Some(p) if p < n && p != i => children[p].push(i), + _ => roots.push(i), + } + } + + // Profundidad por BFS desde las raíces. + let mut depth = vec![0usize; n]; + let mut visited = vec![false; n]; + let mut queue: Vec = roots.clone(); + for &r in &roots { + visited[r] = true; + } + let mut head = 0; + while head < queue.len() { + let u = queue[head]; + head += 1; + for &c in &children[u] { + if !visited[c] { + visited[c] = true; + depth[c] = depth[u] + 1; + queue.push(c); + } + } + } + + // Asignación de `x` por post-order iterativo, raíz por raíz. + let mut next_leaf = 0.0f32; + for &root in &roots { + let mut stack: Vec<(usize, usize)> = vec![(root, 0)]; + while let Some(&mut (u, ref mut ci)) = stack.last_mut() { + if *ci < children[u].len() { + let c = children[u][*ci]; + *ci += 1; + stack.push((c, 0)); + } else { + if children[u].is_empty() { + pos[u].0 = next_leaf; + next_leaf += x_gap; + } else { + let sum: f32 = children[u].iter().map(|&c| pos[c].0).sum(); + pos[u].0 = sum / children[u].len() as f32; + } + pos[u].1 = depth[u] as f32 * y_gap; + stack.pop(); + } + } + } + pos +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn single_node_at_origin() { + let pos = tree_layout(&[None], 10.0, 20.0); + assert_eq!(pos, vec![(0.0, 0.0)]); + } + + #[test] + fn parent_centered_over_two_children() { + // 0 raíz; 1 y 2 hijos. + let pos = tree_layout(&[None, Some(0), Some(0)], 10.0, 20.0); + assert_eq!(pos[1], (0.0, 20.0)); + assert_eq!(pos[2], (10.0, 20.0)); + // padre centrado en x = (0+10)/2 = 5, depth 0. + assert_eq!(pos[0], (5.0, 0.0)); + } + + #[test] + fn depth_increases_down_the_tree() { + // cadena 0 → 1 → 2 + let pos = tree_layout(&[None, Some(0), Some(1)], 10.0, 20.0); + assert_eq!(pos[0].1, 0.0); + assert_eq!(pos[1].1, 20.0); + assert_eq!(pos[2].1, 40.0); + } + + #[test] + fn cycle_in_parents_does_not_hang() { + // 0 ↔ 1 sin raíz: no debe colgar. + let pos = tree_layout(&[Some(1), Some(0)], 10.0, 20.0); + assert_eq!(pos.len(), 2); + } +} diff --git a/00_unanchay/pineal/pineal-phosphor/Cargo.toml b/00_unanchay/pineal/pineal-phosphor/Cargo.toml new file mode 100644 index 0000000..4bf10e8 --- /dev/null +++ b/00_unanchay/pineal/pineal-phosphor/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "pineal-phosphor" +version = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +authors = { workspace = true } +publish = { workspace = true } +description = "Lapaloma — decoración CRT sobre lapaloma-stream: trail con alpha decay por edad, ghost, anotaciones magnéticas ancladas a sample index." + +[dependencies] +pineal-core = { path = "../pineal-core" } +pineal-render = { path = "../pineal-render" } +llimphi-ui = { workspace = true } diff --git a/00_unanchay/pineal/pineal-phosphor/LEEME.md b/00_unanchay/pineal/pineal-phosphor/LEEME.md new file mode 100644 index 0000000..1602ab7 --- /dev/null +++ b/00_unanchay/pineal/pineal-phosphor/LEEME.md @@ -0,0 +1,18 @@ +# pineal-phosphor + +> Canvas con persistencia de fósforo para [pineal](../README.md). Estilo osciloscopio. + +Cada frame nuevo se compone sobre los anteriores con decay exponencial: el trazo "permanece" como en un CRT viejo. Ideal para waveforms, lissajous, signal monitoring donde te interesa ver el "ghost" del último período. + +## API + +```rust +use pineal_phosphor::{Phosphor, Params}; + +let p = Phosphor::new(Params { decay: 0.95, glow: 1.2, ..Default::default() }); +p.push(&samples); +``` + +## Deps + +- [`pineal-core`](../pineal-core/README.md) diff --git a/00_unanchay/pineal/pineal-phosphor/README.md b/00_unanchay/pineal/pineal-phosphor/README.md new file mode 100644 index 0000000..92cf8ff --- /dev/null +++ b/00_unanchay/pineal/pineal-phosphor/README.md @@ -0,0 +1,18 @@ +# pineal-phosphor + +> Phosphor-persistence canvas for [pineal](../README.md). Oscilloscope style. + +Each new frame composites onto previous ones with exponential decay: the trace "persists" like an old CRT. Ideal for waveforms, lissajous, signal monitoring where you want to see the "ghost" of the last period. + +## API + +```rust +use pineal_phosphor::{Phosphor, Params}; + +let p = Phosphor::new(Params { decay: 0.95, glow: 1.2, ..Default::default() }); +p.push(&samples); +``` + +## Deps + +- [`pineal-core`](../pineal-core/README.md) diff --git a/00_unanchay/pineal/pineal-phosphor/src/lib.rs b/00_unanchay/pineal/pineal-phosphor/src/lib.rs new file mode 100644 index 0000000..5a5fc42 --- /dev/null +++ b/00_unanchay/pineal/pineal-phosphor/src/lib.rs @@ -0,0 +1,31 @@ +//! `pineal-phosphor` — decoración CRT sobre `pineal_core::RingBuffer`. +//! +//! El "real" oscilloscope-trail effect de la sección 4.3 del +//! ARCHITECTURE.md renderea cada sample como **2 vértices** +//! (top y bottom, offset ±half_width) atados a un triangle strip +//! con per-vertex color. GPUI 0.2 no expone triangle strips con +//! atributos de vértice de forma directa. +//! +//! v0.1 implementa el efecto con un approach distinto pero +//! visualmente similar: el trail se divide en N **segmentos** +//! consecutivos del ring, cada uno se pinta como una `stroke_polyline` +//! con alpha decreciente del más nuevo (1.0) al más viejo (≈ 0). +//! Cada segmento incluye el primer sample del siguiente para no +//! dejar gaps visibles entre tramos. +//! +//! Coste: N draw calls por frame en lugar de 2 del stream simple. +//! Para N = 16 y ring cap = 512 son sub-millisecond en cualquier +//! laptop moderna. +//! +//! Cuando GPUI/wgpu expongan triangle strip + per-vertex color, +//! la siguiente fase reemplaza esta impl por la canónica. + +#![forbid(unsafe_code)] +#![allow(dead_code)] + +pub mod ghost {} +pub mod magnetic_anchor {} + +pub mod view; + +pub use view::{pineal_phosphor_view, PhosphorView}; diff --git a/00_unanchay/pineal/pineal-phosphor/src/view.rs b/00_unanchay/pineal/pineal-phosphor/src/view.rs new file mode 100644 index 0000000..0d998d4 --- /dev/null +++ b/00_unanchay/pineal/pineal-phosphor/src/view.rs @@ -0,0 +1,196 @@ +//! Vista Llimphi del trail CRT. +//! +//! Mismo enfoque que el Element GPUI: el RingBuffer se parte en N +//! segmentos polilínea con alpha decreciente del más nuevo al más +//! viejo. Wraparound se parte en dos sub-polilíneas para evitar la +//! línea horizontal del slot cap-1 → 0. Opcionalmente bajo cada +//! tramo se pinta una pasada "glow" con stroke más ancho y alpha +//! reducido (efecto halo CRT). +//! +//! No usa `fill_triangle_strip` — sólo `stroke_polyline`. Paridad +//! visual completa con el Element GPUI. + +use llimphi_ui::llimphi_layout::taffy::prelude::{percent, Size, Style}; +use llimphi_ui::View; + +use pineal_core::ring::RingBuffer; +use pineal_render::{Canvas as _, Color, Rect, SceneCanvas, StrokeStyle}; + +const DEFAULT_TRAIL_SEGMENTS: usize = 16; + +pub struct PhosphorView { + buffer: RingBuffer, + base_stroke: StrokeStyle, + background: Option, + y_min: f32, + y_max: f32, + padding: f32, + trail_segments: usize, + glow_width_mult: f32, + glow_alpha: f32, +} + +impl PhosphorView { + pub fn new(buffer: RingBuffer, base_stroke: StrokeStyle) -> Self { + Self { + buffer, + base_stroke, + background: None, + y_min: -1.0, + y_max: 1.0, + padding: 8.0, + trail_segments: DEFAULT_TRAIL_SEGMENTS, + glow_width_mult: 3.0, + glow_alpha: 0.25, + } + } + + pub fn background(mut self, color: Color) -> Self { + self.background = Some(color); + self + } + pub fn y_range(mut self, min: f32, max: f32) -> Self { + debug_assert!(max > min); + self.y_min = min; + self.y_max = max; + self + } + pub fn trail_segments(mut self, n: usize) -> Self { + self.trail_segments = n.max(2); + self + } + pub fn glow(mut self, width_mult: f32, alpha: f32) -> Self { + self.glow_width_mult = width_mult; + self.glow_alpha = alpha; + self + } + pub fn no_glow(mut self) -> Self { + self.glow_width_mult = 0.0; + self.glow_alpha = 0.0; + self + } + + pub fn view(self) -> View { + let PhosphorView { + buffer, + base_stroke, + background, + y_min, + y_max, + padding, + trail_segments, + glow_width_mult, + glow_alpha, + } = 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 + padding, + outer.y + padding, + (outer.w - padding * 2.0).max(1.0), + (outer.h - padding * 2.0).max(1.0), + ); + + let mut canvas = SceneCanvas::new(scene, typesetter); + + if let Some(bg) = background { + canvas.fill_rect(outer, bg); + } + + let filled = buffer.filled_len(); + if filled < 2 { + return; + } + let cap = buffer.capacity(); + let head = buffer.head(); + let coords = buffer.coords(); + let start_slot = if buffer.is_full() { head } else { 0 }; + let n_segs = trail_segments.min(filled / 2).max(2); + let base_per_seg = filled / n_segs; + let glow_enabled = glow_alpha > 0.0 && glow_width_mult > 1.0; + + let mut scratch: Vec = Vec::new(); + + for k in 0..n_segs { + let t_lo = k * base_per_seg; + let t_hi = if k == n_segs - 1 { filled } else { (k + 1) * base_per_seg + 1 }; + if t_hi <= t_lo + 1 { + continue; + } + + let life = (k as f32 + 1.0) / n_segs as f32; + let alpha = base_stroke.color.a * life; + let mut color = base_stroke.color; + color.a = alpha; + let stroke = StrokeStyle::new(base_stroke.width, color); + + let glow_stroke = if glow_enabled { + let mut gc = color; + gc.a *= glow_alpha; + Some(StrokeStyle::new(base_stroke.width * glow_width_mult, gc)) + } else { + None + }; + + let seg_len = t_hi - t_lo; + let abs_start = (start_slot + t_lo) % cap; + let contiguous_len = cap - abs_start; + + if seg_len <= contiguous_len { + let slice = &coords[abs_start * 2..(abs_start + seg_len) * 2]; + scratch.clear(); + project_segment(slice, plot, y_min, y_max, &mut scratch); + if scratch.len() >= 4 { + if let Some(gs) = glow_stroke { + canvas.stroke_polyline(&scratch, gs); + } + canvas.stroke_polyline(&scratch, stroke); + } + } else { + let slice_a = &coords[abs_start * 2..]; + scratch.clear(); + project_segment(slice_a, plot, y_min, y_max, &mut scratch); + if scratch.len() >= 4 { + if let Some(gs) = glow_stroke { + canvas.stroke_polyline(&scratch, gs); + } + canvas.stroke_polyline(&scratch, stroke); + } + let remaining = seg_len - contiguous_len; + let slice_b = &coords[..remaining * 2]; + scratch.clear(); + project_segment(slice_b, plot, y_min, y_max, &mut scratch); + if scratch.len() >= 4 { + if let Some(gs) = glow_stroke { + canvas.stroke_polyline(&scratch, gs); + } + canvas.stroke_polyline(&scratch, stroke); + } + } + } + }) + } +} + +fn project_segment(segment: &[f32], plot: Rect, y_min: f32, y_max: f32, out: &mut Vec) { + let y_span = y_max - y_min; + if y_span.abs() < 1e-9 { + return; + } + let inv = 1.0 / y_span; + for chunk in segment.chunks_exact(2) { + let xn = chunk[0]; + let yv = chunk[1]; + let py_norm = (yv - y_min) * inv; + out.push(plot.x + xn * plot.w); + out.push(plot.bottom() - py_norm * plot.h); + } +} + +pub fn pineal_phosphor_view(buffer: RingBuffer, base_stroke: StrokeStyle) -> PhosphorView { + PhosphorView::new(buffer, base_stroke) +} diff --git a/00_unanchay/pineal/pineal-polar/Cargo.toml b/00_unanchay/pineal/pineal-polar/Cargo.toml new file mode 100644 index 0000000..f3587dc --- /dev/null +++ b/00_unanchay/pineal/pineal-polar/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "pineal-polar" +version = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +authors = { workspace = true } +publish = { workspace = true } +description = "Lapaloma — gráficos polares: pie chart, donut, radar." + +[dependencies] +pineal-core = { path = "../pineal-core" } +pineal-render = { path = "../pineal-render" } diff --git a/00_unanchay/pineal/pineal-polar/LEEME.md b/00_unanchay/pineal/pineal-polar/LEEME.md new file mode 100644 index 0000000..f229677 --- /dev/null +++ b/00_unanchay/pineal/pineal-polar/LEEME.md @@ -0,0 +1,20 @@ +# pineal-polar + +> Canvas polar para [pineal](../README.md): coordenadas radial/angular. + +Backend para gráficos donde la magnitud es la distancia al centro y la fase es el ángulo. Útil para distribuciones direccionales (viento, radio-astronomía), wheels, geometría de antenas. + +## API + +```rust +use pineal_polar::{Polar, Sweep}; + +let plot = Polar::new() + .radius(0.0..1.0) + .angle_units(AngleUnit::Degrees) + .series(Sweep::line(&thetas, &rs)); +``` + +## Deps + +- [`pineal-core`](../pineal-core/README.md) diff --git a/00_unanchay/pineal/pineal-polar/README.md b/00_unanchay/pineal/pineal-polar/README.md new file mode 100644 index 0000000..14d92cd --- /dev/null +++ b/00_unanchay/pineal/pineal-polar/README.md @@ -0,0 +1,20 @@ +# pineal-polar + +> Polar canvas for [pineal](../README.md): radial/angular coordinates. + +Backend for graphs where magnitude is distance from center and phase is angle. Useful for directional distributions (wind, radio-astronomy), wheels, antenna geometry. + +## API + +```rust +use pineal_polar::{Polar, Sweep}; + +let plot = Polar::new() + .radius(0.0..1.0) + .angle_units(AngleUnit::Degrees) + .series(Sweep::line(&thetas, &rs)); +``` + +## Deps + +- [`pineal-core`](../pineal-core/README.md) diff --git a/00_unanchay/pineal/pineal-polar/src/lib.rs b/00_unanchay/pineal/pineal-polar/src/lib.rs new file mode 100644 index 0000000..a844502 --- /dev/null +++ b/00_unanchay/pineal/pineal-polar/src/lib.rs @@ -0,0 +1,15 @@ +//! `pineal-polar` — gráficos en coordenadas polares. +//! +//! Painters agnósticos (hablan contra `Canvas`): el `Canvas` no tiene +//! primitiva de arco, así que cada forma se tesela en triangle strips. +//! +//! - [`pie`] — pie / donut chart. +//! - [`radar`] — radar (spider) chart. + +#![forbid(unsafe_code)] + +pub mod pie; +pub mod radar; + +pub use pie::{paint_pie, Slice}; +pub use radar::paint_radar; diff --git a/00_unanchay/pineal/pineal-polar/src/pie.rs b/00_unanchay/pineal/pineal-polar/src/pie.rs new file mode 100644 index 0000000..19626fe --- /dev/null +++ b/00_unanchay/pineal/pineal-polar/src/pie.rs @@ -0,0 +1,124 @@ +//! Pie / donut chart. +//! +//! Las porciones arrancan a las 12 en punto (-90°) y avanzan en sentido +//! horario. Como el `Canvas` no tiene primitiva de arco, cada cuña se +//! tesela en un triangle strip; la calidad del arco escala con el ángulo. + +use pineal_render::{Canvas, Color, Point}; +use std::f32::consts::{FRAC_PI_2, TAU}; + +/// Una porción del pie: un valor (peso) y su color. +#[derive(Debug, Clone, Copy)] +pub struct Slice { + pub value: f32, + pub color: Color, +} + +impl Slice { + pub fn new(value: f32, color: Color) -> Self { + Self { value, color } + } +} + +/// Segmentos de arco por vuelta completa — controla la suavidad. +const ARC_SEGMENTS_PER_TURN: f32 = 96.0; + +/// Dibuja un pie centrado en `center`. Si `inner_radius > 0` es un donut. +/// Los valores negativos se tratan como 0. +pub fn paint_pie( + slices: &[Slice], + center: Point, + radius: f32, + inner_radius: f32, + canvas: &mut dyn Canvas, +) { + let total: f32 = slices.iter().map(|s| s.value.max(0.0)).sum(); + if total <= 0.0 || radius <= 0.0 { + return; + } + let mut angle = -FRAC_PI_2; + for s in slices { + let sweep = (s.value.max(0.0) / total) * TAU; + if sweep > 0.0 { + paint_wedge(center, radius, inner_radius.max(0.0), angle, angle + sweep, s.color, canvas); + } + angle += sweep; + } +} + +fn arc_point(center: Point, r: f32, angle: f32) -> Point { + Point::new(center.x + r * angle.cos(), center.y + r * angle.sin()) +} + +fn paint_wedge( + center: Point, + r_out: f32, + r_in: f32, + a0: f32, + a1: f32, + color: Color, + canvas: &mut dyn Canvas, +) { + let segs = ((a1 - a0).abs() / TAU * ARC_SEGMENTS_PER_TURN).ceil() as usize; + let segs = segs.max(1); + let mut coords = Vec::with_capacity((segs + 1) * 4); + let mut colors = Vec::with_capacity((segs + 1) * 2); + for i in 0..=segs { + let t = a0 + (a1 - a0) * (i as f32 / segs as f32); + // Borde interno: el centro (pie) o el arco interno (donut). + let inner = if r_in <= 0.0 { + center + } else { + arc_point(center, r_in, t) + }; + let outer = arc_point(center, r_out, t); + coords.push(inner.x); + coords.push(inner.y); + coords.push(outer.x); + coords.push(outer.y); + colors.push(color); + colors.push(color); + } + // Strip [in0,out0,in1,out1,…]: cada par de triángulos cubre un segmento. + canvas.fill_triangle_strip(&coords, &colors); +} + +#[cfg(test)] +mod tests { + use super::*; + use pineal_render::{PlanRecorder, RenderCmd}; + + fn count_strips(cmds: &[RenderCmd]) -> usize { + cmds.iter() + .filter(|c| matches!(c, RenderCmd::FillTriangleStrip { .. })) + .count() + } + + #[test] + fn one_strip_per_nonzero_slice() { + let slices = [ + Slice::new(1.0, Color::WHITE), + Slice::new(1.0, Color::BLACK), + Slice::new(2.0, Color::from_hex(0xff0000)), + ]; + let mut rec = PlanRecorder::new(); + paint_pie(&slices, Point::new(50.0, 50.0), 40.0, 0.0, &mut rec); + assert_eq!(count_strips(&rec.into_plan().cmds), 3); + } + + #[test] + fn zero_total_draws_nothing() { + let slices = [Slice::new(0.0, Color::WHITE)]; + let mut rec = PlanRecorder::new(); + paint_pie(&slices, Point::new(50.0, 50.0), 40.0, 0.0, &mut rec); + assert!(rec.into_plan().cmds.is_empty()); + } + + #[test] + fn donut_also_emits_strips() { + let slices = [Slice::new(1.0, Color::WHITE), Slice::new(1.0, Color::BLACK)]; + let mut rec = PlanRecorder::new(); + paint_pie(&slices, Point::new(50.0, 50.0), 40.0, 20.0, &mut rec); + assert_eq!(count_strips(&rec.into_plan().cmds), 2); + } +} diff --git a/00_unanchay/pineal/pineal-polar/src/radar.rs b/00_unanchay/pineal/pineal-polar/src/radar.rs new file mode 100644 index 0000000..e303b15 --- /dev/null +++ b/00_unanchay/pineal/pineal-polar/src/radar.rs @@ -0,0 +1,97 @@ +//! Radar (spider) chart. +//! +//! `M` ejes equiespaciados desde las 12 en punto. Cada valor se proyecta +//! a una distancia del centro proporcional a `value / max_value`. El +//! polígono resultante se rellena (triangle fan) y se contornea. + +use pineal_render::{Canvas, Color, Point, StrokeStyle}; +use std::f32::consts::{FRAC_PI_2, TAU}; + +/// Dibuja un radar de `values.len()` ejes. `max_value` define el borde. +/// Rellena con `fill` y contornea con `stroke`. +pub fn paint_radar( + values: &[f32], + max_value: f32, + center: Point, + radius: f32, + fill: Color, + stroke: StrokeStyle, + canvas: &mut dyn Canvas, +) { + let m = values.len(); + if m < 3 || max_value <= 0.0 || radius <= 0.0 { + return; + } + + // Punto de cada eje, en orden. + let verts: Vec = values + .iter() + .enumerate() + .map(|(i, &v)| { + let angle = -FRAC_PI_2 + (i as f32 / m as f32) * TAU; + let dist = (v / max_value).clamp(0.0, 1.0) * radius; + Point::new(center.x + dist * angle.cos(), center.y + dist * angle.sin()) + }) + .collect(); + + // Relleno: fan como strip [c, v0, c, v1, …, c, v0] (cierra el polígono). + let mut coords = Vec::with_capacity((m + 1) * 4); + let mut colors = Vec::with_capacity((m + 1) * 2); + for i in 0..=m { + let v = verts[i % m]; + coords.push(center.x); + coords.push(center.y); + coords.push(v.x); + coords.push(v.y); + colors.push(fill); + colors.push(fill); + } + canvas.fill_triangle_strip(&coords, &colors); + + // Contorno: polilínea cerrada. + let mut outline = Vec::with_capacity((m + 1) * 2); + for i in 0..=m { + let v = verts[i % m]; + outline.push(v.x); + outline.push(v.y); + } + canvas.stroke_polyline(&outline, stroke); +} + +#[cfg(test)] +mod tests { + use super::*; + use pineal_render::{PlanRecorder, RenderCmd}; + + #[test] + fn emits_fill_strip_and_outline() { + let mut rec = PlanRecorder::new(); + paint_radar( + &[1.0, 2.0, 3.0, 2.0, 1.0], + 3.0, + Point::new(50.0, 50.0), + 40.0, + Color::WHITE, + StrokeStyle::new(1.5, Color::BLACK), + &mut rec, + ); + let cmds = rec.into_plan().cmds; + assert!(cmds.iter().any(|c| matches!(c, RenderCmd::FillTriangleStrip { .. }))); + assert!(cmds.iter().any(|c| matches!(c, RenderCmd::StrokePolyline { .. }))); + } + + #[test] + fn too_few_axes_draws_nothing() { + let mut rec = PlanRecorder::new(); + paint_radar( + &[1.0, 2.0], + 3.0, + Point::new(0.0, 0.0), + 10.0, + Color::WHITE, + StrokeStyle::new(1.0, Color::BLACK), + &mut rec, + ); + assert!(rec.into_plan().cmds.is_empty()); + } +} diff --git a/00_unanchay/pineal/pineal-render/Cargo.toml b/00_unanchay/pineal/pineal-render/Cargo.toml new file mode 100644 index 0000000..11571df --- /dev/null +++ b/00_unanchay/pineal/pineal-render/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "pineal-render" +version = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +authors = { workspace = true } +publish = { workspace = true } +description = "Lapaloma — abstracción de painter: trait Canvas + RenderPlan + color helpers. Backend Llimphi (vello/wgpu) consumido por demos y consumidores upstream sin tocar a los painters." + +[dependencies] +pineal-core = { path = "../pineal-core" } +llimphi-ui = { workspace = true } diff --git a/00_unanchay/pineal/pineal-render/LEEME.md b/00_unanchay/pineal/pineal-render/LEEME.md new file mode 100644 index 0000000..48f8b38 --- /dev/null +++ b/00_unanchay/pineal/pineal-render/LEEME.md @@ -0,0 +1,10 @@ +# pineal-render + +> Render Llimphi del modelo de [pineal](../README.md). + +Convierte una `Scene` de [`pineal-core`](../pineal-core/README.md) en operaciones `vello` dentro de un `SceneCanvas` de Llimphi. Maneja antialiasing, layers compuestas (alpha blending) y exporta a PNG via `pineal-export` cuando se le pide snapshot. + +## Deps + +- [`pineal-core`](../pineal-core/README.md) +- [`llimphi-ui`](../../../02_ruway/llimphi/) (vello + scene) diff --git a/00_unanchay/pineal/pineal-render/README.md b/00_unanchay/pineal/pineal-render/README.md new file mode 100644 index 0000000..ff8855a --- /dev/null +++ b/00_unanchay/pineal/pineal-render/README.md @@ -0,0 +1,10 @@ +# pineal-render + +> Llimphi render of [pineal](../README.md)'s model. + +Converts a `Scene` from [`pineal-core`](../pineal-core/README.md) into `vello` operations inside a Llimphi `SceneCanvas`. Handles antialiasing, composited layers (alpha blending), exports to PNG via `pineal-export` when snapshot is requested. + +## Deps + +- [`pineal-core`](../pineal-core/README.md) +- [`llimphi-ui`](../../../02_ruway/llimphi/) (vello + scene) diff --git a/00_unanchay/pineal/pineal-render/src/canvas.rs b/00_unanchay/pineal/pineal-render/src/canvas.rs new file mode 100644 index 0000000..667bc13 --- /dev/null +++ b/00_unanchay/pineal/pineal-render/src/canvas.rs @@ -0,0 +1,53 @@ +//! El trait `Canvas` que todos los painters consumen. +//! +//! Mantenemos el set mínimo: line / polyline / rect (fill+stroke) / +//! triangle strip. Cualquier visualización compleja (curvas +//! bezier, gradients) se descompone en estos primitivos por el +//! painter — el backend no necesita entender la semántica. +//! +//! Convención: coordenadas en píxeles del viewport, origen +//! arriba-izquierda, +Y hacia abajo. La proyección de datos→pixel +//! la hace el painter via las escalas de `pineal-core`. + +use crate::{Color, Point, Rect}; + +#[derive(Debug, Clone, Copy)] +pub struct StrokeStyle { + pub width: f32, + pub color: Color, +} + +impl StrokeStyle { + pub const fn new(width: f32, color: Color) -> Self { + Self { width, color } + } +} + +pub trait Canvas { + /// Clip subsiguiente al rect dado. Stack-discipline: + /// `push_clip` + draw + `pop_clip`. + fn push_clip(&mut self, rect: Rect); + fn pop_clip(&mut self); + + /// Rectángulo relleno (sin stroke). + fn fill_rect(&mut self, rect: Rect, color: Color); + + /// Rectángulo sólo stroke (sin fill). + fn stroke_rect(&mut self, rect: Rect, stroke: StrokeStyle); + + /// Línea de a→b. + fn stroke_line(&mut self, a: Point, b: Point, stroke: StrokeStyle); + + /// Polilínea sobre coords interleaved `[x0,y0,x1,y1,…]`. + /// El backend la rendea como un solo draw call cuando puede. + fn stroke_polyline(&mut self, coords: &[f32], stroke: StrokeStyle); + + /// Triangle strip rellenado, con un color por vértice + /// (longitudes deben coincidir: `coords.len()/2 == colors.len()`). + /// Es lo que usa el phosphor trail y los ribbons Sankey. + fn fill_triangle_strip(&mut self, coords: &[f32], colors: &[Color]); + + /// Glyph de texto sencillo. El layout va a un text-cache + /// dentro del backend; por ahora un trazo simple. + fn draw_text(&mut self, p: Point, text: &str, color: Color, size_px: f32); +} diff --git a/00_unanchay/pineal/pineal-render/src/color.rs b/00_unanchay/pineal/pineal-render/src/color.rs new file mode 100644 index 0000000..255daab --- /dev/null +++ b/00_unanchay/pineal/pineal-render/src/color.rs @@ -0,0 +1,35 @@ +//! Color RGBA en f32, agnóstico de backend. + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Color { + pub r: f32, + pub g: f32, + pub b: f32, + pub a: f32, +} + +impl Color { + pub const TRANSPARENT: Self = Self::rgba(0.0, 0.0, 0.0, 0.0); + pub const BLACK: Self = Self::rgb(0.0, 0.0, 0.0); + pub const WHITE: Self = Self::rgb(1.0, 1.0, 1.0); + + pub const fn rgb(r: f32, g: f32, b: f32) -> Self { + Self { r, g, b, a: 1.0 } + } + pub const fn rgba(r: f32, g: f32, b: f32, a: f32) -> Self { + Self { r, g, b, a } + } + + /// Construye desde 0xRRGGBB hex literal. + pub fn from_hex(rgb: u32) -> Self { + let r = ((rgb >> 16) & 0xff) as f32 / 255.0; + let g = ((rgb >> 8) & 0xff) as f32 / 255.0; + let b = (rgb & 0xff) as f32 / 255.0; + Self::rgb(r, g, b) + } + + /// Multiplica el canal alpha — útil para fade del phosphor trail. + pub fn with_alpha(self, a: f32) -> Self { + Self { a, ..self } + } +} diff --git a/00_unanchay/pineal/pineal-render/src/geom.rs b/00_unanchay/pineal/pineal-render/src/geom.rs new file mode 100644 index 0000000..8af2d2b --- /dev/null +++ b/00_unanchay/pineal/pineal-render/src/geom.rs @@ -0,0 +1,36 @@ +//! Tipos geométricos mínimos en `f32`. + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Point { + pub x: f32, + pub y: f32, +} + +impl Point { + pub const fn new(x: f32, y: f32) -> Self { + Self { x, y } + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Rect { + pub x: f32, + pub y: f32, + pub w: f32, + pub h: f32, +} + +impl Rect { + pub const fn new(x: f32, y: f32, w: f32, h: f32) -> Self { + Self { x, y, w, h } + } + pub fn right(&self) -> f32 { + self.x + self.w + } + pub fn bottom(&self) -> f32 { + self.y + self.h + } + pub fn contains(&self, p: Point) -> bool { + p.x >= self.x && p.x <= self.right() && p.y >= self.y && p.y <= self.bottom() + } +} diff --git a/00_unanchay/pineal/pineal-render/src/gpu_canvas.rs b/00_unanchay/pineal/pineal-render/src/gpu_canvas.rs new file mode 100644 index 0000000..b28626e --- /dev/null +++ b/00_unanchay/pineal/pineal-render/src/gpu_canvas.rs @@ -0,0 +1,127 @@ +//! Backend GPU directo del trait [`crate::Canvas`] — Fase 4 del SDD +//! `02_ruway/llimphi/SDD.md` §"GPU directo wgpu". +//! +//! Implementación drop-in del trait que delega cada primitivo a un +//! `llimphi_raster::GpuBatch`. El painter de pineal no se entera: sigue +//! escribiendo `canvas.fill_rect(...)`, `canvas.stroke_line(...)`, etc. +//! La app que monta la visualización elige el backend al enchufar el +//! `paint_with` (SceneCanvas, vello, ~100 K primitivas) o +//! `gpu_paint_with` (GpuSceneCanvas, GPU directo, 1–10 M primitivas). +//! +//! Trade-offs respecto a `SceneCanvas`: +//! +//! - **Texto**: el SDD prohíbe expresamente texto en este backend +//! ("Texto siempre por vello+parley"). `draw_text` queda como +//! **no-op silencioso** — el caller debe pintar labels en un +//! `View::paint_with` (vello) hermano o en `App::view_overlay`. +//! - **Anchura de stroke**: `GpuBatch` tiene una sola `line_width` por +//! flush. Visualizaciones que mezclen grosores van a ver "la última +//! gana". Workaround: emitir cada grosor en su propio flush, o +//! simplemente componer todo con el grosor más común. Para los +//! painters densos típicos (starfield, particles, scatter) no aplica +//! — ahí las strokes son todas del mismo grosor. +//! - **Clip**: `push_clip`/`pop_clip` son no-op, igual que en +//! `SceneCanvas` (el `View` contenedor recorta vía taffy + `clip(true)`). +//! - **Per-vertex color en strips**: ventaja sobre vello — el GPU sí +//! soporta color por vértice nativo, así que `fill_triangle_strip` +//! preserva el color real de cada vértice (no el promedio del +//! triángulo). Beneficia phosphor trail, ribbons Sankey, fan radar. + +use crate::{Canvas, Color, Point, Rect, StrokeStyle}; +use llimphi_ui::llimphi_raster::peniko::Color as PenikoColor; +use llimphi_ui::llimphi_raster::GpuBatch; + +/// Adapter que pinta primitivos de `Canvas` sobre un `GpuBatch`. El +/// batch lo construye y flushea la app dentro del `gpu_paint_with`; +/// `GpuSceneCanvas` vive sólo durante las calls del painter. +pub struct GpuSceneCanvas<'a, 'b> { + batch: &'a mut GpuBatch<'b>, +} + +impl<'a, 'b> GpuSceneCanvas<'a, 'b> { + pub fn new(batch: &'a mut GpuBatch<'b>) -> Self { + Self { batch } + } +} + +fn to_peniko(c: Color) -> PenikoColor { + let to_byte = |x: f32| (x.clamp(0.0, 1.0) * 255.0).round() as u8; + PenikoColor::from_rgba8(to_byte(c.r), to_byte(c.g), to_byte(c.b), to_byte(c.a)) +} + +impl<'a, 'b> Canvas for GpuSceneCanvas<'a, 'b> { + fn push_clip(&mut self, _rect: Rect) {} + fn pop_clip(&mut self) {} + + fn fill_rect(&mut self, rect: Rect, color: Color) { + self.batch + .add_rect(rect.x, rect.y, rect.w, rect.h, to_peniko(color)); + } + + fn stroke_rect(&mut self, rect: Rect, stroke: StrokeStyle) { + // 4 segmentos cerrando el rect. La line_width del batch es + // compartida — actualizamos en cada call (el caller asume "una + // anchura por flush", ver doc del módulo). + self.batch.line_width(stroke.width); + let c = to_peniko(stroke.color); + let tl = (rect.x, rect.y); + let tr = (rect.x + rect.w, rect.y); + let br = (rect.x + rect.w, rect.y + rect.h); + let bl = (rect.x, rect.y + rect.h); + self.batch.add_line(tl, tr, c); + self.batch.add_line(tr, br, c); + self.batch.add_line(br, bl, c); + self.batch.add_line(bl, tl, c); + } + + fn stroke_line(&mut self, a: Point, b: Point, stroke: StrokeStyle) { + self.batch.line_width(stroke.width); + self.batch + .add_line((a.x, a.y), (b.x, b.y), to_peniko(stroke.color)); + } + + fn stroke_polyline(&mut self, coords: &[f32], stroke: StrokeStyle) { + if coords.len() < 4 { + return; + } + self.batch.line_width(stroke.width); + let c = to_peniko(stroke.color); + let mut prev = (coords[0], coords[1]); + let mut i = 2; + while i + 1 < coords.len() { + let cur = (coords[i], coords[i + 1]); + self.batch.add_line(prev, cur, c); + prev = cur; + i += 2; + } + } + + fn fill_triangle_strip(&mut self, coords: &[f32], colors: &[Color]) { + let n = coords.len() / 2; + if n < 3 { + return; + } + // Expandir strip a tri list, con color real por vértice (no el + // promedio del backend vello). Cada triángulo del strip toma + // los índices (t, t+1, t+2). + for t in 0..n - 2 { + let i0 = t; + let i1 = t + 1; + let i2 = t + 2; + let p0 = (coords[i0 * 2], coords[i0 * 2 + 1]); + let p1 = (coords[i1 * 2], coords[i1 * 2 + 1]); + let p2 = (coords[i2 * 2], coords[i2 * 2 + 1]); + let c0 = colors.get(i0).copied().unwrap_or(Color::TRANSPARENT); + let c1 = colors.get(i1).copied().unwrap_or(Color::TRANSPARENT); + let c2 = colors.get(i2).copied().unwrap_or(Color::TRANSPARENT); + self.batch + .add_tri(p0, p1, p2, to_peniko(c0), to_peniko(c1), to_peniko(c2)); + } + } + + fn draw_text(&mut self, _p: Point, _text: &str, _color: Color, _size_px: f32) { + // No-op por diseño: el SDD prohíbe texto en este backend. Los + // labels van por un `paint_with` (vello) hermano o por el + // overlay del runtime. + } +} diff --git a/00_unanchay/pineal/pineal-render/src/lib.rs b/00_unanchay/pineal/pineal-render/src/lib.rs new file mode 100644 index 0000000..d43fa83 --- /dev/null +++ b/00_unanchay/pineal/pineal-render/src/lib.rs @@ -0,0 +1,42 @@ +//! `pineal-render` — abstracción de painter. +//! +//! Los crates de visualización (cartesian, mesh, polar…) no conocen +//! el runtime de UI: hablan contra el trait [`Canvas`] definido acá. +//! Eso deja a la cadena `core → render → painter` agnóstica. +//! +//! Backends activos: +//! +//! - **Llimphi/vello** ([`llimphi_backend::SceneCanvas`]) — canónico +//! para apps gioser; pinta dentro de un `paint_with` del `View` +//! declarativo. +//! - **PlanRecorder** ([`recorder::PlanRecorder`]) — graba cada llamada +//! como `RenderCmd`. Consumido por `pineal-export` para emitir SVG, +//! PNG (raster propio con AA 2×2) y PDF (writer propio). +//! - **GPU directo wgpu** ([`gpu_canvas::GpuSceneCanvas`]) — backend +//! denso para >100 K primitivas. Delega cada llamada del Canvas a +//! `llimphi_raster::GpuBatch` y se enchufa desde `View::gpu_paint_with` +//! en lugar de `paint_with`. Los painters no cambian. Sin texto y sin +//! AA fino — ver doc del módulo para trade-offs. +//! +//! Tipos primitivos (`Color`, `Point`, `Rect`) viven acá para no +//! atarlos al tipo de color/punto de ningún runtime. + +#![forbid(unsafe_code)] + +pub mod color; +pub mod geom; +pub mod canvas; +pub mod plan; +pub mod recorder; + +pub mod llimphi_backend; +pub mod gpu_canvas; + +pub use color::Color; +pub use geom::{Point, Rect}; +pub use canvas::{Canvas, StrokeStyle}; +pub use plan::{RenderCmd, RenderPlan}; +pub use recorder::PlanRecorder; + +pub use llimphi_backend::SceneCanvas; +pub use gpu_canvas::GpuSceneCanvas; diff --git a/00_unanchay/pineal/pineal-render/src/llimphi_backend.rs b/00_unanchay/pineal/pineal-render/src/llimphi_backend.rs new file mode 100644 index 0000000..a350dc9 --- /dev/null +++ b/00_unanchay/pineal/pineal-render/src/llimphi_backend.rs @@ -0,0 +1,201 @@ +//! Backend del trait [`crate::Canvas`] sobre un `vello::Scene` de Llimphi. +//! +//! Bajo el feature `llimphi`. Traduce los primitivos de pineal a las +//! llamadas nativas de vello/peniko/kurbo. No introduce dependencia +//! transitiva a `llimphi-ui` en los crates de visualización — éstos +//! siguen hablando contra el trait abstracto; sólo la función `view` +//! de cada widget enchufa este backend en un `View::paint_with`. +//! +//! Paridad con el backend GPUI: +//! - `push_clip` / `pop_clip` quedan como no-op por ahora — el `View` +//! contenedor ya recorta vía taffy + `clip(true)`; los painters de +//! pineal respetan su `plot_rect` y no salen del bounds. +//! - `fill_triangle_strip` arma un `BezPath` por triángulo y lo rellena +//! con `Fill::NonZero` + el color promedio de los tres vértices. Vello +//! no expone mesh con per-vertex color directo, así que el promedio es +//! el trade-off (igual que el exporter SVG). Suficiente para wedges +//! pie/donut, ribbons Sankey, polígono fan del radar y phosphor trail. +//! +//! Coordenadas: pineal trabaja en pixels absolutos del scene (mismo +//! origen que `PaintRect.{x,y}`). No traduce — los callers ya +//! construyen sus rects en términos del `PaintRect` recibido. +//! +//! El texto se rendea usando el `Typesetter` cacheado del runtime; no +//! creamos `FontContext` por frame. + +use crate::{Canvas, Color, Point, Rect, StrokeStyle}; +use llimphi_ui::llimphi_raster::kurbo::{Affine, BezPath, Line, Rect as KurboRect, Stroke}; +use llimphi_ui::llimphi_raster::peniko::{Color as PenikoColor, Fill}; +use llimphi_ui::llimphi_raster::vello::Scene; +use llimphi_ui::llimphi_text::{draw_block, TextBlock, Typesetter}; + +/// Adapter que pinta sobre un `&mut vello::Scene` + `&mut Typesetter`. +/// +/// Construir uno nuevo dentro del closure de [`llimphi_ui::View::paint_with`]. +/// Vida útil del borrow del scene iguala la del frame. +pub struct SceneCanvas<'a> { + scene: &'a mut Scene, + typesetter: &'a mut Typesetter, +} + +impl<'a> SceneCanvas<'a> { + pub fn new(scene: &'a mut Scene, typesetter: &'a mut Typesetter) -> Self { + Self { scene, typesetter } + } +} + +/// Convierte el `Color` RGBA lineal de pineal al `Color` peniko. Mantiene +/// la convención sin gamma del resto del codebase. peniko trabaja en +/// `[0, 255]` enteros para el canal alfa también, así que clampeamos y +/// multiplicamos. +fn to_peniko(c: Color) -> PenikoColor { + let to_byte = |x: f32| (x.clamp(0.0, 1.0) * 255.0).round() as u8; + PenikoColor::from_rgba8(to_byte(c.r), to_byte(c.g), to_byte(c.b), to_byte(c.a)) +} + +fn avg_color(cs: &[Option]) -> 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) +} + +fn to_kurbo_rect(r: Rect) -> KurboRect { + KurboRect::new( + r.x as f64, + r.y as f64, + (r.x + r.w) as f64, + (r.y + r.h) as f64, + ) +} + +impl<'a> Canvas for SceneCanvas<'a> { + fn push_clip(&mut self, _rect: Rect) { + // No-op por ahora. El View contenedor recorta vía taffy + clip(true). + } + fn pop_clip(&mut self) {} + + fn fill_rect(&mut self, rect: Rect, color: Color) { + self.scene.fill( + Fill::NonZero, + Affine::IDENTITY, + to_peniko(color), + None, + &to_kurbo_rect(rect), + ); + } + + fn stroke_rect(&mut self, rect: Rect, stroke: StrokeStyle) { + self.scene.stroke( + &Stroke::new(stroke.width as f64), + Affine::IDENTITY, + to_peniko(stroke.color), + None, + &to_kurbo_rect(rect), + ); + } + + fn stroke_line(&mut self, a: Point, b: Point, stroke: StrokeStyle) { + let line = Line::new((a.x as f64, a.y as f64), (b.x as f64, b.y as f64)); + self.scene.stroke( + &Stroke::new(stroke.width as f64), + Affine::IDENTITY, + to_peniko(stroke.color), + None, + &line, + ); + } + + fn stroke_polyline(&mut self, coords: &[f32], stroke: StrokeStyle) { + if coords.len() < 4 { + return; // <2 puntos → no hay segmento + } + let mut path = BezPath::new(); + path.move_to((coords[0] as f64, coords[1] as f64)); + let mut i = 2; + while i + 1 < coords.len() { + path.line_to((coords[i] as f64, coords[i + 1] as f64)); + i += 2; + } + self.scene.stroke( + &Stroke::new(stroke.width as f64), + Affine::IDENTITY, + to_peniko(stroke.color), + None, + &path, + ); + } + + fn fill_triangle_strip(&mut self, coords: &[f32], colors: &[Color]) { + let n = coords.len() / 2; + if n < 3 { + return; + } + for t in 0..n - 2 { + let i0 = t; + let i1 = t + 1; + let i2 = t + 2; + let avg = avg_color(&[ + colors.get(i0).copied(), + colors.get(i1).copied(), + colors.get(i2).copied(), + ]); + let mut path = BezPath::new(); + path.move_to((coords[i0 * 2] as f64, coords[i0 * 2 + 1] as f64)); + path.line_to((coords[i1 * 2] as f64, coords[i1 * 2 + 1] as f64)); + path.line_to((coords[i2 * 2] as f64, coords[i2 * 2 + 1] as f64)); + path.close_path(); + self.scene.fill( + Fill::NonZero, + Affine::IDENTITY, + to_peniko(avg), + None, + &path, + ); + } + } + + fn draw_text(&mut self, p: Point, text: &str, color: Color, size_px: f32) { + if text.is_empty() { + return; + } + // Reutiliza el typesetter cacheado del runtime — `TextBlock::simple` + // pone el origen donde pineal lo pide. `draw_block` hace shape + + // glyph_run en una pasada. + let block = TextBlock::simple(text, size_px, to_peniko(color), (p.x as f64, p.y as f64)); + draw_block(self.scene, self.typesetter, &block); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rojo_redondea_a_255() { + let c = to_peniko(Color::rgb(1.0, 0.0, 0.0)).to_rgba8(); + assert_eq!((c.r, c.g, c.b, c.a), (255, 0, 0, 255)); + } + + #[test] + fn alpha_pasa_directo() { + let c = to_peniko(Color::rgba(0.0, 0.0, 1.0, 0.25)).to_rgba8(); + assert_eq!(c.b, 255); + assert_eq!(c.a, 64); // 0.25 * 255 = 63.75 → 64 + } + + #[test] + fn fuera_de_rango_clampea() { + let c = to_peniko(Color::rgba(1.5, -0.2, 0.5, 1.0)).to_rgba8(); + assert_eq!((c.r, c.g, c.b), (255, 0, 128)); + } +} diff --git a/00_unanchay/pineal/pineal-render/src/plan.rs b/00_unanchay/pineal/pineal-render/src/plan.rs new file mode 100644 index 0000000..825fa9e --- /dev/null +++ b/00_unanchay/pineal/pineal-render/src/plan.rs @@ -0,0 +1,35 @@ +//! `RenderPlan` — comandos materializados para backends que no +//! reciben llamadas en vivo (SVG export, snapshot testing). +//! +//! Un painter que escribe contra [`crate::Canvas`] puede ser +//! capturado en un `RenderPlan` usando un `Canvas` adapter que +//! empuja `RenderCmd`s en lugar de dibujar. El exporter consume +//! el plan y emite `` / `` / etc. + +use crate::{Color, Point, Rect, StrokeStyle}; + +#[derive(Debug, Clone)] +pub enum RenderCmd { + PushClip(Rect), + PopClip, + FillRect { rect: Rect, color: Color }, + StrokeRect { rect: Rect, stroke: StrokeStyle }, + StrokeLine { a: Point, b: Point, stroke: StrokeStyle }, + StrokePolyline { coords: Vec, stroke: StrokeStyle }, + FillTriangleStrip { coords: Vec, colors: Vec }, + DrawText { p: Point, text: String, color: Color, size_px: f32 }, +} + +#[derive(Debug, Clone, Default)] +pub struct RenderPlan { + pub cmds: Vec, +} + +impl RenderPlan { + pub fn new() -> Self { + Self::default() + } + pub fn push(&mut self, cmd: RenderCmd) { + self.cmds.push(cmd); + } +} diff --git a/00_unanchay/pineal/pineal-render/src/recorder.rs b/00_unanchay/pineal/pineal-render/src/recorder.rs new file mode 100644 index 0000000..d36c0ff --- /dev/null +++ b/00_unanchay/pineal/pineal-render/src/recorder.rs @@ -0,0 +1,95 @@ +//! `PlanRecorder` — un [`Canvas`] que graba cada llamada como `RenderCmd` +//! en un [`RenderPlan`], en vez de dibujar. +//! +//! Es el puente entre los painters (que hablan contra `Canvas`) y los +//! backends diferidos: `pineal-export` consume el plan grabado y emite +//! SVG; los tests de snapshot comparan planes. + +use crate::{Canvas, Color, Point, Rect, RenderCmd, RenderPlan, StrokeStyle}; + +/// Canvas que materializa todo lo dibujado en un `RenderPlan`. +#[derive(Debug, Default)] +pub struct PlanRecorder { + plan: RenderPlan, +} + +impl PlanRecorder { + pub fn new() -> Self { + Self::default() + } + + /// Consume el recorder y devuelve el plan acumulado. + pub fn into_plan(self) -> RenderPlan { + self.plan + } + + /// Acceso de sólo-lectura al plan en construcción. + pub fn plan(&self) -> &RenderPlan { + &self.plan + } +} + +impl Canvas for PlanRecorder { + 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.to_string(), + color, + size_px, + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn records_calls_in_order() { + let mut rec = PlanRecorder::new(); + rec.fill_rect(Rect::new(0.0, 0.0, 10.0, 10.0), Color::WHITE); + rec.stroke_line( + Point::new(0.0, 0.0), + Point::new(10.0, 10.0), + StrokeStyle::new(1.0, Color::BLACK), + ); + let plan = rec.into_plan(); + assert_eq!(plan.cmds.len(), 2); + assert!(matches!(plan.cmds[0], RenderCmd::FillRect { .. })); + assert!(matches!(plan.cmds[1], RenderCmd::StrokeLine { .. })); + } +} diff --git a/00_unanchay/pineal/pineal-stream/Cargo.toml b/00_unanchay/pineal/pineal-stream/Cargo.toml new file mode 100644 index 0000000..ec09d8e --- /dev/null +++ b/00_unanchay/pineal/pineal-stream/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "pineal-stream" +version = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +authors = { workspace = true } +publish = { workspace = true } +description = "Lapaloma — widget de telemetría tipo osciloscopio. Ring buffer + envelope min/max por columna + render en dos segmentos (split at head)." + +[dependencies] +pineal-core = { path = "../pineal-core" } +pineal-render = { path = "../pineal-render" } +llimphi-ui = { workspace = true } diff --git a/00_unanchay/pineal/pineal-stream/LEEME.md b/00_unanchay/pineal/pineal-stream/LEEME.md new file mode 100644 index 0000000..8ed79ba --- /dev/null +++ b/00_unanchay/pineal/pineal-stream/LEEME.md @@ -0,0 +1,18 @@ +# pineal-stream + +> Canvas de series temporales con scroll para [pineal](../README.md). + +Buffer circular fixed-size; cada nueva muestra empuja el frame a la izquierda. Múltiples series sobre el mismo eje de tiempo. Útil para monitoreo en vivo (CPU, latencia, sensores). Sin retención (eso es trabajo de la app que decide cuánto guardar). + +## API + +```rust +use pineal_stream::{Stream, Window}; + +let mut s = Stream::new(Window::seconds(60)); +s.push(t, sample); +``` + +## Deps + +- [`pineal-core`](../pineal-core/README.md) diff --git a/00_unanchay/pineal/pineal-stream/README.md b/00_unanchay/pineal/pineal-stream/README.md new file mode 100644 index 0000000..27f717a --- /dev/null +++ b/00_unanchay/pineal/pineal-stream/README.md @@ -0,0 +1,18 @@ +# pineal-stream + +> Scrolling time-series canvas for [pineal](../README.md). + +Fixed-size ring buffer; each new sample pushes the frame left. Multiple series on the same time axis. Useful for live monitoring (CPU, latency, sensors). No retention (the app decides how much to keep). + +## API + +```rust +use pineal_stream::{Stream, Window}; + +let mut s = Stream::new(Window::seconds(60)); +s.push(t, sample); +``` + +## Deps + +- [`pineal-core`](../pineal-core/README.md) diff --git a/00_unanchay/pineal/pineal-stream/src/lib.rs b/00_unanchay/pineal/pineal-stream/src/lib.rs new file mode 100644 index 0000000..7b84d2b --- /dev/null +++ b/00_unanchay/pineal/pineal-stream/src/lib.rs @@ -0,0 +1,21 @@ +//! `pineal-stream` — telemetría streaming tipo osciloscopio. +//! +//! Núcleo: `pineal_core::ring::RingBuffer` + render en dos +//! segmentos split-at-head (modo sweep). El emisor de samples vive +//! afuera del Element — típicamente en el `Render` host con un +//! timer `cx.background_executor().timer(...)` que llama a +//! `buffer.push(value)` y `cx.notify()` cada N ms. +//! +//! El Element clona el RingBuffer por frame (para cap = 512 son +//! 4 KB, irrelevante). Para capacidades grandes (100k+) la siguiente +//! optimización es pasar `Arc` con shared read y +//! mutación interna via `Mutex`/`AtomicU64` para el head. + +#![forbid(unsafe_code)] +#![allow(dead_code)] + +pub mod envelope {} + +pub mod view; + +pub use view::{pineal_stream_view, StreamView}; diff --git a/00_unanchay/pineal/pineal-stream/src/view.rs b/00_unanchay/pineal/pineal-stream/src/view.rs new file mode 100644 index 0000000..be10b9c --- /dev/null +++ b/00_unanchay/pineal/pineal-stream/src/view.rs @@ -0,0 +1,163 @@ +//! Vista Llimphi del widget de telemetría (sweep oscilloscope). +//! +//! Reemplazo del Element GPUI: dada un `RingBuffer` + `StrokeStyle`, +//! devuelve un `View` que pinta la traza vía `paint_with` sobre el +//! scene de vello. La lógica de proyección (dos segmentos split-at-head, +//! padding, mapeo y_range → px) es la misma que el Element original; +//! sólo cambia el backend. +//! +//! Llimphi reconstruye el View por frame, así que `scratch` queda dentro +//! del closure: cada paint asigna un Vec local. Para capacidades chicas +//! (cap ≤ 4 K) el costo es despreciable; el día que importe se cachea +//! en el Model del host con un `RefCell`. + +use llimphi_ui::llimphi_layout::taffy::prelude::{percent, Size, Style}; +use llimphi_ui::View; + +use pineal_core::ring::RingBuffer; +use pineal_render::{Color, Rect, SceneCanvas, StrokeStyle}; + +/// Mismas defaults que `LapalomaStreamElement`. Builder chico sobre la +/// función `pineal_stream_view` para no hacer la signature larga. +#[derive(Debug, Clone)] +pub struct StreamView { + buffer: RingBuffer, + stroke: StrokeStyle, + background: Option, + y_min: f32, + y_max: f32, + padding: f32, +} + +impl StreamView { + pub fn new(buffer: RingBuffer, stroke: StrokeStyle) -> Self { + Self { + buffer, + stroke, + background: None, + y_min: -1.0, + y_max: 1.0, + padding: 8.0, + } + } + + pub fn background(mut self, color: Color) -> Self { + self.background = Some(color); + self + } + + pub fn y_range(mut self, min: f32, max: f32) -> Self { + debug_assert!(max > min); + self.y_min = min; + self.y_max = max; + self + } + + pub fn padding(mut self, px: f32) -> Self { + self.padding = px; + self + } + + /// Materializa la vista. Devuelve un nodo que ocupa el 100% del padre + /// y pinta la traza dentro de su rect. Llamar por frame; el costo de + /// reconstruir es despreciable (los campos son `Copy`/`Clone` chicos). + pub fn view(self) -> View { + let StreamView { buffer, stroke, background, y_min, y_max, padding } = 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 = plot_rect(outer, padding); + + let mut canvas = SceneCanvas::new(scene, typesetter); + + if let Some(bg) = background { + use pineal_render::Canvas as _; + canvas.fill_rect(outer, bg); + } + + paint_traza(&mut canvas, &buffer, plot, stroke, y_min, y_max); + }) + } +} + +fn plot_rect(bounds: Rect, padding: f32) -> Rect { + Rect::new( + bounds.x + padding, + bounds.y + padding, + (bounds.w - padding * 2.0).max(1.0), + (bounds.h - padding * 2.0).max(1.0), + ) +} + +/// Proyecta `[x_norm, y_value, …]` del RingBuffer al sistema de píxeles +/// del plot. `out` se extiende — el caller decide el clear. +fn project_segment(segment: &[f32], plot: Rect, y_min: f32, y_max: f32, out: &mut Vec) { + let y_span = y_max - y_min; + if y_span.abs() < 1e-9 { + return; + } + let inv_y_span = 1.0 / y_span; + for chunk in segment.chunks_exact(2) { + let xn = chunk[0]; + let yv = chunk[1]; + let py_norm = (yv - y_min) * inv_y_span; + let px = plot.x + xn * plot.w; + let py = plot.bottom() - py_norm * plot.h; + out.push(px); + out.push(py); + } +} + +/// Render canónico del modo sweep, mismo split-at-head que el Element. +fn paint_traza( + canvas: &mut SceneCanvas<'_>, + buffer: &RingBuffer, + plot: Rect, + stroke: StrokeStyle, + y_min: f32, + y_max: f32, +) { + use pineal_render::Canvas as _; + + let coords = buffer.coords(); + let head = buffer.head(); + let cap = buffer.capacity(); + let mut scratch: Vec = Vec::new(); + + if !buffer.is_full() { + let filled = head; + if filled >= 2 { + let slice = &coords[..filled * 2]; + scratch.clear(); + project_segment(slice, plot, y_min, y_max, &mut scratch); + canvas.stroke_polyline(&scratch, stroke); + } + return; + } + + let split = head * 2; + if split < cap * 2 { + let seg1 = &coords[split..]; + if seg1.len() >= 4 { + scratch.clear(); + project_segment(seg1, plot, y_min, y_max, &mut scratch); + canvas.stroke_polyline(&scratch, stroke); + } + } + if split > 0 { + let seg2 = &coords[..split]; + if seg2.len() >= 4 { + scratch.clear(); + project_segment(seg2, plot, y_min, y_max, &mut scratch); + canvas.stroke_polyline(&scratch, stroke); + } + } +} + +/// Helper builder-style — paralelo al `pineal_stream(...)` del Element GPUI. +pub fn pineal_stream_view(buffer: RingBuffer, stroke: StrokeStyle) -> StreamView { + StreamView::new(buffer, stroke) +} diff --git a/00_unanchay/pineal/pineal-treemap/Cargo.toml b/00_unanchay/pineal/pineal-treemap/Cargo.toml new file mode 100644 index 0000000..6497650 --- /dev/null +++ b/00_unanchay/pineal/pineal-treemap/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "pineal-treemap" +version = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +authors = { workspace = true } +publish = { workspace = true } +description = "Lapaloma — treemap con algoritmo squarified (Bruls / d3-hierarchy formulation)." + +[dependencies] +pineal-core = { path = "../pineal-core" } +pineal-render = { path = "../pineal-render" } diff --git a/00_unanchay/pineal/pineal-treemap/LEEME.md b/00_unanchay/pineal/pineal-treemap/LEEME.md new file mode 100644 index 0000000..9a4e5b8 --- /dev/null +++ b/00_unanchay/pineal/pineal-treemap/LEEME.md @@ -0,0 +1,18 @@ +# pineal-treemap + +> Canvas de treemap jerárquico para [pineal](../README.md). + +Implementa squarified treemap layout (Bruls et al.): divide un rectángulo en sub-rectángulos proporcionales a un peso, recursivo por la jerarquía. Útil para sistemas de archivos, presupuestos, perfiles de CPU. + +## API + +```rust +use pineal_treemap::{Treemap, Node}; + +let tm = Treemap::new(root_node) + .color_by(|n| color_for(n.tag)); +``` + +## Deps + +- [`pineal-core`](../pineal-core/README.md) diff --git a/00_unanchay/pineal/pineal-treemap/README.md b/00_unanchay/pineal/pineal-treemap/README.md new file mode 100644 index 0000000..1ce2c98 --- /dev/null +++ b/00_unanchay/pineal/pineal-treemap/README.md @@ -0,0 +1,18 @@ +# pineal-treemap + +> Hierarchical treemap canvas for [pineal](../README.md). + +Implements squarified treemap layout (Bruls et al.): divides a rectangle into sub-rectangles proportional to a weight, recursively across the hierarchy. Useful for filesystems, budgets, CPU profiles. + +## API + +```rust +use pineal_treemap::{Treemap, Node}; + +let tm = Treemap::new(root_node) + .color_by(|n| color_for(n.tag)); +``` + +## Deps + +- [`pineal-core`](../pineal-core/README.md) diff --git a/00_unanchay/pineal/pineal-treemap/src/lib.rs b/00_unanchay/pineal/pineal-treemap/src/lib.rs new file mode 100644 index 0000000..d96976f --- /dev/null +++ b/00_unanchay/pineal/pineal-treemap/src/lib.rs @@ -0,0 +1,14 @@ +//! `pineal-treemap` — treemap squarified. +//! +//! - [`squarify`] — algoritmo de Bruls, Huizing & van Wijk (2000): +//! asigna a cada peso un rect de área proporcional minimizando el +//! peor aspect ratio. Pre-escala los pesos al área del rect destino. +//! - [`paint`] — painter agnóstico: tiles → `fill_rect` contra `Canvas`. + +#![forbid(unsafe_code)] + +pub mod squarify; +pub mod paint; + +pub use paint::{paint_treemap, Tile}; +pub use squarify::squarify; diff --git a/00_unanchay/pineal/pineal-treemap/src/paint.rs b/00_unanchay/pineal/pineal-treemap/src/paint.rs new file mode 100644 index 0000000..00c7644 --- /dev/null +++ b/00_unanchay/pineal/pineal-treemap/src/paint.rs @@ -0,0 +1,74 @@ +//! Painter agnóstico del treemap: tiles → `fill_rect` contra un `Canvas`. + +use crate::squarify::squarify; +use pineal_render::{Canvas, Color, Rect}; + +/// Una tile del treemap: su peso (área relativa) y su color. +#[derive(Debug, Clone, Copy)] +pub struct Tile { + pub weight: f64, + pub color: Color, +} + +impl Tile { + pub fn new(weight: f64, color: Color) -> Self { + Self { weight, color } + } +} + +/// Dibuja un treemap de `tiles` dentro de `area`. `gap` es el margen +/// (en px) que se recorta de cada lado de cada tile, para separarlas +/// visualmente. Tiles cuya área no alcanza para el gap se omiten. +pub fn paint_treemap(tiles: &[Tile], area: Rect, gap: f32, canvas: &mut dyn Canvas) { + let weights: Vec = tiles.iter().map(|t| t.weight).collect(); + let rects = squarify(&weights, area); + for (tile, r) in tiles.iter().zip(&rects) { + let inset = Rect::new( + r.x + gap, + r.y + gap, + r.w - 2.0 * gap, + r.h - 2.0 * gap, + ); + if inset.w > 0.0 && inset.h > 0.0 { + canvas.fill_rect(inset, tile.color); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pineal_render::{PlanRecorder, RenderCmd}; + + #[test] + fn one_fill_rect_per_visible_tile() { + let tiles = [ + Tile::new(3.0, Color::WHITE), + Tile::new(2.0, Color::BLACK), + Tile::new(1.0, Color::from_hex(0x00ff00)), + ]; + let mut rec = PlanRecorder::new(); + paint_treemap(&tiles, Rect::new(0.0, 0.0, 300.0, 200.0), 1.0, &mut rec); + let n = rec + .into_plan() + .cmds + .iter() + .filter(|c| matches!(c, RenderCmd::FillRect { .. })) + .count(); + assert_eq!(n, 3); + } + + #[test] + fn zero_weight_tile_is_skipped() { + let tiles = [Tile::new(1.0, Color::WHITE), Tile::new(0.0, Color::BLACK)]; + let mut rec = PlanRecorder::new(); + paint_treemap(&tiles, Rect::new(0.0, 0.0, 100.0, 100.0), 0.0, &mut rec); + let n = rec + .into_plan() + .cmds + .iter() + .filter(|c| matches!(c, RenderCmd::FillRect { .. })) + .count(); + assert_eq!(n, 1); + } +} diff --git a/00_unanchay/pineal/pineal-treemap/src/squarify.rs b/00_unanchay/pineal/pineal-treemap/src/squarify.rs new file mode 100644 index 0000000..6a82720 --- /dev/null +++ b/00_unanchay/pineal/pineal-treemap/src/squarify.rs @@ -0,0 +1,157 @@ +//! Treemap squarified (Bruls, Huizing & van Wijk, 2000). +//! +//! Asigna a cada peso un rectángulo de área proporcional, minimizando +//! el peor aspect ratio (rects lo más cuadrados posible). Pre-escala los +//! pesos al área del rect destino para estabilidad numérica. + +use pineal_render::Rect; + +/// Calcula el layout: devuelve un `Rect` por peso, en el mismo orden de +/// entrada. Pesos `<= 0` o no finitos reciben un rect de área cero. +pub fn squarify(weights: &[f64], area: Rect) -> Vec { + let n = weights.len(); + let zero = Rect::new(area.x, area.y, 0.0, 0.0); + let mut out = vec![zero; n]; + + let area_px = area.w as f64 * area.h as f64; + let total: f64 = weights.iter().filter(|w| w.is_finite() && **w > 0.0).sum(); + if n == 0 || total <= 0.0 || area_px <= 0.0 { + return out; + } + let scale = area_px / total; + + // Sólo los pesos positivos participan; los demás quedan con rect cero. + // `idx` se ordena por área descendente — mejora los aspect ratios. + let areas: Vec = weights + .iter() + .map(|w| if w.is_finite() && *w > 0.0 { w * scale } else { 0.0 }) + .collect(); + let mut idx: Vec = (0..n).filter(|&i| areas[i] > 0.0).collect(); + idx.sort_by(|&a, &b| areas[b].partial_cmp(&areas[a]).unwrap_or(std::cmp::Ordering::Equal)); + + let mut free = area; + let mut row: Vec = Vec::new(); + let mut i = 0; + + while i < idx.len() { + let side = free.w.min(free.h) as f64; + let cur = worst_ratio(&row, &areas, side); + row.push(idx[i]); + let with_next = worst_ratio(&row, &areas, side); + + if cur > 0.0 && with_next > cur { + // Agregar el item empeoró el ratio: revertir, cerrar la fila. + row.pop(); + free = layout_row(&row, &areas, free, &mut out); + row.clear(); + } else { + i += 1; + } + } + if !row.is_empty() { + layout_row(&row, &areas, free, &mut out); + } + out +} + +/// Peor aspect ratio de una fila tendida sobre un lado de longitud `side`. +/// Fórmula de Bruls et al.: `max(side²·max / sum², sum² / (side²·min))`. +fn worst_ratio(row: &[usize], areas: &[f64], side: f64) -> f64 { + if row.is_empty() || side <= 0.0 { + return 0.0; + } + let mut sum = 0.0; + let mut mx = f64::MIN; + let mut mn = f64::MAX; + for &i in row { + let a = areas[i]; + sum += a; + mx = mx.max(a); + mn = mn.min(a); + } + if sum <= 0.0 || mn <= 0.0 { + return f64::INFINITY; + } + let s2 = sum * sum; + let w2 = side * side; + (w2 * mx / s2).max(s2 / (w2 * mn)) +} + +/// Tiende una fila sobre el lado corto del rect libre y devuelve el rect +/// libre restante. +fn layout_row(row: &[usize], areas: &[f64], free: Rect, out: &mut [Rect]) -> Rect { + let sum: f64 = row.iter().map(|&i| areas[i]).sum(); + if sum <= 0.0 { + return free; + } + if free.w >= free.h { + // Columna a la izquierda; items apilados verticalmente. + let col_w = (sum / free.h as f64) as f32; + let mut y = free.y; + for &i in row { + let h = (areas[i] / sum * free.h as f64) as f32; + out[i] = Rect::new(free.x, y, col_w, h); + y += h; + } + Rect::new(free.x + col_w, free.y, free.w - col_w, free.h) + } else { + // Fila arriba; items lado a lado horizontalmente. + let row_h = (sum / free.w as f64) as f32; + let mut x = free.x; + for &i in row { + let w = (areas[i] / sum * free.w as f64) as f32; + out[i] = Rect::new(x, free.y, w, row_h); + x += w; + } + Rect::new(free.x, free.y + row_h, free.w, free.h - row_h) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn area_of(r: &Rect) -> f64 { + r.w as f64 * r.h as f64 + } + + #[test] + fn empty_input() { + assert!(squarify(&[], Rect::new(0.0, 0.0, 100.0, 100.0)).is_empty()); + } + + #[test] + fn single_item_fills_rect() { + let rects = squarify(&[1.0], Rect::new(0.0, 0.0, 100.0, 50.0)); + assert_eq!(rects.len(), 1); + assert!((area_of(&rects[0]) - 5000.0).abs() < 1.0); + } + + #[test] + fn areas_proportional_to_weights() { + let area = Rect::new(0.0, 0.0, 200.0, 100.0); + let rects = squarify(&[1.0, 1.0, 2.0], area); + let total: f64 = rects.iter().map(area_of).sum(); + assert!((total - 20_000.0).abs() < 5.0, "área total ≈ rect"); + // El tercer item pesa el doble que cada uno de los otros. + assert!((area_of(&rects[2]) - 2.0 * area_of(&rects[0])).abs() < 50.0); + } + + #[test] + fn zero_and_negative_weights_get_empty_rects() { + let rects = squarify(&[1.0, 0.0, -3.0], Rect::new(0.0, 0.0, 100.0, 100.0)); + assert!(area_of(&rects[1]) == 0.0); + assert!(area_of(&rects[2]) == 0.0); + assert!((area_of(&rects[0]) - 10_000.0).abs() < 1.0); + } + + #[test] + fn all_rects_within_bounds() { + let area = Rect::new(10.0, 20.0, 300.0, 200.0); + let rects = squarify(&[5.0, 3.0, 8.0, 1.0, 2.0, 6.0], area); + for r in &rects { + assert!(r.x >= area.x - 0.01 && r.right() <= area.right() + 0.01); + assert!(r.y >= area.y - 0.01 && r.bottom() <= area.bottom() + 0.01); + } + } +} diff --git a/00_unanchay/pineal/pineal-umbrella/Cargo.toml b/00_unanchay/pineal/pineal-umbrella/Cargo.toml new file mode 100644 index 0000000..146e239 --- /dev/null +++ b/00_unanchay/pineal/pineal-umbrella/Cargo.toml @@ -0,0 +1,48 @@ +[package] +name = "pineal" +version = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +authors = { workspace = true } +publish = { workspace = true } +description = "Lapaloma — paraguas: re-exporta los módulos para prototipos. En producción importar los crates hoja directamente para que tree-shaking descarte lo no usado." + +[dependencies] +pineal-core = { path = "../pineal-core", optional = true } +pineal-render = { path = "../pineal-render", optional = true } +pineal-cartesian = { path = "../pineal-cartesian", optional = true } +pineal-stream = { path = "../pineal-stream", optional = true } +pineal-mesh = { path = "../pineal-mesh", optional = true } +pineal-financial = { path = "../pineal-financial", optional = true } +pineal-polar = { path = "../pineal-polar", optional = true } +pineal-heatmap = { path = "../pineal-heatmap", optional = true } +pineal-treemap = { path = "../pineal-treemap", optional = true } +pineal-flow = { path = "../pineal-flow", optional = true } +pineal-phosphor = { path = "../pineal-phosphor", optional = true } +pineal-export = { path = "../pineal-export", optional = true } +pineal-hexbin = { path = "../pineal-hexbin", optional = true } +pineal-contour = { path = "../pineal-contour", optional = true } +pineal-bars = { path = "../pineal-bars", optional = true } + +[features] +default = ["full"] +full = [ + "core", "render", "cartesian", "stream", "mesh", "financial", + "polar", "heatmap", "treemap", "flow", "phosphor", "export", + "hexbin", "contour", "bars", +] +core = ["dep:pineal-core"] +render = ["dep:pineal-render", "core"] +cartesian = ["dep:pineal-cartesian", "render"] +stream = ["dep:pineal-stream", "render"] +mesh = ["dep:pineal-mesh", "render"] +financial = ["dep:pineal-financial", "cartesian"] +polar = ["dep:pineal-polar", "render"] +heatmap = ["dep:pineal-heatmap", "render"] +treemap = ["dep:pineal-treemap", "render"] +flow = ["dep:pineal-flow", "render"] +phosphor = ["dep:pineal-phosphor", "stream"] +export = ["dep:pineal-export", "render"] +hexbin = ["dep:pineal-hexbin", "heatmap"] +contour = ["dep:pineal-contour", "heatmap"] +bars = ["dep:pineal-bars", "render"] diff --git a/00_unanchay/pineal/pineal-umbrella/LEEME.md b/00_unanchay/pineal/pineal-umbrella/LEEME.md new file mode 100644 index 0000000..9708be2 --- /dev/null +++ b/00_unanchay/pineal/pineal-umbrella/LEEME.md @@ -0,0 +1,20 @@ +# pineal-umbrella + +> Compositor de múltiples pineales para [pineal](../README.md). + +Cuando una vista necesita varios canvas distintos sobre el mismo viewport (ej: heatmap + scatter overlay + ejes cartesianos), `umbrella` los compone respetando z-order y blend modes. Cada sub-canvas mantiene su backend independiente. + +## API + +```rust +use pineal_umbrella::{Umbrella, Layer}; + +let view = Umbrella::new() + .layer(Layer::heatmap(hm)) + .layer(Layer::cartesian(cart)); +``` + +## Deps + +- [`pineal-core`](../pineal-core/README.md) +- Los backends que combine diff --git a/00_unanchay/pineal/pineal-umbrella/README.md b/00_unanchay/pineal/pineal-umbrella/README.md new file mode 100644 index 0000000..66d082a --- /dev/null +++ b/00_unanchay/pineal/pineal-umbrella/README.md @@ -0,0 +1,20 @@ +# pineal-umbrella + +> Multi-pineal compositor for [pineal](../README.md). + +When a view needs several different canvases over the same viewport (e.g., heatmap + scatter overlay + cartesian axes), `umbrella` composites them respecting z-order and blend modes. Each sub-canvas keeps its independent backend. + +## API + +```rust +use pineal_umbrella::{Umbrella, Layer}; + +let view = Umbrella::new() + .layer(Layer::heatmap(hm)) + .layer(Layer::cartesian(cart)); +``` + +## Deps + +- [`pineal-core`](../pineal-core/README.md) +- The backends being combined diff --git a/00_unanchay/pineal/pineal-umbrella/src/lib.rs b/00_unanchay/pineal/pineal-umbrella/src/lib.rs new file mode 100644 index 0000000..1fa2de5 --- /dev/null +++ b/00_unanchay/pineal/pineal-umbrella/src/lib.rs @@ -0,0 +1,62 @@ +//! `pineal` — paraguas re-export. +//! +//! Para **prototipos** que quieren probar varios módulos a la vez +//! sin agregar 8 dependencias a `Cargo.toml`. En producción +//! preferir importar directamente los crates hoja (`pineal-core`, +//! `pineal-cartesian`, …) para que el linker descarte lo no +//! usado y los tiempos de compilación bajen. +//! +//! Las features mapean 1:1 a cada sub-crate: +//! +//! ```toml +//! [dependencies] +//! pineal = { workspace = true, default-features = false, +//! features = ["cartesian", "stream"] } +//! ``` + +#![forbid(unsafe_code)] + +#[cfg(feature = "core")] +pub use pineal_core as core; + +#[cfg(feature = "render")] +pub use pineal_render as render; + +#[cfg(feature = "cartesian")] +pub use pineal_cartesian as cartesian; + +#[cfg(feature = "stream")] +pub use pineal_stream as stream; + +#[cfg(feature = "mesh")] +pub use pineal_mesh as mesh; + +#[cfg(feature = "financial")] +pub use pineal_financial as financial; + +#[cfg(feature = "polar")] +pub use pineal_polar as polar; + +#[cfg(feature = "heatmap")] +pub use pineal_heatmap as heatmap; + +#[cfg(feature = "treemap")] +pub use pineal_treemap as treemap; + +#[cfg(feature = "flow")] +pub use pineal_flow as flow; + +#[cfg(feature = "phosphor")] +pub use pineal_phosphor as phosphor; + +#[cfg(feature = "export")] +pub use pineal_export as export; + +#[cfg(feature = "hexbin")] +pub use pineal_hexbin as hexbin; + +#[cfg(feature = "contour")] +pub use pineal_contour as contour; + +#[cfg(feature = "bars")] +pub use pineal_bars as bars; diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..ec58be7 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3428 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.12.1", + "cc", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys 0.6.0+11769913", + "num_enum", + "thiserror 2.0.18", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "app-bus" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/llimphi.git#c1b11b2c01d7623a4f346519b9d1ec435be30b95" +dependencies = [ + "directories", + "serde", + "toml", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.12.1", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "cc" +version = "1.2.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "codespan-reporting" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" +dependencies = [ + "termcolor", + "unicode-width", +] + +[[package]] +name = "color" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "directories" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "font-types" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02a596f5713680923a2080d86de50fe472fb290693cf0f701187a1c8b36996b7" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "font-types" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b38ad915f6dadd993ced50848a8291a543bd41ca62bc10740d5e64e2ab4cfd7" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "fontconfig-cache-parser" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7f8afb20c8069fd676d27b214559a337cc619a605d25a87baa90b49a06f3b18" +dependencies = [ + "bytemuck", + "thiserror 1.0.69", +] + +[[package]] +name = "fontique" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64763d1f274c8383333851435b6cdf071c31cfcdb39fd5860d20943205a007a7" +dependencies = [ + "bytemuck", + "fontconfig-cache-parser", + "hashbrown 0.15.5", + "icu_locid", + "memmap2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-text", + "objc2-foundation 0.3.2", + "peniko", + "read-fonts 0.29.3", + "roxmltree", + "smallvec", + "windows", + "windows-core", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.4", + "windows-link", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glow" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gpu-alloc" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbcd2dba93594b227a1f57ee09b8b9da8892c34d55aa332e034a228d0fe6a171" +dependencies = [ + "bitflags 2.12.1", + "gpu-alloc-types", +] + +[[package]] +name = "gpu-alloc-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98ff03b468aa837d70984d55f5d3f846f6ec31fe34bbb97c4f85219caeee1ca4" +dependencies = [ + "bitflags 2.12.1", +] + +[[package]] +name = "gpu-allocator" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c151a2a5ef800297b4e79efa4f4bec035c5f51d5ae587287c9b952bdf734cacd" +dependencies = [ + "log", + "presser", + "thiserror 1.0.69", + "windows", +] + +[[package]] +name = "gpu-descriptor" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" +dependencies = [ + "bitflags 2.12.1", + "gpu-descriptor-types", + "hashbrown 0.15.5", +] + +[[package]] +name = "gpu-descriptor-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" +dependencies = [ + "bitflags 2.12.1", +] + +[[package]] +name = "grid" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b40ca9252762c466af32d0b1002e91e4e1bc5398f77455e55474deb466355ff5" + +[[package]] +name = "guillotiere" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b62d5865c036cb1393e23c50693df631d3f5d7bcca4c04fe4cc0fd592e74a782" +dependencies = [ + "euclid", + "svg_fmt", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + +[[package]] +name = "icu_locid" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "kurbo" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62" +dependencies = [ + "arrayvec", + "euclid", + "smallvec", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "bitflags 2.12.1", + "libc", + "plain", + "redox_syscall 0.8.1", +] + +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "llimphi-compositor" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/llimphi.git#c1b11b2c01d7623a4f346519b9d1ec435be30b95" +dependencies = [ + "llimphi-layout", + "llimphi-text", + "vello", + "wgpu", +] + +[[package]] +name = "llimphi-hal" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/llimphi.git#c1b11b2c01d7623a4f346519b9d1ec435be30b95" +dependencies = [ + "pollster", + "raw-window-handle", + "wgpu", + "winit", +] + +[[package]] +name = "llimphi-layout" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/llimphi.git#c1b11b2c01d7623a4f346519b9d1ec435be30b95" +dependencies = [ + "taffy", +] + +[[package]] +name = "llimphi-raster" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/llimphi.git#c1b11b2c01d7623a4f346519b9d1ec435be30b95" +dependencies = [ + "llimphi-hal", + "pollster", + "vello", +] + +[[package]] +name = "llimphi-text" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/llimphi.git#c1b11b2c01d7623a4f346519b9d1ec435be30b95" +dependencies = [ + "parley", + "vello", +] + +[[package]] +name = "llimphi-theme" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/llimphi.git#c1b11b2c01d7623a4f346519b9d1ec435be30b95" +dependencies = [ + "llimphi-raster", +] + +[[package]] +name = "llimphi-ui" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/llimphi.git#c1b11b2c01d7623a4f346519b9d1ec435be30b95" +dependencies = [ + "llimphi-compositor", + "llimphi-hal", + "llimphi-layout", + "llimphi-raster", + "llimphi-text", + "pollster", +] + +[[package]] +name = "llimphi-widget-button" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/llimphi.git#c1b11b2c01d7623a4f346519b9d1ec435be30b95" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-context-menu" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/llimphi.git#c1b11b2c01d7623a4f346519b9d1ec435be30b95" +dependencies = [ + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-panel", +] + +[[package]] +name = "llimphi-widget-menubar" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/llimphi.git#c1b11b2c01d7623a4f346519b9d1ec435be30b95" +dependencies = [ + "app-bus", + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-button", + "llimphi-widget-context-menu", +] + +[[package]] +name = "llimphi-widget-panel" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/llimphi.git#c1b11b2c01d7623a4f346519b9d1ec435be30b95" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "memmap2" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +dependencies = [ + "libc", +] + +[[package]] +name = "metal" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f569fb946490b5743ad69813cb19629130ce9374034abe31614a36402d18f99e" +dependencies = [ + "bitflags 2.12.1", + "block", + "core-graphics-types", + "foreign-types", + "log", + "objc", + "paste", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "naga" +version = "24.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e380993072e52eef724eddfcde0ed013b0c023c3f0417336ed041aa9f076994e" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags 2.12.1", + "cfg_aliases", + "codespan-reporting", + "hexf-parse", + "indexmap", + "log", + "rustc-hash", + "spirv", + "strum", + "termcolor", + "thiserror 2.0.18", + "unicode-xid", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.12.1", + "jni-sys 0.3.1", + "log", + "ndk-sys 0.6.0+11769913", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.5.0+25.2.9519653" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.12.1", + "block2", + "libc", + "objc2 0.5.2", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.12.1", + "block2", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.12.1", + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.12.1", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-contacts", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.12.1", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.12.1", + "block2", + "dispatch", + "libc", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.12.1", + "objc2 0.6.4", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-app-kit", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.12.1", + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.12.1", + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.12.1", + "block2", + "objc2 0.5.2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.12.1", + "block2", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "parley" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e28dadbe655332fd7d996794ec8d0c376695f6ca47bc75aa01e0967c7f28e42a" +dependencies = [ + "fontique", + "hashbrown 0.15.5", + "peniko", + "skrifa 0.31.3", + "swash", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "peniko" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b44f9ddd2f480176b34278eb653ec1c8062f3b143a4e16eeff5ffac3334e288" +dependencies = [ + "color", + "kurbo", + "linebender_resource_handle", + "smallvec", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pineal" +version = "0.1.0" +dependencies = [ + "pineal-bars", + "pineal-cartesian", + "pineal-contour", + "pineal-core", + "pineal-export", + "pineal-financial", + "pineal-flow", + "pineal-heatmap", + "pineal-hexbin", + "pineal-mesh", + "pineal-phosphor", + "pineal-polar", + "pineal-render", + "pineal-stream", + "pineal-treemap", +] + +[[package]] +name = "pineal-bars" +version = "0.1.0" +dependencies = [ + "pineal-core", + "pineal-render", +] + +[[package]] +name = "pineal-bars-demo" +version = "0.1.0" +dependencies = [ + "app-bus", + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-context-menu", + "llimphi-widget-menubar", + "pineal-bars", + "pineal-render", +] + +[[package]] +name = "pineal-cartesian" +version = "0.1.0" +dependencies = [ + "llimphi-ui", + "pineal-core", + "pineal-render", +] + +[[package]] +name = "pineal-contour" +version = "0.1.0" +dependencies = [ + "pineal-heatmap", + "pineal-render", +] + +[[package]] +name = "pineal-contour-demo" +version = "0.1.0" +dependencies = [ + "app-bus", + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-context-menu", + "llimphi-widget-menubar", + "pineal-contour", + "pineal-heatmap", + "pineal-render", +] + +[[package]] +name = "pineal-core" +version = "0.1.0" + +[[package]] +name = "pineal-demo" +version = "0.1.0" +dependencies = [ + "app-bus", + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-context-menu", + "llimphi-widget-menubar", + "pineal-cartesian", + "pineal-core", + "pineal-render", +] + +[[package]] +name = "pineal-export" +version = "0.1.0" +dependencies = [ + "pineal-core", + "pineal-render", + "png 0.18.1", +] + +[[package]] +name = "pineal-financial" +version = "0.1.0" +dependencies = [ + "llimphi-ui", + "pineal-cartesian", + "pineal-core", + "pineal-render", +] + +[[package]] +name = "pineal-financial-demo" +version = "0.1.0" +dependencies = [ + "app-bus", + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-context-menu", + "llimphi-widget-menubar", + "pineal-cartesian", + "pineal-financial", + "pineal-render", +] + +[[package]] +name = "pineal-flow" +version = "0.1.0" +dependencies = [ + "pineal-core", + "pineal-render", +] + +[[package]] +name = "pineal-flow-demo" +version = "0.1.0" +dependencies = [ + "app-bus", + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-context-menu", + "llimphi-widget-menubar", + "pineal-flow", + "pineal-render", +] + +[[package]] +name = "pineal-galeria-demo" +version = "0.1.0" +dependencies = [ + "app-bus", + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-menubar", + "pineal-bars", + "pineal-contour", + "pineal-flow", + "pineal-heatmap", + "pineal-hexbin", + "pineal-mesh", + "pineal-polar", + "pineal-render", + "pineal-treemap", +] + +[[package]] +name = "pineal-gpu-demo" +version = "0.1.0" +dependencies = [ + "app-bus", + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-context-menu", + "llimphi-widget-menubar", + "pineal-render", + "wgpu", +] + +[[package]] +name = "pineal-heatmap" +version = "0.1.0" +dependencies = [ + "pineal-core", + "pineal-render", +] + +[[package]] +name = "pineal-heatmap-demo" +version = "0.1.0" +dependencies = [ + "app-bus", + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-context-menu", + "llimphi-widget-menubar", + "pineal-heatmap", + "pineal-render", +] + +[[package]] +name = "pineal-hexbin" +version = "0.1.0" +dependencies = [ + "pineal-heatmap", + "pineal-render", +] + +[[package]] +name = "pineal-hexbin-demo" +version = "0.1.0" +dependencies = [ + "app-bus", + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-context-menu", + "llimphi-widget-menubar", + "pineal-heatmap", + "pineal-hexbin", + "pineal-render", +] + +[[package]] +name = "pineal-mesh" +version = "0.1.0" +dependencies = [ + "pineal-core", + "pineal-render", +] + +[[package]] +name = "pineal-mesh-demo" +version = "0.1.0" +dependencies = [ + "app-bus", + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-context-menu", + "llimphi-widget-menubar", + "pineal-mesh", + "pineal-render", +] + +[[package]] +name = "pineal-phosphor" +version = "0.1.0" +dependencies = [ + "llimphi-ui", + "pineal-core", + "pineal-render", +] + +[[package]] +name = "pineal-phosphor-demo" +version = "0.1.0" +dependencies = [ + "app-bus", + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-context-menu", + "llimphi-widget-menubar", + "pineal-core", + "pineal-phosphor", + "pineal-render", +] + +[[package]] +name = "pineal-polar" +version = "0.1.0" +dependencies = [ + "pineal-core", + "pineal-render", +] + +[[package]] +name = "pineal-polar-demo" +version = "0.1.0" +dependencies = [ + "app-bus", + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-context-menu", + "llimphi-widget-menubar", + "pineal-polar", + "pineal-render", +] + +[[package]] +name = "pineal-render" +version = "0.1.0" +dependencies = [ + "llimphi-ui", + "pineal-core", +] + +[[package]] +name = "pineal-stream" +version = "0.1.0" +dependencies = [ + "llimphi-ui", + "pineal-core", + "pineal-render", +] + +[[package]] +name = "pineal-stream-demo" +version = "0.1.0" +dependencies = [ + "app-bus", + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-context-menu", + "llimphi-widget-menubar", + "pineal-core", + "pineal-render", + "pineal-stream", +] + +[[package]] +name = "pineal-treemap" +version = "0.1.0" +dependencies = [ + "pineal-core", + "pineal-render", +] + +[[package]] +name = "pineal-treemap-demo" +version = "0.1.0" +dependencies = [ + "app-bus", + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-context-menu", + "llimphi-widget-menubar", + "pineal-render", + "pineal-treemap", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.12.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.12+spec-1.1.0", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "read-fonts" +version = "0.29.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04ca636dac446b5664bd16c069c00a9621806895b8bb02c2dc68542b23b8f25d" +dependencies = [ + "bytemuck", + "font-types 0.9.0", +] + +[[package]] +name = "read-fonts" +version = "0.33.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50ea612a55c08586a1d15134be8a776186c440c312ebda3b9e8efbfe4255b7f4" +dependencies = [ + "bytemuck", + "font-types 0.9.0", +] + +[[package]] +name = "read-fonts" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b634fabf032fab15307ffd272149b622260f55974d9fad689292a5d33df02e5" +dependencies = [ + "bytemuck", + "font-types 0.11.3", +] + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.12.1", +] + +[[package]] +name = "redox_syscall" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b44b894f2a6e36457d665d1e08c3866add6ed5e70050c1b4ba8a8ddedb02ce7" +dependencies = [ + "bitflags 2.12.1", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.12.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.12.1", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "ab_glyph", + "log", + "memmap2", + "smithay-client-toolkit", + "tiny-skia", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "skrifa" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbeb4ca4399663735553a09dd17ce7e49a0a0203f03b706b39628c4d913a8607" +dependencies = [ + "bytemuck", + "read-fonts 0.29.3", +] + +[[package]] +name = "skrifa" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "576e60c7de4bb6a803a0312f9bef17e78cf1e8d25a80e1ade76770d7a0237955" +dependencies = [ + "bytemuck", + "read-fonts 0.33.1", +] + +[[package]] +name = "skrifa" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fbdfe3d2475fbd7ddd1f3e5cf8288a30eb3e5f95832829570cd88115a7434ac" +dependencies = [ + "bytemuck", + "read-fonts 0.37.0", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.12.1", + "calloop", + "calloop-wayland-source", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "spirv" +version = "0.3.0+sdk-1.3.268.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" +dependencies = [ + "bitflags 2.12.1", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + +[[package]] +name = "svg_fmt" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb" + +[[package]] +name = "swash" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "842f3cd369c2ba38966204f983eaa5e54a8e84a7d7159ed36ade2b6c335aae64" +dependencies = [ + "skrifa 0.40.0", + "yazi", + "zeno", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "taffy" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41ba83ebaf2954d31d05d67340fd46cebe99da2b7133b0dd68d70c65473a437b" +dependencies = [ + "arrayvec", + "grid", + "serde", + "slotmap", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tinystr" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +dependencies = [ + "displaydoc", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.3", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.3", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "vello" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa3f8a53870a2ee699ce05b738a3f9974c92c35ed4874de86052ac68d214811c" +dependencies = [ + "bytemuck", + "futures-intrusive", + "log", + "peniko", + "png 0.17.16", + "skrifa 0.35.0", + "static_assertions", + "thiserror 2.0.18", + "vello_encoding", + "vello_shaders", + "wgpu", +] + +[[package]] +name = "vello_encoding" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c69b0fe94b0ac7e47619c504ee2c377355174f5c46353c46d03fa5f7e435922b" +dependencies = [ + "bytemuck", + "guillotiere", + "peniko", + "skrifa 0.35.0", + "smallvec", +] + +[[package]] +name = "vello_shaders" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2ebea426bb2f95b7610bca09178b03d809ede1d3c500a9acf6eca43e8f200be" +dependencies = [ + "bytemuck", + "naga", + "thiserror 2.0.18", + "vello_encoding", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wayland-backend" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.4", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" +dependencies = [ + "bitflags 2.12.1", + "rustix 1.1.4", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.12.1", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.4", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" +dependencies = [ + "bitflags 2.12.1", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.12.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.12.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wgpu" +version = "24.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b0b3436f0729f6cdf2e6e9201f3d39dc95813fad61d826c1ed07918b4539353" +dependencies = [ + "arrayvec", + "bitflags 2.12.1", + "cfg_aliases", + "document-features", + "js-sys", + "log", + "naga", + "parking_lot", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "24.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f0aa306497a238d169b9dc70659105b4a096859a34894544ca81719242e1499" +dependencies = [ + "arrayvec", + "bit-vec", + "bitflags 2.12.1", + "cfg_aliases", + "document-features", + "indexmap", + "log", + "naga", + "once_cell", + "parking_lot", + "profiling", + "raw-window-handle", + "rustc-hash", + "smallvec", + "thiserror 2.0.18", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-hal" +version = "24.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112f464674ca69f3533248508ee30cb84c67cf06c25ff6800685f5e0294e259" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set", + "bitflags 2.12.1", + "block", + "bytemuck", + "cfg_aliases", + "core-graphics-types", + "glow", + "glutin_wgl_sys", + "gpu-alloc", + "gpu-allocator", + "gpu-descriptor", + "js-sys", + "khronos-egl", + "libc", + "libloading", + "log", + "metal", + "naga", + "ndk-sys 0.5.0+25.2.9519653", + "objc", + "once_cell", + "ordered-float", + "parking_lot", + "profiling", + "range-alloc", + "raw-window-handle", + "renderdoc-sys", + "rustc-hash", + "smallvec", + "thiserror 2.0.18", + "wasm-bindgen", + "web-sys", + "wgpu-types", + "windows", + "windows-core", +] + +[[package]] +name = "wgpu-types" +version = "24.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50ac044c0e76c03a0378e7786ac505d010a873665e2d51383dcff8dd227dc69c" +dependencies = [ + "bitflags 2.12.1", + "js-sys", + "log", + "web-sys", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-result", + "windows-strings", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.12.1", + "block2", + "bytemuck", + "calloop", + "cfg_aliases", + "concurrent-queue", + "core-foundation", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2 0.5.2", + "objc2-app-kit", + "objc2-foundation 0.2.2", + "objc2-ui-kit", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "sctk-adwaita", + "smithay-client-toolkit", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.4", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xcursor" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.12.1", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "yazi" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5" + +[[package]] +name = "zeno" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" + +[[package]] +name = "zerocopy" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..815ca4a --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,459 @@ +# Cargo.toml raíz STANDALONE de pineal — front-door sobre Llimphi. +# Solo el código de pineal; Llimphi y lo fundacional por git-dep del monorepo gioser.git. +[workspace] +resolver = "2" +members = [ + "00_unanchay/pineal/demos/pineal-bars-demo", + "00_unanchay/pineal/demos/pineal-contour-demo", + "00_unanchay/pineal/demos/pineal-demo", + "00_unanchay/pineal/demos/pineal-financial-demo", + "00_unanchay/pineal/demos/pineal-flow-demo", + "00_unanchay/pineal/demos/pineal-galeria-demo", + "00_unanchay/pineal/demos/pineal-gpu-demo", + "00_unanchay/pineal/demos/pineal-heatmap-demo", + "00_unanchay/pineal/demos/pineal-hexbin-demo", + "00_unanchay/pineal/demos/pineal-mesh-demo", + "00_unanchay/pineal/demos/pineal-phosphor-demo", + "00_unanchay/pineal/demos/pineal-polar-demo", + "00_unanchay/pineal/demos/pineal-stream-demo", + "00_unanchay/pineal/demos/pineal-treemap-demo", + "00_unanchay/pineal/pineal-bars", + "00_unanchay/pineal/pineal-cartesian", + "00_unanchay/pineal/pineal-contour", + "00_unanchay/pineal/pineal-core", + "00_unanchay/pineal/pineal-export", + "00_unanchay/pineal/pineal-financial", + "00_unanchay/pineal/pineal-flow", + "00_unanchay/pineal/pineal-heatmap", + "00_unanchay/pineal/pineal-hexbin", + "00_unanchay/pineal/pineal-mesh", + "00_unanchay/pineal/pineal-phosphor", + "00_unanchay/pineal/pineal-polar", + "00_unanchay/pineal/pineal-render", + "00_unanchay/pineal/pineal-stream", + "00_unanchay/pineal/pineal-treemap", + "00_unanchay/pineal/pineal-umbrella", +] + +[workspace.package] +version = "0.1.0" +edition = "2021" +rust-version = "1.80" +license = "MIT" +authors = ["Sergio "] +publish = false +repository = "https://gitea.gioser.net/sergio/pineal" + +[workspace.dependencies] + +# === Registro de apps / menú global === +app-bus = { git = "https://gitea.gioser.net/sergio/llimphi.git" } +# === Serialización === +serde = { version = "1", features = ["derive"] } +serde_json = "1" +lsp-types = "0.97" +serde-big-array = "0.5" +postcard = { version = "1", features = ["use-std"] } +toml = "0.8" +ron = "0.8" +bincode = "1" +base64 = "0.22" + +# === Errores === +thiserror = "2" # bump uniforme; arje (era 1) puede requerir ajustes menores +anyhow = "1" + +# === Async === +tokio = { version = "1", features = ["full"] } +tokio-util = { version = "0.7", features = ["compat"] } +async-trait = "0.1" +futures = "0.3" + +# === Observabilidad === +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } + +# === Linux primitives (arje) === +nix = { version = "0.29", features = ["signal", "process", "sched", "mount", "fs", "socket", "net", "user"] } +libc = "0.2" + +# === IDs / Hash / Crypto === +ulid = { version = "1", features = ["serde"] } +uuid = { version = "1", features = ["v4", "rng-getrandom"] } +sha2 = "0.10" +blake3 = "1.5" +ed25519-dalek = "2" +aes-gcm = "0.10" +chacha20poly1305 = "0.10" +argon2 = "0.5" +rand = "0.8" + +# === WASM (arje) === +# wasmi 1.0: unifica la versión con renaser (su kernel ya corre 1.0), para +# que el ABI WASM del host sea idéntico en Linux y en bare-metal. +wasmi = "1.0" +wat = "1" + +# === Storage / DB === +sled = "0.34" +rusqlite = { version = "0.31", features = ["bundled", "blob"] } + +# === Ingesta de documentos (iniy-ingest: PDF / EPUB) === +pdf-extract = "0.7" +epub = "2.1" + +# === Bulk import Wikipedia (iniy-wiki dump) === +bzip2 = "0.4" + +# === Compresión (minga multi-bundle) === +zstd = "0.13" + +# === HTTP server (iniy-server) === +axum = "0.7" +tower = "0.5" + +# === ANN sobre embeddings (iniy nli --ann) === +instant-distance = "0.6" + +# === P2P (minga) === +libp2p = { version = "0.56", features = ["tokio", "tcp", "noise", "yamux", "macros", "kad", "identify", "relay", "dcutr", "autonat", "mdns"] } +libp2p-stream = "=0.4.0-alpha" +libp2p-allow-block-list = "0.6" + +# === SSH (ssh, sandokan RemoteEngine, matilda) === +russh = "0.54" + +# === Math determinista cross-platform (dominium) === +libm = "0.2" + +# === SMF (takiy-midi) === +# midly: parser/emitter SMF tipo 0/1, no_std-friendly, sin allocs en hot path. +midly = "0.5" + +# === Code parsing (minga) === +arboard = "3" +ropey = "1.6" +tree-sitter = "0.24" +tree-sitter-rust = "0.23" +tree-sitter-python = "0.23" +tree-sitter-typescript = "0.23" +tree-sitter-javascript = "0.23" +tree-sitter-go = "0.23" + +# === FS notify === +notify = "6.1" + +# === Grafos (iniy, nakui-core ya lo usa directo en 0.6) === +petgraph = "0.6" + +# === Image decoding (nahual-image-viewer-llimphi) === +# default-features = false: nos quedamos con PNG + JPEG + WebP (lossless). +# tullpu-render exporta a las tres; AVIF/TIFF/… los habilitamos si una app +# los pide específicamente. +image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] } + +# === FUSE (minga-vfs) === +# default-features = false: prescinde de pkg-config/libfuse-dev en build. +# El montaje pasa a ser Rust puro (vía el helper `fusermount3` en runtime). +fuser = { version = "0.15", default-features = false } + +# === CLI / auth (minga) === +clap = { version = "4", features = ["derive"] } +rpassword = "7" + +# === PAM (auth-core) === +pam = "0.8" + +# === D-Bus (arje compat) === +zbus = { version = "4", default-features = false, features = ["tokio"] } + +# === Tests === +tempfile = "3" + +# === Llimphi (motor gráfico soberano) === +# wgpu sobre Vulkan/Metal/DX12, winit para ventana en dev Linux. +# raw-window-handle 0.6 alinea winit 0.30 con wgpu 24. +# vello 0.5 = rasterizador vectorial sobre wgpu 24. +# taffy 0.9 = motor Flexbox/Grid puro Rust (ya pulled por transitivos, lo alineamos). +# parley 0.2 = shaping/layout de texto compatible con peniko 0.4 (que vello 0.5 expone). +wgpu = "24" +winit = "0.30" +raw-window-handle = "0.6" +pollster = "0.4" +vello = "0.5" +taffy = "0.9" +# parley = shaping completo (bidi, ligatures, fallback CJK/emoji vía fontique, line break). +parley = "0.4" +# Bucle Elm (input→update→view→layout→raster→present). Lo consumen las apps. +llimphi-ui = { git = "https://gitea.gioser.net/sergio/llimphi.git" } +# Paleta semántica compartida por las apps y los widgets. +llimphi-theme = { git = "https://gitea.gioser.net/sergio/llimphi.git" } +# Tweens y helpers de animación sobre el bucle Elm. +llimphi-motion = { git = "https://gitea.gioser.net/sergio/llimphi.git" } +# Iconos vectoriales (BezPath en grid 24×24) compartidos por todas las apps. +llimphi-icons = { git = "https://gitea.gioser.net/sergio/llimphi.git" } +# Widgets reusables sobre llimphi-ui — uno por crate. +llimphi-widget-app-header = { git = "https://gitea.gioser.net/sergio/llimphi.git" } +llimphi-widget-banner = { git = "https://gitea.gioser.net/sergio/llimphi.git" } +llimphi-widget-button = { git = "https://gitea.gioser.net/sergio/llimphi.git" } +llimphi-widget-card = { git = "https://gitea.gioser.net/sergio/llimphi.git" } +llimphi-clipboard = { git = "https://gitea.gioser.net/sergio/llimphi.git" } +llimphi-widget-context-menu = { git = "https://gitea.gioser.net/sergio/llimphi.git" } +llimphi-widget-edit-menu = { git = "https://gitea.gioser.net/sergio/llimphi.git" } +llimphi-widget-menubar = { git = "https://gitea.gioser.net/sergio/llimphi.git" } +llimphi-widget-list = { git = "https://gitea.gioser.net/sergio/llimphi.git" } +llimphi-widget-grid = { git = "https://gitea.gioser.net/sergio/llimphi.git" } +llimphi-widget-slider = { git = "https://gitea.gioser.net/sergio/llimphi.git" } +llimphi-widget-scroll = { git = "https://gitea.gioser.net/sergio/llimphi.git" } +llimphi-widget-splitter = { git = "https://gitea.gioser.net/sergio/llimphi.git" } +llimphi-widget-stat-card = { git = "https://gitea.gioser.net/sergio/llimphi.git" } +llimphi-widget-tabs = { git = "https://gitea.gioser.net/sergio/llimphi.git" } +llimphi-module-command-palette = { git = "https://gitea.gioser.net/sergio/llimphi.git" } +llimphi-module-diff-viewer = { git = "https://gitea.gioser.net/sergio/llimphi.git" } +llimphi-module-fif = { git = "https://gitea.gioser.net/sergio/llimphi.git" } +llimphi-module-file-picker = { git = "https://gitea.gioser.net/sergio/llimphi.git" } +llimphi-module-bookmarks = { git = "https://gitea.gioser.net/sergio/llimphi.git" } +llimphi-module-mini-map = { git = "https://gitea.gioser.net/sergio/llimphi.git" } +llimphi-module-shuma-term = { git = "https://gitea.gioser.net/sergio/llimphi.git" } +llimphi-module-symbol-outline = { git = "https://gitea.gioser.net/sergio/llimphi.git" } +llimphi-plugin-host = { git = "https://gitea.gioser.net/sergio/llimphi.git" } +llimphi-widget-theme-switcher = { git = "https://gitea.gioser.net/sergio/llimphi.git" } +llimphi-widget-text-area = { git = "https://gitea.gioser.net/sergio/llimphi.git" } +llimphi-widget-text-editor-core = { git = "https://gitea.gioser.net/sergio/llimphi.git" } +llimphi-widget-text-editor = { git = "https://gitea.gioser.net/sergio/llimphi.git" } +llimphi-widget-text-editor-lsp = { git = "https://gitea.gioser.net/sergio/llimphi.git" } +llimphi-widget-text-input = { git = "https://gitea.gioser.net/sergio/llimphi.git" } +llimphi-widget-tiled = { git = "https://gitea.gioser.net/sergio/llimphi.git" } +llimphi-widget-nodegraph = { git = "https://gitea.gioser.net/sergio/llimphi.git" } +llimphi-widget-tree = { git = "https://gitea.gioser.net/sergio/llimphi.git" } +llimphi-widget-navigator = { git = "https://gitea.gioser.net/sergio/llimphi.git" } +# Sello vectorial wawa (rombo + W implícita + Merkle Core). +llimphi-widget-wawa-mark = { git = "https://gitea.gioser.net/sergio/llimphi.git" } +# Widgets de elegancia transversal (tooltip, spinner, progress, toast, +# modal, empty, status-bar, shortcuts-help, splash). +llimphi-widget-tooltip = { git = "https://gitea.gioser.net/sergio/llimphi.git" } +llimphi-widget-spinner = { git = "https://gitea.gioser.net/sergio/llimphi.git" } +llimphi-widget-progress = { git = "https://gitea.gioser.net/sergio/llimphi.git" } +llimphi-widget-toast = { git = "https://gitea.gioser.net/sergio/llimphi.git" } +llimphi-widget-modal = { git = "https://gitea.gioser.net/sergio/llimphi.git" } +llimphi-widget-empty = { git = "https://gitea.gioser.net/sergio/llimphi.git" } +llimphi-widget-status-bar = { git = "https://gitea.gioser.net/sergio/llimphi.git" } +llimphi-widget-shortcuts-help = { git = "https://gitea.gioser.net/sergio/llimphi.git" } +llimphi-widget-timeline = { git = "https://gitea.gioser.net/sergio/llimphi.git" } +llimphi-widget-splash = { git = "https://gitea.gioser.net/sergio/llimphi.git" } +# Controles de formulario y signaling (switch, segmented, breadcrumb, +# badge, avatar, skeleton, field). +llimphi-widget-switch = { git = "https://gitea.gioser.net/sergio/llimphi.git" } +llimphi-widget-segmented = { git = "https://gitea.gioser.net/sergio/llimphi.git" } +llimphi-widget-dock-rail = { git = "https://gitea.gioser.net/sergio/llimphi.git" } +llimphi-widget-breadcrumb = { git = "https://gitea.gioser.net/sergio/llimphi.git" } +llimphi-widget-badge = { git = "https://gitea.gioser.net/sergio/llimphi.git" } +llimphi-widget-avatar = { git = "https://gitea.gioser.net/sergio/llimphi.git" } +llimphi-widget-skeleton = { git = "https://gitea.gioser.net/sergio/llimphi.git" } +llimphi-widget-field = { git = "https://gitea.gioser.net/sergio/llimphi.git" } +# Firma visual transversal (gradient sutil + hairline accent). +llimphi-widget-panel = { git = "https://gitea.gioser.net/sergio/llimphi.git" } +llimphi-widget-panes = { git = "https://gitea.gioser.net/sergio/llimphi.git" } +llimphi-workspace = { git = "https://gitea.gioser.net/sergio/llimphi.git" } +# Abstracción Selector — host (paths) + wawa (khipus). +llimphi-module-selector = { git = "https://gitea.gioser.net/sergio/llimphi.git" } + +# === Filesystem helpers === +directories = "5" + +# === Diff line-based (llimphi-module-diff-viewer) === +# `similar` es la crate de facto: implementa Myers + Patience + LCS, +# expone `TextDiff` con ChangeTag por línea (Equal/Insert/Delete), +# zero deps fuera de std. La 2.x es estable hace años. +similar = "2" + +# === Fuzzy matching (shuma-history) === +# nucleo-matcher = mismo matcher que helix-editor: rápido, Unicode-correct, +# bonus por prefijos, ranking estable. La versión 0.3 expone el API simple +# que necesitamos (Matcher + Pattern + score). +nucleo-matcher = "0.3" + +# === Transporte autenticado (shuma-link) === +# snow = framework Noise pure-rust. Lo usamos en modo Noise_XK (cliente +# conoce la pubkey del servidor, server descubre la del cliente y la +# valida contra una allowlist). ChaCha20-Poly1305 + X25519 + BLAKE2s. +# La versión 0.9 viene pinneada por libp2p, así nos alineamos. +snow = "0.9" +hex = "0.4" + +# === PTY + emulador de terminal (shuma-exec, módulos REPL) === +# portable-pty aloja un PTY cross-platform; lo usamos para los +# comandos TUI tipo vim/htop/less que necesitan un terminal de verdad. +# vt100 parsea la secuencia de bytes que el PTY emite (ANSI + cursor +# movement + erase + screen state) y mantiene un buffer de pantalla +# renderizable como grid. +portable-pty = "0.9" +vt100 = "0.16" + +# === WASM web (gioser) === +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +js-sys = "0.3" +web-sys = "0.3" +glam = "0.30" + +# === Markdown (pluma) === +pulldown-cmark = { version = "0.12", default-features = false, features = ["html"] } + +# === Archivos comprimidos (nahual archive viewer) === +# Sólo listamos el directorio central (nombres/tamaños); no descomprimimos, +# por eso default-features=false alcanza para ZIP. Para tar.gz sí +# descomprimimos en streaming con flate2 (ya declarado arriba), saltando +# los datos de cada entrada — sólo leemos headers. +zip = { version = "2.4", default-features = false } +tar = { version = "0.4", default-features = false } + +# === Fuentes (nahual font viewer) === +# Parseo de TTF/OTF/TTC y extracción de contornos de glifo a paths. +ttf-parser = "0.25" + +# ============================================================ +# Intra-workspace deps de nahual (referenciadas por workspace = true) +# ============================================================ +nahual-text-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" } +nahual-image-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" } +nahual-thumb-core = { git = "https://gitea.gioser.net/sergio/gioser.git" } +nahual-gallery-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" } +nahual-video-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" } +nahual-card-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" } +nahual-audio-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" } +nahual-tree-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" } +nahual-hex-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" } +nahual-table-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" } +nahual-markdown-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" } +nahual-archive-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" } +nahual-font-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" } +nahual-map-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" } +nahual-geo-core = { git = "https://gitea.gioser.net/sergio/gioser.git" } +nahual-viewer-core = { git = "https://gitea.gioser.net/sergio/gioser.git" } +nahual-file-explorer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" } + +# ============================================================ +# Intra-workspace deps de pineal (módulo de gráficos) +# ============================================================ +pineal-core = { path = "00_unanchay/pineal/pineal-core" } +pineal-render = { path = "00_unanchay/pineal/pineal-render" } +pineal-cartesian = { path = "00_unanchay/pineal/pineal-cartesian" } +pineal-stream = { path = "00_unanchay/pineal/pineal-stream" } +pineal-mesh = { path = "00_unanchay/pineal/pineal-mesh" } +pineal-financial = { path = "00_unanchay/pineal/pineal-financial" } +pineal-polar = { path = "00_unanchay/pineal/pineal-polar" } +pineal-heatmap = { path = "00_unanchay/pineal/pineal-heatmap" } +pineal-treemap = { path = "00_unanchay/pineal/pineal-treemap" } +pineal-flow = { path = "00_unanchay/pineal/pineal-flow" } +pineal-phosphor = { path = "00_unanchay/pineal/pineal-phosphor" } +pineal-export = { path = "00_unanchay/pineal/pineal-export" } +pineal-hexbin = { path = "00_unanchay/pineal/pineal-hexbin" } +pineal-contour = { path = "00_unanchay/pineal/pineal-contour" } +pineal-bars = { path = "00_unanchay/pineal/pineal-bars" } +pineal = { path = "00_unanchay/pineal/pineal-umbrella" } + +# ============================================================ +# Intra-workspace deps de iniy (laboratorio semántico de creencias) +# ============================================================ +iniy-core = { git = "https://gitea.gioser.net/sergio/gioser.git" } +iniy-ingest = { git = "https://gitea.gioser.net/sergio/gioser.git" } +iniy-extract = { git = "https://gitea.gioser.net/sergio/gioser.git" } +iniy-nli = { git = "https://gitea.gioser.net/sergio/gioser.git" } +iniy-nli-llm = { git = "https://gitea.gioser.net/sergio/gioser.git" } +iniy-graph = { git = "https://gitea.gioser.net/sergio/gioser.git" } +iniy-store = { git = "https://gitea.gioser.net/sergio/gioser.git" } + +# === auto: declarados por crates internos faltantes === +cosmos-coords = { git = "https://gitea.gioser.net/sergio/gioser.git" } +cosmos-core = { git = "https://gitea.gioser.net/sergio/gioser.git" } +cosmos-ephemeris = { git = "https://gitea.gioser.net/sergio/gioser.git" } +cosmos-time = { git = "https://gitea.gioser.net/sergio/gioser.git" } +cosmos-wcs = { git = "https://gitea.gioser.net/sergio/gioser.git" } + +# === auto: externas de eternal === +celestial-eop-data = { version = "0.1"} +approx = "0.5" +byteorder = "1.5" +cc = "1.0" +chrono = "0.4" +crc32fast = "1.4" +criterion = "0.5" +csv = "1.4" +flate2 = "1.0" +glob = "0.3" +indicatif = "0.18" +lz4_flex = "0.11" +memmap2 = "0.9" +mockito = "1.0" +ndarray = "0.15" +num-traits = "0.2" +once_cell = "1.19" +parking_lot = "0.12" +png = "0.18" +proptest = "1.4" +quick-xml = "0.31" +rayon = "1.8" +regex = "1.11" +reqwest = "0.12" +tiff = "0.11" +wide = "0.7" +wiremock = "0.6" + +# === i18n (rimay-localize) === +fluent-bundle = "0.15" +unic-langid = { version = "0.9", features = ["macros"] } +sys-locale = "0.3" + +# === Servo (puriy-engine) === +# Crates publicados de Servo embebibles individualmente. html5ever/markup5ever +# ya entran via ammonia→surrealdb→nakui, así que alineamos versión para no +# duplicar el árbol. markup5ever_rcdom es el DOM Rc-based simple (suficiente +# para Fase 2: parsear y renderizar, sin scripting). cssparser es el tokenizer +# CSS de Stylo, sirve para inline styles. ureq = HTTP síncrono minimalista, +# evita pull de tokio en el engine. +html5ever = "0.39" +markup5ever = "0.39" +markup5ever_rcdom = "0.39" +cssparser = "0.35" +url = "2" +ureq = { version = "2", default-features = false, features = ["tls"] } + +# === takiy-synth (SoundFont MIDI) === +# rustysynth = sintetizador SF2 puro Rust, MIT. Reemplaza el oscilador +# feo de takiy-synth por muestras reales (FluidR3, GeneralUser GS, etc). +rustysynth = "1.3" + +# === takiy-playback (audio device output) === +# cpal = backend de audio cross-platform (ALSA/PulseAudio/Pipewire en +# Linux, WASAPI en Windows, CoreAudio en macOS). Lo usamos sólo para +# abrir el device default y empujar muestras f32 — nada de mezclado +# ni efectos en el callback. +cpal = "0.15" + +# === media-source-wav (decoder PCM en disco) === +# hound = lector/escritor WAV puro-Rust, sin deps nativas. Soporta PCM +# entero (8/16/24/32) y float (32). Suficiente para abrir samples y +# stems de prueba sin meter ffmpeg/symphonia. +hound = "3.5" + +# === media-source-{mp3,flac,vorbis} (decoders vía symphonia) === +# symphonia es una colección de decoders puro-Rust mantenida. `mp3` cubre +# media-source-mp3; `flac` (decoder + demuxer FLAC nativo) cubre +# media-source-flac (lossless); `vorbis` + `ogg` (codec + demuxer Ogg) +# cubren media-source-vorbis (lossy clásico, libre de patentes). Sin aac: +# ese tier patentado entra por shared/foreign-av. +symphonia = { version = "0.5", default-features = false, features = ["mp3", "flac", "vorbis", "ogg"] } + +# === media-source-opus (decoder Opus NATIVO puro-Rust) === +# Opus es el formato de audio nativo de gioser (par del video AV1). ogg +# demuxea las páginas Ogg; opus-wave es un port puro-Rust de libopus +# (SILK+CELT, sin C ni FFI) — par del rav1d del lado video. +ogg = "0.9" +opus-wave = "3" + +# === media-source-webm (demux nativo Matroska/WebM) === +# matroska-demuxer es un demuxer puro-Rust de MKV/WebM (EBML). Saca los +# paquetes de los tracks V_AV1 y A_OPUS para alimentar a media-source-av1 +# y media-source-opus — un .webm AV1+Opus se reproduce 100% nativo. +matroska-demuxer = "0.7" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ede9631 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Sergio + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2b2c487 --- /dev/null +++ b/README.md @@ -0,0 +1,28 @@ +# pineal + +> Backend-agnostic visualization — the "third eye". A catalog of GPU painters over a single `Canvas` trait, in Rust, on [Llimphi](https://gitea.gioser.net/sergio/llimphi). + +`pineal` is a catalog of specialized painters — cartesian, polar, mesh, treemap, phosphor, flow, heatmap, stream, financial, hexbin, contour, bars — over **one painter abstraction**, the `Canvas` trait. Any program 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 backend**: the same painter draws to vello/Llimphi on screen, to software PNG/SVG/PDF offline, or straight to the GPU for millions of primitives. + +pineal **does not compute — it only draws.** Simulation lives elsewhere; pineal turns the output into pixels. It's the hammer, not the carpenter. + +## Quick start — the gallery + +```sh +cargo run --release -p pineal-galeria-demo # 11 painters tiled in one window +cargo run --release -p pineal-gpu-demo # 3D starfield, up to 1M primitives/frame (D = density, space = pause) +``` + +Each painter also has its own demo: `pineal-{heatmap,hexbin,treemap,mesh,financial,flow,contour,bars,polar,phosphor,stream}-demo`. + +## Crates + +`pineal-core` (agnostic primitives: interleaved DataBuffer, streaming RingBuffer, SpatialIndex, LTTB, scales — zero UI deps, zero alloc in the hot path) · `pineal-render` · one crate per painter · `pineal` (umbrella re-export for prototyping; in production import the leaf crates so tree-shaking drops the unused). + +## How dependencies work + +This is a clean, light front-door: pineal's only external dependency is the [Llimphi](https://gitea.gioser.net/sergio/llimphi) UI framework, pulled as a git dependency. No monorepo clone, no vendoring — `pineal-core`/`render` are backend-agnostic and the on-screen path is the only one that touches Llimphi. + +## License + +MIT. Builds on [Llimphi](https://gitea.gioser.net/sergio/llimphi).