ohlcv_chart 2.1.0 copy "ohlcv_chart: ^2.1.0" to clipboard
ohlcv_chart: ^2.1.0 copied to clipboard

Candlestick and market-depth charts for Flutter, with MA, BOLL and SAR overlays, MACD, KDJ, RSI, WR and CCI sub-charts, signal markers and interactive drawing tools.

ohlcv_chart #

A candlestick (K-line) and market-depth chart for Flutter, drawn entirely with CustomPainter — no WebView, no JavaScript bridge.

Named for the open-high-low-close-volume bars it renders.

Candles with moving averages, volume and MACD

Features #

  • Eight chart types — candles, OHLC bars, a line, a step line, a filled area, an HLC area, a baseline chart and columns — plus Heikin-Ashi, Renko, three-line break, Kagi, point & figure and range bars as transforms of the candles themselves.
  • 29 indicators, each a configured instance rather than a flag — so ATR(8), ATR(14) and ATR(20) are three panes, with their own settings and colours.
  • Main-chart overlaysMA, EMA, BOLL, SAR, VWAP and anchored VWAP, Supertrend, Keltner and Donchian channels, the Ichimoku Cloud, pivot points and a volume profile.
  • Swing readers — a zigzag through the swing highs and lows, Fibonacci retracement of the last swing, and Elliott wave labels, all sized to the market by default.
  • Sub-chartsMACD, KDJ, RSI, WR, CCI, ATR, OBV, MFI, DMI, Stochastic RSI, ROC, TRIX, a volume average and the Awesome oscillator, each in its own stacked pane — resizable and reorderable by dragging.
  • Linear, logarithmic, percentage or indexed-to-100 price axis, so a decade of compounding reads as well as an afternoon — invertible, with the window's high, low and average close markable on it.
  • A price scale you can drag — pull the labels to stretch or flatten the candles, drag the chart to slide the window, double-tap to fit it back; from code as well, through the controller.
  • Axes that read as round numbers — both axes choose their values first and are ruled where the labels fall, so a price scale steps 69000, 69500, 70000 and an intraday date axis lands on the hour, never on whatever value happened to fall on an evenly spaced pixel.
  • 29 drawing tools — levels and rays, trend lines, arrows, extended lines, boxes, ellipses, triangles, parallel channels, pitchforks, Gann fans and boxes, four Fibonacci tools, a regression trend with its bands, XABCD patterns, multi-leg paths, price and date brackets, a measuring tool, planned positions with their risk-to-reward, notes, callouts, flags and freehand strokes; each placed by tap or drag, then dragged, locked, hidden and removed.
  • A line editor that opens on selection: colour, opacity, thickness, solid/dashed/dotted stroke, fill, label text and visibility, alerts, lock and delete — every option list, control and pixel of it configurable through DrawingStyle.
  • Undo and redo, through a ChartDrawingController that owns the drawings and their history, with ⌘Z, ⇧⌘Z and Delete on the chart itself.
  • Select several and edit them together — shift- or ⌘-click, or ⌘A; then drag, restyle or delete the lot in one step, with ⌘C/⌘V/⌘D to copy and ⌘]/⌘[ to restack.
  • Exact coordinates — a dialog over any drawing's anchors, each price and candle typed in rather than placed by eye.
  • Style templates — save one drawing's look and put it on the rest.
  • A layout that persists — every drawing serialises, so jsonEncode(drawings.toJson()) and ChartDrawings.fromJson are the whole story.
  • A drawing manager — a ready-made panel listing what is drawn, with show/hide, lock, delete, undo, redo and clear.
  • Price alerts on a level, a trend line, either side of a channel or every step of a retracement, reported when the market crosses one.
  • Indicator alerts — an RSI over 70, a histogram turning positive — and panes that can be logarithmic or read as a percentage, with any indicator computable over another's output.
  • A right-click menu on the chart and on any drawing — coordinates, duplicate, restack, lock, hide, alert, delete; paste, select all, fit the scale, undo — with your own items added through one builder.
  • Crosshair on hover and an OHLC legend above the chart, which is how a chart reads on a desktop.
  • Driven from your own codeKChartController zooms, scrolls back to the live candle and hands you the chart as a PNG.
  • The visible window, read and set — which candles are on screen, go-to-date, fit-to-screen, and a callback whenever it moves.
  • Compare a second instrument — overlay any number of other series, rebased so relative performance reads off the same axis, matched to your candles by time.
  • Bar replay — rewind to any candle and step or play the market forward, with the indicators only knowing what has arrived.
  • Buy/sell signal markers pinned to candles.
  • Event marks — earnings, dividends, splits and news badged under the candle they happened on, and tappable.
  • Orders and positions — live lines from your venue, tagged with side, size and P&L, and draggable to amend.
  • Extended-hours shading — the pre-market and after-hours stretches washed behind the candles — and a per-bar colour of your own.
  • Depth chart — a separate DepthChart widget for the order book, drawn as the cumulative curve, a per-rung histogram, both at once, or a numeric ladder of price, size and running total, on a linear, log or percentage axis and zoomable to the levels around the mid.
  • Info dialog on long press, either the built-in Material popup or your own builder.
  • "Now price" line with a live countdown to the close of the current candle.
  • Session dividers and a display time zone, so an intraday chart breaks where the trader's day does.
  • SVG watermark loaded from an asset path you supply.
  • Fully themeableChartStyle for geometry, ChartColors for every colour, DrawingStyle for the drawing tools; ChartTranslations for every label. Filled or hollow candles, dashed or solid crosshair, pane separators, axis-label pills and a placed, tinted watermark.
  • Fits its box — the candles take whatever height the volume and indicator panes leave, so the chart works from a phone to a desktop window without arithmetic on your side.

Contents #

Install #

dependencies:
  ohlcv_chart: ^2.1.0

Usage #

Candlestick chart #

Feed it a List<KLineEntity>. Indicator values are computed in place by DataUtil.calculate before the first paint, and again whenever new candles arrive:

import 'package:ohlcv_chart/ohlcv_chart.dart';

DataUtil.calculate(candles);

KChartWidget(
  candles,
  ChartColors(),
  isTrendLine: false,
  watermarkAssetPath: 'assets/logo.svg',
  timeFrame: const Duration(minutes: 15),
  indicators: [MaIndicator(period: 20), BollIndicator(), MacdIndicator()],
  timeFormat: TimeFormat.YEAR_MONTH_DAY_WITH_HOUR,
  fixedLength: 2,
  onLoadMore: (isRight) {
    if (!isRight) fetchOlderCandles();
  },
);

KLineEntity.fromJson accepts the usual OHLCV shape (open, high, low, close, vol, time/id), or build the entity directly.

Indicators #

Indicators are instances, not flags: pass as many as you like to indicators, including several of the same kind with different settings. Overlays draw over the candles; everything else takes a pane of its own, stacked in the order given.

KChartWidget(
  candles,
  ChartColors(),
  isTrendLine: false,
  watermarkAssetPath: 'assets/logo.svg',
  timeFrame: const Duration(minutes: 15),
  indicators: [
    MaIndicator(period: 7),
    MaIndicator(period: 25),
    MaIndicator(period: 99, color: Colors.amber),
    AtrIndicator(period: 8),
    AtrIndicator(period: 14),
    AtrIndicator(period: 20),
  ],
);

The Ichimoku cloud and Supertrend, over Stochastic RSI and the Awesome oscillator

Overlay Settings
MaIndicator period — the simple moving average
EmaIndicator period
BollIndicator period, deviations
SarIndicator start, step, maximum
VwapIndicator
AnchoredVwapIndicator anchor — the candle it measures from
PivotPointsIndicator method, session; the pivot with three supports and resistances
VolumeProfileIndicator bins, valueArea; volume by price, drawn back from the axis
SupertrendIndicator period, multiplier; flips colour with the trend
KeltnerIndicator period, atrPeriod, multiplier
DonchianIndicator period
IchimokuIndicator conversionPeriod, basePeriod, spanPeriod, displacement; the cloud is shaded
ZigZagIndicator depth — the swing size, in percent
FibonacciIndicator depth, ratios; retraces the last swing
ElliottWaveIndicator depth; labels the swings 15, AC
Pane Settings
MacdIndicator fast, slow, signal
KdjIndicator period, kSmoothing, dSmoothing
RsiIndicator period, with 30/50/70 guides
WrIndicator period
CciIndicator period
AtrIndicator period
ObvIndicator
MfiIndicator period, with 20/80 guides
DmiIndicator period, with the 20 guide
StochRsiIndicator rsiPeriod, period, kSmoothing, dSmoothing, with 20/80 guides
RocIndicator period
TrixIndicator period, signalPeriod
VolumeMaIndicator period
AwesomeIndicator fast, slow

A zigzag, Fibonacci retracement and Elliott wave labels

Three of the overlays read the market's swings rather than a fixed window, all from the same depth — the percentage move that ends a swing: ZigZagIndicator draws the legs, FibonacciIndicator retraces the last one, and ElliottWaveIndicator counts them. Left at its default of 0, depth is sized from the candles — a fixed percentage that gives several swings on a volatile daily chart finds none at all on a quiet intraday one — and the label reads auto. Pass a positive number to set the threshold yourself. The wave count is a reading of the swings, not a rules-checked Elliott count; treat it as a sketch to confirm by eye.

IchimokuIndicator shifts its spans forward and its lagging line back as the indicator is drawn. The chart holds one value per candle, so the stretch of cloud that would project past the newest candle is not drawn.

Levels and profiles

Three overlays read price rather than a window of it.

VolumeProfileIndicator gathers the visible volume into bins price bands and draws them as horizontal bars running in from the side the price labels are not on, so the prices the market actually traded at read off the same axis as the candles. The busiest band — the point of control — is drawn whole in its own colour, the valueArea around it is washed across the width, and every other band is split into the volume that traded on rising candles and the volume that traded on falling ones. A candle's volume is spread evenly over the bands its range covers, which is as much as OHLCV can say; the ticks inside the candle are not known. ChartColors.profileUpColor, profileDownColor, profilePocColor and profileValueAreaColor colour it, profileColor sets one colour for the lot, and ChartStyle.profileWidth — a fraction of the chart's width — sizes the busiest bar.

PivotPointsIndicator works out the previous session's pivot and steps it, with three supports and three resistances, across the current one. PivotMethod.standard, .fibonacci and .camarilla space the levels differently, and PivotSession.day, .week, .month and .year say what counts as a session, so an intraday chart can pivot off the week instead of the day it opened in.

AnchoredVwapIndicator is a VWAP measured from one candle onwards rather than over the whole series, so it can be anchored to a high, a low, an earnings date or the open of a session. It says nothing before its anchor.

KChartWidget(
  data,
  ChartColors(),
  indicators: [
    VolumeProfileIndicator(bins: 32, valueArea: 0.7),
    PivotPointsIndicator(method: PivotMethod.fibonacci),
    AnchoredVwapIndicator(anchor: swingLow),
  ],
);

A volume profile and an anchored VWAP over the candles

An indicator of your own can draw a profile too: return an IndicatorProfile of ProfileBins from computeProfile and the chart draws the bars, picks out the busiest band and shades the value area. A delta or time profile is the same shape of answer.

Comparing a second instrument #

Hand comparisons a list of ComparisonSeries and each is drawn as a line over the candles. By default it is rebased: pinned to the main series at the left edge of the visible window, so the two lines start together and diverge by how differently they moved. That is what comparing two instruments means — relative performance, not price — and panning the chart moves the pin along with the window, so what is read is always the move over what is on screen.

KChartWidget(
  btcCandles,
  ChartColors(),
  comparisons: [
    ComparisonSeries.ofCandles(label: 'ETH', candles: ethCandles),
    ComparisonSeries(
      label: 'DXY',
      points: [for (final p in dollarIndex) (time: p.time, value: p.close)],
      color: Colors.tealAccent,
      style: LineStyle.dashed,
    ),
  ],
);

A second instrument rebased over the candles

ComparisonScale.price draws it at its own prices on the same axis instead, which is right where the two are quoted in the same units — a future against its spot, two tenors of one curve — and misleading where they are not.

Points are matched to candles by time rather than by position, so a compared instrument on a different bar still lines up: each candle takes the last point at or before its own time, and holds it until the next one arrives. A gap breaks the line rather than drawing across it, and candles before the comparison starts draw nothing at all. The price scale opens up to hold whatever the comparison does, and each one reads out its own move as a percentage on a legend row of its own. Colours come from ChartColors.comparisonColors, taken in turn, unless the series names its own.

The arithmetic is exported if you would rather do the drawing yourself: alignComparison lines a series up against a list of candles, comparisonAnchor works out where a rebased one is pinned over a window, and comparisonPriceAt maps one value to the price it draws at.

Pane options

An indicator's pane is fitted to its values and spaced evenly, which is right for almost everything. Two getters change that where it is not:

class LogObvIndicator extends ObvIndicator {
  @override
  IndicatorScale get scale => IndicatorScale.logarithmic;
}

IndicatorScale.logarithmic steps by ratio, so a doubling takes the same room wherever it happens — what a volume or an on-balance-volume pane wants, where the interesting range covers orders of magnitude. A pane whose values reach zero or below has no logarithm to space by and quietly falls back to linear. IndicatorScale.percentage reads out the move away from the first value in view, so panning moves the base along with the window. Both rule and label the pane in their own space, so a log pane's marks land on 1, 2 and 5 times each power of ten.

An indicator over an indicator

ChainedIndicator computes one indicator over another's output instead of over the candles:

KChartWidget(
  candles,
  ChartColors(),
  indicators: [
    MacdIndicator(),
    ChainedIndicator(
      source: MacdIndicator(),
      applied: RsiIndicator(period: 14),
    ),
    // A second smoothing of the MACD's signal line.
    ChainedIndicator(
      source: MacdIndicator(),
      applied: MaIndicator(period: 9),
      sourceLine: 1,
    ),
  ],
);

The source's chosen line is handed on as flat candles — open, high, low and close all the same value — which is what lets any indicator that reads closes be applied. One that reads the range or the volume instead (ATR, OBV, MFI) has nothing to read there and draws nothing; that is the caller's choice to make. The two warm-ups add up rather than the second starting from a guess, and the pane settings, guides, format and colours all come from the applied indicator. flattenToCandles is exported if you would rather do the wrapping yourself.

Indicator alerts

An indicator declares the levels worth watching, and the chart reports when the newest value crosses one:

class AlertingRsi extends RsiIndicator {
  AlertingRsi() : super(period: 14);

  @override
  List<IndicatorAlert> get alerts => const [
    IndicatorAlert(level: 70, label: 'overbought'),
    IndicatorAlert(level: 30, label: 'oversold'),
  ];
}

KChartWidget(
  candles,
  ChartColors(),
  indicators: [AlertingRsi()],
  onIndicatorAlert: (indicator, alert, candle, value) =>
      notifier.push('${indicator.name} ${alert.label}: $value'),
);

line picks which of the indicator's lines to watch — a MACD histogram turning positive is IndicatorAlert(level: 0, line: 2). Each fires once per crossing: the value has to come back through the level before it fires again. Overlays and panes are both watched.

Custom indicators

Subclass Indicator — give it a label, its lines, the settings that make it distinct and a compute — and the chart scales, draws, legends and labels it like a built-in one. Lines are drawn as a stroke, dots, a histogram, a pivotLine (straight across the candles with no value, which is what a zigzag needs) or markers (a dot with your own text beside it). fills shades the area between two lines, and colorForPoint colours a single point — how the Supertrend changes colour at a reversal.

Three ATRs at different periods, each in its own pane

The same kind of indicator, three times over, is the point of the list: each instance carries its own settings and colours.

Colours

Every indicator takes its colours from ChartColors, and a color (or colors, for the multi-line ones) argument overrides that per instance:

indicators: [
  MaIndicator(period: 20, color: Colors.amber),
  MacdIndicator(colors: [Colors.grey, Colors.blue, Colors.orange]),
],

Repeated indicators of one kind take successive theme colours, so three moving averages are three different colours without being told.

Adding and editing at runtime

An indicator is identified by its type and settings — colours are deliberately left out — so upsert restyles the one already on the chart instead of stacking a duplicate:

final indicators = <Indicator>[MaIndicator(period: 20)];

indicators.upsert(AtrIndicator(period: 14));                    // added
indicators.upsert(AtrIndicator(period: 14, color: Colors.red)); // recoloured
indicators.upsert(AtrIndicator(period: 20));                    // a second pane
indicators.toggle(RsiIndicator());                              // on, then off

Building a settings UI

The example app's add-indicator sheet, built from the catalog

indicatorCatalog describes every indicator — its settings, their ranges and its colour slots — so an "add indicator" sheet can be driven by data rather than a hard-coded list. The example app's IndicatorSheet is built entirely from it:

final type = indicatorCatalog.firstWhere((t) => t.name == 'ATR');

for (final setting in type.settings) {
  print('${setting.label}: ${setting.defaultValue} '
      '(${setting.min}–${setting.max})');
}

// What the colour pickers should show, per line.
final labels = type.lineLabels();                     // ['ATR']
final defaults = type.defaultColors(ChartColors());   // theme colours

// Build one from the user's choices.
final indicator = type.create(
  values: {'period': 8},
  colors: [Colors.purple],
);

// And read an existing one back into the form.
final values = indicatorTypeOf(indicator)?.valuesOf(indicator); // {'period': 8}

DataUtil.calculate still fills the indicator fields on each KLineEntity, which the long-press readout uses; the indicators themselves compute their own values from the candles.

Chart types #

chartType decides what the candle area draws:

KChartWidget(
  candles,
  ChartColors(),
  isTrendLine: false,
  watermarkAssetPath: 'assets/logo.svg',
  timeFrame: const Duration(minutes: 15),
  chartType: ChartType.bars,       // candles, bars, line, area, baseline
  baselinePrice: 42_000,           // baseline only; defaults to the oldest close in view
);

Bars, baseline, area, step line, HLC area and columns

ChartType Draws
candles a filled or hollow body with a wick — the default
bars the high-low range, open ticked left and close ticked right
line a line through the closes
area the same line with the area beneath it washed in
baseline the line washed towards a level, up-coloured above it and down-coloured below
stepLine the same line, holding each close flat until the next one
hlcArea the high-low range washed in, with the close drawn through it
columns a column per candle, from the baseline to the close

isLine: true still means ChartType.area, so nothing written against the older API changes behaviour.

Transformed candles

Heikin-Ashi, Renko, three-line break, Kagi, point & figure and range bars all rewrite the candles rather than the way they are drawn, so they are transforms rather than chart types. Run the list through CandleTransforms and recompute the indicators over the result:

final ha = CandleTransforms.heikinAshi(candles);
DataUtil.calculate(ha);

final bricks = CandleTransforms.renko(
  candles,
  brickSize: CandleTransforms.atrBrickSize(candles) ?? 25,
);
DataUtil.calculate(bricks);

KChartWidget(ha, ChartColors(), /* … */);

Heikin-Ashi keeps one candle per candle, at the same times, so anything drawn on the chart stays where it was. The rest throw time away between bars, and each carries the volume of the candles it covers:

Transform What draws a bar
renko(brickSize:) price closing a whole brick beyond the last; a reversal costs two
lineBreak(lines: 3) a close beyond the last block, or beyond the extreme of the last lines blocks to turn round
kagi(reversal:, asPercent:) a retracement of reversal from the extreme; a whole trend is one segment
pointAndFigure(boxSize:, reversalBoxes: 3) a whole box of travel, read off the highs and lows; a new column takes reversalBoxes back
rangeBars(range:) price travelling range from where the bar opened

atrBrickSize sizes a brick, a box, a reversal or a range from the market's own average true range, which is the usual way to pick one:

final step = CandleTransforms.atrBrickSize(candles) ?? candles.last.close * 0.005;

final blocks = CandleTransforms.lineBreak(candles);
final segments = CandleTransforms.kagi(candles, reversal: step);
final columns = CandleTransforms.pointAndFigure(candles, boxSize: step);
final bars = CandleTransforms.rangeBars(candles, range: step);

Line break, Kagi, point & figure and range bars

Every one of them hands back plain candles at their own times, so the chart, the indicators and the drawing tools all work over them unchanged. What each bar means differs: a Kagi segment is a whole trend, a point-and-figure candle is a whole column of boxes, and a range bar is exactly range of travel.

Price axis #

KChartWidget(
  candles,
  ChartColors(),
  isTrendLine: false,
  watermarkAssetPath: 'assets/logo.svg',
  timeFrame: const Duration(days: 1),
  priceAxisScale: PriceAxisScale.logarithmic,
);

The same market indexed to 100, and with the axis inverted

  • linear — equal prices take equal space. The default.
  • logarithmic — equal ratios take equal space, so 10 → 20 covers as much of the axis as 100 → 200. A window whose low is zero or negative has no logarithm to space by and falls back to linear until it scrolls back into positive prices.
  • percentage — spaced linearly, but the axis, the crosshair's price label and the current-price tag read as the move away from the oldest candle in view.
  • indexedTo100 — the same information said the other way about: the oldest candle in view reads 100 and everything else is quoted against it, which is how an index or a rebased performance series is usually written. The axis marks round index levels — 100, 105, 110 — and converts them back to the prices they stand for.

An indicator pane can be logarithmic or read as a percentage in its own right; see Indicator.scale. The volume pane always stays linear.

The axis picks round values and the grid is ruled where they land, so a label and its line always agree. A linear axis steps by 1, 2, 2.5 or 5 times a power of ten; a logarithmic one steps by ratio — 1, 2 and 5 through each power of ten — falling back to linear steps over a range too narrow to hold a decade; a percentage axis chooses round percentages and converts them back to prices.

ChartStyle.gridRows is how densely that happens. It is not a row count: about half as many round values as gridRows land inside the window, so raise it for a denser axis and lower it for a sparser one.

KChartWidget(
  candles,
  ChartColors(),
  isTrendLine: false,
  watermarkAssetPath: 'assets/logo.svg',
  timeFrame: const Duration(days: 1),
  chartStyle: const ChartStyle(gridRows: 12),
);

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.

Reading it the other way, and other extras

KChartWidget(
  candles,
  ChartColors(),
  isTrendLine: false,
  watermarkAssetPath: 'assets/logo.svg',
  timeFrame: const Duration(days: 1),
  invertPriceAxis: true,     // higher prices lower down
  showAverageClose: true,    // a level at the mean close over the window
  showHighLowOnAxis: true,   // the window's extremes tagged where they read
);

invertPriceAxis flips the axis, which is what a chart of a yield or a spread wants — and what a trader who thinks in the other direction reaches for. Everything follows: the candles, the drawings, the crosshair, the orders and the comparisons all read off the same flipped axis, and a rising candle is still coloured as one, because the colour comes from the prices rather than from the pixels. A logarithmic axis inverts and stays logarithmic.

showAverageClose draws a dashed level at the mean close of the visible window — the level a mean-reversion read is taken against — coloured from ChartColors.avgColor, and it moves as the chart is panned, since it describes the window rather than the whole history.

showHighLowOnAxis tags the window's high and low on the axis, in whatever units the axis reads in. The leader lines already point at the candles that set them; this says what to read them off the axis as.

Dragging the scale

The axis fits the window by default, so the candles always fill the height — which is what you want until you want to look closer. Dragging down the strip the price labels sit in stretches the range and makes the candles taller; dragging up compresses it. Once the scale is being held that way, a vertical drag anywhere on the candles slides the window up and down, and a double-tap on the labels hands the axis back to the chart.

KChartWidget(
  candles,
  ChartColors(),
  isTrendLine: false,
  watermarkAssetPath: 'assets/logo.svg',
  timeFrame: const Duration(days: 1),
  controller: chart,
  priceScaleDrag: true,
  chartStyle: const ChartStyle(priceScaleGripWidth: 52),
);

priceScaleGripWidth is how far in from the labelled side that strip reaches, and it is never more than half the chart. A drag through it still scrolls the chart sideways, a tap still selects what is under it, and while a drawing tool is armed it steps aside completely — so a line can still be placed against the axis.

The same three moves are on the controller, for a chart driven from a toolbar:

chart.stretchPrice();     // taller candles, as dragging down does
chart.compressPrice();    // flatter ones
chart.setPriceZoom(2.5);  // or straight to a factor
chart.resetPriceScale();  // back to fitting the window
chart.priceZoom;          // 1 while the chart is fitting it itself

Set priceScaleDrag: false to keep the axis fitted to the window whatever the user does, which is the older behaviour.

The date axis #

The date axis is chosen the same way. Above a day it lands on round dates; below one it reads as a run of clock times — 06:00, 12:00, 18:00 — with the date promoted where the day turns over, so an intraday chart shows where one session ends and the next begins. Labels that would crowd into each other are dropped rather than printed over one another, and the boundaries follow the clock the chart prints: a timeZoneOffset of half an hour still labels round local times.

ChartStyle.gridColumns sets the density, read like gridRows. Formatting can be taken over completely — ChartStyle.dateTimeFormat for a fixed pattern, or dateFormatter for full control, which is handed each candle along with a flag marking the long form the crosshair wants:

KChartWidget(
  candles,
  ChartColors(),
  isTrendLine: false,
  watermarkAssetPath: 'assets/logo.svg',
  timeFrame: const Duration(minutes: 15),
  dateFormatter: (candle, longForm) => DateFormat(
    longForm ? 'EEE d MMM HH:mm' : 'HH:mm',
  ).format(DateTime.fromMillisecondsSinceEpoch(candle.time!)),
  xFrontPadding: 120,
);

xFrontPadding is the empty space kept to the right of the newest candle — room for the "now price" tag and its countdown, and for a level drawn just ahead of the market.

The legend and the crosshair #

With a mouse, the crosshair follows the pointer without waiting for a press — that is crosshairOnHover, on by default and irrelevant to a touch screen, which has nothing that hovers. The values then belong above the chart rather than in a popup, which is what showOhlcLegend draws: date, open, high, low, close, the move over the candle and its volume, on a legend row of its own above the indicator legends, worded by ChartTranslations.

KChartWidget(
  candles,
  ChartColors(),
  isTrendLine: false,
  watermarkAssetPath: 'assets/logo.svg',
  timeFrame: const Duration(minutes: 15),
  showOhlcLegend: true,
  crosshairOnHover: true,
);

The long-press readout is unchanged, and still opens on a press or — with isTapShowInfoDialog — a tap.

Drawing tools #

The line editor open on a selected line

Selecting a drawn line opens the editor over the chart: colour, thickness, stroke style, label text and visibility, lock and delete.

Trend and horizontal lines with labels

Set currentDrawingTool to put the chart into placement mode and handle the callbacks to persist what the user draws. The per-kind lists below are the original API and still work; a drawingController — see Undo, redo and the drawing controller — owns the whole layout for you instead, and drawings: takes drawings of any kind, which is where the later ones live:

KChartWidget(
  candles,
  ChartColors(),
  isTrendLine: true,
  watermarkAssetPath: 'assets/logo.svg',
  timeFrame: const Duration(minutes: 15),
  currentDrawingTool: DrawingTool.trend,
  trendLines: savedTrendLines,
  horizontalLines: savedHorizontalLines,
  verticalLines: savedVerticalLines,
  rectangles: savedRectangles,
  fibRetracements: savedFibRetracements,
  drawings: savedMeasuresChannelsAndNotes,
  onAddTrendLine: repository.save,
  onRemoveTrendLine: repository.delete,
  // Fires for every kind, and is the only report for the later ones.
  onAddDrawing: repository.save,
  onRemoveDrawing: repository.delete,
);

Placement works the way a charting desk expects. A horizontal or vertical line lands with a single tap. A trend line takes one tap per end: tap its start, move, and tap again to finish — the line rubber-bands along with the pointer in between. Pressing and dragging from one end to the other still draws a line in one gesture, on a touch screen as much as with a mouse. Escape abandons a line that is half-placed, as does tapping outside the chart or switching tools, and the line editor stays out of the way until the line is finished.

With magnetMode: true, each point placed snaps to the nearest open, high, low or close within DrawingStyle.magnetSnapDistance pixels, and lands wherever the pointer is when nothing is that close.

What can be drawn

A ray, an arrow, a horizontal ray, a range box and a Fibonacci retracement

DrawingTool Taps Draws
horizontal 1 a level across the whole chart
horizontalRay 1 a level that only applies from that candle rightwards
vertical 1 a line marking one candle
text 1 a note pinned to a point, ready to be typed into
trend 2 a segment between two anchors
ray 2 a segment that carries on past its second anchor
extendedLine 2 a segment that carries on past both anchors
arrow 2 a segment with an arrowhead on its far end
rectangle 2 a box between two opposite corners
ellipse 2 an ellipse inscribed in that box
fibRetracement 2 the levels of a swing, labelled and banded
measure 2 a ruler: the move in price, in percent, in candles and in time
triangle 3 a triangle over three corners
channel 3 a base line and a parallel through the third point
position 3 entry, target and stop, with the risk-to-reward worked out
brush drag a freehand stroke
flag 1 a pennant planted on one candle
gannFan 2 rays at Gann's angles, 1×1 through 1×8 and 8×1
gannBox 2 a box ruled at the same fractions across and down
fibFan 2 rays at the Fibonacci fractions of a swing
fibTimeZones 2 verticals at Fibonacci multiples of a span
regressionTrend 2 the least-squares fit through the candles between, with bands
priceRange 2 a bracket over a price move, in price and percent
dateRange 2 a bracket under a span, in candles and in time
callout 2 a note in a box, with a tail pointing at a candle
pitchfork 3 a median line and its tines, from three swings
fibExtension 3 an impulse projected on from where the retracement ended
xabcd 5 a harmonic pattern, each leg labelled with its retracement
path many straight legs through as many points as are tapped

The three trend variants are all TrendLines: extend (LineExtension.none, .right, .both) decides how far past its anchors the line runs, and arrow puts a head on the far end, so a ray persisted by an older version of the app still loads. HorizontalLine.startTime is what makes a level a ray. Rectangles and retracements are their own types, RectangleDrawing and FibRetracement, passed in rectangles and fibRetracements and reported through onAddRectangle / onRemoveRectangle and onAddFibRetracement / onRemoveFibRetracement.

Every two-point drawing shares one base, TwoPointDrawing: two (time, price) anchors, either of which can be dragged, plus isComplete — false while the second point is still following the pointer.

xabcd and path are MultiPointDrawings instead: their anchors live in a points list rather than in numbered fields, which is what lets a path take as many as it is given. Each tap lands a leg. A pattern finishes when its five points are in; a path has no count to finish on, so it ends when you tap twice in the same place, or when the tool is disarmed — switching tools finishes an open path rather than throwing it away.

Fans, forks and fits

GannFan's second anchor places the 1×1 — one unit of price against one unit of time — and ratios multiplies that slope for the rest of the fan, so 2 is the 1×2 and 0.5 the 2×1. GannBox divides a range by its own proportions instead: ratios are taken as fractions of the box both ways, so the horizontals mark those fractions of the price range and the verticals the same fractions of the span.

FibFan spreads rays between the flat 0 and the diagonal 1 of a swing — support that slopes with time, where a retracement's is level. FibTimeZones reads the other axis: the two anchors set one unit of time and each level marks that many units on, so a swing that took ten candles projects lines at 10, 20, 30, 50 and 80. FibExtension is the trend-based one: the first two anchors are the impulse, the third is where the retracement ended, and the levels are projected on from there rather than drawn between the anchors.

PitchforkDrawing takes a pivot and the swing either side of it. The median runs through the midpoint of the swing and each level draws a tine parallel to it — 1 being the tines through the anchors themselves, 0 the median. PitchforkKind.andrews leaves the handle on the pivot, .schiff lifts it halfway to the median in price, and .modifiedSchiff lifts it in time as well.

RegressionChannel is the one drawing that reads the candles rather than only the anchors: fitRegression runs a least-squares fit through the closes of everything the two anchors span, and deviations places a band either side at that many standard deviations. Move an anchor and the fit is worked out again, so the line always describes the stretch it covers rather than the two points it was dropped on.

KChartWidget(
  data,
  ChartColors(),
  isTrendLine: true,
  currentDrawingTool: DrawingTool.pitchfork,
  drawings: [
    RegressionChannel(
      time1: candles[20].dateTime!, price1: candles[20].close,
      time2: candles[60].dateTime!, price2: candles[60].close,
      deviations: 2,
    ),
    XabcdDrawing(points: [
      for (final i in [10, 20, 30, 40, 50])
        (time: candles[i].dateTime!, price: candles[i].close),
    ]),
  ],
);
RectangleDrawing(
  time1: candles[20].dateTime!, price1: candles[20].high,
  time2: candles[30].dateTime!, price2: candles[30].low,
  fillOpacity: 0.12,
);

FibRetracement(
  time1: swingLow.dateTime!, price1: swingLow.low,
  time2: swingHigh.dateTime!, price2: swingHigh.high,
  levels: [0, 0.382, 0.5, 0.618, 1],   // defaults to the usual seven
);

A retracement's levels run from the drawing rightwards to the edge of the chart, since what a level is worth is what price does after the move. DrawingStyle carries the rest of the geometry: arrowHeadLength, rectangleFillOpacity, shapeFillOpacity, measureFillOpacity, channelFillOpacity, positionFillOpacity, and fibLevels and fibFillOpacity for new retracements and their bands.

The three-point shapes — TriangleDrawing, ParallelChannel, PositionDrawing — share ThreePointDrawing, which adds time3 and price3 and is not complete until the third point lands. A channel's parallel runs through that point; a position takes its entry from the first, its target from the second and its stop from the third, and works out reward, risk and riskReward for the label:

final plan = PositionDrawing(
  time1: entry.dateTime!, price1: entry.close,
  time2: candles.last.dateTime!, price2: entry.close * 1.06,
  time3: candles.last.dateTime!, price3: entry.close * 0.98,
);
plan.isLong;       // true — the target is above the entry
plan.riskReward;   // 3.0

A measurement reads itself out the same way: priceMove, ratio and span, plus the candle count the chart works out for the label. A note is a TextAnnotation whose text the editor's label field types into, and a freehand stroke is a FreehandDrawing — a list of (time, price) points, so it stays on the candles it was drawn over.

keepToolArmed is your own state — the chart never changes currentDrawingTool — but selectAfterDrawing: false stops the editor opening over each drawing as it lands, which is what makes drawing five levels in a row bearable.

Tapping an existing line selects it and opens the editing toolbar. Dragging an anchor of a two-point drawing moves that anchor; dragging the shape by its stroke, its outline or one of its levels moves the whole thing, both anchors together. Every edit — a new colour, a new thickness, a drag, a renamed label — fires the matching onAdd* callback with the updated line, so persisting a change is the same code path as persisting a new one.

Persisting a layout

Every drawing serialises, and drawingFromJson turns a map back into the drawing it came from:

final drawings = ChartDrawings([
  HorizontalLine(price: 42_000, title: 'entry'),
  TrendLine(time1: a, price1: 1, time2: b, price2: 2),
]);

await prefs.setString('layout', jsonEncode(drawings.toJson()));

final saved = prefs.getString('layout');
final restored = saved == null
    ? ChartDrawings()
    : ChartDrawings.fromJson(jsonDecode(saved) as Map<String, dynamic>);

ChartDrawings is an ordered set of drawings with typed views — horizontalLines, trendLines, positions and the rest — so the chart can be handed the whole layout at once through drawings:. A drawing of a kind this version does not know is skipped rather than throwing, so a layout written by a newer release still opens. copyDrawing deep-copies one, by round-tripping it through its own JSON.

Undo, redo and the drawing controller

Hand the chart a ChartDrawingController and it owns the drawings: what the user places, restyles, drags or deletes goes through the controller, which is what makes undo possible.

final drawings = ChartDrawingController();

KChartWidget(
  candles,
  ChartColors(),
  isTrendLine: true,
  watermarkAssetPath: 'assets/logo.svg',
  timeFrame: const Duration(minutes: 15),
  drawingController: drawings,
);

drawings.undo();       // ⌘Z on the chart does the same
drawings.redo();       // ⇧⌘Z, or Ctrl+Y
drawings.clear();      // one undoable step
drawings.select(line); // opens the chart's editor on that drawing
jsonEncode(drawings.toJson());

An edit is a step, so restyling a line and undoing gets the old style back — the controller keeps a deep copy of the last committed state, because the chart edits a drawing in place and only reports it once the edit lands. historyLimit (50 by default) is how far back it goes; clearHistory keeps the drawings and drops the steps, which is what a fresh symbol wants.

Delete removes the selection, and Escape abandons a drawing being placed. All of it can be turned off with enableKeyboardShortcuts: false. Without a controller the per-kind lists and callbacks work exactly as before — there is simply no undo.

Selecting several, and what to do with them

Shift- or ⌘-click a drawing and it joins the selection rather than replacing it; ⌘A takes everything drawn. What follows applies to the lot: dragging one moves them all together, Delete removes them in a single undoable step, and an edit made through the line editor — colour, thickness, stroke, fill, label visibility — is copied onto the rest, which is what a user who selected five lines to recolour meant. The editor stays open on the last one picked and says how many it is editing.

drawings.selection;               // every selected drawing, primary last
drawings.selected;                // the one the editor is open on
drawings.addToSelection(line);
drawings.toggleSelection(line);
drawings.selectAll();
drawings.clearSelection();
drawings.removeAll(drawings.selection);   // one step

⌘C copies, ⌘V pastes and ⌘D duplicates, each nudged a few candles clear of the original so the copy can be seen and grabbed rather than hiding underneath. The clipboard holds copies, so editing or deleting the originals afterwards leaves what was copied alone.

drawings.copyToClipboard(drawings.selection);
drawings.canPaste;
drawings.paste();                  // returns what it added, and selects it
drawings.duplicate(drawings.selection);

⌘] and ⌘[ walk the selection up and down the stack, ⇧⌘] and ⇧⌘[ take it all the way. Later is higher: the last drawing paints over the ones before it, and is the one a tap in an overlap picks up. Saving an edit no longer restacks the drawing it edited, so the order the user set is the order that keeps.

drawings.bringToFront(line);
drawings.sendToBack(line);
drawings.bringForward(line);
drawings.sendBackward(line);
drawings.indexOf(line);            // -1 when it is not there

Style templates

DrawingTemplate is one drawing's look, saved so it can be put on another: colour, thickness, stroke style, fill opacity and label visibility — everything a drawing shares with every other drawing, and nothing that belongs to one kind in particular. Applying one to a rectangle and to a trend line gives them the same look without either having to know about the other.

final house = DrawingTemplate.of(drawings.selected!);
house.applyTo(otherLine);

// Or keep them on the controller, by name:
drawings.saveTemplate('house', drawings.selected!);
drawings.applyTemplate('house', drawings.selection);   // one undoable step
drawings.templates;                                    // by name
jsonEncode(drawings.templatesToJson());

Templates are kept apart from the layout — templatesToJson and loadTemplates, not toJson — because they outlive any one chart's drawings.

Exact coordinates

A drawing placed by hand lands on whichever candle the pointer was over, which is close enough to read a chart by and not close enough to hand to someone else. The editor's ruler button opens a dialog listing every anchor — a price and a candle apiece, named Start, End, X through D, Point 3 — and each one can be typed in exactly. showDrawingCoordinates: false leaves the button out.

The same anchors are readable from code, whatever kind of drawing it is:

for (final anchor in drawingAnchors(line)) {
  print('${anchor.name}: ${anchor.price} at ${anchor.time}');
}
setDrawingAnchor(line, 1, price: 42_000);   // leaves the time alone

A freehand stroke is the one drawing whose anchors cannot be typed into — its shape is the hundreds of points it was drawn with — so it reads out its two ends and drawingAnchorsAreEditable answers false.

The right-click menu

A right-click opens a menu, and what it offers depends on what was clicked. On a drawing: its coordinates, duplicate, copy, restack, lock, hide, its alert where it has one, and delete — applied to the whole selection where there is one. On empty chart: paste, select all, fit the price scale, scroll to the newest candle, undo, redo and clear. Right-clicking a drawing selects it first, so what the menu is about and what the chart highlights always agree.

showContextMenu: false turns it off. contextMenuBuilder is handed what was clicked — the drawing, the candle, the price, and the entries the chart would have shown — so an item of your own is one line:

KChartWidget(
  candles,
  ChartColors(),
  isTrendLine: true,
  watermarkAssetPath: 'assets/logo.svg',
  timeFrame: const Duration(minutes: 15),
  contextMenuBuilder: (request) => [
    ...request.defaults,
    const ChartMenuDivider(),
    ChartMenuItem(
      label: 'Buy at ${request.price?.toStringAsFixed(2)}',
      icon: Icons.shopping_cart_outlined,
      onSelected: () => orders.buy(request.price!),
    ),
  ],
);

Returning a list of your own replaces the menu; returning an empty one shows none. ChartMenuItem takes an icon, an enabled flag, a checked flag for a toggle and destructive for something that throws work away, and ChartMenuDivider rules between groups. showChartMenu opens the same menu from your own button.

The drawing manager

DrawingManager is a plain widget over the same controller: every drawing by name, with show/hide, lock, delete, undo, redo and clear.

Row(
  children: [
    Expanded(child: KChartWidget(candles, colors, drawingController: drawings, /* … */)),
    SizedBox(width: 260, child: DrawingManager(controller: drawings)),
  ],
);

Tapping a row selects that drawing on the chart, and the chart's own selection highlights the row. Hiding one leaves it in the layout but off the chart — ChartLine.hidden — and every string it shows, including what each kind is called, comes from DrawingTranslations.

Level alerts

A HorizontalLine with alert: true reports through onAlertCrossed whenever the newest candle closes on the other side of it:

KChartWidget(
  candles,
  ChartColors(),
  isTrendLine: true,
  watermarkAssetPath: 'assets/logo.svg',
  timeFrame: const Duration(minutes: 15),
  drawings: [HorizontalLine(price: 42_000, alert: true)],
  onAlertCrossed: (line, candle) => notifier.push('crossed ${line.price}'),
);

It fires once per crossing — the market has to come back through the level before it fires again — and the editor's bell button is what arms one from the chart.

Levels are not the only thing that can be crossed. Any drawing mixing in AlertingDrawingHorizontalLine, TrendLine, ParallelChannel and FibRetracement — reports through onDrawingAlert, which carries the price that was crossed as well as the drawing and the candle, since a drawing may have several levels at once:

KChartWidget(
  candles,
  ChartColors(),
  isTrendLine: true,
  watermarkAssetPath: 'assets/logo.svg',
  timeFrame: const Duration(minutes: 15),
  drawings: [
    TrendLine(
      time1: candles[10].dateTime!, price1: candles[10].low,
      time2: candles[40].dateTime!, price2: candles[40].low,
      extend: LineExtension.right,
      alert: true,
    ),
  ],
  onDrawingAlert: (line, candle, level) =>
      notifier.push('crossed ${level.toStringAsFixed(2)}'),
);

alertLevelsAt is what each drawing answers with, at the newest candle's instant, so a sloping line reports where it is now rather than where it was drawn. A segment can only be crossed between its anchors; a ray also counts rightwards of its second one, and an extended line everywhere. A horizontal level reports through both callbacks, so an app written against onAlertCrossed carries on working unchanged.

Customising the line editor #

DrawingStyle decides what the toolbar offers, how it looks, and how close a tap has to land to count. Everything is optional:

KChartWidget(
  candles,
  ChartColors(),
  isTrendLine: true,
  watermarkAssetPath: 'assets/logo.svg',
  timeFrame: const Duration(minutes: 15),
  drawingStyle: const DrawingStyle(
    // What the user may pick
    colorOptions: [Color(0xFF4DABF7), Color(0xFF12B886), Color(0xFFFA5252)],
    thicknessOptions: [1, 2, 3, 5],
    lineStyleOptions: [LineStyle.solid, LineStyle.dashed],
    minThickness: 0.5,
    maxThickness: 8,

    // Which controls appear
    showOpacityControl: false,
    showLabelTextControl: true,
    showFillControl: true,     // shapes with an interior
    showAlertControl: true,    // levels only

    // How the bar looks
    toolbarAxis: Axis.horizontal,
    toolbarInitialOffset: Offset(16, 40),
    accentColor: Color(0xFF4DABF7),
    iconSize: 20,

    // How the lines themselves are painted
    handleRadius: 7,
    dashLength: 6,
    dashGap: 4,
    hitTestTolerance: 22,

    // What a newly drawn shape looks like
    rectangleFillOpacity: 0.12,
    shapeFillOpacity: 0.12,
    measureFillOpacity: 0.14,
    channelFillOpacity: 0.08,
    positionFillOpacity: 0.16,
  ),
);

A control that a drawing has no use for is left out whatever these say: the fill slider only appears on a shape with an interior, the alert bell only on a level, and the label field only on a drawing that can carry one.

A line's own appearance lives on the line, so you can style one before it ever reaches the chart:

HorizontalLine(
  price: 68400,
  title: 'take profit',
  color: const Color(0xFF12B886),
  thickness: 2,
  style: LineStyle.dashed,
  showLabel: true,
  locked: true,
);

ChartLine.opacity reads and writes the alpha of color, and isDashed still works for code written before LineStyle.

The long-press readout #

The crosshair and its OHLC card while a candle is held

Holding the chart puts a crosshair under the finger with the price and time on the axes, and a card of that candle's open, high, low, close, change and volume. isTapShowInfoDialog opens it on a tap as well, infoDialogBuilder replaces the card, and ChartTranslations names every row.

Orders and positions #

orders and positions draw what the account actually holds: a line the full width of the chart, tagged on the axis side. These are not drawings — they come from the venue rather than from the user, and they are not saved with a layout.

KChartWidget(
  candles,
  ChartColors(),
  isTrendLine: false,
  watermarkAssetPath: 'assets/logo.svg',
  timeFrame: const Duration(minutes: 15),
  positions: [
    ChartPosition(
      id: '4471',
      entryPrice: 63_900,
      side: TradeSide.buy,
      quantity: 1.5,
      unrealisedPnl: 812.40,
    ),
  ],
  orders: [
    ChartOrder(id: '17', price: 64_500, side: TradeSide.sell, quantity: 1.5,
        kind: OrderKind.takeProfit),
    ChartOrder(id: '18', price: 62_800, side: TradeSide.sell, quantity: 1.5,
        kind: OrderKind.stop),
  ],
  onOrderDragged: (order, price) => setState(() => preview = price),
  onOrderMoved: (order, price) => broker.amend(order.id, price),
  onOrderTapped: (order) => showOrderSheet(order),
  onPositionTapped: (position) => showPositionSheet(position),
);

A working order and an open position, each tagged on the axis

Drag to modify. Press an order's line and drag it: the line follows the pointer, onOrderDragged fires all the way so a readout can follow it, and onOrderMoved fires once on release with the price it landed at. The chart never amends the order itself — it draws what you hand it, so the line snaps back unless the new price comes round in the next build. That is what you want: the venue gets the last word. draggable: false pins a line down, and an order with no onOrderMoved is not grabbable at all, so the gesture stays the chart's.

A tag reads itself from the side, the kind and the quantity — Buy 0.5, Stop 2, Long 1.5 +812.4 — and label overrides the lot. unrealisedPnl is yours to work out: only you know the contract size, the fees and the currency. Colours come from ChartColors.buyColor and sellColor unless the order names its own, and ChartStyle.trading — a TradingStyle — sets the stroke, the dashes and how near a line a press has to land.

Event marks #

Something happened to the instrument — it reported, it went ex-dividend, it split, it was in the news. events marks each under the candle nearest its own time, as a small badge below the candle area, so it says when without covering the price it happened at:

KChartWidget(
  candles,
  ChartColors(),
  isTrendLine: false,
  watermarkAssetPath: 'assets/logo.svg',
  timeFrame: const Duration(days: 1),
  events: [
    ChartEvent(time: reportedAt, kind: ChartEventKind.earnings),
    ChartEvent(
      time: exDate,
      kind: ChartEventKind.dividend,
      detail: r'$0.24 per share',
    ),
    ChartEvent(time: splitAt, kind: ChartEventKind.split, label: '4:1'),
    ChartEvent(time: headlineAt, kind: ChartEventKind.news),
  ],
  onEventTapped: (event) => showAboutEvent(event),
);

Earnings, a dividend, a split and a news mark under the candles

ChartEventKind.earnings, .dividend, .split, .news and .custom each carry a letter and a colour — E, D, S, N, , coloured from ChartColors.eventColors — and label, color and icon override any of it. detail is for a panel or a tooltip of your own; nothing on the chart reads it.

Tapping a badge reports through onEventTapped, and a tap gets to the badges before it is read as a selection or a drawing point, since they are small targets. ChartStyle.eventMarkRadius sizes them and eventMarkGap sets how far below the candles they sit; a radius of zero draws nothing while leaving the events on the chart for a list of your own. resolveEvents is exported for working out which candle each event falls on without a chart in hand.

Driving the chart #

KChartController reaches into the chart from your own code: how far it is zoomed, where it is scrolled, and what it looks like as an image.

final chart = KChartController();

KChartWidget(
  candles,
  ChartColors(),
  isTrendLine: false,
  watermarkAssetPath: 'assets/logo.svg',
  timeFrame: const Duration(minutes: 15),
  controller: chart,
);

chart.zoomIn();
chart.scrollToNow();                   // animates back to the live candle
chart.isAtRightEdge;                   // whether it is already there
final png = await chart.capture();     // the chart as PNG bytes

capture returns the chart itself — candles, indicators, drawings — without the line editor or any other control floating over it. Everything no-ops while no chart is attached, so a controller built before its widget, or kept after it, is harmless. The chart also shows its own button back to the live candle whenever it is scrolled away from one; showScrollToNowButton: false turns that off, and its tooltip comes from ChartTranslations.jumpToNow.

The visible window

Which candles are on screen is both readable and settable:

final range = chart.visibleRange;      // null until the first frame
range?.firstIndex;                     // the oldest candle in view
range?.lastIndex;                      // the newest
range?.length;                         // how many — a "bars on screen" readout
range?.firstTime;                      // and their instants
range?.span;                           // how long the window covers

chart.showRange(120, 180);             // zoom and scroll to those candles
chart.showTimeRange(candles, from, to);// the same, by time
chart.goToIndex(300);                  // centre that candle, keeping the zoom
chart.goToDate(candles, when);         // the nearest candle to an instant
chart.fitAll();                        // open the window as wide as it goes

showRange moves the zoom and the scroll together so the window holds exactly what was asked for, as near as the chart's zoom limits allow; goToIndex and goToDate keep the zoom and only scroll, animated by default. Each of them reports whether it could move at all, which is false for a chart that has not been laid out yet or one with no candles. showTimeRange widens outwards where the instants fall between candles, so the span asked for is always covered.

onVisibleRangeChanged reports the window whenever it changes — after the frame that changed it, and only when it is actually different, so scrolling within one candle says nothing:

KChartWidget(
  candles,
  ChartColors(),
  isTrendLine: false,
  watermarkAssetPath: 'assets/logo.svg',
  timeFrame: const Duration(minutes: 15),
  onVisibleRangeChanged: (range) {
    setState(() => barsOnScreen = range.length);
    // Load more history as the user reaches the start of it.
    if (range.firstIndex < 20) feed.loadOlder();
  },
);

That is also how two charts are kept in step — hand the range from one to the other's showRange. indexRangeCovering and indexNearest are exported for working out either from a list of candles without a chart in hand.

Panes #

An indicator pane can be made taller by dragging its lower edge, and moved up or down the stack by dragging its legend row:

KChartWidget(
  candles,
  ChartColors(),
  isTrendLine: false,
  watermarkAssetPath: 'assets/logo.svg',
  timeFrame: const Duration(minutes: 15),
  indicators: [MacdIndicator(), RsiIndicator()],
  resizablePanes: true,
  reorderablePanes: true,
  onReorderPane: (from, to) => setState(() {
    indicators.insert(to, indicators.removeAt(from));
  }),
);

Each pane is ruled and labelled at round values of its own, rather than showing only its highest and lowest — which is what lets three ATRs at three periods be read against each other instead of being three unlabelled squiggles. A pane with a range it already knows — RSI, KDJ, WR — keeps its guides instead. MACD and the Awesome oscillator draw their zero line, the axis their histogram changes colour across, and the volume pane marks a round level part-way up so a bar can be read against something.

Heights live in the chart, between ChartStyle.minPaneHeight and maxPaneHeight, and are given up whenever the panes themselves change. The order does not: the indicators own that, so the chart reports where a pane was dropped and leaves the move to you. ChartStyle.paneResizeTolerance and paneGrabHeight decide how big each target is.

Bar replay #

Rewind the chart and let the market happen again. ChartReplayController holds the chart at a candle in the past: everything after it — the candles, the indicators computed from them, the now-price line and the legend — is as it was at that moment, so a setup can be studied without the answer already on screen.

final replay = ChartReplayController(interval: const Duration(milliseconds: 300));

KChartWidget(
  candles,
  ChartColors(),
  isTrendLine: false,
  watermarkAssetPath: 'assets/logo.svg',
  timeFrame: const Duration(minutes: 15),
  replay: replay,
);

replay.start(at: 200);   // draw the oldest 200 candles and hold there
replay.stepForward();    // one more
replay.stepBack();       // one fewer
replay.play();           // or let them arrive on their own
replay.pause();
replay.stop();           // hand the whole series back

play() from cold starts halfway through, so a play button works without a candle having been picked first, and it gives up on its own at the newest one — isPlaying, isActive, position, length and isAtEnd are all there to drive a transport bar from. It is a ChangeNotifier, so those buttons rebuild themselves; dispose it with the widget that owns it.

Nothing is thrown away while a replay runs: the candle list is untouched and the drawings stay where they were placed, including any on candles still to arrive.

Sessions and time zones #

KChartWidget(
  candles,
  ChartColors(),
  isTrendLine: false,
  watermarkAssetPath: 'assets/logo.svg',
  timeFrame: const Duration(minutes: 15),
  chartStyle: const ChartStyle(showSessionDividers: true),
  timeZoneOffset: const Duration(hours: -5),
);

The pre-market and after-hours stretches washed behind the candles

timeZoneOffset is added to every candle's time before it is shown — on the axis, in the crosshair, in the legend and when working out where a day starts. It changes what is displayed and never the data, so drawings stay anchored to the candles they were placed on. showSessionDividers then marks the first candle of each day, in ChartColors.sessionDividerColor.

Extended hours

session says what the regular session is, and the stretches outside it — the pre-market and the after-hours — are washed behind the candles:

KChartWidget(
  candles,
  ChartColors(),
  isTrendLine: false,
  watermarkAssetPath: 'assets/logo.svg',
  timeFrame: const Duration(minutes: 15),
  timeZoneOffset: const Duration(hours: -5),
  session: const TradingSession(
    open: Duration(hours: 9, minutes: 30),
    close: Duration(hours: 16),
  ),
);

Read in the time zone the chart is showing, so the bands land where the trader sees them rather than where UTC does. weekdays chooses the days it is kept on, and a close at or before its open runs overnight — which is how a market that opens in one day and closes in the next is described, right down to Friday night belonging to Friday. Neighbouring candles outside the session are washed as one band, so a long overnight is one rectangle rather than a hundred. The colour is ChartColors.extendedHoursColor.

Colouring a bar yourself

candleColor is asked about every candle, bar and column drawn. Return a colour to use it, or null to leave the up or down colour it would have had:

KChartWidget(
  candles,
  ChartColors(),
  isTrendLine: false,
  watermarkAssetPath: 'assets/logo.svg',
  timeFrame: const Duration(minutes: 15),
  candleColor: (candle, index) {
    final range = candle.high - candle.low;
    return range > candle.close * 0.012 ? Colors.amber : null;
  },
);

Anything the caller can work out can decide: a bar inside a session, one above an average, one that completes a pattern, one belonging to a particular account. The index is into the list handed to the chart, so a precomputed answer can be looked up rather than recalculated.

Depth chart #

Cumulative bid and ask depth

DepthChart(
  DepthEntity.bids(rawBids),
  DepthEntity.asks(rawAsks),
  baseUnit: 2,
  quoteUnit: 6,
);

The chart plots each rung's vol as given, so it must be a running total. DepthEntity.bids and DepthEntity.asks sort raw order-book rungs by price and accumulate them in the right direction — from the best bid downwards and the best ask upwards — which is what makes the two curves meet at the mid price.

Modes

The combined curve and bars beside the order-book ladder

mode chooses what the book looks like, and all four read the same data:

DepthChartMode What it draws
cumulative The running total either side of the mid — the default, and the shape that shows how hard the book is to move through
histogram One bar per level, each the size resting on that rung, so the individual walls stand out
combined The curves with those bars behind them
ladder The numbers: price, size and running total per row, with a bar behind each
DepthChart(
  DepthEntity.bids(rawBids),
  DepthEntity.asks(rawAsks),
  mode: DepthChartMode.combined,
  scale: DepthScale.log,
  zoom: 0.05, // only the book within 5% of the mid
);

scale spaces the volume axis — linear, log for a book whose far side dwarfs the near one, or percent to label it as a share of the deepest total — and zoom narrows the chart to the levels near the mid, where the trading is. A zoom so tight that nothing would be left falls back to the whole book rather than to an empty chart.

Each rung's own size is recovered from the cumulative curves by differencing, so nothing extra has to be passed in. DepthBook.fromCurves does that on its own if you want the levels for something else:

final book = DepthBook.fromCurves(bids, asks, zoom: 0.05);
for (final level in book.bids) {
  print('${level.price}: ${level.size} resting of ${level.cumulative}');
}

The ladder is also a widget in its own right, for putting the numbers beside a chart rather than instead of it:

DepthLadder(bids, asks, levels: 12, barsShowTotal: false);

The long-press readout names the size resting on the rung under the finger as well as the running total out to it.

Theming #

KChartWidget(
  candles,
  ChartColors(
    upColor: const Color(0xFF12B886),
    dnColor: const Color(0xFFFA5252),
    bgColor: const Color(0xFF0E1116),
  ),
  chartStyle: const ChartStyle(),
  chartTranslations: const ChartTranslations(),
  // …
);

ChartTranslations carries every on-chart label (date, open, high, low, close, changeAmount, change, amount, vol, jumpToNow), so localising the chart is a matter of building one from your own AppLocalizations. Its drawing field does the same for the line editor, the drawing manager and what each of the seventeen kinds is called:

ChartTranslations(
  date: l10n.date,
  drawing: DrawingTranslations(
    color: l10n.colour,
    delete: l10n.delete,
    fill: l10n.fill,
    alert: l10n.setAlert,
    drawings: l10n.drawings,
    trendLineName: l10n.trendLine,
  ),
);

DrawingTranslations.nameOf is what turns a drawing into the name the manager shows, so a kind you have renamed reads the same everywhere.

ChartColors gained sessionDividerColor for the day dividers and gridColumnColor for the vertical grid lines, which default to a lighter shade of gridColor — a chart is read across price far more than across time, so the time columns sit behind the price rows. ChartStyle now has a copyWith, so a house geometry can be varied a switch at a time:

final style = ChartTheme.filled.copyWith(showSessionDividers: true);

The long-press readout sizes itself to its content between infoDialogWidth and infoDialogMaxWidth, and is never wider than the chart. Set isTapShowInfoDialog to open it on a plain tap as well.

Sizing #

mBaseHeight is the candle area alone; the volume pane (60px) and each indicator pane (100px, until one is dragged) are stacked underneath. Left unset it is derived from the widget's box, so the whole stack fits — put the chart in an Expanded and it fills the space. Pass a number to pin the candle area instead, for instance inside a scroll view where there is no height to divide up.

Migrating from 1.x #

Nothing was taken off KChartWidget: the per-kind drawing lists, their onAdd* and onRemove* callbacks and isLine all still work, so most apps upgrade by changing the version and nothing else. Three things to know:

  • Two defaults changed what an existing chart shows. crosshairOnHover and showScrollToNowButton are both on. The first only ever fires for a pointer that hovers, so a touch app never sees it; the second draws a small button over the bottom right corner whenever the chart is scrolled away from the newest candle. Set either to false to keep the old behaviour.

  • A custom ChartLine now has to serialise. toJson is part of the base class, since that is what lets a layout be saved and a drawing be copied for the undo history. Build yours on baseJson, and register a fromJson of your own where you decode:

    class MyDrawing extends TwoPointDrawing {
      @override
      Map<String, dynamic> toJson() => {
        ...baseJson('myDrawing'),
        ...anchorsJson(),
      };
    }
    

    Adopting LabelledDrawing or FilledDrawing is what gets your drawing the editor's label field or its fill slider.

  • The painters moved on, if you imported them from src/ rather than through the public API: ChartPainter now takes one drawings list rather than a list per kind, and reports the crosshair's candle through an emitInfoWindow callback rather than a StreamSink. MainRenderer.getValue is now the exact inverse of getY, which also corrects a price read a few pixels out.

ChartLine.hidden is new and defaults to false, so nothing disappears; the drawing manager is what turns it on.

Notes #

  • The zoom slider renders only where there is no pinch gesture — web and desktop.
  • watermarkAssetPath must point at an SVG registered in your app's pubspec.yaml assets; a missing asset is ignored and the chart renders without a watermark.

Support #

This package is free to use in your own projects, and I keep it updated weekly — new indicators, drawing tools and fixes land as I use it myself.

If it saves you some work, the only thing I ask in return is a ⭐ on the repository and a follow on GitHub. That is what keeps the weekly updates coming.

Credits #

The rendering core began as a derivative of the open-source k_chart package and has since been substantially extended with drawing tools, signals, multi-indicator stacking and a reworked painter pipeline.

License #

MIT — see LICENSE.

2
likes
0
points
636
downloads

Publisher

unverified uploader

Weekly Downloads

Candlestick and market-depth charts for Flutter, with MA, BOLL and SAR overlays, MACD, KDJ, RSI, WR and CCI sub-charts, signal markers and interactive drawing tools.

Homepage
Repository (GitHub)
View/report issues

Topics

#chart #candlestick #trading #finance

License

unknown (license)

Dependencies

decimal, flutter, flutter_svg, intl

More

Packages that depend on ohlcv_chart