diff --git a/CHANGELOG.md b/CHANGELOG.md index 85115e5..ea509f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,69 @@ +## 2.4.0 + +### A chart that sits still + +- **New `scrollEnabled` and `zoomEnabled` turn the chart's own gestures off**, + for a chart that is meant to show one fixed stretch rather than be navigated + — an intraday session, a thumbnail, a printed figure. With `scrollEnabled` + off a drag neither slides the window nor flings it, and `onLoadMore` is never + asked for more candles, since no edge is ever reached. With `zoomEnabled` off + a pinch does nothing, and the zoom slider — only ever shown on the web and on + desktop, where there is no pinch — is left off too. +- They are worth turning off together. Zooming out makes the candles narrower, + which leaves the window room to scroll into, so a chart that only had + `scrollEnabled` off could be pinched back into a scrollable one. +- Both hold the user back and leave your own code alone, the way + `priceScaleDrag` already did: `zoomIn`, `zoomOut`, `setChartScale`, + `goToIndex`, `fitAll` and the rest of `KChartController` still work. +- For a chart drawn at a fixed position, give `ChartStyle.pointWidth` roughly + the chart's width divided by the number of candles. Once the whole series + fits, there is nowhere to scroll to even before the flag. + +### Price axis + +- **New `lockPriceScale` keeps the axis still while the chart scrolls.** The + axis fits the candles in the window, so scrolling rescaled it: dragging back + through a trend changed every number on it. Locked, it holds the range it was + already showing and the candles move under a scale that stays where it is — + which is what reading a level off the axis while scrolling needs, and what + paging in history through `onLoadMore` needs in order not to jump. It locks + onto what is already on screen, so turning it on does not move the chart, and + `resetPriceScale` hands the axis back: it refits to the window and holds there + afresh. +- Only the scale is held. The window's own high and low are still measured, so + their markers keep pointing at the candles that set them, and a locked axis + can still be dragged and zoomed — from the range it is held at rather than + the window's. The range is held until it is reset, so a chart that switches to + another instrument should reset it; paging in candles and live ticks need + nothing, which is the point. +- **New `ChartStyle.priceAxisWidth` holds a gutter back for the labels.** It is + taken off whichever side `verticalTextAlignment` puts them on, and the + candles, the grid, the indicator panes and the date axis all stop short of it, + so the labels sit in the gutter on their own instead of candles sliding under + the numbers. The plot is clipped to its own bounds, so nothing spills into the + gutter, and pressing the labels grabs the scale the way pressing the axis + strip always has. Left at 0, the default, nothing changes: the labels are + drawn over the candles exactly as before. + +### Fixed + +- **`onLoadMore` is called again.** The callback was declared, documented and + accepted, but nothing in the package ever invoked it, so paging in older + candles could not work however it was wired up. It is now asked at both + places the scroll is clamped — dragging and flinging — with the edge latched, + so a drag held against the edge asks once when it arrives rather than on + every frame, and asks again after coming away and going back. The flag is + `true` at the newest candle and `false` at the oldest, as documented. + +- **The long-press readout can be turned off and on again.** It listened to a + single-subscription stream from a subtree that is only mounted while + `showInfoDialog` is set. Setting it back to `true` made a second listen on a + stream already listened to, which threw `Bad state: Stream has already been + listened to` as the readout remounted — and because that throw landed while + the enclosing `Stack` was mounting its children, what callers actually saw was + `LateInitializationError: Field '_children' has not been initialized`. The + controller is a broadcast one now. + ## 2.3.1 ### Performance diff --git a/README.md b/README.md index 2f726bf..a472ccf 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ Named for the open-high-low-close-volume bars it renders. - **Sub-charts** — `MACD`, `KDJ`, `RSI`, `WR`, `CCI`, `ATR`, `OBV`, `MFI`, `DMI`, Aroon, Stochastic RSI, `ROC`, `TRIX`, a volume average and the Awesome oscillator, each in its own stacked pane — resizable and reorderable by dragging. - **Linear, logarithmic, percentage or indexed-to-100 price axis**, so a decade of compounding reads as well as an afternoon — invertible, with the window's high, low and average close markable on it. - **A price scale you can drag** — pull the labels to stretch or flatten the candles, drag the chart to slide the window, double-tap to fit it back; from code as well, through the controller. +- **A price axis that can be pinned** — `lockPriceScale` holds it at one range instead of refitting it to whatever is on screen, so scrolling moves the candles under a scale that stays put rather than rescaling every number on the axis; and `priceAxisWidth` holds a gutter back for the labels, so the candles stop short of them instead of sliding underneath. - **Axes that read as round numbers** — both axes choose their values first and are ruled where the labels fall, so a price scale steps `69000, 69500, 70000` and an intraday date axis lands on the hour, never on whatever value happened to fall on an evenly spaced pixel. - **29 drawing tools** — levels and rays, trend lines, arrows, extended lines, boxes, ellipses, triangles, parallel channels, pitchforks, Gann fans and boxes, four Fibonacci tools, a regression trend with its bands, XABCD patterns, multi-leg paths, price and date brackets, a measuring tool, planned positions with their risk-to-reward, notes, callouts, flags and freehand strokes; each placed by tap or drag, then dragged, locked, hidden and removed. - **A line editor** that opens on selection: colour, opacity, thickness, solid/dashed/dotted stroke, fill, label text and visibility, alerts, lock and delete — every option list, control and pixel of it configurable through `DrawingStyle`. @@ -54,6 +55,7 @@ Named for the open-high-low-close-volume bars it renders. - **Higher timeframes** — a daily moving average on a fifteen-minute chart, through `TimeframeIndicator`; each candle reads the last higher-timeframe bar that had closed when it opened, so the line never repaints and never shows a value from its own future. - **A right-click menu** on the chart and on any drawing — coordinates, duplicate, restack, lock, hide, alert, delete; paste, select all, fit the scale, undo — with your own items added through one builder. - **Crosshair on hover** and an **OHLC legend** above the chart, which is how a chart reads on a desktop. +- **Or no interaction at all** — `scrollEnabled` and `zoomEnabled` turn the chart's own gestures off, for an intraday session, a thumbnail or a figure that is meant to sit still rather than be navigated; your own code can still drive it. - **Driven from your own code** — `KChartController` zooms, scrolls back to the live candle and hands you the chart as a PNG. - **The visible window, read and set** — which candles are on screen, go-to-date, fit-to-screen, and a callback whenever it moves. - **An overview strip** — `ChartOverview` draws the whole history under the chart with the visible window lit; drag it to scrub, drag its edges to zoom, tap to jump. @@ -76,11 +78,13 @@ Named for the open-high-low-close-volume bars it renders. ![Two linked charts sharing one crosshair](https://raw.githubusercontent.com/CtrlAltDevelop/ohlcv_chart/main/screenshots/linked-charts.png) +![Bar replay stepping the market forward a candle at a time](https://raw.githubusercontent.com/CtrlAltDevelop/ohlcv_chart/main/screenshots/bar-replay.gif) + ## Install ```yaml dependencies: - ohlcv_chart: ^2.2.0 + ohlcv_chart: ^2.4.0 ``` ## Quick start @@ -117,7 +121,7 @@ One page per feature, in [`doc/`](doc/README.md): | [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 | -| [Price axis](doc/price-axis.md) | Linear, log, percentage or indexed to 100; inverting and dragging it | +| [Price axis](doc/price-axis.md) | Linear, log, percentage or indexed to 100; inverting, dragging, locking it, and its gutter | | [The date axis](doc/date-axis.md) | Round time values, and taking the axis over yourself | | [The legend and the crosshair](doc/legend-and-crosshair.md) | The OHLC row, and the crosshair on hover | | [The long-press readout](doc/readout.md) | The card over a held candle, and your own builder | @@ -126,7 +130,7 @@ One page per feature, in [`doc/`](doc/README.md): | [Orders and positions](doc/orders-and-positions.md) | Live lines from your venue, tagged and draggable | | [Event marks](doc/event-marks.md) | Earnings, dividends, splits and news under the candles | | [Sessions and time zones](doc/sessions.md) | Day dividers, display time zone, extended hours, per-bar colour | -| [Driving the chart](doc/driving-the-chart.md) | `KChartController`, and the visible window | +| [Driving the chart](doc/driving-the-chart.md) | `KChartController`, the visible window, and a chart with no gestures | | [Panes](doc/panes.md) | Stacking, resizing and reordering the indicator panes | | [Bar replay](doc/bar-replay.md) | Rewind, then step or play the market forward | | [Sizing](doc/sizing.md) | How the candle area and the panes divide the height | diff --git a/doc/README.md b/doc/README.md index 7eccc1b..8f2d36d 100644 --- a/doc/README.md +++ b/doc/README.md @@ -25,8 +25,9 @@ For installation, the feature list and support, see the ## Axes and reading the chart - **[Price axis](price-axis.md)** — linear, logarithmic, percentage or indexed - to 100; inverting it, marking the window's high, low and average close, and - dragging the scale by hand. + to 100; inverting it, marking the window's high, low and average close, + dragging the scale by hand, locking it so scrolling cannot rescale it, and + holding a gutter back for its labels. - **[The date axis](date-axis.md)** — round time values, the formats it picks between, and taking it over with `dateFormatter`. - **[The legend and the crosshair](legend-and-crosshair.md)** — the OHLC row @@ -55,7 +56,8 @@ For installation, the feature list and support, see the ## Driving it from your code - **[Driving the chart](driving-the-chart.md)** — `KChartController` for zoom, - scroll and a PNG of the chart, plus reading and setting the visible window. + scroll and a PNG of the chart, reading and setting the visible window, and + turning the chart's own gestures off for one that should sit still. - **[Panes](panes.md)** — stacking, resizing and reordering the indicator panes. - **[Bar replay](bar-replay.md)** — rewind to any candle and step or play the market forward, with the indicators only knowing what has arrived. diff --git a/doc/bar-replay.md b/doc/bar-replay.md index 8b30ca2..da8cc40 100644 --- a/doc/bar-replay.md +++ b/doc/bar-replay.md @@ -5,6 +5,8 @@ the chart at a candle in the past: everything after it — the candles, the indicators computed from them, the now-price line and the legend — is as it was at that moment, so a setup can be studied without the answer already on screen. +![The replay running, a candle at a time](https://raw.githubusercontent.com/CtrlAltDevelop/ohlcv_chart/main/screenshots/bar-replay.gif) + ```dart final replay = ChartReplayController(interval: const Duration(milliseconds: 300)); @@ -31,6 +33,8 @@ candle having been picked first, and it gives up on its own at the newest one drive a transport bar from. It is a `ChangeNotifier`, so those buttons rebuild themselves; dispose it with the widget that owns it. +![The chart held at the 150th candle of 420, under a transport bar](https://raw.githubusercontent.com/CtrlAltDevelop/ohlcv_chart/main/screenshots/bar-replay.png) + Nothing is thrown away while a replay runs: the candle list is untouched and the drawings stay where they were placed, including any on candles still to arrive. diff --git a/doc/candlestick-chart.md b/doc/candlestick-chart.md index a1751c3..40d2694 100644 --- a/doc/candlestick-chart.md +++ b/doc/candlestick-chart.md @@ -1,5 +1,7 @@ # Candlestick chart +![Candles with moving averages, volume and MACD](https://raw.githubusercontent.com/CtrlAltDevelop/ohlcv_chart/main/screenshots/candles.png) + Feed it a `List`. Indicator values are computed in place by `DataUtil.calculate` before the first paint, and again whenever new candles arrive: @@ -26,6 +28,12 @@ KChartWidget( `KLineEntity.fromJson` accepts the usual OHLCV shape (`open`, `high`, `low`, `close`, `vol`, `time`/`id`), or build the entity directly. +`onLoadMore` fires when the scroll lands on an edge — `false` at the oldest +candle, `true` at the newest — once when it arrives rather than on every frame +the drag spends there, and again if the user comes away and goes back. Prepend +the older candles you fetch and hand the chart the longer list; it keeps its +place in the data, so the window does not jump. + --- [← All docs](README.md) · [Package README](../README.md) diff --git a/doc/date-axis.md b/doc/date-axis.md index 61c0929..92f6604 100644 --- a/doc/date-axis.md +++ b/doc/date-axis.md @@ -7,6 +7,8 @@ ends and the next begins. Labels that would crowd into each other are dropped rather than printed over one another, and the boundaries follow the clock the chart prints: a `timeZoneOffset` of half an hour still labels round local times. +![The axis the chart picks, and the same candles under a dateFormatter](https://raw.githubusercontent.com/CtrlAltDevelop/ohlcv_chart/main/screenshots/date-axis.png) + `ChartStyle.gridColumns` sets the density, read like `gridRows`. Formatting can be taken over completely — `ChartStyle.dateTimeFormat` for a fixed pattern, or `dateFormatter` for full control, which is handed each candle along with a flag @@ -21,7 +23,7 @@ KChartWidget( timeFrame: const Duration(minutes: 15), dateFormatter: (candle, longForm) => DateFormat( longForm ? 'EEE d MMM HH:mm' : 'HH:mm', - ).format(DateTime.fromMillisecondsSinceEpoch(candle.time!)), + ).format(candle.dateTime!), xFrontPadding: 120, ); ``` diff --git a/doc/driving-the-chart.md b/doc/driving-the-chart.md index d7ab5ef..7ed3e2e 100644 --- a/doc/driving-the-chart.md +++ b/doc/driving-the-chart.md @@ -76,6 +76,73 @@ KChartWidget( `indexRangeCovering` and `indexNearest` are exported for working out either from a list of candles without a chart in hand. +## Turning the gestures off + +Some charts are not meant to be navigated: an intraday session, a thumbnail in +a list, a figure in a report. `scrollEnabled` and `zoomEnabled` take the +chart's own gestures away. + +```dart +KChartWidget( + sessionCandles, + ChartColors(), + isTrendLine: false, + watermarkAssetPath: 'assets/logo.svg', + timeFrame: const Duration(minutes: 5), + chartType: ChartType.area, + scrollEnabled: false, + zoomEnabled: false, + // 78 candles in a box about 400 wide: 400 / 78 ≈ 5 + chartStyle: const ChartStyle(pointWidth: 5), + xFrontPadding: 0, + volHidden: true, + hideGrid: true, + showNowPrice: false, + showInfoDialog: false, + crosshairOnHover: false, + showContextMenu: false, + showScrollToNowButton: false, + priceScaleDrag: false, +); +``` + +With `scrollEnabled` off a drag neither slides the window nor flings it, and +[`onLoadMore`](candlestick-chart.md) is never asked for more candles — no edge +is ever reached to ask at. With `zoomEnabled` off a pinch does nothing, and the +zoom slider is left off too: that slider only ever appears on the web and on +desktop, standing in for the pinch those platforms do not have. + +Turn the two off together. Zooming out makes the candles narrower, which leaves +the window room to scroll into, so a chart with only `scrollEnabled` off can be +pinched back into a scrollable one. + +The two flags hold the *user* back and leave your own code alone, the way +`priceScaleDrag` does — so a chart nobody can drag can still be moved from a +toolbar, or fitted once at startup: + +```dart +chart.fitAll(); // the whole history in the box +chart.goToIndex(0); // or somewhere particular +``` + +### Filling the width + +`scrollEnabled: false` freezes the window wherever it happens to be, which is +usually at the newest candle with the rest off to the left. For a chart that +shows one fixed stretch, make the candles fit instead: `ChartStyle.pointWidth` +is the space each candle takes — 8 by default — so roughly the chart's width +divided by the number of candles puts the whole series on screen. + +```dart +chartStyle: ChartStyle(pointWidth: width / candles.length), +``` + +Once the series fits there is nowhere to scroll to at all, flag or no flag — +the scroll clamps to zero. `xFrontPadding: 0` gives up the gap the chart +otherwise leaves to the right of the newest candle, so the candles reach the +edge. `fitAll()` is the other way there, and works on any width without the +arithmetic. + ## Keeping charts in step `ChartLink` holds several charts on the same window. Add each one's controller @@ -156,7 +223,9 @@ chart.hideCrosshair(); // take it down The price axis is readable and settable the same way — `priceZoom` and `pricePan` for its stretch and shift, `setPriceZoom`, `setPricePan` and -`resetPriceScale` to move it. +`resetPriceScale` to move it. `resetPriceScale` is also what hands a +[locked axis](price-axis.md#keeping-it-still-while-the-chart-scrolls) back to the +chart, refitting it to the window and holding it there afresh. `onCrosshairChanged` reports where it moved to, on the same terms as `onVisibleRangeChanged`: after the frame that moved it, and only when the diff --git a/doc/legend-and-crosshair.md b/doc/legend-and-crosshair.md index 342d64d..7966d9d 100644 --- a/doc/legend-and-crosshair.md +++ b/doc/legend-and-crosshair.md @@ -1,5 +1,7 @@ # The legend and the crosshair +![The OHLC legend above the chart, reading from the crosshair](https://raw.githubusercontent.com/CtrlAltDevelop/ohlcv_chart/main/screenshots/legend-and-crosshair.png) + With a mouse, the crosshair follows the pointer without waiting for a press — that is `crosshairOnHover`, on by default and irrelevant to a touch screen, which has nothing that hovers. The values then belong above the chart rather than in a diff --git a/doc/line-editor.md b/doc/line-editor.md index ad2c037..dad7773 100644 --- a/doc/line-editor.md +++ b/doc/line-editor.md @@ -1,5 +1,7 @@ # Customising the line editor +![The line editor open on a selected line](https://raw.githubusercontent.com/CtrlAltDevelop/ohlcv_chart/main/screenshots/line-editor.png) + `DrawingStyle` decides what the toolbar offers, how it looks, and how close a tap has to land to count. Everything is optional: diff --git a/doc/panes.md b/doc/panes.md index ec80e4c..922c0a6 100644 --- a/doc/panes.md +++ b/doc/panes.md @@ -1,5 +1,7 @@ # Panes +![Three ATR panes stacked under the candles](https://raw.githubusercontent.com/CtrlAltDevelop/ohlcv_chart/main/screenshots/panes.png) + An indicator pane can be made taller by dragging its lower edge, and moved up or down the stack by dragging its legend row: diff --git a/doc/price-axis.md b/doc/price-axis.md index a099490..e2cb384 100644 --- a/doc/price-axis.md +++ b/doc/price-axis.md @@ -87,6 +87,71 @@ the window rather than the whole history. the axis reads in. The leader lines already point at the candles that set them; this says what to read them off the axis as. +## Keeping it still while the chart scrolls + +The axis fits the candles in the window, so scrolling rescales it: drag back +through a trend and every number on the axis changes as the window moves. +`lockPriceScale` holds it at one range instead. + +```dart +KChartWidget( + data, + ChartColors(), + lockPriceScale: true, + // ... +) +``` + +It locks onto the range the axis was already showing, so turning it on does not +move the chart. From then on the candles move under a scale that stays where it +is — which is what reading a level off the axis while scrolling needs, and what +paging in history through `onLoadMore` needs in order not to jump. + +Only the scale is held. The window's own high and low are still measured, so +`showHighLowOnAxis` and the high and low markers keep pointing at the candles +that set them, and a locked axis can still be dragged and zoomed — from the +range it is held at rather than the window's. + +`resetPriceScale` hands the axis back to the chart: it refits to whatever is on +screen and holds there afresh. + +```dart +chart.resetPriceScale(); // refit to the window, then hold there +``` + +Because the range is held until it is reset, a chart that switches to another +instrument should reset it — a range from one instrument means nothing on +another. Paging in candles and live ticks need nothing, which is the point. + +## Holding a gutter back for it + +By default the price labels are drawn over the candles, and the candles scroll +underneath them. `ChartStyle.priceAxisWidth` holds a gutter back instead: the +candles, the grid, the indicator panes and the date axis all stop short of it, +and the labels sit in it on their own. + +```dart +KChartWidget( + data, + ChartColors(), + chartStyle: const ChartStyle(priceAxisWidth: 56), + // Which side it is held back on follows the labels. + verticalTextAlignment: VerticalTextAlignment.right, + // ... +) +``` + +56 or so suits four or five digits at the default text size. The gutter is +never allowed past half the width, so a narrow chart is still mostly candles. + +The plot is clipped to its own bounds, so nothing — a candle at the edge of the +window, an indicator line, the now-price level — spills into the gutter, and +the axis reads the same however far the chart is scrolled. Pressing the labels +still grabs the scale, as below; the gutter counts as part of the axis strip. + +Left at 0, the default, nothing changes and the labels are drawn over the +candles as they always were. + ## Dragging the scale The axis fits the window by default, so the candles always fill the height — @@ -128,6 +193,11 @@ chart.priceZoom; // 1 while the chart is fitting it itself Set `priceScaleDrag: false` to keep the axis fitted to the window whatever the user does, which is the older behaviour. +That is the opposite of [locking it](#keeping-it-still-while-the-chart-scrolls), +which is worth keeping straight: `priceScaleDrag: false` means the axis *always* +refits to the window, and `lockPriceScale: true` means it *never* does. The two +can be combined — an axis held at one range that the user cannot drag off it. + --- [← All docs](README.md) · [Package README](../README.md) diff --git a/doc/sizing.md b/doc/sizing.md index 29ab09e..ba979b4 100644 --- a/doc/sizing.md +++ b/doc/sizing.md @@ -6,6 +6,8 @@ derived from the widget's box, so the whole stack fits — put the chart in an `Expanded` and it fills the space. Pass a number to pin the candle area instead, for instance inside a scroll view where there is no height to divide up. +![The candle area filling the box, and pinned to 220](https://raw.githubusercontent.com/CtrlAltDevelop/ohlcv_chart/main/screenshots/sizing.png) + --- [← All docs](README.md) · [Package README](../README.md) diff --git a/doc/theming.md b/doc/theming.md index ac33602..1674618 100644 --- a/doc/theming.md +++ b/doc/theming.md @@ -1,5 +1,7 @@ # Theming +![The same chart under the dark and light palettes](https://raw.githubusercontent.com/CtrlAltDevelop/ohlcv_chart/main/screenshots/theming.png) + ```dart KChartWidget( candles, diff --git a/example/README.md b/example/README.md index 7655da5..14ede97 100644 --- a/example/README.md +++ b/example/README.md @@ -65,6 +65,28 @@ 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 intraday demo + +A second entry point, for the other kind of chart: one session drawn as an +area, with the chart's own gestures turned off. + +```bash +flutter run -t lib/intraday_demo.dart +``` + +`scrollEnabled: false` and `zoomEnabled: false` are what make it sit still, and +`ChartStyle.pointWidth` set to the width divided by the candle count is what +fits the whole session on screen. Both switches at the top flip those off again, +so the difference can be felt rather than described: with "static" on, dragging +and pinching do nothing at all. + +Worth trying the pinch in particular. Zooming out narrows the candles, which +leaves the window room to scroll into — so a chart with only `scrollEnabled` +off could be pinched back into a scrollable one. That is why the two flags +belong together. + +See [Turning the gestures off](../doc/driving-the-chart.md#turning-the-gestures-off). + ## Screenshots The images in the package README and on the pub.dev listing are rendered by diff --git a/example/lib/intraday_demo.dart b/example/lib/intraday_demo.dart new file mode 100644 index 0000000..5a25fdb --- /dev/null +++ b/example/lib/intraday_demo.dart @@ -0,0 +1,116 @@ +// A chart with no gestures: one session, drawn as an area, sitting still. +// +// The kind of chart a stock app puts at the top of a symbol's page -- it shows +// today and nothing else, so there is nothing to scroll to and nothing to zoom +// into. An entry point of its own rather than a tab, since the point is a chart +// with no controls around it: +// +// cd example +// flutter run -t lib/intraday_demo.dart +// +// The two switches are there to show the difference; a real one would just set +// `scrollEnabled: false` and `zoomEnabled: false` and leave them. +import 'package:flutter/material.dart'; +import 'package:ohlcv_chart/ohlcv_chart.dart'; + +import 'src/market_data.dart'; + +void main() => runApp(const IntradayDemo()); + +class IntradayDemo extends StatefulWidget { + const IntradayDemo({super.key}); + + @override + State createState() => _IntradayDemoState(); +} + +class _IntradayDemoState extends State { + /// One session: 78 five-minute candles. + late final List _session = () { + final data = MarketData.candles(count: 78); + DataUtil.calculate(data); + return data; + }(); + + bool _static = true; + bool _fitWidth = true; + + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: ThemeData.dark(), + home: Scaffold( + appBar: AppBar(title: const Text('Intraday — static area chart')), + body: Column( + children: [ + SwitchListTile( + title: const Text('Static (no scroll, no zoom)'), + subtitle: Text( + _static + ? 'Try dragging and pinching — nothing should move' + : 'Now it drags and pinches like a normal chart', + ), + value: _static, + onChanged: (v) => setState(() => _static = v), + ), + SwitchListTile( + title: const Text('Fit the whole session to the width'), + subtitle: Text( + _fitWidth + ? 'pointWidth = width / 78, so all 78 candles show' + : 'Default spacing: only part of the session fits', + ), + value: _fitWidth, + onChanged: (v) => setState(() => _fitWidth = v), + ), + const Divider(height: 1), + Expanded( + child: Padding( + padding: const EdgeInsets.all(12), + child: LayoutBuilder( + builder: (context, constraints) { + // The whole point: each candle gets an equal share of the + // width, so the session fills the box exactly. + final pointWidth = _fitWidth + ? constraints.maxWidth / _session.length + : 8.0; + + return KChartWidget( + _session, + ChartColors(), + isTrendLine: false, + watermarkAssetPath: 'assets/none.svg', + timeFrame: const Duration(minutes: 5), + chartType: ChartType.area, + + // What makes it sit still. + scrollEnabled: !_static, + zoomEnabled: !_static, + + chartStyle: ChartStyle(pointWidth: pointWidth), + xFrontPadding: 0, + + // Everything else a plain intraday figure does not want. + volHidden: true, + hideGrid: true, + showNowPrice: false, + showInfoDialog: false, + crosshairOnHover: false, + showContextMenu: false, + showScrollToNowButton: false, + priceScaleDrag: false, + // A page that never scrolls has nothing to page in. + onLoadMore: (isRight) => debugPrint( + 'onLoadMore($isRight) — should never print while static', + ), + ); + }, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/example/lib/src/chart_page.dart b/example/lib/src/chart_page.dart index ec7207f..cdf4e22 100644 --- a/example/lib/src/chart_page.dart +++ b/example/lib/src/chart_page.dart @@ -90,6 +90,9 @@ class _Chart extends StatelessWidget { watermarkAssetPath: 'assets/watermark.svg', timeFrame: MarketData.timeFrame, chartStyle: state.style, + lockPriceScale: state.lockPriceScale, + scrollEnabled: state.scrollEnabled, + zoomEnabled: state.zoomEnabled, drawingStyle: state.drawingStyle, chartTranslations: state.translations, currentDrawingTool: state.tool, diff --git a/example/lib/src/controls.dart b/example/lib/src/controls.dart index 621b376..8fecfcb 100644 --- a/example/lib/src/controls.dart +++ b/example/lib/src/controls.dart @@ -417,6 +417,18 @@ class Controls extends StatelessWidget { value: state.axisOnRight, onChanged: (v) => state.update(() => state.axisOnRight = v), ), + _Toggle( + label: 'Fixed price axis', + subtitle: 'Holds a gutter back so candles stop short of it', + value: state.fixedPriceAxis, + onChanged: (v) => state.update(() => state.fixedPriceAxis = v), + ), + _Toggle( + label: 'Lock the price scale', + subtitle: 'Scrolling moves the candles, not the axis', + value: state.lockPriceScale, + onChanged: (v) => state.update(() => state.lockPriceScale = v), + ), _Toggle( label: 'Now price and countdown', value: state.showNowPrice, @@ -503,6 +515,18 @@ class Controls extends StatelessWidget { onChanged: (v) => state.update(() => state.frontPadding = v.toDouble()), ), + _Toggle( + label: 'Scrolling', + subtitle: 'Off, a drag leaves the window where it is', + value: state.scrollEnabled, + onChanged: (v) => state.update(() => state.scrollEnabled = v), + ), + _Toggle( + label: 'Zooming', + subtitle: 'Off, no pinch — and no slider on desktop', + value: state.zoomEnabled, + onChanged: (v) => state.update(() => state.zoomEnabled = v), + ), _Toggle( label: 'Scroll-to-now button', subtitle: 'Appears once the chart is scrolled back', diff --git a/example/lib/src/demo_state.dart b/example/lib/src/demo_state.dart index 3e1dba5..9df9de5 100644 --- a/example/lib/src/demo_state.dart +++ b/example/lib/src/demo_state.dart @@ -255,6 +255,19 @@ class DemoState extends ChangeNotifier { /// The last layout saved with [saveLayout]. String? savedLayout; + /// Holds a gutter back for the price axis, so the candles stop short of the + /// labels rather than scrolling under them. + bool fixedPriceAxis = false; + + /// Holds the price axis at one range, so scrolling does not rescale it. + bool lockPriceScale = false; + + /// Lets the user scroll the chart sideways. + bool scrollEnabled = true; + + /// Lets the user pinch, or drag the slider on desktop and the web. + bool zoomEnabled = true; + // ── Markers and readouts ──────────────────────────────────────────────── bool showSignals = true; bool showInfoDialog = true; @@ -289,7 +302,10 @@ class DemoState extends ChangeNotifier { /// Geometry for the current candle style, plus the session dividers. ChartStyle get style { final base = hollowCandles ? ChartTheme.hollow : ChartTheme.filled; - return base.copyWith(showSessionDividers: sessionDividers); + return base.copyWith( + showSessionDividers: sessionDividers, + priceAxisWidth: fixedPriceAxis ? 56.0 : 0.0, + ); } /// The line editor's configuration. diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 0bddeb0..2d3ddd3 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -15,6 +15,7 @@ dependencies: dev_dependencies: flutter_lints: ^6.0.0 + image: ^4.5.4 flutter_test: sdk: flutter diff --git a/example/tool/screenshots.dart b/example/tool/screenshots.dart index 13ec48e..6f68022 100644 --- a/example/tool/screenshots.dart +++ b/example/tool/screenshots.dart @@ -10,7 +10,10 @@ // that is adding one rather than redoing the set. // // Each scene is laid out at a fixed size, captured straight off the raster -// boundary and written into `../screenshots/`, then the app exits. Rendering +// boundary and written into `../screenshots/`, then the app exits. A film is +// the same thing sampled over and over and written out as one looping GIF — +// `buildFilms` holds those, and `--dart-define=only=` names them alongside the +// stills. Rendering // the real widgets in a real engine is what keeps the images honest — text, // anti-aliasing and all — rather than a headless golden, which draws every // glyph as a box. @@ -23,6 +26,7 @@ import 'dart:ui' as ui; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; +import 'package:image/image.dart' as gif; import 'package:ohlcv_chart/ohlcv_chart.dart'; import 'package:ohlcv_chart_example/src/chart_theme.dart'; import 'package:ohlcv_chart_example/src/indicator_sheet.dart'; @@ -85,6 +89,21 @@ typedef Scene = ({ Future Function(Rect area, Future Function() shoot)? act, }); +/// One animation to record: the same as a [Scene], but [roll] is handed a +/// shutter it is expected to call many times, and the frames are written out +/// as a single looping GIF rather than a still. +/// +/// Recorded at 1x rather than the stills' 2x: a GIF carries a 256-colour +/// palette and every frame whole, so the file grows with the pixels far faster +/// than a PNG does. +typedef Film = ({ + String name, + Size size, + int fps, + Widget Function() build, + Future Function(Future Function() frame) roll, +}); + /// The next pointer id, so each synthetic gesture is its own. int _pointer = 1; @@ -115,6 +134,97 @@ Future tap(Offset at) async { await Future.delayed(const Duration(milliseconds: 400)); } +/// Moves the mouse to [at] and leaves it there. +/// +/// The crosshair follows a hover without waiting for a press, so a scene that +/// wants it up — and the OHLC legend that reads from it — hovers rather than +/// holds, which would open the readout card instead. +Future hover(Offset at) async { + final pointer = _pointer++; + GestureBinding.instance.handlePointerEvent( + PointerHoverEvent( + pointer: pointer, + kind: PointerDeviceKind.mouse, + position: at, + ), + ); + await Future.delayed(const Duration(milliseconds: 400)); +} + +/// The replay transport, as an app would build one: the buttons and the +/// position readout all come off the controller, so a still says what is being +/// looked at and the film's counter ticks along with the candles. +class ReplayBar extends StatelessWidget { + /// Creates a transport bar over [replay]. + const ReplayBar({required this.replay, super.key}); + + /// The controller the bar reads and drives. + final ChartReplayController replay; + + @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), + ), + ), + ), + ), + 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() { runApp(const ScreenshotApp()); } @@ -130,22 +240,31 @@ class ScreenshotApp extends StatefulWidget { class _ScreenshotAppState extends State { final GlobalKey _boundary = GlobalKey(); - late final List _scenes = _asked(buildScenes()); - /// The scenes [requestedScenes] names, or all of them when it names none. - List _asked(List all) { - if (requestedScenes.isEmpty) return all; - final names = requestedScenes.split(',').map((name) => name.trim()).toSet(); - final asked = all.where((scene) => names.contains(scene.name)).toList(); - if (asked.isEmpty) { - stdout.writeln('no scene goes by ${names.join(', ')}'); - exit(1); - } - return asked; - } + /// The names asked for, or empty when the whole set was. + late final Set _names = requestedScenes.isEmpty + ? const {} + : requestedScenes.split(',').map((name) => name.trim()).toSet(); + + late final List _scenes = _asked(buildScenes(), (s) => s.name); + 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(); + + /// 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: _scenes[_index].size, build: _scenes[_index].build) + : ( + size: _films[_index - _scenes.length].size, + build: _films[_index - _scenes.length].build, + ); + @override void initState() { super.initState(); @@ -155,7 +274,14 @@ class _ScreenshotAppState extends State { late final Directory _output = resolveOutputDirectory(); Future _run() async { - stdout.writeln('writing ${_scenes.length} scenes to ${_output.path}'); + if (_scenes.isEmpty && _films.isEmpty) { + stdout.writeln('no scene goes by ${_names.join(', ')}'); + exit(1); + } + stdout.writeln( + 'writing ${_scenes.length} scenes and ${_films.length} films ' + 'to ${_output.path}', + ); for (var i = 0; i < _scenes.length; i++) { setState(() => _index = i); @@ -171,6 +297,11 @@ class _ScreenshotAppState extends State { await act(_area, () => _capture(scene)); } } + for (var i = 0; i < _films.length; i++) { + setState(() => _index = _scenes.length + i); + await Future.delayed(const Duration(milliseconds: 900)); + await _record(_films[i]); + } if (_stale.isNotEmpty) { stdout.writeln('stale, and not written: ${_stale.join(', ')}'); exit(1); @@ -199,17 +330,70 @@ class _ScreenshotAppState extends State { /// back whatever was last rasterised — which is how a run writes the same /// picture into every file. Marking the boundary dirty and waiting for the /// frame that schedules is what asks for a new one. - Future _raster(RenderRepaintBoundary boundary) async { + Future _raster( + RenderRepaintBoundary boundary, { + double pixelRatio = 2, + ui.ImageByteFormat format = ui.ImageByteFormat.png, + }) async { boundary.markNeedsPaint(); WidgetsBinding.instance.scheduleFrame(); await WidgetsBinding.instance.endOfFrame; - final image = await boundary.toImage(pixelRatio: 2); - final data = await image.toByteData(format: ui.ImageByteFormat.png); + final image = await boundary.toImage(pixelRatio: pixelRatio); + final data = await image.toByteData(format: format); image.dispose(); return data!.buffer.asUint8List(); } + /// Records [film] frame by frame and writes the lot out as one looping GIF. + Future _record(Film film) async { + final boundary = + _boundary.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final width = film.size.width.round(); + final height = film.size.height.round(); + final frames = []; + + Future shoot() async { + final raw = await _raster( + boundary, + pixelRatio: 1, + format: ui.ImageByteFormat.rawRgba, + ); + frames.add( + gif.Image.fromBytes( + width: width, + height: height, + bytes: raw.buffer, + numChannels: 4, + frameDuration: (1000 / film.fps).round(), + ), + ); + } + + await film.roll(shoot); + if (frames.isEmpty) { + stdout.writeln('${film.name} rolled no frames — left alone'); + _stale.add(film.name); + return; + } + + final reel = frames.first; + for (final frame in frames.skip(1)) { + reel.addFrame(frame); + } + // A chart is mostly flat background and a handful of hues, so a coarse + // sampling factor costs it nothing and keeps the file down. + final bytes = gif.encodeGif(reel, repeat: 0, samplingFactor: 20); + + final file = File('${_output.path}/${film.name}.gif'); + file.writeAsBytesSync(bytes); + stdout.writeln( + 'wrote ${file.path} ' + '(${frames.length} frames at ${width}x$height, ' + '${(file.lengthSync() / 1024).round()} KiB)', + ); + } + /// Where the scene sits on screen, so a synthetic gesture can find it. Rect get _area { final boundary = @@ -250,7 +434,7 @@ class _ScreenshotAppState extends State { @override Widget build(BuildContext context) { - final scene = _scenes[_index]; + final showing = _showing; return MaterialApp( debugShowCheckedModeBanner: false, home: ColoredBox( @@ -259,12 +443,12 @@ class _ScreenshotAppState extends State { child: RepaintBoundary( key: _boundary, child: SizedBox.fromSize( - size: scene.size, + size: showing.size, child: MediaQuery( - data: MediaQueryData(size: scene.size), + data: MediaQueryData(size: showing.size), child: Material( type: MaterialType.transparency, - child: scene.build(), + child: showing.build(), ), ), ), @@ -295,6 +479,8 @@ List buildScenes() { // the linked charts are talking to the same chart across every frame the // shutter waits through. final overviewChart = KChartController(); + // Held at one candle for the still; the moving version is its own film. + final replay = ChartReplayController(); final linkedTop = KChartController(); final linkedBottom = KChartController(); // The same market shown twice, so the price axis and the crosshair's height @@ -332,6 +518,10 @@ List buildScenes() { Color? extendedHoursColor, KChartController? controller, bool showInfoDialog = true, + ChartReplayController? replay, + double? mBaseHeight, + String Function(KLineEntity candle, bool longForm)? dateFormatter, + double xFrontPadding = 80, }) { final colors = light ? ChartTheme.lightColors() : ChartTheme.darkColors(); // The default wash is 7% of the text colour — right on a chart being read, @@ -381,6 +571,10 @@ List buildScenes() { invertPriceAxis: invertPriceAxis, showAverageClose: showAverageClose, showHighLowOnAxis: showHighLowOnAxis, + replay: replay, + mBaseHeight: mBaseHeight, + dateFormatter: dateFormatter, + xFrontPadding: xFrontPadding, fixedLength: 0, showNowPrice: true, ), @@ -407,13 +601,18 @@ List buildScenes() { /// /// [centred] moves the name off the top-left corner, for a panel whose chart /// already draws a legend or an extreme's label there. - Widget titled(String text, Widget child, {bool centred = false}) => Stack( + Widget titled( + String text, + Widget child, { + bool centred = false, + double top = 8, + }) => Stack( children: [ Positioned.fill(child: child), Positioned( left: centred ? 0 : 10, right: centred ? 0 : null, - top: 8, + top: top, // On a chip, because the chart draws its own labels in the same corner. child: Align( alignment: centred ? Alignment.topCenter : Alignment.topLeft, @@ -1236,6 +1435,132 @@ List buildScenes() { ); }, ), + ( + // Rewound to the 150th candle of 420: the market to the right of it has + // not happened yet, and the indicators only know what has arrived. + name: 'bar-replay', + size: wide, + act: (area, shoot) async { + replay.start(at: 150); + await Future.delayed(const Duration(milliseconds: 500)); + await shoot(); + }, + build: () => Column( + children: [ + Expanded( + child: chart( + data: longRun, + replay: replay, + indicators: [MaIndicator(period: 20), RsiIndicator()], + ), + ), + ReplayBar(replay: replay), + ], + ), + ), + ( + // Hovered rather than held: the crosshair follows a mouse without a + // press, and the legend row reads from wherever it is. + name: 'legend-and-crosshair', + size: wide, + act: (area, shoot) async { + await hover( + Offset(area.left + area.width * 0.58, area.top + area.height * 0.35), + ); + await shoot(); + }, + build: () => chart( + showOhlcLegend: true, + indicators: [MaIndicator(period: 20), MacdIndicator()], + ), + ), + ( + // The axis it picks for itself beside one taken over wholesale, on the + // same candles, so the difference is the formatting and nothing else. + name: 'date-axis', + 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'; + }, + ), + top: 30, + ), + ), + ], + ), + ), + ( + // The same chart under both palettes: ChartColors is the whole + // difference between them. + name: 'theming', + 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, + ), + ), + ], + ), + ), + ( + // Left to itself the candle area takes what the panes do not want; + // pinned, it keeps its height and the panes stack under it. + name: 'sizing', + 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()]), + ), + ), + ], + ), + ), ( name: 'indicator-settings', size: portrait, @@ -1255,3 +1580,62 @@ List buildScenes() { ), ]; } + +/// The animations to record. Same shape as [buildScenes], but each one is +/// handed a shutter to call repeatedly. +List buildFilms() { + final candles = MarketData.candles(count: 420); + final replay = ChartReplayController(); + + return [ + ( + // Stepping the replay forward a candle at a time, which is the one thing + // a still of it cannot say. + name: 'bar-replay', + // Smaller than a still and at 1x: a GIF carries every frame whole. + size: const Size(680, 400), + fps: 8, + roll: (frame) async { + replay.start(at: 150); + await Future.delayed(const Duration(milliseconds: 600)); + // Played rather than stepped, so the transport reads as running: the + // timer moves the candles while the shutter samples alongside it. + replay.play(interval: const Duration(milliseconds: 125)); + for (var i = 0; i < 30; i++) { + await frame(); + await Future.delayed(const Duration(milliseconds: 125)); + } + replay.pause(); + await frame(); + }, + build: () { + final colors = ChartTheme.darkColors(); + return ColoredBox( + color: colors.bgColor, + child: Column( + children: [ + Expanded( + child: KChartWidget( + candles, + colors, + chartStyle: ChartTheme.filled, + isTrendLine: false, + watermarkAssetPath: 'assets/watermark.svg', + timeFrame: MarketData.timeFrame, + timeFormat: TimeFormat.YEAR_MONTH_DAY_WITH_HOUR, + indicators: [MaIndicator(period: 20), RsiIndicator()], + replay: replay, + showScrollToNowButton: false, + showInfoDialog: false, + fixedLength: 0, + showNowPrice: true, + ), + ), + ReplayBar(replay: replay), + ], + ), + ); + }, + ), + ]; +} diff --git a/lib/src/chart_style.dart b/lib/src/chart_style.dart index 7111269..5ecc083 100644 --- a/lib/src/chart_style.dart +++ b/lib/src/chart_style.dart @@ -475,6 +475,7 @@ class ChartStyle { this.maxPaneHeight = 400.0, this.axisLabelBackground = true, this.axisLabelPadding = 4.0, + this.priceAxisWidth = 0.0, this.labelCornerRadius = 3.0, this.legendPadding = 4.0, this.legendSpacing = 2.0, @@ -607,6 +608,18 @@ class ChartStyle { /// Space between the chart edge and the price axis labels. final double axisLabelPadding; + /// Width of a gutter held back on the price axis side for its labels. + /// + /// Left at 0 — the default — the price labels are drawn over the candles, + /// which scroll underneath them. Given a width, the candles, the grid and + /// the date axis all stop short of it and the labels sit in the gutter on + /// their own, so the axis reads cleanly however far the chart is scrolled. + /// 56 or so suits four or five digits at the default text size. + /// + /// Which side it is held back on follows + /// `KChartWidget.verticalTextAlignment`. + final double priceAxisWidth; + /// Corner radius of the axis label and legend pills. final double labelCornerRadius; @@ -662,6 +675,7 @@ class ChartStyle { double? maxPaneHeight, bool? axisLabelBackground, double? axisLabelPadding, + double? priceAxisWidth, double? labelCornerRadius, double? legendPadding, double? legendSpacing, @@ -705,6 +719,7 @@ class ChartStyle { maxPaneHeight: maxPaneHeight ?? this.maxPaneHeight, axisLabelBackground: axisLabelBackground ?? this.axisLabelBackground, axisLabelPadding: axisLabelPadding ?? this.axisLabelPadding, + priceAxisWidth: priceAxisWidth ?? this.priceAxisWidth, labelCornerRadius: labelCornerRadius ?? this.labelCornerRadius, legendPadding: legendPadding ?? this.legendPadding, legendSpacing: legendSpacing ?? this.legendSpacing, diff --git a/lib/src/k_chart_widget.dart b/lib/src/k_chart_widget.dart index f159b9d..f6f77fb 100644 --- a/lib/src/k_chart_widget.dart +++ b/lib/src/k_chart_widget.dart @@ -231,6 +231,8 @@ class KChartWidget extends StatefulWidget { this.currentDrawingTool = DrawingTool.none, this.magnetMode = false, this.priceScaleDrag = true, + this.scrollEnabled = true, + this.zoomEnabled = true, this.replay, this.enableKeyboardShortcuts = true, this.crosshairOnHover = true, @@ -285,6 +287,7 @@ class KChartWidget extends StatefulWidget { this.hideGrid = false, this.showNowPrice = true, this.showInfoDialog = true, + this.lockPriceScale = false, this.materialInfoDialog = true, this.chartStyle = const ChartStyle(), this.drawingStyle = const DrawingStyle(), @@ -652,6 +655,32 @@ class KChartWidget extends StatefulWidget { /// trader works in. It changes what is displayed, never the data. final Duration timeZoneOffset; + /// Lets the user scroll the chart sideways. + /// + /// Off, the window stays where it is: a drag neither slides it nor flings it, + /// and [onLoadMore] is never asked for more candles, since no edge is ever + /// reached. What is drawn is still whatever the window holds, so a chart that + /// is meant to show one fixed stretch — a session, a day — wants its candles + /// to fit the box: see `ChartStyle.pointWidth`. + /// + /// The controller is unaffected, the way [priceScaleDrag] leaves it: a chart + /// the user cannot scroll can still be scrolled from your own code. + final bool scrollEnabled; + + /// Lets the user zoom the chart in and out. + /// + /// Off, pinching does nothing and the zoom slider — which is only ever shown + /// on the web and on desktop, where there is no pinch — is left off too. + /// + /// Worth turning off alongside [scrollEnabled] for a chart meant to sit + /// still: zooming out makes the candles narrower, which leaves the window + /// with room to scroll into and so hands back the scrolling that + /// [scrollEnabled] took away. + /// + /// The controller is unaffected, so `zoomIn`, `zoomOut` and `setChartScale` + /// still work. + final bool zoomEnabled; + /// Opens the info dialog on tap as well as on long press. final bool isTapShowInfoDialog; @@ -664,6 +693,25 @@ class KChartWidget extends StatefulWidget { /// Enables the long-press info dialog. final bool showInfoDialog; + /// Holds the price axis at one range instead of refitting it to the window. + /// + /// The axis fits whatever candles are on screen by default, so scrolling + /// rescales it and every number on it changes as the window moves. Locked, + /// it keeps the range it had when the lock took hold: the candles move under + /// a scale that stays put, which is what reading a level off the axis while + /// scrolling needs. + /// + /// The scale can still be dragged and zoomed, from the locked range rather + /// than the window's, and `KChartController.resetPriceScale` hands the axis + /// back to the chart — which refits it to the window and locks it there + /// again. + /// + /// The range is held until it is reset, so a chart that switches to another + /// instrument should reset it: a range from one instrument means nothing on + /// another. Paging in history and live ticks need nothing, which is the + /// point — they are what the lock is there to sit still through. + final bool lockPriceScale; + /// Uses the Material info dialog rather than the Cupertino-styled one. final bool materialInfoDialog; @@ -795,8 +843,17 @@ class KChartWidget extends StatefulWidget { class _KChartWidgetState extends State with TickerProviderStateMixin implements KChartHost { + /// What the info dialog is reading out, or null when it has nothing to say. + /// + /// Broadcast on both counts that matter here. The dialog is only in the tree + /// while [KChartWidget.showInfoDialog] is set, so its subscription comes and + /// goes with that flag; a single-subscription stream refused the second + /// listen and threw as the dialog was remounted. And delivery stays + /// asynchronous, which a plain notifier would not be — the painter emits + /// from inside paint, so telling the dialog synchronously would schedule a + /// build during the frame. final StreamController mInfoWindowStream = - StreamController(); + StreamController.broadcast(); /// The drawings the chart is painting, from the controller when there is one /// and from the per-kind lists otherwise. @@ -926,6 +983,12 @@ class _KChartWidgetState extends State double _priceZoom = 1.0; double _pricePan = 0.0; + /// The range a locked price axis is held at, or null while it is free. + /// + /// Taken from the axis as it was last fitted, so turning the lock on holds + /// the chart exactly where the user was already looking. + (double, double)? _lockedPriceRange; + /// Whether the price axis is being held where the user put it. bool get _priceScaleIsManual => _priceZoom != 1.0 || _pricePan != 0.0; double mSelectX = 0.0; @@ -1489,6 +1552,10 @@ class _KChartWidgetState extends State widget.replay?.addListener(_onReplayChanged); } if (!identical(oldWidget.candles, widget.candles)) _resolveIndicators(); + if (oldWidget.lockPriceScale && !widget.lockPriceScale) { + // Unlocked, the axis goes back to fitting the window. + _lockedPriceRange = null; + } if (oldWidget.currentDrawingTool != widget.currentDrawingTool) { // Picking a different tool abandons whatever the last one had started — // except an open-ended shape, which is finished rather than lost, since @@ -1948,6 +2015,21 @@ class _KChartWidgetState extends State paneHeights: _effectivePaneHeights, ); + // Taken from the axis as it stands, which is last frame's fit: this + // runs before the painter for this frame is made, so the range + // captured is the one the user is already looking at. Held in a field + // rather than pushed through setState because it is read straight + // away, by the painter built just below. + if (!widget.lockPriceScale) { + _lockedPriceRange = null; + } else if (_lockedPriceRange == null && _laidOut) { + final min = painter.mMainMinValue; + final max = painter.mMainMaxValue; + if (min.isFinite && max.isFinite && max > min) { + _lockedPriceRange = (min, max); + } + } + _painterBuilt = true; painter = ChartPainter( widget.chartStyle, @@ -2014,6 +2096,8 @@ class _KChartWidgetState extends State priceAxisScale: widget.priceAxisScale, priceZoom: _priceZoom, pricePan: _pricePan, + fixedPriceMin: _lockedPriceRange?.$1, + fixedPriceMax: _lockedPriceRange?.$2, candleIndex: _candleIndex, textCache: _textCache, ); @@ -2182,7 +2266,10 @@ class _KChartWidgetState extends State } if (details.scale != 1.0) { - // Zoom + // Zoom. A pinch on a chart that cannot be zoomed is not + // a scroll either, so it is dropped rather than falling + // through to the pan below. + if (!widget.zoomEnabled) return; mScaleX = (_lastScale * details.scale).clamp(0.1, 3.0); notifyChanged(); return; @@ -2195,11 +2282,14 @@ class _KChartWidgetState extends State } else if (isDraggingHandle) { _applyHandleDrag(pos); } else { - mScrollX += details.focalPointDelta.dx / mScaleX; - mScrollX = mScrollX.clamp( - 0.0, - BaseChartPainter.maxScrollX, - ); + if (widget.scrollEnabled) { + mScrollX += details.focalPointDelta.dx / mScaleX; + mScrollX = mScrollX.clamp( + 0.0, + BaseChartPainter.maxScrollX, + ); + _maybeLoadMore(); + } // Only once the axis is already being held: while it // fits the window there is nothing to slide. if (widget.priceScaleDrag && _priceScaleIsManual) { @@ -2251,7 +2341,9 @@ class _KChartWidgetState extends State isScale = false; _lastScale = mScaleX; - if (!_isDrawing && !isDraggingHandle) { + if (!_isDrawing && + !isDraggingHandle && + widget.scrollEnabled) { final velocity = details.velocity.pixelsPerSecond.dx; _onFling(velocity); } else { @@ -2308,9 +2400,10 @@ class _KChartWidgetState extends State // Touch platforms pinch to zoom; everything else gets the // slider. (`!isIOS || !isAndroid` was always true, so the slider // used to render on mobile too.) - if (kIsWeb || - (defaultTargetPlatform != TargetPlatform.iOS && - defaultTargetPlatform != TargetPlatform.android)) + if (widget.zoomEnabled && + (kIsWeb || + (defaultTargetPlatform != TargetPlatform.iOS && + defaultTargetPlatform != TargetPlatform.android))) _buildScaleX(), if (widget.showScrollToNowButton && !isChartAtRightEdge) _buildScrollToNowButton(), @@ -3646,6 +3739,7 @@ class _KChartWidgetState extends State aniX!.addListener(() { mScrollX = aniX!.value.clamp(0.0, BaseChartPainter.maxScrollX); + _maybeLoadMore(); notifyChanged(); }); @@ -3660,6 +3754,42 @@ class _KChartWidgetState extends State _controller!.forward(); } + /// Which edge [onLoadMore] has already been told about, so it is asked once + /// per arrival rather than on every frame the drag spends pinned there. + /// + /// Cleared as soon as the chart comes away from that edge, so scrolling back + /// out and in asks again. + bool? _loadMoreEdgeNotified; + + /// Asks the host to page in more candles when the scroll lands on an edge. + /// + /// `mScrollX` is clamped to `[0, maxScrollX]`, so those two bounds *are* the + /// edges: 0 is the newest candle and `maxScrollX` the oldest. The flag + /// [KChartWidget.onLoadMore] is given follows that — true at the right. + void _maybeLoadMore() { + final callback = widget.onLoadMore; + if (callback == null) return; + + // Nothing to page towards until the data has been laid out at least once; + // before that both bounds are 0 and every edge looks like both edges. + final maxScroll = BaseChartPainter.maxScrollX; + if (maxScroll <= 0) return; + + final bool? edge = switch (mScrollX) { + <= 0.0 => true, + _ when mScrollX >= maxScroll => false, + _ => null, + }; + + if (edge == null) { + _loadMoreEdgeNotified = null; + return; + } + if (_loadMoreEdgeNotified == edge) return; + _loadMoreEdgeNotified = edge; + callback(edge); + } + void notifyChanged() { setState(() {}); widget.controller?.hostChanged(); @@ -3919,12 +4049,11 @@ class _KChartWidgetState extends State return StreamBuilder( stream: mInfoWindowStream.stream, builder: (context, snapshot) { - if ((!isLongPress && !isOnTap) || - !snapshot.hasData || - snapshot.data?.kLineEntity == null) { + final info = snapshot.data; + if ((!isLongPress && !isOnTap) || info == null) { return const SizedBox.shrink(); } - final entity = snapshot.data!.kLineEntity; + final entity = info.kLineEntity; // Never wider than the chart itself, whatever the caller asked for. final maxWidth = math.min( widget.infoDialogMaxWidth, @@ -3932,12 +4061,12 @@ class _KChartWidgetState extends State ); return Positioned( top: 10, - left: snapshot.data!.isLeft ? 10.0 : null, - right: snapshot.data!.isLeft ? null : 10.0, + left: info.isLeft ? 10.0 : null, + right: info.isLeft ? null : 10.0, child: widget.infoDialogBuilder?.call( context, - snapshot.data?.kLinePreviousEntity, + info.kLinePreviousEntity, entity, ) ?? PopupInfoView( @@ -4009,10 +4138,14 @@ class _KChartWidgetState extends State /// Hands the price axis back to the chart, which fits it to the window. void resetPriceScale() { - if (!_priceScaleIsManual) return; + // A locked axis has something to reset even at zoom 1: the range it is + // being held at. Clearing it refits the axis to the window, and the next + // build locks it there. + if (!_priceScaleIsManual && _lockedPriceRange == null) return; setState(() { _priceZoom = 1.0; _pricePan = 0.0; + _lockedPriceRange = null; }); widget.controller?.hostChanged(); } @@ -4026,18 +4159,28 @@ class _KChartWidgetState extends State if (!widget.priceScaleDrag || !painter.hasLayout) return false; if (widget.currentDrawingTool != DrawingTool.none) return false; - final rect = painter.mMainRect; - if (!rect.contains(pos)) return false; - - final width = widget.chartStyle.priceScaleGripWidth.clamp( + final grip = widget.chartStyle.priceScaleGripWidth.clamp( 0.0, - rect.width / 2, + painter.mMainRect.width / 2, + ); + if (grip <= 0) return false; + + // The gutter is the axis, so pressing the labels themselves grabs the + // scale; the grip is measured in from there. + final onLeft = widget.verticalTextAlignment == VerticalTextAlignment.left; + final gutter = painter.priceAxisGutter; + final rect = painter.mMainRect; + final area = Rect.fromLTRB( + onLeft ? rect.left - gutter : rect.left, + rect.top, + onLeft ? rect.right : rect.right + gutter, + rect.bottom, ); - if (width <= 0) return false; + if (!area.contains(pos)) return false; - return widget.verticalTextAlignment == VerticalTextAlignment.left - ? pos.dx <= rect.left + width - : pos.dx >= rect.right - width; + return onLeft + ? pos.dx <= area.left + grip + gutter + : pos.dx >= area.right - grip - gutter; } /// Handles a tap on the price scale, and reports whether it was one. diff --git a/lib/src/renderer/base_chart_painter.dart b/lib/src/renderer/base_chart_painter.dart index 128fa62..36b0667 100644 --- a/lib/src/renderer/base_chart_painter.dart +++ b/lib/src/renderer/base_chart_painter.dart @@ -112,8 +112,41 @@ abstract class BaseChartPainter extends CustomPainter { /// Secondary list support List mSecondaryRectList = []; late double mDisplayHeight; + + /// Whether the price axis gutter is held back on the left rather than the + /// right. + /// + /// Concrete so a subclass that draws no axis need not care; the chart + /// painter overrides it from its label alignment. + bool get priceAxisOnLeft => false; + + /// Width of the plot: the whole canvas less the price axis gutter. + /// + /// This is what the candles, the grid and the date axis are laid out in, so + /// nothing is drawn under the axis labels. With no gutter it is the full + /// canvas width, which is what it always was. late double mWidth; + /// Full width of the canvas, gutter included. + /// + /// For what belongs against the true edge rather than inside the plot: the + /// axis labels themselves, and the price tags that point at them. + late double mCanvasWidth; + + /// Right edge of the plot, which the gutter takes when the labels are on the + /// right. + double get mPlotRight => mPlotLeft + mWidth; + + /// Width actually held back for the price axis, after clamping. + /// + /// What the renderers are given, so where they put the labels and where the + /// plot stops can never disagree. + double get priceAxisGutter => mCanvasWidth - mWidth; + + /// Left edge of the plot, which the gutter takes when the labels are on the + /// left. 0 whenever they are on the right. + late double mPlotLeft; + // padding double mTopPadding = 20.0; double mBottomPadding = 20.0; @@ -187,7 +220,11 @@ abstract class BaseChartPainter extends CustomPainter { /// crosshair a pixel off the candle it is reading. void layout(Size size) { mDisplayHeight = size.height - mTopPadding - mBottomPadding; - mWidth = size.width; + mCanvasWidth = size.width; + // Never so wide that there is no plot left to draw in. + final gutter = chartStyle.priceAxisWidth.clamp(0.0, size.width / 2); + mWidth = size.width - gutter; + mPlotLeft = priceAxisOnLeft ? gutter : 0.0; initRect(size); calculateValue(); initChartRenderer(); @@ -245,7 +282,7 @@ abstract class BaseChartPainter extends CustomPainter { // The crosshair is measured in candle space, like the candles it picks // out, so it takes the same transform they are drawn in. canvas.save(); - canvas.translate(mTranslateX * scaleX, 0.0); + canvas.translate(mPlotLeft + mTranslateX * scaleX, 0.0); canvas.scale(scaleX, 1.0); drawCrossLine(canvas, size); canvas.restore(); @@ -319,14 +356,19 @@ abstract class BaseChartPainter extends CustomPainter { mainHeight = max(mDisplayHeight - room, 0.0); } - mMainRect = Rect.fromLTRB(0, mTopPadding, mWidth, mTopPadding + mainHeight); + mMainRect = Rect.fromLTRB( + mPlotLeft, + mTopPadding, + mPlotLeft + mWidth, + mTopPadding + mainHeight, + ); _hasLayout = true; if (volHidden != true) { mVolRect = Rect.fromLTRB( - 0, + mPlotLeft, mMainRect.bottom + mChildPadding, - mWidth, + mPlotLeft + mWidth, mMainRect.bottom + volHeight, ); } @@ -338,7 +380,14 @@ abstract class BaseChartPainter extends CustomPainter { ? paneHeights[i] : BaseDimension.secondaryPaneHeight; mSecondaryRectList.add( - RenderRect(Rect.fromLTRB(0, top + mChildPadding, mWidth, top + height)), + RenderRect( + Rect.fromLTRB( + mPlotLeft, + top + mChildPadding, + mPlotLeft + mWidth, + top + height, + ), + ), ); top += height; } @@ -350,8 +399,8 @@ abstract class BaseChartPainter extends CustomPainter { if (candles!.isEmpty) return; maxScrollX = getMinTranslateX().abs(); setTranslateXFromScrollX(scrollX); - mStartIndex = indexOfTranslateX(xToTranslateX(0)); - mStopIndex = indexOfTranslateX(xToTranslateX(mWidth)); + mStartIndex = indexOfTranslateX(xToTranslateX(mPlotLeft)); + mStopIndex = indexOfTranslateX(xToTranslateX(mPlotLeft + mWidth)); // Pinned before the range is measured: a rebased comparison starts from the // left edge of the window, so where it sits depends on the window and what // it contributes to the range depends on where it sits. @@ -457,7 +506,7 @@ abstract class BaseChartPainter extends CustomPainter { } // translate x - double xToTranslateX(double x) => -mTranslateX + x / scaleX; + double xToTranslateX(double x) => -mTranslateX + (x - mPlotLeft) / scaleX; int indexOfTranslateX(double translateX) => _indexOfTranslateX(translateX, 0, mItemCount - 1); @@ -518,7 +567,7 @@ abstract class BaseChartPainter extends CustomPainter { /// translateX is converted to X in view double translateXtoX(double translateX) => - (translateX + mTranslateX) * scaleX; + (translateX + mTranslateX) * scaleX + mPlotLeft; /// define text style TextStyle getTextStyle(Color color) { diff --git a/lib/src/renderer/base_chart_renderer.dart b/lib/src/renderer/base_chart_renderer.dart index f7275ee..7d0d132 100644 --- a/lib/src/renderer/base_chart_renderer.dart +++ b/lib/src/renderer/base_chart_renderer.dart @@ -17,6 +17,8 @@ abstract class BaseChartRenderer { this.labelCornerRadius = 3.0, this.legendPadding = 4.0, this.legendBgColor, + this.priceAxisGutter = 0.0, + this.priceAxisGutterOnLeft = false, }) { if (maxValue == minValue) { maxValue *= 1.5; @@ -43,6 +45,33 @@ abstract class BaseChartRenderer { /// Fill of the legend pill; null leaves the legend unbacked. final Color? legendBgColor; + /// Width held back beside [chartRect] for the price axis labels. + /// + /// Already resolved by the painter, so it is never wider than the canvas can + /// spare. 0 means there is no gutter and the labels are drawn over the plot, + /// which is the long-standing behaviour. + final double priceAxisGutter; + + /// Which side [priceAxisGutter] is held back on. + final bool priceAxisGutterOnLeft; + + /// Where a price axis label [width] wide starts, [padding] in from its edge. + /// + /// With a gutter the label goes in it, on whichever side it was held back, + /// so the plot never runs underneath. Without one the label is drawn just + /// inside the plot, against [onLeft] — which is how the axis has always been + /// drawn, and what every pane still does by default. + double axisLabelX(double width, double padding, {bool onLeft = false}) { + if (priceAxisGutter > 0) { + return priceAxisGutterOnLeft + ? chartRect.left - priceAxisGutter + padding + : chartRect.right + padding; + } + return onLeft + ? chartRect.left + padding + : chartRect.right - width - padding; + } + double maxValue; double minValue; late double scaleY; diff --git a/lib/src/renderer/chart_painter.dart b/lib/src/renderer/chart_painter.dart index 1b55a86..deb9b2c 100644 --- a/lib/src/renderer/chart_painter.dart +++ b/lib/src/renderer/chart_painter.dart @@ -62,6 +62,8 @@ class ChartPainter extends BaseChartPainter { super.isHovering, super.suppressCrosshair, super.isOnTap, + this.fixedPriceMin, + this.fixedPriceMax, super.isTapShowInfoDialog, super.overlays, super.panes, @@ -233,6 +235,19 @@ class ChartPainter extends BaseChartPainter { /// leaves the auto-fitted window where it is. final double pricePan; + /// The range to hold the price axis at, rather than scaling it from the fit + /// to whatever candles are in the window. + /// + /// Both ends are needed for the lock to take; either one left null and the + /// axis is scaled from the window as it always has been. Only the scale is + /// held: [mMainMaxValue] and [mMainMinValue] stay the window's honest fit, so + /// the high and low markers still point at the candles that set them and the + /// chart can be handed back its own scale at any time. + final double? fixedPriceMin; + + /// The upper end of [fixedPriceMin]'s range. + final double? fixedPriceMax; + /// What the candle area draws for each candle. final ChartType chartType; @@ -253,6 +268,10 @@ class ChartPainter extends BaseChartPainter { final bool hideGrid; final bool showNowPrice; final VerticalTextAlignment verticalTextAlignment; + + @override + bool get priceAxisOnLeft => + verticalTextAlignment == VerticalTextAlignment.left; final String Function(KLineEntity entity, bool isCrossLine)? dateFormatter; final vg.PictureInfo? watermarkPicture; final Duration timeFrame; @@ -296,22 +315,34 @@ class ChartPainter extends BaseChartPainter { /// axis, their logarithms for a logarithmic one — so a stretched log axis /// stays a log axis. (double, double) _scaledMainRange() { - if (priceZoom == 1 && pricePan == 0) return (mMainMaxValue, mMainMinValue); - if (!mMainMaxValue.isFinite || !mMainMinValue.isFinite) { - return (mMainMaxValue, mMainMinValue); - } + // A locked axis is scaled from the range it is held at rather than from the + // window's fit, which is what keeps it still while the chart scrolls: the + // candles move and the scale under them does not. + final lockedMin = fixedPriceMin; + final lockedMax = fixedPriceMax; + final locked = + lockedMin != null && + lockedMax != null && + lockedMin.isFinite && + lockedMax.isFinite && + lockedMax > lockedMin; + final fitMax = locked ? lockedMax : mMainMaxValue; + final fitMin = locked ? lockedMin : mMainMinValue; + + if (priceZoom == 1 && pricePan == 0) return (fitMax, fitMin); + if (!fitMax.isFinite || !fitMin.isFinite) return (fitMax, fitMin); final logarithmic = - priceAxisScale == PriceAxisScale.logarithmic && mMainMinValue > 0; + priceAxisScale == PriceAxisScale.logarithmic && fitMin > 0; double toAxis(double price) => logarithmic ? math.log(price) / math.ln10 : price; double toPrice(double value) => logarithmic ? math.pow(10, value).toDouble() : value; - final top = toAxis(mMainMaxValue); - final bottom = toAxis(mMainMinValue); + final top = toAxis(fitMax); + final bottom = toAxis(fitMin); final span = top - bottom; - if (span <= 0) return (mMainMaxValue, mMainMinValue); + if (span <= 0) return (fitMax, fitMin); final middle = (top + bottom) / 2 + span * pricePan; final half = span / 2 / priceZoom; @@ -345,6 +376,8 @@ class ChartPainter extends BaseChartPainter { inverted: invertPriceAxis, averageClose: showAverageClose ? _averageCloseInView : null, candleColor: candleColor, + priceAxisGutter: priceAxisGutter, + priceAxisGutterOnLeft: priceAxisOnLeft, ); if (mVolRect != null) { mVolRenderer = VolRenderer( @@ -355,6 +388,8 @@ class ChartPainter extends BaseChartPainter { fixedLength, chartStyle, chartColors, + priceAxisGutter: priceAxisGutter, + priceAxisGutterOnLeft: priceAxisOnLeft, ); } mIndicatorPaneList = [ @@ -369,6 +404,8 @@ class ChartPainter extends BaseChartPainter { chartColors, panes[i], percentBase: _paneBase(panes[i]), + priceAxisGutter: priceAxisGutter, + priceAxisGutterOnLeft: priceAxisOnLeft, ), ]; } @@ -526,7 +563,11 @@ class ChartPainter extends BaseChartPainter { mMainRenderer.drawProfiles(canvas); canvas.save(); - canvas.translate(mTranslateX * scaleX, 0.0); + // Clipped to the plot so nothing runs under the price axis gutter, then + // moved into candle space -- which starts at the plot's left edge, not the + // canvas's. + canvas.clipRect(Rect.fromLTRB(mPlotLeft, 0, mPlotRight, size.height)); + canvas.translate(mPlotLeft + mTranslateX * scaleX, 0.0); canvas.scale(scaleX, 1.0); for (int i = mStartIndex; candles != null && i <= mStopIndex; i++) { @@ -688,8 +729,8 @@ class ChartPainter extends BaseChartPainter { final trading = chartStyle.trading; paintStyledLine( canvas, - Offset(0, y), - Offset(size.width, y), + Offset(mPlotLeft, y), + Offset(mPlotRight, y), Paint() ..color = color ..strokeWidth = trading.lineWidth @@ -748,7 +789,7 @@ class ChartPainter extends BaseChartPainter { final y = mMainRect.bottom - radius - chartStyle.eventMarkGap; for (final mark in events) { final x = translateXtoX(getX(mark.index)); - if (x < -radius || x > mWidth + radius) continue; + if (x < mPlotLeft - radius || x > mPlotRight + radius) continue; final color = mark.event.color ?? chartColors.eventColor(mark.event.kind.name); @@ -2584,7 +2625,7 @@ class ChartPainter extends BaseChartPainter { final tp = getTextPainter(label, null); final y = size.height - (mBottomPadding - tp.height) / 2 - tp.height; var x = translateXtoX(getX(index)) - tp.width / 2; - x = x.clamp(0.0, math.max(0.0, size.width - tp.width)); + x = x.clamp(mPlotLeft, math.max(mPlotLeft, mPlotRight - tp.width)); // Two labels crowding into each other read as one long number, so the // later one gives way. @@ -2599,19 +2640,19 @@ class ChartPainter extends BaseChartPainter { double calculatePrice(double y) => mMainRenderer.getValue(y); CrossArea getCrossArea(double y) { - if (mMainRect.contains(Offset(0, y))) return CrossArea.main; - if (mVolRect != null && mVolRect!.contains(Offset(0, y))) { + if (mMainRect.contains(Offset(mPlotLeft, y))) return CrossArea.main; + if (mVolRect != null && mVolRect!.contains(Offset(mPlotLeft, y))) { return CrossArea.volume; } for (final sec in mSecondaryRectList) { - if (sec.mRect.contains(Offset(0, y))) return CrossArea.secondary; + if (sec.mRect.contains(Offset(mPlotLeft, y))) return CrossArea.secondary; } return CrossArea.none; } IndicatorPaneRenderer? _getSecondaryRendererByY(double y) { for (int i = 0; i < mSecondaryRectList.length; i++) { - if (mSecondaryRectList[i].mRect.contains(Offset(0, y)) && + if (mSecondaryRectList[i].mRect.contains(Offset(mPlotLeft, y)) && i < mIndicatorPaneList.length) { return mIndicatorPaneList[i]; } @@ -2679,7 +2720,7 @@ class ChartPainter extends BaseChartPainter { double x; bool isLeft; - if (translateXtoX(getX(index)) < mWidth / 2) { + if (translateXtoX(getX(index)) < mPlotLeft + mWidth / 2) { isLeft = false; x = 1; @@ -2703,13 +2744,13 @@ class ChartPainter extends BaseChartPainter { } } else { isLeft = true; - x = mWidth - textWidth - 1 - 2 * w1 - w2; + x = mPlotRight - textWidth - 1 - 2 * w1 - w2; final path = Path() ..moveTo(x, selectY) ..lineTo(x + w2, selectY + r) - ..lineTo(mWidth - 2, selectY + r) - ..lineTo(mWidth - 2, selectY - r) + ..lineTo(mPlotRight - 2, selectY + r) + ..lineTo(mPlotRight - 2, selectY - r) ..lineTo(x + w2, selectY - r) ..close(); @@ -2735,8 +2776,8 @@ class ChartPainter extends BaseChartPainter { if (dateX < dateTp.width / 2 + w1) { dateX = dateTp.width / 2 + w1; - } else if (mWidth - dateX < dateTp.width / 2 + w1) { - dateX = mWidth - dateTp.width / 2 - w1; + } else if (mPlotRight - dateX < dateTp.width / 2 + w1) { + dateX = mPlotRight - dateTp.width / 2 - w1; } canvas.drawRect( @@ -2898,9 +2939,9 @@ class ChartPainter extends BaseChartPainter { ..isAntiAlias = true; // Point away from the nearer edge so the label always has room. - final pointsRight = x < mWidth / 2; + final pointsRight = x < mPlotLeft + mWidth / 2; final textLeft = pointsRight - ? math.min(x + leader + gap, mWidth - tp.width - 2) + ? math.min(x + leader + gap, mPlotRight - tp.width - 2) : math.max(x - leader - gap - tp.width, 2.0); canvas.drawLine( @@ -2929,11 +2970,12 @@ class ChartPainter extends BaseChartPainter { // Dashes run the full width so the level can be read anywhere, while the // stretch since the last candle stays solid. - final lastX = translateXtoX(getX(candles!.length - 1)).clamp(0.0, mWidth); + final lastX = translateXtoX(getX(candles!.length - 1)) + .clamp(mPlotLeft, mPlotRight); if (chartStyle.nowPriceDashed) { paintStyledLine( canvas, - Offset(0, y), + Offset(mPlotLeft, y), Offset(lastX, y), nowPricePaint, style: LineStyle.dashed, @@ -2941,9 +2983,9 @@ class ChartPainter extends BaseChartPainter { dashGap: chartStyle.nowPriceLineSpan, ); } else { - canvas.drawLine(Offset(0, y), Offset(lastX, y), nowPricePaint); + canvas.drawLine(Offset(mPlotLeft, y), Offset(lastX, y), nowPricePaint); } - canvas.drawLine(Offset(lastX, y), Offset(mWidth, y), nowPricePaint); + canvas.drawLine(Offset(lastX, y), Offset(mPlotRight, y), nowPricePaint); String countdown = '00:00'; if (last.dateTime != null) { @@ -2981,8 +3023,8 @@ class ChartPainter extends BaseChartPainter { const padding = 4.0; final tagWidth = tp.width + padding * 2; final left = verticalTextAlignment == VerticalTextAlignment.left - ? mWidth - tagWidth - 1 - : 1.0; + ? mPlotRight - tagWidth - 1 + : mPlotLeft + 1.0; final top = y - tp.height / 2 - padding / 2; canvas.drawRRect( @@ -3017,8 +3059,8 @@ class ChartPainter extends BaseChartPainter { paintStyledLine( canvas, - Offset(0, y), - Offset(mWidth, y), + Offset(mPlotLeft, y), + Offset(mPlotRight, y), linePaint, style: signal.useDash ? LineStyle.dashed : LineStyle.solid, dashLength: chartStyle.nowPriceLineLength, @@ -3121,9 +3163,9 @@ class ChartPainter extends BaseChartPainter { if (picture == null) return; final area = Rect.fromLTRB( - 0, + mPlotLeft, mTopPadding, - mWidth, + mPlotRight, mTopPadding + mMainRect.height, ); final logoWidth = diff --git a/lib/src/renderer/indicator_pane_renderer.dart b/lib/src/renderer/indicator_pane_renderer.dart index e3b4775..134a755 100644 --- a/lib/src/renderer/indicator_pane_renderer.dart +++ b/lib/src/renderer/indicator_pane_renderer.dart @@ -27,6 +27,8 @@ class IndicatorPaneRenderer extends BaseChartRenderer { this.chartColors, this.resolved, { this.percentBase, + super.priceAxisGutter = 0.0, + super.priceAxisGutterOnLeft = false, }) : super( chartRect: rect, maxValue: maxValue, @@ -224,7 +226,7 @@ class IndicatorPaneRenderer extends BaseChartRenderer { chartRect.bottom - tp.height, ); - tp.paint(canvas, Offset(chartRect.width - tp.width - padding, offsetY)); + tp.paint(canvas, Offset(axisLabelX(tp.width, padding), offsetY)); } } diff --git a/lib/src/renderer/main_renderer.dart b/lib/src/renderer/main_renderer.dart index f6dcc5a..d63b119 100644 --- a/lib/src/renderer/main_renderer.dart +++ b/lib/src/renderer/main_renderer.dart @@ -52,6 +52,8 @@ class MainRenderer extends BaseChartRenderer { this.inverted = false, this.averageClose, this.candleColor, + super.priceAxisGutter = 0.0, + super.priceAxisGutterOnLeft = false, }) : super( chartRect: mainRect, maxValue: maxValue, @@ -790,24 +792,20 @@ class MainRenderer extends BaseChartRenderer { /// /// Built once for the pane it fills, since it is measured from the pane and /// not from the data. - Shader get _fillShader => - mLineFillShader ??= - LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - tileMode: TileMode.clamp, - colors: [ - chartColors.lineFillColor, - chartColors.lineFillInsideColor, - ], - ).createShader( - Rect.fromLTRB( - chartRect.left, - chartRect.top, - chartRect.right, - chartRect.bottom, - ), - ); + Shader get _fillShader => mLineFillShader ??= + LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + tileMode: TileMode.clamp, + colors: [chartColors.lineFillColor, chartColors.lineFillInsideColor], + ).createShader( + Rect.fromLTRB( + chartRect.left, + chartRect.top, + chartRect.right, + chartRect.bottom, + ), + ); /// Draws the series collected over the visible window. /// @@ -991,10 +989,11 @@ class MainRenderer extends BaseChartRenderer { // print over that pane's legend. if (hasPanesBelow && chartRect.bottom - y < tp.height) continue; - final double offsetX = switch (verticalTextAlignment) { - VerticalTextAlignment.left => padding, - VerticalTextAlignment.right => chartRect.width - tp.width - padding, - }; + final offsetX = axisLabelX( + tp.width, + padding, + onLeft: verticalTextAlignment == VerticalTextAlignment.left, + ); if (chartStyle.axisLabelBackground) { canvas.drawRRect( @@ -1023,7 +1022,11 @@ class MainRenderer extends BaseChartRenderer { for (final value in priceTicks(gridRows)) { final y = getY(value); if (!y.isFinite || y < chartRect.top || y > chartRect.bottom) continue; - canvas.drawLine(Offset(0, y), Offset(chartRect.width, y), gridPaint); + canvas.drawLine( + Offset(chartRect.left, y), + Offset(chartRect.right, y), + gridPaint, + ); } final columns = diff --git a/lib/src/renderer/vol_renderer.dart b/lib/src/renderer/vol_renderer.dart index c7c3969..5b2a2e2 100644 --- a/lib/src/renderer/vol_renderer.dart +++ b/lib/src/renderer/vol_renderer.dart @@ -17,22 +17,24 @@ class VolRenderer extends BaseChartRenderer { double topPadding, int fixedLength, this.chartStyle, - this.chartColors, - ) : super( - chartRect: mainRect, - maxValue: maxValue, - minValue: minValue, - topPadding: topPadding, - fixedLength: fixedLength, - gridColor: chartColors.gridColor, - separatorColor: chartColors.effectiveSeparatorColor, - gridColumnColor: chartColors.effectiveGridColumnColor, - gridStrokeWidth: chartStyle.gridStrokeWidth, - separatorWidth: chartStyle.separatorWidth, - labelCornerRadius: chartStyle.labelCornerRadius, - legendPadding: chartStyle.legendPadding, - legendBgColor: chartColors.effectiveLegendBgColor, - ) { + this.chartColors, { + super.priceAxisGutter = 0.0, + super.priceAxisGutterOnLeft = false, + }) : super( + chartRect: mainRect, + maxValue: maxValue, + minValue: minValue, + topPadding: topPadding, + fixedLength: fixedLength, + gridColor: chartColors.gridColor, + separatorColor: chartColors.effectiveSeparatorColor, + gridColumnColor: chartColors.effectiveGridColumnColor, + gridStrokeWidth: chartStyle.gridStrokeWidth, + separatorWidth: chartStyle.separatorWidth, + labelCornerRadius: chartStyle.labelCornerRadius, + legendPadding: chartStyle.legendPadding, + legendBgColor: chartColors.effectiveLegendBgColor, + ) { mVolWidth = chartStyle.volWidth; } @@ -179,7 +181,7 @@ class VolRenderer extends BaseChartRenderer { final top = chartRect.top - topPadding; final offsetY = (y - tp.height).clamp(top, chartRect.bottom - tp.height); - tp.paint(canvas, Offset(chartRect.width - tp.width - padding, offsetY)); + tp.paint(canvas, Offset(axisLabelX(tp.width, padding), offsetY)); } } diff --git a/pubspec.yaml b/pubspec.yaml index 0530c65..36f3e57 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ 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.3.1 +version: 2.4.0 homepage: https://github.com/CtrlAltDevelop repository: https://github.com/CtrlAltDevelop/ohlcv_chart issue_tracker: https://github.com/CtrlAltDevelop/ohlcv_chart/issues diff --git a/screenshots/bar-replay.gif b/screenshots/bar-replay.gif new file mode 100644 index 0000000..646e052 Binary files /dev/null and b/screenshots/bar-replay.gif differ diff --git a/screenshots/bar-replay.png b/screenshots/bar-replay.png new file mode 100644 index 0000000..31d2aac Binary files /dev/null and b/screenshots/bar-replay.png differ diff --git a/screenshots/date-axis.png b/screenshots/date-axis.png new file mode 100644 index 0000000..df4951a Binary files /dev/null and b/screenshots/date-axis.png differ diff --git a/screenshots/legend-and-crosshair.png b/screenshots/legend-and-crosshair.png new file mode 100644 index 0000000..d41284f Binary files /dev/null and b/screenshots/legend-and-crosshair.png differ diff --git a/screenshots/sizing.png b/screenshots/sizing.png new file mode 100644 index 0000000..4f0a7e1 Binary files /dev/null and b/screenshots/sizing.png differ diff --git a/screenshots/theming.png b/screenshots/theming.png new file mode 100644 index 0000000..079637d Binary files /dev/null and b/screenshots/theming.png differ diff --git a/test/load_more_and_info_dialog_test.dart b/test/load_more_and_info_dialog_test.dart new file mode 100644 index 0000000..273b7ab --- /dev/null +++ b/test/load_more_and_info_dialog_test.dart @@ -0,0 +1,211 @@ +import 'package:flutter/gestures.dart' show kLongPressTimeout; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:ohlcv_chart/ohlcv_chart.dart'; +import 'package:ohlcv_chart/src/components/popup_info_view.dart'; +import 'package:ohlcv_chart/src/renderer/chart_painter.dart'; + +import 'test_utils.dart'; + +Widget _chart({ + ValueChanged? onLoadMore, + bool showInfoDialog = true, + bool isTapShowInfoDialog = false, +}) { + final data = candles(rampThenFall(120)); + DataUtil.calculate(data); + + return MaterialApp( + home: Scaffold( + body: SizedBox( + width: 500, + height: 600, + child: KChartWidget( + data, + ChartColors(), + isTrendLine: false, + watermarkAssetPath: 'assets/none.svg', + timeFrame: const Duration(minutes: 15), + showNowPrice: false, + onLoadMore: onLoadMore, + showInfoDialog: showInfoDialog, + isTapShowInfoDialog: isTapShowInfoDialog, + ), + ), + ), + ); +} + +ChartPainter _painterOf(WidgetTester tester) { + final dynamic state = tester.state(find.byType(KChartWidget)); + // ignore: avoid_dynamic_calls + return state.painter as ChartPainter; +} + +Widget _chartWith(List data) => MaterialApp( + home: Scaffold( + body: SizedBox( + width: 500, + height: 600, + child: KChartWidget( + data, + ChartColors(), + isTrendLine: false, + watermarkAssetPath: 'assets/none.svg', + timeFrame: const Duration(minutes: 15), + showNowPrice: false, + ), + ), + ), +); + +void main() { + group('onLoadMore', () { + testWidgets('is not called just for building the chart', (tester) async { + final calls = []; + await tester.pumpWidget(_chart(onLoadMore: calls.add)); + expect(calls, isEmpty); + }); + + testWidgets('fires false when the oldest candle is reached', ( + tester, + ) async { + final calls = []; + await tester.pumpWidget(_chart(onLoadMore: calls.add)); + + // Dragging to the right walks back through history. + await tester.drag(find.byType(KChartWidget), const Offset(4000, 0)); + await tester.pumpAndSettle(); + + expect(calls, contains(false)); + }); + + testWidgets('fires once per arrival at an edge, not once per frame', ( + tester, + ) async { + final calls = []; + await tester.pumpWidget(_chart(onLoadMore: calls.add)); + + // One long gesture that spends many frames pinned against the edge. + final gesture = await tester.startGesture(const Offset(250, 200)); + for (var i = 0; i < 40; i++) { + await gesture.moveBy(const Offset(200, 0)); + await tester.pump(); + } + await gesture.up(); + await tester.pumpAndSettle(); + + expect(calls.where((right) => !right).length, 1); + }); + + testWidgets('asks again after leaving the edge and coming back', ( + tester, + ) async { + final calls = []; + await tester.pumpWidget(_chart(onLoadMore: calls.add)); + + await tester.drag(find.byType(KChartWidget), const Offset(4000, 0)); + await tester.pumpAndSettle(); + final first = calls.where((right) => !right).length; + + // Come away from the oldest candle, then go back to it. + await tester.drag(find.byType(KChartWidget), const Offset(-300, 0)); + await tester.pumpAndSettle(); + await tester.drag(find.byType(KChartWidget), const Offset(4000, 0)); + await tester.pumpAndSettle(); + + expect(calls.where((right) => !right).length, first + 1); + }); + + testWidgets('fires true at the newest candle', (tester) async { + final calls = []; + await tester.pumpWidget(_chart(onLoadMore: calls.add)); + + // Back through history, then forward past the newest candle again. + await tester.drag(find.byType(KChartWidget), const Offset(4000, 0)); + await tester.pumpAndSettle(); + await tester.drag(find.byType(KChartWidget), const Offset(-4000, 0)); + await tester.pumpAndSettle(); + + expect(calls, contains(true)); + }); + + testWidgets('the window stays put when older candles arrive', ( + tester, + ) async { + // What a caller does in response to onLoadMore(false): prepend the older + // candles and hand back the longer list. The chart is anchored to the + // newest candle, so the window must not jump. + final recent = candles(rampThenFall(120)); + DataUtil.calculate(recent); + await tester.pumpWidget(_chartWith(recent)); + + await tester.drag(find.byType(KChartWidget), const Offset(300, 0)); + await tester.pumpAndSettle(); + final atRightEdge = recent[_painterOf(tester).mStopIndex].dateTime; + + final longer = [ + for (var i = 0; i < 80; i++) candle(90.0 + i, minute: -80 + i), + ...recent, + ]; + DataUtil.calculate(longer); + await tester.pumpWidget(_chartWith(longer)); + await tester.pumpAndSettle(); + + expect( + longer[_painterOf(tester).mStopIndex].dateTime, + atRightEdge, + reason: 'the same candle is still at the right edge', + ); + }); + + testWidgets('a chart with no callback still scrolls', (tester) async { + await tester.pumpWidget(_chart()); + await tester.drag(find.byType(KChartWidget), const Offset(4000, 0)); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }); + }); + + group('showInfoDialog', () { + testWidgets('survives being turned off and on again', (tester) async { + await tester.pumpWidget(_chart(showInfoDialog: true)); + await tester.pumpWidget(_chart(showInfoDialog: false)); + await tester.pumpWidget(_chart(showInfoDialog: true)); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.byType(KChartWidget), findsOneWidget); + }); + + testWidgets('still reads out a candle after being toggled', (tester) async { + await tester.pumpWidget( + _chart(showInfoDialog: true, isTapShowInfoDialog: true), + ); + await tester.pumpWidget( + _chart(showInfoDialog: false, isTapShowInfoDialog: true), + ); + await tester.pumpWidget( + _chart(showInfoDialog: true, isTapShowInfoDialog: true), + ); + await tester.pumpAndSettle(); + + // Held, not tapped: the readout is up only while the press is down. + final centre = tester.getCenter(find.byType(KChartWidget)); + await tester.startGesture(centre); + await tester.pump(kLongPressTimeout + const Duration(milliseconds: 50)); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.byType(PopupInfoView), findsOneWidget); + }); + + testWidgets('toggling repeatedly never throws', (tester) async { + for (var i = 0; i < 5; i++) { + await tester.pumpWidget(_chart(showInfoDialog: i.isEven)); + await tester.pump(); + expect(tester.takeException(), isNull); + } + }); + }); +} diff --git a/test/locked_price_scale_test.dart b/test/locked_price_scale_test.dart new file mode 100644 index 0000000..a905cf3 --- /dev/null +++ b/test/locked_price_scale_test.dart @@ -0,0 +1,210 @@ +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/chart_painter.dart'; + +import 'test_utils.dart'; + +ChartPainter _painterOf(WidgetTester tester) { + final dynamic state = tester.state(find.byType(KChartWidget)); + // ignore: avoid_dynamic_calls + return state.painter as ChartPainter; +} + +/// A steady climb, so every window holds a visibly different price range. +List _trend([int count = 300]) { + final data = candles([for (var i = 0; i < count; i++) 100.0 + i * 2]); + DataUtil.calculate(data); + return data; +} + +Widget _chart({ + required bool lock, + List? data, + KChartController? controller, +}) => MaterialApp( + home: Scaffold( + body: SizedBox( + width: 500, + height: 600, + child: KChartWidget( + data ?? _trend(), + ChartColors(), + isTrendLine: false, + watermarkAssetPath: 'assets/none.svg', + timeFrame: const Duration(minutes: 15), + showNowPrice: false, + lockPriceScale: lock, + controller: controller, + ), + ), + ), +); + +/// The range the price axis is actually drawn at, which is the renderer's -- +/// the painter's own min and max stay the honest fit to the window. +({double min, double max}) _range(WidgetTester tester) { + final r = _painterOf(tester).mMainRenderer; + return (min: r.minValue, max: r.maxValue); +} + +/// The fit to the candles in the window, locked or not. +({double min, double max}) _windowFit(WidgetTester tester) { + final p = _painterOf(tester); + return (min: p.mMainMinValue, max: p.mMainMaxValue); +} + +void main() { + group('an unlocked price axis', () { + testWidgets('refits to the window as the chart scrolls', (tester) async { + await tester.pumpWidget(_chart(lock: false)); + final before = _range(tester); + + await tester.drag(find.byType(KChartWidget), const Offset(600, 0)); + await tester.pumpAndSettle(); + + // The long-standing behaviour, kept: this is what the lock is for. + expect(_range(tester).min, isNot(before.min)); + }); + }); + + group('a locked price axis', () { + testWidgets('holds its range while the chart scrolls', (tester) async { + await tester.pumpWidget(_chart(lock: true)); + // One more frame, so the axis has a fitted range to lock onto. + await tester.pump(); + final locked = _range(tester); + + await tester.drag(find.byType(KChartWidget), const Offset(600, 0)); + await tester.pumpAndSettle(); + + expect(_range(tester).min, locked.min); + expect(_range(tester).max, locked.max); + }); + + testWidgets('holds it across several scrolls in both directions', ( + tester, + ) async { + await tester.pumpWidget(_chart(lock: true)); + await tester.pump(); + final locked = _range(tester); + + for (final dx in const [400.0, -200.0, 900.0, -600.0]) { + await tester.drag(find.byType(KChartWidget), Offset(dx, 0)); + await tester.pumpAndSettle(); + expect(_range(tester).min, locked.min, reason: 'after dx=$dx'); + expect(_range(tester).max, locked.max, reason: 'after dx=$dx'); + } + }); + + testWidgets('locks onto what the user was already looking at', ( + tester, + ) async { + // Fitted first, then locked: the range must not jump on the way. + await tester.pumpWidget(_chart(lock: false)); + final fitted = _range(tester); + + await tester.pumpWidget(_chart(lock: true)); + await tester.pump(); + + expect(_range(tester).min, fitted.min); + expect(_range(tester).max, fitted.max); + }); + + testWidgets('holds still while candles are paged in behind it', ( + tester, + ) async { + final recent = _trend(); + await tester.pumpWidget(_chart(lock: true, data: recent)); + await tester.pump(); + final locked = _range(tester); + + // What onLoadMore(false) leads to: older candles, at quite other prices. + final longer = [ + for (var i = 0; i < 80; i++) candle(-500.0 + i, minute: -80 + i), + ...recent, + ]; + DataUtil.calculate(longer); + await tester.pumpWidget(_chart(lock: true, data: longer)); + await tester.pumpAndSettle(); + + expect(_range(tester).min, locked.min); + expect(_range(tester).max, locked.max); + }); + + testWidgets('still marks the window own high and low', (tester) async { + await tester.pumpWidget(_chart(lock: true)); + await tester.pump(); + await tester.drag(find.byType(KChartWidget), const Offset(600, 0)); + await tester.pumpAndSettle(); + + final painter = _painterOf(tester); + final data = _trend(); + // The markers read the candles in view, not the locked scale, so they + // still point at the candles that actually set them. + expect(painter.mMainHighMaxValue, data[painter.mMainMaxIndex].high); + expect(painter.mMainLowMinValue, data[painter.mMainMinIndex].low); + + // And the window fit has moved on even though the drawn axis has not: + // the two are genuinely separate. + expect(_windowFit(tester).min, isNot(_range(tester).min)); + }); + + testWidgets('is handed back to the chart by resetPriceScale', ( + tester, + ) async { + final controller = KChartController(); + await tester.pumpWidget(_chart(lock: true, controller: controller)); + await tester.pump(); + final locked = _range(tester); + + await tester.drag(find.byType(KChartWidget), const Offset(600, 0)); + await tester.pumpAndSettle(); + expect(_range(tester).min, locked.min, reason: 'held while scrolled'); + + controller.resetPriceScale(); + await tester.pumpAndSettle(); + + // Refitted to the window it is now over, then locked there afresh. + expect(_range(tester).min, isNot(locked.min)); + final refitted = _range(tester); + await tester.drag(find.byType(KChartWidget), const Offset(-300, 0)); + await tester.pumpAndSettle(); + expect(_range(tester).min, refitted.min, reason: 'locked again'); + }); + + testWidgets('goes back to fitting when the lock is taken off', ( + tester, + ) async { + await tester.pumpWidget(_chart(lock: true)); + await tester.pump(); + final locked = _range(tester); + + await tester.pumpWidget(_chart(lock: false)); + await tester.drag(find.byType(KChartWidget), const Offset(600, 0)); + await tester.pumpAndSettle(); + + expect(_range(tester).min, isNot(locked.min)); + }); + + testWidgets('can still be zoomed, from the range it is held at', ( + tester, + ) async { + final controller = KChartController(); + await tester.pumpWidget(_chart(lock: true, controller: controller)); + await tester.pump(); + final locked = _range(tester); + final span = locked.max - locked.min; + + controller.setPriceZoom(2.0); + await tester.pumpAndSettle(); + + final zoomed = _range(tester); + expect( + zoomed.max - zoomed.min, + closeTo(span / 2, span * 0.02), + reason: 'zoom works off the locked range, not the window', + ); + }); + }); +} diff --git a/test/paint_benchmark.dart b/test/paint_benchmark.dart index c8bed8a..8343532 100644 --- a/test/paint_benchmark.dart +++ b/test/paint_benchmark.dart @@ -100,9 +100,7 @@ void main() { void report(String label, double micros, int candles) { // ignore: avoid_print - print( - 'BENCH|$label|${micros.toStringAsFixed(0)}us|candles=$candles', - ); + print('BENCH|$label|${micros.toStringAsFixed(0)}us|candles=$candles'); } /// Paints [body] into a throwaway recorder, the way a frame would. diff --git a/test/price_axis_gutter_test.dart b/test/price_axis_gutter_test.dart new file mode 100644 index 0000000..3414fe6 --- /dev/null +++ b/test/price_axis_gutter_test.dart @@ -0,0 +1,367 @@ +import 'dart:ui' as 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/chart_painter.dart'; + +import 'counting_canvas.dart'; +import 'test_utils.dart'; + +const double _width = 500; +const double _gutter = 60; + +ChartPainter _painterOf(WidgetTester tester) { + final dynamic state = tester.state(find.byType(KChartWidget)); + // ignore: avoid_dynamic_calls + return state.painter as ChartPainter; +} + +Widget _chart({ + double priceAxisWidth = 0.0, + VerticalTextAlignment alignment = VerticalTextAlignment.right, + int count = 120, +}) { + final data = candles(rampThenFall(count)); + DataUtil.calculate(data); + + return MaterialApp( + home: Scaffold( + body: SizedBox( + width: _width, + height: 600, + child: KChartWidget( + data, + ChartColors(), + isTrendLine: false, + watermarkAssetPath: 'assets/none.svg', + timeFrame: const Duration(minutes: 15), + showNowPrice: true, + verticalTextAlignment: alignment, + chartStyle: ChartStyle(priceAxisWidth: priceAxisWidth), + ), + ), + ), + ); +} + +/// Paints [painter] once onto a real canvas, tallying the draw calls. +/// +/// Forwarding to a recorder rather than swallowing the calls keeps the painter +/// honest: anything that would throw on a real canvas throws here too. +CountingCanvas _record(ChartPainter painter) { + final canvas = CountingCanvas(Canvas(ui.PictureRecorder())); + painter.paint(canvas, const Size(_width, 600)); + return canvas; +} + +/// A canvas that records the clips pushed during a paint, and any line drawn +/// past the plot while the clip in force would have allowed it. +/// +/// Fills are left alone: the background and the axis label pills are *meant* +/// to reach into the gutter. +class _GutterProbe implements Canvas { + _GutterProbe(this.plotRight); + + final double plotRight; + + /// Every clip the painter pushed, in order. + final List clips = []; + + /// Lines drawn past the plot that the clip did not hold back. + final List spill = []; + + Rect _clip = const Rect.fromLTRB(0, 0, 1e9, 1e9); + final List _stack = []; + + /// Whether the plot was clipped to at any point. + bool get clippedToPlot => clips.any((r) => (r.right - plotRight).abs() < 0.5); + + void _check(String what, double x) { + if (x > plotRight + 0.5 && _clip.right > plotRight + 0.5) { + spill.add('$what at $x'); + } + } + + @override + void save() => _stack.add(_clip); + + @override + void restore() { + if (_stack.isNotEmpty) _clip = _stack.removeLast(); + } + + @override + void clipRect( + Rect rect, { + ui.ClipOp clipOp = ui.ClipOp.intersect, + bool doAntiAlias = true, + }) { + clips.add(rect); + _clip = _clip.intersect(rect); + } + + /// Where each run of text was painted. + final List textAt = []; + + /// Every translate the painter applied. + final List translates = []; + + @override + void drawLine(Offset p1, Offset p2, Paint paint) { + _check('line', p1.dx > p2.dx ? p1.dx : p2.dx); + } + + @override + void drawParagraph(ui.Paragraph paragraph, Offset offset) { + textAt.add(offset); + } + + @override + void translate(double dx, double dy) => translates.add(dx); + + @override + void noSuchMethod(Invocation invocation) {} +} + +void main() { + group('the price axis gutter', () { + testWidgets('is not held back by default', (tester) async { + await tester.pumpWidget(_chart()); + final painter = _painterOf(tester); + + expect(painter.priceAxisGutter, 0); + expect(painter.mWidth, _width); + expect(painter.mPlotLeft, 0); + expect(painter.mPlotRight, _width); + expect(painter.mMainRect.right, _width); + }); + + testWidgets('takes width off the plot on the label side', (tester) async { + await tester.pumpWidget(_chart(priceAxisWidth: _gutter)); + final painter = _painterOf(tester); + + expect(painter.priceAxisGutter, _gutter); + expect(painter.mCanvasWidth, _width); + expect(painter.mWidth, _width - _gutter); + expect(painter.mPlotLeft, 0, reason: 'labels on the right'); + expect(painter.mPlotRight, _width - _gutter); + }); + + testWidgets('holds it back on the left when the labels are', ( + tester, + ) async { + await tester.pumpWidget( + _chart(priceAxisWidth: _gutter, alignment: VerticalTextAlignment.left), + ); + final painter = _painterOf(tester); + + expect(painter.mPlotLeft, _gutter); + expect(painter.mPlotRight, _width); + expect(painter.mMainRect.left, _gutter); + }); + + testWidgets('every pane stops at the gutter, not the canvas', ( + tester, + ) async { + await tester.pumpWidget(_chart(priceAxisWidth: _gutter)); + final painter = _painterOf(tester); + + expect(painter.mMainRect.right, _width - _gutter); + expect(painter.mVolRect?.right, _width - _gutter); + for (final pane in painter.mSecondaryRectList) { + expect(pane.mRect.right, _width - _gutter); + } + }); + + testWidgets('the labels are drawn in it, clear of the plot', ( + tester, + ) async { + await tester.pumpWidget(_chart(priceAxisWidth: _gutter)); + final painter = _painterOf(tester); + + final probe = _GutterProbe(_width - _gutter); + painter.paint(probe, const Size(_width, 600)); + + expect( + probe.textAt.where((o) => o.dx >= _width - _gutter), + isNotEmpty, + reason: 'price labels sit inside the gutter', + ); + }); + + testWidgets('without it the labels stay over the plot', (tester) async { + await tester.pumpWidget(_chart()); + final painter = _painterOf(tester); + + final probe = _GutterProbe(_width); + painter.paint(probe, const Size(_width, 600)); + + expect(probe.textAt, isNotEmpty); + expect( + probe.textAt.every((o) => o.dx < _width), + isTrue, + reason: 'nothing is pushed off the canvas', + ); + }); + + testWidgets('is never wide enough to leave no plot', (tester) async { + await tester.pumpWidget(_chart(priceAxisWidth: 10000)); + final painter = _painterOf(tester); + + expect(painter.mWidth, greaterThan(0)); + expect(painter.priceAxisGutter, _width / 2); + }); + }); + + group('the axis stays put while the chart scrolls', () { + testWidgets('the labels do not move when the candles do', (tester) async { + await tester.pumpWidget(_chart(priceAxisWidth: _gutter)); + final painter = _painterOf(tester); + + final before = painter.mPlotRight; + await tester.drag(find.byType(KChartWidget), const Offset(200, 0)); + await tester.pumpAndSettle(); + + expect(_painterOf(tester).mPlotRight, before); + }); + + testWidgets('the newest candle rests against the gutter, not the edge', ( + tester, + ) async { + await tester.pumpWidget(_chart(priceAxisWidth: _gutter)); + final painter = _painterOf(tester); + + // Held at the right edge, the last candle sits at the end of the plot -- + // which is now the gutter's inner edge rather than the canvas edge. + final newest = painter.translateXtoX( + painter.getX(painter.mItemCount - 1), + ); + expect(newest, lessThanOrEqualTo(_width - _gutter)); + + // And it is the gutter that moved it, not a coincidence: with no gutter + // the same candle rests a gutter's width further right. + await tester.pumpWidget(_chart()); + final wide = _painterOf(tester); + final newestWide = wide.translateXtoX(wide.getX(wide.mItemCount - 1)); + expect(newestWide - newest, closeTo(_gutter, 1.0)); + }); + + testWidgets('the plot is clipped so nothing runs under the axis', ( + tester, + ) async { + await tester.pumpWidget(_chart(priceAxisWidth: _gutter)); + + // Scrolled into history, so candles fill the window and would spill over + // the axis were the plot not clipped. + await tester.drag(find.byType(KChartWidget), const Offset(300, 0)); + await tester.pumpAndSettle(); + + final probe = _GutterProbe(_width - _gutter); + _painterOf(tester).paint(probe, const Size(_width, 600)); + + expect(probe.clippedToPlot, isTrue, reason: 'the plot clip is pushed'); + expect(probe.spill, isEmpty, reason: 'and nothing escapes it'); + }); + + testWidgets('without a gutter the plot is the whole canvas', ( + tester, + ) async { + await tester.pumpWidget(_chart()); + await tester.drag(find.byType(KChartWidget), const Offset(300, 0)); + await tester.pumpAndSettle(); + + final probe = _GutterProbe(_width); + _painterOf(tester).paint(probe, const Size(_width, 600)); + + expect(probe.spill, isEmpty); + }); + + testWidgets('painting with a gutter draws without throwing', ( + tester, + ) async { + await tester.pumpWidget(_chart(priceAxisWidth: _gutter)); + final painter = _painterOf(tester); + + expect(_record(painter).totalDraws, greaterThan(0)); + }); + }); + + group('coordinates round-trip through the gutter', () { + testWidgets('a plot x maps back to itself', (tester) async { + await tester.pumpWidget( + _chart(priceAxisWidth: _gutter, alignment: VerticalTextAlignment.left), + ); + final painter = _painterOf(tester); + + for (final x in [_gutter, 200, 480]) { + expect( + painter.translateXtoX(painter.xToTranslateX(x)), + closeTo(x, 0.001), + reason: 'x=$x survives the round trip', + ); + } + }); + + testWidgets('the visible range is measured across the plot', ( + tester, + ) async { + await tester.pumpWidget(_chart(priceAxisWidth: _gutter, count: 400)); + final withGutter = _painterOf(tester); + final narrowed = withGutter.mStopIndex - withGutter.mStartIndex; + + await tester.pumpWidget(_chart(count: 400)); + final full = _painterOf(tester); + final wide = full.mStopIndex - full.mStartIndex; + + expect( + narrowed, + lessThan(wide), + reason: 'a narrower plot shows fewer candles', + ); + }); + + testWidgets('candle space starts at the plot, not the canvas', ( + tester, + ) async { + // The guard on the transform: candles are drawn through a translate, and + // the crosshair and every drawing are placed through translateXtoX. If + // the two disagree about where the plot begins, the crosshair drifts off + // the candle it is reading. + await tester.pumpWidget( + _chart(priceAxisWidth: _gutter, alignment: VerticalTextAlignment.left), + ); + final painter = _painterOf(tester); + + final probe = _GutterProbe(_width); + painter.paint(probe, const Size(_width, 600)); + + final expected = painter.mPlotLeft + painter.mTranslateX * painter.scaleX; + expect( + probe.translates.any((dx) => (dx - expected).abs() < 0.001), + isTrue, + reason: 'candle space is offset by the plot origin', + ); + + // And the two agree: candle 0 lands where the transform says. + expect( + painter.translateXtoX(painter.getX(0)), + closeTo(expected + painter.getX(0) * painter.scaleX, 0.001), + ); + }); + + testWidgets('a long press reads the candle under the pointer', ( + tester, + ) async { + await tester.pumpWidget(_chart(priceAxisWidth: _gutter)); + final painter = _painterOf(tester); + + // The candle the transform puts at this x is the one picked out. + const at = 300.0; + final expected = painter.calculateSelectedX(at); + final x = painter.translateXtoX(painter.getX(expected)); + + expect(x, closeTo(at, painter.mPointWidth)); + }); + }); +} diff --git a/test/render_perf_test.dart b/test/render_perf_test.dart index 6219b10..68a9df7 100644 --- a/test/render_perf_test.dart +++ b/test/render_perf_test.dart @@ -202,7 +202,8 @@ void main() { expect( second - first, lessThan(first ~/ 4), - reason: 'repainting the same chart re-laid-out ${second - first} labels', + reason: + 'repainting the same chart re-laid-out ${second - first} labels', ); }); }); diff --git a/test/static_chart_test.dart b/test/static_chart_test.dart new file mode 100644 index 0000000..a1dd0ed --- /dev/null +++ b/test/static_chart_test.dart @@ -0,0 +1,256 @@ +import 'package:flutter/foundation.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/renderer/base_chart_painter.dart'; +import 'package:ohlcv_chart/src/renderer/chart_painter.dart'; + +import 'test_utils.dart'; + +ChartPainter _painterOf(WidgetTester tester) { + final dynamic state = tester.state(find.byType(KChartWidget)); + // ignore: avoid_dynamic_calls + return state.painter as ChartPainter; +} + +double _scrollOf(WidgetTester tester) { + final dynamic state = tester.state(find.byType(KChartWidget)); + // ignore: avoid_dynamic_calls + return state.mScrollX as double; +} + +/// A session's worth of candles: more than fits the box at the default spacing, +/// so there is something to scroll unless it is turned off. +Widget _chart({ + bool scrollEnabled = true, + bool zoomEnabled = true, + double pointWidth = 8, + ValueChanged? onLoadMore, + KChartController? controller, +}) { + final data = candles(rampThenFall(78)); + DataUtil.calculate(data); + + return MaterialApp( + home: Scaffold( + body: SizedBox( + width: 400, + height: 220, + child: KChartWidget( + data, + ChartColors(), + isTrendLine: false, + watermarkAssetPath: 'assets/none.svg', + timeFrame: const Duration(minutes: 5), + chartType: ChartType.area, + xFrontPadding: 0, + volHidden: true, + showNowPrice: false, + scrollEnabled: scrollEnabled, + zoomEnabled: zoomEnabled, + onLoadMore: onLoadMore, + controller: controller, + chartStyle: ChartStyle(pointWidth: pointWidth), + ), + ), + ), + ); +} + +/// How many zoom sliders are in the tree. +int _sliders() => find + .byWidgetPredicate( + (w) => w is MouseRegion && w.cursor == SystemMouseCursors.resizeLeftRight, + ) + .evaluate() + .length; + +Future _pinchOut(WidgetTester tester) async { + final centre = tester.getCenter(find.byType(KChartWidget)); + final left = await tester.startGesture(centre - const Offset(40, 0)); + final right = await tester.startGesture(centre + const Offset(40, 0)); + await left.moveBy(const Offset(-90, 0)); + await right.moveBy(const Offset(90, 0)); + await tester.pump(); + await left.up(); + await right.up(); + await tester.pumpAndSettle(); +} + +void main() { + group('scrollEnabled', () { + testWidgets('scrolls by default', (tester) async { + await tester.pumpWidget(_chart()); + await tester.drag(find.byType(KChartWidget), const Offset(200, 0)); + await tester.pumpAndSettle(); + + expect(_scrollOf(tester), greaterThan(0)); + }); + + testWidgets('off, a drag leaves the window where it was', (tester) async { + await tester.pumpWidget(_chart(scrollEnabled: false)); + final before = _painterOf(tester); + final range = (before.mStartIndex, before.mStopIndex); + + await tester.drag(find.byType(KChartWidget), const Offset(400, 0)); + await tester.pumpAndSettle(); + + expect(_scrollOf(tester), 0); + final after = _painterOf(tester); + expect((after.mStartIndex, after.mStopIndex), range); + }); + + testWidgets('off, a flick does not fling it either', (tester) async { + await tester.pumpWidget(_chart(scrollEnabled: false)); + + await tester.fling(find.byType(KChartWidget), const Offset(300, 0), 3000); + await tester.pumpAndSettle(); + + expect(_scrollOf(tester), 0); + }); + + testWidgets('off, onLoadMore is never asked', (tester) async { + final calls = []; + await tester.pumpWidget( + _chart(scrollEnabled: false, onLoadMore: calls.add), + ); + + await tester.drag(find.byType(KChartWidget), const Offset(4000, 0)); + await tester.pumpAndSettle(); + await tester.drag(find.byType(KChartWidget), const Offset(-4000, 0)); + await tester.pumpAndSettle(); + + expect(calls, isEmpty); + }); + + testWidgets('off, the controller can still scroll it', (tester) async { + final controller = KChartController(); + await tester.pumpWidget( + _chart(scrollEnabled: false, controller: controller), + ); + + controller.goToIndex(0, animated: false); + await tester.pumpAndSettle(); + + expect( + _scrollOf(tester), + greaterThan(0), + reason: 'the flag holds the user back, not your own code', + ); + }); + }); + + group('zoomEnabled', () { + testWidgets('pinches by default', (tester) async { + await tester.pumpWidget(_chart()); + final before = _painterOf(tester).scaleX; + + await _pinchOut(tester); + + expect(_painterOf(tester).scaleX, isNot(before)); + }); + + testWidgets('off, a pinch leaves the scale alone', (tester) async { + await tester.pumpWidget(_chart(zoomEnabled: false)); + final before = _painterOf(tester).scaleX; + + await _pinchOut(tester); + + expect(_painterOf(tester).scaleX, before); + }); + + testWidgets('off, the zoom slider is left off on desktop and the web', ( + tester, + ) async { + // The slider stands in for the pinch on platforms that have no pinch, so + // it is only ever built on desktop and the web -- and a test runs as + // Android unless it is told otherwise. + debugDefaultTargetPlatformOverride = TargetPlatform.macOS; + try { + // The slider is the only thing that listens for a resize cursor, so it + // is what a desktop pointer would otherwise find at the bottom edge. + await tester.pumpWidget(_chart()); + final withSlider = _sliders(); + + await tester.pumpWidget(_chart(zoomEnabled: false)); + final without = _sliders(); + + expect(withSlider, greaterThan(0), reason: 'shown by default here'); + expect(without, 0); + } finally { + // Reset inside the body: a tear-down runs after the framework has + // already checked that no debug variable was left set. + debugDefaultTargetPlatformOverride = null; + } + }); + + testWidgets('off, the controller can still zoom it', (tester) async { + final controller = KChartController(); + await tester.pumpWidget( + _chart(zoomEnabled: false, controller: controller), + ); + final before = _painterOf(tester).scaleX; + + controller.zoomIn(); + await tester.pumpAndSettle(); + + expect(_painterOf(tester).scaleX, isNot(before)); + }); + }); + + group('a chart meant to sit still', () { + testWidgets('with the candles fitting, nothing can move it', ( + tester, + ) async { + // 78 candles at 5px in a 400px box: the whole session fits, so there is + // nothing to scroll into even before the flag. + await tester.pumpWidget( + _chart(scrollEnabled: false, zoomEnabled: false, pointWidth: 5), + ); + final painter = _painterOf(tester); + + expect(BaseChartPainter.maxScrollX, 0); + expect(painter.mStartIndex, 0); + expect(painter.mStopIndex, 77, reason: 'the whole session is drawn'); + + await tester.drag(find.byType(KChartWidget), const Offset(400, 0)); + await tester.pumpAndSettle(); + await _pinchOut(tester); + + expect(_scrollOf(tester), 0); + expect(_painterOf(tester).mStartIndex, 0); + expect(_painterOf(tester).mStopIndex, 77); + }); + + testWidgets('a pinch cannot hand back the scrolling it took away', ( + tester, + ) async { + // The trap: zooming out narrows the candles, which leaves room to scroll + // into. With zoom off there is no way back in. + await tester.pumpWidget( + _chart(scrollEnabled: false, zoomEnabled: false, pointWidth: 5), + ); + expect(BaseChartPainter.maxScrollX, 0); + + await _pinchOut(tester); + + expect(BaseChartPainter.maxScrollX, 0); + expect(_scrollOf(tester), 0); + }); + + testWidgets('and with zoom left on, it can -- which is why it pairs', ( + tester, + ) async { + await tester.pumpWidget(_chart(pointWidth: 5)); + expect(BaseChartPainter.maxScrollX, 0); + + await _pinchOut(tester); + + expect( + BaseChartPainter.maxScrollX, + greaterThan(0), + reason: 'zooming out opens up somewhere to scroll', + ); + }); + }); +}