> For the complete documentation index, see [llms.txt](https://wong-coupon.gitbook.io/flutter/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://wong-coupon.gitbook.io/flutter/my-flutter/ui-media/financial-chart-renderer.md).

# Choosing a Financial Chart Renderer

How to choose among FL Chart, ECharts, and an MPChart-style renderer based on capabilities, realtime updates, gestures, testing, and WebView cost

## Outcome

In my project, financial charts do not all use the same renderer. Pie charts and some line/bar charts stay entirely in Flutter; complex analytical options run in ECharts inside a WebView; charts that need combined datasets, markers, and viewport control use an MPChart-style renderer.

Over time, I found that the right question is not “which package supports more chart types?” It is:

> Which capabilities does this feature need, and which runtime boundary am I willing to accept?

I keep the financial model independent of chart packages, then pass the data through the appropriate adapter:

```
API / cache / realtime stream
            │
            ▼
  FinancialChartSnapshot
  point · candle · volume
            │
       normalize + validate
            │
            ▼
     renderer-neutral state
            │
      ┌─────┼─────────────┐
      ▼     ▼             ▼
 fl_chart   ECharts       MPChart-style
 Flutter    WebView       controller/dataset
```

This separation produces four practical results:

* Services, stores, and caches do not depend on `FlSpot`, an ECharts `option`, or a chart package's `Entry` type.
* Realtime updates follow one shared policy before they reach a renderer.
* A feature can switch renderers or evaluate two adapters with the same fixture.
* Domain tests, UI state tests, and interaction tests stay in the appropriate layer instead of being pushed into screenshots.

This is the matrix I use to start the discussion:

| Requirement                                   | `fl_chart`                         | ECharts in a WebView                    | MPChart-style controller                                    |
| --------------------------------------------- | ---------------------------------- | --------------------------------------- | ----------------------------------------------------------- |
| Pie/line/bar closely composed with Flutter UI | Prefer                             | Possible, but adds WebView cost         | Possible, but the API is heavier                            |
| Tooltip must be a Flutter widget              | Convenient                         | Requires an HTML tooltip or bridge      | Uses renderer markers/overlays                              |
| `visualMap`, `dataZoom`, multiple axes/series | More limited in the older version  | Convenient                              | Requires detailed dataset/controller setup                  |
| Price line combined with volume bars          | Can be composed manually           | Uses multiple series/axes               | Fits a `CombinedData`-style API                             |
| Candles, indicators, viewport, and load-more  | Depends on version/capabilities    | Fits when ECharts options already exist | Fits when the controller is the main requirement            |
| Realtime updates                              | Create new chart data              | Call `setOption()` through JavaScript   | Update datasets, notify, and redraw                         |
| Avoid PlatformView/WebView                    | Fits                               | Does not fit                            | Fits                                                        |
| Direct goldens in widget tests                | Feasible with a locked environment | Should not be the default assumption    | Depends on the selected public implementation               |
| Public dependency and clean reproducibility   | Yes                                | Yes                                     | Must be checked per package; the source uses a private fork |

This matrix is not a ranking. The source I reviewed has no shared benchmark proving that one renderer is always faster, lighter, or smoother than the other two.

## Problem

### A chart type is not enough to choose a package

Two screens can both display line charts and still have completely different requirements.

The first screen may contain only a few dozen points, need a tooltip with Flutter typography, and place the chart among other cards. The second may combine price, volume, indicators, zoom, history prepending, and viewport preservation when a new tick arrives. If I search only for a `line chart` package, I miss the hardest part of the feature.

I write a capability checklist before adding a dependency:

* Which series are required: line, bar, pie, candle, volume, indicator, or mark line?
* How many points are loaded initially, and how often does the data update?
* Does an update replace the last point, append a new point, or prepend history?
* Does the chart pan/zoom on one axis or two, and must it preserve the viewport when data changes?
* Must the tooltip be a Flutter widget, HTML, or a renderer-owned marker?
* Does the chart live inside a `ListView`, `PageView`, or bottom sheet?
* Does the feature require text alternatives, semantics, or keyboard interaction?
* Does the team accept a WebView, JavaScript bridge, and separate lifecycle?
* Which tests belong in widget tests, and which must run on a device?
* Does the dependency have a public release, a clear license, and an upgrade path?

Only after answering these questions does a package name become meaningful.

### Package models easily leak into the domain

A quick implementation often returns `List<FlSpot>` directly from a repository, stores an ECharts `Option`, or passes an MPChart `Entry` into the realtime reducer. The feature works, but the renderer has become part of the data contract.

The cost appears when:

* A major upgrade changes the chart API.
* The same data must feed another renderer.
* Sort, deduplication, and candle invariants need unit tests without constructing a widget/controller.
* The backend changes timestamp behavior or sends out-of-order ticks.
* The same color, formatter, and gap rules are implemented differently in three charts.

The adapter should know the package. The domain should not.

### Three renderers create three runtime boundaries

`fl_chart` draws inside the Flutter widget tree. Themes, gesture callbacks, and overlays remain in Dart/Flutter, but an update usually creates new chart data.

ECharts receives an option and renders inside a WebView. A data change may require JSON serialization followed by a JavaScript call. Gestures pass through a PlatformView, while resize, readiness, JavaScript errors, and disposal need their own contract.

An MPChart-style renderer uses controllers and datasets. It fits features that need dataset mutation, highlights, or detailed viewport preservation, but the implementation can become deeply coupled to a specific API. In the source I verified, this dependency is a private fork, so its internal reference cannot become a public installation guide.

My practical decision flow is:

```
Financial feature
       │
       ├── Tight Flutter UI composition and Flutter tooltip/semantics?
       │        └── Yes → consider a fl_chart adapter
       │
       ├── Existing complex option, dataZoom/visualMap/custom series?
       │        └── Yes → consider an ECharts WebView adapter
       │
       ├── Combined datasets, viewport controller, marker, history prepend?
       │        └── Yes → consider an MPChart-style adapter
       │
       └── Still unclear → prototype with the same data and benchmark on device
```

### No shared benchmark means no universal winner

A chart with 100 static points on a high-end device does not represent 5,000 candles being zoomed on a mid-range phone. FPS is also insufficient: I still need time to first chart, post-tick latency, memory after repeated create/dispose cycles, and gesture stability.

That is why I avoid claims such as “Flutter-native is always faster” or “JavaScript canvas always handles more points.” I select candidates from their capabilities, then measure them with the same dataset and interaction scenario.

## Solution

### Start with a neutral model

The domain model contains only the data owned by the feature:

```dart
typedef EpochMillis = int;

final class ChartPoint {
  const ChartPoint({
    required this.time,
    required this.value,
  });

  final EpochMillis time;
  final double value;
}

final class CandlePoint {
  const CandlePoint({
    required this.time,
    required this.open,
    required this.high,
    required this.low,
    required this.close,
    required this.volume,
  });

  final EpochMillis time;
  final double open;
  final double high;
  final double low;
  final double close;
  final double volume;
}
```

Timestamp, price, and volume do not know whether a chart will be drawn by Flutter, JavaScript, or a controller/dataset renderer. A repository can return `List<ChartPoint>` or a multi-series snapshot; only the adapter converts those values into package-specific types.

### Normalize and validate before the renderer

I do not let each widget sort and filter data independently. For example, a line series is deduplicated by timestamp, stripped of non-finite values, and sorted once:

```dart
List<ChartPoint> normalizePoints(Iterable<ChartPoint> input) {
  final byTime = <EpochMillis, ChartPoint>{};

  for (final point in input) {
    if (!point.value.isFinite) continue;
    byTime[point.time] = point;
  }

  final result = byTime.values.toList()
    ..sort((a, b) => a.time.compareTo(b.time));

  return List.unmodifiable(result);
}
```

For candles, the pipeline must also verify:

```
low <= open <= high
low <= close <= high
volume >= 0
```

Price, volume, and labels must be normalized from the same candle snapshot. If each series deduplicates independently, two arrays can have the same length while their timestamps are misaligned by index.

Gaps also need an explicit policy: preserve `null`, fill with zero, or drop the point. The three adapters should not guess three different answers.

### Select the renderer with a feature policy

I express capabilities in a small object instead of scattering `if` statements based on chart names:

```dart
enum FinancialChartRenderer {
  flChart,
  eCharts,
  mpChartStyle,
}

final class ChartNeeds {
  const ChartNeeds({
    this.flutterTooltip = false,
    this.existingEChartsOption = false,
    this.combinedDatasets = false,
    this.viewportController = false,
    this.prependHistory = false,
  });

  final bool flutterTooltip;
  final bool existingEChartsOption;
  final bool combinedDatasets;
  final bool viewportController;
  final bool prependHistory;
}

FinancialChartRenderer chooseRenderer(ChartNeeds needs) {
  if (needs.existingEChartsOption) {
    return FinancialChartRenderer.eCharts;
  }

  if (needs.combinedDatasets ||
      needs.viewportController ||
      needs.prependHistory) {
    return FinancialChartRenderer.mpChartStyle;
  }

  return FinancialChartRenderer.flChart;
}
```

This is only a project's starting policy, not a universal algorithm. For example, `flutterTooltip` does not automatically win if the feature also requires `dataZoom`. When capabilities conflict, I build a small prototype and let the benchmark decide.

### `fl_chart` adapter: prioritize Flutter composition

I use `fl_chart` when the chart should fit naturally into a Flutter layout and touch/tooltip behavior can stay in Dart. The adapter converts the neutral model into `FlSpot` values:

```dart
List<FlSpot> toFlSpots(List<ChartPoint> points) {
  return [
    for (var index = 0; index < points.length; index++)
      FlSpot(index.toDouble(), points[index].value),
  ];
}
```

I use the index as the renderer's x-axis value while retaining the original timestamp in the snapshot for formatters and tooltips. This avoids placing very large epoch-millisecond values into the chart's `double` coordinates.

The widget can be built with an API close to the `fl_chart 0.65.0` pattern pinned by the source:

```dart
LineChart(
  LineChartData(
    lineBarsData: [
      LineChartBarData(
        spots: toFlSpots(points),
        isCurved: false,
        dotData: const FlDotData(show: false),
        color: theme.priceLine,
        belowBarData: BarAreaData(
          show: true,
          color: theme.priceLine.withOpacity(0.12),
        ),
      ),
    ],
    lineTouchData: const LineTouchData(enabled: true),
  ),
)
```

The advantage is not “less code in every case.” The actual source contains deeply customized tooltips and overlays. The advantage is that the chart, text, theme, and interactions stay in the same Flutter runtime.

For realtime updates, the adapter creates new `LineChartData` from the normalized snapshot. Large datasets or frequent ticks still require benchmarking; rebuilding in Dart does not automatically prove sufficient performance for every feature.

### ECharts adapter: broad capabilities in exchange for a WebView boundary

ECharts fits when a feature needs complex options such as multiple axes/series, `dataZoom`, `visualMap`, or candlesticks combined with indicators. I keep the data and styling JSON-safe:

```dart
Map<String, Object?> buildEChartsOption(
  List<ChartPoint> points,
  ChartThemeTokens theme,
) {
  return {
    'animation': false,
    'grid': {
      'left': 16,
      'right': 16,
      'top': 12,
      'bottom': 32,
      'containLabel': true,
    },
    'xAxis': {
      'type': 'category',
      'data': points.map((point) => point.time).toList(),
      'axisLine': {
        'lineStyle': {'color': theme.axisHex},
      },
    },
    'yAxis': {
      'type': 'value',
      'splitLine': {
        'lineStyle': {'color': theme.gridHex},
      },
    },
    'series': [
      {
        'type': 'line',
        'showSymbol': false,
        'data': points.map((point) => point.value).toList(),
        'lineStyle': {'color': theme.priceLineHex},
      },
    ],
  };
}
```

The widget then serializes only the controlled object:

```dart
Echarts(
  option: jsonEncode(buildEChartsOption(points, theme)),
  captureHorizontalGestures: true,
  captureVerticalGestures: false,
  onMessage: onEChartsMessage,
)
```

I do not concatenate a symbol, label, or user-provided text into a JavaScript formatter. If an executable formatter is required, I select it from a constant registry owned by the app; dynamic input travels only as JSON data.

A production adapter needs a clearer contract than a widget string:

```dart
abstract interface class EChartsPort {
  Future<void> waitUntilReady();
  Future<void> setOption(Map<String, Object?> option);
  Future<void> resize();
  Stream<ChartInteraction> get interactions;
  Stream<Object> get errors;
  Future<void> dispose();
}
```

I always verify these points:

* The container has a defined width and height before chart initialization.
* A constraint change calls `resize()` instead of only rebuilding the Flutter shell.
* The WebView captures only the gesture axis the chart actually needs, so it does not block parent scrolling.
* `click` and `datazoom` messages are parsed into typed events before reaching the feature.
* JavaScript errors return to Flutter or telemetry instead of disappearing in an empty catch.
* The ECharts instance is disposed before its DOM/WebView is removed.
* The Flutter shell provides loading, error, and fallback states; a blank WebView is not an error UI.

The JavaScript bridge lifecycle, ready handshake, and trust boundary are covered in more detail here:

{% content-ref url="/pages/6hDqpC429EsGfW5oXVfG" %}
[TradingView in a Flutter WebView](/flutter/my-flutter/ui-media/tradingview-webview-javascript-bridge.md)
{% endcontent-ref %}

### MPChart-style adapter: use it when the controller is mandatory

The important MPChart-style pattern is its controller/dataset pipeline:

```
Domain points
  → Entry / CandleEntry
  → DataSet
  → LineData / BarData / CandleData
  → CombinedData
  → Controller
  → Chart painter
```

It fits features that need combined price/volume, markers, highlights, pixel-to-data coordinate conversion, zoom/drag behavior, or viewport preservation after history is prepended.

I isolate the implementation behind a neutral port:

```dart
final class ChartViewport {
  const ChartViewport({
    required this.left,
    required this.right,
  });

  final double left;
  final double right;
}

abstract interface class ViewportChartPort {
  ChartViewport readViewport();
  void replaceLast(CandlePoint candle);
  void append(CandlePoint candle);
  void prepend(List<CandlePoint> candles);
  void notifyDataChanged();
  void restoreViewport(ChartViewport viewport);
}
```

The controller belongs to `State`; I do not recreate it simply because the parent rebuilds. History prepending must follow an ordered flow:

1. Read the data coordinate at the left edge before adding data.
2. Prepend candles and remap the x-index.
3. Notify the dataset/controller.
4. Restore the viewport to the corresponding coordinate.
5. Auto-scroll to the live edge only if the user was already near the right edge before the update.

The source uses a private MPChart fork. This article therefore preserves the architectural pattern without publishing its dependency URL, reference, or private imports. Before using concrete code, a project must select a public package, check its license/API, and rerun interaction tests against that exact implementation.

### Separate the realtime policy from the renderer

An open candle is usually replaced, while a candle in a new bucket is appended. Older ticks need a separate policy so the chart does not jump backward:

```dart
List<CandlePoint> applyCandleUpdate(
  List<CandlePoint> current,
  CandlePoint incoming,
) {
  if (current.isEmpty) return [incoming];

  final last = current.last;

  if (incoming.time < last.time) {
    return current;
  }

  if (incoming.time == last.time) {
    return [
      ...current.take(current.length - 1),
      incoming,
    ];
  }

  return [...current, incoming];
}
```

This reducer is illustrative, not a complete raw-trade aggregation algorithm. The data layer still decides the interval, timezone, session, and out-of-order tick policy. With a large buffer, production code may use a mutable structure or ring buffer and emit immutable snapshots instead of copying the entire list after every tick.

The three adapters differ only in how they apply the new snapshot:

| Renderer      | Update strategy                                                                |
| ------------- | ------------------------------------------------------------------------------ |
| `fl_chart`    | Create new chart data and choose animation duration deliberately.              |
| ECharts       | Call `setOption` and explicitly choose merge or replace behavior.              |
| MPChart-style | Replace/append dataset entries, notify and redraw, then preserve the viewport. |

### Standardize theme, formatting, and interactions

Flutter resolves the theme first, then passes concrete tokens to the adapter. A WebView does not automatically understand the app's `ThemeMode`:

```dart
final class ChartThemeTokens {
  const ChartThemeTokens({
    required this.priceLine,
    required this.priceLineHex,
    required this.axisHex,
    required this.gridHex,
  });

  final Color priceLine;
  final String priceLineHex;
  final String axisHex;
  final String gridHex;
}
```

All three adapters should share definitions for rising/falling/reference colors, text, grids, axes, tooltips, number formatting, date formatting, and selection state. Flutter theme construction is covered separately here:

{% content-ref url="/pages/DDwBamhA1zlGZaY4h0sH" %}
[Dark Mode](/flutter/my-flutter/ui-media/dark-mode.md)
{% endcontent-ref %}

Interactions are also converted into a neutral contract:

```dart
sealed class ChartInteraction {
  const ChartInteraction();
}

final class PointSelected extends ChartInteraction {
  const PointSelected(this.index);

  final int index;
}

final class ViewportChanged extends ChartInteraction {
  const ViewportChanged({
    required this.start,
    required this.end,
  });

  final double start;
  final double end;
}
```

The feature does not depend directly on a `fl_chart` touch response, an ECharts event payload, or an MPChart highlight object.

### Keep loading, empty, error, and accessibility in the Flutter shell

The renderer receives only valid data. The Flutter shell owns:

* The loading skeleton.
* An explanatory empty state.
* The error state and retry action.
* Legends, labels, and selection state.
* A text summary of the main values.
* A fallback when the WebView is not ready or JavaScript fails.
* Semantics/text alternatives when a canvas does not expose enough information.

For example, users should not see only a blank area when ECharts initialization fails. The shell must know whether the adapter is `loading`, `ready`, or `failed` and render the corresponding state outside the PlatformView.

### Test each layer separately

I use the same domain fixture for all three adapters, but I do not force every assertion into the same test type:

| Layer                | What to verify                                                               |
| -------------------- | ---------------------------------------------------------------------------- |
| Domain               | Sorting, deduplication, candle invariants, gaps, tick replacement/appending. |
| Adapter              | Point count, axis/color mapping, option JSON, dataset order.                 |
| Flutter shell        | Loading, empty, error, legend, text summary, retry.                          |
| Renderer integration | Touch/tooltip, zoom, viewport, JavaScript events, resize.                    |
| Visual               | Goldens for Flutter-native rendering; device screenshots for PlatformViews.  |
| Performance          | First chart, update latency, frame timing, memory, gestures.                 |

A reducer unit test needs no chart package:

```dart
test('replaces the open candle in the same bucket', () {
  const first = CandlePoint(
    time: 1000,
    open: 10,
    high: 12,
    low: 9,
    close: 11,
    volume: 100,
  );
  const updated = CandlePoint(
    time: 1000,
    open: 10,
    high: 13,
    low: 9,
    close: 12,
    volume: 140,
  );

  expect(applyCandleUpdate([first], updated), [updated]);
});
```

Widget tests are suitable for verifying that the shell displays empty/error/legend states and routes interactions to the feature. Goldens are a better fit for Flutter-native painters when viewport, DPR, fonts, theme, and animations are locked.

A PlatformView can appear blank or raster differently from production in widget/golden tests. For ECharts, I use integration tests or screenshots on a real device/emulator to verify rendering, gestures, resizing, and themes.

The boundary among widget tests, goldens, and device QA is explained in detail here:

{% content-ref url="/pages/OXzEEJ7gphdUcYxkfusW" %}
[Widget Tests and Golden Regression](/flutter/my-flutter/quality-delivery/widget-test-golden-regression.md)
{% endcontent-ref %}

In the source I reviewed, one widget test directly verifies a Flutter-native chart together with its legend and empty state. I did not find direct tests for the ECharts wrapper, JavaScript channel, combined controller, candle interaction, or viewport behavior.

During research, I ran that focused widget test with `--no-pub`. It stopped before any assertions because the workspace pub cache lacked several dependencies. This is an environment blocker, not evidence that the chart test passed or failed. I do not include the internal feature path in the public article.

### Benchmark with the same dataset and interactions

Before standardizing a renderer for an important feature, I build the same fixture and run the same script:

```
open chart
  → wait for the first frame with data
  → pan left/right
  → zoom in/out
  → select a point
  → replace the open candle
  → append a new candle
  → prepend history
  → dispose and open again
```

Every measurement keeps these inputs fixed:

* Dataset, point count, and series count.
* Device, OS, Flutter version, and build mode.
* Chart size, theme, and containing screen.
* Interaction script and number of simultaneous charts.
* Warm-up period and iteration count.

The metrics include:

* Time to first visible chart.
* Latency from snapshot/tick to the displayed frame.
* Build/raster frame p50, p95, and p99.
* Missed/janky frames during pan/zoom.
* Memory before/after repeated create/dispose cycles.
* JavaScript/WebView error rate when ECharts is used.

I store raw results, device metadata, and the sample commit with the report. I do not turn a debug-mode number into a production threshold.

### Diagnose common failures

| Symptom                                    | First diagnosis                                 | Treatment                                                    |
| ------------------------------------------ | ----------------------------------------------- | ------------------------------------------------------------ |
| Blank chart                                | Check constraints and normalized point count    | Require a size and render explicit empty/error states.       |
| ECharts does not update                    | Log option revision and JavaScript errors       | Choose a merge policy and do not swallow errors.             |
| Parent list does not scroll over the chart | Check which axes the WebView captures           | Capture only the gestures the chart needs.                   |
| Tooltip shifts after resize                | Compare constraints with pixel/data coordinates | Call `resize()` or recalculate the viewport.                 |
| Candle and volume series are misaligned    | Assert timestamps at the same index             | Normalize from one candle snapshot.                          |
| A new tick jumps the chart to the end      | Record the viewport before updating             | Auto-scroll only when the user is at the live edge.          |
| ECharts golden is blank                    | Check whether the PlatformView is rasterized    | Use integration screenshots/device QA.                       |
| A `fl_chart` upgrade breaks visuals        | Compare the API/changelog and baselines         | Upgrade inside the adapter and run visual diffs per feature. |
| MPChart sample cannot be installed         | Check whether the dependency is a private fork  | Select a public package or keep only the port pattern.       |

### Verified versions and references

At research time, the source used Flutter `3.41.2`, Dart `3.11.0`, `fl_chart 0.65.0`, `flutter_echarts 2.5.0`, and `webview_flutter 4.13.1`. Upstream `fl_chart` had reached `1.2.0`, so newer APIs and capabilities such as candlesticks cannot be assumed to exist in the older implementation.

The source MPChart renderer is a private fork with package metadata `1.0.3`. This article does not treat that fork as a public installation contract; the `mp_chart_x` documentation is used only to compare the API lineage and does not prove complete compatibility.

Official references:

* [`fl_chart 0.65.0`](https://pub.dev/packages/fl_chart/versions/0.65.0) and the [current `fl_chart`](https://pub.dev/packages/fl_chart).
* [`flutter_echarts 2.5.0`](https://pub.dev/documentation/flutter_echarts/latest/).
* Apache ECharts on [events and actions](https://echarts.apache.org/handbook/en/concepts/event/), [container sizing, resize, and disposal](https://echarts.apache.org/handbook/en/concepts/chart-size/), and [Canvas versus SVG](https://echarts.apache.org/handbook/en/best-practices/canvas-vs-svg/).
* The public [`mp_chart_x` API](https://pub.dev/documentation/mp_chart_x/latest/) as a pattern reference, not as documentation for the fork verified in the source.

## Conclusion

I do not choose a chart renderer only from the words `line`, `bar`, `pie`, or `candlestick`. I choose it from the feature's capabilities, runtime boundary, and long-term operational requirements.

`fl_chart` is a natural fit when Flutter composition, tooltips, and widget tests are the main advantages. ECharts is worth considering when complex options provide enough value to justify a WebView lifecycle and JavaScript boundary. An MPChart-style renderer fits when a controller, combined datasets, and viewport control are mandatory, but its public implementation must be selected and verified separately.

The architecture stays portable because the domain model, realtime policy, theme, and interaction contract remain neutral. The renderer is only the final adapter. If the capability checklist still does not produce an answer, I use the same dataset and interaction script to benchmark on a real device instead of choosing by intuition.

[Buy Me a Coffee](https://buymeacoffee.com/ducmng12g) | [Support Me on Ko-fi](https://ko-fi.com/I2I81AEJG8)
