diff --git a/CHANGELOG.md b/CHANGELOG.md index ea509f0..dfe34a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,68 @@ +## 2.4.1 + +Five changes, one for each point raised in +[#3](https://github.com/CtrlAltDevelop/ohlcv_chart/issues/3) about the intraday +chart. + +### Fixed + +- **An axis gutter is painted in the chart's own background.** It was filled + from the canvas edge for the plot's width, which left a gutter on the right + showing whatever was under the widget, and shifted the fill when a gutter was + held back on the left. Every band now spans the whole canvas, so a label + drawn in a gutter has the chart behind it. + +- **A price the axis does not reach no longer escapes the candle area.** A + locked axis makes that ordinary — the range is held where it was, so a tick + beyond it had nowhere of its own to go and was drawn over the volume and + indicator panes, or off the canvas where it could not be seen at all. A + horizontal line at such a price is now left out of the plot and its label is + pinned to the edge the price went past, marked with an arrow so the level can + still be found. The current-price line, the signal lines and the trading tags + are held to the same edge, and the trading lines already were. + +### Price axis + +- **New `secondaryPriceAxisScale` draws a second axis down the other side.** + The chart had one price axis, so reading a move as a percentage meant giving + up the prices. A second axis reads the same candles another way — + `PriceAxisScale.percentage` for the change since the oldest candle in view — + in a gutter on the side `verticalTextAlignment` left free, sized by + `ChartStyle.secondaryPriceAxisWidth`. It marks its own round values rather + than labelling the price axis's, the grid stays ruled by the price axis, and + the crosshair and the price tags keep following `priceAxisScale`. The two + gutters share half the chart's width between them, so a second axis cannot + crowd the candles out. + +- **New `priceFormatter` writes the prices the chart shows.** `fixedLength` + only said how many decimals to use, so a currency symbol, a thousands + separator or a tick size had nowhere to go. It takes the writing over the way + `dateFormatter` does on the date axis, and covers every price the chart says: + the axis labels, the crosshair's price label, the current-price tag, the + high, low and signal tags, and the OHLC legend. An axis that reads out a move + rather than a price — `percentage`, `indexedTo100` — writes that move itself + and does not ask. + +- **New `lockedScaleFollowsPrice` keeps the newest candle on a locked axis.** + A locked axis holds the range it was given, so a market that trades past that + range walked off the top or the bottom of the chart until `resetPriceScale` + was called. With this set the locked range grows just enough to cover the + newest candle, and never shrinks back or refits to the window — so the axis + still sits still while scrolling, which is what the lock is for. Only the + newest candle counts, and only while it is in view: growing the axis to + swallow the history a scroll moves over would undo the lock a little at a + time. Off by default, and does nothing without `lockPriceScale`. + +### Layout + +- **New `ChartStyle.fitContent` spreads a short series across the whole plot.** + `pointWidth` is a fixed distance, so a handful of intraday bars bunched up + against the left edge and left the rest of the chart empty. With this set the + spacing is widened to whatever fills the plot, and the candle and volume bars + widen with it so they keep their proportions. It only ever widens: a series + long enough to fill the plot, or one zoomed in past it, is laid out on + `pointWidth` as before, so the flag can stay on while history pages in. + ## 2.4.0 ### A chart that sits still diff --git a/README.md b/README.md index a472ccf..75b20af 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ Named for the open-high-low-close-volume bars it renders. ```yaml dependencies: - ohlcv_chart: ^2.4.0 + ohlcv_chart: ^2.4.1 ``` ## Quick start diff --git a/doc/README.md b/doc/README.md index 8f2d36d..b6fc5ad 100644 --- a/doc/README.md +++ b/doc/README.md @@ -26,8 +26,9 @@ For installation, the feature list and support, see the - **[Price axis](price-axis.md)** — linear, logarithmic, percentage or indexed 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. + dragging the scale by hand, locking it so scrolling cannot rescale it, + writing the prices yourself with `priceFormatter`, 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 diff --git a/doc/driving-the-chart.md b/doc/driving-the-chart.md index 7ed3e2e..6bea743 100644 --- a/doc/driving-the-chart.md +++ b/doc/driving-the-chart.md @@ -129,9 +129,20 @@ chart.goToIndex(0); // or somewhere particular `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. +shows one fixed stretch, make the candles fit instead. `ChartStyle.fitContent` +does it without knowing the width: a series too short to fill the plot is +spread over the whole of it, and the candle bodies widen to match. + +```dart +chartStyle: ChartStyle(fitContent: true), +``` + +This only ever widens the spacing. A series long enough to fill the plot on +`ChartStyle.pointWidth` — 8 by default — is laid out on that as before, so the +flag can stay on while history pages in. + +Doing the arithmetic yourself works too, and is what to reach for when the +spacing matters more than filling the box: ```dart chartStyle: ChartStyle(pointWidth: width / candles.length), diff --git a/doc/price-axis.md b/doc/price-axis.md index e2cb384..5ee3150 100644 --- a/doc/price-axis.md +++ b/doc/price-axis.md @@ -56,6 +56,60 @@ The same arithmetic is exported, for a caller drawing an axis of its own beside the chart: `niceStep`, `niceTicks` and `niceLogTicks` for values, `niceTimeStep`, `timeBucket` and `startsNewDay` for times. +## A second axis down the other side + +`secondaryPriceAxisScale` puts another axis on the side the price axis left +free — the change since the oldest candle in view, next to the prices +themselves: + +```dart +KChartWidget( + candles, + ChartColors(), + secondaryPriceAxisScale: PriceAxisScale.percentage, + chartStyle: const ChartStyle( + priceAxisWidth: 56, + secondaryPriceAxisWidth: 56, + ), + // ... +) +``` + +It marks its own round values, so a percentage axis reads +2%, +4%, +6% rather +than whatever percentages the round prices happen to work out at. The grid +stays ruled by the price axis: a second set of lines over one set of candles +would say nothing the second set of labels does not. + +The two gutters share half the chart's width between them, so a second axis can +never crowd the candles out, and `secondaryPriceAxisWidth: 0` draws its labels +over the candles the way the price axis is drawn without a gutter. + +The crosshair, the current-price tag and the other readouts keep following +`priceAxisScale`. The second axis is an axis, not a second voice for everything +the chart says. + +## Writing the prices yourself + +`fixedLength` is how many decimals a price is written to. `priceFormatter` +takes the writing over, the way `dateFormatter` does on the date axis: + +```dart +KChartWidget( + candles, + ChartColors(), + priceFormatter: (price) => NumberFormat.currency(symbol: r'$').format(price), + // ... +) +``` + +It writes every price the chart says: the axis labels, the crosshair's price +label, the current-price tag, the high, low and signal tags, and the OHLC +legend. An axis that reads out a move rather than a price — `percentage`, +`indexedTo100` — writes that move itself and does not ask. + +Drawings keep their own labels, which are yours to set through each one's +`title`. + ## Reading it the other way, and other extras ```dart @@ -123,6 +177,30 @@ 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. +### When the market trades past the locked range + +A held range is a range the market can leave. `lockedScaleFollowsPrice` grows +it just enough to keep the newest candle on the chart: + +```dart +KChartWidget( + data, + ChartColors(), + lockPriceScale: true, + lockedScaleFollowsPrice: true, + // ... +) +``` + +It only ever grows, and never refits to the window, so the axis still sits +still while the chart is scrolled. Only the newest candle counts, and only +while it is in view — growing the axis to swallow the history a scroll moves +over would undo the lock a little at a time. + +Left off, a price outside the range is not lost either: a level the axis cannot +reach has its label pinned to the edge it went past, marked with an arrow, +rather than being drawn outside the candle area where it cannot be seen. + ## Holding a gutter back for it By default the price labels are drawn over the candles, and the candles scroll diff --git a/example/lib/intraday_demo.dart b/example/lib/intraday_demo.dart index 5a25fdb..e52d2c0 100644 --- a/example/lib/intraday_demo.dart +++ b/example/lib/intraday_demo.dart @@ -57,7 +57,7 @@ class _IntradayDemoState extends State { title: const Text('Fit the whole session to the width'), subtitle: Text( _fitWidth - ? 'pointWidth = width / 78, so all 78 candles show' + ? 'ChartStyle.fitContent: all 78 candles, evenly spread' : 'Default spacing: only part of the session fits', ), value: _fitWidth, @@ -67,44 +67,38 @@ class _IntradayDemoState extends State { 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; + child: KChartWidget( + _session, + ChartColors(), + isTrendLine: false, + watermarkAssetPath: 'assets/none.svg', + timeFrame: const Duration(minutes: 5), + chartType: ChartType.area, - 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, - // What makes it sit still. - scrollEnabled: !_static, - zoomEnabled: !_static, + // The whole point: each candle takes an equal share of the + // width, so the session fills the box exactly. The chart + // works the spacing out from its own width, so nothing here + // has to know how wide it ended up. + chartStyle: ChartStyle(fitContent: _fitWidth), + xFrontPadding: 0, - 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', - ), - ); - }, + // 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 cdf4e22..340e40a 100644 --- a/example/lib/src/chart_page.dart +++ b/example/lib/src/chart_page.dart @@ -91,6 +91,7 @@ class _Chart extends StatelessWidget { timeFrame: MarketData.timeFrame, chartStyle: state.style, lockPriceScale: state.lockPriceScale, + lockedScaleFollowsPrice: state.lockedScaleFollowsPrice, scrollEnabled: state.scrollEnabled, zoomEnabled: state.zoomEnabled, drawingStyle: state.drawingStyle, @@ -113,6 +114,7 @@ class _Chart extends StatelessWidget { drawingController: state.drawings, chartType: state.chartType, priceAxisScale: state.priceAxisScale, + secondaryPriceAxisScale: state.secondaryPriceAxisScale, session: state.tradingSession, candleColor: state.candleColor, invertPriceAxis: state.invertPriceAxis, @@ -139,6 +141,7 @@ class _Chart extends StatelessWidget { fixedLength: state.fixedLength, timeFormat: TimeFormat.YEAR_MONTH_DAY_WITH_HOUR, dateFormatter: state.customDateFormat ? state.formatDate : null, + priceFormatter: state.priceFormatter, baselinePrice: state.baselinePrice, xFrontPadding: state.frontPadding, showScrollToNowButton: state.scrollToNowButton, diff --git a/example/lib/src/controls.dart b/example/lib/src/controls.dart index 8fecfcb..2108bd8 100644 --- a/example/lib/src/controls.dart +++ b/example/lib/src/controls.dart @@ -313,6 +313,16 @@ class Controls extends StatelessWidget { }, onChanged: (v) => state.update(() => state.priceAxisScale = v), ), + _Toggle( + label: 'Change % down the other side', + subtitle: 'A second axis reading the move from the oldest candle', + value: state.secondaryPriceAxisScale != null, + onChanged: (v) => state.update( + () => state.secondaryPriceAxisScale = v + ? PriceAxisScale.percentage + : null, + ), + ), _Toggle( label: 'Invert the price axis', subtitle: 'Higher prices lower down', @@ -429,6 +439,31 @@ class Controls extends StatelessWidget { value: state.lockPriceScale, onChanged: (v) => state.update(() => state.lockPriceScale = v), ), + _Toggle( + label: 'Locked axis follows the price', + subtitle: 'Grows the locked range to keep the newest candle on', + value: state.lockedScaleFollowsPrice, + onChanged: (v) => + state.update(() => state.lockedScaleFollowsPrice = v), + ), + _Toggle( + label: 'Level off the axis', + subtitle: 'A line above every price — its label marks the edge', + value: state.levelOffTheAxis, + onChanged: (v) => state.update(() => state.toggleFarLevel(v)), + ), + _Toggle( + label: 'Fit the candles to the width', + subtitle: 'Spreads a short series over the whole plot', + value: state.fitContent, + onChanged: (v) => state.update(() => state.fitContent = v), + ), + _Toggle( + label: 'Prices as currency', + subtitle: r'priceFormatter writes them as $1234.50', + value: state.currencyPrices, + onChanged: (v) => state.update(() => state.currencyPrices = v), + ), _Toggle( label: 'Now price and countdown', value: state.showNowPrice, diff --git a/example/lib/src/demo_state.dart b/example/lib/src/demo_state.dart index 9df9de5..60a3deb 100644 --- a/example/lib/src/demo_state.dart +++ b/example/lib/src/demo_state.dart @@ -132,6 +132,9 @@ class DemoState extends ChangeNotifier { /// How the price axis is spaced and read out. PriceAxisScale priceAxisScale = PriceAxisScale.linear; + /// A second axis down the other side, or null for the one axis. + PriceAxisScale? secondaryPriceAxisScale; + /// How the candles are rewritten before they are drawn. Aggregation aggregation = Aggregation.none; @@ -262,6 +265,20 @@ class DemoState extends ChangeNotifier { /// Holds the price axis at one range, so scrolling does not rescale it. bool lockPriceScale = false; + /// Grows a locked range rather than letting the newest candle walk off it. + bool lockedScaleFollowsPrice = false; + + /// Spreads a short series over the whole plot rather than bunching it up on + /// the left at the fixed spacing. + bool fitContent = false; + + /// Writes the prices as currency rather than as plain decimals. + bool currencyPrices = false; + + /// Marks a level above everything on the chart, to show what an axis that + /// cannot reach a price does with it. + bool levelOffTheAxis = false; + /// Lets the user scroll the chart sideways. bool scrollEnabled = true; @@ -305,9 +322,45 @@ class DemoState extends ChangeNotifier { return base.copyWith( showSessionDividers: sessionDividers, priceAxisWidth: fixedPriceAxis ? 56.0 : 0.0, + fitContent: fitContent, + ); + } + + /// The far level [levelOffTheAxis] puts on the chart, so it can be taken + /// off again. + HorizontalLine? _farLevel; + + /// Puts a level well above every price on the chart, or takes it off. + /// + /// Nothing on the axis reaches it, so the line itself is not drawn and its + /// label is pinned to the top edge with an arrow — which is what a price the + /// axis cannot reach is supposed to look like. + void toggleFarLevel(bool on) { + levelOffTheAxis = on; + final existing = _farLevel; + if (!on) { + if (existing != null) drawings.remove(existing); + _farLevel = null; + return; + } + + final highest = candles.fold( + 0, + (top, c) => c.high > top ? c.high : top, + ); + _farLevel = HorizontalLine( + price: highest * 1.5, + title: 'Off the axis', + showLabel: true, ); + drawings.save(_farLevel!); } + /// Writes prices as currency, or null to leave them as plain decimals. + String Function(double)? get priceFormatter => currencyPrices + ? (price) => '\$${price.toStringAsFixed(fixedLength)}' + : null; + /// The line editor's configuration. DrawingStyle get drawingStyle => brandedToolbar ? ChartTheme.brandedDrawing : ChartTheme.defaultDrawing; diff --git a/lib/src/chart_style.dart b/lib/src/chart_style.dart index 5ecc083..5dc6697 100644 --- a/lib/src/chart_style.dart +++ b/lib/src/chart_style.dart @@ -445,6 +445,7 @@ class ChartStyle { this.childPadding = 12.0, this.pointWidth = 8, this.candleWidth = 6, + this.fitContent = false, this.candleLineWidth = 1.0, this.volWidth = 6, this.macdWidth = 1.5, @@ -476,6 +477,7 @@ class ChartStyle { this.axisLabelBackground = true, this.axisLabelPadding = 4.0, this.priceAxisWidth = 0.0, + this.secondaryPriceAxisWidth = 56.0, this.labelCornerRadius = 3.0, this.legendPadding = 4.0, this.legendSpacing = 2.0, @@ -493,6 +495,18 @@ class ChartStyle { ///candle width final double candleWidth; + + /// Spreads the candles across the whole plot when they do not fill it. + /// + /// [pointWidth] is a fixed distance, so a short series — a handful of + /// intraday bars, say — bunches up against the left edge and leaves the rest + /// of the chart empty. With this set the spacing is widened to whatever makes + /// the series span the plot, and [candleWidth] and [ChartStyle.volWidth] are + /// widened with it so the bars keep their proportions. + /// + /// Only ever widens: a series long enough to fill the plot, or one zoomed in + /// past it, is laid out on [pointWidth] as before. + final bool fitContent; final double candleLineWidth; ///vol column width @@ -620,6 +634,15 @@ class ChartStyle { /// `KChartWidget.verticalTextAlignment`. final double priceAxisWidth; + /// Width of the gutter held back on the other side for a second axis. + /// + /// Only asked for when the chart was given a + /// `KChartWidget.secondaryPriceAxisScale`; the two gutters share half the + /// chart's width between them, so a second axis can never crowd the candles + /// out. Set it to 0 to draw the second axis over the candles the way the + /// price axis is drawn without a gutter. + final double secondaryPriceAxisWidth; + /// Corner radius of the axis label and legend pills. final double labelCornerRadius; @@ -645,6 +668,7 @@ class ChartStyle { double? childPadding, double? pointWidth, double? candleWidth, + bool? fitContent, double? candleLineWidth, double? volWidth, double? macdWidth, @@ -676,6 +700,7 @@ class ChartStyle { bool? axisLabelBackground, double? axisLabelPadding, double? priceAxisWidth, + double? secondaryPriceAxisWidth, double? labelCornerRadius, double? legendPadding, double? legendSpacing, @@ -689,6 +714,7 @@ class ChartStyle { childPadding: childPadding ?? this.childPadding, pointWidth: pointWidth ?? this.pointWidth, candleWidth: candleWidth ?? this.candleWidth, + fitContent: fitContent ?? this.fitContent, candleLineWidth: candleLineWidth ?? this.candleLineWidth, volWidth: volWidth ?? this.volWidth, macdWidth: macdWidth ?? this.macdWidth, @@ -720,6 +746,8 @@ class ChartStyle { axisLabelBackground: axisLabelBackground ?? this.axisLabelBackground, axisLabelPadding: axisLabelPadding ?? this.axisLabelPadding, priceAxisWidth: priceAxisWidth ?? this.priceAxisWidth, + secondaryPriceAxisWidth: + secondaryPriceAxisWidth ?? this.secondaryPriceAxisWidth, 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 f6f77fb..3288bee 100644 --- a/lib/src/k_chart_widget.dart +++ b/lib/src/k_chart_widget.dart @@ -238,6 +238,7 @@ class KChartWidget extends StatefulWidget { this.crosshairOnHover = true, this.showOhlcLegend = false, this.priceAxisScale = PriceAxisScale.linear, + this.secondaryPriceAxisScale, this.chartType, this.baselinePrice, this.timeZoneOffset = Duration.zero, @@ -288,6 +289,7 @@ class KChartWidget extends StatefulWidget { this.showNowPrice = true, this.showInfoDialog = true, this.lockPriceScale = false, + this.lockedScaleFollowsPrice = false, this.materialInfoDialog = true, this.chartStyle = const ChartStyle(), this.drawingStyle = const DrawingStyle(), @@ -295,6 +297,7 @@ class KChartWidget extends StatefulWidget { this.timeFormat = TimeFormat.YEAR_MONTH_DAY, this.infoDialogBuilder, this.dateFormatter, + this.priceFormatter, this.onLoadMore, this.fixedLength = 2, this.flingTime = 600, @@ -712,6 +715,22 @@ class KChartWidget extends StatefulWidget { /// point — they are what the lock is there to sit still through. final bool lockPriceScale; + /// Widens a locked range rather than letting the newest candle fall off it. + /// + /// A locked axis holds the range it was given, so a market that trades past + /// that range walks off the top or the bottom of the chart. With this set the + /// range grows just enough to keep the newest candle on screen, and never + /// shrinks back or refits to the window — so the axis still sits still while + /// scrolling, which is what the lock is for. + /// + /// Only the newest candle counts, and only while it is in view. Scrolling + /// back through history moves the window over candles the locked range need + /// not cover, and growing the axis to swallow them would undo the lock a + /// little at a time. + /// + /// Does nothing unless [lockPriceScale] is set. + final bool lockedScaleFollowsPrice; + /// Uses the Material info dialog rather than the Cupertino-styled one. final bool materialInfoDialog; @@ -739,6 +758,19 @@ class KChartWidget extends StatefulWidget { /// Overrides axis date formatting; the flag marks the long form. final String Function(KLineEntity, bool)? dateFormatter; + /// Writes the prices the price axis and its readouts show, in place of the + /// plain decimals [fixedLength] gives. + /// + /// Covers the axis labels, the crosshair's price label, the current-price tag + /// and the signal tags — everywhere the chart says what a price is. Use it + /// for a currency, a thousands separator, or a tick size the decimals alone + /// do not carry. + /// + /// An axis that reads out a move rather than a price — [PriceAxisScale + /// .percentage], [PriceAxisScale.indexedTo100] — writes that move itself and + /// does not ask. + final String Function(double price)? priceFormatter; + /// Fires when the user scrolls past an edge; the flag is true at the right. final ValueChanged? onLoadMore; @@ -789,6 +821,25 @@ class KChartWidget extends StatefulWidget { /// See [PriceAxisScale]. The volume and indicator panes stay linear. final PriceAxisScale priceAxisScale; + /// A second axis down the other side of the candles, reading the same prices + /// another way — `PriceAxisScale.percentage` for the change since the oldest + /// candle in view, next to the prices themselves. + /// + /// It marks its own round values rather than labelling the price axis's, so + /// a percentage axis reads +2%, +4%, +6% and not whatever percentages the + /// round prices happen to work out at. The grid stays ruled by the price + /// axis: a second set of lines over one set of candles would say nothing the + /// second set of labels does not. + /// + /// The crosshair, the current-price tag and the rest of the readouts follow + /// [priceAxisScale]; the second axis is an axis, not a second voice for + /// everything the chart says. + /// + /// `ChartStyle.secondaryPriceAxisWidth` is the gutter it is given, on the + /// side [verticalTextAlignment] left free. Null — the default — leaves the + /// chart with the one axis it has always had. + final PriceAxisScale? secondaryPriceAxisScale; + /// Whether dragging the price axis stretches it. /// /// The axis fits the window by default, so the candles always fill the @@ -2028,6 +2079,9 @@ class _KChartWidgetState extends State if (min.isFinite && max.isFinite && max > min) { _lockedPriceRange = (min, max); } + } else if (widget.lockedScaleFollowsPrice && + _lockedPriceRange != null) { + _lockedPriceRange = _rangeFollowingPrice(_lockedPriceRange!); } _painterBuilt = true; @@ -2086,6 +2140,7 @@ class _KChartWidgetState extends State fixedLength: widget.fixedLength, verticalTextAlignment: widget.verticalTextAlignment, dateFormatter: widget.dateFormatter, + priceFormatter: widget.priceFormatter, watermarkPicture: _watermarkPicture, draftLine: _draft, selectedLine: _selected, @@ -2094,6 +2149,7 @@ class _KChartWidgetState extends State chartTranslations: widget.chartTranslations, showOhlcLegend: widget.showOhlcLegend, priceAxisScale: widget.priceAxisScale, + secondaryPriceAxisScale: widget.secondaryPriceAxisScale, priceZoom: _priceZoom, pricePan: _pricePan, fixedPriceMin: _lockedPriceRange?.$1, @@ -4136,6 +4192,30 @@ class _KChartWidgetState extends State _pricePan = (_pricePan + delta / height / _priceZoom).clamp(-5.0, 5.0); } + /// [range] grown to cover the newest candle, for a locked axis that is not + /// meant to let the market trade off the top or the bottom of it. + /// + /// Only ever grows, and only for the newest candle while it is in view: the + /// window moving over older candles is exactly what the lock is there to sit + /// still through. + (double, double) _rangeFollowingPrice((double, double) range) { + final data = _candlesInPlay; + if (data == null || data.isEmpty || !_laidOut) return range; + // Off to the right of the window, the newest candle is not what the user is + // looking at, so the axis has no reason to move for it. Asked of the + // painter as it stands, which is last frame's window over last frame's + // candles — so a tick that has just arrived is measured against a window + // that was at the end of the series, not made to wait a frame for one. + if (painter.mStopIndex < painter.mItemCount - 1) return range; + + final last = data.last; + final low = last.low; + final high = last.high; + if (!low.isFinite || !high.isFinite) return range; + + return (math.min(range.$1, low), math.max(range.$2, high)); + } + /// Hands the price axis back to the chart, which fits it to the window. void resetPriceScale() { // A locked axis has something to reset even at zoom 1: the range it is diff --git a/lib/src/renderer/base_chart_painter.dart b/lib/src/renderer/base_chart_painter.dart index 36b0667..2358b69 100644 --- a/lib/src/renderer/base_chart_painter.dart +++ b/lib/src/renderer/base_chart_painter.dart @@ -141,7 +141,18 @@ abstract class BaseChartPainter extends CustomPainter { /// /// What the renderers are given, so where they put the labels and where the /// plot stops can never disagree. - double get priceAxisGutter => mCanvasWidth - mWidth; + double priceAxisGutter = 0.0; + + /// Width held back on the other side for a second axis, after clamping. + /// + /// Zero unless the chart was given one; see [secondaryAxisWidth]. + double secondaryAxisGutter = 0.0; + + /// How wide a gutter the second axis asks for, before clamping. + /// + /// Concrete so a painter that draws no second axis need not care; the chart + /// painter overrides it from its own settings. + double get secondaryAxisWidth => 0.0; /// Left edge of the plot, which the gutter takes when the labels are on the /// left. 0 whenever they are on the right. @@ -171,6 +182,13 @@ abstract class BaseChartPainter extends CustomPainter { final ChartStyle chartStyle; late double mPointWidth; + /// The style the renderers draw from, which is [chartStyle] unless the + /// candles were spread to fill the plot — see [ChartStyle.fitContent]. + /// + /// Worked out in [layout], since it takes a plot width to know whether the + /// series fills one. + late ChartStyle fittedStyle = chartStyle; + // format time List mFormats = [yyyy, '-', mm, '-', dd, ' ', HH, ':', nn]; double xFrontPadding; @@ -221,10 +239,17 @@ abstract class BaseChartPainter extends CustomPainter { void layout(Size size) { mDisplayHeight = size.height - mTopPadding - mBottomPadding; 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; + // Never so wide that there is no plot left to draw in — the two gutters + // share that half between them, so a chart with an axis on either side is + // still mostly candles. + final room = size.width / 2; + priceAxisGutter = chartStyle.priceAxisWidth.clamp(0.0, room); + secondaryAxisGutter = secondaryAxisWidth.clamp(0.0, room - priceAxisGutter); + mWidth = size.width - priceAxisGutter - secondaryAxisGutter; + // The second axis takes the side the first one left, so whichever of them + // is on the left is what the plot starts after. + mPlotLeft = priceAxisOnLeft ? priceAxisGutter : secondaryAxisGutter; + fitContent(); initRect(size); calculateValue(); initChartRenderer(); @@ -393,6 +418,42 @@ abstract class BaseChartPainter extends CustomPainter { } } + /// Whether the candles were spread to fill the plot on this layout. + /// + /// False when [ChartStyle.fitContent] is off, and when it is on but the + /// series is long enough to fill the plot at its own spacing. + bool contentFitted = false; + + /// Widens the candle spacing to fill the plot when the series is too short + /// to reach the right edge on its own. + /// + /// The series is spread over the whole plot less [xFrontPadding], so the + /// last candle's body ends at the right edge rather than a fraction of the + /// way in. + void fitContent() { + mPointWidth = chartStyle.pointWidth; + fittedStyle = chartStyle; + contentFitted = false; + if (!chartStyle.fitContent || mItemCount == 0) { + mDataLen = mItemCount * mPointWidth; + return; + } + + final available = mWidth / scaleX - xFrontPadding; + final fitted = available / mItemCount; + if (fitted > mPointWidth) { + final spread = fitted / mPointWidth; + mPointWidth = fitted; + contentFitted = true; + fittedStyle = chartStyle.copyWith( + pointWidth: fitted, + candleWidth: chartStyle.candleWidth * spread, + volWidth: chartStyle.volWidth * spread, + ); + } + mDataLen = mItemCount * mPointWidth; + } + /// calculate values void calculateValue() { if (candles == null) return; @@ -549,6 +610,10 @@ abstract class BaseChartPainter extends CustomPainter { /// get the minimum value of translation double getMinTranslateX() { + // A fitted series is exactly as wide as the plot, so the half point the + // scroll normally leaves for the last candle's centre would be scrollable + // slack. There is nothing to scroll to; hold it at zero. + if (contentFitted) return 0.0; final x = -mDataLen + mWidth / scaleX - mPointWidth / 2 - xFrontPadding; return x >= 0 ? 0.0 : x; } diff --git a/lib/src/renderer/chart_painter.dart b/lib/src/renderer/chart_painter.dart index deb9b2c..ccaa183 100644 --- a/lib/src/renderer/chart_painter.dart +++ b/lib/src/renderer/chart_painter.dart @@ -46,6 +46,7 @@ class ChartPainter extends BaseChartPainter { this.chartTranslations = const ChartTranslations(), this.showOhlcLegend = false, this.priceAxisScale = PriceAxisScale.linear, + this.secondaryPriceAxisScale, this.priceZoom = 1.0, this.pricePan = 0.0, CandleIndex? candleIndex, @@ -77,6 +78,7 @@ class ChartPainter extends BaseChartPainter { this.showNowPrice = true, this.fixedLength = 2, this.dateFormatter, + this.priceFormatter, super.repaint, }) : candleIndex = candleIndex ?? CandleIndex(), textCache = textCache ?? TextPainterCache() { @@ -221,6 +223,15 @@ class ChartPainter extends BaseChartPainter { /// How the candle area spaces and reads out its price axis. final PriceAxisScale priceAxisScale; + /// A second axis on the other side, or null for one axis; see + /// [KChartWidget.secondaryPriceAxisScale]. + final PriceAxisScale? secondaryPriceAxisScale; + + @override + double get secondaryAxisWidth => secondaryPriceAxisScale == null + ? 0.0 + : chartStyle.secondaryPriceAxisWidth; + /// How far the price axis is stretched away from the window it would fit. /// /// 1 is the auto-fitted range — exactly the highs and lows in view. Above 1 @@ -273,6 +284,10 @@ class ChartPainter extends BaseChartPainter { bool get priceAxisOnLeft => verticalTextAlignment == VerticalTextAlignment.left; final String Function(KLineEntity entity, bool isCrossLine)? dateFormatter; + + /// Writes the prices the axis and its readouts show; see + /// [KChartWidget.priceFormatter]. + final String Function(double price)? priceFormatter; final vg.PictureInfo? watermarkPicture; final Duration timeFrame; int fixedLength; @@ -300,8 +315,12 @@ class ChartPainter extends BaseChartPainter { double? get _percentBase { // Both readouts measure from the same place: a percentage says how far the // market has moved from it, an index says the same thing with it at 100. - if (priceAxisScale != PriceAxisScale.percentage && - priceAxisScale != PriceAxisScale.indexedTo100) { + bool measuresAMove(PriceAxisScale? scale) => + scale == PriceAxisScale.percentage || + scale == PriceAxisScale.indexedTo100; + + if (!measuresAMove(priceAxisScale) && + !measuresAMove(secondaryPriceAxisScale)) { return null; } final data = candles; @@ -360,7 +379,7 @@ class ChartPainter extends BaseChartPainter { overlays, isLine, fixedLength, - chartStyle, + fittedStyle, chartColors, scaleX, verticalTextAlignment, @@ -376,6 +395,9 @@ class ChartPainter extends BaseChartPainter { inverted: invertPriceAxis, averageClose: showAverageClose ? _averageCloseInView : null, candleColor: candleColor, + priceFormatter: priceFormatter, + secondaryScale: secondaryPriceAxisScale, + secondaryGutter: secondaryAxisGutter, priceAxisGutter: priceAxisGutter, priceAxisGutterOnLeft: priceAxisOnLeft, ); @@ -386,7 +408,7 @@ class ChartPainter extends BaseChartPainter { mVolMinValue, mChildPadding, fixedLength, - chartStyle, + fittedStyle, chartColors, priceAxisGutter: priceAxisGutter, priceAxisGutterOnLeft: priceAxisOnLeft, @@ -441,10 +463,13 @@ class ChartPainter extends BaseChartPainter { @override void drawBg(Canvas canvas, Size size) { final mBgPaint = Paint()..color = chartColors.bgColor; + // Every band is filled across the whole canvas, gutters included: an axis + // gutter is part of the chart, and a label drawn in one needs the chart's + // own background behind it rather than whatever is under the widget. final mainRect = Rect.fromLTRB( 0, 0, - mMainRect.width, + mCanvasWidth, mMainRect.height + mTopPadding, ); canvas.drawRect(mainRect, mBgPaint); @@ -453,7 +478,7 @@ class ChartPainter extends BaseChartPainter { final volRect = Rect.fromLTRB( 0, mVolRect!.top - mChildPadding, - mVolRect!.width, + mCanvasWidth, mVolRect!.bottom, ); canvas.drawRect(volRect, mBgPaint); @@ -464,7 +489,7 @@ class ChartPainter extends BaseChartPainter { final secondaryRect = Rect.fromLTRB( 0, mSecondaryRect.top - mChildPadding, - mSecondaryRect.width, + mCanvasWidth, mSecondaryRect.bottom, ); canvas.drawRect(secondaryRect, mBgPaint); @@ -696,7 +721,7 @@ class ChartPainter extends BaseChartPainter { drawPriceTag( canvas, getTextPainter(position.tagText, chartColors.nowPriceTextColor), - getMainY(position.entryPrice), + clampToMain(getMainY(position.entryPrice)), color, ); } @@ -707,7 +732,7 @@ class ChartPainter extends BaseChartPainter { drawPriceTag( canvas, getTextPainter(order.tagText, chartColors.nowPriceTextColor), - getMainY(order.price), + clampToMain(getMainY(order.price)), color, ); } @@ -724,7 +749,7 @@ class ChartPainter extends BaseChartPainter { final y = getMainY(price); // A line at a price the window does not reach would be drawn over another // pane, so it is left out rather than drawn in the wrong place. - if (y < mMainRect.top || y > mMainRect.bottom) return; + if (!withinMain(y)) return; final trading = chartStyle.trading; paintStyledLine( @@ -992,6 +1017,10 @@ class ChartPainter extends BaseChartPainter { void drawHorizontalLines(Canvas canvas, Size size) { for (final line in _withDraft(horizontalLines)) { final y = getMainY(line.price); + // Drawn at a price the axis does not reach it would land over another + // pane, or off the canvas entirely, so it is left out rather than drawn + // somewhere it does not mean. Its label still marks the edge. + if (!withinMain(y)) continue; // A ray starts at its own candle; a plain level spans the whole chart. final startX = horizontalRayStartX(line) ?? 0.0; if (startX > size.width) continue; @@ -1023,7 +1052,14 @@ class ChartPainter extends BaseChartPainter { final y = getMainY(line.price); final title = line.title ?? line.price.toStringAsFixed(fixedLength); - final tp = getLabelPainter(title, line.color); + // Off the axis, the label is held at the edge the price is beyond and + // carries which way it went, so a level outside a locked range can still + // be found rather than silently disappearing. + final labelY = clampToMain(y); + final tp = getLabelPainter( + withinMain(y) ? title : '$title ${y < mMainRect.top ? '▲' : '▼'}', + line.color, + ); final padding = drawingStyle.labelPadding; final rayStart = horizontalRayStartX(line); @@ -1033,7 +1069,12 @@ class ChartPainter extends BaseChartPainter { ? size.width - tp.width - padding.right - 8 : 8.0 + padding.left; - drawLineLabel(canvas, tp, Offset(textX, y - tp.height / 2), line.color); + drawLineLabel( + canvas, + tp, + Offset(textX, labelY - tp.height / 2), + line.color, + ); } } @@ -2861,7 +2902,7 @@ class ChartPainter extends BaseChartPainter { (labels.close, data.close), ]) TextSpan( - text: '$label ${value.toStringAsFixed(fixedLength)} ', + text: '$label ${mMainRenderer.formatPrice(value)} ', style: getTextStyle(moveColor), ), TextSpan( @@ -2932,7 +2973,7 @@ class ChartPainter extends BaseChartPainter { final x = translateXtoX(getX(index)); final y = getMainY(value); - final tp = getTextPainter(value.toStringAsFixed(fixedLength), color); + final tp = getTextPainter(mMainRenderer.formatPrice(value), color); final linePaint = Paint() ..color = color ..strokeWidth = 1 @@ -2963,6 +3004,9 @@ class ChartPainter extends BaseChartPainter { if (y > getMainY(mMainLowMinValue)) y = getMainY(mMainLowMinValue); if (y < getMainY(mMainHighMaxValue)) y = getMainY(mMainHighMaxValue); + // Those are the window's extremes, which a locked axis need not cover: a + // tick past the range it is held at would be drawn outside the pane. + y = clampToMain(y); nowPricePaint.color = value >= open ? chartColors.nowPriceUpColor @@ -3051,6 +3095,7 @@ class ChartPainter extends BaseChartPainter { if (y > getMainY(mMainLowMinValue)) y = getMainY(mMainLowMinValue); if (y < getMainY(mMainHighMaxValue)) y = getMainY(mMainHighMaxValue); + y = clampToMain(y); final linePaint = Paint() ..color = signal.color @@ -3157,6 +3202,17 @@ class ChartPainter extends BaseChartPainter { double getMainY(double y) => mMainRenderer.getY(y); + /// Whether [y] falls inside the candle area. + /// + /// A price the axis does not reach lands outside it, which a locked axis + /// makes ordinary: the range is held where it was, so a tick beyond it has + /// nowhere of its own to be drawn. + bool withinMain(double y) => y >= mMainRect.top && y <= mMainRect.bottom; + + /// Pins [y] to the candle area, for a label that has to stay findable even + /// when the price it points at is off the top or the bottom of the axis. + double clampToMain(double y) => y.clamp(mMainRect.top, mMainRect.bottom); + @override void drawWatermarkLogo(Canvas canvas, Size size) { final picture = watermarkPicture; diff --git a/lib/src/renderer/main_renderer.dart b/lib/src/renderer/main_renderer.dart index d63b119..93ee1cb 100644 --- a/lib/src/renderer/main_renderer.dart +++ b/lib/src/renderer/main_renderer.dart @@ -52,6 +52,9 @@ class MainRenderer extends BaseChartRenderer { this.inverted = false, this.averageClose, this.candleColor, + this.priceFormatter, + this.secondaryScale, + this.secondaryGutter = 0.0, super.priceAxisGutter = 0.0, super.priceAxisGutterOnLeft = false, }) : super( @@ -147,24 +150,54 @@ class MainRenderer extends BaseChartRenderer { /// Stands in for a price a logarithm cannot take. static const double _logFloor = 1e-9; + /// Writes a price the way the chart should read it out, in place of the + /// plain decimals [fixedLength] gives. + /// + /// Only ever asked about a price: an axis that reads out a move rather than a + /// price — percentage, indexed — writes that move itself. + final String Function(double price)? priceFormatter; + + /// A second reading of the same candles, drawn on the side the price axis + /// left free; null for the single axis the chart has always had. + /// + /// It marks its own round values — round percentages for a percentage axis — + /// so its labels are numbers worth reading rather than whatever the price + /// axis happened to land on. The grid stays ruled by the price axis: two sets + /// of lines over one set of candles would say nothing the second set of + /// labels does not. + final PriceAxisScale? secondaryScale; + + /// Width held back for [secondaryScale]'s labels, or zero to draw them just + /// inside the plot the way the price axis does without a gutter. + final double secondaryGutter; + /// Formats [price] the way the axis reads it. /// /// A percentage axis shows the move away from [percentBase] and an indexed one /// shows it with that base at 100; every other axis shows the price itself. - String formatAxis(double price) { + String formatAxis(double price) => formatAxisAs(priceScale, price); + + /// [price] as [scale] reads it, which is what lets a second axis say the same + /// candle in another unit. + String formatAxisAs(PriceAxisScale scale, double price) { final base = percentBase; - if (base == null || base == 0) return format(price); + if (base == null || base == 0) return formatPrice(price); - return switch (priceScale) { + return switch (scale) { PriceAxisScale.percentage => () { final move = (price / base - 1) * 100; return '${move >= 0 ? '+' : ''}${move.toStringAsFixed(2)}%'; }(), PriceAxisScale.indexedTo100 => (price / base * 100).toStringAsFixed(2), - _ => format(price), + _ => formatPrice(price), }; } + /// [price] as the chart writes prices: through [priceFormatter] when one was + /// given, and as plain decimals otherwise. + String formatPrice(double price) => + priceFormatter?.call(price) ?? format(price); + late double mCandleWidth; late double mCandleLineWidth; @@ -927,21 +960,23 @@ class MainRenderer extends BaseChartRenderer { /// evenly spaced pixels. A logarithmic axis steps by ratio, and a percentage /// or indexed one picks round percentages or index levels and converts them /// back to the prices they stand for. - List priceTicks(int gridRows) { - final cached = _priceTicks; - if (cached != null) return cached; + List priceTicks(int gridRows) => + _priceTicks ??= ticksFor(priceScale, gridRows); + /// The prices [scale] would mark, which for a second axis are its own round + /// values rather than the price axis's. + List ticksFor(PriceAxisScale scale, int gridRows) { final target = math.max(2, gridRows ~/ 2); final base = percentBase; final List ticks; - if (priceScale == PriceAxisScale.percentage && base != null && base != 0) { + if (scale == PriceAxisScale.percentage && base != null && base != 0) { final low = (minValue / base - 1) * 100; final high = (maxValue / base - 1) * 100; ticks = [ for (final move in niceTicks(low, high, target: target)) base * (1 + move / 100), ]; - } else if (priceScale == PriceAxisScale.indexedTo100 && + } else if (scale == PriceAxisScale.indexedTo100 && base != null && base != 0) { // Round index levels — 100, 105, 110 — converted back to the prices they @@ -952,7 +987,7 @@ class MainRenderer extends BaseChartRenderer { for (final level in niceTicks(low, high, target: target)) base * level / 100, ]; - } else if (isLogarithmic) { + } else if (scale == PriceAxisScale.logarithmic && isLogarithmic) { ticks = niceLogTicks(minValue, maxValue, target: target); } else { ticks = niceTicks(minValue, maxValue, target: target); @@ -960,18 +995,68 @@ class MainRenderer extends BaseChartRenderer { // A range too flat to divide would otherwise leave the axis blank; fall // back to the two ends it does have. - return _priceTicks = ticks.isEmpty ? [minValue, maxValue] : ticks; + return ticks.isEmpty ? [minValue, maxValue] : ticks; } @override void drawVerticalText(Canvas canvas, TextStyle textStyle, int gridRows) { + _drawAxisLabels( + canvas, + textStyle, + priceTicks(gridRows), + priceScale, + (width, padding) => axisLabelX( + width, + padding, + onLeft: verticalTextAlignment == VerticalTextAlignment.left, + ), + ); + + final second = secondaryScale; + if (second == null) return; + _drawAxisLabels( + canvas, + textStyle, + ticksFor(second, gridRows), + second, + _secondaryLabelX, + ); + } + + /// Where a label [width] wide goes on the side the price axis left free. + /// + /// With a gutter it goes in it, and without one just inside the plot — the + /// same two placements the price axis has, mirrored. + double _secondaryLabelX(double width, double padding) { + final onLeft = verticalTextAlignment != VerticalTextAlignment.left; + if (secondaryGutter > 0) { + return onLeft + ? chartRect.left - secondaryGutter + padding + : chartRect.right + padding; + } + return onLeft + ? chartRect.left + padding + : chartRect.right - width - padding; + } + + /// Writes one axis: [ticks] read as [scale] says, placed by [xOf]. + void _drawAxisLabels( + Canvas canvas, + TextStyle textStyle, + List ticks, + PriceAxisScale scale, + double Function(double width, double padding) xOf, + ) { final padding = chartStyle.axisLabelPadding; - for (final value in priceTicks(gridRows)) { + for (final value in ticks) { final y = getY(value); if (!y.isFinite) continue; - final TextSpan span = TextSpan(text: formatAxis(value), style: textStyle); + final TextSpan span = TextSpan( + text: formatAxisAs(scale, value), + style: textStyle, + ); final TextPainter tp = TextPainter( text: span, textDirection: TextDirection.ltr, @@ -989,11 +1074,7 @@ class MainRenderer extends BaseChartRenderer { // print over that pane's legend. if (hasPanesBelow && chartRect.bottom - y < tp.height) continue; - final offsetX = axisLabelX( - tp.width, - padding, - onLeft: verticalTextAlignment == VerticalTextAlignment.left, - ); + final offsetX = xOf(tp.width, padding); if (chartStyle.axisLabelBackground) { canvas.drawRRect( diff --git a/pubspec.yaml b/pubspec.yaml index 36f3e57..7771eff 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.4.0 +version: 2.4.1 homepage: https://github.com/CtrlAltDevelop repository: https://github.com/CtrlAltDevelop/ohlcv_chart issue_tracker: https://github.com/CtrlAltDevelop/ohlcv_chart/issues diff --git a/test/fit_content_test.dart b/test/fit_content_test.dart new file mode 100644 index 0000000..da2c720 --- /dev/null +++ b/test/fit_content_test.dart @@ -0,0 +1,79 @@ +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 chart too short to fill its box at the default spacing. +Widget _chart({required bool fitContent, int count = 10}) { + final data = candles(rampThenFall(count)); + 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), + xFrontPadding: 0, + volHidden: true, + showNowPrice: false, + chartStyle: ChartStyle(fitContent: fitContent), + ), + ), + ), + ); +} + +void main() { + group('ChartStyle.fitContent', () { + testWidgets('off, a short series keeps the fixed spacing', (tester) async { + await tester.pumpWidget(_chart(fitContent: false)); + + expect(_painterOf(tester).mPointWidth, const ChartStyle().pointWidth); + }); + + testWidgets('on, a short series spreads across the plot', (tester) async { + await tester.pumpWidget(_chart(fitContent: true)); + final painter = _painterOf(tester); + + expect(painter.mPointWidth, closeTo(40, 0.001)); + // The last candle's body ends at the right edge rather than a tenth of + // the way in, which is what the bunching complaint was about. + final lastX = painter.translateXtoX(painter.getX(9)); + expect(lastX, closeTo(400 - painter.mPointWidth / 2, 0.001)); + }); + + testWidgets('on, the candles widen with the spacing', (tester) async { + await tester.pumpWidget(_chart(fitContent: true)); + final painter = _painterOf(tester); + + const style = ChartStyle(); + final spread = painter.mPointWidth / style.pointWidth; + expect( + painter.fittedStyle.candleWidth, + closeTo(style.candleWidth * spread, 0.001), + ); + }); + + testWidgets('on, a series that already fills the plot is left alone', ( + tester, + ) async { + await tester.pumpWidget(_chart(fitContent: true, count: 200)); + + expect(_painterOf(tester).mPointWidth, const ChartStyle().pointWidth); + }); + }); +} diff --git a/test/golden_test.dart b/test/golden_test.dart index c03ad9f..6455c2f 100644 --- a/test/golden_test.dart +++ b/test/golden_test.dart @@ -56,6 +56,8 @@ Widget _chart({ ChartStyle style = const ChartStyle(), bool showOhlcLegend = false, bool volHidden = false, + PriceAxisScale? secondaryPriceAxisScale, + String Function(double)? priceFormatter, }) => _framed( KChartWidget( data ?? _market(), @@ -67,6 +69,8 @@ Widget _chart({ showScrollToNowButton: false, chartType: chartType, priceAxisScale: priceAxisScale, + secondaryPriceAxisScale: secondaryPriceAxisScale, + priceFormatter: priceFormatter, indicators: indicators, drawings: drawings, chartStyle: style, @@ -249,4 +253,66 @@ void main() { 'channel_and_position', ); }); + + testWidgets('a short series bunched up at the fixed spacing', (tester) async { + await matches( + tester, + _chart(data: _market(count: 12), volHidden: true), + 'short_series', + ); + }); + + testWidgets('the same short series fitted to the width', (tester) async { + await matches( + tester, + _chart( + data: _market(count: 12), + volHidden: true, + style: const ChartStyle(fitContent: true), + ), + 'fit_content', + ); + }); + + testWidgets('a second axis reading the change in percent', (tester) async { + await matches( + tester, + _chart( + secondaryPriceAxisScale: PriceAxisScale.percentage, + style: const ChartStyle( + priceAxisWidth: 56, + secondaryPriceAxisWidth: 56, + ), + volHidden: true, + ), + 'secondary_axis', + ); + }); + + testWidgets('prices written as currency', (tester) async { + await matches( + tester, + _chart( + priceFormatter: (price) => '\$${price.toStringAsFixed(1)}', + showOhlcLegend: true, + volHidden: true, + ), + 'price_formatter', + ); + }); + + testWidgets('a level the axis cannot reach, marked at the edge', ( + tester, + ) async { + await matches( + tester, + _chart( + drawings: [ + HorizontalLine(price: 400, title: 'Off the axis', showLabel: true), + ], + volHidden: true, + ), + 'level_off_axis', + ); + }); } diff --git a/test/goldens/fit_content.png b/test/goldens/fit_content.png new file mode 100644 index 0000000..ebce987 Binary files /dev/null and b/test/goldens/fit_content.png differ diff --git a/test/goldens/level_off_axis.png b/test/goldens/level_off_axis.png new file mode 100644 index 0000000..03510dd Binary files /dev/null and b/test/goldens/level_off_axis.png differ diff --git a/test/goldens/price_formatter.png b/test/goldens/price_formatter.png new file mode 100644 index 0000000..e148584 Binary files /dev/null and b/test/goldens/price_formatter.png differ diff --git a/test/goldens/secondary_axis.png b/test/goldens/secondary_axis.png new file mode 100644 index 0000000..e3d3e07 Binary files /dev/null and b/test/goldens/secondary_axis.png differ diff --git a/test/goldens/short_series.png b/test/goldens/short_series.png new file mode 100644 index 0000000..b569248 Binary files /dev/null and b/test/goldens/short_series.png differ diff --git a/test/locked_price_scale_test.dart b/test/locked_price_scale_test.dart index a905cf3..fea714e 100644 --- a/test/locked_price_scale_test.dart +++ b/test/locked_price_scale_test.dart @@ -22,6 +22,7 @@ Widget _chart({ required bool lock, List? data, KChartController? controller, + bool followsPrice = false, }) => MaterialApp( home: Scaffold( body: SizedBox( @@ -35,6 +36,7 @@ Widget _chart({ timeFrame: const Duration(minutes: 15), showNowPrice: false, lockPriceScale: lock, + lockedScaleFollowsPrice: followsPrice, controller: controller, ), ), @@ -207,4 +209,95 @@ void main() { ); }); }); + + group('a locked axis that follows the price', () { + /// [base] with one more candle, priced at [close]. + List plus(List base, double close) { + final data = [...base, candle(close, minute: base.length)]; + DataUtil.calculate(data); + return data; + } + + /// Pumps [data] and leaves the axis locked onto it. + /// + /// The range is taken from the frame before, so the lock takes hold on the + /// second build rather than the first — a live chart gets there on its next + /// tick; a test has to ask for the frame. + Future lockOnto(WidgetTester tester, List data) async { + await tester.pumpWidget( + _chart(lock: true, followsPrice: true, data: data), + ); + await tester.pump(); + await tester.pumpWidget( + _chart(lock: true, followsPrice: true, data: data), + ); + await tester.pump(); + } + + testWidgets('grows to keep a breakout on the chart', (tester) async { + final data = _trend(); + await lockOnto(tester, data); + final locked = _range(tester); + + // A tick well above everything the axis was locked onto. + final broken = plus(data, locked.max + 50); + await tester.pumpWidget( + _chart(lock: true, followsPrice: true, data: broken), + ); + await tester.pumpAndSettle(); + + expect(_range(tester).max, greaterThanOrEqualTo(locked.max + 50)); + expect(_range(tester).min, locked.min, reason: 'the floor does not move'); + }); + + testWidgets('off, the breakout walks off the axis as before', ( + tester, + ) async { + final data = _trend(); + await tester.pumpWidget(_chart(lock: true, data: data)); + await tester.pump(); + final locked = _range(tester); + + final broken = plus(data, locked.max + 50); + await tester.pumpWidget(_chart(lock: true, data: broken)); + await tester.pumpAndSettle(); + + expect(_range(tester).max, locked.max); + }); + + testWidgets('never shrinks back once it has grown', (tester) async { + final data = _trend(); + await lockOnto(tester, data); + final locked = _range(tester); + + final broken = plus(data, locked.max + 50); + await tester.pumpWidget( + _chart(lock: true, followsPrice: true, data: broken), + ); + await tester.pumpAndSettle(); + final grown = _range(tester); + + // Back to an ordinary price: the room the breakout needed stays. + final settled = plus(broken, locked.max - 10); + await tester.pumpWidget( + _chart(lock: true, followsPrice: true, data: settled), + ); + await tester.pumpAndSettle(); + + expect(_range(tester).max, grown.max); + }); + + testWidgets('still sits still while the chart is scrolled', (tester) async { + await lockOnto(tester, _trend()); + final locked = _range(tester); + + // Scrolling moves the window over candles the locked range does not + // cover, and the axis must not grow to swallow them. + 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); + }); + }); } diff --git a/test/out_of_range_lines_test.dart b/test/out_of_range_lines_test.dart new file mode 100644 index 0000000..b6241af --- /dev/null +++ b/test/out_of_range_lines_test.dart @@ -0,0 +1,185 @@ +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 'test_utils.dart'; + +const double _width = 500; +const double _height = 600; + +ChartPainter _painterOf(WidgetTester tester) { + final dynamic state = tester.state(find.byType(KChartWidget)); + // ignore: avoid_dynamic_calls + return state.painter as ChartPainter; +} + +/// A canvas that notes where every line and every run of text was drawn. +class _Probe implements Canvas { + final List lines = []; + final List textAt = []; + + Offset _translate = Offset.zero; + final List _stack = []; + + /// The lines drawn outside [rect], vertically — the ones that escape the + /// pane they belong to. + List escaping(Rect rect) => lines + .where((o) => o.dy < rect.top - 0.5 || o.dy > rect.bottom + 0.5) + .toList(); + + @override + void save() => _stack.add(_translate); + + @override + void restore() { + if (_stack.isNotEmpty) _translate = _stack.removeLast(); + } + + @override + void translate(double dx, double dy) => + _translate = _translate.translate(dx, dy); + + @override + void drawLine(Offset p1, Offset p2, Paint paint) { + lines + ..add(p1 + _translate) + ..add(p2 + _translate); + } + + @override + void drawParagraph(ui.Paragraph paragraph, Offset offset) => + textAt.add(offset + _translate); + + @override + void noSuchMethod(Invocation invocation) {} +} + +/// A climb steep enough that scrolling leaves a locked range far behind. +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 List data, + ChartDrawingController? controller, + bool showNowPrice = false, +}) => MaterialApp( + home: Scaffold( + body: SizedBox( + width: _width, + height: _height, + child: KChartWidget( + data, + ChartColors(), + isTrendLine: false, + watermarkAssetPath: 'assets/none.svg', + timeFrame: const Duration(minutes: 15), + showNowPrice: showNowPrice, + lockPriceScale: true, + drawingController: controller, + ), + ), + ), +); + +/// Lays the chart out, then records one draw pass of [draw] on its own. +/// +/// Recording the whole paint would sweep in the grid and the date axis, which +/// are drawn outside the candle area because that is where they belong. What +/// is on trial here is the price-anchored lines. +_Probe _record(ChartPainter painter, void Function(Canvas, Size) draw) { + painter.paint(Canvas(ui.PictureRecorder()), const Size(_width, _height)); + final probe = _Probe(); + draw(probe, const Size(_width, _height)); + return probe; +} + +void main() { + group('a price the locked axis does not reach', () { + testWidgets('keeps a horizontal line out of the other panes', ( + tester, + ) async { + // Far above anything the chart ever shows, so it is outside whatever + // range the axis locked onto. + final controller = ChartDrawingController( + drawings: [HorizontalLine(price: 100000)], + ); + await tester.pumpWidget(_chart(data: _trend(), controller: controller)); + await tester.pump(); + + final painter = _painterOf(tester); + expect( + painter.withinMain(painter.getMainY(100000)), + isFalse, + reason: 'the level really is off the axis', + ); + final probe = _record(painter, painter.drawHorizontalLines); + expect(probe.lines, isEmpty); + }); + + testWidgets('still marks the edge with the line label', (tester) async { + final controller = ChartDrawingController( + drawings: [HorizontalLine(price: 100000, showLabel: true)], + ); + await tester.pumpWidget(_chart(data: _trend(), controller: controller)); + await tester.pump(); + + final painter = _painterOf(tester); + final rect = painter.mMainRect; + final probe = _record(painter, painter.drawHorizontalLineTitles); + expect(probe.textAt, isNotEmpty, reason: 'the label is still drawn'); + expect( + probe.textAt.every((o) => o.dy >= rect.top - 20 && o.dy <= rect.bottom), + isTrue, + reason: 'and pinned to the edge the price went past', + ); + }); + + testWidgets('draws a level the axis does reach as it always did', ( + tester, + ) async { + final data = _trend(); + await tester.pumpWidget(_chart(data: data)); + await tester.pump(); + final at = _painterOf(tester).mMainRenderer; + final inRange = (at.minValue + at.maxValue) / 2; + + final controller = ChartDrawingController( + drawings: [HorizontalLine(price: inRange)], + ); + await tester.pumpWidget(_chart(data: data, controller: controller)); + await tester.pump(); + + final painter = _painterOf(tester); + final probe = _record(painter, painter.drawHorizontalLines); + expect(probe.lines, isNotEmpty); + expect(probe.escaping(painter.mMainRect), isEmpty); + }); + + testWidgets('pins the now-price line to the edge it went past', ( + tester, + ) async { + final data = _trend(); + await tester.pumpWidget(_chart(data: data, showNowPrice: true)); + await tester.pump(); + + // Scroll back so the axis is locked on old, low prices while the last + // candle — what the now-price line marks — is far above them. + await tester.drag(find.byType(KChartWidget), const Offset(2000, 0)); + await tester.pumpAndSettle(); + + final painter = _painterOf(tester); + final probe = _record( + painter, + (canvas, _) => painter.drawNowPrice(canvas), + ); + expect(probe.lines, isNotEmpty, reason: 'the line is still drawn'); + expect(probe.escaping(painter.mMainRect), isEmpty); + }); + }); +} diff --git a/test/price_formatter_test.dart b/test/price_formatter_test.dart new file mode 100644 index 0000000..4702160 --- /dev/null +++ b/test/price_formatter_test.dart @@ -0,0 +1,99 @@ +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 'test_utils.dart'; + +const double _width = 500; +const double _height = 600; + +ChartPainter _painterOf(WidgetTester tester) { + final dynamic state = tester.state(find.byType(KChartWidget)); + // ignore: avoid_dynamic_calls + return state.painter as ChartPainter; +} + +Widget _chart({ + String Function(double)? priceFormatter, + PriceAxisScale scale = PriceAxisScale.linear, +}) { + final data = candles(rampThenFall(120)); + DataUtil.calculate(data); + + return MaterialApp( + home: Scaffold( + body: SizedBox( + width: _width, + height: _height, + child: KChartWidget( + data, + ChartColors(), + isTrendLine: false, + watermarkAssetPath: 'assets/none.svg', + timeFrame: const Duration(minutes: 15), + showNowPrice: true, + priceAxisScale: scale, + priceFormatter: priceFormatter, + ), + ), + ), + ); +} + +void main() { + group('priceFormatter', () { + testWidgets('writes the axis labels', (tester) async { + await tester.pumpWidget( + _chart(priceFormatter: (p) => '\$${p.toStringAsFixed(1)}'), + ); + final painter = _painterOf(tester); + + expect(painter.mMainRenderer.formatAxis(1234.5), r'$1234.5'); + }); + + testWidgets('is left out, prices are the plain decimals', (tester) async { + await tester.pumpWidget(_chart()); + final painter = _painterOf(tester); + + expect(painter.mMainRenderer.formatAxis(1234.5), '1234.50'); + }); + + testWidgets('writes the on-chart prices too', (tester) async { + await tester.pumpWidget( + _chart(priceFormatter: (p) => '${p.toStringAsFixed(0)} USD'), + ); + final painter = _painterOf(tester); + + expect(painter.mMainRenderer.formatPrice(99.4), '99 USD'); + }); + + testWidgets('is not asked by an axis that reads out a move', ( + tester, + ) async { + await tester.pumpWidget( + _chart( + scale: PriceAxisScale.percentage, + priceFormatter: (p) => 'never', + ), + ); + final painter = _painterOf(tester); + final base = painter.mMainRenderer.percentBase; + + expect(base, isNotNull); + expect(painter.mMainRenderer.formatAxis(base! * 1.1), '+10.00%'); + }); + + testWidgets('a chart with one paints without complaint', (tester) async { + await tester.pumpWidget( + _chart(priceFormatter: (p) => '\$${p.toStringAsFixed(1)}'), + ); + + _painterOf(tester) + .paint(Canvas(ui.PictureRecorder()), const Size(_width, _height)); + expect(tester.takeException(), isNull); + }); + }); +} diff --git a/test/render_perf_test.dart b/test/render_perf_test.dart index 68a9df7..5fd8332 100644 --- a/test/render_perf_test.dart +++ b/test/render_perf_test.dart @@ -214,9 +214,12 @@ void main() { ) async { final data = _market(count: 20000); final at = data[15000].dateTime!; + // Priced around the newest candle, so every one of them is on screen: a + // line the axis does not reach is not drawn, and so looks up no anchor. + final last = data.last.close; final drawings = [ for (var i = 0; i < 20; i++) - HorizontalLine(price: 100.0 + i, startTime: at), + HorizontalLine(price: last - 1 + i * 0.1, startTime: at), ]; await tester.pumpWidget(_chart(data, drawings: drawings)); diff --git a/test/secondary_axis_test.dart b/test/secondary_axis_test.dart new file mode 100644 index 0000000..68ab904 --- /dev/null +++ b/test/secondary_axis_test.dart @@ -0,0 +1,190 @@ +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 'test_utils.dart'; + +const double _width = 500; +const double _height = 600; +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; +} + +/// The y of every horizontal line drawn. +class _Lines implements Canvas { + final List ys = []; + + @override + void drawLine(Offset p1, Offset p2, Paint paint) { + if ((p1.dy - p2.dy).abs() < 0.5) ys.add(p1.dy); + } + + @override + void noSuchMethod(Invocation invocation) {} +} + +/// Where each run of text was painted. +class _Text implements Canvas { + final List at = []; + + @override + void drawParagraph(ui.Paragraph paragraph, Offset offset) => at.add(offset); + + @override + void noSuchMethod(Invocation invocation) {} +} + +Widget _chart({ + PriceAxisScale? second, + VerticalTextAlignment alignment = VerticalTextAlignment.right, + double priceAxisWidth = _gutter, + double secondaryWidth = _gutter, +}) { + final data = candles(rampThenFall(120)); + DataUtil.calculate(data); + + return MaterialApp( + home: Scaffold( + body: SizedBox( + width: _width, + height: _height, + child: KChartWidget( + data, + ChartColors(), + isTrendLine: false, + watermarkAssetPath: 'assets/none.svg', + timeFrame: const Duration(minutes: 15), + showNowPrice: false, + verticalTextAlignment: alignment, + secondaryPriceAxisScale: second, + chartStyle: ChartStyle( + priceAxisWidth: priceAxisWidth, + secondaryPriceAxisWidth: secondaryWidth, + ), + ), + ), + ), + ); +} + +void main() { + group('a second price axis', () { + testWidgets('is not there unless it is asked for', (tester) async { + await tester.pumpWidget(_chart()); + final painter = _painterOf(tester); + + expect(painter.secondaryAxisGutter, 0); + expect(painter.mWidth, _width - _gutter); + expect(painter.mPlotLeft, 0); + }); + + testWidgets('takes the side the price axis left free', (tester) async { + await tester.pumpWidget(_chart(second: PriceAxisScale.percentage)); + final painter = _painterOf(tester); + + expect(painter.secondaryAxisGutter, _gutter); + expect(painter.mWidth, _width - _gutter * 2); + expect(painter.mPlotLeft, _gutter, reason: 'prices on the right'); + expect(painter.mMainRect.left, _gutter); + expect(painter.mMainRect.right, _width - _gutter); + }); + + testWidgets('and swaps sides with the price axis', (tester) async { + await tester.pumpWidget( + _chart( + second: PriceAxisScale.percentage, + alignment: VerticalTextAlignment.left, + ), + ); + final painter = _painterOf(tester); + + // Prices on the left now, so the second axis holds the right. + expect(painter.mPlotLeft, _gutter); + expect(painter.mMainRect.right, _width - _gutter); + }); + + testWidgets('the panes below stop at it too', (tester) async { + await tester.pumpWidget(_chart(second: PriceAxisScale.percentage)); + final painter = _painterOf(tester); + + expect(painter.mVolRect?.left, _gutter); + expect(painter.mVolRect?.right, _width - _gutter); + }); + + testWidgets('marks its own round values, not the price axis\'s', ( + tester, + ) async { + await tester.pumpWidget(_chart(second: PriceAxisScale.percentage)); + final renderer = _painterOf(tester).mMainRenderer; + + final labels = [ + for (final tick in renderer.ticksFor(PriceAxisScale.percentage, 8)) + renderer.formatAxisAs(PriceAxisScale.percentage, tick), + ]; + + expect(labels, isNotEmpty); + expect(labels.every((l) => l.endsWith('%')), isTrue); + // Round percentages: two decimals that are always zero. + expect( + labels.every((l) => l.contains('.00%')), + isTrue, + reason: '$labels', + ); + }); + + testWidgets('writes its labels in its own gutter', (tester) async { + await tester.pumpWidget(_chart(second: PriceAxisScale.percentage)); + final painter = _painterOf(tester); + + final probe = _Text(); + painter.paint(probe, const Size(_width, _height)); + + expect( + probe.at.where((o) => o.dx < painter.mPlotLeft), + isNotEmpty, + reason: 'the second axis writes in the gutter it was given', + ); + }); + + testWidgets('leaves the grid to the price axis', (tester) async { + await tester.pumpWidget(_chart(second: PriceAxisScale.percentage)); + final renderer = _painterOf(tester).mMainRenderer; + + final probe = _Lines(); + renderer.drawGrid(probe, 8, 4); + + // The rows are ruled at the round prices, not at the round percentages + // the second axis marks — one set of lines, the one the prices agree + // with. + final rows = probe.ys + .where((y) => y >= renderer.chartRect.top - 0.5) + .toSet(); + for (final tick in renderer.priceTicks(8)) { + final y = renderer.getY(tick); + if (y < renderer.chartRect.top || y > renderer.chartRect.bottom) { + continue; + } + expect( + rows.any((r) => (r - y).abs() < 0.5), + isTrue, + reason: 'no grid line at the round price $tick', + ); + } + }); + + testWidgets('the crosshair still reads the price axis', (tester) async { + await tester.pumpWidget(_chart(second: PriceAxisScale.percentage)); + final renderer = _painterOf(tester).mMainRenderer; + + // The second axis is an axis, not a second voice for the readouts. + expect(renderer.formatAxis(123.456), '123.46'); + }); + }); +}