chartificial

Cartesian charts for Flutter, built on the framework's two-dimensional viewport. Designed for the charts that are awkward everywhere else: time series that scroll, history that loads as you reach it, and axis labels that are real widgets.

pub package pub points CI license: MIT

Scrollable AQI history with a value-banded stroke

Why chartificial

  • Lazy paged loading. Give it a ChartLoader and history is fetched before the user scrolls to the edge, with a configurable prefetch margin. Loading never blocks scrolling: the user can keep going into regions whose data hasn't arrived yet, and pages land behind them without moving the pixels already on screen.
  • Axis labels are widgets. AxisLabelConfig.builder returns whatever you like — an icon, a chip, a two-line date. The viewport recycles them instead of rebuilding, so scrolling costs nothing extra.
  • A fixed spine, pluggable policies. The frame sequence is closed; every decision inside it is an interface you can implement. Series, decorations, range policies, tick strategies, scales and pointer snapping are all open seams — the built-in bar series is itself just a client of the public ChartSeries.
  • No dependencies. flutter and nothing else. Android, iOS, web, macOS, Windows and Linux.
Scrolling time axis — newest point pinned right, scroll into the past
Value-space color bands — stroke colored by value, not by position
Line styles — curves, dashes, area fill, gradient stroke, end badge
Floating bars — min–max pills over a background track
Stacked + grouped bars — cumulative segments, clustered per slot
Mixed series — bars and a line sharing a scrolling axis
Custom decorations — replace the grid, ring the viewport edge crossings
Custom series — a lollipop series defined outside the package

Install

$ flutter pub add chartificial
import 'package:chartificial/chartificial.dart';

Quick start

Everything but the axes and series has a working default, and it is all const-constructible:

SizedBox(
  height: 240,
  child: Chartificial(
    xAxis: const NumericAxis(),
    yAxis: const NumericAxis(),
    series: const [
      LineSeries(
        id: 'demo',
        data: [
          ChartPoint(0, 1),
          ChartPoint(1, 3),
          ChartPoint(2, 2),
          ChartPoint(3, 5),
        ],
      ),
    ],
  ),
)

Like every viewport, the chart cannot size itself to its content — give it bounded constraints in both directions (SizedBox, Expanded, AspectRatio).

Scrolling time series

The case the package is built for. AxisAnchor.end starts at the newest point and scrolls left into history; AxisResolution.interval fixes how many pixels an hour occupies, which is what makes the axis scrollable at all; and ChartLoader fetches older pages before the user reaches them.

final controller = ChartificialController(
  followLatest: true, // stay pinned to "now" until the user scrolls away
  loader: ChartLoader(
    onLoadOlder: (request) async {
      final page = await api.fetchBefore(request.edgeDateTime);
      controller.prependPoints('aqi', page.points);
      return page.hasMore; // returning false latches exhaustion
    },
  ),
);

Chartificial(
  controller: controller,
  xAxis: DateTimeAxis(
    anchor: AxisAnchor.end,
    resolution: AxisResolution.interval(const Duration(hours: 1), 24),
    ticks: AxisTicks.timeInterval(const Duration(hours: 6)),
    labels: AxisLabelConfig(
      builder: (context, tick) => Text('${tick.dateTime.hour}:00'),
    ),
  ),
  yAxis: const NumericAxis(range: AxisRange.fixed(0, 300)),
  series: const [
    LineSeries(
      id: 'aqi',
      colorScale: ChartColorScale.bands([
        ColorBand(0, 50, Color(0xFF4CAF50)),
        ColorBand(50, 100, Color(0xFFFFC107)),
        ColorBand(100, 300, Color(0xFFF44336)),
      ]),
    ),
  ],
  trackball: TrackballBehavior(
    builder: (context, details) => MyTooltip(details),
  ),
)

Loading never gates scrolling. While controller.canLoadMore(direction) is true the scroll boundary stays ahead of the finger, so the user can fling straight past the loaded edge: the not-yet-loaded region renders as an empty plot with a live axis, and pages fill it in without shifting what is on screen. ChartLoadRequest.suggestedSpanX covers the whole scrolled-ahead gap, so a source that honors the hint fills it in one page. Only exhaustion (returning false) bounds the scroll — the chart then eases back to the oldest loaded point, and isAtLatest/scrollToLatest always mean the newest datum, never a loading boundary.

ChartColorScale stops are defined in value space, so a band boundary sits exactly at 50 however the axis is transformed — including a log axis.

Extension points

The spine being closed is the point: paint order, label recycling, scroll anchoring and load triggering are invariants you compose within.

Seam Use
ChartSeries Define your own series type: painting, wide-mark overhang, marker color, pointer snapping. BarSeries is built on this same interface.
ChartSeries.snap Per-series trackball/hit snapping — clustered bars shift by their offset; custom series redefine "nearest" or opt out.
AxisRange Custom range policies. The auto/fixed/visible built-ins are exported so yours can delegate to them.
AxisTicks Custom tick strategies via resolve(TickResolveContext)ResolvedTicks.
AxisScale Any strictly-increasing domain transform; LinearScale and LogScale ship.
AxisLabelConfig.builder Any widget as an axis label, recycled while scrolling.
ChartDecoration Paint layers behind or above the series — the default grid is one of these, and is removable.
ChartColorScale Value-space gradient stops or hard bands for series strokes.
ChartPoint Subclass it; yExtent lets range-like data auto-range correctly. BarPoint does exactly this.
SeriesRenderSnapshot.geometry Per-frame channel for a series to publish paint products to decorations above it.
ChartLoader Async paged loading triggered by scroll position, with a prefetch margin.

Every open seam takes a single context object (ChartPaintContext, AxisRangeContext, TickResolveContext), so new capabilities arrive as new context fields rather than as signature breaks.

Example app

example/ has twelve runnable demos:

Demo Shows
Infinite past + lazy loading Newest at the right, pages loaded ahead of the scroll
Live values + followLatest A point per second, pinned to "now" until you scroll away
AQI gradient line Value-band stroke coloring with band backgrounds
Line styles Curves, dashes, area fill, gradient stroke, end badge, opposed axis
Bar chart: daily range Floating min–max pill bars on a background track
Stacked + grouped bars Pollutant mix stacked per bar, indoor and outdoor clustered
Scrolling time bars Hourly bars plus an average line, stable at the edges
Custom grid + edge markers A replaced grid, rings where the line crosses the edges
Custom lollipop series A series defined by the app — the ChartSeries seam
Fixed axes + callbacks Non-scrollable chart with gaps, markers, raw hit callbacks
Logarithmic axis Four decades of particle counts with decade ticks
Visible-window y range The y axis rescales to whatever is on screen
$ cd example && flutter run

Requirements

Flutter >=3.44.0 / Dart ^3.12.0, for the TwoDimensionalViewport APIs the chart is built on.

Contributing

Issues and pull requests are welcome — see CONTRIBUTING.md.

License

MIT — see LICENSE.

Libraries

chartificial
Cartesian charts built on Flutter's two-dimensional viewport: scrollable time series with lazy data loading, widget-based axis labels, value-space color scales, and pluggable rendering layers.