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 overview strip under a chart, with the visible window lit on it](https://raw.githubusercontent.com/CtrlAltDevelop/ohlcv_chart/main/screenshots/overview.png) +## The whole package in pictures -![Two linked charts sharing one crosshair](https://raw.githubusercontent.com/CtrlAltDevelop/ohlcv_chart/main/screenshots/linked-charts.png) +Every widget, every chart type and every tool below is in this one package, and +each picture links to the page that covers it. -![Bar replay stepping the market forward a candle at a time](https://raw.githubusercontent.com/CtrlAltDevelop/ohlcv_chart/main/screenshots/bar-replay.gif) +### Series, pie, radar and heatmap charts + + + + + + + + + + + + + + + + + + + + + + +
Four business charts
Four business charts
A return split at zero, profit bars, flows with a tooltip and a sparkline.
Pie and radar
Pie and radar
A doughnut with its total in the hole, and a web scoring two strategies.
Heatmap
Heatmap
Six months of activity, and orders by hour and weekday, by value.
Scatter
Scatter
A dot per trade, shaped and coloured by the point, read out one at a time.
Stacked and turned
Stacked and turned
Bars piled on each other on a chart running rightwards, both axes named.
Floating bars and bands
Floating bars and bands
A waterfall, and a forecast inside its band with error bars.
A window over long data
A window over long data
Three series over five months, with the strip that moves the window.
One crosshair, two panels
One crosshair, two panels
A balance panel over a profit panel, marking the same day in both.
Depth chart
Depth chart
Cumulative bid and ask depth from the order book.
Depth, four ways
Depth, four ways
The curve, the per-rung bars, both at once, or a numeric ladder.
The bid/ask ratio bar
The bid/ask ratio bar
Which way the resting orders lean, under the depth chart.
+ +### Candles, and the ways to draw them + + + + + + + + + + + + +
Eight chart types
Eight chart types
Bars, baseline, area, step line, HLC area and columns.
Four bar transforms
Four bar transforms
Line break, Kagi, point & figure and range bars.
Heikin-Ashi and Renko
Heikin-Ashi and Renko
The same market re-aggregated before it is drawn.
A second instrument
A second instrument
Another series over the same window, rebased or at its own prices.
Dark and light
Dark and light
The same chart under both palettes, every colour yours.
+ +### 31 indicators + + + + + + + + + + + + +
Main-chart overlays
Main-chart overlays
The Ichimoku cloud and Supertrend, over Stochastic RSI and Awesome.
Profile and anchored VWAP
Profile and anchored VWAP
A volume profile and a VWAP anchored to a candle.
Swing readers
Swing readers
A zigzag, a Fibonacci retracement and Elliott wave labels.
Stacked panes
Stacked panes
Three ATRs at different periods, each in its own resizable pane.
Higher timeframes
Higher timeframes
A four-hour average and RSI over fifteen-minute candles.
Built from the catalogue
Built from the catalogue
The example app's add-indicator sheet, generated from it.
+ +### Drawing tools, orders and events + + + + + + + + + + + + +
29 drawing tools
29 drawing tools
Trend and horizontal lines with labels, snapped and persisted.
Rays, boxes and fibs
Rays, boxes and fibs
A ray, an arrow, a horizontal ray, a range box and a retracement.
The line editor
The line editor
Every control of a selected drawing, styled by DrawingStyle.
Orders and positions
Orders and positions
A working order and an open position, each tagged on the axis.
Planning a trade
Planning a trade
A planned position with its risk-to-reward, inside a channel.
Event marks
Event marks
Earnings, a dividend, a split and news under the candles.
+ +### Reading it, and driving it + + + + + + + + + + + + +
Legend and crosshair
Legend and crosshair
The OHLC row above the chart, reading from the crosshair.
The long-press readout
The long-press readout
The card over a held candle, or a builder of your own.
The overview strip
The overview strip
The whole series under the chart, with the visible window lit.
Linked charts
Linked charts
Two charts sharing one crosshair.
Bar replay
Bar replay
Rewind, then step or play the market forward.
Held mid-replay
Held mid-replay
The chart stopped at the 150th candle of 420, under a transport bar.
+ +### The axes, the session and the box + + + + + + + + + + + + + + + + + +
Four price axes
Four price axes
Linear, log, percentage or indexed to 100 — and invertible.
A log axis
A log axis
Stepping by ratio, so a decade of compounding reads evenly.
The date axis
The date axis
The round time values the chart picks, or your own formatter.
Sessions and time zones
Sessions and time zones
Pre-market and after-hours washed behind the candles.
Session VWAP
Session VWAP
A VWAP restarted each session, with its standard-deviation band.
Fits its box
Fits its box
The candle area filling the box, and pinned to a height.
A watermark of your own
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 + +![Six months of activity as a contribution graph, beside orders by hour and weekday](https://raw.githubusercontent.com/CtrlAltDevelop/ohlcv_chart/main/screenshots/heatmap.png) + +`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. + +![A return split at zero, profit bars, deposits and withdrawals with a tooltip, and a balance sparkline — all SeriesChart](https://raw.githubusercontent.com/CtrlAltDevelop/ohlcv_chart/main/screenshots/series-charts.png) + +## 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 + +![Three series over a window of five months, a day read out, and the range selector that moves the window](https://raw.githubusercontent.com/CtrlAltDevelop/ohlcv_chart/main/screenshots/series-window.png) + +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 panel over a profit panel, one crosshair marking the same day in both](https://raw.githubusercontent.com/CtrlAltDevelop/ohlcv_chart/main/screenshots/series-panels.png) + +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 + +![A doughnut of holdings with a total in the hole, its biggest slice pulled out and badged](https://raw.githubusercontent.com/CtrlAltDevelop/ohlcv_chart/main/screenshots/pie-radar.png) + +`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 + +![A five-feature radar web with two scored strategies drawn over it](https://raw.githubusercontent.com/CtrlAltDevelop/ohlcv_chart/main/screenshots/pie-radar.png) + +`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. + +![A return split at zero, profit bars, deposits and withdrawals with a tooltip, and a balance sparkline — all SeriesChart](https://raw.githubusercontent.com/CtrlAltDevelop/ohlcv_chart/main/screenshots/series-charts.png) + +```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 + +![Every trade as a dot, wins as circles and losses as crosses, one of them read out](https://raw.githubusercontent.com/CtrlAltDevelop/ohlcv_chart/main/screenshots/series-scatter.png) + +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 + +![A waterfall of floating bars beside a forecast with its band and its error bars](https://raw.githubusercontent.com/CtrlAltDevelop/ohlcv_chart/main/screenshots/series-ranges.png) + +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 + +![Stacked in and out bars running rightwards, months down the left, values along the bottom](https://raw.githubusercontent.com/CtrlAltDevelop/ohlcv_chart/main/screenshots/series-horizontal.png) + +`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 balance panel over a profit panel, one crosshair marking the same day in both](https://raw.githubusercontent.com/CtrlAltDevelop/ohlcv_chart/main/screenshots/series-panels.png) + +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 + +![Three series over a window of five months, a day read out, and the range selector that moves the window](https://raw.githubusercontent.com/CtrlAltDevelop/ohlcv_chart/main/screenshots/series-window.png) + +`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 + +![Candles under a watermark made of an icon and a name](https://raw.githubusercontent.com/CtrlAltDevelop/ohlcv_chart/main/screenshots/watermark.png) + +`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,