// Copyright 2026 Ling71671
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
///|
/// Component style descriptor for scientific plotting and charts.
pub struct PlotStyle {
default_w : Double
default_h : Double
pad_left : Double
pad_right : Double
pad_top : Double
pad_bottom : Double
bar_chart_default_w : Double
bar_chart_default_h : Double
bar_chart_gap : Double
} derive(Eq, Debug)
///|
pub fn PlotStyle::default() -> PlotStyle {
{
default_w: 280.0,
default_h: 150.0,
pad_left: 38.0,
pad_right: 14.0,
pad_top: 14.0,
pad_bottom: 24.0,
bar_chart_default_w: 280.0,
bar_chart_default_h: 150.0,
bar_chart_gap: 8.0,
}
}
///|
/// Single 2D data coordinate point for immediate-mode plotting.
pub struct PlotPoint {
x : Double
y : Double
} derive(Eq, Debug)
///|
/// Construct a 2D data point.
pub fn PlotPoint::new(x : Double, y : Double) -> PlotPoint {
{ x, y, }
}
///|
/// Representation style for a plotted data series.
pub enum PlotSeriesKind {
Line
Scatter
Area
} derive(Eq, Debug)
///|
/// Named series of data points rendered on a coordinate canvas.
pub struct PlotSeries {
name : String
points : Array[PlotPoint]
color : @color.Color
kind : PlotSeriesKind
} derive(Eq, Debug)
///|
/// Construct a connected line series.
pub fn PlotSeries::line(
name : String,
points : Array[PlotPoint],
color? : @color.Color = @color.Color::accent_primary(),
) -> PlotSeries {
{ name, points, color, kind: Line, }
}
///|
/// Construct a point marker scatter series.
pub fn PlotSeries::scatter(
name : String,
points : Array[PlotPoint],
color? : @color.Color = @color.Color::accent_hover(),
) -> PlotSeries {
{ name, points, color, kind: Scatter, }
}
///|
/// Construct an area fill series with baseline shading.
pub fn PlotSeries::area(
name : String,
points : Array[PlotPoint],
color? : @color.Color = @color.Color::accent_primary(),
) -> PlotSeries {
{ name, points, color, kind: Area, }
}
///|
fn format_coord_val(v : Double) -> String {
let rounded = (v * 10.0).round() / 10.0
let int_val = rounded.to_int()
if (rounded - int_val.to_double()).abs() < 0.01 {
int_val.to_string()
} else {
rounded.to_string()
}
}
///|
/// Standalone 2D coordinate plot widget supporting fluent configuration.
pub struct Plot {
id_salt : String
series : Array[PlotSeries]
size : @math.Vec2?
show_grid : Bool
show_crosshair : Bool
show_legend : Bool
}
///|
/// Create a new 2D plot widget builder.
pub fn Plot::new(id_salt : String, series : Array[PlotSeries]) -> Plot {
{
id_salt,
series,
size: None,
show_grid: true,
show_crosshair: true,
show_legend: true,
}
}
///|
/// Override plot layout dimensions.
pub fn Plot::size(self : Plot, size : @math.Vec2) -> Plot {
{ ..self, size: Some(size), }
}
///|
/// Toggle background coordinate grid lines.
pub fn Plot::show_grid(self : Plot, show_grid : Bool) -> Plot {
{ ..self, show_grid, }
}
///|
/// Toggle interactive crosshair line and coordinate HUD tooltip.
pub fn Plot::show_crosshair(self : Plot, show_crosshair : Bool) -> Plot {
{ ..self, show_crosshair, }
}
///|
/// Toggle multi-series legend badges.
pub fn Plot::show_legend(self : Plot, show_legend : Bool) -> Plot {
{ ..self, show_legend, }
}
///|
/// Render the plot into the provided UIContext.
pub fn Plot::show(self : Plot, ctx : UIContext) -> (@math.Vec2?, Response) {
match self.size {
Some(s) =>
plot(
ctx,
self.id_salt,
self.series,
size=s,
show_grid=self.show_grid,
show_crosshair=self.show_crosshair,
show_legend=self.show_legend,
)
None =>
plot(
ctx,
self.id_salt,
self.series,
show_grid=self.show_grid,
show_crosshair=self.show_crosshair,
show_legend=self.show_legend,
)
}
}
///|
/// Render an immediate-mode 2D coordinate plot with multi-series support,
/// automatic axis scaling, divide-by-zero protection, background grid,
/// scissor-clipped curve drawing, and interactive crosshair coordinates.
pub fn plot(
ctx : UIContext,
id_salt : String,
series : Array[PlotSeries],
size? : @math.Vec2,
show_grid? : Bool = true,
show_crosshair? : Bool = true,
show_legend? : Bool = true,
) -> (@math.Vec2?, Response) {
let scale = ctx.style.scale
let p_style = PlotStyle::default()
let default_w = p_style.default_w * scale
let default_h = p_style.default_h * scale
let chosen_size = match size {
Some(s) => s
None => @math.Vec2::new(default_w, default_h)
}
ctx.push_id(id_salt)
let (_id, rect, resp) = ctx.allocate_space(chosen_size)
ctx.pop_id()
let radius = ctx.style.radius_md * scale
let pad_left = p_style.pad_left * scale
let pad_right = p_style.pad_right * scale
let pad_top = p_style.pad_top * scale
let pad_bottom = p_style.pad_bottom * scale
let font_sz = ctx.style.font_small * scale
// 1. Draw Container Surface
ctx.painter().add_rect(rect, @color.Color::bg_surface(), radius)
ctx.painter().add_rect_stroke(rect, @color.Color::border_muted(), 1.0, radius)
// Inner Plot Coordinate Rect
let plot_w = rect.w - pad_left - pad_right
let plot_h = rect.h - pad_top - pad_bottom
if plot_w <= 10.0 * scale || plot_h <= 10.0 * scale {
return (None, resp)
}
let plot_rect = @math.Rect::new(
rect.x + pad_left,
rect.y + pad_top,
plot_w,
plot_h,
)
let plot_right = plot_rect.x + plot_rect.w
let plot_bottom = plot_rect.y + plot_rect.h
// 2. Compute Auto Bounds with Flat-Data Protection
let mut min_x = 0.0
let mut max_x = 1.0
let mut min_y = 0.0
let mut max_y = 1.0
let mut first = true
for s in series {
for pt in s.points {
if first {
min_x = pt.x
max_x = pt.x
min_y = pt.y
max_y = pt.y
first = false
} else {
if pt.x < min_x {
min_x = pt.x
}
if pt.x > max_x {
max_x = pt.x
}
if pt.y < min_y {
min_y = pt.y
}
if pt.y > max_y {
max_y = pt.y
}
}
}
}
if (max_x - min_x).abs() < 0.0001 {
max_x = min_x + 1.0
}
if (max_y - min_y).abs() < 0.0001 {
max_y = min_y + 1.0
}
let span_x = max_x - min_x
let span_y = max_y - min_y
// Helper coordinate mapper
let map_data_to_screen = fn(pt : PlotPoint) -> @math.Vec2 {
let norm_x = (pt.x - min_x) / span_x
let norm_y = (pt.y - min_y) / span_y
let sx = plot_rect.x + norm_x * plot_rect.w
let sy = plot_bottom - norm_y * plot_rect.h
@math.Vec2::new(sx, sy)
}
// 3. Grid Lines & Axis Ticks
if show_grid {
// 4 Horizontal lines
let num_h_ticks = 4
for i = 0; i <= num_h_ticks; i = i + 1 {
let t = i.to_double() / num_h_ticks.to_double()
let gy = plot_bottom - t * plot_rect.h
ctx
.painter()
.add_line(
@math.Vec2::new(plot_rect.x, gy),
@math.Vec2::new(plot_right, gy),
@color.Color::grid_line(),
1.0,
)
let tick_val = min_y + t * span_y
let label = format_coord_val(tick_val)
let label_sz = ctx.measure_text(label, font_sz)
ctx
.painter()
.add_text(
@math.Vec2::new(
plot_rect.x - label_sz.x - 4.0 * scale,
gy - font_sz * 0.45,
),
label,
font_sz,
@color.Color::text_muted(),
)
}
// 4 Vertical lines
let num_v_ticks = 4
for i = 0; i <= num_v_ticks; i = i + 1 {
let t = i.to_double() / num_v_ticks.to_double()
let gx = plot_rect.x + t * plot_rect.w
ctx
.painter()
.add_line(
@math.Vec2::new(gx, plot_rect.y),
@math.Vec2::new(gx, plot_bottom),
@color.Color::grid_line(),
1.0,
)
let tick_val = min_x + t * span_x
let label = format_coord_val(tick_val)
let label_sz = ctx.measure_text(label, font_sz)
ctx
.painter()
.add_text(
@math.Vec2::new(gx - label_sz.x * 0.5, plot_bottom + 4.0 * scale),
label,
font_sz,
@color.Color::text_muted(),
)
}
}
// 4. Draw Plotted Series (Scissor Clipped)
ctx.push_clip(plot_rect)
for s in series {
let pts = s.points
let count = pts.length()
if count == 0 {
continue
}
match s.kind {
Line => {
let mut prev_screen = map_data_to_screen(pts[0])
for i = 1; i < count; i = i + 1 {
let curr_screen = map_data_to_screen(pts[i])
ctx.painter().add_line(prev_screen, curr_screen, s.color, 2.0 * scale)
prev_screen = curr_screen
}
}
Scatter => {
let r = 3.5 * scale
for i = 0; i < count; i = i + 1 {
let pt_screen = map_data_to_screen(pts[i])
ctx.painter().add_circle(pt_screen, r, s.color)
}
}
Area => {
let base_y = plot_bottom
let mut prev_screen = map_data_to_screen(pts[0])
for i = 1; i < count; i = i + 1 {
let curr_screen = map_data_to_screen(pts[i])
let p_top = if prev_screen.y < curr_screen.y {
prev_screen.y
} else {
curr_screen.y
}
let seg_rect = @math.Rect::new(
prev_screen.x,
p_top,
curr_screen.x - prev_screen.x,
base_y - p_top,
)
ctx
.painter()
.add_linear_gradient(
seg_rect,
@math.Vec2::new(seg_rect.x, seg_rect.y),
@math.Vec2::new(seg_rect.x, seg_rect.y + seg_rect.h),
s.color.with_alpha(100),
s.color.with_alpha(15),
0.0,
)
ctx.painter().add_line(prev_screen, curr_screen, s.color, 2.0 * scale)
prev_screen = curr_screen
}
}
}
}
ctx.pop_clip()
// 4.5 Legend Rendering
if show_legend && series.length() > 0 {
let mut leg_x = plot_right - 8.0 * scale
for i = series.length() - 1; i >= 0; i = i - 1 {
let s = series[i]
let name_sz = ctx.measure_text(s.name, font_sz)
let leg_w = name_sz.x + 16.0 * scale
leg_x = leg_x - leg_w
let dot_r = 3.0 * scale
let dot_center = @math.Vec2::new(
leg_x + 5.0 * scale,
plot_rect.y + 10.0 * scale,
)
ctx.painter().add_circle(dot_center, dot_r, s.color)
ctx
.painter()
.add_text(
@math.Vec2::new(leg_x + 12.0 * scale, plot_rect.y + 4.0 * scale),
s.name,
font_sz,
@color.Color::text_muted(),
)
leg_x = leg_x - 8.0 * scale
}
}
// 5. Interactive Crosshair & Tooltip Readout
let mouse_pos = ctx.input.mouse_pos
let is_hovered = ctx.is_hovered(plot_rect)
let mut hovered_coord : @math.Vec2? = None
if is_hovered {
ctx.set_cursor_icon("crosshair")
}
if is_hovered && show_crosshair {
hovered_coord = Some(
@math.Vec2::new(
min_x + (mouse_pos.x - plot_rect.x) / plot_rect.w * span_x,
min_y + (plot_bottom - mouse_pos.y) / plot_rect.h * span_y,
),
)
// Vertical Hairline
ctx
.painter()
.add_line(
@math.Vec2::new(mouse_pos.x, plot_rect.y),
@math.Vec2::new(mouse_pos.x, plot_bottom),
@color.Color::plot_crosshair(),
1.0,
)
// Horizontal Hairline
ctx
.painter()
.add_line(
@math.Vec2::new(plot_rect.x, mouse_pos.y),
@math.Vec2::new(plot_right, mouse_pos.y),
@color.Color::plot_crosshair(),
1.0,
)
// Floating Coordinate Tooltip Badge
let hx = hovered_coord.unwrap().x
let hy = hovered_coord.unwrap().y
let text = "(" + format_coord_val(hx) + ", " + format_coord_val(hy) + ")"
let text_sz = ctx.measure_text(text, font_sz)
let tip_pad_x = 6.0 * scale
let tip_pad_y = 3.0 * scale
let tip_w = text_sz.x + tip_pad_x * 2.0
let tip_h = text_sz.y + tip_pad_y * 2.0
let mut tip_x = mouse_pos.x + 8.0 * scale
let mut tip_y = mouse_pos.y - tip_h - 6.0 * scale
if tip_x + tip_w > plot_right {
tip_x = mouse_pos.x - tip_w - 8.0 * scale
}
if tip_y < plot_rect.y {
tip_y = mouse_pos.y + 8.0 * scale
}
let tip_rect = @math.Rect::new(tip_x, tip_y, tip_w, tip_h)
let tip_radius = ctx.style.radius_sm * scale
ctx.painter().add_rect(tip_rect, @color.Color::text_strong(), tip_radius)
ctx
.painter()
.add_text(
@math.Vec2::new(tip_x + tip_pad_x, tip_y + tip_pad_y),
text,
font_sz,
@color.Color::text_inverse(),
)
}
(hovered_coord, resp)
}
///|
/// Standalone categorical bar chart widget supporting fluent configuration.
pub struct BarChart {
id_salt : String
labels : Array[String]
values : Array[Double]
size : @math.Vec2?
bar_color : @color.Color
}
///|
/// Create a new categorical bar chart builder.
pub fn BarChart::new(
id_salt : String,
labels : Array[String],
values : Array[Double],
) -> BarChart {
{
id_salt,
labels,
values,
size: None,
bar_color: @color.Color::accent_primary(),
}
}
///|
/// Override bar chart layout dimensions.
pub fn BarChart::size(self : BarChart, size : @math.Vec2) -> BarChart {
{ ..self, size: Some(size), }
}
///|
/// Set custom bar fill color.
pub fn BarChart::bar_color(
self : BarChart,
bar_color : @color.Color,
) -> BarChart {
{ ..self, bar_color, }
}
///|
/// Render the bar chart into the provided UIContext.
pub fn BarChart::show(self : BarChart, ctx : UIContext) -> (Int?, Response) {
match self.size {
Some(s) =>
bar_chart(
ctx,
self.id_salt,
self.labels,
self.values,
size=s,
bar_color=self.bar_color,
)
None =>
bar_chart(
ctx,
self.id_salt,
self.labels,
self.values,
bar_color=self.bar_color,
)
}
}
///|
/// Render an immediate-mode categorical bar chart with automatic scaling,
/// category labels, hover highlights, and interactive bar indices.
pub fn bar_chart(
ctx : UIContext,
id_salt : String,
labels : Array[String],
values : Array[Double],
size? : @math.Vec2,
bar_color? : @color.Color = @color.Color::accent_primary(),
) -> (Int?, Response) {
let scale = ctx.style.scale
let p_style = PlotStyle::default()
let default_w = p_style.bar_chart_default_w * scale
let default_h = p_style.bar_chart_default_h * scale
let chosen_size = match size {
Some(s) => s
None => @math.Vec2::new(default_w, default_h)
}
ctx.push_id(id_salt)
let (_id, rect, resp) = ctx.allocate_space(chosen_size)
ctx.pop_id()
let radius = ctx.style.radius_md * scale
let pad_left = p_style.pad_left * scale
let pad_right = p_style.pad_right * scale
let pad_top = p_style.pad_top * scale
let pad_bottom = p_style.pad_bottom * scale
let font_sz = ctx.style.font_small * scale
let gap = p_style.bar_chart_gap * scale
// Container Surface
ctx.painter().add_rect(rect, @color.Color::bg_surface(), radius)
ctx.painter().add_rect_stroke(rect, @color.Color::border_muted(), 1.0, radius)
let count = values.length()
if count == 0 {
return (None, resp)
}
let plot_w = rect.w - pad_left - pad_right
let plot_h = rect.h - pad_top - pad_bottom
if plot_w <= 10.0 * scale || plot_h <= 10.0 * scale {
return (None, resp)
}
let plot_rect = @math.Rect::new(
rect.x + pad_left,
rect.y + pad_top,
plot_w,
plot_h,
)
let plot_right = plot_rect.x + plot_rect.w
let plot_bottom = plot_rect.y + plot_rect.h
// Max value calculation
let mut max_val = 1.0
for v in values {
if v > max_val {
max_val = v
}
}
// Draw Grid Lines (3 horizontal guidelines)
let num_guidelines = 3
for i = 0; i <= num_guidelines; i = i + 1 {
let t = i.to_double() / num_guidelines.to_double()
let gy = plot_bottom - t * plot_rect.h
ctx
.painter()
.add_line(
@math.Vec2::new(plot_rect.x, gy),
@math.Vec2::new(plot_right, gy),
@color.Color::grid_line(),
1.0,
)
let val = t * max_val
let label = format_coord_val(val)
let label_sz = ctx.measure_text(label, font_sz)
ctx
.painter()
.add_text(
@math.Vec2::new(
plot_rect.x - label_sz.x - 4.0 * scale,
gy - font_sz * 0.45,
),
label,
font_sz,
@color.Color::text_muted(),
)
}
// Bar Layout
let total_gap = gap * (count - 1).to_double()
let bar_w = if total_gap < plot_w {
(plot_w - total_gap) / count.to_double()
} else {
4.0 * scale
}
let mut hovered_idx : Int? = None
for i = 0; i < count; i = i + 1 {
let val = values[i]
let norm_val = if max_val > 0.0 { val / max_val } else { 0.0 }
let bar_h = norm_val * plot_rect.h
let bx = plot_rect.x + i.to_double() * (bar_w + gap)
let by = plot_bottom - bar_h
let bar_rect = @math.Rect::new(bx, by, bar_w, bar_h)
let is_bar_hovered = ctx.is_hovered(bar_rect)
if is_bar_hovered {
hovered_idx = Some(i)
ctx.set_cursor_icon("pointer")
}
let col = if is_bar_hovered {
@color.Color::accent_hover()
} else {
bar_color
}
let bar_radius = ctx.style.radius_sm * scale
ctx.painter().add_rect(bar_rect, col, bar_radius)
// Category Label
if i < labels.length() {
let lbl = labels[i]
let lbl_sz = ctx.measure_text(lbl, font_sz)
let lx = bx + (bar_w - lbl_sz.x) * 0.5
let ly = plot_bottom + 4.0 * scale
ctx
.painter()
.add_text(
@math.Vec2::new(lx, ly),
lbl,
font_sz,
@color.Color::text_secondary(),
)
}
// Hover value tooltip badge on active bar
if is_bar_hovered {
let val_str = format_coord_val(val)
let val_sz = ctx.measure_text(val_str, font_sz)
let badge_pad_x = 6.0 * scale
let badge_pad_y = 2.0 * scale
let badge_w = val_sz.x + badge_pad_x * 2.0
let badge_h = val_sz.y + badge_pad_y * 2.0
let badge_x = bx + (bar_w - badge_w) * 0.5
let badge_y = by - badge_h - 4.0 * scale
let badge_rect = @math.Rect::new(badge_x, badge_y, badge_w, badge_h)
ctx
.painter()
.add_rect(
badge_rect,
@color.Color::text_strong(),
ctx.style.radius_sm * scale,
)
ctx
.painter()
.add_text(
@math.Vec2::new(badge_x + badge_pad_x, badge_y + badge_pad_y),
val_str,
font_sz,
@color.Color::text_inverse(),
)
}
}
(hovered_idx, resp)
}