Add guides for native UI authoring, layouts, application CSS, and HTML compatibility. Update the CSS reference, rename article files consistently, and point README links to the local guides. Add Copy Link for HTML and Markdown anchors when no text is selected. Resolve relative links against the document URI before copying, and cover Markdown and WebView behavior with tests. Fix layout width policy serialization to use the widget's width.
18 KiB
UI Charts
Overview
UIChart is eepp's interactive, hardware-accelerated line chart widget. It supports multiple
series and Y axes, optional filled areas, live data, pan and zoom, hover tooltips, configurable
grids, smooth curves, and rotated X-axis labels. Charts can be created in C++ or placed in a UI
XML layout as <Chart>.
The public API is available through <eepp/ui.hpp>. The individual headers are under
<eepp/ui/charts/>, and the types live in EE::UI::Charts. The complete runnable example is
src/examples/ui_charts/ui_charts.cpp.
The module currently draws line series on linear axes. X and Y values are numeric doubles;
formatters decide how ticks appear, including dates or units. A chart does not convert date strings
or other application data into numeric coordinates for you.
A first chart
This example assumes an initialized UISceneNode, such as app.getUI() in a UIApplication:
#include <eepp/ui.hpp>
using namespace EE::UI;
using namespace EE::UI::Charts;
auto* chart = UIChart::New();
chart->setParent( ui->getRoot() );
chart->setSize( 500, 300 );
chart->xAxis().setLabel( "Second" );
chart->yAxis().setLabel( "Temperature (C)" );
auto& temperature = chart->addLineSeries( "Temperature" );
temperature.setColor( Color( 255, 113, 132 ) );
temperature.setWidth( 2.f ); // Device-independent pixels.
temperature.setPoints( std::vector<ChartPoint>{
{ 0.0, 20.0 }, { 1.0, 21.5 }, { 2.0, 21.0 }, { 3.0, 23.0 }
} );
chart->fit();
ChartPoint contains double x and double y. setPoints(std::vector<ChartPoint>) creates an
owned data source and moves the vector into it when possible. There are also convenience overloads:
series.setPoints( xs, ys ); // Copies paired spans of double values.
series.setValues( ys, 10.0, 0.5 ); // Copies Y values; X is 10 + index * 0.5.
The paired overload copies up to the length of the shorter span. Supply equal-length spans when every sample matters. If the data already lives in another representation, use one of the data sources described below to avoid an unnecessary point copy.
Chart in an XML layout
Chart is registered with the standard widget creator. The layout controls the widget's size;
configure its model and chart-specific style from C++ after loading it:
<vbox lw="mp" lh="mp" padding="8dp">
<TextView text="Temperature history" lw="mp" lh="wc" />
<Chart id="temperature-chart" lw="mp" lh="0" lw8="1" />
</vbox>
auto* layout = ui->loadLayoutFromString( xml );
auto* chart = layout->find<UIChart>( "temperature-chart" );
chart->addLineSeries( "Temperature" ).setPoints( std::move( points ) );
chart->fit();
The lh="0" lh8="1" combination gives the chart the remaining height in a vbox, so it grows
with its parent. Standard widget CSS can set its background, padding, and other widget properties.
Chart-specific drawing options are set through ChartStyle.
Model, series, and axes
Every UIChart starts with a ChartModel, a bottom X axis, and a left Y axis. The model owns its
axes and line series. addLineSeries(), addAxis(), xAxis(), and yAxis() return borrowed
references to those model-owned objects. Added axes and series remain at stable addresses until the
model is destroyed. Keep a std::shared_ptr<ChartModel> when their lifetime must extend beyond a
particular widget.
auto& load = chart->addLineSeries( "Load" );
load.setPoints( std::move( loadPoints ) );
auto& rightAxis = chart->model().addAxis( AxisPosition::Right );
rightAxis.setLabel( "Temperature (C)" );
auto& ambient = chart->addLineSeries( "Ambient" );
ambient.setYAxis( rightAxis );
ambient.setPoints( std::move( ambientPoints ) );
chart->fit();
New series use the model's default X and Y axes. setXAxis() and setYAxis() accept references to
axes owned by the same model. ChartAxis::setLabel() stores a String, and
setFormatter() customizes tick text:
chart->yAxis().setFormatter( []( double value, double ) {
return String::format( "%.0f%%", value );
} );
The formatter receives the tick value and selected step. The default formatter selects readable decimal or scientific notation. Tick values are chosen automatically to approach the requested visual spacing; a formatter changes their text, not their positions.
To display the same data in separate viewports, share the model:
auto shared = chart->sharedModel();
otherChart->setModel( shared );
otherChart->setAxisRange( otherChart->xAxis(), { 50.0, 100.0 } );
Each widget retains its own visible ranges, style, layout, and hover state. setModel(nullptr)
creates a fresh empty model. References obtained from the old model may then expire unless another
shared_ptr keeps it alive.
Ranges and constraints
fit() fits every axis to the visible series data and turns off X follow mode. Without a manual
range, axes fit their data automatically as sources change. You can set a range for a particular
chart view and read it back:
chart->setAxisRange( chart->xAxis(), { 0.0, 60.0 } );
DataRange visibleX = chart->axisRange( chart->xAxis() );
Setting a range makes that axis manual. fit() returns it to automatic fitting. To limit pan,
zoom, and fitted ranges, configure the axis itself:
RangeConstraints limits;
limits.absoluteMinimum = 0.0;
limits.absoluteMaximum = 100.0;
limits.minimumViewSpan = 5.0;
limits.maximumViewSpan = 100.0; // Zero means unbounded.
chart->yAxis().setConstraints( limits );
Constraints belong to the model's axis and therefore apply to every view sharing that model.
Visible ranges belong to each UIChart.
Data sources
LineSeries::setDataSource(std::shared_ptr<XYDataSource>) accepts a reusable source and retains a
shared reference. Several series can share one source, including across charts. Choose a source
according to how data is stored and updated:
| Source | Use | Storage and updates |
|---|---|---|
OwnedXYDataSource |
An editable sequence of ChartPoint values |
Owns the vector; reserve(), setPoints(), append(), and clear() |
RingXYDataSource |
Bounded live history | Fixed-capacity FIFO; oldest points are discarded on overflow |
ImmutableXYDataSource |
Existing immutable arrays or records | Shares backing storage without copying samples |
ModelXYDataSource |
Numeric columns of an eepp Models::Model |
Copies model values into a typed cache when the model updates |
For a live chart, retain the source and append on a UI timer:
auto ring = std::make_shared<RingXYDataSource>( 600 );
auto& live = chart->addLineSeries( "CPU" );
live.setDataSource( ring );
chart->setFollowX( 60.0 ); // Keep a 60-unit X window at the newest sample.
ui->setInterval( [ring, nextX = 0.0]() mutable {
const double value = readCpuLoad();
ring->append( { nextX++, value } );
}, Milliseconds( 100 ), String::hash( "cpu-chart-update" ) );
Here readCpuLoad() stands for an application-provided sampler. Cancel the timer when the
associated screen is removed if the application keeps its UI scene alive.
Keep X values increasing for the optimized visible-range lookup and pixel-bucket reduction. In a
ring source, a wrapped read exposes two logical chunks in the right order; callers do not need to
rearrange samples. setFollowX(span) uses the newest X value on the primary X axis and refits Y
axes to the visible window as data changes. Pass zero to stop following. Manual range changes,
mouse pan, mouse zoom, and fit() also stop follow mode. Call setFollowX() again to resume it.
For immutable X and Y arrays, keep them in shared vectors and create a zero-copy source:
auto xs = std::make_shared<const std::vector<double>>( std::move( times ) );
auto ys = std::make_shared<const std::vector<Float>>( std::move( values ) );
series.setDataSource( makeArrayXYDataSource( xs, ys ) );
makeMemberXYDataSource() similarly reads numeric members of a shared vector of standard-layout
records. Its X and Y members can be float or double. Treat storage behind an immutable source
as immutable for the source's whole lifetime. ModelXYDataSource accepts a shared
Models::Model, X and Y column indexes, and optional model roles; nonnumeric values become gaps.
Its cache is rebuilt when the model reports an update, so it is most useful when model integration
matters more than avoiding a copy on each update.
Custom sources implement XYDataSource::acquireRead() and publish a coherent XYDataRead with a
generation, data order, and one or more XYDataChunks. A read's spans and backing token must stay
valid until that read is destroyed. For mutable storage, writers must be synchronized with active
readers. DataDelta can describe an append or reset for consumers that can update incrementally.
The built-in mutable sources use locks and release them before notifying listeners. Keep read
transactions short so producers are not blocked by a long-held read lock.
Nonfinite X or Y values (NaN or infinity) form gaps rather than connected line segments. An
ascending X order is required for the fast reduction path; out-of-order X data does not receive
that reduction. If plotting becomes unexpectedly slow or empty, inspect the source's
XYDataRead::order() and the X values.
Appearance
Copy the current ChartStyle, change the desired fields, and pass the full style back. The chart
keeps its own copy. Lengths such as margins, font size, tick spacing, line width, hover distance,
and grid screen spacing are device-independent pixels (dp), scaled by eepp's PixelDensity.
ChartStyle style = chart->chartStyle();
style.fontSize = 13.f;
style.xTickSpacing = 96.f;
style.tickLabelColor = Color( 210, 214, 220 );
style.axisColor = Color( 125, 130, 138 );
chart->setChartStyle( style );
series.setColor( Color( 83, 179, 255 ) );
series.setWidth( 3.f );
series.setJoin( LineJoin::Bevel );
series.setCap( LineCap::Square );
series.setMiterLimit( 4.f );
series.setVisible( true );
ChartStyle group |
Fields |
|---|---|
| Text | font, fontSize, tickLabelColor, axisLabelColor |
| Margins and text gaps | leftMargin, rightMargin, topMargin, topMarginWithLabel, bottomMargin, bottomMarginWithLabel, minimumAxisMargin, axisLabelPadding, rightAxisLabelPadding, axisLabelGap, axisLabelInset, tickLabelGap |
| Ticks | tickLength, xTickSpacing, yTickSpacing, xTickLabelAngle, xTickLabelAnchor |
| Strokes and hover | axisWidth, axisColor, tickColor, hoverColor, hoverDistance, seriesColor |
| Grid | verticalGrid, horizontalGrid |
An unset optional color follows the current UI theme. The chart uses the theme's --tab-line
for axes, --font-hint for tick text, --font for axis labels, and --primary for its default
series and hover colors, with built-in fallback colors. Tick color defaults to the resolved axis
color. Grid color defaults to the axis color with reduced opacity. ChartStyle::font == nullptr
uses the UI theme's default font. The optional font pointer is borrowed; its font must outlive
the chart's use of it. A series's explicit setColor() overrides the style's default series color.
Filled areas
Each line can optionally fill the area down to the bottom edge of the plot. The fill uses the same interpolation as its line, stops at nonfinite data gaps, and is clipped to the plot. All fills are drawn before the line strokes, so overlapping fills blend while the strokes stay visible. Filling is off by default.
series.setFilled( true );
series.setFillColor( Color( 83, 179, 255, 90 ) ); // Optional custom RGBA fill.
series.setFillColor( std::nullopt ); // Return to the derived color.
Without a custom fill color, the chart uses the resolved line color at one-quarter of its alpha.
Thus a themed series follows theme color changes, and a transparent line produces a correspondingly
transparent fill. Set setFilled( false ) to show only the line again. A zero-width line can still
show its fill.
Grids
Vertical and horizontal grids are configured independently. Both start disabled.
ChartGridMode |
Positioning | spacing unit |
Effect of pan and zoom |
|---|---|---|---|
Disabled |
No lines | — | — |
AxisTicks |
Nice data ticks near a target distance | dp | Follows the selected axis |
DataInterval |
Exact data values at offset + n * spacing |
Axis units | Follows the selected axis |
ScreenInterval |
Fixed positions measured from the plot's left or top edge | dp | Stays fixed in the plot |
offset uses axis units for DataInterval, dp for ScreenInterval, and has no effect on
AxisTicks. width is in dp. color is optional. axis is a non-owning pointer to an axis
in the same model; null uses the primary X or Y axis. Select an X-oriented axis for vertical
lines and a Y-oriented axis for horizontal lines:
ChartStyle style = chart->chartStyle();
style.verticalGrid.mode = ChartGridMode::DataInterval;
style.verticalGrid.spacing = 10.0;
style.verticalGrid.offset = 0.0;
style.verticalGrid.width = 1.f;
style.verticalGrid.color = Color( 120, 130, 150, 64 );
style.horizontalGrid.mode = ChartGridMode::AxisTicks;
style.horizontalGrid.spacing = 48.0; // Target gap in dp.
chart->setChartStyle( style );
The plot clips grid lines to its bounds. Very dense configurations are bounded to avoid drawing
an unbounded number of lines; give spacing a positive finite value.
Smoothed lines and angled tick labels
Interpolation is selected per series. Linear joins samples with straight segments.
MonotoneCubic smooths ordered X samples without overshooting adjacent sample Y values. Gaps
break the curve, and the hover hit test follows the smoothed stroke. Use linear interpolation
when straight edges or exact piecewise-linear values are desired.
series.setInterpolation( LineInterpolation::MonotoneCubic );
ChartStyle style = chart->chartStyle();
style.xTickLabelAngle = -45.f;
style.xTickLabelAnchor = ChartTickLabelAnchor::End;
chart->setChartStyle( style );
chart->xAxis().setFormatter( []( double day, double ) {
return String::format( "Day %.0f", day );
} );
xTickLabelAngle is in degrees, clamped to [-90, 90]; negative values tilt labels toward
bottom-to-top reading. The anchor (Start, Center, or End) chooses which part of the rotated
label meets its tick. The layout reserves room for the rotated text automatically. Formatting
dates is an application concern: represent time as a numeric X coordinate and return the desired
date text from the axis formatter.
Hover tooltips and interaction
Moving the cursor near a visible line highlights its X position and displays a tooltip for an original source sample, rather than an interpolated curve point. This also holds for a pixel-bucket-reduced line. The default tooltip contains the series name and formatted X and Y values. Set a provider on a series to return plain or structured text:
series.setTooltipProvider(
[]( const PointTooltipContext& point ) -> std::optional<TooltipPayload> {
TooltipData tooltip;
tooltip.title = "Sample";
tooltip.fields.emplace_back(
TooltipField{ "Index", String::toString( static_cast<Uint64>( point.index ) ) } );
tooltip.fields.emplace_back(
TooltipField{ "Value", String::format( "%.2f", point.y ) } );
tooltip.description = "Measured value";
return tooltip;
} );
PointTooltipContext::index is the current logical index in the source read. A ring's indexes
shift as old samples are discarded; use the sample's X value for a stable time or sequence number.
Returning std::nullopt uses the default tooltip. Returning an empty TooltipText suppresses
visible tooltip text. Providers run when hover changes, not for every line drawn. An updating
chart rechecks hover after its geometry changes, so a stationary cursor follows live samples.
| Input | Result |
|---|---|
| Hover near a series | Show the nearest sample's tooltip and an X guide line |
| Drag with the left mouse button | Pan the visible ranges; stop X follow mode |
| Mouse wheel over the plot | Zoom around the cursor; stop X follow mode |
fit() from application code |
Fit visible series and stop X follow mode |
Hover is limited by ChartStyle::hoverDistance in dp and by the plot area. The tooltip is hidden
when the cursor leaves, the selected series disappears, or its text is empty. Chart interactions
apply to all axes in that view; an axis's RangeConstraints still limit the resulting ranges.
Dense data and update costs
For ascending X data, the chart finds the visible interval and reduces it by screen pixel columns. Each bucket preserves its first, last, minimum, and maximum source samples in source order, so narrow spikes remain visible. Reduced points retain source indexes for hover. A series with a small visible point count is drawn directly. Geometry and layout buffers are reused and rebuilt when their data generation, viewport, series style, or size changes.
Keep frequent data updates in a mutable source instead of replacing a large vector each frame.
Prefer RingXYDataSource for bounded histories and immutable shared arrays for existing static
data. Smooth interpolation adds stroke segments and work after reduction, so linear interpolation
is generally the better starting point for very dense traces. Axis formatters and tooltip
providers can allocate strings; formatters run during layout and providers run on hover, not for
each sample in the render loop.
For a hands-on reference, run the charts example.
It shows a responsive four-chart layout with rotated date-like labels, smooth and linear lines,
multiple Y axes, grid modes, structured tooltips, a 250,000-point trace, and a live ring. Hover or
drag a chart, scroll to zoom, press R to fit all views, Space to append samples, and C or
T to toggle a series.