Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,14 @@
## 3.1.1 - 2026-10-12

### Fixed

- Histogram bars in an indicator pane now widen with the candles. They stayed at
`ChartStyle.macdWidth` while the candles grew, so MACD bars — and any custom
histogram indicator — were hairline-thin beside candles spread by
`ChartStyle.fitContent`, and beside candles zoomed in. They follow both now,
and a histogram drawn over the candles follows the zoom too. A bar is never
thinner than a pixel, so zooming far out does not make them vanish.

## 3.1.0 - 2026-10-06

### Added
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -387,7 +387,7 @@ indicator and drawing tool is included; there is no paid tier.
```yaml
dependencies:
material_ui: ">=1.0.0 <2.0.0"
ohlcv_chart: ">=3.1.0 <4.0.0"
ohlcv_chart: ">=3.1.1 <4.0.0"
```

Every widget here is built on the `material_ui` package rather than the
Expand Down
9 changes: 6 additions & 3 deletions lib/src/chart_style.dart
Original file line number Diff line number Diff line change
Expand Up @@ -523,8 +523,9 @@ class ChartStyle {
/// [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.
/// the series span the plot, and [candleWidth], [ChartStyle.volWidth] and
/// [ChartStyle.macdWidth] 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.
Expand All @@ -534,7 +535,9 @@ class ChartStyle {
///vol column width
final double volWidth;

///macd column width
/// Width of the histogram bars in an indicator pane, MACD or custom.
/// The bars follow the candles: they widen when [fitContent] spreads them and
/// when the chart is zoomed in, and are never thinner than a pixel.
final double macdWidth;

/// Stroke width of an indicator's lines, in a pane or over the candles.
Expand Down
1 change: 1 addition & 0 deletions lib/src/renderer/base_chart_painter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,7 @@ abstract class BaseChartPainter extends CustomPainter {
pointWidth: fitted,
candleWidth: chartStyle.candleWidth * spread,
volWidth: chartStyle.volWidth * spread,
macdWidth: chartStyle.macdWidth * spread,
);
}
mDataLen = mItemCount * mPointWidth;
Expand Down
3 changes: 2 additions & 1 deletion lib/src/renderer/chart_painter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -423,7 +423,7 @@ class ChartPainter extends BaseChartPainter {
mSecondaryRectList[i].mMinValue,
mChildPadding,
fixedLength,
chartStyle,
fittedStyle,
chartColors,
panes[i],
percentBase: _paneBase(panes[i]),
Expand Down Expand Up @@ -662,6 +662,7 @@ class ChartPainter extends BaseChartPainter {
start: mStartIndex,
stop: mStopIndex,
xOf: xOf,
scaleX: scaleX,
);
}
}
Expand Down
4 changes: 3 additions & 1 deletion lib/src/renderer/indicator_pane_renderer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ class IndicatorPaneRenderer extends BaseChartRenderer<KLineEntity> {
required int start,
required int stop,
required double Function(int index) xOf,
double scaleX = 1.0,
}) {
canvas.save();
canvas.clipRect(
Expand All @@ -156,7 +157,8 @@ class IndicatorPaneRenderer extends BaseChartRenderer<KLineEntity> {
yOf: getY,
colors: chartColors,
strokeWidth: chartStyle.indicatorLineWidth,
barWidth: chartStyle.macdWidth,
// View space, so the bar takes the zoom the candles take from the canvas.
barWidth: chartStyle.macdWidth * scaleX,
zeroY: getY(0).clamp(chartRect.top, chartRect.bottom),
);
canvas.restore();
Expand Down
4 changes: 3 additions & 1 deletion lib/src/renderer/main_renderer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -449,7 +449,9 @@ class MainRenderer extends BaseChartRenderer<CandleEntity> {
yOf: getY,
colors: chartColors,
strokeWidth: chartStyle.indicatorLineWidth,
barWidth: chartStyle.candleWidth,
// View space, so the bar takes the zoom the candles take from the
// canvas.
barWidth: chartStyle.candleWidth * scaleX,
);
}
canvas.restore();
Expand Down
6 changes: 5 additions & 1 deletion lib/src/renderer/series_painter.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'dart:math' as math;

import 'package:material_ui/material_ui.dart';

import '../chart_style.dart';
Expand Down Expand Up @@ -227,7 +229,9 @@ void _paintHistogram(
required double zeroY,
}) {
final paint = Paint()..isAntiAlias = true;
final half = barWidth / 2;
// Zoomed far out the candles shrink with the bar; a bar thinner than a hairline
// would vanish.
final half = math.max(barWidth, 1.0) / 2;

for (var i = start; i <= stop; i++) {
final value = resolved.valueAt(line, i);
Expand Down
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ description: >-
Candlestick charts for Flutter with 31 indicators, 29 drawing tools and 39
more chart widgets: depth, order flow, series, pie, treemap, sankey. Pure
CustomPainter, no WebView.
version: 3.1.0
version: 3.1.1
homepage: https://github.com/CtrlAltDevelop
repository: https://github.com/CtrlAltDevelop/ohlcv_chart
issue_tracker: https://github.com/CtrlAltDevelop/ohlcv_chart/issues
Expand Down
165 changes: 164 additions & 1 deletion test/fit_content_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ ChartPainter _painterOf(WidgetTester tester) {
}

/// A chart too short to fill its box at the default spacing.
Widget _chart({required bool fitContent, int count = 10}) {
Widget _chart({
required bool fitContent,
int count = 10,
List<Indicator> indicators = const [],
}) {
final data = candles(rampThenFall(count));
DataUtil.calculate(data);

Expand All @@ -30,13 +34,143 @@ Widget _chart({required bool fitContent, int count = 10}) {
volHidden: true,
showNowPrice: false,
chartStyle: ChartStyle(fitContent: fitContent),
indicators: indicators,
),
),
),
);
}

