// Layout engine: computes plot area, scales, and positioning.
// Uses Result type for error handling.
///|
/// A rectangle in pixel space.
pub struct Rect {
x : Double
y : Double
w : Double
h : Double
}
///|
/// Left edge.
pub fn Rect::left(self : Rect) -> Double {
self.x
}
///|
/// Right edge.
pub fn Rect::right(self : Rect) -> Double {
self.x + self.w
}
///|
/// Top edge.
pub fn Rect::top(self : Rect) -> Double {
self.y
}
///|
/// Bottom edge.
pub fn Rect::bottom(self : Rect) -> Double {
self.y + self.h
}
///|
/// Center X.
pub fn Rect::centerX(self : Rect) -> Double {
self.x + self.w / 2.0
}
///|
/// Center Y.
pub fn Rect::centerY(self : Rect) -> Double {
self.y + self.h / 2.0
}
///|
/// Layout result: everything renderers need.
pub struct Layout {
/// The chart canvas rectangle (full SVG viewport).
canvas : Rect
/// The plot area rectangle (inside margins, where data is drawn).
plot : Rect
/// X-axis scale (category/band scale).
xScale : CategoryScale
/// Y-axis scale.
yScale : LinearScale
/// Bar dodge offsets for each series (indexed by series position).
dodgeOffsets : Array[Double]
/// Bar width (for bar charts).
barWidth : Double
}
///|
/// Compute the layout from a ChartOption.
/// Returns Result: Ok(Layout) on success, Err(message) on failure.
pub fn computeLayout(opt : ChartOption) -> Result[Layout, String] {
// Build canvas rect
let canvas = Rect::{ x: 0.0, y: 0.0, w: opt.width, h: opt.height }
// Determine plot area with dynamic margins (user margin acts as minimum)
let plotLeft = max(
opt.margin.left,
opt.yAxis.tickFontSize * 4.0 + opt.yAxis.labelFontSize + 10.0,
)
let plotRight = opt.width - opt.margin.right
// Top margin: account for title
let titleMargin = match opt.title {
Some(_) => opt.titleFont.size + 16.0
None => 10.0
}
let plotTop = max(opt.margin.top, titleMargin)
// Bottom margin: account for x-axis ticks and label
let xTickSpace = opt.xAxis.tickFontSize + 8.0
let xLabelSpace = match opt.xAxis.label {
Some(_) => opt.xAxis.labelFontSize + 4.0
None => 0.0
}
let plotBottom = opt.height -
max(opt.margin.bottom, xTickSpace + xLabelSpace + 10.0)
let plotW = plotRight - plotLeft
let plotH = plotBottom - plotTop
if plotW <= 0.0 || plotH <= 0.0 {
return Err(
"Plot area too small: width=\{plotW.to_string()}, height=\{plotH.to_string()}",
)
}
let plot = Rect::{ x: plotLeft, y: plotTop, w: plotW, h: plotH }
// Build x-axis tick positions and labels
let (xTicks, xLabels) = buildXTicks(opt)
// Build x scale
let xScale = CategoryScale::new(xTicks, xLabels, plotW)
// Build y scale from yAxis
let Linear(yMin, yMax) = opt.yAxis.kind
let yScale = LinearScale::new(yMin, yMax, plotH)
// Compute dodge offsets — for Bar and BoxPlot series
let nDodged = countDodgedSeries(opt.series)
let barWidth = if nDodged > 0 {
xScale.getBandWidth() * opt.barWidthRatio / max(nDodged.to_double(), 1.0)
} else {
0.0
}
// Pre-compute dodge offsets
let dodgeValues : Array[Double] = []
if nDodged > 1 {
let step = barWidth * (1.0 + opt.barGap)
let totalWidth = barWidth * nDodged.to_double() +
barWidth * opt.barGap * (nDodged.to_double() - 1.0)
let startOffset = -totalWidth / 2.0 + barWidth / 2.0
for i = 0; i < nDodged; i = i + 1 {
dodgeValues.push(startOffset + i.to_double() * step)
}
} else if nDodged == 1 {
dodgeValues.push(0.0)
}
// Map dodge offsets to all series: Bar/BoxPlot get offsets, others get 0.0
let dodgeOffsets : Array[Double] = []
let mut dodgeIdx = 0
for i = 0; i < opt.series.length(); i = i + 1 {
match opt.series[i].chartType {
Bar => {
dodgeOffsets.push(dodgeValues[dodgeIdx])
dodgeIdx = dodgeIdx + 1
}
BoxPlot => {
dodgeOffsets.push(dodgeValues[dodgeIdx])
dodgeIdx = dodgeIdx + 1
}
_ => dodgeOffsets.push(0.0)
}
}
Ok({ canvas, plot, xScale, yScale, dodgeOffsets, barWidth })
}
///|
/// Build X-axis tick positions and labels from options or auto-inference.
fn buildXTicks(opt : ChartOption) -> (Array[Double], Array[String]) {
// Use explicit ticks if provided
if opt.xAxis.tickPositions.length() > 0 {
let positions = opt.xAxis.tickPositions
let labels = if opt.xAxis.tickLabels.length() == positions.length() {
opt.xAxis.tickLabels
} else {
// Generate default labels from positions
let gen : Array[String] = []
for i = 0; i < positions.length(); i = i + 1 {
gen.push(formatPosLabel(positions[i]))
}
gen
}
return (positions, labels)
}
// Auto-infer from series data
autoInferXTicks(opt.series)
}
///|
/// Auto-infer X tick positions and labels from all series data.
fn autoInferXTicks(series : Array[Series]) -> (Array[Double], Array[String]) {
if series.length() == 0 {
return ([], [])
}
// Collect all unique x values from series data and box groups
let xVals : Array[Double] = []
for i = 0; i < series.length(); i = i + 1 {
// Data points
for j = 0; j < series[i].data.length(); j = j + 1 {
let x = series[i].data[j].x
if !containsD(xVals, x) {
xVals.push(x)
}
}
// Box groups
for j = 0; j < series[i].boxGroups.length(); j = j + 1 {
let x = series[i].boxGroups[j].x
if !containsD(xVals, x) {
xVals.push(x)
}
}
}
if xVals.length() == 0 {
return ([0.0], ["0"])
}
// Sort x values
sortDoubles(xVals)
// Generate labels from positions
let labels : Array[String] = []
for i = 0; i < xVals.length(); i = i + 1 {
labels.push(formatPosLabel(xVals[i]))
}
(xVals, labels)
}
///|
/// Check if array contains a double (approximate equality).
fn containsD(arr : Array[Double], val : Double) -> Bool {
for i = 0; i < arr.length(); i = i + 1 {
if (arr[i] - val).abs() < 0.0000000001 {
return true
}
}
false
}
///|
/// Sort an array of doubles (bubble sort for small arrays).
fn sortDoubles(arr : Array[Double]) -> Unit {
for i = 0; i < arr.length(); i = i + 1 {
for j = i + 1; j < arr.length(); j = j + 1 {
if arr[i] > arr[j] {
let tmp : Double = arr[i]
arr[i] = arr[j]
arr[j] = tmp
}
}
}
}
///|
/// Format a position value to a label string.
fn formatPosLabel(value : Double) -> String {
if value == Double::round(value) {
Double::round(value).to_string()
} else {
value.to_string()
}
}
///|
/// Max of two Double values (no Prelude max for Double).
fn max(a : Double, b : Double) -> Double {
if a > b {
a
} else {
b
}
}
///|
/// Count Bar and BoxPlot series (for dodge calculation).
fn countDodgedSeries(series : Array[Series]) -> Int {
let mut count = 0
for i = 0; i < series.length(); i = i + 1 {
match series[i].chartType {
Bar => count = count + 1
BoxPlot => count = count + 1
_ => ()
}
}
count
}