-
Notifications
You must be signed in to change notification settings - Fork 85
Expand file tree
/
Copy pathinteractive_chart.dart
More file actions
371 lines (332 loc) · 13.2 KB
/
interactive_chart.dart
File metadata and controls
371 lines (332 loc) · 13.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
import 'dart:math';
import 'package:flutter/gestures.dart';
import 'package:flutter/widgets.dart';
import 'package:intl/intl.dart' as intl;
import 'candle_data.dart';
import 'chart_painter.dart';
import 'chart_style.dart';
import 'painter_params.dart';
class InteractiveChart extends StatefulWidget {
/// The full list of [CandleData] to be used for this chart.
///
/// It needs to have at least 3 data points. If data is sufficiently large,
/// the chart will default to display the most recent 90 data points when
/// first opened (configurable with [initialVisibleCandleCount] parameter),
/// and allow users to freely zoom and pan however they like.
final List<CandleData> candles;
/// The default number of data points to be displayed when the chart is first
/// opened. The default value is 90. If [CandleData] does not have enough data
/// points, the chart will display all of them.
final int initialVisibleCandleCount;
/// If non-null, the style to use for this chart.
final ChartStyle style;
/// How the date/time label at the bottom are displayed.
///
/// If null, it defaults to use yyyy-mm format if more than 20 data points
/// are visible in the current chart window, otherwise it uses mm-dd format.
final TimeLabelGetter? timeLabel;
/// How the price labels on the right are displayed.
///
/// If null, it defaults to show 2 digits after the decimal point.
final PriceLabelGetter? priceLabel;
/// How the overlay info are displayed, when user touches the chart.
///
/// If null, it defaults to display `date`, `open`, `high`, `low`, `close`
/// and `volume` fields when user selects a data point in the chart.
///
/// To customize it, pass in a function that returns a Map<String,String>:
/// ```dart
/// return {
/// "Date": "Customized date string goes here",
/// "Open": candle.open?.toStringAsFixed(2) ?? "-",
/// "Close": candle.close?.toStringAsFixed(2) ?? "-",
/// };
/// ```
final OverlayInfoGetter? overlayInfo;
/// An optional event, fired when the user clicks on a candlestick.
final ValueChanged<CandleData>? onTap;
/// An optional event, fired when user zooms in/out.
///
/// This provides the width of a candlestick at the current zoom level.
final ValueChanged<double>? onCandleResize;
const InteractiveChart({
Key? key,
required this.candles,
this.initialVisibleCandleCount = 90,
ChartStyle? style,
this.timeLabel,
this.priceLabel,
this.overlayInfo,
this.onTap,
this.onCandleResize,
}) : this.style = style ?? const ChartStyle(),
assert(candles.length >= 3,
"InteractiveChart requires 3 or more CandleData"),
assert(initialVisibleCandleCount >= 3,
"initialVisibleCandleCount must be more 3 or more"),
super(key: key);
@override
_InteractiveChartState createState() => _InteractiveChartState();
}
class _InteractiveChartState extends State<InteractiveChart> {
// The width of an individual bar in the chart.
late double _candleWidth;
// The x offset (in px) of current visible chart window,
// measured against the beginning of the chart.
// i.e. a value of 0.0 means we are displaying data for the very first day,
// and a value of 20 * _candleWidth would be skipping the first 20 days.
late double _startOffset;
// The position that user is currently tapping, null if user let go.
Offset? _tapPosition;
double? _prevChartWidth; // used by _handleResize
late double _prevCandleWidth;
late double _prevStartOffset;
late Offset _initialFocalPoint;
PainterParams? _prevParams; // used in onTapUp event
@override
void didUpdateWidget(covariant InteractiveChart oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.candles.length < widget.candles.length) {
// Change offset to show the latest candle when new data is added
_startOffset =
max(0, widget.candles.length * _candleWidth - _prevChartWidth!);
}
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
final size = constraints.biggest;
final w = size.width - widget.style.priceLabelWidth;
_handleResize(w);
// Find the visible data range
final int start = (_startOffset / _candleWidth).floor();
final int count = (w / _candleWidth).ceil();
final int end = (start + count).clamp(start, widget.candles.length);
final candlesInRange = widget.candles.getRange(start, end).toList();
if (end < widget.candles.length) {
// Put in an extra item, since it can become visible when scrolling
final nextItem = widget.candles[end];
candlesInRange.add(nextItem);
}
// If possible, find neighbouring trend line data,
// so the chart could draw better-connected lines
final leadingTrends = widget.candles.at(start - 1)?.trends;
final trailingTrends = widget.candles.at(end + 1)?.trends;
// Find the horizontal shift needed when drawing the candles.
// First, always shift the chart by half a candle, because when we
// draw a line using a thick paint, it spreads to both sides.
// Then, we find out how much "fraction" of a candle is visible, since
// when users scroll, they don't always stop at exact intervals.
final halfCandle = _candleWidth / 2;
final fractionCandle = _startOffset - start * _candleWidth;
final xShift = halfCandle - fractionCandle;
// Calculate min and max among the visible data
double? highest(CandleData c) {
if (c.high != null) return c.high;
if (c.open != null && c.close != null) return max(c.open!, c.close!);
return c.open ?? c.close;
}
double? lowest(CandleData c) {
if (c.low != null) return c.low;
if (c.open != null && c.close != null) return min(c.open!, c.close!);
return c.open ?? c.close;
}
final maxPrice =
candlesInRange.map(highest).whereType<double>().reduce(max);
final minPrice =
candlesInRange.map(lowest).whereType<double>().reduce(min);
final maxVol = candlesInRange
.map((c) => c.volume)
.whereType<double>()
.fold(double.negativeInfinity, max);
final minVol = candlesInRange
.map((c) => c.volume)
.whereType<double>()
.fold(double.infinity, min);
final child = TweenAnimationBuilder(
tween: PainterParamsTween(
end: PainterParams(
candles: candlesInRange,
style: widget.style,
size: size,
candleWidth: _candleWidth,
startOffset: _startOffset,
maxPrice: maxPrice,
minPrice: minPrice,
maxVol: maxVol,
minVol: minVol,
xShift: xShift,
tapPosition: _tapPosition,
leadingTrends: leadingTrends,
trailingTrends: trailingTrends,
),
),
duration: Duration(milliseconds: 300),
curve: Curves.easeOut,
builder: (_, PainterParams params, __) {
_prevParams = params;
return RepaintBoundary(
child: CustomPaint(
size: size,
painter: ChartPainter(
params: params,
getTimeLabel: widget.timeLabel ?? defaultTimeLabel,
getPriceLabel: widget.priceLabel ?? defaultPriceLabel,
getOverlayInfo: widget.overlayInfo ?? defaultOverlayInfo,
),
),
);
},
);
return Listener(
onPointerSignal: (signal) {
if (signal is PointerScrollEvent) {
final dy = signal.scrollDelta.dy;
if (dy.abs() > 0) {
_onScaleStart(signal.position);
_onScaleUpdate(
dy > 0 ? 0.9 : 1.1,
signal.position,
w,
);
}
}
},
child: GestureDetector(
// Tap and hold to view candle details
onTapDown: (details) => setState(() {
_tapPosition = details.localPosition;
}),
onTapCancel: () => setState(() => _tapPosition = null),
onTapUp: (_) {
// Fire callback event and reset _tapPosition
if (widget.onTap != null) _fireOnTapEvent();
setState(() => _tapPosition = null);
},
// Pan and zoom
onScaleStart: (details) => _onScaleStart(details.localFocalPoint),
onScaleUpdate: (details) =>
_onScaleUpdate(details.scale, details.localFocalPoint, w),
child: child,
),
);
},
);
}
_onScaleStart(Offset focalPoint) {
_prevCandleWidth = _candleWidth;
_prevStartOffset = _startOffset;
_initialFocalPoint = focalPoint;
}
_onScaleUpdate(double scale, Offset focalPoint, double w) {
// Handle zoom
final candleWidth = (_prevCandleWidth * scale)
.clamp(_getMinCandleWidth(w), _getMaxCandleWidth(w));
final clampedScale = candleWidth / _prevCandleWidth;
var startOffset = _prevStartOffset * clampedScale;
// Handle pan
final dx = (focalPoint - _initialFocalPoint).dx * -1;
startOffset += dx;
// Adjust pan when zooming
final double prevCount = w / _prevCandleWidth;
final double currCount = w / candleWidth;
final zoomAdjustment = (currCount - prevCount) * candleWidth;
final focalPointFactor = focalPoint.dx / w;
startOffset -= zoomAdjustment * focalPointFactor;
startOffset = startOffset.clamp(0, _getMaxStartOffset(w, candleWidth));
// Fire candle width resize event
if (candleWidth != _candleWidth) {
widget.onCandleResize?.call(candleWidth);
}
// Apply changes
setState(() {
_candleWidth = candleWidth;
_startOffset = startOffset;
});
}
_handleResize(double w) {
if (w == _prevChartWidth) return;
if (_prevChartWidth != null) {
// Re-clamp when size changes (e.g. screen rotation)
_candleWidth = _candleWidth.clamp(
_getMinCandleWidth(w),
_getMaxCandleWidth(w),
);
_startOffset = _startOffset.clamp(
0,
_getMaxStartOffset(w, _candleWidth),
);
} else {
// Default zoom level. Defaults to a 90 day chart, but configurable.
// If data is shorter, we use the whole range.
final count = min(
widget.candles.length,
widget.initialVisibleCandleCount,
);
_candleWidth = w / count;
// Default show the latest available data, e.g. the most recent 90 days.
_startOffset = (widget.candles.length - count) * _candleWidth;
}
_prevChartWidth = w;
}
// The narrowest candle width, i.e. when drawing all available data points.
double _getMinCandleWidth(double w) => w / widget.candles.length;
// The widest candle width, e.g. when drawing 14 day chart
double _getMaxCandleWidth(double w) => w / min(14, widget.candles.length);
// Max start offset: how far can we scroll towards the end of the chart
double _getMaxStartOffset(double w, double candleWidth) {
final count = w / candleWidth; // visible candles in the window
final start = widget.candles.length - count;
return max(0, candleWidth * start);
}
String defaultTimeLabel(int timestamp, int visibleDataCount) {
final date = DateTime.fromMillisecondsSinceEpoch(timestamp)
.toIso8601String()
.split("T")
.first
.split("-");
if (visibleDataCount > 20) {
// If more than 20 data points are visible, we should show year and month.
return "${date[0]}-${date[1]}"; // yyyy-mm
} else {
// Otherwise, we should show month and date.
return "${date[1]}-${date[2]}"; // mm-dd
}
}
String defaultPriceLabel(double price) => price.toStringAsFixed(2);
Map<String, String> defaultOverlayInfo(CandleData candle) {
final date = intl.DateFormat.yMMMd()
.format(DateTime.fromMillisecondsSinceEpoch(candle.timestamp));
return {
"Date": date,
"Open": candle.open?.toStringAsFixed(2) ?? "-",
"High": candle.high?.toStringAsFixed(2) ?? "-",
"Low": candle.low?.toStringAsFixed(2) ?? "-",
"Close": candle.close?.toStringAsFixed(2) ?? "-",
"Volume": candle.volume?.asAbbreviated() ?? "-",
};
}
void _fireOnTapEvent() {
if (_prevParams == null || _tapPosition == null) return;
final params = _prevParams!;
final dx = _tapPosition!.dx;
final selected = params.getCandleIndexFromOffset(dx);
final candle = params.candles[selected];
widget.onTap?.call(candle);
}
}
extension Formatting on double {
String asPercent() {
final format = this < 100 ? "##0.00" : "#,###";
final v = intl.NumberFormat(format, "en_US").format(this);
return "${this >= 0 ? '+' : ''}$v%";
}
String asAbbreviated() {
if (this < 1000) return this.toStringAsFixed(3);
if (this >= 1e18) return this.toStringAsExponential(3);
final s = intl.NumberFormat("#,###", "en_US").format(this).split(",");
const suffixes = ["K", "M", "B", "T", "Q"];
return "${s[0]}.${s[1]}${suffixes[s.length - 2]}";
}
}