/// The widths of the rects an indicator pane draws across its candles.
List<double> _paneBarWidths(WidgetTester tester, {required double scaleX}) {
final painter = _painterOf(tester);
final canvas = TestRecordingCanvas();
painter.mIndicatorPaneList.single.drawSeries(
canvas,
painter.candles!,
start: 0,
stop: painter.candles!.length - 1,
xOf: (i) => i * 10.0,
scaleX: scaleX,
);
final widths = [
for (final call in canvas.invocations)
if (call.invocation.memberName == #drawRect)
(call.invocation.positionalArguments.first as Rect).width,
];
// everyElement passes on an empty list, which would hide a pane drawing none.
expect(widths, isNotEmpty);
return widths;
}

/// Bars drawn over the candles, one per candle, from zero.
class _OverlayBars extends Indicator {
@override
String get name => 'OVERLAYBARS';

@override
String get label => name;

@override
IndicatorPlacement get placement => IndicatorPlacement.overlay;

@override
List<IndicatorLine> get lines => const [
IndicatorLine('V', shape: IndicatorShape.histogram),
];

@override
List<Object?> get settings => [name];

@override
IndicatorSeries compute(List<KLineEntity> candles) => IndicatorSeries([
[for (final c in candles) c.close],
]);

@override
Color defaultColor(int line, ChartColors theme, int ordinal) =>
theme.ma5Color;
}

Future<void> _pinchOut(WidgetTester tester) async {
final centre = tester.getCenter(find.byType(KChartWidget));
final left = await tester.startGesture(centre - const Offset(40, 0));
final right = await tester.startGesture(centre + const Offset(40, 0));
await left.moveBy(const Offset(-90, 0));
await right.moveBy(const Offset(90, 0));
await tester.pump();
await left.up();
await right.up();
await tester.pumpAndSettle();
}

void main() {
group('indicator pane bars', () {
testWidgets('over the candles, widen with the zoom too', (tester) async {
await tester.pumpWidget(
_chart(fitContent: false, count: 200, indicators: [_OverlayBars()]),
);
await _pinchOut(tester);
final painter = _painterOf(tester);
expect(painter.scaleX, greaterThan(1.2));

final canvas = TestRecordingCanvas();
painter.paint(canvas, tester.getSize(find.byType(KChartWidget)));

final widths = [
for (final call in canvas.invocations)
if (call.invocation.memberName == #drawRect)
(call.invocation.positionalArguments.first as Rect).width,
];
expect(
widths,
contains(
closeTo(const ChartStyle().candleWidth * painter.scaleX, 0.001),
),
);
});

testWidgets('the chart hands its zoom to the panes', (tester) async {
await tester.pumpWidget(
_chart(fitContent: false, count: 200, indicators: [MacdIndicator()]),
);
await _pinchOut(tester);
final painter = _painterOf(tester);
expect(painter.scaleX, greaterThan(1.2));

final canvas = TestRecordingCanvas();
painter.paint(canvas, tester.getSize(find.byType(KChartWidget)));

final widths = [
for (final call in canvas.invocations)
if (call.invocation.memberName == #drawRect)
(call.invocation.positionalArguments.first as Rect).width,
];
expect(
widths,
contains(closeTo(const ChartStyle().macdWidth * painter.scaleX, 0.001)),
);
});

testWidgets('widen with the zoom, as the candles do', (tester) async {
await tester.pumpWidget(
_chart(fitContent: false, indicators: [MacdIndicator()]),
);
final width = const ChartStyle().macdWidth;

expect(_paneBarWidths(tester, scaleX: 1), everyElement(width));
expect(_paneBarWidths(tester, scaleX: 3), everyElement(width * 3));
});

testWidgets('never shrink past a hairline when zoomed out', (tester) async {
await tester.pumpWidget(
_chart(fitContent: false, indicators: [MacdIndicator()]),
);

expect(_paneBarWidths(tester, scaleX: 0.1), everyElement(1.0));
});
});

group('ChartStyle.fitContent', () {
testWidgets('off, a short series keeps the fixed spacing', (tester) async {
await tester.pumpWidget(_chart(fitContent: false));
Expand Down Expand Up @@ -67,6 +201,35 @@ void main() {
);
});

testWidgets('on, an indicator pane\'s bars widen with the spacing', (
tester,
) async {
await tester.pumpWidget(
_chart(fitContent: true, indicators: [MacdIndicator()]),
);
final painter = _painterOf(tester);

const style = ChartStyle();
final spread = painter.mPointWidth / style.pointWidth;
expect(
painter.mIndicatorPaneList.single.chartStyle.macdWidth,
closeTo(style.macdWidth * spread, 0.001),
);
});

testWidgets('off, an indicator pane\'s bars keep their width', (
tester,
) async {
await tester.pumpWidget(
_chart(fitContent: false, indicators: [MacdIndicator()]),
);

expect(
_painterOf(tester).mIndicatorPaneList.single.chartStyle.macdWidth,
const ChartStyle().macdWidth,
);
});

testWidgets('on, a series that already fills the plot is left alone', (
tester,
) async {
Expand Down
Loading