diff --git a/CHANGELOG.md b/CHANGELOG.md
index dfe34a3..7f25aeb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,186 @@
+## 2.5.0
+
+One package for every chart an app draws. A balance over time, sign-ups per
+month or profit per trade had nowhere to go in a candlestick package, so an app
+carried `fl_chart` beside this one; and the candlestick chart itself asked for
+three arguments most charts had no use for.
+
+### Series charts
+
+- **New `SeriesChart` draws values against numbers, not candles against
+ time.** A `LineSeries` or a `BarSeries` takes `SeriesPoint(x, y)` — or plain
+ values through `.values`, placed at `x = 0, 1, 2, …` — with `null` for a gap.
+ Several series share one chart, bottom layer first, and a line and bars can
+ sit together.
+
+- **Lines are joined four ways.** `LineCurve.linear`, `smooth`, `step`, and
+ `monotone`, a curve through every point that never swings past its
+ neighbours, so a peak on the line is a peak in the data. A line can be dashed,
+ stroked with a gradient, dotted per point through `dotBuilder`, and drawn in
+ `negativeColor` below its `baseline`, split exactly where it crosses rather
+ than at the nearest point.
+
+- **An area fades to its baseline and never paints across it.** `SeriesFill`
+ measures its gradient from the line's highest point to the baseline, so the
+ wash is gone exactly at zero, and the part below uses `negativeGradient` — or
+ the same gradient turned upside down — so both halves fade away from the line.
+ `toBaseline: false` fills to the bottom of the plot instead.
+
+- **Bars grow up or down from their baseline, rounded on the far end.**
+ `radius` goes on the top of a bar above the baseline and the bottom of one
+ below it; width is fixed or a share of the room between two x values, held
+ between `minWidth` and `maxWidth`; colour comes from `negativeColor` or a
+ `colorBuilder`, with an optional full-height `trackColor` behind. Bars of the
+ same colour are drawn in one call however many there are.
+
+- **Axes that are written or hidden, not rebuilt.** `SeriesXAxis` labels from a
+ list — a blank entry prints nothing, so month starts space themselves — or
+ from `ticks`, an `interval` or a `labelBuilder`, and drops a label that would
+ run into the one before. `SeriesYAxis` takes a `formatter`, a side and a
+ `width`, which is what lines stacked charts up. Either can be `hidden`, and
+ the room it held goes to the plot. `SeriesGrid` rules dashed or solid lines
+ at the labels, `border` draws round the plot, and `SeriesReferenceLine` and
+ `SeriesBand` mark fixed levels and stretches without widening the range
+ unless asked.
+
+- **The value range fits what is in view.** Set `minX` and `maxX` to show a
+ window of a longer series and the values rescale to that window. The fitted
+ range gets a little room and ends on round ticks, and that room never pushes
+ a series that stays above zero below it. `minY`, `maxY` and `includeZero` pin
+ it.
+
+- **Touch reads out every series at once.** The crosshair snaps to the nearest
+ point on a `press` (while held), a `longPress` (leaving a swipe free to
+ scroll the page), a `tap` (it stays) or a mouse hovering. The default tooltip
+ lists each series in its colour; `SeriesTooltip.builder` replaces it and the
+ chart still places the result beside the crosshair or above the point, kept
+ inside the chart. `onTouch` reports the x and every series' value there, and
+ `null` when the crosshair goes.
+
+- **New `SeriesChartController` shares one crosshair between charts.** Give a
+ balance panel and a profit panel under it the same controller, and holding
+ either marks the same x on both.
+
+- **New `SeriesRangeSelector` puts a window over long data.** It draws the
+ whole data set small; dragging inside the window moves it and dragging a
+ handle moves that edge, measured from where the drag began so the window
+ stays under the finger.
+
+- **Data changes animate.** With `animationDuration` set, values move from
+ where they were when the number of points is unchanged, and grow out of the
+ baseline otherwise — including on the first build, which `fl_chart` never
+ animated.
+
+- **New `ScatterSeries` places dots on both axes at once.** A dot per point,
+ sized, coloured or shaped per point through `dotBuilder` — a circle, a
+ square, a diamond or a cross — and labelled through `labelBuilder`. Pair it
+ with `SeriesTouch(snap: SeriesTouchSnap.nearestPoint)`, which reads out the
+ one dot under the finger instead of everything at that x.
+
+- **Bars stack, float and carry labels.** Bar series that share a `stack` pile
+ up at each x — upwards above the baseline, downwards below it — and only the
+ outermost bar is rounded. A `SeriesPoint` with a `low` draws a bar that
+ floats between two values, which is a range or a waterfall step. `border`
+ outlines each bar and `labelBuilder` writes past its far end.
+
+- **A chart can be turned on its side.** `orientation:
+ SeriesOrientation.horizontal` runs the x axis down the chart and grows the
+ values rightwards, which is the horizontal bar chart, and everything else —
+ curves, fills, gradients, the crosshair and the tooltip — turns with it.
+
+- **Error bars, fills between lines, step position and a line shadow.**
+ `SeriesPoint.xError` and `yError` draw a capped bar either side of a value,
+ styled by `SeriesErrorBars`. `SeriesBetweenFill` shades the gap between two
+ lines — a high and a low, a plan and what happened. `LineSeries.stepPosition`
+ says where along the way a step changes, and `shadow` drops a blurred copy
+ under the line.
+
+- **Axes get names and a side of their own.** `SeriesXAxis.title` and
+ `SeriesYAxis.title` write beyond the labels, turned to read up an axis that
+ runs down the chart, and `SeriesXAxis.side` puts the x labels over the plot
+ instead of under it.
+
+- **Two new pages.** [Series charts](doc/series-chart.md) covers the widget,
+ and [Migrating from fl_chart and candlesticks](doc/migrating-from-fl_chart.md)
+ maps both packages' APIs onto this one, with worked examples.
+
+### Pie, radar and heatmap charts
+
+- **New `PieChart` draws a pie, a doughnut or a ring gauge.** Each `PieSection`
+ is worth a share, not an angle: the values are added up and each section gets
+ the part of the circle it is worth. Sections take a colour or a gradient, a
+ label placed anywhere between the inner and outer edge, their own `radius` so
+ one can stand out, an `offset` that explodes it out of the circle, a border,
+ and a `badge` widget pinned to it. `centerSpaceRadius` opens the middle and
+ `centerChild` fills it with a total or a title; `sectionsSpace`,
+ `startDegreeOffset` and `clockwise` place them. A touch names the section
+ under it, which grows by `touchedSectionGrowth` while it is held.
+
+- **New `RadarChart` compares several things over the same features.** A
+ `RadarSeries` carries one value per feature and is drawn as an outline with a
+ fill, optionally dashed and dotted; `features` names the corners. The web is
+ drawn as a polygon or as rings, with `tickCount` rings between `minValue` and
+ `maxValue`, spokes out to each corner, and ring values when `showTicks` is
+ on. `onTouch` reports the corner nearest the finger.
+
+- **New `HeatmapChart` colours a grid of squares by their value.** It takes a
+ matrix — a list of rows, each a list of columns — or sparse `HeatmapCell`s
+ when the data has holes in it, and colours each square through a
+ `HeatmapGradientScale` that fades between colours or a `HeatmapStepScale`
+ that paints whole bands. `HeatmapAxis` names the columns and the rows on
+ either side; `labelBuilder` writes inside the squares, in black or white by
+ how dark each one is, and leaves out a label that would not fit. A touch
+ names the square under it — including an empty one, which reports itself
+ with no value rather than as no touch — marks it with `hoverBorder` and can
+ raise a card through `tooltipBuilder`. `squareCells` keeps the squares
+ square, which is what a contribution graph wants, and `HeatmapLegend` draws
+ the scale as a bar. [Heatmap](doc/heatmap-chart.md) covers it.
+
+- **All three animate and all three are pure geometry underneath.**
+ `layOutPie`, `pieSectionAt`, `radarCorner`, `RadarLayout` and `HeatmapLayout`
+ are public, so the placement a chart uses can be tested, or reused to put a
+ widget exactly where a section, a corner or a square is.
+
+### Candlestick chart
+
+- **`isTrendLine` and `timeFrame` are optional.**
+ `KChartWidget(candles, ChartColors())` is now a whole chart. The drawing
+ tools stay off unless `isTrendLine` is set, and with no `timeFrame` the
+ current-price tag reads just the price, without a countdown and without the
+ one-second timer that kept it ticking. `timeFrame` is now a `Duration?`, so
+ code that reads it back off the widget as non-null needs a fallback.
+
+- **The watermark is a widget, and `flutter_svg` is gone.** `watermarkAssetPath`
+ took only an SVG asset, which tied every app to `flutter_svg` whether it
+ showed a watermark or not. It is replaced by `watermark`, which takes any
+ widget — an `Image.asset`, an icon, a line of text, or an `SvgPicture` from
+ `flutter_svg` in an app that still wants one — sized and placed by
+ `watermarkScale` and `watermarkAlignment` as before, and painted in
+ `ChartColors.watermarkColor`. It now sits over the candle area, faint and
+ ignoring touches, rather than under the candles. This removes a parameter,
+ so a chart passing `watermarkAssetPath` has to change: drop it, or pass the
+ image as `watermark`.
+
+### Requirements
+
+- **Runs on Flutter 3.27 and Dart 3.6 and later.** The package asked for
+ Flutter 3.47 and Dart 3.13, which shut out every app a few releases behind.
+ Nothing it draws needed them: the two newer language features it used — null-
+ aware list elements and `_` wildcard parameters — are written the older way,
+ and the lowest Flutter left is the one that brought `Color.withValues`.
+
+- **Dependencies float.** `decimal` is accepted from 3.0.0 and `intl` from
+ 0.19.0 up to 0.21, instead of the latest of each, so the package resolves
+ beside the `intl` an app's own `flutter_localizations` pins. The test suite
+ passes on both the lowest and the newest versions. `flutter_svg` is no longer
+ a dependency at all.
+
+### Documentation
+
+- `theming.md` no longer counts the drawing kinds as seventeen, and
+ `indicators.md` counts the indicators that recompute in full as seventeen,
+ so the two groups add up to the 31 there are.
+
## 2.4.1
Five changes, one for each point raised in
diff --git a/README.md b/README.md
index 75b20af..e701bce 100644
--- a/README.md
+++ b/README.md
@@ -23,8 +23,10 @@ If it saves you some work, all I ask in return is:
That is the whole price, and it is what keeps the weekly updates coming.
-A candlestick (K-line) and market-depth chart for Flutter, drawn entirely with
-`CustomPainter` — no WebView, no JavaScript bridge.
+A candlestick (K-line) chart, a market-depth chart, and line, bar, scatter,
+pie, radar and heatmap charts for Flutter, drawn entirely with `CustomPainter`
+— no WebView, no JavaScript bridge. One package for the trading screen and for the balance,
+growth and report charts around it.
Named for the open-high-low-close-volume bars it renders.
@@ -32,6 +34,9 @@ Named for the open-high-low-close-volume bars it renders.
## Features
+- **Series charts for everything else** — `SeriesChart` draws plain values rather than candles: lines with four curves, areas that fade to a baseline and split colour where they cross it, bars that group, stack or float, scatter plots, error bars, a band between two lines, axes you write, name or hide, a chart you can turn on its side, a tooltip of your own, one crosshair shared across charts, a range strip for long data and animated data changes. The [migration guide](doc/migrating-from-fl_chart.md) maps `fl_chart` and `candlesticks` onto it.
+- **A pie chart and a radar chart** — `PieChart` for a pie, a doughnut with a widget in its hole or a ring gauge, with exploded slices and badges; `RadarChart` for a web of features with one outline per series. The two shapes an axis cannot draw, in the same package as the rest.
+- **A heatmap** — `HeatmapChart` colours a grid of squares by their value, from a matrix or from sparse cells: a fading or a stepped colour scale, labels in the squares and down both sides, a legend, and a readout for the square under the pointer. A contribution graph, a correlation matrix or sales by weekday and hour.
- **Eight chart types** — candles, OHLC bars, a line, a step line, a filled area, an HLC area, a baseline chart and columns — plus Heikin-Ashi, Renko, three-line break, Kagi, point & figure and range bars as transforms of the candles themselves.
- **31 indicators**, each a configured instance rather than a flag — so `ATR(8)`, `ATR(14)` and `ATR(20)` are three panes, with their own settings and colours.
- **Main-chart overlays** — `MA`, `EMA`, `BOLL`, `SAR`, `VWAP` in three flavours — whole-series, anchored to a candle, or restarted each session with standard-deviation bands — Supertrend, Keltner and Donchian channels, the Ichimoku Cloud, pivot points and a volume profile.
@@ -70,21 +75,125 @@ Named for the open-high-low-close-volume bars it renders.
- **Info dialog** on long press, either the built-in Material popup or your own builder.
- **"Now price" line** with a live countdown to the close of the current candle.
- **Session dividers and a display time zone**, so an intraday chart breaks where the trader's day does.
-- **SVG watermark** loaded from an asset path you supply.
+- **A watermark of your own** — any widget, such as an `Image.asset` of your logo, painted faintly in one colour over the candle area.
- **Fully themeable** — `ChartStyle` for geometry, `ChartColors` for every colour, `DrawingStyle` for the drawing tools; `ChartTranslations` for every label. Filled or hollow candles, dashed or solid crosshair, pane separators, axis-label pills and a placed, tinted watermark.
- **Fits its box** — the candles take whatever height the volume and indicator panes leave, so the chart works from a phone to a desktop window without arithmetic on your side.
-
+## The whole package in pictures
-
+Every widget, every chart type and every tool below is in this one package, and
+each picture links to the page that covers it.
-
+### Series, pie, radar and heatmap charts
+
+
+
+Four business charts A return split at zero, profit bars, flows with a tooltip and a sparkline.
+Pie and radar A doughnut with its total in the hole, and a web scoring two strategies.
+Heatmap Six months of activity, and orders by hour and weekday, by value.
+
+
+Scatter A dot per trade, shaped and coloured by the point, read out one at a time.
+Stacked and turned Bars piled on each other on a chart running rightwards, both axes named.
+Floating bars and bands A waterfall, and a forecast inside its band with error bars.
+
+
+A window over long data Three series over five months, with the strip that moves the window.
+One crosshair, two panels A balance panel over a profit panel, marking the same day in both.
+Depth chart Cumulative bid and ask depth from the order book.
+
+
+Depth, four ways The curve, the per-rung bars, both at once, or a numeric ladder.
+The bid/ask ratio bar Which way the resting orders lean, under the depth chart.
+
+
+
+
+### Candles, and the ways to draw them
+
+
+
+### 31 indicators
+
+
+
+### Drawing tools, orders and events
+
+
+
+29 drawing tools Trend and horizontal lines with labels, snapped and persisted.
+Rays, boxes and fibs A ray, an arrow, a horizontal ray, a range box and a retracement.
+The line editor Every control of a selected drawing, styled by DrawingStyle.
+
+
+Orders and positions A working order and an open position, each tagged on the axis.
+Planning a trade A planned position with its risk-to-reward, inside a channel.
+Event marks Earnings, a dividend, a split and news under the candles.
+
+
+
+### Reading it, and driving it
+
+
+
+### The axes, the session and the box
+
+
+
+Four price axes Linear, log, percentage or indexed to 100 — and invertible.
+A log axis Stepping by ratio, so a decade of compounding reads evenly.
+The date axis The round time values the chart picks, or your own formatter.
+
+
+Sessions and time zones Pre-market and after-hours washed behind the candles.
+Session VWAP A VWAP restarted each session, with its standard-deviation band.
+Fits its box The candle area filling the box, and pinned to a height.
+
+
+A watermark of your own Any widget, painted faintly over the candle area.
+
+
+
+
## Install
```yaml
dependencies:
- ohlcv_chart: ^2.4.1
+ ohlcv_chart: ^2.5.0
```
## Quick start
@@ -100,7 +209,6 @@ DataUtil.calculate(candles);
KChartWidget(
candles,
ChartColors(),
- isTrendLine: false,
timeFrame: const Duration(minutes: 15),
indicators: [MaIndicator(period: 20), MacdIndicator()],
fixedLength: 2,
@@ -118,6 +226,10 @@ One page per feature, in [`doc/`](doc/README.md):
| | |
| --- | --- |
| [Candlestick chart](doc/candlestick-chart.md) | The candles, and the shape `KLineEntity` expects |
+| [Series charts](doc/series-chart.md) | `SeriesChart` for values: lines, areas, bars, scatter, touch, a range strip, animation |
+| [Pie chart](doc/pie-chart.md) | `PieChart`: sections, a doughnut hole, badges, touch |
+| [Radar chart](doc/radar-chart.md) | `RadarChart`: a web of features with an outline per series |
+| [Heatmap](doc/heatmap-chart.md) | `HeatmapChart`: a grid coloured by value, with a scale and a legend |
| [Indicators](doc/indicators.md) | 31 of them as instances, the catalogue, pane scales, chaining, higher timeframes, alerts |
| [Comparing a second instrument](doc/comparison.md) | Other series over the same window, rebased or at their own prices |
| [Chart types](doc/chart-types.md) | Eight ways to draw a series, and six transforms of the candles |
@@ -137,12 +249,15 @@ One page per feature, in [`doc/`](doc/README.md):
| [Depth chart](doc/depth-chart.md) | The order-book widget: four modes, three axes |
| [Theming](doc/theming.md) | `ChartStyle`, `ChartColors` and `ChartTranslations` |
| [Migrating from 1.x](doc/migrating-from-1.x.md) | What changed, and what to use instead |
+| [Migrating from fl_chart and candlesticks](doc/migrating-from-fl_chart.md) | Their APIs mapped onto this package's, with worked examples |
## Notes
+- Needs Dart 3.6 and Flutter 3.27 or later.
- The zoom slider renders only where there is no pinch gesture — web and desktop.
-- `watermarkAssetPath` must point at an SVG registered in your app's `pubspec.yaml`
- assets; a missing asset is ignored and the chart renders without a watermark.
+- `watermark` takes any widget and paints it in `ChartColors.watermarkColor`, so a
+ full-colour logo reads as a silhouette; the package no longer depends on
+ `flutter_svg` — pass `SvgPicture.asset(...)` from it yourself for an SVG.
## Credits
diff --git a/doc/README.md b/doc/README.md
index b6fc5ad..39fec3f 100644
--- a/doc/README.md
+++ b/doc/README.md
@@ -10,6 +10,22 @@ For installation, the feature list and support, see the
- **[Candlestick chart](candlestick-chart.md)** — the candles a `KChartWidget`
needs, and the shape `KLineEntity` expects them in.
+- **[Series charts](series-chart.md)** — `SeriesChart` for values rather than
+ candles: lines, areas, bars that group, stack or float, and scatter plots,
+ several at once, a chart you can turn on its side, a touch readout of your
+ own, a range strip for long data, and animation between data sets.
+
+- **[Pie chart](pie-chart.md)** — `PieChart`: sections worth a share of the
+ whole, a doughnut hole with a widget in it, exploded slices, badges and
+ touch.
+
+- **[Radar chart](radar-chart.md)** — `RadarChart`: a web of features with one
+ outline per series, drawn as a polygon or as rings.
+
+- **[Heatmap](heatmap-chart.md)** — `HeatmapChart`: a grid of squares coloured
+ by their value, with a scale, a legend and a readout per square.
+- **[Migrating from fl_chart and candlesticks](migrating-from-fl_chart.md)** —
+ their APIs mapped onto this package's, with worked examples.
## Series and indicators
diff --git a/doc/bar-replay.md b/doc/bar-replay.md
index da8cc40..000c928 100644
--- a/doc/bar-replay.md
+++ b/doc/bar-replay.md
@@ -13,8 +13,6 @@ final replay = ChartReplayController(interval: const Duration(milliseconds: 300)
KChartWidget(
candles,
ChartColors(),
- isTrendLine: false,
- watermarkAssetPath: 'assets/logo.svg',
timeFrame: const Duration(minutes: 15),
replay: replay,
);
diff --git a/doc/candlestick-chart.md b/doc/candlestick-chart.md
index 40d2694..2ba5411 100644
--- a/doc/candlestick-chart.md
+++ b/doc/candlestick-chart.md
@@ -13,8 +13,6 @@ DataUtil.calculate(candles);
KChartWidget(
candles,
ChartColors(),
- isTrendLine: false,
- watermarkAssetPath: 'assets/logo.svg',
timeFrame: const Duration(minutes: 15),
indicators: [MaIndicator(period: 20), BollIndicator(), MacdIndicator()],
timeFormat: TimeFormat.YEAR_MONTH_DAY_WITH_HOUR,
@@ -25,6 +23,12 @@ KChartWidget(
);
```
+Only the candles and the colours are required, so `KChartWidget(candles,
+ChartColors())` is a whole chart. `timeFrame` adds a countdown to the close on
+the current-price tag, `isTrendLine: true` turns the drawing tools on, and
+`watermark` takes any widget to draw faintly behind the reading (see
+[Theming](theming.md#watermark)).
+
`KLineEntity.fromJson` accepts the usual OHLCV shape (`open`, `high`, `low`,
`close`, `vol`, `time`/`id`), or build the entity directly.
diff --git a/doc/chart-types.md b/doc/chart-types.md
index 3d3a827..90881b5 100644
--- a/doc/chart-types.md
+++ b/doc/chart-types.md
@@ -6,8 +6,6 @@
KChartWidget(
candles,
ChartColors(),
- isTrendLine: false,
- watermarkAssetPath: 'assets/logo.svg',
timeFrame: const Duration(minutes: 15),
chartType: ChartType.bars, // candles, bars, line, area, baseline
baselinePrice: 42_000, // baseline only; defaults to the oldest close in view
diff --git a/doc/date-axis.md b/doc/date-axis.md
index 92f6604..5680874 100644
--- a/doc/date-axis.md
+++ b/doc/date-axis.md
@@ -18,8 +18,6 @@ marking the long form the crosshair wants:
KChartWidget(
candles,
ChartColors(),
- isTrendLine: false,
- watermarkAssetPath: 'assets/logo.svg',
timeFrame: const Duration(minutes: 15),
dateFormatter: (candle, longForm) => DateFormat(
longForm ? 'EEE d MMM HH:mm' : 'HH:mm',
diff --git a/doc/drawing-tools.md b/doc/drawing-tools.md
index 8a818fd..4495c8b 100644
--- a/doc/drawing-tools.md
+++ b/doc/drawing-tools.md
@@ -19,7 +19,6 @@ KChartWidget(
candles,
ChartColors(),
isTrendLine: true,
- watermarkAssetPath: 'assets/logo.svg',
timeFrame: const Duration(minutes: 15),
currentDrawingTool: DrawingTool.trend,
trendLines: savedTrendLines,
@@ -256,7 +255,6 @@ KChartWidget(
candles,
ChartColors(),
isTrendLine: true,
- watermarkAssetPath: 'assets/logo.svg',
timeFrame: const Duration(minutes: 15),
drawingController: drawings,
);
@@ -385,7 +383,6 @@ KChartWidget(
candles,
ChartColors(),
isTrendLine: true,
- watermarkAssetPath: 'assets/logo.svg',
timeFrame: const Duration(minutes: 15),
contextMenuBuilder: (request) => [
...request.defaults,
@@ -434,7 +431,6 @@ KChartWidget(
candles,
ChartColors(),
isTrendLine: true,
- watermarkAssetPath: 'assets/logo.svg',
timeFrame: const Duration(minutes: 15),
drawings: [HorizontalLine(price: 42_000, alert: true)],
onAlertCrossed: (line, candle) => notifier.push('crossed ${line.price}'),
@@ -455,7 +451,6 @@ KChartWidget(
candles,
ChartColors(),
isTrendLine: true,
- watermarkAssetPath: 'assets/logo.svg',
timeFrame: const Duration(minutes: 15),
drawings: [
TrendLine(
diff --git a/doc/driving-the-chart.md b/doc/driving-the-chart.md
index 6bea743..cb2c256 100644
--- a/doc/driving-the-chart.md
+++ b/doc/driving-the-chart.md
@@ -9,8 +9,6 @@ final chart = KChartController();
KChartWidget(
candles,
ChartColors(),
- isTrendLine: false,
- watermarkAssetPath: 'assets/logo.svg',
timeFrame: const Duration(minutes: 15),
controller: chart,
);
@@ -62,8 +60,6 @@ candle says nothing:
KChartWidget(
candles,
ChartColors(),
- isTrendLine: false,
- watermarkAssetPath: 'assets/logo.svg',
timeFrame: const Duration(minutes: 15),
onVisibleRangeChanged: (range) {
setState(() => barsOnScreen = range.length);
@@ -86,8 +82,6 @@ chart's own gestures away.
KChartWidget(
sessionCandles,
ChartColors(),
- isTrendLine: false,
- watermarkAssetPath: 'assets/logo.svg',
timeFrame: const Duration(minutes: 5),
chartType: ChartType.area,
scrollEnabled: false,
@@ -258,7 +252,6 @@ Column(
child: KChartWidget(
candles,
ChartColors(),
- isTrendLine: false,
timeFrame: const Duration(minutes: 15),
controller: chart,
),
diff --git a/doc/event-marks.md b/doc/event-marks.md
index 191dc94..6ea2bf3 100644
--- a/doc/event-marks.md
+++ b/doc/event-marks.md
@@ -9,8 +9,6 @@ the price it happened at:
KChartWidget(
candles,
ChartColors(),
- isTrendLine: false,
- watermarkAssetPath: 'assets/logo.svg',
timeFrame: const Duration(days: 1),
events: [
ChartEvent(time: reportedAt, kind: ChartEventKind.earnings),
diff --git a/doc/heatmap-chart.md b/doc/heatmap-chart.md
new file mode 100644
index 0000000..d0bfb88
--- /dev/null
+++ b/doc/heatmap-chart.md
@@ -0,0 +1,122 @@
+# Heatmap
+
+
+
+`HeatmapChart` draws a grid of squares coloured by their value — the thing a
+table of numbers hides. A contribution graph, a correlation matrix, sales by
+weekday and hour, a risk grid.
+
+```dart
+HeatmapChart.matrix(
+ byHourAndDay, // a list of rows, each a list of columns
+ scale: HeatmapGradientScale.of(green),
+ xAxis: const HeatmapAxis(labels: dayNames),
+ yAxis: const HeatmapAxis(labels: hourNames, size: 34),
+ spacing: 3,
+ radius: 3,
+);
+```
+
+The chart fills the box it is given, and is `defaultHeight` high in a box with
+no height of its own.
+
+## The squares
+
+`HeatmapChart.matrix` reads a list of rows, each holding its columns — a row
+shorter than the widest one simply has no squares past its end. Give the cells
+yourself when the data is sparse, or when a square needs a colour or a label
+of its own:
+
+```dart
+HeatmapChart(
+ cells: [
+ for (final day in days)
+ HeatmapCell(x: day.week, y: day.weekday, value: day.commits),
+ const HeatmapCell(x: 9, y: 3, value: 12, color: amber, label: 'ship'),
+ ],
+ columns: 53,
+ rows: 7,
+);
+```
+
+A cell with a `null` value is empty and takes the scale's `emptyColor`, which
+is what a month that has not happened yet should look like. Two cells at the
+same position draw the later one. `columns` and `rows` are fitted from the
+cells unless they are given, so a grid that must keep its shape while the data
+fills in should name them.
+
+## Colour
+
+| Scale | Draws |
+| --- | --- |
+| `HeatmapGradientScale(colors: [...], stops: [...])` | a fade through the colours, spread over the value range |
+| `HeatmapGradientScale.of(color)` | one colour deepening from faint to full — the contribution graph |
+| `HeatmapStepScale(steps: [HeatmapStep(from, color)])` | whole bands: under 0, 0 to 5, 5 and up |
+
+The range the scale is spread over is the lowest and highest value among the
+cells, or `minValue` and `maxValue` when those are set — which is what keeps
+two heatmaps beside each other comparable.
+
+A `HeatmapCell.color` overrules the scale for that one square.
+
+## Labels
+
+`HeatmapAxis` names the columns and the rows:
+
+```dart
+HeatmapChart(
+ cells: cells,
+ xAxis: const HeatmapAxis(labels: months, interval: 2),
+ yAxis: const HeatmapAxis(labels: weekdays, size: 34),
+);
+```
+
+| Field | What it does |
+| --- | --- |
+| `labels`, `labelBuilder` | What each column or row is called |
+| `interval` | Label only every nth one |
+| `side` | `start` — under the grid, or left of it — or `end` |
+| `size`, `gap`, `style` | The room held, the space before it, and the text |
+
+`HeatmapAxis.hidden` removes an axis along with its room.
+
+`labelBuilder` on the chart writes inside the squares, and a `HeatmapCell`'s
+own `label` wins over it. With no `labelStyle` the text is black or white by
+how dark its square is, and a label too big for its square is left out rather
+than drawn over the next one.
+
+## Touch
+
+A touch or a hovering mouse names the square under it. `hoverBorder` marks it,
+`onTouch` reports it — column, row, cell and the square in pixels — and
+`tooltipBuilder` puts a card above it:
+
+```dart
+HeatmapChart(
+ cells: cells,
+ onTouch: (details) => setState(() => reading = details),
+ tooltipBuilder: (context, details) => Card(
+ child: Text('${days[details.x]}: ${details.value ?? 0}'),
+ ),
+);
+```
+
+A square with no cell still reports itself, with a `null` cell and value, so an
+empty day reads as "nothing here" rather than as no touch at all.
+
+## The legend
+
+`HeatmapLegend` draws the scale as a bar, with a word at either end:
+
+```dart
+HeatmapLegend(scale: scale, low: 'Less', high: 'More', width: 90);
+```
+
+Left without a `width` it fills the row it is in.
+
+## Shape and animation
+
+`squareCells` keeps the squares square, leaving the grid smaller than the box
+when the two are not the same shape — a contribution graph wants this. With
+`animationDuration` set, the squares come up from the empty colour to their
+own.
diff --git a/doc/indicators.md b/doc/indicators.md
index 12b0103..1c92ade 100644
--- a/doc/indicators.md
+++ b/doc/indicators.md
@@ -8,8 +8,6 @@ the candles; everything else takes a pane of its own, stacked in the order given
KChartWidget(
candles,
ChartColors(),
- isTrendLine: false,
- watermarkAssetPath: 'assets/logo.svg',
timeFrame: const Duration(minutes: 15),
indicators: [
MaIndicator(period: 7),
@@ -127,7 +125,6 @@ intraday chart reads what has been paid on average *today*.
KChartWidget(
data,
ChartColors(),
- isTrendLine: false,
indicators: [
SessionVwapIndicator(), // resets daily, ±1σ
SessionVwapIndicator(session: PivotSession.week), // resets on the Monday
@@ -156,7 +153,6 @@ re-measures as the window moves:
KChartWidget(
data,
ChartColors(),
- isTrendLine: false,
indicators: [AnchoredVwapIndicator(anchor: firstVisible)],
onVisibleRangeChanged: (range) =>
setState(() => firstVisible = range.firstIndex),
@@ -232,7 +228,6 @@ drawn at, so a daily moving average can be read on a fifteen-minute chart:
KChartWidget(
candles,
ChartColors(),
- isTrendLine: false,
timeFrame: const Duration(minutes: 15),
indicators: [
MaIndicator(period: 20),
@@ -337,7 +332,7 @@ class MyIndicator extends Indicator {
```
`from` is the earliest index that can have moved. Return null — the default —
-and the series is computed in full, which is always correct and is what sixteen
+and the series is computed in full, which is always correct and is what seventeen
of the built-in indicators still do: anything reading the whole series at once,
such as a volume profile, a zigzag or the swing overlays built on it, has no
tail to extend. The other fourteen resume, and a chart carrying a moving
diff --git a/doc/legend-and-crosshair.md b/doc/legend-and-crosshair.md
index 7966d9d..978f2db 100644
--- a/doc/legend-and-crosshair.md
+++ b/doc/legend-and-crosshair.md
@@ -13,8 +13,6 @@ indicator legends, worded by `ChartTranslations`.
KChartWidget(
candles,
ChartColors(),
- isTrendLine: false,
- watermarkAssetPath: 'assets/logo.svg',
timeFrame: const Duration(minutes: 15),
showOhlcLegend: true,
crosshairOnHover: true,
diff --git a/doc/line-editor.md b/doc/line-editor.md
index dad7773..c2f69c6 100644
--- a/doc/line-editor.md
+++ b/doc/line-editor.md
@@ -10,7 +10,6 @@ KChartWidget(
candles,
ChartColors(),
isTrendLine: true,
- watermarkAssetPath: 'assets/logo.svg',
timeFrame: const Duration(minutes: 15),
drawingStyle: const DrawingStyle(
// What the user may pick
diff --git a/doc/migrating-from-fl_chart.md b/doc/migrating-from-fl_chart.md
new file mode 100644
index 0000000..86717c0
--- /dev/null
+++ b/doc/migrating-from-fl_chart.md
@@ -0,0 +1,358 @@
+# Migrating from fl_chart and candlesticks
+
+One package can draw both kinds of chart an app usually needs.
+
+- Replace `fl_chart`'s line and bar charts with [`SeriesChart`](series-chart.md).
+- Replace `candlesticks` with [`KChartWidget`](candlestick-chart.md).
+
+The tables below map each API onto its replacement, and the worked examples
+show the patterns apps build most often.
+
+
+
+## From fl_chart
+
+### Data
+
+| fl_chart | ohlcv_chart |
+| --- | --- |
+| `FlSpot(x, y)` | `SeriesPoint(x, y)` |
+| `FlSpot.nullSpot` | `SeriesPoint(x, null)` — a gap |
+| `spots: [for (i, v) … FlSpot(i, v)]` | `LineSeries.values(values)` / `BarSeries.values(values)` |
+| `LineChartData(lineBarsData: [...])` | `SeriesChart(series: [LineSeries(...), ...])` |
+| `BarChartData(barGroups: [...])` | `SeriesChart(series: [BarSeries(...)])` — one `BarSeries` per rod position in a group |
+
+### A line
+
+| `LineChartBarData` | `LineSeries` |
+| --- | --- |
+| `color` | `color` |
+| `gradient` | `gradient` |
+| `barWidth` | `width` |
+| `isCurved: false` | `curve: LineCurve.linear` |
+| `isCurved: true` | `curve: LineCurve.smooth` |
+| `isCurved` + `preventCurveOverShooting` | `curve: LineCurve.monotone` |
+| `isStepLineChart` | `curve: LineCurve.step` |
+| `dashArray` | `dashPattern` |
+| `isStrokeCapRound` | `roundCap` |
+| `dotData: FlDotData(show, getDotPainter)` | `dot: SeriesDot(...)` or `dotBuilder: (index, point) => SeriesDot(...)` |
+| `belowBarData: BarAreaData(gradient)` | `fill: SeriesFill(gradient: ..., toBaseline: false)` |
+| `belowBarData` + `aboveBarData` cut off at `cutOffY: 0` | `fill: SeriesFill(gradient: ...)` with `baseline: 0`; the lower half mirrors itself |
+| a stroke gradient split at zero | `negativeColor` |
+| `showingIndicators` | `SeriesChartController.show(x)` |
+| `isStepLineChart` + `lineChartStepData.stepDirection` | `curve: LineCurve.step` + `stepPosition` |
+| `shadow` | `shadow` |
+| `betweenBarsData: BetweenBarsData(fromIndex, toIndex)` | `betweenFills: [SeriesBetweenFill(from:, to:)]` |
+| `errorIndicatorData` + `FlErrorRange` | `SeriesPoint(x, y, yError: SeriesErrorRange(...))` + `errorBars` |
+
+### Bars
+
+| `BarChartRodData` | `BarSeries` |
+| --- | --- |
+| `toY` | the point's value |
+| `fromY: 0` | `baseline: 0` |
+| `color` / per-rod colour | `color` + `negativeColor`, or `colorBuilder` |
+| `width` | `width`, or `widthFactor` with `minWidth` / `maxWidth` |
+| `borderRadius` rounded away from zero | `radius` — always on the end away from the baseline |
+| `backDrawRodData` | `trackColor` |
+| `gradient` | `gradient` |
+| `fromY` per rod, for a floating bar | `SeriesPoint(x, y, low: ...)` |
+| `BarChartRodStackItem` | one `BarSeries` per layer, sharing a `stack` |
+| `borderSide` | `border` |
+| `showingTooltipIndicators` on a rod | `labelBuilder` for a permanent label |
+
+### The chart around the series
+
+| fl_chart | `SeriesChart` |
+| --- | --- |
+| `minX`, `maxX`, `minY`, `maxY` | the same names; leave them null to fit the data |
+| `minX: -0.5, maxX: n - 0.5` for bars | the default: bars get half a unit either side |
+| `titlesData: FlTitlesData(show: false)` | `xAxis: SeriesXAxis.hidden, yAxis: SeriesYAxis.hidden` |
+| `bottomTitles: SideTitles(getTitlesWidget, interval, reservedSize)` | `xAxis: SeriesXAxis(labels / labelBuilder, interval, height)` |
+| `leftTitles: SideTitles(getTitlesWidget, reservedSize)` | `yAxis: SeriesYAxis(formatter, width)` |
+| `rightTitles` | `yAxis: SeriesYAxis(side: SeriesAxisSide.right)` |
+| `gridData: FlGridData(show: false)` | `grid: SeriesGrid.none` |
+| `horizontalInterval` | `yAxis: SeriesYAxis(interval: ...)`, which also places the labels |
+| `verticalInterval`, `checkToShowVerticalLine` | vertical lines follow the x labels: `xAxis.labels`, `interval` or `ticks` |
+| `getDrawingHorizontalLine: FlLine(color, strokeWidth, dashArray)` | `SeriesGrid(color, width, dashPattern)` |
+| `borderData: FlBorderData(border: Border.all(...))` | `border: BorderSide(...)` |
+| `extraLinesData: HorizontalLine(y, dashArray)` | `referenceLines: [SeriesReferenceLine.horizontal(y, dashPattern: ...)]` |
+| `VerticalLine` | `SeriesReferenceLine.vertical(x)` |
+| `rangeAnnotations` | `bands: [SeriesBand.horizontal(...)]` / `SeriesBand.vertical(...)` |
+| `clipData: FlClipData.all()` | the default, `clipToPlot: true` |
+| `duration`, `curve` | `animationDuration`, `animationCurve`; the first build animates too |
+| `rotationQuarterTurns: 1` on a bar chart | `orientation: SeriesOrientation.horizontal` |
+| `axisNameWidget: AxisTitle(...)` | `xAxis: SeriesXAxis(title: ...)`, `yAxis: SeriesYAxis(title: ...)` |
+| `topTitles` | `xAxis: SeriesXAxis(side: SeriesXSide.top)` |
+
+### The other chart types
+
+| fl_chart | ohlcv_chart |
+| --- | --- |
+| `ScatterChart(scatterSpots: [ScatterSpot(x, y, dotPainter: ...)])` | `SeriesChart(series: [ScatterSeries(points: ...)])` with `SeriesTouchSnap.nearestPoint` |
+| `FlDotCirclePainter`, `FlDotSquarePainter`, `FlDotCrossPainter` | `SeriesDot(shape: SeriesDotShape.circle / square / diamond / cross)` |
+| `PieChart(PieChartData(sections: [PieChartSectionData(value, title, radius)]))` | [`PieChart(sections: [PieSection(value:, label:, radius:)])`](pie-chart.md) |
+| `centerSpaceRadius`, `centerSpaceColor` | the same names; `centerChild` also puts a widget in the hole |
+| `PieChartSectionData.badgeWidget`, `badgePositionPercentageOffset` | `PieSection.badge`, `badgePosition` |
+| `RadarChart(RadarChartData(dataSets: [RadarDataSet(dataEntries: ...)]))` | [`RadarChart(series: [RadarSeries(values: ...)])`](radar-chart.md) |
+| `getTitle` per feature | `features: ['Speed', ...]` |
+| `radarShape: RadarShape.circle` | `shape: RadarShape.circle` |
+| `tickCount`, `ticksTextStyle` | `tickCount`, `tickStyle` with `showTicks` |
+
+### Touch
+
+| fl_chart | `SeriesChart` |
+| --- | --- |
+| `lineTouchData: LineTouchData(enabled: false)` | `touch: null` |
+| default touch (while pressed) | `SeriesTouch(trigger: SeriesTouchTrigger.press)` — the default |
+| a `GestureDetector` with `onLongPress…` over the chart | `SeriesTouch(trigger: SeriesTouchTrigger.longPress)` |
+| `touchCallback` + `response.lineBarSpots` | `onTouch: (details) => …`: `details.index`, `details.values` |
+| `getTooltipItems` → `LineTooltipItem` | `SeriesTooltip(title: ..., valueFormatter: ...)` |
+| a hand-built tooltip overlay in a `Stack` | `SeriesTooltip(builder: (context, details) => …)` |
+| `getTooltipColor`, `tooltipBorder`, `tooltipBorderRadius`, `tooltipPadding` | `backgroundColor`, `borderColor`, `borderRadius`, `padding` |
+| `fitInsideHorizontally`, `fitInsideVertically` | always on |
+| `getTouchedSpotIndicator` → `TouchedSpotIndicatorData(FlLine, FlDotData)` | `SeriesTouch(line: SeriesCrosshairLine(...), markerBuilder: ...)` |
+| two charts kept in step by hand | one `SeriesChartController` given to both |
+
+`handleBuiltInTouches` has no equivalent, because touch is always handled by the
+chart. To react to a touch without showing a tooltip, pass `tooltip: null` and
+use `onTouch`.
+
+## Worked examples
+
+### A sparkline with a press tooltip
+
+```dart
+SizedBox(
+ height: 58,
+ child: SeriesChart(
+ series: [
+ LineSeries.values(
+ balances,
+ color: context.secondaryDark,
+ width: isTouched ? 2 : 1,
+ curve: LineCurve.monotone,
+ fill: SeriesFill(gradient: AppGradients.chartPurpleFade(context)),
+ dotBuilder: isTouched
+ ? (i, _) => SeriesDot(radius: i == touchedIndex ? 3 : 2)
+ : null,
+ ),
+ ],
+ xAxis: SeriesXAxis.hidden,
+ yAxis: SeriesYAxis.hidden,
+ grid: SeriesGrid.none,
+ touch: SeriesTouch(
+ line: null,
+ showMarkers: false,
+ tooltip: SeriesTooltip(
+ placement: SeriesTooltipPlacement.above,
+ backgroundColor: context.backgroundPaper,
+ borderRadius: 10,
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
+ builder: (context, d) => BalanceTooltip(
+ value: formatUsd(d.values.first.value),
+ date: labels[d.index],
+ ),
+ ),
+ ),
+ onTouch: (d) => onTouch(active: d != null, idx: d?.index ?? -1),
+ ),
+);
+```
+
+### A multi-series chart over a window of long data
+
+
+
+This replaces a `LineChart`, a separate y-axis `LineChart`, a long-press
+overlay and a range selector built from a third `LineChart`, all with one
+chart and one strip.
+
+```dart
+Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ SizedBox(
+ height: 220,
+ child: SeriesChart(
+ series: [
+ for (final (i, s) in series.indexed)
+ LineSeries.values(
+ s.values,
+ label: s.label,
+ color: seriesColor(context, i),
+ fill: SeriesFill.fade(seriesColor(context, i)),
+ ),
+ ],
+ minX: window.start - .5,
+ maxX: window.end + .5,
+ xAxis: SeriesXAxis(labels: axisLabels, style: axisLabelStyle),
+ yAxis: SeriesYAxis(width: 30, formatter: formatUsdAxis, style: axisLabelStyle),
+ grid: SeriesGrid(color: context.gray700, dashPattern: const [4, 4]),
+ border: BorderSide(color: context.gray700),
+ touch: SeriesTouch(
+ trigger: SeriesTouchTrigger.longPress,
+ tooltip: SeriesTooltip(builder: (context, d) => TrendTooltip(d)),
+ ),
+ animationDuration: dragging ? Duration.zero : const Duration(milliseconds: 450),
+ ),
+ ),
+ if (count > windowPoints) ...[
+ const SizedBox(height: 12),
+ Padding(
+ padding: const EdgeInsetsDirectional.only(start: 30),
+ child: SeriesRangeSelector(
+ series: overviewSeries,
+ window: window,
+ minSpan: 4,
+ maskColor: context.backgroundBG.withValues(alpha: .6),
+ onChanged: (next) => setState(() => window = next),
+ ),
+ ),
+ ],
+ ],
+);
+```
+
+### Two panels with one crosshair
+
+
+
+A balance line with its average dashed over it, and profit bars underneath.
+Holding either panel marks the same row in both.
+
+```dart
+final crosshair = SeriesChartController();
+
+SeriesChart(
+ series: [
+ LineSeries.values(balance, color: purple, fill: SeriesFill.fade(purple, opacity: .14)),
+ LineSeries.values(average, color: amber, dashPattern: const [6, 4]),
+ ],
+ xAxis: SeriesXAxis.hidden,
+ yAxis: const SeriesYAxis(width: 25),
+ xPadding: .5,
+ controller: crosshair,
+ touch: SeriesTouch(
+ trigger: SeriesTouchTrigger.longPress,
+ tooltip: SeriesTooltip(
+ builder: (context, d) => BalanceTooltip(d, profit: profits[d.index]),
+ ),
+ ),
+);
+SeriesChart(
+ series: [
+ BarSeries.values(profits, color: green, negativeColor: red,
+ radius: 3, minWidth: 3.5, maxWidth: 12),
+ ],
+ xAxis: SeriesXAxis(labels: axisLabels),
+ yAxis: const SeriesYAxis(width: 25),
+ controller: crosshair,
+ touch: const SeriesTouch(trigger: SeriesTouchTrigger.longPress, tooltip: null),
+);
+```
+
+### Green above zero, red below
+
+```dart
+SeriesChart(
+ series: [
+ LineSeries.values(
+ roi,
+ color: green,
+ negativeColor: red,
+ width: 1.5,
+ curve: LineCurve.monotone,
+ fill: SeriesFill.fade(green, negativeColor: red),
+ ),
+ ],
+ includeZero: true,
+ xAxis: SeriesXAxis.hidden,
+ yAxis: SeriesYAxis.hidden,
+ grid: SeriesGrid.none,
+ referenceLines: [
+ SeriesReferenceLine.horizontal(0, color: gray600, dashPattern: const [3, 3]),
+ ],
+ touch: SeriesTouch(
+ markerBuilder: (v) => SeriesDot(radius: 3, color: v.color),
+ tooltip: SeriesTooltip(
+ placement: SeriesTooltipPlacement.above,
+ valueFormatter: (v) => formatPercentFixed(v.value),
+ ),
+ ),
+ animationDuration: const Duration(milliseconds: 300),
+);
+```
+
+The `zeroStop` arithmetic an `fl_chart` stroke gradient needs is gone: the
+line changes colour exactly where it crosses its baseline.
+
+### Draw-in on mount
+
+Code like `ChartDrawIn`, which renders a flat frame first and pushes the real
+values a frame later, is no longer needed. A non-zero `animationDuration` grows
+the first build out of the baseline by itself (`animateOnMount`, on by
+default).
+
+## From candlesticks
+
+| candlesticks | ohlcv_chart |
+| --- | --- |
+| `Candle(date:, open:, high:, low:, close:, volume:)` | `KLineEntity.fromCustom(dateTime:, open:, high:, low:, close:, vol:)` |
+| a list **newest first** | a list **oldest first**: reverse it |
+| — | `DataUtil.calculate(candles)` once, before the chart gets them |
+| `Candlesticks(candles: candles)` | `KChartWidget(candles, ChartColors())` |
+| `loadingWidget` | `candles.isEmpty ? MyLoading() : KChartWidget(...)` |
+| `onLoadMoreCandles` | `onLoadMore: (atRight) { if (!atRight) loadOlder(); }` |
+| `CandlesticksController` | `KChartController` — `zoomIn`, `zoomOut`, `scrollToNow`, `showRange` |
+| `CandleSticksStyle` | `ChartColors` (see below) |
+
+```dart
+final candles = [
+ for (final p in points.reversed)
+ KLineEntity.fromCustom(
+ dateTime: DateTime.fromMillisecondsSinceEpoch(p.time * 1000),
+ open: p.open,
+ high: p.high,
+ low: p.low,
+ close: p.close,
+ vol: p.tickVolume.toDouble(),
+ ),
+];
+DataUtil.calculate(candles);
+
+KChartWidget(
+ candles,
+ ChartColors(
+ bgColor: context.backgroundPaper,
+ gridColor: context.gray700,
+ defaultTextColor: context.textSoft,
+ upColor: context.successMain,
+ dnColor: context.errorMain,
+ ),
+ chartTranslations: ChartTranslations(date: l10n.date, open: l10n.open, …),
+);
+```
+
+`isTrendLine` and `timeFrame` are optional since 2.5.0, and a watermark is any
+widget passed as `watermark`.
+Pass `timeFrame` to get a countdown on the current-price tag, and
+`isTrendLine: true` to turn on the drawing tools.
+
+| `CandleSticksStyle` | `ChartColors` |
+| --- | --- |
+| `chartBackgroundColor` | `bgColor` |
+| `gridLineColor` | `gridColor` |
+| `axisTextColor` | `defaultTextColor` |
+| `candleBullColor`, `candleBearColor` | `upColor`, `dnColor` |
+| `volumeBullColor`, `volumeBearColor` | follow `upColor` / `dnColor` |
+| `crosshairLineColor` | `hCrossColor`, `vCrossColor` |
+| `crosshairLabelTextColor` | `crossTextColor` |
+| `ohlcInfoBullColor`, `ohlcInfoBearColor` | `infoWindowUpColor`, `infoWindowDnColor` |
+| `priceIndicatorBullBackgroundColor`, `…BearBackgroundColor` | `nowPriceUpColor`, `nowPriceDnColor` |
+| `priceIndicatorTextColor` | `nowPriceTextColor` |
+| `loadingIndicatorColor` | your own loading widget |
+
+`candlesticks` builds its own zoom buttons in a toolbar. `KChartWidget` zooms
+by pinch and scroll, and a host that wants buttons calls `KChartController`'s
+`zoomIn` and `zoomOut` from its own.
diff --git a/doc/orders-and-positions.md b/doc/orders-and-positions.md
index 5835f03..3603bab 100644
--- a/doc/orders-and-positions.md
+++ b/doc/orders-and-positions.md
@@ -8,8 +8,6 @@ from the venue rather than from the user, and they are not saved with a layout.
KChartWidget(
candles,
ChartColors(),
- isTrendLine: false,
- watermarkAssetPath: 'assets/logo.svg',
timeFrame: const Duration(minutes: 15),
positions: [
ChartPosition(
diff --git a/doc/panes.md b/doc/panes.md
index 922c0a6..a656a92 100644
--- a/doc/panes.md
+++ b/doc/panes.md
@@ -9,8 +9,6 @@ down the stack by dragging its legend row:
KChartWidget(
candles,
ChartColors(),
- isTrendLine: false,
- watermarkAssetPath: 'assets/logo.svg',
timeFrame: const Duration(minutes: 15),
indicators: [MacdIndicator(), RsiIndicator()],
resizablePanes: true,
diff --git a/doc/pie-chart.md b/doc/pie-chart.md
new file mode 100644
index 0000000..9c8ba56
--- /dev/null
+++ b/doc/pie-chart.md
@@ -0,0 +1,88 @@
+# Pie chart
+
+
+
+`PieChart` draws a ring of sections, each as wide as its share of the whole: a
+pie, a doughnut when the middle is left open, or a single-section gauge. (The
+radar beside it is [its own page](radar-chart.md).)
+
+```dart
+PieChart(
+ sections: [
+ PieSection(value: 40, color: blue, label: '40%'),
+ PieSection(value: 35, color: green, label: '35%'),
+ PieSection(value: 25, color: amber, label: '25%'),
+ ],
+ centerSpaceRadius: 40,
+ centerChild: const Text('Total'),
+);
+```
+
+The chart fills the box it is given, and is `defaultSize` square in a box that
+sets no size of its own.
+
+## Sections
+
+A section's `value` is a share, not an angle. The values are added up and each
+section gets the part of the circle it is worth, so `[40, 35, 25]` and
+`[8, 7, 5]` draw the same pie. A section worth nothing is left out, and a set of
+sections worth nothing at all draws nothing.
+
+| Field | What it does |
+| --- | --- |
+| `color` | Fill colour |
+| `gradient` | Fill gradient, measured over the section; it wins over `color` |
+| `label`, `labelStyle` | Written on the section |
+| `labelPosition` | Where the label sits: 0 at the inner edge, 1 at the outer one |
+| `radius` | How far this one section reaches, so it can stand out |
+| `border` | An outline round it |
+| `offset` | How far it is pushed out of the circle — the exploded slice |
+| `badge`, `badgePosition` | A widget pinned to the section |
+
+## The circle
+
+| Field | What it does |
+| --- | --- |
+| `radius` | How far the sections reach; null fills the box |
+| `centerSpaceRadius` | The hole in the middle; 0 draws a full pie |
+| `centerSpaceColor` | Painted in the hole |
+| `centerChild` | A widget centred in the hole — a total, a title, a button |
+| `sectionsSpace` | The gap between two sections, in pixels round the outer edge |
+| `startDegreeOffset` | Where the first section starts, clockwise from twelve |
+| `clockwise` | Whether the sections go round clockwise |
+
+## Touch
+
+A touch or a hovering mouse names the section under it. That section grows by
+`touchedSectionGrowth` while it is held — the room for it is taken off the
+radius, so nothing is cut off at the edge — and `onTouch` reports which one it
+is, with `null` when the touch leaves.
+
+```dart
+PieChart(
+ sections: sections,
+ onTouch: (details) => setState(() => selected = details?.index),
+);
+```
+
+## Placing your own widgets
+
+The geometry is public, so anything can be put exactly where a section is:
+
+```dart
+final slices = layOutPie(
+ sections: sections,
+ radius: 80,
+ innerRadius: 40,
+);
+final at = slices[1].pointAt(centre, 0.5); // the middle of the second section
+```
+
+`pieSectionAt` answers the other question — which section a point is inside,
+and `null` for the hole or the space around the circle.
+
+## Animation
+
+With `animationDuration` set, the sections sweep open from the start angle,
+each one keeping its place. `animateOnMount: false` draws the first build
+whole.
diff --git a/doc/price-axis.md b/doc/price-axis.md
index 5ee3150..6d06fb0 100644
--- a/doc/price-axis.md
+++ b/doc/price-axis.md
@@ -4,8 +4,6 @@
KChartWidget(
candles,
ChartColors(),
- isTrendLine: false,
- watermarkAssetPath: 'assets/logo.svg',
timeFrame: const Duration(days: 1),
priceAxisScale: PriceAxisScale.logarithmic,
);
@@ -45,8 +43,6 @@ a denser axis and lower it for a sparser one.
KChartWidget(
candles,
ChartColors(),
- isTrendLine: false,
- watermarkAssetPath: 'assets/logo.svg',
timeFrame: const Duration(days: 1),
chartStyle: const ChartStyle(gridRows: 12),
);
@@ -116,8 +112,6 @@ Drawings keep their own labels, which are yours to set through each one's
KChartWidget(
candles,
ChartColors(),
- isTrendLine: false,
- watermarkAssetPath: 'assets/logo.svg',
timeFrame: const Duration(days: 1),
invertPriceAxis: true, // higher prices lower down
showAverageClose: true, // a level at the mean close over the window
@@ -243,8 +237,6 @@ the labels hands the axis back to the chart.
KChartWidget(
candles,
ChartColors(),
- isTrendLine: false,
- watermarkAssetPath: 'assets/logo.svg',
timeFrame: const Duration(days: 1),
controller: chart,
priceScaleDrag: true,
diff --git a/doc/radar-chart.md b/doc/radar-chart.md
new file mode 100644
index 0000000..7a34d93
--- /dev/null
+++ b/doc/radar-chart.md
@@ -0,0 +1,84 @@
+# Radar chart
+
+
+
+`RadarChart` compares several things over the same features: a web of spokes,
+one outline per series. (The doughnut beside it is
+[its own page](pie-chart.md).)
+
+```dart
+RadarChart(
+ features: const ['Speed', 'Power', 'Range', 'Cost', 'Weight'],
+ series: [
+ RadarSeries(values: const [4, 3, 5, 2, 4], color: blue, label: 'Now'),
+ RadarSeries(values: const [3, 5, 2, 4, 3], color: amber, label: 'Target'),
+ ],
+ maxValue: 5,
+);
+```
+
+The chart fills the box it is given, and is `defaultSize` square in a box that
+sets no size of its own.
+
+## Series
+
+A `RadarSeries` carries one value per feature, in the order `features` names
+them. A value beyond the range is held at the edge rather than drawn off the
+web, and a `null` leaves that corner out of the outline.
+
+| Field | What it does |
+| --- | --- |
+| `color` | The outline colour |
+| `fillColor` | Inside the outline; null uses a faint `color` |
+| `width`, `dashPattern` | The outline itself; `width: 0` draws none |
+| `dot` | A dot on every corner |
+| `label` | What the series is called, for a legend of your own |
+
+## The web
+
+| Field | What it does |
+| --- | --- |
+| `features` | What each corner is called; an empty list leaves them unnamed |
+| `minValue`, `maxValue` | The middle and the outer ring; `maxValue` null takes the largest value given |
+| `tickCount` | How many rings are drawn |
+| `shape` | `RadarShape.polygon` — the spider web — or `circle` |
+| `startDegreeOffset` | Where the first feature sits, clockwise from twelve |
+| `gridColor`, `gridWidth` | The rings |
+| `spokeColor`, `spokeWidth` | The spokes out to each corner |
+| `showTicks`, `tickStyle` | The value each ring stands for, written up the first spoke |
+| `featureStyle`, `featureGap` | The names outside the web |
+| `radius` | How far the web reaches; null fits the box, leaving room for the names |
+
+## Touch
+
+`onTouch` reports the corner nearest the finger — which series, which feature
+and the value there — within `touchThreshold` pixels, and `null` when the touch
+leaves.
+
+```dart
+RadarChart(
+ series: series,
+ onTouch: (details) => setState(() => hovered = details?.featureIndex),
+);
+```
+
+## Placing your own widgets
+
+`radarCorner` and `RadarLayout` are public, so a legend chip or a badge can be
+put exactly on a corner:
+
+```dart
+const layout = RadarLayout(
+ centre: Offset(120, 120),
+ radius: 100,
+ count: 5,
+ minValue: 0,
+ maxValue: 10,
+ startAngle: -math.pi / 2,
+);
+final at = layout.cornerFor(2, 7); // where the third feature's 7 is drawn
+```
+
+## Animation
+
+With `animationDuration` set, the outlines grow out of the middle.
diff --git a/doc/series-chart.md b/doc/series-chart.md
new file mode 100644
index 0000000..842ea8c
--- /dev/null
+++ b/doc/series-chart.md
@@ -0,0 +1,437 @@
+# Series charts
+
+`KChartWidget` reads candles against time. `SeriesChart` reads any numbers
+against any numbers: a balance per day, sign-ups per month, profit per trade.
+These are the charts a dashboard, a wallet or a report draws. It has lines,
+areas and bars, several series at once, axes you can write or hide, a touch
+readout you can replace, a range strip for long data, and animation between
+data sets.
+
+
+
+```dart
+SeriesChart(
+ series: [
+ LineSeries.values(
+ balances, // List, placed at x = 0, 1, 2 …
+ color: purple,
+ curve: LineCurve.monotone,
+ fill: SeriesFill.fade(purple),
+ ),
+ ],
+ xAxis: SeriesXAxis(labels: dayNames),
+ yAxis: SeriesYAxis(formatter: formatUsd),
+);
+```
+
+Everything else is optional. Every parameter's default draws a plain,
+readable chart, and each section below changes one part of it.
+
+## Series
+
+A chart takes a list of `PlotSeries`, drawn bottom layer first. There are three
+kinds — lines, bars and scatter — and one chart can mix them.
+
+Points are `SeriesPoint(x, y)` and run from the lowest x to the highest. When
+your data is just a list of values, the `.values` constructors place them at
+`x = 0, 1, 2, …`. A `null` value is a gap: a line breaks there and a bar is
+left out.
+
+### Lines
+
+```dart
+LineSeries(
+ points: [SeriesPoint(0, 12), SeriesPoint(1, 18), SeriesPoint(2, null), …],
+ label: l10n.balance, // named by the default tooltip
+ color: purple,
+ width: 2, // 0 draws no line: with `dot`, a scatter plot
+ curve: LineCurve.monotone,
+ dashPattern: [6, 4], // null draws solid
+ roundCap: true,
+ gradient: someGradient, // paints the stroke instead of `color`
+ negativeColor: red, // below `baseline`, split exactly where it crosses
+ baseline: 0,
+ fill: SeriesFill.fade(purple),
+ dot: SeriesDot(radius: 2),
+ dotBuilder: (index, point) => index == touched ? SeriesDot(radius: 3) : null,
+);
+```
+
+`curve` decides how points are joined:
+
+| `LineCurve` | Draws |
+| --- | --- |
+| `linear` | straight segments |
+| `smooth` | a curve through every point that can swing past a peak (`fl_chart`'s `isCurved`) |
+| `monotone` | a curve through every point that never swings past its neighbours (`isCurved` + `preventCurveOverShooting`) |
+| `step` | holds each value until the next point; `stepPosition` says where between two points it changes — 0 at the first, 0.5 halfway, 1 at the second |
+
+A line also takes a `shadow`, a blurred copy of it drawn underneath.
+
+### Fills
+
+A `SeriesFill` colours the area between the line and its `baseline`, or the
+bottom of the plot when `toBaseline` is false. A fill anchored to the baseline
+never paints across it. Above the baseline it uses `gradient` (or `color`),
+measured from the line's highest point down to the baseline, so it fades out
+exactly at the baseline. Below the baseline it uses `negativeGradient` (or
+`negativeColor`), measured the other way. Left unset, the lower half reuses the
+upper gradient flipped upside down, so both halves fade away from the line.
+
+```dart
+// Green above zero, red below, each fading towards zero.
+LineSeries.values(
+ roi,
+ color: green,
+ negativeColor: red,
+ curve: LineCurve.monotone,
+ fill: SeriesFill.fade(green, negativeColor: red, opacity: .24),
+);
+```
+
+### Bars
+
+```dart
+BarSeries.values(
+ profits,
+ color: green,
+ negativeColor: red, // or colorBuilder: (index, point) => …
+ baseline: 0, // bars grow from here, up or down
+ radius: 3, // on the end away from the baseline
+ widthFactor: .6, // share of the room between two x values
+ minWidth: 3.5,
+ maxWidth: 12,
+ width: null, // or a fixed width
+ trackColor: faint, // a full-height bar behind each one
+ gradient: null, // painted over each bar
+);
+```
+
+Several bar series on one chart stand side by side at each x. When there are
+bars, the x range gets half a unit of room at either end, so the first and last
+bars are drawn whole.
+
+Bar series that share a `stack` are piled on each other instead: each one
+starts where the ones before it ended, upwards for values above the baseline
+and downwards for values below, and only the outermost bar is rounded. A stack
+takes one place in the row, so a stack and a plain series still stand side by
+side.
+
+```dart
+SeriesChart(
+ series: [
+ BarSeries.values(deposits, color: green, stack: 'flow', radius: 0),
+ BarSeries.values(withdrawals, color: red, stack: 'flow', radius: 3),
+ ],
+);
+```
+
+A point with a `low` draws a bar that floats between two values rather than
+growing from the baseline — a range, or a step of a waterfall:
+
+```dart
+BarSeries(
+ points: [SeriesPoint(0, 8, low: 4), SeriesPoint(1, 11, low: 8)],
+);
+```
+
+`border` outlines each bar, and `labelBuilder` writes past its far end:
+
+```dart
+BarSeries.values(
+ profits,
+ border: const BorderSide(color: outline),
+ labelBuilder: (index, point) => formatUsd(point.y!),
+);
+```
+
+### Scatter
+
+
+
+A `ScatterSeries` places a dot per point, free on both axes:
+
+```dart
+SeriesChart(
+ series: [
+ ScatterSeries(
+ points: trades,
+ dotBuilder: (index, point) => SeriesDot(
+ radius: 3 + point.y! / 40,
+ shape: point.y! < 0 ? SeriesDotShape.cross : SeriesDotShape.circle,
+ color: point.y! < 0 ? red : green,
+ ),
+ labelBuilder: (index, point) => names[index],
+ ),
+ ],
+ touch: const SeriesTouch(snap: SeriesTouchSnap.nearestPoint),
+);
+```
+
+`SeriesTouchSnap.nearestPoint` reads out the one dot under the finger, within
+`SeriesTouch.threshold` pixels, instead of everything at that x — which is what
+a scatter plot wants and a line chart does not.
+
+A dot's `shape` is a `circle`, a `square`, a `diamond` or a `cross`.
+
+### Error bars
+
+A point can carry how far it might be off, on either axis:
+
+```dart
+SeriesPoint(
+ 0,
+ 5,
+ yError: const SeriesErrorRange(0.4, 0.9), // below, above
+ xError: const SeriesErrorRange.symmetric(0.2),
+);
+```
+
+Any series draws them, styled by `errorBars: SeriesErrorBars(color, width,
+capLength)`; pass `errorBars: null` to leave them out. The value range makes
+room for them.
+
+### Filling between two lines
+
+
+
+The same picture shows both: floating bars on the left, and on the right a
+`betweenFills` band with error bars every few points.
+
+`betweenFills` shades the gap between two of the series — a high and a low, a
+plan and what happened:
+
+```dart
+SeriesChart(
+ series: [
+ LineSeries.values(high, color: blue),
+ LineSeries.values(low, color: blue),
+ ],
+ betweenFills: const [
+ SeriesBetweenFill(from: 0, to: 1, color: Color(0x2200A3FF)),
+ ],
+);
+```
+
+## Turning the chart on its side
+
+
+
+`orientation: SeriesOrientation.horizontal` runs the x axis down the chart and
+grows the values rightwards, which is the horizontal bar chart:
+
+```dart
+SeriesChart(
+ orientation: SeriesOrientation.horizontal,
+ series: [BarSeries.values(byCategory, radius: 3)],
+ xAxis: SeriesXAxis(labels: categoryNames),
+);
+```
+
+Everything turns with it: curves and steps follow the x axis rather than the
+screen, gradients written for an upright chart still point at the high values,
+the crosshair reads down the chart, and the tooltip hangs off the far end of
+the values. `SeriesAxisSide.left` puts the value axis along the bottom and
+`SeriesXSide.bottom` puts the x labels down the left.
+
+## Axes, grid and border
+
+```dart
+SeriesChart(
+ series: …,
+ xAxis: SeriesXAxis(
+ labels: monthLabels, // labels[i] names x = i; '' prints nothing
+ interval: 2, // keep only every second one
+ height: 22, // room held below the plot
+ style: captionStyle,
+ ),
+ yAxis: SeriesYAxis(
+ width: 30, // room beside the plot; charts sharing it line up
+ side: SeriesAxisSide.left,
+ formatter: formatUsdAxis,
+ tickCount: 5, // or interval: 500, or ticks: [0, 500, 1000]
+ ),
+ grid: SeriesGrid(color: gray700, dashPattern: [4, 4], vertical: true),
+ border: BorderSide(color: gray700),
+);
+```
+
+The x axis chooses which x values to label, and to draw a vertical grid line
+at, in this order:
+
+1. `ticks`, if given.
+2. Otherwise, every index whose entry in `labels` is not blank, kept only at
+ multiples of `interval` when that is set. A label list that names only month
+ starts therefore spaces itself.
+3. Otherwise, every `interval`.
+4. Otherwise, about `tickCount` round values.
+
+Labels that would overlap the one before are dropped, and `fitInside` slides
+the first and last labels in so their text is not cut off. Use `labelBuilder`
+to write a label from its x.
+
+Either axis takes a `title`, written beyond its labels and turned to read up an
+axis that runs down the chart, and a side of its own: `SeriesXSide.top` puts
+the x labels over the plot and `SeriesAxisSide.right` puts the values on the
+right.
+
+```dart
+SeriesChart(
+ xAxis: SeriesXAxis(labels: monthLabels, title: l10n.month),
+ yAxis: SeriesYAxis(formatter: formatUsdAxis, title: l10n.balance),
+);
+```
+
+`SeriesXAxis.hidden`, `SeriesYAxis.hidden` and `SeriesGrid.none` remove each
+part, along with the room it held. A 58-pixel sparkline is a chart with all
+three hidden.
+
+## The range of values
+
+With nothing set, x spans the points and the value range fits the data. The
+fitted range gets `yPadding` (10%) of room above and below, then widens to the
+next round tick at each end. The padding never pushes a series that stays above
+zero below zero, or one that stays below zero above it.
+
+| Parameter | Does |
+| --- | --- |
+| `minX`, `maxX` | Pin the plot's edges. Set both to show a window of a longer series; the values range then fits what is in the window. |
+| `xPadding` | Room either side of the fitted x range, in x units. |
+| `minY`, `maxY` | Pin the value range. |
+| `includeZero` | Always reach zero. |
+| `yPadding` | Room above and below, as a share of the range. |
+| `niceYRange` | Widen to round ticks (on by default). |
+
+## Reference lines and bands
+
+```dart
+referenceLines: [
+ SeriesReferenceLine.horizontal(0, color: gray600, dashPattern: [3, 3]),
+ SeriesReferenceLine.horizontal(target, label: l10n.target, extendsRange: true),
+ SeriesReferenceLine.vertical(todayIndex.toDouble(), label: l10n.today),
+],
+bands: [SeriesBand.vertical(5, 7, color: weekendShade)],
+```
+
+Reference lines are not interactive, and they sit under the series unless
+`aboveSeries` is set. A horizontal line only widens the value range when
+`extendsRange` asks, because otherwise a far-off target would flatten the data.
+
+## Touch
+
+```dart
+SeriesChart(
+ series: …,
+ touch: SeriesTouch(
+ trigger: SeriesTouchTrigger.longPress, // press · longPress · tap · none
+ hover: true, // a mouse shows it too
+ line: SeriesCrosshairLine(color: gray600),
+ horizontalLine: null,
+ showMarkers: true,
+ markerBuilder: (v) => SeriesDot(radius: 3, color: v.value < 0 ? red : green),
+ tooltip: SeriesTooltip(
+ placement: SeriesTooltipPlacement.beside, // or above the point
+ title: (details) => dateLabels[details.index],
+ valueFormatter: (v) => formatUsd(v.value),
+ backgroundColor: paper,
+ borderColor: gray700,
+ ),
+ ),
+ onTouch: (details) => setState(() => touched = details?.index),
+);
+```
+
+| `SeriesTouchTrigger` | Behaviour |
+| --- | --- |
+| `press` | Shows while a finger is down or dragging, and hides when it lifts. This is `fl_chart`'s default. |
+| `longPress` | Shows after a long press and follows the finger, so a quick swipe still scrolls the page. |
+| `tap` | A tap places it and it stays there. A drag moves it, and tapping the same point again clears it. |
+| `none` | Only hover or the controller can show it. |
+
+The crosshair snaps to the nearest point. `onTouch` is called after it moves to
+another x, and with `null` when it goes away. Its `SeriesTouchDetails` carry
+the x (`index` for charts of plain values) and, for every series with a value
+there, the series, the point, its position on the chart and its colour at that
+value.
+
+To draw your own tooltip, give `SeriesTooltip.builder`. The chart still places
+the result beside the crosshair or above the point and keeps it inside the
+chart. Return `null` from the builder, or pass `tooltip: null`, to show no
+tooltip at all. Pass `touch: null` to make the chart ignore touch and hover
+entirely.
+
+### Several charts, one crosshair
+
+
+
+A `SeriesChartController` shows or clears the crosshair from code. Give two
+charts the same controller, and touching either one marks the same x on both.
+For example, a balance panel and a profit panel under it:
+
+```dart
+final crosshair = SeriesChartController();
+
+Column(children: [
+ SizedBox(height: 200, child: SeriesChart(series: balance, controller: crosshair, …)),
+ SizedBox(height: 96, child: SeriesChart(series: profit, controller: crosshair,
+ touch: SeriesTouch(tooltip: null), …)),
+]);
+```
+
+Charts stacked like this line up as long as they give `SeriesYAxis` the same
+`width` and use the same x range.
+
+## A window over long data
+
+
+
+`SeriesRangeSelector` draws the whole data set small, with a window over it.
+Drag inside the window to move it, or drag a handle to move that edge. The
+selector holds no data of its own: show the window through the main chart's
+`minX` and `maxX`, and keep the window it reports.
+
+```dart
+var window = SeriesWindow(count - 30, count - 1.0);
+
+SeriesChart(series: series, minX: window.start - .5, maxX: window.end + .5, …);
+SeriesRangeSelector(
+ series: overviewSeries,
+ window: window,
+ minSpan: 4,
+ onChanged: (next) => setState(() => window = next),
+);
+```
+
+## Animation
+
+```dart
+SeriesChart(
+ series: …,
+ animationDuration: Duration(milliseconds: 450),
+ animationCurve: Curves.easeOutCubic,
+ animateOnMount: true,
+);
+```
+
+With a non-zero duration, new data moves into place. If each series has the
+same number of points as before, every value moves from where it was, and the
+value range moves with it. Otherwise, and on the first build, the values grow
+out of each series' baseline, or from the edge of the plot when the baseline is
+out of range. A change of colour or style alone does not animate.
+
+Leave the duration at zero for data that changes while it is being dragged,
+such as a window moved by a range selector, so the chart keeps up with the
+finger.
+
+## Sizing
+
+The chart fills the box it is given. In a box with no height of its own, such
+as a `Column` without an `Expanded` or a scroll view, it is `defaultHeight`
+(200) tall. `padding` keeps space clear around the whole chart, axes included.
+`clipToPlot: false` lets a thick line or a dot at the edge draw past the plot
+instead of being cut off.
+
+## Migrating
+
+[Migrating from fl_chart and candlesticks](migrating-from-fl_chart.md) maps
+their APIs onto this one, with worked examples.
diff --git a/doc/sessions.md b/doc/sessions.md
index 624ce34..6851574 100644
--- a/doc/sessions.md
+++ b/doc/sessions.md
@@ -4,8 +4,6 @@
KChartWidget(
candles,
ChartColors(),
- isTrendLine: false,
- watermarkAssetPath: 'assets/logo.svg',
timeFrame: const Duration(minutes: 15),
chartStyle: const ChartStyle(showSessionDividers: true),
timeZoneOffset: const Duration(hours: -5),
@@ -29,8 +27,6 @@ pre-market and the after-hours — are washed behind the candles:
KChartWidget(
candles,
ChartColors(),
- isTrendLine: false,
- watermarkAssetPath: 'assets/logo.svg',
timeFrame: const Duration(minutes: 15),
timeZoneOffset: const Duration(hours: -5),
session: const TradingSession(
@@ -57,8 +53,6 @@ to use it, or null to leave the up or down colour it would have had:
KChartWidget(
candles,
ChartColors(),
- isTrendLine: false,
- watermarkAssetPath: 'assets/logo.svg',
timeFrame: const Duration(minutes: 15),
candleColor: (candle, index) {
final range = candle.high - candle.low;
diff --git a/doc/theming.md b/doc/theming.md
index 1674618..23e10af 100644
--- a/doc/theming.md
+++ b/doc/theming.md
@@ -20,7 +20,7 @@ KChartWidget(
`close`, `changeAmount`, `change`, `amount`, `vol`, `jumpToNow`), so localising
the chart is a matter of building one from your own `AppLocalizations`. Its
`drawing` field does the same for the line editor, the drawing manager and what
-each of the seventeen kinds is called:
+each kind of drawing is called:
```dart
ChartTranslations(
@@ -53,6 +53,38 @@ The long-press readout sizes itself to its content between `infoDialogWidth` and
`infoDialogMaxWidth`, and is never wider than the chart. Set
`isTapShowInfoDialog` to open it on a plain tap as well.
+## Watermark
+
+
+
+`watermark` takes any widget — an `Image.asset` of your logo, an icon, a line of
+text — and draws it faintly over the candle area:
+
+```dart
+KChartWidget(
+ candles,
+ ChartColors(watermarkColor: Colors.white.withValues(alpha: .06)),
+ watermark: Image.asset('assets/logo.png'),
+ chartStyle: const ChartStyle(
+ watermarkScale: .3, // width, as a share of the shorter side
+ watermarkAlignment: Alignment.bottomRight,
+ ),
+);
+```
+
+It is painted in one colour, `ChartColors.watermarkColor` — a very faint
+`defaultTextColor` when left null — so a full-colour logo reads as a quiet
+silhouette. It takes no touches, so the chart under it behaves as though it were
+not there. The package has no SVG dependency; an app that wants an SVG
+watermark passes an `SvgPicture.asset` from `flutter_svg` itself.
+
+## Series charts
+
+`SeriesChart` does not read `ChartColors` or `ChartStyle`: each series carries
+its own colour, and the axes, grid, tooltip and range selector take their styles
+directly, so a series chart picks up an app's theme from wherever the app keeps
+it. See [Series charts](series-chart.md).
+
---
[← All docs](README.md) · [Package README](../README.md)
diff --git a/example/README.md b/example/README.md
index 14ede97..63e54e1 100644
--- a/example/README.md
+++ b/example/README.md
@@ -6,7 +6,7 @@ Run it with:
flutter run
```
-A tour of the whole package in two tabs. The market data is generated locally,
+A tour of the whole package in three tabs. The market data is generated locally,
so the demo needs no network.
## The Candles tab
@@ -65,6 +65,33 @@ through `DepthEntity.bids` and `DepthEntity.asks`, with chips for its four modes
— curve, bars, both and the numeric ladder — its linear, log and percent axes,
and how far either side of the mid to look.
+## The Series tab
+
+`SeriesChart` drawing the charts a business app asks for, each in its own card:
+
+- **A sparkline** — 58 pixels high with no axes, a monotone line over a fading
+ fill; press and drag for a tooltip above the point.
+- **Deposits and withdrawals** — two filled series over a 30-day window of 120
+ days, with a long-press tooltip and a `SeriesRangeSelector` underneath to move
+ and resize the window.
+- **Balance and profit** — a balance line with its dashed average over rounded
+ green and red profit bars, the two panels sharing one `SeriesChartController`
+ so holding either marks the same day on both.
+- **ROI** — a line and its fill split green and red exactly at a dashed zero
+ line.
+- **Flows per month** — two bar series stacked on each other, on a chart turned
+ on its side, with both axes named.
+- **Trades** — a scatter plot whose dots take their size, shape and colour from
+ the point, read out one dot at a time.
+- **Holdings** — a `PieChart` doughnut with a total in the hole; hovering a
+ slice pushes it out and names it.
+- **Two strategies scored** — a `RadarChart` over five measures, reporting the
+ corner under the pointer.
+- **Orders by hour and weekday** — a `HeatmapChart` with a legend under it; the
+ card title reads out the square under the pointer.
+
+All nine follow the palette switch in the app bar.
+
## The intraday demo
A second entry point, for the other kind of chart: one session drawn as an
diff --git a/example/assets/watermark.svg b/example/assets/watermark.svg
deleted file mode 100644
index e7b28db..0000000
--- a/example/assets/watermark.svg
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
-
-
-
-
-
-
- OHLCV
-
-
diff --git a/example/lib/intraday_demo.dart b/example/lib/intraday_demo.dart
index e52d2c0..139e2ac 100644
--- a/example/lib/intraday_demo.dart
+++ b/example/lib/intraday_demo.dart
@@ -71,7 +71,6 @@ class _IntradayDemoState extends State {
_session,
ChartColors(),
isTrendLine: false,
- watermarkAssetPath: 'assets/none.svg',
timeFrame: const Duration(minutes: 5),
chartType: ChartType.area,
diff --git a/example/lib/main.dart b/example/lib/main.dart
index 23edaec..cb4aa6f 100644
--- a/example/lib/main.dart
+++ b/example/lib/main.dart
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'src/chart_page.dart';
import 'src/demo_state.dart';
import 'src/depth_page.dart';
+import 'src/series_page.dart';
void main() => runApp(const ExampleApp());
@@ -57,7 +58,7 @@ class _Home extends StatefulWidget {
}
class _HomeState extends State<_Home> with SingleTickerProviderStateMixin {
- late final TabController _tabs = TabController(length: 2, vsync: this);
+ late final TabController _tabs = TabController(length: 3, vsync: this);
@override
void dispose() {
@@ -88,6 +89,7 @@ class _HomeState extends State<_Home> with SingleTickerProviderStateMixin {
tabs: const [
Tab(icon: Icon(Icons.candlestick_chart_outlined), text: 'Candles'),
Tab(icon: Icon(Icons.area_chart_outlined), text: 'Depth'),
+ Tab(icon: Icon(Icons.show_chart), text: 'Series'),
],
),
),
@@ -96,6 +98,7 @@ class _HomeState extends State<_Home> with SingleTickerProviderStateMixin {
children: [
ChartPage(state: widget.state),
DepthPage(state: widget.state),
+ SeriesPage(state: widget.state),
],
),
);
diff --git a/example/lib/src/chart_page.dart b/example/lib/src/chart_page.dart
index 340e40a..9833257 100644
--- a/example/lib/src/chart_page.dart
+++ b/example/lib/src/chart_page.dart
@@ -87,7 +87,9 @@ class _Chart extends StatelessWidget {
// Drawing tools stay enabled so a line can be selected and restyled at
// any time; `tool` decides what the next tap or drag places.
isTrendLine: true,
- watermarkAssetPath: 'assets/watermark.svg',
+ watermark: const FittedBox(
+ child: Text('OHLCV', style: TextStyle(fontWeight: FontWeight.w900)),
+ ),
timeFrame: MarketData.timeFrame,
chartStyle: state.style,
lockPriceScale: state.lockPriceScale,
diff --git a/example/lib/src/market_data.dart b/example/lib/src/market_data.dart
index e04751e..216c54d 100644
--- a/example/lib/src/market_data.dart
+++ b/example/lib/src/market_data.dart
@@ -98,10 +98,17 @@ class MarketData {
}
return [
- ?at(over.length - 180, ChartEventKind.earnings, 'Q3 — beat by a cent'),
- ?at(over.length - 120, ChartEventKind.dividend, r'$0.24 going ex'),
- ?at(over.length - 70, ChartEventKind.split, '4-for-1'),
- ?at(over.length - 30, ChartEventKind.news, 'Listed on another venue'),
+ if (at(over.length - 180, ChartEventKind.earnings, 'Q3 — beat by a cent')
+ case final v?)
+ v,
+ if (at(over.length - 120, ChartEventKind.dividend, r'$0.24 going ex')
+ case final v?)
+ v,
+ if (at(over.length - 70, ChartEventKind.split, '4-for-1') case final v?)
+ v,
+ if (at(over.length - 30, ChartEventKind.news, 'Listed on another venue')
+ case final v?)
+ v,
];
}
diff --git a/example/lib/src/series_page.dart b/example/lib/src/series_page.dart
new file mode 100644
index 0000000..3a1cae9
--- /dev/null
+++ b/example/lib/src/series_page.dart
@@ -0,0 +1,551 @@
+import 'dart:math' as math;
+
+import 'package:flutter/material.dart';
+import 'package:ohlcv_chart/ohlcv_chart.dart';
+
+import 'demo_state.dart';
+
+/// Business charts drawn with `SeriesChart`: a sparkline, several series over
+/// a window of long data, two panels sharing one crosshair, and a line split
+/// at zero.
+class SeriesPage extends StatefulWidget {
+ /// Creates the series demo over [state].
+ const SeriesPage({required this.state, super.key});
+
+ /// The demo's settings, shared with the other pages.
+ final DemoState state;
+
+ @override
+ State createState() => _SeriesPageState();
+}
+
+class _SeriesPageState extends State {
+ static const _days = 120;
+ static const _purple = Color(0xFF9775FA);
+ static const _blue = Color(0xFF4DABF7);
+ static const _amber = Color(0xFFFAB005);
+ static const _green = Color(0xFF12B886);
+ static const _red = Color(0xFFFA5252);
+ static const _months = [
+ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', //
+ 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
+ ];
+
+ final SeriesChartController _crosshair = SeriesChartController();
+ SeriesWindow _window = const SeriesWindow(_days - 30, _days - 1);
+ int? _sparkIndex;
+
+ late final List _dates = [
+ for (var i = 0; i < _days; i++) DateTime(2026).add(Duration(days: i)),
+ ];
+ late final List _monthLabels = [
+ for (final d in _dates) d.day == 1 ? _months[d.month - 1] : '',
+ ];
+ late final List _deposits = _walk(1800, 0.2, 0);
+ late final List _withdrawals = _walk(1100, 0.3, 2);
+ late final List _balance = _walk(12000, 0.08, 1);
+ late final List _average = [
+ for (var i = 0; i < _days; i++)
+ _balance.sublist(math.max(0, i - 9), i + 1).reduce((a, b) => a + b) /
+ (i - math.max(0, i - 9) + 1),
+ ];
+ late final List _profit = [
+ 0,
+ for (var i = 1; i < _days; i++) _balance[i] - _balance[i - 1],
+ ];
+ late final List _roi = [
+ for (var i = 0; i < 40; i++) math.sin(i / 5) * 12 + i * 0.25 - 4,
+ ];
+
+ static List _walk(double base, double swing, double phase) => [
+ for (var i = 0; i < _days; i++)
+ base +
+ math.sin(i / 9 + phase) * base * swing +
+ math.cos(i / 2.5 + phase) * base * swing * 0.3,
+ ];
+
+ /// What each asset is worth, for the pie.
+ static const _holdings = [42.0, 26.0, 18.0, 14.0];
+ static const _holdingNames = ['BTC', 'ETH', 'SOL', 'Cash'];
+
+ /// Two strategies scored over the same five measures, for the radar.
+ static const _scores = [
+ [4.4, 3.1, 4.8, 2.2, 3.6],
+ [3.0, 4.7, 2.4, 4.1, 4.4],
+ ];
+ static const _measures = ['Return', 'Sharpe', 'Win rate', 'Cost', 'Drawdown'];
+
+ int? _slice;
+ String? _corner;
+ HeatmapTouchDetails? _square;
+
+ /// Orders per weekday and hour of the day, for the heatmap.
+ static final _byHourAndDay = [
+ for (var hour = 0; hour < 12; hour++)
+ [
+ for (var day = 0; day < 7; day++)
+ hour < 2 && day > 4
+ ? null
+ : (math.sin(hour / 2.4) + 1.4) *
+ (math.cos(day / 2.1 + 1) + 1.6) *
+ 14 +
+ (hour * day % 5) * 2.0,
+ ],
+ ];
+ static const _weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
+
+ static String _usd(double v) =>
+ '\$${v.abs() >= 1000 ? '${(v / 1000).toStringAsFixed(1)}k' : v.toStringAsFixed(0)}';
+
+ String _date(int index) {
+ final d = _dates[index.clamp(0, _days - 1)];
+ return '${_months[d.month - 1]} ${d.day}';
+ }
+
+ @override
+ void dispose() {
+ _crosshair.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return AnimatedBuilder(
+ animation: widget.state,
+ builder: (context, _) {
+ final dark = widget.state.dark;
+ final grid = dark ? const Color(0xFF2C2E33) : const Color(0xFFDEE2E6);
+ final paper = dark ? const Color(0xFF16191F) : Colors.white;
+ final tooltip = SeriesTooltip(
+ backgroundColor: dark ? const Color(0xFF25282E) : Colors.white,
+ borderColor: grid,
+ valueStyle: TextStyle(color: dark ? Colors.white : Colors.black87),
+ titleStyle: TextStyle(color: dark ? Colors.white60 : Colors.black54),
+ valueFormatter: (v) => _usd(v.value),
+ title: (d) => _date(d.index),
+ );
+
+ return ListView(
+ padding: const EdgeInsets.all(16),
+ children: [
+ _card(context, paper, grid, 'Balance sparkline — press and drag', [
+ SizedBox(
+ height: 58,
+ child: SeriesChart(
+ series: [
+ LineSeries.values(
+ _balance,
+ color: _purple,
+ width: _sparkIndex == null ? 1 : 2,
+ curve: LineCurve.monotone,
+ fill: SeriesFill.fade(_purple, opacity: 0.3),
+ dotBuilder: _sparkIndex == null
+ ? null
+ : (i, _) => i == _sparkIndex
+ ? const SeriesDot(radius: 3)
+ : null,
+ ),
+ ],
+ xAxis: SeriesXAxis.hidden,
+ yAxis: SeriesYAxis.hidden,
+ grid: SeriesGrid.none,
+ touch: SeriesTouch(
+ line: null,
+ showMarkers: false,
+ tooltip: SeriesTooltip(
+ placement: SeriesTooltipPlacement.above,
+ backgroundColor: tooltip.backgroundColor,
+ borderColor: grid,
+ valueStyle: tooltip.valueStyle,
+ titleStyle: tooltip.titleStyle,
+ valueFormatter: tooltip.valueFormatter,
+ title: tooltip.title,
+ ),
+ ),
+ onTouch: (d) => setState(() => _sparkIndex = d?.index),
+ animationDuration: const Duration(milliseconds: 600),
+ ),
+ ),
+ ]),
+ _card(
+ context,
+ paper,
+ grid,
+ 'Deposits and withdrawals — long-press',
+ [
+ SizedBox(
+ height: 220,
+ child: SeriesChart(
+ series: [
+ LineSeries.values(
+ _deposits,
+ label: 'Deposits',
+ color: _blue,
+ fill: SeriesFill.fade(_blue),
+ ),
+ LineSeries.values(
+ _withdrawals,
+ label: 'Withdrawals',
+ color: _amber,
+ fill: SeriesFill.fade(_amber),
+ ),
+ ],
+ minX: _window.start - 0.5,
+ maxX: _window.end + 0.5,
+ xAxis: SeriesXAxis(labels: _labelsFor(_window)),
+ yAxis: SeriesYAxis(width: 40, formatter: _usd),
+ grid: SeriesGrid(color: grid, dashPattern: const [4, 4]),
+ border: BorderSide(color: grid),
+ touch: SeriesTouch(
+ trigger: SeriesTouchTrigger.longPress,
+ tooltip: tooltip,
+ ),
+ ),
+ ),
+ const SizedBox(height: 12),
+ Padding(
+ padding: const EdgeInsets.only(left: 40),
+ child: SeriesRangeSelector(
+ series: [
+ LineSeries.values(_deposits, color: _blue, width: 1),
+ LineSeries.values(_withdrawals, color: _amber, width: 1),
+ ],
+ window: _window,
+ minSpan: 4,
+ border: BorderSide(color: grid),
+ maskColor: paper.withValues(alpha: 0.6),
+ handleColor: paper,
+ onChanged: (next) => setState(() => _window = next),
+ ),
+ ),
+ ],
+ ),
+ _card(context, paper, grid, 'Balance and profit — one crosshair', [
+ SizedBox(
+ height: 180,
+ child: SeriesChart(
+ series: [
+ LineSeries.values(
+ _balance,
+ label: 'Balance',
+ color: _purple,
+ fill: SeriesFill.fade(_purple, opacity: 0.14),
+ ),
+ LineSeries.values(
+ _average,
+ label: 'Average',
+ color: _amber,
+ dashPattern: const [6, 4],
+ ),
+ ],
+ xPadding: 0.5,
+ xAxis: SeriesXAxis.hidden,
+ yAxis: SeriesYAxis(width: 40, formatter: _usd),
+ grid: SeriesGrid(color: grid, dashPattern: const [4, 4]),
+ border: BorderSide(color: grid),
+ controller: _crosshair,
+ touch: SeriesTouch(
+ trigger: SeriesTouchTrigger.longPress,
+ tooltip: SeriesTooltip(
+ builder: (context, d) => DecoratedBox(
+ decoration: BoxDecoration(
+ color: tooltip.backgroundColor,
+ border: Border.all(color: grid),
+ borderRadius: BorderRadius.circular(8),
+ ),
+ child: Padding(
+ padding: const EdgeInsets.all(8),
+ child: Text(
+ '${_date(d.index)}\n'
+ 'Balance ${_usd(_balance[d.index])}\n'
+ 'P/L ${_usd(_profit[d.index])}',
+ style: tooltip.valueStyle,
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ SizedBox(
+ height: 96,
+ child: SeriesChart(
+ series: [
+ BarSeries.values(
+ _profit,
+ color: _green,
+ negativeColor: _red,
+ radius: 3,
+ minWidth: 1,
+ maxWidth: 12,
+ ),
+ ],
+ xAxis: SeriesXAxis(labels: _monthLabels),
+ yAxis: SeriesYAxis(width: 40, formatter: _usd, tickCount: 3),
+ grid: SeriesGrid(
+ vertical: false,
+ color: grid,
+ dashPattern: const [4, 4],
+ ),
+ border: BorderSide(color: grid),
+ controller: _crosshair,
+ touch: const SeriesTouch(
+ trigger: SeriesTouchTrigger.longPress,
+ tooltip: null,
+ ),
+ animationDuration: const Duration(milliseconds: 450),
+ ),
+ ),
+ ]),
+ _card(context, paper, grid, 'ROI — split at zero', [
+ SizedBox(
+ height: 80,
+ child: SeriesChart(
+ series: [
+ LineSeries.values(
+ _roi,
+ color: _green,
+ negativeColor: _red,
+ width: 1.5,
+ curve: LineCurve.monotone,
+ fill: SeriesFill.fade(_green, negativeColor: _red),
+ ),
+ ],
+ includeZero: true,
+ xAxis: SeriesXAxis.hidden,
+ yAxis: SeriesYAxis.hidden,
+ grid: SeriesGrid.none,
+ referenceLines: [
+ SeriesReferenceLine.horizontal(
+ 0,
+ color: grid,
+ dashPattern: const [3, 3],
+ ),
+ ],
+ touch: SeriesTouch(
+ markerBuilder: (v) => SeriesDot(radius: 3, color: v.color),
+ tooltip: SeriesTooltip(
+ placement: SeriesTooltipPlacement.above,
+ backgroundColor: tooltip.backgroundColor,
+ borderColor: grid,
+ valueStyle: tooltip.valueStyle,
+ valueFormatter: (v) =>
+ '${v.value >= 0 ? '+' : ''}${v.value.toStringAsFixed(2)}%',
+ ),
+ ),
+ animationDuration: const Duration(milliseconds: 300),
+ ),
+ ),
+ ]),
+ _card(context, paper, grid, 'Flows per month, stacked and turned', [
+ SizedBox(
+ height: 200,
+ child: SeriesChart(
+ orientation: SeriesOrientation.horizontal,
+ series: [
+ BarSeries.values(
+ const [3.2, 4.1, 2.6, 5.4, 4.8, 6.1],
+ color: _green,
+ stack: 'flow',
+ label: 'In',
+ ),
+ BarSeries.values(
+ const [1.4, 2.2, 3.1, 1.8, 2.6, 2.1],
+ color: _red,
+ stack: 'flow',
+ radius: 3,
+ label: 'Out',
+ ),
+ ],
+ xAxis: SeriesXAxis(
+ labels: _months.take(6).toList(),
+ title: 'Month',
+ ),
+ yAxis: SeriesYAxis(
+ formatter: (v) => '${v.toStringAsFixed(0)}k',
+ title: 'Flow',
+ ),
+ grid: SeriesGrid(color: grid, dashPattern: const [4, 4]),
+ animationDuration: const Duration(milliseconds: 400),
+ ),
+ ),
+ ]),
+ _card(context, paper, grid, 'Trades: size against return', [
+ SizedBox(
+ height: 220,
+ child: SeriesChart(
+ series: [
+ ScatterSeries(
+ points: [
+ for (var i = 0; i < 40; i++)
+ SeriesPoint(
+ 1 + (i * 7 % 23).toDouble(),
+ math.sin(i / 3) * 8 + math.cos(i / 1.7) * 4,
+ ),
+ ],
+ dotBuilder: (index, point) => SeriesDot(
+ radius: 3 + (point.x / 12),
+ shape: point.y! < 0
+ ? SeriesDotShape.cross
+ : SeriesDotShape.circle,
+ color: point.y! < 0 ? _red : _green,
+ ),
+ ),
+ ],
+ xAxis: SeriesXAxis(
+ tickCount: 5,
+ title: 'Size',
+ labelBuilder: (v) => v.toStringAsFixed(0),
+ ),
+ yAxis: SeriesYAxis(
+ title: 'Return',
+ formatter: (v) => '${v.toStringAsFixed(0)}%',
+ ),
+ grid: SeriesGrid(color: grid),
+ referenceLines: [
+ SeriesReferenceLine.horizontal(0, color: grid, width: 1),
+ ],
+ touch: const SeriesTouch(
+ snap: SeriesTouchSnap.nearestPoint,
+ tooltip: SeriesTooltip(),
+ ),
+ ),
+ ),
+ ]),
+ _card(context, paper, grid, 'Holdings — hover a slice', [
+ SizedBox(
+ height: 220,
+ child: PieChart(
+ sections: [
+ for (var i = 0; i < _holdings.length; i++)
+ PieSection(
+ value: _holdings[i],
+ color: [_purple, _blue, _amber, _green][i],
+ label: '${_holdings[i].toStringAsFixed(0)}%',
+ offset: i == _slice ? 6 : 0,
+ ),
+ ],
+ centerSpaceRadius: 46,
+ centerSpaceColor: paper,
+ centerChild: Text(
+ _slice == null ? 'Holdings' : _holdingNames[_slice!],
+ style: Theme.of(context).textTheme.titleSmall,
+ ),
+ sectionsSpace: 2,
+ onTouch: (d) => setState(() => _slice = d?.index),
+ animationDuration: const Duration(milliseconds: 600),
+ ),
+ ),
+ ]),
+ _card(
+ context,
+ paper,
+ grid,
+ _square == null
+ ? 'Orders by hour and weekday'
+ : '${_weekdays[_square!.x]} '
+ '${(_square!.y + 9).toString().padLeft(2, '0')}:00 — '
+ '${_square!.value?.round() ?? 0} orders',
+ [
+ SizedBox(
+ height: 280,
+ child: HeatmapChart.matrix(
+ _byHourAndDay,
+ scale: HeatmapGradientScale.of(_blue),
+ xAxis: const HeatmapAxis(labels: _weekdays),
+ yAxis: HeatmapAxis(
+ size: 36,
+ labelBuilder: (index) =>
+ '${(index + 9).toString().padLeft(2, '0')}:00',
+ ),
+ spacing: 3,
+ radius: 3,
+ onTouch: (d) => setState(() => _square = d),
+ animationDuration: const Duration(milliseconds: 500),
+ ),
+ ),
+ const SizedBox(height: 10),
+ HeatmapLegend(
+ scale: HeatmapGradientScale.of(_blue),
+ low: 'Quiet',
+ high: 'Busy',
+ width: 120,
+ ),
+ ],
+ ),
+ _card(
+ context,
+ paper,
+ grid,
+ _corner == null ? 'Two strategies scored' : 'Scored: $_corner',
+ [
+ SizedBox(
+ height: 260,
+ child: RadarChart(
+ features: _measures,
+ series: [
+ RadarSeries(
+ values: _scores[0],
+ color: _blue,
+ label: 'Momentum',
+ dot: const SeriesDot(radius: 2.5),
+ ),
+ RadarSeries(
+ values: _scores[1],
+ color: _amber,
+ label: 'Mean reversion',
+ dot: const SeriesDot(radius: 2.5),
+ ),
+ ],
+ maxValue: 5,
+ gridColor: grid,
+ spokeColor: grid,
+ showTicks: true,
+ onTouch: (d) => setState(
+ () => _corner = d == null
+ ? null
+ : '${_measures[d.featureIndex]} '
+ '${d.value.toStringAsFixed(1)}',
+ ),
+ animationDuration: const Duration(milliseconds: 600),
+ ),
+ ),
+ ],
+ ),
+ ],
+ );
+ },
+ );
+ }
+
+ /// Month names inside a window; on a short window, every fifth day instead.
+ List _labelsFor(SeriesWindow window) {
+ if (window.span > 45) return _monthLabels;
+ return [for (var i = 0; i < _days; i++) i % 5 == 0 ? _date(i) : ''];
+ }
+
+ Widget _card(
+ BuildContext context,
+ Color paper,
+ Color border,
+ String title,
+ List children,
+ ) =>
+ Container(
+ margin: const EdgeInsets.only(bottom: 16),
+ padding: const EdgeInsets.all(16),
+ decoration: BoxDecoration(
+ color: paper,
+ border: Border.all(color: border),
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ Text(title, style: Theme.of(context).textTheme.titleSmall),
+ const SizedBox(height: 12),
+ ...children,
+ ],
+ ),
+ );
+}
diff --git a/example/pubspec.yaml b/example/pubspec.yaml
index 2d3ddd3..15b214c 100644
--- a/example/pubspec.yaml
+++ b/example/pubspec.yaml
@@ -4,8 +4,8 @@ publish_to: 'none'
version: 1.0.0
environment:
- sdk: ^3.13.0
- flutter: ">=3.47.0"
+ sdk: ">=3.6.0 <4.0.0"
+ flutter: ">=3.27.0"
dependencies:
flutter:
@@ -14,12 +14,10 @@ dependencies:
path: ../
dev_dependencies:
- flutter_lints: ^6.0.0
- image: ^4.5.4
+ flutter_lints: ">=5.0.0 <7.0.0"
+ image: ">=4.0.0 <5.0.0"
flutter_test:
sdk: flutter
flutter:
uses-material-design: true
- assets:
- - assets/watermark.svg
diff --git a/example/test/series_page_test.dart b/example/test/series_page_test.dart
new file mode 100644
index 0000000..493f9ff
--- /dev/null
+++ b/example/test/series_page_test.dart
@@ -0,0 +1,32 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:ohlcv_chart/ohlcv_chart.dart';
+import 'package:ohlcv_chart_example/src/demo_state.dart';
+import 'package:ohlcv_chart_example/src/series_page.dart';
+
+void main() {
+ testWidgets('the series demo builds at both layouts and themes', (
+ tester,
+ ) async {
+ final state = DemoState();
+ addTearDown(state.dispose);
+
+ for (final size in [const Size(1280, 900), const Size(420, 900)]) {
+ for (final dark in [false, true]) {
+ tester.view.physicalSize = size;
+ tester.view.devicePixelRatio = 1;
+ addTearDown(tester.view.reset);
+ state.update(() => state.dark = dark);
+
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Scaffold(body: SeriesPage(state: state)),
+ ),
+ );
+ await tester.pumpAndSettle();
+ expect(tester.takeException(), isNull, reason: '$size dark: $dark');
+ expect(find.byType(SeriesChart), findsWidgets);
+ }
+ }
+ });
+}
diff --git a/example/test/widget_test.dart b/example/test/widget_test.dart
index 2d8ff7d..3e0dea3 100644
--- a/example/test/widget_test.dart
+++ b/example/test/widget_test.dart
@@ -71,16 +71,22 @@ void main() {
addTearDown(state.dispose);
await tester.pumpWidget(panel(state));
- Future addAtr() async {
- // The panel's button, not the sheet's title, which reads the same.
- await tester.tap(find.text('Add indicator').first);
+ // The panel has outgrown the box and the sheet can be taller than the
+ // screen, so each control is scrolled into view before it is tapped.
+ Future tapInView(Finder finder) async {
+ await tester.ensureVisible(finder);
await tester.pumpAndSettle();
- await tester.tap(find.widgetWithText(ChoiceChip, 'ATR'));
- await tester.pumpAndSettle();
- await tester.tap(find.widgetWithText(FilledButton, 'Add'));
+ await tester.tap(finder);
await tester.pumpAndSettle();
}
+ Future addAtr() async {
+ // The panel's button, not the sheet's title, which reads the same.
+ await tapInView(find.text('Add indicator').first);
+ await tapInView(find.widgetWithText(ChoiceChip, 'ATR'));
+ await tapInView(find.widgetWithText(FilledButton, 'Add'));
+ }
+
await addAtr();
expect(find.widgetWithText(InputChip, 'ATR(14)'), findsOneWidget);
diff --git a/example/tool/screenshots.dart b/example/tool/screenshots.dart
index 6f68022..35253ce 100644
--- a/example/tool/screenshots.dart
+++ b/example/tool/screenshots.dart
@@ -163,66 +163,66 @@ class ReplayBar extends StatelessWidget {
@override
Widget build(BuildContext context) => AnimatedBuilder(
- animation: replay,
- builder: (context, _) {
- final position = replay.position ?? replay.length;
- Widget button(IconData icon, {bool on = true}) => Padding(
- padding: const EdgeInsets.symmetric(horizontal: 4),
- child: Icon(
- icon,
- size: 20,
- color: on ? const Color(0xFFDCE3EB) : const Color(0xFF4A5361),
- ),
- );
- return ColoredBox(
- color: const Color(0xFF161B23),
- child: Padding(
- padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
- child: Row(
- children: [
- button(Icons.replay, on: true),
- button(Icons.skip_previous, on: replay.isActive),
- button(
- replay.isPlaying ? Icons.pause : Icons.play_arrow,
- on: true,
- ),
- button(Icons.skip_next, on: replay.isActive),
- button(Icons.stop, on: replay.isActive),
- const SizedBox(width: 12),
- Expanded(
- child: ClipRRect(
- borderRadius: BorderRadius.circular(3),
- child: LinearProgressIndicator(
- value: replay.length == 0 ? 0 : position / replay.length,
- minHeight: 6,
- backgroundColor: const Color(0xFF2A313C),
- valueColor: const AlwaysStoppedAnimation(
- Color(0xFF26A69A),
+ animation: replay,
+ builder: (context, _) {
+ final position = replay.position ?? replay.length;
+ Widget button(IconData icon, {bool on = true}) => Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 4),
+ child: Icon(
+ icon,
+ size: 20,
+ color: on ? const Color(0xFFDCE3EB) : const Color(0xFF4A5361),
+ ),
+ );
+ return ColoredBox(
+ color: const Color(0xFF161B23),
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
+ child: Row(
+ children: [
+ button(Icons.replay, on: true),
+ button(Icons.skip_previous, on: replay.isActive),
+ button(
+ replay.isPlaying ? Icons.pause : Icons.play_arrow,
+ on: true,
+ ),
+ button(Icons.skip_next, on: replay.isActive),
+ button(Icons.stop, on: replay.isActive),
+ const SizedBox(width: 12),
+ Expanded(
+ child: ClipRRect(
+ borderRadius: BorderRadius.circular(3),
+ child: LinearProgressIndicator(
+ value:
+ replay.length == 0 ? 0 : position / replay.length,
+ minHeight: 6,
+ backgroundColor: const Color(0xFF2A313C),
+ valueColor:
+ const AlwaysStoppedAnimation(Color(0xFF26A69A)),
+ ),
),
),
- ),
- ),
- const SizedBox(width: 12),
- SizedBox(
- width: 148,
- child: Text(
- replay.isActive
- ? 'Candle $position of ${replay.length}'
- : 'Live — all ${replay.length} candles',
- textAlign: TextAlign.right,
- style: const TextStyle(
- color: Color(0xFFB6C0CC),
- fontSize: 12,
- fontFeatures: [FontFeature.tabularFigures()],
+ const SizedBox(width: 12),
+ SizedBox(
+ width: 148,
+ child: Text(
+ replay.isActive
+ ? 'Candle $position of ${replay.length}'
+ : 'Live — all ${replay.length} candles',
+ textAlign: TextAlign.right,
+ style: const TextStyle(
+ color: Color(0xFFB6C0CC),
+ fontSize: 12,
+ fontFeatures: [FontFeature.tabularFigures()],
+ ),
+ ),
),
- ),
+ ],
),
- ],
- ),
- ),
+ ),
+ );
+ },
);
- },
- );
}
void main() {
@@ -250,15 +250,15 @@ class _ScreenshotAppState extends State {
late final List _films = _asked(buildFilms(), (f) => f.name);
/// The entries [requestedScenes] names, or all of them when it names none.
- List _asked(List all, String Function(T) name) =>
- _names.isEmpty ? all : all.where((e) => _names.contains(name(e))).toList();
+ List _asked(List all, String Function(T) name) => _names.isEmpty
+ ? all
+ : all.where((e) => _names.contains(name(e))).toList();
/// Where in the run we are: scenes first, then films.
int _index = 0;
/// What is on screen right now, whichever list it came from.
- ({Size size, Widget Function() build}) get _showing =>
- _index < _scenes.length
+ ({Size size, Widget Function() build}) get _showing => _index < _scenes.length
? (size: _scenes[_index].size, build: _scenes[_index].build)
: (
size: _films[_index - _scenes.length].size,
@@ -408,11 +408,9 @@ class _ScreenshotAppState extends State {
// A frame identical to the one before it is the last scene over again, not
// this one: ask for another, and give up loudly rather than write it.
var bytes = await _raster(boundary);
- for (
- var attempt = 0;
- _digest(bytes) == _previous && attempt < 4;
- attempt++
- ) {
+ for (var attempt = 0;
+ _digest(bytes) == _previous && attempt < 4;
+ attempt++) {
await Future.delayed(const Duration(milliseconds: 500));
bytes = await _raster(boundary);
}
@@ -462,6 +460,9 @@ class _ScreenshotAppState extends State {
/// A wide scene, for the README.
const Size wide = Size(1180, 660);
+/// A single row of panels, for the charts that do not need the height.
+const Size shortWide = Size(1180, 400);
+
/// A phone-shaped scene, for the settings sheet.
const Size portrait = Size(392, 700);
@@ -544,13 +545,14 @@ List buildScenes() {
showInfoDialog: showInfoDialog,
showScrollToNowButton: false,
drawings: drawings,
- isTrendLine:
- trendLines.isNotEmpty ||
+ isTrendLine: trendLines.isNotEmpty ||
horizontalLines.isNotEmpty ||
rectangles.isNotEmpty ||
fibRetracements.isNotEmpty ||
drawings.isNotEmpty,
- watermarkAssetPath: 'assets/watermark.svg',
+ watermark: const FittedBox(
+ child: Text('OHLCV', style: TextStyle(fontWeight: FontWeight.w900)),
+ ),
timeFrame: MarketData.timeFrame,
timeFormat: TimeFormat.YEAR_MONTH_DAY_WITH_HOUR,
indicators: indicators,
@@ -606,37 +608,39 @@ List buildScenes() {
Widget child, {
bool centred = false,
double top = 8,
- }) => Stack(
- children: [
- Positioned.fill(child: child),
- Positioned(
- left: centred ? 0 : 10,
- right: centred ? 0 : null,
- top: top,
- // On a chip, because the chart draws its own labels in the same corner.
- child: Align(
- alignment: centred ? Alignment.topCenter : Alignment.topLeft,
- child: DecoratedBox(
- decoration: BoxDecoration(
- color: const Color(0xFF10141A).withValues(alpha: 0.82),
- borderRadius: BorderRadius.circular(4),
- ),
- child: Padding(
- padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
- child: Text(
- text,
- style: const TextStyle(
- color: Color(0xFFB6C0CC),
- fontSize: 12,
- fontWeight: FontWeight.w600,
+ }) =>
+ Stack(
+ children: [
+ Positioned.fill(child: child),
+ Positioned(
+ left: centred ? 0 : 10,
+ right: centred ? 0 : null,
+ top: top,
+ // On a chip, because the chart draws its own labels in the same corner.
+ child: Align(
+ alignment: centred ? Alignment.topCenter : Alignment.topLeft,
+ child: DecoratedBox(
+ decoration: BoxDecoration(
+ color: const Color(0xFF10141A).withValues(alpha: 0.82),
+ borderRadius: BorderRadius.circular(4),
+ ),
+ child: Padding(
+ padding:
+ const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
+ child: Text(
+ text,
+ style: const TextStyle(
+ color: Color(0xFFB6C0CC),
+ fontSize: 12,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
),
),
),
),
- ),
- ),
- ],
- );
+ ],
+ );
/// The chart with the overview strip beneath it.
Widget overviewScene() {
@@ -707,54 +711,54 @@ List buildScenes() {
size: wide,
act: null,
build: () => chart(
- indicators: [
- MaIndicator(period: 5),
- MaIndicator(period: 10),
- MaIndicator(period: 20),
- MacdIndicator(),
- ],
- signals: [
- SignalEntity(
- title: 'TP',
- price: last * 1.05,
- color: const Color(0xFF26A69A),
- ),
- SignalEntity(
- title: 'SL',
- price: last * 0.96,
- color: const Color(0xFFEF5350),
- useDash: true,
+ indicators: [
+ MaIndicator(period: 5),
+ MaIndicator(period: 10),
+ MaIndicator(period: 20),
+ MacdIndicator(),
+ ],
+ signals: [
+ SignalEntity(
+ title: 'TP',
+ price: last * 1.05,
+ color: const Color(0xFF26A69A),
+ ),
+ SignalEntity(
+ title: 'SL',
+ price: last * 0.96,
+ color: const Color(0xFFEF5350),
+ useDash: true,
+ ),
+ ],
),
- ],
- ),
),
(
name: 'overlays',
size: wide,
act: null,
build: () => chart(
- volHidden: true,
- indicators: [
- IchimokuIndicator(),
- SupertrendIndicator(),
- StochRsiIndicator(),
- AwesomeIndicator(),
- ],
- ),
+ volHidden: true,
+ indicators: [
+ IchimokuIndicator(),
+ SupertrendIndicator(),
+ StochRsiIndicator(),
+ AwesomeIndicator(),
+ ],
+ ),
),
(
name: 'swings',
size: wide,
act: null,
build: () => chart(
- volHidden: true,
- indicators: [
- ZigZagIndicator(),
- FibonacciIndicator(),
- ElliottWaveIndicator(),
- RsiIndicator(),
- ],
- ),
+ volHidden: true,
+ indicators: [
+ ZigZagIndicator(),
+ FibonacciIndicator(),
+ ElliottWaveIndicator(),
+ RsiIndicator(),
+ ],
+ ),
),
(
// A four-hour average over fifteen-minute candles: it steps once a bar
@@ -763,42 +767,42 @@ List buildScenes() {
size: wide,
act: null,
build: () => chart(
- volHidden: true,
- data: longRun,
- indicators: [
- // Both cover about twenty hours, so they sit in the same stretch of
- // price and the only difference on show is the stepping. A 4h MA20
- // would reach back eighty hours and drag the whole axis down with it.
- MaIndicator(period: 80),
- TimeframeIndicator(
- timeframe: const Duration(hours: 4),
- applied: MaIndicator(period: 5),
- // A colour of its own, so the stepped line and the smooth one are
- // told apart at a glance rather than by their shape.
- colors: const [Color(0xFF4DABF7)],
- ),
- TimeframeIndicator(
- timeframe: const Duration(hours: 4),
- applied: RsiIndicator(period: 14),
+ volHidden: true,
+ data: longRun,
+ indicators: [
+ // Both cover about twenty hours, so they sit in the same stretch of
+ // price and the only difference on show is the stepping. A 4h MA20
+ // would reach back eighty hours and drag the whole axis down with it.
+ MaIndicator(period: 80),
+ TimeframeIndicator(
+ timeframe: const Duration(hours: 4),
+ applied: MaIndicator(period: 5),
+ // A colour of its own, so the stepped line and the smooth one are
+ // told apart at a glance rather than by their shape.
+ colors: const [Color(0xFF4DABF7)],
+ ),
+ TimeframeIndicator(
+ timeframe: const Duration(hours: 4),
+ applied: RsiIndicator(period: 14),
+ ),
+ ],
),
- ],
- ),
),
(
name: 'session-vwap',
size: wide,
act: null,
build: () => chart(
- volHidden: true,
- data: longRun,
- sessionDividers: true,
- indicators: [
- // One deviation rather than two: the band still says where the
- // session has been trading without stretching the axis to fit it.
- SessionVwapIndicator(),
- AroonIndicator(period: 14),
- ],
- ),
+ volHidden: true,
+ data: longRun,
+ sessionDividers: true,
+ indicators: [
+ // One deviation rather than two: the band still says where the
+ // session has been trading without stretching the axis to fit it.
+ SessionVwapIndicator(),
+ AroonIndicator(period: 14),
+ ],
+ ),
),
(
name: 'overview',
@@ -818,9 +822,10 @@ List buildScenes() {
name: 'linked-charts',
size: wide,
act: (area, shoot) => hold(
- Offset(area.left + area.width * 0.62, area.top + area.height * 0.24),
- shoot,
- ),
+ Offset(
+ area.left + area.width * 0.62, area.top + area.height * 0.24),
+ shoot,
+ ),
build: () => linkedScene(),
),
(
@@ -828,117 +833,117 @@ List buildScenes() {
size: wide,
act: null,
build: () => chart(
- light: true,
- volHidden: true,
- indicators: [
- BollIndicator(),
- AtrIndicator(period: 8),
- AtrIndicator(period: 14),
- AtrIndicator(period: 20),
- ],
- ),
+ light: true,
+ volHidden: true,
+ indicators: [
+ BollIndicator(),
+ AtrIndicator(period: 8),
+ AtrIndicator(period: 14),
+ AtrIndicator(period: 20),
+ ],
+ ),
),
(
name: 'drawing',
size: wide,
act: null,
build: () => chart(
- volHidden: true,
- // The drawn lines label themselves on the left, so the price axis
- // moves over to the right rather than fighting them for the space.
- axis: VerticalTextAlignment.right,
- indicators: [EmaIndicator(period: 21)],
- horizontalLines: [
- HorizontalLine(
- price: last * 1.008,
- title: 'resistance',
- color: const Color(0xFFEF5350),
- style: LineStyle.dashed,
- showLabel: true,
- ),
- HorizontalLine(
- price: last * 0.975,
- title: 'entry',
- color: const Color(0xFF4DABF7),
- showLabel: true,
- ),
- ],
- trendLines: [
- TrendLine(
- time1: candles[candles.length - 90].dateTime!,
- price1: candles[candles.length - 90].low,
- time2: candles[candles.length - 20].dateTime!,
- price2: candles[candles.length - 20].high,
- color: const Color(0xFFF5C26B),
- label2: 'trend',
- showLabel: true,
+ volHidden: true,
+ // The drawn lines label themselves on the left, so the price axis
+ // moves over to the right rather than fighting them for the space.
+ axis: VerticalTextAlignment.right,
+ indicators: [EmaIndicator(period: 21)],
+ horizontalLines: [
+ HorizontalLine(
+ price: last * 1.008,
+ title: 'resistance',
+ color: const Color(0xFFEF5350),
+ style: LineStyle.dashed,
+ showLabel: true,
+ ),
+ HorizontalLine(
+ price: last * 0.975,
+ title: 'entry',
+ color: const Color(0xFF4DABF7),
+ showLabel: true,
+ ),
+ ],
+ trendLines: [
+ TrendLine(
+ time1: candles[candles.length - 90].dateTime!,
+ price1: candles[candles.length - 90].low,
+ time2: candles[candles.length - 20].dateTime!,
+ price2: candles[candles.length - 20].high,
+ color: const Color(0xFFF5C26B),
+ label2: 'trend',
+ showLabel: true,
+ ),
+ ],
),
- ],
- ),
),
(
name: 'shapes',
size: wide,
act: null,
build: () => chart(
- volHidden: true,
- axis: VerticalTextAlignment.right,
- indicators: [],
- horizontalLines: [
- HorizontalLine(
- price: candles[candles.length - 60].high,
- startTime: candles[candles.length - 60].dateTime,
- title: 'broken',
- color: const Color(0xFFEF5350),
- style: LineStyle.dashed,
- showLabel: true,
- ),
- ],
- trendLines: [
- TrendLine(
- time1: candles[candles.length - 150].dateTime!,
- price1: candles[candles.length - 150].low,
- time2: candles[candles.length - 110].dateTime!,
- price2: candles[candles.length - 110].high,
- extend: LineExtension.right,
- color: const Color(0xFFF5C26B),
- ),
- TrendLine(
- time1: candles[candles.length - 95].dateTime!,
- price1: candles[candles.length - 95].high,
- time2: candles[candles.length - 75].dateTime!,
- price2: candles[candles.length - 75].low,
- arrow: true,
- color: const Color(0xFFB197FC),
- ),
- ],
- rectangles: [
- RectangleDrawing(
- time1: candles[candles.length - 58].dateTime!,
- // The box covers the whole range the market held over that span,
- // which is what a range box is for.
- price1: candles
- .sublist(candles.length - 58, candles.length - 38)
- .map((candle) => candle.high)
- .reduce(max),
- time2: candles[candles.length - 38].dateTime!,
- price2: candles
- .sublist(candles.length - 58, candles.length - 38)
- .map((candle) => candle.low)
- .reduce(min),
- label: 'range',
- showLabel: true,
- ),
- ],
- fibRetracements: [
- FibRetracement(
- time1: candles[candles.length - 30].dateTime!,
- price1: candles[candles.length - 30].low,
- time2: candles[candles.length - 12].dateTime!,
- price2: candles[candles.length - 12].high,
+ volHidden: true,
+ axis: VerticalTextAlignment.right,
+ indicators: [],
+ horizontalLines: [
+ HorizontalLine(
+ price: candles[candles.length - 60].high,
+ startTime: candles[candles.length - 60].dateTime,
+ title: 'broken',
+ color: const Color(0xFFEF5350),
+ style: LineStyle.dashed,
+ showLabel: true,
+ ),
+ ],
+ trendLines: [
+ TrendLine(
+ time1: candles[candles.length - 150].dateTime!,
+ price1: candles[candles.length - 150].low,
+ time2: candles[candles.length - 110].dateTime!,
+ price2: candles[candles.length - 110].high,
+ extend: LineExtension.right,
+ color: const Color(0xFFF5C26B),
+ ),
+ TrendLine(
+ time1: candles[candles.length - 95].dateTime!,
+ price1: candles[candles.length - 95].high,
+ time2: candles[candles.length - 75].dateTime!,
+ price2: candles[candles.length - 75].low,
+ arrow: true,
+ color: const Color(0xFFB197FC),
+ ),
+ ],
+ rectangles: [
+ RectangleDrawing(
+ time1: candles[candles.length - 58].dateTime!,
+ // The box covers the whole range the market held over that span,
+ // which is what a range box is for.
+ price1: candles
+ .sublist(candles.length - 58, candles.length - 38)
+ .map((candle) => candle.high)
+ .reduce(max),
+ time2: candles[candles.length - 38].dateTime!,
+ price2: candles
+ .sublist(candles.length - 58, candles.length - 38)
+ .map((candle) => candle.low)
+ .reduce(min),
+ label: 'range',
+ showLabel: true,
+ ),
+ ],
+ fibRetracements: [
+ FibRetracement(
+ time1: candles[candles.length - 30].dateTime!,
+ price1: candles[candles.length - 30].low,
+ time2: candles[candles.length - 12].dateTime!,
+ price2: candles[candles.length - 12].high,
+ ),
+ ],
),
- ],
- ),
),
(
name: 'readout',
@@ -947,13 +952,13 @@ List buildScenes() {
// while a finger is down.
act: (area, shoot) => hold(area.center, shoot),
build: () => chart(
- indicators: [
- MaIndicator(period: 5),
- MaIndicator(period: 10),
- MaIndicator(period: 20),
- MacdIndicator(),
- ],
- ),
+ indicators: [
+ MaIndicator(period: 5),
+ MaIndicator(period: 10),
+ MaIndicator(period: 20),
+ MacdIndicator(),
+ ],
+ ),
),
(
name: 'line-editor',
@@ -966,60 +971,60 @@ List buildScenes() {
await shoot();
},
build: () => chart(
- volHidden: true,
- axis: VerticalTextAlignment.right,
- indicators: [EmaIndicator(period: 21)],
- horizontalLines: [
- HorizontalLine(
- price: last * 1.008,
- title: 'resistance',
- color: const Color(0xFFEF5350),
- style: LineStyle.dashed,
- showLabel: true,
+ volHidden: true,
+ axis: VerticalTextAlignment.right,
+ indicators: [EmaIndicator(period: 21)],
+ horizontalLines: [
+ HorizontalLine(
+ price: last * 1.008,
+ title: 'resistance',
+ color: const Color(0xFFEF5350),
+ style: LineStyle.dashed,
+ showLabel: true,
+ ),
+ ],
),
- ],
- ),
),
(
name: 'chart-types',
size: wide,
act: null,
build: () => ColoredBox(
- color: ChartTheme.darkColors().bgColor,
- child: Column(
- children: [
- for (final row in const [
- [
- (ChartType.bars, 'bars'),
- (ChartType.baseline, 'baseline'),
- (ChartType.area, 'area'),
- ],
- [
- (ChartType.stepLine, 'step line'),
- (ChartType.hlcArea, 'HLC area'),
- (ChartType.columns, 'columns'),
- ],
- ])
- Expanded(
- child: Row(
- children: [
- for (final (type, name) in row)
- Expanded(
- child: titled(
- name,
- chart(
- indicators: [],
- volHidden: true,
- chartType: type,
- ),
- ),
- ),
+ color: ChartTheme.darkColors().bgColor,
+ child: Column(
+ children: [
+ for (final row in const [
+ [
+ (ChartType.bars, 'bars'),
+ (ChartType.baseline, 'baseline'),
+ (ChartType.area, 'area'),
],
- ),
- ),
- ],
- ),
- ),
+ [
+ (ChartType.stepLine, 'step line'),
+ (ChartType.hlcArea, 'HLC area'),
+ (ChartType.columns, 'columns'),
+ ],
+ ])
+ Expanded(
+ child: Row(
+ children: [
+ for (final (type, name) in row)
+ Expanded(
+ child: titled(
+ name,
+ chart(
+ indicators: [],
+ volHidden: true,
+ chartType: type,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
),
(
name: 'aggregations',
@@ -1054,172 +1059,172 @@ List buildScenes() {
size: wide,
act: null,
build: () => chart(
- indicators: [EmaIndicator(period: 50)],
- volHidden: true,
- priceAxisScale: PriceAxisScale.logarithmic,
- showOhlcLegend: true,
- sessionDividers: true,
- ),
+ indicators: [EmaIndicator(period: 50)],
+ volHidden: true,
+ priceAxisScale: PriceAxisScale.logarithmic,
+ showOhlcLegend: true,
+ sessionDividers: true,
+ ),
),
(
name: 'planning',
size: wide,
act: null,
build: () => chart(
- indicators: [],
- volHidden: true,
- axis: VerticalTextAlignment.right,
- showOhlcLegend: true,
- drawings: [
- PositionDrawing(
- time1: candles[candles.length - 60].dateTime!,
- price1: candles[candles.length - 60].close,
- time2: candles[candles.length - 8].dateTime!,
- price2: candles[candles.length - 60].close * 1.06,
- time3: candles[candles.length - 8].dateTime!,
- price3: candles[candles.length - 60].close * 0.98,
- profitColor: const Color(0xFF26A69A),
- lossColor: const Color(0xFFEF5350),
- ),
- ParallelChannel(
- time1: candles[candles.length - 150].dateTime!,
- price1: candles[candles.length - 150].low,
- time2: candles[candles.length - 70].dateTime!,
- price2: candles[candles.length - 70].low,
- time3: candles[candles.length - 110].dateTime!,
- price3: candles[candles.length - 110].high,
- color: const Color(0xFF4DABF7),
- ),
- MeasureDrawing(
- time1: candles[candles.length - 40].dateTime!,
- price1: candles[candles.length - 40].low,
- time2: candles[candles.length - 20].dateTime!,
- price2: candles[candles.length - 20].high,
- ),
- TextAnnotation(
- time: candles[candles.length - 30].dateTime!,
- price: candles[candles.length - 30].high * 1.02,
- text: 'CPI print',
- color: const Color(0xFFF5C26B),
+ indicators: [],
+ volHidden: true,
+ axis: VerticalTextAlignment.right,
+ showOhlcLegend: true,
+ drawings: [
+ PositionDrawing(
+ time1: candles[candles.length - 60].dateTime!,
+ price1: candles[candles.length - 60].close,
+ time2: candles[candles.length - 8].dateTime!,
+ price2: candles[candles.length - 60].close * 1.06,
+ time3: candles[candles.length - 8].dateTime!,
+ price3: candles[candles.length - 60].close * 0.98,
+ profitColor: const Color(0xFF26A69A),
+ lossColor: const Color(0xFFEF5350),
+ ),
+ ParallelChannel(
+ time1: candles[candles.length - 150].dateTime!,
+ price1: candles[candles.length - 150].low,
+ time2: candles[candles.length - 70].dateTime!,
+ price2: candles[candles.length - 70].low,
+ time3: candles[candles.length - 110].dateTime!,
+ price3: candles[candles.length - 110].high,
+ color: const Color(0xFF4DABF7),
+ ),
+ MeasureDrawing(
+ time1: candles[candles.length - 40].dateTime!,
+ price1: candles[candles.length - 40].low,
+ time2: candles[candles.length - 20].dateTime!,
+ price2: candles[candles.length - 20].high,
+ ),
+ TextAnnotation(
+ time: candles[candles.length - 30].dateTime!,
+ price: candles[candles.length - 30].high * 1.02,
+ text: 'CPI print',
+ color: const Color(0xFFF5C26B),
+ ),
+ ],
),
- ],
- ),
),
(
name: 'comparison',
size: wide,
act: null,
build: () => chart(
- indicators: [],
- volHidden: true,
- showOhlcLegend: true,
- axis: VerticalTextAlignment.right,
- comparisons: [MarketData.comparison(candles)],
- ),
+ indicators: [],
+ volHidden: true,
+ showOhlcLegend: true,
+ axis: VerticalTextAlignment.right,
+ comparisons: [MarketData.comparison(candles)],
+ ),
),
(
name: 'trading',
size: wide,
act: null,
build: () => chart(
- indicators: [EmaIndicator(period: 21)],
- volHidden: true,
- axis: VerticalTextAlignment.right,
- orders: [
- ChartOrder(
- id: 'working',
- price: last * 0.985,
- side: TradeSide.buy,
- quantity: 0.5,
- ),
- ChartOrder(
- id: 'target',
- price: last * 1.008,
- side: TradeSide.sell,
- quantity: 0.5,
- ),
- ],
- positions: [
- ChartPosition(
- id: 'open',
- entryPrice: candles[candles.length - 40].close,
- side: TradeSide.buy,
- quantity: 1,
- unrealisedPnl: last - candles[candles.length - 40].close,
+ indicators: [EmaIndicator(period: 21)],
+ volHidden: true,
+ axis: VerticalTextAlignment.right,
+ orders: [
+ ChartOrder(
+ id: 'working',
+ price: last * 0.985,
+ side: TradeSide.buy,
+ quantity: 0.5,
+ ),
+ ChartOrder(
+ id: 'target',
+ price: last * 1.008,
+ side: TradeSide.sell,
+ quantity: 0.5,
+ ),
+ ],
+ positions: [
+ ChartPosition(
+ id: 'open',
+ entryPrice: candles[candles.length - 40].close,
+ side: TradeSide.buy,
+ quantity: 1,
+ unrealisedPnl: last - candles[candles.length - 40].close,
+ ),
+ ],
),
- ],
- ),
),
(
name: 'events',
size: wide,
act: null,
build: () => chart(
- indicators: [MaIndicator(period: 20)],
- showOhlcLegend: true,
- // The demo's own events are spread over the whole history, and the
- // window only holds the last half of it: these sit where they can be
- // seen, one of each kind.
- events: [
- for (final (back, kind, detail) in [
- (78, ChartEventKind.earnings, 'Q3 earnings, after the close'),
- (56, ChartEventKind.dividend, r'$0.24 goes ex'),
- (34, ChartEventKind.split, '2-for-1'),
- (12, ChartEventKind.news, 'Added to the index'),
- ])
- ChartEvent(
- time: candles[candles.length - back].dateTime!,
- kind: kind,
- detail: detail,
- ),
- ],
- ),
+ indicators: [MaIndicator(period: 20)],
+ showOhlcLegend: true,
+ // The demo's own events are spread over the whole history, and the
+ // window only holds the last half of it: these sit where they can be
+ // seen, one of each kind.
+ events: [
+ for (final (back, kind, detail) in [
+ (78, ChartEventKind.earnings, 'Q3 earnings, after the close'),
+ (56, ChartEventKind.dividend, r'$0.24 goes ex'),
+ (34, ChartEventKind.split, '2-for-1'),
+ (12, ChartEventKind.news, 'Added to the index'),
+ ])
+ ChartEvent(
+ time: candles[candles.length - back].dateTime!,
+ kind: kind,
+ detail: detail,
+ ),
+ ],
+ ),
),
(
name: 'sessions',
size: wide,
act: null,
build: () => chart(
- indicators: [],
- showOhlcLegend: true,
- // Kept every day, weekend included: the demo's market never closes,
- // and a weekday-only set washes the whole picture when the data runs
- // over a Saturday.
- session: const TradingSession(
- open: Duration(hours: 9, minutes: 30),
- close: Duration(hours: 16),
- weekdays: {
- DateTime.monday,
- DateTime.tuesday,
- DateTime.wednesday,
- DateTime.thursday,
- DateTime.friday,
- DateTime.saturday,
- DateTime.sunday,
- },
- ),
- extendedHoursColor: const Color(0x2494A9C0),
- // The kind of thing a per-bar colour is for: pick out the handful of
- // bars that travelled much further than the ones around them.
- candleColor: (candle, index) =>
- candle.high - candle.low > candle.close * 0.015
- ? const Color(0xFFFFD54F)
- : null,
- ),
+ indicators: [],
+ showOhlcLegend: true,
+ // Kept every day, weekend included: the demo's market never closes,
+ // and a weekday-only set washes the whole picture when the data runs
+ // over a Saturday.
+ session: const TradingSession(
+ open: Duration(hours: 9, minutes: 30),
+ close: Duration(hours: 16),
+ weekdays: {
+ DateTime.monday,
+ DateTime.tuesday,
+ DateTime.wednesday,
+ DateTime.thursday,
+ DateTime.friday,
+ DateTime.saturday,
+ DateTime.sunday,
+ },
+ ),
+ extendedHoursColor: const Color(0x2494A9C0),
+ // The kind of thing a per-bar colour is for: pick out the handful of
+ // bars that travelled much further than the ones around them.
+ candleColor: (candle, index) =>
+ candle.high - candle.low > candle.close * 0.015
+ ? const Color(0xFFFFD54F)
+ : null,
+ ),
),
(
name: 'profile',
size: wide,
act: null,
build: () => chart(
- volHidden: true,
- showOhlcLegend: true,
- axis: VerticalTextAlignment.right,
- indicators: [
- VolumeProfileIndicator(bins: 28),
- AnchoredVwapIndicator(anchor: candles.length - 90),
- ],
- ),
+ volHidden: true,
+ showOhlcLegend: true,
+ axis: VerticalTextAlignment.right,
+ indicators: [
+ VolumeProfileIndicator(bins: 28),
+ AnchoredVwapIndicator(anchor: candles.length - 90),
+ ],
+ ),
),
(
name: 'bar-types',
@@ -1268,39 +1273,39 @@ List buildScenes() {
size: wide,
act: null,
build: () => ColoredBox(
- color: ChartTheme.darkColors().bgColor,
- child: Row(
- children: [
- Expanded(
- child: titled(
- 'indexed to 100',
- centred: true,
- chart(
- indicators: [],
- volHidden: true,
- axis: VerticalTextAlignment.right,
- priceAxisScale: PriceAxisScale.indexedTo100,
- comparisons: [MarketData.comparison(candles)],
+ color: ChartTheme.darkColors().bgColor,
+ child: Row(
+ children: [
+ Expanded(
+ child: titled(
+ 'indexed to 100',
+ centred: true,
+ chart(
+ indicators: [],
+ volHidden: true,
+ axis: VerticalTextAlignment.right,
+ priceAxisScale: PriceAxisScale.indexedTo100,
+ comparisons: [MarketData.comparison(candles)],
+ ),
+ ),
),
- ),
- ),
- Expanded(
- child: titled(
- 'inverted',
- centred: true,
- chart(
- indicators: [],
- volHidden: true,
- axis: VerticalTextAlignment.right,
- invertPriceAxis: true,
- showAverageClose: true,
- showHighLowOnAxis: true,
+ Expanded(
+ child: titled(
+ 'inverted',
+ centred: true,
+ chart(
+ indicators: [],
+ volHidden: true,
+ axis: VerticalTextAlignment.right,
+ invertPriceAxis: true,
+ showAverageClose: true,
+ showHighLowOnAxis: true,
+ ),
+ ),
),
- ),
+ ],
),
- ],
- ),
- ),
+ ),
),
(
name: 'depth-modes',
@@ -1446,17 +1451,17 @@ List buildScenes() {
await shoot();
},
build: () => Column(
- children: [
- Expanded(
- child: chart(
- data: longRun,
- replay: replay,
- indicators: [MaIndicator(period: 20), RsiIndicator()],
- ),
+ children: [
+ Expanded(
+ child: chart(
+ data: longRun,
+ replay: replay,
+ indicators: [MaIndicator(period: 20), RsiIndicator()],
+ ),
+ ),
+ ReplayBar(replay: replay),
+ ],
),
- ReplayBar(replay: replay),
- ],
- ),
),
(
// Hovered rather than held: the crosshair follows a mouse without a
@@ -1470,9 +1475,9 @@ List buildScenes() {
await shoot();
},
build: () => chart(
- showOhlcLegend: true,
- indicators: [MaIndicator(period: 20), MacdIndicator()],
- ),
+ showOhlcLegend: true,
+ indicators: [MaIndicator(period: 20), MacdIndicator()],
+ ),
),
(
// The axis it picks for itself beside one taken over wholesale, on the
@@ -1481,35 +1486,36 @@ List buildScenes() {
size: wide,
act: null,
build: () => Column(
- children: [
- Expanded(
- child: titled(
- 'the format it picks: clock times, the date where the day turns',
- chart(volHidden: true, indicators: [EmaIndicator(period: 21)]),
- top: 30,
- ),
- ),
- const SizedBox(height: 8),
- Expanded(
- child: titled(
- 'dateFormatter taking it over, xFrontPadding: 120',
- chart(
- volHidden: true,
- indicators: [EmaIndicator(period: 21)],
- xFrontPadding: 120,
- dateFormatter: (candle, longForm) {
- final at = candle.dateTime!;
- final hour = at.hour.toString().padLeft(2, '0');
- return longForm
- ? 'Sep ${at.day}, ${hour}h${at.minute}'
- : '${at.day} Sep · ${hour}h';
- },
+ children: [
+ Expanded(
+ child: titled(
+ 'the format it picks: clock times, the date where the day turns',
+ chart(
+ volHidden: true, indicators: [EmaIndicator(period: 21)]),
+ top: 30,
+ ),
),
- top: 30,
- ),
+ const SizedBox(height: 8),
+ Expanded(
+ child: titled(
+ 'dateFormatter taking it over, xFrontPadding: 120',
+ chart(
+ volHidden: true,
+ indicators: [EmaIndicator(period: 21)],
+ xFrontPadding: 120,
+ dateFormatter: (candle, longForm) {
+ final at = candle.dateTime!;
+ final hour = at.hour.toString().padLeft(2, '0');
+ return longForm
+ ? 'Sep ${at.day}, ${hour}h${at.minute}'
+ : '${at.day} Sep · ${hour}h';
+ },
+ ),
+ top: 30,
+ ),
+ ),
+ ],
),
- ],
- ),
),
(
// The same chart under both palettes: ChartColors is the whole
@@ -1518,24 +1524,27 @@ List buildScenes() {
size: wide,
act: null,
build: () => Row(
- children: [
- Expanded(
- child: titled(
- 'ChartTheme.darkColors()',
- chart(indicators: [BollIndicator(), MacdIndicator()]),
- top: 30,
- ),
- ),
- const SizedBox(width: 8),
- Expanded(
- child: titled(
- 'ChartTheme.lightColors()',
- chart(light: true, indicators: [BollIndicator(), MacdIndicator()]),
- top: 30,
- ),
+ children: [
+ Expanded(
+ child: titled(
+ 'ChartTheme.darkColors()',
+ chart(indicators: [BollIndicator(), MacdIndicator()]),
+ top: 30,
+ ),
+ ),
+ const SizedBox(width: 8),
+ Expanded(
+ child: titled(
+ 'ChartTheme.lightColors()',
+ chart(
+ light: true,
+ indicators: [BollIndicator(), MacdIndicator()],
+ ),
+ top: 30,
+ ),
+ ),
+ ],
),
- ],
- ),
),
(
// Left to itself the candle area takes what the panes do not want;
@@ -1544,40 +1553,119 @@ List buildScenes() {
size: wide,
act: null,
build: () => Row(
- children: [
- Expanded(
- child: titled(
- 'mBaseHeight unset — fills the box',
- chart(indicators: [MacdIndicator()]),
- ),
- ),
- const SizedBox(width: 8),
- Expanded(
- child: titled(
- 'mBaseHeight: 220',
- chart(mBaseHeight: 220, indicators: [MacdIndicator()]),
- ),
+ children: [
+ Expanded(
+ child: titled(
+ 'mBaseHeight unset — fills the box',
+ chart(indicators: [MacdIndicator()]),
+ ),
+ ),
+ const SizedBox(width: 8),
+ Expanded(
+ child: titled(
+ 'mBaseHeight: 220',
+ chart(mBaseHeight: 220, indicators: [MacdIndicator()]),
+ ),
+ ),
+ ],
),
- ],
- ),
),
(
name: 'indicator-settings',
size: portrait,
act: null,
build: () => Theme(
- data: ThemeData.dark(useMaterial3: true),
- child: Scaffold(
- backgroundColor: const Color(0xFF10141A),
- body: SafeArea(
- child: Padding(
- padding: const EdgeInsets.only(top: 8),
- child: IndicatorSheet(theme: ChartTheme.darkColors()),
+ data: ThemeData.dark(useMaterial3: true),
+ child: Scaffold(
+ backgroundColor: const Color(0xFF10141A),
+ body: SafeArea(
+ child: Padding(
+ padding: const EdgeInsets.only(top: 8),
+ child: IndicatorSheet(theme: ChartTheme.darkColors()),
+ ),
+ ),
),
),
- ),
- ),
),
+ (
+ // Four business charts at once, one of them reading out a day, so the
+ // listing shows what SeriesChart is for before any code.
+ name: 'series-charts',
+ size: wide,
+ act: (area, shoot) async {
+ seriesGalleryCrosshair.show(30);
+ await Future.delayed(const Duration(milliseconds: 300));
+ await shoot();
+ seriesGalleryCrosshair.clear();
+ },
+ build: seriesGalleryScene,
+ ),
+ (
+ // A window over five months of data, with the strip that moves it and a
+ // day held under the crosshair.
+ name: 'series-window',
+ size: wide,
+ act: (area, shoot) async {
+ seriesWindowCrosshair.show(118);
+ await Future.delayed(const Duration(milliseconds: 300));
+ await shoot();
+ seriesWindowCrosshair.clear();
+ },
+ build: seriesWindowScene,
+ ),
+ (
+ // Two panels on one controller: the crosshair in both, one tooltip.
+ name: 'series-panels',
+ size: wide,
+ act: (area, shoot) async {
+ seriesPanelsCrosshair.show(41);
+ await Future.delayed(const Duration(milliseconds: 300));
+ await shoot();
+ seriesPanelsCrosshair.clear();
+ },
+ build: seriesPanelsScene,
+ ),
+ (
+ // The two shapes an axis cannot draw: a pie and a radar.
+ name: 'pie-radar',
+ size: shortWide,
+ act: null,
+ build: pieRadarScene,
+ ),
+ (
+ // A grid coloured by value: months of activity, and a week of hours.
+ name: 'heatmap',
+ size: shortWide,
+ act: null,
+ build: heatmapScene,
+ ),
+ (
+ // Dots placed freely on both axes, read out one at a time.
+ name: 'series-scatter',
+ size: shortWide,
+ act: (area, shoot) async {
+ seriesScatterCrosshair.show(18, seriesIndex: 0, pointIndex: 12);
+ await Future.delayed(const Duration(milliseconds: 300));
+ await shoot();
+ seriesScatterCrosshair.clear();
+ },
+ build: seriesScatterScene,
+ ),
+ (
+ // A chart turned on its side, with bars stacked on each other.
+ name: 'series-horizontal',
+ size: shortWide,
+ act: null,
+ build: seriesHorizontalScene,
+ ),
+ (
+ // Bars that float between two values, and a band between two lines.
+ name: 'series-ranges',
+ size: shortWide,
+ act: null,
+ build: seriesRangesScene,
+ ),
+ (name: 'watermark', size: wide, act: null, build: watermarkScene),
];
}
@@ -1620,7 +1708,12 @@ List buildFilms() {
colors,
chartStyle: ChartTheme.filled,
isTrendLine: false,
- watermarkAssetPath: 'assets/watermark.svg',
+ watermark: const FittedBox(
+ child: Text(
+ 'OHLCV',
+ style: TextStyle(fontWeight: FontWeight.w900),
+ ),
+ ),
timeFrame: MarketData.timeFrame,
timeFormat: TimeFormat.YEAR_MONTH_DAY_WITH_HOUR,
indicators: [MaIndicator(period: 20), RsiIndicator()],
@@ -1639,3 +1732,1176 @@ List buildFilms() {
),
];
}
+
+// ── Series charts ────────────────────────────────────────────────────────────
+
+const _seriesBackground = Color(0xFF0D1117);
+const _seriesPaper = Color(0xFF161B22);
+const _seriesGrid = Color(0xFF252C35);
+const _seriesMuted = Color(0xFF5C6570);
+const _seriesPurple = Color(0xFF9775FA);
+const _seriesBlue = Color(0xFF4DABF7);
+const _seriesAmber = Color(0xFFFAB005);
+const _seriesGreen = Color(0xFF12B886);
+const _seriesRed = Color(0xFFFA5252);
+const _seriesAxis = TextStyle(color: Color(0xFF8B95A1), fontSize: 11);
+const _seriesMonths = [
+ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', //
+ 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
+];
+
+/// The crosshairs the series scenes hold still for the shutter.
+final seriesGalleryCrosshair = SeriesChartController();
+final seriesWindowCrosshair = SeriesChartController();
+final seriesPanelsCrosshair = SeriesChartController();
+final seriesScatterCrosshair = SeriesChartController();
+
+/// A drifting wave: no randomness, so the pictures never change between runs.
+List seriesWave(
+ int count, {
+ double base = 0,
+ double swing = 1,
+ double drift = 0,
+ double phase = 0,
+}) =>
+ [
+ for (var i = 0; i < count; i++)
+ base +
+ sin(i / 5 + phase) * swing +
+ cos(i / 2.3 + phase) * swing * 0.35 +
+ i * drift,
+ ];
+
+/// Day [index] of 2026, written the way a dashboard axis would.
+String seriesDate(int index) {
+ final day = DateTime(2026).add(Duration(days: index));
+ return '${_seriesMonths[day.month - 1]} ${day.day}';
+}
+
+/// Dollars, shortened to thousands past a thousand.
+String seriesUsd(double value) {
+ final sign = value < 0 ? '-' : '';
+ final amount = value.abs();
+ final digits = amount >= 1000
+ ? '${(amount / 1000).toStringAsFixed(1)}k'
+ : amount.toStringAsFixed(0);
+ return '$sign\$$digits';
+}
+
+/// A card with a title, a figure on the right, and a chart filling the rest.
+Widget seriesPanel(
+ String title,
+ Widget child, {
+ String? figure,
+ Color figureColor = Colors.white,
+}) =>
+ Container(
+ margin: const EdgeInsets.all(6),
+ padding: const EdgeInsets.fromLTRB(14, 12, 14, 10),
+ decoration: BoxDecoration(
+ color: _seriesPaper,
+ border: Border.all(color: _seriesGrid),
+ borderRadius: BorderRadius.circular(10),
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ Row(
+ children: [
+ Text(
+ title,
+ style: const TextStyle(
+ color: Color(0xFFB6C0CC),
+ fontSize: 13,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ const Spacer(),
+ if (figure != null)
+ Text(
+ figure,
+ style: TextStyle(
+ color: figureColor,
+ fontSize: 13,
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 8),
+ Expanded(child: child),
+ ],
+ ),
+ );
+
+/// The tooltip the series scenes draw: a date, then a coloured row per value.
+Widget seriesCard(String title, List<(String, String, Color)> rows) =>
+ DecoratedBox(
+ decoration: BoxDecoration(
+ color: const Color(0xF21C2128),
+ border: Border.all(color: _seriesGrid),
+ borderRadius: BorderRadius.circular(8),
+ boxShadow: const [BoxShadow(color: Color(0x66000000), blurRadius: 10)],
+ ),
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(title, style: _seriesAxis),
+ const SizedBox(height: 4),
+ for (final (label, value, color) in rows)
+ Padding(
+ padding: const EdgeInsets.only(top: 2),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Container(
+ width: 7,
+ height: 7,
+ decoration: BoxDecoration(
+ color: color,
+ shape: BoxShape.circle,
+ ),
+ ),
+ const SizedBox(width: 6),
+ Text('$label ', style: _seriesAxis.copyWith(fontSize: 12)),
+ Text(
+ value,
+ style: const TextStyle(
+ color: Colors.white,
+ fontSize: 12,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+
+SeriesTooltip _seriesTooltip({String Function(int index)? title}) =>
+ SeriesTooltip(
+ builder: (context, details) =>
+ seriesCard((title ?? seriesDate)(details.index), [
+ for (final value in details.values)
+ (value.series.label ?? '', seriesUsd(value.value), value.color),
+ ]),
+ );
+
+/// Four business charts in a grid.
+Widget seriesGalleryScene() {
+ final roi = [
+ for (final v in seriesWave(60, swing: 9, drift: 0.14, phase: 1.2)) v - 3,
+ ];
+ final profits = seriesWave(28, swing: 120, phase: 0.4);
+ final deposits = seriesWave(45, base: 1600, swing: 380, drift: 6);
+ final withdrawals = seriesWave(45, base: 900, swing: 220, drift: 3, phase: 2);
+ final balance = seriesWave(
+ 90,
+ base: 12000,
+ swing: 900,
+ drift: 14,
+ phase: 0.8,
+ );
+
+ return ColoredBox(
+ color: _seriesBackground,
+ child: Padding(
+ padding: const EdgeInsets.all(6),
+ child: Column(
+ children: [
+ Expanded(
+ child: Row(
+ children: [
+ Expanded(
+ child: seriesPanel(
+ 'Return, split at zero',
+ figure:
+ '${roi.last >= 0 ? '+' : ''}${roi.last.toStringAsFixed(1)}%',
+ figureColor: roi.last >= 0 ? _seriesGreen : _seriesRed,
+ SeriesChart(
+ series: [
+ LineSeries.values(
+ roi,
+ color: _seriesGreen,
+ negativeColor: _seriesRed,
+ curve: LineCurve.monotone,
+ fill: SeriesFill.fade(
+ _seriesGreen,
+ negativeColor: _seriesRed,
+ opacity: 0.3,
+ ),
+ ),
+ ],
+ includeZero: true,
+ xAxis: SeriesXAxis(
+ labels: [
+ for (var i = 0; i < 60; i++)
+ i % 15 == 0 ? seriesDate(i) : '',
+ ],
+ style: _seriesAxis,
+ ),
+ yAxis: SeriesYAxis(
+ width: 38,
+ formatter: (v) => '${v.toStringAsFixed(0)}%',
+ style: _seriesAxis,
+ ),
+ grid: const SeriesGrid(
+ vertical: false,
+ color: _seriesGrid,
+ dashPattern: [4, 4],
+ ),
+ referenceLines: const [
+ SeriesReferenceLine.horizontal(
+ 0,
+ color: _seriesMuted,
+ dashPattern: [3, 3],
+ ),
+ ],
+ touch: null,
+ ),
+ ),
+ ),
+ Expanded(
+ child: seriesPanel(
+ 'Profit per trade',
+ figure: seriesUsd(profits.reduce((a, b) => a + b)),
+ SeriesChart(
+ series: [
+ BarSeries.values(
+ profits,
+ color: _seriesGreen,
+ negativeColor: _seriesRed,
+ radius: 3,
+ maxWidth: 14,
+ ),
+ ],
+ xAxis: SeriesXAxis(
+ labels: [
+ for (var i = 0; i < 28; i++)
+ i % 7 == 0 ? '#${i + 1}' : '',
+ ],
+ style: _seriesAxis,
+ ),
+ yAxis: const SeriesYAxis(
+ width: 38,
+ formatter: seriesUsd,
+ style: _seriesAxis,
+ ),
+ grid: const SeriesGrid(
+ vertical: false,
+ color: _seriesGrid,
+ dashPattern: [4, 4],
+ ),
+ touch: null,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ Expanded(
+ child: Row(
+ children: [
+ Expanded(
+ child: seriesPanel(
+ 'Deposits and withdrawals',
+ SeriesChart(
+ series: [
+ LineSeries.values(
+ deposits,
+ label: 'Deposits',
+ color: _seriesBlue,
+ fill: SeriesFill.fade(_seriesBlue),
+ ),
+ LineSeries.values(
+ withdrawals,
+ label: 'Withdrawals',
+ color: _seriesAmber,
+ fill: SeriesFill.fade(_seriesAmber),
+ ),
+ ],
+ xAxis: SeriesXAxis(
+ labels: [
+ for (var i = 0; i < 45; i++)
+ i % 10 == 0 ? seriesDate(i) : '',
+ ],
+ style: _seriesAxis,
+ ),
+ yAxis: const SeriesYAxis(
+ width: 38,
+ formatter: seriesUsd,
+ style: _seriesAxis,
+ ),
+ grid: const SeriesGrid(
+ color: _seriesGrid,
+ dashPattern: [4, 4],
+ ),
+ controller: seriesGalleryCrosshair,
+ touch: SeriesTouch(
+ trigger: SeriesTouchTrigger.longPress,
+ line: const SeriesCrosshairLine(color: _seriesMuted),
+ tooltip: _seriesTooltip(),
+ ),
+ ),
+ ),
+ ),
+ Expanded(
+ child: seriesPanel(
+ 'Balance',
+ Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ '\$${(balance.last / 1000).toStringAsFixed(2)}k',
+ style: const TextStyle(
+ color: Colors.white,
+ fontSize: 28,
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ const Text(
+ '+9.8% over the quarter',
+ style: TextStyle(color: _seriesGreen, fontSize: 12),
+ ),
+ const Spacer(),
+ SizedBox(
+ height: 96,
+ child: SeriesChart(
+ series: [
+ LineSeries.values(
+ balance,
+ color: _seriesPurple,
+ curve: LineCurve.monotone,
+ fill: SeriesFill.fade(
+ _seriesPurple,
+ opacity: 0.35,
+ ),
+ dotBuilder: (i, _) => i == balance.length - 1
+ ? const SeriesDot(
+ radius: 4,
+ strokeColor: _seriesPaper,
+ strokeWidth: 2,
+ )
+ : null,
+ ),
+ ],
+ xAxis: SeriesXAxis.hidden,
+ yAxis: SeriesYAxis.hidden,
+ grid: SeriesGrid.none,
+ clipToPlot: false,
+ padding: const EdgeInsets.all(4),
+ touch: null,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+}
+
+/// Three series over a window of five months, with the strip that moves it.
+Widget seriesWindowScene() {
+ const days = 150;
+ const window = SeriesWindow(95, 134);
+ final deposits = seriesWave(days, base: 2100, swing: 520, drift: 4);
+ final withdrawals = seriesWave(
+ days,
+ base: 1200,
+ swing: 300,
+ drift: 2,
+ phase: 2.2,
+ );
+ final rebates = seriesWave(days, base: 420, swing: 140, drift: 1, phase: 4);
+
+ List lines({required bool small}) => [
+ for (final (label, values, color) in [
+ ('Deposits', deposits, _seriesBlue),
+ ('Withdrawals', withdrawals, _seriesAmber),
+ ('Rebates', rebates, _seriesGreen),
+ ])
+ LineSeries.values(
+ values,
+ label: label,
+ color: color,
+ width: small ? 1 : 2,
+ fill: SeriesFill.fade(color, opacity: small ? 0.18 : 0.24),
+ ),
+ ];
+
+ return ColoredBox(
+ color: _seriesBackground,
+ child: Padding(
+ padding: const EdgeInsets.all(6),
+ child: seriesPanel(
+ 'Activity trend',
+ figure: '${seriesDate(window.start.round())} – '
+ '${seriesDate(window.end.round())}',
+ figureColor: const Color(0xFF8B95A1),
+ Column(
+ children: [
+ Expanded(
+ child: SeriesChart(
+ series: lines(small: false),
+ minX: window.start - 0.5,
+ maxX: window.end + 0.5,
+ xAxis: SeriesXAxis(
+ labels: [
+ for (var i = 0; i < days; i++)
+ i % 7 == 0 ? seriesDate(i) : '',
+ ],
+ style: _seriesAxis,
+ ),
+ yAxis: const SeriesYAxis(
+ width: 44,
+ formatter: seriesUsd,
+ style: _seriesAxis,
+ ),
+ grid: const SeriesGrid(color: _seriesGrid, dashPattern: [4, 4]),
+ border: const BorderSide(color: _seriesGrid),
+ controller: seriesWindowCrosshair,
+ touch: SeriesTouch(
+ trigger: SeriesTouchTrigger.longPress,
+ line: const SeriesCrosshairLine(color: _seriesMuted),
+ tooltip: _seriesTooltip(),
+ ),
+ ),
+ ),
+ const SizedBox(height: 12),
+ Padding(
+ padding: const EdgeInsets.only(left: 44),
+ child: SeriesRangeSelector(
+ series: lines(small: true),
+ window: window,
+ onChanged: (_) {},
+ minSpan: 4,
+ height: 60,
+ border: const BorderSide(color: _seriesGrid),
+ maskColor: const Color(0xB30D1117),
+ windowBorderColor: _seriesMuted,
+ handleColor: _seriesPaper,
+ handleBorderColor: _seriesMuted,
+ handleGripColor: const Color(0xFF8B95A1),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+}
+
+/// A balance panel over a profit panel, holding one crosshair between them.
+Widget seriesPanelsScene() {
+ const count = 60;
+ final balance = seriesWave(
+ count,
+ base: 10400,
+ swing: 700,
+ drift: 22,
+ phase: 0.5,
+ );
+ final average = [
+ for (var i = 0; i < count; i++)
+ balance.sublist(max(0, i - 9), i + 1).reduce((a, b) => a + b) /
+ (i - max(0, i - 9) + 1),
+ ];
+ final profit = [
+ 0.0,
+ for (var i = 1; i < count; i++) balance[i] - balance[i - 1],
+ ];
+
+ Widget key(String label, Color color, {bool dashed = false}) => Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ for (var i = 0; i < (dashed ? 3 : 1); i++)
+ Container(
+ width: dashed ? 5 : 20,
+ height: 3,
+ margin: EdgeInsets.only(right: dashed ? 2.5 : 0),
+ color: color,
+ ),
+ const SizedBox(width: 8),
+ Text(label, style: TextStyle(color: color, fontSize: 12)),
+ ],
+ );
+
+ return ColoredBox(
+ color: _seriesBackground,
+ child: Padding(
+ padding: const EdgeInsets.all(6),
+ child: seriesPanel(
+ 'Balance and profit — one crosshair, two panels',
+ Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ key('Balance', _seriesPurple),
+ const SizedBox(width: 18),
+ key('10-day average', _seriesAmber, dashed: true),
+ ],
+ ),
+ const SizedBox(height: 8),
+ Expanded(
+ flex: 3,
+ child: SeriesChart(
+ series: [
+ LineSeries.values(
+ balance,
+ label: 'Balance',
+ color: _seriesPurple,
+ fill: SeriesFill.fade(_seriesPurple, opacity: 0.2),
+ ),
+ LineSeries.values(
+ average,
+ label: 'Average',
+ color: _seriesAmber,
+ dashPattern: const [6, 4],
+ ),
+ ],
+ xPadding: 0.5,
+ xAxis: SeriesXAxis.hidden,
+ yAxis: const SeriesYAxis(
+ width: 46,
+ formatter: seriesUsd,
+ style: _seriesAxis,
+ ),
+ grid: const SeriesGrid(color: _seriesGrid, dashPattern: [4, 4]),
+ border: const BorderSide(color: _seriesGrid),
+ controller: seriesPanelsCrosshair,
+ touch: SeriesTouch(
+ trigger: SeriesTouchTrigger.longPress,
+ line: const SeriesCrosshairLine(color: _seriesMuted),
+ tooltip: SeriesTooltip(
+ builder: (context, details) {
+ final i = details.index;
+ return seriesCard(seriesDate(i), [
+ ('Balance', seriesUsd(balance[i]), _seriesPurple),
+ ('Average', seriesUsd(average[i]), _seriesAmber),
+ (
+ 'P/L',
+ seriesUsd(profit[i]),
+ profit[i] < 0 ? _seriesRed : _seriesGreen,
+ ),
+ ]);
+ },
+ ),
+ ),
+ ),
+ ),
+ const SizedBox(height: 12),
+ const Text(
+ 'Profit / loss',
+ style: TextStyle(color: Color(0xFFB6C0CC), fontSize: 12),
+ ),
+ const SizedBox(height: 6),
+ Expanded(
+ flex: 2,
+ child: SeriesChart(
+ series: [
+ BarSeries.values(
+ profit,
+ label: 'P/L',
+ color: _seriesGreen,
+ negativeColor: _seriesRed,
+ radius: 3,
+ minWidth: 3.5,
+ maxWidth: 12,
+ ),
+ ],
+ xAxis: SeriesXAxis(
+ labels: [
+ for (var i = 0; i < count; i++)
+ i % 10 == 0 ? seriesDate(i) : '',
+ ],
+ style: _seriesAxis,
+ ),
+ yAxis: const SeriesYAxis(
+ width: 46,
+ formatter: seriesUsd,
+ tickCount: 3,
+ style: _seriesAxis,
+ ),
+ grid: const SeriesGrid(
+ vertical: false,
+ color: _seriesGrid,
+ dashPattern: [4, 4],
+ ),
+ border: const BorderSide(color: _seriesGrid),
+ controller: seriesPanelsCrosshair,
+ touch: const SeriesTouch(
+ trigger: SeriesTouchTrigger.longPress,
+ line: SeriesCrosshairLine(color: _seriesMuted),
+ tooltip: null,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+}
+
+/// Candles under a watermark that is a widget: an icon and a name.
+/// Half a year of activity beside a week of trading hours, both by value.
+Widget heatmapScene() {
+ // A contribution graph: a column per week, a row per weekday.
+ const weeks = 26;
+ final activity = [
+ for (var weekday = 0; weekday < 7; weekday++)
+ [
+ for (var week = 0; week < weeks; week++)
+ weekday >= 5 && week % 3 == 0
+ ? null
+ : (sin(week / 3.3) + 1.2) * (cos(weekday / 1.9) + 1.3) * 3 +
+ (week * weekday % 4),
+ ],
+ ];
+ final byHourAndDay = [
+ for (var hour = 0; hour < 8; hour++)
+ [
+ for (var day = 0; day < 5; day++)
+ (sin(hour / 2.2) + 1.4) * (cos(day / 2.4 + 1) + 1.5) * 16 +
+ (hour * day % 5) * 3.0,
+ ],
+ ];
+
+ return ColoredBox(
+ color: _seriesBackground,
+ child: Padding(
+ padding: const EdgeInsets.all(6),
+ child: Row(
+ children: [
+ Expanded(
+ flex: 3,
+ child: seriesPanel(
+ 'Six months of activity',
+ figure: '1,284 trades',
+ Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ // Square cells leave the grid shorter than the panel, so it
+ // is given the height it actually needs and centred.
+ SizedBox(
+ height: 210,
+ child: HeatmapChart.matrix(
+ activity,
+ scale: HeatmapGradientScale.of(_seriesGreen),
+ xAxis: HeatmapAxis(
+ interval: 5,
+ style: _seriesAxis,
+ labelBuilder: (index) =>
+ _seriesMonths[(index ~/ 4.4).clamp(0, 11).toInt()],
+ ),
+ yAxis: const HeatmapAxis(
+ size: 30,
+ interval: 2,
+ style: _seriesAxis,
+ labels: ['Mon', '', 'Wed', '', 'Fri', '', 'Sun'],
+ ),
+ spacing: 3,
+ radius: 2,
+ // A contribution graph wants squares, not columns.
+ squareCells: true,
+ ),
+ ),
+ const SizedBox(height: 12),
+ Row(
+ children: [
+ const Spacer(),
+ HeatmapLegend(
+ scale: HeatmapGradientScale.of(_seriesGreen),
+ low: 'Less',
+ high: 'More',
+ labelStyle: _seriesAxis,
+ width: 90,
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ ),
+ Expanded(
+ flex: 2,
+ child: seriesPanel(
+ 'Orders by hour and weekday',
+ HeatmapChart.matrix(
+ byHourAndDay,
+ scale: const HeatmapGradientScale(
+ colors: [_seriesPaper, _seriesBlue, _seriesAmber],
+ ),
+ xAxis: const HeatmapAxis(
+ style: _seriesAxis,
+ labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'],
+ ),
+ yAxis: HeatmapAxis(
+ size: 36,
+ style: _seriesAxis,
+ labelBuilder: (index) =>
+ '${(index + 9).toString().padLeft(2, '0')}:00',
+ ),
+ spacing: 3,
+ radius: 3,
+ labelBuilder: (cell) => cell.value!.round().toString(),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+}
+
+/// Two panels the axis charts cannot draw: a doughnut and a radar web.
+Widget pieRadarScene() {
+ const holdings = [42.0, 26.0, 18.0, 14.0];
+ const names = ['BTC', 'ETH', 'SOL', 'Cash'];
+ const colors = [_seriesPurple, _seriesBlue, _seriesAmber, _seriesGreen];
+
+ return ColoredBox(
+ color: _seriesBackground,
+ child: Padding(
+ padding: const EdgeInsets.all(6),
+ child: Row(
+ children: [
+ Expanded(
+ child: seriesPanel(
+ 'Holdings',
+ figure: seriesUsd(48200),
+ PieChart(
+ sections: [
+ for (var i = 0; i < holdings.length; i++)
+ PieSection(
+ value: holdings[i],
+ color: colors[i],
+ label: '${holdings[i].toStringAsFixed(0)}%',
+ labelStyle: const TextStyle(fontSize: 12),
+ // The biggest holding is pulled out of the ring.
+ offset: i == 0 ? 8 : 0,
+ badge: i == 0 ? seriesChip(names[i], colors[i]) : null,
+ badgePosition: 1.18,
+ ),
+ ],
+ centerSpaceRadius: 52,
+ centerSpaceColor: _seriesPaper,
+ sectionsSpace: 2,
+ startDegreeOffset: -18,
+ centerChild: const Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text('Total', style: _seriesAxis),
+ SizedBox(height: 2),
+ Text(
+ r'$48.2k',
+ style: TextStyle(
+ color: Colors.white,
+ fontSize: 15,
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ Expanded(
+ child: seriesPanel(
+ 'Two strategies scored',
+ RadarChart(
+ features: const [
+ 'Return',
+ 'Sharpe',
+ 'Win rate',
+ 'Cost',
+ 'Drawdown',
+ ],
+ series: const [
+ RadarSeries(
+ values: [4.4, 3.1, 4.8, 2.2, 3.6],
+ color: _seriesBlue,
+ label: 'Momentum',
+ dot: SeriesDot(radius: 2.5),
+ ),
+ RadarSeries(
+ values: [3.0, 4.7, 2.4, 4.1, 4.4],
+ color: _seriesAmber,
+ label: 'Mean reversion',
+ dot: SeriesDot(radius: 2.5),
+ ),
+ ],
+ maxValue: 5,
+ tickCount: 5,
+ gridColor: _seriesGrid,
+ spokeColor: _seriesGrid,
+ featureStyle: _seriesAxis,
+ tickStyle: _seriesAxis,
+ showTicks: true,
+ padding: const EdgeInsets.only(bottom: 6),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+}
+
+/// A small rounded label, for a badge pinned to a pie section.
+Widget seriesChip(String text, Color color) => DecoratedBox(
+ decoration: BoxDecoration(
+ color: _seriesPaper,
+ border: Border.all(color: color),
+ borderRadius: BorderRadius.circular(20),
+ ),
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
+ child: Text(
+ text,
+ style: TextStyle(
+ color: color,
+ fontSize: 11,
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ ),
+ );
+
+/// Every trade as a dot: how big it was against what it returned.
+Widget seriesScatterScene() {
+ final wins = [];
+ final losses = [];
+ for (var i = 0; i < 46; i++) {
+ final size = 2 + (i * 7 % 23).toDouble();
+ final ret = sin(i / 3.1) * 7 + cos(i / 1.7) * 3.4 + size * 0.12 - 1.6;
+ final point = SeriesPoint(size, double.parse(ret.toStringAsFixed(2)));
+ (ret >= 0 ? wins : losses).add(point);
+ }
+ wins.sort((a, b) => a.x.compareTo(b.x));
+ losses.sort((a, b) => a.x.compareTo(b.x));
+
+ return ColoredBox(
+ color: _seriesBackground,
+ child: Padding(
+ padding: const EdgeInsets.all(6),
+ child: seriesPanel(
+ 'Every trade: size against return',
+ figure: '46 trades',
+ SeriesChart(
+ series: [
+ ScatterSeries(
+ points: wins,
+ label: 'Win',
+ color: _seriesGreen,
+ dot: const SeriesDot(radius: 4.5),
+ ),
+ ScatterSeries(
+ points: losses,
+ label: 'Loss',
+ color: _seriesRed,
+ dot: const SeriesDot(
+ radius: 4.5,
+ shape: SeriesDotShape.cross,
+ strokeWidth: 1.6,
+ ),
+ ),
+ ],
+ xAxis: SeriesXAxis(
+ tickCount: 6,
+ title: 'Size, lots',
+ titleStyle: _seriesAxis,
+ style: _seriesAxis,
+ labelBuilder: (v) => v.toStringAsFixed(0),
+ ),
+ yAxis: SeriesYAxis(
+ width: 44,
+ title: 'Return',
+ titleStyle: _seriesAxis,
+ style: _seriesAxis,
+ formatter: (v) => '${v.toStringAsFixed(0)}%',
+ ),
+ grid: const SeriesGrid(color: _seriesGrid, dashPattern: [4, 4]),
+ // Room either side, so the end dots are drawn whole.
+ xPadding: 1.5,
+ referenceLines: const [
+ SeriesReferenceLine.horizontal(
+ 0,
+ color: _seriesMuted,
+ dashPattern: [3, 3],
+ ),
+ ],
+ controller: seriesScatterCrosshair,
+ touch: SeriesTouch(
+ snap: SeriesTouchSnap.nearestPoint,
+ line: null,
+ tooltip: SeriesTooltip(
+ placement: SeriesTooltipPlacement.above,
+ builder: (context, details) => seriesCard(
+ '${details.values.first.point.x.toStringAsFixed(0)} lots',
+ [
+ for (final value in details.values)
+ (
+ value.series.label ?? '',
+ '${value.value.toStringAsFixed(2)}%',
+ value.color,
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+}
+
+/// The same bars a dashboard stacks, on a chart turned on its side.
+Widget seriesHorizontalScene() {
+ const income = [6.4, 5.1, 7.2, 4.6, 6.8, 7.9];
+ const spending = [3.1, 4.2, 2.8, 3.9, 3.2, 2.6];
+
+ return ColoredBox(
+ color: _seriesBackground,
+ child: Padding(
+ padding: const EdgeInsets.all(6),
+ child: seriesPanel(
+ 'Flow per month — stacked, and turned on its side',
+ figure: seriesUsd(25400),
+ SeriesChart(
+ orientation: SeriesOrientation.horizontal,
+ series: [
+ BarSeries.values(
+ income,
+ label: 'In',
+ color: _seriesGreen,
+ stack: 'flow',
+ maxWidth: 18,
+ ),
+ BarSeries.values(
+ spending,
+ label: 'Out',
+ color: _seriesRed,
+ stack: 'flow',
+ radius: 3,
+ maxWidth: 18,
+ labelBuilder: (index, point) =>
+ seriesUsd((income[index] + point.y!) * 1000),
+ labelStyle: _seriesAxis,
+ ),
+ ],
+ xAxis: SeriesXAxis(
+ labels: _seriesMonths.take(6).toList(),
+ style: _seriesAxis,
+ height: 34,
+ ),
+ yAxis: SeriesYAxis(
+ title: 'Thousands',
+ titleStyle: _seriesAxis,
+ style: _seriesAxis,
+ interval: 2,
+ formatter: (v) => v.toStringAsFixed(0),
+ ),
+ grid: const SeriesGrid(
+ horizontal: true,
+ vertical: false,
+ color: _seriesGrid,
+ dashPattern: [4, 4],
+ ),
+ maxY: 12,
+ touch: null,
+ ),
+ ),
+ ),
+ );
+}
+
+/// Bars that float between two values, and a band between two lines.
+Widget seriesRangesScene() {
+ // Each step runs from where the last one ended to where it leaves the
+ // balance; the first and the last are whole bars off zero.
+ const steps = [
+ (0.0, 4.0),
+ (4.0, 9.4),
+ (9.4, 8.1),
+ (8.1, 7.2),
+ (7.2, 5.9),
+ (0.0, 5.9),
+ ];
+ final forecast = seriesWave(30, base: 62, swing: 6, drift: 0.35);
+ final high = [for (var i = 0; i < 30; i++) forecast[i] + 3 + i * 0.22];
+ final low = [for (var i = 0; i < 30; i++) forecast[i] - 3 - i * 0.22];
+
+ return ColoredBox(
+ color: _seriesBackground,
+ child: Padding(
+ padding: const EdgeInsets.all(6),
+ child: Row(
+ children: [
+ Expanded(
+ child: seriesPanel(
+ 'Where the quarter went',
+ figure: r'$5.9k',
+ SeriesChart(
+ series: [
+ BarSeries(
+ points: [
+ for (var i = 0; i < steps.length; i++)
+ SeriesPoint(
+ i.toDouble(),
+ steps[i].$2,
+ low: steps[i].$1,
+ ),
+ ],
+ color: _seriesGreen,
+ radius: 3,
+ maxWidth: 34,
+ colorBuilder: (index, point) =>
+ index == 0 || index == steps.length - 1
+ ? _seriesBlue
+ : point.y! >= (point.low ?? 0)
+ ? _seriesGreen
+ : _seriesRed,
+ labelBuilder: (index, point) => point.y!.toStringAsFixed(1),
+ labelStyle: _seriesAxis,
+ ),
+ ],
+ xAxis: const SeriesXAxis(
+ labels: ['Open', 'Sales', 'Refunds', 'Fees', 'Tax', 'Close'],
+ style: _seriesAxis,
+ ),
+ yAxis: const SeriesYAxis(
+ width: 34,
+ style: _seriesAxis,
+ formatter: seriesUsd,
+ ),
+ grid: const SeriesGrid(
+ vertical: false,
+ color: _seriesGrid,
+ dashPattern: [4, 4],
+ ),
+ touch: null,
+ ),
+ ),
+ ),
+ Expanded(
+ child: seriesPanel(
+ 'Forecast, with its band and its error',
+ figure: '± 2σ',
+ SeriesChart(
+ series: [
+ LineSeries.values(
+ high,
+ color: _seriesBlue.withValues(alpha: 0.7),
+ width: 1,
+ dashPattern: const [5, 4],
+ ),
+ LineSeries.values(
+ low,
+ color: _seriesBlue.withValues(alpha: 0.7),
+ width: 1,
+ dashPattern: const [5, 4],
+ ),
+ LineSeries(
+ points: [
+ for (var i = 0; i < 30; i++)
+ SeriesPoint(
+ i.toDouble(),
+ forecast[i],
+ yError: i % 6 == 5
+ ? SeriesErrorRange.symmetric(2.4 + i * 0.06)
+ : null,
+ ),
+ ],
+ label: 'Forecast',
+ color: _seriesBlue,
+ curve: LineCurve.monotone,
+ errorBars: const SeriesErrorBars(
+ color: _seriesAmber,
+ capLength: 8,
+ ),
+ ),
+ ],
+ betweenFills: [
+ SeriesBetweenFill(
+ from: 0,
+ to: 1,
+ color: _seriesBlue.withValues(alpha: 0.14),
+ ),
+ ],
+ xPadding: 0.8,
+ xAxis: SeriesXAxis(
+ labels: [
+ for (var i = 0; i < 30; i++)
+ i % 7 == 0 ? seriesDate(i) : '',
+ ],
+ style: _seriesAxis,
+ ),
+ yAxis: const SeriesYAxis(
+ width: 34,
+ style: _seriesAxis,
+ formatter: seriesUsd,
+ ),
+ grid: const SeriesGrid(
+ vertical: false,
+ color: _seriesGrid,
+ dashPattern: [4, 4],
+ ),
+ touch: null,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+}
+
+Widget watermarkScene() {
+ final colors = ChartTheme.darkColors()
+ ..watermarkColor = const Color(0x1FFFFFFF);
+ return ColoredBox(
+ color: colors.bgColor,
+ child: KChartWidget(
+ MarketData.candles(count: 160),
+ colors,
+ chartStyle: ChartTheme.filled.copyWith(watermarkScale: 0.6),
+ timeFrame: MarketData.timeFrame,
+ timeFormat: TimeFormat.YEAR_MONTH_DAY_WITH_HOUR,
+ indicators: [MaIndicator(period: 20)],
+ showInfoDialog: false,
+ showScrollToNowButton: false,
+ fixedLength: 0,
+ watermark: const FittedBox(
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(Icons.candlestick_chart, size: 96),
+ SizedBox(width: 16),
+ Text(
+ 'ACME MARKETS',
+ style: TextStyle(
+ fontSize: 64,
+ fontWeight: FontWeight.w900,
+ letterSpacing: 2,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+}
diff --git a/lib/ohlcv_chart.dart b/lib/ohlcv_chart.dart
index 0b49296..29ef773 100644
--- a/lib/ohlcv_chart.dart
+++ b/lib/ohlcv_chart.dart
@@ -1,8 +1,9 @@
-/// Candlestick (K-line) and market-depth charts for Flutter.
+/// Candlestick (K-line), market-depth and series charts for Flutter.
///
-/// The two entry points are [KChartWidget], a fully interactive candlestick
-/// chart with indicator overlays, sub-charts and drawing tools, and
-/// [DepthChart], a bid/ask depth chart.
+/// The three entry points are [KChartWidget], a fully interactive candlestick
+/// chart with indicator overlays, sub-charts and drawing tools; [DepthChart], a
+/// bid/ask depth chart; and [SeriesChart], lines, areas and bars of plain
+/// values for dashboards and reports.
library;
export 'src/export.dart';
diff --git a/lib/src/chart_style.dart b/lib/src/chart_style.dart
index 5dc6697..3efb4df 100644
--- a/lib/src/chart_style.dart
+++ b/lib/src/chart_style.dart
@@ -333,7 +333,7 @@ class ChartColors {
/// Defaults to a translucent [bgColor] when null.
Color? legendBgColor;
- /// Tint of the SVG watermark.
+ /// The one colour the watermark widget is painted in.
///
/// Defaults to a very faint [defaultTextColor] when null.
Color? watermarkColor;
diff --git a/lib/src/chart_workspace.dart b/lib/src/chart_workspace.dart
index ab3f2d8..9afb150 100644
--- a/lib/src/chart_workspace.dart
+++ b/lib/src/chart_workspace.dart
@@ -64,7 +64,8 @@ class ChartWorkspace {
indicators: [
if (rawIndicators is List)
for (final entry in rawIndicators)
- if (entry is Map) ?indicatorFromJson(entry),
+ if (entry is Map)
+ if (indicatorFromJson(entry) case final v?) v,
],
drawings: switch (json['drawings']) {
final Map saved => ChartDrawings.fromJson(saved),
@@ -113,7 +114,8 @@ class ChartWorkspace {
return {
'version': formatVersion,
'indicators': [
- for (final indicator in indicators) ?indicatorToJson(indicator),
+ for (final indicator in indicators)
+ if (indicatorToJson(indicator) case final v?) v,
],
if (saved != null && saved.isNotEmpty) 'drawings': saved.toJson(),
if (chartType != null) 'chartType': chartType!.name,
diff --git a/lib/src/drawing/drawing_controller.dart b/lib/src/drawing/drawing_controller.dart
index d8bfbac..ef23133 100644
--- a/lib/src/drawing/drawing_controller.dart
+++ b/lib/src/drawing/drawing_controller.dart
@@ -20,7 +20,6 @@ import 'drawing_template.dart';
/// candles,
/// ChartColors(),
/// isTrendLine: true,
-/// watermarkAssetPath: 'assets/logo.svg',
/// timeFrame: const Duration(minutes: 15),
/// drawingController: drawings,
/// );
@@ -103,8 +102,10 @@ class ChartDrawingController extends ChangeNotifier {
/// One at most until something is added to the selection; the editor stays on
/// [selected], and an edit made through it is applied to the rest by the
/// chart.
- List get selection =>
- List.unmodifiable([..._alsoSelected, ?_selected]);
+ List get selection => List.unmodifiable([
+ ..._alsoSelected,
+ if (_selected case final v?) v,
+ ]);
/// How many drawings are selected.
int get selectionLength => _alsoSelected.length + (_selected == null ? 0 : 1);
diff --git a/lib/src/drawing/drawing_style.dart b/lib/src/drawing/drawing_style.dart
index 3399ad1..29450c9 100644
--- a/lib/src/drawing/drawing_style.dart
+++ b/lib/src/drawing/drawing_style.dart
@@ -40,7 +40,6 @@ const List kDefaultDrawingThicknesses = [
/// candles,
/// ChartColors(),
/// isTrendLine: true,
-/// watermarkAssetPath: 'assets/logo.svg',
/// timeFrame: const Duration(minutes: 15),
/// drawingStyle: const DrawingStyle(
/// colorOptions: [Colors.tealAccent, Colors.orangeAccent],
diff --git a/lib/src/entity/drawing_codec.dart b/lib/src/entity/drawing_codec.dart
index 30041ec..a261239 100644
--- a/lib/src/entity/drawing_codec.dart
+++ b/lib/src/entity/drawing_codec.dart
@@ -89,7 +89,8 @@ class ChartDrawings {
if (entries is! List) return ChartDrawings();
return ChartDrawings([
for (final entry in entries)
- if (entry is Map) ?drawingFromJson(entry),
+ if (entry is Map)
+ if (drawingFromJson(entry) case final v?) v,
]);
}
diff --git a/lib/src/entity/fib_retracement.dart b/lib/src/entity/fib_retracement.dart
index 84c346f..df601f4 100644
--- a/lib/src/entity/fib_retracement.dart
+++ b/lib/src/entity/fib_retracement.dart
@@ -76,7 +76,10 @@ class FibRetracement extends TwoPointDrawing implements AlertingDrawing {
// used: what matters is where price goes after the move, not during it.
final from = time1.isBefore(time2!) ? time1 : time2!;
if (time.isBefore(from)) return const [];
- return [for (final ratio in levels) ?priceAt(ratio)];
+ return [
+ for (final ratio in levels)
+ if (priceAt(ratio) case final v?) v,
+ ];
}
/// The price at [ratio] of the way from the first anchor to the second.
diff --git a/lib/src/export.dart b/lib/src/export.dart
index 73e25c7..197d6c5 100644
--- a/lib/src/export.dart
+++ b/lib/src/export.dart
@@ -71,3 +71,25 @@ export './renderer/main_renderer.dart' show VerticalTextAlignment;
export './utils/axis_ticks.dart';
export './utils/candle_transforms.dart';
export './utils/data_util.dart';
+export './heatmap/heatmap_chart.dart';
+export './heatmap/heatmap_data.dart';
+export './pie/pie_chart.dart';
+export './radar/radar_chart.dart';
+export './series/series_axis.dart'
+ show
+ SeriesAxisSide,
+ SeriesBand,
+ SeriesDirection,
+ SeriesGrid,
+ SeriesLabelBuilder,
+ SeriesReferenceLine,
+ SeriesXAxis,
+ SeriesXSide,
+ SeriesYAxis,
+ seriesAxisLabelStyle,
+ seriesAxisTitleStyle;
+export './series/series_chart.dart';
+export './series/series_data.dart' hide pointsOf;
+export './series/series_range_selector.dart';
+export './series/series_scale.dart' show SeriesOrientation;
+export './series/series_touch.dart';
diff --git a/lib/src/heatmap/heatmap_chart.dart b/lib/src/heatmap/heatmap_chart.dart
new file mode 100644
index 0000000..2ede9cb
--- /dev/null
+++ b/lib/src/heatmap/heatmap_chart.dart
@@ -0,0 +1,831 @@
+import 'dart:math' as math;
+
+import 'package:flutter/widgets.dart';
+
+import '../renderer/text_painter_cache.dart';
+import '../series/series_axis.dart';
+import 'heatmap_data.dart';
+
+/// Which side of the grid an axis's labels sit on.
+enum HeatmapAxisSide {
+ /// Under the grid, for the columns; to the left of it, for the rows.
+ start,
+
+ /// Over the grid, for the columns; to the right of it, for the rows.
+ end,
+}
+
+/// The labels down one side of a [HeatmapChart].
+@immutable
+class HeatmapAxis {
+ /// Creates an axis.
+ const HeatmapAxis({
+ this.labels = const [],
+ this.labelBuilder,
+ this.show = true,
+ this.size = 24,
+ this.side = HeatmapAxisSide.start,
+ this.style,
+ this.gap = 4,
+ this.interval = 1,
+ });
+
+ /// No labels and no room held for them.
+ static const hidden = HeatmapAxis(show: false, size: 0);
+
+ /// A label per column or row; a blank one prints nothing.
+ final List labels;
+
+ /// Writes the label for a column or row from its index; it wins over
+ /// [labels].
+ final String? Function(int index)? labelBuilder;
+
+ /// Whether labels are drawn and room is held for them.
+ final bool show;
+
+ /// Room held for the labels, across the axis.
+ final double size;
+
+ /// Which side of the grid they sit on.
+ final HeatmapAxisSide side;
+
+ /// Style of the labels.
+ final TextStyle? style;
+
+ /// Space between the grid and the labels.
+ final double gap;
+
+ /// Label only every nth column or row.
+ final int interval;
+
+ /// The label for [index], or null when there is none.
+ String? labelFor(int index) {
+ if (!show) return null;
+ if (interval > 1 && index % interval != 0) return null;
+ final builder = labelBuilder;
+ if (builder != null) return builder(index);
+ return index >= 0 && index < labels.length ? labels[index] : null;
+ }
+
+ /// How much room this axis takes, counting its gap.
+ double get room => show ? size + gap : 0;
+}
+
+/// Where a heatmap's grid sits, and which square is where.
+@immutable
+class HeatmapLayout {
+ /// Creates the layout of a [rows] by [columns] grid filling [grid].
+ const HeatmapLayout({
+ required this.grid,
+ required this.rows,
+ required this.columns,
+ this.spacing = 0,
+ });
+
+ /// The whole grid, labels excluded.
+ final Rect grid;
+
+ /// How many rows it has.
+ final int rows;
+
+ /// How many columns it has.
+ final int columns;
+
+ /// The gap left between two squares.
+ final double spacing;
+
+ /// How wide one column is, gap included.
+ double get columnWidth => columns <= 0 ? 0 : grid.width / columns;
+
+ /// How tall one row is, gap included.
+ double get rowHeight => rows <= 0 ? 0 : grid.height / rows;
+
+ /// The square at column [x], row [y].
+ Rect cellRect(int x, int y) => Rect.fromLTWH(
+ grid.left + x * columnWidth,
+ grid.top + y * rowHeight,
+ columnWidth,
+ rowHeight,
+ ).deflate(spacing / 2);
+
+ /// The square under [local], or null when it is off the grid.
+ (int, int)? cellAt(Offset local) {
+ if (columns <= 0 || rows <= 0 || !grid.contains(local)) return null;
+ final x = ((local.dx - grid.left) / columnWidth).floor();
+ final y = ((local.dy - grid.top) / rowHeight).floor();
+ if (x < 0 || x >= columns || y < 0 || y >= rows) return null;
+ return (x, y);
+ }
+
+ /// The middle of column [x], across the grid.
+ double columnCentre(int x) => grid.left + (x + 0.5) * columnWidth;
+
+ /// The middle of row [y], down the grid.
+ double rowCentre(int y) => grid.top + (y + 0.5) * rowHeight;
+}
+
+/// What a touch on a [HeatmapChart] landed on.
+@immutable
+class HeatmapTouchDetails {
+ /// Creates the details of a touch on the square at ([x], [y]).
+ const HeatmapTouchDetails({
+ required this.x,
+ required this.y,
+ required this.cell,
+ required this.rect,
+ });
+
+ /// The column touched.
+ final int x;
+
+ /// The row touched.
+ final int y;
+
+ /// The cell there; null when that square has none.
+ final HeatmapCell? cell;
+
+ /// The square, in the chart's local pixels.
+ final Rect rect;
+
+ /// The value there, or null when the square is empty.
+ double? get value => cell?.value;
+}
+
+/// A grid of squares coloured by their value — a heatmap.
+///
+/// It draws what a table of numbers hides: a contribution graph, a
+/// correlation matrix, sales by weekday and hour, a risk grid.
+///
+/// ```dart
+/// HeatmapChart(
+/// cells: heatmapCellsOf(byHourAndDay),
+/// scale: HeatmapGradientScale.of(green),
+/// xAxis: const HeatmapAxis(labels: dayNames),
+/// yAxis: const HeatmapAxis(labels: hourNames, size: 34),
+/// spacing: 3,
+/// radius: 3,
+/// );
+/// ```
+///
+/// The chart fills the box it is given, and is [defaultHeight] high in a box
+/// with no height of its own.
+class HeatmapChart extends StatefulWidget {
+ /// Creates a heatmap of [cells].
+ const HeatmapChart({
+ super.key,
+ required this.cells,
+ this.columns,
+ this.rows,
+ this.scale = const HeatmapGradientScale(
+ colors: [Color(0x224C86CD), heatmapDefaultColor],
+ ),
+ this.minValue,
+ this.maxValue,
+ this.xAxis = const HeatmapAxis(),
+ this.yAxis = const HeatmapAxis(size: 30),
+ this.spacing = 2,
+ this.radius = 2,
+ this.squareCells = false,
+ this.border,
+ this.labelBuilder,
+ this.labelStyle,
+ this.hoverBorder = const BorderSide(color: Color(0xCCFFFFFF), width: 1.5),
+ this.onTouch,
+ this.tooltipBuilder,
+ this.tooltipMargin = 10,
+ this.animationDuration = Duration.zero,
+ this.animationCurve = Curves.easeOutCubic,
+ this.animateOnMount = true,
+ this.padding = EdgeInsets.zero,
+ this.backgroundColor,
+ this.defaultHeight = 220,
+ this.semanticLabel,
+ });
+
+ /// Creates a heatmap of [values], a list of rows each holding its columns.
+ HeatmapChart.matrix(
+ List> values, {
+ super.key,
+ this.columns,
+ this.rows,
+ this.scale = const HeatmapGradientScale(
+ colors: [Color(0x224C86CD), heatmapDefaultColor],
+ ),
+ this.minValue,
+ this.maxValue,
+ this.xAxis = const HeatmapAxis(),
+ this.yAxis = const HeatmapAxis(size: 30),
+ this.spacing = 2,
+ this.radius = 2,
+ this.squareCells = false,
+ this.border,
+ this.labelBuilder,
+ this.labelStyle,
+ this.hoverBorder = const BorderSide(color: Color(0xCCFFFFFF), width: 1.5),
+ this.onTouch,
+ this.tooltipBuilder,
+ this.tooltipMargin = 10,
+ this.animationDuration = Duration.zero,
+ this.animationCurve = Curves.easeOutCubic,
+ this.animateOnMount = true,
+ this.padding = EdgeInsets.zero,
+ this.backgroundColor,
+ this.defaultHeight = 220,
+ this.semanticLabel,
+ }) : cells = heatmapCellsOf(values);
+
+ /// The squares, in any order; two cells at one position draw the later one.
+ final List cells;
+
+ /// How many columns the grid has; null takes the widest cell plus one.
+ final int? columns;
+
+ /// How many rows it has; null takes the lowest cell plus one.
+ final int? rows;
+
+ /// What turns a value into a colour.
+ final HeatmapScale scale;
+
+ /// The value the scale starts at; null takes the lowest cell.
+ final double? minValue;
+
+ /// The value it reaches; null takes the highest cell.
+ final double? maxValue;
+
+ /// The labels along the columns; [HeatmapAxis.hidden] for none.
+ final HeatmapAxis xAxis;
+
+ /// The labels down the rows; [HeatmapAxis.hidden] for none.
+ final HeatmapAxis yAxis;
+
+ /// The gap left between two squares.
+ final double spacing;
+
+ /// The corner radius of a square.
+ final double radius;
+
+ /// Whether the squares are kept square, which leaves the grid smaller than
+ /// the box when the two do not have the same shape.
+ final bool squareCells;
+
+ /// An outline round every square.
+ final BorderSide? border;
+
+ /// Writes what is printed in a square; [HeatmapCell.label] wins over it.
+ final String? Function(HeatmapCell cell)? labelBuilder;
+
+ /// Style of the printed labels; null picks black or white per square, by
+ /// how dark the square is.
+ final TextStyle? labelStyle;
+
+ /// Drawn round the square under the pointer; null marks none.
+ final BorderSide? hoverBorder;
+
+ /// Called as a touch moves over the squares, and with null when it leaves.
+ final ValueChanged? onTouch;
+
+ /// Builds a card shown beside the touched square; null shows none.
+ final Widget? Function(BuildContext context, HeatmapTouchDetails details)?
+ tooltipBuilder;
+
+ /// How far the card sits from the square.
+ final double tooltipMargin;
+
+ /// How long the squares take to come up to their colour; zero draws them at
+ /// once.
+ final Duration animationDuration;
+
+ /// The easing of [animationDuration].
+ final Curve animationCurve;
+
+ /// Whether the first build fades in.
+ final bool animateOnMount;
+
+ /// Space kept clear around the chart, labels included.
+ final EdgeInsets padding;
+
+ /// Painted behind the whole chart.
+ final Color? backgroundColor;
+
+ /// The height taken in a box that sets none.
+ final double defaultHeight;
+
+ /// What a screen reader announces for the chart.
+ final String? semanticLabel;
+
+ @override
+ State createState() => _HeatmapChartState();
+}
+
+class _HeatmapChartState extends State
+ with SingleTickerProviderStateMixin {
+ late final AnimationController _animation;
+ final TextPainterCache _text = TextPainterCache(capacity: 128);
+ (int, int)? _touched;
+
+ @override
+ void initState() {
+ super.initState();
+ _animation = AnimationController(
+ vsync: this,
+ duration: widget.animationDuration,
+ value: 1,
+ )..addListener(() => setState(() {}));
+ if (widget.animateOnMount && widget.animationDuration > Duration.zero) {
+ _animation.forward(from: 0);
+ }
+ }
+
+ @override
+ void didUpdateWidget(HeatmapChart oldWidget) {
+ super.didUpdateWidget(oldWidget);
+ _animation.duration = widget.animationDuration;
+ if (oldWidget.cells.length != widget.cells.length &&
+ widget.animationDuration > Duration.zero) {
+ _animation.forward(from: 0);
+ }
+ }
+
+ @override
+ void dispose() {
+ _animation.dispose();
+ super.dispose();
+ }
+
+ /// The cells by position, so a square is looked up rather than searched for.
+ Map<(int, int), HeatmapCell> get _byPosition => {
+ for (final cell in widget.cells) (cell.x, cell.y): cell,
+ };
+
+ int get _columns {
+ final given = widget.columns;
+ if (given != null) return math.max(0, given);
+ var high = -1;
+ for (final cell in widget.cells) {
+ high = math.max(high, cell.x);
+ }
+ return high + 1;
+ }
+
+ int get _rows {
+ final given = widget.rows;
+ if (given != null) return math.max(0, given);
+ var high = -1;
+ for (final cell in widget.cells) {
+ high = math.max(high, cell.y);
+ }
+ return high + 1;
+ }
+
+ void _handle(Offset local, HeatmapLayout layout) {
+ final at = layout.cellAt(local);
+ if (at == _touched) return;
+ setState(() => _touched = at);
+ final onTouch = widget.onTouch;
+ if (onTouch == null) return;
+ onTouch(at == null ? null : _detailsAt(at, layout));
+ }
+
+ void _leave() {
+ if (_touched == null) return;
+ setState(() => _touched = null);
+ widget.onTouch?.call(null);
+ }
+
+ HeatmapTouchDetails _detailsAt((int, int) at, HeatmapLayout layout) {
+ final (x, y) = at;
+ return HeatmapTouchDetails(
+ x: x,
+ y: y,
+ cell: _byPosition[at],
+ rect: layout.cellRect(x, y),
+ );
+ }
+
+ /// The grid, after the labels have taken their room, and squared off when
+ /// that was asked for.
+ HeatmapLayout _layout(Size size, int columns, int rows) {
+ final box = widget.padding.deflateRect(Offset.zero & size);
+ var left = box.left;
+ var right = box.right;
+ var top = box.top;
+ var bottom = box.bottom;
+
+ if (widget.yAxis.side == HeatmapAxisSide.start) {
+ left += widget.yAxis.room;
+ } else {
+ right -= widget.yAxis.room;
+ }
+ if (widget.xAxis.side == HeatmapAxisSide.start) {
+ bottom -= widget.xAxis.room;
+ } else {
+ top += widget.xAxis.room;
+ }
+
+ var grid = Rect.fromLTRB(
+ left,
+ top,
+ math.max(left, right),
+ math.max(top, bottom),
+ );
+ if (widget.squareCells && columns > 0 && rows > 0) {
+ final side = math.min(grid.width / columns, grid.height / rows);
+ grid = Rect.fromLTWH(grid.left, grid.top, side * columns, side * rows);
+ }
+ return HeatmapLayout(
+ grid: grid,
+ rows: rows,
+ columns: columns,
+ spacing: widget.spacing,
+ );
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final columns = _columns;
+ final rows = _rows;
+ final cells = _byPosition;
+ final (fittedMin, fittedMax) = heatmapValueRange(widget.cells);
+ final min = widget.minValue ?? fittedMin;
+ final max = widget.maxValue ?? fittedMax;
+ final t = widget.animationCurve.transform(_animation.value);
+
+ Widget chart = LayoutBuilder(
+ builder: (context, constraints) {
+ final width = constraints.hasBoundedWidth
+ ? constraints.maxWidth
+ : MediaQuery.maybeSizeOf(context)?.width ?? 300;
+ final height = constraints.hasBoundedHeight
+ ? constraints.maxHeight
+ : widget.defaultHeight;
+ final size = Size(width, height);
+ final layout = _layout(size, columns, rows);
+
+ final touched = _touched;
+ final details = touched == null ? null : _detailsAt(touched, layout);
+ final builder = widget.tooltipBuilder;
+ final tooltip = details == null || builder == null
+ ? null
+ : builder(context, details);
+
+ return SizedBox(
+ width: width,
+ height: height,
+ child: MouseRegion(
+ onHover: (e) => _handle(e.localPosition, layout),
+ onExit: (_) => _leave(),
+ child: GestureDetector(
+ behavior: HitTestBehavior.opaque,
+ onTapDown: (d) => _handle(d.localPosition, layout),
+ onTapUp: (_) => _leave(),
+ onTapCancel: _leave,
+ child: Stack(
+ clipBehavior: Clip.none,
+ children: [
+ Positioned.fill(
+ child: CustomPaint(
+ painter: HeatmapChartPainter(
+ chart: widget,
+ layout: layout,
+ cells: cells,
+ minValue: min,
+ maxValue: max,
+ touched: touched,
+ animation: t,
+ textCache: _text,
+ ),
+ ),
+ ),
+ if (tooltip != null && details != null)
+ Positioned.fill(
+ child: IgnorePointer(
+ child: CustomSingleChildLayout(
+ delegate: _HeatmapTooltipLayout(
+ anchor: details.rect,
+ margin: widget.tooltipMargin,
+ ),
+ child: tooltip,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ },
+ );
+
+ final label = widget.semanticLabel;
+ if (label != null) {
+ chart = Semantics(container: true, label: label, child: chart);
+ }
+ return chart;
+ }
+}
+
+/// Puts the tooltip above the touched square, kept inside the chart.
+class _HeatmapTooltipLayout extends SingleChildLayoutDelegate {
+ _HeatmapTooltipLayout({required this.anchor, required this.margin});
+
+ final Rect anchor;
+ final double margin;
+
+ @override
+ BoxConstraints getConstraintsForChild(BoxConstraints constraints) =>
+ BoxConstraints.loose(constraints.biggest);
+
+ @override
+ Offset getPositionForChild(Size size, Size childSize) {
+ var top = anchor.top - margin - childSize.height;
+ if (top < 0) top = anchor.bottom + margin;
+ return Offset(
+ (anchor.center.dx - childSize.width / 2).clamp(
+ 0.0,
+ math.max(0.0, size.width - childSize.width),
+ ),
+ top.clamp(0.0, math.max(0.0, size.height - childSize.height)),
+ );
+ }
+
+ @override
+ bool shouldRelayout(_HeatmapTooltipLayout oldDelegate) =>
+ oldDelegate.anchor != anchor || oldDelegate.margin != margin;
+}
+
+/// Paints a [HeatmapChart]: the squares, their labels and the axis labels.
+class HeatmapChartPainter extends CustomPainter {
+ /// Creates the painter for [chart], laid out as [layout] says.
+ HeatmapChartPainter({
+ required this.chart,
+ required this.layout,
+ required this.cells,
+ required this.minValue,
+ required this.maxValue,
+ required this.touched,
+ required this.animation,
+ required this.textCache,
+ });
+
+ final HeatmapChart chart;
+ final HeatmapLayout layout;
+ final Map<(int, int), HeatmapCell> cells;
+ final double minValue;
+ final double maxValue;
+ final (int, int)? touched;
+ final double animation;
+ final TextPainterCache textCache;
+
+ @override
+ void paint(Canvas canvas, Size size) {
+ final background = chart.backgroundColor;
+ if (background != null) {
+ canvas.drawRect(Offset.zero & size, Paint()..color = background);
+ }
+ if (layout.grid.width <= 0 ||
+ layout.grid.height <= 0 ||
+ layout.columns <= 0 ||
+ layout.rows <= 0) {
+ return;
+ }
+
+ _paintCells(canvas);
+ _paintLabels(canvas, size);
+ }
+
+ void _paintCells(Canvas canvas) {
+ final radius = Radius.circular(chart.radius);
+ final side = chart.border;
+ final outline =
+ side != null && side.style != BorderStyle.none && side.width > 0
+ ? (Paint()
+ ..style = PaintingStyle.stroke
+ ..strokeWidth = side.width
+ ..color = side.color
+ ..isAntiAlias = true)
+ : null;
+ // One path per colour, so a grid of a thousand squares is a handful of
+ // draws rather than a thousand.
+ final byColor = {};
+
+ for (var y = 0; y < layout.rows; y++) {
+ for (var x = 0; x < layout.columns; x++) {
+ final rect = layout.cellRect(x, y);
+ if (rect.width <= 0 || rect.height <= 0) continue;
+ final shape = RRect.fromRectAndRadius(rect, radius);
+ final color = _colorOf(cells[(x, y)]);
+ byColor.putIfAbsent(color, Path.new).addRRect(shape);
+ if (outline != null) canvas.drawRRect(shape, outline);
+ }
+ }
+ byColor.forEach((color, path) {
+ canvas.drawPath(
+ path,
+ Paint()
+ ..color = color
+ ..isAntiAlias = true);
+ });
+
+ final at = touched;
+ final hover = chart.hoverBorder;
+ if (at != null &&
+ hover != null &&
+ hover.style != BorderStyle.none &&
+ hover.width > 0) {
+ canvas.drawRRect(
+ RRect.fromRectAndRadius(
+ layout.cellRect(at.$1, at.$2).deflate(hover.width / 2),
+ radius,
+ ),
+ Paint()
+ ..style = PaintingStyle.stroke
+ ..strokeWidth = hover.width
+ ..color = hover.color
+ ..isAntiAlias = true,
+ );
+ }
+
+ _paintCellLabels(canvas);
+ }
+
+ /// The colour of one square, faded towards the empty colour while the chart
+ /// is still coming up.
+ Color _colorOf(HeatmapCell? cell) {
+ final empty = chart.scale.emptyColor;
+ if (cell == null || cell.isEmpty) return empty;
+ final full =
+ cell.color ?? chart.scale.colorAt(cell.value!, minValue, maxValue);
+ final t = animation.clamp(0.0, 1.0);
+ return t >= 1 ? full : Color.lerp(empty, full, t)!;
+ }
+
+ void _paintCellLabels(Canvas canvas) {
+ final builder = chart.labelBuilder;
+ for (final cell in cells.values) {
+ if (cell.x < 0 ||
+ cell.x >= layout.columns ||
+ cell.y < 0 ||
+ cell.y >= layout.rows) {
+ continue;
+ }
+ final text = cell.label ?? (builder == null ? null : builder(cell));
+ if (text == null || text.isEmpty) continue;
+ final rect = layout.cellRect(cell.x, cell.y);
+ // A label that cannot fit is left out rather than drawn over its
+ // neighbours.
+ final style = chart.labelStyle ??
+ TextStyle(
+ fontSize: 11,
+ fontWeight: FontWeight.w600,
+ color: _readableOn(_colorOf(cell)),
+ );
+ final tp = textCache.get(text, style);
+ if (tp.width > rect.width || tp.height > rect.height) continue;
+ tp.paint(
+ canvas,
+ rect.center - Offset(tp.width / 2, tp.height / 2),
+ );
+ }
+ }
+
+ void _paintLabels(Canvas canvas, Size size) {
+ final grid = layout.grid;
+ if (chart.xAxis.show) {
+ final style = seriesAxisLabelStyle.merge(chart.xAxis.style);
+ for (var x = 0; x < layout.columns; x++) {
+ final text = chart.xAxis.labelFor(x);
+ if (text == null || text.isEmpty) continue;
+ final tp = textCache.get(text, style);
+ tp.paint(
+ canvas,
+ Offset(
+ (layout.columnCentre(x) - tp.width / 2).clamp(
+ 0.0,
+ math.max(0.0, size.width - tp.width),
+ ),
+ chart.xAxis.side == HeatmapAxisSide.start
+ ? grid.bottom + chart.xAxis.gap
+ : math.max(0.0, grid.top - chart.xAxis.gap - tp.height),
+ ),
+ );
+ }
+ }
+
+ if (chart.yAxis.show) {
+ final style = seriesAxisLabelStyle.merge(chart.yAxis.style);
+ for (var y = 0; y < layout.rows; y++) {
+ final text = chart.yAxis.labelFor(y);
+ if (text == null || text.isEmpty) continue;
+ final tp = textCache.get(text, style);
+ tp.paint(
+ canvas,
+ Offset(
+ chart.yAxis.side == HeatmapAxisSide.start
+ ? math.max(0.0, grid.left - chart.yAxis.gap - tp.width)
+ : grid.right + chart.yAxis.gap,
+ (layout.rowCentre(y) - tp.height / 2).clamp(
+ 0.0,
+ math.max(0.0, size.height - tp.height),
+ ),
+ ),
+ );
+ }
+ }
+ }
+
+ /// Black or white, whichever reads on [background].
+ static Color _readableOn(Color background) =>
+ background.computeLuminance() > 0.5
+ ? const Color(0xDD000000)
+ : const Color(0xFFFFFFFF);
+
+ @override
+ bool shouldRepaint(HeatmapChartPainter oldDelegate) => true;
+}
+
+/// The key to a [HeatmapChart]'s colours: the scale as a bar, from the lowest
+/// value to the highest.
+class HeatmapLegend extends StatelessWidget {
+ /// Creates a legend for [scale].
+ const HeatmapLegend({
+ super.key,
+ required this.scale,
+ this.low,
+ this.high,
+ this.labelStyle,
+ this.height = 10,
+ this.width,
+ this.radius = 3,
+ this.gap = 6,
+ });
+
+ /// The scale it draws.
+ final HeatmapScale scale;
+
+ /// What the low end is called; null writes nothing.
+ final String? low;
+
+ /// What the high end is called.
+ final String? high;
+
+ /// Style of [low] and [high].
+ final TextStyle? labelStyle;
+
+ /// How thick the bar is.
+ final double height;
+
+ /// How long the bar is; null lets it take the room it is given.
+ final double? width;
+
+ /// The bar's corner radius.
+ final double radius;
+
+ /// Space between the bar and its labels.
+ final double gap;
+
+ @override
+ Widget build(BuildContext context) {
+ final style = seriesAxisLabelStyle
+ .copyWith(decoration: TextDecoration.none)
+ .merge(labelStyle);
+ final colors = scale.colors;
+ Widget bar = DecoratedBox(
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(radius),
+ // One colour would make a gradient of nothing, so it is doubled.
+ gradient: LinearGradient(
+ colors: colors.length >= 2
+ ? colors
+ : [
+ ...colors,
+ ...colors,
+ if (colors.isEmpty) ...[
+ heatmapEmptyColor,
+ heatmapDefaultColor,
+ ],
+ ],
+ ),
+ ),
+ child: SizedBox(height: height, width: width),
+ );
+ // Without a width the bar takes the room it is given, which only works in
+ // a row that is allowed to fill it.
+ final fills = width == null;
+ if (fills) bar = Expanded(child: bar);
+
+ return Row(
+ mainAxisSize: fills ? MainAxisSize.max : MainAxisSize.min,
+ children: [
+ if (low != null) ...[
+ Text(low!, style: style),
+ SizedBox(width: gap),
+ ],
+ bar,
+ if (high != null) ...[
+ SizedBox(width: gap),
+ Text(high!, style: style),
+ ],
+ ],
+ );
+ }
+}
diff --git a/lib/src/heatmap/heatmap_data.dart b/lib/src/heatmap/heatmap_data.dart
new file mode 100644
index 0000000..2adde87
--- /dev/null
+++ b/lib/src/heatmap/heatmap_data.dart
@@ -0,0 +1,195 @@
+import 'dart:math' as math;
+import 'dart:ui' show lerpDouble;
+
+import 'package:flutter/foundation.dart';
+import 'package:flutter/painting.dart';
+
+/// The colour a heatmap falls back on when its scale names none.
+const Color heatmapDefaultColor = Color(0xFF4C86CD);
+
+/// The colour of a square with no value in it.
+const Color heatmapEmptyColor = Color(0x14909196);
+
+/// One square of a [HeatmapChart]: the value at column [x], row [y].
+///
+/// [x] and [y] are whole positions in the grid, counted from zero — the
+/// column and the row, not pixels.
+@immutable
+class HeatmapCell {
+ /// Creates the square at ([x], [y]).
+ const HeatmapCell({
+ required this.x,
+ required this.y,
+ required this.value,
+ this.color,
+ this.label,
+ });
+
+ /// The column, counted from the left.
+ final int x;
+
+ /// The row, counted from the top.
+ final int y;
+
+ /// The value the square is coloured by; null leaves it empty.
+ final double? value;
+
+ /// A colour of this square's own, which the scale does not overrule.
+ final Color? color;
+
+ /// Written in the square; null asks `HeatmapChart.labelBuilder`.
+ final String? label;
+
+ /// Whether there is no value here to colour.
+ bool get isEmpty {
+ final v = value;
+ return v == null || !v.isFinite;
+ }
+
+ @override
+ bool operator ==(Object other) =>
+ other is HeatmapCell &&
+ other.x == x &&
+ other.y == y &&
+ other.value == value &&
+ other.color == color &&
+ other.label == label;
+
+ @override
+ int get hashCode => Object.hash(x, y, value, color, label);
+
+ @override
+ String toString() => 'HeatmapCell($x, $y, $value)';
+}
+
+/// Turns a value into the colour its square is painted.
+sealed class HeatmapScale {
+ /// Creates a scale.
+ const HeatmapScale({this.emptyColor = heatmapEmptyColor});
+
+ /// The colour of a square with no value.
+ final Color emptyColor;
+
+ /// The colour for [value], where the values run from [min] to [max].
+ Color colorAt(double value, double min, double max);
+
+ /// The colours this scale runs through, lowest first — what a legend draws.
+ List get colors;
+}
+
+/// A scale that fades from one colour to the next.
+///
+/// The values are spread over [colors] evenly, or at [stops] when those are
+/// given — each a share of the way from the lowest value to the highest.
+final class HeatmapGradientScale extends HeatmapScale {
+ /// Creates a scale fading through [colors].
+ const HeatmapGradientScale({
+ required this.colors,
+ this.stops,
+ super.emptyColor,
+ });
+
+ /// A single colour deepening from [lowOpacity] to full — the contribution
+ /// graph's scale.
+ HeatmapGradientScale.of(
+ Color color, {
+ double lowOpacity = 0.12,
+ super.emptyColor,
+ }) : colors = [color.withValues(alpha: lowOpacity), color],
+ stops = null;
+
+ @override
+ final List colors;
+
+ /// Where each colour sits, from 0 at the lowest value to 1 at the highest;
+ /// null spreads them evenly.
+ final List? stops;
+
+ @override
+ Color colorAt(double value, double min, double max) {
+ if (colors.isEmpty) return heatmapDefaultColor;
+ if (colors.length == 1) return colors.first;
+ final span = max - min;
+ final t = span <= 0 ? 1.0 : ((value - min) / span).clamp(0.0, 1.0);
+ final at = stops ?? _evenStops(colors.length);
+ for (var i = 1; i < at.length && i < colors.length; i++) {
+ if (t > at[i]) continue;
+ final width = at[i] - at[i - 1];
+ final local = width <= 0 ? 1.0 : (t - at[i - 1]) / width;
+ return Color.lerp(colors[i - 1], colors[i], local.clamp(0.0, 1.0))!;
+ }
+ return colors.last;
+ }
+
+ static List _evenStops(int count) => [
+ for (var i = 0; i < count; i++)
+ count < 2 ? 0.0 : lerpDouble(0, 1, i / (count - 1))!,
+ ];
+}
+
+/// One band of a [HeatmapStepScale].
+@immutable
+class HeatmapStep {
+ /// Paints every value from [from] up to the next step in [color].
+ const HeatmapStep(this.from, this.color, {this.label});
+
+ /// The lowest value this band covers.
+ final double from;
+
+ /// What that band is painted.
+ final Color color;
+
+ /// What the band is called, for a legend.
+ final String? label;
+}
+
+/// A scale that paints whole bands rather than a fade — under 0, 0 to 5, and
+/// so on.
+///
+/// The steps are read in order; a value below the first one takes the first
+/// step's colour.
+final class HeatmapStepScale extends HeatmapScale {
+ /// Creates a scale of [steps], lowest first.
+ const HeatmapStepScale({required this.steps, super.emptyColor});
+
+ /// The bands, lowest first.
+ final List steps;
+
+ @override
+ List get colors => [for (final step in steps) step.color];
+
+ @override
+ Color colorAt(double value, double min, double max) {
+ if (steps.isEmpty) return heatmapDefaultColor;
+ var chosen = steps.first.color;
+ for (final step in steps) {
+ if (value < step.from) break;
+ chosen = step.color;
+ }
+ return chosen;
+ }
+}
+
+/// The lowest and the highest value among [cells], or (0, 1) when there is
+/// nothing to measure.
+(double, double) heatmapValueRange(List cells) {
+ var low = double.infinity;
+ var high = double.negativeInfinity;
+ for (final cell in cells) {
+ if (cell.isEmpty) continue;
+ low = math.min(low, cell.value!);
+ high = math.max(high, cell.value!);
+ }
+ if (low > high) return (0, 1);
+ return (low, high);
+}
+
+/// Reads [values] — a list of rows, each a list of columns — as cells.
+///
+/// A row shorter than the widest one simply has no cells past its end, so a
+/// ragged table is drawn as the gaps it is.
+List heatmapCellsOf(List> values) => [
+ for (var y = 0; y < values.length; y++)
+ for (var x = 0; x < values[y].length; x++)
+ HeatmapCell(x: x, y: y, value: values[y][x]),
+ ];
diff --git a/lib/src/indicators/indicator.dart b/lib/src/indicators/indicator.dart
index 6cad992..299e70d 100644
--- a/lib/src/indicators/indicator.dart
+++ b/lib/src/indicators/indicator.dart
@@ -268,7 +268,6 @@ class IndicatorAlert {
/// candles,
/// ChartColors(),
/// isTrendLine: false,
-/// watermarkAssetPath: 'assets/logo.svg',
/// timeFrame: const Duration(minutes: 15),
/// indicators: [
/// MaIndicator(period: 50, color: Colors.amber),
diff --git a/lib/src/indicators/indicator_template.dart b/lib/src/indicators/indicator_template.dart
index 880aa80..16f8eed 100644
--- a/lib/src/indicators/indicator_template.dart
+++ b/lib/src/indicators/indicator_template.dart
@@ -36,7 +36,8 @@ class IndicatorTemplate {
indicators: [
if (raw is List)
for (final entry in raw)
- if (entry is Map) ?indicatorFromJson(entry),
+ if (entry is Map)
+ if (indicatorFromJson(entry) case final v?) v,
],
);
}
@@ -60,7 +61,8 @@ class IndicatorTemplate {
Map toJson() => {
'name': name,
'indicators': [
- for (final indicator in indicators) ?indicatorToJson(indicator),
+ for (final indicator in indicators)
+ if (indicatorToJson(indicator) case final v?) v,
],
};
diff --git a/lib/src/k_chart_widget.dart b/lib/src/k_chart_widget.dart
index 3288bee..08dd558 100644
--- a/lib/src/k_chart_widget.dart
+++ b/lib/src/k_chart_widget.dart
@@ -7,7 +7,6 @@ import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
-import 'package:flutter_svg/flutter_svg.dart';
import 'chart_controller.dart';
import 'chart_event.dart';
@@ -186,6 +185,47 @@ class TimeFormat {
];
}
+/// Sizes the watermark to `ChartStyle.watermarkScale` of the candle area's
+/// shorter side and places it by `ChartStyle.watermarkAlignment`, measuring
+/// that area the way [painter] lays it out.
+class _WatermarkLayout extends SingleChildLayoutDelegate {
+ _WatermarkLayout(this.painter);
+
+ final ChartPainter painter;
+
+ Rect _area(Size size) {
+ // The same layout the painter runs when it paints this size, so the
+ // watermark and the candles agree on where the candle area is.
+ painter.layout(size);
+ return painter.mMainRect;
+ }
+
+ @override
+ BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
+ final area = _area(constraints.biggest);
+ final width = math.max(
+ 0.0,
+ math.min(area.width, area.height) * painter.chartStyle.watermarkScale,
+ );
+ return BoxConstraints(
+ minWidth: width,
+ maxWidth: width,
+ maxHeight: math.max(0.0, area.height),
+ );
+ }
+
+ @override
+ Offset getPositionForChild(Size size, Size childSize) => painter
+ .chartStyle
+ .watermarkAlignment
+ .inscribe(childSize, painter.mMainRect)
+ .topLeft;
+
+ @override
+ bool shouldRelayout(_WatermarkLayout oldDelegate) =>
+ !identical(oldDelegate.painter, painter);
+}
+
/// An interactive candlestick chart.
///
/// Pass the candles positionally along with a [ChartColors]. Run
@@ -204,8 +244,6 @@ class TimeFormat {
/// KChartWidget(
/// candles,
/// ChartColors(),
-/// isTrendLine: false,
-/// watermarkAssetPath: 'assets/logo.svg',
/// timeFrame: const Duration(minutes: 15),
/// indicators: [MaIndicator(period: 20), MacdIndicator()],
/// );
@@ -215,9 +253,9 @@ class KChartWidget extends StatefulWidget {
const KChartWidget(
this.candles,
this.chartColors, {
- required this.isTrendLine,
- required this.watermarkAssetPath,
- required this.timeFrame,
+ this.isTrendLine = false,
+ this.watermark,
+ this.timeFrame,
this.xFrontPadding = 80,
this.signals = const [],
this.verticalLines = const [],
@@ -593,8 +631,11 @@ class KChartWidget extends StatefulWidget {
/// between each one.
final bool selectAfterDrawing;
- /// Duration of one candle, used to place lines and count down the close.
- final Duration timeFrame;
+ /// Duration of one candle, which the current-price tag counts down to.
+ ///
+ /// Null — the default — draws the current-price line and tag without a
+ /// countdown, since nothing else is known about when the candle closes.
+ final Duration? timeFrame;
/// Draws a filled close-price line instead of candles.
///
@@ -801,7 +842,7 @@ class KChartWidget extends StatefulWidget {
/// Which side the price axis labels sit on.
final VerticalTextAlignment verticalTextAlignment;
- /// Enables the drawing tools and their edit panel.
+ /// Enables the drawing tools and their edit panel. Off by default.
final bool isTrendLine;
/// Hides the volume pane.
@@ -876,8 +917,14 @@ class KChartWidget extends StatefulWidget {
/// Empty space kept to the right of the newest candle.
final double xFrontPadding;
- /// Asset path of an SVG watermark; a missing asset is ignored.
- final String watermarkAssetPath;
+ /// A watermark drawn faintly over the candle area — an `Image.asset`, an
+ /// icon, a line of text, any widget.
+ ///
+ /// It is painted in one colour, [ChartColors.effectiveWatermarkColor], so a
+ /// full-colour logo reads as a quiet silhouette; `ChartStyle.watermarkScale`
+ /// sets its width and `ChartStyle.watermarkAlignment` where it sits. It takes
+ /// no touches. Null — the default — draws none.
+ final Widget? watermark;
/// Narrowest the long-press info dialog may be.
final double infoDialogWidth;
@@ -946,7 +993,7 @@ class _KChartWidgetState extends State
List get _selection {
final controller = widget.drawingController;
if (controller != null) return controller.selection;
- return [..._localAlsoSelected, ?_localSelection];
+ return [..._localAlsoSelected, if (_localSelection case final v?) v];
}
/// Adds [line] to the selection, or takes it out if it is already in.
@@ -1050,7 +1097,6 @@ class _KChartWidgetState extends State
Animation? aniX;
Timer? _countdownTimer;
- PictureInfo? _watermarkPicture;
late Offset _toolbarOffset;
/// The height of each indicator pane, once the user has dragged one.
@@ -1116,7 +1162,6 @@ class _KChartWidgetState extends State
void initState() {
super.initState();
_toolbarOffset = widget.drawingStyle.toolbarInitialOffset;
- _loadWatermark();
_syncCountdownTimer();
_resolveIndicators();
widget.drawingController?.addListener(_onDrawingsChanged);
@@ -1558,7 +1603,7 @@ class _KChartWidgetState extends State
/// Runs the one-second repaint only while the now-price countdown is shown.
void _syncCountdownTimer() {
- if (widget.showNowPrice) {
+ if (widget.showNowPrice && widget.timeFrame != null) {
_countdownTimer ??= Timer.periodic(const Duration(seconds: 1), (_) {
if (mounted) setState(() {});
});
@@ -1568,26 +1613,11 @@ class _KChartWidgetState extends State
}
}
- Future _loadWatermark() async {
- try {
- final info = await vg.loadPicture(
- SvgAssetLoader(widget.watermarkAssetPath),
- null,
- );
- if (mounted) setState(() => _watermarkPicture = info);
- } catch (_) {
- // A missing or malformed watermark asset simply leaves the chart
- // unwatermarked; it must never break the chart itself.
- }
- }
-
@override
void didUpdateWidget(covariant KChartWidget oldWidget) {
super.didUpdateWidget(oldWidget);
- if (oldWidget.watermarkAssetPath != widget.watermarkAssetPath) {
- _loadWatermark();
- }
- if (oldWidget.showNowPrice != widget.showNowPrice) {
+ if (oldWidget.showNowPrice != widget.showNowPrice ||
+ (oldWidget.timeFrame == null) != (widget.timeFrame == null)) {
_syncCountdownTimer();
}
if (oldWidget.controller != widget.controller) {
@@ -2141,7 +2171,6 @@ class _KChartWidgetState extends State
verticalTextAlignment: widget.verticalTextAlignment,
dateFormatter: widget.dateFormatter,
priceFormatter: widget.priceFormatter,
- watermarkPicture: _watermarkPicture,
draftLine: _draft,
selectedLine: _selected,
selectedLines: _selection,
@@ -2427,6 +2456,25 @@ class _KChartWidgetState extends State
painter: painter,
),
),
+ if (widget.watermark case final watermark?)
+ Positioned.fill(
+ child: IgnorePointer(
+ child: RepaintBoundary(
+ child: CustomSingleChildLayout(
+ delegate: _WatermarkLayout(painter),
+ child: ColorFiltered(
+ colorFilter: ColorFilter.mode(
+ widget
+ .chartColors
+ .effectiveWatermarkColor,
+ BlendMode.srcIn,
+ ),
+ child: watermark,
+ ),
+ ),
+ ),
+ ),
+ ),
RepaintBoundary(
child: CustomPaint(
size: Size.fromHeight(
diff --git a/lib/src/pie/pie_chart.dart b/lib/src/pie/pie_chart.dart
new file mode 100644
index 0000000..8d1a1c5
--- /dev/null
+++ b/lib/src/pie/pie_chart.dart
@@ -0,0 +1,569 @@
+import 'dart:math' as math;
+
+import 'package:flutter/widgets.dart';
+
+import '../renderer/text_painter_cache.dart';
+
+/// The default colour of a section that names none.
+const Color pieDefaultColor = Color(0xFF4C86CD);
+
+/// Where a section's label sits, as a share of the way out from the middle of
+/// the ring to its outer edge.
+const double pieDefaultLabelPosition = 0.5;
+
+/// One wedge of a [PieChart].
+///
+/// [value] is a share, not an angle: the sections are added up and each one
+/// gets the part of the circle its value is worth.
+@immutable
+class PieSection {
+ /// Creates a section worth [value].
+ const PieSection({
+ required this.value,
+ this.color,
+ this.gradient,
+ this.label,
+ this.labelStyle,
+ this.labelPosition = pieDefaultLabelPosition,
+ this.radius,
+ this.border,
+ this.offset = 0,
+ this.badge,
+ this.badgePosition = 1,
+ });
+
+ /// The section's share of the circle. Zero or less draws nothing.
+ final double value;
+
+ /// Fill colour; null uses [pieDefaultColor].
+ final Color? color;
+
+ /// Fill gradient, measured over the section; it wins over [color].
+ final Gradient? gradient;
+
+ /// Written on the section; null or empty writes nothing.
+ final String? label;
+
+ /// Style of [label].
+ final TextStyle? labelStyle;
+
+ /// How far out the label sits: 0 at the inner edge, 1 at the outer one.
+ final double labelPosition;
+
+ /// How far this section reaches out from the middle; null uses the chart's
+ /// own radius, so one section can stand out by asking for more.
+ final double? radius;
+
+ /// An outline round the section.
+ final BorderSide? border;
+
+ /// How far the section is pushed out of the circle, in logical pixels — the
+ /// exploded slice of a pie.
+ final double offset;
+
+ /// A widget pinned to the section, such as an icon or a chip.
+ final Widget? badge;
+
+ /// How far out [badge] is pinned, the way [labelPosition] measures.
+ final double badgePosition;
+}
+
+/// One section as it was laid out: which part of the circle it covers.
+@immutable
+class PieSlice {
+ /// Creates a laid-out section.
+ const PieSlice({
+ required this.index,
+ required this.startAngle,
+ required this.sweepAngle,
+ required this.innerRadius,
+ required this.outerRadius,
+ });
+
+ /// Which of `PieChart.sections` this is.
+ final int index;
+
+ /// Where the section starts, in radians clockwise from three o'clock.
+ final double startAngle;
+
+ /// How much of the circle it covers, in radians.
+ final double sweepAngle;
+
+ /// The radius its inner edge sits at.
+ final double innerRadius;
+
+ /// The radius its outer edge reaches.
+ final double outerRadius;
+
+ /// The angle halfway through the section.
+ double get centreAngle => startAngle + sweepAngle / 2;
+
+ /// The direction the section points in.
+ Offset get direction => Offset(math.cos(centreAngle), math.sin(centreAngle));
+
+ /// The point [t] of the way out from the inner edge to the outer one.
+ Offset pointAt(Offset centre, double t) =>
+ centre + direction * (innerRadius + (outerRadius - innerRadius) * t);
+}
+
+/// Lays [sections] out round a circle.
+///
+/// Each section gets the share of `2π` its value is worth, starting at
+/// [startAngle] and going round clockwise — or the other way with [clockwise]
+/// off. [space] is taken off every section, so the wedges stand apart.
+///
+/// Sections worth nothing are left out; a set of sections worth nothing at
+/// all lays out as nothing.
+List layOutPie({
+ required List sections,
+ required double radius,
+ required double innerRadius,
+ double startAngle = -math.pi / 2,
+ double space = 0,
+ bool clockwise = true,
+ double animation = 1,
+}) {
+ var total = 0.0;
+ for (final section in sections) {
+ if (section.value.isFinite && section.value > 0) total += section.value;
+ }
+ if (total <= 0) return const [];
+
+ final gap = radius > 0 ? space / radius : 0.0;
+ final slices = [];
+ var covered = 0.0;
+ for (var i = 0; i < sections.length; i++) {
+ final section = sections[i];
+ if (!section.value.isFinite || section.value <= 0) continue;
+ final full = section.value / total * math.pi * 2;
+ final sweep = math.max(0.0, full - gap) * animation.clamp(0.0, 1.0);
+ // Going the other way, a section ends where a clockwise one would have
+ // started, so it is laid out backwards from there.
+ final from = clockwise ? startAngle + covered : startAngle - covered - full;
+ slices.add(
+ PieSlice(
+ index: i,
+ startAngle: from + gap / 2,
+ sweepAngle: sweep,
+ innerRadius: innerRadius,
+ outerRadius: section.radius ?? radius,
+ ),
+ );
+ covered += full;
+ }
+ return slices;
+}
+
+/// Which section [local] is inside, or null for the hole or the space around
+/// the circle.
+int? pieSectionAt({
+ required Offset local,
+ required Offset centre,
+ required List slices,
+}) {
+ final delta = local - centre;
+ final distance = delta.distance;
+ var angle = math.atan2(delta.dy, delta.dx);
+ for (final slice in slices) {
+ if (distance < slice.innerRadius || distance > slice.outerRadius) continue;
+ // Both ends are brought into one turn of the circle so a section that
+ // crosses three o'clock still matches.
+ var from = slice.startAngle;
+ while (angle < from) {
+ angle += math.pi * 2;
+ }
+ while (angle > from + math.pi * 2) {
+ from += math.pi * 2;
+ }
+ if (angle <= from + slice.sweepAngle) return slice.index;
+ }
+ return null;
+}
+
+/// What a touch on a [PieChart] landed on.
+@immutable
+class PieTouchDetails {
+ /// Creates the details of a touch on [index].
+ const PieTouchDetails({
+ required this.index,
+ required this.section,
+ required this.localPosition,
+ });
+
+ /// Which of `PieChart.sections` was touched, or -1 for none of them.
+ final int index;
+
+ /// The section touched; null when the touch missed them all.
+ final PieSection? section;
+
+ /// Where the touch was, in the chart's local pixels.
+ final Offset localPosition;
+}
+
+/// A ring of sections, each as wide as its share of the whole — a pie chart,
+/// or a doughnut when the middle is left open.
+///
+/// ```dart
+/// PieChart(
+/// sections: [
+/// PieSection(value: 40, color: blue, label: '40%'),
+/// PieSection(value: 35, color: green, label: '35%'),
+/// PieSection(value: 25, color: amber, label: '25%'),
+/// ],
+/// centerSpaceRadius: 40,
+/// centerChild: Text('Total'),
+/// );
+/// ```
+///
+/// The chart fills the box it is given, and is [defaultSize] square in a box
+/// that sets no size of its own.
+class PieChart extends StatefulWidget {
+ /// Creates a pie of [sections].
+ const PieChart({
+ super.key,
+ required this.sections,
+ this.radius,
+ this.centerSpaceRadius = 0,
+ this.centerSpaceColor,
+ this.centerChild,
+ this.sectionsSpace = 0,
+ this.startDegreeOffset = 0,
+ this.clockwise = true,
+ this.touchedSectionGrowth = 8,
+ this.onTouch,
+ this.animationDuration = Duration.zero,
+ this.animationCurve = Curves.easeOutCubic,
+ this.animateOnMount = true,
+ this.padding = EdgeInsets.zero,
+ this.backgroundColor,
+ this.defaultSize = 200,
+ this.semanticLabel,
+ });
+
+ /// The wedges, going round from [startDegreeOffset].
+ final List sections;
+
+ /// How far the sections reach; null fills the box.
+ final double? radius;
+
+ /// The hole in the middle; 0 draws a full pie.
+ final double centerSpaceRadius;
+
+ /// Painted in the hole; null leaves it clear.
+ final Color? centerSpaceColor;
+
+ /// A widget centred in the hole — a total, a title, a button.
+ final Widget? centerChild;
+
+ /// The gap between two sections, in logical pixels round the outer edge.
+ final double sectionsSpace;
+
+ /// Where the first section starts, in degrees clockwise from twelve
+ /// o'clock.
+ final double startDegreeOffset;
+
+ /// Whether the sections go round clockwise.
+ final bool clockwise;
+
+ /// How much further the section under the finger reaches; 0 leaves it be.
+ final double touchedSectionGrowth;
+
+ /// Called as a touch moves over the sections, and with null when it leaves.
+ final ValueChanged? onTouch;
+
+ /// How long the sections take to sweep in; zero draws them at once.
+ final Duration animationDuration;
+
+ /// The easing of [animationDuration].
+ final Curve animationCurve;
+
+ /// Whether the first build sweeps in.
+ final bool animateOnMount;
+
+ /// Space kept clear around the circle.
+ final EdgeInsets padding;
+
+ /// Painted behind the whole chart.
+ final Color? backgroundColor;
+
+ /// The size taken in a box that sets none.
+ final double defaultSize;
+
+ /// What a screen reader announces for the chart.
+ final String? semanticLabel;
+
+ @override
+ State createState() => _PieChartState();
+}
+
+class _PieChartState extends State
+ with SingleTickerProviderStateMixin {
+ late final AnimationController _animation;
+ final TextPainterCache _text = TextPainterCache(capacity: 64);
+ int? _touched;
+
+ @override
+ void initState() {
+ super.initState();
+ _animation = AnimationController(
+ vsync: this,
+ duration: widget.animationDuration,
+ value: 1,
+ )..addListener(() => setState(() {}));
+ if (widget.animateOnMount && widget.animationDuration > Duration.zero) {
+ _animation.forward(from: 0);
+ }
+ }
+
+ @override
+ void didUpdateWidget(PieChart oldWidget) {
+ super.didUpdateWidget(oldWidget);
+ _animation.duration = widget.animationDuration;
+ if (oldWidget.sections.length != widget.sections.length &&
+ widget.animationDuration > Duration.zero) {
+ _animation.forward(from: 0);
+ }
+ }
+
+ @override
+ void dispose() {
+ _animation.dispose();
+ super.dispose();
+ }
+
+ void _handle(Offset local, Offset centre, List slices) {
+ final index = pieSectionAt(local: local, centre: centre, slices: slices);
+ if (index == _touched) return;
+ setState(() => _touched = index);
+ widget.onTouch?.call(
+ index == null
+ ? null
+ : PieTouchDetails(
+ index: index,
+ section: widget.sections[index],
+ localPosition: local,
+ ),
+ );
+ }
+
+ void _leave() {
+ if (_touched == null) return;
+ setState(() => _touched = null);
+ widget.onTouch?.call(null);
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final t = widget.animationCurve.transform(_animation.value);
+
+ Widget chart = LayoutBuilder(
+ builder: (context, constraints) {
+ final width = constraints.hasBoundedWidth
+ ? constraints.maxWidth
+ : widget.defaultSize;
+ final height = constraints.hasBoundedHeight
+ ? constraints.maxHeight
+ : widget.defaultSize;
+ final size = Size(width, height);
+ final box = widget.padding.deflateRect(Offset.zero & size);
+ final centre = box.center;
+ final room = math.max(0.0, box.shortestSide / 2);
+ final radius = math.max(
+ 0.0,
+ (widget.radius ?? room) - widget.touchedSectionGrowth,
+ );
+ final inner = widget.centerSpaceRadius.clamp(0.0, radius);
+ final slices = layOutPie(
+ sections: widget.sections,
+ radius: radius,
+ innerRadius: inner.toDouble(),
+ startAngle: (widget.startDegreeOffset - 90) * math.pi / 180,
+ space: widget.sectionsSpace,
+ clockwise: widget.clockwise,
+ animation: t,
+ );
+
+ return SizedBox(
+ width: width,
+ height: height,
+ child: MouseRegion(
+ onHover: (e) => _handle(e.localPosition, centre, slices),
+ onExit: (_) => _leave(),
+ child: GestureDetector(
+ behavior: HitTestBehavior.opaque,
+ onTapDown: (d) => _handle(d.localPosition, centre, slices),
+ onTapUp: (_) => _leave(),
+ onTapCancel: _leave,
+ child: Stack(
+ clipBehavior: Clip.none,
+ children: [
+ Positioned.fill(
+ child: CustomPaint(
+ painter: PieChartPainter(
+ sections: widget.sections,
+ slices: slices,
+ centre: centre,
+ touched: _touched,
+ touchedGrowth: widget.touchedSectionGrowth,
+ centerSpaceColor: widget.centerSpaceColor,
+ centerSpaceRadius: inner.toDouble(),
+ backgroundColor: widget.backgroundColor,
+ textCache: _text,
+ ),
+ ),
+ ),
+ if (widget.centerChild != null)
+ Positioned(
+ left: centre.dx - inner,
+ top: centre.dy - inner,
+ width: inner * 2,
+ height: inner * 2,
+ child: Center(child: widget.centerChild),
+ ),
+ for (final slice in slices)
+ if (widget.sections[slice.index].badge != null)
+ _badge(slice, centre),
+ ],
+ ),
+ ),
+ ),
+ );
+ },
+ );
+
+ final label = widget.semanticLabel;
+ if (label != null) {
+ chart = Semantics(container: true, label: label, child: chart);
+ }
+ return chart;
+ }
+
+ Widget _badge(PieSlice slice, Offset centre) {
+ final section = widget.sections[slice.index];
+ final at = slice.pointAt(centre, section.badgePosition) +
+ slice.direction * section.offset;
+ return Positioned(
+ left: at.dx,
+ top: at.dy,
+ child: FractionalTranslation(
+ translation: const Offset(-0.5, -0.5),
+ child: section.badge,
+ ),
+ );
+ }
+}
+
+/// Paints a [PieChart]'s sections and their labels.
+class PieChartPainter extends CustomPainter {
+ /// Creates the painter for [slices], which lay [sections] out.
+ PieChartPainter({
+ required this.sections,
+ required this.slices,
+ required this.centre,
+ required this.touched,
+ required this.touchedGrowth,
+ required this.centerSpaceColor,
+ required this.centerSpaceRadius,
+ required this.backgroundColor,
+ required this.textCache,
+ });
+
+ final List sections;
+ final List slices;
+ final Offset centre;
+ final int? touched;
+ final double touchedGrowth;
+ final Color? centerSpaceColor;
+ final double centerSpaceRadius;
+ final Color? backgroundColor;
+ final TextPainterCache textCache;
+
+ static const TextStyle _labelStyle = TextStyle(
+ fontSize: 12,
+ color: Color(0xFFFFFFFF),
+ fontWeight: FontWeight.w600,
+ );
+
+ @override
+ void paint(Canvas canvas, Size size) {
+ final background = backgroundColor;
+ if (background != null) {
+ canvas.drawRect(Offset.zero & size, Paint()..color = background);
+ }
+
+ final hole = centerSpaceColor;
+ if (hole != null && centerSpaceRadius > 0) {
+ canvas.drawCircle(centre, centerSpaceRadius, Paint()..color = hole);
+ }
+
+ for (final slice in slices) {
+ final section = sections[slice.index];
+ final grown = slice.index == touched ? touchedGrowth : 0.0;
+ final outer = slice.outerRadius + grown;
+ if (outer <= 0 || slice.sweepAngle <= 0) continue;
+ final shift = slice.direction * section.offset;
+ final path = _sectionPath(slice, outer, shift);
+
+ final gradient = section.gradient;
+ final paint = Paint()..isAntiAlias = true;
+ if (gradient != null) {
+ paint.shader = gradient.createShader(path.getBounds());
+ } else {
+ paint.color = section.color ?? pieDefaultColor;
+ }
+ canvas.drawPath(path, paint);
+
+ final border = section.border;
+ if (border != null &&
+ border.style != BorderStyle.none &&
+ border.width > 0) {
+ canvas.drawPath(
+ path,
+ Paint()
+ ..style = PaintingStyle.stroke
+ ..strokeWidth = border.width
+ ..color = border.color
+ ..isAntiAlias = true,
+ );
+ }
+
+ final label = section.label;
+ if (label == null || label.isEmpty) continue;
+ final tp = textCache.get(label, _labelStyle.merge(section.labelStyle));
+ final at = centre +
+ shift +
+ slice.direction *
+ (slice.innerRadius +
+ (outer - slice.innerRadius) * section.labelPosition);
+ tp.paint(canvas, at - Offset(tp.width / 2, tp.height / 2));
+ }
+ }
+
+ /// The wedge of one section: the outer arc, the inner arc back, closed.
+ Path _sectionPath(PieSlice slice, double outer, Offset shift) {
+ final inner = slice.innerRadius;
+ final path = Path();
+ final outerRect = Rect.fromCircle(center: centre + shift, radius: outer);
+ if (inner <= 0) {
+ path
+ ..moveTo(centre.dx + shift.dx, centre.dy + shift.dy)
+ ..arcTo(outerRect, slice.startAngle, slice.sweepAngle, false)
+ ..close();
+ return path;
+ }
+ final innerRect = Rect.fromCircle(center: centre + shift, radius: inner);
+ path
+ ..arcTo(outerRect, slice.startAngle, slice.sweepAngle, true)
+ ..arcTo(
+ innerRect,
+ slice.startAngle + slice.sweepAngle,
+ -slice.sweepAngle,
+ false,
+ )
+ ..close();
+ return path;
+ }
+
+ @override
+ bool shouldRepaint(PieChartPainter oldDelegate) => true;
+}
diff --git a/lib/src/radar/radar_chart.dart b/lib/src/radar/radar_chart.dart
new file mode 100644
index 0000000..0cef01d
--- /dev/null
+++ b/lib/src/radar/radar_chart.dart
@@ -0,0 +1,656 @@
+import 'dart:math' as math;
+
+import 'package:flutter/widgets.dart';
+
+import '../renderer/text_painter_cache.dart';
+import '../series/series_axis.dart';
+import '../series/series_data.dart';
+
+/// The colour a radar series falls back on.
+const Color radarDefaultColor = Color(0xFF4C86CD);
+
+/// The colour of the web behind the series.
+const Color radarGridColor = Color(0x33909196);
+
+/// Which shape the web is drawn in.
+enum RadarShape {
+ /// Straight edges from feature to feature — the usual spider web.
+ polygon,
+
+ /// Rings.
+ circle,
+}
+
+/// One outline on a [RadarChart]: a value for every feature, in order.
+@immutable
+class RadarSeries {
+ /// Creates an outline through [values].
+ const RadarSeries({
+ required this.values,
+ this.label,
+ this.color,
+ this.fillColor,
+ this.width = 2,
+ this.dashPattern,
+ this.dot,
+ this.showInTooltip = true,
+ });
+
+ /// One value per feature; a value beyond the chart's range is clamped, and
+ /// a null one breaks the outline at that feature.
+ final List values;
+
+ /// What the series is called, for a legend or a tooltip.
+ final String? label;
+
+ /// Outline colour; null uses [radarDefaultColor].
+ final Color? color;
+
+ /// The fill inside the outline; null fills it with a faint [color].
+ final Color? fillColor;
+
+ /// Outline width; 0 draws no outline.
+ final double width;
+
+ /// Alternating dash and gap lengths; null draws solid.
+ final List? dashPattern;
+
+ /// A dot on every corner; null draws none.
+ final SeriesDot? dot;
+
+ /// Whether a touch on this series reports it.
+ final bool showInTooltip;
+
+ /// The colour the outline is drawn in.
+ Color get strokeColor => color ?? radarDefaultColor;
+}
+
+/// What a touch on a [RadarChart] landed on.
+@immutable
+class RadarTouchDetails {
+ /// Creates the details of a touch on the corner [featureIndex] of
+ /// [seriesIndex].
+ const RadarTouchDetails({
+ required this.seriesIndex,
+ required this.featureIndex,
+ required this.value,
+ required this.position,
+ });
+
+ /// Which of `RadarChart.series` was touched.
+ final int seriesIndex;
+
+ /// Which feature's corner it was.
+ final int featureIndex;
+
+ /// The value at that corner.
+ final double value;
+
+ /// Where the corner is, in the chart's local pixels.
+ final Offset position;
+}
+
+/// The corner of a radar web: [count] features spread evenly round a circle,
+/// the first one at twelve o'clock unless [startAngle] says otherwise.
+Offset radarCorner({
+ required Offset centre,
+ required double radius,
+ required int count,
+ required int index,
+ double startAngle = -math.pi / 2,
+}) {
+ if (count <= 0) return centre;
+ final angle = startAngle + index * math.pi * 2 / count;
+ return centre + Offset(math.cos(angle), math.sin(angle)) * radius;
+}
+
+/// A web of features with one outline per series — a radar, or spider, chart.
+///
+/// ```dart
+/// RadarChart(
+/// features: const ['Speed', 'Power', 'Range', 'Cost', 'Weight'],
+/// series: [
+/// RadarSeries(values: const [4, 3, 5, 2, 4], color: blue),
+/// RadarSeries(values: const [3, 5, 2, 4, 3], color: amber),
+/// ],
+/// maxValue: 5,
+/// );
+/// ```
+///
+/// The chart fills the box it is given, and is [defaultSize] square in a box
+/// that sets no size of its own.
+class RadarChart extends StatefulWidget {
+ /// Creates a radar of [series] over [features].
+ const RadarChart({
+ super.key,
+ required this.series,
+ this.features = const [],
+ this.minValue = 0,
+ this.maxValue,
+ this.tickCount = 4,
+ this.shape = RadarShape.polygon,
+ this.startDegreeOffset = 0,
+ this.gridColor = radarGridColor,
+ this.gridWidth = 1,
+ this.spokeColor,
+ this.spokeWidth = 1,
+ this.tickStyle,
+ this.showTicks = false,
+ this.featureStyle,
+ this.featureGap = 8,
+ this.radius,
+ this.onTouch,
+ this.touchThreshold = 24,
+ this.animationDuration = Duration.zero,
+ this.animationCurve = Curves.easeOutCubic,
+ this.animateOnMount = true,
+ this.padding = EdgeInsets.zero,
+ this.backgroundColor,
+ this.defaultSize = 220,
+ this.semanticLabel,
+ });
+
+ /// The outlines, bottom one first.
+ final List series;
+
+ /// What each corner is called, in the order the values are given; an empty
+ /// list leaves the corners unnamed.
+ final List features;
+
+ /// The value at the middle of the web.
+ final double minValue;
+
+ /// The value at its outer edge; null takes the largest value given.
+ final double? maxValue;
+
+ /// How many rings the web is drawn with.
+ final int tickCount;
+
+ /// Whether the rings are straight-edged or round.
+ final RadarShape shape;
+
+ /// Where the first feature sits, in degrees clockwise from twelve o'clock.
+ final double startDegreeOffset;
+
+ /// Colour of the rings.
+ final Color gridColor;
+
+ /// Width of the rings; 0 draws none.
+ final double gridWidth;
+
+ /// Colour of the spokes out to each feature; null follows [gridColor].
+ final Color? spokeColor;
+
+ /// Width of the spokes; 0 draws none.
+ final double spokeWidth;
+
+ /// Style of the ring values, when [showTicks] is on.
+ final TextStyle? tickStyle;
+
+ /// Whether each ring is labelled with the value it stands for.
+ final bool showTicks;
+
+ /// Style of the feature names.
+ final TextStyle? featureStyle;
+
+ /// Space between the outer ring and the feature names.
+ final double featureGap;
+
+ /// How far the web reaches; null fits the box, leaving room for the names.
+ final double? radius;
+
+ /// Called when a corner is touched, and with null when the touch leaves.
+ final ValueChanged? onTouch;
+
+ /// How close a touch has to be to a corner to pick it, in logical pixels.
+ final double touchThreshold;
+
+ /// How long the outlines take to grow out of the middle; zero draws them at
+ /// once.
+ final Duration animationDuration;
+
+ /// The easing of [animationDuration].
+ final Curve animationCurve;
+
+ /// Whether the first build grows in.
+ final bool animateOnMount;
+
+ /// Space kept clear around the web.
+ final EdgeInsets padding;
+
+ /// Painted behind the whole chart.
+ final Color? backgroundColor;
+
+ /// The size taken in a box that sets none.
+ final double defaultSize;
+
+ /// What a screen reader announces for the chart.
+ final String? semanticLabel;
+
+ @override
+ State createState() => _RadarChartState();
+}
+
+class _RadarChartState extends State
+ with SingleTickerProviderStateMixin {
+ late final AnimationController _animation;
+ final TextPainterCache _text = TextPainterCache(capacity: 64);
+
+ @override
+ void initState() {
+ super.initState();
+ _animation = AnimationController(
+ vsync: this,
+ duration: widget.animationDuration,
+ value: 1,
+ )..addListener(() => setState(() {}));
+ if (widget.animateOnMount && widget.animationDuration > Duration.zero) {
+ _animation.forward(from: 0);
+ }
+ }
+
+ @override
+ void didUpdateWidget(RadarChart oldWidget) {
+ super.didUpdateWidget(oldWidget);
+ _animation.duration = widget.animationDuration;
+ }
+
+ @override
+ void dispose() {
+ _animation.dispose();
+ super.dispose();
+ }
+
+ /// How many corners the web has: as many as the longest series, or as many
+ /// as there are names when there are more of those.
+ int get _featureCount {
+ var count = widget.features.length;
+ for (final s in widget.series) {
+ count = math.max(count, s.values.length);
+ }
+ return count;
+ }
+
+ double get _maxValue {
+ final given = widget.maxValue;
+ if (given != null) return given;
+ var high = widget.minValue;
+ for (final s in widget.series) {
+ for (final v in s.values) {
+ if (v != null && v.isFinite) high = math.max(high, v);
+ }
+ }
+ return high > widget.minValue ? high : widget.minValue + 1;
+ }
+
+ void _handle(Offset local, RadarLayout layout) {
+ final onTouch = widget.onTouch;
+ if (onTouch == null) return;
+ RadarTouchDetails? best;
+ var bestDistance = widget.touchThreshold * widget.touchThreshold;
+ for (var i = 0; i < widget.series.length; i++) {
+ final s = widget.series[i];
+ if (!s.showInTooltip) continue;
+ for (var j = 0; j < s.values.length; j++) {
+ final value = s.values[j];
+ if (value == null || !value.isFinite) continue;
+ final at = layout.cornerFor(j, value);
+ final distance = (at - local).distanceSquared;
+ if (distance <= bestDistance) {
+ bestDistance = distance;
+ best = RadarTouchDetails(
+ seriesIndex: i,
+ featureIndex: j,
+ value: value,
+ position: at,
+ );
+ }
+ }
+ }
+ onTouch(best);
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final t = widget.animationCurve.transform(_animation.value);
+
+ Widget chart = LayoutBuilder(
+ builder: (context, constraints) {
+ final width = constraints.hasBoundedWidth
+ ? constraints.maxWidth
+ : widget.defaultSize;
+ final height = constraints.hasBoundedHeight
+ ? constraints.maxHeight
+ : widget.defaultSize;
+ final box = widget.padding.deflateRect(
+ Offset.zero & Size(width, height),
+ );
+ // The names are written outside the web, so it gives up the room they
+ // need unless a radius was asked for.
+ final names = widget.features.isEmpty
+ ? 0.0
+ : _text
+ .get(
+ widget.features.reduce(
+ (a, b) => a.length >= b.length ? a : b,
+ ),
+ seriesAxisLabelStyle.merge(widget.featureStyle),
+ )
+ .width /
+ 2;
+ final radius = math.max(
+ 0.0,
+ widget.radius ??
+ box.shortestSide / 2 - widget.featureGap - names.clamp(0.0, 60.0),
+ );
+ final layout = RadarLayout(
+ centre: box.center,
+ radius: radius,
+ count: _featureCount,
+ minValue: widget.minValue,
+ maxValue: _maxValue,
+ startAngle: (widget.startDegreeOffset - 90) * math.pi / 180,
+ );
+
+ return SizedBox(
+ width: width,
+ height: height,
+ child: MouseRegion(
+ onHover: (e) => _handle(e.localPosition, layout),
+ onExit: (_) => widget.onTouch?.call(null),
+ child: GestureDetector(
+ behavior: HitTestBehavior.opaque,
+ onTapDown: (d) => _handle(d.localPosition, layout),
+ onTapUp: (_) => widget.onTouch?.call(null),
+ onTapCancel: () => widget.onTouch?.call(null),
+ child: CustomPaint(
+ painter: RadarChartPainter(
+ chart: widget,
+ layout: layout,
+ animation: t,
+ textCache: _text,
+ ),
+ ),
+ ),
+ ),
+ );
+ },
+ );
+
+ final label = widget.semanticLabel;
+ if (label != null) {
+ chart = Semantics(container: true, label: label, child: chart);
+ }
+ return chart;
+ }
+}
+
+/// Where a radar web sits and how its values turn into pixels.
+@immutable
+class RadarLayout {
+ /// Creates the layout of a web of [count] features.
+ const RadarLayout({
+ required this.centre,
+ required this.radius,
+ required this.count,
+ required this.minValue,
+ required this.maxValue,
+ required this.startAngle,
+ });
+
+ /// The middle of the web.
+ final Offset centre;
+
+ /// How far the outer ring reaches.
+ final double radius;
+
+ /// How many corners it has.
+ final int count;
+
+ /// The value at the middle.
+ final double minValue;
+
+ /// The value at the outer ring.
+ final double maxValue;
+
+ /// Where the first feature sits, in radians from three o'clock.
+ final double startAngle;
+
+ /// How far out [value] sits, from nothing at the middle to [radius].
+ double radiusFor(double value) {
+ final span = maxValue - minValue;
+ if (span <= 0) return 0;
+ return ((value - minValue) / span).clamp(0.0, 1.0) * radius;
+ }
+
+ /// The corner of feature [index] at [value].
+ Offset cornerFor(int index, double value) => radarCorner(
+ centre: centre,
+ radius: radiusFor(value),
+ count: count,
+ index: index,
+ startAngle: startAngle,
+ );
+
+ /// The corner of feature [index] on the ring at [t] of the way out.
+ Offset ringCorner(int index, double t) => radarCorner(
+ centre: centre,
+ radius: radius * t,
+ count: count,
+ index: index,
+ startAngle: startAngle,
+ );
+}
+
+/// Paints a [RadarChart]: the web, the feature names and the outlines.
+class RadarChartPainter extends CustomPainter {
+ /// Creates the painter for [chart], laid out as [layout] says.
+ RadarChartPainter({
+ required this.chart,
+ required this.layout,
+ required this.animation,
+ required this.textCache,
+ });
+
+ final RadarChart chart;
+ final RadarLayout layout;
+ final double animation;
+ final TextPainterCache textCache;
+
+ @override
+ void paint(Canvas canvas, Size size) {
+ final background = chart.backgroundColor;
+ if (background != null) {
+ canvas.drawRect(Offset.zero & size, Paint()..color = background);
+ }
+ if (layout.radius <= 0 || layout.count < 3) return;
+
+ _paintWeb(canvas);
+ _paintFeatures(canvas, size);
+ for (final series in chart.series) {
+ _paintSeries(canvas, series);
+ }
+ }
+
+ void _paintWeb(Canvas canvas) {
+ if (chart.gridWidth > 0 && chart.tickCount > 0) {
+ final paint = _stroke(chart.gridColor, chart.gridWidth);
+ for (var ring = 1; ring <= chart.tickCount; ring++) {
+ final t = ring / chart.tickCount;
+ if (chart.shape == RadarShape.circle) {
+ canvas.drawCircle(layout.centre, layout.radius * t, paint);
+ continue;
+ }
+ final path = Path();
+ for (var i = 0; i < layout.count; i++) {
+ final at = layout.ringCorner(i, t);
+ if (i == 0) {
+ path.moveTo(at.dx, at.dy);
+ } else {
+ path.lineTo(at.dx, at.dy);
+ }
+ }
+ canvas.drawPath(path..close(), paint);
+ }
+ }
+
+ if (chart.spokeWidth > 0) {
+ final paint = _stroke(
+ chart.spokeColor ?? chart.gridColor,
+ chart.spokeWidth,
+ );
+ for (var i = 0; i < layout.count; i++) {
+ canvas.drawLine(layout.centre, layout.ringCorner(i, 1), paint);
+ }
+ }
+
+ if (!chart.showTicks || chart.tickCount <= 0) return;
+ final style = seriesAxisLabelStyle.merge(chart.tickStyle);
+ for (var ring = 1; ring <= chart.tickCount; ring++) {
+ final t = ring / chart.tickCount;
+ final value = chart.minValue + (layout.maxValue - chart.minValue) * t;
+ final tp = textCache.get(_number(value), style);
+ // Written up the first spoke, where the outlines cross it least.
+ final at = layout.ringCorner(0, t);
+ tp.paint(canvas, at + const Offset(4, -2));
+ }
+ }
+
+ void _paintFeatures(Canvas canvas, Size size) {
+ if (chart.features.isEmpty) return;
+ final style = seriesAxisLabelStyle.merge(chart.featureStyle);
+ for (var i = 0; i < chart.features.length && i < layout.count; i++) {
+ final name = chart.features[i];
+ if (name.isEmpty) continue;
+ final tp = textCache.get(name, style);
+ final corner = layout.ringCorner(i, 1);
+ final direction = corner - layout.centre;
+ final distance = direction.distance;
+ final out =
+ distance == 0 ? Offset.zero : direction / distance * chart.featureGap;
+ // Pushed clear of the web by half the label, so a name on the left ends
+ // where the web starts rather than lying over it.
+ final at = corner +
+ out +
+ Offset(
+ (direction.dx / (distance == 0 ? 1 : distance)) * tp.width / 2,
+ (direction.dy / (distance == 0 ? 1 : distance)) * tp.height / 2,
+ );
+ tp.paint(
+ canvas,
+ Offset(
+ (at.dx - tp.width / 2).clamp(
+ 0.0,
+ math.max(0.0, size.width - tp.width),
+ ),
+ (at.dy - tp.height / 2).clamp(
+ 0.0,
+ math.max(0.0, size.height - tp.height),
+ ),
+ ),
+ );
+ }
+ }
+
+ void _paintSeries(Canvas canvas, RadarSeries series) {
+ final corners = [];
+ for (var i = 0; i < series.values.length && i < layout.count; i++) {
+ final value = series.values[i];
+ if (value == null || !value.isFinite) continue;
+ final grown =
+ chart.minValue + (value - chart.minValue) * animation.clamp(0.0, 1.0);
+ corners.add(layout.cornerFor(i, grown));
+ }
+ if (corners.length < 2) {
+ for (final at in corners) {
+ final dot = series.dot;
+ if (dot != null) _paintDot(canvas, at, dot, series.strokeColor);
+ }
+ return;
+ }
+
+ final path = Path()..moveTo(corners.first.dx, corners.first.dy);
+ for (final at in corners.skip(1)) {
+ path.lineTo(at.dx, at.dy);
+ }
+ path.close();
+
+ final fill = series.fillColor ?? series.strokeColor.withValues(alpha: 0.2);
+ canvas.drawPath(path, Paint()..color = fill);
+ if (series.width > 0) {
+ canvas.drawPath(
+ _dashed(path, series.dashPattern),
+ _stroke(series.strokeColor, series.width),
+ );
+ }
+ final dot = series.dot;
+ if (dot != null) {
+ for (final at in corners) {
+ _paintDot(canvas, at, dot, series.strokeColor);
+ }
+ }
+ }
+
+ void _paintDot(Canvas canvas, Offset at, SeriesDot dot, Color fallback) {
+ if (dot.radius <= 0) return;
+ canvas.drawCircle(
+ at,
+ dot.radius,
+ Paint()
+ ..color = dot.color ?? fallback
+ ..isAntiAlias = true,
+ );
+ final ring = dot.strokeColor;
+ if (ring != null && dot.strokeWidth > 0) {
+ canvas.drawCircle(
+ at,
+ dot.radius,
+ Paint()
+ ..style = PaintingStyle.stroke
+ ..strokeWidth = dot.strokeWidth
+ ..color = ring
+ ..isAntiAlias = true,
+ );
+ }
+ }
+
+ static Path _dashed(Path source, List? pattern) {
+ if (pattern == null || pattern.isEmpty || !pattern.any((v) => v > 0)) {
+ return source;
+ }
+ final dashed = Path();
+ for (final metric in source.computeMetrics()) {
+ var distance = 0.0;
+ var index = 0;
+ while (distance < metric.length) {
+ final length = math.max(0.0, pattern[index % pattern.length]);
+ if (index.isEven && length > 0) {
+ dashed.addPath(
+ metric.extractPath(
+ distance,
+ math.min(distance + length, metric.length),
+ ),
+ Offset.zero,
+ );
+ }
+ distance += length;
+ index++;
+ }
+ }
+ return dashed;
+ }
+
+ static Paint _stroke(Color color, double width) => Paint()
+ ..style = PaintingStyle.stroke
+ ..color = color
+ ..strokeWidth = width
+ ..strokeJoin = StrokeJoin.round
+ ..isAntiAlias = true;
+
+ static String _number(double value) => value == value.roundToDouble()
+ ? value.toStringAsFixed(0)
+ : value.toStringAsFixed(1);
+
+ @override
+ bool shouldRepaint(RadarChartPainter oldDelegate) => true;
+}
diff --git a/lib/src/renderer/base_chart_painter.dart b/lib/src/renderer/base_chart_painter.dart
index 2358b69..2e2b6b6 100644
--- a/lib/src/renderer/base_chart_painter.dart
+++ b/lib/src/renderer/base_chart_painter.dart
@@ -354,8 +354,6 @@ abstract class BaseChartPainter extends CustomPainter {
void drawSignals(Canvas canvas);
- void drawWatermarkLogo(Canvas canvas, Size size);
-
/// Smallest the candle area may become before the panes below it give way.
static const double minMainHeight = 60.0;
diff --git a/lib/src/renderer/chart_painter.dart b/lib/src/renderer/chart_painter.dart
index ccaa183..b0d12de 100644
--- a/lib/src/renderer/chart_painter.dart
+++ b/lib/src/renderer/chart_painter.dart
@@ -1,7 +1,6 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
-import 'package:flutter_svg/flutter_svg.dart' as vg;
import '../drawing/line_painting.dart';
import '../drawing/shape_geometry.dart';
@@ -24,7 +23,6 @@ class ChartPainter extends BaseChartPainter {
ChartPainter(
super.chartStyle,
this.chartColors, {
- required this.watermarkPicture,
required this.drawings,
required this.signals,
required this.isTrendLine,
@@ -38,7 +36,7 @@ class ChartPainter extends BaseChartPainter {
required super.xFrontPadding,
required super.baseDimension,
required this.verticalTextAlignment,
- required this.timeFrame,
+ this.timeFrame,
required this.draftLine,
required this.selectedLine,
this.selectedLines = const [],
@@ -288,8 +286,10 @@ class ChartPainter extends BaseChartPainter {
/// Writes the prices the axis and its readouts show; see
/// [KChartWidget.priceFormatter].
final String Function(double price)? priceFormatter;
- final vg.PictureInfo? watermarkPicture;
- final Duration timeFrame;
+
+ /// Duration of one candle; null draws the current-price tag without a
+ /// countdown.
+ final Duration? timeFrame;
int fixedLength;
late MainRenderer mMainRenderer;
@@ -501,7 +501,6 @@ class ChartPainter extends BaseChartPainter {
size.height,
);
canvas.drawRect(dateRect, mBgPaint);
- drawWatermarkLogo(canvas, size);
}
@override
@@ -2414,7 +2413,8 @@ class ChartPainter extends BaseChartPainter {
void drawPaths(Canvas canvas, Size size) {
for (final line in _withDraft(paths)) {
final points = [
- for (final point in line.points) ?_anchor(point.time, point.price),
+ for (final point in line.points)
+ if (_anchor(point.time, point.price) case final v?) v,
];
if (points.isEmpty) continue;
if (points.length == 1) {
@@ -3031,9 +3031,19 @@ class ChartPainter extends BaseChartPainter {
}
canvas.drawLine(Offset(lastX, y), Offset(mPlotRight, y), nowPricePaint);
+ final frame = timeFrame;
+ if (frame == null) {
+ final tp = getTextPainter(
+ mMainRenderer.formatAxis(value),
+ chartColors.nowPriceTextColor,
+ );
+ drawPriceTag(canvas, tp, y, nowPricePaint.color);
+ return;
+ }
+
String countdown = '00:00';
if (last.dateTime != null) {
- final closeTime = last.dateTime!.add(timeFrame);
+ final closeTime = last.dateTime!.add(frame);
final now = DateTime.now().toUtc();
final remaining = closeTime.difference(now);
if (!remaining.isNegative) {
@@ -3212,39 +3222,6 @@ class ChartPainter extends BaseChartPainter {
/// Pins [y] to the candle area, for a label that has to stay findable even
/// when the price it points at is off the top or the bottom of the axis.
double clampToMain(double y) => y.clamp(mMainRect.top, mMainRect.bottom);
-
- @override
- void drawWatermarkLogo(Canvas canvas, Size size) {
- final picture = watermarkPicture;
- if (picture == null) return;
-
- final area = Rect.fromLTRB(
- mPlotLeft,
- mTopPadding,
- mPlotRight,
- mTopPadding + mMainRect.height,
- );
- final logoWidth =
- math.min(area.width, area.height) * chartStyle.watermarkScale;
- final scale = logoWidth / picture.size.width;
- final logoHeight = picture.size.height * scale;
-
- final spot = chartStyle.watermarkAlignment.inscribe(
- Size(logoWidth, logoHeight),
- area,
- );
-
- canvas.save();
- canvas.saveLayer(
- spot,
- Paint()..color = chartColors.effectiveWatermarkColor,
- );
- canvas.translate(spot.left, spot.top);
- canvas.scale(scale, scale);
- canvas.drawPicture(picture.picture);
- canvas.restore();
- canvas.restore();
- }
}
/// Draws the crosshair, its readouts and the legends, over the chart.
diff --git a/lib/src/series/series_axis.dart b/lib/src/series/series_axis.dart
new file mode 100644
index 0000000..c31d64e
--- /dev/null
+++ b/lib/src/series/series_axis.dart
@@ -0,0 +1,368 @@
+import 'package:flutter/foundation.dart';
+import 'package:flutter/painting.dart';
+
+/// The colour axis labels use when no style is given.
+const Color seriesAxisTextColor = Color(0xFF909196);
+
+/// The grid colour when none is given.
+const Color seriesGridColor = Color(0x33909196);
+
+/// The style axis labels start from, before an axis's own style.
+const TextStyle seriesAxisLabelStyle = TextStyle(
+ fontSize: 10,
+ color: seriesAxisTextColor,
+);
+
+/// The style axis titles start from, before an axis's own title style.
+const TextStyle seriesAxisTitleStyle = TextStyle(
+ fontSize: 11,
+ color: seriesAxisTextColor,
+ fontWeight: FontWeight.w600,
+);
+
+/// Which side of the plot the value axis sits on.
+///
+/// In a horizontal chart, where the value axis runs across the plot, `left`
+/// puts it along the bottom and `right` along the top.
+enum SeriesAxisSide {
+ /// Labels to the left of the plot, right-aligned against it.
+ left,
+
+ /// Labels to the right of the plot, left-aligned against it.
+ right,
+}
+
+/// Which side of the plot the x axis sits on.
+///
+/// In a horizontal chart, where the x axis runs down the plot, `bottom` puts
+/// it on the left and `top` on the right.
+enum SeriesXSide {
+ /// Labels under the plot.
+ bottom,
+
+ /// Labels over the plot.
+ top,
+}
+
+/// Writes the label for one position on an axis; null or empty prints none.
+typedef SeriesLabelBuilder = String? Function(double value);
+
+/// The x axis of a [SeriesChart].
+///
+/// Which x values get a label — and a grid line — is decided in this order:
+/// [ticks] if given; otherwise every index whose entry in [labels] is not
+/// empty, thinned to multiples of [interval] when that is set; otherwise every
+/// [interval]; otherwise about [tickCount] round values. Labels that would
+/// overlap the one before are dropped.
+@immutable
+class SeriesXAxis {
+ /// Creates an x axis.
+ const SeriesXAxis({
+ this.show = true,
+ this.height = 22,
+ this.side = SeriesXSide.bottom,
+ this.labels,
+ this.labelBuilder,
+ this.ticks,
+ this.interval,
+ this.tickCount = 6,
+ this.style,
+ this.gap = 4,
+ this.fitInside = true,
+ this.title,
+ this.titleStyle,
+ });
+
+ /// No labels and no room held for them.
+ static const hidden = SeriesXAxis(show: false);
+
+ /// Whether labels are drawn and room is held for them.
+ final bool show;
+
+ /// Room held for the labels, measured across the axis. A horizontal chart
+ /// holds at least this beside the plot, and more when a label needs it.
+ final double height;
+
+ /// Which side of the plot the labels sit on.
+ final SeriesXSide side;
+
+ /// A label per index — `labels[i]` names `x = i`. An empty string prints
+ /// nothing, so a list that only names month starts spaces itself.
+ final List? labels;
+
+ /// Writes the label for an x value, overriding [labels].
+ final SeriesLabelBuilder? labelBuilder;
+
+ /// The exact x values to label and rule.
+ final List? ticks;
+
+ /// Label every this many units of x.
+ final double? interval;
+
+ /// About how many labels to print when nothing else decides.
+ final int tickCount;
+
+ /// Label text style.
+ final TextStyle? style;
+
+ /// Space between the plot and the labels.
+ final double gap;
+
+ /// Slides the first and last labels inward so they are not cut off.
+ final bool fitInside;
+
+ /// A name for the whole axis, written beyond its labels.
+ final String? title;
+
+ /// Style of [title].
+ final TextStyle? titleStyle;
+
+ /// The label for [x], or null when there is none.
+ ///
+ /// [step] is the distance between two labels, which decides how many
+ /// decimals a plain number is written with; without it, [interval] or 1.
+ String? labelFor(double x, {double? step}) {
+ final builder = labelBuilder;
+ if (builder != null) return builder(x);
+ final names = labels;
+ if (names != null) {
+ final index = x.round();
+ if ((x - index).abs() > 1e-9 || index < 0 || index >= names.length) {
+ return null;
+ }
+ return names[index];
+ }
+ return formatSeriesNumber(x, step ?? interval ?? 1);
+ }
+}
+
+/// The value axis of a [SeriesChart].
+@immutable
+class SeriesYAxis {
+ /// Creates a value axis.
+ const SeriesYAxis({
+ this.show = true,
+ this.side = SeriesAxisSide.left,
+ this.width = 40,
+ this.formatter,
+ this.ticks,
+ this.interval,
+ this.tickCount = 5,
+ this.style,
+ this.gap = 4,
+ this.title,
+ this.titleStyle,
+ });
+
+ /// No labels and no room held for them.
+ static const hidden = SeriesYAxis(show: false);
+
+ /// Whether labels are drawn and room is held for them.
+ final bool show;
+
+ /// Which side of the plot the labels sit on.
+ final SeriesAxisSide side;
+
+ /// Room beside the plot for the labels. Charts stacked one above another
+ /// line up when they share it. A horizontal chart measures its value labels
+ /// instead.
+ final double width;
+
+ /// Writes a value as its label; it also writes the default tooltip's values.
+ final String Function(double value)? formatter;
+
+ /// The exact values to label and rule.
+ final List? ticks;
+
+ /// Label every this many units, starting from a multiple of it.
+ final double? interval;
+
+ /// About how many labels to print when nothing else decides.
+ final int tickCount;
+
+ /// Label text style.
+ final TextStyle? style;
+
+ /// Space between the plot and the labels.
+ final double gap;
+
+ /// A name for the whole axis, written beyond its labels.
+ final String? title;
+
+ /// Style of [title].
+ final TextStyle? titleStyle;
+
+ /// Writes [value], using [formatter] when there is one and otherwise as many
+ /// decimals as [step] needs.
+ String format(double value, double step) {
+ final write = formatter;
+ return write != null ? write(value) : formatSeriesNumber(value, step);
+ }
+}
+
+/// The lines ruled across the plot at the axis labels.
+///
+/// Named for an upright chart: [horizontal] lines sit at the values and
+/// [vertical] lines at the x labels, whichever way the chart is turned.
+@immutable
+class SeriesGrid {
+ /// Creates a grid.
+ const SeriesGrid({
+ this.horizontal = true,
+ this.vertical = true,
+ this.color = seriesGridColor,
+ this.width = 1,
+ this.dashPattern,
+ this.verticalColor,
+ this.verticalDashPattern,
+ });
+
+ /// No grid lines.
+ static const none = SeriesGrid(horizontal: false, vertical: false);
+
+ /// Lines at the value-axis ticks.
+ final bool horizontal;
+
+ /// Lines at the x-axis ticks.
+ final bool vertical;
+
+ /// Line colour.
+ final Color color;
+
+ /// Line width.
+ final double width;
+
+ /// Dash and gap lengths, such as `[4, 4]`; null draws solid lines.
+ final List? dashPattern;
+
+ /// Colour of the lines at the x ticks; null follows [color].
+ final Color? verticalColor;
+
+ /// Dashes of the lines at the x ticks; null follows [dashPattern].
+ final List? verticalDashPattern;
+}
+
+/// Where a [SeriesReferenceLine] or [SeriesBand] is measured, named for an
+/// upright chart.
+enum SeriesDirection {
+ /// At a value — across an upright chart, down a horizontal one.
+ horizontal,
+
+ /// At an x — down an upright chart, across a horizontal one.
+ vertical,
+}
+
+/// A fixed level drawn over or under the series, such as a zero line or a
+/// target.
+///
+/// It is not interactive, and it does not widen the value range unless
+/// [extendsRange] says so — a far-off target would otherwise flatten the data.
+@immutable
+class SeriesReferenceLine {
+ /// A line at the value [value].
+ const SeriesReferenceLine.horizontal(
+ this.value, {
+ this.color = const Color(0x99909196),
+ this.width = 1,
+ this.dashPattern,
+ this.label,
+ this.labelStyle,
+ this.labelAlignment = Alignment.topRight,
+ this.aboveSeries = false,
+ this.extendsRange = false,
+ }) : direction = SeriesDirection.horizontal;
+
+ /// A line at x = [value].
+ const SeriesReferenceLine.vertical(
+ this.value, {
+ this.color = const Color(0x99909196),
+ this.width = 1,
+ this.dashPattern,
+ this.label,
+ this.labelStyle,
+ this.labelAlignment = Alignment.topRight,
+ this.aboveSeries = false,
+ }) : direction = SeriesDirection.vertical,
+ extendsRange = false;
+
+ /// What the line is measured at.
+ final SeriesDirection direction;
+
+ /// The value (horizontal) or x (vertical) it is drawn at.
+ final double value;
+
+ /// Line colour.
+ final Color color;
+
+ /// Line width.
+ final double width;
+
+ /// Dash and gap lengths; null draws solid.
+ final List? dashPattern;
+
+ /// Text written beside the line.
+ final String? label;
+
+ /// Style of [label].
+ final TextStyle? labelStyle;
+
+ /// Where along and beside the line [label] sits, as it runs on screen:
+ /// `topRight` puts it above the right end of a line across the plot.
+ final Alignment labelAlignment;
+
+ /// Whether it is drawn over the series rather than under them.
+ final bool aboveSeries;
+
+ /// Whether the value range grows to include a line at a value.
+ final bool extendsRange;
+}
+
+/// A shaded stretch of the plot between two values or two x positions.
+@immutable
+class SeriesBand {
+ /// A band between the values [from] and [to].
+ const SeriesBand.horizontal(this.from, this.to, {this.color, this.gradient})
+ : direction = SeriesDirection.horizontal;
+
+ /// A band between x = [from] and x = [to].
+ const SeriesBand.vertical(this.from, this.to, {this.color, this.gradient})
+ : direction = SeriesDirection.vertical;
+
+ /// What the band is measured at.
+ final SeriesDirection direction;
+
+ /// One edge.
+ final double from;
+
+ /// The other edge.
+ final double to;
+
+ /// Fill colour; ignored when [gradient] is set.
+ final Color? color;
+
+ /// Gradient laid over the band as it is drawn.
+ final Gradient? gradient;
+}
+
+/// Writes [value] with as many decimals as a step of [step] needs to tell its
+/// neighbours apart, and none for a whole number.
+String formatSeriesNumber(double value, double step) {
+ if (!value.isFinite) return '';
+ if (value == value.roundToDouble() && step >= 1) {
+ return value.toStringAsFixed(0);
+ }
+ // As many decimals as it takes for the step itself to be a whole number:
+ // one for 0.5, two for 0.25.
+ var decimals = 0;
+ var scaled = step.abs();
+ while (decimals < 8 &&
+ scaled > 0 &&
+ (scaled - scaled.roundToDouble()).abs() >
+ 1e-6 * (scaled < 1 ? 1 : scaled)) {
+ scaled *= 10;
+ decimals++;
+ }
+ if (step == 0) decimals = 2;
+ final text = value.toStringAsFixed(decimals);
+ return text == '-${0.toStringAsFixed(decimals)}' ? text.substring(1) : text;
+}
diff --git a/lib/src/series/series_chart.dart b/lib/src/series/series_chart.dart
new file mode 100644
index 0000000..4db10f4
--- /dev/null
+++ b/lib/src/series/series_chart.dart
@@ -0,0 +1,858 @@
+import 'dart:math' as math;
+import 'dart:ui' show lerpDouble;
+
+import 'package:flutter/widgets.dart';
+
+import '../renderer/text_painter_cache.dart';
+import 'series_axis.dart';
+import 'series_chart_painter.dart';
+import 'series_data.dart';
+import 'series_scale.dart';
+import 'series_touch.dart';
+
+/// A chart of plain values: lines, areas and bars over a numeric x axis.
+///
+/// Where `KChartWidget` reads candles against time, this reads any numbers
+/// against any numbers — a balance per day, a count per month, a return per
+/// trade — which is what a dashboard, a wallet or a report draws.
+///
+/// ```dart
+/// SeriesChart(
+/// series: [
+/// LineSeries.values(
+/// balances,
+/// color: purple,
+/// curve: LineCurve.monotone,
+/// fill: SeriesFill.fade(purple),
+/// ),
+/// ],
+/// xAxis: SeriesXAxis(labels: dayNames),
+/// yAxis: SeriesYAxis(formatter: formatUsd),
+/// touch: const SeriesTouch(trigger: SeriesTouchTrigger.longPress),
+/// );
+/// ```
+///
+/// The chart fills the box it is given. In a box with no height of its own —
+/// a `Column` without an `Expanded`, a scroll view — it is [defaultHeight]
+/// high.
+class SeriesChart extends StatefulWidget {
+ /// Creates a chart of [series].
+ const SeriesChart({
+ super.key,
+ required this.series,
+ this.xAxis = const SeriesXAxis(),
+ this.yAxis = const SeriesYAxis(),
+ this.grid = const SeriesGrid(),
+ this.border,
+ this.minX,
+ this.maxX,
+ this.xPadding,
+ this.minY,
+ this.maxY,
+ this.includeZero = false,
+ this.yPadding = 0.1,
+ this.niceYRange = true,
+ this.referenceLines = const [],
+ this.bands = const [],
+ this.betweenFills = const [],
+ this.orientation = SeriesOrientation.vertical,
+ this.touch = const SeriesTouch(),
+ this.controller,
+ this.onTouch,
+ this.animationDuration = Duration.zero,
+ this.animationCurve = Curves.easeOutCubic,
+ this.animateOnMount = true,
+ this.padding = EdgeInsets.zero,
+ this.backgroundColor,
+ this.clipToPlot = true,
+ this.defaultHeight = 200,
+ this.semanticLabel,
+ });
+
+ /// What to draw, bottom layer first.
+ final List series;
+
+ /// The horizontal axis; [SeriesXAxis.hidden] for none.
+ final SeriesXAxis xAxis;
+
+ /// The value axis; [SeriesYAxis.hidden] for none.
+ final SeriesYAxis yAxis;
+
+ /// Lines ruled at the axis ticks; [SeriesGrid.none] for none.
+ final SeriesGrid grid;
+
+ /// A line around the plot; null draws none.
+ final BorderSide? border;
+
+ /// The x at the plot's left edge; null fits the data.
+ ///
+ /// With [maxX] this shows a window of a longer series — the values outside
+ /// are clipped away, and the value range fits what is left.
+ final double? minX;
+
+ /// The x at the plot's right edge; null fits the data.
+ final double? maxX;
+
+ /// Room either side of the fitted x range, in x units; null leaves half a
+ /// unit when there are bars, so the end bars are whole, and none otherwise.
+ final double? xPadding;
+
+ /// The value at the plot's bottom edge; null fits the data.
+ final double? minY;
+
+ /// The value at the plot's top edge; null fits the data.
+ final double? maxY;
+
+ /// Whether the fitted value range always reaches zero.
+ final bool includeZero;
+
+ /// Room above and below the fitted values, as a share of their range.
+ final double yPadding;
+
+ /// Whether a fitted value range is widened to a round tick at either end.
+ final bool niceYRange;
+
+ /// Fixed levels, such as a zero line.
+ final List referenceLines;
+
+ /// Shaded stretches of the plot.
+ final List bands;
+
+ /// Areas filled between two of the [series], painted under them.
+ final List betweenFills;
+
+ /// Which way the chart is turned; [SeriesOrientation.horizontal] lays the x
+ /// axis down the side and grows the bars rightwards.
+ final SeriesOrientation orientation;
+
+ /// How the chart answers touch and hover; null makes it ignore both.
+ final SeriesTouch? touch;
+
+ /// Shows the crosshair from code, or shares it with other charts.
+ final SeriesChartController? controller;
+
+ /// Called after the crosshair moves to another x, and with null when it
+ /// goes.
+ final ValueChanged? onTouch;
+
+ /// How long new values take to move into place; zero snaps.
+ ///
+ /// With the same number of points as before, each value moves from where it
+ /// was. Otherwise — and on the first build, when [animateOnMount] is set —
+ /// the values grow out of each series' baseline.
+ final Duration animationDuration;
+
+ /// The easing of [animationDuration].
+ final Curve animationCurve;
+
+ /// Whether the first build grows in, when [animationDuration] is not zero.
+ final bool animateOnMount;
+
+ /// Space kept clear around the chart, axes included.
+ final EdgeInsets padding;
+
+ /// Painted behind the whole chart; null leaves it transparent.
+ final Color? backgroundColor;
+
+ /// Whether the series are cut off at the plot's edges, or only at the
+ /// chart's.
+ final bool clipToPlot;
+
+ /// The height taken in a box that sets none.
+ final double defaultHeight;
+
+ /// What a screen reader announces for the chart.
+ final String? semanticLabel;
+
+ @override
+ State createState() => _SeriesChartState();
+}
+
+class _SeriesChartState extends State
+ with SingleTickerProviderStateMixin {
+ late final AnimationController _animation;
+ final TextPainterCache _text = TextPainterCache(capacity: 128);
+
+ /// Where the running animation started from; null when nothing is moving.
+ List>? _fromValues;
+ SeriesViewport? _fromViewport;
+
+ /// What the last frame drew, which a new animation starts from.
+ List>? _drawnValues;
+ SeriesViewport? _drawnViewport;
+
+ SeriesGeometry? _geometry;
+ double? _localX;
+ int? _localSeries;
+ int? _localPoint;
+ double? _reportedX;
+
+ @override
+ void initState() {
+ super.initState();
+ _animation = AnimationController(
+ vsync: this,
+ duration: widget.animationDuration,
+ value: 1,
+ )
+ ..addListener(_onTick)
+ ..addStatusListener(_onStatus);
+ widget.controller?.addListener(_onController);
+ if (widget.animateOnMount && widget.animationDuration > Duration.zero) {
+ _growIn();
+ }
+ }
+
+ @override
+ void didUpdateWidget(SeriesChart oldWidget) {
+ super.didUpdateWidget(oldWidget);
+ if (oldWidget.controller != widget.controller) {
+ oldWidget.controller?.removeListener(_onController);
+ widget.controller?.addListener(_onController);
+ }
+ _animation.duration = widget.animationDuration;
+
+ if (_sameValues(oldWidget.series, widget.series)) return;
+ if (widget.animationDuration <= Duration.zero) {
+ _fromValues = null;
+ _fromViewport = null;
+ return;
+ }
+ final drawn = _drawnValues;
+ if (drawn != null && _sameShape(drawn, widget.series)) {
+ _fromValues = drawn;
+ _fromViewport = _drawnViewport;
+ _animation.forward(from: 0);
+ } else {
+ _growIn();
+ }
+ }
+
+ @override
+ void dispose() {
+ widget.controller?.removeListener(_onController);
+ _animation.dispose();
+ super.dispose();
+ }
+
+ void _onTick() => setState(() {});
+
+ void _onStatus(AnimationStatus status) {
+ if (status == AnimationStatus.completed) {
+ setState(() {
+ _fromValues = null;
+ _fromViewport = null;
+ });
+ }
+ }
+
+ void _onController() => setState(() {});
+
+ void _growIn() {
+ final target = _targetValues(widget.series);
+ final viewport = _fit(target);
+ _fromValues = [
+ for (var i = 0; i < widget.series.length; i++)
+ [
+ for (final v in target[i])
+ v == null
+ ? null
+ : widget.series[i].baseline.clamp(viewport.minY, viewport.maxY),
+ ],
+ ];
+ _fromViewport = null;
+ _animation.forward(from: 0);
+ }
+
+ static List> _targetValues(List series) => [
+ for (final s in series)
+ [for (final p in s.points) p.isGap ? null : p.y],
+ ];
+
+ static bool _sameValues(List a, List b) {
+ if (identical(a, b)) return true;
+ if (a.length != b.length) return false;
+ for (var i = 0; i < a.length; i++) {
+ final pa = a[i].points;
+ final pb = b[i].points;
+ if (identical(pa, pb)) continue;
+ if (pa.length != pb.length) return false;
+ for (var j = 0; j < pa.length; j++) {
+ if (pa[j] != pb[j]) return false;
+ }
+ }
+ return true;
+ }
+
+ static bool _sameShape(List> values, List series) {
+ if (values.length != series.length) return false;
+ for (var i = 0; i < series.length; i++) {
+ if (values[i].length != series[i].points.length) return false;
+ }
+ return true;
+ }
+
+ SeriesViewport _fit(List> values) => fitSeriesViewport(
+ series: widget.series,
+ values: values,
+ xAxis: widget.xAxis,
+ yAxis: widget.yAxis,
+ referenceLines: widget.referenceLines,
+ minX: widget.minX,
+ maxX: widget.maxX,
+ xPadding: widget.xPadding,
+ minY: widget.minY,
+ maxY: widget.maxY,
+ includeZero: widget.includeZero,
+ yPadding: widget.yPadding,
+ niceYRange: widget.niceYRange,
+ );
+
+ // ── Touch ───────────────────────────────────────────────────────────────
+
+ double? get _touchX {
+ final controller = widget.controller;
+ return controller != null ? controller.x : _localX;
+ }
+
+ int? get _touchSeries {
+ final controller = widget.controller;
+ return controller != null ? controller.seriesIndex : _localSeries;
+ }
+
+ int? get _touchPoint {
+ final controller = widget.controller;
+ return controller != null ? controller.pointIndex : _localPoint;
+ }
+
+ void _showAt(Offset local) {
+ final geometry = _geometry;
+ if (geometry == null) return;
+ final touch = widget.touch;
+ if (touch != null && touch.snap == SeriesTouchSnap.nearestPoint) {
+ final hit = _nearestPoint(geometry, local, touch.threshold);
+ if (hit == null) return;
+ final (x, seriesIndex, pointIndex) = hit;
+ final controller = widget.controller;
+ if (controller != null) {
+ controller.show(x, seriesIndex: seriesIndex, pointIndex: pointIndex);
+ } else if (_localX != x ||
+ _localSeries != seriesIndex ||
+ _localPoint != pointIndex) {
+ setState(() {
+ _localX = x;
+ _localSeries = seriesIndex;
+ _localPoint = pointIndex;
+ });
+ }
+ return;
+ }
+ final x = _snap(geometry, geometry.pxToXAt(local));
+ if (x == null) return;
+ final controller = widget.controller;
+ if (controller != null) {
+ controller.show(x);
+ } else if (_localX != x || _localSeries != null) {
+ setState(() {
+ _localX = x;
+ _localSeries = null;
+ _localPoint = null;
+ });
+ }
+ }
+
+ void _toggleAt(Offset local) {
+ final geometry = _geometry;
+ if (geometry == null) return;
+ final x = _snap(geometry, geometry.pxToXAt(local));
+ if (x != null && x == _touchX) {
+ _release();
+ } else {
+ _showAt(local);
+ }
+ }
+
+ void _release() {
+ final controller = widget.controller;
+ if (controller != null) {
+ controller.clear();
+ } else if (_localX != null) {
+ setState(() {
+ _localX = null;
+ _localSeries = null;
+ _localPoint = null;
+ });
+ }
+ }
+
+ /// The point nearest [local], and its series, within [threshold] pixels.
+ (double, int, int)? _nearestPoint(
+ SeriesGeometry geometry,
+ Offset local,
+ double threshold,
+ ) {
+ final viewport = geometry.viewport;
+ (double, int, int)? best;
+ var bestDistance = threshold * threshold;
+ for (var i = 0; i < widget.series.length; i++) {
+ final s = widget.series[i];
+ if (!s.showInTooltip) continue;
+ final row = _drawnValues != null && i < _drawnValues!.length
+ ? _drawnValues![i]
+ : const [];
+ for (var j = 0; j < s.points.length; j++) {
+ final point = s.points[j];
+ if (point.isGap || point.x < viewport.minX || point.x > viewport.maxX) {
+ continue;
+ }
+ final y = j < row.length ? row[j] : point.y;
+ if (y == null || !y.isFinite) continue;
+ final at = geometry.point(point.x, y);
+ final distance = (at - local).distanceSquared;
+ if (distance <= bestDistance) {
+ bestDistance = distance;
+ best = (point.x, i, j);
+ }
+ }
+ }
+ return best;
+ }
+
+ /// The x of the point nearest [wanted], among those in view.
+ double? _snap(SeriesGeometry geometry, double wanted) {
+ final viewport = geometry.viewport;
+ double? best;
+ var bestDistance = double.infinity;
+ for (final s in widget.series) {
+ final index = _nearest(s.points, wanted);
+ if (index < 0) continue;
+ for (final j in [index - 1, index, index + 1]) {
+ if (j < 0 || j >= s.points.length) continue;
+ final p = s.points[j];
+ if (p.isGap || p.x < viewport.minX || p.x > viewport.maxX) continue;
+ final distance = (p.x - wanted).abs();
+ if (distance < bestDistance) {
+ bestDistance = distance;
+ best = p.x;
+ }
+ }
+ }
+ return best;
+ }
+
+ /// The index of the point with x nearest [x], in points sorted by x.
+ static int _nearest(List points, double x) {
+ if (points.isEmpty) return -1;
+ var low = 0;
+ var high = points.length - 1;
+ while (low < high) {
+ final mid = (low + high) >> 1;
+ if (points[mid].x < x) {
+ low = mid + 1;
+ } else {
+ high = mid;
+ }
+ }
+ if (low > 0 && (x - points[low - 1].x).abs() <= (points[low].x - x).abs()) {
+ return low - 1;
+ }
+ return low;
+ }
+
+ static int _indexAt(List points, double x) {
+ final index = _nearest(points, x);
+ if (index < 0) return -1;
+ return (points[index].x - x).abs() <= 1e-9 ? index : -1;
+ }
+
+ SeriesTouchDetails _detailsAt(
+ SeriesGeometry geometry,
+ List> values,
+ double x, {
+ int? onlySeries,
+ int? onlyPoint,
+ }) {
+ final touched = [];
+ for (var i = 0; i < widget.series.length; i++) {
+ if (onlySeries != null && i != onlySeries) continue;
+ final s = widget.series[i];
+ if (!s.showInTooltip) continue;
+ final j = onlyPoint ?? _indexAt(s.points, x);
+ if (j < 0 || j >= s.points.length) continue;
+ final point = s.points[j];
+ final row = i < values.length ? values[i] : const [];
+ final drawn = j < row.length ? row[j] : null;
+ if (point.isGap || drawn == null) continue;
+ touched.add(
+ SeriesTouchValue(
+ seriesIndex: i,
+ series: s,
+ pointIndex: j,
+ point: point,
+ position: geometry.point(point.x, drawn),
+ color: switch (s) {
+ final BarSeries bars => bars.barColorAt(j, point),
+ final ScatterSeries dots =>
+ dots.dotAt(j, point)?.color ?? s.colorAt(point.y!),
+ LineSeries() => s.colorAt(point.y!),
+ },
+ ),
+ );
+ }
+ // The tooltip hangs off the furthest value along the value axis, so it
+ // never sits on top of what was touched.
+ final Offset anchor;
+ if (geometry.isHorizontal) {
+ final right = touched.isEmpty
+ ? geometry.plot.left
+ : touched.map((v) => v.position.dx).reduce(math.max);
+ anchor = Offset(right, geometry.xToPx(x));
+ } else {
+ final top = touched.isEmpty
+ ? geometry.plot.top
+ : touched.map((v) => v.position.dy).reduce(math.min);
+ anchor = Offset(geometry.xToPx(x), top);
+ }
+ return SeriesTouchDetails(
+ x: x,
+ values: touched,
+ position: anchor,
+ plotRect: geometry.plot,
+ );
+ }
+
+ void _report(SeriesTouchDetails? details) {
+ final x = details?.x;
+ if (x == _reportedX) return;
+ _reportedX = x;
+ final onTouch = widget.onTouch;
+ if (onTouch == null) return;
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (mounted) onTouch(details);
+ });
+ }
+
+ // ── Build ───────────────────────────────────────────────────────────────
+
+ @override
+ Widget build(BuildContext context) {
+ final target = _targetValues(widget.series);
+ final from = _fromValues;
+ final t =
+ from == null ? 1.0 : widget.animationCurve.transform(_animation.value);
+ final values = from == null || !_sameShape(from, widget.series)
+ ? target
+ : [
+ for (var i = 0; i < target.length; i++)
+ [
+ for (var j = 0; j < target[i].length; j++)
+ _lerpValue(from[i][j], target[i][j], t),
+ ],
+ ];
+ final targetViewport = _fit(target);
+ final fromViewport = _fromViewport;
+ final viewport = from != null && fromViewport != null
+ ? SeriesViewport.lerp(fromViewport, targetViewport, t)
+ : targetViewport;
+ _drawnValues = values;
+ _drawnViewport = viewport;
+
+ Widget chart = LayoutBuilder(
+ builder: (context, constraints) {
+ final width = constraints.hasBoundedWidth
+ ? constraints.maxWidth
+ : MediaQuery.maybeSizeOf(context)?.width ?? 300;
+ final height = constraints.hasBoundedHeight
+ ? constraints.maxHeight
+ : widget.defaultHeight;
+ final size = Size(width, height);
+ final geometry = SeriesGeometry.layout(
+ size: size,
+ padding: widget.padding,
+ xAxis: widget.xAxis,
+ yAxis: widget.yAxis,
+ viewport: viewport,
+ orientation: widget.orientation,
+ );
+ _geometry = geometry;
+
+ final touch = widget.touch;
+ final x = _touchX;
+ final details = x == null
+ ? null
+ : _detailsAt(
+ geometry,
+ values,
+ x,
+ onlySeries: _touchSeries,
+ onlyPoint: _touchPoint,
+ );
+ _report(details);
+
+ final tooltip = touch?.tooltip;
+ final tooltipChild = details == null || tooltip == null
+ ? null
+ : (tooltip.builder != null
+ ? tooltip.builder!(context, details)
+ : _defaultTooltip(tooltip, details, viewport));
+
+ return SizedBox(
+ width: width,
+ height: height,
+ child: Stack(
+ clipBehavior: Clip.none,
+ children: [
+ Positioned.fill(
+ child: CustomPaint(
+ painter: SeriesChartPainter(
+ geometry: geometry,
+ series: widget.series,
+ values: values,
+ xAxis: widget.xAxis,
+ yAxis: widget.yAxis,
+ grid: widget.grid,
+ border: widget.border,
+ referenceLines: widget.referenceLines,
+ bands: widget.bands,
+ betweenFills: widget.betweenFills,
+ backgroundColor: widget.backgroundColor,
+ clipToPlot: widget.clipToPlot,
+ textCache: _text,
+ ),
+ foregroundPainter: details == null || touch == null
+ ? null
+ : SeriesOverlayPainter(
+ geometry: geometry,
+ details: details,
+ touch: touch,
+ ),
+ ),
+ ),
+ if (tooltipChild != null && details != null && tooltip != null)
+ Positioned.fill(
+ child: IgnorePointer(
+ child: CustomSingleChildLayout(
+ delegate: _TooltipLayout(
+ anchor: details.position,
+ plot: geometry.plot,
+ placement: tooltip.placement,
+ margin: tooltip.margin,
+ ),
+ child: tooltipChild,
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ },
+ );
+
+ chart = _withGestures(chart);
+ final label = widget.semanticLabel;
+ return label == null
+ ? chart
+ : Semantics(container: true, label: label, child: chart);
+ }
+
+ static double? _lerpValue(double? from, double? to, double t) {
+ if (to == null) return null;
+ if (from == null) return to;
+ return lerpDouble(from, to, t);
+ }
+
+ Widget _withGestures(Widget child) {
+ final touch = widget.touch;
+ if (touch == null) return child;
+ // A horizontal chart is read by dragging down it, an upright one across.
+ final dragsDown = widget.orientation == SeriesOrientation.horizontal;
+
+ Widget result = switch (touch.trigger) {
+ SeriesTouchTrigger.press => GestureDetector(
+ behavior: HitTestBehavior.opaque,
+ onTapDown: (d) => _showAt(d.localPosition),
+ onTapUp: (_) => _release(),
+ onTapCancel: _release,
+ onHorizontalDragStart: (d) => _showAt(d.localPosition),
+ onHorizontalDragUpdate: (d) => _showAt(d.localPosition),
+ onHorizontalDragEnd: (_) => _release(),
+ onHorizontalDragCancel: _release,
+ onVerticalDragStart:
+ dragsDown ? (d) => _showAt(d.localPosition) : null,
+ onVerticalDragUpdate:
+ dragsDown ? (d) => _showAt(d.localPosition) : null,
+ onVerticalDragEnd: dragsDown ? (_) => _release() : null,
+ onVerticalDragCancel: dragsDown ? _release : null,
+ child: child,
+ ),
+ SeriesTouchTrigger.longPress => GestureDetector(
+ behavior: HitTestBehavior.opaque,
+ onLongPressStart: (d) => _showAt(d.localPosition),
+ onLongPressMoveUpdate: (d) => _showAt(d.localPosition),
+ onLongPressEnd: (_) => _release(),
+ onLongPressCancel: _release,
+ child: child,
+ ),
+ SeriesTouchTrigger.tap => GestureDetector(
+ behavior: HitTestBehavior.opaque,
+ onTapUp: (d) => _toggleAt(d.localPosition),
+ onHorizontalDragStart: (d) => _showAt(d.localPosition),
+ onHorizontalDragUpdate: (d) => _showAt(d.localPosition),
+ onVerticalDragStart:
+ dragsDown ? (d) => _showAt(d.localPosition) : null,
+ onVerticalDragUpdate:
+ dragsDown ? (d) => _showAt(d.localPosition) : null,
+ child: child,
+ ),
+ SeriesTouchTrigger.none => child,
+ };
+
+ if (touch.hover) {
+ result = MouseRegion(
+ onHover: (e) => _showAt(e.localPosition),
+ onExit: (_) => _release(),
+ child: result,
+ );
+ }
+ return result;
+ }
+
+ Widget _defaultTooltip(
+ SeriesTooltip tooltip,
+ SeriesTouchDetails details,
+ SeriesViewport viewport,
+ ) {
+ const base = TextStyle(
+ fontSize: 12,
+ color: Color(0xFFFFFFFF),
+ decoration: TextDecoration.none,
+ fontWeight: FontWeight.normal,
+ );
+ final valueStyle = base.merge(tooltip.valueStyle);
+ final titleStyle = base
+ .copyWith(fontSize: 10, color: const Color(0xB3FFFFFF))
+ .merge(tooltip.titleStyle);
+ final titleOf = tooltip.title;
+ final title = titleOf != null
+ ? titleOf(details)
+ : widget.xAxis.labelFor(details.x, step: viewport.xStep);
+
+ final rows = [
+ for (final value in details.values)
+ Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Container(
+ width: 6,
+ height: 6,
+ margin: const EdgeInsets.only(right: 4),
+ decoration: BoxDecoration(
+ color: value.color,
+ shape: BoxShape.circle,
+ ),
+ ),
+ Text(_tooltipText(tooltip, value), maxLines: 1, style: valueStyle),
+ ],
+ ),
+ ];
+ if (rows.isEmpty && (title == null || title.isEmpty)) {
+ return const SizedBox.shrink();
+ }
+
+ return DecoratedBox(
+ decoration: BoxDecoration(
+ color: tooltip.backgroundColor,
+ borderRadius: BorderRadius.circular(tooltip.borderRadius),
+ border: tooltip.borderColor == null
+ ? null
+ : Border.all(color: tooltip.borderColor!),
+ boxShadow: tooltip.shadows,
+ ),
+ child: Padding(
+ padding: tooltip.padding,
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ if (title != null && title.isNotEmpty)
+ Text(title, maxLines: 1, style: titleStyle),
+ ...rows,
+ ],
+ ),
+ ),
+ );
+ }
+
+ String _tooltipText(SeriesTooltip tooltip, SeriesTouchValue value) {
+ final formatter = tooltip.valueFormatter;
+ final axisFormatter = widget.yAxis.formatter;
+ final text = formatter != null
+ ? formatter(value)
+ : axisFormatter != null
+ ? axisFormatter(value.value)
+ : _plainNumber(value.value);
+ final label = value.series.label;
+ return tooltip.showSeriesLabels && label != null && label.isNotEmpty
+ ? '$label: $text'
+ : text;
+ }
+
+ static String _plainNumber(double value) {
+ if (value == value.roundToDouble()) return value.toStringAsFixed(0);
+ var text = value.toStringAsFixed(2);
+ while (text.endsWith('0')) {
+ text = text.substring(0, text.length - 1);
+ }
+ return text.endsWith('.') ? text.substring(0, text.length - 1) : text;
+ }
+}
+
+/// Places the tooltip beside or above the touched point, kept inside the chart.
+class _TooltipLayout extends SingleChildLayoutDelegate {
+ _TooltipLayout({
+ required this.anchor,
+ required this.plot,
+ required this.placement,
+ required this.margin,
+ });
+
+ final Offset anchor;
+ final Rect plot;
+ final SeriesTooltipPlacement placement;
+ final double margin;
+
+ @override
+ BoxConstraints getConstraintsForChild(BoxConstraints constraints) =>
+ BoxConstraints.loose(constraints.biggest);
+
+ @override
+ Offset getPositionForChild(Size size, Size childSize) {
+ final maxLeft = math.max(0.0, size.width - childSize.width);
+ final maxTop = math.max(0.0, size.height - childSize.height);
+ switch (placement) {
+ case SeriesTooltipPlacement.beside:
+ var left = anchor.dx + margin;
+ if (left + childSize.width > size.width) {
+ left = anchor.dx - margin - childSize.width;
+ }
+ return Offset(
+ left.clamp(0.0, maxLeft),
+ (plot.top + 4).clamp(0.0, maxTop),
+ );
+ case SeriesTooltipPlacement.above:
+ var top = anchor.dy - margin - childSize.height;
+ if (top < 0) top = anchor.dy + margin;
+ return Offset(
+ (anchor.dx - childSize.width / 2).clamp(0.0, maxLeft),
+ top.clamp(0.0, maxTop),
+ );
+ }
+ }
+
+ @override
+ bool shouldRelayout(_TooltipLayout oldDelegate) =>
+ oldDelegate.anchor != anchor ||
+ oldDelegate.plot != plot ||
+ oldDelegate.placement != placement ||
+ oldDelegate.margin != margin;
+}
diff --git a/lib/src/series/series_chart_painter.dart b/lib/src/series/series_chart_painter.dart
new file mode 100644
index 0000000..bfd9673
--- /dev/null
+++ b/lib/src/series/series_chart_painter.dart
@@ -0,0 +1,1172 @@
+import 'dart:math' as math;
+
+import 'package:flutter/rendering.dart';
+
+import '../renderer/text_painter_cache.dart';
+import 'series_axis.dart';
+import 'series_data.dart';
+import 'series_paths.dart';
+import 'series_scale.dart';
+import 'series_touch.dart';
+
+/// Paints a [SeriesChart]: bands, grid, reference lines, series, border and
+/// axis labels.
+///
+/// Everything here works in two numbers — an x and a value — and leaves it to
+/// the geometry to say which way each of them runs, so an upright chart and a
+/// horizontal one share one set of drawing code.
+class SeriesChartPainter extends CustomPainter {
+ /// Creates the painter. `values[i][j]` is the value drawn for
+ /// `series[i].points[j]`, which is where an animation puts its in-between
+ /// values.
+ SeriesChartPainter({
+ required this.geometry,
+ required this.series,
+ required this.values,
+ required this.xAxis,
+ required this.yAxis,
+ required this.grid,
+ required this.border,
+ required this.referenceLines,
+ required this.bands,
+ required this.betweenFills,
+ required this.backgroundColor,
+ required this.clipToPlot,
+ required this.textCache,
+ });
+
+ final SeriesGeometry geometry;
+ final List series;
+ final List> values;
+ final SeriesXAxis xAxis;
+ final SeriesYAxis yAxis;
+ final SeriesGrid grid;
+ final BorderSide? border;
+ final List referenceLines;
+ final List bands;
+ final List betweenFills;
+ final Color? backgroundColor;
+ final bool clipToPlot;
+ final TextPainterCache textCache;
+
+ /// Whether the x axis runs down the chart rather than across it.
+ bool get _horizontal => geometry.isHorizontal;
+
+ @override
+ void paint(Canvas canvas, Size size) {
+ final plot = geometry.plot;
+ final background = backgroundColor;
+ if (background != null) {
+ canvas.drawRect(Offset.zero & size, Paint()..color = background);
+ }
+ if (plot.width <= 0 || plot.height <= 0) return;
+
+ canvas.save();
+ canvas.clipRect(clipToPlot ? plot : Offset.zero & size);
+ _paintBands(canvas);
+ _paintGrid(canvas);
+ _paintReferenceLines(canvas, above: false);
+ _paintBetweenFills(canvas);
+
+ final barSeries = [
+ for (final s in series)
+ if (s is BarSeries) s,
+ ];
+ final barSpacing = _barSpacing(barSeries);
+ // Counted by position, not looked up: the same series given twice is two
+ // groups of bars side by side, not one drawn over the other. Series in a
+ // stack share the group of the first of them, since they pile up in one
+ // place rather than standing beside each other.
+ final groupOf = _barGroups(barSeries);
+ final groups = groupOf.isEmpty ? 0 : groupOf.values.reduce(math.max) + 1;
+ var barIndex = 0;
+ for (var i = 0; i < series.length; i++) {
+ final row = i < values.length ? values[i] : const [];
+ switch (series[i]) {
+ case final LineSeries line:
+ _paintLine(canvas, size, line, row);
+ case final BarSeries bars:
+ _paintBars(
+ canvas,
+ size,
+ bars,
+ i,
+ row,
+ groupOf[barIndex++] ?? 0,
+ groups,
+ barSpacing,
+ );
+ case final ScatterSeries dots:
+ _paintScatter(canvas, dots, row);
+ }
+ }
+ _paintReferenceLines(canvas, above: true);
+ canvas.restore();
+
+ _paintBorder(canvas);
+ _paintReferenceLabels(canvas, size);
+ _paintValueLabels(canvas, size);
+ _paintXLabels(canvas, size);
+ _paintAxisTitles(canvas, size);
+ }
+
+ // ── Plot furniture ──────────────────────────────────────────────────────
+
+ void _paintBands(Canvas canvas) {
+ for (final band in bands) {
+ final rect = band.direction == SeriesDirection.horizontal
+ ? geometry.valueBand(band.from, band.to)
+ : geometry.xBand(band.from, band.to);
+ final paint = _areaPaint(_orient(band.gradient), band.color, rect);
+ if (paint != null) canvas.drawRect(rect, paint);
+ }
+ }
+
+ void _paintGrid(Canvas canvas) {
+ if (grid.horizontal && grid.width > 0) {
+ final path = Path();
+ for (final tick in geometry.viewport.yTicks) {
+ _addLine(path, geometry.valueLine(tick));
+ }
+ canvas.drawPath(
+ dashSeriesPath(path, grid.dashPattern),
+ _strokePaint(grid.color, grid.width),
+ );
+ }
+ if (grid.vertical && grid.width > 0) {
+ final path = Path();
+ for (final tick in geometry.viewport.xTicks) {
+ _addLine(path, geometry.xLine(tick));
+ }
+ canvas.drawPath(
+ dashSeriesPath(path, grid.verticalDashPattern ?? grid.dashPattern),
+ _strokePaint(grid.verticalColor ?? grid.color, grid.width),
+ );
+ }
+ }
+
+ void _paintReferenceLines(Canvas canvas, {required bool above}) {
+ for (final line in referenceLines) {
+ if (line.aboveSeries != above || line.width <= 0) continue;
+ final path = Path();
+ _addLine(
+ path,
+ line.direction == SeriesDirection.horizontal
+ ? geometry.valueLine(line.value)
+ : geometry.xLine(line.value),
+ );
+ canvas.drawPath(
+ dashSeriesPath(path, line.dashPattern),
+ _strokePaint(line.color, line.width),
+ );
+ }
+ }
+
+ void _paintReferenceLabels(Canvas canvas, Size size) {
+ final plot = geometry.plot;
+ for (final line in referenceLines) {
+ final label = line.label;
+ if (label == null || label.isEmpty) continue;
+ final style = seriesAxisLabelStyle
+ .copyWith(color: line.color)
+ .merge(line.labelStyle);
+ final tp = textCache.get(label, style);
+ final align = line.labelAlignment;
+ final (start, end) = line.direction == SeriesDirection.horizontal
+ ? geometry.valueLine(line.value)
+ : geometry.xLine(line.value);
+ final rect = Rect.fromPoints(start, end);
+ if (rect.right < plot.left - 0.5 ||
+ rect.left > plot.right + 0.5 ||
+ rect.bottom < plot.top - 0.5 ||
+ rect.top > plot.bottom + 0.5) {
+ continue;
+ }
+ // A line is a segment, so one of these two is a length to slide the
+ // label along and the other is the line itself to sit beside.
+ final dx = rect.width > 0
+ ? _lerp(rect.left + 4, rect.right - 4 - tp.width, (align.x + 1) / 2)
+ : align.x < 0
+ ? rect.left - tp.width - 2
+ : align.x > 0
+ ? rect.left + 2
+ : rect.left - tp.width / 2;
+ final dy = rect.height > 0
+ ? _lerp(rect.top + 2, rect.bottom - 2 - tp.height, (align.y + 1) / 2)
+ : align.y < 0
+ ? rect.top - tp.height - 2
+ : align.y > 0
+ ? rect.top + 2
+ : rect.top - tp.height / 2;
+ tp.paint(
+ canvas,
+ Offset(
+ dx.clamp(0.0, math.max(0.0, size.width - tp.width)),
+ dy.clamp(0.0, math.max(0.0, size.height - tp.height)),
+ ),
+ );
+ }
+ }
+
+ void _paintBorder(Canvas canvas) {
+ final side = border;
+ if (side == null || side.style == BorderStyle.none || side.width <= 0) {
+ return;
+ }
+ canvas.drawRect(
+ geometry.plot.deflate(side.width / 2),
+ _strokePaint(side.color, side.width),
+ );
+ }
+
+ // ── Axis labels ─────────────────────────────────────────────────────────
+
+ /// How much room the value labels take across their axis.
+ double get _valueLabelRoom {
+ if (!yAxis.show) return 0;
+ if (!_horizontal) return yAxis.width;
+ final style = seriesAxisLabelStyle.merge(yAxis.style);
+ return textCache.get('0', style).height + yAxis.gap;
+ }
+
+ void _paintValueLabels(Canvas canvas, Size size) {
+ if (!yAxis.show) return;
+ final plot = geometry.plot;
+ final style = seriesAxisLabelStyle.merge(yAxis.style);
+ final step = geometry.viewport.yStep;
+ var previousEnd = double.negativeInfinity;
+ for (final tick in geometry.viewport.yTicks) {
+ final text = yAxis.format(tick, step);
+ if (text.isEmpty) continue;
+ final tp = textCache.get(text, style);
+ final at = geometry.yToPx(tick);
+ final Offset offset;
+ if (_horizontal) {
+ final left = (at - tp.width / 2)
+ .clamp(0.0, math.max(0.0, size.width - tp.width))
+ .toDouble();
+ if (left < previousEnd + 4) continue;
+ previousEnd = left + tp.width;
+ offset = Offset(
+ left,
+ yAxis.side == SeriesAxisSide.left
+ ? plot.bottom + yAxis.gap
+ : math.max(0.0, plot.top - yAxis.gap - tp.height),
+ );
+ } else {
+ offset = Offset(
+ yAxis.side == SeriesAxisSide.left
+ ? math.max(0.0, plot.left - yAxis.gap - tp.width)
+ : plot.right + yAxis.gap,
+ (at - tp.height / 2).clamp(
+ 0.0,
+ math.max(0.0, size.height - tp.height),
+ ),
+ );
+ }
+ tp.paint(canvas, offset);
+ }
+ }
+
+ void _paintXLabels(Canvas canvas, Size size) {
+ if (!xAxis.show) return;
+ final plot = geometry.plot;
+ final style = seriesAxisLabelStyle.merge(xAxis.style);
+ var previousEnd = double.negativeInfinity;
+ final step = geometry.viewport.xStep;
+ for (final tick in geometry.viewport.xTicks) {
+ final text = xAxis.labelFor(tick, step: step);
+ if (text == null || text.isEmpty) continue;
+ final tp = textCache.get(text, style);
+ final at = geometry.xToPx(tick);
+ final Offset offset;
+ if (_horizontal) {
+ var top = at - tp.height / 2;
+ if (xAxis.fitInside) {
+ top = top.clamp(0.0, math.max(0.0, size.height - tp.height));
+ }
+ if (top < previousEnd + 2) continue;
+ previousEnd = top + tp.height;
+ offset = Offset(
+ xAxis.side == SeriesXSide.bottom
+ ? math.max(0.0, plot.left - xAxis.gap - tp.width)
+ : plot.right + xAxis.gap,
+ top,
+ );
+ } else {
+ var left = at - tp.width / 2;
+ if (xAxis.fitInside) {
+ left = left.clamp(0.0, math.max(0.0, size.width - tp.width));
+ }
+ // A label that would run into the one before it is dropped rather
+ // than drawn on top of it.
+ if (left < previousEnd + 4) continue;
+ previousEnd = left + tp.width;
+ offset = Offset(
+ left,
+ xAxis.side == SeriesXSide.bottom
+ ? plot.bottom + xAxis.gap
+ : math.max(0.0, plot.top - xAxis.gap - tp.height),
+ );
+ }
+ tp.paint(canvas, offset);
+ }
+ }
+
+ void _paintAxisTitles(Canvas canvas, Size size) {
+ final plot = geometry.plot;
+ final valueTitle = yAxis.title;
+ if (yAxis.show && valueTitle != null && valueTitle.isNotEmpty) {
+ final tp = textCache.get(
+ valueTitle,
+ seriesAxisTitleStyle.merge(yAxis.titleStyle),
+ );
+ final room = _valueLabelRoom;
+ if (_horizontal) {
+ _paintTitle(
+ canvas,
+ tp,
+ along: plot.center.dx,
+ across: yAxis.side == SeriesAxisSide.left
+ ? plot.bottom + room + 2
+ : plot.top - room - 2 - tp.height,
+ turned: false,
+ size: size,
+ );
+ } else {
+ _paintTitle(
+ canvas,
+ tp,
+ along: plot.center.dy,
+ across: yAxis.side == SeriesAxisSide.left
+ ? plot.left - room - 2 - tp.height
+ : plot.right + room + 2,
+ turned: true,
+ size: size,
+ );
+ }
+ }
+
+ final xTitle = xAxis.title;
+ if (xAxis.show && xTitle != null && xTitle.isNotEmpty) {
+ final tp = textCache.get(
+ xTitle,
+ seriesAxisTitleStyle.merge(xAxis.titleStyle),
+ );
+ if (_horizontal) {
+ _paintTitle(
+ canvas,
+ tp,
+ along: plot.center.dy,
+ across: xAxis.side == SeriesXSide.bottom
+ ? plot.left - xAxis.height - 2 - tp.height
+ : plot.right + xAxis.height + 2,
+ turned: true,
+ size: size,
+ );
+ } else {
+ _paintTitle(
+ canvas,
+ tp,
+ along: plot.center.dx,
+ across: xAxis.side == SeriesXSide.bottom
+ ? plot.bottom + xAxis.height + 2
+ : plot.top - xAxis.height - 2 - tp.height,
+ turned: false,
+ size: size,
+ );
+ }
+ }
+ }
+
+ /// Writes an axis title centred at [along] down its axis and [across] from
+ /// the plot; [turned] reads it bottom-to-top, for an axis that runs down the
+ /// chart.
+ void _paintTitle(
+ Canvas canvas,
+ TextPainter tp, {
+ required double along,
+ required double across,
+ required bool turned,
+ required Size size,
+ }) {
+ if (turned) {
+ final left = across.clamp(0.0, math.max(0.0, size.width - tp.height));
+ canvas
+ ..save()
+ ..translate(left + tp.height, along + tp.width / 2)
+ ..rotate(-math.pi / 2);
+ tp.paint(canvas, Offset.zero);
+ canvas.restore();
+ return;
+ }
+ tp.paint(
+ canvas,
+ Offset(
+ (along - tp.width / 2).clamp(0.0, math.max(0.0, size.width - tp.width)),
+ across.clamp(0.0, math.max(0.0, size.height - tp.height)),
+ ),
+ );
+ }
+
+ // ── Lines ───────────────────────────────────────────────────────────────
+
+ /// The pixels of one series' points, broken into unbroken runs at its gaps.
+ List> _runs(PlotSeries s, List row) {
+ final runs = >[];
+ var run = [];
+ for (var j = 0; j < s.points.length; j++) {
+ final x = s.points[j].x;
+ final y = j < row.length ? row[j] : null;
+ if (y == null || !y.isFinite || !x.isFinite) {
+ if (run.isNotEmpty) {
+ runs.add(run);
+ run = [];
+ }
+ continue;
+ }
+ run.add(geometry.point(x, y));
+ }
+ if (run.isNotEmpty) runs.add(run);
+ return runs;
+ }
+
+ Path _runPath(LineSeries line, List run) => seriesRunPath(
+ run,
+ line.curve,
+ stepPosition: line.stepPosition,
+ transpose: _horizontal,
+ );
+
+ void _paintLine(
+ Canvas canvas,
+ Size size,
+ LineSeries line,
+ List row,
+ ) {
+ final runs = _runs(line, row);
+ if (runs.isEmpty) return;
+
+ if (line.fill != null) _paintFill(canvas, size, line, runs);
+
+ if (line.width > 0) {
+ final path = Path();
+ for (final run in runs) {
+ path.addPath(_runPath(line, run), Offset.zero);
+ }
+ final dashed = dashSeriesPath(path, line.dashPattern);
+ final shadow = line.shadow;
+ if (shadow != null) {
+ canvas.drawPath(
+ dashed.shift(shadow.offset),
+ Paint()
+ ..style = PaintingStyle.stroke
+ ..strokeWidth = line.width
+ ..strokeCap = line.roundCap ? StrokeCap.round : StrokeCap.butt
+ ..strokeJoin = StrokeJoin.round
+ ..color = shadow.color
+ ..maskFilter = shadow.blurRadius > 0
+ ? MaskFilter.blur(BlurStyle.normal, shadow.blurRadius)
+ : null,
+ );
+ }
+ _paintStroke(canvas, size, line, dashed);
+ }
+
+ _paintDots(canvas, line, row);
+ _paintErrorBars(canvas, line, row);
+ }
+
+ void _paintFill(
+ Canvas canvas,
+ Size size,
+ LineSeries line,
+ List> runs,
+ ) {
+ final fill = line.fill!;
+ final basePx = fill.toBaseline
+ ? _clampValuePx(geometry.yToPx(line.baseline), inside: true)
+ : _lowEdgePx;
+
+ final area = Path();
+ var high = double.nan;
+ var low = double.nan;
+ for (final run in runs) {
+ if (run.length < 2) continue;
+ area.addPath(_runPath(line, run), Offset.zero);
+ _closeToBase(area, run, basePx);
+ for (final p in run) {
+ final at = _valuePx(p);
+ high = high.isNaN ? at : _towardHigh(high, at);
+ low = low.isNaN ? at : _towardLow(low, at);
+ }
+ }
+ if (high.isNaN) return;
+
+ if (!fill.toBaseline) {
+ final paint = _areaPaint(
+ fill.gradient,
+ fill.color,
+ _valueSpan(high, _lowEdgePx),
+ );
+ if (paint != null) canvas.drawPath(area, paint);
+ return;
+ }
+
+ if (_isAbove(high, basePx)) {
+ final paint = _areaPaint(
+ fill.gradient,
+ fill.color,
+ _valueSpan(high, basePx),
+ );
+ if (paint != null) {
+ canvas
+ ..save()
+ ..clipRect(_side(size, basePx, above: true))
+ ..drawPath(area, paint)
+ ..restore();
+ }
+ }
+
+ if (_isAbove(basePx, low)) {
+ final upper = fill.gradient;
+ final gradient = fill.negativeGradient ??
+ (upper == null
+ ? null
+ : fill.mirrorBelowBaseline
+ ? _mirrored(upper)
+ : upper);
+ final paint = _areaPaint(
+ gradient,
+ fill.negativeColor ?? fill.color,
+ _valueSpan(basePx, low),
+ );
+ if (paint != null) {
+ canvas
+ ..save()
+ ..clipRect(_side(size, basePx, above: false))
+ ..drawPath(area, paint)
+ ..restore();
+ }
+ }
+ }
+
+ void _paintBetweenFills(Canvas canvas) {
+ for (final between in betweenFills) {
+ if (between.from < 0 ||
+ between.from >= series.length ||
+ between.to < 0 ||
+ between.to >= series.length) {
+ continue;
+ }
+ final first = series[between.from];
+ final second = series[between.to];
+ if (first is! LineSeries || second is! LineSeries) continue;
+ final top = _runs(first, _rowOf(between.from)).expand((r) => r).toList();
+ final bottom =
+ _runs(second, _rowOf(between.to)).expand((r) => r).toList();
+ if (top.length < 2 || bottom.length < 2) continue;
+
+ final path = Path()
+ ..addPath(_runPath(first, top), Offset.zero)
+ ..extendWithPath(
+ _runPath(second, bottom.reversed.toList()),
+ Offset.zero,
+ )
+ ..close();
+ final paint = _areaPaint(
+ between.gradient,
+ between.color,
+ geometry.plot,
+ );
+ if (paint != null) canvas.drawPath(path, paint);
+ }
+ }
+
+ List _rowOf(int index) =>
+ index < values.length ? values[index] : const [];
+
+ void _paintStroke(Canvas canvas, Size size, LineSeries line, Path path) {
+ final paint = Paint()
+ ..style = PaintingStyle.stroke
+ ..strokeWidth = line.width
+ ..strokeCap = line.roundCap ? StrokeCap.round : StrokeCap.butt
+ ..strokeJoin = StrokeJoin.round
+ ..isAntiAlias = true;
+ final gradient = line.gradient;
+ if (gradient != null) {
+ paint.shader = _orient(gradient)!.createShader(geometry.plot);
+ } else {
+ paint.color = line.color;
+ }
+
+ final negative = line.negativeColor;
+ if (negative == null) {
+ canvas.drawPath(path, paint);
+ return;
+ }
+
+ // Split at the baseline: whatever lies beyond it keeps the series colour
+ // and whatever lies short of it takes the negative one, cut exactly where
+ // the line crosses rather than at the nearest point.
+ final basePx = geometry.yToPx(line.baseline);
+ canvas
+ ..save()
+ ..clipRect(_side(size, basePx, above: true))
+ ..drawPath(path, paint)
+ ..restore();
+ paint
+ ..shader = null
+ ..color = negative;
+ canvas
+ ..save()
+ ..clipRect(_side(size, basePx, above: false))
+ ..drawPath(path, paint)
+ ..restore();
+ }
+
+ void _paintDots(Canvas canvas, LineSeries line, List row) {
+ if (line.dot == null && line.dotBuilder == null) return;
+ for (var j = 0; j < line.points.length && j < row.length; j++) {
+ final y = row[j];
+ final point = line.points[j];
+ if (y == null || !y.isFinite || point.isGap) continue;
+ final dot = line.dotAt(j, point);
+ if (dot == null) continue;
+ paintSeriesDot(
+ canvas,
+ geometry.point(point.x, y),
+ dot,
+ line.colorAt(point.y!),
+ );
+ }
+ }
+
+ // ── Scatter ─────────────────────────────────────────────────────────────
+
+ void _paintScatter(Canvas canvas, ScatterSeries scatter, List row) {
+ for (var j = 0; j < scatter.points.length && j < row.length; j++) {
+ final y = row[j];
+ final point = scatter.points[j];
+ if (y == null || !y.isFinite || point.isGap) continue;
+ final at = geometry.point(point.x, y);
+ final dot = scatter.dotAt(j, point);
+ final color = scatter.colorAt(point.y!);
+ if (dot != null) paintSeriesDot(canvas, at, dot, color);
+ final label = scatter.labelBuilder?.call(j, point);
+ if (label != null && label.isNotEmpty) {
+ final tp = textCache.get(
+ label,
+ seriesAxisLabelStyle.copyWith(color: color).merge(scatter.labelStyle),
+ );
+ tp.paint(
+ canvas,
+ Offset(
+ at.dx - tp.width / 2,
+ at.dy - (dot?.radius ?? 0) - tp.height - 2,
+ ),
+ );
+ }
+ }
+ _paintErrorBars(canvas, scatter, row);
+ }
+
+ // ── Error bars ──────────────────────────────────────────────────────────
+
+ void _paintErrorBars(Canvas canvas, PlotSeries s, List row) {
+ final style = s.errorBars;
+ if (style == null || style.width <= 0) return;
+ Paint? paint;
+ for (var j = 0; j < s.points.length && j < row.length; j++) {
+ final y = row[j];
+ final point = s.points[j];
+ if (y == null || !y.isFinite || point.isGap) continue;
+ final vertical = point.yError;
+ final horizontal = point.xError;
+ if (vertical == null && horizontal == null) continue;
+ paint ??= _strokePaint(
+ style.color ?? s.colorAt(point.y ?? s.baseline),
+ style.width,
+ );
+ if (vertical != null) {
+ _paintErrorBar(
+ canvas,
+ paint,
+ from: geometry.point(point.x, y - vertical.lowerBy),
+ to: geometry.point(point.x, y + vertical.upperBy),
+ capLength: style.capLength,
+ alongValue: true,
+ );
+ }
+ if (horizontal != null) {
+ _paintErrorBar(
+ canvas,
+ paint,
+ from: geometry.point(point.x - horizontal.lowerBy, y),
+ to: geometry.point(point.x + horizontal.upperBy, y),
+ capLength: style.capLength,
+ alongValue: false,
+ );
+ }
+ }
+ }
+
+ void _paintErrorBar(
+ Canvas canvas,
+ Paint paint, {
+ required Offset from,
+ required Offset to,
+ required double capLength,
+ required bool alongValue,
+ }) {
+ canvas.drawLine(from, to, paint);
+ if (capLength <= 0) return;
+ // The caps cross the bar, so they run along whichever axis it does not.
+ final cap = alongValue == _horizontal
+ ? Offset(0, capLength / 2)
+ : Offset(capLength / 2, 0);
+ canvas
+ ..drawLine(from - cap, from + cap, paint)
+ ..drawLine(to - cap, to + cap, paint);
+ }
+
+ // ── Bars ────────────────────────────────────────────────────────────────
+
+ /// The smallest gap between two x values any bar series uses, so bars at
+ /// irregular positions never overlap.
+ static double _barSpacing(List bars) {
+ var spacing = double.infinity;
+ for (final s in bars) {
+ for (var j = 1; j < s.points.length; j++) {
+ final gap = (s.points[j].x - s.points[j - 1].x).abs();
+ if (gap > 0) spacing = math.min(spacing, gap);
+ }
+ }
+ return spacing.isFinite ? spacing : 1;
+ }
+
+ /// The group each bar series stands in, counted along [bars]: series that
+ /// share a stack share a group, and everything else gets its own.
+ static Map _barGroups(List bars) {
+ final groups = {};
+ final stacks = {};
+ var next = 0;
+ for (var i = 0; i < bars.length; i++) {
+ final stack = bars[i].stack;
+ if (stack == null) {
+ groups[i] = next++;
+ } else {
+ groups[i] = stacks.putIfAbsent(stack, () => next++);
+ }
+ }
+ return groups;
+ }
+
+ void _paintBars(
+ Canvas canvas,
+ Size size,
+ BarSeries bars,
+ int index,
+ List row,
+ int group,
+ int groups,
+ double spacing,
+ ) {
+ final slot = geometry.unitWidth * spacing;
+ const gap = 2.0;
+ final raw = bars.width ?? slot * bars.widthFactor / math.max(1, groups);
+ final width = raw
+ .clamp(bars.minWidth, math.max(bars.minWidth, bars.maxWidth))
+ .toDouble();
+ final offset =
+ (group - (groups - 1) / 2) * (width + (groups > 1 ? gap : 0));
+
+ final track = bars.trackColor;
+ final trackPath = Path();
+ final byColor = {};
+ final shaded = [];
+ final outlined = [];
+
+ for (var j = 0; j < bars.points.length && j < row.length; j++) {
+ final y = row[j];
+ final point = bars.points[j];
+ if (y == null || !y.isFinite || point.isGap) continue;
+
+ // Where the bar starts: on the stack under it, on its own low, or on
+ // the series' baseline.
+ final double from;
+ final double to;
+ if (bars.stack != null) {
+ from = stackedBase(series, values, index, j);
+ to = from + (y - bars.baseline);
+ } else {
+ from = point.low ?? bars.baseline;
+ to = y;
+ }
+
+ final centre = geometry.xToPx(point.x) + offset;
+ final rect = _barRect(
+ centre,
+ width,
+ _clampValuePx(geometry.yToPx(from), inside: true),
+ _clampValuePx(geometry.yToPx(to)),
+ );
+ if (!rect.overlaps(geometry.plot.inflate(width))) continue;
+
+ if (track != null) {
+ trackPath.addRRect(
+ RRect.fromRectAndRadius(
+ _trackRect(centre, width),
+ Radius.circular(math.min(bars.radius, width / 2)),
+ ),
+ );
+ }
+
+ final shape = seriesBarBox(
+ rect: rect,
+ radius: bars.radius,
+ horizontal: _horizontal,
+ positive: to >= from,
+ );
+ if (shape == null) continue;
+ if (bars.gradient != null) {
+ shaded.add(shape);
+ } else {
+ byColor
+ .putIfAbsent(bars.barColorAt(j, point), Path.new)
+ .addRRect(shape);
+ }
+ if (bars.border != null) outlined.add(shape);
+ _paintBarLabel(canvas, bars, j, point, rect, positive: to >= from);
+ }
+
+ if (track != null) canvas.drawPath(trackPath, Paint()..color = track);
+ // One draw per colour, however many bars there are.
+ byColor.forEach((color, path) {
+ canvas.drawPath(path, Paint()..color = color);
+ });
+ final gradient = bars.gradient;
+ if (gradient != null) {
+ for (final shape in shaded) {
+ canvas.drawRRect(
+ shape,
+ Paint()..shader = _orient(gradient)!.createShader(shape.outerRect),
+ );
+ }
+ }
+ final side = bars.border;
+ if (side != null && side.style != BorderStyle.none && side.width > 0) {
+ final paint = _strokePaint(side.color, side.width);
+ for (final shape in outlined) {
+ canvas.drawRRect(shape.deflate(side.width / 2), paint);
+ }
+ }
+ _paintErrorBars(canvas, bars, row);
+ }
+
+ void _paintBarLabel(
+ Canvas canvas,
+ BarSeries bars,
+ int index,
+ SeriesPoint point,
+ Rect rect, {
+ required bool positive,
+ }) {
+ final label = bars.labelBuilder?.call(index, point);
+ if (label == null || label.isEmpty) return;
+ final tp = textCache.get(
+ label,
+ seriesAxisLabelStyle.merge(bars.labelStyle),
+ );
+ const pad = 3.0;
+ final Offset offset;
+ if (_horizontal) {
+ offset = Offset(
+ positive ? rect.right + pad : rect.left - pad - tp.width,
+ rect.center.dy - tp.height / 2,
+ );
+ } else {
+ offset = Offset(
+ rect.center.dx - tp.width / 2,
+ positive ? rect.top - pad - tp.height : rect.bottom + pad,
+ );
+ }
+ tp.paint(canvas, offset);
+ }
+
+ /// One bar: [width] across the x axis at [centre], reaching from [fromPx] to
+ /// [toPx] along the value axis.
+ Rect _barRect(double centre, double width, double fromPx, double toPx) {
+ final low = math.min(fromPx, toPx);
+ final high = math.max(fromPx, toPx);
+ return _horizontal
+ ? Rect.fromLTRB(low, centre - width / 2, high, centre + width / 2)
+ : Rect.fromLTRB(centre - width / 2, low, centre + width / 2, high);
+ }
+
+ Rect _trackRect(double centre, double width) {
+ final plot = geometry.plot;
+ return _horizontal
+ ? Rect.fromLTRB(
+ plot.left,
+ centre - width / 2,
+ plot.right,
+ centre + width / 2,
+ )
+ : Rect.fromLTRB(
+ centre - width / 2,
+ plot.top,
+ centre + width / 2,
+ plot.bottom,
+ );
+ }
+
+ // ── The value axis in pixels ────────────────────────────────────────────
+
+ /// Where on the screen a point sits along the value axis.
+ double _valuePx(Offset p) => _horizontal ? p.dx : p.dy;
+
+ /// The pixel of the plot edge the low values are at.
+ double get _lowEdgePx =>
+ _horizontal ? geometry.plot.left : geometry.plot.bottom;
+
+ /// Whichever of two value pixels stands for the higher value.
+ double _towardHigh(double a, double b) =>
+ _horizontal ? math.max(a, b) : math.min(a, b);
+
+ /// Whichever of two value pixels stands for the lower value.
+ double _towardLow(double a, double b) =>
+ _horizontal ? math.min(a, b) : math.max(a, b);
+
+ /// Whether the value at pixel [a] is above the one at [b].
+ bool _isAbove(double a, double b) => _horizontal ? a > b : a < b;
+
+ /// A pixel on the value axis brought back to the plot, [inside] keeping it
+ /// strictly within rather than a pixel over the edge.
+ double _clampValuePx(double px, {bool inside = false}) {
+ final plot = geometry.plot;
+ final slack = inside ? 0.0 : 1.0;
+ return _horizontal
+ ? px.clamp(plot.left - slack, plot.right + slack)
+ : px.clamp(plot.top - slack, plot.bottom + slack);
+ }
+
+ /// The stretch of plot between two value pixels, right across the x axis.
+ Rect _valueSpan(double a, double b) {
+ final plot = geometry.plot;
+ final low = math.min(a, b);
+ final high = math.max(a, b);
+ return _horizontal
+ ? Rect.fromLTRB(low, plot.top, math.max(high, low + 1), plot.bottom)
+ : Rect.fromLTRB(plot.left, low, plot.right, math.max(high, low + 1));
+ }
+
+ /// The half of the chart on one side of the value pixel [basePx].
+ Rect _side(Size size, double basePx, {required bool above}) {
+ if (_horizontal) {
+ return above
+ ? Rect.fromLTRB(basePx, 0, size.width, size.height)
+ : Rect.fromLTRB(0, 0, basePx, size.height);
+ }
+ return above
+ ? Rect.fromLTRB(0, 0, size.width, basePx)
+ : Rect.fromLTRB(0, basePx, size.width, size.height);
+ }
+
+ /// Closes a filled area back to the baseline at [basePx].
+ void _closeToBase(Path area, List run, double basePx) {
+ if (_horizontal) {
+ area
+ ..lineTo(basePx, run.last.dy)
+ ..lineTo(basePx, run.first.dy);
+ } else {
+ area
+ ..lineTo(run.last.dx, basePx)
+ ..lineTo(run.first.dx, basePx);
+ }
+ area.close();
+ }
+
+ void _addLine(Path path, (Offset, Offset) line) {
+ path
+ ..moveTo(line.$1.dx, line.$1.dy)
+ ..lineTo(line.$2.dx, line.$2.dy);
+ }
+
+ /// A gradient written for an upright chart, turned to follow a horizontal
+ /// one: what pointed at the high values still does.
+ Gradient? _orient(Gradient? gradient) {
+ if (!_horizontal || gradient is! LinearGradient) return gradient;
+ return LinearGradient(
+ begin: _turn(gradient.begin),
+ end: _turn(gradient.end),
+ colors: gradient.colors,
+ stops: gradient.stops,
+ tileMode: gradient.tileMode,
+ transform: gradient.transform,
+ );
+ }
+
+ static AlignmentGeometry _turn(AlignmentGeometry alignment) =>
+ alignment is Alignment ? Alignment(-alignment.y, alignment.x) : alignment;
+
+ // ── Helpers ─────────────────────────────────────────────────────────────
+
+ static Paint _strokePaint(Color color, double width) => Paint()
+ ..style = PaintingStyle.stroke
+ ..color = color
+ ..strokeWidth = width
+ ..isAntiAlias = true;
+
+ static Paint? _areaPaint(Gradient? gradient, Color? color, Rect rect) {
+ if (gradient != null) return Paint()..shader = gradient.createShader(rect);
+ if (color != null) return Paint()..color = color;
+ return null;
+ }
+
+ static Gradient _mirrored(Gradient gradient) => gradient is LinearGradient
+ ? LinearGradient(
+ begin: gradient.end,
+ end: gradient.begin,
+ colors: gradient.colors,
+ stops: gradient.stops,
+ tileMode: gradient.tileMode,
+ transform: gradient.transform,
+ )
+ : gradient;
+
+ static double _lerp(double a, double b, double t) => a + (b - a) * t;
+
+ @override
+ bool shouldRepaint(SeriesChartPainter oldDelegate) => true;
+}
+
+/// Paints [dot] at [centre], filled with [fallback] when it names no colour.
+void paintSeriesDot(
+ Canvas canvas,
+ Offset centre,
+ SeriesDot dot,
+ Color fallback,
+) {
+ if (dot.radius <= 0) return;
+ final fill = Paint()
+ ..color = dot.color ?? fallback
+ ..isAntiAlias = true;
+ final ring = dot.strokeColor;
+ final ringPaint = ring == null || dot.strokeWidth <= 0
+ ? null
+ : (Paint()
+ ..style = PaintingStyle.stroke
+ ..strokeWidth = dot.strokeWidth
+ ..color = ring
+ ..isAntiAlias = true);
+
+ switch (dot.shape) {
+ case SeriesDotShape.circle:
+ canvas.drawCircle(centre, dot.radius, fill);
+ if (ringPaint != null) canvas.drawCircle(centre, dot.radius, ringPaint);
+ case SeriesDotShape.square:
+ final rect = Rect.fromCenter(
+ center: centre,
+ width: dot.radius * 2,
+ height: dot.radius * 2,
+ );
+ canvas.drawRect(rect, fill);
+ if (ringPaint != null) canvas.drawRect(rect, ringPaint);
+ case SeriesDotShape.diamond:
+ final path = Path()
+ ..moveTo(centre.dx, centre.dy - dot.radius)
+ ..lineTo(centre.dx + dot.radius, centre.dy)
+ ..lineTo(centre.dx, centre.dy + dot.radius)
+ ..lineTo(centre.dx - dot.radius, centre.dy)
+ ..close();
+ canvas.drawPath(path, fill);
+ if (ringPaint != null) canvas.drawPath(path, ringPaint);
+ case SeriesDotShape.cross:
+ final stroke = Paint()
+ ..style = PaintingStyle.stroke
+ ..strokeCap = StrokeCap.round
+ ..strokeWidth = dot.strokeWidth > 0
+ ? dot.strokeWidth
+ : math.max(1.0, dot.radius / 3)
+ ..color = dot.color ?? fallback
+ ..isAntiAlias = true;
+ final r = dot.radius;
+ canvas
+ ..drawLine(
+ centre + Offset(-r, -r),
+ centre + Offset(r, r),
+ stroke,
+ )
+ ..drawLine(
+ centre + Offset(-r, r),
+ centre + Offset(r, -r),
+ stroke,
+ );
+ }
+}
+
+/// Paints a [SeriesChart]'s crosshair and markers over the plot.
+class SeriesOverlayPainter extends CustomPainter {
+ /// Creates the overlay for [details].
+ SeriesOverlayPainter({
+ required this.geometry,
+ required this.details,
+ required this.touch,
+ });
+
+ final SeriesGeometry geometry;
+ final SeriesTouchDetails details;
+ final SeriesTouch touch;
+
+ @override
+ void paint(Canvas canvas, Size size) {
+ final plot = geometry.plot;
+ final across = touch.line;
+ if (across != null && across.width > 0) {
+ final (start, end) = geometry.xLine(details.x);
+ if (_inside(start, plot) && _inside(end, plot)) {
+ canvas.drawPath(
+ dashSeriesPath(
+ Path()
+ ..moveTo(start.dx, start.dy)
+ ..lineTo(end.dx, end.dy),
+ across.dashPattern,
+ ),
+ SeriesChartPainter._strokePaint(across.color, across.width),
+ );
+ }
+ }
+
+ final along = touch.horizontalLine;
+ if (along != null && along.width > 0 && details.values.isNotEmpty) {
+ final (start, end) = geometry.valueLine(details.values.first.value);
+ canvas.drawPath(
+ dashSeriesPath(
+ Path()
+ ..moveTo(start.dx, start.dy)
+ ..lineTo(end.dx, end.dy),
+ along.dashPattern,
+ ),
+ SeriesChartPainter._strokePaint(along.color, along.width),
+ );
+ }
+
+ if (!touch.showMarkers) return;
+ for (final value in details.values) {
+ final builder = touch.markerBuilder;
+ final dot = builder != null ? builder(value) : const SeriesDot(radius: 4);
+ if (dot == null) continue;
+ paintSeriesDot(canvas, value.position, dot, value.color);
+ }
+ }
+
+ static bool _inside(Offset p, Rect plot) =>
+ p.dx >= plot.left - 0.5 &&
+ p.dx <= plot.right + 0.5 &&
+ p.dy >= plot.top - 0.5 &&
+ p.dy <= plot.bottom + 0.5;
+
+ @override
+ bool shouldRepaint(SeriesOverlayPainter oldDelegate) => true;
+}
diff --git a/lib/src/series/series_data.dart b/lib/src/series/series_data.dart
new file mode 100644
index 0000000..b65d004
--- /dev/null
+++ b/lib/src/series/series_data.dart
@@ -0,0 +1,578 @@
+import 'package:flutter/foundation.dart';
+import 'package:flutter/painting.dart';
+
+/// How far a measurement may be off, below and above it.
+@immutable
+class SeriesErrorRange {
+ /// A range reaching [lowerBy] below the value and [upperBy] above it.
+ const SeriesErrorRange(this.lowerBy, this.upperBy);
+
+ /// A range reaching [by] either side of the value.
+ const SeriesErrorRange.symmetric(double by)
+ : lowerBy = by,
+ upperBy = by;
+
+ /// How far below the value the range reaches.
+ final double lowerBy;
+
+ /// How far above the value the range reaches.
+ final double upperBy;
+
+ @override
+ bool operator ==(Object other) =>
+ other is SeriesErrorRange &&
+ other.lowerBy == lowerBy &&
+ other.upperBy == upperBy;
+
+ @override
+ int get hashCode => Object.hash(lowerBy, upperBy);
+}
+
+/// One value on a [SeriesChart]'s plot.
+///
+/// [x] is any number — an index, a day count, a price — and [y] is the value
+/// drawn against it. A null or non-finite [y] is a gap: a line breaks there,
+/// and a bar or a dot is left out.
+@immutable
+class SeriesPoint {
+ /// Creates the point ([x], [y]).
+ const SeriesPoint(this.x, this.y, {this.low, this.xError, this.yError});
+
+ /// Where the point sits along the x axis.
+ final double x;
+
+ /// The value, or null for a gap.
+ final double? y;
+
+ /// Where a bar starts instead of its baseline, for a bar that floats — a
+ /// range from [low] to [y]. Lines and dots ignore it.
+ final double? low;
+
+ /// How far [x] may be off, drawn as an error bar along the x axis.
+ final SeriesErrorRange? xError;
+
+ /// How far [y] may be off, drawn as an error bar along the value axis.
+ final SeriesErrorRange? yError;
+
+ /// Whether there is no value here to draw.
+ bool get isGap {
+ final value = y;
+ return value == null || !value.isFinite;
+ }
+
+ @override
+ bool operator ==(Object other) =>
+ other is SeriesPoint &&
+ other.x == x &&
+ other.y == y &&
+ other.low == low &&
+ other.xError == xError &&
+ other.yError == yError;
+
+ @override
+ int get hashCode => Object.hash(x, y, low, xError, yError);
+
+ @override
+ String toString() => 'SeriesPoint($x, $y)';
+}
+
+/// Points at `x = 0, 1, 2, …` for a list of plain values.
+List pointsOf(List values) => [
+ for (var i = 0; i < values.length; i++)
+ SeriesPoint(i.toDouble(), values[i]),
+ ];
+
+/// How a [LineSeries] joins one point to the next.
+enum LineCurve {
+ /// Straight segments.
+ linear,
+
+ /// A curve through every point that can swing past a peak or a trough
+ /// between two points — `fl_chart`'s `isCurved`.
+ smooth,
+
+ /// A curve through every point that never swings past its neighbours, so a
+ /// peak on the line is a peak in the data — `fl_chart`'s `isCurved` with
+ /// `preventCurveOverShooting`.
+ monotone,
+
+ /// Holds each value flat, then steps to the next; where along the way the
+ /// step falls is `LineSeries.stepPosition`.
+ step,
+}
+
+/// The shape of a [SeriesDot].
+enum SeriesDotShape {
+ /// A filled circle.
+ circle,
+
+ /// A filled square, [SeriesDot.radius] from its centre to each side.
+ square,
+
+ /// A filled square turned on its corner.
+ diamond,
+
+ /// Two crossed strokes.
+ cross,
+}
+
+/// A dot drawn on a point.
+@immutable
+class SeriesDot {
+ /// Creates a dot of [radius], filled with [color] and ringed by
+ /// [strokeColor].
+ const SeriesDot({
+ this.radius = 3,
+ this.color,
+ this.strokeColor,
+ this.strokeWidth = 0,
+ this.shape = SeriesDotShape.circle,
+ });
+
+ /// Radius in logical pixels.
+ final double radius;
+
+ /// Fill colour; null uses the series colour at that value.
+ final Color? color;
+
+ /// Ring colour; null draws no ring.
+ final Color? strokeColor;
+
+ /// Ring width; 0 draws no ring. A cross is drawn this thick, or at a third
+ /// of its radius when 0.
+ final double strokeWidth;
+
+ /// The dot's shape.
+ final SeriesDotShape shape;
+
+ @override
+ bool operator ==(Object other) =>
+ other is SeriesDot &&
+ other.radius == radius &&
+ other.color == color &&
+ other.strokeColor == strokeColor &&
+ other.strokeWidth == strokeWidth &&
+ other.shape == shape;
+
+ @override
+ int get hashCode =>
+ Object.hash(radius, color, strokeColor, strokeWidth, shape);
+}
+
+/// Decides the dot for one point, or returns null to leave it without one.
+typedef SeriesDotBuilder = SeriesDot? Function(int index, SeriesPoint point);
+
+/// Writes the label drawn beside one point, or returns null for none.
+typedef SeriesPointLabelBuilder = String? Function(
+ int index, SeriesPoint point);
+
+/// How a series draws the error bars of points that carry
+/// [SeriesPoint.xError] or [SeriesPoint.yError].
+@immutable
+class SeriesErrorBars {
+ /// Creates an error-bar style.
+ const SeriesErrorBars({this.color, this.width = 1, this.capLength = 6});
+
+ /// Stroke colour; null uses the series colour.
+ final Color? color;
+
+ /// Stroke width.
+ final double width;
+
+ /// Length of the crossbar at either end; 0 draws none.
+ final double capLength;
+}
+
+/// The area between a [LineSeries] and its baseline, or the bottom of the plot.
+///
+/// A fill anchored to the baseline never paints across it. The stretch above
+/// is painted with [gradient] (or [color]) measured from the line's highest
+/// point down to the baseline, so it fades out exactly at the baseline; the
+/// stretch below is painted with [negativeGradient] (or [negativeColor]),
+/// measured from the baseline down to the line's lowest point. Left unset, the
+/// part below reuses the upper gradient turned upside down, so both halves
+/// fade away from the line the same way.
+///
+/// Gradients are written for an upright chart — top is the high values — and
+/// turn with a horizontal one.
+@immutable
+class SeriesFill {
+ /// Creates a fill.
+ const SeriesFill({
+ this.color,
+ this.gradient,
+ this.negativeColor,
+ this.negativeGradient,
+ this.toBaseline = true,
+ this.mirrorBelowBaseline = true,
+ });
+
+ /// A vertical fade from [color] at [opacity] under the line to transparent
+ /// at the baseline.
+ factory SeriesFill.fade(
+ Color color, {
+ double opacity = 0.24,
+ Color? negativeColor,
+ bool toBaseline = true,
+ }) =>
+ SeriesFill(
+ gradient: _fade(color, opacity),
+ negativeGradient: negativeColor == null
+ ? null
+ : _fade(negativeColor, opacity, upward: true),
+ toBaseline: toBaseline,
+ );
+
+ static LinearGradient _fade(
+ Color color,
+ double opacity, {
+ bool upward = false,
+ }) =>
+ LinearGradient(
+ begin: upward ? Alignment.bottomCenter : Alignment.topCenter,
+ end: upward ? Alignment.topCenter : Alignment.bottomCenter,
+ colors: [
+ color.withValues(alpha: opacity),
+ color.withValues(alpha: 0),
+ ],
+ );
+
+ /// Flat colour above the baseline; ignored when [gradient] is set.
+ final Color? color;
+
+ /// Gradient above the baseline, top of the line to the baseline.
+ final Gradient? gradient;
+
+ /// Flat colour below the baseline; null follows [color].
+ final Color? negativeColor;
+
+ /// Gradient below the baseline, baseline to the bottom of the line; null
+ /// follows [gradient], mirrored when [mirrorBelowBaseline] is set.
+ final Gradient? negativeGradient;
+
+ /// Fills to the series' `baseline` when true, and to the bottom of the plot
+ /// when false.
+ final bool toBaseline;
+
+ /// Whether the part below the baseline turns [gradient] upside down.
+ final bool mirrorBelowBaseline;
+
+ @override
+ bool operator ==(Object other) =>
+ other is SeriesFill &&
+ other.color == color &&
+ other.gradient == gradient &&
+ other.negativeColor == negativeColor &&
+ other.negativeGradient == negativeGradient &&
+ other.toBaseline == toBaseline &&
+ other.mirrorBelowBaseline == mirrorBelowBaseline;
+
+ @override
+ int get hashCode => Object.hash(
+ color,
+ gradient,
+ negativeColor,
+ negativeGradient,
+ toBaseline,
+ mirrorBelowBaseline,
+ );
+}
+
+/// The area between two [LineSeries] on the same chart — a band between a
+/// high and a low, or the gap between a plan and what happened.
+@immutable
+class SeriesBetweenFill {
+ /// Fills between `SeriesChart.series[from]` and `SeriesChart.series[to]`.
+ const SeriesBetweenFill({
+ required this.from,
+ required this.to,
+ this.color,
+ this.gradient,
+ });
+
+ /// Index of one line in `SeriesChart.series`.
+ final int from;
+
+ /// Index of the other.
+ final int to;
+
+ /// Flat colour; ignored when [gradient] is set.
+ final Color? color;
+
+ /// Gradient laid over the filled area.
+ final Gradient? gradient;
+}
+
+const Color _defaultSeriesColor = Color(0xFF4C86CD);
+
+/// One set of values on a [SeriesChart]: a [LineSeries], a [BarSeries] or a
+/// [ScatterSeries].
+///
+/// Points are read in order and should run from the lowest x to the highest.
+sealed class PlotSeries {
+ const PlotSeries({
+ required this.points,
+ this.label,
+ this.color = _defaultSeriesColor,
+ this.negativeColor,
+ this.baseline = 0,
+ this.showInTooltip = true,
+ this.errorBars = const SeriesErrorBars(),
+ });
+
+ /// The values, lowest x first.
+ final List points;
+
+ /// A name for the series, shown by the default tooltip.
+ final String? label;
+
+ /// The series colour.
+ final Color color;
+
+ /// The colour of whatever lies below [baseline]; null keeps [color].
+ final Color? negativeColor;
+
+ /// The value the series is measured from: where a fill stops, where bars
+ /// grow from, and where [negativeColor] takes over.
+ final double baseline;
+
+ /// Whether the series gets a marker and a tooltip row when touched.
+ final bool showInTooltip;
+
+ /// How error ranges on the points are drawn; null draws none.
+ final SeriesErrorBars? errorBars;
+
+ /// The colour the series is drawn in at [value].
+ Color colorAt(double value) {
+ final negative = negativeColor;
+ return negative != null && value < baseline ? negative : color;
+ }
+}
+
+/// A line through a series of values, optionally filled and dotted.
+final class LineSeries extends PlotSeries {
+ /// Creates a line through [points].
+ const LineSeries({
+ required super.points,
+ super.label,
+ super.color,
+ super.negativeColor,
+ super.baseline,
+ super.showInTooltip,
+ super.errorBars,
+ this.gradient,
+ this.width = 2,
+ this.curve = LineCurve.linear,
+ this.stepPosition = 1,
+ this.dashPattern,
+ this.roundCap = true,
+ this.shadow,
+ this.fill,
+ this.dot,
+ this.dotBuilder,
+ });
+
+ /// Creates a line through plain [values], placed at `x = 0, 1, 2, …`.
+ LineSeries.values(
+ List values, {
+ super.label,
+ super.color,
+ super.negativeColor,
+ super.baseline,
+ super.showInTooltip,
+ super.errorBars,
+ this.gradient,
+ this.width = 2,
+ this.curve = LineCurve.linear,
+ this.stepPosition = 1,
+ this.dashPattern,
+ this.roundCap = true,
+ this.shadow,
+ this.fill,
+ this.dot,
+ this.dotBuilder,
+ }) : super(points: pointsOf(values));
+
+ /// Paints the stroke with a gradient laid over the whole plot, top to
+ /// bottom, instead of [color]. [negativeColor] still wins below the
+ /// baseline when it is set.
+ final Gradient? gradient;
+
+ /// Stroke width; 0 draws no line.
+ final double width;
+
+ /// How the line joins its points.
+ final LineCurve curve;
+
+ /// For [LineCurve.step], where between two points the value changes: 0 at
+ /// the first point, 0.5 halfway, 1 — the default — at the second.
+ final double stepPosition;
+
+ /// Alternating dash and gap lengths, such as `[6, 4]`; null draws solid.
+ final List? dashPattern;
+
+ /// Whether the line's ends and dashes are rounded.
+ final bool roundCap;
+
+ /// A blurred copy of the line drawn under it.
+ final Shadow? shadow;
+
+ /// The area under the line; null leaves it unfilled.
+ final SeriesFill? fill;
+
+ /// A dot on every point; null draws none unless [dotBuilder] gives one.
+ final SeriesDot? dot;
+
+ /// Chooses the dot per point, overriding [dot]; return null for no dot.
+ final SeriesDotBuilder? dotBuilder;
+
+ /// The dot drawn on [points]`[index]`, if any.
+ SeriesDot? dotAt(int index, SeriesPoint point) {
+ final builder = dotBuilder;
+ return builder != null ? builder(index, point) : dot;
+ }
+}
+
+/// Chooses the colour of one bar.
+typedef BarColorBuilder = Color Function(int index, SeriesPoint point);
+
+/// One bar per value, grown from the series' baseline.
+///
+/// Several bar series on one chart stand side by side at each x, unless they
+/// share a [stack], in which case they are piled on each other.
+final class BarSeries extends PlotSeries {
+ /// Creates bars at [points].
+ const BarSeries({
+ required super.points,
+ super.label,
+ super.color,
+ super.negativeColor,
+ super.baseline,
+ super.showInTooltip,
+ super.errorBars,
+ this.gradient,
+ this.colorBuilder,
+ this.width,
+ this.widthFactor = 0.6,
+ this.minWidth = 0,
+ this.maxWidth = double.infinity,
+ this.radius = 0,
+ this.trackColor,
+ this.stack,
+ this.border,
+ this.labelBuilder,
+ this.labelStyle,
+ });
+
+ /// Creates bars for plain [values], placed at `x = 0, 1, 2, …`.
+ BarSeries.values(
+ List values, {
+ super.label,
+ super.color,
+ super.negativeColor,
+ super.baseline,
+ super.showInTooltip,
+ super.errorBars,
+ this.gradient,
+ this.colorBuilder,
+ this.width,
+ this.widthFactor = 0.6,
+ this.minWidth = 0,
+ this.maxWidth = double.infinity,
+ this.radius = 0,
+ this.trackColor,
+ this.stack,
+ this.border,
+ this.labelBuilder,
+ this.labelStyle,
+ }) : super(points: pointsOf(values));
+
+ /// Paints every bar with this gradient, measured over the bar itself.
+ final Gradient? gradient;
+
+ /// Chooses each bar's colour, overriding [color] and [negativeColor].
+ final BarColorBuilder? colorBuilder;
+
+ /// A fixed bar width in logical pixels; null sizes bars by [widthFactor].
+ final double? width;
+
+ /// The share of the room between two neighbouring x values that the bars
+ /// at one x take together, before [minWidth] and [maxWidth] are applied.
+ final double widthFactor;
+
+ /// The narrowest a bar may be.
+ final double minWidth;
+
+ /// The widest a bar may be.
+ final double maxWidth;
+
+ /// Corner radius on the end away from the baseline — the top of a bar above
+ /// it, the bottom of one below. In a stack only the outermost bar is
+ /// rounded.
+ final double radius;
+
+ /// A full-height bar painted behind each one, such as a faint track.
+ final Color? trackColor;
+
+ /// Bar series with the same key are stacked: at each x, each bar starts
+ /// where the ones before it in `SeriesChart.series` ended — upwards for
+ /// values above the baseline, downwards for values below. Null stands the
+ /// series beside the others.
+ final Object? stack;
+
+ /// An outline drawn round each bar.
+ final BorderSide? border;
+
+ /// Writes the label drawn beyond the end of each bar.
+ final SeriesPointLabelBuilder? labelBuilder;
+
+ /// Style of the labels [labelBuilder] writes.
+ final TextStyle? labelStyle;
+
+ /// The colour of the bar at [points]`[index]`.
+ Color barColorAt(int index, SeriesPoint point) {
+ final builder = colorBuilder;
+ if (builder != null) return builder(index, point);
+ return colorAt(point.y ?? baseline);
+ }
+}
+
+/// A dot per point, placed freely on both axes — a scatter plot.
+///
+/// Pair it with `SeriesTouch(snap: SeriesTouchSnap.nearestPoint)`, so a touch
+/// reads out the dot under the finger rather than everything at that x.
+final class ScatterSeries extends PlotSeries {
+ /// Creates dots at [points].
+ const ScatterSeries({
+ required super.points,
+ super.label,
+ super.color,
+ super.negativeColor,
+ super.baseline,
+ super.showInTooltip,
+ super.errorBars,
+ this.dot = const SeriesDot(radius: 4),
+ this.dotBuilder,
+ this.labelBuilder,
+ this.labelStyle,
+ });
+
+ /// The dot on every point; [dotBuilder] overrides it.
+ final SeriesDot? dot;
+
+ /// Chooses the dot per point — its size, colour or shape; return null to
+ /// leave a point out.
+ final SeriesDotBuilder? dotBuilder;
+
+ /// Writes the label drawn above each dot.
+ final SeriesPointLabelBuilder? labelBuilder;
+
+ /// Style of the labels [labelBuilder] writes.
+ final TextStyle? labelStyle;
+
+ /// The dot drawn on [points]`[index]`, if any.
+ SeriesDot? dotAt(int index, SeriesPoint point) {
+ final builder = dotBuilder;
+ return builder != null ? builder(index, point) : dot;
+ }
+}
diff --git a/lib/src/series/series_paths.dart b/lib/src/series/series_paths.dart
new file mode 100644
index 0000000..d3365cb
--- /dev/null
+++ b/lib/src/series/series_paths.dart
@@ -0,0 +1,272 @@
+import 'dart:math' as math;
+import 'dart:typed_data';
+import 'dart:ui';
+
+import 'series_data.dart';
+
+/// Appends one unbroken run of [points], in pixels and left to right, to
+/// [path], joined as [curve] says.
+///
+/// [stepPosition] is where a [LineCurve.step] changes value between two
+/// points: 0 at the first, 1 at the second.
+void addSeriesRun(
+ Path path,
+ List points,
+ LineCurve curve, {
+ double stepPosition = 1,
+}) {
+ if (points.isEmpty) return;
+ path.moveTo(points.first.dx, points.first.dy);
+ if (points.length == 1) return;
+ switch (curve) {
+ case LineCurve.linear:
+ for (var i = 1; i < points.length; i++) {
+ path.lineTo(points[i].dx, points[i].dy);
+ }
+ case LineCurve.step:
+ final at = stepPosition.clamp(0.0, 1.0);
+ for (var i = 1; i < points.length; i++) {
+ final previous = points[i - 1];
+ final current = points[i];
+ final stepX = previous.dx + (current.dx - previous.dx) * at;
+ path.lineTo(stepX, previous.dy);
+ path.lineTo(stepX, current.dy);
+ path.lineTo(current.dx, current.dy);
+ }
+ case LineCurve.smooth:
+ _smooth(path, points);
+ case LineCurve.monotone:
+ _monotone(path, points);
+ }
+}
+
+/// A cubic through every point whose handles follow the neighbours on either
+/// side, the way `fl_chart` draws `isCurved`.
+void _smooth(Path path, List points) {
+ const smoothness = 0.35;
+ var handle = Offset.zero;
+ for (var i = 1; i < points.length; i++) {
+ final previous = points[i - 1];
+ final current = points[i];
+ final next = i + 1 < points.length ? points[i + 1] : current;
+ final control1 = previous + handle;
+ handle = (next - previous) / 2 * smoothness;
+ final control2 = current - handle;
+ path.cubicTo(
+ control1.dx,
+ control1.dy,
+ control2.dx,
+ control2.dy,
+ current.dx,
+ current.dy,
+ );
+ }
+}
+
+/// A monotone cubic (Fritsch–Carlson): it passes through every point, and
+/// between two points it never goes above the higher or below the lower.
+void _monotone(Path path, List points) {
+ final tangents = monotoneTangents(points);
+ for (var i = 0; i < points.length - 1; i++) {
+ final a = points[i];
+ final b = points[i + 1];
+ final third = (b.dx - a.dx) / 3;
+ path.cubicTo(
+ a.dx + third,
+ a.dy + tangents[i] * third,
+ b.dx - third,
+ b.dy - tangents[i + 1] * third,
+ b.dx,
+ b.dy,
+ );
+ }
+}
+
+/// The slope of a monotone cubic at each of [points].
+List monotoneTangents(List points) {
+ final n = points.length;
+ if (n < 2) return List.filled(n, 0);
+
+ final secants = List.filled(n - 1, 0);
+ for (var i = 0; i < n - 1; i++) {
+ final run = points[i + 1].dx - points[i].dx;
+ secants[i] = run == 0 ? 0 : (points[i + 1].dy - points[i].dy) / run;
+ }
+
+ final tangents = List.filled(n, 0);
+ tangents[0] = secants[0];
+ tangents[n - 1] = secants[n - 2];
+ for (var i = 1; i < n - 1; i++) {
+ // A peak or a trough gets a flat tangent, which is what keeps the curve
+ // from swinging past it.
+ tangents[i] = secants[i - 1] * secants[i] <= 0
+ ? 0
+ : (secants[i - 1] + secants[i]) / 2;
+ }
+
+ for (var i = 0; i < n - 1; i++) {
+ if (secants[i] == 0) {
+ tangents[i] = 0;
+ tangents[i + 1] = 0;
+ continue;
+ }
+ final a = tangents[i] / secants[i];
+ final b = tangents[i + 1] / secants[i];
+ final magnitude = a * a + b * b;
+ if (magnitude > 9) {
+ final scale = 3 / math.sqrt(magnitude);
+ tangents[i] = scale * a * secants[i];
+ tangents[i + 1] = scale * b * secants[i];
+ }
+ }
+ return tangents;
+}
+
+/// [source] cut into dashes by [pattern] — dash, gap, dash, gap …
+///
+/// Returns [source] itself for an empty or all-zero pattern.
+Path dashSeriesPath(Path source, List? pattern) {
+ if (pattern == null || pattern.isEmpty || !pattern.any((v) => v > 0)) {
+ return source;
+ }
+ final dashed = Path();
+ for (final metric in source.computeMetrics()) {
+ var distance = 0.0;
+ var index = 0;
+ while (distance < metric.length) {
+ final length = math.max(0.0, pattern[index % pattern.length]);
+ if (index.isEven && length > 0) {
+ dashed.addPath(
+ metric.extractPath(
+ distance,
+ math.min(distance + length, metric.length),
+ ),
+ Offset.zero,
+ );
+ }
+ distance += length;
+ index++;
+ }
+ }
+ return dashed;
+}
+
+/// The shape of one bar: [left] to [right] across, from [valueY] to [baseY]
+/// down the plot, rounded by [radius] on the end at [valueY].
+///
+/// Returns null for a bar with no height or no width.
+RRect? seriesBarShape({
+ required double left,
+ required double right,
+ required double valueY,
+ required double baseY,
+ required double radius,
+}) {
+ final width = right - left;
+ final height = (baseY - valueY).abs();
+ if (width <= 0 || height <= 0) return null;
+
+ final rect = Rect.fromLTRB(
+ left,
+ math.min(valueY, baseY),
+ right,
+ math.max(valueY, baseY),
+ );
+ final r = Radius.circular(
+ math.max(0.0, math.min(radius, math.min(width / 2, height))),
+ );
+ // Above the baseline the value is the top edge; below it, the bottom.
+ final up = valueY <= baseY;
+ return RRect.fromRectAndCorners(
+ rect,
+ topLeft: up ? r : Radius.zero,
+ topRight: up ? r : Radius.zero,
+ bottomLeft: up ? Radius.zero : r,
+ bottomRight: up ? Radius.zero : r,
+ );
+}
+
+/// The swap of a point's two coordinates, which turns a horizontal chart's
+/// pixels into the upright ones every curve is worked out in.
+final Float64List _swapAxes = Float64List.fromList(const [
+ 0, 1, 0, 0, //
+ 1, 0, 0, 0, //
+ 0, 0, 1, 0, //
+ 0, 0, 0, 1, //
+]);
+
+/// One run of [points] as a path in pixels.
+///
+/// With [transpose] the run is worked out with its coordinates swapped and
+/// swapped back afterwards, so a horizontal chart curves and steps along its
+/// own x axis rather than along the screen's.
+Path seriesRunPath(
+ List points,
+ LineCurve curve, {
+ double stepPosition = 1,
+ bool transpose = false,
+}) {
+ final path = Path();
+ if (!transpose) {
+ addSeriesRun(path, points, curve, stepPosition: stepPosition);
+ return path;
+ }
+ addSeriesRun(
+ path,
+ [
+ for (final p in points) Offset(p.dy, p.dx),
+ ],
+ curve,
+ stepPosition: stepPosition);
+ return path.transform(_swapAxes);
+}
+
+/// The shape of one bar filling [rect], rounded by [radius] on the end away
+/// from its baseline — the top of an upright bar above it, the right-hand end
+/// of a horizontal one.
+///
+/// Returns null for a bar with no width or no length.
+RRect? seriesBarBox({
+ required Rect rect,
+ required double radius,
+ required bool horizontal,
+ required bool positive,
+}) {
+ if (rect.width <= 0 || rect.height <= 0) return null;
+ // Never more than half the bar's width, nor more than its whole length.
+ final across = horizontal ? rect.height : rect.width;
+ final along = horizontal ? rect.width : rect.height;
+ final r = Radius.circular(
+ math.max(0.0, math.min(radius, math.min(across / 2, along))),
+ );
+ final start = horizontal
+ ? (positive ? _Corners.right : _Corners.left)
+ : (positive ? _Corners.top : _Corners.bottom);
+ return RRect.fromRectAndCorners(
+ rect,
+ topLeft: start.topLeft ? r : Radius.zero,
+ topRight: start.topRight ? r : Radius.zero,
+ bottomLeft: start.bottomLeft ? r : Radius.zero,
+ bottomRight: start.bottomRight ? r : Radius.zero,
+ );
+}
+
+/// Which two corners of a bar are the rounded end.
+class _Corners {
+ const _Corners({
+ this.topLeft = false,
+ this.topRight = false,
+ this.bottomLeft = false,
+ this.bottomRight = false,
+ });
+
+ static const top = _Corners(topLeft: true, topRight: true);
+ static const bottom = _Corners(bottomLeft: true, bottomRight: true);
+ static const left = _Corners(topLeft: true, bottomLeft: true);
+ static const right = _Corners(topRight: true, bottomRight: true);
+
+ final bool topLeft;
+ final bool topRight;
+ final bool bottomLeft;
+ final bool bottomRight;
+}
diff --git a/lib/src/series/series_range_selector.dart b/lib/src/series/series_range_selector.dart
new file mode 100644
index 0000000..d91c3d9
--- /dev/null
+++ b/lib/src/series/series_range_selector.dart
@@ -0,0 +1,294 @@
+import 'dart:math' as math;
+
+import 'package:flutter/gestures.dart' show DragStartBehavior;
+import 'package:flutter/widgets.dart';
+
+import 'series_axis.dart';
+import 'series_chart.dart';
+import 'series_data.dart';
+
+/// A stretch of x, from [start] to [end].
+@immutable
+class SeriesWindow {
+ /// Creates the window [start]–[end].
+ const SeriesWindow(this.start, this.end);
+
+ /// The first x in the window.
+ final double start;
+
+ /// The last x in the window.
+ final double end;
+
+ /// How much x the window covers.
+ double get span => end - start;
+
+ @override
+ bool operator ==(Object other) =>
+ other is SeriesWindow && other.start == start && other.end == end;
+
+ @override
+ int get hashCode => Object.hash(start, end);
+
+ @override
+ String toString() => 'SeriesWindow($start, $end)';
+}
+
+/// A small chart of a whole series with a window over it that can be dragged
+/// and resized — the strip under a chart that shows only part of its data.
+///
+/// Drag inside the window to move it, or drag either handle to move that edge.
+/// The window itself is yours: show it on the main chart through its `minX`
+/// and `maxX`, and keep the one [onChanged] reports.
+///
+/// ```dart
+/// SeriesChart(series: series, minX: window.start - .5, maxX: window.end + .5);
+/// SeriesRangeSelector(
+/// series: series,
+/// window: window,
+/// minSpan: 4,
+/// onChanged: (next) => setState(() => window = next),
+/// );
+/// ```
+class SeriesRangeSelector extends StatefulWidget {
+ /// Creates a range selector over [series].
+ const SeriesRangeSelector({
+ super.key,
+ required this.series,
+ required this.window,
+ required this.onChanged,
+ this.minSpan = 1,
+ this.height = 52,
+ this.xPadding = 0.5,
+ this.border = const BorderSide(color: seriesGridColor),
+ this.maskColor = const Color(0x99000000),
+ this.windowBorderColor = const Color(0xFF909196),
+ this.handleColor = const Color(0xFFFFFFFF),
+ this.handleBorderColor = const Color(0xFF909196),
+ this.handleGripColor = const Color(0xFF909196),
+ });
+
+ /// The whole data set, drawn small.
+ final List series;
+
+ /// The part of it the main chart shows.
+ final SeriesWindow window;
+
+ /// Called with the new window while it is dragged.
+ final ValueChanged onChanged;
+
+ /// The narrowest the window may be made, in x units.
+ final double minSpan;
+
+ /// The selector's height.
+ final double height;
+
+ /// Room either side of the data, in x units.
+ final double xPadding;
+
+ /// A line around the small chart; null draws none.
+ final BorderSide? border;
+
+ /// Shade over the parts outside the window.
+ final Color maskColor;
+
+ /// Lines along the top and bottom of the window.
+ final Color windowBorderColor;
+
+ /// Handle fill.
+ final Color handleColor;
+
+ /// Handle outline.
+ final Color handleBorderColor;
+
+ /// The two grip lines on each handle.
+ final Color handleGripColor;
+
+ @override
+ State createState() => _SeriesRangeSelectorState();
+}
+
+class _SeriesRangeSelectorState extends State {
+ static const double _handleWidth = 10;
+ static const double _handleHitWidth = 28;
+
+ /// The window as it was when the current drag began, and where the finger
+ /// was then. Every update is measured from here rather than added up from
+ /// deltas, so the drag neither loses its slop nor drifts from the finger.
+ SeriesWindow? _dragFrom;
+ double _dragOrigin = 0;
+
+ void _begin(DragStartDetails details) {
+ _dragFrom = widget.window;
+ _dragOrigin = details.globalPosition.dx;
+ }
+
+ void _end([Object? _]) => _dragFrom = null;
+
+ @override
+ Widget build(BuildContext context) {
+ var low = double.infinity;
+ var high = double.negativeInfinity;
+ for (final s in widget.series) {
+ for (final p in s.points) {
+ low = math.min(low, p.x);
+ high = math.max(high, p.x);
+ }
+ }
+ if (!(high > low)) return SizedBox(height: widget.height);
+
+ final domainStart = low - widget.xPadding;
+ final domainEnd = high + widget.xPadding;
+ final domain = domainEnd - domainStart;
+ final minSpan = math.min(widget.minSpan, high - low);
+
+ return SizedBox(
+ height: widget.height,
+ child: LayoutBuilder(
+ builder: (context, constraints) {
+ final width = constraints.maxWidth;
+ double px(double x) => (x - domainStart) / domain * width;
+
+ GestureDragUpdateCallback drag(
+ SeriesWindow Function(SeriesWindow from, double units) move,
+ ) =>
+ (details) {
+ // A selector with no width has nothing to measure a drag against.
+ if (width <= 0) return;
+ final from = _dragFrom ?? widget.window;
+ final units =
+ (details.globalPosition.dx - _dragOrigin) / width * domain;
+ final next = move(from, units);
+ if (next != widget.window) widget.onChanged(next);
+ };
+
+ final pan = drag((from, units) {
+ final span = from.span;
+ final start = (from.start + units)
+ .clamp(low, math.max(low, high - span))
+ .toDouble();
+ return SeriesWindow(start, start + span);
+ });
+ final moveStart = drag((from, units) {
+ final start = (from.start + units)
+ .clamp(low, math.max(low, from.end - minSpan))
+ .toDouble();
+ return SeriesWindow(start, from.end);
+ });
+ final moveEnd = drag((from, units) {
+ final end = (from.end + units)
+ .clamp(math.min(from.start + minSpan, high), high)
+ .toDouble();
+ return SeriesWindow(from.start, end);
+ });
+
+ final startPx = px(widget.window.start);
+ final endPx = px(widget.window.end);
+
+ return Stack(
+ children: [
+ Positioned.fill(
+ child: IgnorePointer(
+ child: SeriesChart(
+ series: widget.series,
+ xAxis: SeriesXAxis.hidden,
+ yAxis: SeriesYAxis.hidden,
+ grid: SeriesGrid.none,
+ border: widget.border,
+ minX: domainStart,
+ maxX: domainEnd,
+ yPadding: 0.05,
+ touch: null,
+ ),
+ ),
+ ),
+ Positioned(
+ left: 0,
+ top: 0,
+ bottom: 0,
+ width: math.max(0, startPx),
+ child: ColoredBox(color: widget.maskColor),
+ ),
+ Positioned(
+ left: endPx,
+ top: 0,
+ bottom: 0,
+ right: 0,
+ child: ColoredBox(color: widget.maskColor),
+ ),
+ Positioned(
+ left: startPx,
+ top: 0,
+ bottom: 0,
+ width: math.max(0, endPx - startPx),
+ child: _draggable(
+ onUpdate: pan,
+ cursor: SystemMouseCursors.grab,
+ child: DecoratedBox(
+ decoration: BoxDecoration(
+ border: Border.symmetric(
+ horizontal: BorderSide(color: widget.windowBorderColor),
+ ),
+ ),
+ ),
+ ),
+ ),
+ _handle(startPx, moveStart),
+ _handle(endPx, moveEnd),
+ ],
+ );
+ },
+ ),
+ );
+ }
+
+ Widget _draggable({
+ required GestureDragUpdateCallback onUpdate,
+ required MouseCursor cursor,
+ required Widget child,
+ }) =>
+ GestureDetector(
+ behavior: HitTestBehavior.translucent,
+ dragStartBehavior: DragStartBehavior.down,
+ onHorizontalDragStart: _begin,
+ onHorizontalDragUpdate: onUpdate,
+ onHorizontalDragEnd: _end,
+ onHorizontalDragCancel: _end,
+ child: MouseRegion(cursor: cursor, child: child),
+ );
+
+ Widget _handle(
+ double centre,
+ GestureDragUpdateCallback onUpdate,
+ ) =>
+ Positioned(
+ left: centre - _handleHitWidth / 2,
+ top: 0,
+ bottom: 0,
+ width: _handleHitWidth,
+ child: _draggable(
+ onUpdate: onUpdate,
+ cursor: SystemMouseCursors.resizeLeftRight,
+ child: Center(
+ child: Container(
+ width: _handleWidth,
+ height: 22,
+ decoration: BoxDecoration(
+ color: widget.handleColor,
+ border: Border.all(color: widget.handleBorderColor),
+ borderRadius: BorderRadius.circular(3),
+ ),
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Container(
+ width: 1, height: 10, color: widget.handleGripColor),
+ const SizedBox(width: 2),
+ Container(
+ width: 1, height: 10, color: widget.handleGripColor),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+}
diff --git a/lib/src/series/series_scale.dart b/lib/src/series/series_scale.dart
new file mode 100644
index 0000000..6a0a0f3
--- /dev/null
+++ b/lib/src/series/series_scale.dart
@@ -0,0 +1,527 @@
+import 'dart:math' as math;
+import 'dart:ui';
+
+import 'package:flutter/painting.dart';
+
+import '../utils/axis_ticks.dart';
+import 'series_axis.dart';
+import 'series_data.dart';
+
+/// The window of values a [SeriesChart] shows, and where its axes are ruled.
+class SeriesViewport {
+ /// Creates a viewport.
+ const SeriesViewport({
+ required this.minX,
+ required this.maxX,
+ required this.minY,
+ required this.maxY,
+ required this.xTicks,
+ required this.yTicks,
+ required this.yStep,
+ });
+
+ /// The x at the left edge of the plot.
+ final double minX;
+
+ /// The x at the right edge of the plot.
+ final double maxX;
+
+ /// The value at the bottom edge of the plot.
+ final double minY;
+
+ /// The value at the top edge of the plot.
+ final double maxY;
+
+ /// The x values labelled and ruled.
+ final List xTicks;
+
+ /// The values labelled and ruled.
+ final List yTicks;
+
+ /// The distance between two value labels, which decides their decimals.
+ final double yStep;
+
+ /// The distance between two x labels, which decides their decimals; 1 when
+ /// there are fewer than two.
+ double get xStep {
+ var step = double.infinity;
+ for (var i = 1; i < xTicks.length; i++) {
+ final gap = (xTicks[i] - xTicks[i - 1]).abs();
+ if (gap > 0 && gap < step) step = gap;
+ }
+ return step.isFinite ? step : 1;
+ }
+
+ /// A viewport part of the way from [a] to [b]; the ticks are [b]'s.
+ static SeriesViewport lerp(SeriesViewport a, SeriesViewport b, double t) =>
+ SeriesViewport(
+ minX: lerpDouble(a.minX, b.minX, t)!,
+ maxX: lerpDouble(a.maxX, b.maxX, t)!,
+ minY: lerpDouble(a.minY, b.minY, t)!,
+ maxY: lerpDouble(a.maxY, b.maxY, t)!,
+ xTicks: b.xTicks,
+ yTicks: b.yTicks,
+ yStep: b.yStep,
+ );
+}
+
+/// Fits a viewport around [series], whose current values are [values] —
+/// `values[i][j]` standing for `series[i].points[j].y`.
+///
+/// Anything given explicitly — [minX], [maxX], [minY], [maxY] — is kept as it
+/// is. The rest is fitted: x spans the points, with [xPadding] either side (half
+/// a unit by default when there are bars, so the end bars are whole); values
+/// span what is inside that x window, plus each bar series' baseline, zero
+/// when [includeZero] is set and any reference line that asks. The value range
+/// is then widened by [yPadding] of its height — never across zero when the
+/// data stays on one side of it — and, with [niceYRange], out to the nearest
+/// round tick either side.
+SeriesViewport fitSeriesViewport({
+ required List series,
+ required List> values,
+ required SeriesXAxis xAxis,
+ required SeriesYAxis yAxis,
+ List referenceLines = const [],
+ double? minX,
+ double? maxX,
+ double? xPadding,
+ double? minY,
+ double? maxY,
+ bool includeZero = false,
+ double yPadding = 0.1,
+ bool niceYRange = true,
+}) {
+ var xLow = double.infinity;
+ var xHigh = double.negativeInfinity;
+ var hasBars = false;
+ for (final s in series) {
+ if (s is BarSeries) hasBars = true;
+ for (final p in s.points) {
+ if (!p.x.isFinite) continue;
+ xLow = math.min(xLow, p.x);
+ xHigh = math.max(xHigh, p.x);
+ }
+ }
+ if (xLow > xHigh) {
+ xLow = 0;
+ xHigh = 1;
+ }
+ final padX = xPadding ?? (hasBars ? 0.5 : 0.0);
+ final left = minX ?? xLow - padX;
+ var right = maxX ?? xHigh + padX;
+ if (!(right > left)) right = left + 1;
+
+ var yLow = double.infinity;
+ var yHigh = double.negativeInfinity;
+ void take(double v) {
+ if (!v.isFinite) return;
+ yLow = math.min(yLow, v);
+ yHigh = math.max(yHigh, v);
+ }
+
+ for (var i = 0; i < series.length; i++) {
+ final s = series[i];
+ final row = i < values.length ? values[i] : const [];
+ var any = false;
+ for (var j = 0; j < s.points.length && j < row.length; j++) {
+ final point = s.points[j];
+ final x = point.x;
+ if (x < left || x > right) continue;
+ final y = row[j];
+ if (y == null) continue;
+ // A stacked bar reaches as far as everything under it plus itself, and
+ // a floating one as far as both of its ends.
+ if (s is BarSeries && s.stack != null) {
+ final base = stackedBase(series, values, i, j);
+ take(base);
+ take(base + (y - s.baseline));
+ } else {
+ final low = point.low;
+ if (low != null) take(low);
+ take(y);
+ }
+ final error = point.yError;
+ if (error != null) {
+ take(y - error.lowerBy);
+ take(y + error.upperBy);
+ }
+ any = true;
+ }
+ if (s is BarSeries && any) take(s.baseline);
+ }
+ if (includeZero) take(0);
+ for (final line in referenceLines) {
+ if (line.direction == SeriesDirection.horizontal && line.extendsRange) {
+ take(line.value);
+ }
+ }
+ final hasData = yLow <= yHigh;
+ if (!hasData) {
+ yLow = 0;
+ yHigh = 1;
+ }
+
+ final span = yHigh - yLow;
+ final pad = span == 0
+ ? math.max(yHigh.abs() * 0.1, 1.0)
+ : span * math.max(0.0, yPadding);
+ var bottom = yLow - pad;
+ var top = yHigh + pad;
+ // Padding is room, not data: a series that never goes below zero does not
+ // get a negative axis to sit on.
+ if (yLow >= 0 && bottom < 0) bottom = 0;
+ if (yHigh <= 0 && top > 0) top = 0;
+ if (minY != null) bottom = minY;
+ if (maxY != null) top = maxY;
+ if (!(top > bottom)) top = bottom + 1;
+
+ var step = yAxis.interval ?? niceStep(top - bottom, yAxis.tickCount);
+ if (niceYRange && step > 0) {
+ if (minY == null) bottom = _floorTo(bottom, step);
+ if (maxY == null) top = _ceilTo(top, step);
+ if (!(top > bottom)) top = bottom + step;
+ }
+
+ final List yTicks;
+ final explicitY = yAxis.ticks;
+ if (explicitY != null) {
+ yTicks = [
+ for (final t in explicitY)
+ if (_within(t, bottom, top)) t,
+ ];
+ if (yTicks.length > 1) step = (yTicks[1] - yTicks[0]).abs();
+ } else if (yAxis.interval != null || niceYRange) {
+ yTicks = _stepTicks(bottom, top, step);
+ } else {
+ yTicks = niceTicks(bottom, top, target: yAxis.tickCount);
+ if (yTicks.length > 1) step = yTicks[1] - yTicks[0];
+ }
+
+ return SeriesViewport(
+ minX: left,
+ maxX: right,
+ minY: bottom,
+ maxY: top,
+ xTicks: _xTicks(xAxis, series, left, right),
+ yTicks: yTicks,
+ yStep: step,
+ );
+}
+
+List _xTicks(
+ SeriesXAxis axis,
+ List series,
+ double left,
+ double right,
+) {
+ final explicit = axis.ticks;
+ if (explicit != null) {
+ return [
+ for (final t in explicit)
+ if (_within(t, left, right)) t,
+ ];
+ }
+
+ final interval = axis.interval;
+ final labels = axis.labels;
+ if (labels != null && axis.labelBuilder == null) {
+ final every = interval == null ? 1 : math.max(1, interval.round());
+ return [
+ for (var i = math.max(0, left.ceil());
+ i <= right.floor() && i < labels.length;
+ i++)
+ if (labels[i].isNotEmpty && i % every == 0) i.toDouble(),
+ ];
+ }
+
+ if (interval != null && interval > 0) {
+ return _stepTicks(left, right, interval);
+ }
+
+ var step = niceStep(right - left, axis.tickCount);
+ final whole = series.every(
+ (s) => s.points.every((p) => p.x == p.x.roundToDouble()),
+ );
+ if (whole) step = math.max(1.0, step.ceilToDouble());
+ return _stepTicks(left, right, step);
+}
+
+bool _within(double v, double low, double high) {
+ final epsilon = (high - low).abs() * 1e-9;
+ return v >= low - epsilon && v <= high + epsilon;
+}
+
+double _floorTo(double v, double step) {
+ final n = v / step;
+ final r = n.roundToDouble();
+ return ((n - r).abs() < 1e-9 ? r : n.floorToDouble()) * step;
+}
+
+double _ceilTo(double v, double step) {
+ final n = v / step;
+ final r = n.roundToDouble();
+ return ((n - r).abs() < 1e-9 ? r : n.ceilToDouble()) * step;
+}
+
+List _stepTicks(double low, double high, double step) {
+ if (!(step > 0) || !low.isFinite || !high.isFinite) return const [];
+ final ticks = [];
+ var n = _ceilTo(low, step) / step;
+ while (ticks.length < 64) {
+ final value = (n * step);
+ // Rounded to the step's precision so 0.1 + 0.2 reads as 0.3.
+ final clean = double.parse(value.toStringAsPrecision(12));
+ if (!_within(clean, low, high)) break;
+ ticks.add(clean == 0 ? 0 : clean);
+ n += 1;
+ }
+ return ticks;
+}
+
+/// Which way a [SeriesChart] is turned.
+enum SeriesOrientation {
+ /// The x axis runs across the chart and the values grow upwards — the
+ /// upright chart.
+ vertical,
+
+ /// The x axis runs down the chart and the values grow rightwards, which is
+ /// what a horizontal bar chart is.
+ horizontal,
+}
+
+/// Where a [SeriesChart]'s plot sits, and the mapping between values and its
+/// pixels.
+///
+/// Two numbers place anything on the plot: the x, along the x axis, and the
+/// value, along the value axis. Which screen direction each of those runs in
+/// is [orientation]'s business, so everything that paints works in x and
+/// value and lets [point] turn them into pixels.
+class SeriesGeometry {
+ /// Creates the geometry of a chart of [size] whose plot is [plot], showing
+ /// [viewport].
+ const SeriesGeometry({
+ required this.size,
+ required this.plot,
+ required this.viewport,
+ this.orientation = SeriesOrientation.vertical,
+ });
+
+ /// Lays out a chart of [size]: [padding] off every edge, then room for each
+ /// axis on its side of the plot.
+ factory SeriesGeometry.layout({
+ required Size size,
+ required EdgeInsets padding,
+ required SeriesXAxis xAxis,
+ required SeriesYAxis yAxis,
+ required SeriesViewport viewport,
+ SeriesOrientation orientation = SeriesOrientation.vertical,
+ }) {
+ final horizontal = orientation == SeriesOrientation.horizontal;
+ final valueRoom = yAxis.show
+ ? (horizontal
+ ? _lineHeight(seriesAxisLabelStyle.merge(yAxis.style)) +
+ yAxis.gap
+ : yAxis.width) +
+ _titleRoom(yAxis.title, yAxis.titleStyle)
+ : 0.0;
+ final xRoom = xAxis.show
+ ? xAxis.height + _titleRoom(xAxis.title, xAxis.titleStyle)
+ : 0.0;
+
+ // Each axis takes its room off one edge; which edge depends on the side
+ // it was given and on which way the chart is turned.
+ var left = padding.left;
+ var right = size.width - padding.right;
+ var top = padding.top;
+ var bottom = size.height - padding.bottom;
+
+ if (horizontal) {
+ if (yAxis.side == SeriesAxisSide.left) {
+ bottom -= valueRoom;
+ } else {
+ top += valueRoom;
+ }
+ if (xAxis.side == SeriesXSide.bottom) {
+ left += xRoom;
+ } else {
+ right -= xRoom;
+ }
+ } else {
+ if (yAxis.side == SeriesAxisSide.left) {
+ left += valueRoom;
+ } else {
+ right -= valueRoom;
+ }
+ if (xAxis.side == SeriesXSide.bottom) {
+ bottom -= xRoom;
+ } else {
+ top += xRoom;
+ }
+ }
+
+ return SeriesGeometry(
+ size: size,
+ plot: Rect.fromLTRB(
+ left,
+ top,
+ math.max(left, right),
+ math.max(top, bottom),
+ ),
+ viewport: viewport,
+ orientation: orientation,
+ );
+ }
+
+ /// The whole chart.
+ final Size size;
+
+ /// The plot area.
+ final Rect plot;
+
+ /// The values shown.
+ final SeriesViewport viewport;
+
+ /// Which way the chart is turned.
+ final SeriesOrientation orientation;
+
+ /// Whether the x axis runs down the chart rather than across it.
+ bool get isHorizontal => orientation == SeriesOrientation.horizontal;
+
+ /// How far along the x axis [x] falls, in pixels — a column on an upright
+ /// chart, a row on a horizontal one.
+ double xToPx(double x) {
+ final span = viewport.maxX - viewport.minX;
+ final t = (x - viewport.minX) / span;
+ return isHorizontal
+ ? plot.top + t * plot.height
+ : plot.left + t * plot.width;
+ }
+
+ /// How far along the value axis [y] falls, in pixels.
+ double yToPx(double y) {
+ final span = viewport.maxY - viewport.minY;
+ final t = (y - viewport.minY) / span;
+ return isHorizontal
+ ? plot.left + t * plot.width
+ : plot.bottom - t * plot.height;
+ }
+
+ /// The pixel the pair ([x], [y]) sits on.
+ Offset point(double x, double y) =>
+ isHorizontal ? Offset(yToPx(y), xToPx(x)) : Offset(xToPx(x), yToPx(y));
+
+ /// The x at [px] pixels along the x axis.
+ double pxToX(double px) {
+ final length = isHorizontal ? plot.height : plot.width;
+ if (length <= 0) return viewport.minX;
+ final span = viewport.maxX - viewport.minX;
+ final start = isHorizontal ? plot.top : plot.left;
+ return viewport.minX + (px - start) / length * span;
+ }
+
+ /// The x under [local], wherever the chart is pointing.
+ double pxToXAt(Offset local) => pxToX(isHorizontal ? local.dy : local.dx);
+
+ /// How many pixels one unit of x takes.
+ double get unitWidth =>
+ (isHorizontal ? plot.height : plot.width) /
+ math.max(1e-12, viewport.maxX - viewport.minX);
+
+ /// The stretch of plot between the x values [from] and [to].
+ Rect xBand(double from, double to) {
+ final a = xToPx(from);
+ final b = xToPx(to);
+ return isHorizontal
+ ? Rect.fromLTRB(plot.left, math.min(a, b), plot.right, math.max(a, b))
+ : Rect.fromLTRB(math.min(a, b), plot.top, math.max(a, b), plot.bottom);
+ }
+
+ /// The stretch of plot between the values [from] and [to].
+ Rect valueBand(double from, double to) {
+ final a = yToPx(from);
+ final b = yToPx(to);
+ return isHorizontal
+ ? Rect.fromLTRB(math.min(a, b), plot.top, math.max(a, b), plot.bottom)
+ : Rect.fromLTRB(plot.left, math.min(a, b), plot.right, math.max(a, b));
+ }
+
+ /// The box covering x [x0] to [x1] and values [v0] to [v1] — a bar.
+ Rect box(double x0, double x1, double v0, double v1) {
+ final a = point(x0, v0);
+ final b = point(x1, v1);
+ return Rect.fromLTRB(
+ math.min(a.dx, b.dx),
+ math.min(a.dy, b.dy),
+ math.max(a.dx, b.dx),
+ math.max(a.dy, b.dy),
+ );
+ }
+
+ /// The line ruled right across the plot at the value [y].
+ (Offset, Offset) valueLine(double y) {
+ final band = valueBand(y, y);
+ return isHorizontal
+ ? (Offset(band.left, plot.top), Offset(band.left, plot.bottom))
+ : (Offset(plot.left, band.top), Offset(plot.right, band.top));
+ }
+
+ /// The line ruled right across the plot at the x [x].
+ (Offset, Offset) xLine(double x) {
+ final band = xBand(x, x);
+ return isHorizontal
+ ? (Offset(plot.left, band.top), Offset(plot.right, band.top))
+ : (Offset(band.left, plot.top), Offset(band.left, plot.bottom));
+ }
+
+ /// How much room a title and its gap take across an axis.
+ static double _titleRoom(String? title, TextStyle? style) {
+ if (title == null || title.isEmpty) return 0;
+ return _lineHeight(seriesAxisTitleStyle.merge(style)) + 2;
+ }
+
+ /// The height of one line of [style], measured once per layout.
+ static double _lineHeight(TextStyle style) {
+ final painter = TextPainter(
+ text: TextSpan(text: '0', style: style),
+ textDirection: TextDirection.ltr,
+ )..layout();
+ final height = painter.height;
+ painter.dispose();
+ return height;
+ }
+}
+
+/// The value a stacked bar starts from: how far the bars before it in the
+/// same stack have already reached at that point, on its own side of the
+/// baseline. A bar that stacks on nothing starts at its series' baseline.
+double stackedBase(
+ List series,
+ List> values,
+ int index,
+ int pointIndex,
+) {
+ final self = series[index];
+ if (self is! BarSeries) return self.baseline;
+ final stack = self.stack;
+ if (stack == null) return self.baseline;
+ final row = index < values.length ? values[index] : const [];
+ final own = pointIndex < row.length ? row[pointIndex] : null;
+ if (own == null) return self.baseline;
+ final up = own >= self.baseline;
+ final x = self.points[pointIndex].x;
+
+ var base = self.baseline;
+ for (var i = 0; i < index; i++) {
+ final other = series[i];
+ if (other is! BarSeries || other.stack != stack) continue;
+ final otherRow = i < values.length ? values[i] : const [];
+ for (var j = 0; j < other.points.length && j < otherRow.length; j++) {
+ if (other.points[j].x != x) continue;
+ final y = otherRow[j];
+ if (y == null || !y.isFinite) continue;
+ final height = y - other.baseline;
+ if (height >= 0 == up) base += height;
+ }
+ }
+ return base;
+}
diff --git a/lib/src/series/series_touch.dart b/lib/src/series/series_touch.dart
new file mode 100644
index 0000000..e7b3885
--- /dev/null
+++ b/lib/src/series/series_touch.dart
@@ -0,0 +1,298 @@
+import 'package:flutter/widgets.dart';
+
+import 'series_data.dart';
+
+/// The gesture that shows a chart's crosshair and tooltip.
+enum SeriesTouchTrigger {
+ /// Shown while a finger is down or dragging across the chart, gone when it
+ /// lifts — `fl_chart`'s default.
+ press,
+
+ /// Shown after a long press, following the finger until it lifts. Leaves a
+ /// quick swipe free to scroll whatever the chart sits in.
+ longPress,
+
+ /// A tap places it and it stays; a drag moves it, and tapping the same point
+ /// again clears it.
+ tap,
+
+ /// Only a mouse hovering, or the controller, shows it.
+ none,
+}
+
+/// What a touch picks out on a [SeriesChart].
+enum SeriesTouchSnap {
+ /// The x nearest the finger, and every series' value there — right for
+ /// lines and bars over a shared x.
+ x,
+
+ /// The single point nearest the finger, within `SeriesTouch.threshold` —
+ /// right for a scatter plot.
+ nearestPoint,
+}
+
+/// Where the tooltip sits relative to the touched point.
+enum SeriesTooltipPlacement {
+ /// Beside the crosshair, at the top of the plot, flipping to the other side
+ /// when there is no room.
+ beside,
+
+ /// Centred above the highest touched value, dropping below it when there is
+ /// no room.
+ above,
+}
+
+/// A line drawn through the touched point.
+@immutable
+class SeriesCrosshairLine {
+ /// Creates a crosshair line.
+ const SeriesCrosshairLine({
+ this.color = const Color(0x66909196),
+ this.width = 1,
+ this.dashPattern,
+ });
+
+ /// Line colour.
+ final Color color;
+
+ /// Line width.
+ final double width;
+
+ /// Dash and gap lengths; null draws solid.
+ final List? dashPattern;
+}
+
+/// Chooses the marker for one touched value; null draws none for it.
+typedef SeriesMarkerBuilder = SeriesDot? Function(SeriesTouchValue value);
+
+/// Builds a tooltip for the touched values; return null to show none.
+typedef SeriesTooltipBuilder = Widget? Function(
+ BuildContext context, SeriesTouchDetails details);
+
+/// The card that names the touched values.
+@immutable
+class SeriesTooltip {
+ /// Creates a tooltip.
+ ///
+ /// With no [builder], the card lists [title] and a coloured row per touched
+ /// series, each value written by [valueFormatter] or the value axis's
+ /// formatter.
+ const SeriesTooltip({
+ this.builder,
+ this.title,
+ this.valueFormatter,
+ this.placement = SeriesTooltipPlacement.beside,
+ this.margin = 8,
+ this.backgroundColor = const Color(0xF2202329),
+ this.borderColor,
+ this.borderRadius = 8,
+ this.padding = const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
+ this.titleStyle,
+ this.valueStyle,
+ this.shadows = const [
+ BoxShadow(color: Color(0x33000000), blurRadius: 8, offset: Offset(0, 2)),
+ ],
+ this.showSeriesLabels = false,
+ });
+
+ /// Builds the whole tooltip; the chart places it and keeps it inside.
+ final SeriesTooltipBuilder? builder;
+
+ /// Writes the heading; null uses the x-axis label of the touched x.
+ final String? Function(SeriesTouchDetails details)? title;
+
+ /// Writes one touched value.
+ final String Function(SeriesTouchValue value)? valueFormatter;
+
+ /// Where the card sits.
+ final SeriesTooltipPlacement placement;
+
+ /// Gap between the card and the crosshair or point.
+ final double margin;
+
+ /// Card colour.
+ final Color backgroundColor;
+
+ /// Card outline; null draws none.
+ final Color? borderColor;
+
+ /// Card corner radius.
+ final double borderRadius;
+
+ /// Space inside the card.
+ final EdgeInsets padding;
+
+ /// Heading style.
+ final TextStyle? titleStyle;
+
+ /// Value style; null uses white at 12.
+ final TextStyle? valueStyle;
+
+ /// Card shadow.
+ final List shadows;
+
+ /// Whether each row starts with the series label.
+ final bool showSeriesLabels;
+}
+
+/// How a [SeriesChart] reacts to touch and hover.
+@immutable
+class SeriesTouch {
+ /// Creates a touch configuration.
+ const SeriesTouch({
+ this.trigger = SeriesTouchTrigger.press,
+ this.hover = true,
+ this.snap = SeriesTouchSnap.x,
+ this.threshold = 24,
+ this.line = const SeriesCrosshairLine(),
+ this.horizontalLine,
+ this.showMarkers = true,
+ this.markerBuilder,
+ this.tooltip = const SeriesTooltip(),
+ });
+
+ /// The gesture that shows the crosshair.
+ final SeriesTouchTrigger trigger;
+
+ /// Whether a hovering mouse shows it too.
+ final bool hover;
+
+ /// Whether a touch picks an x or a single point.
+ final SeriesTouchSnap snap;
+
+ /// How close, in logical pixels, a point has to be for
+ /// [SeriesTouchSnap.nearestPoint] to pick it.
+ final double threshold;
+
+ /// The line through the touched x, across the values; null draws none.
+ final SeriesCrosshairLine? line;
+
+ /// A line through the first touched value, across the x axis; null draws
+ /// none.
+ final SeriesCrosshairLine? horizontalLine;
+
+ /// Whether each touched value gets a marker.
+ final bool showMarkers;
+
+ /// Chooses each marker; null draws a dot of radius 4 in the series colour at
+ /// that value.
+ final SeriesMarkerBuilder? markerBuilder;
+
+ /// The tooltip; null shows none, leaving `onTouch` to do something else.
+ final SeriesTooltip? tooltip;
+}
+
+/// One series' value at the touched x.
+@immutable
+class SeriesTouchValue {
+ /// Creates a touched value.
+ const SeriesTouchValue({
+ required this.seriesIndex,
+ required this.series,
+ required this.pointIndex,
+ required this.point,
+ required this.position,
+ required this.color,
+ });
+
+ /// Where the series is in `SeriesChart.series`.
+ final int seriesIndex;
+
+ /// The series.
+ final PlotSeries series;
+
+ /// Where the point is in the series' points.
+ final int pointIndex;
+
+ /// The point.
+ final SeriesPoint point;
+
+ /// The point on the chart, in the chart's local pixels — the far end of a
+ /// bar.
+ final Offset position;
+
+ /// The series colour at this value.
+ final Color color;
+
+ /// The value.
+ double get value => point.y!;
+}
+
+/// What is under the crosshair.
+@immutable
+class SeriesTouchDetails {
+ /// Creates touch details.
+ const SeriesTouchDetails({
+ required this.x,
+ required this.values,
+ required this.position,
+ required this.plotRect,
+ });
+
+ /// The touched x, snapped to the nearest point.
+ final double x;
+
+ /// Every series with a value at [x], in series order — or, for
+ /// [SeriesTouchSnap.nearestPoint], the one point touched. Series with
+ /// `showInTooltip` off and series with a gap here are left out.
+ final List values;
+
+ /// Where the tooltip is anchored: on an upright chart the crosshair's x at
+ /// the height of the highest touched value; on a horizontal one the
+ /// crosshair's y beside the furthest value.
+ final Offset position;
+
+ /// The plot area, in the chart's local pixels.
+ final Rect plotRect;
+
+ /// [x] as an index, for charts of plain values.
+ int get index => x.round();
+
+ /// The touched value of the series at [seriesIndex], if it has one.
+ SeriesTouchValue? valueOf(int seriesIndex) {
+ for (final value in values) {
+ if (value.seriesIndex == seriesIndex) return value;
+ }
+ return null;
+ }
+}
+
+/// Shows or clears a [SeriesChart]'s crosshair from code, and shares it.
+///
+/// Give two charts the same controller and touching either marks the same x
+/// on both — a price panel and a volume panel under it, say.
+class SeriesChartController extends ChangeNotifier {
+ double? _x;
+ int? _seriesIndex;
+ int? _pointIndex;
+
+ /// The x under the crosshair, or null when it is hidden.
+ double? get x => _x;
+
+ /// The series of the single point shown, when one was named.
+ int? get seriesIndex => _seriesIndex;
+
+ /// The index of the single point shown, when one was named.
+ int? get pointIndex => _pointIndex;
+
+ /// Shows the crosshair at [x] — or, given [seriesIndex] and [pointIndex],
+ /// on that one point.
+ void show(double x, {int? seriesIndex, int? pointIndex}) {
+ if (_x == x && _seriesIndex == seriesIndex && _pointIndex == pointIndex) {
+ return;
+ }
+ _x = x;
+ _seriesIndex = seriesIndex;
+ _pointIndex = pointIndex;
+ notifyListeners();
+ }
+
+ /// Hides the crosshair.
+ void clear() {
+ if (_x == null) return;
+ _x = null;
+ _seriesIndex = null;
+ _pointIndex = null;
+ notifyListeners();
+ }
+}
diff --git a/pubspec.yaml b/pubspec.yaml
index 7771eff..095d195 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,9 +1,9 @@
name: ohlcv_chart
description: >-
- The most complete candlestick chart for Flutter: eight chart types, 31
- indicators and 29 drawing tools, a market-depth chart, alerts and bar replay,
- in pure CustomPainter.
-version: 2.4.1
+ The most complete candlestick chart for Flutter, plus depth, line, bar,
+ scatter, pie, radar and heatmap charts: 31 indicators, 29 drawing tools,
+ pure CustomPainter.
+version: 2.5.0
homepage: https://github.com/CtrlAltDevelop
repository: https://github.com/CtrlAltDevelop/ohlcv_chart
issue_tracker: https://github.com/CtrlAltDevelop/ohlcv_chart/issues
@@ -13,6 +13,7 @@ topics:
- candlestick
- trading
- finance
+ - data-visualization
# Shown on the pub.dev listing. Up to 10 entries; png, jpg, gif or webp;
# 4 MiB each at most. Regenerate them with example/tool/screenshots.dart.
@@ -21,30 +22,30 @@ screenshots:
A candlestick chart with three moving averages, take-profit and
stop-loss markers, the volume pane and a MACD sub-chart.
path: screenshots/candles.png
+ - description: >-
+ Four charts a business screen asks for: a return split at zero, profit
+ bars, deposits and withdrawals with a tooltip, and a balance sparkline.
+ path: screenshots/series-charts.png
+ - description: >-
+ A doughnut of holdings with its total in the hole and a slice pulled
+ out, beside a radar web scoring two strategies over five measures.
+ path: screenshots/pie-radar.png
+ - description: >-
+ Six months of activity as a contribution graph, beside orders by hour
+ and weekday with the count written in each square.
+ path: screenshots/heatmap.png
- description: >-
The Ichimoku cloud and Supertrend over the candles, with Stochastic RSI
and the Awesome oscillator in their own panes.
path: screenshots/overlays.png
- description: >-
- A volume profile drawn back from the axis, its point of control and
- value area picked out, under an anchored VWAP.
- path: screenshots/profile.png
- - description: >-
- A second instrument overlaid on the candles, rebased to the left of the
- visible window so the two say relative performance.
- path: screenshots/comparison.png
+ Bars floating between two values as a waterfall, beside a forecast
+ inside its band with error bars every few points.
+ path: screenshots/series-ranges.png
- description: >-
A working order and an open position, each drawn the width of the chart
and tagged on the axis with its size and unrealised P&L.
path: screenshots/trading.png
- - description: >-
- Earnings, a dividend, a split and a news mark, badged under the candles
- they fell on.
- path: screenshots/events.png
- - description: >-
- Two charts of the same market held on one window and one crosshair, so
- moving or pointing at either carries the other with it.
- path: screenshots/linked-charts.png
- description: >-
The same market as line break, Kagi, point & figure and range bars.
path: screenshots/bar-types.png
@@ -57,17 +58,16 @@ screenshots:
path: screenshots/depth.png
environment:
- sdk: ^3.13.0
- flutter: ">=3.47.0"
+ sdk: ">=3.6.0 <4.0.0"
+ flutter: ">=3.27.0"
dependencies:
flutter:
sdk: flutter
- decimal: ^3.2.6
- flutter_svg: ^2.3.0
- intl: ^0.20.3
+ decimal: ">=3.0.0 <4.0.0"
+ intl: ">=0.19.0 <0.21.0"
dev_dependencies:
- flutter_lints: ^6.0.0
+ flutter_lints: ">=5.0.0 <7.0.0"
flutter_test:
sdk: flutter
diff --git a/screenshots/heatmap.png b/screenshots/heatmap.png
new file mode 100644
index 0000000..5836dc4
Binary files /dev/null and b/screenshots/heatmap.png differ
diff --git a/screenshots/pie-radar.png b/screenshots/pie-radar.png
new file mode 100644
index 0000000..cc7d6f0
Binary files /dev/null and b/screenshots/pie-radar.png differ
diff --git a/screenshots/series-charts.png b/screenshots/series-charts.png
new file mode 100644
index 0000000..27c9a1a
Binary files /dev/null and b/screenshots/series-charts.png differ
diff --git a/screenshots/series-horizontal.png b/screenshots/series-horizontal.png
new file mode 100644
index 0000000..df6537c
Binary files /dev/null and b/screenshots/series-horizontal.png differ
diff --git a/screenshots/series-panels.png b/screenshots/series-panels.png
new file mode 100644
index 0000000..cc82a64
Binary files /dev/null and b/screenshots/series-panels.png differ
diff --git a/screenshots/series-ranges.png b/screenshots/series-ranges.png
new file mode 100644
index 0000000..c3b48a3
Binary files /dev/null and b/screenshots/series-ranges.png differ
diff --git a/screenshots/series-scatter.png b/screenshots/series-scatter.png
new file mode 100644
index 0000000..8d3f895
Binary files /dev/null and b/screenshots/series-scatter.png differ
diff --git a/screenshots/series-window.png b/screenshots/series-window.png
new file mode 100644
index 0000000..f50a76c
Binary files /dev/null and b/screenshots/series-window.png differ
diff --git a/screenshots/watermark.png b/screenshots/watermark.png
new file mode 100644
index 0000000..dd58180
Binary files /dev/null and b/screenshots/watermark.png differ
diff --git a/test/chart_surface_test.dart b/test/chart_surface_test.dart
index 3840f03..9e8b245 100644
--- a/test/chart_surface_test.dart
+++ b/test/chart_surface_test.dart
@@ -33,7 +33,6 @@ Widget _chart({
data,
ChartColors(),
isTrendLine: false,
- watermarkAssetPath: 'assets/none.svg',
timeFrame: const Duration(minutes: 15),
showNowPrice: false,
controller: controller,
diff --git a/test/chart_type_test.dart b/test/chart_type_test.dart
index c0f3535..6bc9a89 100644
--- a/test/chart_type_test.dart
+++ b/test/chart_type_test.dart
@@ -17,7 +17,6 @@ Widget _chart({ChartType? type, bool isLine = false, double? baseline}) {
data,
ChartColors(),
isTrendLine: false,
- watermarkAssetPath: 'assets/none.svg',
timeFrame: const Duration(minutes: 15),
chartType: type,
isLine: isLine,
@@ -77,7 +76,6 @@ void main() {
data,
ChartColors(),
isTrendLine: false,
- watermarkAssetPath: 'assets/none.svg',
timeFrame: const Duration(minutes: 15),
chartType: type,
indicators: [MaIndicator(period: 5)],
diff --git a/test/comparison_test.dart b/test/comparison_test.dart
index ddd221b..5e0c9b8 100644
--- a/test/comparison_test.dart
+++ b/test/comparison_test.dart
@@ -28,7 +28,6 @@ Widget _chart(
data,
colors ?? ChartColors(),
isTrendLine: false,
- watermarkAssetPath: 'assets/none.svg',
timeFrame: const Duration(minutes: 15),
showNowPrice: false,
comparisons: comparisons,
@@ -341,7 +340,6 @@ void main() {
data,
ChartColors(),
isTrendLine: false,
- watermarkAssetPath: 'assets/none.svg',
timeFrame: const Duration(minutes: 15),
showNowPrice: false,
chartType: ChartType.line,
diff --git a/test/context_menu_test.dart b/test/context_menu_test.dart
index 2146cbf..a76ff12 100644
--- a/test/context_menu_test.dart
+++ b/test/context_menu_test.dart
@@ -37,7 +37,6 @@ List _candles() {
data,
ChartColors(),
isTrendLine: true,
- watermarkAssetPath: 'assets/none.svg',
timeFrame: const Duration(minutes: 15),
showNowPrice: false,
drawingStyle: style,
diff --git a/test/crosshair_sync_test.dart b/test/crosshair_sync_test.dart
index 5d6b904..a2011d6 100644
--- a/test/crosshair_sync_test.dart
+++ b/test/crosshair_sync_test.dart
@@ -19,7 +19,6 @@ Widget chart(
data,
ChartColors(),
isTrendLine: false,
- watermarkAssetPath: 'assets/none.svg',
timeFrame: const Duration(minutes: 15),
showNowPrice: false,
controller: controller,
@@ -211,7 +210,6 @@ void main() {
data,
ChartColors(),
isTrendLine: false,
- watermarkAssetPath: 'assets/none.svg',
timeFrame: const Duration(minutes: 15),
showNowPrice: false,
controller: top,
@@ -224,7 +222,6 @@ void main() {
data,
ChartColors(),
isTrendLine: false,
- watermarkAssetPath: 'assets/none.svg',
timeFrame: const Duration(minutes: 15),
showNowPrice: false,
controller: bottom,
diff --git a/test/drawing_controller_test.dart b/test/drawing_controller_test.dart
index ffd0e67..e62eda4 100644
--- a/test/drawing_controller_test.dart
+++ b/test/drawing_controller_test.dart
@@ -26,7 +26,6 @@ Widget _chart({
data,
ChartColors(),
isTrendLine: true,
- watermarkAssetPath: 'assets/none.svg',
timeFrame: const Duration(minutes: 15),
showNowPrice: false,
drawingStyle: style,
diff --git a/test/drawing_manager_test.dart b/test/drawing_manager_test.dart
index d08c306..663ff6a 100644
--- a/test/drawing_manager_test.dart
+++ b/test/drawing_manager_test.dart
@@ -172,7 +172,6 @@ void main() {
],
ChartColors(),
isTrendLine: true,
- watermarkAssetPath: 'assets/none.svg',
timeFrame: const Duration(minutes: 1),
showNowPrice: false,
drawingController: controller,
diff --git a/test/drawing_test.dart b/test/drawing_test.dart
index d497a2a..f2e1a5a 100644
--- a/test/drawing_test.dart
+++ b/test/drawing_test.dart
@@ -34,7 +34,6 @@ _chartWithLine({
data,
ChartColors(),
isTrendLine: true,
- watermarkAssetPath: 'assets/none.svg',
timeFrame: const Duration(minutes: 15),
showNowPrice: false,
drawingStyle: style,
@@ -79,7 +78,6 @@ Future _selectLine(WidgetTester tester) async {
candleData,
ChartColors(),
isTrendLine: true,
- watermarkAssetPath: 'assets/none.svg',
timeFrame: const Duration(minutes: 15),
showNowPrice: false,
currentDrawingTool: tool,
@@ -312,7 +310,6 @@ void main() {
data,
ChartColors(),
isTrendLine: true,
- watermarkAssetPath: 'assets/none.svg',
timeFrame: const Duration(minutes: 15),
showNowPrice: false,
drawingStyle: _grabAnything,
@@ -373,7 +370,6 @@ void main() {
data,
ChartColors(),
isTrendLine: true,
- watermarkAssetPath: 'assets/none.svg',
timeFrame: const Duration(minutes: 15),
showNowPrice: false,
// Ends unreachable, stroke reachable: every grab is a body grab.
@@ -556,7 +552,6 @@ void main() {
data,
ChartColors(),
isTrendLine: true,
- watermarkAssetPath: 'assets/none.svg',
timeFrame: const Duration(minutes: 15),
showNowPrice: false,
magnetMode: true,
@@ -610,7 +605,6 @@ void main() {
data,
ChartColors(),
isTrendLine: true,
- watermarkAssetPath: 'assets/none.svg',
timeFrame: const Duration(minutes: 15),
showNowPrice: false,
magnetMode: true,
@@ -746,7 +740,6 @@ void main() {
data,
ChartColors(),
isTrendLine: true,
- watermarkAssetPath: 'assets/none.svg',
timeFrame: const Duration(minutes: 15),
showNowPrice: false,
drawingStyle: _grabAnything,
@@ -784,7 +777,6 @@ void main() {
data,
ChartColors(),
isTrendLine: true,
- watermarkAssetPath: 'assets/none.svg',
timeFrame: const Duration(minutes: 15),
showNowPrice: false,
drawingStyle: _grabAnything,
diff --git a/test/drawing_ux_test.dart b/test/drawing_ux_test.dart
index 223ae95..3d65c9f 100644
--- a/test/drawing_ux_test.dart
+++ b/test/drawing_ux_test.dart
@@ -30,7 +30,6 @@ Widget _host(Widget child) => MaterialApp(
data,
ChartColors(),
isTrendLine: true,
- watermarkAssetPath: 'assets/none.svg',
timeFrame: const Duration(minutes: 15),
showNowPrice: false,
drawingStyle: style,
@@ -875,7 +874,6 @@ void main() {
data,
ChartColors(),
isTrendLine: true,
- watermarkAssetPath: 'assets/none.svg',
timeFrame: const Duration(minutes: 15),
showNowPrice: false,
drawingStyle: _grabAnything,
diff --git a/test/events_test.dart b/test/events_test.dart
index 7e14d4f..49fe179 100644
--- a/test/events_test.dart
+++ b/test/events_test.dart
@@ -30,7 +30,6 @@ Widget _chart(
data,
colors ?? ChartColors(),
isTrendLine: false,
- watermarkAssetPath: 'assets/none.svg',
timeFrame: const Duration(minutes: 1),
showNowPrice: false,
chartStyle: style ?? ChartStyle(),
diff --git a/test/fit_content_test.dart b/test/fit_content_test.dart
index da2c720..fca970b 100644
--- a/test/fit_content_test.dart
+++ b/test/fit_content_test.dart
@@ -25,7 +25,6 @@ Widget _chart({required bool fitContent, int count = 10}) {
data,
ChartColors(),
isTrendLine: false,
- watermarkAssetPath: 'assets/none.svg',
timeFrame: const Duration(minutes: 5),
xFrontPadding: 0,
volHidden: true,
diff --git a/test/golden_test.dart b/test/golden_test.dart
index 6455c2f..114f546 100644
--- a/test/golden_test.dart
+++ b/test/golden_test.dart
@@ -63,7 +63,6 @@ Widget _chart({
data ?? _market(),
ChartColors(),
isTrendLine: true,
- watermarkAssetPath: 'assets/none.svg',
timeFrame: const Duration(minutes: 15),
showNowPrice: false,
showScrollToNowButton: false,
diff --git a/test/goldens/series_area.png b/test/goldens/series_area.png
new file mode 100644
index 0000000..c155583
Binary files /dev/null and b/test/goldens/series_area.png differ
diff --git a/test/goldens/series_bars.png b/test/goldens/series_bars.png
new file mode 100644
index 0000000..caa54d0
Binary files /dev/null and b/test/goldens/series_bars.png differ
diff --git a/test/goldens/series_lines_tooltip.png b/test/goldens/series_lines_tooltip.png
new file mode 100644
index 0000000..81a3493
Binary files /dev/null and b/test/goldens/series_lines_tooltip.png differ
diff --git a/test/heatmap_test.dart b/test/heatmap_test.dart
new file mode 100644
index 0000000..0bbe88d
--- /dev/null
+++ b/test/heatmap_test.dart
@@ -0,0 +1,265 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:ohlcv_chart/ohlcv_chart.dart';
+
+void main() {
+ group('the grid', () {
+ const layout = HeatmapLayout(
+ grid: Rect.fromLTWH(0, 0, 300, 200),
+ rows: 4,
+ columns: 6,
+ );
+
+ test('squares divide the grid evenly', () {
+ expect(layout.columnWidth, 50);
+ expect(layout.rowHeight, 50);
+ expect(layout.cellRect(0, 0), const Rect.fromLTWH(0, 0, 50, 50));
+ expect(layout.cellRect(5, 3), const Rect.fromLTWH(250, 150, 50, 50));
+ });
+
+ test('spacing is taken off every square, not off the grid', () {
+ const spaced = HeatmapLayout(
+ grid: Rect.fromLTWH(0, 0, 300, 200),
+ rows: 4,
+ columns: 6,
+ spacing: 4,
+ );
+ expect(spaced.cellRect(0, 0), const Rect.fromLTRB(2, 2, 48, 48));
+ expect(spaced.grid.width, 300);
+ });
+
+ test('a point names the square under it', () {
+ expect(layout.cellAt(const Offset(10, 10)), (0, 0));
+ expect(layout.cellAt(const Offset(260, 160)), (5, 3));
+ expect(layout.cellAt(const Offset(-1, 10)), isNull);
+ expect(layout.cellAt(const Offset(10, 400)), isNull);
+ });
+
+ test('an empty grid has no squares to hit', () {
+ const none = HeatmapLayout(
+ grid: Rect.fromLTWH(0, 0, 100, 100),
+ rows: 0,
+ columns: 0,
+ );
+ expect(none.cellAt(const Offset(10, 10)), isNull);
+ });
+ });
+
+ group('scales', () {
+ test('a gradient fades between its colours across the range', () {
+ const scale = HeatmapGradientScale(
+ colors: [Color(0xFF000000), Color(0xFFFFFFFF)],
+ );
+ expect(scale.colorAt(0, 0, 10), const Color(0xFF000000));
+ expect(scale.colorAt(10, 0, 10), const Color(0xFFFFFFFF));
+ expect(scale.colorAt(5, 0, 10).r, closeTo(0.5, 0.02));
+ });
+
+ test('a value outside the range is held at the end it passed', () {
+ const scale = HeatmapGradientScale(
+ colors: [Color(0xFF000000), Color(0xFFFFFFFF)],
+ );
+ expect(scale.colorAt(-5, 0, 10), const Color(0xFF000000));
+ expect(scale.colorAt(99, 0, 10), const Color(0xFFFFFFFF));
+ });
+
+ test('a range of nothing puts every value at the top colour', () {
+ const scale = HeatmapGradientScale(
+ colors: [Color(0xFF000000), Color(0xFFFFFFFF)],
+ );
+ expect(scale.colorAt(3, 3, 3), const Color(0xFFFFFFFF));
+ });
+
+ test('steps paint whole bands, and below the first is the first', () {
+ const scale = HeatmapStepScale(
+ steps: [
+ HeatmapStep(0, Color(0xFF111111)),
+ HeatmapStep(5, Color(0xFF222222)),
+ HeatmapStep(10, Color(0xFF333333)),
+ ],
+ );
+ expect(scale.colorAt(-3, 0, 10), const Color(0xFF111111));
+ expect(scale.colorAt(0, 0, 10), const Color(0xFF111111));
+ expect(scale.colorAt(7, 0, 10), const Color(0xFF222222));
+ expect(scale.colorAt(40, 0, 10), const Color(0xFF333333));
+ });
+ });
+
+ group('reading the cells', () {
+ test('a matrix becomes a cell per value, rows down and columns across', () {
+ final cells = heatmapCellsOf([
+ [1, 2, 3],
+ [4, null],
+ ]);
+ expect(cells, hasLength(5));
+ expect(cells.first.x, 0);
+ expect(cells.first.y, 0);
+ expect(cells[3].x, 0);
+ expect(cells[3].y, 1);
+ expect(cells.last.isEmpty, isTrue);
+ });
+
+ test('the range skips the empty squares', () {
+ final (low, high) = heatmapValueRange(const [
+ HeatmapCell(x: 0, y: 0, value: 4),
+ HeatmapCell(x: 1, y: 0, value: null),
+ HeatmapCell(x: 2, y: 0, value: -2),
+ ]);
+ expect(low, -2);
+ expect(high, 4);
+ });
+
+ test('nothing to measure reads as 0 to 1', () {
+ expect(heatmapValueRange(const []), (0.0, 1.0));
+ });
+ });
+
+ group('the widget', () {
+ testWidgets('it reports the square under a tap', (tester) async {
+ final touched = [];
+ await tester.pumpWidget(
+ Directionality(
+ textDirection: TextDirection.ltr,
+ child: Center(
+ child: SizedBox(
+ width: 300,
+ height: 200,
+ child: HeatmapChart.matrix(
+ const [
+ [1, 2, 3],
+ [4, 5, 6],
+ ],
+ xAxis: HeatmapAxis.hidden,
+ yAxis: HeatmapAxis.hidden,
+ onTouch: touched.add,
+ ),
+ ),
+ ),
+ ),
+ );
+
+ final chart = tester.getRect(find.byType(HeatmapChart));
+ // The top-right square is the third column of the first row.
+ await tester.tapAt(chart.topRight + const Offset(-10, 10));
+ await tester.pump();
+
+ expect(touched.first?.x, 2);
+ expect(touched.first?.y, 0);
+ expect(touched.first?.value, 3);
+ });
+
+ testWidgets('a square with no cell reports itself as empty', (
+ tester,
+ ) async {
+ final touched = [];
+ await tester.pumpWidget(
+ Directionality(
+ textDirection: TextDirection.ltr,
+ child: Center(
+ child: SizedBox(
+ width: 200,
+ height: 200,
+ child: HeatmapChart(
+ cells: const [HeatmapCell(x: 0, y: 0, value: 1)],
+ columns: 2,
+ rows: 2,
+ xAxis: HeatmapAxis.hidden,
+ yAxis: HeatmapAxis.hidden,
+ onTouch: touched.add,
+ ),
+ ),
+ ),
+ ),
+ );
+
+ final chart = tester.getRect(find.byType(HeatmapChart));
+ await tester.tapAt(chart.bottomRight - const Offset(10, 10));
+ await tester.pump();
+
+ expect(touched.first?.x, 1);
+ expect(touched.first?.cell, isNull);
+ expect(touched.first?.value, isNull);
+ });
+
+ testWidgets('square cells keep their shape in a box that is not', (
+ tester,
+ ) async {
+ await tester.pumpWidget(
+ Directionality(
+ textDirection: TextDirection.ltr,
+ child: Center(
+ child: SizedBox(
+ width: 400,
+ height: 100,
+ child: HeatmapChart.matrix(
+ const [
+ [1, 2],
+ [3, 4],
+ ],
+ squareCells: true,
+ xAxis: HeatmapAxis.hidden,
+ yAxis: HeatmapAxis.hidden,
+ ),
+ ),
+ ),
+ ),
+ );
+ expect(tester.takeException(), isNull);
+ });
+
+ testWidgets('a tooltip is placed over the touched square', (tester) async {
+ await tester.pumpWidget(
+ Directionality(
+ textDirection: TextDirection.ltr,
+ child: Center(
+ child: SizedBox(
+ width: 300,
+ height: 200,
+ child: HeatmapChart.matrix(
+ const [
+ [1, 2, 3],
+ ],
+ xAxis: HeatmapAxis.hidden,
+ yAxis: HeatmapAxis.hidden,
+ tooltipBuilder: (context, details) => Text('${details.value}'),
+ ),
+ ),
+ ),
+ ),
+ );
+
+ expect(find.text('2.0'), findsNothing);
+ // Held rather than tapped: letting go clears the touch again.
+ final gesture = await tester.startGesture(
+ tester.getCenter(find.byType(HeatmapChart)),
+ );
+ await tester.pump();
+ expect(find.text('2.0'), findsOneWidget);
+
+ await gesture.up();
+ await tester.pump();
+ expect(find.text('2.0'), findsNothing);
+ });
+
+ testWidgets('the legend draws its scale', (tester) async {
+ await tester.pumpWidget(
+ const Directionality(
+ textDirection: TextDirection.ltr,
+ child: Center(
+ child: SizedBox(
+ width: 200,
+ child: HeatmapLegend(
+ scale: HeatmapGradientScale(colors: [Color(0xFF000000)]),
+ low: 'Less',
+ high: 'More',
+ ),
+ ),
+ ),
+ ),
+ );
+ expect(tester.takeException(), isNull);
+ expect(find.text('Less'), findsOneWidget);
+ expect(find.text('More'), findsOneWidget);
+ });
+ });
+}
diff --git a/test/legend_hover_test.dart b/test/legend_hover_test.dart
index 7536aa0..cce41d2 100644
--- a/test/legend_hover_test.dart
+++ b/test/legend_hover_test.dart
@@ -25,7 +25,6 @@ Widget _chart({
data,
ChartColors(),
isTrendLine: false,
- watermarkAssetPath: 'assets/none.svg',
timeFrame: const Duration(minutes: 15),
showNowPrice: false,
crosshairOnHover: crosshairOnHover,
diff --git a/test/load_more_and_info_dialog_test.dart b/test/load_more_and_info_dialog_test.dart
index 273b7ab..c245b2b 100644
--- a/test/load_more_and_info_dialog_test.dart
+++ b/test/load_more_and_info_dialog_test.dart
@@ -24,7 +24,6 @@ Widget _chart({
data,
ChartColors(),
isTrendLine: false,
- watermarkAssetPath: 'assets/none.svg',
timeFrame: const Duration(minutes: 15),
showNowPrice: false,
onLoadMore: onLoadMore,
@@ -51,7 +50,6 @@ Widget _chartWith(List data) => MaterialApp(
data,
ChartColors(),
isTrendLine: false,
- watermarkAssetPath: 'assets/none.svg',
timeFrame: const Duration(minutes: 15),
showNowPrice: false,
),
diff --git a/test/locked_price_scale_test.dart b/test/locked_price_scale_test.dart
index fea714e..c5e05b0 100644
--- a/test/locked_price_scale_test.dart
+++ b/test/locked_price_scale_test.dart
@@ -32,7 +32,6 @@ Widget _chart({
data ?? _trend(),
ChartColors(),
isTrendLine: false,
- watermarkAssetPath: 'assets/none.svg',
timeFrame: const Duration(minutes: 15),
showNowPrice: false,
lockPriceScale: lock,
diff --git a/test/new_tools_test.dart b/test/new_tools_test.dart
index 831eb09..2785830 100644
--- a/test/new_tools_test.dart
+++ b/test/new_tools_test.dart
@@ -27,7 +27,6 @@ _chartWithTool(DrawingTool tool) {
data,
ChartColors(),
isTrendLine: true,
- watermarkAssetPath: 'assets/none.svg',
timeFrame: const Duration(minutes: 15),
showNowPrice: false,
currentDrawingTool: tool,
@@ -53,7 +52,6 @@ _chartWithTool(DrawingTool tool) {
data,
ChartColors(),
isTrendLine: true,
- watermarkAssetPath: 'assets/none.svg',
timeFrame: const Duration(minutes: 15),
showNowPrice: false,
drawingStyle: style,
@@ -246,7 +244,6 @@ void main() {
data,
ChartColors(),
isTrendLine: true,
- watermarkAssetPath: 'assets/none.svg',
timeFrame: const Duration(minutes: 15),
showNowPrice: false,
currentDrawingTool: tool,
diff --git a/test/optional_params_test.dart b/test/optional_params_test.dart
new file mode 100644
index 0000000..9ec98d9
--- /dev/null
+++ b/test/optional_params_test.dart
@@ -0,0 +1,111 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:ohlcv_chart/ohlcv_chart.dart';
+
+import 'test_utils.dart';
+
+void main() {
+ testWidgets('a chart needs nothing but its candles and colours', (
+ tester,
+ ) async {
+ final data = candles([for (var i = 0; i < 40; i++) 100.0 + i % 7]);
+ DataUtil.calculate(data);
+
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Scaffold(
+ body: SizedBox(
+ width: 400,
+ height: 300,
+ child: KChartWidget(data, ChartColors()),
+ ),
+ ),
+ ),
+ );
+ await tester.pump();
+
+ expect(tester.takeException(), isNull);
+ final widget = tester.widget(find.byType(KChartWidget));
+ expect(widget.isTrendLine, isFalse);
+ expect(widget.watermark, isNull);
+ expect(widget.timeFrame, isNull);
+ // With no time frame there is no countdown, so no one-second timer is left
+ // running when the test ends — the test framework would fail on one.
+ });
+
+ testWidgets('a watermark widget is sized and centred in the candle area', (
+ tester,
+ ) async {
+ final data = candles([for (var i = 0; i < 40; i++) 100.0 + i % 7]);
+ DataUtil.calculate(data);
+ const mark = Key('watermark');
+
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Scaffold(
+ body: Align(
+ alignment: Alignment.topLeft,
+ child: SizedBox(
+ width: 400,
+ height: 300,
+ child: KChartWidget(
+ data,
+ ChartColors(),
+ volHidden: true,
+ watermark: const SizedBox(key: mark, height: 20),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ await tester.pump();
+ expect(tester.takeException(), isNull);
+
+ final state = tester.state(find.byType(KChartWidget)) as dynamic;
+ final Rect main = state.painter.mMainRect;
+ final origin = tester.getTopLeft(find.byType(KChartWidget));
+ final rect = tester.getRect(find.byKey(mark)).shift(-origin);
+ final scale = const ChartStyle().watermarkScale;
+
+ expect(rect.width, closeTo(main.shortestSide * scale, 0.01));
+ expect(rect.center.dx, closeTo(main.center.dx, 0.01));
+ expect(rect.center.dy, closeTo(main.center.dy, 0.01));
+ // It takes no touches: the chart under it still gets them.
+ expect(
+ find.descendant(
+ of: find.byType(IgnorePointer),
+ matching: find.byKey(mark),
+ ),
+ findsOneWidget,
+ );
+ });
+
+ testWidgets('a time frame still counts the current candle down', (
+ tester,
+ ) async {
+ final data = candles([for (var i = 0; i < 40; i++) 100.0 + i % 7]);
+ DataUtil.calculate(data);
+
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Scaffold(
+ body: SizedBox(
+ width: 400,
+ height: 300,
+ child: KChartWidget(
+ data,
+ ChartColors(),
+ timeFrame: const Duration(minutes: 1),
+ ),
+ ),
+ ),
+ ),
+ );
+ await tester.pump();
+ expect(tester.takeException(), isNull);
+
+ // Dispose the chart so its countdown timer is cancelled.
+ await tester.pumpWidget(const SizedBox());
+ });
+}
diff --git a/test/out_of_range_lines_test.dart b/test/out_of_range_lines_test.dart
index b6241af..92ac75c 100644
--- a/test/out_of_range_lines_test.dart
+++ b/test/out_of_range_lines_test.dart
@@ -77,7 +77,6 @@ Widget _chart({
data,
ChartColors(),
isTrendLine: false,
- watermarkAssetPath: 'assets/none.svg',
timeFrame: const Duration(minutes: 15),
showNowPrice: showNowPrice,
lockPriceScale: true,
diff --git a/test/paint_benchmark.dart b/test/paint_benchmark.dart
index 8343532..3b1b108 100644
--- a/test/paint_benchmark.dart
+++ b/test/paint_benchmark.dart
@@ -82,7 +82,6 @@ void main() {
data ?? _market(),
ChartColors(),
isTrendLine: false,
- watermarkAssetPath: 'assets/none.svg',
timeFrame: const Duration(minutes: 15),
chartType: type,
indicators: indicators,
diff --git a/test/pane_options_test.dart b/test/pane_options_test.dart
index 183426c..f0d4908 100644
--- a/test/pane_options_test.dart
+++ b/test/pane_options_test.dart
@@ -26,7 +26,6 @@ Widget _chart(
data,
ChartColors(),
isTrendLine: false,
- watermarkAssetPath: 'assets/none.svg',
timeFrame: const Duration(minutes: 1),
showNowPrice: false,
indicators: indicators,
@@ -432,7 +431,7 @@ void main() {
List.filled(30, value),
alerts: const [IndicatorAlert(level: 50)],
),
- ], onIndicatorAlert: (_, _, _, v) => reports.add(v));
+ ], onIndicatorAlert: (_, __, ___, v) => reports.add(v));
await tester.pumpWidget(chart(10));
await tester.pumpAndSettle();
@@ -453,7 +452,7 @@ void main() {
List.filled(30, value),
alerts: const [IndicatorAlert(level: 50)],
),
- ], onIndicatorAlert: (_, _, _, v) => reports.add(v));
+ ], onIndicatorAlert: (_, __, ___, v) => reports.add(v));
await tester.pumpWidget(chart(10));
await tester.pumpAndSettle();
@@ -471,7 +470,7 @@ void main() {
List.filled(30, null),
alerts: const [IndicatorAlert(level: 50)],
),
- ], onIndicatorAlert: (_, _, _, v) => reports.add(v)),
+ ], onIndicatorAlert: (_, __, ___, v) => reports.add(v)),
);
await tester.pumpAndSettle();
diff --git a/test/pie_radar_test.dart b/test/pie_radar_test.dart
new file mode 100644
index 0000000..2d7bc5b
--- /dev/null
+++ b/test/pie_radar_test.dart
@@ -0,0 +1,276 @@
+import 'dart:math' as math;
+
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:ohlcv_chart/ohlcv_chart.dart';
+
+void main() {
+ group('pie layout', () {
+ const sections = [
+ PieSection(value: 50),
+ PieSection(value: 25),
+ PieSection(value: 25),
+ ];
+
+ test('each section gets the share of the circle its value is worth', () {
+ final slices = layOutPie(
+ sections: sections,
+ radius: 100,
+ innerRadius: 0,
+ startAngle: 0,
+ );
+
+ expect(slices.map((s) => s.index), [0, 1, 2]);
+ expect(slices[0].sweepAngle, closeTo(math.pi, 1e-9));
+ expect(slices[1].sweepAngle, closeTo(math.pi / 2, 1e-9));
+ expect(slices[1].startAngle, closeTo(math.pi, 1e-9));
+ expect(slices[2].startAngle, closeTo(math.pi * 1.5, 1e-9));
+ });
+
+ test(
+ 'sections worth nothing are left out, and nothing at all lays out '
+ 'as nothing', () {
+ final some = layOutPie(
+ sections: const [
+ PieSection(value: 1),
+ PieSection(value: 0),
+ PieSection(value: 1),
+ ],
+ radius: 50,
+ innerRadius: 0,
+ );
+ expect(some.map((s) => s.index), [0, 2]);
+
+ expect(
+ layOutPie(
+ sections: const [PieSection(value: 0)],
+ radius: 50,
+ innerRadius: 0,
+ ),
+ isEmpty,
+ );
+ });
+
+ test('going anticlockwise mirrors the sections about the start', () {
+ final back = layOutPie(
+ sections: sections,
+ radius: 100,
+ innerRadius: 0,
+ startAngle: 0,
+ clockwise: false,
+ );
+ // The first section ends where a clockwise one would have started.
+ expect(back[0].startAngle + back[0].sweepAngle, closeTo(0, 1e-9));
+ expect(back[1].startAngle + back[1].sweepAngle, closeTo(-math.pi, 1e-9));
+ });
+
+ test('the animation sweeps the sections open without moving them', () {
+ final half = layOutPie(
+ sections: sections,
+ radius: 100,
+ innerRadius: 0,
+ startAngle: 0,
+ animation: 0.5,
+ );
+ expect(half[0].sweepAngle, closeTo(math.pi / 2, 1e-9));
+ expect(half[1].startAngle, closeTo(math.pi, 1e-9));
+ });
+
+ test('the space between sections is taken off each of them', () {
+ final spaced = layOutPie(
+ sections: sections,
+ radius: 100,
+ innerRadius: 0,
+ startAngle: 0,
+ space: 10,
+ );
+ expect(spaced[0].sweepAngle, closeTo(math.pi - 0.1, 1e-9));
+ expect(spaced[0].startAngle, closeTo(0.05, 1e-9));
+ });
+ });
+
+ group('what a touch on a pie lands on', () {
+ final slices = layOutPie(
+ sections: const [PieSection(value: 1), PieSection(value: 1)],
+ radius: 100,
+ innerRadius: 40,
+ startAngle: 0,
+ );
+ const centre = Offset(100, 100);
+
+ test('a point in a section names it', () {
+ expect(
+ pieSectionAt(
+ local: centre + const Offset(60, 20),
+ centre: centre,
+ slices: slices,
+ ),
+ 0,
+ );
+ expect(
+ pieSectionAt(
+ local: centre + const Offset(-60, -20),
+ centre: centre,
+ slices: slices,
+ ),
+ 1,
+ );
+ });
+
+ test('the hole and the space outside name nothing', () {
+ expect(
+ pieSectionAt(local: centre, centre: centre, slices: slices),
+ isNull,
+ );
+ expect(
+ pieSectionAt(
+ local: centre + const Offset(200, 0),
+ centre: centre,
+ slices: slices,
+ ),
+ isNull,
+ );
+ });
+
+ test('a section that crosses three o\'clock still matches', () {
+ final wrapped = layOutPie(
+ sections: const [PieSection(value: 1)],
+ radius: 100,
+ innerRadius: 0,
+ startAngle: -math.pi / 4,
+ );
+ expect(
+ pieSectionAt(
+ local: centre + const Offset(50, 1),
+ centre: centre,
+ slices: wrapped,
+ ),
+ 0,
+ );
+ });
+ });
+
+ group('radar geometry', () {
+ test('features are spread evenly, the first at twelve o\'clock', () {
+ const centre = Offset(100, 100);
+ final top = radarCorner(
+ centre: centre,
+ radius: 50,
+ count: 4,
+ index: 0,
+ );
+ final right = radarCorner(
+ centre: centre,
+ radius: 50,
+ count: 4,
+ index: 1,
+ );
+ expect(top.dx, closeTo(100, 1e-9));
+ expect(top.dy, closeTo(50, 1e-9));
+ expect(right.dx, closeTo(150, 1e-9));
+ expect(right.dy, closeTo(100, 1e-9));
+ });
+
+ test('a value is placed between the middle and the outer ring', () {
+ const layout = RadarLayout(
+ centre: Offset.zero,
+ radius: 100,
+ count: 5,
+ minValue: 0,
+ maxValue: 10,
+ startAngle: 0,
+ );
+ expect(layout.radiusFor(0), 0);
+ expect(layout.radiusFor(5), 50);
+ expect(layout.radiusFor(10), 100);
+ // Values beyond the range are held at the edge rather than drawn off it.
+ expect(layout.radiusFor(20), 100);
+ expect(layout.radiusFor(-5), 0);
+ });
+ });
+
+ group('the widgets', () {
+ testWidgets('a pie draws its sections and answers a tap', (tester) async {
+ final touched = [];
+ await tester.pumpWidget(
+ Directionality(
+ textDirection: TextDirection.ltr,
+ child: Center(
+ child: SizedBox(
+ width: 200,
+ height: 200,
+ child: PieChart(
+ sections: const [
+ PieSection(value: 1, color: Color(0xFF00FF00), label: 'a'),
+ PieSection(value: 1, color: Color(0xFF0000FF), label: 'b'),
+ ],
+ centerSpaceRadius: 20,
+ centerChild: const Text('mid'),
+ onTouch: touched.add,
+ ),
+ ),
+ ),
+ ),
+ );
+
+ expect(find.text('mid'), findsOneWidget);
+ expect(find.byType(CustomPaint), findsWidgets);
+
+ // Right of the middle is the first section, which starts at twelve.
+ await tester.tapAt(
+ tester.getCenter(find.byType(PieChart)) + const Offset(60, 10));
+ await tester.pump();
+ expect(touched.first?.index, 0);
+ });
+
+ testWidgets('a radar reports the corner under a tap', (tester) async {
+ final touched = [];
+ await tester.pumpWidget(
+ Directionality(
+ textDirection: TextDirection.ltr,
+ child: Center(
+ child: SizedBox(
+ width: 300,
+ height: 300,
+ child: RadarChart(
+ features: const ['a', 'b', 'c', 'd'],
+ series: const [
+ RadarSeries(values: [10, 5, 5, 5]),
+ ],
+ minValue: 0,
+ maxValue: 10,
+ radius: 100,
+ onTouch: touched.add,
+ ),
+ ),
+ ),
+ ),
+ );
+
+ final centre = tester.getCenter(find.byType(RadarChart));
+ final top = Offset(centre.dx, centre.dy - 100);
+ await tester.tapAt(top);
+ await tester.pump();
+ expect(touched.first?.featureIndex, 0);
+ expect(touched.first?.value, 10);
+ });
+
+ testWidgets('a radar with no features still draws', (tester) async {
+ await tester.pumpWidget(
+ const Directionality(
+ textDirection: TextDirection.ltr,
+ child: SizedBox(
+ width: 200,
+ height: 200,
+ child: RadarChart(
+ series: [
+ RadarSeries(values: [1, 2, 3]),
+ ],
+ ),
+ ),
+ ),
+ );
+ expect(tester.takeException(), isNull);
+ });
+ });
+}
diff --git a/test/price_axis_gutter_test.dart b/test/price_axis_gutter_test.dart
index 3414fe6..77798df 100644
--- a/test/price_axis_gutter_test.dart
+++ b/test/price_axis_gutter_test.dart
@@ -34,7 +34,6 @@ Widget _chart({
data,
ChartColors(),
isTrendLine: false,
- watermarkAssetPath: 'assets/none.svg',
timeFrame: const Duration(minutes: 15),
showNowPrice: true,
verticalTextAlignment: alignment,
diff --git a/test/price_axis_test.dart b/test/price_axis_test.dart
index 7637759..d086cf8 100644
--- a/test/price_axis_test.dart
+++ b/test/price_axis_test.dart
@@ -48,7 +48,6 @@ Widget _chart({
data,
ChartColors(),
isTrendLine: false,
- watermarkAssetPath: 'assets/none.svg',
timeFrame: const Duration(minutes: 15),
priceAxisScale: scale,
),
diff --git a/test/price_formatter_test.dart b/test/price_formatter_test.dart
index 4702160..2e37d2b 100644
--- a/test/price_formatter_test.dart
+++ b/test/price_formatter_test.dart
@@ -32,7 +32,6 @@ Widget _chart({
data,
ChartColors(),
isTrendLine: false,
- watermarkAssetPath: 'assets/none.svg',
timeFrame: const Duration(minutes: 15),
showNowPrice: true,
priceAxisScale: scale,
diff --git a/test/price_scale_extras_test.dart b/test/price_scale_extras_test.dart
index c486c34..6dba4fe 100644
--- a/test/price_scale_extras_test.dart
+++ b/test/price_scale_extras_test.dart
@@ -28,7 +28,6 @@ Widget _chart(
data,
ChartColors(),
isTrendLine: false,
- watermarkAssetPath: 'assets/none.svg',
timeFrame: const Duration(minutes: 1),
showNowPrice: false,
chartType: type,
@@ -295,7 +294,6 @@ void main() {
_candles(),
ChartColors(),
isTrendLine: false,
- watermarkAssetPath: 'assets/none.svg',
timeFrame: const Duration(minutes: 1),
showNowPrice: false,
priceAxisScale: PriceAxisScale.indexedTo100,
diff --git a/test/price_scale_test.dart b/test/price_scale_test.dart
index 1f34d97..83e6f03 100644
--- a/test/price_scale_test.dart
+++ b/test/price_scale_test.dart
@@ -24,7 +24,6 @@ Widget _chart({
data,
ChartColors(),
isTrendLine: false,
- watermarkAssetPath: 'assets/none.svg',
timeFrame: const Duration(minutes: 15),
controller: controller,
priceScaleDrag: priceScaleDrag,
@@ -232,7 +231,6 @@ void main() {
data,
ChartColors(),
isTrendLine: true,
- watermarkAssetPath: 'assets/none.svg',
timeFrame: const Duration(minutes: 15),
currentDrawingTool: DrawingTool.horizontal,
),
diff --git a/test/profile_and_levels_test.dart b/test/profile_and_levels_test.dart
index 5935978..8a4868d 100644
--- a/test/profile_and_levels_test.dart
+++ b/test/profile_and_levels_test.dart
@@ -532,7 +532,6 @@ void main() {
data,
ChartColors(),
isTrendLine: false,
- watermarkAssetPath: 'assets/none.svg',
timeFrame: const Duration(minutes: 15),
indicators: [
VolumeProfileIndicator(),
diff --git a/test/render_perf_test.dart b/test/render_perf_test.dart
index 5fd8332..548ef52 100644
--- a/test/render_perf_test.dart
+++ b/test/render_perf_test.dart
@@ -58,7 +58,6 @@ Widget _chart(
data,
ChartColors(),
isTrendLine: false,
- watermarkAssetPath: 'assets/none.svg',
timeFrame: const Duration(minutes: 15),
chartType: type,
indicators: indicators,
diff --git a/test/replay_test.dart b/test/replay_test.dart
index c9ac1fc..5af1696 100644
--- a/test/replay_test.dart
+++ b/test/replay_test.dart
@@ -15,7 +15,6 @@ Widget _chart(List data, {ChartReplayController? replay}) {
data,
ChartColors(),
isTrendLine: false,
- watermarkAssetPath: 'assets/none.svg',
timeFrame: const Duration(minutes: 15),
replay: replay,
indicators: [MaIndicator(period: 5)],
diff --git a/test/scale_test.dart b/test/scale_test.dart
index 0176a9c..a5fc726 100644
--- a/test/scale_test.dart
+++ b/test/scale_test.dart
@@ -47,8 +47,6 @@ class _ProbePainter extends BaseChartPainter {
void drawCrossLineText(Canvas canvas, Size size) {}
@override
void drawSignals(Canvas canvas) {}
- @override
- void drawWatermarkLogo(Canvas canvas, Size size) {}
}
/// Paints [data] with [indicators] enabled and returns the computed rects.
diff --git a/test/secondary_axis_test.dart b/test/secondary_axis_test.dart
index 68ab904..2036154 100644
--- a/test/secondary_axis_test.dart
+++ b/test/secondary_axis_test.dart
@@ -59,7 +59,6 @@ Widget _chart({
data,
ChartColors(),
isTrendLine: false,
- watermarkAssetPath: 'assets/none.svg',
timeFrame: const Duration(minutes: 15),
showNowPrice: false,
verticalTextAlignment: alignment,
diff --git a/test/series_chart_test.dart b/test/series_chart_test.dart
new file mode 100644
index 0000000..8521522
--- /dev/null
+++ b/test/series_chart_test.dart
@@ -0,0 +1,353 @@
+import 'package:flutter/gestures.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:ohlcv_chart/ohlcv_chart.dart';
+import 'package:ohlcv_chart/src/series/series_chart_painter.dart';
+
+Widget _host(Widget child, {double width = 300, double height = 200}) =>
+ MaterialApp(
+ home: Scaffold(
+ body: Align(
+ alignment: Alignment.topLeft,
+ child: SizedBox(width: width, height: height, child: child),
+ ),
+ ),
+ );
+
+SeriesChartPainter _painter(WidgetTester tester) {
+ final paints = tester.widgetList(
+ find.descendant(
+ of: find.byType(SeriesChart),
+ matching: find.byType(CustomPaint),
+ ),
+ );
+ return paints.map((p) => p.painter).whereType().first;
+}
+
+/// A chart whose plot is the whole 300 × 200 box, so x = i sits at 75 * i.
+SeriesChart _bare(
+ List values, {
+ SeriesTouch? touch = const SeriesTouch(),
+ SeriesChartController? controller,
+ ValueChanged? onTouch,
+ Duration animationDuration = Duration.zero,
+ bool animateOnMount = true,
+}) => SeriesChart(
+ series: [LineSeries.values(values)],
+ xAxis: SeriesXAxis.hidden,
+ yAxis: SeriesYAxis.hidden,
+ touch: touch,
+ controller: controller,
+ onTouch: onTouch,
+ animationDuration: animationDuration,
+ animateOnMount: animateOnMount,
+);
+
+void main() {
+ testWidgets('draws every kind of series and furniture', (tester) async {
+ await tester.pumpWidget(
+ _host(
+ SeriesChart(
+ series: [
+ BarSeries.values(
+ [3, -2, 5, -1, 4],
+ color: Colors.green,
+ negativeColor: Colors.red,
+ radius: 3,
+ trackColor: Colors.white10,
+ ),
+ LineSeries.values(
+ [1, 4, null, -3, 2],
+ curve: LineCurve.monotone,
+ negativeColor: Colors.red,
+ fill: SeriesFill.fade(Colors.blue, negativeColor: Colors.red),
+ dot: const SeriesDot(),
+ dashPattern: const [4, 2],
+ ),
+ LineSeries.values([2, 2, 2, 2, 2], curve: LineCurve.step),
+ LineSeries.values([0, 1, 0, 1, 0], curve: LineCurve.smooth),
+ ],
+ xAxis: const SeriesXAxis(labels: ['a', 'b', 'c', 'd', 'e']),
+ yAxis: const SeriesYAxis(side: SeriesAxisSide.right),
+ grid: const SeriesGrid(dashPattern: [4, 4]),
+ border: const BorderSide(color: Colors.grey),
+ referenceLines: const [
+ SeriesReferenceLine.horizontal(
+ 0,
+ dashPattern: [3, 3],
+ label: 'zero',
+ ),
+ SeriesReferenceLine.vertical(2, label: 'now'),
+ ],
+ bands: const [SeriesBand.vertical(3, 4, color: Colors.white12)],
+ semanticLabel: 'demo',
+ ),
+ ),
+ );
+
+ expect(tester.takeException(), isNull);
+ expect(find.bySemanticsLabel('demo'), findsOneWidget);
+ });
+
+ testWidgets('hidden axes give the plot the whole chart', (tester) async {
+ await tester.pumpWidget(_host(_bare([1, 2, 3]), height: 58));
+
+ expect(_painter(tester).geometry.plot, const Rect.fromLTWH(0, 0, 300, 58));
+ });
+
+ testWidgets('in a box with no height it takes its default height', (
+ tester,
+ ) async {
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Scaffold(
+ body: SingleChildScrollView(
+ child: Column(
+ children: [
+ SeriesChart(
+ series: [
+ LineSeries.values([1, 2, 3]),
+ ],
+ defaultHeight: 120,
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+
+ expect(tester.getSize(find.byType(SeriesChart)).height, 120);
+ });
+
+ testWidgets('a long press shows the touched values until it lifts', (
+ tester,
+ ) async {
+ final reports = [];
+ await tester.pumpWidget(
+ _host(
+ _bare(
+ [10, 20, 30, 40, 50],
+ touch: const SeriesTouch(trigger: SeriesTouchTrigger.longPress),
+ onTouch: reports.add,
+ ),
+ ),
+ );
+
+ final origin = tester.getTopLeft(find.byType(SeriesChart));
+ final gesture = await tester.startGesture(origin + const Offset(160, 100));
+ await tester.pump(kLongPressTimeout + const Duration(milliseconds: 50));
+ await tester.pump();
+
+ expect(reports.last!.x, 2);
+ expect(reports.last!.values.single.value, 30);
+ expect(find.text('30'), findsOneWidget);
+
+ await gesture.up();
+ await tester.pump();
+ await tester.pump();
+
+ expect(reports.last, isNull);
+ expect(find.text('30'), findsNothing);
+ });
+
+ testWidgets('a press follows a drag across the chart', (tester) async {
+ final reports = [];
+ await tester.pumpWidget(
+ _host(_bare([10, 20, 30, 40, 50], onTouch: reports.add)),
+ );
+
+ final origin = tester.getTopLeft(find.byType(SeriesChart));
+ final gesture = await tester.startGesture(origin + const Offset(75, 100));
+ await tester.pump(const Duration(milliseconds: 150));
+ await tester.pump();
+ expect(reports.last!.x, 1);
+
+ await gesture.moveBy(const Offset(60, 0));
+ await gesture.moveBy(const Offset(90, 0));
+ await tester.pump();
+ await tester.pump();
+ expect(reports.last!.x, 3);
+
+ await gesture.up();
+ await tester.pump();
+ await tester.pump();
+ expect(reports.last, isNull);
+ });
+
+ testWidgets('one controller marks the same x on two charts', (tester) async {
+ final controller = SeriesChartController();
+ addTearDown(controller.dispose);
+ final top = [];
+ final bottom = [];
+
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Scaffold(
+ body: Column(
+ children: [
+ SizedBox(
+ height: 100,
+ child: _bare(
+ [1, 2, 3],
+ controller: controller,
+ onTouch: top.add,
+ ),
+ ),
+ SizedBox(
+ height: 100,
+ child: SeriesChart(
+ series: [
+ BarSeries.values([5, -5, 5]),
+ ],
+ controller: controller,
+ onTouch: bottom.add,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+
+ controller.show(1);
+ await tester.pump();
+ await tester.pump();
+
+ expect(top.last!.values.single.value, 2);
+ expect(bottom.last!.values.single.value, -5);
+
+ controller.clear();
+ await tester.pump();
+ await tester.pump();
+ expect(top.last, isNull);
+ expect(bottom.last, isNull);
+ });
+
+ testWidgets('a tooltip builder replaces the card', (tester) async {
+ final controller = SeriesChartController();
+ addTearDown(controller.dispose);
+ await tester.pumpWidget(
+ _host(
+ _bare(
+ [1, 2, 3],
+ controller: controller,
+ touch: SeriesTouch(
+ tooltip: SeriesTooltip(
+ builder: (context, details) => Text('custom ${details.index}'),
+ ),
+ ),
+ ),
+ ),
+ );
+
+ controller.show(1);
+ await tester.pump();
+
+ expect(find.text('custom 1'), findsOneWidget);
+ });
+
+ testWidgets('new values move from the old ones', (tester) async {
+ await tester.pumpWidget(
+ _host(
+ _bare(
+ [0, 0],
+ animationDuration: const Duration(milliseconds: 200),
+ animateOnMount: false,
+ ),
+ ),
+ );
+ expect(_painter(tester).values[0][0], 0);
+
+ await tester.pumpWidget(
+ _host(
+ _bare(
+ [100, 100],
+ animationDuration: const Duration(milliseconds: 200),
+ animateOnMount: false,
+ ),
+ ),
+ );
+ await tester.pump(const Duration(milliseconds: 100));
+ final halfway = _painter(tester).values[0][0]!;
+ expect(halfway, greaterThan(0));
+ expect(halfway, lessThan(100));
+
+ await tester.pumpAndSettle();
+ expect(_painter(tester).values[0][0], 100);
+ });
+
+ testWidgets('the first build grows out of the baseline', (tester) async {
+ await tester.pumpWidget(
+ _host(
+ _bare([-10, 20], animationDuration: const Duration(milliseconds: 200)),
+ ),
+ );
+ // Zero is in range here, so both values start on it.
+ expect(_painter(tester).values[0], [0, 0]);
+
+ await tester.pumpAndSettle();
+ expect(_painter(tester).values[0], [-10, 20]);
+ });
+
+ testWidgets('a baseline out of range grows from the plot edge', (
+ tester,
+ ) async {
+ await tester.pumpWidget(
+ _host(
+ _bare([10, 20], animationDuration: const Duration(milliseconds: 200)),
+ ),
+ );
+ final painter = _painter(tester);
+ expect(painter.values[0][0], painter.geometry.viewport.minY);
+
+ await tester.pumpAndSettle();
+ expect(_painter(tester).values[0][0], 10);
+ });
+
+ testWidgets('the range selector pans and resizes its window', (tester) async {
+ var window = const SeriesWindow(10, 15);
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Scaffold(
+ body: Align(
+ alignment: Alignment.topLeft,
+ child: SizedBox(
+ width: 300,
+ child: StatefulBuilder(
+ builder: (context, setState) => SeriesRangeSelector(
+ series: [
+ LineSeries.values([
+ for (var i = 0; i < 21; i++) i.toDouble(),
+ ]),
+ ],
+ window: window,
+ minSpan: 4,
+ onChanged: (next) => setState(() => window = next),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+
+ // The data spans -0.5 to 20.5, so one unit is 300 / 21 pixels wide.
+ final origin = tester.getTopLeft(find.byType(SeriesRangeSelector));
+ const unit = 300 / 21;
+ await tester.dragFrom(
+ origin + const Offset(12.5 * unit + 0.5, 26),
+ const Offset(3 * unit, 0),
+ );
+ await tester.pump();
+ expect(window.start, closeTo(13, 0.05));
+ expect(window.end, closeTo(18, 0.05));
+
+ await tester.dragFrom(
+ origin + Offset((window.end + 0.5) * unit, 26),
+ const Offset(-200, 0),
+ );
+ await tester.pump();
+ expect(window.end, closeTo(window.start + 4, 0.001));
+ });
+}
diff --git a/test/series_extras_test.dart b/test/series_extras_test.dart
new file mode 100644
index 0000000..d1640f9
--- /dev/null
+++ b/test/series_extras_test.dart
@@ -0,0 +1,447 @@
+import 'dart:ui';
+
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:ohlcv_chart/ohlcv_chart.dart';
+import 'package:ohlcv_chart/src/renderer/text_painter_cache.dart';
+import 'package:ohlcv_chart/src/series/series_chart_painter.dart';
+import 'package:ohlcv_chart/src/series/series_paths.dart';
+import 'package:ohlcv_chart/src/series/series_scale.dart';
+
+/// Records the geometry that actually reached the canvas.
+class Recorder implements Canvas {
+ final List paths = [];
+ final List rrects = [];
+ final List rects = [];
+ final List<(Offset, Offset)> lines = [];
+ int labels = 0;
+
+ @override
+ void drawPath(Path path, Paint paint) => paths.add(path.getBounds());
+ @override
+ void drawRRect(RRect rrect, Paint paint) => rrects.add(rrect);
+ @override
+ void drawRect(Rect rect, Paint paint) => rects.add(rect);
+ @override
+ void drawLine(Offset a, Offset b, Paint paint) => lines.add((a, b));
+ @override
+ void drawParagraph(Paragraph paragraph, Offset offset) => labels++;
+ @override
+ void noSuchMethod(Invocation invocation) {}
+}
+
+Recorder paintChart(
+ List series, {
+ SeriesOrientation orientation = SeriesOrientation.vertical,
+ List betweenFills = const [],
+ SeriesXAxis xAxis = const SeriesXAxis(),
+ SeriesYAxis yAxis = const SeriesYAxis(),
+ Size size = const Size(200, 100),
+}) {
+ final values = [
+ for (final s in series) [for (final p in s.points) p.isGap ? null : p.y],
+ ];
+ final viewport = fitSeriesViewport(
+ series: series,
+ values: values,
+ xAxis: xAxis,
+ yAxis: yAxis,
+ );
+ final geometry = SeriesGeometry.layout(
+ size: size,
+ padding: EdgeInsets.zero,
+ xAxis: xAxis,
+ yAxis: yAxis,
+ viewport: viewport,
+ orientation: orientation,
+ );
+ final recorder = Recorder();
+ SeriesChartPainter(
+ geometry: geometry,
+ series: series,
+ values: values,
+ xAxis: xAxis,
+ yAxis: yAxis,
+ grid: SeriesGrid.none,
+ border: null,
+ referenceLines: const [],
+ bands: const [],
+ betweenFills: betweenFills,
+ backgroundColor: null,
+ clipToPlot: true,
+ textCache: TextPainterCache(capacity: 16),
+ ).paint(recorder, size);
+ return recorder;
+}
+
+void main() {
+ group('a horizontal chart', () {
+ test('runs its x axis down the plot and grows its values rightwards', () {
+ const viewport = SeriesViewport(
+ minX: 0,
+ maxX: 10,
+ minY: 0,
+ maxY: 100,
+ xTicks: [],
+ yTicks: [],
+ yStep: 10,
+ );
+ final geometry = SeriesGeometry(
+ size: const Size(200, 100),
+ plot: const Rect.fromLTRB(0, 0, 200, 100),
+ viewport: viewport,
+ orientation: SeriesOrientation.horizontal,
+ );
+
+ expect(geometry.point(0, 0), const Offset(0, 0));
+ expect(geometry.point(10, 100), const Offset(200, 100));
+ expect(geometry.point(5, 50), const Offset(100, 50));
+ // The x under a touch is read down the chart, not across it.
+ expect(geometry.pxToXAt(const Offset(180, 50)), 5);
+ });
+
+ test(
+ 'lays the value axis along the bottom and the x axis down the '
+ 'left', () {
+ final geometry = SeriesGeometry.layout(
+ size: const Size(200, 100),
+ padding: EdgeInsets.zero,
+ xAxis: const SeriesXAxis(height: 30),
+ yAxis: const SeriesYAxis(),
+ viewport: const SeriesViewport(
+ minX: 0,
+ maxX: 1,
+ minY: 0,
+ maxY: 1,
+ xTicks: [],
+ yTicks: [],
+ yStep: 1,
+ ),
+ orientation: SeriesOrientation.horizontal,
+ );
+ expect(geometry.plot.left, 30);
+ expect(geometry.plot.right, 200);
+ expect(geometry.plot.top, 0);
+ expect(geometry.plot.bottom, lessThan(100));
+ });
+
+ test('grows its bars rightwards from the baseline', () {
+ final recorder = paintChart(
+ [
+ BarSeries.values(const [1, 2], color: const Color(0xFF00FF00)),
+ ],
+ orientation: SeriesOrientation.horizontal,
+ xAxis: SeriesXAxis.hidden,
+ yAxis: SeriesYAxis.hidden,
+ );
+ expect(recorder.paths, hasLength(1));
+ final bars = recorder.paths.single;
+ expect(bars.left, 0);
+ // The bars run rightwards out of the baseline rather than upwards.
+ expect(bars.right, greaterThan(100));
+ expect(bars.height, lessThan(100));
+ });
+ });
+
+ group('stacked bars', () {
+ final lower = BarSeries.values(const [2, 2], stack: 'a');
+ final upper = BarSeries.values(const [3, 1], stack: 'a');
+
+ test('a bar starts where the ones under it ended', () {
+ final values = [
+ [2.0, 2.0],
+ [3.0, 1.0],
+ ];
+ expect(stackedBase([lower, upper], values, 0, 0), 0);
+ expect(stackedBase([lower, upper], values, 1, 0), 2);
+ expect(stackedBase([lower, upper], values, 1, 1), 2);
+ });
+
+ test('the value range reaches the top of the stack', () {
+ final viewport = fitSeriesViewport(
+ series: [lower, upper],
+ values: [
+ [2.0, 2.0],
+ [3.0, 1.0],
+ ],
+ xAxis: const SeriesXAxis(),
+ yAxis: const SeriesYAxis(),
+ yPadding: 0,
+ );
+ expect(viewport.maxY, greaterThanOrEqualTo(5));
+ });
+
+ test('bars below the baseline stack downwards', () {
+ final down = [
+ BarSeries.values(const [-2], stack: 'a'),
+ BarSeries.values(const [-3], stack: 'a'),
+ ];
+ final values = [
+ [-2.0],
+ [-3.0],
+ ];
+ expect(stackedBase(down, values, 1, 0), -2);
+ });
+
+ test('a stack stands in one place rather than side by side', () {
+ final stacked = paintChart([
+ BarSeries.values(const [1, 1],
+ stack: 'a', color: const Color(0xFF110000)),
+ BarSeries.values(const [1, 1],
+ stack: 'a', color: const Color(0xFF220000)),
+ ], xAxis: SeriesXAxis.hidden, yAxis: SeriesYAxis.hidden);
+ final beside = paintChart([
+ BarSeries.values(const [1, 1], color: const Color(0xFF110000)),
+ BarSeries.values(const [1, 1], color: const Color(0xFF220000)),
+ ], xAxis: SeriesXAxis.hidden, yAxis: SeriesYAxis.hidden);
+
+ expect(stacked.paths[0].center.dx, stacked.paths[1].center.dx);
+ expect(
+ beside.paths[0].center.dx,
+ isNot(closeTo(beside.paths[1].center.dx, 1)),
+ );
+ });
+ });
+
+ group('floating bars and error bars', () {
+ test('a bar with a low runs between its two ends', () {
+ final recorder = paintChart([
+ const BarSeries(
+ points: [SeriesPoint(0, 8, low: 4)],
+ color: Color(0xFF00FF00),
+ ),
+ ], xAxis: SeriesXAxis.hidden, yAxis: SeriesYAxis.hidden);
+
+ final bar = recorder.paths.single;
+ expect(bar.top, greaterThan(0));
+ expect(bar.bottom, lessThan(100));
+ });
+
+ test('the value range takes in the low end', () {
+ final viewport = fitSeriesViewport(
+ series: const [
+ BarSeries(points: [SeriesPoint(0, 8, low: 4)]),
+ ],
+ values: const [
+ [8.0],
+ ],
+ xAxis: const SeriesXAxis(),
+ yAxis: const SeriesYAxis(),
+ yPadding: 0,
+ niceYRange: false,
+ );
+ expect(viewport.minY, lessThanOrEqualTo(0));
+ expect(viewport.maxY, greaterThanOrEqualTo(8));
+ });
+
+ test('an error range draws a bar with a cap at either end', () {
+ final recorder = paintChart([
+ const ScatterSeries(
+ points: [
+ SeriesPoint(0, 5, yError: SeriesErrorRange.symmetric(1)),
+ ],
+ ),
+ ], xAxis: SeriesXAxis.hidden, yAxis: SeriesYAxis.hidden);
+
+ // The bar itself and one cap at each end.
+ expect(recorder.lines, hasLength(3));
+ final (from, to) = recorder.lines.first;
+ expect(from.dx, closeTo(to.dx, 1e-9));
+ expect(from.dy, greaterThan(to.dy));
+ });
+
+ test('the value range makes room for the error ranges', () {
+ final viewport = fitSeriesViewport(
+ series: const [
+ ScatterSeries(
+ points: [SeriesPoint(0, 5, yError: SeriesErrorRange(2, 3))],
+ ),
+ ],
+ values: const [
+ [5.0],
+ ],
+ xAxis: const SeriesXAxis(),
+ yAxis: const SeriesYAxis(),
+ yPadding: 0,
+ niceYRange: false,
+ );
+ expect(viewport.maxY, greaterThanOrEqualTo(8));
+ });
+ });
+
+ group('scatter', () {
+ testWidgets('a touch picks the dot under the finger, not the whole x', (
+ tester,
+ ) async {
+ final reported = [];
+ await tester.pumpWidget(
+ Directionality(
+ textDirection: TextDirection.ltr,
+ child: Center(
+ child: SizedBox(
+ width: 200,
+ height: 200,
+ child: SeriesChart(
+ series: const [
+ ScatterSeries(
+ points: [SeriesPoint(0, 0), SeriesPoint(0, 10)],
+ ),
+ ],
+ xAxis: SeriesXAxis.hidden,
+ yAxis: SeriesYAxis.hidden,
+ minY: 0,
+ maxY: 10,
+ niceYRange: false,
+ yPadding: 0,
+ touch: const SeriesTouch(
+ snap: SeriesTouchSnap.nearestPoint,
+ trigger: SeriesTouchTrigger.press,
+ ),
+ onTouch: reported.add,
+ ),
+ ),
+ ),
+ ),
+ );
+
+ final chart = tester.getRect(find.byType(SeriesChart));
+ // The top of the plot is the point at 10, the bottom the one at 0.
+ await tester.startGesture(chart.topLeft + const Offset(2, 2));
+ await tester.pumpAndSettle();
+
+ expect(reported, isNotEmpty);
+ final details = reported.whereType().first;
+ expect(details.values, hasLength(1));
+ expect(details.values.single.pointIndex, 1);
+ expect(details.values.single.value, 10);
+ });
+ });
+
+ group('lines', () {
+ test('a step changes value where its step position says', () {
+ const points = [Offset(0, 10), Offset(10, 0)];
+ final late = seriesRunPath(points, LineCurve.step).getBounds();
+ final middle = seriesRunPath(
+ points,
+ LineCurve.step,
+ stepPosition: 0.5,
+ ).getBounds();
+ expect(late, middle);
+
+ // Where the corner sits is what changes, so the paths differ in metric
+ // length rather than in their bounds.
+ expect(
+ seriesRunPath(points, LineCurve.step, stepPosition: 0)
+ .computeMetrics()
+ .first
+ .length,
+ closeTo(20, 1e-6),
+ );
+ });
+
+ test('a fill between two lines covers the gap between them', () {
+ final recorder = paintChart(
+ [
+ LineSeries.values(const [10, 10], width: 0),
+ LineSeries.values(const [0, 0], width: 0),
+ ],
+ betweenFills: const [
+ SeriesBetweenFill(from: 0, to: 1, color: Color(0x8800FF00)),
+ ],
+ xAxis: SeriesXAxis.hidden,
+ yAxis: SeriesYAxis.hidden,
+ );
+ expect(recorder.paths, hasLength(1));
+ expect(recorder.paths.single.width, closeTo(200, 1));
+ });
+
+ test('a fill naming a series that is not there is left out', () {
+ final recorder = paintChart(
+ [
+ LineSeries.values(const [1, 2], width: 0),
+ ],
+ betweenFills: const [
+ SeriesBetweenFill(from: 0, to: 4, color: Color(0x8800FF00)),
+ ],
+ xAxis: SeriesXAxis.hidden,
+ yAxis: SeriesYAxis.hidden,
+ );
+ expect(recorder.paths, isEmpty);
+ });
+ });
+
+ group('axis titles', () {
+ testWidgets('each axis writes its name beyond its labels', (tester) async {
+ await tester.pumpWidget(
+ Directionality(
+ textDirection: TextDirection.ltr,
+ child: Center(
+ child: SizedBox(
+ width: 300,
+ height: 200,
+ child: SeriesChart(
+ series: [
+ LineSeries.values(const [1, 2, 3]),
+ ],
+ xAxis: const SeriesXAxis(title: 'Day'),
+ yAxis: const SeriesYAxis(title: 'Balance'),
+ ),
+ ),
+ ),
+ ),
+ );
+ expect(tester.takeException(), isNull);
+ });
+
+ test('a title takes room off the plot', () {
+ const viewport = SeriesViewport(
+ minX: 0,
+ maxX: 1,
+ minY: 0,
+ maxY: 1,
+ xTicks: [],
+ yTicks: [],
+ yStep: 1,
+ );
+ final plain = SeriesGeometry.layout(
+ size: const Size(200, 100),
+ padding: EdgeInsets.zero,
+ xAxis: const SeriesXAxis(),
+ yAxis: const SeriesYAxis(),
+ viewport: viewport,
+ );
+ final titled = SeriesGeometry.layout(
+ size: const Size(200, 100),
+ padding: EdgeInsets.zero,
+ xAxis: const SeriesXAxis(title: 'Day'),
+ yAxis: const SeriesYAxis(title: 'Balance'),
+ viewport: viewport,
+ );
+ expect(titled.plot.width, lessThan(plain.plot.width));
+ expect(titled.plot.height, lessThan(plain.plot.height));
+ });
+
+ test('an axis on the far side leaves its room on that side', () {
+ const viewport = SeriesViewport(
+ minX: 0,
+ maxX: 1,
+ minY: 0,
+ maxY: 1,
+ xTicks: [],
+ yTicks: [],
+ yStep: 1,
+ );
+ final flipped = SeriesGeometry.layout(
+ size: const Size(200, 100),
+ padding: EdgeInsets.zero,
+ xAxis: const SeriesXAxis(side: SeriesXSide.top, height: 20),
+ yAxis: const SeriesYAxis(side: SeriesAxisSide.right, width: 40),
+ viewport: viewport,
+ );
+ expect(flipped.plot.left, 0);
+ expect(flipped.plot.right, 160);
+ expect(flipped.plot.top, 20);
+ expect(flipped.plot.bottom, 100);
+ });
+ });
+}
diff --git a/test/series_golden_test.dart b/test/series_golden_test.dart
new file mode 100644
index 0000000..5934805
--- /dev/null
+++ b/test/series_golden_test.dart
@@ -0,0 +1,149 @@
+import 'dart:math' as math;
+
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:ohlcv_chart/ohlcv_chart.dart';
+
+/// Golden tests over [SeriesChart]: what an area, a set of bars and several
+/// lines with a tooltip look like. Run `flutter test --update-goldens` after a
+/// deliberate change to how they are drawn.
+Widget _framed(Widget child) => MaterialApp(
+ debugShowCheckedModeBanner: false,
+ home: Scaffold(
+ backgroundColor: const Color(0xFF17181C),
+ body: Center(
+ child: SizedBox(
+ width: 480,
+ height: 300,
+ child: Padding(padding: const EdgeInsets.all(12), child: child),
+ ),
+ ),
+ ),
+);
+
+const _purple = Color(0xFF9775FA);
+const _green = Color(0xFF12B886);
+const _red = Color(0xFFFA5252);
+const _amber = Color(0xFFFAB005);
+const _gridColor = Color(0xFF2C2E33);
+
+List _wave(int count, {double phase = 0, double scale = 1}) => [
+ for (var i = 0; i < count; i++)
+ (math.sin(i / 3 + phase) * 40 + i * 1.5 - 12) * scale,
+];
+
+void main() {
+ testWidgets('an area crossing zero', (tester) async {
+ await tester.pumpWidget(
+ _framed(
+ SeriesChart(
+ series: [
+ LineSeries.values(
+ _wave(24),
+ color: _green,
+ negativeColor: _red,
+ curve: LineCurve.monotone,
+ fill: SeriesFill.fade(_green, negativeColor: _red),
+ ),
+ ],
+ xAxis: SeriesXAxis(
+ labels: [
+ for (var i = 0; i < 24; i++) i % 6 == 0 ? 'W${i ~/ 6 + 1}' : '',
+ ],
+ ),
+ grid: const SeriesGrid(color: _gridColor, dashPattern: [4, 4]),
+ border: const BorderSide(color: _gridColor),
+ referenceLines: const [
+ SeriesReferenceLine.horizontal(
+ 0,
+ color: Color(0xFF5C5F66),
+ dashPattern: [3, 3],
+ ),
+ ],
+ ),
+ ),
+ );
+
+ await expectLater(
+ find.byType(SeriesChart),
+ matchesGoldenFile('goldens/series_area.png'),
+ );
+ });
+
+ testWidgets('rounded profit bars', (tester) async {
+ await tester.pumpWidget(
+ _framed(
+ SeriesChart(
+ series: [
+ BarSeries.values(
+ _wave(18, phase: 1),
+ color: _green,
+ negativeColor: _red,
+ radius: 3,
+ minWidth: 3.5,
+ maxWidth: 12,
+ ),
+ ],
+ xAxis: const SeriesXAxis(interval: 3),
+ grid: const SeriesGrid(
+ vertical: false,
+ color: _gridColor,
+ dashPattern: [4, 4],
+ ),
+ border: const BorderSide(color: _gridColor),
+ ),
+ ),
+ );
+
+ await expectLater(
+ find.byType(SeriesChart),
+ matchesGoldenFile('goldens/series_bars.png'),
+ );
+ });
+
+ testWidgets('several lines with a tooltip', (tester) async {
+ final controller = SeriesChartController();
+ addTearDown(controller.dispose);
+
+ await tester.pumpWidget(
+ _framed(
+ SeriesChart(
+ series: [
+ LineSeries.values(
+ _wave(30, scale: 20).map((v) => v + 1500).toList(),
+ label: 'Deposits',
+ color: _purple,
+ fill: SeriesFill.fade(_purple, opacity: 0.2),
+ ),
+ LineSeries.values(
+ _wave(30, phase: 2, scale: 12).map((v) => v + 900).toList(),
+ label: 'Withdrawals',
+ color: _amber,
+ dashPattern: const [6, 4],
+ ),
+ ],
+ yAxis: SeriesYAxis(
+ width: 44,
+ formatter: (v) => '\$${(v / 1000).toStringAsFixed(1)}k',
+ ),
+ grid: const SeriesGrid(color: _gridColor, dashPattern: [4, 4]),
+ border: const BorderSide(color: _gridColor),
+ controller: controller,
+ touch: SeriesTouch(
+ tooltip: SeriesTooltip(
+ title: (d) => 'Day ${d.index + 1}',
+ showSeriesLabels: true,
+ ),
+ ),
+ ),
+ ),
+ );
+ controller.show(12);
+ await tester.pump();
+
+ await expectLater(
+ find.byType(SeriesChart),
+ matchesGoldenFile('goldens/series_lines_tooltip.png'),
+ );
+ });
+}
diff --git a/test/series_scale_test.dart b/test/series_scale_test.dart
new file mode 100644
index 0000000..82034ba
--- /dev/null
+++ b/test/series_scale_test.dart
@@ -0,0 +1,276 @@
+import 'dart:ui';
+
+import 'package:flutter/painting.dart' show EdgeInsets;
+import 'package:flutter_test/flutter_test.dart';
+import 'package:ohlcv_chart/ohlcv_chart.dart';
+import 'package:ohlcv_chart/src/series/series_axis.dart'
+ show formatSeriesNumber;
+import 'package:ohlcv_chart/src/series/series_paths.dart';
+import 'package:ohlcv_chart/src/series/series_scale.dart';
+
+List> _valuesOf(List series) => [
+ for (final s in series) [for (final p in s.points) p.isGap ? null : p.y],
+];
+
+SeriesViewport _fit(
+ List series, {
+ SeriesXAxis xAxis = const SeriesXAxis(),
+ SeriesYAxis yAxis = const SeriesYAxis(),
+ List referenceLines = const [],
+ double? minX,
+ double? maxX,
+ double? minY,
+ double? maxY,
+}) => fitSeriesViewport(
+ series: series,
+ values: _valuesOf(series),
+ xAxis: xAxis,
+ yAxis: yAxis,
+ referenceLines: referenceLines,
+ minX: minX,
+ maxX: maxX,
+ minY: minY,
+ maxY: maxY,
+);
+
+void main() {
+ group('fitSeriesViewport', () {
+ test('spans the points and widens the values to round ticks', () {
+ final viewport = _fit([
+ LineSeries.values([10, 20, 15, 30]),
+ ]);
+
+ expect(viewport.minX, 0);
+ expect(viewport.maxX, 3);
+ expect(viewport.minY, 5);
+ expect(viewport.maxY, 35);
+ expect(viewport.yTicks, [5, 10, 15, 20, 25, 30, 35]);
+ });
+
+ test('never pads a series that stays above zero below it', () {
+ final viewport = _fit([
+ LineSeries.values([0, 5, 10]),
+ ]);
+
+ expect(viewport.minY, 0);
+ expect(viewport.maxY, greaterThan(10));
+ });
+
+ test('bars reach their baseline and get half a unit either side', () {
+ final viewport = _fit([
+ BarSeries.values([5, 8]),
+ ]);
+
+ expect(viewport.minX, -0.5);
+ expect(viewport.maxX, 1.5);
+ expect(viewport.minY, 0);
+ });
+
+ test('keeps the bounds it is given', () {
+ final viewport = _fit(
+ [
+ LineSeries.values([1, 2]),
+ ],
+ minY: -3,
+ maxY: 7,
+ );
+
+ expect(viewport.minY, -3);
+ expect(viewport.maxY, 7);
+ });
+
+ test('fits the values only to the x window in view', () {
+ final viewport = _fit(
+ [
+ LineSeries.values([100, 1, 2, 3]),
+ ],
+ minX: 0.5,
+ maxX: 3.5,
+ );
+
+ expect(viewport.maxY, lessThan(10));
+ });
+
+ test('gives a flat series a range of its own', () {
+ final viewport = _fit([
+ LineSeries.values([5, 5, 5]),
+ ]);
+
+ expect(viewport.maxY, greaterThan(viewport.minY));
+ expect(viewport.minY, lessThan(5));
+ expect(viewport.maxY, greaterThan(5));
+ });
+
+ test('labels the indices whose label is not blank', () {
+ final viewport = _fit([
+ LineSeries.values([1, 2, 3, 4, 5, 6]),
+ ], xAxis: const SeriesXAxis(labels: ['Jan', '', '', 'Feb', '', 'Mar']));
+
+ expect(viewport.xTicks, [0, 3, 5]);
+ });
+
+ test('rules only the value ticks it is given, inside the range', () {
+ final viewport = _fit([
+ LineSeries.values([0, 100]),
+ ], yAxis: const SeriesYAxis(ticks: [0, 50, 100, 1000]));
+
+ expect(viewport.yTicks, [0, 50, 100]);
+ });
+
+ test('takes a reference line into the range only when it asks', () {
+ final series = [
+ LineSeries.values([1, 2]),
+ ];
+
+ expect(
+ _fit(
+ series,
+ referenceLines: const [SeriesReferenceLine.horizontal(10)],
+ ).maxY,
+ lessThan(10),
+ );
+ expect(
+ _fit(
+ series,
+ referenceLines: const [
+ SeriesReferenceLine.horizontal(10, extendsRange: true),
+ ],
+ ).maxY,
+ greaterThanOrEqualTo(10),
+ );
+ });
+
+ test('maps values to pixels and back', () {
+ final geometry = SeriesGeometry.layout(
+ size: const Size(200, 100),
+ padding: EdgeInsets.zero,
+ xAxis: SeriesXAxis.hidden,
+ yAxis: SeriesYAxis.hidden,
+ viewport: const SeriesViewport(
+ minX: 0,
+ maxX: 4,
+ minY: 0,
+ maxY: 10,
+ xTicks: [],
+ yTicks: [],
+ yStep: 1,
+ ),
+ );
+
+ expect(geometry.plot, const Rect.fromLTWH(0, 0, 200, 100));
+ expect(geometry.xToPx(2), 100);
+ expect(geometry.yToPx(10), 0);
+ expect(geometry.yToPx(0), 100);
+ expect(geometry.pxToX(50), 1);
+ });
+ });
+
+ group('paths', () {
+ test('a monotone curve never swings past the points either side', () {
+ const points = [
+ Offset(0, 0),
+ Offset(10, 10),
+ Offset(20, 10),
+ Offset(30, 0),
+ Offset(40, 5),
+ Offset(50, 50),
+ ];
+ final path = Path();
+ addSeriesRun(path, points, LineCurve.monotone);
+
+ final metric = path.computeMetrics().single;
+ for (var d = 0.0; d <= metric.length; d += 0.5) {
+ final p = metric.getTangentForOffset(d)!.position;
+ var segment = 0;
+ while (segment < points.length - 2 && p.dx > points[segment + 1].dx) {
+ segment++;
+ }
+ final a = points[segment].dy;
+ final b = points[segment + 1].dy;
+ expect(
+ p.dy,
+ inInclusiveRange((a < b ? a : b) - 0.01, (a > b ? a : b) + 0.01),
+ reason: 'at $p',
+ );
+ }
+ });
+
+ test('dashes cut a line into its pattern', () {
+ final line = Path()
+ ..moveTo(0, 0)
+ ..lineTo(100, 0);
+ final metrics = dashSeriesPath(line, [10, 10]).computeMetrics().toList();
+
+ expect(metrics, hasLength(5));
+ expect(
+ metrics.fold(0, (sum, m) => sum + m.length),
+ closeTo(50, 0.001),
+ );
+ expect(identical(dashSeriesPath(line, null), line), isTrue);
+ });
+
+ test('a bar is rounded on the end away from its baseline', () {
+ final up = seriesBarShape(
+ left: 0,
+ right: 10,
+ valueY: 20,
+ baseY: 100,
+ radius: 3,
+ )!;
+ expect(up.tlRadiusX, 3);
+ expect(up.blRadiusX, 0);
+
+ final down = seriesBarShape(
+ left: 0,
+ right: 10,
+ valueY: 150,
+ baseY: 100,
+ radius: 3,
+ )!;
+ expect(down.tlRadiusX, 0);
+ expect(down.blRadiusX, 3);
+
+ final wide = seriesBarShape(
+ left: 0,
+ right: 10,
+ valueY: 0,
+ baseY: 100,
+ radius: 50,
+ )!;
+ expect(wide.tlRadiusX, 5);
+
+ expect(
+ seriesBarShape(left: 0, right: 10, valueY: 50, baseY: 50, radius: 3),
+ isNull,
+ );
+ });
+ });
+
+ test('numbers get the decimals their step needs', () {
+ expect(formatSeriesNumber(5, 1), '5');
+ expect(formatSeriesNumber(7.5, 2.5), '7.5');
+ expect(formatSeriesNumber(0.25, 0.25), '0.25');
+ expect(formatSeriesNumber(1, 0.25), '1.00');
+ expect(formatSeriesNumber(0.3, 0.1), '0.3');
+ });
+
+ test('an x label between whole numbers keeps its decimals', () {
+ const axis = SeriesXAxis();
+ expect(axis.labelFor(0.5, step: 0.5), '0.5');
+ // Every label on an axis stepping by 0.5 carries one decimal, whole or not.
+ expect(axis.labelFor(2, step: 0.5), '2.0');
+ expect(axis.labelFor(3), '3');
+
+ final viewport = _fit([
+ LineSeries(
+ points: const [
+ SeriesPoint(0, 1),
+ SeriesPoint(0.5, 2),
+ SeriesPoint(1, 3),
+ ],
+ ),
+ ], xAxis: const SeriesXAxis(interval: 0.25));
+ expect(viewport.xTicks, [0, 0.25, 0.5, 0.75, 1]);
+ expect(viewport.xStep, 0.25);
+ });
+}
diff --git a/test/sessions_test.dart b/test/sessions_test.dart
index d1834b8..096a422 100644
--- a/test/sessions_test.dart
+++ b/test/sessions_test.dart
@@ -46,7 +46,6 @@ Widget _chart(
data,
colors ?? ChartColors(),
isTrendLine: false,
- watermarkAssetPath: 'assets/none.svg',
timeFrame: const Duration(minutes: 30),
showNowPrice: false,
chartType: type,
@@ -267,7 +266,7 @@ void main() {
testWidgets('returning null leaves the usual colour', (tester) async {
await tester.pumpWidget(
- _chart(_halfHourly(_on(1, 0), 48), candleColor: (_, _) => null),
+ _chart(_halfHourly(_on(1, 0), 48), candleColor: (_, __) => null),
);
await tester.pumpAndSettle();
@@ -333,7 +332,6 @@ void main() {
data,
ChartColors(),
isTrendLine: false,
- watermarkAssetPath: 'assets/none.svg',
timeFrame: const Duration(minutes: 30),
showNowPrice: false,
invertPriceAxis: true,
diff --git a/test/shapes_test.dart b/test/shapes_test.dart
index 0725de3..11580b0 100644
--- a/test/shapes_test.dart
+++ b/test/shapes_test.dart
@@ -30,7 +30,6 @@ _chartWithTool(
data,
ChartColors(),
isTrendLine: true,
- watermarkAssetPath: 'assets/none.svg',
timeFrame: const Duration(minutes: 15),
showNowPrice: false,
drawingStyle: style,
@@ -58,7 +57,6 @@ _chartWithTool(
data,
ChartColors(),
isTrendLine: true,
- watermarkAssetPath: 'assets/none.svg',
timeFrame: const Duration(minutes: 15),
showNowPrice: false,
drawingStyle: style,
@@ -414,7 +412,6 @@ void main() {
data,
ChartColors(),
isTrendLine: true,
- watermarkAssetPath: 'assets/none.svg',
timeFrame: const Duration(minutes: 15),
showNowPrice: false,
drawings: [level],
@@ -458,7 +455,6 @@ void main() {
data,
ChartColors(),
isTrendLine: true,
- watermarkAssetPath: 'assets/none.svg',
timeFrame: const Duration(minutes: 15),
showNowPrice: false,
drawings: [level],
diff --git a/test/static_chart_test.dart b/test/static_chart_test.dart
index a1dd0ed..993c226 100644
--- a/test/static_chart_test.dart
+++ b/test/static_chart_test.dart
@@ -40,7 +40,6 @@ Widget _chart({
data,
ChartColors(),
isTrendLine: false,
- watermarkAssetPath: 'assets/none.svg',
timeFrame: const Duration(minutes: 5),
chartType: ChartType.area,
xFrontPadding: 0,
diff --git a/test/trading_test.dart b/test/trading_test.dart
index 69b4d58..3fc47a2 100644
--- a/test/trading_test.dart
+++ b/test/trading_test.dart
@@ -30,7 +30,6 @@ Widget _chart(
data,
ChartColors(),
isTrendLine: false,
- watermarkAssetPath: 'assets/none.svg',
timeFrame: const Duration(minutes: 1),
showNowPrice: false,
chartStyle: style ?? ChartStyle(),
@@ -338,7 +337,7 @@ void main() {
final order = ChartOrder(id: '1', price: price, side: TradeSide.buy);
await tester.pumpWidget(
- _chart(_candles(), orders: [order], onOrderMoved: (_, _) {}),
+ _chart(_candles(), orders: [order], onOrderMoved: (_, __) {}),
);
await tester.pumpAndSettle();
@@ -364,7 +363,7 @@ void main() {
draggable: false,
),
],
- onOrderMoved: (_, _) {},
+ onOrderMoved: (_, __) {},
),
);
await tester.pumpAndSettle();
@@ -384,7 +383,7 @@ void main() {
_chart(
_candles(),
orders: [far, near],
- onOrderMoved: (_, _) {},
+ onOrderMoved: (_, __) {},
style: ChartStyle(trading: const TradingStyle(grabTolerance: 200)),
),
);
diff --git a/test/transforms_test.dart b/test/transforms_test.dart
index 5463a69..cb34988 100644
--- a/test/transforms_test.dart
+++ b/test/transforms_test.dart
@@ -33,7 +33,6 @@ Widget _chart(List data) => MaterialApp(
data,
ChartColors(),
isTrendLine: false,
- watermarkAssetPath: 'assets/none.svg',
timeFrame: const Duration(minutes: 15),
),
),
diff --git a/test/visible_range_test.dart b/test/visible_range_test.dart
index 3e20634..5c3bbce 100644
--- a/test/visible_range_test.dart
+++ b/test/visible_range_test.dart
@@ -27,7 +27,6 @@ Widget _chart(
data,
ChartColors(),
isTrendLine: false,
- watermarkAssetPath: 'assets/none.svg',
timeFrame: const Duration(minutes: 1),
showNowPrice: false,
controller: controller,
diff --git a/test/widget_test.dart b/test/widget_test.dart
index 9a97926..a758ada 100644
--- a/test/widget_test.dart
+++ b/test/widget_test.dart
@@ -21,7 +21,6 @@ Widget _chart(
data,
ChartColors(),
isTrendLine: false,
- watermarkAssetPath: 'assets/none.svg',
timeFrame: const Duration(minutes: 15),
showNowPrice: showNowPrice,
indicators: indicators ?? [MaIndicator()],
@@ -216,7 +215,6 @@ void main() {
data,
ChartColors(),
isTrendLine: false,
- watermarkAssetPath: 'assets/none.svg',
timeFrame: const Duration(minutes: 15),
showNowPrice: false,
timeFormat: TimeFormat.YEAR_MONTH_DAY_WITH_HOUR,