From cbdbc5ee2e9d2cd699574209f31cc2e2440e59d5 Mon Sep 17 00:00:00 2001 From: Mohammad Zarif Date: Sat, 12 Sep 2026 14:42:22 +0330 Subject: [PATCH 1/7] feat: spread a short series across the plot with ChartStyle.fitContent 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. Fitting widens the spacing to fill the plot, and widens the candle and volume bars with it so they keep their proportions. It only ever widens, so a series long enough to fill the plot is laid out on pointWidth as before and the flag can stay on while history pages in. A fitted series is exactly as wide as the plot, so the scroll holds at zero rather than leaving the half point the last candle's centre normally needs. --- CHANGELOG.md | 12 ++++ doc/driving-the-chart.md | 17 ++++- lib/src/chart_style.dart | 15 +++++ lib/src/renderer/base_chart_painter.dart | 48 ++++++++++++++ lib/src/renderer/chart_painter.dart | 4 +- test/fit_content_test.dart | 79 ++++++++++++++++++++++++ 6 files changed, 170 insertions(+), 5 deletions(-) create mode 100644 test/fit_content_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index ea509f0..2a80183 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +## Unreleased + +### 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/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/lib/src/chart_style.dart b/lib/src/chart_style.dart index 5ecc083..de0c6e4 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, @@ -493,6 +494,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 @@ -645,6 +658,7 @@ class ChartStyle { double? childPadding, double? pointWidth, double? candleWidth, + bool? fitContent, double? candleLineWidth, double? volWidth, double? macdWidth, @@ -689,6 +703,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, diff --git a/lib/src/renderer/base_chart_painter.dart b/lib/src/renderer/base_chart_painter.dart index 36b0667..88b227a 100644 --- a/lib/src/renderer/base_chart_painter.dart +++ b/lib/src/renderer/base_chart_painter.dart @@ -171,6 +171,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; @@ -225,6 +232,7 @@ abstract class BaseChartPainter extends CustomPainter { final gutter = chartStyle.priceAxisWidth.clamp(0.0, size.width / 2); mWidth = size.width - gutter; mPlotLeft = priceAxisOnLeft ? gutter : 0.0; + fitContent(); initRect(size); calculateValue(); initChartRenderer(); @@ -393,6 +401,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 +593,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..c8c50b5 100644 --- a/lib/src/renderer/chart_painter.dart +++ b/lib/src/renderer/chart_painter.dart @@ -360,7 +360,7 @@ class ChartPainter extends BaseChartPainter { overlays, isLine, fixedLength, - chartStyle, + fittedStyle, chartColors, scaleX, verticalTextAlignment, @@ -386,7 +386,7 @@ class ChartPainter extends BaseChartPainter { mVolMinValue, mChildPadding, fixedLength, - chartStyle, + fittedStyle, chartColors, priceAxisGutter: priceAxisGutter, priceAxisGutterOnLeft: priceAxisOnLeft, 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); + }); + }); +} From 4639a3ec3b82a2b608be7eefb3bba56287b09ca3 Mon Sep 17 00:00:00 2001 From: Mohammad Zarif Date: Sat, 12 Sep 2026 14:47:03 +0330 Subject: [PATCH 2/7] fix: keep a price the locked axis cannot reach inside the candle area A locked axis holds the range it was given, so a tick beyond it had nowhere of its own to be drawn: the line was painted over the volume and indicator panes, or off the canvas where it was invisible. 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 and marked with an arrow so the level can still be found. The current-price line, the signal lines and the trading tags are clamped to the same edge; the trading lines already skipped themselves, and now say so through the same shared test. The large-history perf test priced its drawings below everything on screen, which no longer resolves an anchor at all now that an unreachable line is skipped before the lookup. Priced around the newest candle instead. --- CHANGELOG.md | 11 ++ lib/src/renderer/chart_painter.dart | 41 +++++- test/out_of_range_lines_test.dart | 185 ++++++++++++++++++++++++++++ test/render_perf_test.dart | 5 +- 4 files changed, 236 insertions(+), 6 deletions(-) create mode 100644 test/out_of_range_lines_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a80183..b8284ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ ## Unreleased +### Fixed + +- **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. + ### Layout - **New `ChartStyle.fitContent` spreads a short series across the whole plot.** diff --git a/lib/src/renderer/chart_painter.dart b/lib/src/renderer/chart_painter.dart index c8c50b5..879e169 100644 --- a/lib/src/renderer/chart_painter.dart +++ b/lib/src/renderer/chart_painter.dart @@ -696,7 +696,7 @@ class ChartPainter extends BaseChartPainter { drawPriceTag( canvas, getTextPainter(position.tagText, chartColors.nowPriceTextColor), - getMainY(position.entryPrice), + clampToMain(getMainY(position.entryPrice)), color, ); } @@ -707,7 +707,7 @@ class ChartPainter extends BaseChartPainter { drawPriceTag( canvas, getTextPainter(order.tagText, chartColors.nowPriceTextColor), - getMainY(order.price), + clampToMain(getMainY(order.price)), color, ); } @@ -724,7 +724,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 +992,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 +1027,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 +1044,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, + ); } } @@ -2963,6 +2979,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 +3070,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 +3177,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/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/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)); From 0846acf54ed3f974f55200de74e5f6e4a140618c Mon Sep 17 00:00:00 2001 From: Mohammad Zarif Date: Sat, 12 Sep 2026 15:13:02 +0330 Subject: [PATCH 3/7] feat: keep the newest candle on a locked axis with lockedScaleFollowsPrice A locked axis holds the range it was given, so a market that trades past it walked off the top or the bottom of the chart until resetPriceScale was called. The new flag grows the locked range just enough to cover the newest candle, and never shrinks it back or refits it 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. Whether it is in view is 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 the window it arrived into rather than waiting a frame. --- CHANGELOG.md | 12 ++++ doc/price-axis.md | 24 ++++++++ lib/src/k_chart_widget.dart | 44 +++++++++++++++ test/locked_price_scale_test.dart | 93 +++++++++++++++++++++++++++++++ 4 files changed, 173 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b8284ca..fd22954 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,18 @@ 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 `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.** diff --git a/doc/price-axis.md b/doc/price-axis.md index e2cb384..eaa9223 100644 --- a/doc/price-axis.md +++ b/doc/price-axis.md @@ -123,6 +123,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/lib/src/k_chart_widget.dart b/lib/src/k_chart_widget.dart index f6f77fb..e8b6479 100644 --- a/lib/src/k_chart_widget.dart +++ b/lib/src/k_chart_widget.dart @@ -288,6 +288,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(), @@ -712,6 +713,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; @@ -2028,6 +2045,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; @@ -4136,6 +4156,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/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); + }); + }); } From 7d781ca9fef35211809d66a8f4d8654b93bd354c Mon Sep 17 00:00:00 2001 From: Mohammad Zarif Date: Sat, 12 Sep 2026 15:15:56 +0330 Subject: [PATCH 4/7] feat: write the chart's prices with priceFormatter fixedLength only said how many decimals a price is written to, so a currency symbol, a thousands separator or a tick size had nowhere to go. The new callback takes the writing over, the way dateFormatter already does on the date axis. It covers every price the chart itself 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 writes that move itself and does not ask. Drawings keep their own titles. --- CHANGELOG.md | 9 +++ doc/README.md | 5 +- doc/price-axis.md | 22 +++++++ lib/src/k_chart_widget.dart | 15 +++++ lib/src/renderer/chart_painter.dart | 10 ++- lib/src/renderer/main_renderer.dart | 17 ++++- test/price_formatter_test.dart | 99 +++++++++++++++++++++++++++++ 7 files changed, 171 insertions(+), 6 deletions(-) create mode 100644 test/price_formatter_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index fd22954..e4b7918 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,15 @@ ### Price axis +- **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` 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/price-axis.md b/doc/price-axis.md index eaa9223..9481aec 100644 --- a/doc/price-axis.md +++ b/doc/price-axis.md @@ -56,6 +56,28 @@ 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. +## 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 diff --git a/lib/src/k_chart_widget.dart b/lib/src/k_chart_widget.dart index e8b6479..493150d 100644 --- a/lib/src/k_chart_widget.dart +++ b/lib/src/k_chart_widget.dart @@ -296,6 +296,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, @@ -756,6 +757,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; @@ -2106,6 +2120,7 @@ class _KChartWidgetState extends State fixedLength: widget.fixedLength, verticalTextAlignment: widget.verticalTextAlignment, dateFormatter: widget.dateFormatter, + priceFormatter: widget.priceFormatter, watermarkPicture: _watermarkPicture, draftLine: _draft, selectedLine: _selected, diff --git a/lib/src/renderer/chart_painter.dart b/lib/src/renderer/chart_painter.dart index 879e169..49aeb00 100644 --- a/lib/src/renderer/chart_painter.dart +++ b/lib/src/renderer/chart_painter.dart @@ -77,6 +77,7 @@ class ChartPainter extends BaseChartPainter { this.showNowPrice = true, this.fixedLength = 2, this.dateFormatter, + this.priceFormatter, super.repaint, }) : candleIndex = candleIndex ?? CandleIndex(), textCache = textCache ?? TextPainterCache() { @@ -273,6 +274,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; @@ -376,6 +381,7 @@ class ChartPainter extends BaseChartPainter { inverted: invertPriceAxis, averageClose: showAverageClose ? _averageCloseInView : null, candleColor: candleColor, + priceFormatter: priceFormatter, priceAxisGutter: priceAxisGutter, priceAxisGutterOnLeft: priceAxisOnLeft, ); @@ -2877,7 +2883,7 @@ class ChartPainter extends BaseChartPainter { (labels.close, data.close), ]) TextSpan( - text: '$label ${value.toStringAsFixed(fixedLength)} ', + text: '$label ${mMainRenderer.formatPrice(value)} ', style: getTextStyle(moveColor), ), TextSpan( @@ -2948,7 +2954,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 diff --git a/lib/src/renderer/main_renderer.dart b/lib/src/renderer/main_renderer.dart index d63b119..caf0ac7 100644 --- a/lib/src/renderer/main_renderer.dart +++ b/lib/src/renderer/main_renderer.dart @@ -52,6 +52,7 @@ class MainRenderer extends BaseChartRenderer { this.inverted = false, this.averageClose, this.candleColor, + this.priceFormatter, super.priceAxisGutter = 0.0, super.priceAxisGutterOnLeft = false, }) : super( @@ -147,13 +148,20 @@ 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; + /// 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) { final base = percentBase; - if (base == null || base == 0) return format(price); + if (base == null || base == 0) return formatPrice(price); return switch (priceScale) { PriceAxisScale.percentage => () { @@ -161,10 +169,15 @@ class MainRenderer extends BaseChartRenderer { 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; 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); + }); + }); +} From 1434ba143e4229a2136669f050e82cce5e8a8366 Mon Sep 17 00:00:00 2001 From: Mohammad Zarif Date: Sat, 12 Sep 2026 15:22:55 +0330 Subject: [PATCH 5/7] feat: draw a second price axis down the other side The chart had one price axis, so reading a move as a percentage meant giving up the prices. secondaryPriceAxisScale reads the same candles a second way in a gutter on the side verticalTextAlignment left free. It marks its own round values rather than labelling the price axis's, so a percentage axis reads +2%, +4%, +6%. 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, and the crosshair and the price tags keep following priceAxisScale. The two gutters are clamped against each other rather than separately, so between them they can never take more than half the chart's width. --- CHANGELOG.md | 11 ++ doc/price-axis.md | 32 ++++ example/lib/src/chart_page.dart | 1 + example/lib/src/controls.dart | 10 ++ example/lib/src/demo_state.dart | 3 + lib/src/chart_style.dart | 13 ++ lib/src/k_chart_widget.dart | 21 +++ lib/src/renderer/base_chart_painter.dart | 27 +++- lib/src/renderer/chart_painter.dart | 20 ++- lib/src/renderer/main_renderer.dart | 100 ++++++++++-- test/secondary_axis_test.dart | 190 +++++++++++++++++++++++ 11 files changed, 405 insertions(+), 23 deletions(-) create mode 100644 test/secondary_axis_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index e4b7918..fc03639 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,17 @@ ### 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 diff --git a/doc/price-axis.md b/doc/price-axis.md index 9481aec..5ee3150 100644 --- a/doc/price-axis.md +++ b/doc/price-axis.md @@ -56,6 +56,38 @@ 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` diff --git a/example/lib/src/chart_page.dart b/example/lib/src/chart_page.dart index cdf4e22..439354d 100644 --- a/example/lib/src/chart_page.dart +++ b/example/lib/src/chart_page.dart @@ -113,6 +113,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, diff --git a/example/lib/src/controls.dart b/example/lib/src/controls.dart index 8fecfcb..613d47e 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', diff --git a/example/lib/src/demo_state.dart b/example/lib/src/demo_state.dart index 9df9de5..a7e9667 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; diff --git a/lib/src/chart_style.dart b/lib/src/chart_style.dart index de0c6e4..5dc6697 100644 --- a/lib/src/chart_style.dart +++ b/lib/src/chart_style.dart @@ -477,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, @@ -633,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; @@ -690,6 +700,7 @@ class ChartStyle { bool? axisLabelBackground, double? axisLabelPadding, double? priceAxisWidth, + double? secondaryPriceAxisWidth, double? labelCornerRadius, double? legendPadding, double? legendSpacing, @@ -735,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 493150d..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, @@ -820,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 @@ -2129,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, diff --git a/lib/src/renderer/base_chart_painter.dart b/lib/src/renderer/base_chart_painter.dart index 88b227a..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. @@ -228,10 +239,16 @@ 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(); diff --git a/lib/src/renderer/chart_painter.dart b/lib/src/renderer/chart_painter.dart index 49aeb00..9783f28 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, @@ -222,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 @@ -305,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; @@ -382,6 +396,8 @@ class ChartPainter extends BaseChartPainter { averageClose: showAverageClose ? _averageCloseInView : null, candleColor: candleColor, priceFormatter: priceFormatter, + secondaryScale: secondaryPriceAxisScale, + secondaryGutter: secondaryAxisGutter, priceAxisGutter: priceAxisGutter, priceAxisGutterOnLeft: priceAxisOnLeft, ); diff --git a/lib/src/renderer/main_renderer.dart b/lib/src/renderer/main_renderer.dart index caf0ac7..93ee1cb 100644 --- a/lib/src/renderer/main_renderer.dart +++ b/lib/src/renderer/main_renderer.dart @@ -53,6 +53,8 @@ class MainRenderer extends BaseChartRenderer { this.averageClose, this.candleColor, this.priceFormatter, + this.secondaryScale, + this.secondaryGutter = 0.0, super.priceAxisGutter = 0.0, super.priceAxisGutterOnLeft = false, }) : super( @@ -155,15 +157,33 @@ class MainRenderer extends BaseChartRenderer { /// 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 formatPrice(price); - return switch (priceScale) { + return switch (scale) { PriceAxisScale.percentage => () { final move = (price / base - 1) * 100; return '${move >= 0 ? '+' : ''}${move.toStringAsFixed(2)}%'; @@ -940,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 @@ -965,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); @@ -973,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, @@ -1002,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/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'); + }); + }); +} From e3abeccfe510ad4ffaaac68751b7455d768e1c4c Mon Sep 17 00:00:00 2001 From: Mohammad Zarif Date: Sat, 12 Sep 2026 15:30:06 +0330 Subject: [PATCH 6/7] test: show the five changes in the example and in the goldens The intraday demo was working the candle spacing out by hand, which is what ChartStyle.fitContent now does; it sets the flag instead. The tour app gets a toggle for each of the rest: a locked axis that follows the price, a level the axis cannot reach, fitting the candles to the width, and prices written as currency. Goldens for four of them, including a short series before and after fitting, which is the complaint the issue opened with. Drawing those turned up a background filled from the canvas edge for the plot's width: a gutter on the right showed whatever was under the widget, and a gutter on the left shifted the fill. Every band now spans the canvas. --- CHANGELOG.md | 6 +++ example/lib/intraday_demo.dart | 66 +++++++++++++--------------- example/lib/src/chart_page.dart | 2 + example/lib/src/controls.dart | 25 +++++++++++ example/lib/src/demo_state.dart | 50 +++++++++++++++++++++ lib/src/renderer/chart_painter.dart | 9 ++-- test/golden_test.dart | 66 ++++++++++++++++++++++++++++ test/goldens/fit_content.png | Bin 0 -> 6639 bytes test/goldens/level_off_axis.png | Bin 0 -> 10747 bytes test/goldens/price_formatter.png | Bin 0 -> 10247 bytes test/goldens/secondary_axis.png | Bin 0 -> 8532 bytes test/goldens/short_series.png | Bin 0 -> 5982 bytes 12 files changed, 185 insertions(+), 39 deletions(-) create mode 100644 test/goldens/fit_content.png create mode 100644 test/goldens/level_off_axis.png create mode 100644 test/goldens/price_formatter.png create mode 100644 test/goldens/secondary_axis.png create mode 100644 test/goldens/short_series.png diff --git a/CHANGELOG.md b/CHANGELOG.md index fc03639..6b15ced 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ### 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 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 439354d..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, @@ -140,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 613d47e..2108bd8 100644 --- a/example/lib/src/controls.dart +++ b/example/lib/src/controls.dart @@ -439,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 a7e9667..60a3deb 100644 --- a/example/lib/src/demo_state.dart +++ b/example/lib/src/demo_state.dart @@ -265,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; @@ -308,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/renderer/chart_painter.dart b/lib/src/renderer/chart_painter.dart index 9783f28..ccaa183 100644 --- a/lib/src/renderer/chart_painter.dart +++ b/lib/src/renderer/chart_painter.dart @@ -463,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); @@ -475,7 +478,7 @@ class ChartPainter extends BaseChartPainter { final volRect = Rect.fromLTRB( 0, mVolRect!.top - mChildPadding, - mVolRect!.width, + mCanvasWidth, mVolRect!.bottom, ); canvas.drawRect(volRect, mBgPaint); @@ -486,7 +489,7 @@ class ChartPainter extends BaseChartPainter { final secondaryRect = Rect.fromLTRB( 0, mSecondaryRect.top - mChildPadding, - mSecondaryRect.width, + mCanvasWidth, mSecondaryRect.bottom, ); canvas.drawRect(secondaryRect, mBgPaint); 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 0000000000000000000000000000000000000000..ebce98700941d1d9c2fb5aaa3747fed6a0eb9bc4 GIT binary patch literal 6639 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 Mpyf}Q`>|L54-86qm;e9( literal 0 HcmV?d00001 diff --git a/test/goldens/price_formatter.png b/test/goldens/price_formatter.png new file mode 100644 index 0000000000000000000000000000000000000000..e1485848d0fdee73a6c8996bbb8e063fb791edfb GIT binary patch literal 10247 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 J-fow3{|oYFU%3DP literal 0 HcmV?d00001 diff --git a/test/goldens/secondary_axis.png b/test/goldens/secondary_axis.png new file mode 100644 index 0000000000000000000000000000000000000000..e3d3e078c9fa37e975f214e9e26b694c63cb11af GIT binary patch literal 8532 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 C@Aw}8 literal 0 HcmV?d00001 From 12eb8d1089d93953f9c917f351c1d2873769b666 Mon Sep 17 00:00:00 2001 From: Mohammad Zarif Date: Sat, 12 Sep 2026 16:28:44 +0330 Subject: [PATCH 7/7] chore: release 2.4.1 Five changes, one for each point raised in issue #3 about the intraday chart: candles fitted to the width, a price the locked axis cannot reach held inside the candle area, a locked axis that can follow the price, a formatter for the prices the chart writes, and a second price axis. --- CHANGELOG.md | 6 +++++- README.md | 2 +- pubspec.yaml | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b15ced..dfe34a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,8 @@ -## Unreleased +## 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 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/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