// 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.
///|
/// Render a real-time micro sparkline trend chart with auto-normalized scaling,
/// flat-data divide-by-zero protection, gradient area fill, and interactive hover point snapping.
///|
pub struct Sparkline {
id_salt : String
values : Array[Double]
size : @math.Vec2
color : @color.Color
fill : Bool
show_hover : Bool
} derive(Eq, Debug)
///|
pub fn Sparkline::new(id_salt : String, values : Array[Double]) -> Sparkline {
{
id_salt,
values,
size: @math.Vec2::new(120.0, 36.0),
color: @color.Color::accent_primary(),
fill: true,
show_hover: true,
}
}
///|
pub fn Sparkline::size(self : Sparkline, size : @math.Vec2) -> Sparkline {
{ ..self, size, }
}
///|
pub fn Sparkline::color(self : Sparkline, color : @color.Color) -> Sparkline {
{ ..self, color, }
}
///|
pub fn Sparkline::fill(self : Sparkline, fill : Bool) -> Sparkline {
{ ..self, fill, }
}
///|
pub fn Sparkline::show_hover(self : Sparkline, show_hover : Bool) -> Sparkline {
{ ..self, show_hover, }
}
///|
pub fn Sparkline::show(self : Sparkline, ctx : UIContext) -> (Int?, Response) {
sparkline(
ctx,
self.id_salt,
self.values,
size=self.size,
color=self.color,
fill=self.fill,
show_hover=self.show_hover,
)
}
///|
pub fn sparkline(
ctx : UIContext,
id_salt : String,
values : Array[Double],
size? : @math.Vec2 = @math.Vec2::new(120.0, 36.0),
color? : @color.Color = @color.Color::accent_primary(),
fill? : Bool = true,
show_hover? : Bool = true,
) -> (Int?, Response) {
ctx.push_id(id_salt)
let (_id, rect, resp) = ctx.allocate_space(size)
ctx.pop_id()
let count = values.length()
let scale = ctx.style.scale
let radius = ctx.style.radius_sm * scale
// 1. Container Card
ctx.painter().add_rect(rect, @color.Color::sparkline_wash(), radius)
ctx.painter().add_rect_stroke(rect, @color.Color::border_muted(), 1.0, radius)
if count == 0 {
return (None, resp)
}
let pad_x = 4.0
let pad_y = 4.0
let plot_w = rect.w - pad_x * 2.0
let plot_h = rect.h - pad_y * 2.0
let base_y = rect.y + pad_y + plot_h
if count == 1 {
let mid_y = rect.y + pad_y + plot_h * 0.5
ctx
.painter()
.add_line(
@math.Vec2::new(rect.x + pad_x, mid_y),
@math.Vec2::new(rect.x + pad_x + plot_w, mid_y),
color,
1.5,
)
return (None, resp)
}
// 2. Extremum Calculation with Zero-Delta Protection
let mut min_val = values[0]
let mut max_val = values[0]
for v in values {
if v < min_val {
min_val = v
}
if v > max_val {
max_val = v
}
}
let delta = max_val - min_val
let is_flat = delta < 0.00001
// 3. Compute Data Coordinates
let points : Array[@math.Vec2] = []
let denom = (count - 1).to_double()
for i = 0; i < count; i = i + 1 {
let px = rect.x + pad_x + i.to_double() / denom * plot_w
let py = if is_flat {
rect.y + pad_y + plot_h * 0.5
} else {
let norm = (values[i] - min_val) / delta
rect.y + pad_y + (1.0 - norm) * plot_h
}
points.push(@math.Vec2::new(px, py))
}
// 4. Subtle Area Wash Fill
if fill {
let fill_col = @color.Color::area_wash(color)
for i = 0; i < count - 1; i = i + 1 {
let p1 = points[i]
let p2 = points[i + 1]
let slice_w = p2.x - p1.x
let avg_y = (p1.y + p2.y) * 0.5
let slice_h = base_y - avg_y
if slice_h > 0.0 {
ctx
.painter()
.add_rect(@math.Rect::new(p1.x, avg_y, slice_w, slice_h), fill_col, 0.0)
}
}
}
// 5. Antialiased Polyline
for i = 0; i < count - 1; i = i + 1 {
ctx.painter().add_line(points[i], points[i + 1], color, 1.8)
}
// 6. Interactive Hover Guideline & Data Dot Snapping
let mut hovered_idx : Int? = None
if show_hover && ctx.is_hovered(rect) {
// The chart answers the pointer with a snapped readout, so say so.
ctx.set_cursor_icon("pointer")
let mouse_rel = (ctx.input.mouse_pos.x - (rect.x + pad_x)) / plot_w
let raw_idx = (mouse_rel * denom + 0.5).to_int()
let snapped_idx = if raw_idx < 0 {
0
} else if raw_idx >= count {
count - 1
} else {
raw_idx
}
hovered_idx = Some(snapped_idx)
let pt = points[snapped_idx]
// Vertical dashed guideline
ctx
.painter()
.add_line(
@math.Vec2::new(pt.x, rect.y + pad_y),
@math.Vec2::new(pt.x, base_y),
@color.Color::guideline_wash(color),
1.0,
)
// Snapped target dot with dual-ring highlight
ctx.painter().add_circle(pt, 4.0 * scale, color)
ctx.painter().add_circle(pt, 2.0 * scale, @color.Color::bg_window())
}
(hovered_idx, resp)
}