feat: pineal standalone — catálogo de painters GPU sobre Llimphi (front-door liviano)
Visualización backend-agnóstica (cartesian/polar/mesh/treemap/phosphor/flow/ heatmap/stream/financial/hexbin/contour/bars) sobre el trait Canvas. Front-door liviano: única dep externa es Llimphi (git-dep a llimphi.git, app-bus incluido) — sin clonar el monorepo. 16 crates lib + 14 demos (galería + GPU 1M primitivas). cargo check --workspace pasa (0 errores). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Semantic delta · 998
00_unanchay/pineal/demos/pineal-bars-demo/src/main.rs
- new struct BarsDemo
- new def C_AMARILLO
- new def C_AZUL
- new def C_LILA
- new def C_NARANJA
- new def C_VERDE
- new struct Model
- new type Model
- new enum Msg
- new type Msg
- new def PLOT_BG
- new fn app_menu
- new fn canvas_style
- new fn handle_menu_command
- new fn init
- new fn initial_size
- new fn inset
- new fn main
- new fn menubar_spec
- new fn row_style
- new fn tile
- new fn tile_agrupadas
- new fn tile_apiladas
- new fn tile_columnas
- new fn tile_histograma
- new fn tile_horizontal
- new fn title
- new fn update
- new fn view
- new fn view_overlay
- new fn viewport_of
00_unanchay/pineal/demos/pineal-contour-demo/src/main.rs
- new struct ContourDemo
- new def H
- new struct Model
- new type Model
- new enum Msg
- new type Msg
- new def TICK
- new def W
- new fn app_menu
- new fn context_menu_for_plot
- new fn fill
- new fn handle_menu_command
- new fn init
- new fn initial_size
- new fn main
- new fn menubar_spec
- new fn title
- new fn update
- new fn view
- new fn view_overlay
- new fn viewport_of
00_unanchay/pineal/demos/pineal-demo/src/main.rs
- new def COLOR_COS
- new def COLOR_MIX
- new def COLOR_SIN
- new struct Demo
- new struct Model
- new type Model
- new enum Msg
- new type Msg
- new def N_SAMPLES
- new def WHEEL_SENSITIVITY
- new def ZOOM_IN_FACTOR
- new def ZOOM_OUT_FACTOR
- new fn app_menu
- new fn color_rgb
- new fn context_menu_for_plot
- new fn handle_menu_command
- new fn init
- new fn initial_size
- new fn main
- new fn menubar_spec
- new fn on_wheel
- new fn title
- new fn update
- new fn view
- new fn view_overlay
- new fn viewport_of
00_unanchay/pineal/demos/pineal-financial-demo/src/main.rs
- new struct FinancialDemo
- new struct Model
- new type Model
- new enum Msg
- new type Msg
- new def N_BARS
- new def WHEEL_SENSITIVITY
- new def ZOOM_IN_FACTOR
- new def ZOOM_OUT_FACTOR
- new fn app_menu
- new fn context_menu_for_plot
- new fn handle_menu_command
- new fn init
- new fn initial_size
- new fn main
- new fn menubar_spec
- new fn on_wheel
- new fn rand_f32
- new fn synth_random_walk
- new fn title
- new fn update
- new fn view
- new fn view_overlay
- new fn viewport_of
- new fn xorshift32
00_unanchay/pineal/demos/pineal-flow-demo/src/main.rs
- new struct FlowDemo
- new struct Model
- new type Model
- new enum Msg
- new type Msg
- new fn app_menu
- new fn context_menu_for_plot
- new fn handle_menu_command
- new fn init
- new fn initial_size
- new fn main
- new fn menubar_spec
- new fn title
- new fn update
- new fn view
- new fn view_overlay
- new fn viewport_of
00_unanchay/pineal/demos/pineal-galeria-demo/src/main.rs
- new struct GaleriaDemo
- new struct Graph
- new def H
- new def HEX_RADIUS
- new struct Model
- new type Model
- new enum Msg
- new type Msg
- new def N
- new def N_POINTS
- new def N_RING
- new def N_SAT
- new def W
- new fn app_menu
- new fn bars_tile
- new fn build_field
- new fn build_hexgrid
- new fn canvas_view
- new fn cartesian_tile
- new fn contour_tile
- new fn grid_row
- new fn handle_menu_command
- new fn heatmap_tile
- new fn hexbin_tile
- new fn histogram_tile
- new fn init
- new fn initial_size
- new fn main
- new fn menubar_spec
- new fn mesh_tile
- new fn paint_graph
- new fn pie_tile
- new fn radar_tile
- new fn relaxed
- new fn sankey_tile
- new fn tile
- new fn title
- new fn treemap_tile
- new fn update
- new fn view
- new fn view_overlay
- new fn viewport_of
00_unanchay/pineal/demos/pineal-gpu-demo/src/main.rs
- new def DENSITIES
- new def FAR
- new def FRAME_PERIOD
- new struct FieldSnapshot
- new struct GpuDemo
- new struct Model
- new type Model
- new enum Msg
- new type Msg
- new def NEAR
- new struct StarField
- new def TARGET_FORMAT
- new def WARP_SPEED
- new fn advance
- new fn app_menu
- new fn fmt_count
- new fn handle_menu_command
- new fn init
- new fn initial_size
- new fn main
- new fn menubar_spec
- new fn model_field_snapshot
- new fn new
- new fn on_key
- new fn respawn
- new fn title
- new fn unit
- new fn update
- new fn view
- new fn view_overlay
- new fn viewport_of
00_unanchay/pineal/demos/pineal-heatmap-demo/src/main.rs
- new def H
- new struct HeatmapDemo
- new struct Model
- new type Model
- new enum Msg
- new type Msg
- new def TICK_PERIOD
- new def W
- new fn app_menu
- new fn fill
- new fn handle_menu_command
- new fn init
- new fn initial_size
- new fn main
- new fn menubar_spec
- new fn title
- new fn update
- new fn view
- new fn view_overlay
- new fn viewport_of
00_unanchay/pineal/demos/pineal-hexbin-demo/src/main.rs
- new def HEX_RADIUS
- new struct HexbinDemo
- new struct Model
- new type Model
- new enum Msg
- new type Msg
- new def N_POINTS
- new fn app_menu
- new fn build_grid
- new fn handle_menu_command
- new fn init
- new fn initial_size
- new fn main
- new fn menubar_spec
- new fn title
- new fn update
- new fn view
- new fn view_overlay
- new fn viewport_of
00_unanchay/pineal/demos/pineal-mesh-demo/src/main.rs
- new struct Graph
- new struct MeshDemo
- new struct Model
- new type Model
- new enum Msg
- new type Msg
- new def N_RING
- new def N_SAT
- new def TICK_PERIOD
- new fn app_menu
- new fn handle_menu_command
- new fn init
- new fn initial_size
- new fn main
- new fn menubar_spec
- new fn new
- new fn paint_graph
- new fn title
- new fn update
- new fn view
- new fn view_overlay
- new fn viewport_of
00_unanchay/pineal/demos/pineal-phosphor-demo/src/main.rs
- new struct Model
- new type Model
- new enum Msg
- new type Msg
- new struct PhosphorDemo
- new def RING_CAPACITY
- new def SAMPLE_PERIOD
- new fn app_menu
- new fn handle_menu_command
- new fn init
- new fn initial_size
- new fn main
- new fn menubar_spec
- new fn synthesize
- new fn title
- new fn update
- new fn view
- new fn view_overlay
- new fn viewport_of
00_unanchay/pineal/demos/pineal-polar-demo/src/main.rs
- new struct Model
- new type Model
- new enum Msg
- new type Msg
- new struct PolarDemo
- new fn app_menu
- new fn context_menu_for_plot
- new fn handle_menu_command
- new fn init
- new fn initial_size
- new fn main
- new fn menubar_spec
- new fn title
- new fn update
- new fn view
- new fn view_overlay
- new fn viewport_of
00_unanchay/pineal/demos/pineal-stream-demo/src/main.rs
- new struct Model
- new type Model
- new enum Msg
- new type Msg
- new def RING_CAPACITY
- new def SAMPLE_PERIOD
- new struct StreamDemo
- new fn app_menu
- new fn context_menu_for_plot
- new fn handle_menu_command
- new fn init
- new fn initial_size
- new fn main
- new fn menubar_spec
- new fn synthesize
- new fn title
- new fn update
- new fn view
- new fn view_overlay
- new fn viewport_of
00_unanchay/pineal/demos/pineal-treemap-demo/src/main.rs
- new struct Model
- new type Model
- new enum Msg
- new type Msg
- new struct TreemapDemo
- new fn app_menu
- new fn context_menu_for_plot
- new fn handle_menu_command
- new fn init
- new fn initial_size
- new fn main
- new fn menubar_spec
- new fn title
- new fn update
- new fn view
- new fn view_overlay
- new fn viewport_of
00_unanchay/pineal/pineal-bars/src/histogram.rs
- new struct Histogram pub
- new fn counts_sum_to_sample_size
- new fn degenerate_range_does_not_panic
- new fn empty_and_zero_bins_are_safe
- new fn max_count pub
- new fn max_value_lands_in_last_bin
- new fn new pub
- new mod tests
- new fn to_bars pub
00_unanchay/pineal/pineal-bars/src/paint.rs
- new struct Axis
- new struct Bar pub
- new struct BarStyle pub
- new enum Orientation pub
- new fn auto_range
- new fn bar_rect
- new fn cat_span
- new fn default
- new fn fill_rects
- new fn grouped_emits_all_bars
- new fn horizontal pub
- new fn horizontal_swaps_axes
- new fn negative_grows_below_baseline
- new fn new pub
- new fn new
- new fn one_rect_per_bar
- new fn paint_bars pub
- new fn paint_grouped pub
- new fn paint_stacked pub
- new fn slot
- new fn stacked_segments_dont_overlap
- new fn taller_value_taller_bar
- new mod tests
- new fn value_px
- new fn vertical pub
- new fn with_baseline pub
- new fn with_gap pub
- new fn with_range pub
00_unanchay/pineal/pineal-cartesian/src/axis.rs
- new enum AxisSide pub
- new struct AxisStyle pub
- new def MONO_GLYPH_RATIO
- new fn decimate_labels pub
- new fn decimate_pasa_todo_cuando_hay_lugar
- new fn decimate_preserva_primero
- new fn decimate_vacio
- new fn default
- new fn format_tick pub
- new fn format_tick_fraccional
- new fn format_tick_integer
- new fn is_horizontal pub
- new fn paint_axes pub
- new mod tests
- new fn ticks_nice pub
- new fn ticks_nice_clipea_fuera_de_rango
- new fn ticks_nice_genera_alineados_a_step
- new fn ticks_nice_rango_fraccional
00_unanchay/pineal/pineal-cartesian/src/coord_system.rs
- new struct CoordinateSystem pub
- new fn data_to_pixel pub
- new fn data_to_pixel_centro
- new fn data_to_pixel_origen
- new fn data_to_pixel_top_right
- new fn fixture
- new fn new pub
- new fn pixel_to_data pub
- new fn pixel_to_data_roundtrip
- new fn project_buffer pub
- new fn project_buffer_consistente_con_pointwise
- new mod tests
00_unanchay/pineal/pineal-cartesian/src/lib.rs
- new mod axis pub
- new mod coord_system pub
- new mod series pub
- new mod view pub
- new mod viewport pub
00_unanchay/pineal/pineal-cartesian/src/series.rs
- new struct Capture
- new struct LineSeries pub
- new struct PaintCtx pub
- new enum RenderMode pub
- new trait Series pub
- new fn compute_projected pub
- new fn draw_text
- new fn effective_target pub
- new fn fill_rect
- new fn fill_triangle_strip
- new fn hit_test
- new fn hit_test
- new fn hit_test_acepta_punto_cercano
- new fn hit_test_rechaza_punto_lejano
- new fn line_series_emite_un_solo_drawcall
- new fn line_series_pinta_polyline
- new fn line_series_vacio_no_emite
- new fn lttb_se_dispara_con_alta_densidad
- new fn new pub
- new fn new
- new fn paint
- new fn pop_clip
- new fn push_clip
- new fn stroke_line
- new fn stroke_polyline
- new fn stroke_rect
- new mod tests
00_unanchay/pineal/pineal-cartesian/src/view.rs
- new struct ChartCache pub
- new type ChartCacheHandle pub
- new struct ChartSeriesItem pub
- new struct ChartView pub
- new def TARGET_TICKS_X
- new def TARGET_TICKS_Y
- new fn add_series pub
- new fn add_series_named pub
- new fn axis_color pub
- new fn background pub
- new fn chart_cache pub
- new fn invalidate pub
- new fn lapaloma_chart_view pub
- new fn margins pub
- new fn named pub
- new fn new pub
- new fn new pub
- new fn new pub
- new fn pan_blit_paint
- new fn pan_blits pub
- new fn rebuild_and_paint
- new fn rebuilds pub
- new fn structural_hash
- new fn view pub
- new fn with_cache pub
00_unanchay/pineal/pineal-cartesian/src/viewport.rs
- new struct ChartViewport pub
- new fn new pub
- new fn pan pub
- new fn pan_fraction pub
- new fn pan_fraction_es_independiente_de_plot
- new fn pan_no_cambia_span
- new fn pan_pixels pub
- new fn pan_pixels_invertido
- new mod tests
- new fn x_span pub
- new fn y_span pub
- new fn zoom_anchor_esquina
- new fn zoom_at pub
- new fn zoom_in_preserva_anchor
- new fn zoom_uniform pub
00_unanchay/pineal/pineal-contour/src/lib.rs
- new type Segment pub
- new fn empty_matrix_no_segments
- new fn extract_contour pub
- new fn extract_contours pub
- new fn extract_contours_produces_n_levels
- new fn flat_matrix_no_contours
- new fn lerp_color
- new fn lerp_t
- new fn out_of_range_iso_no_segments
- new fn paint_contour pub
- new fn paint_contour_emits_one_polyline_per_segment
- new fn paint_contours pub
- new fn paint_contours_emits_per_level
- new fn push
- new fn ramp
- new fn ramp_at_mid_level_yields_horizontal_segments
- new mod tests
00_unanchay/pineal/pineal-core/src/buffer.rs
- new struct DataBuffer pub
- new fn clear pub
- new fn coords pub
- new fn coords_slice_is_zero_copy
- new fn from_interleaved pub
- new fn is_empty pub
- new fn len pub
- new fn new pub
- new fn push pub
- new fn push_y_len
- new fn replace_from pub
- new fn revision pub
- new fn revision_bumps
- new fn set_xy pub
- new mod tests
- new fn with_capacity pub
- new fn xy pub
00_unanchay/pineal/pineal-core/src/lib.rs
00_unanchay/pineal/pineal-core/src/lttb.rs
- new fn extremos_preservados
- new fn in_range_indices_son_absolutos
- new fn indices_sorted_y_unicos
- new fn lttb_coords pub
- new fn lttb_in_range_indices pub
- new fn lttb_indices pub
- new fn no_decimate_si_n_menor_que_target
- new fn preserva_picos_extremos
- new mod tests
00_unanchay/pineal/pineal-core/src/ring.rs
- new struct RingBuffer pub
- new fn capacity pub
- new fn coords pub
- new fn count pub
- new fn filled_len pub
- new fn filled_len_bloquea_prefill
- new fn head pub
- new fn is_full pub
- new fn new pub
- new fn push pub
- new fn push_actualiza_y_no_x
- new fn push_all pub
- new fn push_all_oversized_se_queda_con_la_cola
- new fn push_all_wrap_around
- new fn revision pub
- new mod tests
- new fn values pub
- new fn x_norm_precomputado
00_unanchay/pineal/pineal-core/src/scale.rs
- new struct LinearScale pub
- new struct LogScale pub
- new trait Scale pub
- new struct TimeScale pub
- new fn domain
- new fn domain
- new fn domain
- new fn from_norm
- new fn from_norm
- new fn from_norm
- new fn linear_roundtrip
- new fn log_roundtrip
- new fn new pub
- new fn new pub
- new fn new pub
- new fn nice_step pub
- new fn nice_step_es_potencia
- new mod tests
- new fn to_norm
- new fn to_norm
- new fn to_norm
00_unanchay/pineal/pineal-core/src/spatial.rs
- new struct SpatialIndex pub
- new fn fixture
- new fn is_empty pub
- new fn len pub
- new fn nearest pub
- new fn nearest_dentro
- new fn nearest_empty
- new fn nearest_fuera_clamp
- new fn new pub
- new fn range pub
- new fn range_clip
- new mod tests
00_unanchay/pineal/pineal-export/src/pdf.rs
- new fn assemble
- new fn avg_color
- new fn build_content
- new fn clip_uses_q_and_Q_blocks
- new fn decimate_polylines
- new fn decimated_polyline_shrinks_to_target
- new fn decimation_preserves_other_primitives
- new fn empty_plan_still_well_formed
- new fn fill_rect_emits_re_f
- new fn pdf_contains_required_objects
- new fn pdf_ends_with_eof
- new fn pdf_starts_with_header
- new fn rect_to_pdf
- new fn same_color
- new fn sample_plan
- new fn set_fill
- new fn set_stroke
- new fn set_width
- new fn stroke_line_emits_m_l_s
- new mod tests
- new fn to_pdf pub
- new fn to_pdf_decimated pub
- new fn triangle_strip_emits_polygon_per_triangle
00_unanchay/pineal/pineal-export/src/png.rs
- new struct RasterBuffer
- new def SAMPLES
- new fn avg_color
- new fn blend
- new fn bound
- new fn clip_blocks_writes_outside_rect
- new fn edge
- new fn encode_png
- new fn fill_rect
- new fn fill_triangle
- new fn intersect
- new fn new
- new fn png_starts_with_magic
- new fn record_one_rect
- new fn rect_is_painted_red_on_white_background
- new fn replay
- new fn stroke_line_writes_pixels_along_segment
- new fn stroke_segment
- new mod tests
- new fn to_png pub
- new fn triangle_strip_fills_pixels
- new fn x1_clamped
00_unanchay/pineal/pineal-export/src/svg.rs
- new fn avg_color
- new fn emit_cmd
- new fn emit_points
- new fn emit_triangle_strip
- new fn emits_each_primitive
- new fn emits_well_formed_svg
- new fn escape_xml
- new fn escapes_xml_in_text
- new fn sample_plan
- new fn svg_color
- new mod tests
- new fn to_svg pub
- new fn triangle_strip_becomes_polygons
00_unanchay/pineal/pineal-financial/src/aggregate.rs
- new fn aggregate_time_bucketed pub
- new fn aggregation_preserva_volatilidad
- new fn bucket_de_3_agrega_a_4_bars
- new fn empty_no_emite
- new fn fallback_a_index_si_span_cero
- new fn fixture
- new mod tests
00_unanchay/pineal/pineal-financial/src/candlestick.rs
- new struct CandlestickStyle pub
- new fn default
- new fn paint_candlesticks pub
00_unanchay/pineal/pineal-financial/src/lib.rs
- new mod aggregate pub
- new mod candlestick pub
- new mod ohlc_buffer pub
- new mod view pub
00_unanchay/pineal/pineal-financial/src/ohlc_buffer.rs
- new struct Bar pub
- new struct OhlcBuffer pub
- new def STRIDE pub
- new fn bar pub
- new fn bars pub
- new fn bull_y_bear
- new fn clear pub
- new fn from_raw pub
- new fn is_bear pub
- new fn is_bull pub
- new fn is_empty pub
- new fn len pub
- new fn new pub
- new fn price_range pub
- new fn price_range_correcto
- new fn push_bar pub
- new fn push_values pub
- new fn push_y_lectura
- new fn revision pub
- new fn revision_bumps_en_push_y_clear
- new mod tests
- new fn time_range pub
- new fn time_range
- new fn with_capacity pub
00_unanchay/pineal/pineal-financial/src/view.rs
- new struct CandlestickView pub
- new def TARGET_TICKS_X
- new def TARGET_TICKS_Y
- new fn axis_color pub
- new fn background pub
- new fn lapaloma_candlestick_view pub
- new fn margins pub
- new fn new pub
- new fn style pub
- new fn view pub
00_unanchay/pineal/pineal-flow/src/layout.rs
- new struct LinkBand pub
- new struct NodeBox pub
- new struct SankeyLayout pub
- new struct SankeyLink pub
- new struct SankeyNode pub
- new fn assign_columns
- new fn back_edge_does_not_loop_forever
- new fn barycenter_pass
- new fn chain_assigns_increasing_columns
- new fn compute_layout pub
- new fn empty_input
- new fn new pub
- new fn node_height_proportional_to_flow
- new fn nodes
- new mod tests
00_unanchay/pineal/pineal-flow/src/ribbon.rs
- new def RIBBON_SEGMENTS
- new fn paint_ribbon pub
- new fn paint_sankey pub
- new fn paint_sankey_emits_nodes_and_ribbons
- new fn ribbon_strip pub
- new fn ribbon_strip_has_expected_vertex_count
- new fn smoothstep
- new mod tests
00_unanchay/pineal/pineal-heatmap/src/encoder.rs
- new fn encode_argb pub
- new fn encodes_one_pixel_per_cell
- new fn flat_matrix_does_not_divide_by_zero
- new fn normalizes_min_to_ramp_start
- new fn pack_argb
- new mod tests
00_unanchay/pineal/pineal-heatmap/src/lib.rs
00_unanchay/pineal/pineal-heatmap/src/matrix.rs
- new struct HeatmapMatrix pub
- new fn data pub
- new fn from_data pub
- new fn from_data_checks_length
- new fn get pub
- new fn get_set_and_revision
- new fn height pub
- new fn min_max pub
- new fn min_max_over_values
- new fn new pub
- new fn replace_data pub
- new fn revision pub
- new fn set pub
- new mod tests
- new fn width pub
00_unanchay/pineal/pineal-heatmap/src/paint.rs
- new fn emits_one_fill_rect_per_cell
- new fn empty_matrix_emits_nothing
- new fn paint pub
- new mod tests
00_unanchay/pineal/pineal-heatmap/src/palette.rs
- new enum Ramp pub
- new def VIRIDIS
- new fn grayscale_endpoints
- new fn lerp_color
- new fn lerp_stops
- new fn sample pub
- new fn sample_clamps_out_of_range
- new mod tests
- new fn viridis_endpoints_match_control_points
- new fn viridis_midpoint_is_between
00_unanchay/pineal/pineal-hexbin/src/lib.rs
- new struct HexGrid pub
- new fn bounds pub
- new fn bounds_covers_extremes
- new fn cells pub
- new fn coincident_points_bin_together
- new fn cube_round
- new fn empty_grid_is_noop
- new fn far_points_bin_separately
- new fn from_xy pub
- new fn from_xy_populates_correctly
- new fn is_empty pub
- new fn min_max pub
- new fn new pub
- new fn oddr_to_pixel pub
- new fn paint_emits_one_strip_per_cell
- new fn paint_hex
- new fn paint_hexbin pub
- new fn pixel_to_oddr
- new fn push pub
- new fn radius pub
- new mod tests
00_unanchay/pineal/pineal-mesh/src/barnes_hut.rs
- new def MAX_DEPTH
- new struct QuadNode
- new struct Quadtree pub
- new fn accumulate
- new fn bh_approximates_naive_for_distant_clusters
- new fn build pub
- new fn child_center
- new fn coincident_bodies_do_not_explode
- new fn descend_and_insert
- new fn empty_tree_has_no_force
- new fn force_on pub
- new fn insert
- new fn is_internal
- new fn new
- new fn quadrant_of
- new fn single_body_self_skipped
- new mod tests
- new fn two_bodies_repel_along_axis
00_unanchay/pineal/pineal-mesh/src/buffers.rs
- new struct EdgeBuffer pub
- new struct NodeBuffer pub
- new fn edge pub
- new fn edge_buffer_roundtrip
- new fn is_empty pub
- new fn is_empty pub
- new fn iter pub
- new fn len pub
- new fn len pub
- new fn new pub
- new fn new pub
- new fn node_buffer_push_and_access
- new fn pos pub
- new fn push pub
- new fn push pub
- new fn radius pub
- new fn raw pub
- new fn set_pos pub
- new mod tests
- new fn with_capacity pub
00_unanchay/pineal/pineal-mesh/src/camera.rs
- new struct Camera pub
- new fn close
- new fn default
- new fn new pub
- new fn pan_by pub
- new fn screen_to_world pub
- new mod tests
- new fn world_screen_roundtrip
- new fn world_to_screen pub
- new fn zoom_at pub
- new fn zoom_at_keeps_anchor_world_point_fixed
00_unanchay/pineal/pineal-mesh/src/fdeb.rs
- new struct FdebParams pub
- new type Path pub
- new fn bundle pub
- new fn compatibility
- new fn default
- new fn empty_input_returns_empty
- new fn endpoints_stay_fixed_after_bundle
- new fn make_edges
- new fn orthogonal_edges_do_not_bundle
- new fn parallel_edges_get_pulled_together
- new fn path_has_subdivisions_plus_two_points
- new mod tests
00_unanchay/pineal/pineal-mesh/src/force.rs
- new struct ForceLayout pub
- new struct ForceParams pub
- new fn build
- new fn coincident_nodes_do_not_nan
- new fn default
- new fn empty_graph_is_noop
- new fn new pub
- new fn step pub
- new fn step_bh pub
- new fn step_bh_converges_to_similar_layout_as_naive
- new fn step_bh_empty_is_noop
- new fn temperature pub
- new mod tests
- new fn two_connected_nodes_settle_near_k
00_unanchay/pineal/pineal-mesh/src/hierarchical.rs
- new struct HierarchicalLayout pub
- new fn chain_assigns_increasing_x
- new fn cycle_does_not_loop
- new fn empty_input_returns_empty
- new fn fan_out_distributes_children_vertically
- new fn positions_fall_within_area
- new fn sugiyama_layout pub
- new mod tests
00_unanchay/pineal/pineal-mesh/src/lib.rs
- new mod barnes_hut pub
- new mod buffers pub
- new mod camera pub
- new mod fdeb pub
- new mod force pub
- new mod hierarchical pub
- new mod spatial_hash pub
- new mod tree pub
00_unanchay/pineal/pineal-mesh/src/spatial_hash.rs
- new struct SpatialHash pub
- new fn cell_of
- new fn new pub
- new fn query pub
- new fn query_hits_node_under_point
- new fn query_misses_empty_space
- new fn rebuild pub
- new mod tests
00_unanchay/pineal/pineal-mesh/src/tree.rs
- new fn cycle_in_parents_does_not_hang
- new fn depth_increases_down_the_tree
- new fn parent_centered_over_two_children
- new fn single_node_at_origin
- new mod tests
- new fn tree_layout pub
00_unanchay/pineal/pineal-phosphor/src/lib.rs
- new mod ghost pub
- new mod magnetic_anchor pub
- new mod view pub
00_unanchay/pineal/pineal-phosphor/src/view.rs
- new def DEFAULT_TRAIL_SEGMENTS
- new struct PhosphorView pub
- new fn background pub
- new fn glow pub
- new fn new pub
- new fn no_glow pub
- new fn pineal_phosphor_view pub
- new fn project_segment
- new fn trail_segments pub
- new fn view pub
- new fn y_range pub
00_unanchay/pineal/pineal-polar/src/pie.rs
- new def ARC_SEGMENTS_PER_TURN
- new struct Slice pub
- new fn arc_point
- new fn count_strips
- new fn donut_also_emits_strips
- new fn new pub
- new fn one_strip_per_nonzero_slice
- new fn paint_pie pub
- new fn paint_wedge
- new mod tests
- new fn zero_total_draws_nothing
00_unanchay/pineal/pineal-polar/src/radar.rs
- new fn emits_fill_strip_and_outline
- new fn paint_radar pub
- new mod tests
- new fn too_few_axes_draws_nothing
00_unanchay/pineal/pineal-render/src/canvas.rs
- new trait Canvas pub
- new struct StrokeStyle pub
- new fn new pub
00_unanchay/pineal/pineal-render/src/color.rs
- new def BLACK pub
- new struct Color pub
- new def TRANSPARENT pub
- new def WHITE pub
- new fn from_hex pub
- new fn rgb pub
- new fn rgba pub
- new fn with_alpha pub
00_unanchay/pineal/pineal-render/src/geom.rs
00_unanchay/pineal/pineal-render/src/gpu_canvas.rs
- new struct GpuSceneCanvas pub
- new fn draw_text
- new fn fill_rect
- new fn fill_triangle_strip
- new fn new pub
- new fn pop_clip
- new fn push_clip
- new fn stroke_line
- new fn stroke_polyline
- new fn stroke_rect
- new fn to_peniko
00_unanchay/pineal/pineal-render/src/lib.rs
- new mod canvas pub
- new mod color pub
- new mod geom pub
- new mod gpu_canvas pub
- new mod llimphi_backend pub
- new mod plan pub
- new mod recorder pub
00_unanchay/pineal/pineal-render/src/llimphi_backend.rs
- new struct SceneCanvas pub
- new fn alpha_pasa_directo
- new fn avg_color
- new fn draw_text
- new fn fill_rect
- new fn fill_triangle_strip
- new fn fuera_de_rango_clampea
- new fn new pub
- new fn pop_clip
- new fn push_clip
- new fn rojo_redondea_a_255
- new fn stroke_line
- new fn stroke_polyline
- new fn stroke_rect
- new mod tests
- new fn to_kurbo_rect
- new fn to_peniko
00_unanchay/pineal/pineal-render/src/plan.rs
- new enum RenderCmd pub
- new struct RenderPlan pub
- new fn new pub
- new fn push pub
00_unanchay/pineal/pineal-render/src/recorder.rs
- new struct PlanRecorder pub
- new fn draw_text
- new fn fill_rect
- new fn fill_triangle_strip
- new fn into_plan pub
- new fn new pub
- new fn plan pub
- new fn pop_clip
- new fn push_clip
- new fn records_calls_in_order
- new fn stroke_line
- new fn stroke_polyline
- new fn stroke_rect
- new mod tests
00_unanchay/pineal/pineal-stream/src/view.rs
- new struct StreamView pub
- new fn background pub
- new fn new pub
- new fn padding pub
- new fn paint_traza
- new fn pineal_stream_view pub
- new fn plot_rect
- new fn project_segment
- new fn view pub
- new fn y_range pub
00_unanchay/pineal/pineal-treemap/src/paint.rs
- new struct Tile pub
- new fn new pub
- new fn one_fill_rect_per_visible_tile
- new fn paint_treemap pub
- new mod tests
- new fn zero_weight_tile_is_skipped
00_unanchay/pineal/pineal-treemap/src/squarify.rs
- new fn all_rects_within_bounds
- new fn area_of
- new fn areas_proportional_to_weights
- new fn empty_input
- new fn layout_row
- new fn single_item_fills_rect
- new fn squarify pub
- new mod tests
- new fn worst_ratio
- new fn zero_and_negative_weights_get_empty_rects
149 files · +18852 −0
- .gitignore +3 −0
- 00_unanchay/pineal/LEEME.md +127 −0
- 00_unanchay/pineal/README.md +136 −0
- 00_unanchay/pineal/README.qu.md +30 −0
- 00_unanchay/pineal/SDD.md +244 −0
- 00_unanchay/pineal/demos/pineal-bars-demo/Cargo.toml +17 −0
- 00_unanchay/pineal/demos/pineal-bars-demo/src/main.rs +348 −0
- 00_unanchay/pineal/demos/pineal-contour-demo/Cargo.toml +19 −0
- 00_unanchay/pineal/demos/pineal-contour-demo/src/main.rs +307 −0
- 00_unanchay/pineal/demos/pineal-demo/Cargo.toml +19 −0
- 00_unanchay/pineal/demos/pineal-demo/LEEME.md +16 −0
- 00_unanchay/pineal/demos/pineal-demo/README.md +16 −0
- 00_unanchay/pineal/demos/pineal-demo/src/main.rs +363 −0
- 00_unanchay/pineal/demos/pineal-financial-demo/Cargo.toml +19 −0
- 00_unanchay/pineal/demos/pineal-financial-demo/LEEME.md +16 −0
- 00_unanchay/pineal/demos/pineal-financial-demo/README.md +16 −0
- 00_unanchay/pineal/demos/pineal-financial-demo/src/main.rs +360 −0
- 00_unanchay/pineal/demos/pineal-flow-demo/Cargo.toml +18 −0
- 00_unanchay/pineal/demos/pineal-flow-demo/src/main.rs +267 −0
- 00_unanchay/pineal/demos/pineal-galeria-demo/Cargo.toml +23 −0
- 00_unanchay/pineal/demos/pineal-galeria-demo/src/main.rs +633 −0
- 00_unanchay/pineal/demos/pineal-gpu-demo/Cargo.toml +17 −0
- 00_unanchay/pineal/demos/pineal-gpu-demo/src/main.rs +448 −0
- 00_unanchay/pineal/demos/pineal-heatmap-demo/Cargo.toml +17 −0
- 00_unanchay/pineal/demos/pineal-heatmap-demo/src/main.rs +275 −0
- 00_unanchay/pineal/demos/pineal-hexbin-demo/Cargo.toml +18 −0
- 00_unanchay/pineal/demos/pineal-hexbin-demo/src/main.rs +286 −0
- 00_unanchay/pineal/demos/pineal-mesh-demo/Cargo.toml +17 −0
- 00_unanchay/pineal/demos/pineal-mesh-demo/src/main.rs +350 −0
- 00_unanchay/pineal/demos/pineal-phosphor-demo/Cargo.toml +18 −0
- 00_unanchay/pineal/demos/pineal-phosphor-demo/LEEME.md +15 −0
- 00_unanchay/pineal/demos/pineal-phosphor-demo/README.md +15 −0
- 00_unanchay/pineal/demos/pineal-phosphor-demo/src/main.rs +264 −0
- 00_unanchay/pineal/demos/pineal-polar-demo/Cargo.toml +17 −0
- 00_unanchay/pineal/demos/pineal-polar-demo/src/main.rs +297 −0
- 00_unanchay/pineal/demos/pineal-stream-demo/Cargo.toml +18 −0
- 00_unanchay/pineal/demos/pineal-stream-demo/LEEME.md +15 −0
- 00_unanchay/pineal/demos/pineal-stream-demo/README.md +15 −0
- 00_unanchay/pineal/demos/pineal-stream-demo/src/main.rs +290 −0
- 00_unanchay/pineal/demos/pineal-treemap-demo/Cargo.toml +17 −0
- 00_unanchay/pineal/demos/pineal-treemap-demo/src/main.rs +244 −0
- 00_unanchay/pineal/pineal-bars/Cargo.toml +12 −0
- 00_unanchay/pineal/pineal-bars/src/histogram.rs +102 −0
- 00_unanchay/pineal/pineal-bars/src/lib.rs +26 −0
- 00_unanchay/pineal/pineal-bars/src/paint.rs +330 −0
- 00_unanchay/pineal/pineal-cartesian/Cargo.toml +13 −0
- 00_unanchay/pineal/pineal-cartesian/LEEME.md +20 −0
- 00_unanchay/pineal/pineal-cartesian/README.md +20 −0
- 00_unanchay/pineal/pineal-cartesian/src/axis.rs +310 −0
- 00_unanchay/pineal/pineal-cartesian/src/coord_system.rs +133 −0
- 00_unanchay/pineal/pineal-cartesian/src/lib.rs +32 −0
- 00_unanchay/pineal/pineal-cartesian/src/series.rs +283 −0
- 00_unanchay/pineal/pineal-cartesian/src/view.rs +340 −0
- 00_unanchay/pineal/pineal-cartesian/src/viewport.rs +147 −0
- 00_unanchay/pineal/pineal-contour/Cargo.toml +12 −0
- 00_unanchay/pineal/pineal-contour/src/lib.rs +266 −0
- 00_unanchay/pineal/pineal-core/Cargo.toml +12 −0
- 00_unanchay/pineal/pineal-core/LEEME.md +19 −0
- 00_unanchay/pineal/pineal-core/README.md +19 −0
- 00_unanchay/pineal/pineal-core/src/buffer.rs +128 −0
- 00_unanchay/pineal/pineal-core/src/lib.rs +37 −0
- 00_unanchay/pineal/pineal-core/src/lttb.rs +177 −0
- 00_unanchay/pineal/pineal-core/src/ring.rs +216 −0
- 00_unanchay/pineal/pineal-core/src/scale.rs +153 −0
- 00_unanchay/pineal/pineal-core/src/spatial.rs +149 −0
- 00_unanchay/pineal/pineal-export/Cargo.toml +13 −0
- 00_unanchay/pineal/pineal-export/LEEME.md +19 −0
- 00_unanchay/pineal/pineal-export/README.md +19 −0
- 00_unanchay/pineal/pineal-export/src/lib.rs +22 −0
- 00_unanchay/pineal/pineal-export/src/pdf.rs +414 −0
- 00_unanchay/pineal/pineal-export/src/png.rs +411 −0
- 00_unanchay/pineal/pineal-export/src/svg.rs +223 −0
- 00_unanchay/pineal/pineal-financial/Cargo.toml +14 −0
- 00_unanchay/pineal/pineal-financial/LEEME.md +19 −0
- 00_unanchay/pineal/pineal-financial/README.md +19 −0
- 00_unanchay/pineal/pineal-financial/src/aggregate.rs +165 −0
- 00_unanchay/pineal/pineal-financial/src/candlestick.rs +121 −0
- 00_unanchay/pineal/pineal-financial/src/lib.rs +33 −0
- 00_unanchay/pineal/pineal-financial/src/ohlc_buffer.rs +186 −0
- 00_unanchay/pineal/pineal-financial/src/view.rs +124 −0
- 00_unanchay/pineal/pineal-flow/Cargo.toml +12 −0
- 00_unanchay/pineal/pineal-flow/LEEME.md +19 −0
- 00_unanchay/pineal/pineal-flow/README.md +19 −0
- 00_unanchay/pineal/pineal-flow/src/layout.rs +300 −0
- 00_unanchay/pineal/pineal-flow/src/lib.rs +18 −0
- 00_unanchay/pineal/pineal-flow/src/ribbon.rs +105 −0
- 00_unanchay/pineal/pineal-heatmap/Cargo.toml +12 −0
- 00_unanchay/pineal/pineal-heatmap/LEEME.md +20 −0
- 00_unanchay/pineal/pineal-heatmap/README.md +20 −0
- 00_unanchay/pineal/pineal-heatmap/src/encoder.rs +57 −0
- 00_unanchay/pineal/pineal-heatmap/src/lib.rs +23 −0
- 00_unanchay/pineal/pineal-heatmap/src/matrix.rs +93 −0
- 00_unanchay/pineal/pineal-heatmap/src/paint.rs +61 −0
- 00_unanchay/pineal/pineal-heatmap/src/palette.rs +94 −0
- 00_unanchay/pineal/pineal-hexbin/Cargo.toml +12 −0
- 00_unanchay/pineal/pineal-hexbin/src/lib.rs +249 −0
- 00_unanchay/pineal/pineal-mesh/Cargo.toml +12 −0
- 00_unanchay/pineal/pineal-mesh/LEEME.md +18 −0
- 00_unanchay/pineal/pineal-mesh/README.md +18 −0
- 00_unanchay/pineal/pineal-mesh/src/barnes_hut.rs +294 −0
- 00_unanchay/pineal/pineal-mesh/src/buffers.rs +111 −0
- 00_unanchay/pineal/pineal-mesh/src/camera.rs +72 −0
- 00_unanchay/pineal/pineal-mesh/src/fdeb.rs +262 −0
- 00_unanchay/pineal/pineal-mesh/src/force.rs +264 −0
- 00_unanchay/pineal/pineal-mesh/src/hierarchical.rs +229 −0
- 00_unanchay/pineal/pineal-mesh/src/lib.rs +33 −0
- 00_unanchay/pineal/pineal-mesh/src/spatial_hash.rs +80 −0
- 00_unanchay/pineal/pineal-mesh/src/tree.rs +105 −0
- 00_unanchay/pineal/pineal-phosphor/Cargo.toml +13 −0
- 00_unanchay/pineal/pineal-phosphor/LEEME.md +18 −0
- 00_unanchay/pineal/pineal-phosphor/README.md +18 −0
- 00_unanchay/pineal/pineal-phosphor/src/lib.rs +31 −0
- 00_unanchay/pineal/pineal-phosphor/src/view.rs +196 −0
- 00_unanchay/pineal/pineal-polar/Cargo.toml +12 −0
- 00_unanchay/pineal/pineal-polar/LEEME.md +20 −0
- 00_unanchay/pineal/pineal-polar/README.md +20 −0
- 00_unanchay/pineal/pineal-polar/src/lib.rs +15 −0
- 00_unanchay/pineal/pineal-polar/src/pie.rs +124 −0
- 00_unanchay/pineal/pineal-polar/src/radar.rs +97 −0
- 00_unanchay/pineal/pineal-render/Cargo.toml +12 −0
- 00_unanchay/pineal/pineal-render/LEEME.md +10 −0
- 00_unanchay/pineal/pineal-render/README.md +10 −0
- 00_unanchay/pineal/pineal-render/src/canvas.rs +53 −0
- 00_unanchay/pineal/pineal-render/src/color.rs +35 −0
- 00_unanchay/pineal/pineal-render/src/geom.rs +36 −0
- 00_unanchay/pineal/pineal-render/src/gpu_canvas.rs +127 −0
- 00_unanchay/pineal/pineal-render/src/lib.rs +42 −0
- 00_unanchay/pineal/pineal-render/src/llimphi_backend.rs +201 −0
- 00_unanchay/pineal/pineal-render/src/plan.rs +35 −0
- 00_unanchay/pineal/pineal-render/src/recorder.rs +95 −0
- 00_unanchay/pineal/pineal-stream/Cargo.toml +13 −0
- 00_unanchay/pineal/pineal-stream/LEEME.md +18 −0
- 00_unanchay/pineal/pineal-stream/README.md +18 −0
- 00_unanchay/pineal/pineal-stream/src/lib.rs +21 −0
- 00_unanchay/pineal/pineal-stream/src/view.rs +163 −0
- 00_unanchay/pineal/pineal-treemap/Cargo.toml +12 −0
- 00_unanchay/pineal/pineal-treemap/LEEME.md +18 −0
- 00_unanchay/pineal/pineal-treemap/README.md +18 −0
- 00_unanchay/pineal/pineal-treemap/src/lib.rs +14 −0
- 00_unanchay/pineal/pineal-treemap/src/paint.rs +74 −0
- 00_unanchay/pineal/pineal-treemap/src/squarify.rs +157 −0
- 00_unanchay/pineal/pineal-umbrella/Cargo.toml +48 −0
- 00_unanchay/pineal/pineal-umbrella/LEEME.md +20 −0
- 00_unanchay/pineal/pineal-umbrella/README.md +20 −0
- 00_unanchay/pineal/pineal-umbrella/src/lib.rs +62 −0
- Cargo.lock +3428 −0
- Cargo.toml +459 −0
- LICENSE +21 −0
- README.md +28 −0
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<f32>` planos interleaved
+ (`[x0, y0, x1, y1, …]`), nunca `Vec<Point>`. Calientes en L1,
+ SIMD-loopables, listos para vertex buffer sin transformación.
+2. **Zero alloc en el hot path.** Los buffers se reservan al construir y
+ se mutan in-place para siempre. Los helpers escriben a un `&mut Vec`
+ provisto por el caller, no devuelven uno nuevo. El `RingBuffer` del
+ stream lo demuestra: `push(v)` son 2 escrituras + 2 increments.
+3. **Una draw call por capa.** Los painters teselan en un solo
+ polyline / triangle-strip / batch instanciado por serie. El backend
+ pinta cada uno como una draw call cuando puede.
+
+## Backends
+
+| Backend | Dónde | Notas |
+|---|---|---|
+| **vello / llimphi** (`SceneCanvas`) | `pineal-render::llimphi_backend` | En pantalla. Pinta en `View::paint_with`. ~100 K primitivas. |
+| **GPU directo wgpu** (`GpuSceneCanvas`) | `pineal-render::gpu_canvas` | 0.1–10 M primitivas en un batch instanciado. Pinta en `View::gpu_paint_with`. Sin texto, sin AA fino (por diseño). |
+| **SVG vectorial** (`to_svg`) | `pineal-export::svg` | Vector real `<rect>`/`<polyline>`/`<polygon>`. |
+| **PNG raster** (`to_png`) | `pineal-export::png` | Rasterizador software propio, AA 2×2. Sin `tiny-skia`/`cairo`. |
+| **PDF** (`to_pdf`) | `pineal-export::pdf` | Writer propio (sin `printpdf`), 1 página, operadores PDF-1.4. Con decimación LTTB contextual. |
+
+El texto se omite en los caminos GPU y PNG a propósito — usar SVG (o una
+pasada vello hermana) cuando hagan falta etiquetas.
+
+## Compatibilidad
+
+- **Linux / macOS / Windows** — render Llimphi (vello/wgpu).
+- **Wawa bare-metal** — Llimphi corre directo sobre el framebuffer; mismo
+ árbol gráfico, mismos painters.
+
+## Crates
+
+### Núcleo, render y export
+
+| Crate | Rol |
+|---|---|
+| [`pineal-core`](pineal-core/) | Algoritmos: buffers planos, ring, índice espacial, decimación LTTB, escalas. Sin gráficos. |
+| [`pineal-render`](pineal-render/) | El trait `Canvas` + `SceneCanvas` (vello), `GpuSceneCanvas` (wgpu) y `PlanRecorder` (replay diferido). |
+| [`pineal-export`](pineal-export/) | `RenderPlan` → SVG + PNG + PDF. |
+| [`pineal-umbrella`](pineal-umbrella/) (crate `pineal`) | Re-export del catálogo entero bajo features. Cómodo en prototipos; en producción importar los crates hoja para que tree-shaking descarte lo demás. |
+
+### Painters
+
+| Crate | Painter | Algoritmo clave |
+|---|---|---|
+| [`pineal-cartesian`](pineal-cartesian/) | `ChartView` | Ticks log/lin, viewport con zoom anclado, cache de panning. |
+| [`pineal-polar`](pineal-polar/) | `paint_pie`, `paint_radar` | Wedge teselado a 96 segs/vuelta, fan para radar. |
+| [`pineal-mesh`](pineal-mesh/) | `paint_graph`, `tree_layout`, `ForceLayout`, `bundle` | Fruchterman-Reingold O(n²) + Barnes-Hut O(n log n), Sugiyama-lite, bundling FDEB. |
+| [`pineal-treemap`](pineal-treemap/) | `paint_treemap` | Squarified (Bruls / d3-hierarchy). |
+| [`pineal-phosphor`](pineal-phosphor/) | trail tipo CRT | Triangle strip con alpha decay + glow. |
+| [`pineal-flow`](pineal-flow/) | `paint_sankey` | Longest-path + barycenter + ribbons smoothstep. |
+| [`pineal-heatmap`](pineal-heatmap/) | `paint`, `encode_argb` | Ramp Viridis + textura para matrices grandes. |
+| [`pineal-stream`](pineal-stream/) | `pineal_stream_view` | Sweep oscilloscope split-at-head. |
+| [`pineal-financial`](pineal-financial/) | `paint_candles` | OHLC + agregación por bucket temporal. |
+| [`pineal-hexbin`](pineal-hexbin/) | `paint_hexbin` | Bineado hexagonal pointy-top + ramp Viridis. |
+| [`pineal-contour`](pineal-contour/) | `paint_contours` | Marching squares (16 casos) → polilíneas por nivel. |
+| [`pineal-bars`](pineal-bars/) | `paint_bars`, `paint_grouped`, `paint_stacked` | Columnas/barras vertical u horizontal, agrupadas y apiladas, baseline con negativos; `Histogram` binea `&[f32]` → barras. |
+
+## Consideraciones
+
+- pineal **no calcula** — sólo dibuja. Para correr una simulación hablás
+ con `dominium`, `tinkuy`, `cosmos`, etc., y le pasás el resultado.
+- El export a SVG es vector real (no captura de píxeles). El PNG es un
+ rasterizador software diminuto — sin stack gráfico nativo. El texto se
+ omite en PNG y en el camino GPU (usar SVG o una pasada vello hermana).
+- El único techo es el del motor: vello satura cerca de 1 M de primitivas
+ por frame. El camino GPU directo (`GpuSceneCanvas`) es justamente la vía
+ para superarlo — y es un asunto horizontal de `llimphi-raster`, no de
+ pineal. Ver [`02_ruway/llimphi/SDD.md`](../../02_ruway/llimphi/SDD.md).
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<f32>`
+ (`[x0, y0, x1, y1, …]`), never `Vec<Point>`. Hot in L1, SIMD-loopable,
+ ready for a vertex buffer with no transformation.
+2. **Zero alloc on the hot path.** Buffers are reserved at construction
+ and mutated in place forever. Helpers write into a caller-provided
+ `&mut Vec`, they don't return fresh ones. The stream `RingBuffer`
+ proves it: `push(v)` is two writes plus two increments.
+3. **One draw call per layer.** Painters tessellate into a single
+ polyline / triangle-strip / instanced batch per series. The backend
+ draws each as one call when it can.
+
+## Backends
+
+| Backend | Where | Notes |
+|---|---|---|
+| **vello / llimphi** (`SceneCanvas`) | `pineal-render::llimphi_backend` | On-screen. Paints inside `View::paint_with`. ~100 K primitives. |
+| **GPU direct wgpu** (`GpuSceneCanvas`) | `pineal-render::gpu_canvas` | 0.1–10 M primitives via one instanced batch. Paints inside `View::gpu_paint_with`. No text, no fine AA (by design). |
+| **SVG vector** (`to_svg`) | `pineal-export::svg` | True vector `<rect>`/`<polyline>`/`<polygon>`. |
+| **PNG raster** (`to_png`) | `pineal-export::png` | Own software rasterizer, 2×2 AA. No `tiny-skia`/`cairo`. |
+| **PDF** (`to_pdf`) | `pineal-export::pdf` | Own writer (no `printpdf`), one page, PDF-1.4 operators. With LTTB contextual decimation. |
+
+Text is omitted on the GPU and PNG paths on purpose — use SVG (or a
+sibling vello pass) when you need labels.
+
+## Compatibility
+
+- **Linux / macOS / Windows** — Llimphi (vello/wgpu) rendering.
+- **Wawa bare-metal** — Llimphi runs straight on the framebuffer; same
+ scene tree, same painters.
+
+## Crates
+
+### Core, render & export
+
+| Crate | Role |
+|---|---|
+| [`pineal-core`](pineal-core/) | Algorithms: flat buffers, ring, spatial index, LTTB decimation, scales. No graphics. |
+| [`pineal-render`](pineal-render/) | The `Canvas` trait + `SceneCanvas` (vello), `GpuSceneCanvas` (wgpu) and `PlanRecorder` (deferred replay). |
+| [`pineal-export`](pineal-export/) | `RenderPlan` → SVG + PNG + PDF. |
+| [`pineal-umbrella`](pineal-umbrella/) (crate `pineal`) | Feature-gated re-export of the whole catalog. Handy in prototypes; in production import the leaf crates so tree-shaking drops the rest. |
+
+### Painters
+
+| Crate | Painter | Key algorithm |
+|---|---|---|
+| [`pineal-cartesian`](pineal-cartesian/) | `ChartView` | Log/lin ticks, zoom-anchored viewport, panning cache. |
+| [`pineal-polar`](pineal-polar/) | `paint_pie`, `paint_radar` | Wedge tessellation at 96 segs/turn, radar fan. |
+| [`pineal-mesh`](pineal-mesh/) | `paint_graph`, `tree_layout`, `ForceLayout`, `bundle` | Fruchterman-Reingold O(n²) + Barnes-Hut O(n log n), Sugiyama-lite, FDEB bundling. |
+| [`pineal-treemap`](pineal-treemap/) | `paint_treemap` | Squarified (Bruls / d3-hierarchy). |
+| [`pineal-phosphor`](pineal-phosphor/) | CRT-style trail | Triangle strip with alpha decay + glow. |
+| [`pineal-flow`](pineal-flow/) | `paint_sankey` | Longest-path + barycenter + smoothstep ribbons. |
+| [`pineal-heatmap`](pineal-heatmap/) | `paint`, `encode_argb` | Viridis ramp + texture for large matrices. |
+| [`pineal-stream`](pineal-stream/) | `pineal_stream_view` | Split-at-head sweep oscilloscope. |
+| [`pineal-financial`](pineal-financial/) | `paint_candles` | OHLC + time-bucket aggregation. |
+| [`pineal-hexbin`](pineal-hexbin/) | `paint_hexbin` | Pointy-top hexagonal binning + Viridis ramp. |
+| [`pineal-contour`](pineal-contour/) | `paint_contours` | Marching squares (16 cases) → per-level polylines. |
+| [`pineal-bars`](pineal-bars/) | `paint_bars`, `paint_grouped`, `paint_stacked` | Columns/bars vertical or horizontal, grouped and stacked, baseline with negatives; `Histogram` bins `&[f32]` → bars. |
+
+## Considerations
+
+- pineal **doesn't compute** — only draws. To run a simulation talk to
+ [`dominium`](../../01_yachay/dominium/), [`tinkuy`](../../01_yachay/tinkuy/),
+ [`cosmos`](../../01_yachay/cosmos/), etc., and feed it the result.
+- SVG export is true vector (not a pixel capture). PNG export is a tiny
+ software rasterizer — no native graphics stack. Text is skipped in PNG
+ and on the GPU path (use SVG or a sibling vello pass for labels).
+- The only ceiling is the engine's: vello tops out around 1 M primitives
+ per frame. The GPU-direct path (`GpuSceneCanvas`) is exactly the way
+ past it — and it's a horizontal concern of `llimphi-raster`, not of
+ pineal. See [`02_ruway/llimphi/SDD.md`](../../02_ruway/llimphi/SDD.md).
+
+## Tests
+
+`cargo test -p <crate>`. 140+ green across the catalog: `pineal-core`
+(buffers, ring, spatial, LTTB, scale), `pineal-render` (color + recorder
+roundtrip), `pineal-mesh` (Barnes-Hut vs naïve, Sugiyama), `pineal-bars`
+(simple/grouped/stacked + histogram), `pineal-export` (SVG/PNG byte
+validation), and 4–13 per painter via `PlanRecorder`.
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 @@
+<!-- Quechua (Cusco/Collao). Revisión bienvenida. -->
+
+# pineal
+
+> Backend mana hap'iq rikuchikuy. Monorepuq "kimsa-ñawin".
+
+Canvas tukuylla (cartesiano · polar · mesh · treemap · phosphor · flow · heatmap · stream · financial · umbrella), huk Llimphi `SceneCanvas` patapi. Ima willay kawsasqakunapas (cosmos, dominium, nakui, tinkuy, chasqui) pineal-man k'antinkunata haywariy atin, sapan grafica pipeline mana kanchu.
+
+## Churay
+
+```sh
+cargo run --release -p pineal-demo
+cargo run --release -p pineal-financial-demo
+cargo run --release -p pineal-phosphor-demo
+cargo run --release -p pineal-stream-demo
+```
+
+## Tinkuy
+
+- **Linux / macOS / Windows** — Llimphi (vello/wgpu) renderiy.
+- **Wawa bare-metal** — Llimphi framebuffer patapi; kikin escena sach'a.
+
+## Crateskuna
+
+Tukuy crates yachachiyninwan iskay versionkunapi: [`pineal-core`](pineal-core/README.md) escena modelo, [`pineal-render`](pineal-render/README.md) Llimphi rikuchikuy, [`pineal-{cartesian,polar,mesh,treemap,phosphor,flow,heatmap,stream,financial,umbrella}`](pineal-cartesian/README.md) sapanka layakuna, [`pineal-export`](pineal-export/README.md) PNG/SVG/GIF qatinapaq, [`pineal-{demo,financial-demo,phosphor-demo,stream-demo}`](pineal-demo/README.md) muestrakuna.
+
+## Yuyaykunaq
+
+- pineal **manan yupanchu** — siq'illanmi. Simulación ruwananpaqqa [`dominium`](../../01_yachay/dominium/README.md), [`tinkuy`](../../01_yachay/tinkuy/README.md), [`cosmos`](../../01_yachay/cosmos/README.md), chaykunawan rimay, hinaspa kutichiyninta pineal-man qun.
+- SVG qatinaqa cheqaq vector kanmi (mana píxeles laqasqachu).
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<f32>` planos
+interleaved `[x0, y0, x1, y1, ...]`, nunca como `Vec<Point2D>`. Hot
+en cache L1, SIMD-loopable por el compilador, listo para vertex buffer
+sin transformación. Aplica a `DataBuffer`, `RingBuffer`, `NodeBuffer`.
+
+**P2 — Zero alloc en hot path.** Buffers se reservan al construir y se
+mutan in-place para siempre. Helpers escriben a `&mut Vec` provistos por
+el caller, no devuelven `Vec` nuevos. El `RingBuffer` del stream
+demuestra esto: `push(v)` son 2 escrituras + 2 increments.
+
+**P3 — Una draw call por capa.** Los painters tesselan en un solo
+`polyline` / `triangle_strip` por serie. El backend pinta cada uno como
+un draw call cuando puede.
+
+## 2. Topología de crates
+
+```
+pineal-core ─┬─ buffer, ring, spatial, lttb, scale
+ └─ (algoritmos puros — sin gráficos)
+
+pineal-render ──── trait Canvas
+ ├── SceneCanvas (backend vello/llimphi)
+ └── PlanRecorder (replay diferido)
+
+pineal-{cartesian, polar, mesh, treemap, phosphor, flow,
+ heatmap, stream, financial, hexbin, contour, bars} ── painters
+
+pineal-export ── consume RenderPlan → SVG + PNG
+
+pineal-umbrella ── re-exports todo bajo features (cómodo en prototipos)
+```
+
+Regla dura: los painters hablan **únicamente** contra el trait `Canvas`
+de `pineal-render`. No conocen el runtime UI. Esto deja toda la cadena
+`core → render → painter` agnóstica del backend gráfico.
+
+## 3. El trait `Canvas`
+
+Set mínimo deliberado — cualquier viz compleja se descompone en estos
+primitivos por el painter, no por el backend:
+
+```rust
+trait Canvas {
+ fn push_clip(&mut self, rect: Rect);
+ fn pop_clip(&mut self);
+ fn fill_rect(&mut self, rect: Rect, color: Color);
+ fn stroke_rect(&mut self, rect: Rect, stroke: StrokeStyle);
+ fn stroke_line(&mut self, a: Point, b: Point, stroke: StrokeStyle);
+ fn stroke_polyline(&mut self, coords: &[f32], stroke: StrokeStyle);
+ fn fill_triangle_strip(&mut self, coords: &[f32], colors: &[Color]);
+ fn draw_text(&mut self, p: Point, text: &str, color: Color, size_px: f32);
+}
+```
+
+Convención de coordenadas: pixels absolutos del scene, origen
+arriba-izquierda, +Y hacia abajo. La proyección datos→pixel la hace el
+painter vía las escalas de `pineal-core`.
+
+## 4. Backends activos
+
+| Backend | Status | Ubicación |
+|---|---|---|
+| **vello/llimphi** (`SceneCanvas`) | Producción. Pinta en `View::paint_with`. | `pineal-render::llimphi_backend` |
+| **SVG vectorial** (`to_svg`) | Producción. Emite `<rect>`/`<polyline>`/`<polygon>`. | `pineal-export::svg` |
+| **PNG raster** (`to_png`) | Producción. Software rasterizer propio con AA 2×2. | `pineal-export::png` |
+| **PDF** (`to_pdf`) | Producción. Writer propio (sin `printpdf`), 1 página, operadores PDF-1.4. | `pineal-export::pdf` |
+| **GPU directo `wgpu`** (`GpuSceneCanvas`) | Producción. Para 0.1–10 M primitivas. Pinta en `View::gpu_paint_with`, sin texto y sin AA fino. | `pineal-render::gpu_canvas` |
+
+El rasterizador PNG es propio para no depender de `tiny-skia`/`cairo`/etc.
+Texto se omite a propósito — para labels usar SVG. Coverage 2×2 (4
+samples por pixel) da AA suficiente para reportes y dashboards.
+
+## 5. Canvases (painters)
+
+| Crate | Painter | Algoritmo clave |
+|---|---|---|
+| `pineal-cartesian` | `ChartView` | Ticks por escala log/lin, viewport con zoom anclado, cache de panning |
+| `pineal-polar` | `paint_pie`, `paint_radar` | Wedge teselado a 96 segs/vuelta, fan para radar |
+| `pineal-mesh` | `paint_graph`, `tree_layout`, `ForceLayout`, `bundle` | Fruchterman-Reingold O(n²) y Barnes-Hut O(n log n), Sugiyama-lite layered, FDEB-lite para bundling |
+| `pineal-treemap` | `paint_treemap` | Squarified (Bruls / d3-hierarchy) |
+| `pineal-phosphor` | trail tipo CRT | Triangle strip con alpha decay |
+| `pineal-flow` | `paint_sankey` | Longest-path + barycenter + ribbons smoothstep |
+| `pineal-heatmap` | `paint`, `encode_argb` | Ramp Viridis + textura para matrices grandes |
+| `pineal-stream` | `pineal_stream_view` | Sweep oscilloscope split-at-head |
+| `pineal-financial` | `paint_candles` | OHLC + agregación por bucket temporal |
+| `pineal-hexbin` | `paint_hexbin` | Bineado hexagonal pointy-top + ramp Viridis |
+| `pineal-contour` | `paint_contours` | Marching squares 16 casos → polilíneas por nivel |
+| `pineal-bars` | `paint_bars`, `paint_grouped`, `paint_stacked` | Columnas/barras vertical u horizontal, agrupadas y apiladas, baseline con negativos; `Histogram` binea `&[f32]` → barras |
+
+### 5.1 Barnes-Hut (added 2026-05-28)
+
+`pineal-mesh::barnes_hut::Quadtree` aproxima la fuerza repulsiva con
+criterio MAC (`s/d < theta`). Usar `ForceLayout::step_bh(theta=0.5)`
+para grafos > ~1 K nodos. Para grafos chicos `step()` naïve es más
+rápido en práctica (sin overhead del árbol).
+
+### 5.2 Sugiyama (added 2026-05-28)
+
+`pineal-mesh::hierarchical::sugiyama_layout` produce layout layered en
+3 pasadas: DFS para romper ciclos → Kahn longest-path para capas →
+barycenter en 2 pasadas (down + up) para reducir cruces. Devuelve
+posiciones + agrupación por capa.
+
+### 5.3 FDEB (added 2026-05-28)
+
+`pineal-mesh::fdeb::bundle` aplica Force-Directed Edge Bundling: cada
+arista se subdivide en N puntos intermedios que se atraen a puntos
+correspondientes de aristas compatibles (paralelas + cerca + similar
+escala). Endpoints fijos. Útil para grafos densos donde el spaghetti
+oculta el flujo macroscópico.
+
+### 5.4 PDF con decimación contextual (added 2026-05-28)
+
+`pineal-export::to_pdf_decimated(plan, w_pts, h_pts, dpi)` aplica LTTB
+a cada polyline antes de emitir el PDF, con
+`target = width_inches × dpi × 3 vértices/px`. Output PDF mucho más
+chico sin sacrificar la silueta visible al DPI destino.
+
+## 6. Decisión: AA por defecto en PNG, no en pantalla
+
+- **PNG**: AA 2×2 supersample siempre. PNG es output offline; el costo
+ no importa, el resultado vive para siempre.
+- **Vello/llimphi**: AA del compositor (lo que vello hace nativamente).
+ No agregamos nada encima.
+- **SVG**: el renderer destino decide. Pineal no tiene rasterizado allí.
+
+## 7. Tests
+
+Cobertura por crate:
+
+- `pineal-core` — 23 unit tests (buffers, ring, spatial, lttb, scale).
+- `pineal-render` — 4 (color conversion + recorder roundtrip).
+- `pineal-mesh` — 25 (incluye Barnes-Hut vs naïve dentro del 30 %,
+ Sugiyama chain/fan/cycle).
+- `pineal-export` — 9 (SVG + PNG, validación de bytes magic + roundtrip
+ decode/check pixel).
+- `pineal-bars` — 10 (simple/agrupado/apilado en ambas orientaciones,
+ baseline con negativos, histograma: suma de conteos, último bin, rango
+ degenerado).
+- Cada painter trae 4–13 tests propios usando `PlanRecorder`.
+
+Total al 2026-06-01: **140+ tests verdes**.
+
+## 8. Decisiones explícitas
+
+| Decisión | Razón |
+|---|---|
+| `fill_triangle_strip` con color promedio por triángulo | Vello no expone mesh con per-vertex color trivial. Sankey/radar/wedge usan colores uniformes igualmente. |
+| No mock GPUI ni Skia ni cairo | El catálogo entero está sobre vello/llimphi en pantalla y software-puro en PNG. Cero deps gráficas externas. |
+| `RingBuffer` con `revision` u64 | Permite a los backends invalidar texturas cacheadas sin diff por valor. Mismo patrón en `HeatmapMatrix`. |
+| Painters dibujan ABSOLUTO en pixels del scene | Composición vía `View::paint_with`: el caller pasa `PaintRect` y el painter no necesita conocer transformations. |
+
+## Estado (2026-06-01)
+
+### Hecho
+
+- Catálogo: 15 crates viz/render/export (core, render, cartesian,
+ polar, mesh, treemap, phosphor, flow, heatmap, stream, financial,
+ hexbin, contour, bars, umbrella) + export SVG/PNG/PDF + 14 demos
+ ejecutables (incluyendo `pineal-galeria-demo`, que muestra 11 painters
+ en una sola ventana, y `pineal-bars-demo`).
+- **bars** (added 2026-06-01, a pedido): el gráfico común que faltaba.
+ `paint_bars`/`paint_grouped`/`paint_stacked` (vertical u horizontal,
+ baseline con negativos) + `Histogram` para bineado de muestras. 10
+ tests propios con `PlanRecorder`.
+- Algoritmos de grafo: Fruchterman-Reingold + Barnes-Hut O(n log n),
+ Sugiyama-lite layered, FDEB-lite para bundling.
+- Backends en producción: vello/llimphi (`SceneCanvas`), SVG vectorial,
+ PNG raster con AA 2×2, PDF writer propio (+ decimación LTTB contextual).
+- **GPU directo wgpu** (`GpuSceneCanvas`, Fase 4): mismo trait `Canvas`
+ sobre `llimphi-raster::GpuBatch`; pinta en `View::gpu_paint_with`.
+ Validado en Iris Xe (11.76×@1M, 141 fps).
+- **Primer consumidor real del camino GPU** (`pineal-gpu-demo`,
+ 2026-06-01): starfield warp 3D que proyecta perspectiva en CPU y emite
+ hasta **1 M `fill_rect`** por frame contra el trait `Canvas` — detrás,
+ `GpuSceneCanvas` los colapsa en **una sola draw call instanciada**.
+ Densidad cicable 50K→200K→500K→1M (tecla `D` / menú), pausa con
+ `espacio`. Cierra el pendiente que el SDD arrastraba: el camino GPU ya
+ no es sólo teoría + tests, hay una app que lo ejercita end-to-end.
+- Menú principal + contextual cableados en las demos.
+- 140+ tests verdes (los 10 de bars incluidos).
+
+### Pendiente
+
+- Texto y AA fino en el camino GPU (limitación documentada del backend;
+ los labels del starfield van por la pasada vello hermana, como prescribe
+ el módulo `gpu_canvas`).
+- El techo de >1M primitivos por frame en vello es horizontal de
+ `llimphi-raster`, no de pineal. El camino GPU directo (ya ejercitado)
+ es justamente la vía para superarlo.
+
+## 9. Roadmap
+
+El catálogo de pineal cubre 15 crates de viz/render/export
++ 14 demos ejecutables + SDD propio. **Pineal no tiene roadmap propio
+pendiente.** El catálogo no está congelado por dogma: cuando aparece un
+tipo de gráfico genuinamente común que falta (como `bars`, agregado el
+2026-06-01), entra — siempre que respete las tres reglas y hable sólo
+contra el trait `Canvas`.
+
+El único techo es el del motor (vello no aguanta >1M primitivos por
+frame), y ése es un asunto horizontal de `llimphi-raster`, no de pineal
+— afecta por igual a cosmos, tinkuy, nakui y cualquier app sobre
+llimphi. Ver `02_ruway/llimphi/SDD.md §"GPU directo wgpu"`.
+
+**Hecho 2026-05-28**: `pineal-render::gpu_canvas::GpuSceneCanvas` ya
+existe — implementa el mismo trait `Canvas` apoyándose en
+`llimphi_raster::GpuBatch`. Los painters no cambian. Lo enchufa la app
+desde un `View::gpu_paint_with` (en lugar de `paint_with`). Trade-offs
+(sin texto, una sola `line_width` por flush, sin AA fino) documentados
+en el módulo.
+
+**Hecho 2026-06-01**: `pineal-gpu-demo` es la primera visualización densa
+que lo ejercita — un starfield warp 3D que empuja hasta 1 M `fill_rect`
+por frame por el camino GPU directo. El SDD daba "cosmos starfield" como
+candidata; se hizo un starfield autónomo dentro de pineal para no acoplar
+el demo a la maquinaria de cosmos (que computa, y pineal no computa).
+cosmos puede ahora copiar el patrón cuando quiera su propio cielo GPU.
+
+## 10. Lo que NO va a pineal
+
+- Lógica de dominio (queda en `cosmos`, `dominium`, etc.).
+- Persistencia (queda en `pluma-store`, `nakui-store`, etc.).
+- Input / event handling (queda en `llimphi-ui`).
+- Decisión de qué pintar (queda en el caller — pineal es el martillo,
+ no el carpintero).
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<usize>),
+ MenuCommand(String),
+ CloseMenus,
+ CycleTheme,
+ ContextMenuOpen(f32, f32),
+}
+
+struct Model {
+ theme: Theme,
+ menu_open: Option<usize>,
+ context_menu: Option<(f32, f32)>,
+}
+
+struct BarsDemo;
+
+impl App for BarsDemo {
+ type Model = Model;
+ type Msg = Msg;
+
+ fn title() -> &'static str {
+ "pineal — barras (columnas · agrupadas · apiladas · histograma)"
+ }
+ fn initial_size() -> (u32, u32) {
+ (1100, 720)
+ }
+
+ fn init(_handle: &Handle<Msg>) -> Model {
+ Model {
+ theme: Theme::dark(),
+ menu_open: None,
+ context_menu: None,
+ }
+ }
+
+ fn update(mut model: Model, msg: Msg, handle: &Handle<Msg>) -> Model {
+ match msg {
+ Msg::MenuOpen(which) => {
+ model.menu_open = which;
+ model.context_menu = None;
+ }
+ Msg::MenuCommand(cmd) => {
+ model.menu_open = None;
+ return handle_menu_command(model, &cmd, handle);
+ }
+ Msg::CloseMenus => {
+ model.menu_open = None;
+ model.context_menu = None;
+ }
+ Msg::CycleTheme => model.theme = Theme::next_after(model.theme.name),
+ Msg::ContextMenuOpen(x, y) => {
+ model.menu_open = None;
+ model.context_menu = Some((x, y));
+ }
+ }
+ model
+ }
+
+ fn view(model: &Model) -> View<Msg> {
+ let theme = &model.theme;
+ let menu = app_menu();
+ let menubar = menubar_view(&menubar_spec(&menu, model));
+
+ let row_top = View::new(row_style())
+ .children(vec![tile_columnas(theme), tile_agrupadas(theme), tile_apiladas(theme)]);
+ let row_bot = View::new(row_style())
+ .children(vec![tile_horizontal(theme), tile_histograma(theme)]);
+
+ let body = View::new(Style {
+ flex_direction: FlexDirection::Column,
+ size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+ flex_grow: 1.0,
+ padding: PadRect {
+ left: length(14.0_f32),
+ right: length(14.0_f32),
+ top: length(12.0_f32),
+ bottom: length(12.0_f32),
+ },
+ gap: Size { width: length(0.0_f32), height: length(12.0_f32) },
+ ..Default::default()
+ })
+ .fill(theme.bg_app)
+ .children(vec![row_top, row_bot]);
+
+ View::new(Style {
+ flex_direction: FlexDirection::Column,
+ size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+ ..Default::default()
+ })
+ .fill(theme.bg_app)
+ .on_right_click_at(|x, y, _w, _h| Some(Msg::ContextMenuOpen(x, y)))
+ .children(vec![menubar, body])
+ }
+
+ fn view_overlay(model: &Model) -> Option<View<Msg>> {
+ if let Some((x, y)) = model.context_menu {
+ let items = vec![ContextMenuItem::action("Cambiar tema")];
+ let on_pick: Arc<dyn Fn(usize) -> Msg + Send + Sync> = Arc::new(|_i| Msg::CycleTheme);
+ return Some(context_menu_view(ContextMenuSpec {
+ anchor: (x, y),
+ viewport: viewport_of(model),
+ header: Some("Barras".to_string()),
+ items,
+ active: usize::MAX,
+ on_pick,
+ on_dismiss: Msg::CloseMenus,
+ palette: ContextMenuPalette::from_theme(&model.theme),
+ }));
+ }
+ let menu = app_menu();
+ menubar_overlay(&menubar_spec(&menu, model))
+ }
+}
+
+fn row_style() -> Style {
+ Style {
+ flex_direction: FlexDirection::Row,
+ size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+ flex_grow: 1.0,
+ gap: Size { width: length(12.0_f32), height: length(0.0_f32) },
+ ..Default::default()
+ }
+}
+
+/// Envuelve un canvas en una celda con título arriba.
+fn tile(name: &str, theme: &Theme, canvas: View<Msg>) -> View<Msg> {
+ let title = View::new(Style {
+ size: Size { width: percent(1.0_f32), height: length(20.0_f32) },
+ ..Default::default()
+ })
+ .text_aligned(name.to_string(), 13.0, theme.fg_text, Alignment::Start);
+
+ let plot = View::new(Style {
+ size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+ flex_grow: 1.0,
+ ..Default::default()
+ })
+ .clip(true)
+ .children(vec![canvas]);
+
+ View::new(Style {
+ flex_direction: FlexDirection::Column,
+ size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+ flex_grow: 1.0,
+ gap: Size { width: length(0.0_f32), height: length(4.0_f32) },
+ ..Default::default()
+ })
+ .children(vec![title, plot])
+}
+
+const PLOT_BG: Color = Color::rgba(0.05, 0.06, 0.08, 1.0);
+
+fn canvas_style() -> Style {
+ Style {
+ size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+ flex_grow: 1.0,
+ ..Default::default()
+ }
+}
+
+/// Margen interior para que las barras no toquen el borde del tile.
+fn inset(rect: &llimphi_ui::PaintRect, m: f32) -> Rect {
+ Rect::new(rect.x + m, rect.y + m, rect.w - 2.0 * m, rect.h - 2.0 * m)
+}
+
+fn tile_columnas(theme: &Theme) -> View<Msg> {
+ let bars = vec![
+ Bar::new(4.0, Color::from_hex(C_AZUL)),
+ Bar::new(7.0, Color::from_hex(C_AZUL)),
+ Bar::new(2.0, Color::from_hex(C_AZUL)),
+ Bar::new(-3.0, Color::from_hex(C_NARANJA)),
+ Bar::new(5.0, Color::from_hex(C_AZUL)),
+ Bar::new(6.5, Color::from_hex(C_AZUL)),
+ ];
+ let canvas = View::new(canvas_style()).clip(true).paint_with(move |scene, ts, rect| {
+ let outer = Rect::new(rect.x, rect.y, rect.w, rect.h);
+ let mut c = SceneCanvas::new(scene, ts);
+ c.fill_rect(outer, PLOT_BG);
+ paint_bars(&bars, inset(&rect, 10.0), &BarStyle::vertical(), &mut c);
+ });
+ tile("Columnas (con negativo)", theme, canvas)
+}
+
+fn tile_horizontal(theme: &Theme) -> View<Msg> {
+ let bars = vec![
+ Bar::new(8.0, Color::from_hex(C_VERDE)),
+ Bar::new(5.0, Color::from_hex(C_VERDE)),
+ Bar::new(11.0, Color::from_hex(C_VERDE)),
+ Bar::new(3.0, Color::from_hex(C_VERDE)),
+ ];
+ let canvas = View::new(canvas_style()).clip(true).paint_with(move |scene, ts, rect| {
+ let outer = Rect::new(rect.x, rect.y, rect.w, rect.h);
+ let mut c = SceneCanvas::new(scene, ts);
+ c.fill_rect(outer, PLOT_BG);
+ paint_bars(&bars, inset(&rect, 10.0), &BarStyle::horizontal(), &mut c);
+ });
+ tile("Barras horizontales", theme, canvas)
+}
+
+fn tile_agrupadas(theme: &Theme) -> View<Msg> {
+ let serie_a = vec![
+ Bar::new(4.0, Color::from_hex(C_AZUL)),
+ Bar::new(6.0, Color::from_hex(C_AZUL)),
+ Bar::new(3.0, Color::from_hex(C_AZUL)),
+ Bar::new(7.0, Color::from_hex(C_AZUL)),
+ ];
+ let serie_b = vec![
+ Bar::new(5.0, Color::from_hex(C_NARANJA)),
+ Bar::new(2.0, Color::from_hex(C_NARANJA)),
+ Bar::new(6.0, Color::from_hex(C_NARANJA)),
+ Bar::new(4.0, Color::from_hex(C_NARANJA)),
+ ];
+ let canvas = View::new(canvas_style()).clip(true).paint_with(move |scene, ts, rect| {
+ let outer = Rect::new(rect.x, rect.y, rect.w, rect.h);
+ let mut c = SceneCanvas::new(scene, ts);
+ c.fill_rect(outer, PLOT_BG);
+ let series: [&[Bar]; 2] = [&serie_a, &serie_b];
+ paint_grouped(&series, inset(&rect, 10.0), &BarStyle::vertical().with_gap(0.25), &mut c);
+ });
+ tile("Agrupadas (2 series)", theme, canvas)
+}
+
+fn tile_apiladas(theme: &Theme) -> View<Msg> {
+ let mk = |a: f64, b: f64, d: f64| {
+ vec![
+ Bar::new(a, Color::from_hex(C_AZUL)),
+ Bar::new(b, Color::from_hex(C_VERDE)),
+ Bar::new(d, Color::from_hex(C_AMARILLO)),
+ ]
+ };
+ let s0 = mk(3.0, 4.0, 2.0);
+ let s1 = mk(5.0, 2.0, 3.0);
+ let s2 = mk(2.0, 6.0, 1.0);
+ let s3 = mk(4.0, 3.0, 4.0);
+ let canvas = View::new(canvas_style()).clip(true).paint_with(move |scene, ts, rect| {
+ let outer = Rect::new(rect.x, rect.y, rect.w, rect.h);
+ let mut c = SceneCanvas::new(scene, ts);
+ c.fill_rect(outer, PLOT_BG);
+ let stacks: [&[Bar]; 4] = [&s0, &s1, &s2, &s3];
+ paint_stacked(&stacks, inset(&rect, 10.0), &BarStyle::vertical().with_gap(0.3), &mut c);
+ });
+ tile("Apiladas (3 segmentos)", theme, canvas)
+}
+
+fn tile_histograma(theme: &Theme) -> View<Msg> {
+ // Muestra ~gaussiana: suma de 6 uniformes (teorema central del
+ // límite) de un LCG sembrado con constante. Determinista.
+ let mut rng: u32 = 0x1234_5678;
+ let mut next = || {
+ rng = rng.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
+ (rng >> 8) as f32 / (1u32 << 24) as f32 // [0,1)
+ };
+ let n = 4000;
+ let mut sample = Vec::with_capacity(n);
+ for _ in 0..n {
+ let g: f32 = (0..6).map(|_| next()).sum::<f32>() / 6.0; // media 0.5
+ sample.push((g - 0.5) * 6.0); // centrado, escala ~[-3,3]
+ }
+ let hist = Histogram::new(&sample, 28);
+ let bars = hist.to_bars(Color::from_hex(C_LILA));
+ let canvas = View::new(canvas_style()).clip(true).paint_with(move |scene, ts, rect| {
+ let outer = Rect::new(rect.x, rect.y, rect.w, rect.h);
+ let mut c = SceneCanvas::new(scene, ts);
+ c.fill_rect(outer, PLOT_BG);
+ // Histograma: barras pegadas (gap 0) para la silueta continua.
+ paint_bars(&bars, inset(&rect, 10.0), &BarStyle::vertical().with_gap(0.05), &mut c);
+ });
+ tile("Histograma (4000 muestras, 28 bins)", theme, canvas)
+}
+
+fn viewport_of(_model: &Model) -> (f32, f32) {
+ let (w, h) = BarsDemo::initial_size();
+ (w as f32, h as f32)
+}
+
+fn menubar_spec<'a>(menu: &'a AppMenu, model: &'a Model) -> MenuBarSpec<'a, Msg> {
+ MenuBarSpec {
+ menu,
+ open: model.menu_open,
+ theme: &model.theme,
+ viewport: viewport_of(model),
+ height: MENU_H,
+ on_open: Arc::new(Msg::MenuOpen),
+ on_command: Arc::new(|c: &str| Msg::MenuCommand(c.to_string())),
+ }
+}
+
+fn app_menu() -> AppMenu {
+ AppMenu::new()
+ .menu(Menu::new("Archivo").item(MenuItem::new("Salir", "file.quit").shortcut("Ctrl+Q")))
+ .menu(Menu::new("Ver").item(MenuItem::new("Cambiar tema", "view.theme")))
+ .menu(Menu::new("Ayuda").item(MenuItem::new("Acerca de", "help.about")))
+}
+
+fn handle_menu_command(model: Model, cmd: &str, handle: &Handle<Msg>) -> Model {
+ match cmd {
+ "file.quit" => std::process::exit(0),
+ "view.theme" => {
+ handle.dispatch(Msg::CycleTheme);
+ model
+ }
+ _ => model,
+ }
+}
+
+fn main() {
+ llimphi_ui::run::<BarsDemo>();
+}
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<usize>),
+ /// Comando elegido en la barra o el contextual → `Msg` real.
+ MenuCommand(String),
+ /// Cierra cualquier menú abierto (click-fuera / Esc).
+ CloseMenus,
+ /// Right-click sobre el plot → abre el contextual anclado en `(x, y)`.
+ ContextMenuOpen(f32, f32),
+}
+
+struct Model {
+ matrix: Arc<Mutex<HeatmapMatrix>>,
+ t: u64,
+ paused: bool,
+ theme: Theme,
+ menu_open: Option<usize>,
+ context_menu: Option<(f32, f32)>,
+}
+
+struct ContourDemo;
+
+impl App for ContourDemo {
+ type Model = Model;
+ type Msg = Msg;
+
+ fn title() -> &'static str {
+ "Lapaloma — contour (campo + 8 isolíneas)"
+ }
+ fn initial_size() -> (u32, u32) {
+ (960, 640)
+ }
+
+ fn init(handle: &Handle<Msg>) -> Model {
+ handle.spawn_periodic(TICK, || Msg::Tick);
+ let mut m = HeatmapMatrix::new(W, H);
+ fill(&mut m, 0);
+ Model {
+ matrix: Arc::new(Mutex::new(m)),
+ t: 0,
+ paused: false,
+ theme: Theme::dark(),
+ menu_open: None,
+ context_menu: None,
+ }
+ }
+
+ fn update(mut model: Model, msg: Msg, handle: &Handle<Msg>) -> Model {
+ match msg {
+ Msg::Tick => {
+ if !model.paused {
+ model.t = model.t.wrapping_add(1);
+ if let Ok(mut m) = model.matrix.lock() {
+ fill(&mut m, model.t);
+ }
+ }
+ }
+ Msg::TogglePause => {
+ model.paused = !model.paused;
+ }
+ Msg::Reset => {
+ model.t = 0;
+ if let Ok(mut m) = model.matrix.lock() {
+ fill(&mut m, 0);
+ }
+ }
+ Msg::CycleTheme => {
+ model.theme = Theme::next_after(model.theme.name);
+ }
+ Msg::MenuOpen(which) => {
+ model.menu_open = which;
+ model.context_menu = None;
+ }
+ Msg::CloseMenus => {
+ model.menu_open = None;
+ model.context_menu = None;
+ }
+ Msg::ContextMenuOpen(x, y) => {
+ model.menu_open = None;
+ model.context_menu = Some((x, y));
+ }
+ Msg::MenuCommand(cmd) => {
+ model.menu_open = None;
+ model.context_menu = None;
+ handle_menu_command(&cmd, handle);
+ }
+ }
+ model
+ }
+
+ fn view(model: &Model) -> View<Msg> {
+ let theme = &model.theme;
+ let plot_bg = Color::rgba(0.05, 0.06, 0.09, 1.0);
+ let matrix = model.matrix.clone();
+
+ let menu = app_menu(model);
+ let menubar = menubar_view(&menubar_spec(&menu, model));
+
+ let header = View::new(Style {
+ size: Size { width: percent(1.0_f32), height: length(28.0_f32) },
+ ..Default::default()
+ })
+ .text_aligned(
+ "Lapaloma — contour".to_string(),
+ 18.0,
+ theme.fg_text,
+ Alignment::Start,
+ );
+
+ let legend = View::new(Style {
+ size: Size { width: percent(1.0_f32), height: length(20.0_f32) },
+ ..Default::default()
+ })
+ .text_aligned(
+ format!("campo {}×{} · 8 isolíneas · marching squares · tick = {}", W, H, model.t),
+ 11.0,
+ theme.fg_muted,
+ Alignment::Start,
+ );
+
+ let panel = View::new(Style {
+ size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+ flex_grow: 1.0,
+ ..Default::default()
+ })
+ .clip(true)
+ .paint_with(move |scene, ts, rect| {
+ let outer = Rect::new(rect.x, rect.y, rect.w, rect.h);
+ let mut canvas = SceneCanvas::new(scene, ts);
+ canvas.fill_rect(outer, plot_bg);
+ if let Ok(m) = matrix.lock() {
+ paint_heatmap(&m, Ramp::Viridis, outer, &mut canvas);
+ paint_contours(
+ &m,
+ 8,
+ outer,
+ Color::rgba(0.4, 0.6, 1.0, 0.9),
+ Color::rgba(1.0, 0.4, 0.3, 0.95),
+ 1.2,
+ &mut canvas,
+ );
+ }
+ });
+
+ let body = View::new(Style {
+ flex_direction: FlexDirection::Column,
+ size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+ flex_grow: 1.0,
+ padding: TaffyRect {
+ left: length(16.0_f32),
+ right: length(16.0_f32),
+ top: length(16.0_f32),
+ bottom: length(16.0_f32),
+ },
+ gap: Size { width: length(0.0_f32), height: length(10.0_f32) },
+ ..Default::default()
+ })
+ .fill(theme.bg_app)
+ .children(vec![header, legend, panel]);
+
+ View::new(Style {
+ flex_direction: FlexDirection::Column,
+ size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+ ..Default::default()
+ })
+ .fill(theme.bg_app)
+ .on_right_click_at(|x, y, _w, _h| Some(Msg::ContextMenuOpen(x, y)))
+ .children(vec![menubar, body])
+ }
+
+ fn view_overlay(model: &Model) -> Option<View<Msg>> {
+ if let Some((x, y)) = model.context_menu {
+ return Some(context_menu_for_plot(model, x, y));
+ }
+ let menu = app_menu(model);
+ menubar_overlay(&menubar_spec(&menu, model))
+ }
+}
+
+// =====================================================================
+// Menú principal + contextual del plot
+// =====================================================================
+
+fn viewport_of(_model: &Model) -> (f32, f32) {
+ let (w, h) = ContourDemo::initial_size();
+ (w as f32, h as f32)
+}
+
+fn menubar_spec<'a>(menu: &'a AppMenu, model: &'a Model) -> MenuBarSpec<'a, Msg> {
+ MenuBarSpec {
+ menu,
+ open: model.menu_open,
+ theme: &model.theme,
+ viewport: viewport_of(model),
+ height: MENU_H,
+ on_open: Arc::new(Msg::MenuOpen),
+ on_command: Arc::new(|c: &str| Msg::MenuCommand(c.to_string())),
+ }
+}
+
+fn app_menu(model: &Model) -> AppMenu {
+ let pause_label = if model.paused { "Reanudar" } else { "Pausar" };
+ AppMenu::new()
+ .menu(Menu::new("Archivo").item(MenuItem::new("Salir", "app.quit").shortcut("Esc")))
+ .menu(
+ Menu::new("Ver")
+ .item(MenuItem::new("Reiniciar campo", "view.reset"))
+ .item(MenuItem::new(pause_label, "view.pause"))
+ .item(MenuItem::new("Cambiar tema", "view.theme").separated()),
+ )
+ .menu(Menu::new("Ayuda").item(MenuItem::new("Acerca de", "help.about")))
+}
+
+fn handle_menu_command(cmd: &str, handle: &Handle<Msg>) {
+ let msg = match cmd {
+ "app.quit" => {
+ std::process::exit(0);
+ }
+ "view.reset" => Some(Msg::Reset),
+ "view.pause" => Some(Msg::TogglePause),
+ "view.theme" => Some(Msg::CycleTheme),
+ _ => None,
+ };
+ if let Some(msg) = msg {
+ handle.dispatch(msg);
+ }
+}
+
+fn context_menu_for_plot(model: &Model, x: f32, y: f32) -> View<Msg> {
+ let pause_label = if model.paused { "Reanudar" } else { "Pausar" };
+ let items = vec![
+ ContextMenuItem::action("Reiniciar campo"),
+ ContextMenuItem::action(pause_label),
+ ];
+ let cmds: Vec<&'static str> = vec!["view.reset", "view.pause"];
+ let on_pick: Arc<dyn Fn(usize) -> Msg + Send + Sync> = Arc::new(move |i: usize| {
+ Msg::MenuCommand(cmds.get(i).copied().unwrap_or("").to_string())
+ });
+
+ context_menu_view(ContextMenuSpec {
+ anchor: (x, y),
+ viewport: viewport_of(model),
+ header: Some("campo".to_string()),
+ items,
+ active: usize::MAX,
+ on_pick,
+ on_dismiss: Msg::CloseMenus,
+ palette: ContextMenuPalette::from_theme(&model.theme),
+ })
+}
+
+fn fill(m: &mut HeatmapMatrix, t: u64) {
+ let phase = t as f32 * 0.10;
+ let phase2 = t as f32 * 0.07;
+ let mut data = Vec::with_capacity(W * H);
+ for y in 0..H {
+ for x in 0..W {
+ let v = (x as f32 * 0.4 - phase).sin() + (y as f32 * 0.4 + phase2).cos();
+ data.push(v);
+ }
+ }
+ m.replace_data(data);
+}
+
+fn main() {
+ llimphi_ui::run::<ContourDemo>();
+}
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<usize>),
+ /// Comando elegido en la barra o el contextual — se traduce al `Msg` real.
+ MenuCommand(String),
+ /// Cierra cualquier menú abierto (click-fuera / Esc).
+ CloseMenus,
+ /// Right-click sobre el plot → abre el menú contextual anclado en `(x, y)`.
+ ContextMenuOpen(f32, f32),
+}
+
+struct Model {
+ series_sin: DataBuffer,
+ series_cos: DataBuffer,
+ series_mix: DataBuffer,
+ viewport: ChartViewport,
+ initial_viewport: ChartViewport,
+ chart_cache: ChartCacheHandle,
+ /// Tamaño actual de la ventana — necesario para mapear cursor
+ /// absoluto a fracciones del plot en el handler de wheel.
+ win_w: f32,
+ win_h: f32,
+ /// Tema activo — viste la barra de menú y los overlays.
+ theme: Theme,
+ /// Barra de menú principal: índice del menú raíz abierto (`None` cerrado).
+ menu_open: Option<usize>,
+ /// Menú contextual del plot: ancla `(x, y)` en coords de ventana.
+ context_menu: Option<(f32, f32)>,
+}
+
+struct Demo;
+
+impl App for Demo {
+ type Model = Model;
+ type Msg = Msg;
+
+ fn title() -> &'static str {
+ "Lapaloma — multi-series (wheel = zoom, click = reset)"
+ }
+ fn initial_size() -> (u32, u32) {
+ (900, 560)
+ }
+
+ fn init(_: &Handle<Msg>) -> Model {
+ let mut sin = DataBuffer::with_capacity(N_SAMPLES);
+ let mut cos = DataBuffer::with_capacity(N_SAMPLES);
+ let mut mix = DataBuffer::with_capacity(N_SAMPLES);
+ for i in 0..N_SAMPLES {
+ let x = i as f32;
+ sin.push(x, (x * 0.04).sin());
+ cos.push(x, (x * 0.04).cos());
+ mix.push(x, 0.5 * (x * 0.02).sin() + 0.5 * (x * 0.08).cos());
+ }
+ let viewport = ChartViewport::new(0.0, (N_SAMPLES - 1) as f64, -1.3, 1.3);
+ Model {
+ series_sin: sin,
+ series_cos: cos,
+ series_mix: mix,
+ viewport,
+ initial_viewport: viewport,
+ chart_cache: chart_cache(),
+ win_w: 900.0,
+ win_h: 560.0,
+ theme: Theme::dark(),
+ menu_open: None,
+ context_menu: None,
+ }
+ }
+
+ fn update(mut model: Model, msg: Msg, handle: &Handle<Msg>) -> Model {
+ match msg {
+ Msg::Zoom { factor, anchor_x, anchor_y } => {
+ model.viewport.zoom_uniform(factor, (anchor_x, anchor_y));
+ }
+ Msg::ZoomStep(factor) => {
+ model.viewport.zoom_uniform(factor, (0.5, 0.5));
+ }
+ Msg::Reset => {
+ model.viewport = model.initial_viewport;
+ model.chart_cache.lock().unwrap().invalidate();
+ }
+ Msg::CycleTheme => {
+ model.theme = Theme::next_after(model.theme.name);
+ }
+ Msg::MenuOpen(which) => {
+ model.menu_open = which;
+ model.context_menu = None;
+ }
+ Msg::CloseMenus => {
+ model.menu_open = None;
+ model.context_menu = None;
+ }
+ Msg::ContextMenuOpen(x, y) => {
+ model.menu_open = None;
+ model.context_menu = Some((x, y));
+ }
+ Msg::MenuCommand(cmd) => {
+ model.menu_open = None;
+ model.context_menu = None;
+ handle_menu_command(&cmd, handle);
+ }
+ }
+ model
+ }
+
+ fn on_wheel(
+ model: &Model,
+ delta: WheelDelta,
+ cursor: (f32, f32),
+ _mods: Modifiers,
+ ) -> Option<Msg> {
+ if model.win_w <= 0.0 || model.win_h <= 0.0 {
+ return None;
+ }
+ let factor = (-delta.y as f64 * WHEEL_SENSITIVITY).exp();
+ let ax = (cursor.0 / model.win_w).clamp(0.0, 1.0) as f64;
+ // Llimphi reporta cursor con +Y hacia abajo; el viewport quiere
+ // +Y hacia arriba (anchor a fondo de plot = 0).
+ let ay = (1.0 - cursor.1 / model.win_h).clamp(0.0, 1.0) as f64;
+ Some(Msg::Zoom { factor, anchor_x: ax, anchor_y: ay })
+ }
+
+ fn view(model: &Model) -> View<Msg> {
+ let theme = &model.theme;
+ let plot_bg = Color::rgba(0.10, 0.12, 0.16, 1.0);
+
+ let menu = app_menu();
+ let menubar = menubar_view(&menubar_spec(&menu, model));
+
+ let chart = ChartView::new(model.viewport)
+ .background(plot_bg)
+ .with_cache(model.chart_cache.clone())
+ .add_series_named(
+ model.series_sin.clone(),
+ StrokeStyle::new(2.0, color_rgb(COLOR_SIN)),
+ "sin",
+ )
+ .add_series_named(
+ model.series_cos.clone(),
+ StrokeStyle::new(2.0, color_rgb(COLOR_COS)),
+ "cos",
+ )
+ .add_series_named(
+ model.series_mix.clone(),
+ StrokeStyle::new(2.0, color_rgb(COLOR_MIX)),
+ "mix",
+ )
+ .view::<Msg>();
+
+ let (pan_blits, rebuilds) = {
+ let c = model.chart_cache.lock().unwrap();
+ (c.pan_blits(), c.rebuilds())
+ };
+
+ let header = View::new(Style {
+ size: Size { width: percent(1.0_f32), height: length(28.0_f32) },
+ ..Default::default()
+ })
+ .text_aligned(
+ "Lapaloma — demo cartesian multi-series".to_string(),
+ 18.0,
+ theme.fg_text,
+ Alignment::Start,
+ );
+
+ let legend = format!(
+ "sin(x · 0.04) cos(x · 0.04) ½·sin(x · 0.02) + ½·cos(x · 0.08) \
+ cache: {} pan-blits / {} rebuilds",
+ pan_blits, rebuilds,
+ );
+ let legend_row = View::new(Style {
+ size: Size { width: percent(1.0_f32), height: length(20.0_f32) },
+ ..Default::default()
+ })
+ .text_aligned(legend, 11.0, theme.fg_muted, Alignment::Start);
+
+ let plot_panel = View::new(Style {
+ size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+ flex_grow: 1.0,
+ ..Default::default()
+ })
+ .clip(true)
+ .children(vec![chart])
+ .on_click(Msg::Reset);
+
+ let body = View::new(Style {
+ flex_direction: FlexDirection::Column,
+ size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+ flex_grow: 1.0,
+ padding: Rect {
+ left: length(16.0_f32),
+ right: length(16.0_f32),
+ top: length(16.0_f32),
+ bottom: length(16.0_f32),
+ },
+ gap: Size { width: length(0.0_f32), height: length(12.0_f32) },
+ ..Default::default()
+ })
+ .fill(theme.bg_app)
+ .children(vec![header, legend_row, plot_panel]);
+
+ View::new(Style {
+ flex_direction: FlexDirection::Column,
+ size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+ ..Default::default()
+ })
+ .fill(theme.bg_app)
+ .on_right_click_at(|x, y, _w, _h| Some(Msg::ContextMenuOpen(x, y)))
+ .children(vec![menubar, body])
+ }
+
+ fn view_overlay(model: &Model) -> Option<View<Msg>> {
+ // Prioridad: menú contextual del plot.
+ if let Some((x, y)) = model.context_menu {
+ return Some(context_menu_for_plot(model, x, y));
+ }
+ // Si no, el dropdown del menú principal.
+ let menu = app_menu();
+ menubar_overlay(&menubar_spec(&menu, model))
+ }
+}
+
+// =====================================================================
+// Menú principal + contextual del plot
+// =====================================================================
+
+/// Viewport para clampear overlays — coords de ventana actual.
+fn viewport_of(model: &Model) -> (f32, f32) {
+ (model.win_w, model.win_h)
+}
+
+/// Arma el `MenuBarSpec` compartido por `menubar_view` y `menubar_overlay`.
+fn menubar_spec<'a>(menu: &'a AppMenu, model: &'a Model) -> MenuBarSpec<'a, Msg> {
+ MenuBarSpec {
+ menu,
+ open: model.menu_open,
+ theme: &model.theme,
+ viewport: viewport_of(model),
+ height: MENU_H,
+ on_open: Arc::new(Msg::MenuOpen),
+ on_command: Arc::new(|c: &str| Msg::MenuCommand(c.to_string())),
+ }
+}
+
+/// Menú principal. Archivo / Ver / Ayuda — sólo comandos que mapean a
+/// `Msg` reales. No hay "Editar": es un canvas de chart, sin texto.
+fn app_menu() -> AppMenu {
+ AppMenu::new()
+ .menu(Menu::new("Archivo").item(MenuItem::new("Salir", "app.quit").shortcut("Esc")))
+ .menu(
+ Menu::new("Ver")
+ .item(MenuItem::new("Reiniciar vista", "view.reset"))
+ .item(MenuItem::new("Acercar", "view.zoom_in").shortcut("+").separated())
+ .item(MenuItem::new("Alejar", "view.zoom_out").shortcut("-"))
+ .item(MenuItem::new("Cambiar tema", "view.theme").separated()),
+ )
+ .menu(Menu::new("Ayuda").item(MenuItem::new("Acerca de", "help.about")))
+}
+
+const ZOOM_IN_FACTOR: f64 = 0.8;
+const ZOOM_OUT_FACTOR: f64 = 1.25;
+
+/// Traduce un command id (barra o contextual) al `Msg` real y lo dispatcha.
+fn handle_menu_command(cmd: &str, handle: &Handle<Msg>) {
+ let msg = match cmd {
+ "app.quit" => {
+ std::process::exit(0);
+ }
+ "view.reset" => Some(Msg::Reset),
+ "view.zoom_in" => Some(Msg::ZoomStep(ZOOM_IN_FACTOR)),
+ "view.zoom_out" => Some(Msg::ZoomStep(ZOOM_OUT_FACTOR)),
+ "view.theme" => Some(Msg::CycleTheme),
+ // "help.about" y desconocidos: no-op (sin diálogo todavía).
+ _ => None,
+ };
+ if let Some(msg) = msg {
+ handle.dispatch(msg);
+ }
+}
+
+/// Menú contextual del plot — expone las acciones de vista del chart.
+fn context_menu_for_plot(model: &Model, x: f32, y: f32) -> View<Msg> {
+ let items = vec![
+ ContextMenuItem::action("Reiniciar vista"),
+ ContextMenuItem::separator(),
+ ContextMenuItem::action("Acercar").with_shortcut("+"),
+ ContextMenuItem::action("Alejar").with_shortcut("-"),
+ ];
+ // Mapeo índice de item → command id de `handle_menu_command`.
+ let cmds: Vec<&'static str> = vec!["view.reset", "", "view.zoom_in", "view.zoom_out"];
+ let on_pick: Arc<dyn Fn(usize) -> Msg + Send + Sync> = Arc::new(move |i: usize| {
+ Msg::MenuCommand(cmds.get(i).copied().unwrap_or("").to_string())
+ });
+
+ context_menu_view(ContextMenuSpec {
+ anchor: (x, y),
+ viewport: viewport_of(model),
+ header: Some("vista".to_string()),
+ items,
+ active: usize::MAX,
+ on_pick,
+ on_dismiss: Msg::CloseMenus,
+ palette: ContextMenuPalette::from_theme(&model.theme),
+ })
+}
+
+fn main() {
+ llimphi_ui::run::<Demo>();
+}
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<usize>),
+ /// Comando elegido en la barra o el contextual → `Msg` real.
+ MenuCommand(String),
+ /// Cierra cualquier menú abierto (click-fuera / Esc).
+ CloseMenus,
+ /// Right-click sobre el plot → abre el contextual anclado en `(x, y)`.
+ ContextMenuOpen(f32, f32),
+}
+
+struct Model {
+ data: OhlcBuffer,
+ viewport: ChartViewport,
+ initial_viewport: ChartViewport,
+ win_w: f32,
+ win_h: f32,
+ theme: Theme,
+ menu_open: Option<usize>,
+ context_menu: Option<(f32, f32)>,
+}
+
+struct FinancialDemo;
+
+impl App for FinancialDemo {
+ type Model = Model;
+ type Msg = Msg;
+
+ fn title() -> &'static str {
+ "Lapaloma — candlesticks (wheel = zoom, click = reset)"
+ }
+ fn initial_size() -> (u32, u32) {
+ (960, 560)
+ }
+
+ fn init(_: &Handle<Msg>) -> Model {
+ let data = synth_random_walk(N_BARS, 100.0, 0xc0ffee);
+ let (lo, hi) = data.price_range().unwrap_or((0.0, 1.0));
+ let pad = (hi - lo) * 0.08;
+ let viewport = ChartViewport::new(
+ -0.5,
+ N_BARS as f64 - 0.5,
+ (lo - pad) as f64,
+ (hi + pad) as f64,
+ );
+ Model {
+ data,
+ viewport,
+ initial_viewport: viewport,
+ win_w: 960.0,
+ win_h: 560.0,
+ theme: Theme::dark(),
+ menu_open: None,
+ context_menu: None,
+ }
+ }
+
+ fn update(mut model: Model, msg: Msg, handle: &Handle<Msg>) -> Model {
+ match msg {
+ Msg::Zoom { factor, anchor_x, anchor_y } => {
+ model.viewport.zoom_uniform(factor, (anchor_x, anchor_y));
+ }
+ Msg::ZoomStep(factor) => {
+ model.viewport.zoom_uniform(factor, (0.5, 0.5));
+ }
+ Msg::Reset => {
+ model.viewport = model.initial_viewport;
+ }
+ Msg::CycleTheme => {
+ model.theme = Theme::next_after(model.theme.name);
+ }
+ Msg::MenuOpen(which) => {
+ model.menu_open = which;
+ model.context_menu = None;
+ }
+ Msg::CloseMenus => {
+ model.menu_open = None;
+ model.context_menu = None;
+ }
+ Msg::ContextMenuOpen(x, y) => {
+ model.menu_open = None;
+ model.context_menu = Some((x, y));
+ }
+ Msg::MenuCommand(cmd) => {
+ model.menu_open = None;
+ model.context_menu = None;
+ handle_menu_command(&cmd, handle);
+ }
+ }
+ model
+ }
+
+ fn on_wheel(
+ model: &Model,
+ delta: WheelDelta,
+ cursor: (f32, f32),
+ _mods: Modifiers,
+ ) -> Option<Msg> {
+ if model.win_w <= 0.0 || model.win_h <= 0.0 {
+ return None;
+ }
+ let factor = (-delta.y as f64 * WHEEL_SENSITIVITY).exp();
+ let ax = (cursor.0 / model.win_w).clamp(0.0, 1.0) as f64;
+ let ay = (1.0 - cursor.1 / model.win_h).clamp(0.0, 1.0) as f64;
+ Some(Msg::Zoom { factor, anchor_x: ax, anchor_y: ay })
+ }
+
+ fn view(model: &Model) -> View<Msg> {
+ let theme = &model.theme;
+ let plot_bg = Color::rgba(0.06, 0.08, 0.10, 1.0);
+
+ let menu = app_menu();
+ let menubar = menubar_view(&menubar_spec(&menu, model));
+
+ let style = CandlestickStyle {
+ bull_color: Color::rgb(0.639, 0.745, 0.549),
+ bear_color: Color::rgb(0.749, 0.380, 0.416),
+ ..CandlestickStyle::default()
+ };
+
+ let chart = lapaloma_candlestick_view(model.data.clone(), model.viewport)
+ .background(plot_bg)
+ .style(style)
+ .view::<Msg>();
+
+ let (lo, hi) = model.data.price_range().unwrap_or((0.0, 0.0));
+
+ let header = View::new(Style {
+ size: Size { width: percent(1.0_f32), height: length(28.0_f32) },
+ ..Default::default()
+ })
+ .text_aligned(
+ "Lapaloma — candlesticks".to_string(),
+ 18.0,
+ theme.fg_text,
+ Alignment::Start,
+ );
+
+ let stats = format!(
+ "{} bars (random walk) price [{:.2}, {:.2}] wheel = zoom, click = reset",
+ N_BARS, lo, hi,
+ );
+ let stats_row = View::new(Style {
+ size: Size { width: percent(1.0_f32), height: length(20.0_f32) },
+ ..Default::default()
+ })
+ .text_aligned(stats, 11.0, theme.fg_muted, Alignment::Start);
+
+ let plot_panel = View::new(Style {
+ size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+ flex_grow: 1.0,
+ ..Default::default()
+ })
+ .clip(true)
+ .children(vec![chart])
+ .on_click(Msg::Reset);
+
+ let body = View::new(Style {
+ flex_direction: FlexDirection::Column,
+ size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+ flex_grow: 1.0,
+ padding: Rect {
+ left: length(16.0_f32),
+ right: length(16.0_f32),
+ top: length(16.0_f32),
+ bottom: length(16.0_f32),
+ },
+ gap: Size { width: length(0.0_f32), height: length(10.0_f32) },
+ ..Default::default()
+ })
+ .fill(theme.bg_app)
+ .children(vec![header, stats_row, plot_panel]);
+
+ View::new(Style {
+ flex_direction: FlexDirection::Column,
+ size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+ ..Default::default()
+ })
+ .fill(theme.bg_app)
+ .on_right_click_at(|x, y, _w, _h| Some(Msg::ContextMenuOpen(x, y)))
+ .children(vec![menubar, body])
+ }
+
+ fn view_overlay(model: &Model) -> Option<View<Msg>> {
+ if let Some((x, y)) = model.context_menu {
+ return Some(context_menu_for_plot(model, x, y));
+ }
+ let menu = app_menu();
+ menubar_overlay(&menubar_spec(&menu, model))
+ }
+}
+
+// =====================================================================
+// Menú principal + contextual del plot
+// =====================================================================
+
+fn viewport_of(model: &Model) -> (f32, f32) {
+ (model.win_w, model.win_h)
+}
+
+fn menubar_spec<'a>(menu: &'a AppMenu, model: &'a Model) -> MenuBarSpec<'a, Msg> {
+ MenuBarSpec {
+ menu,
+ open: model.menu_open,
+ theme: &model.theme,
+ viewport: viewport_of(model),
+ height: MENU_H,
+ on_open: Arc::new(Msg::MenuOpen),
+ on_command: Arc::new(|c: &str| Msg::MenuCommand(c.to_string())),
+ }
+}
+
+fn app_menu() -> AppMenu {
+ AppMenu::new()
+ .menu(Menu::new("Archivo").item(MenuItem::new("Salir", "app.quit").shortcut("Esc")))
+ .menu(
+ Menu::new("Ver")
+ .item(MenuItem::new("Reiniciar vista", "view.reset"))
+ .item(MenuItem::new("Acercar", "view.zoom_in").shortcut("+").separated())
+ .item(MenuItem::new("Alejar", "view.zoom_out").shortcut("-"))
+ .item(MenuItem::new("Cambiar tema", "view.theme").separated()),
+ )
+ .menu(Menu::new("Ayuda").item(MenuItem::new("Acerca de", "help.about")))
+}
+
+const ZOOM_IN_FACTOR: f64 = 0.8;
+const ZOOM_OUT_FACTOR: f64 = 1.25;
+
+fn handle_menu_command(cmd: &str, handle: &Handle<Msg>) {
+ let msg = match cmd {
+ "app.quit" => {
+ std::process::exit(0);
+ }
+ "view.reset" => Some(Msg::Reset),
+ "view.zoom_in" => Some(Msg::ZoomStep(ZOOM_IN_FACTOR)),
+ "view.zoom_out" => Some(Msg::ZoomStep(ZOOM_OUT_FACTOR)),
+ "view.theme" => Some(Msg::CycleTheme),
+ _ => None,
+ };
+ if let Some(msg) = msg {
+ handle.dispatch(msg);
+ }
+}
+
+fn context_menu_for_plot(model: &Model, x: f32, y: f32) -> View<Msg> {
+ let items = vec![
+ ContextMenuItem::action("Reiniciar vista"),
+ ContextMenuItem::separator(),
+ ContextMenuItem::action("Acercar").with_shortcut("+"),
+ ContextMenuItem::action("Alejar").with_shortcut("-"),
+ ];
+ let cmds: Vec<&'static str> = vec!["view.reset", "", "view.zoom_in", "view.zoom_out"];
+ let on_pick: Arc<dyn Fn(usize) -> Msg + Send + Sync> = Arc::new(move |i: usize| {
+ Msg::MenuCommand(cmds.get(i).copied().unwrap_or("").to_string())
+ });
+
+ context_menu_view(ContextMenuSpec {
+ anchor: (x, y),
+ viewport: viewport_of(model),
+ header: Some("vista".to_string()),
+ items,
+ active: usize::MAX,
+ on_pick,
+ on_dismiss: Msg::CloseMenus,
+ palette: ContextMenuPalette::from_theme(&model.theme),
+ })
+}
+
+/// xorshift32 inline — RNG determinístico mínimo. No criptográfico,
+/// pero perfecto para series sintéticas reproducibles.
+fn xorshift32(state: &mut u32) -> u32 {
+ let mut x = *state;
+ x ^= x << 13;
+ x ^= x >> 17;
+ x ^= x << 5;
+ *state = x;
+ x
+}
+
+fn rand_f32(state: &mut u32) -> f32 {
+ xorshift32(state) as f32 / u32::MAX as f32
+}
+
+fn synth_random_walk(n: usize, start_price: f32, seed: u32) -> OhlcBuffer {
+ let mut rng = seed.max(1);
+ let mut buf = OhlcBuffer::with_capacity(n);
+ let mut close = start_price;
+ let drift = 0.05;
+ let vol = 1.2;
+ for i in 0..n {
+ let r1 = rand_f32(&mut rng) - 0.5;
+ let r2 = rand_f32(&mut rng) - 0.5;
+ let r3 = rand_f32(&mut rng) - 0.5;
+ let r4 = rand_f32(&mut rng) - 0.5;
+
+ let open = close;
+ let move_close = drift + r1 * vol * 2.0;
+ let new_close = (open + move_close).max(1.0);
+ let body_hi = open.max(new_close);
+ let body_lo = open.min(new_close);
+ let wick_up = (r2.abs() * vol * 1.2).max(0.05);
+ let wick_dn = (r3.abs() * vol * 1.2).max(0.05);
+ let high = body_hi + wick_up;
+ let low = (body_lo - wick_dn).max(0.1);
+ let volume = 1000.0 + r4.abs() * 8000.0;
+
+ buf.push_bar(Bar {
+ t: i as f32,
+ o: open,
+ h: high,
+ l: low,
+ c: new_close,
+ v: volume,
+ });
+ close = new_close;
+ }
+ buf
+}
+
+fn main() {
+ llimphi_ui::run::<FinancialDemo>();
+}
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<usize>),
+ /// Comando elegido en la barra o el contextual → `Msg` real.
+ MenuCommand(String),
+ /// Cierra cualquier menú abierto (click-fuera / Esc).
+ CloseMenus,
+ /// Right-click sobre el plot → abre el contextual anclado en `(x, y)`.
+ ContextMenuOpen(f32, f32),
+}
+
+struct Model {
+ theme: Theme,
+ menu_open: Option<usize>,
+ context_menu: Option<(f32, f32)>,
+}
+
+struct FlowDemo;
+
+impl App for FlowDemo {
+ type Model = Model;
+ type Msg = Msg;
+
+ fn title() -> &'static str {
+ "Lapaloma — Sankey (presupuesto)"
+ }
+ fn initial_size() -> (u32, u32) {
+ (1080, 620)
+ }
+
+ fn init(_: &Handle<Msg>) -> Model {
+ Model { theme: Theme::dark(), menu_open: None, context_menu: None }
+ }
+
+ fn update(mut model: Model, msg: Msg, handle: &Handle<Msg>) -> Model {
+ match msg {
+ Msg::CycleTheme => {
+ model.theme = Theme::next_after(model.theme.name);
+ }
+ Msg::MenuOpen(which) => {
+ model.menu_open = which;
+ model.context_menu = None;
+ }
+ Msg::CloseMenus => {
+ model.menu_open = None;
+ model.context_menu = None;
+ }
+ Msg::ContextMenuOpen(x, y) => {
+ model.menu_open = None;
+ model.context_menu = Some((x, y));
+ }
+ Msg::MenuCommand(cmd) => {
+ model.menu_open = None;
+ model.context_menu = None;
+ handle_menu_command(&cmd, handle);
+ }
+ }
+ model
+ }
+
+ fn view(model: &Model) -> View<Msg> {
+ let theme = &model.theme;
+ let plot_bg = Color::rgba(0.08, 0.10, 0.13, 1.0);
+
+ let menu = app_menu();
+ let menubar = menubar_view(&menubar_spec(&menu, model));
+
+ // 0..4: ingresos · 5..9: categorías de gasto · 10: ahorro.
+ let nodes: Vec<SankeyNode> = [
+ "Sueldo", "Freelance", "Renta", "Dividendos",
+ "Vivienda", "Comida", "Transporte", "Ocio", "Salud",
+ "Ahorro",
+ ]
+ .iter()
+ .map(|n| SankeyNode::new(*n))
+ .collect();
+
+ let links: Vec<SankeyLink> = vec![
+ // Sueldo → todo
+ SankeyLink { source: 0, target: 4, value: 1200.0 },
+ SankeyLink { source: 0, target: 5, value: 600.0 },
+ SankeyLink { source: 0, target: 6, value: 250.0 },
+ SankeyLink { source: 0, target: 9, value: 950.0 },
+ // Freelance
+ SankeyLink { source: 1, target: 5, value: 200.0 },
+ SankeyLink { source: 1, target: 7, value: 300.0 },
+ SankeyLink { source: 1, target: 9, value: 400.0 },
+ // Renta
+ SankeyLink { source: 2, target: 4, value: 400.0 },
+ SankeyLink { source: 2, target: 8, value: 150.0 },
+ // Dividendos
+ SankeyLink { source: 3, target: 9, value: 350.0 },
+ SankeyLink { source: 3, target: 7, value: 80.0 },
+ ];
+
+ let header = View::new(Style {
+ size: Size { width: percent(1.0_f32), height: length(28.0_f32) },
+ ..Default::default()
+ })
+ .text_aligned(
+ "Lapaloma — Sankey".to_string(),
+ 18.0,
+ theme.fg_text,
+ Alignment::Start,
+ );
+
+ let legend = View::new(Style {
+ size: Size { width: percent(1.0_f32), height: length(20.0_f32) },
+ ..Default::default()
+ })
+ .text_aligned(
+ "4 ingresos → 5 categorías + ahorro · longest-path + barycenter + ribbons smoothstep"
+ .to_string(),
+ 11.0,
+ theme.fg_muted,
+ Alignment::Start,
+ );
+
+ let panel = View::new(Style {
+ size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+ flex_grow: 1.0,
+ ..Default::default()
+ })
+ .clip(true)
+ .paint_with(move |scene, ts, rect| {
+ let outer = Rect::new(rect.x, rect.y, rect.w, rect.h);
+ let mut canvas = SceneCanvas::new(scene, ts);
+ canvas.fill_rect(outer, plot_bg);
+
+ // 20 px de margen interior para el cómputo del layout.
+ let area = Rect::new(outer.x + 20.0, outer.y + 20.0, outer.w - 40.0, outer.h - 40.0);
+ let layout = compute_layout(&nodes, &links, area, 18.0, 8.0);
+ paint_sankey(
+ &layout,
+ Color::from_hex(0xe5e9f0),
+ Color::rgba(0.533, 0.753, 0.816, 0.45),
+ &mut canvas,
+ );
+ });
+
+ let body = View::new(Style {
+ flex_direction: FlexDirection::Column,
+ size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+ flex_grow: 1.0,
+ padding: TaffyRect {
+ left: length(16.0_f32),
+ right: length(16.0_f32),
+ top: length(16.0_f32),
+ bottom: length(16.0_f32),
+ },
+ gap: Size { width: length(0.0_f32), height: length(10.0_f32) },
+ ..Default::default()
+ })
+ .fill(theme.bg_app)
+ .children(vec![header, legend, panel]);
+
+ View::new(Style {
+ flex_direction: FlexDirection::Column,
+ size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+ ..Default::default()
+ })
+ .fill(theme.bg_app)
+ .on_right_click_at(|x, y, _w, _h| Some(Msg::ContextMenuOpen(x, y)))
+ .children(vec![menubar, body])
+ }
+
+ fn view_overlay(model: &Model) -> Option<View<Msg>> {
+ if let Some((x, y)) = model.context_menu {
+ return Some(context_menu_for_plot(model, x, y));
+ }
+ let menu = app_menu();
+ menubar_overlay(&menubar_spec(&menu, model))
+ }
+}
+
+// =====================================================================
+// Menú principal + contextual del plot
+// =====================================================================
+
+fn viewport_of(_model: &Model) -> (f32, f32) {
+ let (w, h) = FlowDemo::initial_size();
+ (w as f32, h as f32)
+}
+
+fn menubar_spec<'a>(menu: &'a AppMenu, model: &'a Model) -> MenuBarSpec<'a, Msg> {
+ MenuBarSpec {
+ menu,
+ open: model.menu_open,
+ theme: &model.theme,
+ viewport: viewport_of(model),
+ height: MENU_H,
+ on_open: Arc::new(Msg::MenuOpen),
+ on_command: Arc::new(|c: &str| Msg::MenuCommand(c.to_string())),
+ }
+}
+
+/// Menú principal. El Sankey es un diagrama estático (sin zoom/pan), así
+/// que "Ver" sólo ofrece cambio de tema; no hay reset de vista que mapear.
+fn app_menu() -> AppMenu {
+ AppMenu::new()
+ .menu(Menu::new("Archivo").item(MenuItem::new("Salir", "app.quit").shortcut("Esc")))
+ .menu(Menu::new("Ver").item(MenuItem::new("Cambiar tema", "view.theme")))
+ .menu(Menu::new("Ayuda").item(MenuItem::new("Acerca de", "help.about")))
+}
+
+fn handle_menu_command(cmd: &str, handle: &Handle<Msg>) {
+ let msg = match cmd {
+ "app.quit" => {
+ std::process::exit(0);
+ }
+ "view.theme" => Some(Msg::CycleTheme),
+ _ => None,
+ };
+ if let Some(msg) = msg {
+ handle.dispatch(msg);
+ }
+}
+
+fn context_menu_for_plot(model: &Model, x: f32, y: f32) -> View<Msg> {
+ let items = vec![ContextMenuItem::action("Cambiar tema")];
+ let cmds: Vec<&'static str> = vec!["view.theme"];
+ let on_pick: Arc<dyn Fn(usize) -> Msg + Send + Sync> = Arc::new(move |i: usize| {
+ Msg::MenuCommand(cmds.get(i).copied().unwrap_or("").to_string())
+ });
+
+ context_menu_view(ContextMenuSpec {
+ anchor: (x, y),
+ viewport: viewport_of(model),
+ header: Some("Sankey".to_string()),
+ items,
+ active: usize::MAX,
+ on_pick,
+ on_dismiss: Msg::CloseMenus,
+ palette: ContextMenuPalette::from_theme(&model.theme),
+ })
+}
+
+fn main() {
+ llimphi_ui::run::<FlowDemo>();
+}
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-<painter>-demo`, así que las firmas son las reales. Cada
+//! closure de `paint_with` captura SUS datos por `move`.
+//!
+//! Cableado de UI: barra de menú principal mínima (Archivo / Ver). Los
+//! tiles son canvas estáticos sin edición ni clipboard.
+
+use std::sync::Arc;
+
+use llimphi_theme::Theme;
+use llimphi_ui::llimphi_layout::taffy::prelude::{length, percent, FlexDirection, Size, Style};
+use llimphi_ui::llimphi_layout::taffy::Rect as TaffyRect;
+use llimphi_ui::llimphi_text::Alignment;
+use llimphi_ui::{App, Handle, View};
+
+use app_bus::{AppMenu, Menu, MenuItem};
+use llimphi_widget_menubar::{menubar_overlay, menubar_view, MenuBarSpec, DEFAULT_HEIGHT as MENU_H};
+
+use pineal_bars::{paint_bars, Bar, BarStyle, Histogram};
+use pineal_contour::paint_contours;
+use pineal_flow::{compute_layout, paint_sankey, SankeyLink, SankeyNode};
+use pineal_heatmap::{paint as paint_heatmap, HeatmapMatrix, Ramp};
+use pineal_hexbin::{paint_hexbin, HexGrid};
+use pineal_mesh::{EdgeBuffer, ForceLayout, ForceParams, NodeBuffer};
+use pineal_polar::{paint_pie, paint_radar, Slice};
+use pineal_render::{Canvas as _, Color, Point, Rect, SceneCanvas, StrokeStyle};
+use pineal_treemap::{paint_treemap, Tile};
+
+// =====================================================================
+// Modelo y mensajes
+// =====================================================================
+
+#[derive(Clone)]
+enum Msg {
+ /// Barra de menú principal: abrir/cerrar un menú raíz (`None` cierra).
+ MenuOpen(Option<usize>),
+ /// Comando elegido en la barra → se traduce al `Msg` real.
+ MenuCommand(String),
+ /// Cicla el preset de tema.
+ CycleTheme,
+}
+
+struct Model {
+ theme: Theme,
+ menu_open: Option<usize>,
+ /// Grafo del tile mesh, ya relajado en el init (posiciones fijas).
+ graph: Arc<Graph>,
+ /// Hexbin pre-construido (5 000 puntos gaussianos deterministas).
+ hex: HexGrid,
+ /// Campo escalar 48×32 para heatmap y contour (estático, t = 0).
+ field: Arc<HeatmapMatrix>,
+}
+
+struct GaleriaDemo;
+
+impl App for GaleriaDemo {
+ type Model = Model;
+ type Msg = Msg;
+
+ fn title() -> &'static str {
+ "pineal — galería de painters (grilla 3×3)"
+ }
+ fn initial_size() -> (u32, u32) {
+ (1280, 860)
+ }
+
+ fn init(_: &Handle<Msg>) -> Model {
+ Model {
+ theme: Theme::dark(),
+ menu_open: None,
+ graph: Arc::new(Graph::relaxed()),
+ hex: build_hexgrid(),
+ field: Arc::new(build_field()),
+ }
+ }
+
+ fn update(mut model: Model, msg: Msg, handle: &Handle<Msg>) -> Model {
+ match msg {
+ Msg::MenuOpen(which) => model.menu_open = which,
+ Msg::CycleTheme => {
+ model.theme = Theme::next_after(model.theme.name);
+ }
+ Msg::MenuCommand(cmd) => {
+ model.menu_open = None;
+ handle_menu_command(&cmd, handle);
+ }
+ }
+ model
+ }
+
+ fn view(model: &Model) -> View<Msg> {
+ let theme = &model.theme;
+
+ let menu = app_menu();
+ let menubar = menubar_view(&menubar_spec(&menu, model));
+
+ let header = View::new(Style {
+ size: Size { width: percent(1.0_f32), height: length(28.0_f32) },
+ ..Default::default()
+ })
+ .text_aligned(
+ "pineal — galería de painters".to_string(),
+ 18.0,
+ theme.fg_text,
+ Alignment::Start,
+ );
+
+ let legend = View::new(Style {
+ size: Size { width: percent(1.0_f32), height: length(20.0_f32) },
+ ..Default::default()
+ })
+ .text_aligned(
+ "11 tiles · cartesian · pie · radar · treemap · heatmap · hexbin · contour · sankey · mesh · bars · histograma".to_string(),
+ 11.0,
+ theme.fg_muted,
+ Alignment::Start,
+ );
+
+ // Tres filas de tres tiles cada una.
+ let row0 = grid_row(theme, vec![
+ tile("cartesian (sin)", theme, cartesian_tile(0.0)),
+ tile("polar · pie/donut", theme, pie_tile()),
+ tile("polar · radar", theme, radar_tile()),
+ ]);
+ let row1 = grid_row(theme, vec![
+ tile("treemap", theme, treemap_tile()),
+ tile("heatmap (Viridis)", theme, heatmap_tile(model.field.clone())),
+ tile("hexbin (Viridis)", theme, hexbin_tile(model.hex.clone())),
+ ]);
+ let row2 = grid_row(theme, vec![
+ tile("contour (8 niveles)", theme, contour_tile(model.field.clone())),
+ tile("flow · sankey", theme, sankey_tile()),
+ tile("mesh (force-directed)", theme, mesh_tile(model.graph.clone())),
+ ]);
+ let row3 = grid_row(theme, vec![
+ tile("bars (con negativo)", theme, bars_tile()),
+ tile("histograma", theme, histogram_tile()),
+ ]);
+
+ let grid = View::new(Style {
+ flex_direction: FlexDirection::Column,
+ size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+ flex_grow: 1.0,
+ gap: Size { width: length(0.0_f32), height: length(10.0_f32) },
+ ..Default::default()
+ })
+ .children(vec![row0, row1, row2, row3]);
+
+ let body = View::new(Style {
+ flex_direction: FlexDirection::Column,
+ size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+ flex_grow: 1.0,
+ padding: TaffyRect {
+ left: length(16.0_f32),
+ right: length(16.0_f32),
+ top: length(16.0_f32),
+ bottom: length(16.0_f32),
+ },
+ gap: Size { width: length(0.0_f32), height: length(10.0_f32) },
+ ..Default::default()
+ })
+ .fill(theme.bg_app)
+ .children(vec![header, legend, grid]);
+
+ View::new(Style {
+ flex_direction: FlexDirection::Column,
+ size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+ ..Default::default()
+ })
+ .fill(theme.bg_app)
+ .children(vec![menubar, body])
+ }
+
+ fn view_overlay(model: &Model) -> Option<View<Msg>> {
+ let menu = app_menu();
+ menubar_overlay(&menubar_spec(&menu, model))
+ }
+}
+
+// =====================================================================
+// Estructura de la grilla: fila de tiles + tile (label + canvas)
+// =====================================================================
+
+/// Una fila horizontal de tiles, cada uno con `flex_grow: 1.0`.
+fn grid_row(_theme: &Theme, tiles: Vec<View<Msg>>) -> View<Msg> {
+ View::new(Style {
+ flex_direction: FlexDirection::Row,
+ size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+ flex_grow: 1.0,
+ gap: Size { width: length(10.0_f32), height: length(0.0_f32) },
+ ..Default::default()
+ })
+ .children(tiles)
+}
+
+/// Un tile: label arriba + el canvas del painter abajo (que ya viene
+/// como una `View` con `paint_with`).
+fn tile(name: &str, theme: &Theme, canvas: View<Msg>) -> View<Msg> {
+ let label = View::new(Style {
+ size: Size { width: percent(1.0_f32), height: length(16.0_f32) },
+ ..Default::default()
+ })
+ .text_aligned(name.to_string(), 11.0, theme.fg_text, Alignment::Start);
+
+ View::new(Style {
+ flex_direction: FlexDirection::Column,
+ size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+ flex_grow: 1.0,
+ flex_basis: length(0.0_f32),
+ gap: Size { width: length(0.0_f32), height: length(4.0_f32) },
+ ..Default::default()
+ })
+ .children(vec![label, canvas])
+}
+
+/// Plantilla del canvas de un tile: una `View` con clip + `paint_with`
+/// que rellena el fondo y delega al painter.
+fn canvas_view<F>(painter: F) -> View<Msg>
+where
+ F: Fn(&mut SceneCanvas<'_>, Rect) + Send + Sync + 'static,
+{
+ let plot_bg = Color::rgba(0.06, 0.08, 0.10, 1.0);
+ View::new(Style {
+ size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+ flex_grow: 1.0,
+ ..Default::default()
+ })
+ .clip(true)
+ .paint_with(move |scene, ts, rect| {
+ let outer = Rect::new(rect.x, rect.y, rect.w, rect.h);
+ let mut canvas = SceneCanvas::new(scene, ts);
+ canvas.fill_rect(outer, plot_bg);
+ painter(&mut canvas, outer);
+ })
+}
+
+// =====================================================================
+// Tiles — un painter por función. Cada uno captura SUS datos por move.
+// =====================================================================
+
+/// Cartesian: una sinusoide a mano. Construimos `[x0,y0,x1,y1,…]`
+/// mapeados al rect y los pintamos con `stroke_polyline` directo (sin
+/// ChartView / viewport / caché).
+fn cartesian_tile(phase: f32) -> View<Msg> {
+ canvas_view(move |canvas, outer| {
+ const N: usize = 240;
+ let pad = 6.0_f32;
+ let x0 = outer.x + pad;
+ let w = (outer.w - 2.0 * pad).max(1.0);
+ let cy = outer.y + outer.h * 0.5;
+ let amp = (outer.h * 0.5 - pad).max(1.0);
+ let mut coords: Vec<f32> = Vec::with_capacity(N * 2);
+ for i in 0..N {
+ let t = i as f32 / (N - 1) as f32; // 0..1
+ let px = x0 + t * w;
+ // Suma de dos armónicos para que se vea algo más que un seno.
+ let v = (t * std::f32::consts::TAU * 3.0 + phase).sin() * 0.7
+ + (t * std::f32::consts::TAU * 7.0 + phase).sin() * 0.25;
+ let py = cy - v * amp;
+ coords.push(px);
+ coords.push(py);
+ }
+ canvas.stroke_polyline(&coords, StrokeStyle::new(1.8, Color::from_hex(0x88c0d0)));
+ })
+}
+
+/// Barras — una serie de columnas con un valor negativo para mostrar el
+/// baseline.
+fn bars_tile() -> View<Msg> {
+ canvas_view(move |canvas, outer| {
+ let bars = [
+ Bar::new(4.0, Color::from_hex(0x88c0d0)),
+ Bar::new(7.0, Color::from_hex(0x88c0d0)),
+ Bar::new(2.0, Color::from_hex(0x88c0d0)),
+ Bar::new(-3.0, Color::from_hex(0xd08770)),
+ Bar::new(5.0, Color::from_hex(0x88c0d0)),
+ Bar::new(6.5, Color::from_hex(0x88c0d0)),
+ ];
+ let area = Rect::new(outer.x + 8.0, outer.y + 8.0, outer.w - 16.0, outer.h - 16.0);
+ paint_bars(&bars, area, &BarStyle::vertical(), canvas);
+ })
+}
+
+/// Histograma — muestra ~gaussiana (suma de uniformes de un LCG
+/// sembrado) bineada en 24 bins.
+fn histogram_tile() -> View<Msg> {
+ canvas_view(move |canvas, outer| {
+ let mut rng: u32 = 0x0BAD_F00D;
+ let mut next = || {
+ rng = rng.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
+ (rng >> 8) as f32 / (1u32 << 24) as f32
+ };
+ let mut sample = Vec::with_capacity(3000);
+ for _ in 0..3000 {
+ let g: f32 = (0..6).map(|_| next()).sum::<f32>() / 6.0;
+ sample.push((g - 0.5) * 6.0);
+ }
+ let bars = Histogram::new(&sample, 24).to_bars(Color::from_hex(0xb48ead));
+ let area = Rect::new(outer.x + 8.0, outer.y + 8.0, outer.w - 16.0, outer.h - 16.0);
+ paint_bars(&bars, area, &BarStyle::vertical().with_gap(0.05), canvas);
+ })
+}
+
+/// Polar — pie/donut. 6 porciones de un presupuesto sintético.
+fn pie_tile() -> View<Msg> {
+ canvas_view(move |canvas, outer| {
+ let cx = outer.x + outer.w * 0.5;
+ let cy = outer.y + outer.h * 0.5;
+ let r_out = (outer.w.min(outer.h) * 0.42).max(20.0);
+ let r_in = r_out * 0.45;
+ let slices = [
+ Slice::new(28.0, Color::from_hex(0x88c0d0)),
+ Slice::new(18.0, Color::from_hex(0xd08770)),
+ Slice::new(14.0, Color::from_hex(0xa3be8c)),
+ Slice::new(12.0, Color::from_hex(0xebcb8b)),
+ Slice::new(10.0, Color::from_hex(0xb48ead)),
+ Slice::new(8.0, Color::from_hex(0x5e81ac)),
+ ];
+ paint_pie(&slices, Point::new(cx, cy), r_out, r_in, canvas);
+ })
+}
+
+/// Polar — radar (spider). 6 ejes, círculos guía + polígono.
+fn radar_tile() -> View<Msg> {
+ canvas_view(move |canvas, outer| {
+ let cx = outer.x + outer.w * 0.5;
+ let cy = outer.y + outer.h * 0.5;
+ let r = (outer.w.min(outer.h) * 0.42).max(20.0);
+
+ // Ejes guía: 4 círculos concéntricos cada 25 % del radio.
+ for step in 1..=4 {
+ let t = step as f32 / 4.0;
+ let ring: Vec<f32> = (0..=72)
+ .flat_map(|i| {
+ let a = (i as f32 / 72.0) * std::f32::consts::TAU
+ - std::f32::consts::FRAC_PI_2;
+ [cx + (r * t) * a.cos(), cy + (r * t) * a.sin()]
+ })
+ .collect();
+ canvas.stroke_polyline(
+ &ring,
+ StrokeStyle::new(0.6, Color::rgba(0.55, 0.6, 0.7, 0.35)),
+ );
+ }
+
+ let values = [8.0_f32, 6.5, 9.0, 4.0, 7.0, 5.5];
+ paint_radar(
+ &values,
+ 10.0,
+ Point::new(cx, cy),
+ r,
+ Color::rgba(0.639, 0.745, 0.549, 0.35),
+ StrokeStyle::new(1.6, Color::from_hex(0xa3be8c)),
+ canvas,
+ );
+ })
+}
+
+/// Treemap squarified: 12 tiles con pesos a mano.
+fn treemap_tile() -> View<Msg> {
+ let palette = [
+ 0x88c0d0, 0xd08770, 0xa3be8c, 0xebcb8b, 0xb48ead, 0x5e81ac, 0x81a1c1, 0xbf616a,
+ 0x8fbcbb, 0xd8dee9, 0xa3be8c, 0xebcb8b,
+ ];
+ let weights = [40.0, 28.0, 22.0, 18.0, 14.0, 10.0, 8.0, 6.0, 5.0, 4.0, 3.0, 2.0];
+ let tiles: Vec<Tile> = weights
+ .iter()
+ .zip(palette.iter())
+ .map(|(&w, &c)| Tile::new(w, Color::from_hex(c)))
+ .collect();
+ canvas_view(move |canvas, outer| {
+ paint_treemap(&tiles, outer, 2.0, canvas);
+ })
+}
+
+/// Heatmap: campo 48×32 Viridis (estático, t = 0).
+fn heatmap_tile(field: Arc<HeatmapMatrix>) -> View<Msg> {
+ canvas_view(move |canvas, outer| {
+ paint_heatmap(&field, Ramp::Viridis, outer, canvas);
+ })
+}
+
+/// Hexbin: 5 000 puntos gaussianos deterministas, bineados Viridis.
+fn hexbin_tile(grid: HexGrid) -> View<Msg> {
+ canvas_view(move |canvas, outer| {
+ paint_hexbin(&grid, Ramp::Viridis, (outer.x, outer.y), canvas);
+ })
+}
+
+/// Contour: heatmap base + 8 isolíneas (marching squares) del mismo
+/// campo escalar que el tile heatmap.
+fn contour_tile(field: Arc<HeatmapMatrix>) -> View<Msg> {
+ canvas_view(move |canvas, outer| {
+ paint_heatmap(&field, Ramp::Viridis, outer, canvas);
+ paint_contours(
+ &field,
+ 8,
+ outer,
+ Color::rgba(0.4, 0.6, 1.0, 0.9),
+ Color::rgba(1.0, 0.4, 0.3, 0.95),
+ 1.2,
+ canvas,
+ );
+ })
+}
+
+/// Flow — Sankey de presupuesto familiar (mismos nodos/links que el
+/// `pineal-flow-demo`). El layout se computa dentro del closure a partir
+/// del rect del tile.
+fn sankey_tile() -> View<Msg> {
+ let nodes: Vec<SankeyNode> = [
+ "Sueldo", "Freelance", "Renta", "Dividendos", "Vivienda", "Comida", "Transporte",
+ "Ocio", "Salud", "Ahorro",
+ ]
+ .iter()
+ .map(|n| SankeyNode::new(*n))
+ .collect();
+
+ let links: Vec<SankeyLink> = vec![
+ SankeyLink { source: 0, target: 4, value: 1200.0 },
+ SankeyLink { source: 0, target: 5, value: 600.0 },
+ SankeyLink { source: 0, target: 6, value: 250.0 },
+ SankeyLink { source: 0, target: 9, value: 950.0 },
+ SankeyLink { source: 1, target: 5, value: 200.0 },
+ SankeyLink { source: 1, target: 7, value: 300.0 },
+ SankeyLink { source: 1, target: 9, value: 400.0 },
+ SankeyLink { source: 2, target: 4, value: 400.0 },
+ SankeyLink { source: 2, target: 8, value: 150.0 },
+ SankeyLink { source: 3, target: 9, value: 350.0 },
+ SankeyLink { source: 3, target: 7, value: 80.0 },
+ ];
+
+ canvas_view(move |canvas, outer| {
+ let area = Rect::new(outer.x + 12.0, outer.y + 12.0, outer.w - 24.0, outer.h - 24.0);
+ let layout = compute_layout(&nodes, &links, area, 14.0, 6.0);
+ paint_sankey(
+ &layout,
+ Color::from_hex(0xe5e9f0),
+ Color::rgba(0.533, 0.753, 0.816, 0.45),
+ canvas,
+ );
+ })
+}
+
+/// Mesh — grafo force-directed ya relajado (posiciones fijas calculadas
+/// en el init). Replica el `paint_graph` local del `pineal-mesh-demo`
+/// (pineal-mesh no exporta un painter de grafo).
+fn mesh_tile(graph: Arc<Graph>) -> View<Msg> {
+ canvas_view(move |canvas, outer| {
+ paint_graph(canvas, &graph, outer);
+ })
+}
+
+// =====================================================================
+// Datos sintéticos deterministas
+// =====================================================================
+
+/// Campo escalar 48×32 compartido por heatmap y contour (estático t = 0).
+fn build_field() -> HeatmapMatrix {
+ const W: usize = 48;
+ const H: usize = 32;
+ let mut m = HeatmapMatrix::new(W, H);
+ let mut data = Vec::with_capacity(W * H);
+ for y in 0..H {
+ for x in 0..W {
+ // Mismo perfil que el heatmap-demo en t = 0.
+ let v = (x as f32 * 0.25).sin() + (y as f32 * 0.30).cos();
+ data.push(v);
+ }
+ }
+ m.replace_data(data);
+ m
+}
+
+/// HexGrid con 5 000 puntos gaussianos deterministas (LCG sembrado).
+fn build_hexgrid() -> HexGrid {
+ const N_POINTS: usize = 5000;
+ const HEX_RADIUS: f32 = 7.0;
+ let mut g = HexGrid::new(HEX_RADIUS);
+ let mut state: u64 = 0xC0FFEE;
+ let mut rng = || -> f32 {
+ state = state
+ .wrapping_mul(6364136223846793005)
+ .wrapping_add(1442695040888963407);
+ ((state >> 32) as f32) / (u32::MAX as f32)
+ };
+ let mut gauss = || -> (f32, f32) {
+ let u1 = (rng()).max(1e-9);
+ let u2 = rng();
+ let r = (-2.0 * u1.ln()).sqrt();
+ let theta = 2.0 * std::f32::consts::PI * u2;
+ (r * theta.cos(), r * theta.sin())
+ };
+ for i in 0..N_POINTS {
+ let (g0, g1) = gauss();
+ if i % 3 == 0 {
+ g.push(300.0 + g0 * 50.0, 300.0 + g1 * 50.0);
+ } else {
+ g.push(520.0 + g0 * 80.0, 380.0 + g1 * 80.0);
+ }
+ }
+ g
+}
+
+// =====================================================================
+// Grafo del tile mesh — mismo armado que el pineal-mesh-demo, pero
+// relajado en el init (corremos N pasos de force layout una vez).
+// =====================================================================
+
+const N_RING: usize = 12;
+const N_SAT: usize = 12;
+
+struct Graph {
+ nodes: NodeBuffer,
+ edges: EdgeBuffer,
+}
+
+impl Graph {
+ /// Construye la topología y la deja relajada tras unos pasos de
+ /// Fruchterman-Reingold (sin timers: todo el cómputo en el init).
+ fn relaxed() -> Self {
+ let mut nodes = NodeBuffer::new();
+ for i in 0..N_RING {
+ let a = (i as f32 / N_RING as f32) * std::f32::consts::TAU;
+ nodes.push(20.0 * a.cos(), 20.0 * a.sin(), 6.0);
+ }
+ for i in 0..N_SAT {
+ let a = (i as f32 / N_SAT as f32) * std::f32::consts::TAU + 0.13;
+ nodes.push(60.0 * a.cos(), 60.0 * a.sin(), 4.5);
+ }
+ let mut edges = EdgeBuffer::new();
+ for i in 0..N_RING {
+ edges.push(i, (i + 1) % N_RING);
+ }
+ for i in 0..N_RING {
+ edges.push(i, (i + 3) % N_RING);
+ }
+ for i in 0..N_SAT {
+ edges.push(i, N_RING + i);
+ }
+ let mut sim = ForceLayout::new(ForceParams { k: 38.0, temperature: 60.0, cooling: 0.985 });
+ // Relajación estática: corremos pasos hasta que se enfríe.
+ for _ in 0..400 {
+ let _ = sim.step(&mut nodes, &edges);
+ }
+ Self { nodes, edges }
+ }
+}
+
+/// Pinta el grafo dentro de `area` (centrado). Replica el painter local
+/// del `pineal-mesh-demo`: aristas grises + nodos como rect rellenos.
+fn paint_graph(canvas: &mut SceneCanvas<'_>, g: &Graph, area: Rect) {
+ let n = g.nodes.len();
+ if n == 0 {
+ return;
+ }
+ let cx = area.x + area.w * 0.5;
+ let cy = area.y + area.h * 0.5;
+
+ let edge_stroke = StrokeStyle::new(1.0, Color::rgba(0.6, 0.65, 0.7, 0.45));
+ for (u, v) in g.edges.iter() {
+ let (xu, yu) = g.nodes.pos(u);
+ let (xv, yv) = g.nodes.pos(v);
+ canvas.stroke_line(
+ Point::new(cx + xu, cy + yu),
+ Point::new(cx + xv, cy + yv),
+ edge_stroke,
+ );
+ }
+ for i in 0..n {
+ let (x, y) = g.nodes.pos(i);
+ let r = g.nodes.radius(i);
+ let color = if i < N_RING {
+ Color::from_hex(0x88c0d0)
+ } else {
+ Color::from_hex(0xa3be8c)
+ };
+ let rect = Rect::new(cx + x - r, cy + y - r, r * 2.0, r * 2.0);
+ canvas.fill_rect(rect, color);
+ }
+}
+
+// =====================================================================
+// Menú principal
+// =====================================================================
+
+fn viewport_of(_model: &Model) -> (f32, f32) {
+ let (w, h) = GaleriaDemo::initial_size();
+ (w as f32, h as f32)
+}
+
+fn menubar_spec<'a>(menu: &'a AppMenu, model: &'a Model) -> MenuBarSpec<'a, Msg> {
+ MenuBarSpec {
+ menu,
+ open: model.menu_open,
+ theme: &model.theme,
+ viewport: viewport_of(model),
+ height: MENU_H,
+ on_open: Arc::new(Msg::MenuOpen),
+ on_command: Arc::new(|c: &str| Msg::MenuCommand(c.to_string())),
+ }
+}
+
+fn app_menu() -> AppMenu {
+ AppMenu::new()
+ .menu(Menu::new("Archivo").item(MenuItem::new("Salir", "file.quit").shortcut("Esc")))
+ .menu(Menu::new("Ver").item(MenuItem::new("Cambiar tema", "view.theme")))
+}
+
+fn handle_menu_command(cmd: &str, handle: &Handle<Msg>) {
+ match cmd {
+ "file.quit" => std::process::exit(0),
+ "view.theme" => handle.dispatch(Msg::CycleTheme),
+ _ => {}
+ }
+}
+
+fn main() {
+ llimphi_ui::run::<GaleriaDemo>();
+}
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<f32>` plano interleaved
+//! `[x,y,z, ...]`, mutado in-place (P1 + P2: zero boxing, zero alloc
+//! en hot path).
+//! - `gpu_paint_with(...)` proyecta perspectiva y dibuja: lee el campo
+//! de estrellas, calcula el pixel de cada una contra el rect del nodo
+//! y llama `canvas.fill_rect(...)`. Ni el demo ni el painter saben de
+//! `wgpu` — sólo hablan el trait `Canvas`.
+//!
+//! El campo se comparte entre `update` (escribe) y el painter (lee) por
+//! `Arc<Mutex<StarField>>`. Ambos corren en el hilo de UI, así que el
+//! mutex nunca se disputa; el `Arc` existe sólo porque la closure GPU
+//! debe ser `'static` y no puede tomar prestado el `Model`.
+//!
+//! Las `GpuPipelines` (shaders + render pipelines) se compilan una vez y
+//! se cachean en un `OnceLock` que vive en el `Model`, no en `view()` —
+//! recompilarlas por frame mataría el framerate.
+
+use std::sync::{Arc, Mutex, OnceLock};
+use std::time::Duration;
+
+use llimphi_theme::Theme;
+use llimphi_ui::llimphi_layout::taffy::prelude::{length, percent, FlexDirection, Size, Style};
+use llimphi_ui::llimphi_layout::taffy::Rect as PadRect;
+use llimphi_ui::llimphi_raster::peniko::Color as PenikoColor;
+use llimphi_ui::llimphi_raster::{GpuBatch, GpuPipelines};
+use llimphi_ui::llimphi_text::Alignment;
+use llimphi_ui::{App, Handle, KeyEvent, KeyState, View};
+use llimphi_widget_context_menu::{
+ context_menu_view, ContextMenuItem, ContextMenuPalette, ContextMenuSpec,
+};
+use llimphi_widget_menubar::{menubar_overlay, menubar_view, MenuBarSpec, DEFAULT_HEIGHT as MENU_H};
+
+use app_bus::{AppMenu, Menu, MenuItem};
+
+use pineal_render::{Canvas, Color, GpuSceneCanvas, Rect};
+
+/// La intermediate del frame es `Rgba8Unorm` (ver
+/// `llimphi-compositor::GpuPaintFn`). Las pipelines deben coincidir.
+const TARGET_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
+
+const FRAME_PERIOD: Duration = Duration::from_millis(16);
+const NEAR: f32 = 0.06;
+const FAR: f32 = 1.0;
+const WARP_SPEED: f32 = 0.012;
+
+/// Densidades cicladas desde el menú. La última (1 M) es la que el SDD
+/// daba como techo del camino GPU directo.
+const DENSITIES: [usize; 4] = [50_000, 200_000, 500_000, 1_000_000];
+
+#[derive(Clone)]
+enum Msg {
+ Tick,
+ /// Cicla a la siguiente densidad de estrellas y reconstruye el campo.
+ CycleDensity,
+ /// Pausa/reanuda el warp (las estrellas quedan congeladas).
+ TogglePause,
+ MenuOpen(Option<usize>),
+ MenuCommand(String),
+ CloseMenus,
+ CycleTheme,
+ ContextMenuOpen(f32, f32),
+}
+
+/// Campo de estrellas en coordenadas de cámara. `xyz` interleaved
+/// `[x0,y0,z0, x1,y1,z1, ...]`; x,y ∈ [-1,1], z ∈ (NEAR, FAR].
+struct StarField {
+ xyz: Vec<f32>,
+ count: usize,
+ rng: u32,
+}
+
+impl StarField {
+ fn new(count: usize) -> Self {
+ let mut field = StarField {
+ xyz: vec![0.0; count * 3],
+ count,
+ rng: 0x9E37_79B9,
+ };
+ for i in 0..count {
+ field.respawn(i, true);
+ }
+ field
+ }
+
+ /// xorshift32 → f32 en [-1, 1).
+ fn unit(&mut self) -> f32 {
+ let mut x = self.rng;
+ x ^= x << 13;
+ x ^= x >> 17;
+ x ^= x << 5;
+ self.rng = x;
+ (x as f32 / u32::MAX as f32) * 2.0 - 1.0
+ }
+
+ /// Reposiciona la estrella `i`. Con `fresh` la siembra a una `z`
+ /// aleatoria en todo el rango (arranque); si no, la manda al fondo
+ /// (reciclaje cuando cruzó el plano cercano).
+ fn respawn(&mut self, i: usize, fresh: bool) {
+ let x = self.unit();
+ let y = self.unit();
+ let z = if fresh {
+ // Aleatoria en (NEAR, FAR] para no arrancar todas en fila.
+ NEAR + (self.unit() * 0.5 + 0.5) * (FAR - NEAR)
+ } else {
+ FAR
+ };
+ let b = i * 3;
+ self.xyz[b] = x;
+ self.xyz[b + 1] = y;
+ self.xyz[b + 2] = z;
+ }
+
+ /// Un paso de simulación: todas vuelan hacia el observador; las que
+ /// cruzan el plano cercano se reciclan al fondo. Zero alloc.
+ fn advance(&mut self) {
+ for i in 0..self.count {
+ let zi = i * 3 + 2;
+ let z = self.xyz[zi] - WARP_SPEED;
+ if z <= NEAR {
+ self.respawn(i, false);
+ } else {
+ self.xyz[zi] = z;
+ }
+ }
+ }
+}
+
+struct Model {
+ field: Arc<Mutex<StarField>>,
+ pipelines: Arc<OnceLock<GpuPipelines>>,
+ density_idx: usize,
+ paused: bool,
+ frame: u64,
+ theme: Theme,
+ menu_open: Option<usize>,
+ context_menu: Option<(f32, f32)>,
+}
+
+struct GpuDemo;
+
+impl App for GpuDemo {
+ type Model = Model;
+ type Msg = Msg;
+
+ fn title() -> &'static str {
+ "pineal — starfield warp (GPU directo)"
+ }
+ fn initial_size() -> (u32, u32) {
+ (1100, 700)
+ }
+
+ fn init(handle: &Handle<Msg>) -> Model {
+ handle.spawn_periodic(FRAME_PERIOD, || Msg::Tick);
+ let density_idx = 1; // 200 K por defecto
+ Model {
+ field: Arc::new(Mutex::new(StarField::new(DENSITIES[density_idx]))),
+ pipelines: Arc::new(OnceLock::new()),
+ density_idx,
+ paused: false,
+ frame: 0,
+ theme: Theme::dark(),
+ menu_open: None,
+ context_menu: None,
+ }
+ }
+
+ fn update(mut model: Model, msg: Msg, handle: &Handle<Msg>) -> Model {
+ match msg {
+ Msg::Tick => {
+ if !model.paused {
+ model.field.lock().unwrap().advance();
+ model.frame = model.frame.wrapping_add(1);
+ }
+ }
+ Msg::CycleDensity => {
+ model.density_idx = (model.density_idx + 1) % DENSITIES.len();
+ let n = DENSITIES[model.density_idx];
+ model.field = Arc::new(Mutex::new(StarField::new(n)));
+ }
+ Msg::TogglePause => model.paused = !model.paused,
+ Msg::MenuOpen(which) => {
+ model.menu_open = which;
+ model.context_menu = None;
+ }
+ Msg::MenuCommand(cmd) => {
+ model.menu_open = None;
+ return handle_menu_command(model, &cmd, handle);
+ }
+ Msg::CloseMenus => {
+ model.menu_open = None;
+ model.context_menu = None;
+ }
+ Msg::CycleTheme => model.theme = Theme::next_after(model.theme.name),
+ Msg::ContextMenuOpen(x, y) => {
+ model.menu_open = None;
+ model.context_menu = Some((x, y));
+ }
+ }
+ model
+ }
+
+ fn view(model: &Model) -> View<Msg> {
+ let theme = &model.theme;
+ let menu = app_menu();
+ let menubar = menubar_view(&menubar_spec(&menu, model));
+
+ let header = View::new(Style {
+ size: Size { width: percent(1.0_f32), height: length(26.0_f32) },
+ ..Default::default()
+ })
+ .text_aligned(
+ "pineal — starfield warp · GpuSceneCanvas → 1 draw call instanciada".to_string(),
+ 18.0,
+ theme.fg_text,
+ Alignment::Start,
+ );
+
+ let stats = format!(
+ "estrellas = {} {} frame = {} backend = GpuBatch (wgpu, Rgba8Unorm) \
+ [D] densidad · [espacio] pausa · click-derecho menú",
+ fmt_count(DENSITIES[model.density_idx]),
+ if model.paused { "PAUSA" } else { "warp" },
+ model.frame,
+ );
+ let stats_row = View::new(Style {
+ size: Size { width: percent(1.0_f32), height: length(18.0_f32) },
+ ..Default::default()
+ })
+ .text_aligned(stats, 11.0, theme.fg_muted, Alignment::Start);
+
+ // El panel del cielo: fondo negro por vello + estrellas por GPU
+ // directo encima (LoadOp::Load preserva el fondo).
+ let field = model.field.clone();
+ let pipelines = model.pipelines.clone();
+ let sky = View::new(Style {
+ size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+ flex_grow: 1.0,
+ ..Default::default()
+ })
+ .clip(true)
+ .fill(PenikoColor::from_rgba8(2, 3, 9, 255))
+ .gpu_paint_with(move |device, queue, encoder, target, rect, viewport| {
+ let pipes = pipelines.get_or_init(|| GpuPipelines::new(device, TARGET_FORMAT));
+ let mut batch = GpuBatch::new(pipes);
+
+ // Proyección perspectiva contra el rect del nodo (pixels
+ // absolutos del scene). Todo lo que sigue es el caller
+ // decidiendo "qué pintar"; el "cómo" es del backend.
+ {
+ let mut canvas = GpuSceneCanvas::new(&mut batch);
+ let cx = rect.x + rect.w * 0.5;
+ let cy = rect.y + rect.h * 0.5;
+ let kx = rect.w * 0.05;
+ let ky = rect.h * 0.05;
+ let f = model_field_snapshot(&field);
+ let xyz = &f.xyz;
+ for i in 0..f.count {
+ let b = i * 3;
+ let (x, y, z) = (xyz[b], xyz[b + 1], xyz[b + 2]);
+ let inv = 1.0 / z;
+ let sx = cx + x * inv * kx;
+ let sy = cy + y * inv * ky;
+ // Cull fuera del rect (barato; evita rects enormes).
+ if sx < rect.x || sx > rect.x + rect.w || sy < rect.y || sy > rect.y + rect.h {
+ continue;
+ }
+ // Cerca = grande y brillante; lejos = punto tenue.
+ let bright = ((FAR - z) / (FAR - NEAR)).clamp(0.0, 1.0);
+ let size = 0.6 + bright * bright * 2.6;
+ let half = size * 0.5;
+ let a = 0.25 + bright * 0.75;
+ // Tinte ligeramente frío hacia el azul-blanco.
+ let col = Color::rgba(0.78 + bright * 0.22, 0.84 + bright * 0.16, 1.0, a);
+ canvas.fill_rect(
+ Rect { x: sx - half, y: sy - half, w: size, h: size },
+ col,
+ );
+ }
+ }
+
+ batch.flush(
+ device,
+ queue,
+ encoder,
+ target,
+ (viewport.0 as f32, viewport.1 as f32),
+ wgpu::LoadOp::Load,
+ );
+ });
+
+ let sky_panel = View::new(Style {
+ size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+ flex_grow: 1.0,
+ ..Default::default()
+ })
+ .clip(true)
+ .children(vec![sky]);
+
+ let body = View::new(Style {
+ flex_direction: FlexDirection::Column,
+ size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+ flex_grow: 1.0,
+ padding: PadRect {
+ left: length(16.0_f32),
+ right: length(16.0_f32),
+ top: length(14.0_f32),
+ bottom: length(14.0_f32),
+ },
+ gap: Size { width: length(0.0_f32), height: length(8.0_f32) },
+ ..Default::default()
+ })
+ .fill(theme.bg_app)
+ .children(vec![header, stats_row, sky_panel]);
+
+ View::new(Style {
+ flex_direction: FlexDirection::Column,
+ size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+ ..Default::default()
+ })
+ .fill(theme.bg_app)
+ .on_right_click_at(|x, y, _w, _h| Some(Msg::ContextMenuOpen(x, y)))
+ .children(vec![menubar, body])
+ }
+
+ fn on_key(_model: &Model, event: &KeyEvent) -> Option<Msg> {
+ if event.state != KeyState::Pressed {
+ return None;
+ }
+ match event.text.as_deref() {
+ Some("d") | Some("D") => Some(Msg::CycleDensity),
+ Some(" ") => Some(Msg::TogglePause),
+ _ => None,
+ }
+ }
+
+ fn view_overlay(model: &Model) -> Option<View<Msg>> {
+ if let Some((x, y)) = model.context_menu {
+ let items = vec![
+ ContextMenuItem::action("Más estrellas"),
+ ContextMenuItem::action("Pausa / reanudar"),
+ ContextMenuItem::action("Cambiar tema"),
+ ];
+ let on_pick: Arc<dyn Fn(usize) -> Msg + Send + Sync> = Arc::new(|i: usize| match i {
+ 0 => Msg::CycleDensity,
+ 1 => Msg::TogglePause,
+ _ => Msg::CycleTheme,
+ });
+ return Some(context_menu_view(ContextMenuSpec {
+ anchor: (x, y),
+ viewport: viewport_of(model),
+ header: Some("Starfield".to_string()),
+ items,
+ active: usize::MAX,
+ on_pick,
+ on_dismiss: Msg::CloseMenus,
+ palette: ContextMenuPalette::from_theme(&model.theme),
+ }));
+ }
+ let menu = app_menu();
+ menubar_overlay(&menubar_spec(&menu, model))
+ }
+}
+
+/// Clona el snapshot del campo bajo el lock (libera el mutex antes de
+/// la proyección). El campo es `Vec<f32>` plano: el clone es un memcpy
+/// contiguo, sin punteros ni boxing (P1).
+fn model_field_snapshot(field: &Arc<Mutex<StarField>>) -> FieldSnapshot {
+ let g = field.lock().unwrap();
+ FieldSnapshot { xyz: g.xyz.clone(), count: g.count }
+}
+
+struct FieldSnapshot {
+ xyz: Vec<f32>,
+ count: usize,
+}
+
+fn fmt_count(n: usize) -> String {
+ if n >= 1_000_000 {
+ format!("{} M", n / 1_000_000)
+ } else {
+ format!("{} K", n / 1_000)
+ }
+}
+
+fn viewport_of(_model: &Model) -> (f32, f32) {
+ let (w, h) = GpuDemo::initial_size();
+ (w as f32, h as f32)
+}
+
+fn menubar_spec<'a>(menu: &'a AppMenu, model: &'a Model) -> MenuBarSpec<'a, Msg> {
+ MenuBarSpec {
+ menu,
+ open: model.menu_open,
+ theme: &model.theme,
+ viewport: viewport_of(model),
+ height: MENU_H,
+ on_open: Arc::new(Msg::MenuOpen),
+ on_command: Arc::new(|c: &str| Msg::MenuCommand(c.to_string())),
+ }
+}
+
+fn app_menu() -> AppMenu {
+ AppMenu::new()
+ .menu(Menu::new("Archivo").item(MenuItem::new("Salir", "file.quit").shortcut("Ctrl+Q")))
+ .menu(
+ Menu::new("Ver")
+ .item(MenuItem::new("Más estrellas", "view.density"))
+ .item(MenuItem::new("Pausa / reanudar", "view.pause"))
+ .item(MenuItem::new("Cambiar tema", "view.theme").separated()),
+ )
+ .menu(Menu::new("Ayuda").item(MenuItem::new("Acerca de", "help.about")))
+}
+
+fn handle_menu_command(model: Model, cmd: &str, handle: &Handle<Msg>) -> Model {
+ match cmd {
+ "file.quit" => std::process::exit(0),
+ "view.density" => {
+ handle.dispatch(Msg::CycleDensity);
+ model
+ }
+ "view.pause" => {
+ handle.dispatch(Msg::TogglePause);
+ model
+ }
+ "view.theme" => {
+ handle.dispatch(Msg::CycleTheme);
+ model
+ }
+ _ => model,
+ }
+}
+
+fn main() {
+ llimphi_ui::run::<GpuDemo>();
+}
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<usize>),
+ /// Comando elegido en el menú principal — se traduce al `Msg` real.
+ MenuCommand(String),
+ /// Cierra cualquier menú abierto (click-fuera / Esc).
+ CloseMenus,
+ /// Cicla el tema claro/oscuro.
+ CycleTheme,
+ /// Right-click en la raíz → abre el menú contextual en `(x, y)`.
+ ContextMenuOpen(f32, f32),
+}
+
+struct Model {
+ matrix: Arc<Mutex<HeatmapMatrix>>,
+ t: u64,
+ theme: Theme,
+ menu_open: Option<usize>,
+ context_menu: Option<(f32, f32)>,
+}
+
+struct HeatmapDemo;
+
+impl App for HeatmapDemo {
+ type Model = Model;
+ type Msg = Msg;
+
+ fn title() -> &'static str {
+ "Lapaloma — heatmap 48×32 (Viridis · onda viajera)"
+ }
+ fn initial_size() -> (u32, u32) {
+ (960, 600)
+ }
+
+ fn init(handle: &Handle<Msg>) -> Model {
+ handle.spawn_periodic(TICK_PERIOD, || Msg::Tick);
+ let mut m = HeatmapMatrix::new(W, H);
+ fill(&mut m, 0);
+ Model {
+ matrix: Arc::new(Mutex::new(m)),
+ t: 0,
+ theme: Theme::dark(),
+ menu_open: None,
+ context_menu: None,
+ }
+ }
+
+ fn update(mut model: Model, msg: Msg, handle: &Handle<Msg>) -> Model {
+ match msg {
+ Msg::Tick => {
+ model.t = model.t.wrapping_add(1);
+ if let Ok(mut m) = model.matrix.lock() {
+ fill(&mut m, model.t);
+ }
+ }
+ Msg::Reset => {
+ model.t = 0;
+ if let Ok(mut m) = model.matrix.lock() {
+ fill(&mut m, 0);
+ }
+ }
+ Msg::MenuOpen(which) => {
+ model.menu_open = which;
+ model.context_menu = None;
+ }
+ Msg::MenuCommand(cmd) => {
+ model.menu_open = None;
+ return handle_menu_command(model, &cmd, handle);
+ }
+ Msg::CloseMenus => {
+ model.menu_open = None;
+ model.context_menu = None;
+ }
+ Msg::CycleTheme => {
+ model.theme = Theme::next_after(model.theme.name);
+ }
+ Msg::ContextMenuOpen(x, y) => {
+ model.menu_open = None;
+ model.context_menu = Some((x, y));
+ }
+ }
+ model
+ }
+
+ fn view(model: &Model) -> View<Msg> {
+ let theme = &model.theme;
+ let plot_bg = Color::rgba(0.06, 0.07, 0.10, 1.0);
+ let matrix = model.matrix.clone();
+
+ let menu = app_menu();
+ let menubar = menubar_view(&menubar_spec(&menu, model));
+
+ let header = View::new(Style {
+ size: Size { width: percent(1.0_f32), height: length(28.0_f32) },
+ ..Default::default()
+ })
+ .text_aligned(
+ "Lapaloma — heatmap".to_string(),
+ 18.0,
+ theme.fg_text,
+ Alignment::Start,
+ );
+
+ let stats = format!("matriz {}×{} · tick = {} · ramp = Viridis", W, H, model.t);
+ let legend = View::new(Style {
+ size: Size { width: percent(1.0_f32), height: length(20.0_f32) },
+ ..Default::default()
+ })
+ .text_aligned(stats, 11.0, theme.fg_muted, Alignment::Start);
+
+ let panel = View::new(Style {
+ size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+ flex_grow: 1.0,
+ ..Default::default()
+ })
+ .clip(true)
+ .paint_with(move |scene, ts, rect| {
+ let outer = Rect::new(rect.x, rect.y, rect.w, rect.h);
+ let mut canvas = SceneCanvas::new(scene, ts);
+ canvas.fill_rect(outer, plot_bg);
+ if let Ok(m) = matrix.lock() {
+ paint(&m, Ramp::Viridis, outer, &mut canvas);
+ }
+ });
+
+ let body = View::new(Style {
+ flex_direction: FlexDirection::Column,
+ size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+ flex_grow: 1.0,
+ padding: TaffyRect {
+ left: length(16.0_f32),
+ right: length(16.0_f32),
+ top: length(16.0_f32),
+ bottom: length(16.0_f32),
+ },
+ gap: Size { width: length(0.0_f32), height: length(10.0_f32) },
+ ..Default::default()
+ })
+ .fill(theme.bg_app)
+ .children(vec![header, legend, panel]);
+
+ View::new(Style {
+ flex_direction: FlexDirection::Column,
+ size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+ ..Default::default()
+ })
+ .fill(theme.bg_app)
+ .on_right_click_at(|x, y, _w, _h| Some(Msg::ContextMenuOpen(x, y)))
+ .children(vec![menubar, body])
+ }
+
+ fn view_overlay(model: &Model) -> Option<View<Msg>> {
+ if let Some((x, y)) = model.context_menu {
+ let items = vec![
+ ContextMenuItem::action("Reiniciar vista"),
+ ContextMenuItem::action("Cambiar tema"),
+ ];
+ let on_pick: Arc<dyn Fn(usize) -> Msg + Send + Sync> =
+ Arc::new(|i: usize| match i {
+ 0 => Msg::Reset,
+ _ => Msg::CycleTheme,
+ });
+ return Some(context_menu_view(ContextMenuSpec {
+ anchor: (x, y),
+ viewport: viewport_of(model),
+ header: Some("Heatmap".to_string()),
+ items,
+ active: usize::MAX,
+ on_pick,
+ on_dismiss: Msg::CloseMenus,
+ palette: ContextMenuPalette::from_theme(&model.theme),
+ }));
+ }
+ let menu = app_menu();
+ menubar_overlay(&menubar_spec(&menu, model))
+ }
+}
+
+fn viewport_of(_model: &Model) -> (f32, f32) {
+ let (w, h) = HeatmapDemo::initial_size();
+ (w as f32, h as f32)
+}
+
+fn menubar_spec<'a>(menu: &'a AppMenu, model: &'a Model) -> MenuBarSpec<'a, Msg> {
+ MenuBarSpec {
+ menu,
+ open: model.menu_open,
+ theme: &model.theme,
+ viewport: viewport_of(model),
+ height: MENU_H,
+ on_open: Arc::new(Msg::MenuOpen),
+ on_command: Arc::new(|c: &str| Msg::MenuCommand(c.to_string())),
+ }
+}
+
+fn app_menu() -> AppMenu {
+ AppMenu::new()
+ .menu(Menu::new("Archivo").item(MenuItem::new("Salir", "file.quit").shortcut("Ctrl+Q")))
+ .menu(
+ Menu::new("Ver")
+ .item(MenuItem::new("Reiniciar vista", "view.reset"))
+ .item(MenuItem::new("Cambiar tema", "view.theme").separated()),
+ )
+ .menu(Menu::new("Ayuda").item(MenuItem::new("Acerca de", "help.about")))
+}
+
+fn handle_menu_command(model: Model, cmd: &str, handle: &Handle<Msg>) -> Model {
+ match cmd {
+ "file.quit" => std::process::exit(0),
+ "view.reset" => {
+ handle.dispatch(Msg::Reset);
+ model
+ }
+ "view.theme" => {
+ handle.dispatch(Msg::CycleTheme);
+ model
+ }
+ // "help.about" y desconocidos: no-op (sin diálogo todavía).
+ _ => model,
+ }
+}
+
+fn fill(m: &mut HeatmapMatrix, t: u64) {
+ let phase = t as f32 * 0.1;
+ let phase2 = t as f32 * 0.07;
+ let mut data = Vec::with_capacity(W * H);
+ for y in 0..H {
+ for x in 0..W {
+ let v = (x as f32 * 0.25 - phase).sin() + (y as f32 * 0.30 + phase2).cos();
+ data.push(v);
+ }
+ }
+ m.replace_data(data);
+}
+
+fn main() {
+ llimphi_ui::run::<HeatmapDemo>();
+}
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<usize>),
+ /// Comando elegido en el menú principal — se traduce al `Msg` real.
+ MenuCommand(String),
+ /// Cierra cualquier menú abierto (click-fuera / Esc).
+ CloseMenus,
+ /// Cicla el tema claro/oscuro.
+ CycleTheme,
+ /// Right-click en la raíz → abre el menú contextual en `(x, y)`.
+ ContextMenuOpen(f32, f32),
+}
+
+struct Model {
+ grid: HexGrid,
+ theme: Theme,
+ menu_open: Option<usize>,
+ context_menu: Option<(f32, f32)>,
+}
+
+struct HexbinDemo;
+
+impl App for HexbinDemo {
+ type Model = Model;
+ type Msg = Msg;
+
+ fn title() -> &'static str {
+ "Lapaloma — hexbin (5 000 puntos gaussianos)"
+ }
+ fn initial_size() -> (u32, u32) {
+ (900, 620)
+ }
+
+ fn init(_: &Handle<Msg>) -> Model {
+ Model {
+ grid: build_grid(),
+ theme: Theme::dark(),
+ menu_open: None,
+ context_menu: None,
+ }
+ }
+
+ fn update(mut model: Model, msg: Msg, handle: &Handle<Msg>) -> Model {
+ match msg {
+ Msg::Reset => {
+ model.grid = build_grid();
+ }
+ Msg::MenuOpen(which) => {
+ model.menu_open = which;
+ model.context_menu = None;
+ }
+ Msg::MenuCommand(cmd) => {
+ model.menu_open = None;
+ return handle_menu_command(model, &cmd, handle);
+ }
+ Msg::CloseMenus => {
+ model.menu_open = None;
+ model.context_menu = None;
+ }
+ Msg::CycleTheme => {
+ model.theme = Theme::next_after(model.theme.name);
+ }
+ Msg::ContextMenuOpen(x, y) => {
+ model.menu_open = None;
+ model.context_menu = Some((x, y));
+ }
+ }
+ model
+ }
+
+ fn view(model: &Model) -> View<Msg> {
+ let theme = &model.theme;
+ let plot_bg = Color::rgba(0.06, 0.08, 0.10, 1.0);
+ let grid = &model.grid;
+ let snapshot = grid.clone();
+ let (min, max) = grid.min_max();
+
+ let menu = app_menu();
+ let menubar = menubar_view(&menubar_spec(&menu, model));
+
+ let header = View::new(Style {
+ size: Size { width: percent(1.0_f32), height: length(28.0_f32) },
+ ..Default::default()
+ })
+ .text_aligned(
+ "Lapaloma — hexbin".to_string(),
+ 18.0,
+ theme.fg_text,
+ Alignment::Start,
+ );
+
+ let legend = View::new(Style {
+ size: Size { width: percent(1.0_f32), height: length(20.0_f32) },
+ ..Default::default()
+ })
+ .text_aligned(
+ format!(
+ "{} pts · radio {} px · {} bines · count ∈ [{}, {}] · Viridis",
+ N_POINTS,
+ HEX_RADIUS as i32,
+ grid.cells().count(),
+ min,
+ max,
+ ),
+ 11.0,
+ theme.fg_muted,
+ Alignment::Start,
+ );
+
+ let panel = View::new(Style {
+ size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+ flex_grow: 1.0,
+ ..Default::default()
+ })
+ .clip(true)
+ .paint_with(move |scene, ts, rect| {
+ let outer = Rect::new(rect.x, rect.y, rect.w, rect.h);
+ let mut canvas = SceneCanvas::new(scene, ts);
+ canvas.fill_rect(outer, plot_bg);
+ paint_hexbin(&snapshot, Ramp::Viridis, (outer.x, outer.y), &mut canvas);
+ });
+
+ let body = View::new(Style {
+ flex_direction: FlexDirection::Column,
+ size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+ flex_grow: 1.0,
+ padding: TaffyRect {
+ left: length(16.0_f32),
+ right: length(16.0_f32),
+ top: length(16.0_f32),
+ bottom: length(16.0_f32),
+ },
+ gap: Size { width: length(0.0_f32), height: length(10.0_f32) },
+ ..Default::default()
+ })
+ .fill(theme.bg_app)
+ .children(vec![header, legend, panel]);
+
+ View::new(Style {
+ flex_direction: FlexDirection::Column,
+ size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+ ..Default::default()
+ })
+ .fill(theme.bg_app)
+ .on_right_click_at(|x, y, _w, _h| Some(Msg::ContextMenuOpen(x, y)))
+ .children(vec![menubar, body])
+ }
+
+ fn view_overlay(model: &Model) -> Option<View<Msg>> {
+ if let Some((x, y)) = model.context_menu {
+ let items = vec![
+ ContextMenuItem::action("Recalcular"),
+ ContextMenuItem::action("Cambiar tema"),
+ ];
+ let on_pick: Arc<dyn Fn(usize) -> Msg + Send + Sync> =
+ Arc::new(|i: usize| match i {
+ 0 => Msg::Reset,
+ _ => Msg::CycleTheme,
+ });
+ return Some(context_menu_view(ContextMenuSpec {
+ anchor: (x, y),
+ viewport: viewport_of(model),
+ header: Some("Hexbin".to_string()),
+ items,
+ active: usize::MAX,
+ on_pick,
+ on_dismiss: Msg::CloseMenus,
+ palette: ContextMenuPalette::from_theme(&model.theme),
+ }));
+ }
+ let menu = app_menu();
+ menubar_overlay(&menubar_spec(&menu, model))
+ }
+}
+
+fn viewport_of(_model: &Model) -> (f32, f32) {
+ let (w, h) = HexbinDemo::initial_size();
+ (w as f32, h as f32)
+}
+
+fn menubar_spec<'a>(menu: &'a AppMenu, model: &'a Model) -> MenuBarSpec<'a, Msg> {
+ MenuBarSpec {
+ menu,
+ open: model.menu_open,
+ theme: &model.theme,
+ viewport: viewport_of(model),
+ height: MENU_H,
+ on_open: Arc::new(Msg::MenuOpen),
+ on_command: Arc::new(|c: &str| Msg::MenuCommand(c.to_string())),
+ }
+}
+
+fn app_menu() -> AppMenu {
+ AppMenu::new()
+ .menu(Menu::new("Archivo").item(MenuItem::new("Salir", "file.quit").shortcut("Ctrl+Q")))
+ .menu(
+ Menu::new("Ver")
+ .item(MenuItem::new("Recalcular", "view.reset"))
+ .item(MenuItem::new("Cambiar tema", "view.theme").separated()),
+ )
+ .menu(Menu::new("Ayuda").item(MenuItem::new("Acerca de", "help.about")))
+}
+
+fn handle_menu_command(model: Model, cmd: &str, handle: &Handle<Msg>) -> Model {
+ match cmd {
+ "file.quit" => std::process::exit(0),
+ "view.reset" => {
+ handle.dispatch(Msg::Reset);
+ model
+ }
+ "view.theme" => {
+ handle.dispatch(Msg::CycleTheme);
+ model
+ }
+ // "help.about" y desconocidos: no-op (sin diálogo todavía).
+ _ => model,
+ }
+}
+
+/// Construye el HexGrid con los 5 000 puntos gaussianos deterministas.
+fn build_grid() -> HexGrid {
+ let mut g = HexGrid::new(HEX_RADIUS);
+ // LCG determinista — no agrega dep para randomness.
+ let mut state: u64 = 0xC0FFEE;
+ let mut rng = || -> f32 {
+ state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
+ ((state >> 32) as f32) / (u32::MAX as f32)
+ };
+ let mut gauss = || -> (f32, f32) {
+ // Box-Muller.
+ let u1 = (rng()).max(1e-9);
+ let u2 = rng();
+ let r = (-2.0 * u1.ln()).sqrt();
+ let theta = 2.0 * std::f32::consts::PI * u2;
+ (r * theta.cos(), r * theta.sin())
+ };
+ // Cluster A: centro (300, 300), sigma 50. Cluster B: (520, 380), sigma 80.
+ for i in 0..N_POINTS {
+ let (g0, g1) = gauss();
+ if i % 3 == 0 {
+ g.push(300.0 + g0 * 50.0, 300.0 + g1 * 50.0);
+ } else {
+ g.push(520.0 + g0 * 80.0, 380.0 + g1 * 80.0);
+ }
+ }
+ g
+}
+
+fn main() {
+ llimphi_ui::run::<HexbinDemo>();
+}
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<usize>),
+ /// Comando elegido en el menú principal — se traduce al `Msg` real.
+ MenuCommand(String),
+ /// Cierra cualquier menú abierto (click-fuera / Esc).
+ CloseMenus,
+ /// Cicla el tema claro/oscuro.
+ CycleTheme,
+ /// Right-click en la raíz → abre el menú contextual en `(x, y)`.
+ ContextMenuOpen(f32, f32),
+}
+
+struct Graph {
+ nodes: NodeBuffer,
+ edges: EdgeBuffer,
+ sim: ForceLayout,
+}
+
+impl Graph {
+ fn new() -> Self {
+ let mut nodes = NodeBuffer::new();
+ // Anillo principal centrado en el origen, radios chicos para que
+ // la fuerza repulsiva los separe.
+ for i in 0..N_RING {
+ let a = (i as f32 / N_RING as f32) * std::f32::consts::TAU;
+ nodes.push(20.0 * a.cos(), 20.0 * a.sin(), 6.0);
+ }
+ // Satélites: cada uno colgado de un nodo del anillo, levemente
+ // desplazado.
+ for i in 0..N_SAT {
+ let a = (i as f32 / N_SAT as f32) * std::f32::consts::TAU + 0.13;
+ nodes.push(60.0 * a.cos(), 60.0 * a.sin(), 4.5);
+ }
+ let mut edges = EdgeBuffer::new();
+ // Anillo.
+ for i in 0..N_RING {
+ edges.push(i, (i + 1) % N_RING);
+ }
+ // Cordales (i ↔ i+3).
+ for i in 0..N_RING {
+ edges.push(i, (i + 3) % N_RING);
+ }
+ // Satélites enganchados a su nodo de anillo correspondiente.
+ for i in 0..N_SAT {
+ edges.push(i, N_RING + i);
+ }
+ let sim = ForceLayout::new(ForceParams { k: 38.0, temperature: 60.0, cooling: 0.985 });
+ Self { nodes, edges, sim }
+ }
+}
+
+struct Model {
+ graph: Arc<Mutex<Graph>>,
+ steps: u64,
+ theme: Theme,
+ menu_open: Option<usize>,
+ context_menu: Option<(f32, f32)>,
+}
+
+struct MeshDemo;
+
+impl App for MeshDemo {
+ type Model = Model;
+ type Msg = Msg;
+
+ fn title() -> &'static str {
+ "Lapaloma — mesh (force-directed, 24 nodos)"
+ }
+ fn initial_size() -> (u32, u32) {
+ (900, 700)
+ }
+
+ fn init(handle: &Handle<Msg>) -> Model {
+ handle.spawn_periodic(TICK_PERIOD, || Msg::Step);
+ Model {
+ graph: Arc::new(Mutex::new(Graph::new())),
+ steps: 0,
+ theme: Theme::dark(),
+ menu_open: None,
+ context_menu: None,
+ }
+ }
+
+ fn update(mut model: Model, msg: Msg, handle: &Handle<Msg>) -> Model {
+ match msg {
+ Msg::Step => {
+ if let Ok(mut g) = model.graph.lock() {
+ // Split-borrow legítimo de campos distintos del struct.
+ let Graph { nodes, edges, sim } = &mut *g;
+ let _ = sim.step(nodes, edges);
+ }
+ model.steps = model.steps.wrapping_add(1);
+ }
+ Msg::Reset => {
+ if let Ok(mut g) = model.graph.lock() {
+ *g = Graph::new();
+ }
+ model.steps = 0;
+ }
+ Msg::MenuOpen(which) => {
+ model.menu_open = which;
+ model.context_menu = None;
+ }
+ Msg::MenuCommand(cmd) => {
+ model.menu_open = None;
+ return handle_menu_command(model, &cmd, handle);
+ }
+ Msg::CloseMenus => {
+ model.menu_open = None;
+ model.context_menu = None;
+ }
+ Msg::CycleTheme => {
+ model.theme = Theme::next_after(model.theme.name);
+ }
+ Msg::ContextMenuOpen(x, y) => {
+ model.menu_open = None;
+ model.context_menu = Some((x, y));
+ }
+ }
+ model
+ }
+
+ fn view(model: &Model) -> View<Msg> {
+ let theme = &model.theme;
+ let plot_bg = Color::rgba(0.06, 0.08, 0.10, 1.0);
+ let graph = model.graph.clone();
+
+ let menu = app_menu();
+ let menubar = menubar_view(&menubar_spec(&menu, model));
+
+ let header = View::new(Style {
+ size: Size { width: percent(1.0_f32), height: length(28.0_f32) },
+ ..Default::default()
+ })
+ .text_aligned(
+ "Lapaloma — mesh".to_string(),
+ 18.0,
+ theme.fg_text,
+ Alignment::Start,
+ );
+
+ let temp = model
+ .graph
+ .lock()
+ .map(|g| g.sim.temperature())
+ .unwrap_or(0.0);
+ let legend = View::new(Style {
+ size: Size { width: percent(1.0_f32), height: length(20.0_f32) },
+ ..Default::default()
+ })
+ .text_aligned(
+ format!(
+ "24 nodos · 24 aristas · pasos = {} · T = {:.2} · click = reset",
+ model.steps, temp,
+ ),
+ 11.0,
+ theme.fg_muted,
+ Alignment::Start,
+ );
+
+ let panel = View::new(Style {
+ size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+ flex_grow: 1.0,
+ ..Default::default()
+ })
+ .clip(true)
+ .on_click(Msg::Reset)
+ .paint_with(move |scene, ts, rect| {
+ let outer = Rect::new(rect.x, rect.y, rect.w, rect.h);
+ let mut canvas = SceneCanvas::new(scene, ts);
+ canvas.fill_rect(outer, plot_bg);
+
+ if let Ok(g) = graph.lock() {
+ paint_graph(&mut canvas, &g, outer);
+ }
+ });
+
+ let body = View::new(Style {
+ flex_direction: FlexDirection::Column,
+ size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+ flex_grow: 1.0,
+ padding: TaffyRect {
+ left: length(16.0_f32),
+ right: length(16.0_f32),
+ top: length(16.0_f32),
+ bottom: length(16.0_f32),
+ },
+ gap: Size { width: length(0.0_f32), height: length(10.0_f32) },
+ ..Default::default()
+ })
+ .fill(theme.bg_app)
+ .children(vec![header, legend, panel]);
+
+ View::new(Style {
+ flex_direction: FlexDirection::Column,
+ size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+ ..Default::default()
+ })
+ .fill(theme.bg_app)
+ .on_right_click_at(|x, y, _w, _h| Some(Msg::ContextMenuOpen(x, y)))
+ .children(vec![menubar, body])
+ }
+
+ fn view_overlay(model: &Model) -> Option<View<Msg>> {
+ if let Some((x, y)) = model.context_menu {
+ let items = vec![
+ ContextMenuItem::action("Reiniciar (recalentar)"),
+ ContextMenuItem::action("Cambiar tema"),
+ ];
+ let on_pick: Arc<dyn Fn(usize) -> Msg + Send + Sync> =
+ Arc::new(|i: usize| match i {
+ 0 => Msg::Reset,
+ _ => Msg::CycleTheme,
+ });
+ return Some(context_menu_view(ContextMenuSpec {
+ anchor: (x, y),
+ viewport: viewport_of(model),
+ header: Some("Mesh".to_string()),
+ items,
+ active: usize::MAX,
+ on_pick,
+ on_dismiss: Msg::CloseMenus,
+ palette: ContextMenuPalette::from_theme(&model.theme),
+ }));
+ }
+ let menu = app_menu();
+ menubar_overlay(&menubar_spec(&menu, model))
+ }
+}
+
+fn viewport_of(_model: &Model) -> (f32, f32) {
+ let (w, h) = MeshDemo::initial_size();
+ (w as f32, h as f32)
+}
+
+fn menubar_spec<'a>(menu: &'a AppMenu, model: &'a Model) -> MenuBarSpec<'a, Msg> {
+ MenuBarSpec {
+ menu,
+ open: model.menu_open,
+ theme: &model.theme,
+ viewport: viewport_of(model),
+ height: MENU_H,
+ on_open: Arc::new(Msg::MenuOpen),
+ on_command: Arc::new(|c: &str| Msg::MenuCommand(c.to_string())),
+ }
+}
+
+fn app_menu() -> AppMenu {
+ AppMenu::new()
+ .menu(Menu::new("Archivo").item(MenuItem::new("Salir", "file.quit").shortcut("Ctrl+Q")))
+ .menu(
+ Menu::new("Ver")
+ .item(MenuItem::new("Reiniciar (recalentar)", "view.reset"))
+ .item(MenuItem::new("Cambiar tema", "view.theme").separated()),
+ )
+ .menu(Menu::new("Ayuda").item(MenuItem::new("Acerca de", "help.about")))
+}
+
+fn handle_menu_command(model: Model, cmd: &str, handle: &Handle<Msg>) -> Model {
+ match cmd {
+ "file.quit" => std::process::exit(0),
+ "view.reset" => {
+ handle.dispatch(Msg::Reset);
+ model
+ }
+ "view.theme" => {
+ handle.dispatch(Msg::CycleTheme);
+ model
+ }
+ // "help.about" y desconocidos: no-op (sin diálogo todavía).
+ _ => model,
+ }
+}
+
+fn paint_graph(canvas: &mut SceneCanvas<'_>, g: &Graph, area: Rect) {
+ let n = g.nodes.len();
+ if n == 0 {
+ return;
+ }
+ let cx = area.x + area.w * 0.5;
+ let cy = area.y + area.h * 0.5;
+
+ // Aristas en gris.
+ let edge_stroke = StrokeStyle::new(1.0, Color::rgba(0.6, 0.65, 0.7, 0.45));
+ for (u, v) in g.edges.iter() {
+ let (xu, yu) = g.nodes.pos(u);
+ let (xv, yv) = g.nodes.pos(v);
+ canvas.stroke_line(
+ Point::new(cx + xu, cy + yu),
+ Point::new(cx + xv, cy + yv),
+ edge_stroke,
+ );
+ }
+ // Nodos como rectángulos rellenos (quad chico aproxima un disco para
+ // el `Canvas` mínimo). Anillo + satélites con colores distintos.
+ for i in 0..n {
+ let (x, y) = g.nodes.pos(i);
+ let r = g.nodes.radius(i);
+ let color = if i < N_RING {
+ Color::from_hex(0x88c0d0)
+ } else {
+ Color::from_hex(0xa3be8c)
+ };
+ let rect = Rect::new(cx + x - r, cy + y - r, r * 2.0, r * 2.0);
+ canvas.fill_rect(rect, color);
+ }
+}
+
+fn main() {
+ llimphi_ui::run::<MeshDemo>();
+}
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<usize>),
+ /// Comando elegido en el menú principal — se traduce al `Msg` real.
+ MenuCommand(String),
+ /// Cierra cualquier menú abierto (click-fuera / Esc).
+ CloseMenus,
+ /// Cicla el tema claro/oscuro.
+ CycleTheme,
+ /// Right-click en la raíz → abre el menú contextual en `(x, y)`.
+ ContextMenuOpen(f32, f32),
+}
+
+struct Model {
+ buffer: RingBuffer,
+ t: u64,
+ theme: Theme,
+ menu_open: Option<usize>,
+ context_menu: Option<(f32, f32)>,
+}
+
+struct PhosphorDemo;
+
+impl App for PhosphorDemo {
+ type Model = Model;
+ type Msg = Msg;
+
+ fn title() -> &'static str {
+ "Lapaloma — phosphor trail (CRT 60 Hz)"
+ }
+ fn initial_size() -> (u32, u32) {
+ (900, 480)
+ }
+
+ fn init(handle: &Handle<Msg>) -> Model {
+ handle.spawn_periodic(SAMPLE_PERIOD, || Msg::Tick);
+ Model {
+ buffer: RingBuffer::new(RING_CAPACITY),
+ t: 0,
+ theme: Theme::dark(),
+ menu_open: None,
+ context_menu: None,
+ }
+ }
+
+ fn update(mut model: Model, msg: Msg, handle: &Handle<Msg>) -> Model {
+ match msg {
+ Msg::Tick => {
+ let v = synthesize(model.t);
+ model.buffer.push(v);
+ model.t = model.t.wrapping_add(1);
+ }
+ Msg::Reset => {
+ model.buffer = RingBuffer::new(RING_CAPACITY);
+ model.t = 0;
+ }
+ Msg::MenuOpen(which) => {
+ model.menu_open = which;
+ model.context_menu = None;
+ }
+ Msg::MenuCommand(cmd) => {
+ model.menu_open = None;
+ return handle_menu_command(model, &cmd, handle);
+ }
+ Msg::CloseMenus => {
+ model.menu_open = None;
+ model.context_menu = None;
+ }
+ Msg::CycleTheme => {
+ model.theme = Theme::next_after(model.theme.name);
+ }
+ Msg::ContextMenuOpen(x, y) => {
+ model.menu_open = None;
+ model.context_menu = Some((x, y));
+ }
+ }
+ model
+ }
+
+ fn view(model: &Model) -> View<Msg> {
+ let theme = &model.theme;
+ let plot_bg = Color::rgba(0.03, 0.05, 0.04, 1.0);
+ let trace = StrokeStyle::new(1.6, Color::rgb(0.608, 1.0, 0.549));
+
+ let menu = app_menu();
+ let menubar = menubar_view(&menubar_spec(&menu, model));
+
+ let header = View::new(Style {
+ size: Size { width: percent(1.0_f32), height: length(28.0_f32) },
+ ..Default::default()
+ })
+ .text_aligned("Lapaloma — phosphor".to_string(), 18.0, theme.fg_text, Alignment::Start);
+
+ let stats = format!(
+ "cap = {} head = {} trail = 24 segs glow = 4× / α 0.18 t = {}",
+ RING_CAPACITY,
+ model.buffer.head(),
+ model.t,
+ );
+ let stats_row = View::new(Style {
+ size: Size { width: percent(1.0_f32), height: length(20.0_f32) },
+ ..Default::default()
+ })
+ .text_aligned(stats, 11.0, theme.fg_muted, Alignment::Start);
+
+ let phosphor = pineal_phosphor_view(model.buffer.clone(), trace)
+ .background(plot_bg)
+ .y_range(-1.2, 1.2)
+ .trail_segments(24)
+ .glow(4.0, 0.18)
+ .view::<Msg>();
+
+ let plot_panel = View::new(Style {
+ size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+ flex_grow: 1.0,
+ ..Default::default()
+ })
+ .clip(true)
+ .children(vec![phosphor]);
+
+ let body = View::new(Style {
+ flex_direction: FlexDirection::Column,
+ size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+ flex_grow: 1.0,
+ padding: Rect {
+ left: length(16.0_f32),
+ right: length(16.0_f32),
+ top: length(16.0_f32),
+ bottom: length(16.0_f32),
+ },
+ gap: Size { width: length(0.0_f32), height: length(10.0_f32) },
+ ..Default::default()
+ })
+ .fill(theme.bg_app)
+ .children(vec![header, stats_row, plot_panel]);
+
+ View::new(Style {
+ flex_direction: FlexDirection::Column,
+ size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+ ..Default::default()
+ })
+ .fill(theme.bg_app)
+ .on_right_click_at(|x, y, _w, _h| Some(Msg::ContextMenuOpen(x, y)))
+ .children(vec![menubar, body])
+ }
+
+ fn view_overlay(model: &Model) -> Option<View<Msg>> {
+ if let Some((x, y)) = model.context_menu {
+ let items = vec![
+ ContextMenuItem::action("Limpiar trail"),
+ ContextMenuItem::action("Cambiar tema"),
+ ];
+ let on_pick: Arc<dyn Fn(usize) -> Msg + Send + Sync> =
+ Arc::new(|i: usize| match i {
+ 0 => Msg::Reset,
+ _ => Msg::CycleTheme,
+ });
+ return Some(context_menu_view(ContextMenuSpec {
+ anchor: (x, y),
+ viewport: viewport_of(model),
+ header: Some("Phosphor".to_string()),
+ items,
+ active: usize::MAX,
+ on_pick,
+ on_dismiss: Msg::CloseMenus,
+ palette: ContextMenuPalette::from_theme(&model.theme),
+ }));
+ }
+ let menu = app_menu();
+ menubar_overlay(&menubar_spec(&menu, model))
+ }
+}
+
+fn viewport_of(_model: &Model) -> (f32, f32) {
+ let (w, h) = PhosphorDemo::initial_size();
+ (w as f32, h as f32)
+}
+
+fn menubar_spec<'a>(menu: &'a AppMenu, model: &'a Model) -> MenuBarSpec<'a, Msg> {
+ MenuBarSpec {
+ menu,
+ open: model.menu_open,
+ theme: &model.theme,
+ viewport: viewport_of(model),
+ height: MENU_H,
+ on_open: Arc::new(Msg::MenuOpen),
+ on_command: Arc::new(|c: &str| Msg::MenuCommand(c.to_string())),
+ }
+}
+
+fn app_menu() -> AppMenu {
+ AppMenu::new()
+ .menu(Menu::new("Archivo").item(MenuItem::new("Salir", "file.quit").shortcut("Ctrl+Q")))
+ .menu(
+ Menu::new("Ver")
+ .item(MenuItem::new("Limpiar trail", "view.reset"))
+ .item(MenuItem::new("Cambiar tema", "view.theme").separated()),
+ )
+ .menu(Menu::new("Ayuda").item(MenuItem::new("Acerca de", "help.about")))
+}
+
+fn handle_menu_command(model: Model, cmd: &str, handle: &Handle<Msg>) -> Model {
+ match cmd {
+ "file.quit" => std::process::exit(0),
+ "view.reset" => {
+ handle.dispatch(Msg::Reset);
+ model
+ }
+ "view.theme" => {
+ handle.dispatch(Msg::CycleTheme);
+ model
+ }
+ // "help.about" y desconocidos: no-op (sin diálogo todavía).
+ _ => model,
+ }
+}
+
+fn synthesize(t: u64) -> f32 {
+ let phase = t as f32;
+ let signal = (phase * 0.07).sin() * 0.75 + (phase * 0.19).sin() * 0.22;
+ let jitter = ((phase * 37.0).sin() * 1000.0).fract() * 0.04;
+ signal + jitter
+}
+
+fn main() {
+ llimphi_ui::run::<PhosphorDemo>();
+}
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<usize>),
+ /// Comando elegido en la barra o en el contextual.
+ MenuCommand(String),
+ /// Cierra cualquier menú abierto (click-fuera / Esc).
+ CloseMenus,
+ /// Right-click sobre el plot → abre el contextual en `(x, y)` de ventana.
+ ContextMenuOpen(f32, f32),
+ /// Cicla el preset de tema.
+ CycleTheme,
+}
+
+struct Model {
+ theme: Theme,
+ menu_open: Option<usize>,
+ context_menu: Option<(f32, f32)>,
+}
+
+struct PolarDemo;
+
+impl App for PolarDemo {
+ type Model = Model;
+ type Msg = Msg;
+
+ fn title() -> &'static str {
+ "Lapaloma — polar (pie · donut · radar)"
+ }
+ fn initial_size() -> (u32, u32) {
+ (1000, 520)
+ }
+
+ fn init(_: &Handle<Msg>) -> Model {
+ Model { theme: Theme::dark(), menu_open: None, context_menu: None }
+ }
+
+ fn update(mut model: Model, msg: Msg, handle: &Handle<Msg>) -> Model {
+ match msg {
+ Msg::MenuOpen(which) => {
+ model.menu_open = which;
+ model.context_menu = None;
+ }
+ Msg::CloseMenus => {
+ model.menu_open = None;
+ model.context_menu = None;
+ }
+ Msg::ContextMenuOpen(x, y) => {
+ model.menu_open = None;
+ model.context_menu = Some((x, y));
+ }
+ Msg::CycleTheme => {
+ model.theme = Theme::next_after(model.theme.name);
+ }
+ Msg::MenuCommand(cmd) => {
+ model.menu_open = None;
+ model.context_menu = None;
+ handle_menu_command(&cmd, handle);
+ }
+ }
+ model
+ }
+
+ fn view(model: &Model) -> View<Msg> {
+ let theme = &model.theme;
+ let plot_bg = Color::rgba(0.10, 0.12, 0.16, 1.0);
+
+ let menu = app_menu();
+ let menubar = menubar_view(&menubar_spec(&menu, model));
+
+ let header = View::new(Style {
+ size: Size { width: percent(1.0_f32), height: length(28.0_f32) },
+ ..Default::default()
+ })
+ .text_aligned(
+ "Lapaloma — polar".to_string(),
+ 18.0,
+ theme.fg_text,
+ Alignment::Start,
+ );
+
+ let legend = View::new(Style {
+ size: Size { width: percent(1.0_f32), height: length(20.0_f32) },
+ ..Default::default()
+ })
+ .text_aligned(
+ "izq: donut (6 porciones) · der: radar (6 ejes, max=10)".to_string(),
+ 11.0,
+ theme.fg_muted,
+ Alignment::Start,
+ );
+
+ let pie_panel = View::new(Style {
+ size: Size { width: percent(0.5_f32), height: percent(1.0_f32) },
+ flex_grow: 1.0,
+ ..Default::default()
+ })
+ .clip(true)
+ .paint_with(move |scene, ts, rect| {
+ let outer = Rect::new(rect.x, rect.y, rect.w, rect.h);
+ let mut canvas = SceneCanvas::new(scene, ts);
+ canvas.fill_rect(outer, plot_bg);
+
+ let cx = outer.x + outer.w * 0.5;
+ let cy = outer.y + outer.h * 0.5;
+ let r_out = (outer.w.min(outer.h) * 0.42).max(20.0);
+ let r_in = r_out * 0.45;
+
+ let slices = [
+ Slice::new(28.0, Color::from_hex(0x88c0d0)),
+ Slice::new(18.0, Color::from_hex(0xd08770)),
+ Slice::new(14.0, Color::from_hex(0xa3be8c)),
+ Slice::new(12.0, Color::from_hex(0xebcb8b)),
+ Slice::new(10.0, Color::from_hex(0xb48ead)),
+ Slice::new(8.0, Color::from_hex(0x5e81ac)),
+ ];
+ paint_pie(&slices, Point::new(cx, cy), r_out, r_in, &mut canvas);
+ });
+
+ let radar_panel = View::new(Style {
+ size: Size { width: percent(0.5_f32), height: percent(1.0_f32) },
+ flex_grow: 1.0,
+ ..Default::default()
+ })
+ .clip(true)
+ .paint_with(move |scene, ts, rect| {
+ let outer = Rect::new(rect.x, rect.y, rect.w, rect.h);
+ let mut canvas = SceneCanvas::new(scene, ts);
+ canvas.fill_rect(outer, plot_bg);
+
+ let cx = outer.x + outer.w * 0.5;
+ let cy = outer.y + outer.h * 0.5;
+ let r = (outer.w.min(outer.h) * 0.42).max(20.0);
+
+ // Ejes guía: 4 círculos concéntricos cada 25% del radio.
+ for step in 1..=4 {
+ let t = step as f32 / 4.0;
+ let ring: Vec<f32> = (0..=72)
+ .flat_map(|i| {
+ let a = (i as f32 / 72.0) * std::f32::consts::TAU
+ - std::f32::consts::FRAC_PI_2;
+ [cx + (r * t) * a.cos(), cy + (r * t) * a.sin()]
+ })
+ .collect();
+ canvas.stroke_polyline(
+ &ring,
+ StrokeStyle::new(0.6, Color::rgba(0.55, 0.6, 0.7, 0.35)),
+ );
+ }
+
+ let values = [8.0_f32, 6.5, 9.0, 4.0, 7.0, 5.5];
+ paint_radar(
+ &values,
+ 10.0,
+ Point::new(cx, cy),
+ r,
+ Color::rgba(0.639, 0.745, 0.549, 0.35),
+ StrokeStyle::new(1.6, Color::from_hex(0xa3be8c)),
+ &mut canvas,
+ );
+ });
+
+ let row = View::new(Style {
+ flex_direction: FlexDirection::Row,
+ size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+ flex_grow: 1.0,
+ gap: Size { width: length(12.0_f32), height: length(0.0_f32) },
+ ..Default::default()
+ })
+ .children(vec![pie_panel, radar_panel]);
+
+ let body = View::new(Style {
+ flex_direction: FlexDirection::Column,
+ size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+ flex_grow: 1.0,
+ padding: TaffyRect {
+ left: length(16.0_f32),
+ right: length(16.0_f32),
+ top: length(16.0_f32),
+ bottom: length(16.0_f32),
+ },
+ gap: Size { width: length(0.0_f32), height: length(10.0_f32) },
+ ..Default::default()
+ })
+ .children(vec![header, legend, row]);
+
+ // Right-click en cualquier punto de la raíz abre el contextual;
+ // origen (0,0) ⇒ coords locales == coords de ventana.
+ View::new(Style {
+ flex_direction: FlexDirection::Column,
+ size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+ ..Default::default()
+ })
+ .fill(theme.bg_app)
+ .on_right_click_at(|x, y, _, _| Some(Msg::ContextMenuOpen(x, y)))
+ .children(vec![menubar, body])
+ }
+
+ fn view_overlay(model: &Model) -> Option<View<Msg>> {
+ if let Some((x, y)) = model.context_menu {
+ return Some(context_menu_for_plot(model, x, y));
+ }
+ let menu = app_menu();
+ menubar_overlay(&menubar_spec(&menu, model))
+ }
+}
+
+// =====================================================================
+// Menú principal + contextual del plot
+// =====================================================================
+
+fn viewport_of(_model: &Model) -> (f32, f32) {
+ let (w, h) = PolarDemo::initial_size();
+ (w as f32, h as f32)
+}
+
+fn menubar_spec<'a>(menu: &'a AppMenu, model: &'a Model) -> MenuBarSpec<'a, Msg> {
+ MenuBarSpec {
+ menu,
+ open: model.menu_open,
+ theme: &model.theme,
+ viewport: viewport_of(model),
+ height: MENU_H,
+ on_open: Arc::new(Msg::MenuOpen),
+ on_command: Arc::new(|c: &str| Msg::MenuCommand(c.to_string())),
+ }
+}
+
+fn app_menu() -> AppMenu {
+ AppMenu::new()
+ .menu(Menu::new("Archivo").item(MenuItem::new("Salir", "file.quit").shortcut("Esc")))
+ .menu(Menu::new("Ver").item(MenuItem::new("Cambiar tema", "view.theme")))
+ .menu(Menu::new("Ayuda").item(MenuItem::new("Acerca de", "help.about")))
+}
+
+fn handle_menu_command(cmd: &str, handle: &Handle<Msg>) {
+ match cmd {
+ "file.quit" => std::process::exit(0),
+ "view.theme" => handle.dispatch(Msg::CycleTheme),
+ _ => {}
+ }
+}
+
+/// Menú contextual del plot. Canvas estáticos sin objetos
+/// seleccionables ni edición — sólo ofrece cambiar tema.
+fn context_menu_for_plot(model: &Model, x: f32, y: f32) -> View<Msg> {
+ let items = vec![ContextMenuItem::action("Cambiar tema")];
+ let cmds: Vec<&'static str> = vec!["view.theme"];
+ let on_pick: Arc<dyn Fn(usize) -> Msg + Send + Sync> = Arc::new(move |i: usize| {
+ Msg::MenuCommand(cmds.get(i).copied().unwrap_or("").to_string())
+ });
+
+ context_menu_view(ContextMenuSpec {
+ anchor: (x, y),
+ viewport: viewport_of(model),
+ header: Some("polar".to_string()),
+ items,
+ active: usize::MAX,
+ on_pick,
+ on_dismiss: Msg::CloseMenus,
+ palette: ContextMenuPalette::from_theme(&model.theme),
+ })
+}
+
+fn main() {
+ llimphi_ui::run::<PolarDemo>();
+}
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<usize>),
+ /// Comando elegido en la barra o en el contextual.
+ MenuCommand(String),
+ /// Cierra cualquier menú abierto (click-fuera / Esc).
+ CloseMenus,
+ /// Right-click sobre el plot → abre el contextual en `(x, y)` de ventana.
+ ContextMenuOpen(f32, f32),
+ /// Cicla el preset de tema.
+ CycleTheme,
+}
+
+struct Model {
+ buffer: RingBuffer,
+ /// Tick count global. Sirve de fase para la señal sintética y se
+ /// muestra en el header para verificar que el timer corre.
+ t: u64,
+ theme: Theme,
+ menu_open: Option<usize>,
+ context_menu: Option<(f32, f32)>,
+}
+
+struct StreamDemo;
+
+impl App for StreamDemo {
+ type Model = Model;
+ type Msg = Msg;
+
+ fn title() -> &'static str {
+ "Lapaloma — stream (osciloscopio sintético 60 Hz)"
+ }
+
+ fn initial_size() -> (u32, u32) {
+ (900, 480)
+ }
+
+ fn init(handle: &Handle<Msg>) -> Model {
+ handle.spawn_periodic(SAMPLE_PERIOD, || Msg::Tick);
+ Model {
+ buffer: RingBuffer::new(RING_CAPACITY),
+ t: 0,
+ theme: Theme::dark(),
+ menu_open: None,
+ context_menu: None,
+ }
+ }
+
+ fn update(mut model: Model, msg: Msg, handle: &Handle<Msg>) -> Model {
+ match msg {
+ Msg::Tick => {
+ let v = synthesize(model.t);
+ model.buffer.push(v);
+ model.t = model.t.wrapping_add(1);
+ }
+ Msg::Reset => {
+ model.buffer = RingBuffer::new(RING_CAPACITY);
+ model.t = 0;
+ }
+ Msg::MenuOpen(which) => {
+ model.menu_open = which;
+ model.context_menu = None;
+ }
+ Msg::CloseMenus => {
+ model.menu_open = None;
+ model.context_menu = None;
+ }
+ Msg::ContextMenuOpen(x, y) => {
+ model.menu_open = None;
+ model.context_menu = Some((x, y));
+ }
+ Msg::CycleTheme => {
+ model.theme = Theme::next_after(model.theme.name);
+ }
+ Msg::MenuCommand(cmd) => {
+ model.menu_open = None;
+ model.context_menu = None;
+ handle_menu_command(&cmd, handle);
+ }
+ }
+ model
+ }
+
+ fn view(model: &Model) -> View<Msg> {
+ let theme = &model.theme;
+ let plot_bg = Color::rgba(0.08, 0.10, 0.13, 1.0);
+ let stroke = StrokeStyle::new(1.8, Color::rgb(0.639, 0.745, 0.549));
+
+ let menu = app_menu();
+ let menubar = menubar_view(&menubar_spec(&menu, model));
+
+ let header = View::new(Style {
+ size: Size { width: percent(1.0_f32), height: length(28.0_f32) },
+ padding: Rect {
+ left: length(2.0_f32),
+ right: length(2.0_f32),
+ top: length(0.0_f32),
+ bottom: length(0.0_f32),
+ },
+ ..Default::default()
+ })
+ .text_aligned("Lapaloma — stream".to_string(), 18.0, theme.fg_text, Alignment::Start);
+
+ let fill_pct = (model.buffer.filled_len() * 100) / RING_CAPACITY;
+ let stats = format!(
+ "cap = {} head = {} filled = {}% t = {} rev = {}",
+ RING_CAPACITY,
+ model.buffer.head(),
+ fill_pct,
+ model.t,
+ model.buffer.revision(),
+ );
+ let stats_row = View::new(Style {
+ size: Size { width: percent(1.0_f32), height: length(20.0_f32) },
+ ..Default::default()
+ })
+ .text_aligned(stats, 11.0, theme.fg_muted, Alignment::Start);
+
+ let stream = pineal_stream_view(model.buffer.clone(), stroke)
+ .background(plot_bg)
+ .y_range(-1.2, 1.2)
+ .view::<Msg>();
+
+ let plot_panel = View::new(Style {
+ size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+ flex_grow: 1.0,
+ ..Default::default()
+ })
+ .clip(true)
+ .children(vec![stream]);
+
+ let body = View::new(Style {
+ flex_direction: FlexDirection::Column,
+ size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+ flex_grow: 1.0,
+ padding: Rect {
+ left: length(16.0_f32),
+ right: length(16.0_f32),
+ top: length(16.0_f32),
+ bottom: length(16.0_f32),
+ },
+ gap: Size { width: length(0.0_f32), height: length(10.0_f32) },
+ ..Default::default()
+ })
+ .children(vec![header, stats_row, plot_panel]);
+
+ // Right-click en cualquier punto de la raíz abre el contextual;
+ // origen (0,0) ⇒ coords locales == coords de ventana.
+ View::new(Style {
+ flex_direction: FlexDirection::Column,
+ size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+ ..Default::default()
+ })
+ .fill(theme.bg_app)
+ .on_right_click_at(|x, y, _, _| Some(Msg::ContextMenuOpen(x, y)))
+ .children(vec![menubar, body])
+ }
+
+ fn view_overlay(model: &Model) -> Option<View<Msg>> {
+ if let Some((x, y)) = model.context_menu {
+ return Some(context_menu_for_plot(model, x, y));
+ }
+ let menu = app_menu();
+ menubar_overlay(&menubar_spec(&menu, model))
+ }
+}
+
+// =====================================================================
+// Menú principal + contextual del plot
+// =====================================================================
+
+fn viewport_of(_model: &Model) -> (f32, f32) {
+ let (w, h) = StreamDemo::initial_size();
+ (w as f32, h as f32)
+}
+
+fn menubar_spec<'a>(menu: &'a AppMenu, model: &'a Model) -> MenuBarSpec<'a, Msg> {
+ MenuBarSpec {
+ menu,
+ open: model.menu_open,
+ theme: &model.theme,
+ viewport: viewport_of(model),
+ height: MENU_H,
+ on_open: Arc::new(Msg::MenuOpen),
+ on_command: Arc::new(|c: &str| Msg::MenuCommand(c.to_string())),
+ }
+}
+
+fn app_menu() -> AppMenu {
+ AppMenu::new()
+ .menu(Menu::new("Archivo").item(MenuItem::new("Salir", "file.quit").shortcut("Esc")))
+ .menu(
+ Menu::new("Ver")
+ .item(MenuItem::new("Reiniciar vista", "view.reset"))
+ .item(MenuItem::new("Cambiar tema", "view.theme")),
+ )
+ .menu(Menu::new("Ayuda").item(MenuItem::new("Acerca de", "help.about")))
+}
+
+fn handle_menu_command(cmd: &str, handle: &Handle<Msg>) {
+ match cmd {
+ "file.quit" => std::process::exit(0),
+ "view.reset" => handle.dispatch(Msg::Reset),
+ "view.theme" => handle.dispatch(Msg::CycleTheme),
+ _ => {}
+ }
+}
+
+/// Menú contextual del plot. El osciloscopio no tiene objetos
+/// seleccionables ni edición — ofrece reiniciar la vista y cambiar tema.
+fn context_menu_for_plot(model: &Model, x: f32, y: f32) -> View<Msg> {
+ let items = vec![
+ ContextMenuItem::action("Reiniciar vista"),
+ ContextMenuItem::separator(),
+ ContextMenuItem::action("Cambiar tema"),
+ ];
+ let cmds: Vec<&'static str> = vec!["view.reset", "", "view.theme"];
+ let on_pick: Arc<dyn Fn(usize) -> Msg + Send + Sync> = Arc::new(move |i: usize| {
+ Msg::MenuCommand(cmds.get(i).copied().unwrap_or("").to_string())
+ });
+
+ context_menu_view(ContextMenuSpec {
+ anchor: (x, y),
+ viewport: viewport_of(model),
+ header: Some(format!("stream · t = {}", model.t)),
+ items,
+ active: usize::MAX,
+ on_pick,
+ on_dismiss: Msg::CloseMenus,
+ palette: ContextMenuPalette::from_theme(&model.theme),
+ })
+}
+
+/// Señal sintética: suma de dos sinusoides + jitter determinístico. El
+/// rango efectivo queda en `[-1, 1]` aproximadamente.
+fn synthesize(t: u64) -> f32 {
+ let phase = t as f32;
+ let signal = (phase * 0.07).sin() * 0.75 + (phase * 0.19).sin() * 0.22;
+ let jitter = ((phase * 37.0).sin() * 1000.0).fract() * 0.04;
+ signal + jitter
+}
+
+fn main() {
+ llimphi_ui::run::<StreamDemo>();
+}
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<usize>),
+ /// Comando elegido en la barra o en el contextual — se traduce al
+ /// `Msg` real existente.
+ MenuCommand(String),
+ /// Cierra cualquier menú abierto (click-fuera / Esc).
+ CloseMenus,
+ /// Right-click sobre el plot → abre el menú contextual anclado en
+ /// `(x, y)` de ventana.
+ ContextMenuOpen(f32, f32),
+ /// Cicla el preset de tema.
+ CycleTheme,
+}
+
+struct Model {
+ theme: Theme,
+ menu_open: Option<usize>,
+ context_menu: Option<(f32, f32)>,
+}
+
+struct TreemapDemo;
+
+impl App for TreemapDemo {
+ type Model = Model;
+ type Msg = Msg;
+
+ fn title() -> &'static str {
+ "Lapaloma — treemap (squarified)"
+ }
+ fn initial_size() -> (u32, u32) {
+ (1000, 620)
+ }
+
+ fn init(_: &Handle<Msg>) -> Model {
+ Model { theme: Theme::dark(), menu_open: None, context_menu: None }
+ }
+
+ fn update(mut model: Model, msg: Msg, handle: &Handle<Msg>) -> Model {
+ match msg {
+ Msg::MenuOpen(which) => {
+ model.menu_open = which;
+ model.context_menu = None;
+ }
+ Msg::CloseMenus => {
+ model.menu_open = None;
+ model.context_menu = None;
+ }
+ Msg::ContextMenuOpen(x, y) => {
+ model.menu_open = None;
+ model.context_menu = Some((x, y));
+ }
+ Msg::CycleTheme => {
+ model.theme = Theme::next_after(model.theme.name);
+ }
+ Msg::MenuCommand(cmd) => {
+ model.menu_open = None;
+ model.context_menu = None;
+ handle_menu_command(&cmd, handle);
+ }
+ }
+ model
+ }
+
+ fn view(model: &Model) -> View<Msg> {
+ let theme = &model.theme;
+ let plot_bg = Color::rgba(0.08, 0.10, 0.13, 1.0);
+
+ let palette = [
+ 0x88c0d0, 0xd08770, 0xa3be8c, 0xebcb8b, 0xb48ead, 0x5e81ac,
+ 0x81a1c1, 0xbf616a, 0x8fbcbb, 0xd8dee9, 0xa3be8c, 0xebcb8b,
+ ];
+ let weights = [40.0, 28.0, 22.0, 18.0, 14.0, 10.0, 8.0, 6.0, 5.0, 4.0, 3.0, 2.0];
+ let tiles: Vec<Tile> = weights
+ .iter()
+ .zip(palette.iter())
+ .map(|(&w, &c)| Tile::new(w, Color::from_hex(c)))
+ .collect();
+
+ let menu = app_menu();
+ let menubar = menubar_view(&menubar_spec(&menu, model));
+
+ let header = View::new(Style {
+ size: Size { width: percent(1.0_f32), height: length(28.0_f32) },
+ ..Default::default()
+ })
+ .text_aligned(
+ "Lapaloma — treemap".to_string(),
+ 18.0,
+ theme.fg_text,
+ Alignment::Start,
+ );
+
+ let legend = View::new(Style {
+ size: Size { width: percent(1.0_f32), height: length(20.0_f32) },
+ ..Default::default()
+ })
+ .text_aligned(
+ "12 tiles · pesos 40, 28, 22, 18, 14, 10, 8, 6, 5, 4, 3, 2 · gap 2 px".to_string(),
+ 11.0,
+ theme.fg_muted,
+ Alignment::Start,
+ );
+
+ let panel = View::new(Style {
+ size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+ flex_grow: 1.0,
+ ..Default::default()
+ })
+ .clip(true)
+ .paint_with(move |scene, ts, rect| {
+ let outer = Rect::new(rect.x, rect.y, rect.w, rect.h);
+ let mut canvas = SceneCanvas::new(scene, ts);
+ canvas.fill_rect(outer, plot_bg);
+ paint_treemap(&tiles, outer, 2.0, &mut canvas);
+ });
+
+ let body = View::new(Style {
+ flex_direction: FlexDirection::Column,
+ size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+ flex_grow: 1.0,
+ padding: TaffyRect {
+ left: length(16.0_f32),
+ right: length(16.0_f32),
+ top: length(16.0_f32),
+ bottom: length(16.0_f32),
+ },
+ gap: Size { width: length(0.0_f32), height: length(10.0_f32) },
+ ..Default::default()
+ })
+ .children(vec![header, legend, panel]);
+
+ // Right-click en cualquier punto de la raíz abre el contextual;
+ // origen (0,0) ⇒ coords locales == coords de ventana.
+ View::new(Style {
+ flex_direction: FlexDirection::Column,
+ size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+ ..Default::default()
+ })
+ .fill(theme.bg_app)
+ .on_right_click_at(|x, y, _, _| Some(Msg::ContextMenuOpen(x, y)))
+ .children(vec![menubar, body])
+ }
+
+ fn view_overlay(model: &Model) -> Option<View<Msg>> {
+ if let Some((x, y)) = model.context_menu {
+ return Some(context_menu_for_plot(model, x, y));
+ }
+ let menu = app_menu();
+ menubar_overlay(&menubar_spec(&menu, model))
+ }
+}
+
+// =====================================================================
+// Menú principal + contextual del plot
+// =====================================================================
+
+fn viewport_of(_model: &Model) -> (f32, f32) {
+ let (w, h) = TreemapDemo::initial_size();
+ (w as f32, h as f32)
+}
+
+fn menubar_spec<'a>(menu: &'a AppMenu, model: &'a Model) -> MenuBarSpec<'a, Msg> {
+ MenuBarSpec {
+ menu,
+ open: model.menu_open,
+ theme: &model.theme,
+ viewport: viewport_of(model),
+ height: MENU_H,
+ on_open: Arc::new(Msg::MenuOpen),
+ on_command: Arc::new(|c: &str| Msg::MenuCommand(c.to_string())),
+ }
+}
+
+fn app_menu() -> AppMenu {
+ AppMenu::new()
+ .menu(Menu::new("Archivo").item(MenuItem::new("Salir", "file.quit").shortcut("Esc")))
+ .menu(Menu::new("Ver").item(MenuItem::new("Cambiar tema", "view.theme")))
+ .menu(Menu::new("Ayuda").item(MenuItem::new("Acerca de", "help.about")))
+}
+
+fn handle_menu_command(cmd: &str, handle: &Handle<Msg>) {
+ match cmd {
+ "file.quit" => std::process::exit(0),
+ "view.theme" => handle.dispatch(Msg::CycleTheme),
+ // "help.about" y desconocidos: no-op (sin diálogo todavía).
+ _ => {}
+ }
+}
+
+/// Menú contextual del plot. El treemap es un canvas estático sin
+/// objetos seleccionables ni edición — sólo ofrece cambiar tema.
+fn context_menu_for_plot(model: &Model, x: f32, y: f32) -> View<Msg> {
+ let items = vec![ContextMenuItem::action("Cambiar tema")];
+ let cmds: Vec<&'static str> = vec!["view.theme"];
+ let on_pick: Arc<dyn Fn(usize) -> Msg + Send + Sync> = Arc::new(move |i: usize| {
+ Msg::MenuCommand(cmds.get(i).copied().unwrap_or("").to_string())
+ });
+
+ context_menu_view(ContextMenuSpec {
+ anchor: (x, y),
+ viewport: viewport_of(model),
+ header: Some("treemap".to_string()),
+ items,
+ active: usize::MAX,
+ on_pick,
+ on_dismiss: Msg::CloseMenus,
+ palette: ContextMenuPalette::from_theme(&model.theme),
+ })
+}
+
+fn main() {
+ llimphi_ui::run::<TreemapDemo>();
+}
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<f64>,
+ pub lo: f32,
+ pub hi: f32,
+ /// Ancho de cada bin en unidades de dato.
+ pub bin_width: f32,
+}
+
+impl Histogram {
+ /// Binea `values` en `n_bins` sobre su propio min..max. Valores
+ /// fuera de rango (NaN) se ignoran. Con `n_bins == 0` o muestra
+ /// vacía devuelve un histograma vacío.
+ pub fn new(values: &[f32], n_bins: usize) -> Self {
+ if n_bins == 0 || values.is_empty() {
+ return Self { counts: Vec::new(), lo: 0.0, hi: 0.0, bin_width: 0.0 };
+ }
+ let mut lo = f32::INFINITY;
+ let mut hi = f32::NEG_INFINITY;
+ for &v in values {
+ if v.is_nan() {
+ continue;
+ }
+ lo = lo.min(v);
+ hi = hi.max(v);
+ }
+ if !lo.is_finite() || !hi.is_finite() {
+ return Self { counts: vec![0.0; n_bins], lo: 0.0, hi: 0.0, bin_width: 0.0 };
+ }
+ // Rango degenerado (todos iguales): ensancha para no dividir por 0.
+ if (hi - lo).abs() < f32::EPSILON {
+ hi = lo + 1.0;
+ }
+ let bin_width = (hi - lo) / n_bins as f32;
+ let mut counts = vec![0.0_f64; n_bins];
+ for &v in values {
+ if v.is_nan() {
+ continue;
+ }
+ let mut idx = ((v - lo) / bin_width) as usize;
+ if idx >= n_bins {
+ idx = n_bins - 1; // el máximo cae en el último bin
+ }
+ counts[idx] += 1.0;
+ }
+ Self { counts, lo, hi, bin_width }
+ }
+
+ /// Convierte los conteos en barras de un mismo color, listas para
+ /// [`crate::paint_bars`].
+ pub fn to_bars(&self, color: Color) -> Vec<Bar> {
+ self.counts.iter().map(|&c| Bar::new(c, color)).collect()
+ }
+
+ /// Conteo del bin más poblado (útil para fijar el rango del eje).
+ pub fn max_count(&self) -> f64 {
+ self.counts.iter().copied().fold(0.0, f64::max)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn counts_sum_to_sample_size() {
+ let v = [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0];
+ let h = Histogram::new(&v, 5);
+ let total: f64 = h.counts.iter().sum();
+ assert_eq!(total, 10.0);
+ assert_eq!(h.counts.len(), 5);
+ }
+
+ #[test]
+ fn max_value_lands_in_last_bin() {
+ let v = [0.0, 10.0];
+ let h = Histogram::new(&v, 4);
+ assert_eq!(h.counts[3], 1.0, "el máximo cae en el último bin");
+ assert_eq!(h.counts[0], 1.0);
+ }
+
+ #[test]
+ fn empty_and_zero_bins_are_safe() {
+ assert!(Histogram::new(&[], 5).counts.iter().all(|&c| c == 0.0) || Histogram::new(&[], 5).counts.is_empty());
+ assert!(Histogram::new(&[1.0, 2.0], 0).counts.is_empty());
+ }
+
+ #[test]
+ fn degenerate_range_does_not_panic() {
+ let v = [3.0, 3.0, 3.0];
+ let h = Histogram::new(&v, 4);
+ assert_eq!(h.counts.iter().sum::<f64>(), 3.0);
+ }
+}
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<Item = f64>, baseline: f64) -> (f64, f64) {
+ let mut lo = baseline;
+ let mut hi = baseline;
+ for v in values {
+ if v < lo {
+ lo = v;
+ }
+ if v > hi {
+ hi = v;
+ }
+ }
+ (lo, hi)
+}
+
+/// Dibuja una serie de barras dentro de `area`. Una `fill_rect` por
+/// barra; valores negativos crecen al otro lado del baseline.
+pub fn paint_bars(bars: &[Bar], area: Rect, style: &BarStyle, canvas: &mut dyn Canvas) {
+ if bars.is_empty() {
+ return;
+ }
+ let (vmin, vmax) = style
+ .range
+ .unwrap_or_else(|| auto_range(bars.iter().map(|b| b.value), style.baseline));
+ let axis = Axis::new(area, vmin, vmax, style.orientation);
+ let (s0, s1) = axis.cat_span();
+ for (i, bar) in bars.iter().enumerate() {
+ let (lo, hi) = slot(s0, s1, bars.len(), i, style.gap_ratio);
+ let r = axis.bar_rect(lo, hi, style.baseline, bar.value);
+ if r.w > 0.0 && r.h > 0.0 {
+ canvas.fill_rect(r, bar.color);
+ }
+ }
+}
+
+/// Dibuja varias series agrupadas (clustered): cada categoría es un
+/// slot que se subdivide entre las `series.len()` series. `series[k]`
+/// debe tener un valor por categoría; series más cortas se rellenan
+/// hasta la categoría que tengan.
+pub fn paint_grouped(series: &[&[Bar]], area: Rect, style: &BarStyle, canvas: &mut dyn Canvas) {
+ let n_series = series.len();
+ if n_series == 0 {
+ return;
+ }
+ let n_cats = series.iter().map(|s| s.len()).max().unwrap_or(0);
+ if n_cats == 0 {
+ return;
+ }
+ let all = series.iter().flat_map(|s| s.iter().map(|b| b.value));
+ let (vmin, vmax) = style.range.unwrap_or_else(|| auto_range(all, style.baseline));
+ let axis = Axis::new(area, vmin, vmax, style.orientation);
+ let (s0, s1) = axis.cat_span();
+ for cat in 0..n_cats {
+ // Slot de la categoría (sin gap: el gap se aplica adentro, entre
+ // las barras del cluster).
+ let (clo, chi) = slot(s0, s1, n_cats, cat, 0.0);
+ for (k, serie) in series.iter().enumerate() {
+ let Some(bar) = serie.get(cat) else { continue };
+ let (lo, hi) = slot(clo, chi, n_series, k, style.gap_ratio);
+ let r = axis.bar_rect(lo, hi, style.baseline, bar.value);
+ if r.w > 0.0 && r.h > 0.0 {
+ canvas.fill_rect(r, bar.color);
+ }
+ }
+ }
+}
+
+/// Dibuja barras apiladas: `stacks[c]` son los segmentos de la
+/// categoría `c`, acumulados desde el baseline. Pensado para segmentos
+/// del mismo signo (lo habitual en stacked bars).
+pub fn paint_stacked(stacks: &[&[Bar]], area: Rect, style: &BarStyle, canvas: &mut dyn Canvas) {
+ if stacks.is_empty() {
+ return;
+ }
+ // Rango: del baseline al mínimo/máximo acumulado de cada pila.
+ let mut lo = style.baseline;
+ let mut hi = style.baseline;
+ for stack in stacks {
+ let mut acc = style.baseline;
+ for seg in stack.iter() {
+ acc += seg.value;
+ lo = lo.min(acc);
+ hi = hi.max(acc);
+ }
+ }
+ let (vmin, vmax) = style.range.unwrap_or((lo, hi));
+ let axis = Axis::new(area, vmin, vmax, style.orientation);
+ let (s0, s1) = axis.cat_span();
+ for (c, stack) in stacks.iter().enumerate() {
+ let (clo, chi) = slot(s0, s1, stacks.len(), c, style.gap_ratio);
+ let mut acc = style.baseline;
+ for seg in stack.iter() {
+ let from = acc;
+ acc += seg.value;
+ let r = axis.bar_rect(clo, chi, from, acc);
+ if r.w > 0.0 && r.h > 0.0 {
+ canvas.fill_rect(r, seg.color);
+ }
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use pineal_render::{PlanRecorder, RenderCmd};
+
+ fn fill_rects(rec: PlanRecorder) -> Vec<Rect> {
+ rec.into_plan()
+ .cmds
+ .into_iter()
+ .filter_map(|c| match c {
+ RenderCmd::FillRect { rect, .. } => Some(rect),
+ _ => None,
+ })
+ .collect()
+ }
+
+ #[test]
+ fn one_rect_per_bar() {
+ let bars = [
+ Bar::new(3.0, Color::WHITE),
+ Bar::new(5.0, Color::BLACK),
+ Bar::new(1.0, Color::from_hex(0x00ff00)),
+ ];
+ let mut rec = PlanRecorder::new();
+ paint_bars(&bars, Rect::new(0.0, 0.0, 300.0, 200.0), &BarStyle::vertical(), &mut rec);
+ assert_eq!(fill_rects(rec).len(), 3);
+ }
+
+ #[test]
+ fn taller_value_taller_bar() {
+ let bars = [Bar::new(1.0, Color::WHITE), Bar::new(4.0, Color::WHITE)];
+ let mut rec = PlanRecorder::new();
+ paint_bars(&bars, Rect::new(0.0, 0.0, 200.0, 100.0), &BarStyle::vertical(), &mut rec);
+ let rects = fill_rects(rec);
+ assert!(rects[1].h > rects[0].h, "la barra de mayor valor debe ser más alta");
+ }
+
+ #[test]
+ fn negative_grows_below_baseline() {
+ // Con baseline 0 y rango simétrico, un valor negativo debe quedar
+ // por debajo (y mayor) que uno positivo.
+ let bars = [Bar::new(2.0, Color::WHITE), Bar::new(-2.0, Color::WHITE)];
+ let style = BarStyle::vertical().with_range(-3.0, 3.0);
+ let mut rec = PlanRecorder::new();
+ paint_bars(&bars, Rect::new(0.0, 0.0, 200.0, 100.0), &style, &mut rec);
+ let rects = fill_rects(rec);
+ // baseline (v=0) está en el medio (y=50). El positivo arranca
+ // arriba del baseline; el negativo abajo.
+ assert!(rects[0].y < 50.0, "positivo arriba del baseline");
+ assert!(rects[1].y >= 50.0 - f32::EPSILON, "negativo en/abajo del baseline");
+ }
+
+ #[test]
+ fn horizontal_swaps_axes() {
+ let bars = [Bar::new(1.0, Color::WHITE), Bar::new(4.0, Color::WHITE)];
+ let mut rec = PlanRecorder::new();
+ paint_bars(&bars, Rect::new(0.0, 0.0, 200.0, 100.0), &BarStyle::horizontal(), &mut rec);
+ let rects = fill_rects(rec);
+ // En horizontal el largo es el ancho (w), no la altura.
+ assert!(rects[1].w > rects[0].w, "mayor valor = barra más larga (w)");
+ }
+
+ #[test]
+ fn grouped_emits_all_bars() {
+ let a = [Bar::new(1.0, Color::WHITE), Bar::new(2.0, Color::WHITE)];
+ let b = [Bar::new(3.0, Color::BLACK), Bar::new(4.0, Color::BLACK)];
+ let series: [&[Bar]; 2] = [&a, &b];
+ let mut rec = PlanRecorder::new();
+ paint_grouped(&series, Rect::new(0.0, 0.0, 400.0, 200.0), &BarStyle::vertical(), &mut rec);
+ assert_eq!(fill_rects(rec).len(), 4);
+ }
+
+ #[test]
+ fn stacked_segments_dont_overlap() {
+ let s0 = [Bar::new(2.0, Color::WHITE), Bar::new(3.0, Color::BLACK)];
+ let stacks: [&[Bar]; 1] = [&s0];
+ let mut rec = PlanRecorder::new();
+ paint_stacked(&stacks, Rect::new(0.0, 0.0, 100.0, 100.0), &BarStyle::vertical(), &mut rec);
+ let rects = fill_rects(rec);
+ assert_eq!(rects.len(), 2);
+ // Apilados verticalmente: el primer segmento (baseline→2) queda
+ // debajo del segundo (2→5). Sin solape ⇒ el de abajo empieza
+ // donde termina el de arriba (con tolerancia de borde).
+ let bottom0 = rects[0].y + rects[0].h;
+ let bottom1 = rects[1].y + rects[1].h;
+ assert!(bottom1 <= bottom0 + 0.01 && rects[1].y < rects[0].y);
+ }
+}
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<f64> {
+ debug_assert!(max > min && target_ticks > 0);
+ let step = nice_step(min, max, target_ticks);
+ let mut t = (min / step).ceil() * step;
+ let mut out = Vec::with_capacity(target_ticks + 2);
+ // Tolerancia para incluir el borde derecho cuando cae justo
+ // por epsilon arriba del max.
+ let epsilon = step * 1e-9;
+ while t <= max + epsilon {
+ out.push(t);
+ t += step;
+ }
+ out
+}
+
+/// Filtra una lista de `(pixel_pos, label)` para que los labels
+/// no se solapen. Devuelve los **índices** que sobreviven (los
+/// del input). Asume input ordenado por `pixel_pos`.
+///
+/// `min_spacing_px` es la distancia mínima entre el borde
+/// derecho de un label aprobado y el borde izquierdo del
+/// siguiente. Si no tenés el ancho del label, pasá un valor
+/// conservador (≈ 48 px del Flutter doc).
+pub fn decimate_labels(
+ positions_px: &[f32],
+ label_widths_px: &[f32],
+ min_spacing_px: f32,
+) -> Vec<usize> {
+ debug_assert_eq!(positions_px.len(), label_widths_px.len());
+ if positions_px.is_empty() {
+ return Vec::new();
+ }
+
+ let mut out = Vec::with_capacity(positions_px.len());
+ // Primero (más a la izquierda) siempre va.
+ out.push(0);
+ let mut last_right = positions_px[0] + label_widths_px[0] * 0.5;
+
+ for i in 1..positions_px.len() {
+ let half_w = label_widths_px[i] * 0.5;
+ let my_left = positions_px[i] - half_w;
+ if my_left - last_right >= min_spacing_px {
+ out.push(i);
+ last_right = positions_px[i] + half_w;
+ }
+ }
+
+ out
+}
+
+/// Formateo numérico básico con decimales dependientes del step.
+///
+/// - `step >= 1` → sin decimales: "1", "20", "300".
+/// - `0 < step < 1` → decimales suficientes para distinguir step
+/// de step + step (típicamente `-floor(log10(step))`).
+/// - Valores absolutos muy chicos quedan en "0".
+pub fn format_tick(value: f64, step: f64) -> String {
+ if step >= 1.0 {
+ format!("{}", value.round() as i64)
+ } else if step <= 0.0 {
+ format!("{}", value)
+ } else {
+ let decimals = (-step.log10().floor()) as i32;
+ let decimals = decimals.clamp(1, 9) as usize;
+ format!("{:.*}", decimals, value)
+ }
+}
+
+/// Estilo visual del eje. Lo consume el Element en `paint()`.
+#[derive(Debug, Clone, Copy)]
+pub struct AxisStyle {
+ pub tick_length_px: f32,
+ pub tick_width_px: f32,
+ pub axis_line_width_px: f32,
+ pub label_size_px: f32,
+ pub label_offset_px: f32,
+ /// Min spacing entre labels después de decimar.
+ pub label_min_spacing_px: f32,
+}
+
+impl Default for AxisStyle {
+ fn default() -> Self {
+ Self {
+ tick_length_px: 4.0,
+ tick_width_px: 1.0,
+ axis_line_width_px: 1.0,
+ label_size_px: 10.0,
+ label_offset_px: 4.0,
+ label_min_spacing_px: 8.0,
+ }
+ }
+}
+
+const MONO_GLYPH_RATIO: f32 = 0.55;
+
+/// Pinta las dos líneas base (X y Y), los tick marks y los labels
+/// decimados de ambos ejes. Función reusable entre crates de
+/// visualización (cartesian, financial, etc.) — recibe todo por
+/// args para no atarse al state de un Element específico.
+pub fn paint_axes(
+ canvas: &mut dyn Canvas,
+ cs: &CoordinateSystem,
+ viewport: &ChartViewport,
+ color: Color,
+ style: AxisStyle,
+ target_ticks_x: usize,
+ target_ticks_y: usize,
+) {
+ let plot = cs.plot;
+ let axis_stroke = StrokeStyle::new(style.axis_line_width_px, color);
+ let tick_stroke = StrokeStyle::new(style.tick_width_px, color);
+ let tlen = style.tick_length_px;
+
+ canvas.stroke_line(
+ Point::new(plot.x, plot.bottom()),
+ Point::new(plot.right(), plot.bottom()),
+ axis_stroke,
+ );
+ canvas.stroke_line(
+ Point::new(plot.x, plot.y),
+ Point::new(plot.x, plot.bottom()),
+ axis_stroke,
+ );
+
+ // X axis ticks + labels.
+ let x_ticks = ticks_nice(viewport.x_min, viewport.x_max, target_ticks_x);
+ let x_step = nice_step(viewport.x_min, viewport.x_max, target_ticks_x);
+ let mut x_pos: Vec<f32> = Vec::with_capacity(x_ticks.len());
+ let mut x_lbl: Vec<String> = Vec::with_capacity(x_ticks.len());
+ let mut x_widths: Vec<f32> = Vec::with_capacity(x_ticks.len());
+ for v in &x_ticks {
+ let pixel = cs.data_to_pixel(*v, viewport.y_min).x;
+ if pixel < plot.x - 0.5 || pixel > plot.right() + 0.5 {
+ continue;
+ }
+ canvas.stroke_line(
+ Point::new(pixel, plot.bottom()),
+ Point::new(pixel, plot.bottom() + tlen),
+ tick_stroke,
+ );
+ let lbl = format_tick(*v, x_step);
+ let w = lbl.len() as f32 * style.label_size_px * MONO_GLYPH_RATIO;
+ x_pos.push(pixel);
+ x_widths.push(w);
+ x_lbl.push(lbl);
+ }
+ let keep_x = decimate_labels(&x_pos, &x_widths, style.label_min_spacing_px);
+ for i in keep_x {
+ let half = x_widths[i] * 0.5;
+ canvas.draw_text(
+ Point::new(
+ x_pos[i] - half,
+ plot.bottom() + tlen + style.label_offset_px,
+ ),
+ &x_lbl[i],
+ color,
+ style.label_size_px,
+ );
+ }
+
+ // Y axis ticks + labels con decimación vertical.
+ let y_ticks = ticks_nice(viewport.y_min, viewport.y_max, target_ticks_y);
+ let y_step = nice_step(viewport.y_min, viewport.y_max, target_ticks_y);
+ let y_label_pitch = style.label_size_px + style.label_min_spacing_px;
+ let mut prev_py: Option<f32> = None;
+
+ for v in &y_ticks {
+ let py = cs.data_to_pixel(viewport.x_min, *v).y;
+ if py < plot.y - 0.5 || py > plot.bottom() + 0.5 {
+ continue;
+ }
+ canvas.stroke_line(
+ Point::new(plot.x - tlen, py),
+ Point::new(plot.x, py),
+ tick_stroke,
+ );
+ let label_ok = match prev_py {
+ None => true,
+ Some(p) => (py - p).abs() >= y_label_pitch,
+ };
+ if !label_ok {
+ continue;
+ }
+ let lbl = format_tick(*v, y_step);
+ let w = lbl.len() as f32 * style.label_size_px * MONO_GLYPH_RATIO;
+ canvas.draw_text(
+ Point::new(
+ plot.x - tlen - style.label_offset_px - w,
+ py - style.label_size_px * 0.5,
+ ),
+ &lbl,
+ color,
+ style.label_size_px,
+ );
+ prev_py = Some(py);
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn ticks_nice_genera_alineados_a_step() {
+ let t = ticks_nice(0.0, 10.0, 5);
+ assert_eq!(t, vec![0.0, 2.0, 4.0, 6.0, 8.0, 10.0]);
+ }
+
+ #[test]
+ fn ticks_nice_clipea_fuera_de_rango() {
+ let t = ticks_nice(0.3, 9.8, 5);
+ // step = 2; ticks dentro [0.3, 9.8] son 2,4,6,8.
+ assert_eq!(t, vec![2.0, 4.0, 6.0, 8.0]);
+ }
+
+ #[test]
+ fn ticks_nice_rango_fraccional() {
+ let t = ticks_nice(0.0, 1.0, 5);
+ // step = 0.2 → 0, 0.2, 0.4, 0.6, 0.8, 1.0
+ assert_eq!(t.len(), 6);
+ for (i, v) in t.iter().enumerate() {
+ assert!((v - (i as f64 * 0.2)).abs() < 1e-9);
+ }
+ }
+
+ #[test]
+ fn decimate_preserva_primero() {
+ let pos = vec![0.0, 5.0, 10.0, 100.0];
+ let w = vec![20.0; 4];
+ // min_spacing 10 px. 0 va; 5 está a 5-10=-5 del borde der → no
+ // entra; 10 está a 10-10=0 → no entra; 100 sí.
+ let keep = decimate_labels(&pos, &w, 10.0);
+ assert_eq!(keep, vec![0, 3]);
+ }
+
+ #[test]
+ fn decimate_vacio() {
+ let keep = decimate_labels(&[], &[], 10.0);
+ assert!(keep.is_empty());
+ }
+
+ #[test]
+ fn decimate_pasa_todo_cuando_hay_lugar() {
+ let pos = vec![0.0, 50.0, 100.0];
+ let w = vec![10.0, 10.0, 10.0];
+ let keep = decimate_labels(&pos, &w, 5.0);
+ assert_eq!(keep, vec![0, 1, 2]);
+ }
+
+ #[test]
+ fn format_tick_integer() {
+ assert_eq!(format_tick(42.0, 1.0), "42");
+ assert_eq!(format_tick(0.0, 5.0), "0");
+ assert_eq!(format_tick(1000.0, 100.0), "1000");
+ }
+
+ #[test]
+ fn format_tick_fraccional() {
+ assert_eq!(format_tick(0.5, 0.1), "0.5");
+ assert_eq!(format_tick(0.05, 0.01), "0.05");
+ }
+}
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<f32>) {
+ debug_assert!(data.len() % 2 == 0);
+ // Factorizamos para evitar la división por iteración.
+ let sx = self.plot.w / self.viewport.x_span() as f32;
+ let sy = self.plot.h / self.viewport.y_span() as f32;
+ let tx = self.plot.x - self.viewport.x_min as f32 * sx;
+ let ty = self.plot.bottom() + self.viewport.y_min as f32 * sy;
+
+ out.reserve(data.len());
+ let mut i = 0;
+ while i < data.len() {
+ let px = data[i] * sx + tx;
+ let py = ty - data[i + 1] * sy;
+ out.push(px);
+ out.push(py);
+ i += 2;
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn fixture() -> CoordinateSystem {
+ // Viewport [0..10, 0..10] sobre plot de 100×100 en (0, 0).
+ CoordinateSystem::new(
+ ChartViewport::new(0.0, 10.0, 0.0, 10.0),
+ Rect::new(0.0, 0.0, 100.0, 100.0),
+ )
+ }
+
+ #[test]
+ fn data_to_pixel_origen() {
+ let cs = fixture();
+ // (0,0) data → (0, 100) pixel (bottom-left del plot).
+ let p = cs.data_to_pixel(0.0, 0.0);
+ assert!((p.x - 0.0).abs() < 1e-6);
+ assert!((p.y - 100.0).abs() < 1e-6);
+ }
+
+ #[test]
+ fn data_to_pixel_centro() {
+ let cs = fixture();
+ // (5, 5) → (50, 50)
+ let p = cs.data_to_pixel(5.0, 5.0);
+ assert!((p.x - 50.0).abs() < 1e-6);
+ assert!((p.y - 50.0).abs() < 1e-6);
+ }
+
+ #[test]
+ fn data_to_pixel_top_right() {
+ let cs = fixture();
+ // (10, 10) → (100, 0)
+ let p = cs.data_to_pixel(10.0, 10.0);
+ assert!((p.x - 100.0).abs() < 1e-6);
+ assert!((p.y - 0.0).abs() < 1e-6);
+ }
+
+ #[test]
+ fn pixel_to_data_roundtrip() {
+ let cs = fixture();
+ for (x, y) in [(2.5_f64, 7.5), (0.0, 0.0), (10.0, 10.0), (3.14, 1.59)] {
+ let p = cs.data_to_pixel(x, y);
+ let (x2, y2) = cs.pixel_to_data(p);
+ assert!((x - x2).abs() < 1e-4, "x roundtrip: {} vs {}", x, x2);
+ assert!((y - y2).abs() < 1e-4, "y roundtrip: {} vs {}", y, y2);
+ }
+ }
+
+ #[test]
+ fn project_buffer_consistente_con_pointwise() {
+ let cs = fixture();
+ let data: Vec<f32> = vec![0.0, 0.0, 5.0, 5.0, 10.0, 10.0];
+ let mut out = Vec::new();
+ cs.project_buffer(&data, &mut out);
+ assert_eq!(out.len(), data.len());
+ for i in 0..3 {
+ let expected = cs.data_to_pixel(data[i * 2] as f64, data[i * 2 + 1] as f64);
+ assert!((out[i * 2] - expected.x).abs() < 1e-4);
+ assert!((out[i * 2 + 1] - expected.y).abs() < 1e-4);
+ }
+ }
+}
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<Msg>` declarativo en
+//! un árbol llimphi-ui. Incluye `ChartCache` para translate-only pan-blit
+//! con hash de invalidación.
+
+#![forbid(unsafe_code)]
+#![allow(dead_code)]
+
+pub mod viewport;
+pub mod coord_system;
+pub mod series;
+pub mod axis;
+pub mod view;
+
+pub use viewport::ChartViewport;
+pub use coord_system::CoordinateSystem;
+pub use series::{LineSeries, PaintCtx, RenderMode, Series};
+
+pub use view::{
+ chart_cache, lapaloma_chart_view, ChartCache, ChartCacheHandle, ChartSeriesItem, ChartView,
+};
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<f32>,
+}
+
+pub trait Series {
+ fn paint(&self, ctx: &mut PaintCtx<'_>, canvas: &mut dyn Canvas);
+
+ /// Devuelve `Some(point_index)` del sample más cercano al
+ /// pixel pasado, si está dentro del threshold de hit (default
+ /// 8 px). El default impl asume que el data buffer expuesto
+ /// por la serie está sorted por X (caso de [`LineSeries`]).
+ fn hit_test(&self, _pixel: pineal_render::Point, _cs: &CoordinateSystem) -> Option<usize> {
+ None
+ }
+}
+
+/// Serie de polilínea simple. Decimación LTTB cuando
+/// `data.len() > 3 × plot_width_px`.
+pub struct LineSeries<'a> {
+ pub data: &'a DataBuffer,
+ pub stroke: StrokeStyle,
+ /// Si `None`, se usa heurística `target = plot_width × 3`.
+ pub lttb_target: Option<usize>,
+}
+
+impl<'a> LineSeries<'a> {
+ pub fn new(data: &'a DataBuffer, stroke: StrokeStyle) -> Self {
+ Self { data, stroke, lttb_target: None }
+ }
+
+ pub fn effective_target(&self, plot_w: f32) -> usize {
+ self.lttb_target.unwrap_or_else(|| (plot_w as usize).saturating_mul(3))
+ }
+
+ /// Materializa las coords proyectadas a pixel space en `out`,
+ /// aplicando LTTB cuando densidad > target. `out` se clearea.
+ ///
+ /// Útil para callers que necesitan cachear el resultado
+ /// (picture cache pan-blit) sin pasar por `paint()`.
+ pub fn compute_projected(&self, cs: &CoordinateSystem, out: &mut Vec<f32>) {
+ out.clear();
+ if self.data.len() < 2 {
+ return;
+ }
+ let target = self.effective_target(cs.plot.w);
+ if self.data.len() > target {
+ let mut idx: Vec<usize> = Vec::with_capacity(target);
+ lttb::lttb_indices(self.data.coords(), target, &mut idx);
+ let mut decimated: Vec<f32> = Vec::with_capacity(idx.len() * 2);
+ for i in idx {
+ decimated.push(self.data.coords()[i * 2]);
+ decimated.push(self.data.coords()[i * 2 + 1]);
+ }
+ cs.project_buffer(&decimated, out);
+ } else {
+ cs.project_buffer(self.data.coords(), out);
+ }
+ }
+}
+
+impl<'a> Series for LineSeries<'a> {
+ fn paint(&self, ctx: &mut PaintCtx<'_>, canvas: &mut dyn Canvas) {
+ self.compute_projected(&ctx.cs, ctx.scratch);
+ if ctx.scratch.len() < 4 {
+ return;
+ }
+ canvas.stroke_polyline(ctx.scratch, self.stroke);
+ }
+
+ fn hit_test(&self, pixel: pineal_render::Point, cs: &CoordinateSystem) -> Option<usize> {
+ let (target_x, _) = cs.pixel_to_data(pixel);
+ let idx = pineal_core::spatial::SpatialIndex::new(self.data.coords())
+ .nearest(target_x as f32)?;
+ // Threshold de 8px sobre la distancia en pixeles real,
+ // no sólo la X — evita match cuando el punto está lejos
+ // verticalmente.
+ let (dx, dy) = self.data.xy(idx);
+ let p = cs.data_to_pixel(dx as f64, dy as f64);
+ let dist2 = (p.x - pixel.x).powi(2) + (p.y - pixel.y).powi(2);
+ if dist2 <= 64.0 { Some(idx) } else { None }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::viewport::ChartViewport;
+ use pineal_render::{Color, Point, Rect, RenderCmd, RenderPlan};
+
+ /// Canvas mock que captura comandos en un `RenderPlan`.
+ struct Capture {
+ plan: RenderPlan,
+ }
+ impl Capture {
+ fn new() -> Self {
+ Self { plan: RenderPlan::new() }
+ }
+ }
+ impl Canvas for Capture {
+ fn push_clip(&mut self, rect: Rect) {
+ self.plan.push(RenderCmd::PushClip(rect));
+ }
+ fn pop_clip(&mut self) {
+ self.plan.push(RenderCmd::PopClip);
+ }
+ fn fill_rect(&mut self, rect: Rect, color: Color) {
+ self.plan.push(RenderCmd::FillRect { rect, color });
+ }
+ fn stroke_rect(&mut self, rect: Rect, stroke: StrokeStyle) {
+ self.plan.push(RenderCmd::StrokeRect { rect, stroke });
+ }
+ fn stroke_line(&mut self, a: Point, b: Point, stroke: StrokeStyle) {
+ self.plan.push(RenderCmd::StrokeLine { a, b, stroke });
+ }
+ fn stroke_polyline(&mut self, coords: &[f32], stroke: StrokeStyle) {
+ self.plan.push(RenderCmd::StrokePolyline {
+ coords: coords.to_vec(),
+ stroke,
+ });
+ }
+ fn fill_triangle_strip(&mut self, coords: &[f32], colors: &[Color]) {
+ self.plan.push(RenderCmd::FillTriangleStrip {
+ coords: coords.to_vec(),
+ colors: colors.to_vec(),
+ });
+ }
+ fn draw_text(&mut self, p: Point, text: &str, color: Color, size_px: f32) {
+ self.plan.push(RenderCmd::DrawText {
+ p,
+ text: text.into(),
+ color,
+ size_px,
+ });
+ }
+ }
+
+ fn line_series_pinta_polyline() -> (Capture, usize) {
+ let mut buf = DataBuffer::with_capacity(5);
+ for i in 0..5 {
+ buf.push(i as f32, (i as f32).powi(2));
+ }
+ let series = LineSeries::new(&buf, StrokeStyle::new(2.0, Color::WHITE));
+ let cs = CoordinateSystem::new(
+ ChartViewport::new(0.0, 4.0, 0.0, 16.0),
+ Rect::new(0.0, 0.0, 100.0, 100.0),
+ );
+ let mut scratch = Vec::new();
+ let mut ctx = PaintCtx {
+ cs,
+ mode: RenderMode::UiRich,
+ scratch: &mut scratch,
+ };
+ let mut cap = Capture::new();
+ series.paint(&mut ctx, &mut cap);
+
+ let n = cap.plan.cmds.len();
+ (cap, n)
+ }
+
+ #[test]
+ fn line_series_emite_un_solo_drawcall() {
+ let (cap, n) = line_series_pinta_polyline();
+ assert_eq!(n, 1, "una sola draw call (P3 del ARCHITECTURE.md)");
+ match &cap.plan.cmds[0] {
+ RenderCmd::StrokePolyline { coords, .. } => {
+ assert_eq!(coords.len(), 10, "5 puntos × 2 = 10 floats");
+ }
+ other => panic!("se esperaba StrokePolyline, se vio {:?}", other),
+ }
+ }
+
+ #[test]
+ fn lttb_se_dispara_con_alta_densidad() {
+ // 10k puntos sobre plot de 50px → target = 150 → debe decimar
+ let mut buf = DataBuffer::with_capacity(10_000);
+ for i in 0..10_000 {
+ buf.push(i as f32, (i as f32 * 0.01).sin());
+ }
+ let series = LineSeries::new(&buf, StrokeStyle::new(1.0, Color::WHITE));
+ let cs = CoordinateSystem::new(
+ ChartViewport::new(0.0, 9999.0, -1.0, 1.0),
+ Rect::new(0.0, 0.0, 50.0, 100.0),
+ );
+ let mut scratch = Vec::new();
+ let mut ctx = PaintCtx {
+ cs,
+ mode: RenderMode::HighDensity,
+ scratch: &mut scratch,
+ };
+ let mut cap = Capture::new();
+ series.paint(&mut ctx, &mut cap);
+
+ match &cap.plan.cmds[0] {
+ RenderCmd::StrokePolyline { coords, .. } => {
+ // Debe haber muchos menos que 10k puntos (target ≈ 150).
+ assert!(coords.len() / 2 <= 160);
+ assert!(coords.len() / 2 >= 100);
+ }
+ _ => panic!(),
+ }
+ }
+
+ #[test]
+ fn line_series_vacio_no_emite() {
+ let buf = DataBuffer::new();
+ let series = LineSeries::new(&buf, StrokeStyle::new(1.0, Color::WHITE));
+ let cs = CoordinateSystem::new(
+ ChartViewport::new(0.0, 1.0, 0.0, 1.0),
+ Rect::new(0.0, 0.0, 100.0, 100.0),
+ );
+ let mut scratch = Vec::new();
+ let mut ctx = PaintCtx {
+ cs,
+ mode: RenderMode::UiRich,
+ scratch: &mut scratch,
+ };
+ let mut cap = Capture::new();
+ series.paint(&mut ctx, &mut cap);
+ assert_eq!(cap.plan.cmds.len(), 0);
+ }
+
+ #[test]
+ fn hit_test_acepta_punto_cercano() {
+ let mut buf = DataBuffer::with_capacity(5);
+ for i in 0..5 {
+ buf.push(i as f32, (i as f32).powi(2));
+ }
+ let series = LineSeries::new(&buf, StrokeStyle::new(1.0, Color::WHITE));
+ let cs = CoordinateSystem::new(
+ ChartViewport::new(0.0, 4.0, 0.0, 16.0),
+ Rect::new(0.0, 0.0, 100.0, 100.0),
+ );
+ // Punto (2,4) en data → ¿qué pixel? (2/4)·100=50, (1-4/16)·100=75
+ let target = pineal_render::Point::new(50.0, 75.0);
+ let hit = series.hit_test(target, &cs);
+ assert_eq!(hit, Some(2));
+ }
+
+ #[test]
+ fn hit_test_rechaza_punto_lejano() {
+ let mut buf = DataBuffer::with_capacity(2);
+ buf.push(0.0, 0.0);
+ buf.push(1.0, 1.0);
+ let series = LineSeries::new(&buf, StrokeStyle::new(1.0, Color::WHITE));
+ let cs = CoordinateSystem::new(
+ ChartViewport::new(0.0, 1.0, 0.0, 1.0),
+ Rect::new(0.0, 0.0, 100.0, 100.0),
+ );
+ // Pixel muy lejos de la línea.
+ let far = pineal_render::Point::new(50.0, 99.0);
+ assert!(series.hit_test(far, &cs).is_none());
+ }
+}
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<Msg>` declarativo de Llimphi reconstruye el árbol
+//! por frame. El estado que debe persistir entre frames (el cache de
+//! coords proyectadas para pan-blit) vive afuera del View, en el Model
+//! del host, vía un `ChartCacheHandle = Arc<Mutex<ChartCache>>` que el
+//! caller crea una vez y le pasa el clone a cada `chart_view(...)`.
+//!
+//! `scratch` del Element original era estado mutable del Element. En el
+//! View es un `Vec<f32>` local del closure: por frame se asigna y se
+//! drop al terminar el paint. Para series de hasta 50 K puntos el costo
+//! del alloc/dealloc es despreciable comparado con la proyección y el
+//! shaping de glifos.
+
+use std::collections::hash_map::DefaultHasher;
+use std::hash::{Hash, Hasher};
+use std::sync::{Arc, Mutex};
+
+use llimphi_ui::llimphi_layout::taffy::prelude::{percent, Size, Style};
+use llimphi_ui::View;
+
+use pineal_core::buffer::DataBuffer;
+use pineal_render::{Canvas as _, Color, Rect, SceneCanvas, StrokeStyle};
+
+use crate::axis::{self, AxisStyle};
+use crate::coord_system::CoordinateSystem;
+use crate::series::LineSeries;
+use crate::viewport::ChartViewport;
+
+const TARGET_TICKS_X: usize = 8;
+const TARGET_TICKS_Y: usize = 6;
+
+/// Cache de coords proyectadas para reuso entre frames. Lo que habilita
+/// el pan-blit: el caller lo crea una vez en su Model y le pasa el
+/// `Arc<Mutex<_>>` clonado a cada frame.
+#[derive(Default, Debug)]
+pub struct ChartCache {
+ projected: Vec<Vec<f32>>,
+ structural_hash: u64,
+ cached_x_min: f64,
+ cached_y_min: f64,
+ pan_blits: u64,
+ rebuilds: u64,
+ has_valid_cache: bool,
+}
+
+impl ChartCache {
+ pub fn new() -> Self {
+ Self::default()
+ }
+ pub fn pan_blits(&self) -> u64 {
+ self.pan_blits
+ }
+ pub fn rebuilds(&self) -> u64 {
+ self.rebuilds
+ }
+ pub fn invalidate(&mut self) {
+ *self = Self::default();
+ }
+}
+
+pub type ChartCacheHandle = Arc<Mutex<ChartCache>>;
+
+/// Atajo: cache compartido listo para enchufar en el host.
+pub fn chart_cache() -> ChartCacheHandle {
+ Arc::new(Mutex::new(ChartCache::new()))
+}
+
+#[derive(Clone)]
+pub struct ChartSeriesItem {
+ pub data: DataBuffer,
+ pub stroke: StrokeStyle,
+ pub name: Option<String>,
+}
+
+impl ChartSeriesItem {
+ pub fn new(data: DataBuffer, stroke: StrokeStyle) -> Self {
+ Self { data, stroke, name: None }
+ }
+ pub fn named(data: DataBuffer, stroke: StrokeStyle, name: impl Into<String>) -> Self {
+ Self { data, stroke, name: Some(name.into()) }
+ }
+}
+
+/// Configuración del chart. Builder estilo Element. `view::<Msg>()`
+/// materializa el `View<Msg>`.
+pub struct ChartView {
+ series: Vec<ChartSeriesItem>,
+ viewport: ChartViewport,
+ background: Option<Color>,
+ axis_color: Color,
+ axis_style: AxisStyle,
+ margin_top: f32,
+ margin_right: f32,
+ margin_bottom: f32,
+ margin_left: f32,
+ cache: Option<ChartCacheHandle>,
+}
+
+impl ChartView {
+ pub fn new(viewport: ChartViewport) -> Self {
+ Self {
+ series: Vec::new(),
+ viewport,
+ background: None,
+ axis_color: Color::rgba(0.6, 0.6, 0.65, 0.8),
+ axis_style: AxisStyle::default(),
+ margin_top: 8.0,
+ margin_right: 8.0,
+ margin_bottom: 24.0,
+ margin_left: 32.0,
+ cache: None,
+ }
+ }
+
+ pub fn add_series(mut self, data: DataBuffer, stroke: StrokeStyle) -> Self {
+ self.series.push(ChartSeriesItem::new(data, stroke));
+ self
+ }
+
+ pub fn add_series_named(
+ mut self,
+ data: DataBuffer,
+ stroke: StrokeStyle,
+ name: impl Into<String>,
+ ) -> Self {
+ self.series.push(ChartSeriesItem::named(data, stroke, name));
+ self
+ }
+
+ pub fn background(mut self, color: Color) -> Self {
+ self.background = Some(color);
+ self
+ }
+
+ pub fn axis_color(mut self, color: Color) -> Self {
+ self.axis_color = color;
+ self
+ }
+
+ pub fn margins(mut self, top: f32, right: f32, bottom: f32, left: f32) -> Self {
+ self.margin_top = top;
+ self.margin_right = right;
+ self.margin_bottom = bottom;
+ self.margin_left = left;
+ self
+ }
+
+ pub fn with_cache(mut self, cache: ChartCacheHandle) -> Self {
+ self.cache = Some(cache);
+ self
+ }
+
+ /// Materializa el `View<Msg>`. Devuelve un nodo que ocupa 100% del
+ /// padre y pinta el chart dentro de su rect.
+ pub fn view<Msg: Clone + 'static>(self) -> View<Msg> {
+ let ChartView {
+ series,
+ viewport,
+ background,
+ axis_color,
+ axis_style,
+ margin_top,
+ margin_right,
+ margin_bottom,
+ margin_left,
+ cache,
+ } = self;
+
+ View::new(Style {
+ size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+ ..Default::default()
+ })
+ .paint_with(move |scene, typesetter, rect| {
+ let outer = Rect::new(rect.x, rect.y, rect.w, rect.h);
+ let plot = Rect::new(
+ outer.x + margin_left,
+ outer.y + margin_top,
+ (outer.w - margin_left - margin_right).max(1.0),
+ (outer.h - margin_top - margin_bottom).max(1.0),
+ );
+ let cs = CoordinateSystem::new(viewport, plot);
+
+ let mut canvas = SceneCanvas::new(scene, typesetter);
+
+ if let Some(bg) = background {
+ canvas.fill_rect(outer, bg);
+ }
+
+ axis::paint_axes(
+ &mut canvas,
+ &cs,
+ &viewport,
+ axis_color,
+ axis_style,
+ TARGET_TICKS_X,
+ TARGET_TICKS_Y,
+ );
+
+ let current_hash =
+ structural_hash(plot, viewport.x_span(), viewport.y_span(), &series);
+ let pan_only = cache
+ .as_ref()
+ .map(|h| {
+ let c = h.lock().unwrap();
+ c.has_valid_cache
+ && c.structural_hash == current_hash
+ && c.projected.len() == series.len()
+ })
+ .unwrap_or(false);
+
+ if pan_only {
+ pan_blit_paint(&series, plot, viewport, cache.as_ref().unwrap(), &mut canvas);
+ } else {
+ rebuild_and_paint(
+ &series,
+ &cs,
+ plot,
+ viewport,
+ current_hash,
+ cache.as_ref(),
+ &mut canvas,
+ );
+ }
+ })
+ }
+}
+
+/// Rebuild full: LTTB + projection por serie. Pinta directo del cache
+/// (si está enchufado) para no copiar dos veces.
+fn rebuild_and_paint(
+ series: &[ChartSeriesItem],
+ cs: &CoordinateSystem,
+ _plot: Rect,
+ viewport: ChartViewport,
+ current_hash: u64,
+ cache: Option<&ChartCacheHandle>,
+ canvas: &mut SceneCanvas<'_>,
+) {
+ if let Some(handle) = cache {
+ let mut cached = handle.lock().unwrap();
+ cached.projected.clear();
+ cached.projected.resize_with(series.len(), Vec::new);
+ for (i, item) in series.iter().enumerate() {
+ let s = LineSeries::new(&item.data, item.stroke);
+ s.compute_projected(cs, &mut cached.projected[i]);
+ if cached.projected[i].len() >= 4 {
+ canvas.stroke_polyline(&cached.projected[i], item.stroke);
+ }
+ }
+ cached.structural_hash = current_hash;
+ cached.cached_x_min = viewport.x_min;
+ cached.cached_y_min = viewport.y_min;
+ cached.has_valid_cache = true;
+ cached.pan_blits = 0;
+ cached.rebuilds = cached.rebuilds.wrapping_add(1);
+ } else {
+ let mut scratch = Vec::new();
+ for item in series {
+ let s = LineSeries::new(&item.data, item.stroke);
+ s.compute_projected(cs, &mut scratch);
+ if scratch.len() >= 4 {
+ canvas.stroke_polyline(&scratch, item.stroke);
+ }
+ }
+ }
+}
+
+/// Emite las coords cacheadas con un offset en pixel space. Se usa
+/// cuando el hash estructural coincide — solo cambió el origen del
+/// viewport (pan puro).
+fn pan_blit_paint(
+ series: &[ChartSeriesItem],
+ plot: Rect,
+ viewport: ChartViewport,
+ cache: &ChartCacheHandle,
+ canvas: &mut SceneCanvas<'_>,
+) {
+ let mut cached = cache.lock().unwrap();
+ let dx_px =
+ ((cached.cached_x_min - viewport.x_min) * plot.w as f64 / viewport.x_span()) as f32;
+ let dy_px =
+ ((viewport.y_min - cached.cached_y_min) * plot.h as f64 / viewport.y_span()) as f32;
+ let mut scratch = Vec::new();
+ for (i, item) in series.iter().enumerate() {
+ let projected = &cached.projected[i];
+ if projected.len() < 4 {
+ continue;
+ }
+ scratch.clear();
+ scratch.reserve(projected.len());
+ let mut k = 0;
+ while k + 1 < projected.len() {
+ scratch.push(projected[k] + dx_px);
+ scratch.push(projected[k + 1] + dy_px);
+ k += 2;
+ }
+ canvas.stroke_polyline(&scratch, item.stroke);
+ }
+ cached.pan_blits = cached.pan_blits.wrapping_add(1);
+}
+
+/// Hash de la geometría + identidades de data. NO incluye `x_min`/`y_min`:
+/// el pan los mueve sin invalidar.
+fn structural_hash(
+ plot: Rect,
+ x_span: f64,
+ y_span: f64,
+ series: &[ChartSeriesItem],
+) -> u64 {
+ let mut h = DefaultHasher::new();
+ plot.x.to_bits().hash(&mut h);
+ plot.y.to_bits().hash(&mut h);
+ plot.w.to_bits().hash(&mut h);
+ plot.h.to_bits().hash(&mut h);
+ x_span.to_bits().hash(&mut h);
+ y_span.to_bits().hash(&mut h);
+ (series.len() as u64).hash(&mut h);
+ for s in series {
+ s.data.revision().hash(&mut h);
+ (s.data.len() as u64).hash(&mut h);
+ s.stroke.width.to_bits().hash(&mut h);
+ s.stroke.color.r.to_bits().hash(&mut h);
+ s.stroke.color.g.to_bits().hash(&mut h);
+ s.stroke.color.b.to_bits().hash(&mut h);
+ s.stroke.color.a.to_bits().hash(&mut h);
+ }
+ h.finish()
+}
+
+/// Helper builder-style — paralelo al `lapaloma_chart(...)` del Element GPUI.
+pub fn lapaloma_chart_view(
+ data: DataBuffer,
+ viewport: ChartViewport,
+ stroke: StrokeStyle,
+) -> ChartView {
+ ChartView::new(viewport).add_series(data, stroke)
+}
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<Segment> {
+ let w = matrix.width();
+ let h = matrix.height();
+ if w < 2 || h < 2 {
+ return Vec::new();
+ }
+ let mut segs = Vec::new();
+ for y in 0..h - 1 {
+ for x in 0..w - 1 {
+ // Esquinas: nw=00, ne=10, se=11, sw=01 (clockwise desde top-left).
+ let nw = matrix.get(x, y);
+ let ne = matrix.get(x + 1, y);
+ let se = matrix.get(x + 1, y + 1);
+ let sw = matrix.get(x, y + 1);
+ let mut idx = 0u8;
+ if nw >= iso { idx |= 1; }
+ if ne >= iso { idx |= 2; }
+ if se >= iso { idx |= 4; }
+ if sw >= iso { idx |= 8; }
+ if idx == 0 || idx == 15 {
+ continue;
+ }
+ // Interpolación lineal sobre cada borde activo.
+ let xf = x as f32;
+ let yf = y as f32;
+ let top = (xf + lerp_t(nw, ne, iso), yf);
+ let right = (xf + 1.0, yf + lerp_t(ne, se, iso));
+ let bot = (xf + lerp_t(sw, se, iso), yf + 1.0);
+ let left = (xf, yf + lerp_t(nw, sw, iso));
+ // Lookup table de marching squares — 16 casos.
+ match idx {
+ 1 | 14 => push(&mut segs, top, left),
+ 2 | 13 => push(&mut segs, top, right),
+ 3 | 12 => push(&mut segs, left, right),
+ 4 | 11 => push(&mut segs, right, bot),
+ 5 => {
+ push(&mut segs, top, left);
+ push(&mut segs, right, bot);
+ }
+ 6 | 9 => push(&mut segs, top, bot),
+ 7 | 8 => push(&mut segs, left, bot),
+ 10 => {
+ push(&mut segs, top, right);
+ push(&mut segs, left, bot);
+ }
+ _ => {}
+ }
+ }
+ }
+ segs
+}
+
+fn lerp_t(a: f32, b: f32, iso: f32) -> f32 {
+ let d = b - a;
+ if d.abs() < 1e-9 {
+ return 0.5;
+ }
+ ((iso - a) / d).clamp(0.0, 1.0)
+}
+
+fn push(segs: &mut Vec<Segment>, a: (f32, f32), b: (f32, f32)) {
+ segs.push([a.0, a.1, b.0, b.1]);
+}
+
+/// Extrae N niveles equiespaciados entre `min` y `max` de la matriz
+/// (exclusivos en ambos extremos: el primer nivel está en `min + step`,
+/// el último en `max - step`).
+pub fn extract_contours(matrix: &HeatmapMatrix, n_levels: usize) -> Vec<(f32, Vec<Segment>)> {
+ if n_levels == 0 {
+ return Vec::new();
+ }
+ let (min, max) = matrix.min_max();
+ if (max - min).abs() < 1e-9 {
+ return Vec::new();
+ }
+ let step = (max - min) / (n_levels + 1) as f32;
+ (1..=n_levels)
+ .map(|i| {
+ let iso = min + step * i as f32;
+ (iso, extract_contour(matrix, iso))
+ })
+ .collect()
+}
+
+/// Pinta los segmentos en `coords` de matriz mapeados al `area` destino.
+/// Cada nivel se dibuja con `stroke`.
+pub fn paint_contour(
+ segments: &[Segment],
+ matrix_w: usize,
+ matrix_h: usize,
+ area: Rect,
+ stroke: StrokeStyle,
+ canvas: &mut dyn Canvas,
+) {
+ if matrix_w < 2 || matrix_h < 2 {
+ return;
+ }
+ let cell_w = area.w / (matrix_w - 1) as f32;
+ let cell_h = area.h / (matrix_h - 1) as f32;
+ for seg in segments {
+ let coords = [
+ area.x + seg[0] * cell_w,
+ area.y + seg[1] * cell_h,
+ area.x + seg[2] * cell_w,
+ area.y + seg[3] * cell_h,
+ ];
+ canvas.stroke_polyline(&coords, stroke);
+ }
+}
+
+/// Atajo: pinta N niveles cada uno con su propio color (gradiente
+/// lineal entre `color_low` y `color_high` por isolínea).
+pub fn paint_contours(
+ matrix: &HeatmapMatrix,
+ n_levels: usize,
+ area: Rect,
+ color_low: Color,
+ color_high: Color,
+ line_width: f32,
+ canvas: &mut dyn Canvas,
+) {
+ let levels = extract_contours(matrix, n_levels);
+ if levels.is_empty() {
+ return;
+ }
+ let n = levels.len().max(1) as f32;
+ for (i, (_iso, segs)) in levels.iter().enumerate() {
+ let t = if n > 1.0 { i as f32 / (n - 1.0) } else { 0.5 };
+ let c = lerp_color(color_low, color_high, t);
+ paint_contour(segs, matrix.width(), matrix.height(), area, StrokeStyle::new(line_width, c), canvas);
+ }
+}
+
+fn lerp_color(a: Color, b: Color, t: f32) -> Color {
+ Color::rgba(
+ a.r + (b.r - a.r) * t,
+ a.g + (b.g - a.g) * t,
+ a.b + (b.b - a.b) * t,
+ a.a + (b.a - a.a) * t,
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use pineal_render::{PlanRecorder, RenderCmd};
+
+ fn ramp(w: usize, h: usize) -> HeatmapMatrix {
+ let data: Vec<f32> = (0..w * h).map(|i| (i / w) as f32).collect();
+ HeatmapMatrix::from_data(data, w, h).unwrap()
+ }
+
+ #[test]
+ fn empty_matrix_no_segments() {
+ let m = HeatmapMatrix::new(0, 0);
+ assert!(extract_contour(&m, 0.5).is_empty());
+ }
+
+ #[test]
+ fn ramp_at_mid_level_yields_horizontal_segments() {
+ // ramp 4x4: rows = 0,1,2,3. iso=1.5 cae entre row 1 y row 2 → línea horizontal.
+ let m = ramp(4, 4);
+ let segs = extract_contour(&m, 1.5);
+ assert!(!segs.is_empty());
+ // Todos los segmentos deberían ser ~horizontales (y0 ≈ y1).
+ for s in &segs {
+ assert!((s[1] - s[3]).abs() < 0.1, "no-horizontal: {s:?}");
+ }
+ }
+
+ #[test]
+ fn out_of_range_iso_no_segments() {
+ let m = ramp(4, 4);
+ assert!(extract_contour(&m, -5.0).is_empty());
+ assert!(extract_contour(&m, 100.0).is_empty());
+ }
+
+ #[test]
+ fn extract_contours_produces_n_levels() {
+ let m = ramp(4, 4);
+ let levels = extract_contours(&m, 3);
+ assert_eq!(levels.len(), 3);
+ // Niveles ordenados.
+ assert!(levels[0].0 < levels[1].0 && levels[1].0 < levels[2].0);
+ }
+
+ #[test]
+ fn paint_contour_emits_one_polyline_per_segment() {
+ let m = ramp(4, 4);
+ let segs = extract_contour(&m, 1.5);
+ let mut rec = PlanRecorder::new();
+ paint_contour(
+ &segs,
+ m.width(),
+ m.height(),
+ Rect::new(0.0, 0.0, 300.0, 200.0),
+ StrokeStyle::new(1.0, Color::BLACK),
+ &mut rec,
+ );
+ let n = rec
+ .into_plan()
+ .cmds
+ .iter()
+ .filter(|c| matches!(c, RenderCmd::StrokePolyline { .. }))
+ .count();
+ assert_eq!(n, segs.len());
+ }
+
+ #[test]
+ fn paint_contours_emits_per_level() {
+ let m = ramp(8, 8);
+ let mut rec = PlanRecorder::new();
+ paint_contours(
+ &m,
+ 4,
+ Rect::new(0.0, 0.0, 200.0, 200.0),
+ Color::from_hex(0x000080),
+ Color::from_hex(0xff0000),
+ 1.0,
+ &mut rec,
+ );
+ let n = rec
+ .into_plan()
+ .cmds
+ .iter()
+ .filter(|c| matches!(c, RenderCmd::StrokePolyline { .. }))
+ .count();
+ assert!(n > 0);
+ }
+
+ #[test]
+ fn flat_matrix_no_contours() {
+ let m = HeatmapMatrix::from_data(vec![5.0; 16], 4, 4).unwrap();
+ let levels = extract_contours(&m, 3);
+ assert!(levels.is_empty(), "matriz constante no debería tener isolíneas");
+ }
+}
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<f32>,
+ revision: u64,
+}
+
+impl DataBuffer {
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// Reserva espacio para `n` puntos sin agregarlos. Usalo al
+ /// montar el widget para que `push` no realloque después.
+ pub fn with_capacity(n: usize) -> Self {
+ Self {
+ coords: Vec::with_capacity(n * 2),
+ revision: 0,
+ }
+ }
+
+ /// Construye a partir de coords interleaved ya armadas.
+ /// Útil en tests y carga inicial.
+ pub fn from_interleaved(coords: Vec<f32>) -> Self {
+ assert!(coords.len() % 2 == 0, "interleaved coords deben ser pares");
+ Self {
+ coords,
+ revision: 0,
+ }
+ }
+
+ pub fn push(&mut self, x: f32, y: f32) {
+ self.coords.push(x);
+ self.coords.push(y);
+ self.revision = self.revision.wrapping_add(1);
+ }
+
+ /// Sobrescribe un punto existente. `i` es el índice de punto
+ /// (no de float), 0-based.
+ pub fn set_xy(&mut self, i: usize, x: f32, y: f32) {
+ self.coords[i * 2] = x;
+ self.coords[i * 2 + 1] = y;
+ self.revision = self.revision.wrapping_add(1);
+ }
+
+ /// Pisa el contenido completo con la nueva slice.
+ /// Útil para hidratar el buffer en un solo memcpy.
+ pub fn replace_from(&mut self, src: &[f32]) {
+ assert!(src.len() % 2 == 0);
+ self.coords.clear();
+ self.coords.extend_from_slice(src);
+ self.revision = self.revision.wrapping_add(1);
+ }
+
+ pub fn clear(&mut self) {
+ self.coords.clear();
+ self.revision = self.revision.wrapping_add(1);
+ }
+
+ pub fn len(&self) -> usize {
+ self.coords.len() / 2
+ }
+
+ pub fn is_empty(&self) -> bool {
+ self.coords.is_empty()
+ }
+
+ pub fn xy(&self, i: usize) -> (f32, f32) {
+ (self.coords[i * 2], self.coords[i * 2 + 1])
+ }
+
+ /// Slice plana lista para `drawRawPoints` / `wgpu::Buffer`
+ /// / `<polyline points>`. No realiza copia.
+ pub fn coords(&self) -> &[f32] {
+ &self.coords
+ }
+
+ pub fn revision(&self) -> u64 {
+ self.revision
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn push_y_len() {
+ let mut b = DataBuffer::with_capacity(4);
+ b.push(0.0, 1.0);
+ b.push(1.0, 2.0);
+ assert_eq!(b.len(), 2);
+ assert_eq!(b.xy(1), (1.0, 2.0));
+ }
+
+ #[test]
+ fn revision_bumps() {
+ let mut b = DataBuffer::new();
+ let r0 = b.revision();
+ b.push(0.0, 0.0);
+ let r1 = b.revision();
+ b.set_xy(0, 1.0, 1.0);
+ let r2 = b.revision();
+ assert_ne!(r0, r1);
+ assert_ne!(r1, r2);
+ }
+
+ #[test]
+ fn coords_slice_is_zero_copy() {
+ let raw = vec![0.0, 0.0, 1.0, 1.0, 2.0, 2.0];
+ let b = DataBuffer::from_interleaved(raw);
+ assert_eq!(b.coords(), &[0.0, 0.0, 1.0, 1.0, 2.0, 2.0]);
+ assert_eq!(b.len(), 3);
+ }
+}
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<f32>` planos
+//! indexados, nunca como `Vec<Point2D>`. Cache L1 caliente y el
+//! compilador puede SIMD-loopearlo.
+//! - **P2 Zero alloc en hot path.** Buffers se reservan al construir,
+//! se mutan in-place para siempre. Helpers escriben a `&mut Vec`
+//! provistos por el caller, no devuelven `Vec` nuevos.
+//! - **P3 Una draw call por capa.** Acá no se dibuja; pero los
+//! tipos exponen slices contiguos listos para mandar al GPU
+//! sin copia.
+//!
+//! Convención de coordenadas: el buffer canónico es interleaved
+//! `[x0, y0, x1, y1, ...]`. Esto es el format que `drawRawPoints`,
+//! `Vertices.raw`, `wgpu` vertex buffers y `<polyline points>` SVG
+//! consumen sin transformación.
+
+#![forbid(unsafe_code)]
+
+pub mod buffer;
+pub mod ring;
+pub mod spatial;
+pub mod lttb;
+pub mod scale;
+
+// Algoritmos de layout: cada uno vive en el crate de la viz que lo
+// usa, no acá. Esto es deliberado — `pineal-core` no debe arrastrar
+// dependencias de visualización.
+//
+// - Barnes-Hut + Sugiyama + tree layout: `pineal-mesh`.
+// - Squarified treemap: `pineal-treemap`.
+// - Sankey layered: `pineal-flow`.
+// - FDEB (Force-Directed Edge Bundling): roadmap, vendrá a `pineal-mesh`.
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<usize>) {
+ let n = coords.len() / 2;
+ if n == 0 {
+ return;
+ }
+ if n <= target || target < 3 {
+ out.extend(0..n);
+ return;
+ }
+ lttb_in_range_indices(coords, 0, n, target, out);
+}
+
+/// Variante que opera sobre el rango `[start, end)` de un buffer
+/// más grande. Los índices devueltos son **absolutos** (relativos
+/// al `coords` original), no al sub-rango — esto le ahorra al caller
+/// la corrección de offset después de un `SpatialIndex::range`.
+pub fn lttb_in_range_indices(
+ coords: &[f32],
+ start: usize,
+ end: usize,
+ target: usize,
+ out: &mut Vec<usize>,
+) {
+ debug_assert!(coords.len() % 2 == 0);
+ debug_assert!(start <= end && end <= coords.len() / 2);
+
+ let len = end - start;
+ if len == 0 {
+ return;
+ }
+ if len <= target || target < 3 {
+ out.extend(start..end);
+ return;
+ }
+
+ // Primero el extremo izquierdo.
+ out.push(start);
+
+ let bucket_size = (len - 2) as f64 / (target - 2) as f64;
+ let mut a = start; // último punto elegido
+
+ for i in 0..target - 2 {
+ // Bucket actual y siguiente, en índices absolutos.
+ let cur_lo = start + 1 + (i as f64 * bucket_size).floor() as usize;
+ let cur_hi = start + 1 + ((i + 1) as f64 * bucket_size).floor() as usize;
+ let next_lo = cur_hi.min(end);
+ let next_hi = (start + 1 + ((i + 2) as f64 * bucket_size).floor() as usize).min(end);
+
+ // Centroide del bucket siguiente. Si está vacío, fallback
+ // al último punto.
+ let (avg_x, avg_y) = if next_hi > next_lo {
+ let span = (next_hi - next_lo) as f32;
+ let mut sx = 0.0f32;
+ let mut sy = 0.0f32;
+ for j in next_lo..next_hi {
+ sx += coords[j * 2];
+ sy += coords[j * 2 + 1];
+ }
+ (sx / span, sy / span)
+ } else {
+ (coords[(end - 1) * 2], coords[(end - 1) * 2 + 1])
+ };
+
+ let ax = coords[a * 2];
+ let ay = coords[a * 2 + 1];
+
+ let mut max_area = -1.0f32;
+ let mut max_idx = cur_lo;
+ for j in cur_lo..cur_hi.min(end) {
+ let bx = coords[j * 2];
+ let by = coords[j * 2 + 1];
+ // Área del triángulo (sin /2 porque comparamos relativos).
+ let area = ((ax - avg_x) * (by - ay) - (ax - bx) * (avg_y - ay)).abs();
+ if area > max_area {
+ max_area = area;
+ max_idx = j;
+ }
+ }
+ out.push(max_idx);
+ a = max_idx;
+ }
+
+ // Extremo derecho.
+ out.push(end - 1);
+}
+
+/// Variante que materializa coords decimadas directamente — útil
+/// cuando el painter sólo quiere un slice listo para `drawRawPoints`
+/// y no necesita los índices.
+pub fn lttb_coords(coords: &[f32], target: usize, out: &mut Vec<f32>) {
+ let mut idx_buf: Vec<usize> = Vec::with_capacity(target);
+ lttb_indices(coords, target, &mut idx_buf);
+ for i in idx_buf {
+ out.push(coords[i * 2]);
+ out.push(coords[i * 2 + 1]);
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn no_decimate_si_n_menor_que_target() {
+ let coords: Vec<f32> = (0..5).flat_map(|i| [i as f32, (i * i) as f32]).collect();
+ let mut out = Vec::new();
+ lttb_indices(&coords, 10, &mut out);
+ assert_eq!(out, vec![0, 1, 2, 3, 4]);
+ }
+
+ #[test]
+ fn extremos_preservados() {
+ let n = 100;
+ let coords: Vec<f32> = (0..n).flat_map(|i| [i as f32, (i as f32).sin()]).collect();
+ let mut out = Vec::new();
+ lttb_indices(&coords, 10, &mut out);
+ assert_eq!(out.first(), Some(&0));
+ assert_eq!(out.last(), Some(&(n - 1)));
+ assert!(out.len() <= 10);
+ }
+
+ #[test]
+ fn indices_sorted_y_unicos() {
+ let coords: Vec<f32> = (0..1000)
+ .flat_map(|i| [i as f32, (i as f32 * 0.01).sin()])
+ .collect();
+ let mut out = Vec::new();
+ lttb_indices(&coords, 50, &mut out);
+ for w in out.windows(2) {
+ assert!(w[0] < w[1], "indices deben ser estrictamente crecientes");
+ }
+ }
+
+ #[test]
+ fn in_range_indices_son_absolutos() {
+ let n = 100;
+ let coords: Vec<f32> = (0..n).flat_map(|i| [i as f32, i as f32]).collect();
+ let mut out = Vec::new();
+ lttb_in_range_indices(&coords, 20, 80, 10, &mut out);
+ assert_eq!(out.first(), Some(&20));
+ assert_eq!(out.last(), Some(&79));
+ // ningún índice fuera del rango pedido
+ for &i in &out {
+ assert!(i >= 20 && i < 80);
+ }
+ }
+
+ #[test]
+ fn preserva_picos_extremos() {
+ // Señal plana con un pico al medio: LTTB debe agarrar el pico.
+ let mut coords: Vec<f32> = Vec::new();
+ for i in 0..200 {
+ coords.push(i as f32);
+ coords.push(if i == 100 { 10.0 } else { 0.0 });
+ }
+ let mut out = Vec::new();
+ lttb_indices(&coords, 20, &mut out);
+ assert!(out.contains(&100), "pico debe sobrevivir el downsample");
+ }
+}
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<f32>,
+ /// `[x_norm, y_value]` por slot. `x_norm = slot / (cap - 1)`,
+ /// fijo. `y_value` = `values[slot]`.
+ coords: Vec<f32>,
+ capacity: usize,
+ /// Próximo slot a escribir.
+ head: usize,
+ /// Monotonic, sobrevive wraparound. Útil para anclar
+ /// anotaciones por sample index absoluto.
+ count: u64,
+ revision: u64,
+}
+
+impl RingBuffer {
+ /// Asume `capacity >= 2` para que `x_norm` no divida por cero.
+ pub fn new(capacity: usize) -> Self {
+ assert!(capacity >= 2, "RingBuffer requiere capacity >= 2");
+ let mut coords = vec![0.0; capacity * 2];
+ let denom = (capacity - 1) as f32;
+ for slot in 0..capacity {
+ coords[slot * 2] = slot as f32 / denom;
+ }
+ Self {
+ values: vec![0.0; capacity],
+ coords,
+ capacity,
+ head: 0,
+ count: 0,
+ revision: 0,
+ }
+ }
+
+ pub fn push(&mut self, v: f32) {
+ self.values[self.head] = v;
+ self.coords[self.head * 2 + 1] = v;
+ self.head = (self.head + 1) % self.capacity;
+ self.count = self.count.wrapping_add(1);
+ self.revision = self.revision.wrapping_add(1);
+ }
+
+ /// Inserción en batch con dos memcpys (cola + wrap-around).
+ /// Para batches > capacity se queda con los últimos `capacity`
+ /// samples (los anteriores se sobreescribirían igual).
+ pub fn push_all(&mut self, batch: &[f32]) {
+ if batch.is_empty() {
+ return;
+ }
+
+ let cap = self.capacity;
+ let src = if batch.len() > cap {
+ &batch[batch.len() - cap..]
+ } else {
+ batch
+ };
+
+ let tail = cap - self.head;
+ if src.len() <= tail {
+ self.values[self.head..self.head + src.len()].copy_from_slice(src);
+ for (i, v) in src.iter().enumerate() {
+ self.coords[(self.head + i) * 2 + 1] = *v;
+ }
+ self.head = (self.head + src.len()) % cap;
+ } else {
+ let (a, b) = src.split_at(tail);
+ self.values[self.head..].copy_from_slice(a);
+ for (i, v) in a.iter().enumerate() {
+ self.coords[(self.head + i) * 2 + 1] = *v;
+ }
+ self.values[..b.len()].copy_from_slice(b);
+ for (i, v) in b.iter().enumerate() {
+ self.coords[i * 2 + 1] = *v;
+ }
+ self.head = b.len();
+ }
+
+ self.count = self.count.wrapping_add(src.len() as u64);
+ self.revision = self.revision.wrapping_add(1);
+ }
+
+ pub fn capacity(&self) -> usize {
+ self.capacity
+ }
+
+ pub fn head(&self) -> usize {
+ self.head
+ }
+
+ pub fn count(&self) -> u64 {
+ self.count
+ }
+
+ pub fn revision(&self) -> u64 {
+ self.revision
+ }
+
+ pub fn is_full(&self) -> bool {
+ self.count >= self.capacity as u64
+ }
+
+ /// Cantidad de slots con datos reales. Antes del fill es
+ /// `head`; después es `capacity`. El painter clipea a este
+ /// valor para evitar el flicker del pre-fill.
+ pub fn filled_len(&self) -> usize {
+ if self.is_full() {
+ self.capacity
+ } else {
+ self.head
+ }
+ }
+
+ /// Slice interleaved de `[x_norm, y]`. Para render en dos
+ /// segmentos: `&coords()[..head*2]` y `&coords()[head*2..]`
+ /// (cuando is_full).
+ pub fn coords(&self) -> &[f32] {
+ &self.coords
+ }
+
+ /// Slice plana de samples raw — útil para downsample envelope
+ /// min/max sin pasar por coords.
+ pub fn values(&self) -> &[f32] {
+ &self.values
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn x_norm_precomputado() {
+ let r = RingBuffer::new(4);
+ // x_norm en slots 0, 1, 2, 3 = 0.0, 1/3, 2/3, 1.0
+ assert!((r.coords()[0] - 0.0).abs() < 1e-6);
+ assert!((r.coords()[2] - 1.0 / 3.0).abs() < 1e-6);
+ assert!((r.coords()[6] - 1.0).abs() < 1e-6);
+ }
+
+ #[test]
+ fn push_actualiza_y_no_x() {
+ let mut r = RingBuffer::new(4);
+ r.push(5.0);
+ r.push(7.0);
+ // slot 0 → y=5, slot 1 → y=7, x quedó igual
+ assert_eq!(r.coords()[1], 5.0);
+ assert_eq!(r.coords()[3], 7.0);
+ assert!((r.coords()[2] - 1.0 / 3.0).abs() < 1e-6);
+ assert_eq!(r.head(), 2);
+ assert_eq!(r.count(), 2);
+ }
+
+ #[test]
+ fn filled_len_bloquea_prefill() {
+ let mut r = RingBuffer::new(4);
+ assert_eq!(r.filled_len(), 0);
+ r.push(1.0);
+ r.push(2.0);
+ assert_eq!(r.filled_len(), 2);
+ r.push(3.0);
+ r.push(4.0);
+ assert_eq!(r.filled_len(), 4);
+ r.push(5.0); // wrap
+ assert_eq!(r.filled_len(), 4);
+ assert!(r.is_full());
+ }
+
+ #[test]
+ fn push_all_wrap_around() {
+ let mut r = RingBuffer::new(4);
+ r.push_all(&[1.0, 2.0, 3.0]); // head=3
+ r.push_all(&[4.0, 5.0, 6.0]); // wrap: 4 en slot 3, 5 en slot 0, 6 en slot 1
+ assert_eq!(r.values()[3], 4.0);
+ assert_eq!(r.values()[0], 5.0);
+ assert_eq!(r.values()[1], 6.0);
+ assert_eq!(r.head(), 2);
+ assert_eq!(r.count(), 6);
+ }
+
+ #[test]
+ fn push_all_oversized_se_queda_con_la_cola() {
+ let mut r = RingBuffer::new(4);
+ r.push_all(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0]);
+ // Sólo los últimos 4 importan: [6,7,8,9]
+ assert_eq!(r.values()[0], 6.0);
+ assert_eq!(r.values()[1], 7.0);
+ assert_eq!(r.values()[2], 8.0);
+ assert_eq!(r.values()[3], 9.0);
+ assert!(r.is_full());
+ }
+}
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<usize> {
+ let n = self.len();
+ if n == 0 {
+ return None;
+ }
+ // Binary search sobre la columna X.
+ let mut lo = 0usize;
+ let mut hi = n;
+ while lo < hi {
+ let mid = (lo + hi) / 2;
+ if self.coords[mid * 2] < target_x {
+ lo = mid + 1;
+ } else {
+ hi = mid;
+ }
+ }
+ // `lo` es la primera X >= target_x. El más cercano es lo o lo-1.
+ if lo == 0 {
+ Some(0)
+ } else if lo >= n {
+ Some(n - 1)
+ } else {
+ let prev = lo - 1;
+ let dx_prev = target_x - self.coords[prev * 2];
+ let dx_next = self.coords[lo * 2] - target_x;
+ if dx_prev <= dx_next {
+ Some(prev)
+ } else {
+ Some(lo)
+ }
+ }
+ }
+
+ /// Rango `[start, end)` de puntos con X en `[x_min, x_max]`.
+ /// Útil para clip-to-viewport antes de LTTB.
+ pub fn range(&self, x_min: f32, x_max: f32) -> (usize, usize) {
+ let n = self.len();
+ if n == 0 {
+ return (0, 0);
+ }
+ // lower bound: primer i con coords[i*2] >= x_min
+ let start = {
+ let mut lo = 0usize;
+ let mut hi = n;
+ while lo < hi {
+ let mid = (lo + hi) / 2;
+ if self.coords[mid * 2] < x_min {
+ lo = mid + 1;
+ } else {
+ hi = mid;
+ }
+ }
+ lo
+ };
+ // upper bound: primer i con coords[i*2] > x_max
+ let end = {
+ let mut lo = start;
+ let mut hi = n;
+ while lo < hi {
+ let mid = (lo + hi) / 2;
+ if self.coords[mid * 2] <= x_max {
+ lo = mid + 1;
+ } else {
+ hi = mid;
+ }
+ }
+ lo
+ };
+ (start, end)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn fixture() -> Vec<f32> {
+ // x: 0, 1, 3, 5, 8 — y irrelevante.
+ vec![0.0, 0.0, 1.0, 0.0, 3.0, 0.0, 5.0, 0.0, 8.0, 0.0]
+ }
+
+ #[test]
+ fn nearest_dentro() {
+ let c = fixture();
+ let s = SpatialIndex::new(&c);
+ assert_eq!(s.nearest(0.0), Some(0));
+ assert_eq!(s.nearest(2.0), Some(1)); // 1 está más cerca que 3
+ assert_eq!(s.nearest(2.5), Some(2)); // 3 está más cerca que 1
+ assert_eq!(s.nearest(8.0), Some(4));
+ }
+
+ #[test]
+ fn nearest_fuera_clamp() {
+ let c = fixture();
+ let s = SpatialIndex::new(&c);
+ assert_eq!(s.nearest(-10.0), Some(0));
+ assert_eq!(s.nearest(99.0), Some(4));
+ }
+
+ #[test]
+ fn nearest_empty() {
+ let empty: [f32; 0] = [];
+ assert_eq!(SpatialIndex::new(&empty).nearest(0.0), None);
+ }
+
+ #[test]
+ fn range_clip() {
+ let c = fixture();
+ let s = SpatialIndex::new(&c);
+ assert_eq!(s.range(1.0, 5.0), (1, 4)); // incluye x=1,3,5
+ assert_eq!(s.range(2.0, 4.0), (2, 3)); // sólo x=3
+ assert_eq!(s.range(-1.0, 100.0), (0, 5));
+ assert_eq!(s.range(10.0, 20.0), (5, 5));
+ }
+}
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 (`<path>`/`<rect>`/`<polygon>`…).
+//! - [`png`] — exporter PNG raster, rasterizador software propio que
+//! replayea cada `RenderCmd` sobre un buffer RGBA8. Sin deps nativas.
+//! - [`pdf`] — exporter PDF mínimo, writer propio (sin `printpdf`).
+//! 1 página, content stream con operadores básicos PDF-1.4.
+
+#![forbid(unsafe_code)]
+
+pub mod svg;
+pub mod png;
+pub mod pdf;
+
+pub use svg::to_svg;
+pub use crate::png::to_png;
+pub use crate::pdf::{to_pdf, to_pdf_decimated};
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 `<text>`. PDF embedeable de texto
+//! requiere font subsetting, complejo y no es lo que se necesita para
+//! reportes de chart.
+//!
+//! Sin compresión flate / sin streams comprimidos: cada export queda
+//! human-readable y debugeable. Tamaño aceptable para los volúmenes
+//! que un chart produce.
+
+use pineal_core::lttb::lttb_indices;
+use pineal_render::{Color, Rect, RenderCmd, RenderPlan};
+use std::fmt::Write;
+
+/// Convierte un `RenderPlan` a un PDF de página única `width × height`
+/// (en puntos PDF, 72 dpi). Devuelve los bytes del documento.
+pub fn to_pdf(plan: &RenderPlan, width: f32, height: f32) -> Vec<u8> {
+ let content = build_content(plan, height);
+ assemble(width, height, &content)
+}
+
+/// Igual que [`to_pdf`] pero aplica decimación LTTB a los polylines
+/// antes de emitirlos. El target se computa según el DPI destino:
+///
+/// ```text
+/// vertices_target = width_inches × dpi × vertices_per_pixel
+/// ```
+///
+/// `vertices_per_pixel` es 3 (regla práctica del LTTB: tres vértices por
+/// pixel — el rasterizado/AA del visor rellena el resto). Para un PDF
+/// US-letter a 300 DPI: `target = 8.5 × 300 × 3 = 7 650` vértices máx
+/// por polyline. Rects, líneas, triangle-strips y texto pasan intactos.
+pub fn to_pdf_decimated(
+ plan: &RenderPlan,
+ width: f32,
+ height: f32,
+ dpi: f32,
+) -> Vec<u8> {
+ let width_inches = width / 72.0;
+ let target = (width_inches * dpi * 3.0).max(16.0) as usize;
+ let decimated = decimate_polylines(plan, target);
+ to_pdf(&decimated, width, height)
+}
+
+/// Aplica LTTB a cada `StrokePolyline` del plan, dejando el resto
+/// intacto. Si la polyline ya está por debajo del target, queda igual.
+fn decimate_polylines(plan: &RenderPlan, target: usize) -> RenderPlan {
+ let mut out = RenderPlan::new();
+ for cmd in &plan.cmds {
+ match cmd {
+ RenderCmd::StrokePolyline { coords, stroke } => {
+ let n = coords.len() / 2;
+ if n <= target {
+ out.push(RenderCmd::StrokePolyline {
+ coords: coords.clone(),
+ stroke: *stroke,
+ });
+ continue;
+ }
+ let mut indices = Vec::with_capacity(target);
+ lttb_indices(coords, target, &mut indices);
+ let mut new_coords = Vec::with_capacity(indices.len() * 2);
+ for &i in &indices {
+ new_coords.push(coords[i * 2]);
+ new_coords.push(coords[i * 2 + 1]);
+ }
+ out.push(RenderCmd::StrokePolyline {
+ coords: new_coords,
+ stroke: *stroke,
+ });
+ }
+ other => out.push(other.clone()),
+ }
+ }
+ out
+}
+
+
+fn build_content(plan: &RenderPlan, page_h: f32) -> String {
+ let mut s = String::with_capacity(plan.cmds.len() * 64 + 128);
+ // Línea por default.
+ let mut current_fill = Color::TRANSPARENT;
+ let mut current_stroke = Color::TRANSPARENT;
+ let mut current_width = -1.0f32;
+ for cmd in &plan.cmds {
+ match cmd {
+ RenderCmd::PushClip(r) => {
+ let (x, y, w, h) = rect_to_pdf(*r, page_h);
+ let _ = writeln!(s, "q");
+ let _ = writeln!(s, "{} {} {} {} re W n", x, y, w, h);
+ }
+ RenderCmd::PopClip => {
+ let _ = writeln!(s, "Q");
+ }
+ RenderCmd::FillRect { rect, color } => {
+ set_fill(&mut s, *color, &mut current_fill);
+ let (x, y, w, h) = rect_to_pdf(*rect, page_h);
+ let _ = writeln!(s, "{} {} {} {} re f", x, y, w, h);
+ }
+ RenderCmd::StrokeRect { rect, stroke } => {
+ set_stroke(&mut s, stroke.color, &mut current_stroke);
+ set_width(&mut s, stroke.width, &mut current_width);
+ let (x, y, w, h) = rect_to_pdf(*rect, page_h);
+ let _ = writeln!(s, "{} {} {} {} re S", x, y, w, h);
+ }
+ RenderCmd::StrokeLine { a, b, stroke } => {
+ set_stroke(&mut s, stroke.color, &mut current_stroke);
+ set_width(&mut s, stroke.width, &mut current_width);
+ let _ = writeln!(s, "{} {} m {} {} l S", a.x, page_h - a.y, b.x, page_h - b.y);
+ }
+ RenderCmd::StrokePolyline { coords, stroke } => {
+ if coords.len() < 4 {
+ continue;
+ }
+ set_stroke(&mut s, stroke.color, &mut current_stroke);
+ set_width(&mut s, stroke.width, &mut current_width);
+ let _ = writeln!(s, "{} {} m", coords[0], page_h - coords[1]);
+ let mut i = 2;
+ while i + 1 < coords.len() {
+ let _ = writeln!(s, "{} {} l", coords[i], page_h - coords[i + 1]);
+ i += 2;
+ }
+ let _ = writeln!(s, "S");
+ }
+ RenderCmd::FillTriangleStrip { coords, colors } => {
+ let n = coords.len() / 2;
+ if n < 3 {
+ continue;
+ }
+ for t in 0..n - 2 {
+ let avg = avg_color(&[
+ colors.get(t).copied(),
+ colors.get(t + 1).copied(),
+ colors.get(t + 2).copied(),
+ ]);
+ set_fill(&mut s, avg, &mut current_fill);
+ let p0 = (coords[t * 2], coords[t * 2 + 1]);
+ let p1 = (coords[(t + 1) * 2], coords[(t + 1) * 2 + 1]);
+ let p2 = (coords[(t + 2) * 2], coords[(t + 2) * 2 + 1]);
+ let _ = writeln!(
+ s,
+ "{} {} m {} {} l {} {} l h f",
+ p0.0,
+ page_h - p0.1,
+ p1.0,
+ page_h - p1.1,
+ p2.0,
+ page_h - p2.1
+ );
+ }
+ }
+ // DrawText: skip a propósito. SVG cubre vectorial-con-texto.
+ RenderCmd::DrawText { .. } => {}
+ }
+ }
+ s
+}
+
+fn rect_to_pdf(r: Rect, page_h: f32) -> (f32, f32, f32, f32) {
+ // PDF rect: x, y, w, h donde (x, y) es la esquina inferior-izquierda.
+ let pdf_y = page_h - r.y - r.h;
+ (r.x, pdf_y, r.w, r.h)
+}
+
+fn set_fill(s: &mut String, c: Color, current: &mut Color) {
+ if !same_color(*current, c) {
+ let _ = writeln!(s, "{} {} {} rg", c.r.clamp(0.0, 1.0), c.g.clamp(0.0, 1.0), c.b.clamp(0.0, 1.0));
+ *current = c;
+ }
+}
+
+fn set_stroke(s: &mut String, c: Color, current: &mut Color) {
+ if !same_color(*current, c) {
+ let _ = writeln!(s, "{} {} {} RG", c.r.clamp(0.0, 1.0), c.g.clamp(0.0, 1.0), c.b.clamp(0.0, 1.0));
+ *current = c;
+ }
+}
+
+fn set_width(s: &mut String, w: f32, current: &mut f32) {
+ let w = w.max(0.0);
+ if (*current - w).abs() > 1e-4 {
+ let _ = writeln!(s, "{} w", w);
+ *current = w;
+ }
+}
+
+fn same_color(a: Color, b: Color) -> bool {
+ (a.r - b.r).abs() < 1e-4
+ && (a.g - b.g).abs() < 1e-4
+ && (a.b - b.b).abs() < 1e-4
+ && (a.a - b.a).abs() < 1e-4
+}
+
+fn avg_color(cs: &[Option<Color>]) -> Color {
+ let mut acc = Color::rgba(0.0, 0.0, 0.0, 0.0);
+ let mut n = 0.0;
+ for c in cs.iter().flatten() {
+ acc.r += c.r;
+ acc.g += c.g;
+ acc.b += c.b;
+ acc.a += c.a;
+ n += 1.0;
+ }
+ if n == 0.0 {
+ return Color::TRANSPARENT;
+ }
+ Color::rgba(acc.r / n, acc.g / n, acc.b / n, acc.a / n)
+}
+
+/// Ensambla el documento PDF: header, 4 objetos (catalog, pages, page,
+/// content stream), xref y trailer. Cada offset se va anotando para el
+/// xref final.
+fn assemble(width: f32, height: f32, content: &str) -> Vec<u8> {
+ let mut buf: Vec<u8> = Vec::with_capacity(content.len() + 512);
+ let mut offsets: Vec<usize> = Vec::with_capacity(5);
+
+ buf.extend_from_slice(b"%PDF-1.4\n");
+ // Comentario binario obligatorio para PDFs que mezclan binario.
+ buf.extend_from_slice(b"%\xE2\xE3\xCF\xD3\n");
+
+ offsets.push(buf.len());
+ buf.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
+
+ offsets.push(buf.len());
+ buf.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n");
+
+ offsets.push(buf.len());
+ let page = format!(
+ "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {w} {h}] /Contents 4 0 R \
+ /Resources << >> >>\nendobj\n",
+ w = width,
+ h = height,
+ );
+ buf.extend_from_slice(page.as_bytes());
+
+ offsets.push(buf.len());
+ let content_bytes = content.as_bytes();
+ let stream_header = format!("4 0 obj\n<< /Length {} >>\nstream\n", content_bytes.len());
+ buf.extend_from_slice(stream_header.as_bytes());
+ buf.extend_from_slice(content_bytes);
+ buf.extend_from_slice(b"\nendstream\nendobj\n");
+
+ // xref.
+ let xref_offset = buf.len();
+ buf.extend_from_slice(b"xref\n0 5\n");
+ buf.extend_from_slice(b"0000000000 65535 f \n");
+ for off in &offsets {
+ let line = format!("{:010} 00000 n \n", off);
+ buf.extend_from_slice(line.as_bytes());
+ }
+ // Trailer.
+ let trailer = format!(
+ "trailer\n<< /Size 5 /Root 1 0 R >>\nstartxref\n{}\n%%EOF\n",
+ xref_offset
+ );
+ buf.extend_from_slice(trailer.as_bytes());
+ buf
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use pineal_render::{Canvas, Color, Point, StrokeStyle};
+
+ fn sample_plan() -> RenderPlan {
+ let mut rec = pineal_render::PlanRecorder::new();
+ rec.fill_rect(Rect::new(10.0, 20.0, 100.0, 60.0), Color::from_hex(0xff0000));
+ rec.stroke_line(
+ Point::new(0.0, 0.0),
+ Point::new(200.0, 100.0),
+ StrokeStyle::new(2.0, Color::BLACK),
+ );
+ rec.into_plan()
+ }
+
+ #[test]
+ fn pdf_starts_with_header() {
+ let pdf = to_pdf(&sample_plan(), 300.0, 200.0);
+ assert!(pdf.starts_with(b"%PDF-1.4"));
+ }
+
+ #[test]
+ fn pdf_ends_with_eof() {
+ let pdf = to_pdf(&sample_plan(), 300.0, 200.0);
+ let tail = &pdf[pdf.len().saturating_sub(8)..];
+ assert!(tail.windows(5).any(|w| w == b"%%EOF"));
+ }
+
+ #[test]
+ fn pdf_contains_required_objects() {
+ let pdf = to_pdf(&sample_plan(), 300.0, 200.0);
+ let s = String::from_utf8_lossy(&pdf);
+ assert!(s.contains("/Type /Catalog"));
+ assert!(s.contains("/Type /Pages"));
+ assert!(s.contains("/Type /Page "));
+ assert!(s.contains("/MediaBox [0 0 300 200]"));
+ assert!(s.contains("xref"));
+ assert!(s.contains("startxref"));
+ }
+
+ #[test]
+ fn fill_rect_emits_re_f() {
+ let pdf = to_pdf(&sample_plan(), 300.0, 200.0);
+ let s = String::from_utf8_lossy(&pdf);
+ // rg para fill, re y f para rect+fill.
+ assert!(s.contains("1 0 0 rg"));
+ assert!(s.contains("re f"));
+ }
+
+ #[test]
+ fn stroke_line_emits_m_l_s() {
+ let pdf = to_pdf(&sample_plan(), 300.0, 200.0);
+ let s = String::from_utf8_lossy(&pdf);
+ assert!(s.contains(" m"));
+ assert!(s.contains(" l"));
+ assert!(s.contains("S\n") || s.contains("S\r"));
+ }
+
+ #[test]
+ fn empty_plan_still_well_formed() {
+ let pdf = to_pdf(&RenderPlan::default(), 100.0, 50.0);
+ assert!(pdf.starts_with(b"%PDF"));
+ let s = String::from_utf8_lossy(&pdf);
+ assert!(s.contains("%%EOF"));
+ }
+
+ #[test]
+ fn triangle_strip_emits_polygon_per_triangle() {
+ let mut rec = pineal_render::PlanRecorder::new();
+ rec.fill_triangle_strip(
+ &[0.0, 0.0, 10.0, 0.0, 5.0, 10.0, 15.0, 10.0],
+ &[Color::WHITE; 4],
+ );
+ let pdf = to_pdf(&rec.into_plan(), 50.0, 50.0);
+ let s = String::from_utf8_lossy(&pdf);
+ // 4 vértices → 2 triángulos → 2 polígonos (m + 2 l + h + f).
+ assert_eq!(s.matches(" h f").count(), 2);
+ }
+
+ #[test]
+ fn decimated_polyline_shrinks_to_target() {
+ let mut rec = pineal_render::PlanRecorder::new();
+ // 1000 vértices en una polyline (datos sintéticos).
+ let coords: Vec<f32> = (0..1000)
+ .flat_map(|i| {
+ let x = i as f32;
+ let y = (x * 0.1).sin() * 50.0 + 100.0;
+ [x, y]
+ })
+ .collect();
+ rec.stroke_polyline(&coords, StrokeStyle::new(1.0, Color::BLACK));
+ let plan = rec.into_plan();
+
+ // PDF de 200×100 pts (≈ 2.78 inches) a 72 DPI →
+ // target = 2.78 × 72 × 3 ≈ 600.
+ let pdf = to_pdf_decimated(&plan, 200.0, 100.0, 72.0);
+ let s = String::from_utf8_lossy(&pdf);
+ let moveto_count = s.matches(" m\n").count() + s.matches(" m ").count();
+ let lineto_count = s.matches(" l\n").count();
+ // 1 moveto + (N-1) linetos. Target < 1000 → debería ser menos
+ // que el original.
+ assert!(moveto_count >= 1);
+ assert!(
+ lineto_count < 999,
+ "esperaba decimación, hubo {lineto_count} linetos"
+ );
+ }
+
+ #[test]
+ fn decimation_preserves_other_primitives() {
+ let mut rec = pineal_render::PlanRecorder::new();
+ rec.fill_rect(Rect::new(0.0, 0.0, 50.0, 50.0), Color::WHITE);
+ let coords: Vec<f32> = (0..200).flat_map(|i| [i as f32, 0.0]).collect();
+ rec.stroke_polyline(&coords, StrokeStyle::new(1.0, Color::BLACK));
+ let pdf = to_pdf_decimated(&rec.into_plan(), 100.0, 100.0, 300.0);
+ let s = String::from_utf8_lossy(&pdf);
+ // Rect intacto.
+ assert!(s.contains("re f"));
+ }
+
+ #[test]
+ fn clip_uses_q_and_Q_blocks() {
+ let mut rec = pineal_render::PlanRecorder::new();
+ rec.push_clip(Rect::new(0.0, 0.0, 50.0, 50.0));
+ rec.fill_rect(Rect::new(10.0, 10.0, 20.0, 20.0), Color::BLACK);
+ rec.pop_clip();
+ let pdf = to_pdf(&rec.into_plan(), 100.0, 100.0);
+ let s = String::from_utf8_lossy(&pdf);
+ // q al inicio del clip + W n para fijarlo + Q al cerrar.
+ assert!(s.contains("q\n"));
+ assert!(s.contains(" W n"));
+ assert!(s.contains("Q\n"));
+ }
+}
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 `<text>` cuando se quiere vectorial.
+//! - `PushClip` / `PopClip` — stack de clip-rect activo, AND con el
+//! bound del buffer al escribir.
+//!
+//! Composición: source-over premultiplicado. Suficiente para overlays
+//! semitransparentes (radar fill, palette ramps).
+
+use pineal_render::{Color, Rect, RenderCmd, RenderPlan};
+use std::io::Cursor;
+
+/// Convierte un `RenderPlan` a PNG (bytes). `bg` es el color con que se
+/// inicializa el canvas (alpha incluido — `Color::TRANSPARENT` da fondo
+/// transparente). Devuelve `Err` si la codificación PNG falla.
+pub fn to_png(
+ plan: &RenderPlan,
+ width: u32,
+ height: u32,
+ bg: Color,
+) -> Result<Vec<u8>, png::EncodingError> {
+ let mut buf = RasterBuffer::new(width, height, bg);
+ let mut clip_stack: Vec<Rect> = Vec::new();
+ for cmd in &plan.cmds {
+ replay(cmd, &mut buf, &mut clip_stack);
+ }
+ encode_png(&buf)
+}
+
+/// Buffer RGBA8 row-major, +Y hacia abajo (igual que `RenderPlan`).
+struct RasterBuffer {
+ pixels: Vec<u8>,
+ w: u32,
+ h: u32,
+}
+
+impl RasterBuffer {
+ fn new(w: u32, h: u32, bg: Color) -> Self {
+ let r = (bg.r.clamp(0.0, 1.0) * 255.0).round() as u8;
+ let g = (bg.g.clamp(0.0, 1.0) * 255.0).round() as u8;
+ let b = (bg.b.clamp(0.0, 1.0) * 255.0).round() as u8;
+ let a = (bg.a.clamp(0.0, 1.0) * 255.0).round() as u8;
+ let mut pixels = Vec::with_capacity((w * h * 4) as usize);
+ for _ in 0..(w * h) {
+ pixels.extend_from_slice(&[r, g, b, a]);
+ }
+ Self { pixels, w, h }
+ }
+
+ /// Source-over con premultiplicación. `x`/`y` ya deben caer dentro.
+ #[inline]
+ fn blend(&mut self, x: u32, y: u32, c: Color) {
+ let idx = ((y * self.w + x) * 4) as usize;
+ let sa = c.a.clamp(0.0, 1.0);
+ if sa <= 0.0 {
+ return;
+ }
+ let sr = c.r.clamp(0.0, 1.0) * sa;
+ let sg = c.g.clamp(0.0, 1.0) * sa;
+ let sb = c.b.clamp(0.0, 1.0) * sa;
+ let inv = 1.0 - sa;
+ let dr = self.pixels[idx] as f32 / 255.0;
+ let dg = self.pixels[idx + 1] as f32 / 255.0;
+ let db = self.pixels[idx + 2] as f32 / 255.0;
+ let da = self.pixels[idx + 3] as f32 / 255.0;
+ let or = sr + dr * inv;
+ let og = sg + dg * inv;
+ let ob = sb + db * inv;
+ let oa = sa + da * inv;
+ self.pixels[idx] = (or.clamp(0.0, 1.0) * 255.0).round() as u8;
+ self.pixels[idx + 1] = (og.clamp(0.0, 1.0) * 255.0).round() as u8;
+ self.pixels[idx + 2] = (ob.clamp(0.0, 1.0) * 255.0).round() as u8;
+ self.pixels[idx + 3] = (oa.clamp(0.0, 1.0) * 255.0).round() as u8;
+ }
+
+ /// Llena `rect` (en pixels) con `color`, intersectándolo primero con
+ /// el clip actual y el bound del buffer.
+ fn fill_rect(&mut self, rect: Rect, color: Color, clip: Option<Rect>) {
+ let bound = self.bound();
+ let mut r = intersect(rect, bound);
+ if let Some(c) = clip {
+ r = intersect(r, c);
+ }
+ if r.w <= 0.0 || r.h <= 0.0 {
+ return;
+ }
+ let x0 = r.x.floor() as u32;
+ let y0 = r.y.floor() as u32;
+ let x1 = (r.x + r.w).ceil() as u32;
+ let y1 = (r.y + r.h).ceil() as u32;
+ for y in y0..x1_clamped(y1, self.h) {
+ for x in x0..x1_clamped(x1, self.w) {
+ self.blend(x, y, color);
+ }
+ }
+ }
+
+ fn bound(&self) -> Rect {
+ Rect::new(0.0, 0.0, self.w as f32, self.h as f32)
+ }
+}
+
+fn x1_clamped(v: u32, max: u32) -> u32 {
+ if v > max {
+ max
+ } else {
+ v
+ }
+}
+
+fn intersect(a: Rect, b: Rect) -> Rect {
+ let x0 = a.x.max(b.x);
+ let y0 = a.y.max(b.y);
+ let x1 = (a.x + a.w).min(b.x + b.w);
+ let y1 = (a.y + a.h).min(b.y + b.h);
+ Rect::new(x0, y0, (x1 - x0).max(0.0), (y1 - y0).max(0.0))
+}
+
+fn replay(cmd: &RenderCmd, buf: &mut RasterBuffer, clip: &mut Vec<Rect>) {
+ let active_clip = clip.last().copied();
+ match cmd {
+ RenderCmd::PushClip(r) => {
+ // Push el AND con el clip anterior.
+ let new_clip = match active_clip {
+ Some(prev) => intersect(*r, prev),
+ None => *r,
+ };
+ clip.push(new_clip);
+ }
+ RenderCmd::PopClip => {
+ clip.pop();
+ }
+ RenderCmd::FillRect { rect, color } => {
+ buf.fill_rect(*rect, *color, active_clip);
+ }
+ RenderCmd::StrokeRect { rect, stroke } => {
+ let w = stroke.width.max(1.0);
+ // Top, bottom, left, right como rects rellenos.
+ buf.fill_rect(Rect::new(rect.x, rect.y, rect.w, w), stroke.color, active_clip);
+ buf.fill_rect(
+ Rect::new(rect.x, rect.y + rect.h - w, rect.w, w),
+ stroke.color,
+ active_clip,
+ );
+ buf.fill_rect(Rect::new(rect.x, rect.y, w, rect.h), stroke.color, active_clip);
+ buf.fill_rect(
+ Rect::new(rect.x + rect.w - w, rect.y, w, rect.h),
+ stroke.color,
+ active_clip,
+ );
+ }
+ RenderCmd::StrokeLine { a, b, stroke } => {
+ stroke_segment(buf, (a.x, a.y), (b.x, b.y), stroke.color, stroke.width, active_clip);
+ }
+ RenderCmd::StrokePolyline { coords, stroke } => {
+ for w in coords.chunks_exact(2).collect::<Vec<_>>().windows(2) {
+ stroke_segment(
+ buf,
+ (w[0][0], w[0][1]),
+ (w[1][0], w[1][1]),
+ stroke.color,
+ stroke.width,
+ active_clip,
+ );
+ }
+ }
+ RenderCmd::FillTriangleStrip { coords, colors } => {
+ let n = coords.len() / 2;
+ if n < 3 {
+ return;
+ }
+ for t in 0..n - 2 {
+ let p0 = (coords[t * 2], coords[t * 2 + 1]);
+ let p1 = (coords[(t + 1) * 2], coords[(t + 1) * 2 + 1]);
+ let p2 = (coords[(t + 2) * 2], coords[(t + 2) * 2 + 1]);
+ let avg = avg_color(&[
+ colors.get(t).copied(),
+ colors.get(t + 1).copied(),
+ colors.get(t + 2).copied(),
+ ]);
+ fill_triangle(buf, p0, p1, p2, avg, active_clip);
+ }
+ }
+ // DrawText: PNG export deliberadamente skipea texto. Ver doc del módulo.
+ RenderCmd::DrawText { .. } => {}
+ }
+}
+
+fn avg_color(cs: &[Option<Color>]) -> Color {
+ let mut acc = Color::rgba(0.0, 0.0, 0.0, 0.0);
+ let mut n = 0.0;
+ for c in cs.iter().flatten() {
+ acc.r += c.r;
+ acc.g += c.g;
+ acc.b += c.b;
+ acc.a += c.a;
+ n += 1.0;
+ }
+ if n == 0.0 {
+ return Color::TRANSPARENT;
+ }
+ Color::rgba(acc.r / n, acc.g / n, acc.b / n, acc.a / n)
+}
+
+/// Segmento expandido a un quad orientado (perpendicular ± width/2),
+/// rasterizado como dos triángulos.
+fn stroke_segment(
+ buf: &mut RasterBuffer,
+ a: (f32, f32),
+ b: (f32, f32),
+ color: Color,
+ width: f32,
+ clip: Option<Rect>,
+) {
+ let dx = b.0 - a.0;
+ let dy = b.1 - a.1;
+ let len = (dx * dx + dy * dy).sqrt();
+ if len < 1e-6 {
+ return;
+ }
+ let half = width.max(1.0) * 0.5;
+ // Perpendicular unitario.
+ let nx = -dy / len * half;
+ let ny = dx / len * half;
+ let p0 = (a.0 + nx, a.1 + ny);
+ let p1 = (a.0 - nx, a.1 - ny);
+ let p2 = (b.0 + nx, b.1 + ny);
+ let p3 = (b.0 - nx, b.1 - ny);
+ fill_triangle(buf, p0, p1, p2, color, clip);
+ fill_triangle(buf, p1, p3, p2, color, clip);
+}
+
+/// Scanline fill de un triángulo con AA por supersampling 2×2 (4 samples
+/// por pixel → coverage ∈ {0, ¼, ½, ¾, 1}). El color final se blendea
+/// multiplicando alpha por coverage. Edge ratio razonable: ~4× más
+/// cómputo que sample-único pero los bordes dejan de tener escalera.
+fn fill_triangle(
+ buf: &mut RasterBuffer,
+ a: (f32, f32),
+ b: (f32, f32),
+ c: (f32, f32),
+ color: Color,
+ clip: Option<Rect>,
+) {
+ let bound = buf.bound();
+ let active = match clip {
+ Some(c) => intersect(c, bound),
+ None => bound,
+ };
+ let min_x = a.0.min(b.0).min(c.0).max(active.x).floor() as i32;
+ let max_x = a.0.max(b.0).max(c.0).min(active.x + active.w).ceil() as i32;
+ let min_y = a.1.min(b.1).min(c.1).max(active.y).floor() as i32;
+ let max_y = a.1.max(b.1).max(c.1).min(active.y + active.h).ceil() as i32;
+ if max_x <= min_x || max_y <= min_y {
+ return;
+ }
+ let area = edge(a, b, c);
+ if area.abs() < 1e-6 {
+ return;
+ }
+ let sign = area.signum();
+ const SAMPLES: [(f32, f32); 4] = [(0.25, 0.25), (0.75, 0.25), (0.25, 0.75), (0.75, 0.75)];
+ for y in min_y..max_y {
+ for x in min_x..max_x {
+ let mut hits = 0u32;
+ for (sx, sy) in SAMPLES.iter() {
+ let p = (x as f32 + *sx, y as f32 + *sy);
+ let w0 = edge(b, c, p) * sign;
+ let w1 = edge(c, a, p) * sign;
+ let w2 = edge(a, b, p) * sign;
+ if w0 >= 0.0 && w1 >= 0.0 && w2 >= 0.0 {
+ hits += 1;
+ }
+ }
+ if hits == 0 {
+ continue;
+ }
+ let cov = hits as f32 / SAMPLES.len() as f32;
+ let blended = Color { a: color.a * cov, ..color };
+ buf.blend(x as u32, y as u32, blended);
+ }
+ }
+}
+
+#[inline]
+fn edge(a: (f32, f32), b: (f32, f32), c: (f32, f32)) -> f32 {
+ (b.0 - a.0) * (c.1 - a.1) - (b.1 - a.1) * (c.0 - a.0)
+}
+
+fn encode_png(buf: &RasterBuffer) -> Result<Vec<u8>, png::EncodingError> {
+ let mut out = Vec::with_capacity(buf.pixels.len() / 4);
+ {
+ let mut encoder = png::Encoder::new(Cursor::new(&mut out), buf.w, buf.h);
+ encoder.set_color(png::ColorType::Rgba);
+ encoder.set_depth(png::BitDepth::Eight);
+ let mut writer = encoder.write_header()?;
+ writer.write_image_data(&buf.pixels)?;
+ }
+ Ok(out)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use pineal_render::{Canvas, Color, Point, StrokeStyle};
+
+ fn record_one_rect() -> RenderPlan {
+ let mut rec = pineal_render::PlanRecorder::new();
+ rec.fill_rect(Rect::new(2.0, 2.0, 6.0, 6.0), Color::from_hex(0xff0000));
+ rec.into_plan()
+ }
+
+ #[test]
+ fn png_starts_with_magic() {
+ let bytes = to_png(&record_one_rect(), 16, 16, Color::WHITE).unwrap();
+ assert_eq!(&bytes[..8], b"\x89PNG\r\n\x1a\n");
+ }
+
+ #[test]
+ fn rect_is_painted_red_on_white_background() {
+ let bytes = to_png(&record_one_rect(), 16, 16, Color::WHITE).unwrap();
+ // Decodificar de vuelta y verificar un pixel dentro del rect (≈ 255,0,0)
+ // y otro fuera (255,255,255).
+ let decoder = png::Decoder::new(Cursor::new(bytes));
+ let mut reader = decoder.read_info().unwrap();
+ let mut img = vec![0; reader.output_buffer_size().unwrap()];
+ let info = reader.next_frame(&mut img).unwrap();
+ assert_eq!(info.color_type, png::ColorType::Rgba);
+ let idx_in = ((4u32 * 16 + 4) * 4) as usize;
+ let idx_out = ((0u32 * 16 + 0) * 4) as usize;
+ assert_eq!(img[idx_in], 255);
+ assert!(img[idx_in + 1] < 5);
+ assert!(img[idx_in + 2] < 5);
+ assert_eq!(img[idx_out], 255);
+ assert_eq!(img[idx_out + 1], 255);
+ assert_eq!(img[idx_out + 2], 255);
+ }
+
+ #[test]
+ fn stroke_line_writes_pixels_along_segment() {
+ let mut rec = pineal_render::PlanRecorder::new();
+ rec.stroke_line(
+ Point::new(2.0, 2.0),
+ Point::new(13.0, 13.0),
+ StrokeStyle::new(2.0, Color::BLACK),
+ );
+ let bytes = to_png(&rec.into_plan(), 16, 16, Color::WHITE).unwrap();
+ let decoder = png::Decoder::new(Cursor::new(bytes));
+ let mut reader = decoder.read_info().unwrap();
+ let mut img = vec![0; reader.output_buffer_size().unwrap()];
+ reader.next_frame(&mut img).unwrap();
+ // Algún pixel cerca de la diagonal debería estar marcado (con AA
+ // los bordes tienen coverage parcial — basta con que sea
+ // visiblemente más oscuro que el fondo blanco).
+ let idx = ((7u32 * 16 + 7) * 4) as usize;
+ assert!(img[idx] < 150, "esperaba pixel marcado en (7,7), got R={}", img[idx]);
+ }
+
+ #[test]
+ fn triangle_strip_fills_pixels() {
+ let mut rec = pineal_render::PlanRecorder::new();
+ rec.fill_triangle_strip(
+ &[0.0, 0.0, 16.0, 0.0, 0.0, 16.0, 16.0, 16.0],
+ &[Color::BLACK; 4],
+ );
+ let bytes = to_png(&rec.into_plan(), 16, 16, Color::WHITE).unwrap();
+ let decoder = png::Decoder::new(Cursor::new(bytes));
+ let mut reader = decoder.read_info().unwrap();
+ let mut img = vec![0; reader.output_buffer_size().unwrap()];
+ reader.next_frame(&mut img).unwrap();
+ // Casi todos los pixels deberían ser negros (1 strip cubre el cuadrado).
+ let black = img
+ .chunks_exact(4)
+ .filter(|p| p[0] < 30 && p[1] < 30 && p[2] < 30)
+ .count();
+ assert!(black > 240, "se esperaban >240 pixels negros, hubo {black}");
+ }
+
+ #[test]
+ fn clip_blocks_writes_outside_rect() {
+ let mut rec = pineal_render::PlanRecorder::new();
+ rec.push_clip(Rect::new(0.0, 0.0, 8.0, 16.0));
+ rec.fill_rect(Rect::new(0.0, 0.0, 16.0, 16.0), Color::BLACK);
+ rec.pop_clip();
+ let bytes = to_png(&rec.into_plan(), 16, 16, Color::WHITE).unwrap();
+ let decoder = png::Decoder::new(Cursor::new(bytes));
+ let mut reader = decoder.read_info().unwrap();
+ let mut img = vec![0; reader.output_buffer_size().unwrap()];
+ reader.next_frame(&mut img).unwrap();
+ // Lado izquierdo (x=2) negro; lado derecho (x=12) blanco.
+ let idx_l = ((8u32 * 16 + 2) * 4) as usize;
+ let idx_r = ((8u32 * 16 + 12) * 4) as usize;
+ assert!(img[idx_l] < 30);
+ assert!(img[idx_r] > 230);
+ }
+}
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,
+ "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{width}\" \
+ height=\"{height}\" viewBox=\"0 0 {width} {height}\">"
+ );
+ for cmd in &plan.cmds {
+ emit_cmd(&mut s, cmd);
+ }
+ s.push_str("</svg>");
+ s
+}
+
+fn emit_cmd(s: &mut String, cmd: &RenderCmd) {
+ match cmd {
+ // v1: clips ignorados (ver doc del módulo).
+ RenderCmd::PushClip(_) | RenderCmd::PopClip => {}
+
+ RenderCmd::FillRect { rect, color } => {
+ let (c, a) = svg_color(*color);
+ let _ = write!(
+ s,
+ "<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" \
+ fill=\"{c}\" fill-opacity=\"{a}\"/>",
+ rect.x, rect.y, rect.w, rect.h
+ );
+ }
+
+ RenderCmd::StrokeRect { rect, stroke } => {
+ let (c, a) = svg_color(stroke.color);
+ let _ = write!(
+ s,
+ "<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" \
+ fill=\"none\" stroke=\"{c}\" stroke-opacity=\"{a}\" \
+ stroke-width=\"{}\"/>",
+ rect.x, rect.y, rect.w, rect.h, stroke.width
+ );
+ }
+
+ RenderCmd::StrokeLine { a: p0, b: p1, stroke } => {
+ let (c, alpha) = svg_color(stroke.color);
+ let _ = write!(
+ s,
+ "<line x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" \
+ stroke=\"{c}\" stroke-opacity=\"{alpha}\" stroke-width=\"{}\"/>",
+ p0.x, p0.y, p1.x, p1.y, stroke.width
+ );
+ }
+
+ RenderCmd::StrokePolyline { coords, stroke } => {
+ let (c, alpha) = svg_color(stroke.color);
+ s.push_str("<polyline points=\"");
+ emit_points(s, coords);
+ let _ = write!(
+ s,
+ "\" fill=\"none\" stroke=\"{c}\" stroke-opacity=\"{alpha}\" \
+ stroke-width=\"{}\"/>",
+ stroke.width
+ );
+ }
+
+ RenderCmd::FillTriangleStrip { coords, colors } => {
+ emit_triangle_strip(s, coords, colors);
+ }
+
+ RenderCmd::DrawText { p, text, color, size_px } => {
+ let (c, a) = svg_color(*color);
+ let _ = write!(
+ s,
+ "<text x=\"{}\" y=\"{}\" fill=\"{c}\" fill-opacity=\"{a}\" \
+ font-size=\"{size_px}\">{}</text>",
+ p.x, p.y, escape_xml(text)
+ );
+ }
+ }
+}
+
+/// Emite `x0,y0 x1,y1 …` para el atributo `points` de polyline/polygon.
+fn emit_points(s: &mut String, coords: &[f32]) {
+ for (i, pair) in coords.chunks_exact(2).enumerate() {
+ if i > 0 {
+ s.push(' ');
+ }
+ let _ = write!(s, "{},{}", pair[0], pair[1]);
+ }
+}
+
+/// Un triangle strip de N vértices = N-2 triángulos. Cada triángulo se
+/// emite como `<polygon>` con el color promedio de sus 3 vértices (SVG
+/// no tiene gradient por-vértice trivial).
+fn emit_triangle_strip(s: &mut String, coords: &[f32], colors: &[Color]) {
+ let n = coords.len() / 2;
+ if n < 3 {
+ return;
+ }
+ for t in 0..n - 2 {
+ let (i0, i1, i2) = (t, t + 1, t + 2);
+ let avg = avg_color(&[
+ colors.get(i0).copied(),
+ colors.get(i1).copied(),
+ colors.get(i2).copied(),
+ ]);
+ let (c, a) = svg_color(avg);
+ let _ = write!(
+ s,
+ "<polygon points=\"{},{} {},{} {},{}\" fill=\"{c}\" fill-opacity=\"{a}\"/>",
+ coords[i0 * 2], coords[i0 * 2 + 1],
+ coords[i1 * 2], coords[i1 * 2 + 1],
+ coords[i2 * 2], coords[i2 * 2 + 1],
+ );
+ }
+}
+
+fn avg_color(cs: &[Option<Color>]) -> Color {
+ let mut acc = Color::rgba(0.0, 0.0, 0.0, 0.0);
+ let mut n = 0.0;
+ for c in cs.iter().flatten() {
+ acc.r += c.r;
+ acc.g += c.g;
+ acc.b += c.b;
+ acc.a += c.a;
+ n += 1.0;
+ }
+ if n == 0.0 {
+ return Color::TRANSPARENT;
+ }
+ Color::rgba(acc.r / n, acc.g / n, acc.b / n, acc.a / n)
+}
+
+/// `Color` f32 → (`rgb(R,G,B)` con enteros 0-255, alpha 0-1).
+fn svg_color(c: Color) -> (String, f32) {
+ let to255 = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
+ (
+ format!("rgb({},{},{})", to255(c.r), to255(c.g), to255(c.b)),
+ c.a.clamp(0.0, 1.0),
+ )
+}
+
+/// Escapa los 5 caracteres especiales de XML en contenido de texto.
+fn escape_xml(text: &str) -> String {
+ let mut out = String::with_capacity(text.len());
+ for ch in text.chars() {
+ match ch {
+ '&' => out.push_str("&"),
+ '<' => out.push_str("<"),
+ '>' => out.push_str(">"),
+ '"' => out.push_str("""),
+ '\'' => out.push_str("'"),
+ c => out.push(c),
+ }
+ }
+ out
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use pineal_render::{Canvas, Point, Rect, StrokeStyle};
+
+ fn sample_plan() -> RenderPlan {
+ let mut rec = pineal_render::PlanRecorder::new();
+ rec.fill_rect(Rect::new(1.0, 2.0, 30.0, 40.0), Color::from_hex(0xff0000));
+ rec.stroke_line(
+ Point::new(0.0, 0.0),
+ Point::new(100.0, 50.0),
+ StrokeStyle::new(2.0, Color::BLACK),
+ );
+ rec.draw_text(Point::new(5.0, 10.0), "a<b&c", Color::WHITE, 12.0);
+ rec.into_plan()
+ }
+
+ #[test]
+ fn emits_well_formed_svg() {
+ let svg = to_svg(&sample_plan(), 200.0, 100.0);
+ assert!(svg.starts_with("<svg xmlns=\"http://www.w3.org/2000/svg\""));
+ assert!(svg.ends_with("</svg>"));
+ assert!(svg.contains("width=\"200\""));
+ assert!(svg.contains("viewBox=\"0 0 200 100\""));
+ }
+
+ #[test]
+ fn emits_each_primitive() {
+ let svg = to_svg(&sample_plan(), 200.0, 100.0);
+ assert!(svg.contains("<rect "));
+ assert!(svg.contains("fill=\"rgb(255,0,0)\""));
+ assert!(svg.contains("<line "));
+ assert!(svg.contains("<text "));
+ }
+
+ #[test]
+ fn escapes_xml_in_text() {
+ let svg = to_svg(&sample_plan(), 200.0, 100.0);
+ assert!(svg.contains("a<b&c"));
+ assert!(!svg.contains("a<b&c"));
+ }
+
+ #[test]
+ fn triangle_strip_becomes_polygons() {
+ let mut rec = pineal_render::PlanRecorder::new();
+ rec.fill_triangle_strip(
+ &[0.0, 0.0, 10.0, 0.0, 5.0, 10.0, 15.0, 10.0],
+ &[Color::WHITE, Color::WHITE, Color::WHITE, Color::WHITE],
+ );
+ let svg = to_svg(&rec.into_plan(), 20.0, 20.0);
+ // 4 vértices → 2 triángulos → 2 polígonos.
+ assert_eq!(svg.matches("<polygon").count(), 2);
+ }
+}
diff --git a/00_unanchay/pineal/pineal-financial/Cargo.toml b/00_unanchay/pineal/pineal-financial/Cargo.toml
new file mode 100644
index 0000000..7cf01ac
--- /dev/null
+++ b/00_unanchay/pineal/pineal-financial/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "pineal-financial"
+version = { workspace = true }
+edition = { workspace = true }
+license = { workspace = true }
+authors = { workspace = true }
+publish = { workspace = true }
+description = "Lapaloma — gráficos financieros. OHLC / candlesticks con agregación que preserva volatilidad (no LTTB, time-bucketing con max/min de wicks)."
+
+[dependencies]
+pineal-core = { path = "../pineal-core" }
+pineal-render = { path = "../pineal-render" }
+pineal-cartesian = { path = "../pineal-cartesian" }
+llimphi-ui = { workspace = true }
diff --git a/00_unanchay/pineal/pineal-financial/LEEME.md b/00_unanchay/pineal/pineal-financial/LEEME.md
new file mode 100644
index 0000000..1d41bd0
--- /dev/null
+++ b/00_unanchay/pineal/pineal-financial/LEEME.md
@@ -0,0 +1,19 @@
+# pineal-financial
+
+> Canvas financiero para [pineal](../README.md): velas, volúmenes, overlays técnicos.
+
+OHLCV (Open/High/Low/Close/Volume) candles con estilo configurable (color up/down, wick width). Overlays técnicos: SMA, EMA, Bollinger, RSI, MACD. Pensado para inspección manual, no para HFT — el render es prioridad antes que latencia.
+
+## API
+
+```rust
+use pineal_financial::{Chart, Bar, Overlay};
+
+let chart = Chart::new(&bars)
+ .overlay(Overlay::sma(20))
+ .overlay(Overlay::bollinger(20, 2.0));
+```
+
+## Deps
+
+- [`pineal-core`](../pineal-core/README.md)
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<f32>,
+ revision: u64,
+}
+
+impl OhlcBuffer {
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ pub fn with_capacity(n: usize) -> Self {
+ Self {
+ bars: Vec::with_capacity(n * STRIDE),
+ revision: 0,
+ }
+ }
+
+ pub fn from_raw(bars: Vec<f32>) -> Self {
+ assert!(bars.len() % STRIDE == 0, "OhlcBuffer: stride 6 required");
+ Self { bars, revision: 0 }
+ }
+
+ pub fn push_bar(&mut self, b: Bar) {
+ self.bars.push(b.t);
+ self.bars.push(b.o);
+ self.bars.push(b.h);
+ self.bars.push(b.l);
+ self.bars.push(b.c);
+ self.bars.push(b.v);
+ self.revision = self.revision.wrapping_add(1);
+ }
+
+ pub fn push_values(&mut self, t: f32, o: f32, h: f32, l: f32, c: f32, v: f32) {
+ self.push_bar(Bar { t, o, h, l, c, v });
+ }
+
+ pub fn len(&self) -> usize {
+ self.bars.len() / STRIDE
+ }
+
+ pub fn is_empty(&self) -> bool {
+ self.bars.is_empty()
+ }
+
+ pub fn bar(&self, i: usize) -> Bar {
+ let off = i * STRIDE;
+ Bar {
+ t: self.bars[off],
+ o: self.bars[off + 1],
+ h: self.bars[off + 2],
+ l: self.bars[off + 3],
+ c: self.bars[off + 4],
+ v: self.bars[off + 5],
+ }
+ }
+
+ /// Slice plano del buffer subyacente.
+ pub fn bars(&self) -> &[f32] {
+ &self.bars
+ }
+
+ pub fn revision(&self) -> u64 {
+ self.revision
+ }
+
+ pub fn clear(&mut self) {
+ self.bars.clear();
+ self.revision = self.revision.wrapping_add(1);
+ }
+
+ /// Min/max de `low` y `high` sobre todo el buffer.
+ /// Útil para autoscale del Y axis.
+ pub fn price_range(&self) -> Option<(f32, f32)> {
+ if self.is_empty() {
+ return None;
+ }
+ let mut lo = f32::INFINITY;
+ let mut hi = f32::NEG_INFINITY;
+ for i in 0..self.len() {
+ let b = self.bar(i);
+ if b.l < lo {
+ lo = b.l;
+ }
+ if b.h > hi {
+ hi = b.h;
+ }
+ }
+ Some((lo, hi))
+ }
+
+ /// Rango temporal `[t_min, t_max]`. None si vacío.
+ pub fn time_range(&self) -> Option<(f32, f32)> {
+ if self.is_empty() {
+ return None;
+ }
+ let first = self.bars[0];
+ let last = self.bars[self.bars.len() - STRIDE];
+ Some((first, last))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn push_y_lectura() {
+ let mut b = OhlcBuffer::with_capacity(2);
+ b.push_values(1.0, 10.0, 12.0, 9.0, 11.0, 100.0);
+ b.push_values(2.0, 11.0, 13.0, 10.0, 10.5, 80.0);
+ assert_eq!(b.len(), 2);
+ assert_eq!(b.bar(0).c, 11.0);
+ assert_eq!(b.bar(1).h, 13.0);
+ }
+
+ #[test]
+ fn bull_y_bear() {
+ let bull = Bar { t: 0.0, o: 10.0, h: 11.0, l: 9.0, c: 10.5, v: 0.0 };
+ let bear = Bar { t: 0.0, o: 10.0, h: 11.0, l: 9.0, c: 9.5, v: 0.0 };
+ assert!(bull.is_bull());
+ assert!(!bull.is_bear());
+ assert!(bear.is_bear());
+ assert!(!bear.is_bull());
+ }
+
+ #[test]
+ fn price_range_correcto() {
+ let mut b = OhlcBuffer::new();
+ b.push_values(0.0, 10.0, 15.0, 8.0, 12.0, 0.0);
+ b.push_values(1.0, 12.0, 14.0, 7.0, 9.0, 0.0);
+ b.push_values(2.0, 9.0, 11.0, 9.0, 10.0, 0.0);
+ let (lo, hi) = b.price_range().unwrap();
+ assert_eq!(lo, 7.0);
+ assert_eq!(hi, 15.0);
+ }
+
+ #[test]
+ fn time_range() {
+ let mut b = OhlcBuffer::new();
+ b.push_values(10.0, 0.0, 0.0, 0.0, 0.0, 0.0);
+ b.push_values(50.0, 0.0, 0.0, 0.0, 0.0, 0.0);
+ b.push_values(100.0, 0.0, 0.0, 0.0, 0.0, 0.0);
+ assert_eq!(b.time_range(), Some((10.0, 100.0)));
+ }
+
+ #[test]
+ fn revision_bumps_en_push_y_clear() {
+ let mut b = OhlcBuffer::new();
+ let r0 = b.revision();
+ b.push_values(0.0, 1.0, 1.0, 1.0, 1.0, 1.0);
+ assert_ne!(r0, b.revision());
+ let r1 = b.revision();
+ b.clear();
+ assert_ne!(r1, b.revision());
+ }
+}
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<Color>,
+ axis_color: Color,
+ axis_style: AxisStyle,
+ margin_top: f32,
+ margin_right: f32,
+ margin_bottom: f32,
+ margin_left: f32,
+}
+
+impl CandlestickView {
+ pub fn new(data: OhlcBuffer, viewport: ChartViewport) -> Self {
+ Self {
+ data,
+ viewport,
+ style: CandlestickStyle::default(),
+ background: None,
+ axis_color: Color::rgba(0.6, 0.6, 0.65, 0.8),
+ axis_style: AxisStyle::default(),
+ margin_top: 8.0,
+ margin_right: 8.0,
+ margin_bottom: 24.0,
+ margin_left: 48.0,
+ }
+ }
+
+ pub fn background(mut self, color: Color) -> Self {
+ self.background = Some(color);
+ self
+ }
+ pub fn axis_color(mut self, color: Color) -> Self {
+ self.axis_color = color;
+ self
+ }
+ pub fn style(mut self, style: CandlestickStyle) -> Self {
+ self.style = style;
+ self
+ }
+ pub fn margins(mut self, top: f32, right: f32, bottom: f32, left: f32) -> Self {
+ self.margin_top = top;
+ self.margin_right = right;
+ self.margin_bottom = bottom;
+ self.margin_left = left;
+ self
+ }
+
+ /// Materializa el `View<Msg>`. Pinta velas + ejes dentro del rect.
+ pub fn view<Msg: Clone + 'static>(self) -> View<Msg> {
+ let CandlestickView {
+ data,
+ viewport,
+ style,
+ background,
+ axis_color,
+ axis_style,
+ margin_top,
+ margin_right,
+ margin_bottom,
+ margin_left,
+ } = self;
+ View::new(Style {
+ size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+ ..Default::default()
+ })
+ .paint_with(move |scene, typesetter, rect| {
+ let outer = Rect::new(rect.x, rect.y, rect.w, rect.h);
+ let plot = Rect::new(
+ outer.x + margin_left,
+ outer.y + margin_top,
+ (outer.w - margin_left - margin_right).max(1.0),
+ (outer.h - margin_top - margin_bottom).max(1.0),
+ );
+ let cs = CoordinateSystem::new(viewport, plot);
+ let mut canvas = SceneCanvas::new(scene, typesetter);
+
+ if let Some(bg) = background {
+ canvas.fill_rect(outer, bg);
+ }
+
+ axis::paint_axes(
+ &mut canvas,
+ &cs,
+ &viewport,
+ axis_color,
+ axis_style,
+ TARGET_TICKS_X,
+ TARGET_TICKS_Y,
+ );
+
+ paint_candlesticks(&mut canvas, &cs, &data, style);
+ })
+ }
+}
+
+/// Helper builder-style — paralelo al `lapaloma_candlestick(...)` GPUI.
+pub fn lapaloma_candlestick_view(
+ data: OhlcBuffer,
+ viewport: ChartViewport,
+) -> CandlestickView {
+ CandlestickView::new(data, viewport)
+}
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<String>) -> Self {
+ Self { label: label.into() }
+ }
+}
+
+/// Un flujo dirigido `source → target` con un caudal `value`.
+#[derive(Debug, Clone, Copy)]
+pub struct SankeyLink {
+ pub source: usize,
+ pub target: usize,
+ pub value: f64,
+}
+
+/// Caja de un nodo ya ubicada en el lienzo.
+#[derive(Debug, Clone)]
+pub struct NodeBox {
+ pub rect: Rect,
+ pub column: usize,
+}
+
+/// Banda de un link: cuatro anclas (arriba/abajo en origen y destino).
+#[derive(Debug, Clone, Copy)]
+pub struct LinkBand {
+ pub link: usize,
+ pub src_top: Point,
+ pub src_bot: Point,
+ pub dst_top: Point,
+ pub dst_bot: Point,
+}
+
+/// Layout completo: cajas de nodos + bandas de links.
+#[derive(Debug, Clone, Default)]
+pub struct SankeyLayout {
+ pub nodes: Vec<NodeBox>,
+ pub links: Vec<LinkBand>,
+}
+
+/// Calcula el layout de un Sankey dentro de `area`.
+pub fn compute_layout(
+ nodes: &[SankeyNode],
+ links: &[SankeyLink],
+ area: Rect,
+ node_width: f32,
+ node_gap: f32,
+) -> SankeyLayout {
+ let n = nodes.len();
+ if n == 0 || area.w <= 0.0 || area.h <= 0.0 {
+ return SankeyLayout::default();
+ }
+ let valid: Vec<&SankeyLink> = links
+ .iter()
+ .filter(|l| l.source < n && l.target < n && l.source != l.target && l.value > 0.0)
+ .collect();
+
+ let columns = assign_columns(n, &valid);
+ let n_cols = columns.iter().copied().max().unwrap_or(0) + 1;
+
+ // Valor de cada nodo = max(suma entrante, suma saliente).
+ let mut in_sum = vec![0.0f64; n];
+ let mut out_sum = vec![0.0f64; n];
+ for l in &valid {
+ in_sum[l.target] += l.value;
+ out_sum[l.source] += l.value;
+ }
+ let node_value: Vec<f64> = (0..n).map(|i| in_sum[i].max(out_sum[i]).max(0.0)).collect();
+
+ // Nodos por columna.
+ let mut by_col: Vec<Vec<usize>> = vec![Vec::new(); n_cols];
+ for (i, &c) in columns.iter().enumerate() {
+ by_col[c].push(i);
+ }
+
+ // Escala vertical: la columna más cargada llena `area.h` (con gaps).
+ let max_col_value = by_col
+ .iter()
+ .map(|col| col.iter().map(|&i| node_value[i]).sum::<f64>())
+ .fold(0.0f64, f64::max)
+ .max(1e-9);
+ let max_col_count = by_col.iter().map(|c| c.len()).max().unwrap_or(1).max(1);
+ let usable_h = (area.h - node_gap * (max_col_count.saturating_sub(1)) as f32).max(1.0);
+ let v_scale = usable_h as f64 / max_col_value;
+
+ // Una pasada de barycenter para ordenar cada columna.
+ barycenter_pass(&mut by_col, &valid, &columns);
+
+ // Geometría de cada nodo.
+ let col_step = if n_cols > 1 {
+ (area.w - node_width) / (n_cols - 1) as f32
+ } else {
+ 0.0
+ };
+ let mut boxes = vec![
+ NodeBox { rect: Rect::new(0.0, 0.0, 0.0, 0.0), column: 0 };
+ n
+ ];
+ for (c, col) in by_col.iter().enumerate() {
+ let mut y = area.y;
+ for &i in col {
+ let h = (node_value[i] * v_scale) as f32;
+ let x = area.x + c as f32 * col_step;
+ boxes[i] = NodeBox {
+ rect: Rect::new(x, y, node_width, h.max(1.0)),
+ column: c,
+ };
+ y += h + node_gap;
+ }
+ }
+
+ // Bandas de links: apiladas en el borde derecho del origen y el
+ // borde izquierdo del destino, en el orden de aparición.
+ let mut src_cursor = vec![0.0f32; n];
+ let mut dst_cursor = vec![0.0f32; n];
+ let mut bands = Vec::with_capacity(valid.len());
+ for (vi, l) in valid.iter().enumerate() {
+ let sb = &boxes[l.source].rect;
+ let tb = &boxes[l.target].rect;
+ let thick_s = (l.value * v_scale) as f32;
+ let s_y0 = sb.y + src_cursor[l.source];
+ let t_y0 = tb.y + dst_cursor[l.target];
+ src_cursor[l.source] += thick_s;
+ dst_cursor[l.target] += thick_s;
+ // El índice real del link en el slice original.
+ let link_idx = links
+ .iter()
+ .position(|x| {
+ x.source == l.source && x.target == l.target && x.value == l.value
+ })
+ .unwrap_or(vi);
+ bands.push(LinkBand {
+ link: link_idx,
+ src_top: Point::new(sb.right(), s_y0),
+ src_bot: Point::new(sb.right(), s_y0 + thick_s),
+ dst_top: Point::new(tb.x, t_y0),
+ dst_bot: Point::new(tb.x, t_y0 + thick_s),
+ });
+ }
+
+ SankeyLayout { nodes: boxes, links: bands }
+}
+
+/// Columna de cada nodo = longest-path desde una fuente. Las back-edges
+/// (detectadas por DFS) se descartan para romper ciclos.
+fn assign_columns(n: usize, links: &[&SankeyLink]) -> Vec<usize> {
+ let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
+ for l in links {
+ adj[l.source].push(l.target);
+ }
+ // DFS marcando back-edges (destino en la pila actual).
+ let mut state = vec![0u8; n]; // 0=blanco 1=en-pila 2=hecho
+ let mut back: Vec<(usize, usize)> = Vec::new();
+ for s in 0..n {
+ if state[s] != 0 {
+ continue;
+ }
+ let mut stack = vec![(s, 0usize)];
+ state[s] = 1;
+ while let Some(&mut (u, ref mut i)) = stack.last_mut() {
+ if *i < adj[u].len() {
+ let v = adj[u][*i];
+ *i += 1;
+ match state[v] {
+ 0 => {
+ state[v] = 1;
+ stack.push((v, 0));
+ }
+ 1 => back.push((u, v)),
+ _ => {}
+ }
+ } else {
+ state[u] = 2;
+ stack.pop();
+ }
+ }
+ }
+ // Longest-path en el DAG (sin back-edges) vía relajación topológica.
+ let mut indeg = vec![0usize; n];
+ let mut dag: Vec<Vec<usize>> = vec![Vec::new(); n];
+ for l in links {
+ if !back.contains(&(l.source, l.target)) {
+ dag[l.source].push(l.target);
+ indeg[l.target] += 1;
+ }
+ }
+ let mut col = vec![0usize; n];
+ let mut queue: Vec<usize> = (0..n).filter(|&i| indeg[i] == 0).collect();
+ let mut head = 0;
+ while head < queue.len() {
+ let u = queue[head];
+ head += 1;
+ for &v in &dag[u] {
+ col[v] = col[v].max(col[u] + 1);
+ indeg[v] -= 1;
+ if indeg[v] == 0 {
+ queue.push(v);
+ }
+ }
+ }
+ col
+}
+
+/// Reordena los nodos de cada columna por el promedio de las posiciones
+/// de sus vecinos (barycenter heuristic), una pasada izquierda→derecha.
+fn barycenter_pass(by_col: &mut [Vec<usize>], links: &[&SankeyLink], columns: &[usize]) {
+ let n = columns.len();
+ let mut order_in_col = vec![0usize; n];
+ for col in by_col.iter() {
+ for (pos, &i) in col.iter().enumerate() {
+ order_in_col[i] = pos;
+ }
+ }
+ for c in 1..by_col.len() {
+ let bary: Vec<(usize, f64)> = by_col[c]
+ .iter()
+ .map(|&node| {
+ let mut sum = 0.0;
+ let mut cnt = 0.0;
+ for l in links {
+ if l.target == node && columns[l.source] == c - 1 {
+ sum += order_in_col[l.source] as f64;
+ cnt += 1.0;
+ }
+ }
+ (node, if cnt > 0.0 { sum / cnt } else { f64::MAX })
+ })
+ .collect();
+ let mut sorted = bary;
+ sorted.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
+ by_col[c] = sorted.into_iter().map(|(node, _)| node).collect();
+ for (pos, &i) in by_col[c].iter().enumerate() {
+ order_in_col[i] = pos;
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn nodes(n: usize) -> Vec<SankeyNode> {
+ (0..n).map(|i| SankeyNode::new(format!("n{i}"))).collect()
+ }
+
+ #[test]
+ fn empty_input() {
+ let l = compute_layout(&[], &[], Rect::new(0.0, 0.0, 100.0, 100.0), 20.0, 4.0);
+ assert!(l.nodes.is_empty());
+ }
+
+ #[test]
+ fn chain_assigns_increasing_columns() {
+ // 0 → 1 → 2
+ let links = [
+ SankeyLink { source: 0, target: 1, value: 5.0 },
+ SankeyLink { source: 1, target: 2, value: 5.0 },
+ ];
+ let l = compute_layout(&nodes(3), &links, Rect::new(0.0, 0.0, 300.0, 100.0), 20.0, 4.0);
+ assert_eq!(l.nodes[0].column, 0);
+ assert_eq!(l.nodes[1].column, 1);
+ assert_eq!(l.nodes[2].column, 2);
+ assert_eq!(l.links.len(), 2);
+ }
+
+ #[test]
+ fn back_edge_does_not_loop_forever() {
+ // ciclo 0 → 1 → 0 ; debe terminar y no panickear.
+ let links = [
+ SankeyLink { source: 0, target: 1, value: 3.0 },
+ SankeyLink { source: 1, target: 0, value: 1.0 },
+ ];
+ let l = compute_layout(&nodes(2), &links, Rect::new(0.0, 0.0, 200.0, 100.0), 20.0, 4.0);
+ assert_eq!(l.nodes.len(), 2);
+ }
+
+ #[test]
+ fn node_height_proportional_to_flow() {
+ // 0 manda 10 a 1 y 1 a 2 ; nodo 0 más "grueso" que nodo 2.
+ let links = [
+ SankeyLink { source: 0, target: 1, value: 10.0 },
+ SankeyLink { source: 0, target: 2, value: 2.0 },
+ ];
+ let l = compute_layout(&nodes(3), &links, Rect::new(0.0, 0.0, 200.0, 200.0), 20.0, 4.0);
+ assert!(l.nodes[0].rect.h > l.nodes[2].rect.h);
+ }
+}
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<f32> {
+ let mut coords = Vec::with_capacity((RIBBON_SEGMENTS + 1) * 4);
+ for i in 0..=RIBBON_SEGMENTS {
+ let t = i as f32 / RIBBON_SEGMENTS as f32;
+ let e = smoothstep(t);
+ let x_top = band.src_top.x + (band.dst_top.x - band.src_top.x) * t;
+ let y_top = band.src_top.y + (band.dst_top.y - band.src_top.y) * e;
+ let x_bot = band.src_bot.x + (band.dst_bot.x - band.src_bot.x) * t;
+ let y_bot = band.src_bot.y + (band.dst_bot.y - band.src_bot.y) * e;
+ coords.push(x_top);
+ coords.push(y_top);
+ coords.push(x_bot);
+ coords.push(y_bot);
+ }
+ coords
+}
+
+/// Dibuja una sola banda con el color dado.
+pub fn paint_ribbon(band: &LinkBand, color: Color, canvas: &mut dyn Canvas) {
+ let coords = ribbon_strip(band);
+ let colors = vec![color; coords.len() / 2];
+ canvas.fill_triangle_strip(&coords, &colors);
+}
+
+/// Dibuja un Sankey completo: ribbons primero (al fondo), nodos encima.
+pub fn paint_sankey(
+ layout: &SankeyLayout,
+ node_color: Color,
+ link_color: Color,
+ canvas: &mut dyn Canvas,
+) {
+ for band in &layout.links {
+ paint_ribbon(band, link_color, canvas);
+ }
+ for nb in &layout.nodes {
+ if nb.rect.w > 0.0 && nb.rect.h > 0.0 {
+ canvas.fill_rect(nb.rect, node_color);
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::layout::{compute_layout, SankeyLink, SankeyNode};
+ use pineal_render::{PlanRecorder, Rect, RenderCmd};
+
+ #[test]
+ fn ribbon_strip_has_expected_vertex_count() {
+ let band = LinkBand {
+ link: 0,
+ src_top: pineal_render::Point::new(0.0, 0.0),
+ src_bot: pineal_render::Point::new(0.0, 10.0),
+ dst_top: pineal_render::Point::new(100.0, 50.0),
+ dst_bot: pineal_render::Point::new(100.0, 60.0),
+ };
+ let coords = ribbon_strip(&band);
+ assert_eq!(coords.len(), (RIBBON_SEGMENTS + 1) * 4);
+ }
+
+ #[test]
+ fn paint_sankey_emits_nodes_and_ribbons() {
+ let nodes = vec![
+ SankeyNode::new("a"),
+ SankeyNode::new("b"),
+ SankeyNode::new("c"),
+ ];
+ let links = [
+ SankeyLink { source: 0, target: 1, value: 5.0 },
+ SankeyLink { source: 1, target: 2, value: 3.0 },
+ ];
+ let layout = compute_layout(
+ &nodes,
+ &links,
+ Rect::new(0.0, 0.0, 300.0, 150.0),
+ 18.0,
+ 6.0,
+ );
+ let mut rec = PlanRecorder::new();
+ paint_sankey(&layout, Color::from_hex(0x335577), Color::from_hex(0x88aacc), &mut rec);
+ let cmds = rec.into_plan().cmds;
+ let rects = cmds.iter().filter(|c| matches!(c, RenderCmd::FillRect { .. })).count();
+ let strips = cmds.iter().filter(|c| matches!(c, RenderCmd::FillTriangleStrip { .. })).count();
+ assert_eq!(rects, 3, "un fi
The patch goes on, but was cut here: fetch the commit with git to see it whole.