diff --git a/CHANGELOG.md b/CHANGELOG.md index de860f2..63ebd24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,147 @@ +## 3.2.0 - 2026-10-15 + +### Changed + +- The data classes charts take — `WaterfallStep`, `TreemapItem`, `DeltaBar`, + `PieSection`, `TimelineTrade`, `MarketProfile`, `MonteCarloResult` and the + rest — compare by value, so two lists built from the same numbers are equal. + Things that do not shape the data — `ParallelAxis.formatter` and + `PieSection.badge` — are left out of the comparison, so an inline closure or + widget does not make every rebuild look like new data. +- `PieChart` and `HeatmapChart` now animate in again when their values change, + as every other chart does; before, only a change in how many sections or + cells there were started the animation. + +### Added + +- **`ProgressBars`** — how far each goal has got, as pill, block or segmented + bars with a label and figure above each, an optional target mark, touch and a + draw-in animation. A new "Progress and goals" group in the gallery holds it. + `ProgressItem`, `ProgressShape`, `ProgressLayout`, `ProgressRowLayout` and + `layOutProgress` are public. See [Progress bars](doc/progress-bars.md). +- **`ProgressCard`** — a card around any widget whose whole background is a + progress fill, for a course, a goal or a task. `mode` picks how it fills: + `ProgressCardMode.sweep` sweeps round the card like a clock, `circle` grows + a circle from its left edge, `linear` fills along it from the left. +- **`TokenHeatmap` is easier to read.** Touching a token opens a readout of its + score, its rank, the probability the model gave it and — from the new + `TokenScore.alternatives` — what it was weighing instead. `highlightTop` rings + and numbers the most doubtful tokens, `focusAbove` leaves the confident ones + unshaded, `showStrip` draws every token's score in order for a long text, + `showWhitespace` writes spaces and line breaks as `·` and `↵`, and + `selectedIndex` holds one outlined. Tokens now have room round their text and + a gap where the tokenizer put a space, and are shaded softly — a tint with a + line of the full colour under the text — rather than in solid blocks that ran + together; `shading: TokenShading.solid` brings the old look back. The legend + marks the mean. `tokenRanks`, + `topTokens`, `binTokenScores` and `tokenDisplay` are the numbers behind it. +- **Ten charts for training and serving language models:** `TrainingCurveChart`, + `ScalingLawChart`, `SweepChart`, `TrainingTimelineChart`, + `BenchmarkMatrixChart`, `ModelParetoChart`, `LatencyBandChart`, + `TokenUsageChart`, `EvalBreakdownChart` and `TokenHeatmap`. The example app's + Gallery tab has a *Models and training* group for them — 50 charts in all — + and each has a documentation page with its screenshot. +- **`TokenHeatmap`** shows a model's text with each token shaded by a score — its surprisal, an entropy, an attribution — flowing and wrapping like text, with a legend and the perplexity. `summarizeTokens` and `flowTokens` are the numbers behind it. +- **`EvalBreakdownChart`** shows an evaluation by category as bars of passed, failed and skipped tasks, worst first, with the pass rate, how many tasks stand behind it, an overall figure and a threshold. `wilsonInterval`, `overallPassRate` and `sortedByPassRate` are the numbers behind it. +- **`TokenUsageChart`** shows tokens served over time as stacked cached, input and output columns with the cost as a line against its own axis. `usageTotals`, `UsageBucket.cacheHitRate` and `compactCount` are the numbers behind it. +- **`LatencyBandChart`** shows inference latency over time as p50, p95 and p99 bands, with an objective drawn across and the intervals that broke it marked. `sloBreaches` is the number behind it. +- **`ModelParetoChart`** plots models by cost against quality — a price per million tokens against a benchmark score — with the Pareto frontier traced through the ones nothing beats, bubbles sized by context window and models grouped by provider. `paretoIndices` is the number behind it. +- **`BenchmarkMatrixChart`** compares models across benchmarks in one grid coloured by each score's standing within its benchmark, the leader of each starred and an average column to rank by. `benchmarkQuality`, `benchmarkAverages` and `benchmarkLeaders` are the numbers behind it. +- **`TrainingTimelineChart`** shows training jobs on a timeline — a lane per machine, a bar from start to end coloured by whether it completed, failed, was preempted or cancelled, and a strip counting the jobs running at each moment. +- **`SweepChart`** lays a two-setting hyperparameter sweep out as a grid of scores coloured good to bad, with the best combination starred and the mean of each setting alongside. `sweepGrid` is the numbers behind it. +- **`ScalingLawChart`** plots loss against parameters, tokens or compute on log-log axes with the fitted power law, a residual band, an extrapolation and the scale at which the law reaches a target loss. `fitPowerLaw` and `PowerLawFit` are the numbers behind it. +- **`TrainingCurveChart`** draws the loss and accuracy curves of one or more training runs, with exponential smoothing over faint raw readings, a logarithmic axis, the best checkpoint, the step a run diverged at and the train/validation gap. `smoothValues`, `bestIndex` and `divergenceIndex` are the numbers behind it. +- The example app's Gallery tab has the six new charts — 40 in all — and the + documentation pages have their screenshots. +- **`ActivityClockChart`** lays a week out as a clock face — the hours of the + day round the dial, a weekday on each ring — coloured by what happened then. + It takes the same `SeasonalSample`s as `SeasonalityChart`. `layOutClock` and + `clockSegmentAt` are the layout and hit test behind it. +- **`FuturesCurveChart`** plots futures prices against delivery month, one or + more curves at a time, with the first curve's shape — contango or + backwardation — and slope in the corner and the calendar spread on touch. + `curveShape`, `curveSlope` and `calendarSpreads` are the numbers behind it. +- **`VarChart`** shows a set of returns as a histogram with the loss tail + picked out and the value at risk and expected shortfall marked. + `ValueAtRisk.fromReturns` works the figures out by the historical method, and + `varBins` colours the tail for a histogram of your own. +- **`EfficientFrontierChart`** plots portfolios by risk and return and traces + the efficient frontier through the ones nothing beats, marking the best + return per unit of risk, the least risky portfolio, the capital market line + and the portfolio held now. `efficientFrontierIndices`, `maxSharpeIndex` and + `minRiskIndex` are the numbers behind it. +- **`VolatilitySurfaceChart`** draws implied volatility across strike and + expiry as a heatmap, with the skew against the at-the-money strike on touch. + `atTheMoneyIndex` and `surfaceSkew` are the numbers behind it. +- **`MaeMfeChart`** plots every trade by how far it went against the position + (MAE) or for it (MFE) and what it made, with the limit line no trade can pass + and reference lines for a stop or a target. `ExcursionStats` and + `winnersStoppedAt` work out the edge ratio and what a stop would have cost. +- **`KChartController.resetZoom`** zooms back to the natural candle width, + undoing `zoomIn`/`zoomOut`/`zoomTo` the way `resetPriceScale` already undoes + a price stretch. +- **`ChartReplayController.progress`** reports how far a replay has got, from 0 + to 1, for a scrub bar or progress indicator. + +### Fixed + +- **Overflow in narrow and short boxes.** A tooltip card wider than its chart + now shrinks to fit it instead of overflowing; the widget put in the middle of + a sunburst, a doughnut or a gauge shrinks to fit the hole; the equity curve's + time labels no longer throw when the plot is narrower than a label; and the + training timeline's legend shrinks its items in a narrow box. Every chart is + now built in boxes from 60 to 800 pixels across, with overflow counted as a + failure, and the example's Gallery is built at widths from 240 to 1,400. +- Log axes spanning more than six decades ticked on every 1, 2 and 5, which + crowded the axis; they tick on powers of ten, every few when there are very + many. +- Williams %R looked back over `period + 1` candles instead of `period`, so the + line sat a little off every other charting tool. It reads exactly `period` + candles now; the values shift slightly. +- **A candle chart no longer throws while painting** when a pane is squeezed + smaller than its own padding — a small box with several indicator panes — or + when a pane's values are all equal (a single candle, a flat market, MACD at + zero). A flat negative range was also drawn upside down. +- A market profile counted nothing for a candle with no range that sat on a + level's edge, and `tickSize: 0` threw. +- Option break-evens reported a payoff that crossed zero exactly on a strike + twice. +- `histogramBins` with a decimal `binWidth` such as 0.1 added an empty bin at + the end. +- Weekly time buckets, and so weekly resampling and the week axis labels, + started on a Thursday; they start on a Monday now. A 180-day step bucketed by + quarter instead of by half-year. +- A pair's hedge ratio fell back to 1 for prices quoted in very small numbers. +- A treemap item with an infinite value starved every other tile of space. +- **A chart with an entry animation no longer restarts it** every time its + parent rebuilds with the same data in a new list, which made a chart flash + empty on every hover when the parent kept the touched item in its state. + Animations now start only when the data's values change. +- The candle chart and the depth chart no longer throw for a candle or a rung + whose price is NaN or infinite; the bad point is left as a gap and does not + poison the price scale or the volume scale. +- A moving average, volume average and anything built on them no longer turn + to NaN for the rest of the series after one close that is not a number. +- `CandleTransforms.renko` and `rangeBars` stop at `maxBlocks` instead of + looping for millions of bricks when the size is far smaller than the move. +- A replay interval of zero started a timer that fired without pause; it is + held to a millisecond. +- The cumulative-delta chart worked out its divergences again on every frame + of its animation. +- The documentation of `indexRangeCovering` said a span after the data gives + null; it gives the last candle, which is open for its whole period, and the + documentation now says so. +- A funnel with a value that is not finite threw while labelling it, and a + bubble, footprint, order-book or option-payoff chart with a NaN threw while + painting. They draw around it now. +- An R-multiple chart in a box too short for its figures overflowed. +- A chart taken off a `ChartLink` while a scroll was waiting for the end of the + frame still moved the others once the frame ended. +- A replay that caught up with the end of a shrunken series stopped its timer + without telling its listeners, leaving a play/pause button stale. Passing + `play(interval:)` while playing now changes the pace instead of being ignored, + and a series reduced to nothing keeps one candle in play. + ## 3.1.1 - 2026-10-12 ### Fixed diff --git a/README.md b/README.md index e599b66..30a9aa3 100644 --- a/README.md +++ b/README.md @@ -5,395 +5,48 @@ [![CI](https://github.com/CtrlAltDevelop/ohlcv_chart/actions/workflows/ci.yml/badge.svg)](https://github.com/CtrlAltDevelop/ohlcv_chart/actions/workflows/ci.yml) [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/CtrlAltDevelop/ohlcv_chart/blob/main/LICENSE) -A candlestick chart for Flutter with 31 indicators and 29 drawing tools, and 39 -other chart widgets: the order book and the tape, the charts a backtest report -is built from, and the ones a dashboard is. Everything is rendered with -`CustomPainter` — no WebView and no JavaScript bridge. - -## Gallery - -Each image links to the documentation page for that feature. - -### Series, pie, radar, heatmap and depth charts - - - - - - - - - - - - - - - - - - - - - - -
Four business charts
Four business charts
Returns split at zero, profit bars, cash flows with a tooltip, and a sparkline.
Pie and radar
Pie and radar
A doughnut with its total in the centre, and a radar comparing two strategies.
Heatmap
Heatmap
Six months of activity, and orders by hour and weekday.
Scatter
Scatter
One point per trade, with per-point shape and colour and a single-point readout.
Stacked horizontal bars
Stacked horizontal bars
Stacked bars in horizontal orientation, with titled axes.
Floating bars and bands
Floating bars and bands
A waterfall chart, and a forecast band with error bars.
Range selector
Range selector
Three series over five months, with a range selector below.
Synchronised crosshair
Synchronised crosshair
Balance and profit panels sharing one crosshair.
Depth chart
Depth chart
Cumulative bid and ask depth.
Depth chart modes
Depth chart modes
Cumulative, histogram, combined and ladder modes.
The bid/ask ratio bar
The bid/ask ratio bar
The balance of bid and ask volume, below the depth chart.
- -### Order flow and derivatives - - - - - - - - - - - - - - - - - -
Market profile
Market profile
Time at price as TPO letters, with the point of control and value area.
Footprint
Footprint
Bid and ask volume at every price, inside each candle.
Order-book heatmap
Order-book heatmap
Resting liquidity over time, with walls as bright lines.
Cumulative delta
Cumulative delta
Running delta over a session, with buying and selling per bar.
Liquidity map
Liquidity map
Where the leveraged stops sit, longs below the price and shorts above.
Open interest and funding
Open interest and funding
How much money is in the trade, and which side is paying.
Options payoff
Options payoff
A bull call spread and a long straddle at expiry, with break-evens.
Volatility curves
Volatility curves
A smile at three expiries, and a term structure.
Pair spread
Pair spread
The log ratio of two symbols, with bands and a z-score.
- -### Backtests and results - - - - - - - - - - - - - - - - - -
Equity curve
Equity curve
The curve, with the underwater drawdown panel beneath it.
Monte Carlo fan
Monte Carlo fan
Simulated paths as percentile bands, with the odds of loss and ruin.
R-multiple distribution
R-multiple distribution
Results in R, with expectancy, profit factor and SQN.
Trade timeline
Trade timeline
Every trade from entry to exit, a lane per symbol.
Seasonality
Seasonality
Returns by month and year, compounded, with totals and averages.
Calendar
Calendar
Daily profit and loss on real month panels.
Waterfall
Waterfall
A balance built up step by step, from opening to closing.
- -### Dashboards and reports - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Treemap
Treemap
A market map of sectors and symbols, and portfolio weight.
Sunburst
Sunburst
A hierarchy as rings, with the total in the middle.
Sankey
Sankey
Fee income flowing through revenue into costs and profit.
Chord
Chord
Flow between venues, both ways round a ring.
Marimekko
Marimekko
Two dimensions at once: column width and cell height.
Stream graph
Stream graph
Two years of composition, on a wiggle baseline.
Parallel coordinates
Parallel coordinates
Five strategies compared on five measures at once.
Slope and bump
Slope and bump
Returns by quarter, by value and by changing rank.
Bubble
Bubble
Risk against return, sized by capital.
Box plot
Box plot
Quartiles, whiskers and outliers for four strategies.
Histogram
Histogram
Daily returns split at zero, and a spread of trade sizes.
Violin and ridgeline
Violin and ridgeline
The shape of a distribution, side by side or stacked.
Dumbbell
Dumbbell
Two values a row, joined by a bar.
Bullet
Bullet
KPIs against their targets, on banded tracks.
Gauge
Gauge
A dial with coloured ranges, a half circle and a progress ring.
Waffle
Waffle
Shares as a hundred squares, counted rather than judged.
Funnel
Funnel
Where the people go, tapered or straight-sided.
Sparkline grid
Sparkline grid
A watchlist of small multiples, a name and a shape each.
- -### Chart types - - - - - - - - - - - - -
Eight chart types
Eight chart types
OHLC bars, baseline, area, step line, HLC area and columns.
Four bar transforms
Four bar transforms
Line break, Kagi, point & figure and range bars.
Heikin-Ashi and Renko
Heikin-Ashi and Renko
Candles re-aggregated before rendering.
Symbol comparison
Symbol comparison
Another instrument on the same axis, rebased or at actual prices.
Dark and light
Dark and light
The same chart in both themes, with every colour configurable.
- -### 31 indicators - - - - - - - - - - - - -
Main-chart overlays
Main-chart overlays
Ichimoku Cloud and Supertrend, with Stochastic RSI and Awesome Oscillator panes.
Profile and anchored VWAP
Profile and anchored VWAP
A volume profile and a VWAP anchored to a candle.
Swing analysis
Swing analysis
ZigZag, Fibonacci retracement and Elliott wave labels.
Stacked panes
Stacked panes
Three ATR periods, each in its own resizable pane.
Higher timeframes
Higher timeframes
A four-hour moving average and RSI on 15-minute candles.
Indicator catalogue
Indicator catalogue
The example app's indicator picker, generated from the catalogue.
- -### Drawing tools and trading - - - - - - - - - - - - -
29 drawing tools
29 drawing tools
Labelled trend and horizontal lines, with snapping and persistence.
Rays, boxes and Fibonacci
Rays, boxes and Fibonacci
A ray, an arrow, a horizontal ray, a range box and a Fibonacci retracement.
The line editor
The line editor
Controls for the selected drawing, customisable through DrawingStyle.
Orders and positions
Orders and positions
A working order and an open position, each labelled on the axis.
Planning a trade
Planning a trade
A planned position with risk-to-reward, inside a channel.
Event marks
Event marks
Earnings, dividend, split and news markers below the candles.
- -### Interaction and navigation - - - - - - - - - - - - -
Legend and crosshair
Legend and crosshair
The OHLC legend, updated from the crosshair position.
The long-press readout
The long-press readout
The built-in candle details card, or a custom builder.
The overview strip
The overview strip
The full series below the chart, with the visible range highlighted.
Linked charts
Linked charts
Two charts with a synchronised crosshair.
Bar replay
Bar replay
Rewind, then step or play forward candle by candle.
Replay controls
Replay controls
Paused at candle 150 of 420, with the playback bar.
- -### Axes, sessions and layout - - - - - - - - - - - - - - - - - -
Four price axes
Four price axes
Linear, logarithmic, percentage or indexed to 100, and invertible.
Logarithmic axis
Logarithmic axis
Evenly spaced ratios for long-term price history.
The date axis
The date axis
Automatic round time intervals, or a custom formatter.
Sessions and time zones
Sessions and time zones
Pre-market and after-hours periods shaded behind the candles.
Session VWAP
Session VWAP
VWAP reset each session, with standard-deviation bands.
Responsive sizing
Responsive sizing
The candle area filling its container, or set to a fixed height.
Custom watermark
Custom watermark
Any widget, rendered faintly over the candle area.
- -## Overview - -`ohlcv_chart` covers both the trading screen and the balance, growth and -reporting charts that surround it, in a single dependency. The name refers to -the open, high, low, close and volume data the candlestick chart renders. - -The entire package is free, including for commercial use. Every chart type, -indicator and drawing tool is included; there is no paid tier. +Candlestick charts for Flutter, with indicators, drawing tools and a library of +other chart types. Pure `CustomPainter` — no WebView, no JavaScript bridge. +Free for any use, with no paid tier. + +

+ A candlestick chart with moving averages, volume and a MACD pane +

+ +## Highlights + +- **Candlestick chart** — eight chart types, Heikin-Ashi, Renko, Kagi and more, + four price scales, sessions, comparison overlays and bar replay. +- **31 indicators** — overlays and stacked panes, each a configured instance, + with higher-timeframe values that never repaint. +- **29 drawing tools** — Fibonacci, channels, patterns and positions, with an + editor, undo/redo, alerts and JSON persistence. +- **55 more chart widgets** — series, pie, heatmap, treemap, sankey, depth, + order flow, volatility and futures curves, risk and portfolio charts, the + charts a backtest report is made of, and charts for training and serving + language models. +- **Fully themeable** — `ChartStyle`, `ChartColors` and `ChartTranslations`. + +See the [full feature list](doc/features.md) and the [gallery](doc/gallery.md). > [!NOTE] -> The package is updated frequently, and the feature list below is a summary. -> See [`CHANGELOG.md`](CHANGELOG.md) for what changed in each release, and -> [`doc/`](doc/README.md) for a reference page per feature. - -## Features - -### Candlestick chart - -- **Eight chart types** — candles, OHLC bars, line, step line, area, HLC area, - baseline and columns — plus Heikin-Ashi, Renko, three-line break, Kagi, - point & figure and range bars as transforms of the source candles. -- **Four price scales** — linear, logarithmic, percentage or indexed to 100. - The axis can be inverted and can mark the visible high, low and average close. -- **Interactive price scale** — drag the axis labels to stretch or compress the - candles, drag the chart to pan, and double-tap to refit. All of it is also - available through the controller. -- **Fixed price range** — `lockPriceScale` holds the axis at a set range instead - of refitting it while scrolling, and `priceAxisWidth` reserves a gutter so the - candles never run underneath the labels. -- **Round axis values** — both axes pick readable values first (`69000`, - `69500`, `70000`; intraday dates on the hour) and place gridlines to match. -- **Responsive sizing** — the candle area takes the height left over by the - volume and indicator panes, from a phone to a desktop window. - -### Indicators - -- **31 indicators**, each a configured instance rather than a flag, so - `ATR(8)`, `ATR(14)` and `ATR(20)` can run side by side with their own - settings and colours. -- **Overlays** — `MA`, `EMA`, `BOLL`, `SAR`, Supertrend, Keltner and Donchian - channels, the Ichimoku Cloud, pivot points, a volume profile, and `VWAP` - (whole-series, anchored to a candle, or per session with standard-deviation - bands). -- **Swing analysis** — ZigZag, Fibonacci retracement of the latest swing and - Elliott wave labels, sized to the market by default. -- **Sub-chart panes** — `MACD`, `KDJ`, `RSI`, `WR`, `CCI`, `ATR`, `OBV`, `MFI`, - `DMI`, Aroon, Stochastic RSI, `ROC`, `TRIX`, volume average and the Awesome - Oscillator, each in a stacked pane that can be resized and reordered. -- **Higher timeframes** — for example, a daily moving average on a - 15-minute chart via `TimeframeIndicator`. Values come only from bars closed - before each candle opened, so the line never repaints. -- **Composition and alerts** — compute any indicator over another's output, use - logarithmic or percentage pane scales, and raise alerts on conditions such as - RSI above 70. -- **Indicator templates** — save a named set of indicators and apply it to any - chart; four starter sets are included. - -### Drawing tools - -- **29 tools** — horizontal levels and rays, trend lines, arrows, extended - lines, rectangles, ellipses, triangles, parallel channels, pitchforks, Gann - fans and boxes, four Fibonacci tools, regression trend, XABCD patterns, - multi-leg paths, price and date ranges, a measuring tool, long/short positions - with risk-to-reward, notes, callouts, flags and freehand. Each can be placed, - moved, locked, hidden and removed. -- **Line editor** — opens on selection with colour, opacity, width, line style, - fill, label, alerts, lock and delete. Every control is customisable through - `DrawingStyle`. -- **Undo and redo** through `ChartDrawingController`, with ⌘Z, ⇧⌘Z and Delete - shortcuts. -- **Multi-select** — shift- or ⌘-click, or ⌘A, then move, restyle or delete as a - group; ⌘C, ⌘V and ⌘D copy, paste and duplicate, and ⌘] / ⌘[ change stacking - order. -- **Precise placement** — a dialog for entering each anchor's price and candle - directly. -- **Style templates** — save a drawing's appearance and apply it to others. -- **Persistence** — drawings serialise to JSON with `drawings.toJson()` and load - back with `ChartDrawings.fromJson`. -- **Drawing manager** — a ready-made panel listing all drawings, with - show/hide, lock, delete, undo, redo and clear. -- **Price alerts** on horizontal levels, trend lines, channel boundaries and - Fibonacci levels, triggered when price crosses them. - -### Interaction and navigation - -- **Crosshair and OHLC legend** on hover, for desktop and web. -- **Long-press readout** — the built-in Material card or your own builder. -- **Context menu** on the chart and on drawings, extensible with your own items. -- **`KChartController`** — zoom, jump back to the latest candle and export the - chart as a PNG. -- **Visible range** — read and set the visible candles, go to a date, fit to - screen, and listen for changes. -- **Overview strip** — `ChartOverview` shows the full history with the visible - range highlighted; drag to scroll, drag its edges to zoom, tap to jump. -- **Linked charts** — `ChartLink` synchronises the visible range and crosshair - across any number of charts; `ChartLink.all()` also syncs the price axis. -- **Static mode** — `scrollEnabled` and `zoomEnabled` disable built-in gestures - for thumbnails and fixed figures, while programmatic control still works. -- **Bar replay** — rewind to any candle and step or play forward, with - indicators limited to the data revealed so far. - -### Trading features - -- **Orders and positions** — live lines showing side, size and P&L, draggable - to modify. -- **Signal markers** — buy and sell markers pinned to candles. -- **Event marks** — tappable badges for earnings, dividends, splits and news. -- **Symbol comparison** — overlay any number of instruments, rebased for - relative performance and aligned by timestamp. -- **Sessions and time zones** — session dividers, a display time zone, - extended-hours shading and per-bar colours. -- **Live price line** with a countdown to the current candle's close. - -### Other chart widgets - -- **`SeriesChart`** — lines with four curve styles, areas with baseline fills - that change colour where they cross it, grouped, stacked and floating bars, - scatter plots, error bars, bands, custom or hidden axes, horizontal - orientation, custom tooltips, shared crosshairs, a range selector for long - data, and animated updates. -- **`PieChart`** — pie, doughnut (with a widget in the centre) or ring gauge, - with exploded slices and badges. -- **`RadarChart`** — multi-series radar charts, one outline per series. -- **`HeatmapChart`** — a grid coloured by value from a matrix or sparse cells, - with continuous or stepped colour scales, cell and axis labels, a legend and - a hover readout. Suited to contribution graphs, correlation matrices and - time-of-day breakdowns. -- **`TreemapChart`** — squarified tiles sized by value, with nested groups, - colour scales for market maps, labels, touch and tooltips. -- **`GaugeChart`** — a dial or semicircle for a single value, with coloured - ranges, a needle, ticks, centre text and animated transitions. -- **`WaterfallChart`** — a bridge from an opening total to a closing one, step - by step, with subtotals, connectors and values over the bars. -- **`FunnelChart`** — tapered or stepped stages with conversion percentages, - fitted or side labels, touch and tooltips. -- **`SankeyChart`** — flows between nodes as ribbons sized by value, with - automatic columns, node and link touch, and highlight on hover. -- **`SunburstChart`** — a hierarchy as rings round a centre, from the same tree - a treemap takes, with curved labels, a centre widget and touch. -- **`BoxPlotChart`** — quartiles, whiskers, mean and outliers side by side, with - the statistics worked out from raw samples for you. -- **`HistogramChart`** — the shape of a distribution, with binning by count or - width, a colour for negative bins and reference lines. -- **`BubbleChart`** — risk against return with a third number as bubble area, - with axis titles, reference lines and labelled bubbles. -- **`CalendarChart`** — daily profit and loss as real months, each day coloured - by its value, with monthly totals in the headers. -- **`EquityCurveChart`** — an account's value with an underwater drawdown panel, - the deepest fall marked, and drawdown statistics you can read yourself. -- **`OptionPayoffChart`** — an options strategy's profit at expiry, with exact - break-evens, strike lines, the spot marked, and profit and loss shaded apart. -- **`VolatilityCurveChart`** — implied volatility smiles, skews and term - structures, several expiries at once with a crosshair that reads them all. -- **`MarketProfileChart`** — time at price as TPO letters, with the point of - control and the value area worked out from your candles. -- **`FootprintChart`** — bid against ask volume at every price inside every bar, - shaded by size, with imbalances outlined and cumulative delta. -- **`BookHeatmapChart`** — resting order-book liquidity over time, walls showing - as bright lines, with a mid-price track and a crosshair readout. -- **`CumulativeDeltaChart`** — buying less selling as a running total with each - bar's own delta beneath, and divergences against price marked. -- **`TradeTimelineChart`** — every trade as a bar from entry to exit, a lane per - symbol, coloured by result, with an open-trade count beneath. -- **`RMultipleChart`** — trade results in R, with expectancy, win rate, profit - factor and SQN worked out for you. -- **`MonteCarloChart`** — a fan of simulated equity paths as percentile bands, - with the odds of loss and ruin and the real curve laid over. -- **`SeasonalityChart`** — returns by month and year or weekday and hour, with - yearly totals and the typical month worked out for you. -- **`DepthChart`** — order-book depth as a cumulative curve, per-level - histogram, both combined, or a numeric ladder; on a linear, logarithmic or - percentage axis, zoomable around the mid price, with an optional bid/ask - ratio bar. - -### Customisation - -- **Theming** — `ChartStyle` for layout, `ChartColors` for colours, - `DrawingStyle` for drawing tools and `ChartTranslations` for every label. - Includes filled or hollow candles, crosshair style, pane separators and - axis-label pills. -- **Watermark** — any widget, such as an `Image.asset` logo, painted as a faint - single-colour overlay on the candle area. - -## Requirements - -- Dart 3.12.0 or later -- Flutter 3.44.0 or later +> This package has grown large. To keep it fast and focused, the next update +> will split it into separate packages. `ohlcv_chart` will keep only the +> candlestick (OHLCV) chart, its indicators and its drawing tools; the other +> chart widgets will move to their own packages. ## Installation ```yaml dependencies: material_ui: ">=1.0.0 <2.0.0" - ohlcv_chart: ">=3.1.1 <4.0.0" + ohlcv_chart: ">=3.2.0 <4.0.0" ``` -Every widget here is built on the `material_ui` package rather than the -Material library that used to ship inside the Flutter SDK, so import -`package:material_ui/material_ui.dart` where your app imported -`package:flutter/material.dart`. +Requires Dart 3.12 and Flutter 3.44 or later. Widgets are built on the +`material_ui` package rather than the Material library that used to ship in the +Flutter SDK, so import `package:material_ui/material_ui.dart` where your app +imported `package:flutter/material.dart`. ## Quick start @@ -414,101 +67,103 @@ KChartWidget( ); ``` -See [Candlestick chart](doc/candlestick-chart.md) for the full constructor -reference. The [example app](example/) demonstrates every option in the package. +The [example app](example/) demonstrates every option in the package. + +## Charts + +Screenshots and a reference page for every chart are in the +[gallery](doc/gallery.md). + +### Candlestick, indicators and drawing + +- **Chart types** — [candles, Heikin-Ashi, Renko, Kagi and more](doc/chart-types.md), + [symbol comparison](doc/comparison.md), [bar replay](doc/bar-replay.md) +- **Indicators** — [31 overlays and stacked panes](doc/indicators.md), + [panes](doc/panes.md) +- **Drawing tools and trading** — [29 drawing tools](doc/drawing-tools.md), + [line editor](doc/line-editor.md), + [orders and positions](doc/orders-and-positions.md), + [event marks](doc/event-marks.md) + +### Business and statistical + +- **Series, pie, radar, heatmap and depth** — [series](doc/series-chart.md), + [pie and radar](doc/pie-chart.md), [heatmap](doc/heatmap-chart.md), + [depth](doc/depth-chart.md) +- **Dashboards and reports** — [treemap](doc/treemap-chart.md), + [sunburst](doc/sunburst-chart.md), [sankey](doc/sankey-chart.md), + [chord](doc/chord-chart.md), [marimekko](doc/marimekko-chart.md), + [stream](doc/stream-chart.md), [parallel coordinates](doc/parallel-chart.md), + [slope and bump](doc/slope-chart.md), [bubble](doc/bubble-chart.md), + [box plot](doc/box-plot-chart.md), [histogram](doc/histogram-chart.md), + [violin and ridgeline](doc/violin-chart.md), [dumbbell](doc/dumbbell-chart.md), + [bullet](doc/bullet-chart.md), [gauge](doc/gauge-chart.md), + [waffle](doc/waffle-chart.md), [funnel](doc/funnel-chart.md), + [sparkline grid](doc/sparkline-grid.md) +- **Progress and goals** — [progress bars and progress cards](doc/progress-bars.md) + +### Trading analysis + +- **Order flow and derivatives** — [market profile](doc/market-profile-chart.md), + [footprint](doc/footprint-chart.md), + [order-book heatmap](doc/book-heatmap-chart.md), + [cumulative delta](doc/cumulative-delta-chart.md), + [liquidity map](doc/liquidity-map-chart.md), + [open interest and funding](doc/open-interest-chart.md), + [options payoff](doc/option-payoff-chart.md), + [volatility curves](doc/volatility-curve-chart.md), + [volatility surface](doc/volatility-surface-chart.md), + [pair spread](doc/pair-spread-chart.md), + [futures curve](doc/futures-curve-chart.md) +- **Backtests and results** — [equity curve](doc/equity-curve-chart.md), + [Monte Carlo fan](doc/monte-carlo-chart.md), + [R-multiple](doc/r-multiple-chart.md), + [trade timeline](doc/trade-timeline-chart.md), + [seasonality](doc/seasonality-chart.md), [calendar](doc/calendar-chart.md), + [waterfall](doc/waterfall-chart.md), [MAE / MFE](doc/mae-mfe-chart.md), + [value at risk](doc/var-chart.md), + [efficient frontier](doc/efficient-frontier-chart.md), + [activity clock](doc/activity-clock-chart.md) + +### Models, tokens and AI + +- **Training** — [training curves](doc/training-curve-chart.md), + [scaling laws](doc/scaling-law-chart.md), + [hyperparameter sweep](doc/sweep-chart.md), + [training timeline](doc/training-timeline-chart.md) +- **Evaluation** — [benchmark matrix](doc/benchmark-matrix-chart.md), + [model cost against quality](doc/model-pareto-chart.md), + [evaluation breakdown](doc/eval-breakdown-chart.md) +- **Serving and tokens** — [latency bands](doc/latency-band-chart.md), + [token usage](doc/token-usage-chart.md), + [token heatmap](doc/token-heatmap.md) + +### Axes and interaction + +- **Axes, sessions and layout** — [price axes](doc/price-axis.md), + [date axis](doc/date-axis.md), [sessions](doc/sessions.md), + [sizing](doc/sizing.md), [theming](doc/theming.md) +- **Interaction** — [legend and crosshair](doc/legend-and-crosshair.md), + [long-press readout](doc/readout.md), + [driving the chart](doc/driving-the-chart.md) ## Documentation -Each feature has a reference page in [`doc/`](doc/README.md): - -| Page | Covers | -| --- | --- | -| [Candlestick chart](doc/candlestick-chart.md) | The main chart widget and the `KLineEntity` data model | -| [Series charts](doc/series-chart.md) | `SeriesChart`: lines, areas, bars, scatter, touch, range selector and animation | -| [Pie chart](doc/pie-chart.md) | `PieChart`: sections, doughnut charts, badges and touch | -| [Radar chart](doc/radar-chart.md) | `RadarChart`: multi-series comparison across features | -| [Heatmap](doc/heatmap-chart.md) | `HeatmapChart`: value-coloured grids, colour scales and legends | -| [Treemap](doc/treemap-chart.md) | `TreemapChart`: tiles sized by value, groups and market maps | -| [Gauge](doc/gauge-chart.md) | `GaugeChart`: dials with ranges, needle, ticks and centre content | -| [Waterfall](doc/waterfall-chart.md) | `WaterfallChart`: a total built up step by step | -| [Funnel](doc/funnel-chart.md) | `FunnelChart`: stage drop-off with conversion percentages | -| [Sankey](doc/sankey-chart.md) | `SankeyChart`: flows between nodes, sized by value | -| [Sunburst](doc/sunburst-chart.md) | `SunburstChart`: a hierarchy as rings round a centre | -| [Box plot](doc/box-plot-chart.md) | `BoxPlotChart`: quartiles, whiskers and outliers | -| [Histogram](doc/histogram-chart.md) | `HistogramChart`: distribution shape, with binning helpers | -| [Bubble](doc/bubble-chart.md) | `BubbleChart`: three numbers at once, size carried by area | -| [Calendar](doc/calendar-chart.md) | `CalendarChart`: daily values laid out as real months | -| [Equity curve](doc/equity-curve-chart.md) | `EquityCurveChart`: equity with an underwater drawdown panel | -| [Options payoff](doc/option-payoff-chart.md) | `OptionPayoffChart`: strategy profit at expiry, with break-evens | -| [Volatility curve](doc/volatility-curve-chart.md) | `VolatilityCurveChart`: smiles, skews and term structures | -| [Market profile](doc/market-profile-chart.md) | `MarketProfileChart`: time at price, TPO letters and value area | -| [Footprint](doc/footprint-chart.md) | `FootprintChart`: bid and ask volume at every price, order flow | -| [Order-book heatmap](doc/book-heatmap-chart.md) | `BookHeatmapChart`: resting liquidity over time | -| [Open interest and funding](doc/open-interest-chart.md) | `OpenInterestChart`: how much money is in the trade, and who is paying | -| [Liquidity map](doc/liquidity-map-chart.md) | `LiquidityMapChart`: where the stops are, and what a move would set off | -| [Cumulative delta](doc/cumulative-delta-chart.md) | `CumulativeDeltaChart`: running delta with divergences | -| [Trade timeline](doc/trade-timeline-chart.md) | `TradeTimelineChart`: when each trade was open, and what it made | -| [R-multiple distribution](doc/r-multiple-chart.md) | `RMultipleChart`: results in R, with expectancy and SQN | -| [Monte Carlo fan](doc/monte-carlo-chart.md) | `MonteCarloChart`: simulated equity paths as percentile bands | -| [Seasonality](doc/seasonality-chart.md) | `SeasonalityChart`: results by month and year, or weekday and hour | -| [Sparkline grid](doc/sparkline-grid.md) | `SparklineGrid`: dozens of little charts in one table | -| [Parallel coordinates](doc/parallel-chart.md) | `ParallelChart`: many things on many measures, one axis each | -| [Chord](doc/chord-chart.md) | `ChordChart`: flow between nodes both ways round a ring | -| [Marimekko](doc/marimekko-chart.md) | `MarimekkoChart`: two dimensions at once, column width and cell height | -| [Stream graph](doc/stream-chart.md) | `StreamChart`: a stack that flows, on a wiggle baseline | -| [Violin and ridgeline](doc/violin-chart.md) | `ViolinChart`: the shape of a distribution, not just its quartiles | -| [Slope and bump](doc/slope-chart.md) | `SlopeChart`: how things moved between periods, by value or by rank | -| [Dumbbell](doc/dumbbell-chart.md) | `DumbbellChart`: two values a row, joined by a bar | -| [Waffle](doc/waffle-chart.md) | `WaffleChart`: parts of a whole as a grid of squares | -| [Bullet](doc/bullet-chart.md) | `BulletChart`: a measure against its target on a banded track | -| [Pair spread](doc/pair-spread-chart.md) | `PairSpreadChart`: the spread or ratio of two symbols, with z-score bands | -| [Indicators](doc/indicators.md) | All 31 indicators, the catalogue, pane scales, chaining, higher timeframes and alerts | -| [Symbol comparison](doc/comparison.md) | Overlaying other instruments, rebased or at actual prices | -| [Chart types](doc/chart-types.md) | Eight chart types and six candle transforms | -| [Price axis](doc/price-axis.md) | Scale types, inversion, dragging, locking and label gutter | -| [Date axis](doc/date-axis.md) | Automatic time intervals and custom formatting | -| [Legend and crosshair](doc/legend-and-crosshair.md) | The OHLC legend and hover crosshair | -| [Long-press readout](doc/readout.md) | The candle details card and custom builders | -| [Drawing tools](doc/drawing-tools.md) | All 29 tools, persistence, undo, multi-select, templates and alerts | -| [Customising the line editor](doc/line-editor.md) | Configuring the editor through `DrawingStyle` | -| [Orders and positions](doc/orders-and-positions.md) | Live order and position lines, labelled and draggable | -| [Event marks](doc/event-marks.md) | Earnings, dividend, split and news markers | -| [Sessions and time zones](doc/sessions.md) | Session dividers, display time zone, extended hours and per-bar colours | -| [Driving the chart](doc/driving-the-chart.md) | `KChartController`, the visible range and static charts | -| [Panes](doc/panes.md) | Stacking, resizing and reordering indicator panes | -| [Bar replay](doc/bar-replay.md) | Replaying historical data candle by candle | -| [Sizing](doc/sizing.md) | How the candle area and panes share the available height | -| [Depth chart](doc/depth-chart.md) | `DepthChart`: four display modes and three scales | -| [Theming](doc/theming.md) | `ChartStyle`, `ChartColors` and `ChartTranslations` | -| [Migrating from 1.x](doc/migrating-from-1.x.md) | Breaking changes and their replacements | -| [Migrating from fl_chart and candlesticks](doc/migrating-from-fl_chart.md) | API mapping from both packages, with examples | - -## Platform notes - -- The zoom slider is shown only on platforms without pinch gestures (web and - desktop). -- `watermark` paints the widget in `ChartColors.watermarkColor`, so full-colour - logos render as a silhouette. The package does not depend on `flutter_svg`; - to use an SVG, pass `SvgPicture.asset(...)` from that package. - -## Support the project - -If `ohlcv_chart` is useful to you, please consider: - -- starring the [repository](https://github.com/CtrlAltDevelop/ohlcv_chart) -- liking the [package on pub.dev](https://pub.dev/packages/ohlcv_chart) -- following [CtrlAltDevelop on GitHub](https://github.com/CtrlAltDevelop) - -It helps other developers find the package and supports its continued -development. Bug reports and pull requests are welcome — see -[`CONTRIBUTING.md`](CONTRIBUTING.md). - -## Credits - -The rendering core is derived from the open-source `k_chart` package and has -since been substantially extended with drawing tools, signals, multi-indicator -stacking and a reworked rendering pipeline. +- [Documentation index](doc/README.md) — a reference page per feature +- [Features](doc/features.md) and [gallery](doc/gallery.md) +- [Changelog](CHANGELOG.md) — what changed in each release +- [Migrating from 1.x](doc/migrating-from-1.x.md) and + [from fl_chart and candlesticks](doc/migrating-from-fl_chart.md) + +## Contributing + +Bug reports and pull requests are welcome — see +[`CONTRIBUTING.md`](CONTRIBUTING.md). If the package is useful to you, a star +on the [repository](https://github.com/CtrlAltDevelop/ohlcv_chart) or a like on +[pub.dev](https://pub.dev/packages/ohlcv_chart) helps others find it. ## License -Released under the MIT License. See [LICENSE](LICENSE). +Released under the MIT License. See [LICENSE](LICENSE). The rendering core is +derived from the open-source `k_chart` package and has since been substantially +extended. diff --git a/doc/README.md b/doc/README.md index 9a617ce..8c525e9 100644 --- a/doc/README.md +++ b/doc/README.md @@ -4,8 +4,9 @@ Reference documentation for every feature, one page each. Start with [Candlestick chart](candlestick-chart.md); the remaining pages can be read in any order. -For installation and an overview of features, see the -[package README](../README.md). +For installation and a quick start, see the [package README](../README.md); +for what the package does at a glance, see [Features](features.md) and the +[Gallery](gallery.md). ## Getting started @@ -46,6 +47,11 @@ For installation and an overview of features, see the strategy makes or loses at expiry, with break-evens marked. - **[Volatility curve](volatility-curve-chart.md)** — `VolatilityCurveChart`: smiles, skews and term structures, several expiries at once. +- **[Volatility surface](volatility-surface-chart.md)** — + `VolatilitySurfaceChart`: implied volatility across strike and expiry as a + heatmap, with the skew against the at-the-money strike. +- **[Futures curve](futures-curve-chart.md)** — `FuturesCurveChart`: prices + against delivery month, with contango, backwardation and calendar spreads. - **[Market profile](market-profile-chart.md)** — `MarketProfileChart`: time at price as TPO letters, with the point of control and value area. - **[Footprint](footprint-chart.md)** — `FootprintChart`: bid and ask volume at @@ -62,10 +68,16 @@ For installation and an overview of features, see the trade from entry to exit, a lane per symbol, coloured by result. - **[R-multiple distribution](r-multiple-chart.md)** — `RMultipleChart`: trade results in R, with expectancy, profit factor and SQN. +- **[MAE / MFE scatter](mae-mfe-chart.md)** — `MaeMfeChart`: every trade by how + far it went against you or for you, to place a stop and a target. +- **[Value at risk](var-chart.md)** — `VarChart`: a histogram of returns with the + loss tail picked out, and VaR and expected shortfall marked. - **[Monte Carlo fan](monte-carlo-chart.md)** — `MonteCarloChart`: simulated equity paths as percentile bands, with the odds of loss and ruin. - **[Seasonality](seasonality-chart.md)** — `SeasonalityChart`: results by month and year, or weekday and hour, with totals and averages. +- **[Activity clock](activity-clock-chart.md)** — `ActivityClockChart`: a week + as a clock face, an hour round the dial and a weekday on each ring. - **[Sparkline grid](sparkline-grid.md)** — `SparklineGrid`: small multiples for a watchlist, a name and a shape each. - **[Parallel coordinates](parallel-chart.md)** — `ParallelChart`: many things @@ -86,10 +98,27 @@ For installation and an overview of features, see the squares, a cell per per cent. - **[Bullet](bullet-chart.md)** — `BulletChart`: a measure against its target on a banded track, a row per KPI. +- **[Progress bars and cards](progress-bars.md)** — `ProgressBars`: how far + each goal has got, as pills, blocks or cells, with a mark for where it should + be; `ProgressCard`: a card whose whole background is the progress, as a sweep, + a growing circle or a bar. - **[Pair spread](pair-spread-chart.md)** — `PairSpreadChart`: the spread or ratio of two symbols, with z-score bands and entry and exit signals. +- **[Efficient frontier](efficient-frontier-chart.md)** — + `EfficientFrontierChart`: portfolios by risk and return, with the frontier, + the best Sharpe ratio and the capital market line. - **[Migrating from fl_chart and candlesticks](migrating-from-fl_chart.md)** — API mapping from both packages, with examples. +- **[Training curves](training-curve-chart.md)** — `TrainingCurveChart`: loss and accuracy of one or more runs, with smoothing, a log axis, the best checkpoint and divergence marked. +- **[Scaling laws](scaling-law-chart.md)** — `ScalingLawChart`: loss against compute on log-log axes, with the power law fitted and carried past the data. +- **[Hyperparameter sweep](sweep-chart.md)** — `SweepChart`: a two-setting sweep as a grid of scores, with the best combination starred. +- **[Training timeline](training-timeline-chart.md)** — `TrainingTimelineChart`: training jobs by machine, coloured by how they finished. +- **[Benchmark matrix](benchmark-matrix-chart.md)** — `BenchmarkMatrixChart`: models against benchmarks, coloured by standing, with the leaders starred. +- **[Model cost against quality](model-pareto-chart.md)** — `ModelParetoChart`: models by cost and score with the Pareto frontier traced through the ones nothing beats. +- **[Latency bands](latency-band-chart.md)** — `LatencyBandChart`: p50, p95 and p99 latency over time as bands, with an objective and the intervals that broke it. +- **[Token usage](token-usage-chart.md)** — `TokenUsageChart`: input, output and cached tokens over time as stacked columns, with the cost as a line. +- **[Evaluation breakdown](eval-breakdown-chart.md)** — `EvalBreakdownChart`: an evaluation by category: passed, failed and skipped, worst first, with an interval on each rate. +- **[Token heatmap](token-heatmap.md)** — `TokenHeatmap`: a model's text with each token shaded by its surprisal, entropy or attribution. ## Indicators and data diff --git a/doc/activity-clock-chart.md b/doc/activity-clock-chart.md new file mode 100644 index 0000000..2a36e39 --- /dev/null +++ b/doc/activity-clock-chart.md @@ -0,0 +1,80 @@ +# Activity clock + +![Trades opened by hour and weekday on a clock face](https://raw.githubusercontent.com/CtrlAltDevelop/ohlcv_chart/main/screenshots/activity-clock.png) + +`ActivityClockChart` lays a week out as a clock face: the hours of the day run +round the dial, each weekday takes a ring, and every segment is coloured by what +happened then — trades opened, volume traded, profit made. + +Where `SeasonalityChart` shows the same numbers in a grid, the clock wraps the +day round so the quiet night and the busy open are a *shape* rather than a +column of cells. + +```dart +ActivityClockChart( + samples: [ + for (final t in trades) SeasonalSample(time: t.openedAt, value: t.pnl), + ], + aggregate: SeasonalityAggregate.mean, +); +``` + +Twelve o'clock is midnight and the hours run clockwise; Monday is the innermost +ring and Sunday the outermost. The chart fills the largest square its box +allows. When the height is unbounded, it uses `defaultHeight`. + +## Data + +The chart takes `SeasonalSample`s, the same class `SeasonalityChart` does, so +`seasonalReturnsFromPrices` works here too. `aggregate` says how the samples that +land in one segment become its value: + +| `SeasonalityAggregate` | Segment value | +| --- | --- | +| `mean` (default) | Their average | +| `sum` | Their total | +| `count` | How many there were — the scale starts at zero | +| `median`, `winRate`, `compound` | As their names say | + +The time of a sample is read as given, so convert it to the zone you want the +clock to show before passing it in. + +## Colour + +`scale` is any `HeatmapScale` — a `HeatmapGradientScale` from a faint to a full +colour by default. `minValue` and `maxValue` fix its ends, and otherwise it runs +from the lowest to the highest segment. A segment with no sample takes the +scale's `emptyColor`. + +## Numbers + +| Function | Description | +| --- | --- | +| `layOutClock(rows, columns, bounds)` | The `ClockSegment`s the chart paints | +| `clockSegmentAt(segments, center, local)` | Which segment a point is in, or `null` | + +Both are usable without a widget. + +## Appearance + +| Parameter | Description | +| --- | --- | +| `innerFraction` | How much of the radius the empty hole in the middle takes | +| `ringGap` | Pixels between rings | +| `showHourLabels`, `hourLabelStep` | Hours written round the outside, every nth | +| `showWeekdayLabels`, `weekdayLabels` | Weekday names up the twelve o'clock line | +| `labelStyle`, `hoverBorder`, `backgroundColor`, `padding` | Style and box | + +## Touch + +`onTouch` reports an `ActivityClockTouchDetails` with the `weekday` (0 for Monday), +`hour`, `weekdayLabel`, `value` and `count`, and `null` when the pointer leaves. +`tooltipBuilder` builds a card beside the segment. + +## Animation + +`animationDuration` fades the segments in; `animationCurve` eases it and +`animateOnMount` controls the first build. It animates again only when the +samples' values change. + +[← All docs](README.md) · [Package README](../README.md) diff --git a/doc/benchmark-matrix-chart.md b/doc/benchmark-matrix-chart.md new file mode 100644 index 0000000..4de8402 --- /dev/null +++ b/doc/benchmark-matrix-chart.md @@ -0,0 +1,60 @@ +# Benchmark matrix + +![Six models against six benchmarks, each score coloured by its standing in its column](https://raw.githubusercontent.com/CtrlAltDevelop/ohlcv_chart/main/screenshots/benchmark-matrix.png) + +`BenchmarkMatrixChart` compares models across benchmarks in one grid: each score +coloured by **how it stands among the models on that benchmark**, the leader of +each benchmark starred, and an average column to rank the models by. + +```dart +BenchmarkMatrixChart( + models: const ['Model A', 'Model B', 'Model C'], + benchmarks: const ['MMLU', 'GSM8K', 'HumanEval', 'Perplexity'], + scores: [ + [86.4, 92.0, 80.5, 5.1], + [83.1, 88.7, 76.2, 5.6], + [79.9, 81.3, 71.0, 6.4], + ], + lowerIsBetter: const {3}, // perplexity: lower wins +); +``` + +The chart fills its constraints. When the height is unbounded, it uses +`defaultHeight`. It is built on `HeatmapChart`. + +## Reading it + +Colour is **relative within a column**, so a hard benchmark and an easy one read +alike and one metric's scale does not wash out the others; the number written in +the square is the raw score. `lowerIsBetter` holds the indices of benchmarks +where a lower score wins, and turns their colours over. + +The `Avg` column is each model's mean standing, 0 to 100, across the +benchmarks it has a score for. With `sortByAverage` the best model is on top. +A missing score — `null` or not finite — is an empty square and is left out of +the average. + +## Numbers + +| Function | Description | +| --- | --- | +| `benchmarkQuality(scores, lowerIsBetter:)` | Each score's standing in its column, 0 to 1 | +| `benchmarkAverages(quality)` | Each model's mean standing | +| `benchmarkLeaders(scores, lowerIsBetter:)` | The best model of each benchmark | + +## Appearance + +| Parameter | Description | +| --- | --- | +| `lowColor`, `midColor`, `highColor` | The scale, worst to best | +| `markLeaders`, `showAverage`, `averageLabel` | The star and the average column | +| `scoreFormatter`, `labelStyle`, `axisLabelStyle` | Numbers and labels | +| `modelAxisWidth`, `benchmarkAxisHeight`, `spacing`, `radius` | Layout | + +## Touch + +`onTouch` reports a `BenchmarkTouchDetails` with the `model`, `benchmark`, raw +`score`, its `quality`, and whether it `isLeader` or the `isAverage` column, and +`null` when the pointer leaves. + +[← All docs](README.md) · [Package README](../README.md) diff --git a/doc/efficient-frontier-chart.md b/doc/efficient-frontier-chart.md new file mode 100644 index 0000000..bfc5547 --- /dev/null +++ b/doc/efficient-frontier-chart.md @@ -0,0 +1,83 @@ +# Efficient frontier + +![Portfolios by risk and return with the efficient frontier](https://raw.githubusercontent.com/CtrlAltDevelop/ohlcv_chart/main/screenshots/efficient-frontier.png) + +`EfficientFrontierChart` plots portfolios by risk and expected return and traces +the **efficient frontier** through the ones nothing beats: for the risk they +carry, no other candidate returns more. It marks the portfolio with the best +return per unit of risk, the least risky one, and — optionally — the portfolio +you hold now, apart from the candidates. + +```dart +EfficientFrontierChart( + points: [ + for (final w in candidateWeights) + PortfolioPoint(risk: w.volatility, expectedReturn: w.mean), + ], + riskFreeRate: 0.02, + current: const PortfolioPoint(risk: 0.14, expectedReturn: 0.07), + riskFormatter: (v) => '${(v * 100).toStringAsFixed(0)}%', + returnFormatter: (v) => '${(v * 100).toStringAsFixed(0)}%', +); +``` + +The chart fills its constraints. When the height is unbounded, it uses +`defaultHeight`. + +## Portfolios + +| Field | Description | +| --- | --- | +| `risk` | Volatility, or standard deviation | +| `expectedReturn` | What it is expected to return, in the same period and unit | +| `label`, `color`, `data` | Name, a colour of its own, and app data returned on touch | + +`risk` and `expectedReturn` share a unit — fractions or percentages — and a +period, such as annualised. + +## What it marks + +| Mark | Meaning | Switch | +| --- | --- | --- | +| Line through green dots | The frontier: portfolios no other beats | `showFrontier` | +| Diamond | The best return per unit of risk above `riskFreeRate` | `showMaxSharpe` | +| Square | The least risky portfolio | `showMinRisk` | +| Dashed line | The capital market line, from the risk-free rate through the diamond | `showCapitalMarketLine` | +| Ringed dot | `current`, the portfolio held now | pass `current` | + +## Numbers + +| Function | Description | +| --- | --- | +| `efficientFrontierIndices(points)` | The frontier's indices, by rising risk | +| `maxSharpeIndex(points, riskFree:)` | The best return per unit of risk, or `null` | +| `minRiskIndex(points)` | The least risky portfolio | +| `PortfolioPoint.sharpe(riskFree)` | One portfolio's return per unit of risk above the rate | +| `frontierRange(points)` | The axis ranges the chart would pick | + +A portfolio with a number that is not finite is never on the frontier and is +not drawn. + +## Appearance + +| Parameter | Description | +| --- | --- | +| `portfolioColor`, `frontierColor`, `maxSharpeColor`, `minRiskColor`, `currentColor` | Colours | +| `dotRadius`, `markerSize`, `frontierWidth` | Sizes | +| `showMarkerLabels`, `maxSharpeLabel`, `minRiskLabel`, `currentLabel` | Names beside the markers | +| `showAxes`, `riskFormatter`, `returnFormatter`, `riskAxisTitle`, `returnAxisTitle` | Axes | +| `gridColor`, `hoverBorder`, `backgroundColor`, `padding` | Grid, hover ring, box | + +## Touch + +`onTouch` reports a `FrontierTouchDetails` with the `dot` (its `role`, `point` and +`index`) and the `sharpe` ratio, and `null` when the pointer leaves. +`tooltipBuilder` builds a card beside the dot. + +## Animation + +`animationDuration` grows the plot in; `animationCurve` eases it and +`animateOnMount` controls the first build. It animates again only when the +portfolios' values change. + +[← All docs](README.md) · [Package README](../README.md) diff --git a/doc/eval-breakdown-chart.md b/doc/eval-breakdown-chart.md new file mode 100644 index 0000000..76d6623 --- /dev/null +++ b/doc/eval-breakdown-chart.md @@ -0,0 +1,67 @@ +# Evaluation breakdown + +![An evaluation by category as bars of passed, failed and skipped tasks with a threshold](https://raw.githubusercontent.com/CtrlAltDevelop/ohlcv_chart/main/screenshots/eval-breakdown.png) + +`EvalBreakdownChart` shows how an evaluation went **by category**: a bar of +passed, failed and skipped tasks for each, with the pass rate and the number of +tasks behind it, the worst category first. An overall figure sits above them, +and a threshold can be held across every bar. + +```dart +EvalBreakdownChart( + categories: const [ + EvalCategory(name: 'Math', passed: 71, failed: 29), + EvalCategory(name: 'Coding', passed: 88, failed: 12, skipped: 3), + EvalCategory(name: 'Safety', passed: 96, failed: 4), + ], + threshold: 0.8, +); +``` + +The chart fills its constraints. When the height is unbounded, it uses +`defaultHeight`; categories that do not fit the box are cut off at the bottom. + +## Categories + +| Field | Description | +| --- | --- | +| `name` | What the category is called | +| `passed`, `failed` | Tasks that ran | +| `skipped` | Tasks that errored or were not run — drawn in grey and left out of the pass rate | +| `passRate` | Passed over attempted; null when none ran | + +Bars share one scale, so a category with more tasks is a longer bar. + +## How sure the rate is + +A pass rate means little without how many tasks are behind it. `n=` is written +beside each rate, and touching a row reports the **Wilson 95% interval**: + +| Function | Description | +| --- | --- | +| `wilsonInterval(passed, attempted, z:)` | `(low, high)`; stays within 0..1 and is honest about small samples — 3 of 3 is not 100% sure | +| `overallPassRate(categories)` | The rate over every task that ran | +| `sortedByPassRate(categories)` | Worst first, empty categories last, stable | + +## The threshold + +`threshold` — 0.8 for "must pass 80%" — marks, on each bar, how far the passed +part has to reach. A bar whose green stops short of the tick misses it, and its +rate is written in the failed colour. + +## Appearance + +| Parameter | Description | +| --- | --- | +| `sortWorstFirst` | Worst category on top, or the order given | +| `showOverall`, `overallLabel`, `showCounts`, `rateFormatter` | The figures | +| `passedColor`, `failedColor`, `skippedColor`, `thresholdColor` | Colours | +| `nameWidth`, `rateWidth`, `barHeight`, `rowGap`, `barRadius` | Layout | + +## Touch + +`onTouch` reports an `EvalTouchDetails` with the `category`, its `index` in the +order drawn and the interval `low` and `high`, and `null` when the pointer +leaves. + +[← All docs](README.md) · [Package README](../README.md) diff --git a/doc/features.md b/doc/features.md new file mode 100644 index 0000000..3ba163a --- /dev/null +++ b/doc/features.md @@ -0,0 +1,203 @@ +# Features + +## Candlestick chart + +- **Eight chart types** — candles, OHLC bars, line, step line, area, HLC area, + baseline and columns — plus Heikin-Ashi, Renko, three-line break, Kagi, + point & figure and range bars as transforms of the source candles. +- **Four price scales** — linear, logarithmic, percentage or indexed to 100. + The axis can be inverted and can mark the visible high, low and average close. +- **Interactive price scale** — drag the axis labels to stretch or compress the + candles, drag the chart to pan, and double-tap to refit. All of it is also + available through the controller. +- **Fixed price range** — `lockPriceScale` holds the axis at a set range instead + of refitting it while scrolling, and `priceAxisWidth` reserves a gutter so the + candles never run underneath the labels. +- **Round axis values** — both axes pick readable values first (`69000`, + `69500`, `70000`; intraday dates on the hour) and place gridlines to match. +- **Responsive sizing** — the candle area takes the height left over by the + volume and indicator panes, from a phone to a desktop window. + +## Indicators + +- **31 indicators**, each a configured instance rather than a flag, so + `ATR(8)`, `ATR(14)` and `ATR(20)` can run side by side with their own + settings and colours. +- **Overlays** — `MA`, `EMA`, `BOLL`, `SAR`, Supertrend, Keltner and Donchian + channels, the Ichimoku Cloud, pivot points, a volume profile, and `VWAP` + (whole-series, anchored to a candle, or per session with standard-deviation + bands). +- **Swing analysis** — ZigZag, Fibonacci retracement of the latest swing and + Elliott wave labels, sized to the market by default. +- **Sub-chart panes** — `MACD`, `KDJ`, `RSI`, `WR`, `CCI`, `ATR`, `OBV`, `MFI`, + `DMI`, Aroon, Stochastic RSI, `ROC`, `TRIX`, volume average and the Awesome + Oscillator, each in a stacked pane that can be resized and reordered. +- **Higher timeframes** — for example, a daily moving average on a + 15-minute chart via `TimeframeIndicator`. Values come only from bars closed + before each candle opened, so the line never repaints. +- **Composition and alerts** — compute any indicator over another's output, use + logarithmic or percentage pane scales, and raise alerts on conditions such as + RSI above 70. +- **Indicator templates** — save a named set of indicators and apply it to any + chart; four starter sets are included. + +## Drawing tools + +- **29 tools** — horizontal levels and rays, trend lines, arrows, extended + lines, rectangles, ellipses, triangles, parallel channels, pitchforks, Gann + fans and boxes, four Fibonacci tools, regression trend, XABCD patterns, + multi-leg paths, price and date ranges, a measuring tool, long/short positions + with risk-to-reward, notes, callouts, flags and freehand. Each can be placed, + moved, locked, hidden and removed. +- **Line editor** — opens on selection with colour, opacity, width, line style, + fill, label, alerts, lock and delete. Every control is customisable through + `DrawingStyle`. +- **Undo and redo** through `ChartDrawingController`, with ⌘Z, ⇧⌘Z and Delete + shortcuts. +- **Multi-select** — shift- or ⌘-click, or ⌘A, then move, restyle or delete as a + group; ⌘C, ⌘V and ⌘D copy, paste and duplicate, and ⌘] / ⌘[ change stacking + order. +- **Precise placement** — a dialog for entering each anchor's price and candle + directly. +- **Style templates** — save a drawing's appearance and apply it to others. +- **Persistence** — drawings serialise to JSON with `drawings.toJson()` and load + back with `ChartDrawings.fromJson`. +- **Drawing manager** — a ready-made panel listing all drawings, with + show/hide, lock, delete, undo, redo and clear. +- **Price alerts** on horizontal levels, trend lines, channel boundaries and + Fibonacci levels, triggered when price crosses them. + +## Interaction and navigation + +- **Crosshair and OHLC legend** on hover, for desktop and web. +- **Long-press readout** — the built-in Material card or your own builder. +- **Context menu** on the chart and on drawings, extensible with your own items. +- **`KChartController`** — zoom, jump back to the latest candle and export the + chart as a PNG. +- **Visible range** — read and set the visible candles, go to a date, fit to + screen, and listen for changes. +- **Overview strip** — `ChartOverview` shows the full history with the visible + range highlighted; drag to scroll, drag its edges to zoom, tap to jump. +- **Linked charts** — `ChartLink` synchronises the visible range and crosshair + across any number of charts; `ChartLink.all()` also syncs the price axis. +- **Static mode** — `scrollEnabled` and `zoomEnabled` disable built-in gestures + for thumbnails and fixed figures, while programmatic control still works. +- **Bar replay** — rewind to any candle and step or play forward, with + indicators limited to the data revealed so far. + +## Trading features + +- **Orders and positions** — live lines showing side, size and P&L, draggable + to modify. +- **Signal markers** — buy and sell markers pinned to candles. +- **Event marks** — tappable badges for earnings, dividends, splits and news. +- **Symbol comparison** — overlay any number of instruments, rebased for + relative performance and aligned by timestamp. +- **Sessions and time zones** — session dividers, a display time zone, + extended-hours shading and per-bar colours. +- **Live price line** with a countdown to the current candle's close. + +## Other chart widgets + +- **`SeriesChart`** — lines with four curve styles, areas with baseline fills + that change colour where they cross it, grouped, stacked and floating bars, + scatter plots, error bars, bands, custom or hidden axes, horizontal + orientation, custom tooltips, shared crosshairs, a range selector for long + data, and animated updates. +- **`PieChart`** — pie, doughnut (with a widget in the centre) or ring gauge, + with exploded slices and badges. +- **`RadarChart`** — multi-series radar charts, one outline per series. +- **`HeatmapChart`** — a grid coloured by value from a matrix or sparse cells, + with continuous or stepped colour scales, cell and axis labels, a legend and + a hover readout. Suited to contribution graphs, correlation matrices and + time-of-day breakdowns. +- **`TreemapChart`** — squarified tiles sized by value, with nested groups, + colour scales for market maps, labels, touch and tooltips. +- **`GaugeChart`** — a dial or semicircle for a single value, with coloured + ranges, a needle, ticks, centre text and animated transitions. +- **`WaterfallChart`** — a bridge from an opening total to a closing one, step + by step, with subtotals, connectors and values over the bars. +- **`FunnelChart`** — tapered or stepped stages with conversion percentages, + fitted or side labels, touch and tooltips. +- **`SankeyChart`** — flows between nodes as ribbons sized by value, with + automatic columns, node and link touch, and highlight on hover. +- **`SunburstChart`** — a hierarchy as rings round a centre, from the same tree + a treemap takes, with curved labels, a centre widget and touch. +- **`BoxPlotChart`** — quartiles, whiskers, mean and outliers side by side, with + the statistics worked out from raw samples for you. +- **`HistogramChart`** — the shape of a distribution, with binning by count or + width, a colour for negative bins and reference lines. +- **`BubbleChart`** — risk against return with a third number as bubble area, + with axis titles, reference lines and labelled bubbles. +- **`CalendarChart`** — daily profit and loss as real months, each day coloured + by its value, with monthly totals in the headers. +- **`EquityCurveChart`** — an account's value with an underwater drawdown panel, + the deepest fall marked, and drawdown statistics you can read yourself. +- **`OptionPayoffChart`** — an options strategy's profit at expiry, with exact + break-evens, strike lines, the spot marked, and profit and loss shaded apart. +- **`VolatilityCurveChart`** — implied volatility smiles, skews and term + structures, several expiries at once with a crosshair that reads them all. +- **`VolatilitySurfaceChart`** — implied volatility across strike and expiry as + a heatmap, with the skew against the at-the-money strike on touch. +- **`FuturesCurveChart`** — prices against delivery month, one or more curves, + with contango and backwardation read off the first and the calendar spread on + touch. +- **`MarketProfileChart`** — time at price as TPO letters, with the point of + control and the value area worked out from your candles. +- **`FootprintChart`** — bid against ask volume at every price inside every bar, + shaded by size, with imbalances outlined and cumulative delta. +- **`BookHeatmapChart`** — resting order-book liquidity over time, walls showing + as bright lines, with a mid-price track and a crosshair readout. +- **`CumulativeDeltaChart`** — buying less selling as a running total with each + bar's own delta beneath, and divergences against price marked. +- **`TradeTimelineChart`** — every trade as a bar from entry to exit, a lane per + symbol, coloured by result, with an open-trade count beneath. +- **`RMultipleChart`** — trade results in R, with expectancy, win rate, profit + factor and SQN worked out for you. +- **`MaeMfeChart`** — every trade as a point by how far it went against you or + for you and what it made, with the limit line, reference stops, and the + winners a stop would have cost. +- **`VarChart`** — a histogram of returns with the loss tail picked out and the + value at risk and expected shortfall marked, worked out from the history. +- **`EfficientFrontierChart`** — portfolios by risk and return with the + efficient frontier traced through the best, the best Sharpe ratio, the least + risky portfolio and the capital market line. +- **`MonteCarloChart`** — a fan of simulated equity paths as percentile bands, + with the odds of loss and ruin and the real curve laid over. +- **`SeasonalityChart`** — returns by month and year or weekday and hour, with + yearly totals and the typical month worked out for you. +- **`ActivityClockChart`** — a week as a clock face: the hours of the day round + the dial and a weekday on each ring, coloured by trades, volume or profit. +- **`DepthChart`** — order-book depth as a cumulative curve, per-level + histogram, both combined, or a numeric ladder; on a linear, logarithmic or + percentage axis, zoomable around the mid price, with an optional bid/ask + ratio bar. +- **`TokenHeatmap`** — a model's text with each token shaded by its surprisal, entropy or attribution, flowing and wrapping like text. +- **`EvalBreakdownChart`** — an evaluation by category as bars of passed, failed and skipped tasks, worst first, with pass rate, task count and a threshold. +- **`TokenUsageChart`** — tokens served over time as stacked cached, input and output columns, with the cost as a line on its own axis. +- **`LatencyBandChart`** — latency over time as bands — the median, and the span to the 95th and 99th percentile — with an objective drawn across and the intervals that broke it marked. +- **`ModelParetoChart`** — models by cost against quality with the Pareto frontier traced through the ones nothing beats, sized by context window or parameters. +- **`BenchmarkMatrixChart`** — models against benchmarks coloured by how each score stands in its column, with each leader starred and an average column. +- **`TrainingTimelineChart`** — training jobs on a timeline, a lane per machine, coloured by how each finished, with the running count underneath. +- **`SweepChart`** — a hyperparameter sweep as a grid of scores with the best combination starred and the mean of each setting. +- **`ScalingLawChart`** — loss against parameters, tokens or compute on log-log axes, with the fitted power law, its band, the extrapolation and where it reaches a target. +- **`TrainingCurveChart`** — loss and accuracy curves of one or more training runs with smoothing, a log axis, the best checkpoint, divergence and the train/validation gap marked. + +## Customisation + +- **Theming** — `ChartStyle` for layout, `ChartColors` for colours, + `DrawingStyle` for drawing tools and `ChartTranslations` for every label. + Includes filled or hollow candles, crosshair style, pane separators and + axis-label pills. +- **Watermark** — any widget, such as an `Image.asset` logo, painted as a faint + single-colour overlay on the candle area. + +## Platform notes + +- The zoom slider is shown only on platforms without pinch gestures (web and + desktop). +- `watermark` paints the widget in `ChartColors.watermarkColor`, so full-colour + logos render as a silhouette. The package does not depend on `flutter_svg`; + to use an SVG, pass `SvgPicture.asset(...)` from that package. + +Back to the [documentation index](README.md). diff --git a/doc/futures-curve-chart.md b/doc/futures-curve-chart.md new file mode 100644 index 0000000..8b50d25 --- /dev/null +++ b/doc/futures-curve-chart.md @@ -0,0 +1,79 @@ +# Futures curve + +![A futures curve in contango, with last month dashed behind it](https://raw.githubusercontent.com/CtrlAltDevelop/ohlcv_chart/main/screenshots/futures-curve.png) + +`FuturesCurveChart` plots futures prices against delivery month — the market's +term structure. One curve shows whether the market is in **contango** (later +contracts cost more) or **backwardation** (they cost less); several curves, such +as today's and last month's drawn dashed, show how it has moved. + +```dart +FuturesCurveChart( + curves: [ + FuturesCurve( + label: 'Today', + contracts: [ + FuturesContract(label: 'Jan', price: 71.2), + FuturesContract(label: 'Feb', price: 71.9), + FuturesContract(label: 'Mar', price: 72.4), + ], + ), + FuturesCurve(label: 'A month ago', dashed: true, contracts: before), + ], +); +``` + +The chart fills its constraints. When the height is unbounded, it uses +`defaultHeight`. + +## Data + +| Type | Fields | +| --- | --- | +| `FuturesContract` | `label` (the delivery month), `price`, `data` | +| `FuturesCurve` | `label`, `contracts` nearest first, `color`, `dashed` | + +Contracts are spread evenly, one slot each, so the same contract sits at the +same place on every curve. A curve shorter than the longest leaves the far slots +empty, and a contract whose price is not finite leaves a gap in the line. The +labels along the bottom are those of the longest curve. + +## The shape + +The first curve's shape and slope are written in the corner — +`Contango +2.3%` — and the badge is coloured green for contango and red for +backwardation. `shapeTolerance` is the share of price a step must exceed to +count as a rise or a fall, so noise in the last decimal does not make a +contango look mixed. `showShapeBadge: false` hides it, and `contangoLabel`, +`backwardationLabel`, `flatLabel` and `mixedLabel` translate it. + +| Function | Description | +| --- | --- | +| `curveShape(contracts, tolerance:)` | `contango`, `backwardation`, `flat`, `mixed` or `unknown` | +| `curveSlope(contracts)` | The last price over the first, as a fraction | +| `calendarSpreads(contracts)` | Each price less the one before it | +| `layOutFutures(curves, bounds, …)` | The dots the chart paints, usable without a widget | + +## Appearance + +| Parameter | Description | +| --- | --- | +| `palette` | Colours taken in turn by curves with none of their own | +| `lineWidth`, `dotRadius`, `showDots` | The lines and their points | +| `showLegend`, `legendStyle` | The curves' names, when there is more than one | +| `showAxes`, `priceFormatter`, `priceAxisTitle`, `tickCount` | The price axis | +| `gridColor`, `hoverBorder`, `backgroundColor`, `padding` | Grid, hover ring, box | + +## Touch + +`onTouch` reports a `FuturesTouchDetails` with the `contract`, its `curve`, and +the calendar `spread` to the contract before it, and `null` when the pointer +leaves. `tooltipBuilder` builds a card beside the dot. + +## Animation + +`animationDuration` draws the curves in from the left; `animationCurve` eases it +and `animateOnMount` controls the first build. It animates again only when the +curves' values change. + +[← All docs](README.md) · [Package README](../README.md) diff --git a/doc/gallery.md b/doc/gallery.md new file mode 100644 index 0000000..ad7bb3a --- /dev/null +++ b/doc/gallery.md @@ -0,0 +1,230 @@ +# Gallery + +Each image links to the documentation page for that feature. + +## Series, pie, radar, heatmap and depth charts + + + + + + + + + + + + + + + + + + + + + + +
Four business charts
Four business charts
Returns split at zero, profit bars, cash flows with a tooltip, and a sparkline.
Pie and radar
Pie and radar
A doughnut with its total in the centre, and a radar comparing two strategies.
Heatmap
Heatmap
Six months of activity, and orders by hour and weekday.
Scatter
Scatter
One point per trade, with per-point shape and colour and a single-point readout.
Stacked horizontal bars
Stacked horizontal bars
Stacked bars in horizontal orientation, with titled axes.
Floating bars and bands
Floating bars and bands
A waterfall chart, and a forecast band with error bars.
Range selector
Range selector
Three series over five months, with a range selector below.
Synchronised crosshair
Synchronised crosshair
Balance and profit panels sharing one crosshair.
Depth chart
Depth chart
Cumulative bid and ask depth.
Depth chart modes
Depth chart modes
Cumulative, histogram, combined and ladder modes.
The bid/ask ratio bar
The bid/ask ratio bar
The balance of bid and ask volume, below the depth chart.
+ +## Order flow and derivatives + + + + + + + + + + + + + + + + + + + + + + +
Market profile
Market profile
Time at price as TPO letters, with the point of control and value area.
Footprint
Footprint
Bid and ask volume at every price, inside each candle.
Order-book heatmap
Order-book heatmap
Resting liquidity over time, with walls as bright lines.
Cumulative delta
Cumulative delta
Running delta over a session, with buying and selling per bar.
Liquidity map
Liquidity map
Where the leveraged stops sit, longs below the price and shorts above.
Open interest and funding
Open interest and funding
How much money is in the trade, and which side is paying.
Options payoff
Options payoff
A bull call spread and a long straddle at expiry, with break-evens.
Volatility curves
Volatility curves
A smile at three expiries, and a term structure.
Pair spread
Pair spread
The log ratio of two symbols, with bands and a z-score.
Volatility surface
Volatility surface
Implied volatility across strike and expiry, with the skew against the money.
Futures curve
Futures curve
Prices by delivery month, with contango or backwardation read off.
+ +## Backtests and results + + + + + + + + + + + + + + + + + + + + + + +
Equity curve
Equity curve
The curve, with the underwater drawdown panel beneath it.
Monte Carlo fan
Monte Carlo fan
Simulated paths as percentile bands, with the odds of loss and ruin.
R-multiple distribution
R-multiple distribution
Results in R, with expectancy, profit factor and SQN.
Trade timeline
Trade timeline
Every trade from entry to exit, a lane per symbol.
Seasonality
Seasonality
Returns by month and year, compounded, with totals and averages.
Calendar
Calendar
Daily profit and loss on real month panels.
Waterfall
Waterfall
A balance built up step by step, from opening to closing.
MAE / MFE scatter
MAE / MFE scatter
Every trade by how far it went against you or for you, and what it made.
Value at risk
Value at risk
Returns with the loss tail picked out, and VaR and expected shortfall marked.
Efficient frontier
Efficient frontier
Portfolios by risk and return, with the frontier and the best Sharpe ratio.
Activity clock
Activity clock
A week as a clock face: an hour round the dial, a weekday on each ring.
+ +## Dashboards and reports + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Treemap
Treemap
A market map of sectors and symbols, and portfolio weight.
Sunburst
Sunburst
A hierarchy as rings, with the total in the middle.
Sankey
Sankey
Fee income flowing through revenue into costs and profit.
Chord
Chord
Flow between venues, both ways round a ring.
Marimekko
Marimekko
Two dimensions at once: column width and cell height.
Stream graph
Stream graph
Two years of composition, on a wiggle baseline.
Parallel coordinates
Parallel coordinates
Five strategies compared on five measures at once.
Slope and bump
Slope and bump
Returns by quarter, by value and by changing rank.
Bubble
Bubble
Risk against return, sized by capital.
Box plot
Box plot
Quartiles, whiskers and outliers for four strategies.
Histogram
Histogram
Daily returns split at zero, and a spread of trade sizes.
Violin and ridgeline
Violin and ridgeline
The shape of a distribution, side by side or stacked.
Dumbbell
Dumbbell
Two values a row, joined by a bar.
Bullet
Bullet
KPIs against their targets, on banded tracks.
Gauge
Gauge
A dial with coloured ranges, a half circle and a progress ring.
Waffle
Waffle
Shares as a hundred squares, counted rather than judged.
Funnel
Funnel
Where the people go, tapered or straight-sided.
Sparkline grid
Sparkline grid
A watchlist of small multiples, a name and a shape each.
+ +## Progress and goals + + + + + + + +
Progress bars
Progress bars
Goals as pills, blocks or cells, with a mark for where each should be.
Progress card
Progress card
Course cards whose whole background is the progress: a sweep, a growing circle or a bar.
+ +## Models and training + + + + + + + + + + + + + + + + + + + + + + +
Training curves
Training curves
Loss and accuracy of runs, smoothed, with the best checkpoint and divergence marked.
Scaling laws
Scaling laws
Loss against compute on log-log axes, with the power law fitted and extrapolated.
Hyperparameter sweep
Hyperparameter sweep
A learning-rate by batch-size grid, the best combination starred.
Training timeline
Training timeline
Jobs by machine, coloured by how each finished.
Benchmark matrix
Benchmark matrix
Models against benchmarks, coloured by standing, leaders starred.
Model cost against quality
Model cost against quality
Models by price and score, with the Pareto frontier.
Latency bands
Latency bands
p50, p95 and p99 over a day, with an objective and its breaches.
Token usage
Token usage
Cached, input and output tokens, with the cost as a line.
Evaluation breakdown
Evaluation breakdown
Pass rate by category, worst first, with a threshold.
Token heatmap
Token heatmap
A completion with each token shaded by its surprisal.
+ +## Chart types + + + + + + + + + + + + +
Eight chart types
Eight chart types
OHLC bars, baseline, area, step line, HLC area and columns.
Four bar transforms
Four bar transforms
Line break, Kagi, point & figure and range bars.
Heikin-Ashi and Renko
Heikin-Ashi and Renko
Candles re-aggregated before rendering.
Symbol comparison
Symbol comparison
Another instrument on the same axis, rebased or at actual prices.
Dark and light
Dark and light
The same chart in both themes, with every colour configurable.
+ +## 31 indicators + + + + + + + + + + + + +
Main-chart overlays
Main-chart overlays
Ichimoku Cloud and Supertrend, with Stochastic RSI and Awesome Oscillator panes.
Profile and anchored VWAP
Profile and anchored VWAP
A volume profile and a VWAP anchored to a candle.
Swing analysis
Swing analysis
ZigZag, Fibonacci retracement and Elliott wave labels.
Stacked panes
Stacked panes
Three ATR periods, each in its own resizable pane.
Higher timeframes
Higher timeframes
A four-hour moving average and RSI on 15-minute candles.
Indicator catalogue
Indicator catalogue
The example app's indicator picker, generated from the catalogue.
+ +## Drawing tools and trading + + + + + + + + + + + + +
29 drawing tools
29 drawing tools
Labelled trend and horizontal lines, with snapping and persistence.
Rays, boxes and Fibonacci
Rays, boxes and Fibonacci
A ray, an arrow, a horizontal ray, a range box and a Fibonacci retracement.
The line editor
The line editor
Controls for the selected drawing, customisable through DrawingStyle.
Orders and positions
Orders and positions
A working order and an open position, each labelled on the axis.
Planning a trade
Planning a trade
A planned position with risk-to-reward, inside a channel.
Event marks
Event marks
Earnings, dividend, split and news markers below the candles.
+ +## Interaction and navigation + + + + + + + + + + + + +
Legend and crosshair
Legend and crosshair
The OHLC legend, updated from the crosshair position.
The long-press readout
The long-press readout
The built-in candle details card, or a custom builder.
The overview strip
The overview strip
The full series below the chart, with the visible range highlighted.
Linked charts
Linked charts
Two charts with a synchronised crosshair.
Bar replay
Bar replay
Rewind, then step or play forward candle by candle.
Replay controls
Replay controls
Paused at candle 150 of 420, with the playback bar.
+ +## Axes, sessions and layout + + + + + + + + + + + + + + + + + +
Four price axes
Four price axes
Linear, logarithmic, percentage or indexed to 100, and invertible.
Logarithmic axis
Logarithmic axis
Evenly spaced ratios for long-term price history.
The date axis
The date axis
Automatic round time intervals, or a custom formatter.
Sessions and time zones
Sessions and time zones
Pre-market and after-hours periods shaded behind the candles.
Session VWAP
Session VWAP
VWAP reset each session, with standard-deviation bands.
Responsive sizing
Responsive sizing
The candle area filling its container, or set to a fixed height.
Custom watermark
Custom watermark
Any widget, rendered faintly over the candle area.
+ +Back to the [documentation index](README.md). diff --git a/doc/latency-band-chart.md b/doc/latency-band-chart.md new file mode 100644 index 0000000..b937f0b --- /dev/null +++ b/doc/latency-band-chart.md @@ -0,0 +1,61 @@ +# Latency bands + +![Latency over a day as p50, p95 and p99 bands with an objective and the intervals that broke it](https://raw.githubusercontent.com/CtrlAltDevelop/ohlcv_chart/main/screenshots/latency-band.png) + +`LatencyBandChart` shows an inference service's latency over time as **bands**: +the median as a line, the span up to the 95th percentile and on to the 99th +shaded, an **objective** drawn across, and the intervals that broke it marked. +The tail is what users feel, and a median line alone hides it. + +```dart +LatencyBandChart( + samples: [ + for (final w in windows) + LatencySample(time: w.start, p50: w.p50, p95: w.p95, p99: w.p99), + ], + slo: 1200, + valueFormatter: (ms) => '${ms.round()} ms', +); +``` + +The chart fills its constraints. When the height is unbounded, it uses +`defaultHeight`. + +## Samples + +| Field | Description | +| --- | --- | +| `time` | When the interval began | +| `p50`, `p95`, `p99` | The latencies, in one unit of your choice | +| `data` | App data, returned on touch | + +A sample with a value that is not finite is left out of the lines and the bands. + +## The objective + +`slo` draws a dashed line, and `sloPercentile` says which percentile it is set +on — `p95` by default. Every interval whose percentile is over it gets a red +mark on the bottom of the plot, and is flagged `breached` on touch. + +| Function | Description | +| --- | --- | +| `sloBreaches(samples, slo, percentile:)` | The indices of the intervals over the objective | + +An interval exactly on the objective is within it. + +## Appearance + +| Parameter | Description | +| --- | --- | +| `logY` | A logarithmic axis, for a tail far above the median | +| `medianColor`, `p95Color`, `p99Color`, `sloColor`, `breachColor` | Colours | +| `lineWidth`, `showLegend`, `showBreaches` | Line weight, the legend and the marks | +| `valueFormatter`, `timeFormatter`, `yAxisTitle` | Axes | + +## Touch + +`onTouch` reports a `LatencyTouchDetails` with the `sample`, its `index` and +whether it `breached`, and `null` when the pointer leaves. `tooltipBuilder` +builds a card beside the crosshair. + +[← All docs](README.md) · [Package README](../README.md) diff --git a/doc/mae-mfe-chart.md b/doc/mae-mfe-chart.md new file mode 100644 index 0000000..e71a20d --- /dev/null +++ b/doc/mae-mfe-chart.md @@ -0,0 +1,81 @@ +# MAE / MFE scatter + +![Every trade by how far it went against you and for you](https://raw.githubusercontent.com/CtrlAltDevelop/ohlcv_chart/main/screenshots/mae-mfe.png) + +`MaeMfeChart` puts every trade on a plane by how far it went against you (maximum +adverse excursion, MAE) or for you (maximum favourable excursion, MFE), and what +it finally made. It is the picture system designers use to place a stop and a +target: if few winners ever went deep against the position, a tight stop costs +little. + +```dart +MaeMfeChart( + trades: [ + for (final t in backtest.trades) + TradeExcursion(pnl: t.r, mae: t.maxAdverseR, mfe: t.maxFavourableR), + ], + xReferenceLines: const [1], // the stop in use +); +``` + +The chart fills its constraints. When the height is unbounded, it uses +`defaultHeight`. + +## Trades + +| Field | Description | +| --- | --- | +| `pnl` | What the trade made or lost, in R, points or money | +| `mae` | How far it went against the position, as a distance of zero or more | +| `mfe` | How far it went in the position's favour, as a distance of zero or more | +| `label`, `color`, `data` | Name, a colour of its own, and app data returned on touch | + +`mae`, `mfe` and `pnl` are all in the same unit. + +## Which excursion + +`kind: ExcursionKind.adverse` (the default) puts the MAE along the bottom axis. +`ExcursionKind.favourable` puts the MFE there. Winners and losers take +`winnerColor` and `loserColor`. + +A line marks the limit no trade can pass: a result never beats its favourable +excursion, nor loses more than its adverse one, so every dot sits on one side +of it. `showLimit: false` hides it, and `showZeroLine` marks a result of zero. +`xReferenceLines` marks excursions such as the stop in use or a target. + +## Numbers + +| Function | Description | +| --- | --- | +| `ExcursionStats.fromTrades(trades)` | Count, winners, losers, average MAE and MFE, and `edgeRatio` | +| `winnersStoppedAt(trades, stop)` | How many winners a stop `stop` away would have taken out first | +| `excursionRange(trades, kind)` | The ranges the chart would pick | +| `layOutExcursions(...)` / `excursionDotAt(...)` | The dots it paints, and a hit test; both usable without a widget | + +`edgeRatio` is the average MFE over the average MAE: above 1 the market moved +more for the positions than against them. + +## Appearance + +| Parameter | Description | +| --- | --- | +| `dotRadius`, `dotOpacity` | Size of a dot, and how opaque; a crowd shows where it is densest | +| `limitColor`, `referenceColor` | Colours of the limit line and the reference lines | +| `showAxes`, `axisWidth`, `axisHeight`, `tickCount` | The axes and their room | +| `xFormatter`, `yFormatter`, `axisLabelStyle` | Axis labels | +| `xAxisTitle`, `yAxisTitle`, `axisTitleStyle` | Axis titles; the bottom one names the excursion by default | +| `gridColor` | Grid ruled at each tick; `null` rules none | +| `hoverBorder`, `backgroundColor`, `padding` | Hover ring, background, space around the chart | + +## Touch + +`onTouch` reports an `ExcursionTouchDetails` with the `dot` and its `trade`, and +`null` when the pointer leaves. `tooltipBuilder` builds a card beside the dot. + +## Animation + +`animationDuration` grows the dots in; `animationCurve` eases it and +`animateOnMount` controls the first build. The chart animates again only when +the trades' values change, not when the same numbers arrive in a new list. + +[← All docs](README.md) · [Package README](../README.md) diff --git a/doc/model-pareto-chart.md b/doc/model-pareto-chart.md new file mode 100644 index 0000000..35f3813 --- /dev/null +++ b/doc/model-pareto-chart.md @@ -0,0 +1,67 @@ +# Model cost against quality + +![Models by price per million tokens against a benchmark score with the Pareto frontier](https://raw.githubusercontent.com/CtrlAltDevelop/ohlcv_chart/main/screenshots/model-pareto.png) + +`ModelParetoChart` plots models by what they cost and how good they are, and +traces the **Pareto frontier** through the ones nothing beats: the cheapest way +to get each level of quality. Models off the frontier are drawn faintly, and +touching one says what the same money buys instead. + +```dart +ModelParetoChart( + models: const [ + ModelPoint(name: 'Large', cost: 15, score: 88, size: 200000, group: 'A'), + ModelPoint(name: 'Small', cost: 0.25, score: 71, size: 128000, group: 'A'), + ModelPoint(name: 'Open 70B', cost: 0.9, score: 79, group: 'B'), + ], + costFormatter: (v) => '\$${v}', + xAxisTitle: 'Price per 1M tokens', + yAxisTitle: 'MMLU', +); +``` + +The chart fills its constraints. When the height is unbounded, it uses +`defaultHeight`. + +## Models + +| Field | Description | +| --- | --- | +| `name` | What the model is called | +| `cost` | What it costs — a price per million tokens, a latency, a parameter count. Lower is better | +| `score` | How good it is. Higher is better | +| `size` | What the bubble's **area** shows, such as the context window; null draws every bubble alike | +| `group` | A family or provider; models of a group share a palette colour | +| `color`, `data` | A colour of its own, and app data returned on touch | + +## The frontier + +`paretoIndices(models)` returns the indices of the models on it, by rising cost. +A model is on it when none is both cheaper and better. Of two models at one cost +only the higher score is on it. A model whose cost or score is not finite is +never on it. + +## Axes + +`logCost` (on by default) puts cost on a logarithmic axis, since prices span +decades; a model whose cost is zero or less is then not drawn. Turn it off for a +linear axis. + +## Appearance + +| Parameter | Description | +| --- | --- | +| `minRadius`, `maxRadius` | The radii the sizes are spread between | +| `palette` | Colours taken in turn by groups | +| `showFrontier`, `frontierColor`, `dimOpacity` | The frontier line, and how faint off-frontier models are | +| `labelAll`, `labelStyle` | Name every model, not only those on the frontier | +| `showLegend`, `legendStyle` | The groups, in the corner | +| `costFormatter`, `scoreFormatter`, `xAxisTitle`, `yAxisTitle` | Axes | + +## Touch + +`onTouch` reports a `ModelTouchDetails` with the `model`, whether it is +`onFrontier`, and — for one that is not — `beatenBy`: the best frontier model +that costs no more. `null` when the pointer leaves. + +[← All docs](README.md) · [Package README](../README.md) diff --git a/doc/progress-bars.md b/doc/progress-bars.md new file mode 100644 index 0000000..c556320 --- /dev/null +++ b/doc/progress-bars.md @@ -0,0 +1,103 @@ +# Progress bars and cards + +![Goals as pills, cells and blocks](https://raw.githubusercontent.com/CtrlAltDevelop/ohlcv_chart/main/screenshots/progress.png) + +`ProgressBars` shows how far each of several things has got. Every row is a +label and a figure above a bar; the bar is a pill, a block or a row of cells, +and can carry a mark for where it should be by now. + +```dart +ProgressBars( + items: const [ + ProgressItem(label: 'Net profit', value: 72, target: 60), + ProgressItem(label: 'Trades taken', value: 32, total: 50), + ], + shape: ProgressShape.pill, +); +``` + +The chart fills the width it is given. In a box with no height it takes +`intrinsicHeight`; in a box too short for its rows they shrink together to fit. + +## An item + +| Field | What it is | +| --- | --- | +| `label` | Written above the bar at the left | +| `value`, `total` | How far it has got, out of what; `total` defaults to 100 | +| `target` | A mark across the bar, in the same units as `total` | +| `color` | Overrides the chart's fill colour for this row | +| `valueLabel` | Written above the bar at the right; null writes the per cent done | + +A value past `total` fills the bar and no more; a value that is not a number, +or a `total` of zero or less, fills nothing. + +## Shapes + +| `ProgressShape` | Look | +| --- | --- | +| `pill` | One bar with fully round ends | +| `block` | One bar with a small corner radius | +| `segmented` | `segments` separate cells, `segmentGap` apart, filled from the left and the last one part way | + +A short fill keeps the track's rounded left end rather than floating inside it. + +## Layout + +`layOutProgress(items, size:, ...)` is the layout on its own. It returns a +`ProgressLayout` of `ProgressRowLayout`s, each with `rowRect`, `headerRect`, +`trackRect`, `fillRect` and `targetX`; `layout.rowAt(point)` finds the row under +a point. `progress` shortens every fill for a draw-in animation and leaves the +targets where they are. + +## Touch + +`onItemTap` is called with an item when it is touched or dragged over, and with +null when the touch leaves. The touched row is highlighted. + +## Styling + +| Field | Default | +| --- | --- | +| `color` | A blue, where a row names no colour | +| `trackColor`, `targetColor` | A faint grey, and a near-white mark | +| `barHeight`, `headerHeight`, `headerGap`, `rowGap` | 10, 18, 6 and 16 | +| `showValues`, `valueFormatter` | The figure written at the right of the header | +| `labelStyle`, `valueStyle` | 12 px, grey and white | +| `animationDuration`, `animationCurve`, `animateOnMount` | The draw-in | + +`semanticLabel` is what a screen reader announces for the chart. + +## Progress card + +`ProgressCard` wraps any widget — a course, a goal, a task — in a card whose +whole background is a progress fill. The card is faintly tinted in `color` +(`trackOpacity`, 0.08) and a part of it (`fillOpacity`, 0.55) fills in as far as +`progress`, from 0 to 1, has got. The child is drawn as it is, over the fill, +and the fill animates to a new `progress` over `animationDuration`. + +| `ProgressCardMode` | Fill | +| --- | --- | +| `sweep` (default) | A wedge sweeps round the card's centre like a clock, from twelve o'clock and clockwise: half done is the right half. The angle is the progress, so it reads at a glance on a tile | +| `circle` | A circle grows from the middle of the card's left edge and has reached the far corners at full progress; its curved edge suits a wide card better than a sweep | +| `linear` | A bar fills the card from its left edge, as wide as the progress; best on a wide, short card | + +```dart +ProgressCard( + progress: 12 / 20, + mode: ProgressCardMode.sweep, + color: Colors.teal, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Technical analysis'), + Text('12 of 20 lessons'), + ], + ), +); +``` + +## See also + +- [Bullet](bullet-chart.md) — a measure against a target on a banded track +- [Gauge](gauge-chart.md) — one value on a dial, or a progress ring diff --git a/doc/scaling-law-chart.md b/doc/scaling-law-chart.md new file mode 100644 index 0000000..cf947cb --- /dev/null +++ b/doc/scaling-law-chart.md @@ -0,0 +1,68 @@ +# Scaling laws + +![Loss against training compute on log-log axes with the fitted power law carried to a target](https://raw.githubusercontent.com/CtrlAltDevelop/ohlcv_chart/main/screenshots/scaling-law.png) + +`ScalingLawChart` plots the loss models reached against how much they were given +— parameters, training tokens or FLOPs — on log-log axes, fits the **power law** +through them and carries it past the largest model, so the question "what would +ten times the compute buy?" has a picture. + +```dart +ScalingLawChart( + points: [ + for (final m in runs) ScalingPoint(scale: m.flops, loss: m.loss, label: m.name), + ], + fitFrom: 1e19, // leave the small models that sit off the law out + extrapolateTo: 1e24, + targetLoss: 1.8, // where is the line predicted to reach this? +); +``` + +The chart fills its constraints. When the height is unbounded, it uses +`defaultHeight`. + +## The fit + +`fitPowerLaw(points)` is a least-squares line through the logarithms of the +points, `loss = coefficient × scale ^ exponent`: + +| Field | Description | +| --- | --- | +| `exponent` | The slope on the log-log plot; negative when more scale lowers the loss | +| `coefficient` | The loss at a scale of 1 | +| `rSquared` | How much of the variation in log loss the line explains | +| `logResidual` | The standard deviation of the points' distance from the line, in log10 of the loss | +| `predict(scale)`, `scaleFor(loss)` | The loss at a scale, and the scale for a loss | + +It needs two points at two different scales. A point whose scale or loss is not +a positive finite number is skipped. The fit is a pure power law with no +irreducible term, which is a good description over the range where models are +still improving; `fitFrom` leaves out the small models that sit off it. + +## What is drawn + +| Mark | Meaning | +| --- | --- | +| Dots | The models, named beside them with `showLabels` | +| Solid line | The fit across the range of the points | +| Dashed line | The fit carried on to `extrapolateTo` | +| Band | One residual standard deviation either side of the fit (`showBand`) | +| Green ring | Where the fit reaches `targetLoss`, with the scale written beside it | +| Corner text | The law and its R² (`showFitLabel`) | + +## Touch + +`onTouch` reports a `ScalingTouchDetails` with the `point`, what the fit +`predicted` there and the `ratio` — 1.1 is a model 10% worse than the law — or +`null` when the pointer leaves. `tooltipBuilder` builds a card beside the point. + +## Appearance + +| Parameter | Description | +| --- | --- | +| `pointColor`, `fitColor`, `bandColor`, `targetColor` | Colours | +| `dotRadius`, `labelStyle` | Point size and label style | +| `scaleFormatter`, `lossFormatter` | Axis labels; the scale is written as `1e21` by default | +| `xAxisTitle`, `yAxisTitle`, `axisLabelStyle`, `gridColor` | Axes and grid | + +[← All docs](README.md) · [Package README](../README.md) diff --git a/doc/sweep-chart.md b/doc/sweep-chart.md new file mode 100644 index 0000000..50eb418 --- /dev/null +++ b/doc/sweep-chart.md @@ -0,0 +1,63 @@ +# Hyperparameter sweep + +![A hyperparameter sweep as a grid of validation losses with the best combination starred](https://raw.githubusercontent.com/CtrlAltDevelop/ohlcv_chart/main/screenshots/sweep.png) + +`SweepChart` lays a two-setting sweep out as a grid of scores — a learning rate +across, a batch size down — coloured good to bad, with the **best combination +starred** and a last column and row holding the **mean** of each setting, which +is what it is worth whatever the other is set to. + +```dart +SweepChart( + trials: [ + for (final r in runs) + SweepTrial(x: r.learningRate, y: r.batchSize, score: r.valLoss), + ], + xAxisLabel: 'learning rate', + yAxisLabel: 'batch size', +); +``` + +The chart fills its constraints. When the height is unbounded, it uses +`defaultHeight`. It is built on `HeatmapChart`. + +## Trials + +| Field | Description | +| --- | --- | +| `x`, `y` | The two settings; the grid has a column per distinct `x` and a row per distinct `y`, ascending | +| `score` | The result: a validation loss, an accuracy | +| `data` | App data, returned on touch | + +Trials that share both settings are averaged. A trial whose settings or score +are not finite is skipped, and a combination nobody tried is an empty square. + +## The grid + +`sweepGrid(trials, lowerIsBetter:)` returns what the chart draws: + +| Member | Description | +| --- | --- | +| `xs`, `ys` | The distinct settings | +| `scores[y][x]`, `counts[y][x]` | The mean score and the trial count of each combination | +| `bestX`, `bestY`, `best` | The best combination — lowest, or highest with `lowerIsBetter: false` | +| `columnMeans`, `rowMeans` | The mean score of each setting | + +## Appearance + +| Parameter | Description | +| --- | --- | +| `lowerIsBetter` | Which end of the scores is good; sets the colour direction too | +| `goodColor`, `midColor`, `badColor` | The scale | +| `showMarginals`, `marginalLabel` | The mean column and row | +| `showValues`, `scoreFormatter` | Scores written in the squares | +| `xFormatter`, `yFormatter` | Setting labels; `3e-4`, `128` by default | +| `yAxisWidth`, `xAxisHeight`, `spacing`, `radius` | Layout | + +## Touch + +`onTouch` reports a `SweepTouchDetails` with the settings `x` and `y`, the +`score`, the trial `count`, and whether the square `isBest` or a marginal, and +`null` when the pointer leaves. + +[← All docs](README.md) · [Package README](../README.md) diff --git a/doc/token-heatmap.md b/doc/token-heatmap.md new file mode 100644 index 0000000..3330395 --- /dev/null +++ b/doc/token-heatmap.md @@ -0,0 +1,130 @@ +# Token heatmap + +![A model completion with each token shaded by how surprised the model was by it](https://raw.githubusercontent.com/CtrlAltDevelop/ohlcv_chart/main/screenshots/token-heatmap.png) + +`TokenHeatmap` shows a model's text with **each token shaded by a number**: +where the model was unsure, what a prediction leaned on, which words an +attribution method found important. Tokens flow and wrap like text, so a +completion reads as a paragraph with its doubts lit up. + +```dart +TokenHeatmap( + tokens: [ + for (final t in completion.tokens) + TokenScore.fromLogProb( + t.text, + t.logProb, + alternatives: [ + for (final a in t.topLogProbs) + TokenAlternative(a.text, exp(a.logProb)), + ], + ), + ], + highlightTop: 3, + showStrip: true, + showSummary: true, +); +``` + +The chart takes the height its text needs. In a box that is shorter, the text is +cut off. It does not scroll — put it in a scroll view for a long document. + +## Tokens + +| Member | Description | +| --- | --- | +| `TokenScore(token, value)` | A token's text, as the tokenizer produced it, and its score | +| `probability` | The probability the model gave the token, 0 to 1, for the readout | +| `alternatives` | `TokenAlternative(token, probability)` — what else the model was weighing, most likely first | +| `TokenScore.fromLogProb(token, logProb)` | Scores a token by its **surprisal**, `-logProb`: the less likely the model thought it, the higher the score. Sets `probability` too | + +A newline inside a token ends the line. Leading spaces are kept, so tokens such +as `" capital"` read as they were produced. A score that is not finite leaves +the token uncoloured. + +## How it is drawn + +Each token is a rounded box with room round its text. A token that begins a word +— where the tokenizer put a space — sits a little apart from the one before it, +and one that carries a word on sits close, so the boxes of a word read as a word. + +| Parameter | Description | +| --- | --- | +| `shading` | `TokenShading.soft` (the default) tints the box and draws a line of the full colour under the text; `TokenShading.solid` fills it with the full colour, the text turning dark or light to suit | +| `horizontalPadding`, `verticalPadding` | Room round the text inside a box | +| `wordGap`, `tokenGap` | The gap before a token that begins a word, and before one that carries on | +| `lineGap`, `tokenRadius` | Space between lines, and the rounding | + +## Reading a long text + +| Parameter | What it does | +| --- | --- | +| `highlightTop: n` | Rings the *n* highest-scoring tokens and numbers them 1 to *n* — the places to look first | +| `focusAbove: v` | Leaves every token scoring below *v* unshaded and dim, so only the doubtful ones are lit | +| `showStrip` | A strip of every token's score, in order, above the legend. Over a text longer than the strip is wide, each bar is the highest of the tokens it covers, so a single bad token is never averaged away | +| `showWhitespace` | Writes spaces as `·` and line breaks as `↵`, to see what the tokenizer really produced | +| `selectedIndex` | Holds one token outlined, for a selection your app keeps | + +The legend marks where the **mean** falls on the scale, and `showSummary` writes +the mean and the perplexity under it. + +## The readout + +Touching a token opens a card with its text, its score, its **rank** among the +tokens (`rank 2 of 28`), the probability the model gave it, and — if you +supplied them — the alternatives it was weighing instead. It needs no code: + +```dart +TokenHeatmap(tokens: tokens, scoreLabel: 'surprisal') +``` + +`scoreLabel` names the score in it, `showReadout: false` turns it off, and +`tooltipBuilder` replaces it with a card of your own. + +## Colour + +The default `scale` runs green (confident) through amber to red (surprising), +which suits surprisals and entropies. For probabilities, where high is +confident, turn it over: + +```dart +TokenHeatmap( + tokens: probabilities, + scale: HeatmapGradientScale(colors: [red, amber, green]), + minValue: 0, + maxValue: 1, +); +``` + +`minValue` and `maxValue` fix the ends of the scale — useful to compare two +completions on the same colours — and otherwise take the lowest and highest +score. Text is dark on a light colour and light on a dark one. + +## Numbers + +| Function | Description | +| --- | --- | +| `summarizeTokens(tokens)` | `count`, `mean`, `max`, `maxIndex` and `perplexity` (`exp(mean)`, for surprisals in nats) | +| `tokenRanks(tokens)` | Each token's rank, 1 for the highest; equal scores share one | +| `topTokens(tokens, n)` | The indices of the *n* highest, highest first | +| `binTokenScores(tokens, bins)` | The strip's bars: the highest score of each slice | +| `flowTokens(tokens, width, measure)` | The `TokenBox`es and the height, without a renderer | +| `tokenDisplay(token, showWhitespace:)` | How a token is written on screen | + +## Appearance + +| Parameter | Description | +| --- | --- | +| `textStyle` | The text; 14 px by default — use a monospaced font for code | +| `tokenRadius`, `verticalPadding`, `lineGap` | The rectangles and the lines | +| `showLegend`, `lowLabel`, `highLabel`, `legendStyle` | The scale bar | +| `stripHeight`, `highlightColor`, `dimColor` | The strip, the rings, the dimmed text | +| `valueFormatter`, `hoverBorder` | Score formatting and the hover outline | + +## Touch + +`onTouch` reports a `TokenTouchDetails` with the token's `index`, the `token`, +its `rect`, its `rank` and how many tokens were `scored`, and `null` when the +pointer leaves. + +[← All docs](README.md) · [Package README](../README.md) diff --git a/doc/token-usage-chart.md b/doc/token-usage-chart.md new file mode 100644 index 0000000..32ee5f0 --- /dev/null +++ b/doc/token-usage-chart.md @@ -0,0 +1,65 @@ +# Token usage + +![Tokens served over two weeks as stacked cached, input and output columns with the cost as a line](https://raw.githubusercontent.com/CtrlAltDevelop/ohlcv_chart/main/screenshots/token-usage.png) + +`TokenUsageChart` shows what a model served over time: tokens as **stacked +columns** — cached, input and output — and the **cost** as a line against its +own axis on the right. The cached share, the part of the prompt that was a cache +read, is its own colour because it is cheaper and faster than the rest. + +```dart +TokenUsageChart( + buckets: [ + for (final d in days) + UsageBucket( + time: d.date, + inputTokens: d.prompt, + outputTokens: d.completion, + cachedTokens: d.cacheRead, + cost: d.dollars, + ), + ], + costFormatter: (v) => '\$${v.toStringAsFixed(0)}', +); +``` + +The chart fills its constraints. When the height is unbounded, it uses +`defaultHeight`. + +## Buckets + +| Field | Description | +| --- | --- | +| `time` | When the interval began; columns are evenly spaced in the order given | +| `inputTokens` | Prompt tokens processed | +| `outputTokens` | Tokens generated | +| `cachedTokens` | Prompt tokens served from a cache — counted **apart** from `inputTokens`, not inside it | +| `cost` | What the interval cost; null draws no point | + +A value that is not finite, or negative, counts as zero. `total` is the sum of +the three kinds and `cacheHitRate` is cached over the whole prompt. + +## Numbers + +| Function | Description | +| --- | --- | +| `usageTotals(buckets)` | `input`, `output`, `cached`, `tokens`, `cost` and `cacheHitRate` over all of them | +| `compactCount(value)` | `950`, `12.4k`, `3.1M`, `2B` — the default axis format | + +## Appearance + +| Parameter | Description | +| --- | --- | +| `showCost`, `costColor`, `costFormatter` | The cost line and its right-hand axis; omitted when no bucket has a cost | +| `inputColor`, `outputColor`, `cachedColor` | The stack | +| `barSpacing`, `barRadius` | Column gap and the rounding of the top | +| `tokenFormatter`, `timeFormatter`, `axisLabelStyle`, `gridColor` | Axes | +| `showLegend`, `legendStyle` | The series, in the corner | + +## Touch + +`onTouch` reports a `UsageTouchDetails` with the bucket's `index` and `bucket`, +and `null` when the pointer leaves. `tooltipBuilder` builds a card beside the +column. + +[← All docs](README.md) · [Package README](../README.md) diff --git a/doc/training-curve-chart.md b/doc/training-curve-chart.md new file mode 100644 index 0000000..65f09b6 --- /dev/null +++ b/doc/training-curve-chart.md @@ -0,0 +1,79 @@ +# Training curves + +![Training curves: train and validation loss with the overfitting gap, and three learning rates on a log axis](https://raw.githubusercontent.com/CtrlAltDevelop/ohlcv_chart/main/screenshots/training-curve.png) + +`TrainingCurveChart` draws the loss and accuracy curves of one or more training +runs, the way a training dashboard does: raw readings faint under a **smoothed** +line, an optional **log axis** for a loss that spans decades, the **best +checkpoint** of each run marked, and the step a run's loss **diverged** at. + +```dart +TrainingCurveChart( + runs: [ + TrainingRun(label: 'train', points: train), + TrainingRun(label: 'val', points: val, dashed: true), + ], + smoothing: 0.9, + logY: true, + gapBetween: (0, 1), // shade the train/validation gap +); +``` + +The chart fills its constraints. When the height is unbounded, it uses +`defaultHeight`. + +## Runs + +| Type | Fields | +| --- | --- | +| `TrainingPoint` | `step` (optimiser step, epoch or tokens seen), `value` | +| `TrainingRun` | `label`, `points`, `color`, `dashed`, `data` | + +A point whose value is not finite leaves a gap in the line; a run of one point, +or of none, draws nothing and does not throw. + +## Smoothing + +`smoothing` runs from 0 (the raw line) to just under 1. It is the exponential +moving average dashboards use, corrected for its slow start, so the first +readings are not pulled toward zero. With `showRaw` the unsmoothed readings stay +as a faint line at `rawOpacity`. + +## What it marks + +| Mark | Meaning | Switch | +| --- | --- | --- | +| Ringed dot | A run's best checkpoint: its lowest loss, or its highest value when `lowerIsBetter` is false | `showBest` | +| Red line, "diverged" | The step the loss climbed past `divergenceFactor` times its lowest, or went NaN | `showDivergence` | +| Shaded band | The gap between two runs, usually train and validation — overfitting shows as the band opening | `gapBetween: (train, val)` | + +## Numbers + +| Function | Description | +| --- | --- | +| `smoothValues(values, weight)` | The smoothed series; null where a value was not finite | +| `bestIndex(values, lowerIsBetter:)` | The best reading's index | +| `divergenceIndex(values, factor:)` | Where a loss blew up, or `null` | + +## Appearance + +| Parameter | Description | +| --- | --- | +| `logY` | A logarithmic value axis; readings of zero or below are not drawn | +| `lineWidth`, `palette` | Line weight and the colours runs take in turn | +| `showLegend`, `legendStyle` | Run names in the corner when there is more than one | +| `showAxes`, `stepFormatter`, `valueFormatter`, `xAxisTitle`, `yAxisTitle` | Axes | +| `gridColor`, `crosshairColor`, `bestColor`, `divergenceColor`, `gapColor` | Colours | + +## Touch + +`onTouch` reports a `TrainingTouchDetails` with the `step` and a `TrainingReading` +per run — its nearest `point` and `smoothed` value — and `null` when the pointer +leaves. `tooltipBuilder` builds a card beside the crosshair. + +## Animation + +`animationDuration` draws the lines in from the left. It runs again only when +the runs' values, `smoothing` or `logY` change. + +[← All docs](README.md) · [Package README](../README.md) diff --git a/doc/training-timeline-chart.md b/doc/training-timeline-chart.md new file mode 100644 index 0000000..842a352 --- /dev/null +++ b/doc/training-timeline-chart.md @@ -0,0 +1,67 @@ +# Training timeline + +![Training jobs by pod, coloured by completed, failed, preempted, cancelled and running](https://raw.githubusercontent.com/CtrlAltDevelop/ohlcv_chart/main/screenshots/training-timeline.png) + +`TrainingTimelineChart` shows training jobs on a timeline: a lane per machine or +pool, a bar from start to end **coloured by how the job finished**, and a strip +underneath counting how many jobs were running at each moment. It answers "what +was the cluster doing, and where did it fall over?" + +```dart +TrainingTimelineChart( + jobs: [ + TrainingJob( + name: 'llama-8b-sft', + node: 'pod-a', + start: DateTime(2026, 9, 1, 8), + end: DateTime(2026, 9, 1, 19), + status: JobStatus.completed, + gpus: 64, + ), + TrainingJob(name: 'rl-sweep', node: 'pod-b', start: DateTime(2026, 9, 1, 10)), + ], +); +``` + +The chart fills its constraints. When the height is unbounded, it uses +`defaultHeight`. It is a `TradeTimelineChart` underneath, so its lanes, time +axis and pointer behave as that chart's do. + +## Jobs + +| Field | Description | +| --- | --- | +| `name` | What the job is called | +| `start`, `end` | When it ran; a null `end`, or `JobStatus.running`, runs to `now` | +| `node` | The lane: a machine or a pool; null is an unnamed lane | +| `status` | `running`, `completed`, `failed`, `preempted` or `cancelled` | +| `gpus`, `data` | Accelerators held, and app data returned on touch | + +## Colour + +| Status | Colour parameter | +| --- | --- | +| `running` | `runningColor` | +| `completed` | `completedColor` | +| `failed` | `failedColor` | +| `preempted` | `preemptedColor` | +| `cancelled` | `cancelledColor` | + +`colorOf(status)` returns the colour in use, for a legend of your own. + +## Touch + +`onTouch` reports a `TrainingJobTouchDetails` with the `job`, the `time` under +the pointer and `duration(now)`, and `null` when the pointer leaves. +`tooltipBuilder` builds a card beside the bar. + +## Appearance + +| Parameter | Description | +| --- | --- | +| `showRunningCount`, `countColor` | The strip counting concurrent jobs | +| `laneLabelWidth`, `laneLabelStyle` | The lane names | +| `axisLabelStyle`, `timeFormatter`, `gridColor` | The time axis | +| `now` | The time running jobs are drawn to | + +[← All docs](README.md) · [Package README](../README.md) diff --git a/doc/var-chart.md b/doc/var-chart.md new file mode 100644 index 0000000..73bba84 --- /dev/null +++ b/doc/var-chart.md @@ -0,0 +1,49 @@ +# Value at risk + +![Daily returns with the loss tail, VaR and expected shortfall](https://raw.githubusercontent.com/CtrlAltDevelop/ohlcv_chart/main/screenshots/var.png) + +`VarChart` shows the shape of a set of returns with the loss tail picked out and +two figures marked: **value at risk** (VaR), the return that only a small share +of outcomes were worse than, and **expected shortfall** (CVaR), the average of +the outcomes beyond it. Both are worked out from the history itself. + +```dart +VarChart(returns: dailyReturns, confidence: 0.99); +``` + +Returns are fractions: `-0.03` is a 3% loss. The chart fills its constraints. +When the height is unbounded, it uses `defaultHeight`; a box too short for the +row of figures shows only the histogram. + +## The numbers + +`ValueAtRisk.fromReturns(returns, confidence: 0.95)` returns: + +| Field | Description | +| --- | --- | +| `valueAtRisk` | The return only `1 - confidence` of outcomes fell below; zero or less for a history with losses | +| `expectedShortfall` | The average of the outcomes at or beyond `valueAtRisk`; never above it | +| `mean`, `standardDeviation` | Average, and the sample standard deviation | +| `worst`, `count` | The worst return, and how many there were | + +It uses the historical method — no assumption about the shape of returns — with +linear interpolation between the two outcomes the threshold falls between. +Returns that are not finite are skipped. + +`varBins(returns, valueAtRisk:, tailColor:)` counts the returns into +`HistogramBin`s and colours those in the tail, if you want to hand them to a +`HistogramChart` of your own. + +## Parameters + +| Parameter | Description | +| --- | --- | +| `confidence` | The share of outcomes VaR is not worse than; 0.95 by default | +| `binCount`, `binWidth` | How the histogram is cut; the default is the square root of the count | +| `barColor`, `tailColor` | Bars outside and inside the loss tail | +| `showVarLine`, `showShortfallLine`, `showMeanLine` | Which lines mark the figures | +| `showStats`, `statsBuilder` | The row of figures, and a builder for your own | +| `valueFormatter` | Writes a return; the default writes a percentage | +| `onTouch`, `tooltipBuilder` | The histogram's touch and card | + +[← All docs](README.md) · [Package README](../README.md) diff --git a/doc/volatility-surface-chart.md b/doc/volatility-surface-chart.md new file mode 100644 index 0000000..4f80ad5 --- /dev/null +++ b/doc/volatility-surface-chart.md @@ -0,0 +1,68 @@ +# Volatility surface + +![Implied volatility across strike and expiry](https://raw.githubusercontent.com/CtrlAltDevelop/ohlcv_chart/main/screenshots/volatility-surface.png) + +`VolatilitySurfaceChart` draws implied volatility across strike and expiry at +once, as a heatmap. `VolatilityCurveChart` shows one smile or one term structure +at a time; the surface is the whole picture, where a skew that steepens into +short expiries or a kink at one strike stands out as colour. + +```dart +VolatilitySurfaceChart( + strikes: const [90, 95, 100, 105, 110], + expiries: const ['1W', '1M', '3M', '6M'], + volatilities: [ + [0.34, 0.30, 0.28, 0.29, 0.32], // one row per expiry + [0.31, 0.28, 0.26, 0.27, 0.29], + [0.29, 0.27, 0.25, 0.26, 0.28], + [0.28, 0.26, 0.25, 0.26, 0.27], + ], + spot: 100, +); +``` + +The chart fills its constraints. When the height is unbounded, it uses +`defaultHeight`. It is built on `HeatmapChart`, so a cell is `null` where there +is no quote and is left empty. + +## Data + +| Parameter | Description | +| --- | --- | +| `strikes` | One per column, left to right | +| `expiries` | One name per row, top to bottom | +| `volatilities` | A row per expiry holding a value per strike, as fractions: 0.25 is 25% | +| `spot` | The underlying's price; used to find the at-the-money strike for the skew | + +## Colour + +`lowColor`, `midColor` and `highColor` run the scale, cool to warm by default. +`minVolatility` and `maxVolatility` fix its ends — useful to keep two surfaces +comparable — and otherwise take the lowest and highest reading drawn. + +## Numbers + +| Function | Description | +| --- | --- | +| `atTheMoneyIndex(strikes, spot)` | The column of the strike nearest the spot | +| `surfaceSkew(row, atmIndex)` | Each strike's volatility less the at-the-money one | + +## Touch + +`onTouch` reports a `VolatilitySurfaceTouchDetails` with the `expiry`, `strike`, +`volatility`, the `atTheMoneyVolatility` of the same expiry and the `skew` +between them, and `null` when the pointer leaves. `tooltipBuilder` builds a card +beside the square. + +## Appearance + +| Parameter | Description | +| --- | --- | +| `showValues`, `valueFormatter`, `labelStyle` | Volatilities written in the squares | +| `strikeFormatter`, `axisLabelStyle` | Strike labels and their style | +| `expiryAxisWidth`, `strikeAxisHeight` | Room for the labels | +| `spacing`, `radius` | Gap between squares and their corner rounding | +| `animationDuration`, `animationCurve`, `animateOnMount` | Fade-in | +| `backgroundColor`, `padding`, `defaultHeight` | Box | + +[← All docs](README.md) · [Package README](../README.md) diff --git a/example/README.md b/example/README.md index 5ff897d..81dfdc7 100644 --- a/example/README.md +++ b/example/README.md @@ -96,7 +96,7 @@ All nine follow the palette switch in the app bar. ## The Gallery tab Every chart in the package that is not the candlestick, depth or series screen -— 34 of them — with a page each: the order-flow and derivatives charts, the +— 50 of them — with a page each: the order-flow and derivatives charts, the ones a backtest report is built from, and the ones a dashboard is. The tab opens on a searchable list grouped by what the charts are for. Tapping diff --git a/example/lib/src/gallery/gallery_data.dart b/example/lib/src/gallery/gallery_data.dart index ccfc83c..4625407 100644 --- a/example/lib/src/gallery/gallery_data.dart +++ b/example/lib/src/gallery/gallery_data.dart @@ -371,3 +371,469 @@ List galleryBand( for (var i = 0; i < periods; i++) (base + sin(i / 3.4 + phase) * base * 0.45 + i * drift).clamp(2.0, 200.0), ]; + +/// Trades seen from the inside, in R: the losers go about a stop's distance +/// against the position before they are cut, the winners dip a little first and +/// then run. +List galleryExcursions({int count = 140}) => [ + for (var i = 0; i < count; i++) + () { + final wave = sin(i * 1.7).abs(); + final other = cos(i * 0.9).abs(); + if (i % 5 < 3) { + // A loser: out near -1R, having gone a little further against, and + // sometimes a little way for the position first. + final loss = 0.85 + wave * 0.35; + return TradeExcursion( + pnl: -loss * 0.95, + mae: loss + other * 0.15, + mfe: 0.1 + other * 0.55, + ); + } + // A winner: a shallow dip, then a run of its own size. + final win = 0.5 + wave * 2.6 + other * 0.9; + return TradeExcursion( + pnl: win, + mae: other * 0.7 + (i % 11 == 0 ? 0.9 : 0), + mfe: win + 0.15 + wave * 0.8, + ); + }(), +]; + +/// A volatility surface: a smile that deepens into short expiries, and a term +/// structure that slopes up a little. +({List strikes, List expiries, List> vols}) +galleryVolatilitySurface() { + const strikes = [80.0, 85.0, 90.0, 95.0, 100.0, 105.0, 110.0, 115.0, 120.0]; + const expiries = ['1W', '2W', '1M', '2M', '3M', '6M', '1Y']; + const months = [0.25, 0.5, 1.0, 2.0, 3.0, 6.0, 12.0]; + return ( + strikes: strikes, + expiries: expiries, + vols: [ + for (var e = 0; e < expiries.length; e++) + [ + for (var s = 0; s < strikes.length; s++) + // No quote for the far wings of the shortest expiries. + e < 2 && (s == 0 || s == strikes.length - 1) + ? null + : 0.22 + + 0.01 * log(1 + months[e]) + + pow((strikes[s] - 100) / 100, 2) * + (1.4 / sqrt(months[e])) - + (strikes[s] - 100) / 100 * 0.12, + ], + ], + ); +} + +/// Candidate portfolios scattered under a concave frontier. +List galleryPortfolios({int count = 160}) => [ + for (var i = 0; i < count; i++) + () { + final risk = 0.07 + (i / count) * 0.23; + // The best return a portfolio of this risk can have. + final best = 0.02 + 0.115 * sqrt((risk - 0.06) / 0.24); + // How far short of it this one falls, deterministic and never negative. + final shortfall = + (0.012 + sin(i * 2.1).abs() * 0.03) * (i % 4 == 0 ? 0.2 : 1); + return PortfolioPoint(risk: risk, expectedReturn: best - shortfall); + }(), +]; + +/// Two futures curves: today's in mild contango, and last month's flatter. +({List today, List before}) +galleryFuturesCurves() { + const months = [ + 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', // + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', + ]; + return ( + today: [ + for (var i = 0; i < 12; i++) + FuturesContract( + label: months[i], + price: 71.4 + i * 0.55 - i * i * 0.012 + sin(i / 2.3) * 0.18, + ), + ], + before: [ + for (var i = 0; i < 12; i++) + FuturesContract( + label: months[i], + price: 69.6 + i * 0.2 - i * i * 0.004 + sin(i / 2.3) * 0.12, + ), + ], + ); +} + +/// Eight weeks of trades opened through the day: busy at the open and again in +/// the afternoon, quiet at night and at weekends. +List galleryActivity({int weeks = 8}) => [ + for (var day = 0; day < weeks * 7; day++) + for (var hour = 0; hour < 24; hour++) + SeasonalSample( + time: DateTime(2026, 3, 2).add(Duration(days: day, hours: hour)), + value: + (day % 7 >= 5 ? 0.15 : 1.0) * + (exp(-pow(hour - 9.5, 2) / 6) * 9 + + exp(-pow(hour - 15.5, 2) / 5) * 6 + + (hour >= 1 && hour <= 6 ? 0.2 : 1.2) + + sin(day * 1.3 + hour) * 0.6), + ), +]; + +// ── Models and training ──────────────────────────────────────────────────── + +/// A training loss that falls fast and flattens, with noise; a validation loss +/// that follows it, then turns up as the model starts to overfit. +({List train, List validation}) +galleryLossCurves({int points = 160}) { + final train = []; + final validation = []; + for (var i = 0; i < points; i++) { + final step = i * 25.0; + final base = 0.55 + 3.4 * exp(-i / 22); + final noise = sin(i * 1.9) * 0.07 + cos(i * 0.7) * 0.05; + train.add(TrainingPoint(step, base + noise)); + final overfit = i > 90 ? (i - 90) * 0.0042 : 0.0; + validation.add(TrainingPoint(step, base + 0.16 + overfit + noise * 0.55)); + } + return (train: train, validation: validation); +} + +/// Three learning rates on one task: one learns, one is slow, one blows up. +List galleryLearningRates({int points = 120}) => [ + TrainingRun( + label: 'lr 3e-4', + points: [ + for (var i = 0; i < points; i++) + TrainingPoint(i * 50.0, 0.8 + 5.2 * exp(-i / 18) + sin(i * 2.3) * 0.05), + ], + ), + TrainingRun( + label: 'lr 3e-5', + points: [ + for (var i = 0; i < points; i++) + TrainingPoint(i * 50.0, 1.6 + 4.4 * exp(-i / 60) + cos(i * 1.7) * 0.04), + ], + ), + TrainingRun( + label: 'lr 3e-3', + points: [ + for (var i = 0; i < points; i++) + TrainingPoint( + i * 50.0, + i < 46 + ? 0.9 + 5.0 * exp(-i / 14) + sin(i * 2.9) * 0.06 + : (i < 50 ? 1.4 * pow(2.6, (i - 45).toDouble()) : double.nan), + ), + ], + ), +]; + +/// Eight models on a loss that falls as a power of compute, with scatter. +List galleryScaling() => [ + for (var i = 0; i < 8; i++) + ScalingPoint( + scale: 1e18 * pow(5.0, i), + loss: + 14.0 * + pow(1e18 * pow(5.0, i), -0.0325).toDouble() * + (1 + sin(i * 2.7) * 0.012), + label: ['125M', '350M', '760M', '1.3B', '2.7B', '6.7B', '13B', '30B'][i], + ), +]; + +/// A learning-rate by batch-size sweep with a valley and a cliff. +List gallerySweep() { + const rates = [1e-4, 3e-4, 1e-3, 3e-3, 1e-2]; + const batches = [16.0, 32.0, 64.0, 128.0, 256.0]; + return [ + for (final lr in rates) + for (final bs in batches) + SweepTrial( + x: lr, + y: bs, + score: + 1.9 + + pow(log(lr / 1.2e-3) / ln10, 2) * 0.55 + + pow(log(bs / 80) / ln10, 2) * 0.35 + + (lr >= 1e-2 && bs <= 32 ? 1.4 : 0), + ), + ]; +} + +/// Training jobs over two days across four pods. +List galleryJobs() { + final t0 = DateTime(2026, 9, 1, 6); + TrainingJob job( + String name, + String node, + double startHour, + double? hours, + JobStatus status, { + int gpus = 64, + }) => TrainingJob( + name: name, + node: node, + start: t0.add(Duration(minutes: (startHour * 60).round())), + end: hours == null + ? null + : t0.add(Duration(minutes: ((startHour + hours) * 60).round())), + status: status, + gpus: gpus, + ); + return [ + job('pretrain-7b', 'pod-a', 0, 30, JobStatus.completed, gpus: 256), + job('sft-7b', 'pod-a', 31, 9, JobStatus.completed), + job('dpo-7b', 'pod-a', 41, null, JobStatus.running), + job('pretrain-13b', 'pod-b', 2, 14, JobStatus.preempted, gpus: 512), + job('pretrain-13b·2', 'pod-b', 17, 20, JobStatus.failed, gpus: 512), + job('pretrain-13b·3', 'pod-b', 39, null, JobStatus.running, gpus: 512), + job('eval-sweep', 'pod-c', 4, 3, JobStatus.completed, gpus: 8), + job('ablation-1', 'pod-c', 8, 6, JobStatus.completed, gpus: 16), + job('ablation-2', 'pod-c', 15, 2, JobStatus.cancelled, gpus: 16), + job('ablation-3', 'pod-c', 18, 7, JobStatus.completed, gpus: 16), + job('rl-tune', 'pod-d', 10, 12, JobStatus.completed, gpus: 32), + job('rl-tune·2', 'pod-d', 24, 5, JobStatus.failed, gpus: 32), + job('rl-tune·3', 'pod-d', 30, null, JobStatus.running, gpus: 32), + ]; +} + +/// Six models across six benchmarks; the last is perplexity, where lower wins. +({List models, List benchmarks, List> scores}) +galleryBenchmarkScores() => ( + models: [ + 'Atlas-L', + 'Atlas-M', + 'Borealis', + 'Cobalt-70B', + 'Delta-8B', + 'Echo-mini', + ], + benchmarks: ['MMLU', 'GSM8K', 'HumanEval', 'MATH', 'GPQA', 'Perplexity'], + scores: [ + [88.4, 95.1, 90.2, 72.0, 56.1, 4.6], + [84.9, 91.0, 84.8, 61.5, 48.7, 5.0], + [86.7, 92.4, 88.1, 66.3, 52.9, 4.8], + [82.1, 88.9, 79.4, 53.0, 41.5, 5.4], + [73.6, 79.2, 68.0, 36.8, 31.0, 6.3], + [62.0, 58.3, null, 21.0, 24.8, 7.9], + ], +); + +/// Fourteen models by price per million tokens and a quality score. +List galleryModels() => const [ + ModelPoint( + name: 'Atlas-L', + cost: 15, + score: 88.4, + size: 200000, + group: 'Atlas', + ), + ModelPoint( + name: 'Atlas-M', + cost: 3, + score: 84.9, + size: 200000, + group: 'Atlas', + ), + ModelPoint( + name: 'Atlas-S', + cost: 0.25, + score: 75.2, + size: 200000, + group: 'Atlas', + ), + ModelPoint( + name: 'Borealis', + cost: 10, + score: 86.7, + size: 128000, + group: 'Borealis', + ), + ModelPoint( + name: 'Borealis-mini', + cost: 0.6, + score: 79.5, + size: 128000, + group: 'Borealis', + ), + ModelPoint( + name: 'Cobalt-70B', + cost: 0.9, + score: 82.1, + size: 32000, + group: 'Open', + ), + ModelPoint( + name: 'Cobalt-8B', + cost: 0.15, + score: 69.0, + size: 32000, + group: 'Open', + ), + ModelPoint( + name: 'Delta-8B', + cost: 0.2, + score: 73.6, + size: 128000, + group: 'Open', + ), + ModelPoint( + name: 'Echo-mini', + cost: 0.08, + score: 62.0, + size: 8000, + group: 'Open', + ), + ModelPoint( + name: 'Foxtrot', + cost: 30, + score: 84.0, + size: 100000, + group: 'Foxtrot', + ), + ModelPoint( + name: 'Foxtrot-fast', + cost: 5, + score: 78.8, + size: 100000, + group: 'Foxtrot', + ), + ModelPoint( + name: 'Golf-XL', + cost: 20, + score: 83.2, + size: 1000000, + group: 'Golf', + ), + ModelPoint( + name: 'Golf-Flash', + cost: 0.4, + score: 76.9, + size: 1000000, + group: 'Golf', + ), + ModelPoint( + name: 'Hotel', + cost: 6, + score: 70.5, + size: 16000, + group: 'Foxtrot', + ), +]; + +/// Latency every fifteen minutes for a day, with a bad hour in the afternoon. +List galleryLatency({int count = 96}) => [ + for (var i = 0; i < count; i++) + () { + final hour = i / 4; + final load = 1 + 0.35 * sin((hour - 6) / 24 * 2 * pi).clamp(-1, 1); + final incident = (i >= 58 && i <= 66) + ? 1 + (1 - (i - 62).abs() / 5) * 1.6 + : 1.0; + final p50 = + 420 * load * (1 + sin(i * 1.3) * 0.04) * (incident > 1 ? 1.3 : 1); + return LatencySample( + time: DateTime(2026, 9, 1).add(Duration(minutes: i * 15)), + p50: p50, + p95: p50 * (2.0 * incident + 0.2 * sin(i * 0.7)), + p99: p50 * (3.4 * incident + 0.5 * cos(i * 0.5)), + ); + }(), +]; + +/// Fourteen days of tokens served, with a cache that warms up. +List galleryUsage({int days = 14}) => [ + for (var i = 0; i < days; i++) + () { + final traffic = 1 + i * 0.05 + sin(i * 1.2) * 0.18; + final cacheShare = (0.15 + i * 0.045).clamp(0.0, 0.7); + final prompt = 42e6 * traffic; + return UsageBucket( + time: DateTime(2026, 9, 1).add(Duration(days: i)), + inputTokens: prompt * (1 - cacheShare), + cachedTokens: prompt * cacheShare, + outputTokens: 9e6 * traffic, + cost: + prompt * (1 - cacheShare) * 3e-6 + + prompt * cacheShare * 0.3e-6 + + 9e6 * traffic * 15e-6, + ); + }(), +]; + +/// An evaluation across eight categories, with very different sample sizes. +List galleryEval() => const [ + EvalCategory(name: 'Arithmetic', passed: 188, failed: 12), + EvalCategory(name: 'Word problems', passed: 142, failed: 58), + EvalCategory(name: 'Coding', passed: 301, failed: 44, skipped: 7), + EvalCategory(name: 'Long context', passed: 64, failed: 36, skipped: 9), + EvalCategory(name: 'Tool use', passed: 77, failed: 23), + EvalCategory(name: 'Safety', passed: 480, failed: 20), + EvalCategory(name: 'Translation', passed: 9, failed: 1), + EvalCategory(name: 'Reasoning', passed: 118, failed: 82), +]; + +/// A completion with the surprisal of each token: mostly sure, doubtful where +/// it had to pick a fact — and, there, what it was weighing instead. +List galleryTokens() { + const words = [ + ('The', 0.3), + (' capital', 0.9), + (' of', 0.1), + (' Australia', 2.1), + (' is', 0.2), + (' Canberra', 3.6), + (',', 0.4), + (' not', 2.8), + (' Sydney', 1.9), + (' as', 1.2), + (' many', 1.5), + (' people', 0.6), + (' assume', 0.9), + ('.\n', 0.5), + ('It', 1.1), + (' was', 0.8), + (' chosen', 2.4), + (' in', 0.7), + (' 1908', 4.8), + (' as', 1.3), + (' a', 0.2), + (' compromise', 2.2), + (' between', 0.4), + (' the', 0.2), + (' two', 0.8), + (' largest', 1.4), + (' cities', 0.3), + ('.', 0.2), + ]; + const instead = { + ' Canberra': [ + TokenAlternative(' Sydney', 0.62), + TokenAlternative(' Melbourne', 0.21), + TokenAlternative(' Brisbane', 0.04), + ], + ' 1908': [ + TokenAlternative(' 1913', 0.31), + TokenAlternative(' 1901', 0.22), + TokenAlternative(' 1927', 0.14), + ], + ' not': [TokenAlternative(' and', 0.38), TokenAlternative(' which', 0.21)], + ' chosen': [ + TokenAlternative(' founded', 0.27), + TokenAlternative(' built', 0.19), + TokenAlternative(' selected', 0.12), + ], + }; + return [ + for (final (token, s) in words) + TokenScore( + token, + s, + probability: exp(-s), + alternatives: instead[token] ?? const [], + ), + ]; +} diff --git a/example/lib/src/gallery/gallery_entries.dart b/example/lib/src/gallery/gallery_entries.dart index 32bc705..f4e4e8e 100644 --- a/example/lib/src/gallery/gallery_entries.dart +++ b/example/lib/src/gallery/gallery_entries.dart @@ -19,7 +19,13 @@ enum GalleryGroup { results('Backtests and results'), /// The charts a dashboard or a report is built from. - dashboard('Dashboards and reports'); + dashboard('Dashboards and reports'), + + /// How far things have got towards a goal. + progress('Progress and goals'), + + /// Training runs, evaluations and what models cost to serve. + models('Models and training'); const GalleryGroup(this.title); @@ -96,6 +102,7 @@ List galleryEntries() => [ ..._orderFlow(), ..._results(), ..._dashboard(), + ..._models(), ]; /// The entry with this [id], or null when there is none. @@ -116,6 +123,73 @@ const _darkLabel = TextStyle( const _grid = Color(0x14FFFFFF); List _orderFlow() => [ + GalleryEntry( + id: 'volatility-surface', + title: 'Volatility surface', + blurb: + 'Implied volatility across strike and expiry at once, with the ' + 'skew against the at-the-money strike on touch.', + group: GalleryGroup.orderFlow, + doc: 'volatility-surface-chart.md', + variants: [ + GalleryVariant( + title: 'Strike by expiry', + figure: 'spot 100', + height: 360, + build: () { + final surface = galleryVolatilitySurface(); + return VolatilitySurfaceChart( + strikes: surface.strikes, + expiries: surface.expiries, + volatilities: surface.vols, + spot: 100, + lowColor: galleryBlue, + midColor: galleryAmber, + highColor: galleryRed, + axisLabelStyle: _smallAxisStyle, + labelStyle: _darkLabel, + ); + }, + ), + ], + ), + GalleryEntry( + id: 'futures-curve', + title: 'Futures curve', + blurb: + 'Prices against delivery month, with contango or backwardation ' + 'read off the curve and last month\'s drawn dashed behind it.', + group: GalleryGroup.orderFlow, + doc: 'futures-curve-chart.md', + variants: [ + GalleryVariant( + title: 'Crude, today and a month ago', + figure: '12 months out', + height: 340, + build: () { + final curves = galleryFuturesCurves(); + return FuturesCurveChart( + curves: [ + FuturesCurve( + label: 'Today', + contracts: curves.today, + color: galleryBlue, + ), + FuturesCurve( + label: 'A month ago', + contracts: curves.before, + color: galleryAmber, + dashed: true, + ), + ], + priceFormatter: (v) => '\$${v.toStringAsFixed(1)}', + axisLabelStyle: _smallAxisStyle, + gridColor: _grid, + ); + }, + ), + ], + ), GalleryEntry( id: 'market-profile', title: 'Market profile', @@ -434,6 +508,139 @@ List _orderFlow() => [ ]; List _results() => [ + GalleryEntry( + id: 'mae-mfe', + title: 'MAE / MFE scatter', + blurb: + 'Every trade by how far it went against you, or for you, and what ' + 'it finally made — to place a stop and a target.', + group: GalleryGroup.results, + doc: 'mae-mfe-chart.md', + variants: [ + GalleryVariant( + title: 'Max adverse excursion', + figure: 'stop at 1R', + height: 360, + build: () => MaeMfeChart( + trades: galleryExcursions(), + xReferenceLines: const [1], + winnerColor: galleryGreen, + loserColor: galleryRed, + xFormatter: (v) => '${v.toStringAsFixed(1)}R', + yFormatter: (v) => '${v.toStringAsFixed(0)}R', + axisLabelStyle: _smallAxisStyle, + axisTitleStyle: _smallAxisStyle, + yAxisTitle: 'Result', + gridColor: _grid, + ), + ), + GalleryVariant( + title: 'Max favourable excursion', + figure: 'the limit line', + height: 360, + build: () => MaeMfeChart( + trades: galleryExcursions(), + kind: ExcursionKind.favourable, + winnerColor: galleryGreen, + loserColor: galleryRed, + xFormatter: (v) => '${v.toStringAsFixed(1)}R', + yFormatter: (v) => '${v.toStringAsFixed(0)}R', + axisLabelStyle: _smallAxisStyle, + axisTitleStyle: _smallAxisStyle, + yAxisTitle: 'Result', + gridColor: _grid, + ), + ), + ], + ), + GalleryEntry( + id: 'var', + title: 'Value at risk', + blurb: + 'The shape of the returns, with the loss tail picked out and the ' + 'value at risk and expected shortfall marked.', + group: GalleryGroup.results, + doc: 'var-chart.md', + variants: [ + GalleryVariant( + title: 'Daily returns, 95%', + figure: '400 days', + height: 380, + build: () => VarChart( + returns: [for (final r in galleryReturns()) r / 100], + barColor: galleryBlue, + tailColor: galleryRed, + axisLabelStyle: _smallAxisStyle, + statLabelStyle: _smallAxisStyle, + statValueStyle: const TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.w700, + ), + gridColor: _grid, + ), + ), + ], + ), + GalleryEntry( + id: 'efficient-frontier', + title: 'Efficient frontier', + blurb: + 'Portfolios by risk and return, with the frontier, the best Sharpe ' + 'ratio, the least risky portfolio and the one held now.', + group: GalleryGroup.results, + doc: 'efficient-frontier-chart.md', + variants: [ + GalleryVariant( + title: 'Risk and return', + figure: 'risk-free 2%', + height: 400, + build: () => EfficientFrontierChart( + points: galleryPortfolios(), + riskFreeRate: 0.02, + current: const PortfolioPoint(risk: 0.19, expectedReturn: 0.083), + portfolioColor: const Color(0x664DABF7), + frontierColor: galleryGreen, + maxSharpeColor: galleryAmber, + minRiskColor: galleryTeal, + currentColor: galleryRed, + riskFormatter: (v) => '${(v * 100).toStringAsFixed(0)}%', + returnFormatter: (v) => '${(v * 100).toStringAsFixed(0)}%', + axisLabelStyle: _smallAxisStyle, + axisTitleStyle: _smallAxisStyle, + gridColor: _grid, + ), + ), + ], + ), + GalleryEntry( + id: 'activity-clock', + title: 'Activity clock', + blurb: + 'A week as a clock face — an hour round the dial, a weekday on ' + 'each ring — coloured by when the trades were opened.', + group: GalleryGroup.results, + doc: 'activity-clock-chart.md', + variants: [ + GalleryVariant( + title: 'Trades opened by hour', + figure: 'Mon is innermost', + height: 420, + build: () => ActivityClockChart( + samples: galleryActivity(), + aggregate: SeasonalityAggregate.mean, + scale: HeatmapGradientScale( + colors: [ + galleryBlue.withValues(alpha: 0.12), + galleryBlue, + galleryAmber, + ], + ), + labelStyle: _smallAxisStyle, + ), + ), + ], + ), GalleryEntry( id: 'equity-curve', title: 'Equity curve', @@ -1542,3 +1749,562 @@ TreemapItem _symbol(String name, double weight, double change) => TreemapItem( label: '$name\n${change >= 0 ? '+' : ''}${change.toStringAsFixed(1)}%', colorValue: change, ); + +List _models() => [ + GalleryEntry( + id: 'training-curve', + title: 'Training curves', + blurb: + 'Loss and accuracy of one or more runs, with smoothing, a log axis, ' + 'the best checkpoint and the step a run diverged.', + group: GalleryGroup.models, + doc: 'training-curve-chart.md', + variants: [ + GalleryVariant( + title: 'Train and validation', + figure: 'overfits at 2,250', + height: 380, + build: () { + final curves = galleryLossCurves(); + return TrainingCurveChart( + runs: [ + TrainingRun( + label: 'train', + points: curves.train, + color: galleryBlue, + ), + TrainingRun( + label: 'validation', + points: curves.validation, + color: galleryAmber, + dashed: true, + ), + ], + smoothing: 0.85, + gapBetween: (0, 1), + gapColor: const Color(0x33FA5252), + yAxisTitle: 'Loss', + axisLabelStyle: _smallAxisStyle, + axisTitleStyle: _smallAxisStyle, + gridColor: _grid, + ); + }, + ), + GalleryVariant( + title: 'Three learning rates', + figure: 'log axis', + height: 380, + build: () => TrainingCurveChart( + runs: galleryLearningRates(), + logY: true, + smoothing: 0.4, + yAxisTitle: 'Loss (log)', + axisLabelStyle: _smallAxisStyle, + axisTitleStyle: _smallAxisStyle, + gridColor: _grid, + ), + ), + ], + ), + GalleryEntry( + id: 'scaling-law', + title: 'Scaling laws', + blurb: + 'Loss against compute on log-log axes, with the power law fitted ' + 'and carried to where it reaches a target.', + group: GalleryGroup.models, + doc: 'scaling-law-chart.md', + variants: [ + GalleryVariant( + title: 'Loss against FLOPs', + figure: 'fit and extrapolation', + height: 400, + build: () => ScalingLawChart( + points: galleryScaling(), + extrapolateTo: 1e26, + targetLoss: 2.0, + pointColor: galleryBlue, + fitColor: galleryAmber, + bandColor: const Color(0x22FAB005), + targetColor: galleryGreen, + xAxisTitle: 'Training compute (FLOPs)', + yAxisTitle: 'Loss', + axisLabelStyle: _smallAxisStyle, + axisTitleStyle: _smallAxisStyle, + gridColor: _grid, + ), + ), + ], + ), + GalleryEntry( + id: 'sweep', + title: 'Hyperparameter sweep', + blurb: + 'A learning-rate by batch-size grid of validation losses, the best ' + 'combination starred and the mean of each setting alongside.', + group: GalleryGroup.models, + doc: 'sweep-chart.md', + variants: [ + GalleryVariant( + title: 'Validation loss', + figure: '5 × 5', + height: 340, + build: () => SweepChart( + trials: gallerySweep(), + goodColor: galleryGreen, + midColor: galleryAmber, + badColor: galleryRed, + labelStyle: _darkLabel, + axisLabelStyle: _smallAxisStyle, + ), + ), + ], + ), + GalleryEntry( + id: 'training-timeline', + title: 'Training timeline', + blurb: + 'Training jobs by machine, coloured by how each finished, with how ' + 'many were running underneath.', + group: GalleryGroup.models, + doc: 'training-timeline-chart.md', + variants: [ + GalleryVariant( + title: 'Two days on four pods', + figure: '13 jobs', + height: 360, + build: () => TrainingTimelineChart( + jobs: galleryJobs(), + runningColor: galleryBlue, + completedColor: galleryGreen, + failedColor: galleryRed, + preemptedColor: galleryAmber, + cancelledColor: const Color(0xFF6B7480), + axisLabelStyle: _smallAxisStyle, + laneLabelStyle: _smallAxisStyle, + gridColor: _grid, + ), + ), + ], + ), + GalleryEntry( + id: 'benchmark-matrix', + title: 'Benchmark matrix', + blurb: + 'Models against benchmarks, each score coloured by where it stands ' + 'in its column, the leaders starred.', + group: GalleryGroup.models, + doc: 'benchmark-matrix-chart.md', + variants: [ + GalleryVariant( + title: 'Six models, six benchmarks', + figure: 'perplexity: lower wins', + height: 340, + build: () { + final data = galleryBenchmarkScores(); + return BenchmarkMatrixChart( + models: data.models, + benchmarks: data.benchmarks, + scores: data.scores, + lowerIsBetter: const {5}, + lowColor: galleryRed, + midColor: galleryAmber, + highColor: galleryGreen, + labelStyle: _darkLabel, + axisLabelStyle: _smallAxisStyle, + modelAxisWidth: 96, + ); + }, + ), + ], + ), + GalleryEntry( + id: 'model-pareto', + title: 'Model cost against quality', + blurb: + 'Models by price and score on a log axis, with the Pareto frontier ' + 'through the ones nothing beats.', + group: GalleryGroup.models, + doc: 'model-pareto-chart.md', + variants: [ + GalleryVariant( + title: 'Price against MMLU', + figure: 'size = context window', + height: 420, + build: () => ModelParetoChart( + models: galleryModels(), + palette: galleryPalette, + costFormatter: (v) => '\$${v < 1 ? v : v.toStringAsFixed(0)}', + xAxisTitle: 'Price per 1M tokens', + yAxisTitle: 'MMLU', + axisLabelStyle: _smallAxisStyle, + axisTitleStyle: _smallAxisStyle, + legendStyle: _axisStyle, + gridColor: _grid, + ), + ), + ], + ), + GalleryEntry( + id: 'latency-band', + title: 'Latency bands', + blurb: + 'p50, p95 and p99 latency over a day as bands, with an objective ' + 'drawn across and the intervals that broke it marked.', + group: GalleryGroup.models, + doc: 'latency-band-chart.md', + variants: [ + GalleryVariant( + title: 'Time to first token', + figure: 'SLO 1,200 ms on p95', + figureColor: galleryAmber, + height: 380, + build: () => LatencyBandChart( + samples: galleryLatency(), + slo: 1200, + medianColor: galleryBlue, + p95Color: galleryBlue, + p99Color: galleryPurple, + sloColor: galleryAmber, + breachColor: galleryRed, + valueFormatter: (v) => '${v.round()} ms', + axisLabelStyle: _smallAxisStyle, + gridColor: _grid, + ), + ), + ], + ), + GalleryEntry( + id: 'token-usage', + title: 'Token usage', + blurb: + 'Cached, input and output tokens over two weeks as stacked columns, ' + 'with the cost as a line on its own axis.', + group: GalleryGroup.models, + doc: 'token-usage-chart.md', + variants: [ + GalleryVariant( + title: 'Tokens and spend', + figure: 'cache warming up', + height: 380, + build: () => TokenUsageChart( + buckets: galleryUsage(), + inputColor: galleryBlue, + outputColor: galleryPurple, + cachedColor: galleryTeal, + costColor: galleryAmber, + costFormatter: (v) => '\$${v.toStringAsFixed(0)}', + axisLabelStyle: _smallAxisStyle, + legendStyle: _axisStyle, + gridColor: _grid, + ), + ), + ], + ), + GalleryEntry( + id: 'eval-breakdown', + title: 'Evaluation breakdown', + blurb: + 'An evaluation by category as bars of passed, failed and skipped ' + 'tasks, the worst first, with a threshold held across.', + group: GalleryGroup.models, + doc: 'eval-breakdown-chart.md', + variants: [ + GalleryVariant( + title: 'Pass rate by category', + figure: 'threshold 80%', + figureColor: galleryAmber, + height: 380, + build: () => EvalBreakdownChart( + categories: galleryEval(), + threshold: 0.8, + passedColor: galleryGreen, + failedColor: galleryRed, + skippedColor: const Color(0xFF6B7480), + thresholdColor: galleryAmber, + labelStyle: const TextStyle(color: Color(0xFFD0D5DB), fontSize: 12), + ), + ), + ], + ), + GalleryEntry( + id: 'token-heatmap', + title: 'Token heatmap', + blurb: + 'A completion with each token shaded by how surprised the model was ' + 'by it — the doubtful ones ringed and numbered, what it weighed ' + 'instead on touch.', + group: GalleryGroup.models, + doc: 'token-heatmap.md', + variants: [ + GalleryVariant( + title: 'Surprisal per token', + figure: 'top 3 ringed', + height: 300, + build: () => TokenHeatmap( + tokens: galleryTokens(), + showSummary: true, + showStrip: true, + highlightTop: 3, + textStyle: const TextStyle(fontSize: 15), + legendStyle: _axisStyle, + ), + ), + GalleryVariant( + title: 'Only the doubtful', + figure: 'above 2 nats', + height: 300, + build: () => TokenHeatmap( + tokens: galleryTokens(), + focusAbove: 2, + showWhitespace: true, + showStrip: true, + textStyle: const TextStyle(fontSize: 15), + legendStyle: _axisStyle, + ), + ), + ], + ), + GalleryEntry( + id: 'progress', + title: 'Progress bars', + blurb: + 'How far each goal has got, as pills, blocks or cells, with a mark ' + 'for where it should be by now.', + group: GalleryGroup.progress, + doc: 'progress-bars.md', + variants: [ + GalleryVariant( + title: 'Goals this month', + figure: '3 of 4', + figureColor: galleryGreen, + height: 300, + build: () => const ProgressBars( + items: [ + ProgressItem( + label: 'Net profit', + value: 72, + target: 60, + color: galleryGreen, + ), + ProgressItem( + label: 'Trades taken', + value: 64, + target: 50, + color: galleryBlue, + ), + ProgressItem( + label: 'Journal entries', + value: 88, + target: 75, + color: galleryPurple, + ), + ProgressItem( + label: 'Risk reviews', + value: 35, + target: 60, + color: galleryAmber, + ), + ], + barHeight: 14, + rowGap: 34, + labelStyle: TextStyle(color: Color(0xFFD0D5DB), fontSize: 13), + valueStyle: TextStyle( + color: Color(0xFFE9ECEF), + fontSize: 13, + fontWeight: FontWeight.w600, + ), + padding: EdgeInsets.fromLTRB(4, 8, 4, 0), + ), + ), + GalleryVariant( + title: 'In cells', + figure: '7 of 10', + height: 300, + build: () => const ProgressBars( + shape: ProgressShape.segmented, + items: [ + ProgressItem( + label: 'Backtest coverage', + value: 70, + valueLabel: '7 / 10', + color: galleryBlue, + ), + ProgressItem( + label: 'Walk-forward windows', + value: 40, + valueLabel: '4 / 10', + color: galleryTeal, + ), + ProgressItem( + label: 'Paper-trading days', + value: 95, + valueLabel: '19 / 20', + color: galleryGreen, + ), + ], + segments: 10, + barHeight: 16, + rowGap: 48, + labelStyle: TextStyle(color: Color(0xFFD0D5DB), fontSize: 13), + valueStyle: TextStyle( + color: Color(0xFFE9ECEF), + fontSize: 13, + fontWeight: FontWeight.w600, + ), + padding: EdgeInsets.fromLTRB(4, 8, 4, 0), + ), + ), + GalleryVariant( + title: 'Budgets used', + figure: '1 over', + figureColor: galleryRed, + height: 300, + build: () => const ProgressBars( + shape: ProgressShape.block, + items: [ + ProgressItem( + label: 'Daily loss limit', + value: 41, + color: galleryGreen, + ), + ProgressItem(label: 'Margin used', value: 78, color: galleryAmber), + ProgressItem(label: 'Order rate', value: 100, color: galleryRed), + ProgressItem(label: 'API quota', value: 22, color: galleryBlue), + ], + barHeight: 14, + rowGap: 34, + labelStyle: TextStyle(color: Color(0xFFD0D5DB), fontSize: 13), + valueStyle: TextStyle( + color: Color(0xFFE9ECEF), + fontSize: 13, + fontWeight: FontWeight.w600, + ), + padding: EdgeInsets.fromLTRB(4, 8, 4, 0), + ), + ), + ], + ), + GalleryEntry( + id: 'progress-card', + title: 'Progress card', + blurb: + 'A card around any widget whose whole background is the progress — ' + 'swept round like a clock, grown as a circle, or filled along the ' + 'card.', + group: GalleryGroup.progress, + doc: 'progress-bars.md', + variants: [ + GalleryVariant( + title: 'Sweep, like a clock', + figure: 'sweep', + height: 300, + build: () => const _CourseCards(mode: ProgressCardMode.sweep), + ), + GalleryVariant( + title: 'A growing circle', + figure: 'circle', + height: 300, + build: () => const _CourseCards(mode: ProgressCardMode.circle), + ), + GalleryVariant( + title: 'Along the card', + figure: 'linear', + height: 300, + build: () => const _CourseCards(mode: ProgressCardMode.linear), + ), + ], + ), +]; + +/// Two courses as cards, one just begun and one nearly done, filled in as +/// [mode] says. +class _CourseCards extends StatelessWidget { + const _CourseCards({required this.mode}); + + final ProgressCardMode mode; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _CourseCard(title: 'Chart patterns', lessons: 3, of: 20, mode: mode), + const SizedBox(height: 14), + _CourseCard( + title: 'Order flow', + lessons: 9, + of: 12, + color: galleryGreen, + mode: mode, + ), + ], + ); + } +} + +/// A course as a card: its name, how many lessons are done and the share. +class _CourseCard extends StatelessWidget { + const _CourseCard({ + required this.title, + required this.lessons, + required this.of, + required this.mode, + this.color = galleryBlue, + }); + + final String title; + final int lessons; + final int of; + final ProgressCardMode mode; + final Color color; + + @override + Widget build(BuildContext context) { + final progress = lessons / of; + return ProgressCard( + progress: progress, + mode: mode, + color: color, + semanticLabel: '$title, $lessons of $of lessons', + child: SizedBox( + height: 76, + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Color(0xFFF1F3F5), + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(width: 8), + Text( + '${(progress * 100).round()}%', + style: const TextStyle( + color: Color(0xFFF1F3F5), + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + Text( + '$lessons of $of lessons', + style: const TextStyle(color: Color(0xFFD0D5DB), fontSize: 13), + ), + ], + ), + ), + ); + } +} diff --git a/example/lib/src/gallery/gallery_page.dart b/example/lib/src/gallery/gallery_page.dart index fd7400d..8ad413f 100644 --- a/example/lib/src/gallery/gallery_page.dart +++ b/example/lib/src/gallery/gallery_page.dart @@ -167,22 +167,37 @@ class _GalleryDetailPageState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Expanded( + // Both give way when the page is narrow, rather than + // pushing one another off its edge. + Flexible( + flex: 3, child: Text( variant.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.titleSmall ?.copyWith(fontWeight: FontWeight.w600), ), ), if (variant.figure != null) - Text( - variant.figure!, - style: Theme.of(context).textTheme.titleSmall - ?.copyWith( - fontWeight: FontWeight.w700, - color: variant.figureColor, - ), + Flexible( + flex: 2, + child: Padding( + padding: const EdgeInsets.only(left: 8), + child: Text( + variant.figure!, + maxLines: 2, + textAlign: TextAlign.end, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleSmall + ?.copyWith( + fontWeight: FontWeight.w700, + color: variant.figureColor, + ), + ), + ), ), ], ), diff --git a/example/test/overflow_sweep_test.dart b/example/test/overflow_sweep_test.dart new file mode 100644 index 0000000..450a3fd --- /dev/null +++ b/example/test/overflow_sweep_test.dart @@ -0,0 +1,37 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:ohlcv_chart_example/src/demo_state.dart'; +import 'package:ohlcv_chart_example/src/gallery/gallery_entries.dart'; +import 'package:ohlcv_chart_example/src/gallery/gallery_page.dart'; + +void main() { + const widths = [240.0, 280.0, 320.0, 360.0, 420.0, 600.0, 900.0, 1400.0]; + for (final entry in galleryEntries()) { + for (final width in widths) { + testWidgets('${entry.id} at $width wide', (tester) async { + final state = DemoState(); + addTearDown(state.dispose); + tester.view.physicalSize = Size(width, 1600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + + final errors = []; + final original = FlutterError.onError; + FlutterError.onError = errors.add; + await tester.pumpWidget( + MaterialApp( + home: GalleryDetailPage(entry: entry, state: state), + ), + ); + await tester.pumpAndSettle(); + FlutterError.onError = original; + + expect( + errors.map((e) => e.exceptionAsString().split('\n').first).toList(), + isEmpty, + reason: '${entry.id} at $width', + ); + }); + } + } +} diff --git a/example/tool/gallery_scenes.dart b/example/tool/gallery_scenes.dart index 1b9f461..2300dba 100644 --- a/example/tool/gallery_scenes.dart +++ b/example/tool/gallery_scenes.dart @@ -27,6 +27,9 @@ const _shortScenes = { 'waterfall', 'box-plot', 'histogram', + 'token-heatmap', + 'progress', + 'progress-card', }; /// The gallery's scenes, added to the set [buildScenes] shoots. diff --git a/example/tool/screenshots.dart b/example/tool/screenshots.dart index 453e6dc..6d6639f 100644 --- a/example/tool/screenshots.dart +++ b/example/tool/screenshots.dart @@ -1807,23 +1807,37 @@ Widget seriesPanel( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text( - title, - style: const TextStyle( - color: Color(0xFFB6C0CC), - fontSize: 13, - fontWeight: FontWeight.w600, + Flexible( + flex: 3, + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Color(0xFFB6C0CC), + fontSize: 13, + fontWeight: FontWeight.w600, + ), ), ), - const Spacer(), if (figure != null) - Text( - figure, - style: TextStyle( - color: figureColor, - fontSize: 13, - fontWeight: FontWeight.w700, + Flexible( + flex: 2, + child: Padding( + padding: const EdgeInsets.only(left: 8), + child: Text( + figure, + maxLines: 1, + textAlign: TextAlign.end, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: figureColor, + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), ), ), ], diff --git a/lib/src/book/book_heatmap_chart.dart b/lib/src/book/book_heatmap_chart.dart index 1e5e46a..16f0038 100644 --- a/lib/src/book/book_heatmap_chart.dart +++ b/lib/src/book/book_heatmap_chart.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart' show listEquals; import 'dart:math' as math; import 'package:flutter/widgets.dart'; @@ -36,6 +37,18 @@ class BookLevel { /// Anything the app wants back when this level is touched. final Object? data; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is BookLevel && + other.price == price && + other.size == size && + other.side == side && + other.data == data; + + @override + int get hashCode => Object.hashAll([price, size, side, data]); } /// The book at one moment. @@ -60,6 +73,18 @@ class BookSnapshot { /// Anything the app wants back when this column is touched. final Object? data; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is BookSnapshot && + other.time == time && + listEquals(other.levels, levels) && + other.mid == mid && + other.data == data; + + @override + int get hashCode => Object.hashAll([time, Object.hashAll(levels), mid, data]); } /// Where one [BookLevel] was laid out. @@ -677,7 +702,7 @@ class BookHeatmapChartPainter extends CustomPainter { Offset? previous; for (final column in layout.columns) { final y = column.midY; - if (y == null) { + if (y == null || !y.isFinite) { previous = null; continue; } diff --git a/lib/src/bubble/bubble_chart.dart b/lib/src/bubble/bubble_chart.dart index 17adc18..38a0b8b 100644 --- a/lib/src/bubble/bubble_chart.dart +++ b/lib/src/bubble/bubble_chart.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart' show listEquals; import 'dart:math' as math; import 'package:flutter/widgets.dart'; @@ -38,6 +39,20 @@ class BubblePoint { /// Anything the app wants back when this bubble is touched. final Object? data; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is BubblePoint && + other.x == x && + other.y == y && + other.size == size && + other.label == label && + other.color == color && + other.data == data; + + @override + int get hashCode => Object.hashAll([x, y, size, label, color, data]); } /// Where one [BubblePoint] was laid out. @@ -385,7 +400,7 @@ class _BubbleChartState extends State void didUpdateWidget(BubbleChart oldWidget) { super.didUpdateWidget(oldWidget); _animation.duration = widget.animationDuration; - if (!identical(oldWidget.points, widget.points)) { + if (!listEquals(oldWidget.points, widget.points)) { if (_touched != null && _touched! >= widget.points.length) { _touched = null; } @@ -609,6 +624,11 @@ class BubbleChartPainter extends CustomPainter { // Largest first, so a small bubble is never buried. final order = [...circles]..sort((a, b) => b.radius.compareTo(a.radius)); for (final circle in order) { + if (!circle.center.dx.isFinite || + !circle.center.dy.isFinite || + !circle.radius.isFinite) { + continue; + } final color = _colorOf(circle); final radius = circle.radius * t; canvas diff --git a/lib/src/bullet/bullet_chart.dart b/lib/src/bullet/bullet_chart.dart index b89bea7..2eae831 100644 --- a/lib/src/bullet/bullet_chart.dart +++ b/lib/src/bullet/bullet_chart.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart' show listEquals; import 'dart:math' as math; import 'package:flutter/widgets.dart'; @@ -19,6 +20,17 @@ class BulletBand { /// What it is called, for a legend of your own. final String? label; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is BulletBand && + other.to == to && + other.color == color && + other.label == label; + + @override + int get hashCode => Object.hashAll([to, color, label]); } /// One measure against its target — a row of a [BulletChart]. @@ -85,6 +97,35 @@ class BulletRow { if (top <= min) return min + 1; return bands.isEmpty && max == null ? top + (top - min) * 0.08 : top; } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is BulletRow && + other.label == label && + other.value == value && + other.target == target && + other.min == min && + other.max == max && + listEquals(other.bands, bands) && + other.color == color && + other.targetColor == targetColor && + other.valueLabel == valueLabel && + other.tooltip == tooltip; + + @override + int get hashCode => Object.hashAll([ + label, + value, + target, + min, + max, + Object.hashAll(bands), + color, + targetColor, + valueLabel, + tooltip, + ]); } /// Where one row of a [BulletChart] sits. @@ -396,7 +437,7 @@ class _BulletChartState extends State void didUpdateWidget(BulletChart oldWidget) { super.didUpdateWidget(oldWidget); _animation.duration = widget.animationDuration; - if (!identical(oldWidget.rows, widget.rows) && + if (!listEquals(oldWidget.rows, widget.rows) && widget.animationDuration > Duration.zero) { _animation.forward(from: 0); } diff --git a/lib/src/calendar/calendar_chart.dart b/lib/src/calendar/calendar_chart.dart index 0bc2d29..f3c8ab0 100644 --- a/lib/src/calendar/calendar_chart.dart +++ b/lib/src/calendar/calendar_chart.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart' show listEquals; import 'dart:math' as math; import 'package:flutter/widgets.dart'; @@ -29,6 +30,18 @@ class CalendarDay { /// Anything the app wants back when this day is touched. final Object? data; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is CalendarDay && + other.date == date && + other.value == value && + other.color == color && + other.data == data; + + @override + int get hashCode => Object.hashAll([date, value, color, data]); } /// Where one day was laid out. @@ -535,7 +548,7 @@ class _CalendarChartState extends State void didUpdateWidget(CalendarChart oldWidget) { super.didUpdateWidget(oldWidget); _animation.duration = widget.animationDuration; - if (!identical(oldWidget.days, widget.days)) { + if (!listEquals(oldWidget.days, widget.days)) { _touched = null; if (widget.animationDuration > Duration.zero) { _animation.forward(from: 0); diff --git a/lib/src/chart_controller.dart b/lib/src/chart_controller.dart index 46d7b87..0c70687 100644 --- a/lib/src/chart_controller.dart +++ b/lib/src/chart_controller.dart @@ -148,6 +148,9 @@ class KChartController extends ChangeNotifier { /// Zooms out by [step]. void zoomOut([double step = 0.2]) => zoomTo(scale - step); + /// Resets the zoom to the natural candle width, undoing any [zoomTo]. + void resetZoom() => zoomTo(1.0); + /// Whether the newest candle is in view. bool get isAtRightEdge => _host?.isChartAtRightEdge ?? true; @@ -362,7 +365,8 @@ class KChartController extends ChangeNotifier { /// /// Widened outwards where the instants fall between candles, so the span asked /// for is always covered. Null where there are no candles, or where the span -/// falls entirely outside them. +/// ends before the first of them. A span that starts after the last candle is +/// covered by that candle, which is still open for the length of its period. (int, int)? indexRangeCovering( List candles, DateTime from, diff --git a/lib/src/chart_link.dart b/lib/src/chart_link.dart index 1a28ef5..8d8a384 100644 --- a/lib/src/chart_link.dart +++ b/lib/src/chart_link.dart @@ -100,7 +100,11 @@ class ChartLink { /// Adds [controller], if it is not already on the link. void add(KChartController controller) { if (_following.containsKey(controller)) return; - void listener() => _whenSafe(() => _spreadFrom(controller)); + // The work may be deferred to after the frame, by which time the chart may + // have been taken off the link — and a removed chart must not move the rest. + void listener() => _whenSafe(() { + if (_following.containsKey(controller)) _spreadFrom(controller); + }); _following[controller] = listener; controller.addListener(listener); } diff --git a/lib/src/chord/chord_chart.dart b/lib/src/chord/chord_chart.dart index 9095600..4cab3a7 100644 --- a/lib/src/chord/chord_chart.dart +++ b/lib/src/chord/chord_chart.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart' show listEquals; import 'dart:math' as math; import 'package:flutter/widgets.dart'; @@ -33,6 +34,19 @@ class ChordFlow { /// The value actually drawn. double get drawnValue => value.isFinite && value > 0 ? value : 0; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ChordFlow && + other.from == from && + other.to == to && + other.value == value && + other.color == color && + other.tooltip == tooltip; + + @override + int get hashCode => Object.hashAll([from, to, value, color, tooltip]); } /// A node round the ring of a [ChordChart]. @@ -50,6 +64,17 @@ class ChordNode { /// Shown when its arc is touched; null shows its label and total. final String? tooltip; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ChordNode && + other.label == label && + other.color == color && + other.tooltip == tooltip; + + @override + int get hashCode => Object.hashAll([label, color, tooltip]); } /// The totals of a set of [ChordFlow]s, by node. @@ -525,8 +550,8 @@ class _ChordChartState extends State void didUpdateWidget(ChordChart oldWidget) { super.didUpdateWidget(oldWidget); _animation.duration = widget.animationDuration; - if (!identical(oldWidget.flows, widget.flows) || - !identical(oldWidget.nodes, widget.nodes)) { + if (!listEquals(oldWidget.flows, widget.flows) || + !listEquals(oldWidget.nodes, widget.nodes)) { _node = null; _flow = null; if (widget.animationDuration > Duration.zero) { diff --git a/lib/src/delta/cumulative_delta_chart.dart b/lib/src/delta/cumulative_delta_chart.dart index f996898..a7bff7d 100644 --- a/lib/src/delta/cumulative_delta_chart.dart +++ b/lib/src/delta/cumulative_delta_chart.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart' show listEquals; import 'dart:math' as math; import 'package:flutter/widgets.dart'; @@ -29,6 +30,18 @@ class DeltaBar { /// Anything the app wants back when this bar is touched. final Object? data; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is DeltaBar && + other.time == time && + other.delta == delta && + other.price == price && + other.data == data; + + @override + int get hashCode => Object.hashAll([time, delta, price, data]); } /// Turns [bars] into delta bars, taking each bar's delta and close. @@ -493,7 +506,7 @@ class _CumulativeDeltaChartState extends State void didUpdateWidget(CumulativeDeltaChart oldWidget) { super.didUpdateWidget(oldWidget); _animation.duration = widget.animationDuration; - if (!identical(oldWidget.bars, widget.bars)) { + if (!listEquals(oldWidget.bars, widget.bars)) { _touched = null; if (widget.animationDuration > Duration.zero) { _animation.forward(from: 0); @@ -508,6 +521,33 @@ class _CumulativeDeltaChartState extends State super.dispose(); } + // The marks depend on the bars alone, but build runs on every animation + // frame; working them out each time is a scan of the window per bar. + List? _marksFor; + int _marksLength = -1; + int _marksLookback = -1; + double? _marksLastDelta; + double? _marksLastPrice; + List _marks = const []; + + List _divergences() { + final bars = widget.bars; + final last = bars.isEmpty ? null : bars.last; + if (!identical(_marksFor, bars) || + _marksLength != bars.length || + _marksLookback != widget.divergenceLookback || + _marksLastDelta != last?.delta || + _marksLastPrice != last?.price) { + _marksFor = bars; + _marksLength = bars.length; + _marksLookback = widget.divergenceLookback; + _marksLastDelta = last?.delta; + _marksLastPrice = last?.price; + _marks = deltaDivergences(bars, lookback: widget.divergenceLookback); + } + return _marks; + } + void _handle(Offset local) { final index = _layout.indexAt(local.dx); if (index == _touched) return; @@ -537,7 +577,7 @@ class _CumulativeDeltaChartState extends State Widget build(BuildContext context) { final t = widget.animationCurve.transform(_animation.value); final marks = widget.showDivergences - ? deltaDivergences(widget.bars, lookback: widget.divergenceLookback) + ? _divergences() : const []; Widget chart = LayoutBuilder( diff --git a/lib/src/distribution/box_plot_chart.dart b/lib/src/distribution/box_plot_chart.dart index 7cc46cc..7b32148 100644 --- a/lib/src/distribution/box_plot_chart.dart +++ b/lib/src/distribution/box_plot_chart.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart' show listEquals; import 'dart:math' as math; import 'package:flutter/widgets.dart'; @@ -117,6 +118,29 @@ class BoxPlotStats { /// The highest value drawn, outliers included. double get max => outliers.fold(upper, math.max); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is BoxPlotStats && + other.lower == lower && + other.q1 == q1 && + other.median == median && + other.q3 == q3 && + other.upper == upper && + other.mean == mean && + listEquals(other.outliers, outliers); + + @override + int get hashCode => Object.hashAll([ + lower, + q1, + median, + q3, + upper, + mean, + Object.hashAll(outliers), + ]); } /// One box of a [BoxPlotChart] — a strategy, a symbol, a month. @@ -145,6 +169,18 @@ class BoxPlotEntry { /// Anything the app wants back when this entry is touched. final Object? data; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is BoxPlotEntry && + other.stats == stats && + other.label == label && + other.color == color && + other.data == data; + + @override + int get hashCode => Object.hashAll([stats, label, color, data]); } /// Where one [BoxPlotEntry] was laid out. @@ -478,7 +514,7 @@ class _BoxPlotChartState extends State void didUpdateWidget(BoxPlotChart oldWidget) { super.didUpdateWidget(oldWidget); _animation.duration = widget.animationDuration; - if (!identical(oldWidget.entries, widget.entries)) { + if (!listEquals(oldWidget.entries, widget.entries)) { if (_touched != null && _touched! >= widget.entries.length) { _touched = null; } diff --git a/lib/src/distribution/histogram_chart.dart b/lib/src/distribution/histogram_chart.dart index 4db04e2..b86f9c2 100644 --- a/lib/src/distribution/histogram_chart.dart +++ b/lib/src/distribution/histogram_chart.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart' show listEquals; import 'dart:math' as math; import 'package:flutter/widgets.dart'; @@ -39,6 +40,19 @@ class HistogramBin { /// How wide the range is. double get width => to - from; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is HistogramBin && + other.from == from && + other.to == to && + other.count == count && + other.color == color && + other.data == data; + + @override + int get hashCode => Object.hashAll([from, to, count, color, data]); } /// Counts [samples] into bins of equal width. @@ -72,7 +86,9 @@ List histogramBins( final int count; if (binWidth != null && binWidth > 0) { - count = math.max(1, ((high - low) / binWidth).ceil()); + // The quotient of two decimals is a hair over a whole number as often as + // not (0.7 / 0.1), which would otherwise earn an extra, empty bin. + count = math.max(1, ((high - low) / binWidth - 1e-9).ceil()); high = low + count * binWidth; } else { count = math.max(1, binCount ?? math.sqrt(values.length).round()); @@ -357,7 +373,7 @@ class _HistogramChartState extends State void didUpdateWidget(HistogramChart oldWidget) { super.didUpdateWidget(oldWidget); _animation.duration = widget.animationDuration; - if (!identical(oldWidget.bins, widget.bins)) { + if (!listEquals(oldWidget.bins, widget.bins)) { if (_touched != null && _touched! >= widget.bins.length) _touched = null; if (widget.animationDuration > Duration.zero) { _animation.forward(from: 0); diff --git a/lib/src/distribution/mae_mfe_chart.dart b/lib/src/distribution/mae_mfe_chart.dart new file mode 100644 index 0000000..f1abc5f --- /dev/null +++ b/lib/src/distribution/mae_mfe_chart.dart @@ -0,0 +1,782 @@ +import 'dart:math' as math; + +import 'package:flutter/foundation.dart' show listEquals; +import 'package:flutter/widgets.dart'; + +import '../renderer/text_painter_cache.dart'; +import '../utils/xy_plot.dart'; + +/// One trade seen from the inside: what it ended at, how far it went against +/// the trader on the way, and how far it went their way. +@immutable +class TradeExcursion { + /// Creates a trade that made [pnl], after going [mae] against the position at + /// worst and [mfe] in its favour at best. + /// + /// Both excursions are distances, so zero or more: a trade that was never + /// under water has an [mae] of 0. They are in the same unit as [pnl] — R, + /// points or money. + const TradeExcursion({ + required this.pnl, + required this.mae, + required this.mfe, + this.label, + this.color, + this.data, + }); + + /// What the trade made or lost. + final double pnl; + + /// The most it went against the position: maximum adverse excursion. + final double mae; + + /// The most it went in the position's favour: maximum favourable excursion. + final double mfe; + + /// What the trade is called. + final String? label; + + /// A colour of this trade's own, used instead of the win or loss colour. + final Color? color; + + /// Anything the app wants back when this trade is touched. + final Object? data; + + /// Whether the trade made money. + bool get isWinner => pnl > 0; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is TradeExcursion && + other.pnl == pnl && + other.mae == mae && + other.mfe == mfe && + other.label == label && + other.color == color && + other.data == data; + + @override + int get hashCode => Object.hashAll([pnl, mae, mfe, label, color, data]); +} + +/// Which excursion a [MaeMfeChart] puts along its bottom axis. +enum ExcursionKind { + /// How far each trade went against the position. A winner that went far + /// against you was survived by a stop that was too wide to be sensible, and a + /// stop placed where few winners reached would have cost little. + adverse, + + /// How far each trade went in the position's favour. A target placed where + /// the winners topped out would have caught most of what they had to give. + favourable, +} + +/// What a set of trades' excursions add up to. +@immutable +class ExcursionStats { + /// Creates statistics from their parts; see [ExcursionStats.fromTrades]. + const ExcursionStats({ + required this.count, + required this.winners, + required this.losers, + required this.averageAdverse, + required this.averageFavourable, + }); + + /// Works the statistics out from [trades]. + /// + /// A trade with a number that is not finite is skipped. A trade of exactly + /// zero counts as neither a winner nor a loser. + factory ExcursionStats.fromTrades(Iterable trades) { + var count = 0; + var winners = 0; + var losers = 0; + var adverse = 0.0; + var favourable = 0.0; + for (final trade in trades) { + if (!trade.pnl.isFinite || !trade.mae.isFinite || !trade.mfe.isFinite) { + continue; + } + count++; + if (trade.pnl > 0) winners++; + if (trade.pnl < 0) losers++; + adverse += trade.mae.abs(); + favourable += trade.mfe.abs(); + } + return ExcursionStats( + count: count, + winners: winners, + losers: losers, + averageAdverse: count == 0 ? 0 : adverse / count, + averageFavourable: count == 0 ? 0 : favourable / count, + ); + } + + /// How many trades there were. + final int count; + + /// How many made money. + final int winners; + + /// How many lost it. + final int losers; + + /// The average distance the trades went against the position. + final double averageAdverse; + + /// The average distance they went in its favour. + final double averageFavourable; + + /// What the trades had to give, against what they cost to hold: the average + /// favourable excursion over the average adverse one. Above 1 the market + /// moved more for the positions than against them; infinite when none ever + /// went against. + double get edgeRatio { + if (averageAdverse <= 0) { + return averageFavourable > 0 ? double.infinity : 0; + } + return averageFavourable / averageAdverse; + } +} + +/// How many of the winners among [trades] a stop [stop] away from the entry +/// would have taken out before they won — the cost of that stop. +/// +/// A trade is stopped when it went at least [stop] against the position. +int winnersStoppedAt(Iterable trades, double stop) { + var stopped = 0; + for (final trade in trades) { + if (trade.isWinner && trade.mae.isFinite && trade.mae >= stop) stopped++; + } + return stopped; +} + +/// The ranges [trades] need on the two axes of a chart of [kind]. +/// +/// The bottom axis runs from zero to the longest excursion; the side axis holds +/// every result and zero, with room either side. +({double minX, double maxX, double minY, double maxY}) excursionRange( + List trades, + ExcursionKind kind, +) { + var longest = 0.0; + final results = []; + for (final trade in trades) { + final reach = kind == ExcursionKind.adverse ? trade.mae : trade.mfe; + if (!reach.isFinite || !trade.pnl.isFinite) continue; + longest = math.max(longest, reach.abs()); + results.add(trade.pnl); + } + final y = paddedRange(results, includeZero: true); + return ( + minX: 0, + maxX: longest <= 0 ? 1 : longest * 1.08, + minY: y.min, + maxY: y.max, + ); +} + +/// Where one [TradeExcursion] was laid out. +@immutable +class ExcursionDot { + /// Creates the dot of [trade]. + const ExcursionDot({ + required this.trade, + required this.index, + required this.center, + }); + + /// The trade this dot draws. + final TradeExcursion trade; + + /// Its position in the list it was given in. + final int index; + + /// Where it sits, in the chart's local pixels. + final Offset center; +} + +/// Places [trades] inside [bounds] against the ranges given. +/// +/// A trade whose numbers are not finite is left out, so the returned dots may +/// be fewer than the trades; each keeps its [ExcursionDot.index]. +List layOutExcursions( + List trades, + Rect bounds, { + required ExcursionKind kind, + required double minX, + required double maxX, + required double minY, + required double maxY, +}) { + if (trades.isEmpty || bounds.width <= 0 || bounds.height <= 0) { + return const []; + } + return [ + for (var i = 0; i < trades.length; i++) + if (trades[i].pnl.isFinite && + (kind == ExcursionKind.adverse ? trades[i].mae : trades[i].mfe) + .isFinite) + ExcursionDot( + trade: trades[i], + index: i, + center: Offset( + mapAcross( + (kind == ExcursionKind.adverse ? trades[i].mae : trades[i].mfe) + .abs(), + minX, + maxX, + bounds.left, + bounds.right, + ), + mapAcross(trades[i].pnl, minY, maxY, bounds.bottom, bounds.top), + ), + ), + ]; +} + +/// The dot nearest [local] within [reach] pixels, or null when none is. +ExcursionDot? excursionDotAt( + List dots, + Offset local, { + double reach = 10, +}) { + ExcursionDot? best; + var closest = reach; + for (final dot in dots) { + final distance = (dot.center - local).distance; + if (distance > closest) continue; + closest = distance; + best = dot; + } + return best; +} + +/// What a touch on a [MaeMfeChart] landed on. +@immutable +class ExcursionTouchDetails { + /// Creates the details of a touch on [dot]. + const ExcursionTouchDetails({required this.dot}); + + /// The dot touched. + final ExcursionDot dot; + + /// The trade it draws. + TradeExcursion get trade => dot.trade; +} + +/// Every trade as a point: how far it went against you, or for you, and what it +/// finally made — a MAE or MFE scatter. +/// +/// ```dart +/// MaeMfeChart( +/// trades: [ +/// for (final t in backtest.trades) +/// TradeExcursion(pnl: t.r, mae: t.maxAdverseR, mfe: t.maxFavourableR), +/// ], +/// xReferenceLines: const [1], // the stop used +/// ); +/// ``` +/// +/// Winners and losers take their own colours. A line marks the limit no trade +/// can pass — a result never beats its favourable excursion, nor loses more +/// than its adverse one. The chart fills the box it is given, and is +/// [defaultHeight] high in a box with no height of its own. +class MaeMfeChart extends StatefulWidget { + /// Creates a scatter of [trades]. + const MaeMfeChart({ + super.key, + required this.trades, + this.kind = ExcursionKind.adverse, + this.minX, + this.maxX, + this.minY, + this.maxY, + this.winnerColor = const Color(0xFF2F9E44), + this.loserColor = const Color(0xFFE03131), + this.dotRadius = 4, + this.dotOpacity = 0.75, + this.showLimit = true, + this.limitColor = const Color(0x55FFFFFF), + this.showZeroLine = true, + this.showAxes = true, + this.axisWidth = 44, + this.axisHeight = 18, + this.tickCount = 5, + this.xFormatter, + this.yFormatter, + this.axisLabelStyle, + this.xAxisTitle, + this.yAxisTitle, + this.axisTitleStyle, + this.gridColor = const Color(0x22FFFFFF), + this.xReferenceLines = const [], + this.referenceColor = const Color(0x99FFFFFF), + this.hoverBorder = const BorderSide(color: Color(0xCCFFFFFF), width: 1.5), + this.onTouch, + this.tooltipBuilder, + this.tooltipMargin = 10, + this.animationDuration = Duration.zero, + this.animationCurve = Curves.easeOutCubic, + this.animateOnMount = true, + this.padding = EdgeInsets.zero, + this.backgroundColor, + this.defaultHeight = 260, + this.semanticLabel, + }); + + /// The trades, one dot each. + final List trades; + + /// Which excursion runs along the bottom axis. + final ExcursionKind kind; + + /// The left of the bottom axis; null starts it at zero. + final double? minX; + + /// The right of the bottom axis; null reaches the longest excursion. + final double? maxX; + + /// The bottom of the side axis; null reads it off the results. + final double? minY; + + /// The top of the side axis; null reads it off the results. + final double? maxY; + + /// The colour of a trade that made money. + final Color winnerColor; + + /// The colour of a trade that lost it. + final Color loserColor; + + /// The radius of a dot. + final double dotRadius; + + /// How opaque a dot is, so a crowd of them shows where it is densest. + final double dotOpacity; + + /// Whether the line no trade can cross is drawn. + final bool showLimit; + + /// The colour of that line. + final Color limitColor; + + /// Whether a line marks a result of zero. + final bool showZeroLine; + + /// Whether the two axes are drawn. + final bool showAxes; + + /// How much room the side axis takes. + final double axisWidth; + + /// How much room the bottom axis takes. + final double axisHeight; + + /// About how many ticks to write on each axis. + final int tickCount; + + /// Writes a value on the bottom axis; null writes at most two decimals. + final String Function(double value)? xFormatter; + + /// Writes a value on the side axis; null writes at most two decimals. + final String Function(double value)? yFormatter; + + /// Style of an axis label. + final TextStyle? axisLabelStyle; + + /// Written under the bottom axis; null names the excursion. + final String? xAxisTitle; + + /// Written up the side axis; null writes nothing. + final String? yAxisTitle; + + /// Style of an axis title. + final TextStyle? axisTitleStyle; + + /// Colour of the grid ruled at each tick; null rules none. + final Color? gridColor; + + /// Excursions marked with a vertical line — the stop in use, a target. + final List xReferenceLines; + + /// The colour of those lines. + final Color referenceColor; + + /// Drawn round the dot under the pointer; null marks none. + final BorderSide? hoverBorder; + + /// Called as a touch moves over the dots, and with null when it leaves. + final ValueChanged? onTouch; + + /// Builds a card shown beside the touched dot; null shows none. + final Widget? Function(BuildContext context, ExcursionTouchDetails details)? + tooltipBuilder; + + /// How far the card sits from the dot. + final double tooltipMargin; + + /// How long the dots take to grow in; zero draws them at once. + final Duration animationDuration; + + /// The easing of [animationDuration]. + final Curve animationCurve; + + /// Whether the first build grows in. + final bool animateOnMount; + + /// Space kept clear around the chart. + final EdgeInsets padding; + + /// Painted behind the whole chart. + final Color? backgroundColor; + + /// The height taken in a box that sets none. + final double defaultHeight; + + /// What a screen reader announces for the chart. + final String? semanticLabel; + + @override + State createState() => _MaeMfeChartState(); +} + +class _MaeMfeChartState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _animation; + final TextPainterCache _text = TextPainterCache(capacity: 64); + List _dots = const []; + int? _touched; + + @override + void initState() { + super.initState(); + _animation = AnimationController( + vsync: this, + duration: widget.animationDuration, + value: 1, + )..addListener(() => setState(() {})); + if (widget.animateOnMount && widget.animationDuration > Duration.zero) { + _animation.forward(from: 0); + } + } + + @override + void didUpdateWidget(MaeMfeChart oldWidget) { + super.didUpdateWidget(oldWidget); + _animation.duration = widget.animationDuration; + if (!listEquals(oldWidget.trades, widget.trades)) { + if (_touched != null && _touched! >= widget.trades.length) { + _touched = null; + } + if (widget.animationDuration > Duration.zero) { + _animation.forward(from: 0); + } + } + } + + @override + void dispose() { + _text.dispose(); + _animation.dispose(); + super.dispose(); + } + + void _handle(Offset local) { + final index = excursionDotAt( + _dots, + local, + reach: widget.dotRadius + 6, + )?.index; + if (index == _touched) return; + setState(() => _touched = index); + widget.onTouch?.call( + index == null + ? null + : ExcursionTouchDetails( + dot: _dots.firstWhere((d) => d.index == index), + ), + ); + } + + void _leave() { + if (_touched == null) return; + setState(() => _touched = null); + widget.onTouch?.call(null); + } + + @override + Widget build(BuildContext context) { + final t = widget.animationCurve.transform(_animation.value); + final range = excursionRange(widget.trades, widget.kind); + final minX = widget.minX ?? range.minX; + final maxX = widget.maxX ?? range.maxX; + final minY = widget.minY ?? range.minY; + final maxY = widget.maxY ?? range.maxY; + + Widget chart = LayoutBuilder( + builder: (context, constraints) { + final width = constraints.hasBoundedWidth + ? constraints.maxWidth + : MediaQuery.maybeSizeOf(context)?.width ?? 300; + final height = constraints.hasBoundedHeight + ? constraints.maxHeight + : widget.defaultHeight; + final size = Size(width, height); + final box = widget.padding.deflateRect(Offset.zero & size); + final plot = Rect.fromLTRB( + box.left + (widget.showAxes ? widget.axisWidth : 0), + box.top, + box.right, + math.max( + box.top, + box.bottom - (widget.showAxes ? widget.axisHeight + 14 : 0), + ), + ); + _dots = layOutExcursions( + widget.trades, + plot, + kind: widget.kind, + minX: minX, + maxX: maxX, + minY: minY, + maxY: maxY, + ); + + final touched = _touched; + final dot = touched == null + ? null + : _dots.where((d) => d.index == touched).firstOrNull; + final details = dot == null ? null : ExcursionTouchDetails(dot: dot); + final builder = widget.tooltipBuilder; + final tooltip = details == null || builder == null + ? null + : builder(context, details); + + return SizedBox( + width: width, + height: height, + child: MouseRegion( + onHover: (e) => _handle(e.localPosition), + onExit: (_) => _leave(), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: (d) => _handle(d.localPosition), + onTapUp: (_) => _leave(), + onTapCancel: _leave, + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned.fill( + child: CustomPaint( + painter: MaeMfeChartPainter( + chart: widget, + dots: _dots, + plot: plot, + minX: minX, + maxX: maxX, + minY: minY, + maxY: maxY, + touched: touched, + animation: t, + textCache: _text, + ), + ), + ), + if (tooltip != null && dot != null) + Positioned.fill( + child: IgnorePointer( + child: CustomSingleChildLayout( + delegate: XyTooltipLayout( + anchor: Rect.fromCircle( + center: dot.center, + radius: widget.dotRadius, + ), + margin: widget.tooltipMargin, + ), + child: tooltip, + ), + ), + ), + ], + ), + ), + ), + ); + }, + ); + + final label = widget.semanticLabel; + if (label != null) { + chart = Semantics(container: true, label: label, child: chart); + } + return chart; + } +} + +/// Paints a [MaeMfeChart]: the grid, the axes, the limit line and the dots. +class MaeMfeChartPainter extends CustomPainter { + /// Creates the painter for [chart], laid out as [dots] inside [plot]. + MaeMfeChartPainter({ + required this.chart, + required this.dots, + required this.plot, + required this.minX, + required this.maxX, + required this.minY, + required this.maxY, + required this.touched, + required this.animation, + required this.textCache, + }); + + /// The chart being painted. + final MaeMfeChart chart; + + /// Where each trade sits. + final List dots; + + /// The area the dots are drawn in. + final Rect plot; + + /// The ranges of the two axes. + final double minX, maxX, minY, maxY; + + /// The index of the trade under the pointer. + final int? touched; + + /// How far the entry animation has got, from 0 to 1. + final double animation; + + /// Where the labels are laid out. + final TextPainterCache textCache; + + @override + void paint(Canvas canvas, Size size) { + final background = chart.backgroundColor; + if (background != null) { + canvas.drawRect(Offset.zero & size, Paint()..color = background); + } + if (dots.isEmpty) return; + + paintXyAxes( + canvas, + textCache, + plot: plot, + minX: minX, + maxX: maxX, + minY: minY, + maxY: maxY, + tickCount: chart.tickCount, + gridColor: chart.gridColor, + showAxes: chart.showAxes, + xFormatter: chart.xFormatter, + yFormatter: chart.yFormatter, + labelStyle: chart.axisLabelStyle, + xTitle: + chart.xAxisTitle ?? + (chart.kind == ExcursionKind.adverse + ? 'Max adverse excursion' + : 'Max favourable excursion'), + yTitle: chart.yAxisTitle, + titleStyle: chart.axisTitleStyle, + axisWidth: chart.axisWidth, + axisHeight: chart.axisHeight, + ); + + canvas + ..save() + ..clipRect(plot); + _paintLines(canvas); + canvas.restore(); + + final t = animation.clamp(0.0, 1.0); + final radius = chart.dotRadius * t; + final alpha = chart.dotOpacity.clamp(0.0, 1.0); + for (final dot in dots) { + if (!dot.center.dx.isFinite || !dot.center.dy.isFinite) continue; + final color = + dot.trade.color ?? + (dot.trade.isWinner ? chart.winnerColor : chart.loserColor); + canvas.drawCircle( + dot.center, + radius, + Paint() + ..color = color.withValues(alpha: alpha) + ..isAntiAlias = true, + ); + } + + final hover = chart.hoverBorder; + final at = touched; + if (at != null && hover != null && hover.width > 0) { + for (final dot in dots) { + if (dot.index != at) continue; + canvas.drawCircle( + dot.center, + radius + hover.width + 1, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = hover.width + ..color = hover.color + ..isAntiAlias = true, + ); + } + } + } + + void _paintLines(Canvas canvas) { + if (chart.showZeroLine && minY < 0 && maxY > 0) { + final y = mapAcross(0, minY, maxY, plot.bottom, plot.top); + canvas.drawLine( + Offset(plot.left, y), + Offset(plot.right, y), + Paint() + ..color = const Color(0x66FFFFFF) + ..strokeWidth = 1, + ); + } + + if (chart.showLimit) { + // No result beats its favourable excursion, and none loses more than its + // adverse one: the dots all sit on one side of this line. + final sign = chart.kind == ExcursionKind.adverse ? -1.0 : 1.0; + canvas.drawLine( + Offset( + plot.left, + mapAcross(sign * minX, minY, maxY, plot.bottom, plot.top), + ), + Offset( + plot.right, + mapAcross(sign * maxX, minY, maxY, plot.bottom, plot.top), + ), + Paint() + ..color = chart.limitColor + ..strokeWidth = 1, + ); + } + + final pen = Paint() + ..color = chart.referenceColor + ..strokeWidth = 1; + for (final value in chart.xReferenceLines) { + if (!value.isFinite || value < minX || value > maxX) continue; + final x = mapAcross(value, minX, maxX, plot.left, plot.right); + canvas.drawLine(Offset(x, plot.top), Offset(x, plot.bottom), pen); + } + } + + @override + bool shouldRepaint(MaeMfeChartPainter oldDelegate) => + !identical(oldDelegate.chart, chart) || + !identical(oldDelegate.dots, dots) || + oldDelegate.plot != plot || + oldDelegate.minX != minX || + oldDelegate.maxX != maxX || + oldDelegate.minY != minY || + oldDelegate.maxY != maxY || + oldDelegate.touched != touched || + oldDelegate.animation != animation; +} diff --git a/lib/src/distribution/r_multiple_chart.dart b/lib/src/distribution/r_multiple_chart.dart index 33b137a..7f0c490 100644 --- a/lib/src/distribution/r_multiple_chart.dart +++ b/lib/src/distribution/r_multiple_chart.dart @@ -364,6 +364,9 @@ class RMultipleChart extends StatelessWidget { final height = constraints.hasBoundedHeight ? constraints.maxHeight : defaultHeight; + // A box too short to hold the figures and a histogram under them shows + // only the histogram, rather than overflowing. + final room = height - padding.vertical; return Container( height: height, color: backgroundColor, @@ -371,22 +374,29 @@ class RMultipleChart extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - if (showStats) ...[ - _StatsRow( - stats: - (statsBuilder ?? - (s) => defaultRMultipleStats( - s, - profitColor: profitColor, - lossColor: lossColor, - ))(stats), - labelStyle: seriesAxisLabelStyle.merge(statLabelStyle), - valueStyle: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: Color(0xFFE9ECEF), - ).merge(statValueStyle), - spacing: statSpacing, + if (showStats && room >= 120) ...[ + ConstrainedBox( + // Never more than part of the box, so a wrapped row cannot starve + // the histogram; what does not fit is clipped. + constraints: BoxConstraints(maxHeight: room * 0.4), + child: ClipRect( + child: _StatsRow( + stats: + (statsBuilder ?? + (s) => defaultRMultipleStats( + s, + profitColor: profitColor, + lossColor: lossColor, + ))(stats), + labelStyle: seriesAxisLabelStyle.merge(statLabelStyle), + valueStyle: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Color(0xFFE9ECEF), + ).merge(statValueStyle), + spacing: statSpacing, + ), + ), ), SizedBox(height: statsGap), ], diff --git a/lib/src/distribution/var_chart.dart b/lib/src/distribution/var_chart.dart new file mode 100644 index 0000000..ba8c3f0 --- /dev/null +++ b/lib/src/distribution/var_chart.dart @@ -0,0 +1,430 @@ +import 'dart:math' as math; + +import 'package:flutter/widgets.dart'; + +import '../series/series_axis.dart'; +import 'histogram_chart.dart'; +import 'r_multiple_chart.dart' show RMultipleStat; + +/// How much a set of returns can be expected to lose: value at risk and +/// expected shortfall, worked out from the history itself. +@immutable +class ValueAtRisk { + /// Creates a result from its parts; see [ValueAtRisk.fromReturns]. + const ValueAtRisk({ + required this.confidence, + required this.valueAtRisk, + required this.expectedShortfall, + required this.mean, + required this.standardDeviation, + required this.worst, + required this.count, + }); + + /// Works the figures out from [returns], by the historical method. + /// + /// [confidence] is the share of outcomes the figure should not be worse than: + /// at 0.95, [valueAtRisk] is the return that only one in twenty was worse + /// than. Returns that are not finite are skipped. With nothing to measure + /// every figure is zero. + factory ValueAtRisk.fromReturns( + Iterable returns, { + double confidence = 0.95, + }) { + final sorted = [ + for (final value in returns) + if (value.isFinite) value, + ]..sort(); + final level = confidence.isFinite ? confidence.clamp(0.0, 1.0) : 0.95; + if (sorted.isEmpty) { + return ValueAtRisk( + confidence: level, + valueAtRisk: 0, + expectedShortfall: 0, + mean: 0, + standardDeviation: 0, + worst: 0, + count: 0, + ); + } + + // The return a (1 - confidence) share of outcomes fall at or below, read + // off the sorted history by linear interpolation. + final at = (1 - level) * (sorted.length - 1); + final low = at.floor(); + final high = at.ceil(); + final threshold = low == high + ? sorted[low] + : sorted[low] + (sorted[high] - sorted[low]) * (at - low); + + var tailSum = 0.0; + var tailCount = 0; + var sum = 0.0; + for (final value in sorted) { + sum += value; + if (value <= threshold) { + tailSum += value; + tailCount++; + } + } + final mean = sum / sorted.length; + var squares = 0.0; + for (final value in sorted) { + squares += (value - mean) * (value - mean); + } + final deviation = sorted.length > 1 + ? math.sqrt(squares / (sorted.length - 1)) + : 0.0; + + return ValueAtRisk( + confidence: level, + valueAtRisk: threshold, + // The tail always holds at least the worst outcome. + expectedShortfall: tailCount == 0 ? sorted.first : tailSum / tailCount, + mean: mean, + standardDeviation: deviation, + worst: sorted.first, + count: sorted.length, + ); + } + + /// The share of outcomes [valueAtRisk] is not worse than. + final double confidence; + + /// The return that only `1 - confidence` of outcomes were worse than. Zero or + /// less for a history with any losses. + final double valueAtRisk; + + /// The average of the outcomes at or beyond [valueAtRisk]: what a bad day + /// costs once it has gone past the threshold. Also called CVaR. + final double expectedShortfall; + + /// The average return. + final double mean; + + /// How widely the returns spread around [mean]. + final double standardDeviation; + + /// The single worst return. + final double worst; + + /// How many returns there were. + final int count; +} + +/// Counts [returns] into bins, those in the loss tail — at or beyond +/// [valueAtRisk] — carrying [tailColor] so a histogram shows where the risk is. +/// +/// A bin counts as tail when its middle is at or beyond the threshold. +List varBins( + Iterable returns, { + required double valueAtRisk, + required Color tailColor, + int? binCount, + double? binWidth, +}) { + final bins = histogramBins(returns, binCount: binCount, binWidth: binWidth); + return [ + for (final bin in bins) + bin.center <= valueAtRisk + ? HistogramBin( + from: bin.from, + to: bin.to, + count: bin.count, + color: tailColor, + ) + : bin, + ]; +} + +/// The shape of a set of returns, with the loss tail picked out and the value +/// at risk and expected shortfall marked. +/// +/// ```dart +/// VarChart(returns: dailyReturns, confidence: 0.99); +/// ``` +/// +/// A row of figures sits over a histogram. The bars in the loss tail take +/// [tailColor], and lines mark the value at risk and the expected shortfall. +/// Returns are fractions: -0.03 is a 3% loss. The chart fills the box it is +/// given, and is [defaultHeight] high in a box with no height of its own. +class VarChart extends StatelessWidget { + /// Creates a chart of [returns]. + const VarChart({ + super.key, + required this.returns, + this.confidence = 0.95, + this.binCount, + this.binWidth, + this.barColor = const Color(0xFF4C86CD), + this.tailColor = const Color(0xFFE03131), + this.showStats = true, + this.statsBuilder, + this.statLabelStyle, + this.statValueStyle, + this.statSpacing = 16, + this.statsGap = 10, + this.showVarLine = true, + this.showShortfallLine = true, + this.showMeanLine = false, + this.referenceColor = const Color(0x99FFFFFF), + this.barSpacing = 1, + this.barRadius = const BorderRadius.vertical(top: Radius.circular(1)), + this.tickCount = 5, + this.valueFormatter, + this.axisLabelStyle, + this.gridColor = const Color(0x22FFFFFF), + this.onTouch, + this.tooltipBuilder, + this.animationDuration = Duration.zero, + this.animationCurve = Curves.easeOutCubic, + this.animateOnMount = true, + this.padding = EdgeInsets.zero, + this.backgroundColor, + this.defaultHeight = 260, + this.semanticLabel, + }); + + /// The returns, as fractions: -0.03 is a 3% loss. + final List returns; + + /// The share of outcomes the value at risk is not worse than. + final double confidence; + + /// How many bins; null takes the square root of the number of returns. + final int? binCount; + + /// How wide each bin is; wins over [binCount]. + final double? binWidth; + + /// The colour of the bars outside the tail. + final Color barColor; + + /// The colour of the bars in the loss tail. + final Color tailColor; + + /// Whether the row of figures is shown. + final bool showStats; + + /// Builds the figures; null shows the defaults. + final List Function(ValueAtRisk risk)? statsBuilder; + + /// Style of a figure's name. + final TextStyle? statLabelStyle; + + /// Style of a figure's value. + final TextStyle? statValueStyle; + + /// The space between figures. + final double statSpacing; + + /// The space between the figures and the histogram. + final double statsGap; + + /// Whether a line marks the value at risk. + final bool showVarLine; + + /// Whether a line marks the expected shortfall. + final bool showShortfallLine; + + /// Whether a line marks the mean. + final bool showMeanLine; + + /// The colour of those lines. + final Color referenceColor; + + /// How many pixels are taken off each side of a bar. + final double barSpacing; + + /// The rounding of each bar's corners. + final BorderRadius barRadius; + + /// About how many ticks to write on each axis. + final int tickCount; + + /// Writes a return on the bottom axis; null writes a percentage. + final String Function(double value)? valueFormatter; + + /// Style of an axis label. + final TextStyle? axisLabelStyle; + + /// Colour of the grid ruled at each tick; null rules none. + final Color? gridColor; + + /// Called as a touch moves over the bars, and with null when it leaves. + final ValueChanged? onTouch; + + /// Builds a card shown beside the touched bar; null shows none. + final Widget? Function(BuildContext context, HistogramTouchDetails details)? + tooltipBuilder; + + /// How long the bars take to grow in; zero draws them at once. + final Duration animationDuration; + + /// The easing of [animationDuration]. + final Curve animationCurve; + + /// Whether the first build grows in. + final bool animateOnMount; + + /// Space kept clear around the chart. + final EdgeInsets padding; + + /// Painted behind the whole chart. + final Color? backgroundColor; + + /// The height taken in a box that sets none. + final double defaultHeight; + + /// What a screen reader announces for the chart. + final String? semanticLabel; + + @override + Widget build(BuildContext context) { + final risk = ValueAtRisk.fromReturns(returns, confidence: confidence); + final bins = varBins( + returns, + valueAtRisk: risk.valueAtRisk, + tailColor: tailColor, + binCount: binCount, + binWidth: binWidth, + ); + final format = valueFormatter ?? _percent; + + final histogram = HistogramChart( + bins: bins, + barColor: barColor, + barSpacing: barSpacing, + barRadius: barRadius, + tickCount: tickCount, + valueFormatter: format, + axisLabelStyle: axisLabelStyle, + gridColor: gridColor, + referenceLines: [ + if (risk.count > 0) ...[ + if (showVarLine) risk.valueAtRisk, + if (showShortfallLine) risk.expectedShortfall, + if (showMeanLine) risk.mean, + ], + ], + referenceColor: referenceColor, + onTouch: onTouch, + tooltipBuilder: tooltipBuilder, + animationDuration: animationDuration, + animationCurve: animationCurve, + animateOnMount: animateOnMount, + ); + + final figures = (statsBuilder ?? (r) => defaultVarStats(r, format))(risk); + + Widget chart = LayoutBuilder( + builder: (context, constraints) { + final height = constraints.hasBoundedHeight + ? constraints.maxHeight + : defaultHeight; + // A box too short for the figures and a histogram under them shows + // only the histogram, rather than overflowing. + final room = height - padding.vertical; + return Container( + height: height, + color: backgroundColor, + padding: padding, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (showStats && room >= 120) ...[ + ConstrainedBox( + // Never more than part of the box, so a wrapped row cannot starve + // the histogram; what does not fit is clipped. + constraints: BoxConstraints(maxHeight: room * 0.4), + child: ClipRect( + child: _FiguresRow( + figures: figures, + labelStyle: seriesAxisLabelStyle.merge(statLabelStyle), + valueStyle: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Color(0xFFE9ECEF), + ).merge(statValueStyle), + spacing: statSpacing, + ), + ), + ), + SizedBox(height: statsGap), + ], + Expanded(child: histogram), + ], + ), + ); + }, + ); + + final label = semanticLabel; + if (label != null) { + chart = Semantics(container: true, label: label, child: chart); + } + return chart; + } + + static String _percent(double value) { + final percent = value * 100; + final text = percent == percent.roundToDouble() + ? percent.toStringAsFixed(0) + : percent.toStringAsFixed(1); + return '$text%'; + } +} + +/// The figures a [VarChart] shows by default. +List defaultVarStats( + ValueAtRisk risk, + String Function(double value) format, +) { + final level = '${(risk.confidence * 100).toStringAsFixed(0)}%'; + return [ + RMultipleStat('Returns', '${risk.count}'), + RMultipleStat('VaR $level', format(risk.valueAtRisk)), + RMultipleStat('CVaR $level', format(risk.expectedShortfall)), + RMultipleStat('Mean', format(risk.mean)), + RMultipleStat('Std dev', format(risk.standardDeviation)), + RMultipleStat('Worst', format(risk.worst)), + ]; +} + +class _FiguresRow extends StatelessWidget { + const _FiguresRow({ + required this.figures, + required this.labelStyle, + required this.valueStyle, + required this.spacing, + }); + + final List figures; + final TextStyle labelStyle; + final TextStyle valueStyle; + final double spacing; + + @override + Widget build(BuildContext context) { + return Wrap( + spacing: spacing, + runSpacing: 6, + children: [ + for (final figure in figures) + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(figure.label, style: labelStyle), + Text( + figure.value, + style: figure.color == null + ? valueStyle + : valueStyle.copyWith(color: figure.color), + ), + ], + ), + ], + ); + } +} diff --git a/lib/src/dumbbell/dumbbell_chart.dart b/lib/src/dumbbell/dumbbell_chart.dart index 230f6c5..357409b 100644 --- a/lib/src/dumbbell/dumbbell_chart.dart +++ b/lib/src/dumbbell/dumbbell_chart.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart' show listEquals; import 'dart:math' as math; import 'package:flutter/widgets.dart'; @@ -45,6 +46,22 @@ class DumbbellRow { /// Whether the row ended higher than it started. bool get rose => to >= from; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is DumbbellRow && + other.label == label && + other.from == from && + other.to == to && + other.fromColor == fromColor && + other.toColor == toColor && + other.barColor == barColor && + other.tooltip == tooltip; + + @override + int get hashCode => + Object.hashAll([label, from, to, fromColor, toColor, barColor, tooltip]); } /// Where one row of a [DumbbellChart] sits. @@ -417,7 +434,7 @@ class _DumbbellChartState extends State void didUpdateWidget(DumbbellChart oldWidget) { super.didUpdateWidget(oldWidget); _animation.duration = widget.animationDuration; - if (!identical(oldWidget.rows, widget.rows) && + if (!listEquals(oldWidget.rows, widget.rows) && widget.animationDuration > Duration.zero) { _animation.forward(from: 0); } diff --git a/lib/src/equity/equity_curve_chart.dart b/lib/src/equity/equity_curve_chart.dart index a25ff70..e95cb82 100644 --- a/lib/src/equity/equity_curve_chart.dart +++ b/lib/src/equity/equity_curve_chart.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart' show listEquals; import 'dart:math' as math; import 'package:flutter/widgets.dart'; @@ -20,6 +21,17 @@ class EquityPoint { /// Anything the app wants back when this point is touched. final Object? data; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is EquityPoint && + other.time == time && + other.equity == equity && + other.data == data; + + @override + int get hashCode => Object.hashAll([time, equity, data]); } /// What an equity curve is worth and how far it fell. @@ -495,7 +507,7 @@ class _EquityCurveChartState extends State void didUpdateWidget(EquityCurveChart oldWidget) { super.didUpdateWidget(oldWidget); _animation.duration = widget.animationDuration; - if (!identical(oldWidget.points, widget.points)) { + if (!listEquals(oldWidget.points, widget.points)) { _touched = null; if (widget.animationDuration > Duration.zero) { _animation.forward(from: 0); @@ -899,10 +911,17 @@ class EquityCurveChartPainter extends CustomPainter { final time = _timeAt(index); if (time == null) continue; final tp = textCache.get(_formatTime(time), style); - final left = (points[index].dx - tp.width / 2).clamp( - layout.equityRect.left, - layout.equityRect.right - tp.width, - ); + // A label wider than the plot is pinned to its left edge, not clamped + // into a range that has turned inside out. + final left = (points[index].dx - tp.width / 2) + .clamp( + layout.equityRect.left, + math.max( + layout.equityRect.left, + layout.equityRect.right - tp.width, + ), + ) + .toDouble(); if (left < written) continue; tp.paint(canvas, Offset(left, layout.drawdownRect.bottom + 3)); written = left + tp.width + 6; diff --git a/lib/src/equity/monte_carlo_chart.dart b/lib/src/equity/monte_carlo_chart.dart index 216fad2..a398439 100644 --- a/lib/src/equity/monte_carlo_chart.dart +++ b/lib/src/equity/monte_carlo_chart.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart' show listEquals; import 'dart:math' as math; import 'package:flutter/widgets.dart'; @@ -99,6 +100,31 @@ class MonteCarloResult { /// The account value counted as ruin; null counts none. final double? ruinLevel; + /// Two results are alike when they hold the same paths. The bands and the + /// drawdowns are worked out from those, so they are not compared again. + /// + /// This is what lets a parent rebuild with a result it made again from the + /// same seed without the fan animating in afresh. + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other is! MonteCarloResult || + other.startingEquity != startingEquity || + other.ruinLevel != ruinLevel || + other.paths.length != paths.length || + !listEquals(other.percentiles, percentiles)) { + return false; + } + for (var i = 0; i < paths.length; i++) { + if (!listEquals(other.paths[i], paths[i])) return false; + } + return true; + } + + @override + int get hashCode => + Object.hash(startingEquity, ruinLevel, paths.length, steps); + /// Whether there is nothing to show. bool get isEmpty => bands.isEmpty || bands.first.isEmpty; @@ -606,7 +632,7 @@ class _MonteCarloChartState extends State void didUpdateWidget(MonteCarloChart oldWidget) { super.didUpdateWidget(oldWidget); _animation.duration = widget.animationDuration; - if (!identical(oldWidget.result, widget.result)) { + if (oldWidget.result != widget.result) { _touched = null; if (widget.animationDuration > Duration.zero) { _animation.forward(from: 0); diff --git a/lib/src/export.dart b/lib/src/export.dart index c190539..8fea280 100644 --- a/lib/src/export.dart +++ b/lib/src/export.dart @@ -75,7 +75,10 @@ export './utils/data_util.dart'; export './heatmap/heatmap_chart.dart'; export './heatmap/heatmap_data.dart'; export './heatmap/seasonality_chart.dart'; +export './heatmap/activity_clock_chart.dart'; export './bullet/bullet_chart.dart'; +export './progress/progress_bars.dart'; +export './progress/progress_card.dart'; export './dumbbell/dumbbell_chart.dart'; export './slope/slope_chart.dart'; export './sparkline/sparkline_grid.dart'; @@ -96,12 +99,15 @@ export './sunburst/sunburst_chart.dart'; export './distribution/box_plot_chart.dart'; export './distribution/histogram_chart.dart'; export './distribution/r_multiple_chart.dart'; +export './distribution/mae_mfe_chart.dart'; +export './distribution/var_chart.dart'; export './bubble/bubble_chart.dart'; export './calendar/calendar_chart.dart'; export './equity/equity_curve_chart.dart'; export './equity/monte_carlo_chart.dart'; export './options/option_payoff_chart.dart'; export './volatility/volatility_curve_chart.dart'; +export './volatility/volatility_surface_chart.dart'; export './profile/market_profile_chart.dart'; export './footprint/footprint_chart.dart'; export './book/book_heatmap_chart.dart'; @@ -129,3 +135,15 @@ export './series/series_data.dart' hide pointsOf; export './series/series_range_selector.dart'; export './series/series_scale.dart' show SeriesOrientation; export './series/series_touch.dart'; +export './portfolio/efficient_frontier_chart.dart'; +export './futures/futures_curve_chart.dart'; +export './ml/training_curve_chart.dart'; +export './ml/scaling_law_chart.dart'; +export './ml/sweep_chart.dart'; +export './ml/training_timeline_chart.dart'; +export './ml/benchmark_matrix_chart.dart'; +export './ml/model_pareto_chart.dart'; +export './ml/latency_band_chart.dart'; +export './ml/token_usage_chart.dart'; +export './ml/eval_breakdown_chart.dart'; +export './ml/token_heatmap.dart'; diff --git a/lib/src/extension/num_ext.dart b/lib/src/extension/num_ext.dart index f7ebc42..2498bec 100644 --- a/lib/src/extension/num_ext.dart +++ b/lib/src/extension/num_ext.dart @@ -6,3 +6,10 @@ extension NumExt on num? { return this!.abs().toStringAsFixed(4) != '0.0000'; } } + +extension SafeClamp on double { + /// [clamp], except that a range turned inside out — a pane squeezed below + /// its own padding, say — collapses to [lower] instead of throwing. + double clampWithin(double lower, double upper) => + clamp(lower, upper < lower ? lower : upper).toDouble(); +} diff --git a/lib/src/footprint/footprint_chart.dart b/lib/src/footprint/footprint_chart.dart index 0baa085..548fa75 100644 --- a/lib/src/footprint/footprint_chart.dart +++ b/lib/src/footprint/footprint_chart.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart' show listEquals; import 'dart:math' as math; import 'package:flutter/widgets.dart'; @@ -37,6 +38,18 @@ class FootprintLevel { /// How lopsided the level was, from -1 (all selling) to 1 (all buying). double get imbalance => total <= 0 ? 0 : delta / total; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is FootprintLevel && + other.price == price && + other.bidVolume == bidVolume && + other.askVolume == askVolume && + other.data == data; + + @override + int get hashCode => Object.hashAll([price, bidVolume, askVolume, data]); } /// One bar of a [FootprintChart]: a candle and what traded inside it. @@ -118,6 +131,29 @@ class FootprintBar { } return value; } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is FootprintBar && + other.time == time && + listEquals(other.levels, levels) && + other.open == open && + other.high == high && + other.low == low && + other.close == close && + other.data == data; + + @override + int get hashCode => Object.hashAll([ + time, + Object.hashAll(levels), + open, + high, + low, + close, + data, + ]); } /// The running total of [bars]' deltas — the cumulative delta. @@ -817,6 +853,12 @@ class FootprintChartPainter extends CustomPainter { final open = column.openY; final close = column.closeY; if (open == null || close == null) return; + if (!(open.isFinite && + close.isFinite && + column.highY.isFinite && + column.lowY.isFinite)) { + return; + } final x = column.rect.center.dx; final pen = Paint() ..color = chart.candleColor diff --git a/lib/src/funnel/funnel_chart.dart b/lib/src/funnel/funnel_chart.dart index 4aa7586..66bbf74 100644 --- a/lib/src/funnel/funnel_chart.dart +++ b/lib/src/funnel/funnel_chart.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart' show listEquals; import 'dart:math' as math; import 'package:flutter/widgets.dart'; @@ -23,6 +24,18 @@ class FunnelStage { /// Anything the app wants back when this stage is touched. final Object? data; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is FunnelStage && + other.value == value && + other.label == label && + other.color == color && + other.data == data; + + @override + int get hashCode => Object.hashAll([value, label, color, data]); } /// How a [FunnelChart]'s stages join. @@ -312,7 +325,7 @@ class _FunnelChartState extends State void didUpdateWidget(FunnelChart oldWidget) { super.didUpdateWidget(oldWidget); _animation.duration = widget.animationDuration; - if (!identical(oldWidget.stages, widget.stages)) { + if (!listEquals(oldWidget.stages, widget.stages)) { if (_touched != null && _touched! >= widget.stages.length) { _touched = null; } @@ -539,6 +552,7 @@ class FunnelChartPainter extends CustomPainter { String _formatValue(double value) { final format = chart.valueFormatter; if (format != null) return format(value); + if (!value.isFinite) return '–'; final whole = value.round(); final digits = whole.abs().toString(); final grouped = digits.replaceAllMapped( diff --git a/lib/src/futures/futures_curve_chart.dart b/lib/src/futures/futures_curve_chart.dart new file mode 100644 index 0000000..2e9b8ce --- /dev/null +++ b/lib/src/futures/futures_curve_chart.dart @@ -0,0 +1,809 @@ +import 'dart:math' as math; + +import 'package:flutter/foundation.dart' show listEquals; +import 'package:flutter/widgets.dart'; + +import '../renderer/text_painter_cache.dart'; +import '../treemap/treemap_data.dart' show treemapPalette; +import '../utils/xy_plot.dart'; + +/// One contract on a futures curve: its delivery month and its price. +@immutable +class FuturesContract { + /// Creates a contract called [label] priced at [price]. + const FuturesContract({required this.label, required this.price, this.data}); + + /// What the contract is called: its delivery month, `Mar 26`, or `Z5`. + final String label; + + /// Its price; a value that is not finite leaves a gap. + final double price; + + /// Anything the app wants back when this contract is touched. + final Object? data; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is FuturesContract && + other.label == label && + other.price == price && + other.data == data; + + @override + int get hashCode => Object.hash(label, price, data); +} + +/// A futures curve: the prices of consecutive contracts at one moment. +@immutable +class FuturesCurve { + /// Creates a curve called [label] through [contracts], nearest first. + const FuturesCurve({ + required this.label, + required this.contracts, + this.color, + this.dashed = false, + }); + + /// What the curve is called — `Today`, `A month ago`. + final String label; + + /// The contracts, nearest delivery first. + final List contracts; + + /// A colour of this curve's own; null takes one from the chart's palette. + final Color? color; + + /// Whether the line is dashed, to set an earlier curve apart. + final bool dashed; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is FuturesCurve && + other.label == label && + listEquals(other.contracts, contracts) && + other.color == color && + other.dashed == dashed; + + @override + int get hashCode => + Object.hash(label, Object.hashAll(contracts), color, dashed); +} + +/// The shape of a futures curve. +enum CurveShape { + /// Later contracts cost more: the curve slopes up. + contango, + + /// Later contracts cost less: the curve slopes down. + backwardation, + + /// Every contract is priced alike. + flat, + + /// The curve rises in places and falls in others. + mixed, + + /// Fewer than two prices to compare. + unknown, +} + +/// Reads the shape of [contracts]: whether each contract costs more, less or +/// the same as the one before it. +/// +/// A step smaller than [tolerance] of the price is read as flat, so noise in +/// the last decimal does not turn a contango into a mixed curve. Contracts +/// without a finite price are skipped. +CurveShape curveShape(List contracts, {double tolerance = 0}) { + final prices = [ + for (final c in contracts) + if (c.price.isFinite) c.price, + ]; + if (prices.length < 2) return CurveShape.unknown; + + var up = false; + var down = false; + for (var i = 1; i < prices.length; i++) { + final step = prices[i] - prices[i - 1]; + final scale = math.max(prices[i - 1].abs(), prices[i].abs()); + if (step.abs() <= scale * tolerance) continue; + if (step > 0) up = true; + if (step < 0) down = true; + } + if (up && down) return CurveShape.mixed; + if (up) return CurveShape.contango; + if (down) return CurveShape.backwardation; + return CurveShape.flat; +} + +/// How much the last priced contract costs over the first, as a fraction; +/// null with fewer than two prices or a first price of zero. +double? curveSlope(List contracts) { + final prices = [ + for (final c in contracts) + if (c.price.isFinite) c.price, + ]; + if (prices.length < 2 || prices.first == 0) return null; + return prices.last / prices.first - 1; +} + +/// Each contract's price less the one before it — the calendar spread — with +/// null for the first contract and wherever either price is missing. +List calendarSpreads(List contracts) => [ + for (var i = 0; i < contracts.length; i++) + i == 0 || !contracts[i].price.isFinite || !contracts[i - 1].price.isFinite + ? null + : contracts[i].price - contracts[i - 1].price, +]; + +/// Where one contract of one curve was laid out. +@immutable +class FuturesDot { + /// Creates the dot of a contract. + const FuturesDot({ + required this.curveIndex, + required this.contractIndex, + required this.curve, + required this.contract, + required this.center, + }); + + /// Which curve it is on. + final int curveIndex; + + /// Which contract of that curve. + final int contractIndex; + + /// The curve it is on. + final FuturesCurve curve; + + /// The contract it draws. + final FuturesContract contract; + + /// Where it sits, in the chart's local pixels. + final Offset center; +} + +/// The price range [curves] need, with room either side. +({double min, double max}) futuresPriceRange(List curves) => + paddedRange([ + for (final curve in curves) + for (final contract in curve.contracts) contract.price, + ], pad: 0.12); + +/// How many contract slots the longest of [curves] needs across the chart. +int futuresSlots(List curves) { + var slots = 0; + for (final curve in curves) { + slots = math.max(slots, curve.contracts.length); + } + return slots; +} + +/// Places every contract of [curves] inside [bounds]. +/// +/// Contracts are spread evenly, one slot each, so the same contract sits at the +/// same place on every curve. A contract without a finite price has no dot. +List layOutFutures( + List curves, + Rect bounds, { + required double minPrice, + required double maxPrice, +}) { + final slots = futuresSlots(curves); + if (slots == 0 || bounds.width <= 0 || bounds.height <= 0) return const []; + return [ + for (var c = 0; c < curves.length; c++) + for (var i = 0; i < curves[c].contracts.length; i++) + if (curves[c].contracts[i].price.isFinite) + FuturesDot( + curveIndex: c, + contractIndex: i, + curve: curves[c], + contract: curves[c].contracts[i], + center: Offset( + mapAcross( + i.toDouble(), + -0.5, + slots - 0.5, + bounds.left, + bounds.right, + ), + mapAcross( + curves[c].contracts[i].price, + minPrice, + maxPrice, + bounds.bottom, + bounds.top, + ), + ), + ), + ]; +} + +/// What a touch on a [FuturesCurveChart] landed on. +@immutable +class FuturesTouchDetails { + /// Creates the details of a touch on [dot]. + const FuturesTouchDetails({required this.dot, required this.spread}); + + /// The dot touched. + final FuturesDot dot; + + /// The contract's price less the previous contract's on its curve; null for + /// the first contract or where either price is missing. + final double? spread; + + /// The contract touched. + FuturesContract get contract => dot.contract; + + /// The curve it is on. + FuturesCurve get curve => dot.curve; +} + +/// Futures prices against delivery month — one or more curves, so the market's +/// term structure and how it has moved can be read at a glance. +/// +/// ```dart +/// FuturesCurveChart( +/// curves: [ +/// FuturesCurve(label: 'Today', contracts: today), +/// FuturesCurve(label: 'A month ago', contracts: before, dashed: true), +/// ], +/// ); +/// ``` +/// +/// The first curve is the one read for the shape badge — contango or +/// backwardation, with its slope. The chart fills the box it is given, and is +/// [defaultHeight] high in a box with no height of its own. +class FuturesCurveChart extends StatefulWidget { + /// Creates a chart of [curves]. + const FuturesCurveChart({ + super.key, + required this.curves, + this.palette = treemapPalette, + this.lineWidth = 2, + this.dotRadius = 3.5, + this.showDots = true, + this.showShapeBadge = true, + this.contangoLabel = 'Contango', + this.backwardationLabel = 'Backwardation', + this.flatLabel = 'Flat', + this.mixedLabel = 'Mixed', + this.shapeTolerance = 0.0005, + this.badgeStyle, + this.showLegend = true, + this.legendStyle, + this.showAxes = true, + this.axisWidth = 48, + this.axisHeight = 18, + this.tickCount = 5, + this.priceFormatter, + this.axisLabelStyle, + this.priceAxisTitle, + this.axisTitleStyle, + this.gridColor = const Color(0x22FFFFFF), + this.hoverBorder = const BorderSide(color: Color(0xCCFFFFFF), width: 1.5), + this.onTouch, + this.tooltipBuilder, + this.tooltipMargin = 10, + this.animationDuration = Duration.zero, + this.animationCurve = Curves.easeOutCubic, + this.animateOnMount = true, + this.padding = EdgeInsets.zero, + this.backgroundColor, + this.defaultHeight = 260, + this.semanticLabel, + }); + + /// The curves, the first being the one the badge reads. + final List curves; + + /// Colours taken in turn by curves that name none. + final List palette; + + /// How thick a curve is drawn. + final double lineWidth; + + /// The radius of a contract's dot. + final double dotRadius; + + /// Whether each contract is marked with a dot. + final bool showDots; + + /// Whether the first curve's shape and slope are written in the corner. + final bool showShapeBadge; + + /// What a rising curve is called. + final String contangoLabel; + + /// What a falling curve is called. + final String backwardationLabel; + + /// What an even curve is called. + final String flatLabel; + + /// What a curve that both rises and falls is called. + final String mixedLabel; + + /// The share of price a step must exceed to count as a rise or a fall. + final double shapeTolerance; + + /// Style of the badge. + final TextStyle? badgeStyle; + + /// Whether the curves' names are listed in the corner. + final bool showLegend; + + /// Style of the legend. + final TextStyle? legendStyle; + + /// Whether the two axes are drawn. + final bool showAxes; + + /// How much room the price axis takes. + final double axisWidth; + + /// How much room the contract axis takes. + final double axisHeight; + + /// About how many ticks to write on the price axis. + final int tickCount; + + /// Writes a price; null writes at most two decimals. + final String Function(double value)? priceFormatter; + + /// Style of an axis label. + final TextStyle? axisLabelStyle; + + /// Written up the price axis. + final String? priceAxisTitle; + + /// Style of an axis title. + final TextStyle? axisTitleStyle; + + /// Colour of the grid ruled at each tick; null rules none. + final Color? gridColor; + + /// Drawn round the dot under the pointer; null marks none. + final BorderSide? hoverBorder; + + /// Called as a touch moves over the contracts, and with null when it leaves. + final ValueChanged? onTouch; + + /// Builds a card shown beside the touched contract; null shows none. + final Widget? Function(BuildContext context, FuturesTouchDetails details)? + tooltipBuilder; + + /// How far the card sits from the dot. + final double tooltipMargin; + + /// How long the curves take to draw in; zero draws them at once. + final Duration animationDuration; + + /// The easing of [animationDuration]. + final Curve animationCurve; + + /// Whether the first build animates. + final bool animateOnMount; + + /// Space kept clear around the chart. + final EdgeInsets padding; + + /// Painted behind the whole chart. + final Color? backgroundColor; + + /// The height taken in a box that sets none. + final double defaultHeight; + + /// What a screen reader announces for the chart. + final String? semanticLabel; + + @override + State createState() => _FuturesCurveChartState(); +} + +class _FuturesCurveChartState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _animation; + final TextPainterCache _text = TextPainterCache(capacity: 64); + List _dots = const []; + ({int curve, int contract})? _touched; + + @override + void initState() { + super.initState(); + _animation = AnimationController( + vsync: this, + duration: widget.animationDuration, + value: 1, + )..addListener(() => setState(() {})); + if (widget.animateOnMount && widget.animationDuration > Duration.zero) { + _animation.forward(from: 0); + } + } + + @override + void didUpdateWidget(FuturesCurveChart oldWidget) { + super.didUpdateWidget(oldWidget); + _animation.duration = widget.animationDuration; + if (!listEquals(oldWidget.curves, widget.curves)) { + _touched = null; + if (widget.animationDuration > Duration.zero) { + _animation.forward(from: 0); + } + } + } + + @override + void dispose() { + _text.dispose(); + _animation.dispose(); + super.dispose(); + } + + FuturesDot? _dotAt(Offset local) { + FuturesDot? best; + var closest = widget.dotRadius + 10; + for (final dot in _dots) { + final distance = (dot.center - local).distance; + if (distance > closest) continue; + closest = distance; + best = dot; + } + return best; + } + + FuturesTouchDetails _details(FuturesDot dot) { + final spreads = calendarSpreads(dot.curve.contracts); + return FuturesTouchDetails(dot: dot, spread: spreads[dot.contractIndex]); + } + + void _handle(Offset local) { + final dot = _dotAt(local); + final key = dot == null + ? null + : (curve: dot.curveIndex, contract: dot.contractIndex); + if (key == _touched) return; + setState(() => _touched = key); + widget.onTouch?.call(dot == null ? null : _details(dot)); + } + + void _leave() { + if (_touched == null) return; + setState(() => _touched = null); + widget.onTouch?.call(null); + } + + @override + Widget build(BuildContext context) { + final t = widget.animationCurve.transform(_animation.value); + final range = futuresPriceRange(widget.curves); + + Widget chart = LayoutBuilder( + builder: (context, constraints) { + final width = constraints.hasBoundedWidth + ? constraints.maxWidth + : MediaQuery.maybeSizeOf(context)?.width ?? 300; + final height = constraints.hasBoundedHeight + ? constraints.maxHeight + : widget.defaultHeight; + final size = Size(width, height); + final box = widget.padding.deflateRect(Offset.zero & size); + final plot = Rect.fromLTRB( + box.left + (widget.showAxes ? widget.axisWidth : 0), + box.top, + box.right, + math.max( + box.top, + box.bottom - (widget.showAxes ? widget.axisHeight : 0), + ), + ); + _dots = layOutFutures( + widget.curves, + plot, + minPrice: range.min, + maxPrice: range.max, + ); + + final touched = _touched; + final dot = touched == null + ? null + : _dots + .where( + (d) => + d.curveIndex == touched.curve && + d.contractIndex == touched.contract, + ) + .firstOrNull; + final builder = widget.tooltipBuilder; + final tooltip = dot == null || builder == null + ? null + : builder(context, _details(dot)); + + return SizedBox( + width: width, + height: height, + child: MouseRegion( + onHover: (e) => _handle(e.localPosition), + onExit: (_) => _leave(), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: (d) => _handle(d.localPosition), + onTapUp: (_) => _leave(), + onTapCancel: _leave, + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned.fill( + child: CustomPaint( + painter: FuturesCurveChartPainter( + chart: widget, + dots: _dots, + plot: plot, + minPrice: range.min, + maxPrice: range.max, + touched: touched, + animation: t, + textCache: _text, + ), + ), + ), + if (tooltip != null && dot != null) + Positioned.fill( + child: IgnorePointer( + child: CustomSingleChildLayout( + delegate: XyTooltipLayout( + anchor: Rect.fromCircle( + center: dot.center, + radius: widget.dotRadius, + ), + margin: widget.tooltipMargin, + ), + child: tooltip, + ), + ), + ), + ], + ), + ), + ), + ); + }, + ); + + final label = widget.semanticLabel; + if (label != null) { + chart = Semantics(container: true, label: label, child: chart); + } + return chart; + } +} + +/// Paints a [FuturesCurveChart]: the axes, the curves, the dots and the badge. +class FuturesCurveChartPainter extends CustomPainter { + /// Creates the painter for [chart], laid out as [dots] inside [plot]. + FuturesCurveChartPainter({ + required this.chart, + required this.dots, + required this.plot, + required this.minPrice, + required this.maxPrice, + required this.touched, + required this.animation, + required this.textCache, + }); + + /// The chart being painted. + final FuturesCurveChart chart; + + /// Where each contract sits. + final List dots; + + /// The area the curves are drawn in. + final Rect plot; + + /// The ends of the price axis. + final double minPrice, maxPrice; + + /// The contract under the pointer. + final ({int curve, int contract})? touched; + + /// How far the entry animation has got, from 0 to 1. + final double animation; + + /// Where the labels are laid out. + final TextPainterCache textCache; + + Color _colorOf(int curveIndex) { + final own = chart.curves[curveIndex].color; + if (own != null) return own; + final palette = chart.palette; + if (palette.isEmpty) return const Color(0xFF4C86CD); + return palette[curveIndex % palette.length]; + } + + @override + void paint(Canvas canvas, Size size) { + final background = chart.backgroundColor; + if (background != null) { + canvas.drawRect(Offset.zero & size, Paint()..color = background); + } + if (dots.isEmpty) return; + + final slots = futuresSlots(chart.curves); + final longest = chart.curves.firstWhere( + (c) => c.contracts.length == slots, + orElse: () => chart.curves.first, + ); + paintXyAxes( + canvas, + textCache, + plot: plot, + minX: -0.5, + maxX: slots - 0.5, + minY: minPrice, + maxY: maxPrice, + tickCount: chart.tickCount, + gridColor: chart.gridColor, + showAxes: chart.showAxes, + yFormatter: chart.priceFormatter, + labelStyle: chart.axisLabelStyle, + yTitle: chart.priceAxisTitle, + titleStyle: chart.axisTitleStyle, + axisWidth: chart.axisWidth, + axisHeight: chart.axisHeight, + xLabels: [for (final c in longest.contracts) c.label], + ); + + final t = animation.clamp(0.0, 1.0); + // Curves are drawn last first, so the first stays on top. + for (var c = chart.curves.length - 1; c >= 0; c--) { + _paintCurve(canvas, c, t); + } + + final at = touched; + final hover = chart.hoverBorder; + if (at != null && hover != null && hover.width > 0) { + for (final dot in dots) { + if (dot.curveIndex != at.curve || dot.contractIndex != at.contract) { + continue; + } + canvas.drawCircle( + dot.center, + chart.dotRadius + hover.width + 2, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = hover.width + ..color = hover.color + ..isAntiAlias = true, + ); + } + } + + _paintBadge(canvas); + } + + void _paintCurve(Canvas canvas, int curveIndex, double t) { + final mine = [ + for (final dot in dots) + if (dot.curveIndex == curveIndex) dot, + ]; + if (mine.isEmpty) return; + final color = _colorOf(curveIndex); + final dashed = chart.curves[curveIndex].dashed; + + // The line grows in from the left: only the first `t` of its length shows. + final reach = plot.left + plot.width * t; + final pen = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = chart.lineWidth + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round + ..color = color + ..isAntiAlias = true; + + canvas + ..save() + ..clipRect(Rect.fromLTRB(0, 0, reach + chart.dotRadius, plot.bottom + 8)); + for (var i = 1; i < mine.length; i++) { + // A gap in the prices breaks the line rather than bridging it. + if (mine[i].contractIndex != mine[i - 1].contractIndex + 1) continue; + _segment(canvas, mine[i - 1].center, mine[i].center, pen, dashed); + } + if (chart.showDots) { + for (final dot in mine) { + canvas.drawCircle( + dot.center, + chart.dotRadius, + Paint() + ..color = color + ..isAntiAlias = true, + ); + } + } + canvas.restore(); + } + + void _segment(Canvas canvas, Offset a, Offset b, Paint pen, bool dashed) { + if (!dashed) { + canvas.drawLine(a, b, pen); + return; + } + const dash = 6.0; + const gap = 4.0; + final total = (b - a).distance; + if (total <= 0) return; + final direction = (b - a) / total; + var travelled = 0.0; + while (travelled < total) { + final end = math.min(travelled + dash, total); + canvas.drawLine(a + direction * travelled, a + direction * end, pen); + travelled += dash + gap; + } + } + + void _paintBadge(Canvas canvas) { + var top = plot.top + 4; + if (chart.showShapeBadge && chart.curves.isNotEmpty) { + final contracts = chart.curves.first.contracts; + final shape = curveShape(contracts, tolerance: chart.shapeTolerance); + final name = switch (shape) { + CurveShape.contango => chart.contangoLabel, + CurveShape.backwardation => chart.backwardationLabel, + CurveShape.flat => chart.flatLabel, + CurveShape.mixed => chart.mixedLabel, + CurveShape.unknown => null, + }; + if (name != null) { + final slope = curveSlope(contracts); + final text = slope == null + ? name + : '$name ${slope >= 0 ? '+' : ''}' + '${(slope * 100).toStringAsFixed(1)}%'; + final style = TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: switch (shape) { + CurveShape.contango => const Color(0xFF2F9E44), + CurveShape.backwardation => const Color(0xFFE03131), + _ => const Color(0xCCFFFFFF), + }, + ).merge(chart.badgeStyle); + final tp = textCache.get(text, style); + tp.paint(canvas, Offset(plot.left + 8, top)); + top += tp.height + 4; + } + } + + if (chart.showLegend && chart.curves.length > 1) { + final style = const TextStyle( + fontSize: 10, + color: Color(0xCCFFFFFF), + ).merge(chart.legendStyle); + for (var c = 0; c < chart.curves.length; c++) { + final tp = textCache.get(chart.curves[c].label, style); + final y = top + c * (tp.height + 3); + canvas.drawLine( + Offset(plot.left + 8, y + tp.height / 2), + Offset(plot.left + 24, y + tp.height / 2), + Paint() + ..color = _colorOf(c) + ..strokeWidth = 2, + ); + tp.paint(canvas, Offset(plot.left + 30, y)); + } + } + } + + @override + bool shouldRepaint(FuturesCurveChartPainter oldDelegate) => + !identical(oldDelegate.chart, chart) || + !identical(oldDelegate.dots, dots) || + oldDelegate.plot != plot || + oldDelegate.minPrice != minPrice || + oldDelegate.maxPrice != maxPrice || + oldDelegate.touched != touched || + oldDelegate.animation != animation; +} diff --git a/lib/src/gauge/gauge_chart.dart b/lib/src/gauge/gauge_chart.dart index b1d99e8..02413c9 100644 --- a/lib/src/gauge/gauge_chart.dart +++ b/lib/src/gauge/gauge_chart.dart @@ -492,7 +492,9 @@ class _GaugeChartState extends State top: layout.center.dy - layout.radius, width: layout.radius * 2, height: layout.radius * 2, - child: Center(child: center), + child: Center( + child: FittedBox(fit: BoxFit.scaleDown, child: center), + ), ), ], ), diff --git a/lib/src/heatmap/activity_clock_chart.dart b/lib/src/heatmap/activity_clock_chart.dart new file mode 100644 index 0000000..70a0af4 --- /dev/null +++ b/lib/src/heatmap/activity_clock_chart.dart @@ -0,0 +1,665 @@ +import 'dart:math' as math; + +import 'package:flutter/foundation.dart' show listEquals; +import 'package:flutter/widgets.dart'; + +import '../renderer/text_painter_cache.dart'; +import '../series/series_axis.dart'; +import 'heatmap_data.dart'; +import 'seasonality_chart.dart'; + +/// Where one square of an [ActivityClockChart] was laid out: a segment of a +/// ring. +@immutable +class ClockSegment { + /// Creates the segment at [row] and [column]. + const ClockSegment({ + required this.row, + required this.column, + required this.innerRadius, + required this.outerRadius, + required this.startAngle, + required this.sweep, + }); + + /// Which ring, counted from the inside. + final int row; + + /// Which hour, counted from the top, clockwise. + final int column; + + /// How far from the centre it begins. + final double innerRadius; + + /// How far from the centre it ends. + final double outerRadius; + + /// Where it starts, in radians, as the canvas reads them: 0 is three + /// o'clock and the angle grows clockwise. + final double startAngle; + + /// How far round it goes, in radians. + final double sweep; + + /// The angle of its middle. + double get midAngle => startAngle + sweep / 2; + + /// The radius of its middle. + double get midRadius => (innerRadius + outerRadius) / 2; +} + +/// Lays a clock of [rows] rings of [columns] segments inside [bounds]. +/// +/// The clock is the largest circle that fits, centred; its rings run from +/// [innerFraction] of the radius out to the edge, with [ringGap] pixels +/// between them. The first column starts at twelve o'clock and they run +/// clockwise, so the hours of a day read as a clock face. +/// +/// Returns an empty list when there is no room or nothing to lay out. +List layOutClock( + int rows, + int columns, + Rect bounds, { + double innerFraction = 0.25, + double ringGap = 2, + double angleGap = 0.01, +}) { + if (rows <= 0 || columns <= 0 || bounds.width <= 0 || bounds.height <= 0) { + return const []; + } + final radius = math.min(bounds.width, bounds.height) / 2; + if (radius <= 0) return const []; + + final inner = radius * innerFraction.clamp(0.0, 0.9); + final pitch = (radius - inner) / rows; + final step = 2 * math.pi / columns; + final sweep = math.max(0.0, step - angleGap); + return [ + for (var r = 0; r < rows; r++) + for (var c = 0; c < columns; c++) + ClockSegment( + row: r, + column: c, + innerRadius: inner + r * pitch + (r == 0 ? 0 : ringGap / 2), + outerRadius: + inner + (r + 1) * pitch - (r == rows - 1 ? 0 : ringGap / 2), + startAngle: -math.pi / 2 + c * step + angleGap / 2, + sweep: sweep, + ), + ]; +} + +/// The segment under [local] on a clock centred at [center], or null when the +/// point is in the hole in the middle, outside the rings, or in a gap. +ClockSegment? clockSegmentAt( + List segments, + Offset center, + Offset local, +) { + if (segments.isEmpty) return null; + final delta = local - center; + final radius = delta.distance; + // The angle clockwise from twelve o'clock, in 0..2π. + var angle = math.atan2(delta.dy, delta.dx) + math.pi / 2; + while (angle < 0) { + angle += 2 * math.pi; + } + angle %= 2 * math.pi; + + for (final segment in segments) { + if (radius < segment.innerRadius || radius > segment.outerRadius) continue; + var from = segment.startAngle + math.pi / 2; + while (from < 0) { + from += 2 * math.pi; + } + from %= 2 * math.pi; + var offset = angle - from; + if (offset < 0) offset += 2 * math.pi; + if (offset <= segment.sweep) return segment; + } + return null; +} + +/// What a touch on an [ActivityClockChart] landed on. +@immutable +class ActivityClockTouchDetails { + /// Creates the details of a touch on the segment at [weekday] and [hour]. + const ActivityClockTouchDetails({ + required this.weekday, + required this.hour, + required this.weekdayLabel, + required this.value, + required this.count, + }); + + /// The weekday, 0 for Monday to 6 for Sunday. + final int weekday; + + /// The hour of the day, 0 to 23. + final int hour; + + /// What the weekday is called. + final String weekdayLabel; + + /// The segment's value; null where no sample fell. + final double? value; + + /// How many samples fell there. + final int count; +} + +/// A week as a clock face: an hour of the day round the dial, a weekday on +/// each ring, every segment coloured by what happened then. +/// +/// ```dart +/// ActivityClockChart( +/// samples: [for (final t in trades) SeasonalSample(time: t.openedAt, value: t.pnl)], +/// aggregate: SeasonalityAggregate.mean, +/// ); +/// ``` +/// +/// Where [SeasonalityChart] lays the same numbers out in a grid, this wraps the +/// day round so the quiet night and the busy open are a shape rather than a +/// column. Twelve o'clock is midnight and the hours run clockwise; Monday is +/// the innermost ring. The chart fills the largest square its box allows, and is +/// [defaultHeight] high in a box with no height of its own. +class ActivityClockChart extends StatefulWidget { + /// Creates a clock of [samples]. + const ActivityClockChart({ + super.key, + required this.samples, + this.aggregate = SeasonalityAggregate.mean, + this.weekdayLabels = seasonalityWeekdayLabels, + this.scale = const HeatmapGradientScale( + colors: [Color(0x224C86CD), Color(0xFF4C86CD)], + ), + this.minValue, + this.maxValue, + this.innerFraction = 0.28, + this.ringGap = 2, + this.showHourLabels = true, + this.hourLabelStep = 3, + this.showWeekdayLabels = true, + this.labelStyle, + this.hoverBorder = const BorderSide(color: Color(0xCCFFFFFF), width: 1.5), + this.onTouch, + this.tooltipBuilder, + this.tooltipMargin = 10, + this.animationDuration = Duration.zero, + this.animationCurve = Curves.easeOutCubic, + this.animateOnMount = true, + this.padding = EdgeInsets.zero, + this.backgroundColor, + this.defaultHeight = 300, + this.semanticLabel, + }); + + /// The values to gather, each placed in time. + final List samples; + + /// How the samples falling in one segment are made into its value. + final SeasonalityAggregate aggregate; + + /// The weekday names, Monday first. + final List weekdayLabels; + + /// How a value becomes a colour. + final HeatmapScale scale; + + /// The value painted at the low end of [scale]; null takes the lowest + /// segment (zero for counts). + final double? minValue; + + /// The value painted at the high end; null takes the highest segment. + final double? maxValue; + + /// How much of the radius the empty hole in the middle takes. + final double innerFraction; + + /// The gap between rings, in pixels. + final double ringGap; + + /// Whether the hours are written round the outside. + final bool showHourLabels; + + /// Write an hour every this many hours. + final int hourLabelStep; + + /// Whether the weekdays are written up the twelve o'clock line. + final bool showWeekdayLabels; + + /// Style of the hour and weekday labels. + final TextStyle? labelStyle; + + /// Drawn round the segment under the pointer; null marks none. + final BorderSide? hoverBorder; + + /// Called as a touch moves over the segments, and with null when it leaves. + final ValueChanged? onTouch; + + /// Builds a card shown beside the touched segment; null shows none. + final Widget? Function( + BuildContext context, + ActivityClockTouchDetails details, + )? + tooltipBuilder; + + /// How far the card sits from the segment. + final double tooltipMargin; + + /// How long the segments take to fade in; zero draws them at once. + final Duration animationDuration; + + /// The easing of [animationDuration]. + final Curve animationCurve; + + /// Whether the first build animates. + final bool animateOnMount; + + /// Space kept clear around the chart. + final EdgeInsets padding; + + /// Painted behind the whole chart. + final Color? backgroundColor; + + /// The height taken in a box that sets none. + final double defaultHeight; + + /// What a screen reader announces for the chart. + final String? semanticLabel; + + @override + State createState() => _ActivityClockChartState(); +} + +class _ActivityClockChartState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _animation; + final TextPainterCache _text = TextPainterCache(capacity: 64); + List _segments = const []; + Offset _center = Offset.zero; + ({int row, int column})? _touched; + + @override + void initState() { + super.initState(); + _animation = AnimationController( + vsync: this, + duration: widget.animationDuration, + value: 1, + )..addListener(() => setState(() {})); + if (widget.animateOnMount && widget.animationDuration > Duration.zero) { + _animation.forward(from: 0); + } + } + + @override + void didUpdateWidget(ActivityClockChart oldWidget) { + super.didUpdateWidget(oldWidget); + _animation.duration = widget.animationDuration; + if (!listEquals(oldWidget.samples, widget.samples)) { + _touched = null; + if (widget.animationDuration > Duration.zero) { + _animation.forward(from: 0); + } + } + } + + @override + void dispose() { + _text.dispose(); + _animation.dispose(); + super.dispose(); + } + + // Gathering the samples is a pass over all of them, and build runs on every + // frame of the animation, so the table is kept until its inputs change. + SeasonalityTable? _table; + List? _tableSamples; + int _tableLength = -1; + SeasonalityAggregate? _tableAggregate; + List? _tableLabels; + + SeasonalityTable _tableFor() { + final samples = widget.samples; + final cached = _table; + if (cached == null || + !identical(_tableSamples, samples) || + _tableLength != samples.length || + _tableAggregate != widget.aggregate || + !identical(_tableLabels, widget.weekdayLabels)) { + _tableSamples = samples; + _tableLength = samples.length; + _tableAggregate = widget.aggregate; + _tableLabels = widget.weekdayLabels; + return _table = seasonalityTable( + samples, + grid: SeasonalityGrid.weekdayByHour, + aggregate: widget.aggregate, + weekdayLabels: widget.weekdayLabels, + ); + } + return cached; + } + + ActivityClockTouchDetails _details( + SeasonalityTable table, + ClockSegment segment, + ) => ActivityClockTouchDetails( + weekday: segment.row, + hour: segment.column, + weekdayLabel: table.rowLabels[segment.row], + value: table.values[segment.row][segment.column], + count: table.counts[segment.row][segment.column], + ); + + void _handle(Offset local, SeasonalityTable table) { + final segment = clockSegmentAt(_segments, _center, local); + final key = segment == null + ? null + : (row: segment.row, column: segment.column); + if (key == _touched) return; + setState(() => _touched = key); + widget.onTouch?.call(segment == null ? null : _details(table, segment)); + } + + void _leave() { + if (_touched == null) return; + setState(() => _touched = null); + widget.onTouch?.call(null); + } + + @override + Widget build(BuildContext context) { + final t = widget.animationCurve.transform(_animation.value); + final table = _tableFor(); + + // The value the scale runs between: the largest segment, and zero for a + // count or the smallest segment otherwise. + double? low; + double? high; + for (final row in table.values) { + for (final value in row) { + if (value == null || !value.isFinite) continue; + low = low == null ? value : math.min(low, value); + high = high == null ? value : math.max(high, value); + } + } + final minValue = + widget.minValue ?? + (widget.aggregate == SeasonalityAggregate.count ? 0.0 : low ?? 0.0); + final maxValue = widget.maxValue ?? high ?? 1.0; + + Widget chart = LayoutBuilder( + builder: (context, constraints) { + final width = constraints.hasBoundedWidth + ? constraints.maxWidth + : MediaQuery.maybeSizeOf(context)?.width ?? 300; + final height = constraints.hasBoundedHeight + ? constraints.maxHeight + : widget.defaultHeight; + final size = Size(width, height); + final box = widget.padding.deflateRect(Offset.zero & size); + // Room round the dial for the hour labels. + final margin = widget.showHourLabels ? 20.0 : 2.0; + final dial = box.deflate(margin); + final diameter = math.max(0.0, math.min(dial.width, dial.height)); + final dialRect = Rect.fromCenter( + center: box.center, + width: diameter, + height: diameter, + ); + _center = dialRect.center; + _segments = layOutClock( + table.rowCount, + table.columnCount, + dialRect, + innerFraction: widget.innerFraction, + ringGap: widget.ringGap, + ); + + final touched = _touched; + final segment = touched == null + ? null + : _segments + .where( + (s) => s.row == touched.row && s.column == touched.column, + ) + .firstOrNull; + final builder = widget.tooltipBuilder; + final tooltip = segment == null || builder == null + ? null + : builder(context, _details(table, segment)); + + return SizedBox( + width: width, + height: height, + child: MouseRegion( + onHover: (e) => _handle(e.localPosition, table), + onExit: (_) => _leave(), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: (d) => _handle(d.localPosition, table), + onTapUp: (_) => _leave(), + onTapCancel: _leave, + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned.fill( + child: CustomPaint( + painter: ActivityClockChartPainter( + chart: widget, + table: table, + segments: _segments, + center: _center, + minValue: minValue, + maxValue: maxValue, + touched: touched, + animation: t, + textCache: _text, + ), + ), + ), + if (tooltip != null && segment != null) + Positioned.fill( + child: IgnorePointer( + child: CustomSingleChildLayout( + delegate: _ClockTooltipLayout( + anchor: Rect.fromCircle( + center: + _center + + Offset.fromDirection( + segment.midAngle, + segment.midRadius, + ), + radius: 4, + ), + margin: widget.tooltipMargin, + ), + child: tooltip, + ), + ), + ), + ], + ), + ), + ), + ); + }, + ); + + final label = widget.semanticLabel; + if (label != null) { + chart = Semantics(container: true, label: label, child: chart); + } + return chart; + } +} + +class _ClockTooltipLayout extends SingleChildLayoutDelegate { + _ClockTooltipLayout({required this.anchor, required this.margin}); + + final Rect anchor; + final double margin; + + @override + BoxConstraints getConstraintsForChild(BoxConstraints constraints) => + BoxConstraints.loose(constraints.biggest); + + @override + Offset getPositionForChild(Size size, Size childSize) { + var left = anchor.right + margin; + if (left + childSize.width > size.width) { + left = anchor.left - margin - childSize.width; + } + return Offset( + left.clamp(0.0, math.max(0.0, size.width - childSize.width)), + (anchor.center.dy - childSize.height / 2).clamp( + 0.0, + math.max(0.0, size.height - childSize.height), + ), + ); + } + + @override + bool shouldRelayout(_ClockTooltipLayout oldDelegate) => + oldDelegate.anchor != anchor || oldDelegate.margin != margin; +} + +/// Paints an [ActivityClockChart]: the rings, the hours and the weekdays. +class ActivityClockChartPainter extends CustomPainter { + /// Creates the painter for [chart], laid out as [segments] round [center]. + ActivityClockChartPainter({ + required this.chart, + required this.table, + required this.segments, + required this.center, + required this.minValue, + required this.maxValue, + required this.touched, + required this.animation, + required this.textCache, + }); + + /// The chart being painted. + final ActivityClockChart chart; + + /// What fell in each segment. + final SeasonalityTable table; + + /// Where each segment sits. + final List segments; + + /// The middle of the dial. + final Offset center; + + /// The values the colour scale runs between. + final double minValue, maxValue; + + /// The segment under the pointer. + final ({int row, int column})? touched; + + /// How far the entry animation has got, from 0 to 1. + final double animation; + + /// Where the labels are laid out. + final TextPainterCache textCache; + + Path _pathOf(ClockSegment s) { + final outer = Rect.fromCircle(center: center, radius: s.outerRadius); + final inner = Rect.fromCircle(center: center, radius: s.innerRadius); + return Path() + ..arcTo(outer, s.startAngle, s.sweep, true) + ..arcTo(inner, s.startAngle + s.sweep, -s.sweep, false) + ..close(); + } + + @override + void paint(Canvas canvas, Size size) { + final background = chart.backgroundColor; + if (background != null) { + canvas.drawRect(Offset.zero & size, Paint()..color = background); + } + if (segments.isEmpty) return; + + final t = animation.clamp(0.0, 1.0); + for (final segment in segments) { + final value = table.values[segment.row][segment.column]; + var color = value == null || !value.isFinite + ? chart.scale.emptyColor + : chart.scale.colorAt(value, minValue, maxValue); + color = color.withValues(alpha: color.a * t); + canvas.drawPath( + _pathOf(segment), + Paint() + ..color = color + ..isAntiAlias = true, + ); + } + + final at = touched; + final hover = chart.hoverBorder; + if (at != null && hover != null && hover.width > 0) { + for (final segment in segments) { + if (segment.row != at.row || segment.column != at.column) continue; + canvas.drawPath( + _pathOf(segment), + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = hover.width + ..color = hover.color + ..isAntiAlias = true, + ); + } + } + + _paintLabels(canvas); + } + + void _paintLabels(Canvas canvas) { + final style = seriesAxisLabelStyle.merge(chart.labelStyle); + final outer = segments.map((s) => s.outerRadius).reduce(math.max); + final inner = segments.map((s) => s.innerRadius).reduce(math.min); + + if (chart.showHourLabels && chart.hourLabelStep > 0) { + for ( + var hour = 0; + hour < table.columnCount; + hour += chart.hourLabelStep + ) { + final angle = + -math.pi / 2 + (hour + 0.5) * 2 * math.pi / table.columnCount; + final tp = textCache.get(table.columnLabels[hour], style); + final at = center + Offset.fromDirection(angle, outer + 9); + tp.paint(canvas, at - Offset(tp.width / 2, tp.height / 2)); + } + } + + if (chart.showWeekdayLabels) { + // Written up the twelve o'clock line, a ring each, at the left of it. + for (final segment in segments.where((s) => s.column == 0)) { + final pitch = segment.outerRadius - segment.innerRadius; + final tp = textCache.get(table.rowLabels[segment.row], style); + if (tp.height > pitch + 2 || inner <= 0) continue; + tp.paint( + canvas, + Offset( + center.dx - 4 - tp.width, + center.dy - segment.midRadius - tp.height / 2, + ), + ); + } + } + } + + @override + bool shouldRepaint(ActivityClockChartPainter oldDelegate) => + !identical(oldDelegate.chart, chart) || + !identical(oldDelegate.segments, segments) || + !identical(oldDelegate.table, table) || + oldDelegate.center != center || + oldDelegate.minValue != minValue || + oldDelegate.maxValue != maxValue || + oldDelegate.touched != touched || + oldDelegate.animation != animation; +} diff --git a/lib/src/heatmap/heatmap_chart.dart b/lib/src/heatmap/heatmap_chart.dart index f98e276..7a989c2 100644 --- a/lib/src/heatmap/heatmap_chart.dart +++ b/lib/src/heatmap/heatmap_chart.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart' show listEquals; import 'dart:math' as math; import 'package:flutter/widgets.dart'; @@ -341,7 +342,7 @@ class _HeatmapChartState extends State void didUpdateWidget(HeatmapChart oldWidget) { super.didUpdateWidget(oldWidget); _animation.duration = widget.animationDuration; - if (oldWidget.cells.length != widget.cells.length && + if (!listEquals(oldWidget.cells, widget.cells) && widget.animationDuration > Duration.zero) { _animation.forward(from: 0); } diff --git a/lib/src/heatmap/seasonality_chart.dart b/lib/src/heatmap/seasonality_chart.dart index 7ccd514..03e60bb 100644 --- a/lib/src/heatmap/seasonality_chart.dart +++ b/lib/src/heatmap/seasonality_chart.dart @@ -16,6 +16,14 @@ class SeasonalSample { /// Its result — usually a return, as a fraction: 0.012 is 1.2%. final double value; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SeasonalSample && other.time == time && other.value == value; + + @override + int get hashCode => Object.hashAll([time, value]); } /// Turns a price series into the return of each period over the one before. diff --git a/lib/src/indicators/indicators.dart b/lib/src/indicators/indicators.dart index 36ae64a..26023fa 100644 --- a/lib/src/indicators/indicators.dart +++ b/lib/src/indicators/indicators.dart @@ -800,12 +800,11 @@ class WrIndicator extends Indicator { IndicatorSeries previous, int from, ) => IndicatorSeries([ - // The window reaches back `period` candles, not `period - 1`. graftTail( candles, previous.lines[0], from, - period, + period - 1, (slice) => wrSeries(slice, period), ), ]); diff --git a/lib/src/indicators/series_math.dart b/lib/src/indicators/series_math.dart index 109133f..1184ded 100644 --- a/lib/src/indicators/series_math.dart +++ b/lib/src/indicators/series_math.dart @@ -18,11 +18,27 @@ List smaSeries(List candles, int period) { final out = List.filled(candles.length, null); if (period <= 0) return out; + // A window holding a value that is not a number has no average. Counting + // them, rather than adding them in, keeps one bad candle from turning the + // running sum — and every average after it — into NaN. var sum = 0.0; + var missing = 0; for (var i = 0; i < candles.length; i++) { - sum += candles[i].close; - if (i >= period) sum -= candles[i - period].close; - if (i >= period - 1) out[i] = sum / period; + final entering = candles[i].close; + if (entering.isFinite) { + sum += entering; + } else { + missing++; + } + if (i >= period) { + final leaving = candles[i - period].close; + if (leaving.isFinite) { + sum -= leaving; + } else { + missing--; + } + } + if (i >= period - 1 && missing == 0) out[i] = sum / period; } return out; } @@ -543,7 +559,7 @@ List wrSeries(List candles, int period) { for (var i = 0; i < candles.length; i++) { var highest = -double.maxFinite; var lowest = double.maxFinite; - for (var window = max(0, i - period); window <= i; window++) { + for (var window = max(0, i - period + 1); window <= i; window++) { highest = max(highest, candles[window].high); lowest = min(lowest, candles[window].low); } @@ -1006,10 +1022,23 @@ List volumeMaSeries(List candles, int period) { if (period <= 0) return out; var sum = 0.0; + var missing = 0; for (var i = 0; i < candles.length; i++) { - sum += candles[i].vol; - if (i >= period) sum -= candles[i - period].vol; - if (i >= period - 1) out[i] = sum / period; + final entering = candles[i].vol; + if (entering.isFinite) { + sum += entering; + } else { + missing++; + } + if (i >= period) { + final leaving = candles[i - period].vol; + if (leaving.isFinite) { + sum -= leaving; + } else { + missing--; + } + } + if (i >= period - 1 && missing == 0) out[i] = sum / period; } return out; } @@ -1236,10 +1265,21 @@ List _smaOf(List values, int period) { if (period <= 0) return out; var sum = 0.0; + var missing = 0; for (var i = 0; i < values.length; i++) { - sum += values[i]; - if (i >= period) sum -= values[i - period]; - if (i >= period - 1) out[i] = sum / period; + if (values[i].isFinite) { + sum += values[i]; + } else { + missing++; + } + if (i >= period) { + if (values[i - period].isFinite) { + sum -= values[i - period]; + } else { + missing--; + } + } + if (i >= period - 1 && missing == 0) out[i] = sum / period; } return out; } diff --git a/lib/src/k_chart_widget.dart b/lib/src/k_chart_widget.dart index 983c44a..f2f55ac 100644 --- a/lib/src/k_chart_widget.dart +++ b/lib/src/k_chart_widget.dart @@ -63,6 +63,7 @@ import 'renderer/chart_painter.dart'; import 'renderer/main_renderer.dart'; import 'renderer/text_painter_cache.dart'; import 'utils/date_format_util.dart'; +import 'extension/num_ext.dart'; /// The drawing mode the chart is currently in. /// @@ -2628,7 +2629,7 @@ class _KChartWidgetState extends State isOnTap = false; isLongPress = true; mSelectX = details.localPosition.dx; - mSelectY = details.localPosition.dy.clamp( + mSelectY = details.localPosition.dy.clampWithin( painter.mMainRect.top, painter.mMainRect.bottom, ); @@ -2952,7 +2953,7 @@ class _KChartWidgetState extends State // A pane the host made taller than the usual ceiling keeps its height // under the finger rather than snapping down to it. - heights[index] = (heights[index] + delta).clamp( + heights[index] = (heights[index] + delta).clampWithin( _minPaneHeight, math.max(widget.chartStyle.maxPaneHeight, heights[index]), ); @@ -3164,7 +3165,10 @@ class _KChartWidgetState extends State return; } mSelectX = pos.dx; - mSelectY = pos.dy.clamp(painter.mMainRect.top, painter.mMainRect.bottom); + mSelectY = pos.dy.clampWithin( + painter.mMainRect.top, + painter.mMainRect.bottom, + ); } /// The last thing the info dialog was told, so a repeat can be dropped. @@ -4778,7 +4782,7 @@ class _KChartWidgetState extends State ? middle : painter .getMainY(price) - .clamp(painter.mMainRect.top, painter.mMainRect.bottom); + .clampWithin(painter.mMainRect.top, painter.mMainRect.bottom); // A crosshair put up from outside reads as a hover, not as a press, so it // does not take a held finger's place or leave one behind when it goes. diff --git a/lib/src/liquidity/liquidity_map_chart.dart b/lib/src/liquidity/liquidity_map_chart.dart index c71e64e..f6c2a26 100644 --- a/lib/src/liquidity/liquidity_map_chart.dart +++ b/lib/src/liquidity/liquidity_map_chart.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart' show listEquals; import 'dart:math' as math; import 'package:flutter/widgets.dart'; @@ -44,6 +45,19 @@ class LiquidityLevel { /// The size actually counted. double get drawnSize => size.isFinite && size > 0 ? size : 0; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is LiquidityLevel && + other.price == price && + other.size == size && + other.side == side && + other.leverage == leverage && + other.label == label; + + @override + int get hashCode => Object.hashAll([price, size, side, leverage, label]); } /// One price bucket of a [LiquidityMapChart]. @@ -82,6 +96,20 @@ class LiquidityBin { /// Which side dominates it, and by how much, from -1 (all long) to 1 (all /// short). double get imbalance => total > 0 ? (shortSize - longSize) / total : 0; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is LiquidityBin && + other.from == from && + other.to == to && + other.longSize == longSize && + other.shortSize == shortSize && + listEquals(other.levels, levels); + + @override + int get hashCode => + Object.hashAll([from, to, longSize, shortSize, Object.hashAll(levels)]); } /// Buckets [levels] into [binCount] price bands between [min] and [max]. @@ -504,7 +532,7 @@ class _LiquidityMapChartState extends State void didUpdateWidget(LiquidityMapChart oldWidget) { super.didUpdateWidget(oldWidget); _animation.duration = widget.animationDuration; - if (!identical(oldWidget.bins, widget.bins) && + if (!listEquals(oldWidget.bins, widget.bins) && widget.animationDuration > Duration.zero) { _animation.forward(from: 0); } diff --git a/lib/src/marimekko/marimekko_chart.dart b/lib/src/marimekko/marimekko_chart.dart index b1912a0..3a5c2c9 100644 --- a/lib/src/marimekko/marimekko_chart.dart +++ b/lib/src/marimekko/marimekko_chart.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart' show listEquals; import 'dart:math' as math; import 'package:flutter/widgets.dart'; @@ -30,6 +31,18 @@ class MarimekkoCell { /// The value actually drawn. double get drawnValue => value.isFinite && value > 0 ? value : 0; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is MarimekkoCell && + other.label == label && + other.value == value && + other.color == color && + other.tooltip == tooltip; + + @override + int get hashCode => Object.hashAll([label, value, color, tooltip]); } /// One column of a [MarimekkoChart] — a whole split into cells. @@ -67,6 +80,19 @@ class MarimekkoColumn { if (given != null && given.isFinite && given > 0) return given; return total; } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is MarimekkoColumn && + other.label == label && + listEquals(other.cells, cells) && + other.width == width && + other.tooltip == tooltip; + + @override + int get hashCode => + Object.hashAll([label, Object.hashAll(cells), width, tooltip]); } /// Where one cell of a [MarimekkoChart] sits. @@ -458,7 +484,7 @@ class _MarimekkoChartState extends State void didUpdateWidget(MarimekkoChart oldWidget) { super.didUpdateWidget(oldWidget); _animation.duration = widget.animationDuration; - if (!identical(oldWidget.columns, widget.columns)) { + if (!listEquals(oldWidget.columns, widget.columns)) { _touched = null; if (widget.animationDuration > Duration.zero) { _animation.forward(from: 0); diff --git a/lib/src/ml/benchmark_matrix_chart.dart b/lib/src/ml/benchmark_matrix_chart.dart new file mode 100644 index 0000000..b4cf71f --- /dev/null +++ b/lib/src/ml/benchmark_matrix_chart.dart @@ -0,0 +1,355 @@ +import 'package:flutter/widgets.dart'; + +import '../heatmap/heatmap_chart.dart'; +import '../heatmap/heatmap_data.dart'; + +/// How well each benchmark score of each model stands, from 0 (the worst of +/// its column) to 1 (the best), where [lowerIsBetter] holds the benchmarks on +/// which a lower score wins — perplexity, error rate, latency. +/// +/// A column in which every model scored alike is 0.5 throughout; a missing +/// score stays null. +List> benchmarkQuality( + List> scores, { + Set lowerIsBetter = const {}, +}) { + if (scores.isEmpty) return const []; + final columns = scores.map((r) => r.length).reduce((a, b) => a > b ? a : b); + final out = List.generate( + scores.length, + (_) => List.filled(columns, null), + ); + for (var c = 0; c < columns; c++) { + var low = double.infinity; + var high = double.negativeInfinity; + for (final row in scores) { + final v = c < row.length ? row[c] : null; + if (v == null || !v.isFinite) continue; + if (v < low) low = v; + if (v > high) high = v; + } + for (var r = 0; r < scores.length; r++) { + final v = c < scores[r].length ? scores[r][c] : null; + if (v == null || !v.isFinite) continue; + var q = high == low ? 0.5 : (v - low) / (high - low); + if (lowerIsBetter.contains(c)) q = 1 - q; + out[r][c] = q; + } + } + return out; +} + +/// The mean of each model's [quality] across the benchmarks it has a score for; +/// null for a model with none. +List benchmarkAverages(List> quality) => [ + for (final row in quality) + () { + var sum = 0.0; + var n = 0; + for (final q in row) { + if (q == null) continue; + sum += q; + n++; + } + return n == 0 ? null : sum / n; + }(), +]; + +/// The index of the best model in each benchmark, or null where no model has a +/// score. The first of equals. +List benchmarkLeaders( + List> scores, { + Set lowerIsBetter = const {}, +}) { + if (scores.isEmpty) return const []; + final columns = scores.map((r) => r.length).reduce((a, b) => a > b ? a : b); + return [ + for (var c = 0; c < columns; c++) + () { + int? best; + double? top; + for (var r = 0; r < scores.length; r++) { + final v = c < scores[r].length ? scores[r][c] : null; + if (v == null || !v.isFinite) continue; + if (top == null || (lowerIsBetter.contains(c) ? v < top : v > top)) { + top = v; + best = r; + } + } + return best; + }(), + ]; +} + +/// What a touch on a [BenchmarkMatrixChart] landed on. +@immutable +class BenchmarkTouchDetails { + /// Creates the details of a touch on a square. + const BenchmarkTouchDetails({ + required this.model, + required this.benchmark, + required this.score, + required this.quality, + required this.isLeader, + required this.isAverage, + required this.rect, + }); + + /// The model's name. + final String model; + + /// The benchmark's name; null on the average column. + final String? benchmark; + + /// The raw score; null on the average column or where there is none. + final double? score; + + /// How well it stands in its column, from 0 to 1. + final double? quality; + + /// Whether this is the best score on its benchmark. + final bool isLeader; + + /// Whether this is the average column. + final bool isAverage; + + /// The square, in the chart's local pixels. + final Rect rect; +} + +/// Models against benchmarks as a heatmap: each score coloured by how it +/// stands among the models on that benchmark, the leader of each marked, and an +/// average column to rank them by. +/// +/// ```dart +/// BenchmarkMatrixChart( +/// models: const ['Model A', 'Model B', 'Model C'], +/// benchmarks: const ['MMLU', 'GSM8K', 'HumanEval', 'Perplexity'], +/// scores: [ +/// [86.4, 92.0, 80.5, 5.1], +/// [83.1, 88.7, 76.2, 5.6], +/// [79.9, 81.3, 71.0, 6.4], +/// ], +/// lowerIsBetter: const {3}, +/// ); +/// ``` +/// +/// Colour is relative within a benchmark, so a hard benchmark and an easy one +/// read alike; the written number is the raw score. The chart fills the box it +/// is given, and is [defaultHeight] high in a box with no height of its own. +class BenchmarkMatrixChart extends StatelessWidget { + /// Creates a matrix of [scores], a row per model and a column per benchmark. + const BenchmarkMatrixChart({ + super.key, + required this.models, + required this.benchmarks, + required this.scores, + this.lowerIsBetter = const {}, + this.sortByAverage = true, + this.showAverage = true, + this.averageLabel = 'Avg', + this.markLeaders = true, + this.lowColor = const Color(0xFFE03131), + this.midColor = const Color(0xFFE8C547), + this.highColor = const Color(0xFF2F9E44), + this.scoreFormatter, + this.labelStyle, + this.axisLabelStyle, + this.modelAxisWidth = 88, + this.benchmarkAxisHeight = 22, + this.spacing = 1, + this.radius = const BorderRadius.all(Radius.circular(2)), + this.onTouch, + this.tooltipBuilder, + this.animationDuration = Duration.zero, + this.animationCurve = Curves.easeOutCubic, + this.animateOnMount = true, + this.padding = EdgeInsets.zero, + this.backgroundColor, + this.defaultHeight = 240, + this.semanticLabel, + }); + + /// The model names, one per row of [scores]. + final List models; + + /// The benchmark names, one per column. + final List benchmarks; + + /// The scores, `scores[model][benchmark]`; null or a value that is not + /// finite leaves a square empty. + final List> scores; + + /// The indices of benchmarks where a lower score is better. + final Set lowerIsBetter; + + /// Whether the best model by average is put at the top. + final bool sortByAverage; + + /// Whether a last column holds each model's average standing, from 0 to 100. + final bool showAverage; + + /// What the average column is called. + final String averageLabel; + + /// Whether the best score of each benchmark is starred. + final bool markLeaders; + + /// The colour of the worst score on a benchmark. + final Color lowColor; + + /// The colour half way. + final Color midColor; + + /// The colour of the best. + final Color highColor; + + /// Writes a score; null writes one decimal. + final String Function(double score)? scoreFormatter; + + /// Style of the scores in the squares. + final TextStyle? labelStyle; + + /// Style of the model and benchmark names. + final TextStyle? axisLabelStyle; + + /// Room held for the model names. + final double modelAxisWidth; + + /// Room held for the benchmark names. + final double benchmarkAxisHeight; + + /// The gap between squares. + final double spacing; + + /// The rounding of each square's corners. + final BorderRadius radius; + + /// Called as a touch moves over the squares, and with null when it leaves. + final ValueChanged? onTouch; + + /// Builds a card shown beside the touched square; null shows none. + final Widget? Function(BuildContext context, BenchmarkTouchDetails details)? + tooltipBuilder; + + /// How long the squares take to fade in; zero draws them at once. + final Duration animationDuration; + + /// The easing of [animationDuration]. + final Curve animationCurve; + + /// Whether the first build animates. + final bool animateOnMount; + + /// Space kept clear around the chart. + final EdgeInsets padding; + + /// Painted behind the whole chart. + final Color? backgroundColor; + + /// The height taken in a box that sets none. + final double defaultHeight; + + /// What a screen reader announces for the chart. + final String? semanticLabel; + + @override + Widget build(BuildContext context) { + final rowsCount = models.length < scores.length + ? models.length + : scores.length; + final data = scores.take(rowsCount).toList(); + final quality = benchmarkQuality(data, lowerIsBetter: lowerIsBetter); + final averages = benchmarkAverages(quality); + final leaders = benchmarkLeaders(data, lowerIsBetter: lowerIsBetter); + + // The order the rows are drawn in: best average first, or as given. + final order = [for (var i = 0; i < rowsCount; i++) i]; + if (sortByAverage) { + order.sort((a, b) { + final qa = averages[a] ?? -1; + final qb = averages[b] ?? -1; + return qb.compareTo(qa); + }); + } + + final format = scoreFormatter ?? (v) => v.toStringAsFixed(1); + final columns = benchmarks.length + (showAverage ? 1 : 0); + + final cells = [ + for (var row = 0; row < order.length; row++) ...[ + for (var c = 0; c < benchmarks.length; c++) + if (quality[order[row]][c] case final q?) + HeatmapCell( + x: c, + y: row, + value: q, + label: + (markLeaders && leaders[c] == order[row] ? '★ ' : '') + + format(data[order[row]][c]!), + ), + if (showAverage) + if (averages[order[row]] case final avg?) + HeatmapCell( + x: benchmarks.length, + y: row, + value: avg, + label: (avg * 100).round().toString(), + ), + ], + ]; + + BenchmarkTouchDetails details(HeatmapTouchDetails t) { + final model = order[t.y]; + final isAverage = t.x >= benchmarks.length; + return BenchmarkTouchDetails( + model: models[model], + benchmark: isAverage ? null : benchmarks[t.x], + score: isAverage ? null : data[model][t.x], + quality: isAverage ? averages[model] : quality[model][t.x], + isLeader: !isAverage && leaders[t.x] == model, + isAverage: isAverage, + rect: t.rect, + ); + } + + final touch = onTouch; + final builder = tooltipBuilder; + return HeatmapChart( + cells: cells, + columns: columns, + rows: order.length, + scale: HeatmapGradientScale(colors: [lowColor, midColor, highColor]), + minValue: 0, + maxValue: 1, + xAxis: HeatmapAxis( + labelBuilder: (c) => + c < benchmarks.length ? benchmarks[c] : averageLabel, + size: benchmarkAxisHeight, + style: axisLabelStyle, + ), + yAxis: HeatmapAxis( + labels: [for (final i in order) models[i]], + size: modelAxisWidth, + style: axisLabelStyle, + ), + spacing: spacing, + radius: radius, + labelBuilder: (cell) => cell.label, + labelStyle: labelStyle, + onTouch: touch == null + ? null + : (t) => touch(t == null ? null : details(t)), + tooltipBuilder: builder == null + ? null + : (context, t) => builder(context, details(t)), + animationDuration: animationDuration, + animationCurve: animationCurve, + animateOnMount: animateOnMount, + padding: padding, + backgroundColor: backgroundColor, + defaultHeight: defaultHeight, + semanticLabel: semanticLabel, + ); + } +} diff --git a/lib/src/ml/eval_breakdown_chart.dart b/lib/src/ml/eval_breakdown_chart.dart new file mode 100644 index 0000000..f247348 --- /dev/null +++ b/lib/src/ml/eval_breakdown_chart.dart @@ -0,0 +1,486 @@ +import 'dart:math' as math; + +import 'package:flutter/widgets.dart'; + +import '../utils/plot_scaffold.dart'; + +/// How an evaluation went in one category: tasks passed, failed and skipped. +@immutable +class EvalCategory { + /// Creates a category called [name]. + const EvalCategory({ + required this.name, + required this.passed, + required this.failed, + this.skipped = 0, + this.data, + }); + + /// What the category is called. + final String name; + + /// How many tasks passed. + final int passed; + + /// How many failed. + final int failed; + + /// How many errored or were not run; counted out of the pass rate. + final int skipped; + + /// Anything the app wants back when this category is touched. + final Object? data; + + /// The tasks that ran: passed and failed. + int get attempted => math.max(0, passed) + math.max(0, failed); + + /// Every task, run or not. + int get total => attempted + math.max(0, skipped); + + /// The share of attempted tasks that passed, 0 to 1; null when none ran. + double? get passRate => + attempted == 0 ? null : math.max(0, passed) / attempted; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is EvalCategory && + other.name == name && + other.passed == passed && + other.failed == failed && + other.skipped == skipped && + other.data == data; + + @override + int get hashCode => Object.hash(name, passed, failed, skipped, data); +} + +/// The Wilson score interval for [passed] of [attempted] at the given +/// [z] — 1.96 is 95% — as (low, high). With nothing attempted it is (0, 1). +/// +/// Unlike the plain proportion it stays inside 0..1 and is honest about small +/// samples: three of three passing is not 100% sure. +(double, double) wilsonInterval(int passed, int attempted, {double z = 1.96}) { + if (attempted <= 0) return (0, 1); + final n = attempted.toDouble(); + final p = (passed.clamp(0, attempted)) / n; + final z2 = z * z; + final denominator = 1 + z2 / n; + final centre = (p + z2 / (2 * n)) / denominator; + final margin = + z * math.sqrt(p * (1 - p) / n + z2 / (4 * n * n)) / denominator; + return ((centre - margin).clamp(0.0, 1.0), (centre + margin).clamp(0.0, 1.0)); +} + +/// The pass rate over every attempted task in [categories]; null when none ran. +double? overallPassRate(Iterable categories) { + var passed = 0; + var attempted = 0; + for (final c in categories) { + passed += math.max(0, c.passed); + attempted += c.attempted; + } + return attempted == 0 ? null : passed / attempted; +} + +/// [categories] ordered worst pass rate first; categories where nothing ran go +/// last. The sort is stable. +List sortedByPassRate(Iterable categories) { + final list = categories.toList(); + final order = [for (var i = 0; i < list.length; i++) i] + ..sort((a, b) { + final ra = list[a].passRate; + final rb = list[b].passRate; + if (ra == null && rb == null) return a.compareTo(b); + if (ra == null) return 1; + if (rb == null) return -1; + final c = ra.compareTo(rb); + return c != 0 ? c : a.compareTo(b); + }); + return [for (final i in order) list[i]]; +} + +/// What a touch on an [EvalBreakdownChart] landed on. +@immutable +class EvalTouchDetails { + /// Creates the details of a touch on a category. + const EvalTouchDetails({ + required this.index, + required this.category, + required this.low, + required this.high, + }); + + /// The category's position in the order drawn. + final int index; + + /// The category. + final EvalCategory category; + + /// The low end of its 95% pass-rate interval. + final double low; + + /// The high end. + final double high; +} + +/// An evaluation by category: a bar of passed, failed and skipped tasks for +/// each, with the pass rate and how many tasks stand behind it, the worst +/// category first. +/// +/// ```dart +/// EvalBreakdownChart( +/// categories: const [ +/// EvalCategory(name: 'Math', passed: 71, failed: 29), +/// EvalCategory(name: 'Coding', passed: 88, failed: 12, skipped: 3), +/// EvalCategory(name: 'Safety', passed: 96, failed: 4), +/// ], +/// threshold: 0.8, +/// ); +/// ``` +/// +/// The chart fills the box it is given, and is [defaultHeight] high in a box +/// with no height of its own. +class EvalBreakdownChart extends StatelessWidget { + /// Creates a chart of [categories]. + const EvalBreakdownChart({ + super.key, + required this.categories, + this.sortWorstFirst = true, + this.threshold, + this.showOverall = true, + this.overallLabel = 'Overall', + this.passedColor = const Color(0xFF2F9E44), + this.failedColor = const Color(0xFFE03131), + this.skippedColor = const Color(0xFF5C6570), + this.thresholdColor = const Color(0xFFF2B01E), + this.showCounts = true, + this.rateFormatter, + this.labelStyle, + this.nameWidth = 96, + this.rateWidth = 92, + this.barHeight = 18, + this.maxBarHeight = 34, + this.rowGap = 8, + this.barRadius = const BorderRadius.all(Radius.circular(3)), + this.hoverColor = const Color(0x14FFFFFF), + this.onTouch, + this.tooltipBuilder, + this.tooltipMargin = 10, + this.animationDuration = Duration.zero, + this.animationCurve = Curves.easeOutCubic, + this.animateOnMount = true, + this.padding = EdgeInsets.zero, + this.backgroundColor, + this.defaultHeight = 260, + this.semanticLabel, + }); + + /// The categories. + final List categories; + + /// Whether the worst pass rate is at the top. + final bool sortWorstFirst; + + /// A pass rate to hold the categories to, 0 to 1, drawn across every bar. + final double? threshold; + + /// Whether the overall pass rate is written above the bars. + final bool showOverall; + + /// What the overall figure is called. + final String overallLabel; + + /// The colour of passed tasks. + final Color passedColor; + + /// The colour of failed tasks. + final Color failedColor; + + /// The colour of skipped tasks. + final Color skippedColor; + + /// The colour of the threshold line. + final Color thresholdColor; + + /// Whether the task count is written with the pass rate. + final bool showCounts; + + /// Writes a pass rate, 0 to 1; null writes a percentage with one decimal. + final String Function(double rate)? rateFormatter; + + /// Style of the names and figures. + final TextStyle? labelStyle; + + /// Room held for the category names. + final double nameWidth; + + /// Room held for the pass rate and count. + final double rateWidth; + + /// How short a bar may be, when there are more categories than the box holds + /// comfortably. + final double barHeight; + + /// How tall a bar may be: the bars grow from [barHeight] to this to fill the + /// box, so a few categories are not left at the top of it. + final double maxBarHeight; + + /// The gap between bars. + final double rowGap; + + /// The rounding of a bar's corners. + final BorderRadius barRadius; + + /// The colour behind the row under the pointer. + final Color hoverColor; + + /// Called as a touch moves over the rows, and with null when it leaves. + final ValueChanged? onTouch; + + /// Builds a card shown beside the touched row; null shows none. + final Widget? Function(BuildContext context, EvalTouchDetails details)? + tooltipBuilder; + + /// How far the card sits from the row. + final double tooltipMargin; + + /// How long the bars take to grow in; zero draws them at once. + final Duration animationDuration; + + /// The easing of [animationDuration]. + final Curve animationCurve; + + /// Whether the first build animates. + final bool animateOnMount; + + /// Space kept clear around the chart. + final EdgeInsets padding; + + /// Painted behind the whole chart. + final Color? backgroundColor; + + /// The height taken in a box that sets none. + final double defaultHeight; + + /// What a screen reader announces for the chart. + final String? semanticLabel; + + /// How tall a bar is in a plot [height] high holding [count] categories. + double barHeightFor(double height, int count, double header) { + if (count == 0) return barHeight; + final room = (height - header) / count - rowGap; + return room.clamp(barHeight, math.max(barHeight, maxBarHeight)); + } + + @override + Widget build(BuildContext context) { + final rows = sortWorstFirst ? sortedByPassRate(categories) : categories; + final overall = overallPassRate(categories); + final headerHeight = showOverall ? 26.0 : 0.0; + + Rect rowRect(int i, Rect plot) { + final bar = barHeightFor(plot.height, rows.length, headerHeight); + final pitch = bar + rowGap; + return Rect.fromLTWH( + plot.left, + plot.top + headerHeight + i * pitch - rowGap / 2, + plot.width, + bar + rowGap, + ); + } + + EvalTouchDetails? hit(Offset local, Rect plot) { + for (var i = 0; i < rows.length; i++) { + if (!rowRect(i, plot).contains(local)) continue; + final c = rows[i]; + final (low, high) = wilsonInterval(c.passed, c.attempted); + return EvalTouchDetails(index: i, category: c, low: low, high: high); + } + return null; + } + + return PlotScaffold( + restartKey: Object.hashAll([...categories, sortWorstFirst]), + padding: padding, + backgroundColor: backgroundColor, + defaultHeight: defaultHeight, + animationDuration: animationDuration, + animationCurve: animationCurve, + animateOnMount: animateOnMount, + semanticLabel: semanticLabel, + tooltipMargin: tooltipMargin, + onTouch: onTouch, + tooltipBuilder: tooltipBuilder, + hitTest: hit, + keyOf: (d) => d.index, + anchorOf: (d, plot) => rowRect(d.index, plot), + painter: (plot, t, text, hovered) => _EvalPainter( + chart: this, + rows: rows, + overall: overall, + headerHeight: headerHeight, + hovered: hovered?.index, + plot: plot, + animation: t, + text: text, + backgroundColor: backgroundColor, + ), + ); + } +} + +class _EvalPainter extends PlotPainter { + _EvalPainter({ + required this.chart, + required this.rows, + required this.overall, + required this.headerHeight, + required this.hovered, + required super.plot, + required super.animation, + required super.text, + super.backgroundColor, + }); + + final EvalBreakdownChart chart; + final List rows; + final double? overall; + final double headerHeight; + final int? hovered; + + String _rate(double r) => + chart.rateFormatter?.call(r) ?? '${(r * 100).toStringAsFixed(1)}%'; + + @override + void paintPlot(Canvas canvas, Size size) { + final base = const TextStyle( + fontSize: 12, + color: Color(0xFFD0D5DB), + ).merge(chart.labelStyle); + final t = animation.clamp(0.0, 1.0); + + if (chart.showOverall) { + final value = overall == null ? '–' : _rate(overall!); + final tp = text.get( + '${chart.overallLabel} $value', + base.copyWith( + fontWeight: FontWeight.w700, + color: const Color(0xFFFFFFFF), + ), + ); + tp.paint(canvas, Offset(plot.left, plot.top + 2)); + } + + final bar = chart.barHeightFor(plot.height, rows.length, headerHeight); + final barLeft = plot.left + chart.nameWidth; + final barRight = math.max(barLeft, plot.right - chart.rateWidth); + final barWidth = barRight - barLeft; + final pitch = bar + chart.rowGap; + + // The longest category sets the scale, so every bar counts in the same unit. + var longest = 0; + for (final c in rows) { + longest = math.max(longest, c.total); + } + + for (var i = 0; i < rows.length; i++) { + final c = rows[i]; + final top = plot.top + headerHeight + i * pitch; + if (top + bar > plot.bottom + 1) break; + + if (hovered == i) { + canvas.drawRect( + Rect.fromLTRB( + plot.left, + top - chart.rowGap / 2, + plot.right, + top + bar + chart.rowGap / 2, + ), + Paint()..color = chart.hoverColor, + ); + } + + final name = text.get(c.name, base); + var shown = name; + if (name.width > chart.nameWidth - 8) { + shown = text.get(_fit(c.name, base, chart.nameWidth - 8), base); + } + shown.paint(canvas, Offset(plot.left, top + (bar - shown.height) / 2)); + + if (longest > 0 && barWidth > 0) { + final scale = barWidth / longest * t; + var x = barLeft; + final outline = RRect.fromRectAndRadius( + Rect.fromLTWH(barLeft, top, c.total * scale, bar), + chart.barRadius.topLeft, + ); + canvas + ..save() + ..clipRRect(outline); + for (final (count, color) in [ + (math.max(0, c.passed), chart.passedColor), + (math.max(0, c.failed), chart.failedColor), + (math.max(0, c.skipped), chart.skippedColor), + ]) { + if (count == 0) continue; + final w = count * scale; + canvas.drawRect( + Rect.fromLTWH(x, top, w, bar), + Paint()..color = color, + ); + x += w; + } + canvas.restore(); + } + + final r = c.passRate; + final figure = r == null ? '–' : _rate(r); + final count = chart.showCounts ? ' n=${c.attempted}' : ''; + final tp = text.get( + '$figure$count', + base.copyWith( + fontWeight: FontWeight.w600, + color: r == null + ? const Color(0xFF8B95A1) + : (chart.threshold != null && r < chart.threshold! + ? chart.failedColor + : base.color), + ), + ); + tp.paint(canvas, Offset(barRight + 8, top + (bar - tp.height) / 2)); + } + + // The threshold: where a bar would have to reach its passed part to meet it + // is relative to each bar, so a line is drawn on the pass-rate scale of the + // longest bar's width. + final th = chart.threshold; + if (th != null && th > 0 && th <= 1 && barWidth > 0 && longest > 0) { + // Drawn as a tick at each bar: threshold × that bar's total. + for (var i = 0; i < rows.length; i++) { + final c = rows[i]; + if (c.attempted == 0) continue; + final top = plot.top + headerHeight + i * pitch; + if (top + bar > plot.bottom + 1) break; + final x = barLeft + c.attempted * th * (barWidth / longest) * t; + canvas.drawLine( + Offset(x, top - 2), + Offset(x, top + bar + 2), + Paint() + ..color = chart.thresholdColor + ..strokeWidth = 2, + ); + } + } + } + + String _fit(String s, TextStyle style, double width) { + var out = s; + while (out.length > 1) { + out = out.substring(0, out.length - 1); + final tp = text.get('$out…', style); + if (tp.width <= width) return '$out…'; + } + return '…'; + } +} diff --git a/lib/src/ml/latency_band_chart.dart b/lib/src/ml/latency_band_chart.dart new file mode 100644 index 0000000..2253fd7 --- /dev/null +++ b/lib/src/ml/latency_band_chart.dart @@ -0,0 +1,626 @@ +import 'dart:math' as math; + +import 'package:flutter/widgets.dart'; + +import '../utils/plot_scaffold.dart'; +import '../utils/xy_plot.dart'; + +/// Latency over one interval: the median and the tail percentiles. +@immutable +class LatencySample { + /// Creates a sample at [time] with the given latencies, in one unit. + const LatencySample({ + required this.time, + required this.p50, + required this.p95, + required this.p99, + this.data, + }); + + /// When the interval began. + final DateTime time; + + /// The median latency. + final double p50; + + /// The 95th percentile. + final double p95; + + /// The 99th percentile. + final double p99; + + /// Anything the app wants back when this sample is touched. + final Object? data; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is LatencySample && + other.time == time && + other.p50 == p50 && + other.p95 == p95 && + other.p99 == p99 && + other.data == data; + + @override + int get hashCode => Object.hash(time, p50, p95, p99, data); +} + +/// Which percentile an SLO is set on. +enum LatencyPercentile { + /// The median. + p50, + + /// The 95th percentile. + p95, + + /// The 99th percentile. + p99, +} + +double _valueOf(LatencySample s, LatencyPercentile p) => switch (p) { + LatencyPercentile.p50 => s.p50, + LatencyPercentile.p95 => s.p95, + LatencyPercentile.p99 => s.p99, +}; + +/// The indices of the samples that broke an objective: those whose [percentile] +/// was over [slo]. A sample whose value is not finite is not a breach. +List sloBreaches( + List samples, + double slo, { + LatencyPercentile percentile = LatencyPercentile.p95, +}) => [ + for (var i = 0; i < samples.length; i++) + if (_valueOf(samples[i], percentile).isFinite && + _valueOf(samples[i], percentile) > slo) + i, +]; + +/// What a touch on a [LatencyBandChart] landed on. +@immutable +class LatencyTouchDetails { + /// Creates the details of a touch on the sample at [index]. + const LatencyTouchDetails({ + required this.index, + required this.sample, + required this.breached, + }); + + /// The sample's position. + final int index; + + /// The sample. + final LatencySample sample; + + /// Whether it broke the chart's objective. + final bool breached; +} + +/// Latency over time as bands: the median as a line, the span up to the 95th +/// percentile and on to the 99th shaded, and an objective drawn across with +/// the intervals that broke it marked. +/// +/// ```dart +/// LatencyBandChart( +/// samples: [ +/// for (final w in windows) +/// LatencySample(time: w.start, p50: w.p50, p95: w.p95, p99: w.p99), +/// ], +/// slo: 1200, +/// valueFormatter: (ms) => '${ms.round()} ms', +/// ); +/// ``` +/// +/// The chart fills the box it is given, and is [defaultHeight] high in a box +/// with no height of its own. +class LatencyBandChart extends StatelessWidget { + /// Creates a chart of [samples], in time order. + const LatencyBandChart({ + super.key, + required this.samples, + this.slo, + this.sloPercentile = LatencyPercentile.p95, + this.logY = false, + this.medianColor = const Color(0xFF4C86CD), + this.p95Color = const Color(0xFF4C86CD), + this.p99Color = const Color(0xFF4C86CD), + this.sloColor = const Color(0xFFF2B01E), + this.breachColor = const Color(0xFFE03131), + this.showBreaches = true, + this.lineWidth = 2, + this.showLegend = true, + this.legendStyle, + this.showAxes = true, + this.axisWidth = 52, + this.axisHeight = 18, + this.tickCount = 5, + this.valueFormatter, + this.timeFormatter, + this.axisLabelStyle, + this.yAxisTitle, + this.axisTitleStyle, + this.gridColor = const Color(0x22FFFFFF), + this.crosshairColor = const Color(0x66FFFFFF), + this.onTouch, + this.tooltipBuilder, + this.tooltipMargin = 12, + this.animationDuration = Duration.zero, + this.animationCurve = Curves.easeOutCubic, + this.animateOnMount = true, + this.padding = EdgeInsets.zero, + this.backgroundColor, + this.defaultHeight = 260, + this.semanticLabel, + }); + + /// The samples, in time order. + final List samples; + + /// The objective, in the samples' unit; null draws none. + final double? slo; + + /// Which percentile the objective is set on. + final LatencyPercentile sloPercentile; + + /// Whether the latency axis is logarithmic, for a tail that runs far above + /// the median. + final bool logY; + + /// The colour of the median line and the band round it. + final Color medianColor; + + /// The colour of the 95th-percentile line. + final Color p95Color; + + /// The colour of the 99th-percentile band. + final Color p99Color; + + /// The colour of the objective line. + final Color sloColor; + + /// The colour of the intervals that broke the objective. + final Color breachColor; + + /// Whether the intervals over the objective are marked. + final bool showBreaches; + + /// How thick the median line is. + final double lineWidth; + + /// Whether the percentiles are named in the corner. + final bool showLegend; + + /// Style of the legend. + final TextStyle? legendStyle; + + /// Whether the two axes are drawn. + final bool showAxes; + + /// How much room the latency axis takes. + final double axisWidth; + + /// How much room the time axis takes. + final double axisHeight; + + /// About how many ticks to write on the latency axis. + final int tickCount; + + /// Writes a latency; null writes at most two decimals. + final String Function(double value)? valueFormatter; + + /// Writes a time on the axis; null writes `HH:mm`, or `MMM d` over a span of + /// days. + final String Function(DateTime time)? timeFormatter; + + /// Style of an axis label. + final TextStyle? axisLabelStyle; + + /// Written up the latency axis. + final String? yAxisTitle; + + /// Style of an axis title. + final TextStyle? axisTitleStyle; + + /// Colour of the grid ruled at each tick; null rules none. + final Color? gridColor; + + /// The colour of the line under the pointer. + final Color crosshairColor; + + /// Called as a touch moves along the time axis, and with null when it leaves. + final ValueChanged? onTouch; + + /// Builds a card shown beside the touched sample; null shows none. + final Widget? Function(BuildContext context, LatencyTouchDetails details)? + tooltipBuilder; + + /// How far the card sits from the crosshair. + final double tooltipMargin; + + /// How long the bands take to draw in; zero draws them at once. + final Duration animationDuration; + + /// The easing of [animationDuration]. + final Curve animationCurve; + + /// Whether the first build animates. + final bool animateOnMount; + + /// Space kept clear around the chart. + final EdgeInsets padding; + + /// Painted behind the whole chart. + final Color? backgroundColor; + + /// The height taken in a box that sets none. + final double defaultHeight; + + /// What a screen reader announces for the chart. + final String? semanticLabel; + + @override + Widget build(BuildContext context) { + final usable = [ + for (var i = 0; i < samples.length; i++) + if (samples[i].p50.isFinite && + samples[i].p95.isFinite && + samples[i].p99.isFinite && + (!logY || samples[i].p50 > 0)) + i, + ]; + final breaches = slo == null + ? const [] + : sloBreaches(samples, slo!, percentile: sloPercentile); + + double? minT, maxT; + final values = []; + for (final i in usable) { + final ms = samples[i].time.millisecondsSinceEpoch.toDouble(); + minT = minT == null ? ms : math.min(minT, ms); + maxT = maxT == null ? ms : math.max(maxT, ms); + values + ..add(samples[i].p50) + ..add(samples[i].p95) + ..add(samples[i].p99); + } + if (slo != null && slo!.isFinite && (!logY || slo! > 0)) values.add(slo!); + var t0 = minT ?? 0; + var t1 = maxT ?? 1; + if (t0 == t1) t1 = t0 + 1; + + double lo; + double hi; + if (values.isEmpty) { + lo = logY ? 1 : 0; + hi = logY ? 100 : 1; + } else if (logY) { + lo = values.where((v) => v > 0).fold(double.infinity, math.min); + hi = values.fold(0.0, math.max); + if (!lo.isFinite || lo >= hi) { + lo = hi / 10; + } + lo /= 1.3; + hi *= 1.3; + } else { + lo = 0; + hi = values.reduce(math.max) * 1.1; + if (hi <= 0) hi = 1; + } + + Offset at(Rect plot, DateTime time, double v) { + final x = mapAcross( + time.millisecondsSinceEpoch.toDouble(), + t0, + t1, + plot.left, + plot.right, + ); + final y = logY + ? mapAcrossLog(v, lo, hi, plot.bottom, plot.top) ?? double.nan + : mapAcross(v, lo, hi, plot.bottom, plot.top); + return Offset(x, y); + } + + LatencyTouchDetails? hit(Offset local, Rect plot) { + if (!plot.inflate(2).contains(local) || usable.isEmpty) return null; + int? best; + var closest = double.infinity; + for (final i in usable) { + final x = at(plot, samples[i].time, samples[i].p50).dx; + final d = (x - local.dx).abs(); + if (d < closest) { + closest = d; + best = i; + } + } + if (best == null) return null; + return LatencyTouchDetails( + index: best, + sample: samples[best], + breached: breaches.contains(best), + ); + } + + final span = Duration(milliseconds: (t1 - t0).round()); + String formatTime(DateTime time) { + if (timeFormatter != null) return timeFormatter!(time); + String two(int n) => n.toString().padLeft(2, '0'); + if (span >= const Duration(days: 2)) { + const months = [ + 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', // + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', + ]; + return '${months[time.month - 1]} ${time.day}'; + } + return '${two(time.hour)}:${two(time.minute)}'; + } + + return PlotScaffold( + restartKey: Object.hashAll([...samples, slo, logY]), + insets: EdgeInsets.fromLTRB( + showAxes ? axisWidth : 0, + 0, + 0, + showAxes ? axisHeight : 0, + ), + padding: padding, + backgroundColor: backgroundColor, + defaultHeight: defaultHeight, + animationDuration: animationDuration, + animationCurve: animationCurve, + animateOnMount: animateOnMount, + semanticLabel: semanticLabel, + tooltipMargin: tooltipMargin, + onTouch: onTouch, + tooltipBuilder: tooltipBuilder, + hitTest: hit, + keyOf: (d) => d.index, + anchorOf: (d, plot) { + final x = at(plot, d.sample.time, d.sample.p50).dx; + return Rect.fromLTRB(x - 1, plot.top, x + 1, plot.bottom); + }, + painter: (plot, t, text, hovered) => _LatencyPainter( + chart: this, + usable: usable, + breaches: breaches.toSet(), + project: (time, v) => at(plot, time, v), + timeAt: (f) => + DateTime.fromMillisecondsSinceEpoch((t0 + (t1 - t0) * f).round()), + formatTime: formatTime, + lo: lo, + hi: hi, + hovered: hovered?.index, + plot: plot, + animation: t, + text: text, + backgroundColor: backgroundColor, + ), + ); + } +} + +class _LatencyPainter extends PlotPainter { + _LatencyPainter({ + required this.chart, + required this.usable, + required this.breaches, + required this.project, + required this.timeAt, + required this.formatTime, + required this.lo, + required this.hi, + required this.hovered, + required super.plot, + required super.animation, + required super.text, + super.backgroundColor, + }); + + final LatencyBandChart chart; + final List usable; + final Set breaches; + final Offset Function(DateTime time, double value) project; + final DateTime Function(double fraction) timeAt; + final String Function(DateTime time) formatTime; + final double lo, hi; + final int? hovered; + + @override + void paintPlot(Canvas canvas, Size size) { + paintXyAxes( + canvas, + text, + plot: plot, + // The time axis is labelled by hand below, from the formatter. + minX: 0, + maxX: 1, + minY: lo, + maxY: hi, + tickCount: chart.tickCount, + gridColor: chart.gridColor, + showAxes: chart.showAxes, + xLabels: const [], + yFormatter: chart.valueFormatter, + labelStyle: chart.axisLabelStyle, + yTitle: chart.yAxisTitle, + titleStyle: chart.axisTitleStyle, + axisWidth: chart.axisWidth, + axisHeight: chart.axisHeight, + logY: chart.logY, + ); + _timeAxis(canvas); + + final t = animation.clamp(0.0, 1.0); + if (usable.isEmpty) return; + canvas + ..save() + ..clipRect( + Rect.fromLTRB( + plot.left, + plot.top, + plot.left + plot.width * t + 1, + plot.bottom, + ), + ); + + List line(double Function(LatencySample s) of) => [ + for (final i in usable) + project(chart.samples[i].time, of(chart.samples[i])), + ]; + final p50 = line((s) => s.p50); + final p95 = line((s) => s.p95); + final p99 = line((s) => s.p99); + + void band(List upper, List lower, Color color) { + final points = [...upper, ...lower.reversed]; + if (points.length < 3 || + points.any((o) => !o.dx.isFinite || !o.dy.isFinite)) { + return; + } + final path = Path()..moveTo(points.first.dx, points.first.dy); + for (final o in points.skip(1)) { + path.lineTo(o.dx, o.dy); + } + path.close(); + canvas.drawPath(path, Paint()..color = color); + } + + band(p99, p95, chart.p99Color.withValues(alpha: 0.12)); + band(p95, p50, chart.p95Color.withValues(alpha: 0.22)); + + void stroke(List points, Color color, double width) { + final clean = points + .where((o) => o.dx.isFinite && o.dy.isFinite) + .toList(); + if (clean.length < 2) return; + final path = Path()..moveTo(clean.first.dx, clean.first.dy); + for (final o in clean.skip(1)) { + path.lineTo(o.dx, o.dy); + } + canvas.drawPath( + path, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = width + ..strokeJoin = StrokeJoin.round + ..color = color + ..isAntiAlias = true, + ); + } + + stroke(p99, chart.p99Color.withValues(alpha: 0.5), 1); + stroke(p95, chart.p95Color.withValues(alpha: 0.8), 1.2); + stroke(p50, chart.medianColor, chart.lineWidth); + canvas.restore(); + + _paintSlo(canvas); + _paintHover(canvas); + _paintLegend(canvas); + } + + void _timeAxis(Canvas canvas) { + if (!chart.showAxes || usable.isEmpty) return; + final style = const TextStyle( + fontSize: 10, + color: Color(0xFF8B95A1), + ).merge(chart.axisLabelStyle); + const count = 5; + var written = -double.infinity; + for (var i = 0; i < count; i++) { + final f = i / (count - 1); + final label = text.get(formatTime(timeAt(f)), style); + final x = plot.left + plot.width * f; + var left = x - label.width / 2; + left = left.clamp( + plot.left - 4, + math.max(plot.left, plot.right - label.width), + ); + if (left < written) continue; + label.paint(canvas, Offset(left, plot.bottom + 3)); + written = left + label.width + 6; + } + } + + void _paintSlo(Canvas canvas) { + final slo = chart.slo; + if (slo == null || !slo.isFinite || (chart.logY && slo <= 0)) return; + final y = project(chart.samples[usable.first].time, slo).dy; + if (!y.isFinite || y < plot.top || y > plot.bottom) return; + final pen = Paint() + ..color = chart.sloColor + ..strokeWidth = 1.2; + for (var x = plot.left; x < plot.right; x += 9) { + canvas.drawLine( + Offset(x, y), + Offset(math.min(x + 5, plot.right), y), + pen, + ); + } + if (chart.showBreaches) { + for (final i in breaches) { + if (!usable.contains(i)) continue; + final x = project(chart.samples[i].time, slo).dx; + canvas.drawRect( + Rect.fromLTWH(x - 1.5, plot.bottom - 5, 3, 5), + Paint()..color = chart.breachColor, + ); + } + } + } + + void _paintHover(Canvas canvas) { + final i = hovered; + if (i == null || i >= chart.samples.length) return; + final s = chart.samples[i]; + final x = project(s.time, s.p50).dx; + canvas.drawLine( + Offset(x, plot.top), + Offset(x, plot.bottom), + Paint() + ..color = chart.crosshairColor + ..strokeWidth = 1, + ); + for (final (v, color) in [ + (s.p50, chart.medianColor), + (s.p95, chart.p95Color), + (s.p99, chart.p99Color), + ]) { + final o = project(s.time, v); + if (!o.dx.isFinite || !o.dy.isFinite) continue; + canvas.drawCircle( + o, + 3.5, + Paint() + ..color = color + ..isAntiAlias = true, + ); + } + } + + void _paintLegend(Canvas canvas) { + if (!chart.showLegend) return; + final style = const TextStyle( + fontSize: 10, + color: Color(0xCCFFFFFF), + ).merge(chart.legendStyle); + var top = plot.top + 4; + for (final (name, color) in [ + ('p99', chart.p99Color.withValues(alpha: 0.6)), + ('p95', chart.p95Color.withValues(alpha: 0.85)), + ('p50', chart.medianColor), + ]) { + final tp = text.get(name, style); + final left = plot.right - 8 - tp.width; + canvas.drawLine( + Offset(left - 20, top + tp.height / 2), + Offset(left - 6, top + tp.height / 2), + Paint() + ..color = color + ..strokeWidth = 2, + ); + tp.paint(canvas, Offset(left, top)); + top += tp.height + 3; + } + } +} diff --git a/lib/src/ml/model_pareto_chart.dart b/lib/src/ml/model_pareto_chart.dart new file mode 100644 index 0000000..fd6dc1c --- /dev/null +++ b/lib/src/ml/model_pareto_chart.dart @@ -0,0 +1,607 @@ +import 'dart:math' as math; + +import 'package:flutter/widgets.dart'; + +import '../treemap/treemap_data.dart' show treemapPalette; +import '../utils/plot_scaffold.dart'; +import '../utils/xy_plot.dart'; + +/// One model on a cost against quality plot. +@immutable +class ModelPoint { + /// Creates a model [name] that costs [cost] and scores [score]. + const ModelPoint({ + required this.name, + required this.cost, + required this.score, + this.size, + this.group, + this.color, + this.data, + }); + + /// What the model is called. + final String name; + + /// What it costs: a price per million tokens, a latency, a number of + /// parameters. Lower is better, and positive on a logarithmic axis. + final double cost; + + /// How good it is: a benchmark score, an Elo rating. Higher is better. + final double score; + + /// What the bubble's area shows — the context window, the parameter count; + /// null draws every model the same size. + final double? size; + + /// Which family or provider it belongs to; models of a group share a colour. + final String? group; + + /// A colour of this model's own, over its group's. + final Color? color; + + /// Anything the app wants back when this model is touched. + final Object? data; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ModelPoint && + other.name == name && + other.cost == cost && + other.score == score && + other.size == size && + other.group == group && + other.color == color && + other.data == data; + + @override + int get hashCode => Object.hash(name, cost, score, size, group, color, data); +} + +/// The indices of the models on the Pareto frontier: those no other model +/// beats by being both cheaper and better. By rising cost. +/// +/// A model whose cost or score is not finite is never on it. Of two with the +/// same cost only the higher score is; of two with the same score only the +/// cheaper. +List paretoIndices(List models) { + final order = + [ + for (var i = 0; i < models.length; i++) + if (models[i].cost.isFinite && models[i].score.isFinite) i, + ]..sort((a, b) { + final byCost = models[a].cost.compareTo(models[b].cost); + if (byCost != 0) return byCost; + return models[b].score.compareTo(models[a].score); + }); + final frontier = []; + var best = double.negativeInfinity; + for (final i in order) { + if (models[i].score > best) { + frontier.add(i); + best = models[i].score; + } + } + return frontier; +} + +/// What a touch on a [ModelParetoChart] landed on. +@immutable +class ModelTouchDetails { + /// Creates the details of a touch on the model at [index]. + const ModelTouchDetails({ + required this.index, + required this.model, + required this.onFrontier, + required this.beatenBy, + }); + + /// The model's position in the chart's models. + final int index; + + /// The model touched. + final ModelPoint model; + + /// Whether nothing is both cheaper and better. + final bool onFrontier; + + /// The best frontier model that costs no more and scores at least as well — + /// what the same money buys instead; null for a model on the frontier. + final ModelPoint? beatenBy; +} + +/// Models by what they cost and how good they are, with the Pareto frontier +/// traced through the ones nothing beats. +/// +/// ```dart +/// ModelParetoChart( +/// models: const [ +/// ModelPoint(name: 'Large', cost: 15, score: 88, size: 200000, group: 'A'), +/// ModelPoint(name: 'Small', cost: 0.25, score: 71, size: 128000, group: 'A'), +/// ModelPoint(name: 'Open 70B', cost: 0.9, score: 79, group: 'B'), +/// ], +/// costFormatter: (v) => '\$$v', +/// ); +/// ``` +/// +/// Cost is on a logarithmic axis by default, since prices span decades. A model +/// on the frontier is the cheapest way to get its score; the others are drawn +/// faintly. The chart fills the box it is given, and is [defaultHeight] high in +/// a box with no height of its own. +class ModelParetoChart extends StatelessWidget { + /// Creates a chart of [models]. + const ModelParetoChart({ + super.key, + required this.models, + this.logCost = true, + this.minRadius = 6, + this.maxRadius = 20, + this.palette = treemapPalette, + this.showFrontier = true, + this.frontierColor = const Color(0x99FFFFFF), + this.dimOpacity = 0.35, + this.labelAll = false, + this.labelStyle, + this.showLegend = true, + this.legendStyle, + this.showAxes = true, + this.axisWidth = 44, + this.axisHeight = 18, + this.tickCount = 5, + this.costFormatter, + this.scoreFormatter, + this.axisLabelStyle, + this.xAxisTitle = 'Cost', + this.yAxisTitle = 'Score', + this.axisTitleStyle, + this.gridColor = const Color(0x22FFFFFF), + this.onTouch, + this.tooltipBuilder, + this.tooltipMargin = 10, + this.animationDuration = Duration.zero, + this.animationCurve = Curves.easeOutCubic, + this.animateOnMount = true, + this.padding = EdgeInsets.zero, + this.backgroundColor, + this.defaultHeight = 300, + this.semanticLabel, + }); + + /// The models. + final List models; + + /// Whether cost is on a logarithmic axis. Models that cost nothing or less + /// are not drawn on one. + final bool logCost; + + /// The radius of the smallest bubble. + final double minRadius; + + /// The radius of the largest. + final double maxRadius; + + /// Colours taken in turn by groups. + final List palette; + + /// Whether the frontier is traced. + final bool showFrontier; + + /// The colour of the frontier line. + final Color frontierColor; + + /// How opaque a model off the frontier is drawn. + final double dimOpacity; + + /// Whether every model is named, not only those on the frontier. + final bool labelAll; + + /// Style of a model's name. + final TextStyle? labelStyle; + + /// Whether the groups are listed in the corner. + final bool showLegend; + + /// Style of the legend. + final TextStyle? legendStyle; + + /// Whether the two axes are drawn. + final bool showAxes; + + /// How much room the score axis takes. + final double axisWidth; + + /// How much room the cost axis takes. + final double axisHeight; + + /// About how many ticks to write on each axis. + final int tickCount; + + /// Writes a cost; null writes at most two decimals. + final String Function(double value)? costFormatter; + + /// Writes a score; null writes at most two decimals. + final String Function(double value)? scoreFormatter; + + /// Style of an axis label. + final TextStyle? axisLabelStyle; + + /// Written under the cost axis. + final String? xAxisTitle; + + /// Written up the score axis. + final String? yAxisTitle; + + /// Style of an axis title. + final TextStyle? axisTitleStyle; + + /// Colour of the grid ruled at each tick; null rules none. + final Color? gridColor; + + /// Called as a touch moves over the models, and with null when it leaves. + final ValueChanged? onTouch; + + /// Builds a card shown beside the touched model; null shows none. + final Widget? Function(BuildContext context, ModelTouchDetails details)? + tooltipBuilder; + + /// How far the card sits from the bubble. + final double tooltipMargin; + + /// How long the bubbles take to grow in; zero draws them at once. + final Duration animationDuration; + + /// The easing of [animationDuration]. + final Curve animationCurve; + + /// Whether the first build animates. + final bool animateOnMount; + + /// Space kept clear around the chart. + final EdgeInsets padding; + + /// Painted behind the whole chart. + final Color? backgroundColor; + + /// The height taken in a box that sets none. + final double defaultHeight; + + /// What a screen reader announces for the chart. + final String? semanticLabel; + + @override + Widget build(BuildContext context) { + // The models that can be placed: finite, and positive cost on a log axis. + final drawn = [ + for (var i = 0; i < models.length; i++) + if (models[i].cost.isFinite && + models[i].score.isFinite && + (!logCost || models[i].cost > 0)) + i, + ]; + final frontier = paretoIndices([for (final i in drawn) models[i]]); + final onFrontier = {for (final f in frontier) drawn[f]}; + + var minCost = double.infinity; + var maxCost = double.negativeInfinity; + var minScore = double.infinity; + var maxScore = double.negativeInfinity; + var largest = 0.0; + for (final i in drawn) { + final m = models[i]; + minCost = math.min(minCost, m.cost); + maxCost = math.max(maxCost, m.cost); + minScore = math.min(minScore, m.score); + maxScore = math.max(maxScore, m.score); + if (m.size != null && m.size!.isFinite) { + largest = math.max(largest, m.size!); + } + } + if (drawn.isEmpty) { + minCost = logCost ? 0.1 : 0; + maxCost = logCost ? 10 : 1; + minScore = 0; + maxScore = 100; + } + if (minCost == maxCost) { + if (logCost) { + minCost /= 2; + maxCost *= 2; + } else { + minCost -= 1; + maxCost += 1; + } + } + final scoreRange = paddedRange([minScore, maxScore], pad: 0.12); + double x0 = minCost, x1 = maxCost; + if (logCost) { + final pad = math.pow(maxCost / minCost, 0.1).toDouble(); + x0 = minCost / pad; + x1 = maxCost * pad; + } else { + final r = paddedRange([minCost, maxCost], pad: 0.08); + x0 = r.min; + x1 = r.max; + } + + final groups = []; + for (final m in models) { + final g = m.group; + if (g != null && !groups.contains(g)) groups.add(g); + } + Color colorOf(ModelPoint m) { + if (m.color != null) return m.color!; + final g = m.group; + if (g == null || palette.isEmpty) return const Color(0xFF4C86CD); + return palette[groups.indexOf(g) % palette.length]; + } + + double radiusOf(ModelPoint m) { + final s = m.size; + if (s == null || !s.isFinite || s <= 0 || largest <= 0) return minRadius; + final share = (s / largest).clamp(0.0, 1.0); + return minRadius + (maxRadius - minRadius) * math.sqrt(share); + } + + Offset? at(Rect plot, ModelPoint m) { + final x = logCost + ? mapAcrossLog(m.cost, x0, x1, plot.left, plot.right) + : mapAcross(m.cost, x0, x1, plot.left, plot.right); + if (x == null) return null; + final y = mapAcross( + m.score, + scoreRange.min, + scoreRange.max, + plot.bottom, + plot.top, + ); + return Offset(x, y); + } + + ModelTouchDetails details(int index) { + final model = models[index]; + ModelPoint? beaten; + if (!onFrontier.contains(index)) { + // The best frontier model that is no dearer: what the same money buys. + for (final f in frontier) { + final m = models[drawn[f]]; + if (m.cost <= model.cost && m.score >= model.score) { + if (beaten == null || m.score > beaten.score) beaten = m; + } + } + } + return ModelTouchDetails( + index: index, + model: model, + onFrontier: onFrontier.contains(index), + beatenBy: beaten, + ); + } + + ModelTouchDetails? hit(Offset local, Rect plot) { + int? best; + var closest = double.infinity; + for (final i in drawn) { + final o = at(plot, models[i]); + if (o == null) continue; + final d = (o - local).distance; + if (d <= radiusOf(models[i]) + 4 && d < closest) { + closest = d; + best = i; + } + } + return best == null ? null : details(best); + } + + return PlotScaffold( + restartKey: Object.hashAll([...models, logCost]), + insets: EdgeInsets.fromLTRB( + showAxes ? axisWidth : 0, + 0, + 0, + showAxes ? axisHeight + (xAxisTitle != null ? 14 : 0) : 0, + ), + padding: padding, + backgroundColor: backgroundColor, + defaultHeight: defaultHeight, + animationDuration: animationDuration, + animationCurve: animationCurve, + animateOnMount: animateOnMount, + semanticLabel: semanticLabel, + tooltipMargin: tooltipMargin, + onTouch: onTouch, + tooltipBuilder: tooltipBuilder, + hitTest: hit, + keyOf: (d) => d.index, + anchorOf: (d, plot) { + final o = at(plot, d.model) ?? plot.center; + return Rect.fromCircle(center: o, radius: radiusOf(d.model)); + }, + painter: (plot, t, text, hovered) => _ParetoPainter( + chart: this, + drawn: drawn, + frontier: [for (final f in frontier) drawn[f]], + groups: groups, + x0: x0, + x1: x1, + y0: scoreRange.min, + y1: scoreRange.max, + place: (m) => at(plot, m), + radiusOf: radiusOf, + colorOf: colorOf, + hovered: hovered?.index, + plot: plot, + animation: t, + text: text, + backgroundColor: backgroundColor, + ), + ); + } +} + +class _ParetoPainter extends PlotPainter { + _ParetoPainter({ + required this.chart, + required this.drawn, + required this.frontier, + required this.groups, + required this.x0, + required this.x1, + required this.y0, + required this.y1, + required this.place, + required this.radiusOf, + required this.colorOf, + required this.hovered, + required super.plot, + required super.animation, + required super.text, + super.backgroundColor, + }); + + final ModelParetoChart chart; + final List drawn; + final List frontier; + final List groups; + final double x0, x1, y0, y1; + final Offset? Function(ModelPoint m) place; + final double Function(ModelPoint m) radiusOf; + final Color Function(ModelPoint m) colorOf; + final int? hovered; + + @override + void paintPlot(Canvas canvas, Size size) { + paintXyAxes( + canvas, + text, + plot: plot, + minX: x0, + maxX: x1, + minY: y0, + maxY: y1, + tickCount: chart.tickCount, + gridColor: chart.gridColor, + showAxes: chart.showAxes, + xFormatter: chart.costFormatter, + yFormatter: chart.scoreFormatter, + labelStyle: chart.axisLabelStyle, + xTitle: chart.xAxisTitle, + yTitle: chart.yAxisTitle, + titleStyle: chart.axisTitleStyle, + axisWidth: 44, + axisHeight: chart.axisHeight, + logX: chart.logCost, + ); + + final t = animation.clamp(0.0, 1.0); + final onFrontier = frontier.toSet(); + + if (chart.showFrontier && frontier.length > 1) { + final path = Path(); + var first = true; + for (final i in frontier) { + final o = place(chart.models[i]); + if (o == null) continue; + if (first) { + path.moveTo(o.dx, o.dy); + first = false; + } else { + path.lineTo(o.dx, o.dy); + } + } + canvas.drawPath( + path, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5 + ..strokeJoin = StrokeJoin.round + ..color = chart.frontierColor.withValues( + alpha: chart.frontierColor.a * t, + ) + ..isAntiAlias = true, + ); + } + + // Largest first, so a small model is never hidden under a large one. + final order = [...drawn] + ..sort( + (a, b) => + radiusOf(chart.models[b]).compareTo(radiusOf(chart.models[a])), + ); + for (final i in order) { + final m = chart.models[i]; + final o = place(m); + if (o == null || !o.dx.isFinite || !o.dy.isFinite) continue; + final color = colorOf(m); + final strong = onFrontier.contains(i) || hovered == i; + final alpha = strong ? 0.85 : chart.dimOpacity.clamp(0.0, 1.0); + final r = radiusOf(m) * t; + canvas + ..drawCircle( + o, + r, + Paint() + ..color = color.withValues(alpha: alpha) + ..isAntiAlias = true, + ) + ..drawCircle( + o, + r, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = hovered == i ? 2 : 1 + ..color = hovered == i ? const Color(0xFFFFFFFF) : color + ..isAntiAlias = true, + ); + } + + if (t >= 1) { + for (final i in order.reversed) { + final named = chart.labelAll || onFrontier.contains(i) || hovered == i; + if (named) _label(canvas, chart.models[i]); + } + } + _legend(canvas); + } + + void _label(Canvas canvas, ModelPoint m) { + final o = place(m); + if (o == null) return; + final tp = text.get( + m.name, + const TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: Color(0xDDFFFFFF), + ).merge(chart.labelStyle), + ); + var x = o.dx + radiusOf(m) + 4; + if (x + tp.width > plot.right) x = o.dx - radiusOf(m) - 4 - tp.width; + tp.paint(canvas, Offset(math.max(plot.left, x), o.dy - tp.height / 2)); + } + + void _legend(Canvas canvas) { + if (!chart.showLegend || groups.length < 2) return; + final style = const TextStyle( + fontSize: 10, + color: Color(0xCCFFFFFF), + ).merge(chart.legendStyle); + var top = plot.top + 4; + for (var g = 0; g < groups.length; g++) { + final tp = text.get(groups[g], style); + final left = plot.right - 8 - tp.width; + final color = chart.palette.isEmpty + ? const Color(0xFF4C86CD) + : chart.palette[g % chart.palette.length]; + canvas.drawCircle( + Offset(left - 8, top + tp.height / 2), + 4, + Paint() + ..color = color + ..isAntiAlias = true, + ); + tp.paint(canvas, Offset(left, top)); + top += tp.height + 3; + } + } +} diff --git a/lib/src/ml/scaling_law_chart.dart b/lib/src/ml/scaling_law_chart.dart new file mode 100644 index 0000000..d975cc5 --- /dev/null +++ b/lib/src/ml/scaling_law_chart.dart @@ -0,0 +1,716 @@ +import 'dart:math' as math; + +import 'package:flutter/widgets.dart'; + +import '../utils/plot_scaffold.dart'; +import '../utils/xy_plot.dart'; + +/// One trained model on a scaling plot: how much it was given, and the loss it +/// reached. +@immutable +class ScalingPoint { + /// Creates a model that reached [loss] with [scale] of whatever the x axis + /// counts — parameters, training tokens, FLOPs. + const ScalingPoint({ + required this.scale, + required this.loss, + this.label, + this.color, + this.data, + }); + + /// The amount of compute, data or parameters; positive. + final double scale; + + /// The loss, or any other error measure; positive. + final double loss; + + /// What the model is called. + final String? label; + + /// A colour of this point's own. + final Color? color; + + /// Anything the app wants back when this point is touched. + final Object? data; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ScalingPoint && + other.scale == scale && + other.loss == loss && + other.label == label && + other.color == color && + other.data == data; + + @override + int get hashCode => Object.hash(scale, loss, label, color, data); +} + +/// A power law `loss = coefficient × scale ^ exponent`, fitted to points. +@immutable +class PowerLawFit { + /// Creates a fit from its parts; see [fitPowerLaw]. + const PowerLawFit({ + required this.coefficient, + required this.exponent, + required this.rSquared, + required this.logResidual, + required this.count, + }); + + /// The loss at a scale of 1. + final double coefficient; + + /// The slope on a log-log plot; negative when more scale lowers the loss. + final double exponent; + + /// How much of the variation in log loss the line explains, from 0 to 1. + final double rSquared; + + /// The standard deviation of the points' distance from the line in log10 of + /// the loss: 0.05 is points typically within 12% of the fit. + final double logResidual; + + /// How many points were fitted. + final int count; + + /// The loss the fit predicts at [scale]. + double predict(double scale) => + coefficient * math.pow(scale, exponent).toDouble(); + + /// The scale at which the fit predicts [loss]; null when the line is flat or + /// the loss is not a positive number. + double? scaleFor(double loss) { + if (!loss.isFinite || loss <= 0 || exponent == 0 || coefficient <= 0) { + return null; + } + final scale = math.pow(loss / coefficient, 1 / exponent).toDouble(); + return scale.isFinite && scale > 0 ? scale : null; + } +} + +/// Fits a power law to [points] by least squares on their logarithms. +/// +/// A point whose scale or loss is not a positive finite number is skipped. Null +/// when fewer than two points remain, or they all share one scale. +PowerLawFit? fitPowerLaw(Iterable points) { + final xs = []; + final ys = []; + for (final p in points) { + final x = log10OrNull(p.scale); + final y = log10OrNull(p.loss); + if (x == null || y == null) continue; + xs.add(x); + ys.add(y); + } + final n = xs.length; + if (n < 2) return null; + + final meanX = xs.reduce((a, b) => a + b) / n; + final meanY = ys.reduce((a, b) => a + b) / n; + var sxx = 0.0; + var sxy = 0.0; + var syy = 0.0; + for (var i = 0; i < n; i++) { + final dx = xs[i] - meanX; + final dy = ys[i] - meanY; + sxx += dx * dx; + sxy += dx * dy; + syy += dy * dy; + } + if (sxx <= 0) return null; + + final slope = sxy / sxx; + final intercept = meanY - slope * meanX; + var residuals = 0.0; + for (var i = 0; i < n; i++) { + final e = ys[i] - (intercept + slope * xs[i]); + residuals += e * e; + } + return PowerLawFit( + coefficient: math.pow(10.0, intercept).toDouble(), + exponent: slope, + rSquared: syy <= 0 ? 1 : (1 - residuals / syy).clamp(0.0, 1.0), + logResidual: n > 2 ? math.sqrt(residuals / (n - 2)) : 0, + count: n, + ); +} + +/// What a touch on a [ScalingLawChart] landed on. +@immutable +class ScalingTouchDetails { + /// Creates the details of a touch on the point at [index]. + const ScalingTouchDetails({ + required this.index, + required this.point, + required this.predicted, + }); + + /// The point's position in the chart's points. + final int index; + + /// The point touched. + final ScalingPoint point; + + /// What the fit predicts at its scale; null with no fit. + final double? predicted; + + /// How far above the fit the point sits, as a ratio: 1.1 is 10% worse than + /// predicted. Null with no fit. + double? get ratio { + final p = predicted; + return p == null || p <= 0 ? null : point.loss / p; + } +} + +/// Loss against scale on log-log axes, with the power law through them and its +/// extrapolation — a scaling-law plot. +/// +/// ```dart +/// ScalingLawChart( +/// points: [ +/// for (final m in runs) ScalingPoint(scale: m.flops, loss: m.loss), +/// ], +/// extrapolateTo: 1e24, +/// targetLoss: 1.8, +/// ); +/// ``` +/// +/// The fit is drawn solid over the range of the points and dashed beyond it, +/// with a band one residual standard deviation either side. [targetLoss] marks +/// where the line is predicted to reach a loss. The chart fills the box it is +/// given, and is [defaultHeight] high in a box with no height of its own. +class ScalingLawChart extends StatelessWidget { + /// Creates a chart of [points]. + const ScalingLawChart({ + super.key, + required this.points, + this.fitFrom, + this.extrapolateTo, + this.showFit = true, + this.showBand = true, + this.targetLoss, + this.pointColor = const Color(0xFF4C86CD), + this.fitColor = const Color(0xFFF2B01E), + this.bandColor = const Color(0x22F2B01E), + this.targetColor = const Color(0xFF2F9E44), + this.dotRadius = 5, + this.showLabels = true, + this.labelStyle, + this.showFitLabel = true, + this.showAxes = true, + this.axisWidth = 48, + this.axisHeight = 18, + this.tickCount = 5, + this.scaleFormatter, + this.lossFormatter, + this.axisLabelStyle, + this.xAxisTitle = 'Compute', + this.yAxisTitle = 'Loss', + this.axisTitleStyle, + this.gridColor = const Color(0x22FFFFFF), + this.onTouch, + this.tooltipBuilder, + this.tooltipMargin = 10, + this.animationDuration = Duration.zero, + this.animationCurve = Curves.easeOutCubic, + this.animateOnMount = true, + this.padding = EdgeInsets.zero, + this.backgroundColor, + this.defaultHeight = 280, + this.semanticLabel, + }); + + /// The models. + final List points; + + /// Fit only the points at or above this scale, to leave out the small models + /// that sit off the law; null fits them all. + final double? fitFrom; + + /// How far to carry the fit to the right, as a scale; null stops at the + /// largest point. + final double? extrapolateTo; + + /// Whether the fitted line is drawn. + final bool showFit; + + /// Whether the band round the fit is drawn. + final bool showBand; + + /// A loss to mark on the fit: where the line is predicted to reach it. + final double? targetLoss; + + /// The colour of a point. + final Color pointColor; + + /// The colour of the fit. + final Color fitColor; + + /// The colour of the band. + final Color bandColor; + + /// The colour of the target marker. + final Color targetColor; + + /// The radius of a point. + final double dotRadius; + + /// Whether each point's label is written beside it. + final bool showLabels; + + /// Style of a point's label. + final TextStyle? labelStyle; + + /// Whether the fitted law is written in the corner. + final bool showFitLabel; + + /// Whether the two axes are drawn. + final bool showAxes; + + /// How much room the loss axis takes. + final double axisWidth; + + /// How much room the scale axis takes. + final double axisHeight; + + /// About how many ticks to write on each axis. + final int tickCount; + + /// Writes a scale; null writes it in scientific form. + final String Function(double value)? scaleFormatter; + + /// Writes a loss; null writes at most two decimals. + final String Function(double value)? lossFormatter; + + /// Style of an axis label. + final TextStyle? axisLabelStyle; + + /// Written under the scale axis. + final String? xAxisTitle; + + /// Written up the loss axis. + final String? yAxisTitle; + + /// Style of an axis title. + final TextStyle? axisTitleStyle; + + /// Colour of the grid ruled at each tick; null rules none. + final Color? gridColor; + + /// Called as a touch moves over the points, and with null when it leaves. + final ValueChanged? onTouch; + + /// Builds a card shown beside the touched point; null shows none. + final Widget? Function(BuildContext context, ScalingTouchDetails details)? + tooltipBuilder; + + /// How far the card sits from the point. + final double tooltipMargin; + + /// How long the plot takes to draw in; zero draws it at once. + final Duration animationDuration; + + /// The easing of [animationDuration]. + final Curve animationCurve; + + /// Whether the first build animates. + final bool animateOnMount; + + /// Space kept clear around the chart. + final EdgeInsets padding; + + /// Painted behind the whole chart. + final Color? backgroundColor; + + /// The height taken in a box that sets none. + final double defaultHeight; + + /// What a screen reader announces for the chart. + final String? semanticLabel; + + /// The fit the chart draws, or null when there are too few points. + PowerLawFit? get fit => fitPowerLaw( + fitFrom == null + ? points + : [ + for (final p in points) + if (p.scale >= fitFrom!) p, + ], + ); + + static String _scientific(double v) { + if (!v.isFinite || v <= 0) return '–'; + var exponent = (math.log(v) / math.ln10 + 1e-9).floor(); + var mantissa = v / math.pow(10.0, exponent); + // Rounding to one decimal can carry into the next power: 9.97 -> 10.0. + if (double.parse(mantissa.toStringAsFixed(1)) >= 10) { + exponent += 1; + mantissa = v / math.pow(10.0, exponent); + } + var m = mantissa.toStringAsFixed(1); + if (m.endsWith('.0')) m = m.substring(0, m.length - 2); + return m == '1' ? '1e$exponent' : '${m}e$exponent'; + } + + @override + Widget build(BuildContext context) { + final law = fit; + final valid = [ + for (var i = 0; i < points.length; i++) + if (log10OrNull(points[i].scale) != null && + log10OrNull(points[i].loss) != null) + i, + ]; + + var minX = double.infinity; + var maxX = 0.0; + var minY = double.infinity; + var maxY = 0.0; + for (final i in valid) { + minX = math.min(minX, points[i].scale); + maxX = math.max(maxX, points[i].scale); + minY = math.min(minY, points[i].loss); + maxY = math.max(maxY, points[i].loss); + } + if (valid.isEmpty) { + minX = 1; + maxX = 100; + minY = 1; + maxY = 10; + } + final reach = extrapolateTo; + if (reach != null && reach.isFinite && reach > maxX) maxX = reach; + if (law != null && showFit) { + for (final x in [minX, maxX]) { + final y = law.predict(x); + if (y.isFinite && y > 0) { + minY = math.min(minY, y); + maxY = math.max(maxY, y); + } + } + } + final target = targetLoss; + if (target != null && target.isFinite && target > 0) { + minY = math.min(minY, target); + maxY = math.max(maxY, target); + } + if (minX == maxX) { + minX /= 2; + maxX *= 2; + } + if (minY == maxY) { + minY /= 2; + maxY *= 2; + } + // A little room, in the log: the extremes are not drawn on the frame. + final xPad = math.pow(maxX / minX, 0.04).toDouble(); + final yPad = math.pow(maxY / minY, 0.1).toDouble(); + final x0 = minX / xPad, x1 = maxX * xPad; + final y0 = minY / yPad, y1 = maxY * yPad; + + Offset? at(Rect plot, double scale, double loss) { + final x = mapAcrossLog(scale, x0, x1, plot.left, plot.right); + final y = mapAcrossLog(loss, y0, y1, plot.bottom, plot.top); + if (x == null || y == null) return null; + return Offset(x, y); + } + + ScalingTouchDetails? hit(Offset local, Rect plot) { + int? best; + var closest = dotRadius + 8; + for (final i in valid) { + final o = at(plot, points[i].scale, points[i].loss); + if (o == null) continue; + final d = (o - local).distance; + if (d <= closest) { + closest = d; + best = i; + } + } + if (best == null) return null; + return ScalingTouchDetails( + index: best, + point: points[best], + predicted: law?.predict(points[best].scale), + ); + } + + return PlotScaffold( + restartKey: Object.hashAll([ + ...points, + fitFrom, + extrapolateTo, + targetLoss, + ]), + insets: EdgeInsets.fromLTRB( + showAxes ? axisWidth : 0, + 0, + 0, + showAxes ? axisHeight + (xAxisTitle != null ? 14 : 0) : 0, + ), + padding: padding, + backgroundColor: backgroundColor, + defaultHeight: defaultHeight, + animationDuration: animationDuration, + animationCurve: animationCurve, + animateOnMount: animateOnMount, + semanticLabel: semanticLabel, + tooltipMargin: tooltipMargin, + onTouch: onTouch, + tooltipBuilder: tooltipBuilder, + hitTest: hit, + keyOf: (d) => d.index, + anchorOf: (d, plot) { + final o = at(plot, d.point.scale, d.point.loss) ?? plot.center; + return Rect.fromCircle(center: o, radius: dotRadius); + }, + painter: (plot, t, text, hovered) => _ScalingPainter( + chart: this, + law: law, + valid: valid, + x0: x0, + x1: x1, + y0: y0, + y1: y1, + project: (s, l) => at(plot, s, l), + hovered: hovered, + plot: plot, + animation: t, + text: text, + backgroundColor: backgroundColor, + ), + ); + } +} + +class _ScalingPainter extends PlotPainter { + _ScalingPainter({ + required this.chart, + required this.law, + required this.valid, + required this.x0, + required this.x1, + required this.y0, + required this.y1, + required this.project, + required this.hovered, + required super.plot, + required super.animation, + required super.text, + super.backgroundColor, + }); + + final ScalingLawChart chart; + final PowerLawFit? law; + final List valid; + final double x0, x1, y0, y1; + final Offset? Function(double scale, double loss) project; + final ScalingTouchDetails? hovered; + + @override + void paintPlot(Canvas canvas, Size size) { + paintXyAxes( + canvas, + text, + plot: plot, + minX: x0, + maxX: x1, + minY: y0, + maxY: y1, + tickCount: chart.tickCount, + gridColor: chart.gridColor, + showAxes: chart.showAxes, + xFormatter: chart.scaleFormatter ?? ScalingLawChart._scientific, + yFormatter: chart.lossFormatter, + labelStyle: chart.axisLabelStyle, + xTitle: chart.xAxisTitle, + yTitle: chart.yAxisTitle, + titleStyle: chart.axisTitleStyle, + axisWidth: chart.axisWidth, + axisHeight: chart.axisHeight, + logX: true, + logY: true, + ); + + final t = animation.clamp(0.0, 1.0); + canvas + ..save() + ..clipRect(plot); + _paintFit(canvas, t); + _paintTarget(canvas); + canvas.restore(); + _paintPoints(canvas, t); + _paintFitLabel(canvas); + } + + /// The fitted line from [from] to [to], as points a short way apart in log x. + List _line(double from, double to, double multiplier) { + final fit = law; + if (fit == null || from <= 0 || to <= from) return const []; + const steps = 48; + final out = []; + for (var i = 0; i <= steps; i++) { + final x = from * math.pow(to / from, i / steps).toDouble(); + final o = project(x, fit.predict(x) * multiplier); + if (o != null && o.dx.isFinite && o.dy.isFinite) out.add(o); + } + return out; + } + + void _paintFit(Canvas canvas, double t) { + final fit = law; + if (fit == null || !chart.showFit || valid.isEmpty) return; + var lo = double.infinity; + var hi = 0.0; + for (final i in valid) { + lo = math.min(lo, chart.points[i].scale); + hi = math.max(hi, chart.points[i].scale); + } + final end = chart.extrapolateTo != null && chart.extrapolateTo! > hi + ? chart.extrapolateTo! + : hi; + final start = chart.fitFrom != null ? math.max(lo, chart.fitFrom!) : lo; + + if (chart.showBand && fit.logResidual > 0) { + final up = math.pow(10.0, fit.logResidual).toDouble(); + final top = _line(start, end, up); + final bottom = _line(start, end, 1 / up); + if (top.length > 1 && bottom.length > 1) { + final path = Path()..moveTo(top.first.dx, top.first.dy); + for (final o in top.skip(1)) { + path.lineTo(o.dx, o.dy); + } + for (final o in bottom.reversed) { + path.lineTo(o.dx, o.dy); + } + path.close(); + canvas.drawPath(path, Paint()..color = chart.bandColor); + } + } + + final pen = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 2 + ..strokeCap = StrokeCap.round + ..color = chart.fitColor.withValues(alpha: t) + ..isAntiAlias = true; + // Solid across the data, dashed beyond it. + final within = _line(start, hi, 1); + if (within.length > 1) { + final path = Path()..moveTo(within.first.dx, within.first.dy); + for (final o in within.skip(1)) { + path.lineTo(o.dx, o.dy); + } + canvas.drawPath(path, pen); + } + final beyond = _line(hi, end, 1); + for (var i = 1; i < beyond.length; i += 2) { + canvas.drawLine(beyond[i - 1], beyond[i], pen); + } + } + + void _paintTarget(Canvas canvas) { + final target = chart.targetLoss; + if (target == null || !target.isFinite || target <= 0) return; + final left = project(x0, target); + final right = project(x1, target); + if (left == null || right == null) return; + final pen = Paint() + ..color = chart.targetColor.withValues(alpha: 0.7) + ..strokeWidth = 1; + canvas.drawLine( + Offset(plot.left, left.dy), + Offset(plot.right, left.dy), + pen, + ); + + final reachAt = law?.scaleFor(target); + if (reachAt == null) return; + final o = project(reachAt, target); + if (o == null || !plot.inflate(1).contains(o)) return; + canvas.drawCircle( + o, + 6, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 2 + ..color = chart.targetColor + ..isAntiAlias = true, + ); + final label = ScalingLawChart._scientific(reachAt); + final tp = text.get( + '$label at ${chart.lossFormatter?.call(target) ?? formatPlotNumber(target)}', + TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: chart.targetColor, + ), + ); + // Below the line and to the left of the ring, off the fitted line that + // comes down into it from the upper left. + final x = math.max(plot.left, o.dx + 8 - tp.width); + tp.paint(canvas, Offset(x, o.dy + 8)); + } + + void _paintPoints(Canvas canvas, double t) { + for (final i in valid) { + final p = chart.points[i]; + final o = project(p.scale, p.loss); + if (o == null || !o.dx.isFinite || !o.dy.isFinite) continue; + final color = p.color ?? chart.pointColor; + canvas.drawCircle( + o, + chart.dotRadius * t, + Paint() + ..color = color + ..isAntiAlias = true, + ); + if (hovered?.index == i) { + canvas.drawCircle( + o, + chart.dotRadius + 3, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5 + ..color = const Color(0xCCFFFFFF) + ..isAntiAlias = true, + ); + } + final name = p.label; + if (chart.showLabels && name != null && name.isNotEmpty && t >= 1) { + final tp = text.get( + name, + const TextStyle( + fontSize: 10, + color: Color(0xCCFFFFFF), + ).merge(chart.labelStyle), + ); + // Above and to the right: the fit descends to the right, so that + // corner is the empty one. + var x = o.dx + chart.dotRadius + 3; + if (x + tp.width > plot.right) { + x = o.dx - chart.dotRadius - 3 - tp.width; + } + tp.paint(canvas, Offset(x, o.dy - chart.dotRadius - tp.height + 2)); + } + } + } + + void _paintFitLabel(Canvas canvas) { + final fit = law; + if (fit == null || !chart.showFit || !chart.showFitLabel) return; + final exponent = fit.exponent.toStringAsFixed(3); + final tp = text.get( + 'L ∝ C^$exponent R² ${fit.rSquared.toStringAsFixed(3)}', + TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: chart.fitColor, + ), + ); + tp.paint(canvas, Offset(plot.right - tp.width - 8, plot.top + 6)); + } +} diff --git a/lib/src/ml/sweep_chart.dart b/lib/src/ml/sweep_chart.dart new file mode 100644 index 0000000..55fd40e --- /dev/null +++ b/lib/src/ml/sweep_chart.dart @@ -0,0 +1,477 @@ +import 'dart:math' as math; + +import 'package:flutter/foundation.dart' show listEquals; +import 'package:flutter/widgets.dart'; + +import '../heatmap/heatmap_chart.dart'; +import '../heatmap/heatmap_data.dart'; + +/// One trial of a hyperparameter sweep: two settings and the score they got. +@immutable +class SweepTrial { + /// Creates a trial that scored [score] with [x] on the bottom axis and [y] + /// on the side axis — a learning rate and a batch size, say. + const SweepTrial({ + required this.x, + required this.y, + required this.score, + this.data, + }); + + /// The setting along the bottom axis. + final double x; + + /// The setting up the side axis. + final double y; + + /// The result: a validation loss, an accuracy. + final double score; + + /// Anything the app wants back when this trial is touched. + final Object? data; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SweepTrial && + other.x == x && + other.y == y && + other.score == score && + other.data == data; + + @override + int get hashCode => Object.hash(x, y, score, data); +} + +/// A sweep's trials gathered into a grid: the distinct settings of each axis +/// and the score of each combination. +@immutable +class SweepGrid { + /// Creates a grid from its parts; see [sweepGrid]. + const SweepGrid({ + required this.xs, + required this.ys, + required this.scores, + required this.counts, + required this.bestX, + required this.bestY, + }); + + /// The distinct values of the bottom setting, ascending. + final List xs; + + /// The distinct values of the side setting, ascending. + final List ys; + + /// The score at each combination, `scores[yIndex][xIndex]`: the mean where a + /// combination was tried more than once, null where it was not tried. + final List> scores; + + /// How many trials fell on each combination. + final List> counts; + + /// The column of the best score, or null with no score. + final int? bestX; + + /// The row of the best score. + final int? bestY; + + /// The best score. + double? get best => + bestX == null || bestY == null ? null : scores[bestY!][bestX!]; + + /// The mean score of each column — what a setting is worth whatever the other + /// is — null where the column holds none. + List get columnMeans => [ + for (var x = 0; x < xs.length; x++) + _mean([for (final row in scores) row[x]]), + ]; + + /// The mean score of each row. + List get rowMeans => [for (final row in scores) _mean(row)]; + + static double? _mean(List values) { + var sum = 0.0; + var n = 0; + for (final v in values) { + if (v == null) continue; + sum += v; + n++; + } + return n == 0 ? null : sum / n; + } +} + +/// Gathers [trials] into a [SweepGrid]. +/// +/// A trial whose settings or score are not finite is skipped. [lowerIsBetter] +/// says which end of the scores is the best. +SweepGrid sweepGrid(Iterable trials, {bool lowerIsBetter = true}) { + final valid = [ + for (final t in trials) + if (t.x.isFinite && t.y.isFinite && t.score.isFinite) t, + ]; + final xs = ({for (final t in valid) t.x}.toList())..sort(); + final ys = ({for (final t in valid) t.y}.toList())..sort(); + final sums = [for (final _ in ys) List.filled(xs.length, 0)]; + final counts = [for (final _ in ys) List.filled(xs.length, 0)]; + for (final t in valid) { + final xi = xs.indexOf(t.x); + final yi = ys.indexOf(t.y); + sums[yi][xi] += t.score; + counts[yi][xi]++; + } + final scores = [ + for (var y = 0; y < ys.length; y++) + [ + for (var x = 0; x < xs.length; x++) + counts[y][x] == 0 ? null : sums[y][x] / counts[y][x], + ], + ]; + + int? bestX; + int? bestY; + double? top; + for (var y = 0; y < ys.length; y++) { + for (var x = 0; x < xs.length; x++) { + final v = scores[y][x]; + if (v == null) continue; + if (top == null || (lowerIsBetter ? v < top : v > top)) { + top = v; + bestX = x; + bestY = y; + } + } + } + return SweepGrid( + xs: xs, + ys: ys, + scores: scores, + counts: counts, + bestX: bestX, + bestY: bestY, + ); +} + +/// What a touch on a [SweepChart] landed on. +@immutable +class SweepTouchDetails { + /// Creates the details of a touch on a square. + const SweepTouchDetails({ + required this.xIndex, + required this.yIndex, + required this.x, + required this.y, + required this.score, + required this.count, + required this.isBest, + required this.isMarginal, + required this.rect, + }); + + /// The column, counted from the left; the mean column is one past the last. + final int xIndex; + + /// The row, counted from the top; the mean row is one past the last. + final int yIndex; + + /// The bottom setting there; null in the mean column. + final double? x; + + /// The side setting there; null in the mean row. + final double? y; + + /// The score, or the mean for a marginal; null where nothing was tried. + final double? score; + + /// How many trials fell there; zero for a marginal. + final int count; + + /// Whether this is the best combination. + final bool isBest; + + /// Whether this is a mean rather than a combination. + final bool isMarginal; + + /// The square, in the chart's local pixels. + final Rect rect; +} + +/// A hyperparameter sweep as a grid of scores, the best combination starred. +/// +/// ```dart +/// SweepChart( +/// trials: [ +/// for (final r in runs) +/// SweepTrial(x: r.learningRate, y: r.batchSize, score: r.valLoss), +/// ], +/// xFormatter: (v) => v.toStringAsExponential(0), +/// xAxisLabel: 'learning rate', +/// yAxisLabel: 'batch size', +/// ); +/// ``` +/// +/// Squares run from [goodColor] to [badColor] by how well they did. A last +/// column and row hold the mean of each setting — what it is worth whatever the +/// other is. The chart fills the box it is given, and is [defaultHeight] high +/// in a box with no height of its own. +class SweepChart extends StatelessWidget { + /// Creates a grid of [trials]. + const SweepChart({ + super.key, + required this.trials, + this.lowerIsBetter = true, + this.goodColor = const Color(0xFF2F9E44), + this.midColor = const Color(0xFFE8C547), + this.badColor = const Color(0xFFE03131), + this.showMarginals = true, + this.marginalLabel = 'mean', + this.showValues = true, + this.scoreFormatter, + this.xFormatter, + this.yFormatter, + this.xAxisLabel, + this.yAxisLabel, + this.labelStyle, + this.axisLabelStyle, + this.yAxisWidth = 56, + this.xAxisHeight = 20, + this.spacing = 1, + this.radius = const BorderRadius.all(Radius.circular(2)), + this.onTouch, + this.tooltipBuilder, + this.animationDuration = Duration.zero, + this.animationCurve = Curves.easeOutCubic, + this.animateOnMount = true, + this.padding = EdgeInsets.zero, + this.backgroundColor, + this.defaultHeight = 260, + this.semanticLabel, + }); + + /// The trials; ones that share both settings are averaged. + final List trials; + + /// Whether a lower score is better — a loss — or a higher one. + final bool lowerIsBetter; + + /// The colour of the best scores. + final Color goodColor; + + /// The colour half way. + final Color midColor; + + /// The colour of the worst. + final Color badColor; + + /// Whether a last column and row hold the mean of each setting. + final bool showMarginals; + + /// What the marginals are called on the axes. + final String marginalLabel; + + /// Whether each score is written in its square. + final bool showValues; + + /// Writes a score; null writes at most three significant digits. + final String Function(double score)? scoreFormatter; + + /// Writes a bottom setting; null writes it compactly. + final String Function(double value)? xFormatter; + + /// Writes a side setting; null writes it compactly. + final String Function(double value)? yFormatter; + + /// Names the bottom setting, written in the axis's corner label. + final String? xAxisLabel; + + /// Names the side setting. + final String? yAxisLabel; + + /// Style of the scores in the squares. + final TextStyle? labelStyle; + + /// Style of the setting names. + final TextStyle? axisLabelStyle; + + /// Room held for the side settings. + final double yAxisWidth; + + /// Room held for the bottom settings. + final double xAxisHeight; + + /// The gap between squares. + final double spacing; + + /// The rounding of each square's corners. + final BorderRadius radius; + + /// Called as a touch moves over the squares, and with null when it leaves. + final ValueChanged? onTouch; + + /// Builds a card shown beside the touched square; null shows none. + final Widget? Function(BuildContext context, SweepTouchDetails details)? + tooltipBuilder; + + /// How long the squares take to fade in; zero draws them at once. + final Duration animationDuration; + + /// The easing of [animationDuration]. + final Curve animationCurve; + + /// Whether the first build animates. + final bool animateOnMount; + + /// Space kept clear around the chart. + final EdgeInsets padding; + + /// Painted behind the whole chart. + final Color? backgroundColor; + + /// The height taken in a box that sets none. + final double defaultHeight; + + /// What a screen reader announces for the chart. + final String? semanticLabel; + + /// Writes a setting compactly: `0.0003` as `3e-4`, `128` as `128`. + static String formatSetting(double value) { + if (value == 0) return '0'; + final abs = value.abs(); + if (abs >= 1e4 || abs < 1e-2) { + final text = value.toStringAsExponential(0); + return text.replaceFirst('e+', 'e'); + } + final rounded = double.parse(value.toStringAsFixed(3)); + return rounded == rounded.roundToDouble() + ? rounded.toStringAsFixed(0) + : rounded.toString(); + } + + static String _score(double v) { + if (v == 0) return '0'; + final digits = v.abs() >= 100 + ? 0 + : v.abs() >= 10 + ? 1 + : v.abs() >= 1 + ? 2 + : 3; + return v.toStringAsFixed(digits); + } + + @override + Widget build(BuildContext context) { + final grid = sweepGrid(trials, lowerIsBetter: lowerIsBetter); + final format = scoreFormatter ?? _score; + final columns = grid.xs.length + (showMarginals ? 1 : 0); + final rows = grid.ys.length + (showMarginals ? 1 : 0); + final bestRow = grid.bestY; + final bestCol = grid.bestX; + + final cells = [ + for (var y = 0; y < grid.ys.length; y++) + for (var x = 0; x < grid.xs.length; x++) + if (grid.scores[y][x] case final v?) + HeatmapCell( + x: x, + y: y, + value: v, + label: x == bestCol && y == bestRow + ? '★ ${format(v)}' + : format(v), + ), + if (showMarginals) ...[ + for (var y = 0; y < grid.ys.length; y++) + if (grid.rowMeans[y] case final v?) + HeatmapCell(x: grid.xs.length, y: y, value: v, label: format(v)), + for (var x = 0; x < grid.xs.length; x++) + if (grid.columnMeans[x] case final v?) + HeatmapCell(x: x, y: grid.ys.length, value: v, label: format(v)), + ], + ]; + + var low = double.infinity; + var high = double.negativeInfinity; + for (final c in cells) { + if (c.isEmpty) continue; + low = math.min(low, c.value!); + high = math.max(high, c.value!); + } + if (!low.isFinite) { + low = 0; + high = 1; + } + + final scale = HeatmapGradientScale( + colors: lowerIsBetter + ? [goodColor, midColor, badColor] + : [badColor, midColor, goodColor], + ); + + String xLabel(int c) => c < grid.xs.length + ? (xFormatter ?? formatSetting)(grid.xs[c]) + : marginalLabel; + String yLabel(int r) => r < grid.ys.length + ? (yFormatter ?? formatSetting)(grid.ys[r]) + : marginalLabel; + + SweepTouchDetails details(HeatmapTouchDetails t) { + final inX = t.x < grid.xs.length; + final inY = t.y < grid.ys.length; + return SweepTouchDetails( + xIndex: t.x, + yIndex: t.y, + x: inX ? grid.xs[t.x] : null, + y: inY ? grid.ys[t.y] : null, + score: t.value, + count: inX && inY ? grid.counts[t.y][t.x] : 0, + isBest: inX && inY && t.x == bestCol && t.y == bestRow, + isMarginal: !inX || !inY, + rect: t.rect, + ); + } + + final touch = onTouch; + final builder = tooltipBuilder; + return HeatmapChart( + cells: cells, + columns: columns, + rows: rows, + scale: scale, + minValue: low, + maxValue: high, + xAxis: HeatmapAxis( + labelBuilder: xLabel, + size: xAxisHeight, + style: axisLabelStyle, + ), + yAxis: HeatmapAxis( + labelBuilder: yLabel, + size: yAxisWidth, + style: axisLabelStyle, + ), + spacing: spacing, + radius: radius, + labelBuilder: showValues ? (cell) => cell.label : null, + labelStyle: labelStyle, + onTouch: touch == null + ? null + : (t) => touch(t == null ? null : details(t)), + tooltipBuilder: builder == null + ? null + : (context, t) => builder(context, details(t)), + animationDuration: animationDuration, + animationCurve: animationCurve, + animateOnMount: animateOnMount, + padding: padding, + backgroundColor: backgroundColor, + defaultHeight: defaultHeight, + semanticLabel: semanticLabel, + ); + } + + /// Whether [a] and [b] are the same trials, for a parent deciding to rebuild. + static bool sameTrials(List a, List b) => + listEquals(a, b); +} diff --git a/lib/src/ml/token_heatmap.dart b/lib/src/ml/token_heatmap.dart new file mode 100644 index 0000000..055f6fb --- /dev/null +++ b/lib/src/ml/token_heatmap.dart @@ -0,0 +1,1100 @@ +import 'dart:math' as math; + +import 'package:flutter/foundation.dart' show listEquals; +import 'package:flutter/widgets.dart'; + +import '../heatmap/heatmap_data.dart'; +import '../utils/plot_scaffold.dart'; + +/// A token the model could have written instead of the one it did. +@immutable +class TokenAlternative { + /// Creates the alternative [token], which the model gave [probability]. + const TokenAlternative(this.token, this.probability); + + /// The alternative's text. + final String token; + + /// The probability the model gave it, 0 to 1. + final double probability; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is TokenAlternative && + other.token == token && + other.probability == probability; + + @override + int get hashCode => Object.hash(token, probability); +} + +/// One token of a model's text, with a number that says something about it. +@immutable +class TokenScore { + /// Creates a token [token] scored [value] — a surprisal, an entropy, an + /// attribution, a probability. + /// + /// [probability] is the probability the model gave the token itself, and + /// [alternatives] what else it was weighing, most likely first; both are for + /// the readout. + const TokenScore( + this.token, + this.value, { + this.probability, + this.alternatives = const [], + this.data, + }); + + /// Creates a token from the log-probability the model gave it, scored as its + /// surprisal `-logProb`: the less likely the model thought it, the higher. + /// Its [probability] is `exp(logProb)`. + factory TokenScore.fromLogProb( + String token, + double logProb, { + List alternatives = const [], + Object? data, + }) => TokenScore( + token, + -logProb, + probability: logProb.isFinite ? math.exp(logProb) : null, + alternatives: alternatives, + data: data, + ); + + /// The token's text, as the tokenizer produced it. A newline in it ends the + /// line. + final String token; + + /// The score; a value that is not finite is left uncoloured. + final double value; + + /// The probability the model gave this token, 0 to 1, if known. + final double? probability; + + /// What else the model was weighing at this position, most likely first. + final List alternatives; + + /// Anything the app wants back when this token is touched. + final Object? data; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is TokenScore && + other.token == token && + other.value == value && + other.probability == probability && + listEquals(other.alternatives, alternatives) && + other.data == data; + + @override + int get hashCode => Object.hash( + token, + value, + probability, + Object.hashAll(alternatives), + data, + ); +} + +/// How [token] is written on screen. +/// +/// A space is a no-break space, so the width of a leading space is kept, and a +/// newline is dropped. With [showWhitespace] a space is a `·` and a newline a +/// `↵`, so what the tokenizer really produced can be read. +String tokenDisplay(String token, {bool showWhitespace = false}) { + final clean = token.replaceAll('\r', ''); + if (showWhitespace) { + return clean.replaceAll(' ', '·').replaceAll('\n', '↵'); + } + return clean.replaceAll('\n', '').replaceAll(' ', ' '); +} + +/// The text of [token] without the space it begins with, as it is drawn inside +/// its box — the space is the gap before the box, not part of it. +/// +/// Spaces inside the token are no-break ones, so they keep their width. With +/// [showWhitespace] nothing is trimmed: the spaces and line breaks are written +/// as `·` and `↵` inside the box. +String tokenText(String token, {bool showWhitespace = false}) { + if (showWhitespace) return tokenDisplay(token, showWhitespace: true); + return token + .replaceAll('\r', '') + .replaceAll('\n', '') + .trim() + .replaceAll(' ', '\u00A0'); +} + +/// How the colour of a token is drawn. +enum TokenShading { + /// A tint of the colour behind the text, and a line of the full colour under + /// it: the text stays easy to read, and the tokens stay apart. + soft, + + /// The full colour behind the text, which turns dark or light to suit it. + solid, +} + +/// Where one token was laid out. +@immutable +class TokenBox { + /// Creates the box of the token at [index]. + const TokenBox({required this.index, required this.rect}); + + /// The token's position in the list it was given in. + final int index; + + /// Where it sits, in the chart's local pixels. + final Rect rect; +} + +/// Flows [tokens] into lines no wider than [width], in the way text wraps. +/// +/// [measure] gives the size of one token's text as it is displayed — see +/// [tokenDisplay] — so the layout can be worked out, and tested, without a +/// renderer. A token that contains a newline ends its line; one wider than a +/// whole line sits alone on its own. With [showWhitespace] a token that is only +/// a newline still takes a box, to show the `↵`. Returns the boxes and the +/// height the lines take. +({List boxes, double height}) flowTokens( + List tokens, + double width, + Size Function(String token) measure, { + double lineGap = 4, + bool showWhitespace = false, + bool trimSpaces = false, + double horizontalPadding = 0, + double wordGap = 0, + double tokenGap = 0, +}) { + final boxes = []; + var x = 0.0; + var y = 0.0; + var lineHeight = 0.0; + var any = false; + + for (var i = 0; i < tokens.length; i++) { + final raw = tokens[i].token; + // With [trimSpaces] a leading space is the gap before the box rather than + // a part of it, so the boxes of one word sit close and words sit apart. + final text = trimSpaces + ? tokenText(raw, showWhitespace: showWhitespace) + : tokenDisplay(raw, showWhitespace: showWhitespace); + final startsWord = trimSpaces && !showWhitespace && raw.startsWith(' '); + final size = text.isEmpty ? const Size(0, 0) : measure(text); + final h = size.height.isFinite ? size.height : 0.0; + final w = + (size.width.isFinite ? size.width : 0.0) + + (text.isEmpty ? 0 : horizontalPadding * 2); + final gap = x == 0 ? 0.0 : (startsWord ? wordGap : tokenGap); + + if (x > 0 && x + gap + w > width) { + y += lineHeight + lineGap; + x = 0; + lineHeight = 0; + } else { + x += gap; + } + lineHeight = math.max(lineHeight, h); + if (text.isNotEmpty) { + boxes.add(TokenBox(index: i, rect: Rect.fromLTWH(x, y, w, h))); + x += w; + any = true; + } + if (tokens[i].token.contains('\n')) { + y += (lineHeight > 0 ? lineHeight : h) + lineGap; + x = 0; + lineHeight = 0; + } + } + final height = !any ? 0.0 : y + lineHeight; + return (boxes: boxes, height: math.max(0, height)); +} + +/// The box under [local] among [boxes], or null. +TokenBox? tokenBoxAt(List boxes, Offset local) { + for (final box in boxes) { + if (box.rect.contains(local)) return box; + } + return null; +} + +/// What a list of token scores add up to. +@immutable +class TokenSummary { + /// Creates a summary; see [summarizeTokens]. + const TokenSummary({ + required this.count, + required this.mean, + required this.max, + required this.maxIndex, + }); + + /// How many tokens had a score. + final int count; + + /// Their mean. + final double mean; + + /// The highest. + final double max; + + /// The index of the highest, or null with no score. + final int? maxIndex; + + /// `exp(mean)` — the perplexity, when the scores are surprisals in nats. + double get perplexity => math.exp(mean); +} + +/// Summarises [tokens]: how many were scored, their mean, and the highest. +TokenSummary summarizeTokens(List tokens) { + var count = 0; + var sum = 0.0; + var max = double.negativeInfinity; + int? at; + for (var i = 0; i < tokens.length; i++) { + final v = tokens[i].value; + if (!v.isFinite) continue; + count++; + sum += v; + if (v > max) { + max = v; + at = i; + } + } + return TokenSummary( + count: count, + mean: count == 0 ? 0 : sum / count, + max: count == 0 ? 0 : max, + maxIndex: at, + ); +} + +/// Each token's rank by score: 1 for the highest. Null for a token whose score +/// is not finite. Equal scores share the better rank. +List tokenRanks(List tokens) { + final values = [ + for (final t in tokens) + if (t.value.isFinite) t.value, + ]..sort((a, b) => b.compareTo(a)); + return [ + for (final t in tokens) + if (!t.value.isFinite) null else values.indexOf(t.value) + 1, + ]; +} + +/// The indices of the [count] highest-scoring tokens, highest first. Ties go to +/// the earlier token. Fewer when fewer have a score. +List topTokens(List tokens, int count) { + if (count <= 0) return const []; + final order = + [ + for (var i = 0; i < tokens.length; i++) + if (tokens[i].value.isFinite) i, + ]..sort((a, b) { + final c = tokens[b].value.compareTo(tokens[a].value); + return c != 0 ? c : a.compareTo(b); + }); + return order.take(count).toList(); +} + +/// The maximum score of each of [bins] equal slices of [tokens], in order — +/// what the strip over a long text draws. A slice with no score is null. +List binTokenScores(List tokens, int bins) { + if (bins <= 0) return const []; + final out = List.filled(bins, null); + if (tokens.isEmpty) return out; + for (var i = 0; i < tokens.length; i++) { + final v = tokens[i].value; + if (!v.isFinite) continue; + final bin = math.min(bins - 1, i * bins ~/ tokens.length); + final current = out[bin]; + if (current == null || v > current) out[bin] = v; + } + return out; +} + +/// What a touch on a [TokenHeatmap] landed on. +@immutable +class TokenTouchDetails { + /// Creates the details of a touch on the token at [index]. + const TokenTouchDetails({ + required this.index, + required this.token, + required this.rect, + required this.rank, + required this.scored, + }); + + /// The token's position. + final int index; + + /// The token. + final TokenScore token; + + /// Where it was drawn, in the chart's local pixels. + final Rect rect; + + /// Its rank by score among the tokens, 1 for the highest; null when it has + /// none. + final int? rank; + + /// How many tokens have a score. + final int scored; +} + +/// A model's text with each token shaded by a number — where it was unsure, what +/// a prediction leaned on. +/// +/// ```dart +/// TokenHeatmap( +/// tokens: [ +/// for (final t in completion.tokens) +/// TokenScore.fromLogProb( +/// t.text, +/// t.logProb, +/// alternatives: [ +/// for (final a in t.topLogProbs) +/// TokenAlternative(a.text, exp(a.logProb)), +/// ], +/// ), +/// ], +/// highlightTop: 3, +/// showStrip: true, +/// ); +/// ``` +/// +/// Tokens flow and wrap like text, each over a rectangle coloured by its score +/// on [scale]. Touching one opens a readout — its score, its rank, and what the +/// model was weighing instead — unless [tooltipBuilder] says otherwise. A +/// legend under the text runs from the lowest score to the highest, and a strip +/// above it shows the score of every token in order, which is how a long text is +/// read at a glance. The chart takes the height its text needs, unless the box it +/// is given is shorter, when the text is cut off. +class TokenHeatmap extends StatefulWidget { + /// Creates a heatmap of [tokens]. + const TokenHeatmap({ + super.key, + required this.tokens, + this.scale = const HeatmapGradientScale( + colors: [Color(0xFF2F9E44), Color(0xFFE8C547), Color(0xFFE03131)], + ), + this.minValue, + this.maxValue, + this.textStyle, + this.shading = TokenShading.soft, + this.tokenRadius = const BorderRadius.all(Radius.circular(5)), + this.horizontalPadding = 5, + this.verticalPadding = 4, + this.wordGap = 7, + this.tokenGap = 2, + this.lineGap = 10, + this.showWhitespace = false, + this.focusAbove, + this.dimColor = const Color(0xFF6B7480), + this.highlightTop = 0, + this.highlightColor = const Color(0xFFFFFFFF), + this.selectedIndex, + this.showStrip = false, + this.stripHeight = 28, + this.showLegend = true, + this.lowLabel, + this.highLabel, + this.showSummary = false, + this.summaryLabel = 'perplexity', + this.legendStyle, + this.valueFormatter, + this.hoverBorder = const BorderSide(color: Color(0xFFFFFFFF), width: 1.5), + this.showReadout = true, + this.scoreLabel = 'surprisal', + this.onTouch, + this.tooltipBuilder, + this.tooltipMargin = 8, + this.padding = EdgeInsets.zero, + this.backgroundColor, + this.semanticLabel, + }); + + /// The tokens, in order. + final List tokens; + + /// How a score becomes a colour. + final HeatmapScale scale; + + /// The score painted in the scale's first colour; null takes the lowest. + final double? minValue; + + /// The score painted in the scale's last colour; null takes the highest. + final double? maxValue; + + /// Style of the text; null is 14 px, in the default font. + final TextStyle? textStyle; + + /// How the colour is drawn: a soft tint with a line under the text, or the + /// full colour behind it. + final TokenShading shading; + + /// The rounding of each token's rectangle. + final BorderRadius tokenRadius; + + /// Space to the left and right of a token's text inside its rectangle. + final double horizontalPadding; + + /// The gap before a token that begins a word, where the tokenizer put a + /// space. + final double wordGap; + + /// The gap before a token that carries on a word. + final double tokenGap; + + /// Space above and below a token's text inside its rectangle. + final double verticalPadding; + + /// The gap between lines. + final double lineGap; + + /// Whether spaces and line breaks are made visible, as `·` and `↵`, so that + /// what the tokenizer produced can be read. + final bool showWhitespace; + + /// A score below which a token is left unshaded and dimmed, so that only the + /// doubtful ones are lit. Null shades every token. + final double? focusAbove; + + /// The colour of the text of a token left out by [focusAbove]. + final Color dimColor; + + /// How many of the highest-scoring tokens are ringed and numbered, 1 for the + /// highest; zero marks none. + final int highlightTop; + + /// The colour of that ring. + final Color highlightColor; + + /// A token to hold outlined whatever the pointer does — one the app has + /// selected; null holds none. + final int? selectedIndex; + + /// Whether a strip of every token's score, in order, is drawn above the + /// legend. Over a text longer than the strip is wide, each bar is the highest + /// of the tokens it covers. + final bool showStrip; + + /// How tall the strip is. + final double stripHeight; + + /// Whether the legend bar is drawn under the text. + final bool showLegend; + + /// What the low end of the legend is called; null writes its value. + final String? lowLabel; + + /// What the high end is called; null writes its value. + final String? highLabel; + + /// Whether the mean, and the perplexity it implies, are written in the legend. + final bool showSummary; + + /// What the summary calls `exp(mean)`. + final String summaryLabel; + + /// Style of the legend. + final TextStyle? legendStyle; + + /// Writes a score; null writes at most two decimals. + final String Function(double value)? valueFormatter; + + /// Drawn round the token under the pointer; null marks none. + final BorderSide? hoverBorder; + + /// Whether touching a token opens the built-in readout. Ignored when + /// [tooltipBuilder] is given. + final bool showReadout; + + /// What the score is called in the readout. + final String scoreLabel; + + /// Called as a touch moves over the tokens, and with null when it leaves. + final ValueChanged? onTouch; + + /// Builds a card shown beside the touched token, in place of the readout. + final Widget? Function(BuildContext context, TokenTouchDetails details)? + tooltipBuilder; + + /// How far the card sits from the token. + final double tooltipMargin; + + /// Space kept clear around the chart. + final EdgeInsets padding; + + /// Painted behind the whole chart. + final Color? backgroundColor; + + /// What a screen reader announces; null reads the text. + final String? semanticLabel; + + @override + State createState() => _TokenHeatmapState(); +} + +class _TokenHeatmapState extends State { + // Measuring every token is the costly part, so the layout is kept until the + // tokens, the width or the style change. + List? _flowTokens; + double _flowWidth = -1; + TextStyle? _flowStyle; + double _flowPadding = -1; + double _flowGap = -1; + bool _flowWhitespace = false; + double _flowHPad = -1; + double _flowWord = -1; + double _flowToken = -1; + ({List boxes, double height})? _flow; + + TextStyle get _style => const TextStyle( + fontSize: 14, + color: Color(0xFFE9ECEF), + ).merge(widget.textStyle); + + ({List boxes, double height}) _layOut(double width) { + final style = _style; + final pad = widget.verticalPadding; + if (_flow != null && + identical(_flowTokens, widget.tokens) && + _flowWidth == width && + _flowStyle == style && + _flowPadding == pad && + _flowGap == widget.lineGap && + _flowWhitespace == widget.showWhitespace && + _flowHPad == widget.horizontalPadding && + _flowWord == widget.wordGap && + _flowToken == widget.tokenGap) { + return _flow!; + } + final painter = TextPainter(textDirection: TextDirection.ltr); + final result = flowTokens( + widget.tokens, + width, + (text) { + painter + ..text = TextSpan(text: text, style: style) + ..layout(); + return Size(painter.width, painter.height + pad * 2); + }, + lineGap: widget.lineGap, + showWhitespace: widget.showWhitespace, + trimSpaces: true, + horizontalPadding: widget.horizontalPadding, + wordGap: widget.wordGap, + tokenGap: widget.tokenGap, + ); + painter.dispose(); + _flowTokens = widget.tokens; + _flowWidth = width; + _flowStyle = style; + _flowPadding = pad; + _flowGap = widget.lineGap; + _flowWhitespace = widget.showWhitespace; + _flowHPad = widget.horizontalPadding; + _flowWord = widget.wordGap; + _flowToken = widget.tokenGap; + return _flow = result; + } + + @override + Widget build(BuildContext context) { + final tokens = widget.tokens; + final summary = summarizeTokens(tokens); + final finite = [ + for (final t in tokens) + if (t.value.isFinite) t.value, + ]; + final lo = + widget.minValue ?? (finite.isEmpty ? 0.0 : finite.reduce(math.min)); + final hi = + widget.maxValue ?? (finite.isEmpty ? 1.0 : finite.reduce(math.max)); + final ranks = tokenRanks(tokens); + final top = topTokens(tokens, widget.highlightTop); + + return LayoutBuilder( + builder: (context, constraints) { + final width = constraints.hasBoundedWidth + ? constraints.maxWidth + : MediaQuery.maybeSizeOf(context)?.width ?? 400; + final inner = math.max(0.0, width - widget.padding.horizontal); + final flow = _layOut(inner); + final stripHeight = widget.showStrip && tokens.isNotEmpty + ? widget.stripHeight + 10 + : 0.0; + final legendHeight = widget.showLegend + ? (widget.showSummary ? 52.0 : 34.0) + : 0.0; + final wanted = + widget.padding.vertical + + flow.height + + stripHeight + + (widget.showLegend ? 12 : 0) + + legendHeight; + final height = constraints.hasBoundedHeight + ? math.min(constraints.maxHeight, wanted) + : wanted; + + TokenTouchDetails details(TokenBox box, Rect plot) => TokenTouchDetails( + index: box.index, + token: tokens[box.index], + rect: box.rect.shift(plot.topLeft), + rank: ranks[box.index], + scored: summary.count, + ); + + TokenTouchDetails? hit(Offset local, Rect plot) { + final box = tokenBoxAt(flow.boxes, local - plot.topLeft); + return box == null ? null : details(box, plot); + } + + final custom = widget.tooltipBuilder; + return SizedBox( + width: width, + height: height, + child: PlotScaffold( + restartKey: Object.hashAll(tokens), + padding: widget.padding, + backgroundColor: widget.backgroundColor, + defaultHeight: height, + semanticLabel: + widget.semanticLabel ?? + tokens.map((t) => t.token).join().trim(), + tooltipMargin: widget.tooltipMargin, + onTouch: widget.onTouch, + tooltipBuilder: + custom ?? + (widget.showReadout + ? (context, d) => _Readout( + details: d, + scoreLabel: widget.scoreLabel, + format: _format, + scale: widget.scale, + low: lo, + high: hi, + showWhitespace: widget.showWhitespace, + ) + : null), + hitTest: hit, + keyOf: (d) => d.index, + anchorOf: (d, plot) => d.rect, + painter: (plot, t, text, hovered) => _TokenPainter( + chart: widget, + boxes: flow.boxes, + flowHeight: flow.height, + style: _style, + low: lo, + high: hi, + summary: summary, + top: top, + hovered: hovered?.index, + plot: plot, + animation: t, + text: text, + backgroundColor: widget.backgroundColor, + ), + ), + ); + }, + ); + } + + String _format(double v) { + if (widget.valueFormatter != null) return widget.valueFormatter!(v); + final r = double.parse(v.toStringAsFixed(2)); + return r == r.roundToDouble() ? r.toStringAsFixed(0) : r.toString(); + } +} + +/// The card shown over a touched token: its text, its score, how it ranks, how +/// likely the model thought it, and what it was weighing instead. +class _Readout extends StatelessWidget { + const _Readout({ + required this.details, + required this.scoreLabel, + required this.format, + required this.scale, + required this.low, + required this.high, + required this.showWhitespace, + }); + + final TokenTouchDetails details; + final String scoreLabel; + final String Function(double) format; + final HeatmapScale scale; + final double low; + final double high; + final bool showWhitespace; + + static String _percent(double p) { + final v = p * 100; + if (v >= 10) return '${v.toStringAsFixed(0)}%'; + if (v >= 1) return '${v.toStringAsFixed(1)}%'; + if (v > 0) return '<1%'; + return '0%'; + } + + @override + Widget build(BuildContext context) { + final token = details.token; + const muted = Color(0xFF8B95A1); + const text = Color(0xFFF1F3F5); + final swatch = token.value.isFinite + ? scale.colorAt(token.value, low, high) + : scale.emptyColor; + final shown = tokenDisplay(token.token, showWhitespace: true); + + final lines = [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + DecoratedBox( + decoration: BoxDecoration( + color: swatch, + borderRadius: const BorderRadius.all(Radius.circular(2)), + ), + child: const SizedBox(width: 10, height: 10), + ), + const SizedBox(width: 6), + Text( + shown.isEmpty ? '∅' : shown, + style: const TextStyle( + color: text, + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + token.value.isFinite + ? '$scoreLabel ${format(token.value)}' + : 'no $scoreLabel', + style: const TextStyle(color: text, fontSize: 12), + ), + if (details.rank != null) + Text( + 'rank ${details.rank} of ${details.scored}', + style: const TextStyle(color: muted, fontSize: 11), + ), + if (token.probability != null) + Text( + 'probability ${_percent(token.probability!)}', + style: const TextStyle(color: muted, fontSize: 11), + ), + ]; + + if (token.alternatives.isNotEmpty) { + lines + ..add(const SizedBox(height: 6)) + ..add( + const Text('instead', style: TextStyle(color: muted, fontSize: 10)), + ); + for (final a in token.alternatives.take(5)) { + lines.add( + Text( + '${tokenDisplay(a.token, showWhitespace: true)} ' + '${_percent(a.probability)}', + style: const TextStyle(color: text, fontSize: 11), + ), + ); + } + } + + return DecoratedBox( + decoration: const BoxDecoration( + color: Color(0xF2161B22), + borderRadius: BorderRadius.all(Radius.circular(6)), + border: Border.fromBorderSide(BorderSide(color: Color(0x33FFFFFF))), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: lines, + ), + ), + ); + } +} + +class _TokenPainter extends PlotPainter { + _TokenPainter({ + required this.chart, + required this.boxes, + required this.flowHeight, + required this.style, + required this.low, + required this.high, + required this.summary, + required this.top, + required this.hovered, + required super.plot, + required super.animation, + required super.text, + super.backgroundColor, + }); + + final TokenHeatmap chart; + final List boxes; + final double flowHeight; + final TextStyle style; + final double low, high; + final TokenSummary summary; + final List top; + final int? hovered; + + String _format(double v) { + if (chart.valueFormatter != null) return chart.valueFormatter!(v); + final r = double.parse(v.toStringAsFixed(2)); + return r == r.roundToDouble() ? r.toStringAsFixed(0) : r.toString(); + } + + @override + void paintPlot(Canvas canvas, Size size) { + canvas + ..save() + ..clipRect(Rect.fromLTWH(plot.left, plot.top, plot.width, plot.height)); + + for (final box in boxes) { + final token = chart.tokens[box.index]; + final rect = box.rect.shift(plot.topLeft); + if (rect.top > plot.bottom) break; + final scored = token.value.isFinite; + final focused = + chart.focusAbove == null || + (scored && token.value >= chart.focusAbove!); + final rrect = chart.tokenRadius.toRRect(rect); + final base = style.color ?? const Color(0xFFE9ECEF); + + var textColor = base; + if (!focused) { + textColor = chart.dimColor; + } else if (scored) { + final color = chart.scale.colorAt(token.value, low, high); + if (chart.shading == TokenShading.soft) { + // A tint behind the text, and a line of the full colour under it. + canvas.drawRRect( + rrect, + Paint() + ..color = color.withValues(alpha: 0.4) + ..isAntiAlias = true, + ); + canvas + ..save() + ..clipRRect(rrect) + ..drawRect( + Rect.fromLTWH(rect.left, rect.bottom - 3, rect.width, 3), + Paint()..color = color, + ) + ..restore(); + } else { + canvas.drawRRect( + rrect, + Paint() + ..color = color + ..isAntiAlias = true, + ); + // Dark text on a light colour, and the other way round. + final opaque = Color.alphaBlend(color, const Color(0xFF161B22)); + textColor = opaque.computeLuminance() > 0.5 + ? const Color(0xFF10151C) + : const Color(0xFFF1F3F5); + } + } else { + // No score: a faint outline, so the token is still a token. + canvas.drawRRect( + rrect, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 1 + ..color = const Color(0x33FFFFFF) + ..isAntiAlias = true, + ); + } + + final shown = tokenText( + token.token, + showWhitespace: chart.showWhitespace, + ); + final tp = text.get(shown, style.copyWith(color: textColor)); + tp.paint( + canvas, + Offset( + rect.left + chart.horizontalPadding, + rect.top + (rect.height - tp.height) / 2 - 1, + ), + ); + + final hover = chart.hoverBorder; + if (hovered == box.index && hover != null && hover.width > 0) { + canvas.drawRRect( + rrect.inflate(hover.width / 2), + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = hover.width + ..color = hover.color + ..isAntiAlias = true, + ); + } + if (chart.selectedIndex == box.index) { + canvas.drawRRect( + rrect.inflate(1.5), + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 2 + ..color = chart.highlightColor + ..isAntiAlias = true, + ); + } + } + + canvas.restore(); + // After the clip: a badge on the first line sits partly above the text. + _paintTop(canvas); + + var y = plot.top + flowHeight + 10; + if (chart.showStrip && chart.tokens.isNotEmpty) { + _strip(canvas, y); + y += chart.stripHeight + 10; + } + if (chart.showLegend) _legend(canvas, y + 2); + } + + /// A ring and a rank on each of the highest-scoring tokens. + void _paintTop(Canvas canvas) { + for (var rank = 0; rank < top.length; rank++) { + final box = boxes.where((b) => b.index == top[rank]).firstOrNull; + if (box == null) continue; + final rect = box.rect.shift(plot.topLeft); + canvas.drawRRect( + chart.tokenRadius.toRRect(rect.deflate(0.5)).inflate(1.5), + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5 + ..color = chart.highlightColor + ..isAntiAlias = true, + ); + final badge = text.get( + '${rank + 1}', + const TextStyle( + fontSize: 9, + fontWeight: FontWeight.w800, + color: Color(0xFF10151C), + ), + ); + final centre = Offset(rect.right - 1, rect.top + 1); + canvas.drawCircle( + centre, + math.max(badge.width, badge.height) / 2 + 2, + Paint() + ..color = chart.highlightColor + ..isAntiAlias = true, + ); + badge.paint(canvas, centre - Offset(badge.width / 2, badge.height / 2)); + } + } + + /// One bar per slice of the text, the height of its highest score. + void _strip(Canvas canvas, double top) { + final height = chart.stripHeight; + final bins = math.max(1, math.min(chart.tokens.length, plot.width ~/ 4)); + final values = binTokenScores(chart.tokens, bins); + final width = plot.width / bins; + final range = high - low; + + canvas.drawRect( + Rect.fromLTWH(plot.left, top, plot.width, height), + Paint()..color = const Color(0x10FFFFFF), + ); + for (var i = 0; i < bins; i++) { + final v = values[i]; + if (v == null) continue; + final f = range <= 0 ? 1.0 : ((v - low) / range).clamp(0.0, 1.0); + final h = math.max(1.5, height * f); + canvas.drawRect( + Rect.fromLTWH(plot.left + i * width, top + height - h, width, h), + Paint()..color = chart.scale.colorAt(v, low, high), + ); + } + + final at = hovered; + if (at != null && chart.tokens.isNotEmpty) { + final bin = math.min(bins - 1, at * bins ~/ chart.tokens.length); + canvas.drawRect( + Rect.fromLTWH( + plot.left + bin * width - 1, + top - 2, + width + 2, + height + 4, + ), + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5 + ..color = const Color(0xFFFFFFFF), + ); + } + } + + void _legend(Canvas canvas, double top) { + if (top + 10 > plot.bottom + 40) return; + const barHeight = 8.0; + final barWidth = math.min(plot.width, 220.0); + final bar = Rect.fromLTWH(plot.left, top, barWidth, barHeight); + const steps = 32; + for (var i = 0; i < steps; i++) { + final f = i / (steps - 1); + canvas.drawRect( + Rect.fromLTWH( + bar.left + bar.width * i / steps, + bar.top, + bar.width / steps + 0.5, + barHeight, + ), + Paint()..color = chart.scale.colorAt(low + (high - low) * f, low, high), + ); + } + final labelStyle = const TextStyle( + fontSize: 10, + color: Color(0xFF8B95A1), + ).merge(chart.legendStyle); + final lowText = text.get(chart.lowLabel ?? _format(low), labelStyle); + final highText = text.get(chart.highLabel ?? _format(high), labelStyle); + lowText.paint(canvas, Offset(bar.left, bar.bottom + 3)); + highText.paint(canvas, Offset(bar.right - highText.width, bar.bottom + 3)); + + // Where the mean falls on the scale. + if (summary.count > 0 && high > low) { + final f = ((summary.mean - low) / (high - low)).clamp(0.0, 1.0); + final x = bar.left + bar.width * f; + canvas.drawPath( + Path() + ..moveTo(x - 4, bar.top - 5) + ..lineTo(x + 4, bar.top - 5) + ..lineTo(x, bar.top - 1) + ..close(), + Paint() + ..color = const Color(0xFFE9ECEF) + ..isAntiAlias = true, + ); + } + + if (chart.showSummary && summary.count > 0) { + final tp = text.get( + 'mean ${_format(summary.mean)} · ${chart.summaryLabel} ' + '${_format(summary.perplexity)}', + labelStyle, + ); + // Under the scale, where there is room however narrow the chart is. + tp.paint(canvas, Offset(bar.left, bar.bottom + 20)); + } + } +} diff --git a/lib/src/ml/token_usage_chart.dart b/lib/src/ml/token_usage_chart.dart new file mode 100644 index 0000000..80a42d0 --- /dev/null +++ b/lib/src/ml/token_usage_chart.dart @@ -0,0 +1,582 @@ +import 'dart:math' as math; + +import 'package:flutter/widgets.dart'; + +import '../utils/axis_ticks.dart'; +import '../utils/plot_scaffold.dart'; +import '../utils/xy_plot.dart'; + +/// The tokens a model served over one interval, and what they cost. +@immutable +class UsageBucket { + /// Creates a bucket for the interval starting at [time]. + const UsageBucket({ + required this.time, + this.inputTokens = 0, + this.outputTokens = 0, + this.cachedTokens = 0, + this.cost, + this.data, + }); + + /// When the interval began. + final DateTime time; + + /// Prompt tokens that were processed. + final double inputTokens; + + /// Tokens the model generated. + final double outputTokens; + + /// Prompt tokens served from a cache, which cost less and run faster. Counted + /// apart from [inputTokens], not inside it. + final double cachedTokens; + + /// What the interval cost, in the unit of your choice; null draws no point. + final double? cost; + + /// Anything the app wants back when this bucket is touched. + final Object? data; + + /// Every token of the interval. + double get total => + _finite(inputTokens) + _finite(outputTokens) + _finite(cachedTokens); + + /// The share of prompt tokens that came from the cache, 0 to 1; zero with no + /// prompt. + double get cacheHitRate { + final prompt = _finite(inputTokens) + _finite(cachedTokens); + return prompt <= 0 ? 0 : _finite(cachedTokens) / prompt; + } + + static double _finite(double v) => v.isFinite && v > 0 ? v : 0; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is UsageBucket && + other.time == time && + other.inputTokens == inputTokens && + other.outputTokens == outputTokens && + other.cachedTokens == cachedTokens && + other.cost == cost && + other.data == data; + + @override + int get hashCode => + Object.hash(time, inputTokens, outputTokens, cachedTokens, cost, data); +} + +/// What [buckets] add up to. +@immutable +class UsageTotals { + /// Creates totals from their parts; see [usageTotals]. + const UsageTotals({ + required this.input, + required this.output, + required this.cached, + required this.cost, + }); + + /// Prompt tokens processed. + final double input; + + /// Tokens generated. + final double output; + + /// Prompt tokens served from the cache. + final double cached; + + /// The total cost of the buckets that have one. + final double cost; + + /// Every token. + double get tokens => input + output + cached; + + /// The share of prompt tokens served from the cache, 0 to 1. + double get cacheHitRate { + final prompt = input + cached; + return prompt <= 0 ? 0 : cached / prompt; + } +} + +/// Adds [buckets] up. A value that is not finite, or negative, counts as zero. +UsageTotals usageTotals(Iterable buckets) { + var input = 0.0, output = 0.0, cached = 0.0, cost = 0.0; + for (final b in buckets) { + input += UsageBucket._finite(b.inputTokens); + output += UsageBucket._finite(b.outputTokens); + cached += UsageBucket._finite(b.cachedTokens); + final c = b.cost; + if (c != null && c.isFinite) cost += c; + } + return UsageTotals(input: input, output: output, cached: cached, cost: cost); +} + +/// Writes a token count compactly: `950`, `12.4k`, `3.1M`, `2B`. +String compactCount(double value) { + if (!value.isFinite) return '–'; + final abs = value.abs(); + String trim(double v) { + final text = v.toStringAsFixed(v.abs() >= 100 ? 0 : 1); + return text.endsWith('.0') ? text.substring(0, text.length - 2) : text; + } + + if (abs >= 1e12) return '${trim(value / 1e12)}T'; + if (abs >= 1e9) return '${trim(value / 1e9)}B'; + if (abs >= 1e6) return '${trim(value / 1e6)}M'; + if (abs >= 1e3) return '${trim(value / 1e3)}k'; + return trim(value); +} + +/// What a touch on a [TokenUsageChart] landed on. +@immutable +class UsageTouchDetails { + /// Creates the details of a touch on the bucket at [index]. + const UsageTouchDetails({required this.index, required this.bucket}); + + /// The bucket's position. + final int index; + + /// The bucket. + final UsageBucket bucket; +} + +/// Token use over time as stacked columns — cached, input and output — with the +/// cost drawn as a line against its own axis. +/// +/// ```dart +/// TokenUsageChart( +/// buckets: [ +/// for (final d in days) +/// UsageBucket( +/// time: d.date, +/// inputTokens: d.prompt, +/// outputTokens: d.completion, +/// cachedTokens: d.cacheRead, +/// cost: d.dollars, +/// ), +/// ], +/// costFormatter: (v) => '\$${v.toStringAsFixed(0)}', +/// ); +/// ``` +/// +/// The chart fills the box it is given, and is [defaultHeight] high in a box +/// with no height of its own. +class TokenUsageChart extends StatelessWidget { + /// Creates a chart of [buckets], in time order. + const TokenUsageChart({ + super.key, + required this.buckets, + this.showCost = true, + this.inputColor = const Color(0xFF4C86CD), + this.outputColor = const Color(0xFF9775FA), + this.cachedColor = const Color(0xFF3BC9DB), + this.costColor = const Color(0xFFF2B01E), + this.barSpacing = 2, + this.barRadius = const BorderRadius.vertical(top: Radius.circular(2)), + this.showLegend = true, + this.legendStyle, + this.showAxes = true, + this.axisWidth = 44, + this.costAxisWidth = 44, + this.axisHeight = 18, + this.tickCount = 5, + this.tokenFormatter, + this.costFormatter, + this.timeFormatter, + this.axisLabelStyle, + this.gridColor = const Color(0x22FFFFFF), + this.hoverColor = const Color(0x14FFFFFF), + this.onTouch, + this.tooltipBuilder, + this.tooltipMargin = 10, + this.animationDuration = Duration.zero, + this.animationCurve = Curves.easeOutCubic, + this.animateOnMount = true, + this.padding = EdgeInsets.zero, + this.backgroundColor, + this.defaultHeight = 260, + this.semanticLabel, + }); + + /// The intervals, in time order. + final List buckets; + + /// Whether the cost line and its axis are drawn. + final bool showCost; + + /// The colour of prompt tokens. + final Color inputColor; + + /// The colour of generated tokens. + final Color outputColor; + + /// The colour of cached prompt tokens. + final Color cachedColor; + + /// The colour of the cost line. + final Color costColor; + + /// Pixels taken off each side of a column. + final double barSpacing; + + /// The rounding of a column's top corners. + final BorderRadius barRadius; + + /// Whether the series are named in the corner. + final bool showLegend; + + /// Style of the legend. + final TextStyle? legendStyle; + + /// Whether the axes are drawn. + final bool showAxes; + + /// Room held for the token axis. + final double axisWidth; + + /// Room held for the cost axis, when [showCost] and a bucket has a cost. + final double costAxisWidth; + + /// Room held for the time axis. + final double axisHeight; + + /// About how many ticks to write on each value axis. + final int tickCount; + + /// Writes a token count; null writes `12.4k`, `3.1M`. + final String Function(double value)? tokenFormatter; + + /// Writes a cost; null writes at most two decimals. + final String Function(double value)? costFormatter; + + /// Writes a time under a column; null writes `HH:mm`, or `MMM d` for buckets + /// a day or more apart. + final String Function(DateTime time)? timeFormatter; + + /// Style of an axis label. + final TextStyle? axisLabelStyle; + + /// Colour of the grid ruled at each tick; null rules none. + final Color? gridColor; + + /// The colour behind the column under the pointer. + final Color hoverColor; + + /// Called as a touch moves over the columns, and with null when it leaves. + final ValueChanged? onTouch; + + /// Builds a card shown beside the touched column; null shows none. + final Widget? Function(BuildContext context, UsageTouchDetails details)? + tooltipBuilder; + + /// How far the card sits from the column. + final double tooltipMargin; + + /// How long the columns take to grow in; zero draws them at once. + final Duration animationDuration; + + /// The easing of [animationDuration]. + final Curve animationCurve; + + /// Whether the first build animates. + final bool animateOnMount; + + /// Space kept clear around the chart. + final EdgeInsets padding; + + /// Painted behind the whole chart. + final Color? backgroundColor; + + /// The height taken in a box that sets none. + final double defaultHeight; + + /// What a screen reader announces for the chart. + final String? semanticLabel; + + @override + Widget build(BuildContext context) { + var maxTokens = 0.0; + var maxCost = 0.0; + var anyCost = false; + for (final b in buckets) { + maxTokens = math.max(maxTokens, b.total); + final c = b.cost; + if (c != null && c.isFinite && c > 0) { + maxCost = math.max(maxCost, c); + anyCost = true; + } + } + final withCost = showCost && anyCost; + final tokenMax = maxTokens <= 0 ? 1.0 : maxTokens * 1.08; + final costMax = maxCost <= 0 ? 1.0 : maxCost * 1.08; + + Rect columnOf(int i, Rect plot) { + final width = plot.width / buckets.length; + return Rect.fromLTWH(plot.left + i * width, plot.top, width, plot.height); + } + + UsageTouchDetails? hit(Offset local, Rect plot) { + if (buckets.isEmpty || + plot.width <= 0 || + local.dx < plot.left || + local.dx > plot.right || + local.dy < plot.top || + local.dy > plot.bottom + 1) { + return null; + } + final i = (((local.dx - plot.left) / plot.width) * buckets.length) + .floor() + .clamp(0, buckets.length - 1); + return UsageTouchDetails(index: i, bucket: buckets[i]); + } + + return PlotScaffold( + restartKey: Object.hashAll([...buckets, showCost]), + insets: EdgeInsets.fromLTRB( + showAxes ? axisWidth : 0, + 0, + showAxes && withCost ? costAxisWidth : 0, + showAxes ? axisHeight : 0, + ), + padding: padding, + backgroundColor: backgroundColor, + defaultHeight: defaultHeight, + animationDuration: animationDuration, + animationCurve: animationCurve, + animateOnMount: animateOnMount, + semanticLabel: semanticLabel, + tooltipMargin: tooltipMargin, + onTouch: onTouch, + tooltipBuilder: tooltipBuilder, + hitTest: hit, + keyOf: (d) => d.index, + anchorOf: (d, plot) => columnOf(d.index, plot), + painter: (plot, t, text, hovered) => _UsagePainter( + chart: this, + tokenMax: tokenMax, + costMax: costMax, + withCost: withCost, + hovered: hovered?.index, + plot: plot, + animation: t, + text: text, + backgroundColor: backgroundColor, + ), + ); + } +} + +class _UsagePainter extends PlotPainter { + _UsagePainter({ + required this.chart, + required this.tokenMax, + required this.costMax, + required this.withCost, + required this.hovered, + required super.plot, + required super.animation, + required super.text, + super.backgroundColor, + }); + + final TokenUsageChart chart; + final double tokenMax; + final double costMax; + final bool withCost; + final int? hovered; + + @override + void paintPlot(Canvas canvas, Size size) { + final buckets = chart.buckets; + final t = animation.clamp(0.0, 1.0); + final labelStyle = const TextStyle( + fontSize: 10, + color: Color(0xFF8B95A1), + ).merge(chart.axisLabelStyle); + + // Grid and the token axis. + final grid = chart.gridColor == null + ? null + : (Paint() + ..color = chart.gridColor! + ..strokeWidth = 1); + for (final tick in niceTicks(0, tokenMax, target: chart.tickCount)) { + final y = mapAcross(tick, 0, tokenMax, plot.bottom, plot.top); + if (grid != null) { + canvas.drawLine(Offset(plot.left, y), Offset(plot.right, y), grid); + } + if (!chart.showAxes) continue; + final tp = text.get( + (chart.tokenFormatter ?? compactCount)(tick), + labelStyle, + ); + if (plot.left - 6 - tp.width >= 0) { + tp.paint(canvas, Offset(plot.left - 6 - tp.width, y - tp.height / 2)); + } + } + + if (buckets.isEmpty) return; + final width = plot.width / buckets.length; + + // Columns: cached at the bottom, then input, then output. + for (var i = 0; i < buckets.length; i++) { + final b = buckets[i]; + final x = plot.left + i * width; + if (hovered == i) { + canvas.drawRect( + Rect.fromLTWH(x, plot.top, width, plot.height), + Paint()..color = chart.hoverColor, + ); + } + final inset = math.min(chart.barSpacing, width / 3); + var base = plot.bottom; + final parts = [ + (UsageBucket._finite(b.cachedTokens), chart.cachedColor), + (UsageBucket._finite(b.inputTokens), chart.inputColor), + (UsageBucket._finite(b.outputTokens), chart.outputColor), + ]; + final tops = parts.where((p) => p.$1 > 0).length; + var drawn = 0; + for (final (value, color) in parts) { + if (value <= 0) continue; + drawn++; + final h = value / tokenMax * plot.height * t; + final rect = Rect.fromLTRB( + x + inset, + base - h, + x + width - inset, + base, + ); + final radius = drawn == tops + ? BorderRadius.only( + topLeft: chart.barRadius.topLeft, + topRight: chart.barRadius.topRight, + ) + : BorderRadius.zero; + canvas.drawRRect( + radius.toRRect(rect), + Paint() + ..color = color + ..isAntiAlias = true, + ); + base -= h; + } + } + + // Time labels, thinned so they do not collide. + if (chart.showAxes) { + var written = -double.infinity; + final days = buckets.length > 1 + ? buckets[1].time.difference(buckets[0].time).inHours >= 24 + : false; + for (var i = 0; i < buckets.length; i++) { + final label = text.get(_time(buckets[i].time, days), labelStyle); + final left = plot.left + (i + 0.5) * width - label.width / 2; + if (left < written || left + label.width > plot.right + 4) continue; + label.paint(canvas, Offset(left, plot.bottom + 3)); + written = left + label.width + 6; + } + } + + if (withCost) _paintCost(canvas, width, t, labelStyle); + _paintLegend(canvas); + } + + String _time(DateTime time, bool days) { + if (chart.timeFormatter != null) return chart.timeFormatter!(time); + String two(int n) => n.toString().padLeft(2, '0'); + if (days) { + const m = [ + 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', // + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', + ]; + return '${m[time.month - 1]} ${time.day}'; + } + return '${two(time.hour)}:${two(time.minute)}'; + } + + void _paintCost(Canvas canvas, double width, double t, TextStyle style) { + final points = []; + for (var i = 0; i < chart.buckets.length; i++) { + final c = chart.buckets[i].cost; + if (c == null || !c.isFinite) continue; + points.add( + Offset( + plot.left + (i + 0.5) * width, + mapAcross(c, 0, costMax, plot.bottom, plot.top), + ), + ); + } + if (points.isNotEmpty) { + final path = Path()..moveTo(points.first.dx, points.first.dy); + for (final o in points.skip(1)) { + path.lineTo(o.dx, o.dy); + } + canvas + ..save() + ..clipRect( + Rect.fromLTRB( + plot.left, + plot.top, + plot.left + plot.width * t, + plot.bottom + 2, + ), + ) + ..drawPath( + path, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 2 + ..strokeJoin = StrokeJoin.round + ..color = chart.costColor + ..isAntiAlias = true, + ); + for (final o in points) { + canvas.drawCircle( + o, + 3, + Paint() + ..color = chart.costColor + ..isAntiAlias = true, + ); + } + canvas.restore(); + } + if (chart.showAxes) { + for (final tick in niceTicks(0, costMax, target: chart.tickCount)) { + final y = mapAcross(tick, 0, costMax, plot.bottom, plot.top); + final tp = text.get( + formatPlotNumber(tick, chart.costFormatter), + style.copyWith(color: chart.costColor), + ); + tp.paint(canvas, Offset(plot.right + 6, y - tp.height / 2)); + } + } + } + + void _paintLegend(Canvas canvas) { + if (!chart.showLegend) return; + final style = const TextStyle( + fontSize: 10, + color: Color(0xCCFFFFFF), + ).merge(chart.legendStyle); + var top = plot.top + 4; + for (final (name, color) in [ + ('Output', chart.outputColor), + ('Input', chart.inputColor), + ('Cached', chart.cachedColor), + if (withCost) ('Cost', chart.costColor), + ]) { + final tp = text.get(name, style); + final left = plot.left + 8; + canvas.drawRect( + Rect.fromLTWH(left, top + tp.height / 2 - 4, 8, 8), + Paint()..color = color, + ); + tp.paint(canvas, Offset(left + 13, top)); + top += tp.height + 3; + } + } +} diff --git a/lib/src/ml/training_curve_chart.dart b/lib/src/ml/training_curve_chart.dart new file mode 100644 index 0000000..326d127 --- /dev/null +++ b/lib/src/ml/training_curve_chart.dart @@ -0,0 +1,854 @@ +import 'dart:math' as math; + +import 'package:flutter/foundation.dart' show listEquals; +import 'package:flutter/widgets.dart'; + +import '../treemap/treemap_data.dart' show treemapPalette; +import '../utils/plot_scaffold.dart'; +import '../utils/xy_plot.dart'; + +/// One reading from a training run: the step it was taken at, and its value. +@immutable +class TrainingPoint { + /// Creates a reading of [value] at [step]. + const TrainingPoint(this.step, this.value); + + /// The optimiser step, or the epoch or the number of tokens seen. + final double step; + + /// The loss, the accuracy, or whatever is being tracked; a value that is not + /// finite leaves a gap. + final double value; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is TrainingPoint && other.step == step && other.value == value; + + @override + int get hashCode => Object.hash(step, value); +} + +/// One training run: a named series of readings. +@immutable +class TrainingRun { + /// Creates a run called [label] through [points], in order of step. + const TrainingRun({ + required this.label, + required this.points, + this.color, + this.dashed = false, + this.data, + }); + + /// What the run is called: `train`, `val`, `lr=3e-4`. + final String label; + + /// The readings, by rising step. + final List points; + + /// A colour of this run's own; null takes one from the chart's palette. + final Color? color; + + /// Whether the line is dashed, to set a validation curve apart. + final bool dashed; + + /// Anything the app wants back when this run is touched. + final Object? data; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is TrainingRun && + other.label == label && + listEquals(other.points, points) && + other.color == color && + other.dashed == dashed && + other.data == data; + + @override + int get hashCode => + Object.hash(label, Object.hashAll(points), color, dashed, data); +} + +/// Smooths [values] with the exponential moving average training dashboards +/// use, corrected for its slow start so the first readings are not dragged +/// toward zero. +/// +/// [weight] runs from 0 (no smoothing) to just under 1 (a very slow average). A +/// value that is not finite is skipped — the average carries on across it — and +/// comes back as null in its own place. +List smoothValues(List values, double weight) { + final w = weight.isFinite ? weight.clamp(0.0, 0.999) : 0.0; + var last = 0.0; + var count = 0; + return [ + for (final value in values) + if (!value.isFinite) + null + else + () { + last = last * w + (1 - w) * value; + count++; + final debias = 1 - math.pow(w, count).toDouble(); + return debias <= 0 ? value : last / debias; + }(), + ]; +} + +/// The index of the best value in [values]: the lowest, or the highest when +/// [lowerIsBetter] is false. Null when none is finite. The first of equals. +int? bestIndex(List values, {bool lowerIsBetter = true}) { + int? best; + for (var i = 0; i < values.length; i++) { + final v = values[i]; + if (!v.isFinite) continue; + if (best == null || (lowerIsBetter ? v < values[best] : v > values[best])) { + best = i; + } + } + return best; +} + +/// The index where a loss curve blew up: the first value that is not finite +/// after finite ones, or that climbs past [factor] times the lowest it had +/// reached. Null when it never did. +int? divergenceIndex(List values, {double factor = 3}) { + var lowest = double.infinity; + for (var i = 0; i < values.length; i++) { + final v = values[i]; + if (!v.isFinite) { + if (lowest.isFinite) return i; + continue; + } + if (lowest.isFinite && lowest > 0 && v > lowest * factor) return i; + lowest = math.min(lowest, v); + } + return null; +} + +/// One run's reading at the step under the pointer. +@immutable +class TrainingReading { + /// Creates a reading of [run] at the touched step. + const TrainingReading({ + required this.run, + required this.runIndex, + required this.point, + required this.smoothed, + }); + + /// The run. + final TrainingRun run; + + /// Its position in the chart's runs. + final int runIndex; + + /// Its reading nearest the touched step; null when it has none. + final TrainingPoint? point; + + /// The smoothed value there; null when it has none. + final double? smoothed; +} + +/// What a touch on a [TrainingCurveChart] landed on: a step, and every run's +/// reading at it. +@immutable +class TrainingTouchDetails { + /// Creates the details of a touch at [step]. + const TrainingTouchDetails({required this.step, required this.readings}); + + /// The step touched. + final double step; + + /// Each run's reading near it, in the chart's order. + final List readings; +} + +/// Loss and accuracy curves of one or more training runs: smoothing, a log +/// axis, the best checkpoint and the point a run diverged. +/// +/// ```dart +/// TrainingCurveChart( +/// runs: [ +/// TrainingRun(label: 'train', points: train), +/// TrainingRun(label: 'val', points: val, dashed: true), +/// ], +/// smoothing: 0.9, +/// gapBetween: (0, 1), // shade the overfitting gap +/// ); +/// ``` +/// +/// The raw readings are drawn faintly under the smoothed line. The chart fills +/// the box it is given, and is [defaultHeight] high in a box with no height of +/// its own. +class TrainingCurveChart extends StatelessWidget { + /// Creates a chart of [runs]. + const TrainingCurveChart({ + super.key, + required this.runs, + this.smoothing = 0, + this.showRaw = true, + this.rawOpacity = 0.25, + this.lowerIsBetter = true, + this.logY = false, + this.showBest = true, + this.showDivergence = true, + this.divergenceFactor = 3, + this.gapBetween, + this.gapColor = const Color(0x33E03131), + this.lineWidth = 2, + this.palette = treemapPalette, + this.showLegend = true, + this.legendStyle, + this.showAxes = true, + this.axisWidth = 48, + this.axisHeight = 18, + this.tickCount = 5, + this.stepFormatter, + this.valueFormatter, + this.axisLabelStyle, + this.xAxisTitle = 'Step', + this.yAxisTitle, + this.axisTitleStyle, + this.gridColor = const Color(0x22FFFFFF), + this.crosshairColor = const Color(0x66FFFFFF), + this.bestColor = const Color(0xFFF2B01E), + this.divergenceColor = const Color(0xFFE03131), + this.onTouch, + this.tooltipBuilder, + this.tooltipMargin = 12, + this.animationDuration = Duration.zero, + this.animationCurve = Curves.easeOutCubic, + this.animateOnMount = true, + this.padding = EdgeInsets.zero, + this.backgroundColor, + this.defaultHeight = 280, + this.semanticLabel, + }); + + /// The runs, drawn in this order and given palette colours in it. + final List runs; + + /// How much the lines are smoothed, from 0 (not at all) to just under 1. + final double smoothing; + + /// Whether the raw readings are drawn faintly under a smoothed line. + final bool showRaw; + + /// How opaque the raw readings are. + final double rawOpacity; + + /// Whether a lower value is better — a loss — or a higher one — accuracy. + final bool lowerIsBetter; + + /// Whether the value axis is logarithmic, for losses spanning decades. + final bool logY; + + /// Whether each run's best checkpoint is marked. + final bool showBest; + + /// Whether the step a run's loss blew up at is marked. + final bool showDivergence; + + /// How many times its lowest value a loss must climb to count as diverged. + final double divergenceFactor; + + /// The indices of two runs — typically train and validation — whose gap is + /// shaded. Null shades nothing. + final (int, int)? gapBetween; + + /// The colour of that shading. + final Color gapColor; + + /// How thick a line is. + final double lineWidth; + + /// Colours taken in turn by runs that name none. + final List palette; + + /// Whether the runs' names are listed in the corner. + final bool showLegend; + + /// Style of the legend. + final TextStyle? legendStyle; + + /// Whether the two axes are drawn. + final bool showAxes; + + /// How much room the value axis takes. + final double axisWidth; + + /// How much room the step axis takes. + final double axisHeight; + + /// About how many ticks to write on each axis. + final int tickCount; + + /// Writes a step; null writes at most two decimals. + final String Function(double step)? stepFormatter; + + /// Writes a value; null writes at most two decimals. + final String Function(double value)? valueFormatter; + + /// Style of an axis label. + final TextStyle? axisLabelStyle; + + /// Written under the step axis. + final String? xAxisTitle; + + /// Written up the value axis. + final String? yAxisTitle; + + /// Style of an axis title. + final TextStyle? axisTitleStyle; + + /// Colour of the grid ruled at each tick; null rules none. + final Color? gridColor; + + /// The colour of the line under the pointer. + final Color crosshairColor; + + /// The colour of the best-checkpoint marker. + final Color bestColor; + + /// The colour of the divergence marker. + final Color divergenceColor; + + /// Called as a touch moves along the steps, and with null when it leaves. + final ValueChanged? onTouch; + + /// Builds a card shown beside the touched step; null shows none. + final Widget? Function(BuildContext context, TrainingTouchDetails details)? + tooltipBuilder; + + /// How far the card sits from the crosshair. + final double tooltipMargin; + + /// How long the lines take to draw in; zero draws them at once. + final Duration animationDuration; + + /// The easing of [animationDuration]. + final Curve animationCurve; + + /// Whether the first build animates. + final bool animateOnMount; + + /// Space kept clear around the chart. + final EdgeInsets padding; + + /// Painted behind the whole chart. + final Color? backgroundColor; + + /// The height taken in a box that sets none. + final double defaultHeight; + + /// What a screen reader announces for the chart. + final String? semanticLabel; + + Color _colorOf(int index) { + final own = runs[index].color; + if (own != null) return own; + if (palette.isEmpty) return const Color(0xFF4C86CD); + return palette[index % palette.length]; + } + + _Frame _frame() { + var minStep = double.infinity; + var maxStep = double.negativeInfinity; + final values = []; + final smoothed = >[]; + for (final run in runs) { + final raw = [for (final p in run.points) p.value]; + final line = smoothValues(raw, smoothing); + smoothed.add(line); + for (final p in run.points) { + if (!p.step.isFinite) continue; + minStep = math.min(minStep, p.step); + maxStep = math.max(maxStep, p.step); + } + for (var i = 0; i < raw.length; i++) { + final v = raw[i]; + if (v.isFinite && (!logY || v > 0)) values.add(v); + final s = line[i]; + if (s != null && (!logY || s > 0)) values.add(s); + } + } + if (!minStep.isFinite) { + minStep = 0; + maxStep = 1; + } + if (minStep == maxStep) maxStep = minStep + 1; + + double lo; + double hi; + if (values.isEmpty) { + lo = logY ? 0.1 : 0; + hi = logY ? 10 : 1; + } else if (logY) { + lo = values.reduce(math.min); + hi = values.reduce(math.max); + if (lo == hi) { + lo /= 2; + hi *= 2; + } + // Half a decade of room, so the lowest curve is not drawn on the axis. + lo /= 1.4; + hi *= 1.4; + } else { + final r = paddedRange(values, pad: 0.06); + lo = r.min; + hi = r.max; + } + return _Frame( + minStep: minStep, + maxStep: maxStep, + minValue: lo, + maxValue: hi, + smoothed: smoothed, + ); + } + + @override + Widget build(BuildContext context) { + final frame = _frame(); + final insets = EdgeInsets.fromLTRB( + showAxes ? axisWidth : 0, + 0, + 0, + showAxes ? axisHeight + (xAxisTitle != null ? 14 : 0) : 0, + ); + + Offset at(Rect plot, double step, double value) { + final x = mapAcross( + step, + frame.minStep, + frame.maxStep, + plot.left, + plot.right, + ); + final y = logY + ? mapAcrossLog( + value, + frame.minValue, + frame.maxValue, + plot.bottom, + plot.top, + ) ?? + double.nan + : mapAcross( + value, + frame.minValue, + frame.maxValue, + plot.bottom, + plot.top, + ); + return Offset(x, y); + } + + TrainingTouchDetails? hit(Offset local, Rect plot) { + if (!plot.inflate(2).contains(local) || runs.isEmpty) return null; + // The step nearest the pointer among every run's readings. + double? nearest; + var closest = double.infinity; + for (final run in runs) { + for (final p in run.points) { + if (!p.step.isFinite) continue; + final x = mapAcross( + p.step, + frame.minStep, + frame.maxStep, + plot.left, + plot.right, + ); + final d = (x - local.dx).abs(); + if (d < closest) { + closest = d; + nearest = p.step; + } + } + } + if (nearest == null) return null; + return TrainingTouchDetails( + step: nearest, + readings: [ + for (var r = 0; r < runs.length; r++) + () { + TrainingPoint? best; + var bestIndex = -1; + var gap = double.infinity; + for (var i = 0; i < runs[r].points.length; i++) { + final p = runs[r].points[i]; + if (!p.step.isFinite) continue; + final d = (p.step - nearest!).abs(); + if (d < gap) { + gap = d; + best = p; + bestIndex = i; + } + } + return TrainingReading( + run: runs[r], + runIndex: r, + point: best, + smoothed: bestIndex < 0 ? null : frame.smoothed[r][bestIndex], + ); + }(), + ], + ); + } + + return PlotScaffold( + restartKey: Object.hashAll([...runs, smoothing, logY]), + insets: insets, + padding: padding, + backgroundColor: backgroundColor, + defaultHeight: defaultHeight, + animationDuration: animationDuration, + animationCurve: animationCurve, + animateOnMount: animateOnMount, + semanticLabel: semanticLabel, + tooltipMargin: tooltipMargin, + onTouch: onTouch, + tooltipBuilder: tooltipBuilder, + hitTest: hit, + keyOf: (d) => d.step, + anchorOf: (d, plot) { + final x = mapAcross( + d.step, + frame.minStep, + frame.maxStep, + plot.left, + plot.right, + ); + return Rect.fromLTRB(x - 1, plot.top, x + 1, plot.bottom); + }, + painter: (plot, t, text, hovered) => _TrainingCurvePainter( + chart: this, + frame: frame, + project: (step, value) => at(plot, step, value), + plot: plot, + animation: t, + text: text, + hovered: hovered, + backgroundColor: backgroundColor, + colorOf: _colorOf, + ), + ); + } +} + +class _Frame { + const _Frame({ + required this.minStep, + required this.maxStep, + required this.minValue, + required this.maxValue, + required this.smoothed, + }); + + final double minStep, maxStep, minValue, maxValue; + final List> smoothed; +} + +class _TrainingCurvePainter extends PlotPainter { + _TrainingCurvePainter({ + required this.chart, + required this.frame, + required this.project, + required this.hovered, + required this.colorOf, + required super.plot, + required super.animation, + required super.text, + super.backgroundColor, + }); + + final TrainingCurveChart chart; + final _Frame frame; + final Offset Function(double step, double value) project; + final TrainingTouchDetails? hovered; + final Color Function(int index) colorOf; + + @override + void paintPlot(Canvas canvas, Size size) { + paintXyAxes( + canvas, + text, + plot: plot, + minX: frame.minStep, + maxX: frame.maxStep, + minY: frame.minValue, + maxY: frame.maxValue, + tickCount: chart.tickCount, + gridColor: chart.gridColor, + showAxes: chart.showAxes, + xFormatter: chart.stepFormatter, + yFormatter: chart.valueFormatter, + labelStyle: chart.axisLabelStyle, + xTitle: chart.xAxisTitle, + yTitle: chart.yAxisTitle, + titleStyle: chart.axisTitleStyle, + axisWidth: chart.axisWidth, + axisHeight: chart.axisHeight, + logY: chart.logY, + ); + + final t = animation.clamp(0.0, 1.0); + canvas + ..save() + ..clipRect( + Rect.fromLTRB( + plot.left, + plot.top, + plot.left + plot.width * t + 2, + plot.bottom, + ), + ); + + _paintGap(canvas); + for (var r = 0; r < chart.runs.length; r++) { + _paintRun(canvas, r); + } + canvas.restore(); + + for (var r = 0; r < chart.runs.length; r++) { + _paintMarkers(canvas, r); + } + _paintCrosshair(canvas); + _paintLegend(canvas); + } + + /// The polyline through a run's points, broken wherever a value is missing. + List> _segments(int r, {required bool smoothed}) { + final run = chart.runs[r]; + final line = frame.smoothed[r]; + final segments = >[]; + var current = []; + for (var i = 0; i < run.points.length; i++) { + final p = run.points[i]; + final value = smoothed ? line[i] : p.value; + final usable = + p.step.isFinite && + value != null && + value.isFinite && + (!chart.logY || value > 0); + if (!usable) { + if (current.length > 1) segments.add(current); + current = []; + continue; + } + final o = project(p.step, value); + if (!o.dx.isFinite || !o.dy.isFinite) continue; + current.add(o); + } + if (current.length > 1) segments.add(current); + return segments; + } + + void _paintGap(Canvas canvas) { + final gap = chart.gapBetween; + if (gap == null) return; + final a = gap.$1; + final b = gap.$2; + if (a < 0 || b < 0 || a >= chart.runs.length || b >= chart.runs.length) { + return; + } + final upper = _segments(a, smoothed: true); + final lower = _segments(b, smoothed: true); + if (upper.isEmpty || lower.isEmpty) return; + // One band between the two lines, over the steps they share. + final top = [for (final s in upper) ...s]; + final bottom = [for (final s in lower) ...s]; + final path = Path()..moveTo(top.first.dx, top.first.dy); + for (final o in top.skip(1)) { + path.lineTo(o.dx, o.dy); + } + for (final o in bottom.reversed) { + path.lineTo(o.dx, o.dy); + } + path.close(); + canvas.drawPath(path, Paint()..color = chart.gapColor); + } + + void _paintRun(Canvas canvas, int r) { + final color = colorOf(r); + final dashed = chart.runs[r].dashed; + final smoothed = chart.smoothing > 0; + + if (smoothed && chart.showRaw) { + final pen = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = math.max(1, chart.lineWidth - 1) + ..color = color.withValues(alpha: chart.rawOpacity.clamp(0.0, 1.0)) + ..isAntiAlias = true; + for (final segment in _segments(r, smoothed: false)) { + _polyline(canvas, segment, pen, false); + } + } + + final pen = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = chart.lineWidth + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round + ..color = color + ..isAntiAlias = true; + for (final segment in _segments(r, smoothed: true)) { + _polyline(canvas, segment, pen, dashed); + } + } + + void _polyline(Canvas canvas, List points, Paint pen, bool dashed) { + if (!dashed) { + final path = Path()..moveTo(points.first.dx, points.first.dy); + for (final o in points.skip(1)) { + path.lineTo(o.dx, o.dy); + } + canvas.drawPath(path, pen); + return; + } + const dash = 6.0; + const gap = 4.0; + var draw = true; + var remaining = dash; + for (var i = 1; i < points.length; i++) { + var from = points[i - 1]; + final to = points[i]; + var length = (to - from).distance; + if (length <= 0) continue; + final direction = (to - from) / length; + while (length > 0) { + final step = math.min(remaining, length); + final end = from + direction * step; + if (draw) canvas.drawLine(from, end, pen); + from = end; + length -= step; + remaining -= step; + if (remaining <= 0) { + draw = !draw; + remaining = draw ? dash : gap; + } + } + } + } + + void _paintMarkers(Canvas canvas, int r) { + final run = chart.runs[r]; + final values = [ + for (var i = 0; i < run.points.length; i++) + frame.smoothed[r][i] ?? double.nan, + ]; + if (chart.showBest) { + final index = bestIndex(values, lowerIsBetter: chart.lowerIsBetter); + if (index != null && (!chart.logY || values[index] > 0)) { + final o = project(run.points[index].step, values[index]); + if (o.dx.isFinite && o.dy.isFinite && plot.inflate(2).contains(o)) { + canvas.drawCircle( + o, + 5, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 2 + ..color = chart.bestColor + ..isAntiAlias = true, + ); + canvas.drawCircle( + o, + 2, + Paint() + ..color = chart.bestColor + ..isAntiAlias = true, + ); + } + } + } + if (chart.showDivergence && chart.lowerIsBetter) { + final index = divergenceIndex([ + for (final p in run.points) p.value, + ], factor: chart.divergenceFactor); + if (index != null) { + final x = mapAcross( + run.points[index].step, + frame.minStep, + frame.maxStep, + plot.left, + plot.right, + ); + if (x.isFinite && x >= plot.left && x <= plot.right) { + canvas.drawLine( + Offset(x, plot.top), + Offset(x, plot.bottom), + Paint() + ..color = chart.divergenceColor.withValues(alpha: 0.7) + ..strokeWidth = 1, + ); + final tp = text.get( + 'diverged', + TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: chart.divergenceColor, + ), + ); + final left = math.min(x + 4, plot.right - tp.width); + tp.paint(canvas, Offset(math.max(plot.left, left), plot.top + 2)); + } + } + } + } + + void _paintCrosshair(Canvas canvas) { + final at = hovered; + if (at == null) return; + final x = mapAcross( + at.step, + frame.minStep, + frame.maxStep, + plot.left, + plot.right, + ); + canvas.drawLine( + Offset(x, plot.top), + Offset(x, plot.bottom), + Paint() + ..color = chart.crosshairColor + ..strokeWidth = 1, + ); + for (final reading in at.readings) { + final v = reading.smoothed; + if (v == null || (chart.logY && v <= 0)) continue; + final o = project(at.step, v); + if (!o.dx.isFinite || !o.dy.isFinite) continue; + canvas.drawCircle( + o, + 4, + Paint() + ..color = colorOf(reading.runIndex) + ..isAntiAlias = true, + ); + } + } + + void _paintLegend(Canvas canvas) { + if (!chart.showLegend || chart.runs.length < 2) return; + final style = const TextStyle( + fontSize: 10, + color: Color(0xCCFFFFFF), + ).merge(chart.legendStyle); + var top = plot.top + 4; + for (var r = 0; r < chart.runs.length; r++) { + final tp = text.get(chart.runs[r].label, style); + final left = plot.right - 8 - tp.width; + canvas.drawLine( + Offset(left - 22, top + tp.height / 2), + Offset(left - 6, top + tp.height / 2), + Paint() + ..color = colorOf(r) + ..strokeWidth = 2, + ); + tp.paint(canvas, Offset(left, top)); + top += tp.height + 3; + } + } +} diff --git a/lib/src/ml/training_timeline_chart.dart b/lib/src/ml/training_timeline_chart.dart new file mode 100644 index 0000000..51f5b2f --- /dev/null +++ b/lib/src/ml/training_timeline_chart.dart @@ -0,0 +1,393 @@ +import 'dart:math' as math; + +import 'package:flutter/foundation.dart' show listEquals; +import 'package:flutter/widgets.dart'; + +import '../timeline/trade_timeline_chart.dart'; + +/// Where a training job is in its life. +enum JobStatus { + /// Still running. + running, + + /// Finished normally. + completed, + + /// Stopped by an error: an out-of-memory, a NaN loss, a crash. + failed, + + /// Taken off its machine for something else, to be resumed. + preempted, + + /// Stopped by hand. + cancelled, +} + +/// One training job: what ran, where, and when. +@immutable +class TrainingJob { + /// Creates a job called [name] that ran on [node] from [start] to [end]. + const TrainingJob({ + required this.name, + required this.start, + this.end, + this.node, + this.status = JobStatus.completed, + this.gpus, + this.data, + }); + + /// What the job is called. + final String name; + + /// When it started. + final DateTime start; + + /// When it ended; null while it is still running. + final DateTime? end; + + /// The machine or pool it ran on — one lane each; null puts it in an unnamed + /// lane. + final String? node; + + /// How it ended. + final JobStatus status; + + /// How many accelerators it held. + final int? gpus; + + /// Anything the app wants back when this job is touched. + final Object? data; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is TrainingJob && + other.name == name && + other.start == start && + other.end == end && + other.node == node && + other.status == status && + other.gpus == gpus && + other.data == data; + + @override + int get hashCode => Object.hash(name, start, end, node, status, gpus, data); +} + +/// What a touch on a [TrainingTimelineChart] landed on. +@immutable +class TrainingJobTouchDetails { + /// Creates the details of a touch on [job] at [time]. + const TrainingJobTouchDetails({required this.job, required this.time}); + + /// The job touched. + final TrainingJob job; + + /// The time under the pointer. + final DateTime time; + + /// How long the job has run, to [now] while it is still going. + Duration duration(DateTime now) => (job.end ?? now).difference(job.start); +} + +/// Training jobs on a timeline: a lane per machine, a bar from start to end +/// coloured by how it finished, and how many were running underneath. +/// +/// ```dart +/// TrainingTimelineChart( +/// jobs: [ +/// TrainingJob( +/// name: 'llama-8b-sft', +/// node: 'pod-a', +/// start: DateTime(2026, 9, 1, 8), +/// end: DateTime(2026, 9, 1, 19), +/// status: JobStatus.completed, +/// ), +/// ], +/// ); +/// ``` +/// +/// It is a [TradeTimelineChart] underneath, so lanes, the time axis and the +/// pointer behave the same. The chart fills the box it is given, and is +/// [defaultHeight] high in a box with no height of its own. +class TrainingTimelineChart extends StatelessWidget { + /// Creates a timeline of [jobs]. + const TrainingTimelineChart({ + super.key, + required this.jobs, + this.now, + this.runningColor = const Color(0xFF4C86CD), + this.completedColor = const Color(0xFF2F9E44), + this.failedColor = const Color(0xFFE03131), + this.preemptedColor = const Color(0xFFF2B01E), + this.cancelledColor = const Color(0xFF868E96), + this.showNames = true, + this.nameStyle, + this.showRunningCount = true, + this.countColor = const Color(0xFF4C86CD), + this.maxRowHeight = 44, + this.showLegend = true, + this.legendStyle, + this.statusLabels = const {}, + this.laneLabelWidth = 72, + this.laneLabelStyle, + this.axisLabelStyle, + this.timeFormatter, + this.gridColor = const Color(0x22FFFFFF), + this.onTouch, + this.tooltipBuilder, + this.animationDuration = Duration.zero, + this.animationCurve = Curves.easeOutCubic, + this.animateOnMount = true, + this.padding = EdgeInsets.zero, + this.backgroundColor, + this.defaultHeight = 240, + this.semanticLabel, + }); + + /// The jobs, in any order. + final List jobs; + + /// The time running jobs are drawn to; null uses the latest time among the + /// jobs. + final DateTime? now; + + /// The colour of a job still running. + final Color runningColor; + + /// The colour of a job that finished. + final Color completedColor; + + /// The colour of a job that failed. + final Color failedColor; + + /// The colour of a job that was preempted. + final Color preemptedColor; + + /// The colour of a job that was cancelled. + final Color cancelledColor; + + /// Whether each job's name is written in its bar. + final bool showNames; + + /// Style of the names. + final TextStyle? nameStyle; + + /// Whether a strip underneath counts the jobs running at each moment. + final bool showRunningCount; + + /// The colour of that strip. + final Color countColor; + + /// How tall a job's bar may be, so a few lanes fill the box rather than + /// hugging the top of it. + final double maxRowHeight; + + /// Whether the statuses in use are listed under the chart. + final bool showLegend; + + /// Style of the legend. + final TextStyle? legendStyle; + + /// What each status is called in the legend; those left out use English. + final Map statusLabels; + + /// Room held for the lane names. + final double laneLabelWidth; + + /// Style of the lane names. + final TextStyle? laneLabelStyle; + + /// Style of the time axis labels. + final TextStyle? axisLabelStyle; + + /// Writes a time on the axis; null picks a form by the span. + final String Function(DateTime time)? timeFormatter; + + /// Colour of the grid ruled at each tick; null rules none. + final Color gridColor; + + /// Called as a touch moves over the jobs, and with null when it leaves. + final ValueChanged? onTouch; + + /// Builds a card shown beside the touched job; null shows none. + final Widget? Function(BuildContext context, TrainingJobTouchDetails details)? + tooltipBuilder; + + /// How long the bars take to grow in; zero draws them at once. + final Duration animationDuration; + + /// The easing of [animationDuration]. + final Curve animationCurve; + + /// Whether the first build animates. + final bool animateOnMount; + + /// Space kept clear around the chart. + final EdgeInsets padding; + + /// Painted behind the whole chart. + final Color? backgroundColor; + + /// The height taken in a box that sets none. + final double defaultHeight; + + /// What a screen reader announces for the chart. + final String? semanticLabel; + + /// The colour a job of [status] is drawn in. + Color colorOf(JobStatus status) => switch (status) { + JobStatus.running => runningColor, + JobStatus.completed => completedColor, + JobStatus.failed => failedColor, + JobStatus.preempted => preemptedColor, + JobStatus.cancelled => cancelledColor, + }; + + @override + Widget build(BuildContext context) { + final trades = [ + for (final job in jobs) + TimelineTrade( + entryTime: job.start, + // A job still marked running has no end, whatever it was given. + exitTime: job.status == JobStatus.running ? null : job.end, + lane: job.node, + color: colorOf(job.status), + quantity: job.gpus?.toDouble(), + data: job, + ), + ]; + + final touch = onTouch; + final builder = tooltipBuilder; + TrainingJobTouchDetails details(TradeTimelineTouchDetails t) => + TrainingJobTouchDetails( + job: t.trade.data! as TrainingJob, + time: t.time, + ); + + final chart = TradeTimelineChart( + trades: trades, + maxRowHeight: maxRowHeight, + now: now, + showPnl: false, + showSideMarkers: false, + shadeByPnl: false, + showExposure: showRunningCount, + exposureColor: countColor, + laneLabelWidth: laneLabelWidth, + laneLabelStyle: laneLabelStyle, + axisLabelStyle: axisLabelStyle, + timeFormatter: timeFormatter, + gridColor: gridColor, + onTouch: touch == null + ? null + : (t) => touch(t == null ? null : details(t)), + tooltipBuilder: builder == null + ? null + : (context, t) => builder(context, details(t)), + animationDuration: animationDuration, + animationCurve: animationCurve, + animateOnMount: animateOnMount, + padding: padding, + backgroundColor: backgroundColor, + defaultHeight: defaultHeight, + semanticLabel: semanticLabel, + ); + if (!showLegend || jobs.isEmpty) return chart; + + const names = { + JobStatus.running: 'Running', + JobStatus.completed: 'Completed', + JobStatus.failed: 'Failed', + JobStatus.preempted: 'Preempted', + JobStatus.cancelled: 'Cancelled', + }; + final used = [ + for (final s in JobStatus.values) + if (jobs.any((j) => j.status == s)) s, + ]; + final style = const TextStyle( + fontSize: 11, + color: Color(0xFF8B95A1), + ).merge(legendStyle); + final legend = LayoutBuilder( + builder: (context, space) => Padding( + padding: EdgeInsets.only( + top: 8, + left: padding.left + laneLabelWidth, + right: padding.right, + ), + child: Wrap( + spacing: 14, + runSpacing: 4, + children: [ + for (final s in used) + // An item wider than the room left shrinks to it. + ConstrainedBox( + constraints: BoxConstraints( + maxWidth: math.max( + 40.0, + space.maxWidth - padding.horizontal - laneLabelWidth, + ), + ), + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + DecoratedBox( + decoration: BoxDecoration( + color: colorOf(s), + borderRadius: const BorderRadius.all( + Radius.circular(2), + ), + ), + child: const SizedBox(width: 10, height: 10), + ), + const SizedBox(width: 5), + Text(statusLabels[s] ?? names[s]!, style: style), + ], + ), + ), + ), + ], + ), + ), + ); + + return LayoutBuilder( + builder: (context, constraints) => + (constraints.hasBoundedHeight && constraints.maxHeight < 100) || + (constraints.hasBoundedWidth && + constraints.maxWidth < + padding.horizontal + laneLabelWidth + 80) + // No room for a legend as well: the chart alone, rather than + // overflowing. + ? chart + : constraints.hasBoundedHeight + ? Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded(child: chart), + legend, + ], + ) + : Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox(height: defaultHeight, child: chart), + legend, + ], + ), + ); + } + + /// Whether [a] and [b] are the same jobs. + static bool sameJobs(List a, List b) => + listEquals(a, b); +} diff --git a/lib/src/openinterest/open_interest_chart.dart b/lib/src/openinterest/open_interest_chart.dart index 3095e98..fb4318a 100644 --- a/lib/src/openinterest/open_interest_chart.dart +++ b/lib/src/openinterest/open_interest_chart.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart' show listEquals; import 'dart:math' as math; import 'package:flutter/widgets.dart'; @@ -34,6 +35,18 @@ class OpenInterestPoint { final rate = funding; return rate != null && rate.isFinite ? rate : null; } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is OpenInterestPoint && + other.time == time && + other.openInterest == openInterest && + other.funding == funding && + other.price == price; + + @override + int get hashCode => Object.hashAll([time, openInterest, funding, price]); } /// What open interest and price did together over one step. @@ -530,7 +543,7 @@ class _OpenInterestChartState extends State void didUpdateWidget(OpenInterestChart oldWidget) { super.didUpdateWidget(oldWidget); _animation.duration = widget.animationDuration; - if (!identical(oldWidget.points, widget.points)) { + if (!listEquals(oldWidget.points, widget.points)) { _moves = openInterestMoves(widget.points); if (widget.animationDuration > Duration.zero) { _animation.forward(from: 0); diff --git a/lib/src/options/option_payoff_chart.dart b/lib/src/options/option_payoff_chart.dart index 158c5d0..40eb276 100644 --- a/lib/src/options/option_payoff_chart.dart +++ b/lib/src/options/option_payoff_chart.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart' show listEquals; import 'dart:math' as math; import 'package:flutter/widgets.dart'; @@ -123,6 +124,21 @@ class OptionLeg { final cost = kind == OptionKind.underlying ? 0.0 : premium; return (intrinsic - cost) * quantity * contractSize; } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is OptionLeg && + other.kind == kind && + other.strike == strike && + other.premium == premium && + other.quantity == quantity && + other.contractSize == contractSize && + other.label == label; + + @override + int get hashCode => + Object.hashAll([kind, strike, premium, quantity, contractSize, label]); } /// What [legs] are worth together if the underlying expires at [price]. @@ -188,7 +204,17 @@ List optionBreakEvens(List legs, double min, double max) { found.add(a + (b - a) * (-pa / span)); } if (optionPayoff(legs, max) == 0) found.add(max); - return found; + + // A payoff that reaches zero exactly on a strike is found from both sides of + // it, and the same price must not be reported twice. + final span = max - min; + final unique = []; + for (final price in found) { + if (unique.isEmpty || (price - unique.last).abs() > span * 1e-9) { + unique.add(price); + } + } + return unique; } /// Where an option payoff was laid out. @@ -548,7 +574,7 @@ class _OptionPayoffChartState extends State void didUpdateWidget(OptionPayoffChart oldWidget) { super.didUpdateWidget(oldWidget); _animation.duration = widget.animationDuration; - if (!identical(oldWidget.legs, widget.legs)) { + if (!listEquals(oldWidget.legs, widget.legs)) { _touched = null; if (widget.animationDuration > Duration.zero) { _animation.forward(from: 0); @@ -852,7 +878,7 @@ class OptionPayoffChartPainter extends CustomPainter { ..color = chart.strikeColor ..strokeWidth = 1; for (final leg in chart.legs) { - if (leg.kind == OptionKind.underlying) continue; + if (leg.kind == OptionKind.underlying || !leg.strike.isFinite) continue; if (leg.strike < layout.minPrice || leg.strike > layout.maxPrice) { continue; } diff --git a/lib/src/pairs/pair_spread_chart.dart b/lib/src/pairs/pair_spread_chart.dart index 78c86a1..770dbc9 100644 --- a/lib/src/pairs/pair_spread_chart.dart +++ b/lib/src/pairs/pair_spread_chart.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart' show listEquals; import 'dart:math' as math; import 'package:flutter/widgets.dart'; @@ -28,6 +29,18 @@ class PairPoint { /// Anything the app wants back when this point is touched. final Object? data; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is PairPoint && + other.time == time && + other.a == a && + other.b == b && + other.data == data; + + @override + int get hashCode => Object.hashAll([time, a, b, data]); } /// Pairs two price series by time, keeping only the times both have. @@ -77,7 +90,8 @@ double pairHedgeRatio(List points) { } if (n < 2) return 1; final variance = sumBB - sumB * sumB / n; - if (variance.abs() < 1e-12) return 1; + // Relative to the prices' own size, so a series quoted in 1e-7s still fits. + if (variance.abs() <= 1e-12 * sumBB) return 1; return (sumAB - sumA * sumB / n) / variance; } @@ -618,7 +632,7 @@ class _PairSpreadChartState extends State void didUpdateWidget(PairSpreadChart oldWidget) { super.didUpdateWidget(oldWidget); _animation.duration = widget.animationDuration; - if (!identical(oldWidget.points, widget.points)) { + if (!listEquals(oldWidget.points, widget.points)) { _touched = null; if (widget.animationDuration > Duration.zero) { _animation.forward(from: 0); diff --git a/lib/src/parallel/parallel_chart.dart b/lib/src/parallel/parallel_chart.dart index c4e7db9..47fe242 100644 --- a/lib/src/parallel/parallel_chart.dart +++ b/lib/src/parallel/parallel_chart.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart' show listEquals; import 'dart:math' as math; import 'package:flutter/widgets.dart'; @@ -32,6 +33,21 @@ class ParallelAxis { /// Writes a value on the axis; null writes it with as few decimals as it /// needs. final String Function(double value)? formatter; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ParallelAxis && + other.label == label && + other.min == min && + other.max == max && + other.inverted == inverted; + // [formatter] is left out: it changes how a value is written, never where it + // is drawn, and an inline closure is a different object on every build — it + // would make every rebuild look like new data. + + @override + int get hashCode => Object.hash(label, min, max, inverted); } /// One line of a [ParallelChart] — a thing measured on every axis. @@ -65,6 +81,19 @@ class ParallelLine { final value = values[index]; return value != null && value.isFinite ? value : null; } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ParallelLine && + other.label == label && + listEquals(other.values, values) && + other.color == color && + other.tooltip == tooltip; + + @override + int get hashCode => + Object.hashAll([label, Object.hashAll(values), color, tooltip]); } /// The ends of one axis, worked out from the data where they were not given. @@ -509,7 +538,7 @@ class _ParallelChartState extends State void didUpdateWidget(ParallelChart oldWidget) { super.didUpdateWidget(oldWidget); _animation.duration = widget.animationDuration; - if (!identical(oldWidget.lines, widget.lines) && + if (!listEquals(oldWidget.lines, widget.lines) && widget.animationDuration > Duration.zero) { _animation.forward(from: 0); } diff --git a/lib/src/pie/pie_chart.dart b/lib/src/pie/pie_chart.dart index c998b2d..5db252f 100644 --- a/lib/src/pie/pie_chart.dart +++ b/lib/src/pie/pie_chart.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart' show listEquals; import 'dart:math' as math; import 'package:flutter/widgets.dart'; @@ -66,6 +67,37 @@ class PieSection { /// How far out [badge] is pinned, the way [labelPosition] measures. final double badgePosition; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is PieSection && + other.value == value && + other.color == color && + other.gradient == gradient && + other.label == label && + other.labelStyle == labelStyle && + other.labelPosition == labelPosition && + other.radius == radius && + other.border == border && + other.offset == offset && + other.badgePosition == badgePosition; + // [badge] is left out: a widget made inline is a new object on every build, + // and it is drawn over the section rather than shaping it. + + @override + int get hashCode => Object.hashAll([ + value, + color, + gradient, + label, + labelStyle, + labelPosition, + radius, + border, + offset, + badgePosition, + ]); } /// One section as it was laid out: which part of the circle it covers. @@ -319,7 +351,7 @@ class _PieChartState extends State void didUpdateWidget(PieChart oldWidget) { super.didUpdateWidget(oldWidget); _animation.duration = widget.animationDuration; - if (oldWidget.sections.length != widget.sections.length && + if (!listEquals(oldWidget.sections, widget.sections) && widget.animationDuration > Duration.zero) { _animation.forward(from: 0); } @@ -419,7 +451,12 @@ class _PieChartState extends State top: centre.dy - inner, width: inner * 2, height: inner * 2, - child: Center(child: widget.centerChild), + child: Center( + child: FittedBox( + fit: BoxFit.scaleDown, + child: widget.centerChild, + ), + ), ), for (final slice in slices) if (widget.sections[slice.index].badge != null) diff --git a/lib/src/portfolio/efficient_frontier_chart.dart b/lib/src/portfolio/efficient_frontier_chart.dart new file mode 100644 index 0000000..e0516db --- /dev/null +++ b/lib/src/portfolio/efficient_frontier_chart.dart @@ -0,0 +1,918 @@ +import 'dart:math' as math; + +import 'package:flutter/foundation.dart' show listEquals; +import 'package:flutter/widgets.dart'; + +import '../renderer/text_painter_cache.dart'; +import '../utils/xy_plot.dart'; + +/// One portfolio: how risky it is and what it is expected to return. +@immutable +class PortfolioPoint { + /// Creates a portfolio with [risk] — volatility, or standard deviation — and + /// [expectedReturn], both in the same period and the same unit, a fraction + /// or a percentage. + const PortfolioPoint({ + required this.risk, + required this.expectedReturn, + this.label, + this.color, + this.data, + }); + + /// How risky it is. + final double risk; + + /// What it is expected to return. + final double expectedReturn; + + /// What the portfolio is called. + final String? label; + + /// A colour of this portfolio's own. + final Color? color; + + /// Anything the app wants back when this portfolio is touched. + final Object? data; + + /// Its return per unit of risk above [riskFree]; null with no risk to + /// measure it against. + double? sharpe([double riskFree = 0]) { + if (!risk.isFinite || !expectedReturn.isFinite || risk <= 0) return null; + return (expectedReturn - riskFree) / risk; + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is PortfolioPoint && + other.risk == risk && + other.expectedReturn == expectedReturn && + other.label == label && + other.color == color && + other.data == data; + + @override + int get hashCode => + Object.hashAll([risk, expectedReturn, label, color, data]); +} + +/// The indices of the portfolios on the efficient frontier: those no other +/// portfolio beats by returning more for no more risk. In order of rising risk. +/// +/// A portfolio with a number that is not finite is never on it. +List efficientFrontierIndices(List points) { + final order = + [ + for (var i = 0; i < points.length; i++) + if (points[i].risk.isFinite && points[i].expectedReturn.isFinite) i, + ]..sort((a, b) { + final byRisk = points[a].risk.compareTo(points[b].risk); + if (byRisk != 0) return byRisk; + // At the same risk the larger return comes first, and the smaller one is + // then beaten by it. + return points[b].expectedReturn.compareTo(points[a].expectedReturn); + }); + + final frontier = []; + var best = double.negativeInfinity; + for (final i in order) { + if (points[i].expectedReturn > best) { + frontier.add(i); + best = points[i].expectedReturn; + } + } + return frontier; +} + +/// The index of the portfolio with the best return per unit of risk above +/// [riskFree]; null when none has a risk to measure against. +int? maxSharpeIndex(List points, {double riskFree = 0}) { + int? best; + var top = double.negativeInfinity; + for (var i = 0; i < points.length; i++) { + final ratio = points[i].sharpe(riskFree); + if (ratio != null && ratio > top) { + top = ratio; + best = i; + } + } + return best; +} + +/// The index of the least risky portfolio; null when there is none. Of two +/// at the same risk, the one that returns more. +int? minRiskIndex(List points) { + int? best; + for (var i = 0; i < points.length; i++) { + final p = points[i]; + if (!p.risk.isFinite || !p.expectedReturn.isFinite) continue; + if (best == null || + p.risk < points[best].risk || + (p.risk == points[best].risk && + p.expectedReturn > points[best].expectedReturn)) { + best = i; + } + } + return best; +} + +/// What a portfolio is, on a [EfficientFrontierChart]. +enum FrontierRole { + /// An ordinary portfolio, beaten by one on the frontier. + portfolio, + + /// On the efficient frontier. + frontier, + + /// The frontier portfolio with the best return per unit of risk. + maxSharpe, + + /// The least risky portfolio. + minRisk, + + /// The portfolio held now, passed apart from the candidates. + current, +} + +/// Where one portfolio was laid out. +@immutable +class FrontierDot { + /// Creates the dot of [point]. + const FrontierDot({ + required this.point, + required this.index, + required this.center, + required this.role, + }); + + /// The portfolio this dot draws. + final PortfolioPoint point; + + /// Its position in the list it was given in; one past the end for the + /// current portfolio. + final int index; + + /// Where it sits, in the chart's local pixels. + final Offset center; + + /// What it is on the chart. + final FrontierRole role; +} + +/// The ranges [points] need on the two axes: risk from zero, return with room. +({double minRisk, double maxRisk, double minReturn, double maxReturn}) +frontierRange(Iterable points, {double riskFree = 0}) { + final risks = [0]; + final returns = []; + for (final point in points) { + if (!point.risk.isFinite || !point.expectedReturn.isFinite) continue; + risks.add(point.risk); + returns.add(point.expectedReturn); + } + if (riskFree.isFinite) returns.add(riskFree); + final x = paddedRange(risks, pad: 0.08); + final y = paddedRange(returns); + return ( + minRisk: 0, + maxRisk: x.max <= 0 ? 1 : x.max, + minReturn: y.min, + maxReturn: y.max, + ); +} + +/// What a touch on an [EfficientFrontierChart] landed on. +@immutable +class FrontierTouchDetails { + /// Creates the details of a touch on [dot]. + const FrontierTouchDetails({required this.dot, required this.sharpe}); + + /// The dot touched. + final FrontierDot dot; + + /// Its return per unit of risk above the chart's risk-free rate. + final double? sharpe; + + /// The portfolio it draws. + PortfolioPoint get point => dot.point; +} + +/// Portfolios by risk and return, with the efficient frontier traced through +/// the best of them. +/// +/// ```dart +/// EfficientFrontierChart( +/// points: [ +/// for (final w in candidateWeights) +/// PortfolioPoint(risk: w.volatility, expectedReturn: w.mean), +/// ], +/// riskFreeRate: 0.02, +/// current: PortfolioPoint(risk: 0.14, expectedReturn: 0.07, label: 'Now'), +/// ); +/// ``` +/// +/// The frontier joins the portfolios nothing beats; the one with the best +/// return per unit of risk is marked, and so is the least risky. A line from +/// the risk-free rate through the best of them is the capital market line. +/// The chart fills the box it is given, and is [defaultHeight] high in a box +/// with no height of its own. +class EfficientFrontierChart extends StatefulWidget { + /// Creates a risk-return plot of [points]. + const EfficientFrontierChart({ + super.key, + required this.points, + this.current, + this.riskFreeRate = 0, + this.showFrontier = true, + this.showMaxSharpe = true, + this.showMinRisk = true, + this.showCapitalMarketLine = true, + this.portfolioColor = const Color(0x884C86CD), + this.frontierColor = const Color(0xFF2F9E44), + this.maxSharpeColor = const Color(0xFFF2B01E), + this.minRiskColor = const Color(0xFF3FA7D6), + this.currentColor = const Color(0xFFE03131), + this.lineColor = const Color(0x66FFFFFF), + this.dotRadius = 3.5, + this.markerSize = 7, + this.frontierWidth = 2, + this.showMarkerLabels = true, + this.maxSharpeLabel = 'Max Sharpe', + this.minRiskLabel = 'Min risk', + this.currentLabel = 'Current', + this.markerLabelStyle, + this.showAxes = true, + this.axisWidth = 44, + this.axisHeight = 18, + this.tickCount = 5, + this.riskFormatter, + this.returnFormatter, + this.axisLabelStyle, + this.riskAxisTitle = 'Risk', + this.returnAxisTitle = 'Return', + this.axisTitleStyle, + this.gridColor = const Color(0x22FFFFFF), + this.hoverBorder = const BorderSide(color: Color(0xCCFFFFFF), width: 1.5), + this.onTouch, + this.tooltipBuilder, + this.tooltipMargin = 10, + this.animationDuration = Duration.zero, + this.animationCurve = Curves.easeOutCubic, + this.animateOnMount = true, + this.padding = EdgeInsets.zero, + this.backgroundColor, + this.defaultHeight = 280, + this.semanticLabel, + }); + + /// The candidate portfolios. + final List points; + + /// The portfolio held now, drawn apart from the candidates; null draws none. + final PortfolioPoint? current; + + /// The return with no risk at all, in the same unit as the returns. + final double riskFreeRate; + + /// Whether the frontier is traced. + final bool showFrontier; + + /// Whether the best return per unit of risk is marked. + final bool showMaxSharpe; + + /// Whether the least risky portfolio is marked. + final bool showMinRisk; + + /// Whether the line from the risk-free rate through the best portfolio is + /// drawn. Needs [showMaxSharpe]'s portfolio to exist. + final bool showCapitalMarketLine; + + /// The colour of an ordinary portfolio. + final Color portfolioColor; + + /// The colour of the frontier and the portfolios on it. + final Color frontierColor; + + /// The colour of the best-Sharpe marker. + final Color maxSharpeColor; + + /// The colour of the least-risk marker. + final Color minRiskColor; + + /// The colour of the current portfolio. + final Color currentColor; + + /// The colour of the capital market line. + final Color lineColor; + + /// The radius of an ordinary dot. + final double dotRadius; + + /// Half the size of a marker. + final double markerSize; + + /// How thick the frontier is. + final double frontierWidth; + + /// Whether the markers are named beside them. + final bool showMarkerLabels; + + /// What the best-Sharpe marker is called. + final String maxSharpeLabel; + + /// What the least-risk marker is called. + final String minRiskLabel; + + /// What the current portfolio is called. + final String currentLabel; + + /// Style of a marker's name. + final TextStyle? markerLabelStyle; + + /// Whether the two axes are drawn. + final bool showAxes; + + /// How much room the side axis takes. + final double axisWidth; + + /// How much room the bottom axis takes. + final double axisHeight; + + /// About how many ticks to write on each axis. + final int tickCount; + + /// Writes a risk on the bottom axis; null writes at most two decimals. + final String Function(double value)? riskFormatter; + + /// Writes a return on the side axis; null writes at most two decimals. + final String Function(double value)? returnFormatter; + + /// Style of an axis label. + final TextStyle? axisLabelStyle; + + /// Written under the bottom axis. + final String? riskAxisTitle; + + /// Written up the side axis. + final String? returnAxisTitle; + + /// Style of an axis title. + final TextStyle? axisTitleStyle; + + /// Colour of the grid ruled at each tick; null rules none. + final Color? gridColor; + + /// Drawn round the dot under the pointer; null marks none. + final BorderSide? hoverBorder; + + /// Called as a touch moves over the dots, and with null when it leaves. + final ValueChanged? onTouch; + + /// Builds a card shown beside the touched dot; null shows none. + final Widget? Function(BuildContext context, FrontierTouchDetails details)? + tooltipBuilder; + + /// How far the card sits from the dot. + final double tooltipMargin; + + /// How long the plot takes to grow in; zero draws it at once. + final Duration animationDuration; + + /// The easing of [animationDuration]. + final Curve animationCurve; + + /// Whether the first build grows in. + final bool animateOnMount; + + /// Space kept clear around the chart. + final EdgeInsets padding; + + /// Painted behind the whole chart. + final Color? backgroundColor; + + /// The height taken in a box that sets none. + final double defaultHeight; + + /// What a screen reader announces for the chart. + final String? semanticLabel; + + @override + State createState() => _EfficientFrontierChartState(); +} + +class _EfficientFrontierChartState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _animation; + final TextPainterCache _text = TextPainterCache(capacity: 64); + List _dots = const []; + List _frontier = const []; + int? _touched; + + @override + void initState() { + super.initState(); + _animation = AnimationController( + vsync: this, + duration: widget.animationDuration, + value: 1, + )..addListener(() => setState(() {})); + if (widget.animateOnMount && widget.animationDuration > Duration.zero) { + _animation.forward(from: 0); + } + } + + @override + void didUpdateWidget(EfficientFrontierChart oldWidget) { + super.didUpdateWidget(oldWidget); + _animation.duration = widget.animationDuration; + if (!listEquals(oldWidget.points, widget.points) || + oldWidget.current != widget.current) { + _touched = null; + if (widget.animationDuration > Duration.zero) { + _animation.forward(from: 0); + } + } + } + + @override + void dispose() { + _text.dispose(); + _animation.dispose(); + super.dispose(); + } + + FrontierDot? _dotAt(Offset local) { + FrontierDot? best; + var closest = widget.dotRadius + widget.markerSize + 4; + for (final dot in _dots) { + final distance = (dot.center - local).distance; + if (distance > closest) continue; + closest = distance; + best = dot; + } + return best; + } + + FrontierTouchDetails _details(FrontierDot dot) => FrontierTouchDetails( + dot: dot, + sharpe: dot.point.sharpe(widget.riskFreeRate), + ); + + void _handle(Offset local) { + final dot = _dotAt(local); + final index = dot?.index; + if (index == _touched) return; + setState(() => _touched = index); + widget.onTouch?.call(dot == null ? null : _details(dot)); + } + + void _leave() { + if (_touched == null) return; + setState(() => _touched = null); + widget.onTouch?.call(null); + } + + @override + Widget build(BuildContext context) { + final t = widget.animationCurve.transform(_animation.value); + final everything = [ + ...widget.points, + if (widget.current != null) widget.current!, + ]; + final range = frontierRange(everything, riskFree: widget.riskFreeRate); + + Widget chart = LayoutBuilder( + builder: (context, constraints) { + final width = constraints.hasBoundedWidth + ? constraints.maxWidth + : MediaQuery.maybeSizeOf(context)?.width ?? 300; + final height = constraints.hasBoundedHeight + ? constraints.maxHeight + : widget.defaultHeight; + final size = Size(width, height); + final box = widget.padding.deflateRect(Offset.zero & size); + final plot = Rect.fromLTRB( + box.left + (widget.showAxes ? widget.axisWidth : 0), + box.top, + box.right, + math.max( + box.top, + box.bottom - (widget.showAxes ? widget.axisHeight + 14 : 0), + ), + ); + + _frontier = efficientFrontierIndices(widget.points); + final sharpe = maxSharpeIndex( + widget.points, + riskFree: widget.riskFreeRate, + ); + final minRisk = minRiskIndex(widget.points); + final onFrontier = _frontier.toSet(); + _dots = [ + if (plot.width > 0 && plot.height > 0) + for (var i = 0; i < widget.points.length; i++) + if (widget.points[i].risk.isFinite && + widget.points[i].expectedReturn.isFinite) + FrontierDot( + point: widget.points[i], + index: i, + center: Offset( + mapAcross( + widget.points[i].risk, + range.minRisk, + range.maxRisk, + plot.left, + plot.right, + ), + mapAcross( + widget.points[i].expectedReturn, + range.minReturn, + range.maxReturn, + plot.bottom, + plot.top, + ), + ), + role: i == sharpe && widget.showMaxSharpe + ? FrontierRole.maxSharpe + : i == minRisk && widget.showMinRisk + ? FrontierRole.minRisk + : onFrontier.contains(i) && widget.showFrontier + ? FrontierRole.frontier + : FrontierRole.portfolio, + ), + if (plot.width > 0 && + plot.height > 0 && + widget.current != null && + widget.current!.risk.isFinite && + widget.current!.expectedReturn.isFinite) + FrontierDot( + point: widget.current!, + index: widget.points.length, + center: Offset( + mapAcross( + widget.current!.risk, + range.minRisk, + range.maxRisk, + plot.left, + plot.right, + ), + mapAcross( + widget.current!.expectedReturn, + range.minReturn, + range.maxReturn, + plot.bottom, + plot.top, + ), + ), + role: FrontierRole.current, + ), + ]; + + final touched = _touched; + final dot = touched == null + ? null + : _dots.where((d) => d.index == touched).firstOrNull; + final builder = widget.tooltipBuilder; + final tooltip = dot == null || builder == null + ? null + : builder(context, _details(dot)); + + return SizedBox( + width: width, + height: height, + child: MouseRegion( + onHover: (e) => _handle(e.localPosition), + onExit: (_) => _leave(), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: (d) => _handle(d.localPosition), + onTapUp: (_) => _leave(), + onTapCancel: _leave, + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned.fill( + child: CustomPaint( + painter: EfficientFrontierChartPainter( + chart: widget, + dots: _dots, + frontier: _frontier, + plot: plot, + range: range, + touched: touched, + animation: t, + textCache: _text, + ), + ), + ), + if (tooltip != null && dot != null) + Positioned.fill( + child: IgnorePointer( + child: CustomSingleChildLayout( + delegate: XyTooltipLayout( + anchor: Rect.fromCircle( + center: dot.center, + radius: widget.dotRadius + widget.markerSize, + ), + margin: widget.tooltipMargin, + ), + child: tooltip, + ), + ), + ), + ], + ), + ), + ), + ); + }, + ); + + final label = widget.semanticLabel; + if (label != null) { + chart = Semantics(container: true, label: label, child: chart); + } + return chart; + } +} + +/// Paints an [EfficientFrontierChart]: the axes, the capital market line, the +/// frontier and the portfolios. +class EfficientFrontierChartPainter extends CustomPainter { + /// Creates the painter for [chart], laid out as [dots] inside [plot]. + EfficientFrontierChartPainter({ + required this.chart, + required this.dots, + required this.frontier, + required this.plot, + required this.range, + required this.touched, + required this.animation, + required this.textCache, + }); + + /// The chart being painted. + final EfficientFrontierChart chart; + + /// Where each portfolio sits. + final List dots; + + /// The indices of the frontier portfolios, by rising risk. + final List frontier; + + /// The area the dots are drawn in. + final Rect plot; + + /// The ranges of the two axes. + final ({double minRisk, double maxRisk, double minReturn, double maxReturn}) + range; + + /// The index of the portfolio under the pointer. + final int? touched; + + /// How far the entry animation has got, from 0 to 1. + final double animation; + + /// Where the labels are laid out. + final TextPainterCache textCache; + + Offset _at(double risk, double expectedReturn) => Offset( + mapAcross(risk, range.minRisk, range.maxRisk, plot.left, plot.right), + mapAcross( + expectedReturn, + range.minReturn, + range.maxReturn, + plot.bottom, + plot.top, + ), + ); + + @override + void paint(Canvas canvas, Size size) { + final background = chart.backgroundColor; + if (background != null) { + canvas.drawRect(Offset.zero & size, Paint()..color = background); + } + if (dots.isEmpty) return; + + paintXyAxes( + canvas, + textCache, + plot: plot, + minX: range.minRisk, + maxX: range.maxRisk, + minY: range.minReturn, + maxY: range.maxReturn, + tickCount: chart.tickCount, + gridColor: chart.gridColor, + showAxes: chart.showAxes, + xFormatter: chart.riskFormatter, + yFormatter: chart.returnFormatter, + labelStyle: chart.axisLabelStyle, + xTitle: chart.riskAxisTitle, + yTitle: chart.returnAxisTitle, + titleStyle: chart.axisTitleStyle, + axisWidth: chart.axisWidth, + axisHeight: chart.axisHeight, + ); + + final t = animation.clamp(0.0, 1.0); + canvas + ..save() + ..clipRect(plot.inflate(chart.markerSize + 2)); + + _paintCapitalMarketLine(canvas); + _paintFrontier(canvas, t); + + for (final dot in dots) { + if (!dot.center.dx.isFinite || !dot.center.dy.isFinite) continue; + _paintDot(canvas, dot, t); + } + canvas.restore(); + + final at = touched; + final hover = chart.hoverBorder; + if (at != null && hover != null && hover.width > 0) { + for (final dot in dots) { + if (dot.index != at) continue; + canvas.drawCircle( + dot.center, + chart.dotRadius + chart.markerSize * 0.5 + hover.width + 2, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = hover.width + ..color = hover.color + ..isAntiAlias = true, + ); + } + } + } + + void _paintCapitalMarketLine(Canvas canvas) { + if (!chart.showCapitalMarketLine || !chart.showMaxSharpe) return; + final best = dots + .where((d) => d.role == FrontierRole.maxSharpe) + .firstOrNull; + if (best == null || best.point.risk <= 0) return; + + // The line from (0, riskFree) through the best portfolio, run on to the + // right-hand edge of the plot. + final slope = + (best.point.expectedReturn - chart.riskFreeRate) / best.point.risk; + final from = _at(0, chart.riskFreeRate); + final to = _at(range.maxRisk, chart.riskFreeRate + slope * range.maxRisk); + const dash = 5.0; + const gap = 4.0; + final total = (to - from).distance; + if (!total.isFinite || total <= 0) return; + final direction = (to - from) / total; + final pen = Paint() + ..color = chart.lineColor + ..strokeWidth = 1; + var travelled = 0.0; + while (travelled < total) { + final end = math.min(travelled + dash, total); + canvas.drawLine( + from + direction * travelled, + from + direction * end, + pen, + ); + travelled += dash + gap; + } + } + + void _paintFrontier(Canvas canvas, double t) { + if (!chart.showFrontier || frontier.length < 2) return; + final path = Path(); + var first = true; + for (final index in frontier) { + final dot = dots.where((d) => d.index == index).firstOrNull; + if (dot == null) continue; + if (first) { + path.moveTo(dot.center.dx, dot.center.dy); + first = false; + } else { + path.lineTo(dot.center.dx, dot.center.dy); + } + } + canvas.drawPath( + path, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = chart.frontierWidth + ..strokeJoin = StrokeJoin.round + ..strokeCap = StrokeCap.round + ..color = chart.frontierColor.withValues(alpha: t) + ..isAntiAlias = true, + ); + } + + void _paintDot(Canvas canvas, FrontierDot dot, double t) { + final own = dot.point.color; + final marker = chart.markerSize * t; + switch (dot.role) { + case FrontierRole.portfolio: + canvas.drawCircle( + dot.center, + chart.dotRadius * t, + Paint() + ..color = own ?? chart.portfolioColor + ..isAntiAlias = true, + ); + case FrontierRole.frontier: + canvas.drawCircle( + dot.center, + chart.dotRadius * t, + Paint() + ..color = own ?? chart.frontierColor + ..isAntiAlias = true, + ); + case FrontierRole.maxSharpe: + _paintDiamond(canvas, dot.center, marker, own ?? chart.maxSharpeColor); + _paintName(canvas, dot, chart.maxSharpeLabel, t); + case FrontierRole.minRisk: + canvas.drawRect( + Rect.fromCenter( + center: dot.center, + width: marker * 1.6, + height: marker * 1.6, + ), + Paint() + ..color = own ?? chart.minRiskColor + ..isAntiAlias = true, + ); + _paintName(canvas, dot, chart.minRiskLabel, t); + case FrontierRole.current: + canvas.drawCircle( + dot.center, + marker, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 2 + ..color = own ?? chart.currentColor + ..isAntiAlias = true, + ); + canvas.drawCircle( + dot.center, + chart.dotRadius * t, + Paint() + ..color = own ?? chart.currentColor + ..isAntiAlias = true, + ); + _paintName(canvas, dot, dot.point.label ?? chart.currentLabel, t); + } + } + + void _paintDiamond(Canvas canvas, Offset c, double r, Color color) { + canvas.drawPath( + Path() + ..moveTo(c.dx, c.dy - r) + ..lineTo(c.dx + r, c.dy) + ..lineTo(c.dx, c.dy + r) + ..lineTo(c.dx - r, c.dy) + ..close(), + Paint() + ..color = color + ..isAntiAlias = true, + ); + } + + void _paintName(Canvas canvas, FrontierDot dot, String name, double t) { + if (!chart.showMarkerLabels || name.isEmpty || t < 1) return; + final style = const TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: Color(0xCCFFFFFF), + ).merge(chart.markerLabelStyle); + final tp = textCache.get(name, style); + + // The frontier climbs to the right, so the room is above and to the left of + // a marker on it; the current portfolio sits off the line and takes its + // name at the side. + final Offset at; + if (dot.role == FrontierRole.current) { + var left = dot.center.dx + chart.markerSize + 4; + if (left + tp.width > plot.right + chart.markerSize) { + left = dot.center.dx - chart.markerSize - 4 - tp.width; + } + at = Offset(left, dot.center.dy - tp.height / 2); + } else { + var top = dot.center.dy - chart.markerSize - 4 - tp.height; + if (top < plot.top) top = dot.center.dy + chart.markerSize + 4; + at = Offset( + math.max(plot.left, dot.center.dx + chart.markerSize - tp.width), + top, + ); + } + tp.paint(canvas, at); + } + + @override + bool shouldRepaint(EfficientFrontierChartPainter oldDelegate) => + !identical(oldDelegate.chart, chart) || + !identical(oldDelegate.dots, dots) || + oldDelegate.plot != plot || + oldDelegate.range != range || + oldDelegate.touched != touched || + oldDelegate.animation != animation; +} diff --git a/lib/src/profile/market_profile_chart.dart b/lib/src/profile/market_profile_chart.dart index c6f7317..7061504 100644 --- a/lib/src/profile/market_profile_chart.dart +++ b/lib/src/profile/market_profile_chart.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart' show listEquals; import 'dart:math' as math; import 'package:flutter/widgets.dart'; @@ -30,6 +31,17 @@ class MarketProfileRow { /// How many periods touched it. int get count => periods.length; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is MarketProfileRow && + other.from == from && + other.to == to && + listEquals(other.periods, periods); + + @override + int get hashCode => Object.hashAll([from, to, Object.hashAll(periods)]); } /// Price levels, the level most traded, and the range around it where most of @@ -89,6 +101,27 @@ class MarketProfile { /// Whether [price] is inside the value area. bool inValueArea(double price) => price >= valueAreaLow && price <= valueAreaHigh; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is MarketProfile && + listEquals(other.rows, rows) && + other.tickSize == tickSize && + other.pointOfControl == pointOfControl && + other.valueAreaLow == valueAreaLow && + other.valueAreaHigh == valueAreaHigh && + other.periodCount == periodCount; + + @override + int get hashCode => Object.hashAll([ + Object.hashAll(rows), + tickSize, + pointOfControl, + valueAreaLow, + valueAreaHigh, + periodCount, + ]); } /// Builds a market profile from [candles], one period per candle. @@ -104,6 +137,10 @@ MarketProfile buildMarketProfile( int? rowCount, double valueAreaFraction = 0.7, }) { + // A tick that is not a positive number would divide the range by zero. + if (tickSize != null && !(tickSize > 0 && tickSize.isFinite)) { + tickSize = null; + } var low = double.infinity; var high = double.negativeInfinity; for (final candle in candles) { @@ -127,7 +164,11 @@ MarketProfile buildMarketProfile( final candle = candles[i]; if (!candle.low.isFinite || !candle.high.isFinite) continue; final first = ((candle.low - low) / tick).floor().clamp(0, rows - 1); - final last = ((candle.high - low) / tick).ceil().clamp(1, rows); + // A candle with no range sitting on a level's edge still traded there. + final last = math.max( + first + 1, + ((candle.high - low) / tick).ceil().clamp(1, rows), + ); for (var row = first; row < last; row++) { periods[row].add(i); } @@ -441,7 +482,7 @@ class _MarketProfileChartState extends State void didUpdateWidget(MarketProfileChart oldWidget) { super.didUpdateWidget(oldWidget); _animation.duration = widget.animationDuration; - if (!identical(oldWidget.profile, widget.profile)) { + if (oldWidget.profile != widget.profile) { _touched = null; if (widget.animationDuration > Duration.zero) { _animation.forward(from: 0); diff --git a/lib/src/progress/progress_bars.dart b/lib/src/progress/progress_bars.dart new file mode 100644 index 0000000..aff33e2 --- /dev/null +++ b/lib/src/progress/progress_bars.dart @@ -0,0 +1,610 @@ +import 'dart:math' as math; + +import 'package:flutter/foundation.dart' show listEquals; +import 'package:flutter/widgets.dart'; + +import '../renderer/text_painter_cache.dart'; + +/// The shape of a [ProgressBars] track and its fill. +enum ProgressShape { + /// One bar with fully round ends. + pill, + + /// One bar with square-ish ends and a small corner radius. + block, + + /// A row of separate cells, filled from the left, the last one partly. + segmented, +} + +/// One thing's progress — a row of a [ProgressBars]. +@immutable +class ProgressItem { + /// Creates a row called [label] that has reached [value] of [total]. + const ProgressItem({ + required this.label, + required this.value, + this.total = 100, + this.target, + this.color, + this.valueLabel, + }); + + /// What the row is called, written above the bar at the left. + final String label; + + /// How far it has got, in the same units as [total]. + final double value; + + /// What full is. A [value] past it fills the bar and no more. + final double total; + + /// A mark across the bar for where it should be by now, in the same units as + /// [total]; null marks none. + final double? target; + + /// What the fill is painted in; null takes the chart's colour. + final Color? color; + + /// Written above the bar at the right; null writes the share done, as a + /// whole per cent. + final String? valueLabel; + + /// How much of the bar is filled, from 0 to 1. + double get fraction => _share(value); + + /// Where the target sits along the bar, from 0 to 1; null when there is none. + double? get targetFraction { + final goal = target; + return goal == null || !goal.isFinite ? null : _share(goal); + } + + double _share(double of) { + if (!of.isFinite || !total.isFinite || total <= 0) return 0; + return (of / total).clamp(0.0, 1.0); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ProgressItem && + other.label == label && + other.value == value && + other.total == total && + other.target == target && + other.color == color && + other.valueLabel == valueLabel; + + @override + int get hashCode => + Object.hash(label, value, total, target, color, valueLabel); +} + +/// Where one row of a [ProgressBars] sits. +@immutable +class ProgressRowLayout { + /// Creates the layout of the row at [index]. + const ProgressRowLayout({ + required this.index, + required this.item, + required this.rowRect, + required this.headerRect, + required this.trackRect, + required this.fillRect, + required this.targetX, + }); + + /// Which row this is, into the chart's items. + final int index; + + /// The row itself. + final ProgressItem item; + + /// The whole row: its header and its bar. + final Rect rowRect; + + /// Where the label and the value are written. + final Rect headerRect; + + /// The bar at its full length. + final Rect trackRect; + + /// The part of the bar that is done, from the track's left edge. + final Rect fillRect; + + /// Where the target mark crosses the bar; null when the row has no target. + final double? targetX; + + /// Whether [point] falls in the row, or the half of the gap under it. + bool hit(Offset point) => + point.dy >= rowRect.top && point.dy <= rowRect.bottom + _hitSlack; + + static const _hitSlack = 6.0; +} + +/// Where every row of a [ProgressBars] sits. +@immutable +class ProgressLayout { + /// Creates a laid-out chart. + const ProgressLayout({required this.size, required this.rows}); + + /// Nothing to draw. + static const empty = ProgressLayout(size: Size.zero, rows: []); + + /// The box the chart was laid out in. + final Size size; + + /// The rows, in the order they were given. + final List rows; + + /// Whether there is anything to draw. + bool get isEmpty => rows.isEmpty; + + /// The row under [point]; null when it is past the last row. + ProgressRowLayout? rowAt(Offset point) { + for (final row in rows) { + if (row.hit(point)) return row; + } + return null; + } +} + +/// Lays out [items] in [size]: a header line of [headerHeight], [headerGap], +/// then a bar [barHeight] tall, with [rowGap] between rows. +/// +/// [progress] runs from 0 to 1 and shortens every fill for a draw-in +/// animation; the targets do not move with it. +ProgressLayout layOutProgress( + List items, { + required Size size, + double barHeight = 10, + double headerHeight = 18, + double headerGap = 6, + double rowGap = 16, + EdgeInsets padding = EdgeInsets.zero, + double progress = 1, +}) { + if (items.isEmpty) return ProgressLayout(size: size, rows: const []); + final box = padding.deflateRect(Offset.zero & size); + if (box.width <= 0 || box.height <= 0) { + return ProgressLayout(size: size, rows: const []); + } + + // Rows share whatever height they are given, shrinking to fit a short box + // rather than spilling out of it. + final natural = headerHeight + headerGap + barHeight; + final wanted = items.length * natural + (items.length - 1) * rowGap; + final scale = wanted > box.height && wanted > 0 ? box.height / wanted : 1.0; + final header = headerHeight * scale; + final gap = headerGap * scale; + final bar = barHeight * scale; + final between = rowGap * scale; + final t = progress.clamp(0.0, 1.0); + + final rows = []; + for (var i = 0; i < items.length; i++) { + final item = items[i]; + final top = box.top + i * (header + gap + bar + between); + final track = Rect.fromLTWH(box.left, top + header + gap, box.width, bar); + final goal = item.targetFraction; + rows.add( + ProgressRowLayout( + index: i, + item: item, + rowRect: Rect.fromLTWH(box.left, top, box.width, header + gap + bar), + headerRect: Rect.fromLTWH(box.left, top, box.width, header), + trackRect: track, + fillRect: Rect.fromLTWH( + track.left, + track.top, + track.width * item.fraction * t, + track.height, + ), + targetX: goal == null ? null : track.left + goal * track.width, + ), + ); + } + return ProgressLayout(size: size, rows: rows); +} + +/// Bars that fill to how far each thing has got — progress bars. +/// +/// A column of them reads as a checklist of goals: a label and a figure above +/// each bar, the bar itself pill-shaped, blocky or cut into cells, and an +/// optional mark for where it should be by now. +/// +/// ```dart +/// ProgressBars( +/// items: const [ +/// ProgressItem(label: 'Monthly profit', value: 72, target: 60), +/// ProgressItem(label: 'Drawdown budget', value: 31, total: 100), +/// ], +/// shape: ProgressShape.segmented, +/// ); +/// ``` +class ProgressBars extends StatefulWidget { + /// Creates progress bars for [items]. + const ProgressBars({ + super.key, + required this.items, + this.shape = ProgressShape.pill, + this.segments = 10, + this.segmentGap = 3, + this.color = const Color(0xFF4C86CD), + this.trackColor = const Color(0x22909196), + this.targetColor = const Color(0xFFE9ECEF), + this.barHeight = 10, + this.headerHeight = 18, + this.headerGap = 6, + this.rowGap = 16, + this.showValues = true, + this.labelStyle, + this.valueStyle, + this.valueFormatter, + this.padding = EdgeInsets.zero, + this.backgroundColor, + this.animationDuration = Duration.zero, + this.animationCurve = Curves.easeOutCubic, + this.animateOnMount = true, + this.onItemTap, + this.semanticLabel, + }); + + /// The rows, drawn top to bottom in the order given. + final List items; + + /// The shape of the bars. + final ProgressShape shape; + + /// How many cells a [ProgressShape.segmented] bar is cut into. + final int segments; + + /// Space between those cells. + final double segmentGap; + + /// What the fills are painted in, where a row names no colour. + final Color color; + + /// The bar behind a fill. + final Color trackColor; + + /// What the target marks are painted in. + final Color targetColor; + + /// How tall a bar is. + final double barHeight; + + /// How tall the line of label and value above a bar is. + final double headerHeight; + + /// Space between that line and its bar. + final double headerGap; + + /// Space between two rows. + final double rowGap; + + /// Whether each row's figure is written at the right of its header. + final bool showValues; + + /// Style of the row labels. + final TextStyle? labelStyle; + + /// Style of the figures. + final TextStyle? valueStyle; + + /// Writes a row's figure; null writes the share done as a whole per cent. + final String Function(ProgressItem item)? valueFormatter; + + /// Space kept clear around the rows. + final EdgeInsets padding; + + /// Painted behind the whole chart. + final Color? backgroundColor; + + /// How long the fills take to draw in; zero draws them at once. + final Duration animationDuration; + + /// The easing of [animationDuration]. + final Curve animationCurve; + + /// Whether the first build draws the fills in. + final bool animateOnMount; + + /// Called with a row when it is touched, and with null when the touch + /// leaves the rows. + final void Function(ProgressItem? item)? onItemTap; + + /// What a screen reader announces for the chart. + final String? semanticLabel; + + /// How tall the chart is in a box that sets no height. + double get intrinsicHeight => items.isEmpty + ? padding.vertical + : padding.vertical + + items.length * (headerHeight + headerGap + barHeight) + + (items.length - 1) * rowGap; + + @override + State createState() => _ProgressBarsState(); +} + +class _ProgressBarsState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _animation; + final TextPainterCache _text = TextPainterCache(capacity: 128); + ProgressLayout _layout = ProgressLayout.empty; + int? _touched; + + @override + void initState() { + super.initState(); + _animation = AnimationController( + vsync: this, + duration: widget.animationDuration, + value: 1, + )..addListener(() => setState(() {})); + if (widget.animateOnMount && widget.animationDuration > Duration.zero) { + _animation.forward(from: 0); + } + } + + @override + void didUpdateWidget(ProgressBars oldWidget) { + super.didUpdateWidget(oldWidget); + _animation.duration = widget.animationDuration; + if (!listEquals(oldWidget.items, widget.items) && + widget.animationDuration > Duration.zero) { + _animation.forward(from: 0); + } + if (_touched != null && _touched! >= widget.items.length) _touched = null; + } + + @override + void dispose() { + _text.dispose(); + _animation.dispose(); + super.dispose(); + } + + void _touch(Offset point) { + final row = _layout.rowAt(point); + if (row?.index == _touched) return; + setState(() => _touched = row?.index); + widget.onItemTap?.call(row?.item); + } + + void _clear() { + if (_touched == null) return; + setState(() => _touched = null); + widget.onItemTap?.call(null); + } + + @override + Widget build(BuildContext context) { + final progress = widget.animationCurve.transform(_animation.value); + + return Semantics( + container: true, + label: widget.semanticLabel, + child: LayoutBuilder( + builder: (context, constraints) { + final width = constraints.hasBoundedWidth + ? constraints.maxWidth + : 320.0; + final height = + constraints.hasBoundedHeight && constraints.maxHeight.isFinite + ? constraints.maxHeight + : widget.intrinsicHeight; + _layout = layOutProgress( + widget.items, + size: Size(width, height), + barHeight: widget.barHeight, + headerHeight: widget.headerHeight, + headerGap: widget.headerGap, + rowGap: widget.rowGap, + padding: widget.padding, + progress: progress, + ); + + return SizedBox( + width: width, + height: height, + // A raw listener: gesture arbitration would swallow the first + // hundred milliseconds of a drag down the rows. + child: Listener( + onPointerDown: (event) => _touch(event.localPosition), + onPointerMove: (event) => _touch(event.localPosition), + onPointerUp: (_) => _clear(), + onPointerCancel: (_) => _clear(), + child: CustomPaint( + size: Size(width, height), + painter: ProgressBarsPainter( + chart: widget, + layout: _layout, + touched: _touched, + textCache: _text, + ), + ), + ), + ); + }, + ), + ); + } +} + +String _valueOf(ProgressBars chart, ProgressItem item) { + final format = chart.valueFormatter; + if (format != null) return format(item); + return item.valueLabel ?? '${(item.fraction * 100).round()}%'; +} + +/// Paints a [ProgressBars]: the labels, the tracks, the fills and the targets. +class ProgressBarsPainter extends CustomPainter { + /// Creates the painter for [chart], laid out as [layout]. + ProgressBarsPainter({ + required this.chart, + required this.layout, + required this.touched, + required this.textCache, + }); + + final ProgressBars chart; + final ProgressLayout layout; + + /// The row under the finger, into [ProgressBars.items]; null when none is. + final int? touched; + final TextPainterCache textCache; + + /// The outline of a track, whole or cut into cells. + Path _trackPath(Rect rect) { + final radius = chart.shape == ProgressShape.pill + ? rect.height / 2 + : math.min(3.0, rect.height / 2); + if (chart.shape != ProgressShape.segmented) { + return Path() + ..addRRect(RRect.fromRectAndRadius(rect, Radius.circular(radius))); + } + // Never so many cells that one is narrower than a pixel. + final gap = chart.segmentGap; + final count = chart.segments + .clamp(1, math.max(1, ((rect.width + gap) / (1 + gap)).floor())) + .toInt(); + final width = (rect.width - gap * (count - 1)) / count; + final path = Path(); + for (var i = 0; i < count; i++) { + final left = rect.left + i * (width + gap); + path.addRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH(left, rect.top, width, rect.height), + Radius.circular(radius), + ), + ); + } + return path; + } + + @override + void paint(Canvas canvas, Size size) { + final background = chart.backgroundColor; + if (background != null) { + canvas.drawRect(Offset.zero & size, Paint()..color = background); + } + if (layout.isEmpty) return; + + final labelStyle = + chart.labelStyle ?? + const TextStyle(color: Color(0xFFB4B8C0), fontSize: 12); + final valueStyle = + chart.valueStyle ?? + const TextStyle( + color: Color(0xFFE9ECEF), + fontSize: 12, + fontWeight: FontWeight.w600, + ); + + for (final row in layout.rows) { + if (touched == row.index) { + canvas.drawRRect( + RRect.fromRectAndRadius( + row.rowRect.inflate(4), + const Radius.circular(6), + ), + Paint()..color = const Color(0x14FFFFFF), + ); + } + + // The header: the figure at the right, the label in what is left. + var labelRight = row.headerRect.right; + if (chart.showValues) { + final text = _valueOf(chart, row.item); + if (text.isNotEmpty) { + final painter = textCache.get(text, valueStyle); + painter.paint( + canvas, + Offset( + row.headerRect.right - painter.width, + row.headerRect.center.dy - painter.height / 2, + ), + ); + labelRight -= painter.width + 8; + } + } + if (labelRight - row.headerRect.left > 4) { + final painter = textCache.get(row.item.label, labelStyle); + // Clipped rather than ellipsised: the cache paints plain lines, and a + // long label must not run under the figure beside it. + canvas + ..save() + ..clipRect( + Rect.fromLTRB( + row.headerRect.left, + row.headerRect.top, + labelRight, + row.headerRect.bottom, + ), + ); + painter.paint( + canvas, + Offset( + row.headerRect.left, + row.headerRect.center.dy - painter.height / 2, + ), + ); + canvas.restore(); + } + + // The track, then the fill clipped to it so a short fill keeps the + // track's rounded left end and a cell is filled part way. + final track = _trackPath(row.trackRect); + canvas.drawPath( + track, + Paint() + ..color = chart.trackColor + ..isAntiAlias = true, + ); + if (row.fillRect.width > 0) { + canvas + ..save() + ..clipPath(track); + final fill = Paint() + ..color = row.item.color ?? chart.color + ..isAntiAlias = true; + if (chart.shape == ProgressShape.segmented) { + canvas.drawRect(row.fillRect, fill); + } else { + final radius = chart.shape == ProgressShape.pill + ? row.fillRect.height / 2 + : math.min(3.0, row.fillRect.height / 2); + canvas.drawRRect( + RRect.fromRectAndRadius(row.fillRect, Radius.circular(radius)), + fill, + ); + } + canvas.restore(); + } + + // The target: a rounded tick standing a little proud of the bar. + final target = row.targetX; + if (target != null) { + final over = row.trackRect.height * 0.35; + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTRB( + target - 1, + row.trackRect.top - over, + target + 1, + row.trackRect.bottom + over, + ), + const Radius.circular(1), + ), + Paint()..color = chart.targetColor, + ); + } + } + } + + @override + bool shouldRepaint(ProgressBarsPainter old) => + old.chart != chart || old.layout != layout || old.touched != touched; +} diff --git a/lib/src/progress/progress_card.dart b/lib/src/progress/progress_card.dart new file mode 100644 index 0000000..6ddf661 --- /dev/null +++ b/lib/src/progress/progress_card.dart @@ -0,0 +1,177 @@ +import 'dart:math' as math; + +import 'package:flutter/widgets.dart'; + +/// How a [ProgressCard]'s background fills in. +enum ProgressCardMode { + /// A wedge sweeps round the card's centre like a clock, from twelve o'clock + /// and clockwise. Half done is the right half; a quarter is the top right. + /// The angle is the progress, so it reads at a glance on a tile. + sweep, + + /// A circle grows from the middle of the card's left edge, and at full + /// progress has reached the far corners. Its edge is a curve rather than a + /// line, which suits a wide card better than a sweep does. + circle, + + /// A bar fills the card from its left edge, as wide as the progress. Reads + /// best on a wide, short card. + linear, +} + +/// A card around any widget whose whole background is a progress fill. +/// +/// The card is faintly tinted in [color], and a part of it fills in as far as +/// [progress] has got — round the card like a clock, as a circle growing from +/// its left edge, or along it from the left, as [mode] says. The child is drawn as it is, over the fill. +/// +/// ```dart +/// ProgressCard( +/// progress: 12 / 20, +/// color: Colors.teal, +/// child: Text('Technical analysis · 12 of 20 lessons'), +/// ); +/// ``` +class ProgressCard extends StatelessWidget { + /// Creates a card around [child] filled to [progress], from 0 to 1. + const ProgressCard({ + super.key, + required this.child, + required this.progress, + this.mode = ProgressCardMode.sweep, + this.color = const Color(0xFF4C86CD), + this.trackOpacity = 0.08, + this.fillOpacity = 0.55, + this.borderRadius = const BorderRadius.all(Radius.circular(14)), + this.padding = const EdgeInsets.all(16), + this.animationDuration = const Duration(milliseconds: 600), + this.animationCurve = Curves.easeOutCubic, + this.semanticLabel, + }); + + /// What the card holds. + final Widget child; + + /// How far along the thing is, from 0 to 1. Values outside that range, and + /// ones that are not numbers, are held to it. + final double progress; + + /// How the background fills in: round the card, as a circle, or along it. + final ProgressCardMode mode; + + /// What the background is painted in. + final Color color; + + /// The opacity of [color] over the part of the card not yet filled. + final double trackOpacity; + + /// The opacity of [color] over the part that is. + final double fillOpacity; + + /// The rounding of the card's corners. + final BorderRadius borderRadius; + + /// Space between the card's edge and its child. + final EdgeInsetsGeometry padding; + + /// How long the fill takes to reach [progress], on the first build and on + /// every change. + final Duration animationDuration; + + /// The easing of [animationDuration]. + final Curve animationCurve; + + /// What a screen reader announces for the card. + final String? semanticLabel; + + /// How much of the card is filled, from 0 to 1. + double get fraction => progress.isFinite ? progress.clamp(0.0, 1.0) : 0.0; + + @override + Widget build(BuildContext context) { + return Semantics( + container: true, + label: semanticLabel, + child: Container( + // Clipped, so the fill takes the card's rounded corners. + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: color.withValues(alpha: trackOpacity), + borderRadius: borderRadius, + // A line of the full colour keeps an empty card's edge readable. + border: Border.all(color: color.withValues(alpha: 0.35)), + ), + child: TweenAnimationBuilder( + tween: Tween(end: fraction), + duration: animationDuration, + curve: animationCurve, + builder: (context, value, child) => CustomPaint( + painter: _FillPainter( + mode: mode, + fraction: value, + color: color.withValues(alpha: fillOpacity), + ), + child: child, + ), + child: Padding(padding: padding, child: child), + ), + ), + ); + } +} + +/// Fills the part of the box that [fraction] of the way round, or along, is. +class _FillPainter extends CustomPainter { + _FillPainter({ + required this.mode, + required this.fraction, + required this.color, + }); + + final ProgressCardMode mode; + final double fraction; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + if (fraction <= 0 || size.isEmpty) return; + final paint = Paint() + ..color = color + ..isAntiAlias = true; + if (fraction >= 1) { + canvas.drawRect(Offset.zero & size, paint); + return; + } + if (mode == ProgressCardMode.linear) { + canvas.drawRect( + Rect.fromLTWH(0, 0, size.width * fraction, size.height), + paint, + ); + return; + } + if (mode == ProgressCardMode.circle) { + // From the middle of the left edge, out to the farthest corner at 1. + final reach = math.sqrt( + size.width * size.width + size.height * size.height / 4, + ); + canvas.drawCircle(Offset(0, size.height / 2), reach * fraction, paint); + return; + } + // Wide enough to reach the corners, so the wedge runs edge to edge. + final center = size.center(Offset.zero); + final radius = math.sqrt( + size.width * size.width + size.height * size.height, + ); + canvas.drawArc( + Rect.fromCircle(center: center, radius: radius), + -math.pi / 2, + 2 * math.pi * fraction, + true, + paint, + ); + } + + @override + bool shouldRepaint(_FillPainter old) => + old.mode != mode || old.fraction != fraction || old.color != color; +} diff --git a/lib/src/radar/radar_chart.dart b/lib/src/radar/radar_chart.dart index 73aeb39..e9d157a 100644 --- a/lib/src/radar/radar_chart.dart +++ b/lib/src/radar/radar_chart.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart' show listEquals; import 'dart:math' as math; import 'package:flutter/widgets.dart'; @@ -63,6 +64,31 @@ class RadarSeries { /// The colour the outline is drawn in. Color get strokeColor => color ?? radarDefaultColor; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is RadarSeries && + listEquals(other.values, values) && + other.label == label && + other.color == color && + other.fillColor == fillColor && + other.width == width && + listEquals(other.dashPattern, dashPattern) && + other.dot == dot && + other.showInTooltip == showInTooltip; + + @override + int get hashCode => Object.hashAll([ + Object.hashAll(values), + label, + color, + fillColor, + width, + Object.hashAll(dashPattern ?? const []), + dot, + showInTooltip, + ]); } /// What a touch on a [RadarChart] landed on. diff --git a/lib/src/renderer/base_chart_painter.dart b/lib/src/renderer/base_chart_painter.dart index f6548dc..575dc81 100644 --- a/lib/src/renderer/base_chart_painter.dart +++ b/lib/src/renderer/base_chart_painter.dart @@ -538,8 +538,10 @@ abstract class BaseChartPainter extends CustomPainter { /// compute maximum and minimum value void getMainMaxMinValue(KLineEntity item, int i) { - double maxPrice = item.high; - double minPrice = item.low; + // A candle with no price (a feed's NaN) must not poison the scale: `max` + // of a NaN is a NaN, and the whole axis would go with it. + double maxPrice = item.high.isFinite ? item.high : double.negativeInfinity; + double minPrice = item.low.isFinite ? item.low : double.infinity; // An overlay may sit outside the candles it is drawn over — a long average // lags, a band steps outside — so the price scale has to hold it too. @@ -577,7 +579,7 @@ abstract class BaseChartPainter extends CustomPainter { mMainMinIndex = i; } - if (isLine == true) { + if (isLine == true && item.close.isFinite) { mMainMaxValue = max(mMainMaxValue, item.close); mMainMinValue = min(mMainMinValue, item.close); } @@ -585,14 +587,12 @@ abstract class BaseChartPainter extends CustomPainter { // get the maximum and minimum of the Vol value void getVolMaxMinValue(KLineEntity item) { - mVolMaxValue = max( - mVolMaxValue, - max(item.vol, max(item.ma5Volume ?? 0, item.ma10Volume ?? 0)), - ); - mVolMinValue = min( - mVolMinValue, - min(item.vol, min(item.ma5Volume ?? 0, item.ma10Volume ?? 0)), - ); + double finite(double? v) => v != null && v.isFinite ? v : 0; + final vol = finite(item.vol); + final ma5 = finite(item.ma5Volume); + final ma10 = finite(item.ma10Volume); + mVolMaxValue = max(mVolMaxValue, max(vol, max(ma5, ma10))); + mVolMinValue = min(mVolMinValue, min(vol, min(ma5, ma10))); } // translate x diff --git a/lib/src/renderer/base_chart_renderer.dart b/lib/src/renderer/base_chart_renderer.dart index 96eb067..4d0e1d0 100644 --- a/lib/src/renderer/base_chart_renderer.dart +++ b/lib/src/renderer/base_chart_renderer.dart @@ -25,9 +25,15 @@ abstract class BaseChartRenderer { this.priceAxisGutter = 0.0, this.priceAxisGutterOnLeft = false, }) : gridDashPattern = _validDash(gridDashPattern) { - if (maxValue == minValue) { - maxValue *= 1.5; - minValue /= 2; + if (!maxValue.isFinite || !minValue.isFinite) { + maxValue = 1; + minValue = 0; + } else if (maxValue == minValue) { + // A flat range needs room either side of it. Scaling it would leave 0 + // with no span at all, and turn a negative range upside down. + final room = maxValue == 0 ? 1.0 : maxValue.abs() / 2; + maxValue += room; + minValue -= room; } scaleY = chartRect.height / (maxValue - minValue); gridPaint diff --git a/lib/src/renderer/chart_painter.dart b/lib/src/renderer/chart_painter.dart index e3d19a5..1b036af 100644 --- a/lib/src/renderer/chart_painter.dart +++ b/lib/src/renderer/chart_painter.dart @@ -17,6 +17,7 @@ import 'base_chart_renderer.dart'; import 'indicator_pane_renderer.dart'; import 'main_renderer.dart'; import 'vol_renderer.dart'; +import '../extension/num_ext.dart'; enum CrossArea { main, volume, secondary, none } @@ -2985,6 +2986,8 @@ class ChartPainter extends BaseChartPainter { final x = translateXtoX(getX(index)); final y = getMainY(value); + // No candle in view had a price to mark. + if (!x.isFinite || !y.isFinite) return; final tp = getTextPainter(mMainRenderer.formatPrice(value), color); final linePaint = Paint() ..color = color @@ -3231,7 +3234,8 @@ class ChartPainter extends BaseChartPainter { /// Pins [y] to the candle area, for a label that has to stay findable even /// when the price it points at is off the top or the bottom of the axis. - double clampToMain(double y) => y.clamp(mMainRect.top, mMainRect.bottom); + double clampToMain(double y) => + y.clampWithin(mMainRect.top, mMainRect.bottom); } /// Draws the crosshair, its readouts and the legends, over the chart. diff --git a/lib/src/renderer/depth_painter.dart b/lib/src/renderer/depth_painter.dart index 2e8e04c..e0a2028 100644 --- a/lib/src/renderer/depth_painter.dart +++ b/lib/src/renderer/depth_painter.dart @@ -11,6 +11,7 @@ import '../entity/depth_book.dart'; import '../entity/depth_entity.dart'; import '../extension/canvas_extension.dart'; import '../utils/number_util.dart'; +import '../extension/num_ext.dart'; class DepthChartPainter extends CustomPainter { DepthChartPainter( @@ -54,6 +55,11 @@ class DepthChartPainter extends CustomPainter { ..strokeWidth = chartStyle.crossWidth ..color = chartColors.crossColor; + // A rung with no price or no volume has nowhere to be drawn. + bool drawable(DepthEntity e) => e.price.isFinite && e.vol.isFinite; + mBuyData = mBuyData?.where(drawable).toList(); + mSellData = mSellData?.where(drawable).toList(); + mBuyPath ??= Path(); mSellPath ??= Path(); book = DepthBook.fromCurves( @@ -448,7 +454,7 @@ class DepthChartPainter extends CustomPainter { dx = dx < mWidth * 0.25 ? dx + offset.dx : dx - offset.dx - popupPainter.width; - dy = (dy - popupPainter.height / 2).clamp( + dy = (dy - popupPainter.height / 2).clampWithin( offset.dy, mDrawHeight - popupPainter.height - offset.dy, ); @@ -513,7 +519,7 @@ class DepthChartPainter extends CustomPainter { // dy = dy < mDrawHeight / 2 // ? dy + offset.dy // : dy - offset.dy - popupPainter.height; - dy = (dy - popupPainter.height / 2).clamp( + dy = (dy - popupPainter.height / 2).clampWithin( offset.dy, mDrawHeight - popupPainter.height - offset.dy, ); diff --git a/lib/src/renderer/indicator_pane_renderer.dart b/lib/src/renderer/indicator_pane_renderer.dart index 80a61c4..207a646 100644 --- a/lib/src/renderer/indicator_pane_renderer.dart +++ b/lib/src/renderer/indicator_pane_renderer.dart @@ -9,6 +9,7 @@ import '../utils/axis_ticks.dart'; import '../utils/number_util.dart'; import 'base_chart_renderer.dart'; import 'series_painter.dart'; +import '../extension/num_ext.dart'; /// One indicator in its own pane below the candles. /// @@ -159,7 +160,7 @@ class IndicatorPaneRenderer extends BaseChartRenderer { strokeWidth: chartStyle.indicatorLineWidth, // 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), + zeroY: getY(0).clampWithin(chartRect.top, chartRect.bottom), ); canvas.restore(); } @@ -227,7 +228,7 @@ class IndicatorPaneRenderer extends BaseChartRenderer { // The pane is short, so a label is pulled inside its own box rather than // being allowed to run over the pane above or below. - final offsetY = (y - tp.height).clamp( + final offsetY = (y - tp.height).clampWithin( chartRect.top - topPadding, chartRect.bottom - tp.height, ); diff --git a/lib/src/renderer/main_renderer.dart b/lib/src/renderer/main_renderer.dart index df94e61..ffc8746 100644 --- a/lib/src/renderer/main_renderer.dart +++ b/lib/src/renderer/main_renderer.dart @@ -15,6 +15,7 @@ import '../utils/axis_ticks.dart'; import 'base_chart_renderer.dart'; import 'path_batch.dart'; import 'series_painter.dart'; +import '../extension/num_ext.dart'; /// Which side of the main chart the price axis labels sit on. enum VerticalTextAlignment { @@ -556,6 +557,15 @@ class MainRenderer extends BaseChartRenderer { Canvas canvas, { int index = 0, }) { + // A candle with a hole in it — a feed's NaN for a missing bar — is left as + // a gap; handing it to the canvas would throw. + if (!(curPoint.open.isFinite && + curPoint.high.isFinite && + curPoint.low.isFinite && + curPoint.close.isFinite && + lastPoint.close.isFinite)) { + return; + } switch (chartType) { case ChartType.candles: drawCandle(curPoint, canvas, curX, index); @@ -1073,7 +1083,7 @@ class MainRenderer extends BaseChartRenderer { // The label sits above its own line, and is nudged back inside the pane // at either end so the top one clears the legend and the bottom one is // not clipped away. - final offsetY = (y - tp.height).clamp( + final offsetY = (y - tp.height).clampWithin( chartRect.top - topPadding, chartRect.bottom - tp.height, ); diff --git a/lib/src/renderer/vol_renderer.dart b/lib/src/renderer/vol_renderer.dart index 2d1a0a5..161c5e3 100644 --- a/lib/src/renderer/vol_renderer.dart +++ b/lib/src/renderer/vol_renderer.dart @@ -73,7 +73,7 @@ class VolRenderer extends BaseChartRenderer { final r = mVolWidth / 2; final bottom = chartRect.bottom; final top = math.min(getVolY(curPoint.vol), bottom - 1); - if (curPoint.vol != 0) { + if (curPoint.vol != 0 && curPoint.vol.isFinite && top.isFinite) { canvas.drawRect( Rect.fromLTRB(curX - r, top, curX + r, bottom), chartPaint @@ -183,7 +183,10 @@ class VolRenderer extends BaseChartRenderer { // The top mark is lifted onto the legend row, where the old single // maximum label sat; the rest ride above their own line. final top = chartRect.top - topPadding; - final offsetY = (y - tp.height).clamp(top, chartRect.bottom - tp.height); + final offsetY = (y - tp.height).clampWithin( + top, + chartRect.bottom - tp.height, + ); tp.paint(canvas, Offset(axisLabelX(tp.width, padding), offsetY)); tp.dispose(); diff --git a/lib/src/replay_controller.dart b/lib/src/replay_controller.dart index 991df60..228800a 100644 --- a/lib/src/replay_controller.dart +++ b/lib/src/replay_controller.dart @@ -29,9 +29,15 @@ class ChartReplayController extends ChangeNotifier { ChartReplayController({ Duration interval = const Duration(milliseconds: 500), }) { - _interval = interval; + _interval = _atLeastOneMillisecond(interval); } + /// A zero or negative interval would make the timer fire without pause. + static Duration _atLeastOneMillisecond(Duration value) => + value < const Duration(milliseconds: 1) + ? const Duration(milliseconds: 1) + : value; + int? _position; int _length = 0; late Duration _interval; @@ -55,6 +61,14 @@ class ChartReplayController extends ChangeNotifier { /// How many candles there are in total, as the chart last reported it. int get length => _length; + /// How far the playback has got, from 0 to 1 — for a scrub bar or a progress + /// indicator. 1 while the whole series is showing, since nothing is held back. + double get progress { + final position = _position; + if (position == null || _length == 0) return 1; + return (position / _length).clamp(0.0, 1.0); + } + /// Whether the playback has reached the newest candle. bool get isAtEnd => _position != null && _position! >= _length; @@ -68,7 +82,9 @@ class ChartReplayController extends ChangeNotifier { void reportLength(int length) { if (length == _length) return; _length = length; - if (_position != null && _position! > length) _position = length; + if (_position != null && _position! > length) { + _position = length < 1 ? 1 : length; + } } /// Rewinds to [at] and holds the chart there. @@ -102,7 +118,11 @@ class ChartReplayController extends ChangeNotifier { if (_position == null || by <= 0) return; final next = _clamp(_position! + by); if (next == _position) { - _stopTimer(); + // Caught up while playing: say so, or a play/pause button stays stale. + if (_timer != null) { + _stopTimer(); + notifyListeners(); + } return; } _position = next; @@ -125,7 +145,15 @@ class ChartReplayController extends ChangeNotifier { /// button works without a bar having been picked first. Does nothing once /// the playback is at the end — [stepBack] or [start] first. void play({Duration? interval}) { - if (interval != null) _interval = interval; + if (interval != null) interval = _atLeastOneMillisecond(interval); + if (interval != null && interval != _interval) { + // Already playing: the new pace applies from the next candle on. + if (_timer != null) { + setInterval(interval); + return; + } + _interval = interval; + } if (_position == null) start(at: (_length / 2).round()); if (isAtEnd || _timer != null) return; @@ -147,6 +175,7 @@ class ChartReplayController extends ChangeNotifier { /// /// Takes effect immediately, restarting the timer if one is running. void setInterval(Duration value) { + value = _atLeastOneMillisecond(value); if (value == _interval) return; _interval = value; if (_timer != null) { diff --git a/lib/src/sankey/sankey_chart.dart b/lib/src/sankey/sankey_chart.dart index 3a5029f..6e5433c 100644 --- a/lib/src/sankey/sankey_chart.dart +++ b/lib/src/sankey/sankey_chart.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart' show listEquals; import 'dart:math' as math; import 'package:flutter/widgets.dart'; @@ -22,6 +23,18 @@ class SankeyNode { /// Anything the app wants back when this node is touched. final Object? data; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SankeyNode && + other.id == id && + other.label == label && + other.color == color && + other.data == data; + + @override + int get hashCode => Object.hashAll([id, label, color, data]); } /// A quantity moving from one [SankeyNode] to another. @@ -50,6 +63,19 @@ class SankeyLink { /// Anything the app wants back when this flow is touched. final Object? data; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SankeyLink && + other.source == source && + other.target == target && + other.value == value && + other.color == color && + other.data == data; + + @override + int get hashCode => Object.hashAll([source, target, value, color, data]); } /// Where one [SankeyNode] was laid out. @@ -492,8 +518,8 @@ class _SankeyChartState extends State void didUpdateWidget(SankeyChart oldWidget) { super.didUpdateWidget(oldWidget); _animation.duration = widget.animationDuration; - if (!identical(oldWidget.nodes, widget.nodes) || - !identical(oldWidget.links, widget.links)) { + if (!listEquals(oldWidget.nodes, widget.nodes) || + !listEquals(oldWidget.links, widget.links)) { _node = null; _link = null; if (widget.animationDuration > Duration.zero) { diff --git a/lib/src/slope/slope_chart.dart b/lib/src/slope/slope_chart.dart index 9a126ce..f173d2f 100644 --- a/lib/src/slope/slope_chart.dart +++ b/lib/src/slope/slope_chart.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart' show listEquals; import 'dart:math' as math; import 'package:flutter/widgets.dart'; @@ -29,6 +30,19 @@ class SlopeSeries { /// Shown when the series is touched; null shows its label and values. final String? tooltip; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SlopeSeries && + other.label == label && + listEquals(other.values, values) && + other.color == color && + other.tooltip == tooltip; + + @override + int get hashCode => + Object.hashAll([label, Object.hashAll(values), color, tooltip]); } /// What a [SlopeChart] puts on its vertical axis. @@ -500,7 +514,7 @@ class _SlopeChartState extends State void didUpdateWidget(SlopeChart oldWidget) { super.didUpdateWidget(oldWidget); _animation.duration = widget.animationDuration; - if (!identical(oldWidget.series, widget.series) && + if (!listEquals(oldWidget.series, widget.series) && widget.animationDuration > Duration.zero) { _animation.forward(from: 0); } diff --git a/lib/src/sparkline/sparkline_grid.dart b/lib/src/sparkline/sparkline_grid.dart index e87c415..991af79 100644 --- a/lib/src/sparkline/sparkline_grid.dart +++ b/lib/src/sparkline/sparkline_grid.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart' show listEquals; import 'dart:math' as math; import 'package:flutter/widgets.dart'; @@ -66,6 +67,27 @@ class SparklineTile { if (from == null || to == null) return true; return to >= from; } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SparklineTile && + other.label == label && + listEquals(other.values, values) && + other.color == color && + other.valueLabel == valueLabel && + other.subtitle == subtitle && + other.tooltip == tooltip; + + @override + int get hashCode => Object.hashAll([ + label, + Object.hashAll(values), + color, + valueLabel, + subtitle, + tooltip, + ]); } /// Where one tile of a [SparklineGrid] sits. @@ -487,7 +509,7 @@ class _SparklineGridState extends State void didUpdateWidget(SparklineGrid oldWidget) { super.didUpdateWidget(oldWidget); _animation.duration = widget.animationDuration; - if (!identical(oldWidget.tiles, widget.tiles) && + if (!listEquals(oldWidget.tiles, widget.tiles) && widget.animationDuration > Duration.zero) { _animation.forward(from: 0); } diff --git a/lib/src/stream/stream_chart.dart b/lib/src/stream/stream_chart.dart index fc8e3d8..bb3ebd0 100644 --- a/lib/src/stream/stream_chart.dart +++ b/lib/src/stream/stream_chart.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart' show listEquals; import 'dart:math' as math; import 'package:flutter/widgets.dart'; @@ -36,6 +37,19 @@ class StreamSeries { final value = values[period]; return value.isFinite && value > 0 ? value : 0; } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is StreamSeries && + other.label == label && + listEquals(other.values, values) && + other.color == color && + other.tooltip == tooltip; + + @override + int get hashCode => + Object.hashAll([label, Object.hashAll(values), color, tooltip]); } /// Where a [StreamChart]'s stack is centred. @@ -599,7 +613,7 @@ class _StreamChartState extends State void didUpdateWidget(StreamChart oldWidget) { super.didUpdateWidget(oldWidget); _animation.duration = widget.animationDuration; - if (!identical(oldWidget.series, widget.series) && + if (!listEquals(oldWidget.series, widget.series) && widget.animationDuration > Duration.zero) { _animation.forward(from: 0); } diff --git a/lib/src/sunburst/sunburst_chart.dart b/lib/src/sunburst/sunburst_chart.dart index bdc7074..d00ac9f 100644 --- a/lib/src/sunburst/sunburst_chart.dart +++ b/lib/src/sunburst/sunburst_chart.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart' show listEquals; import 'dart:math' as math; import 'package:flutter/widgets.dart'; @@ -365,7 +366,7 @@ class _SunburstChartState extends State void didUpdateWidget(SunburstChart oldWidget) { super.didUpdateWidget(oldWidget); _animation.duration = widget.animationDuration; - if (!identical(oldWidget.items, widget.items)) { + if (!listEquals(oldWidget.items, widget.items)) { _touched = null; if (widget.animationDuration > Duration.zero) { _animation.forward(from: 0); @@ -464,7 +465,15 @@ class _SunburstChartState extends State center: _center, radius: inner / math.sqrt2, ), - child: IgnorePointer(child: Center(child: widget.center)), + child: IgnorePointer( + // Shrunk to fit the hole rather than overflowing it. + child: Center( + child: FittedBox( + fit: BoxFit.scaleDown, + child: widget.center, + ), + ), + ), ), if (tooltip != null && details != null) Positioned.fill( diff --git a/lib/src/timeline/trade_timeline_chart.dart b/lib/src/timeline/trade_timeline_chart.dart index df6a89a..494b8df 100644 --- a/lib/src/timeline/trade_timeline_chart.dart +++ b/lib/src/timeline/trade_timeline_chart.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart' show listEquals; import 'dart:math' as math; import 'package:flutter/widgets.dart'; @@ -64,6 +65,35 @@ class TimelineTrade { final exit = exitTime ?? now; return exit.isBefore(entryTime) ? entryTime : exit; } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is TimelineTrade && + other.entryTime == entryTime && + other.exitTime == exitTime && + other.lane == lane && + other.side == side && + other.pnl == pnl && + other.entryPrice == entryPrice && + other.exitPrice == exitPrice && + other.quantity == quantity && + other.color == color && + other.data == data; + + @override + int get hashCode => Object.hashAll([ + entryTime, + exitTime, + lane, + side, + pnl, + entryPrice, + exitPrice, + quantity, + color, + data, + ]); } /// The span [trades] cover, from the first entry to the last exit. @@ -663,7 +693,7 @@ class _TradeTimelineChartState extends State void didUpdateWidget(TradeTimelineChart oldWidget) { super.didUpdateWidget(oldWidget); _animation.duration = widget.animationDuration; - if (!identical(oldWidget.trades, widget.trades)) { + if (!listEquals(oldWidget.trades, widget.trades)) { _touched = null; if (widget.animationDuration > Duration.zero) { _animation.forward(from: 0); diff --git a/lib/src/treemap/treemap_chart.dart b/lib/src/treemap/treemap_chart.dart index ff21aa7..da1ba14 100644 --- a/lib/src/treemap/treemap_chart.dart +++ b/lib/src/treemap/treemap_chart.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart' show listEquals; import 'dart:math' as math; import 'package:flutter/widgets.dart'; @@ -195,7 +196,7 @@ class _TreemapChartState extends State void didUpdateWidget(TreemapChart oldWidget) { super.didUpdateWidget(oldWidget); _animation.duration = widget.animationDuration; - if (!identical(oldWidget.items, widget.items)) { + if (!listEquals(oldWidget.items, widget.items)) { _touched = null; if (widget.animationDuration > Duration.zero) { _animation.forward(from: 0); diff --git a/lib/src/treemap/treemap_data.dart b/lib/src/treemap/treemap_data.dart index ded12f3..b7a86ce 100644 --- a/lib/src/treemap/treemap_data.dart +++ b/lib/src/treemap/treemap_data.dart @@ -71,6 +71,27 @@ class TreemapItem { } return sum; } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is TreemapItem && + other.value == value && + listEquals(other.children, children) && + other.label == label && + other.color == color && + other.colorValue == colorValue && + other.data == data; + + @override + int get hashCode => Object.hashAll([ + value, + Object.hashAll(children), + label, + color, + colorValue, + data, + ]); } /// Where one [TreemapItem] was laid out. @@ -164,7 +185,8 @@ void _layOutLevel( final entries = <_Entry>[ for (var i = 0; i < items.length; i++) - if (items[i].total > 0) _Entry(items[i], i, items[i].total), + if (items[i].total.isFinite && items[i].total > 0) + _Entry(items[i], i, items[i].total), ]; if (entries.isEmpty) return; if (sort) entries.sort((a, b) => b.weight.compareTo(a.weight)); diff --git a/lib/src/utils/axis_ticks.dart b/lib/src/utils/axis_ticks.dart index 7e00a99..fa7292b 100644 --- a/lib/src/utils/axis_ticks.dart +++ b/lib/src/utils/axis_ticks.dart @@ -23,8 +23,10 @@ double niceStep(double span, int target) { if (!span.isFinite || span <= 0 || target <= 0) return 0; final raw = span / target; + // 10.0, not 10: with two ints `pow` is an integer power, which wraps round + // past 1e18 and turns the axis of a very large number into noise. final magnitude = math - .pow(10, (math.log(raw) / math.ln10).floor()) + .pow(10.0, (math.log(raw) / math.ln10).floor()) .toDouble(); if (magnitude <= 0 || !magnitude.isFinite) return 0; @@ -73,9 +75,23 @@ List niceLogTicks(double min, double max, {int target = 5}) { final lowDecade = (math.log(min) / math.ln10).floor(); final highDecade = (math.log(max) / math.ln10).ceil(); + // Past a handful of decades the 2s and 5s only crowd the axis: tick on powers + // of ten, every few of them when there are very many. + final decades = highDecade - lowDecade; + if (decades > 6) { + final every = (decades / target).ceil().clamp(1, decades); + final wide = []; + for (var decade = lowDecade; decade <= highDecade; decade += every) { + final value = math.pow(10.0, decade).toDouble(); + if (value >= min && value <= max) wide.add(value); + if (wide.length >= _maxTicks) break; + } + if (wide.length >= 2) return wide; + } + final ticks = []; for (var decade = lowDecade; decade <= highDecade; decade++) { - final magnitude = math.pow(10, decade).toDouble(); + final magnitude = math.pow(10.0, decade).toDouble(); for (final mantissa in const [1.0, 2.0, 5.0]) { final value = mantissa * magnitude; if (value < min || value > max) continue; @@ -153,7 +169,11 @@ bool _isCalendarStep(Duration step) => step >= const Duration(days: 28); int timeBucket(DateTime time, Duration step) { if (_isCalendarStep(step)) { if (step >= const Duration(days: 365)) return time.year; - final months = step.inDays >= 90 ? 3 : 1; + final months = step.inDays >= 180 + ? 6 + : step.inDays >= 90 + ? 3 + : 1; return time.year * 12 + (time.month - 1) ~/ months; } final wallClock = DateTime.utc( @@ -164,7 +184,14 @@ int timeBucket(DateTime time, Duration step) { time.minute, time.second, ); - return wallClock.millisecondsSinceEpoch ~/ step.inMilliseconds; + // Weeks are counted from a Monday. The epoch began on a Thursday, so a bare + // division would open every weekly bucket — and every weekly candle — then. + const week = Duration.millisecondsPerDay * 7; + final shift = step.inMilliseconds % week == 0 + ? Duration.millisecondsPerDay * 4 + : 0; + return ((wallClock.millisecondsSinceEpoch - shift) / step.inMilliseconds) + .floor(); } /// Whether a label at [time] should name the day rather than the clock time. diff --git a/lib/src/utils/candle_transforms.dart b/lib/src/utils/candle_transforms.dart index 1c8b185..fa36a47 100644 --- a/lib/src/utils/candle_transforms.dart +++ b/lib/src/utils/candle_transforms.dart @@ -16,6 +16,10 @@ import 'axis_ticks.dart'; /// KChartWidget(ha, colors, /* … */); /// ``` abstract final class CandleTransforms { + /// The most bricks or bars one call lays down. A size far smaller than the + /// market's moves would otherwise ask for millions, and hang the app. + static const int maxBlocks = 200000; + /// Smooths [candles] into Heikin-Ashi candles. /// /// Each candle is averaged with the one before it, which strips most of the @@ -66,7 +70,8 @@ abstract final class CandleTransforms { /// the axis and for anything drawn on it. Volume is carried across from the /// candles a brick covers. /// - /// Returns an empty list for a non-positive [brickSize]. + /// Returns an empty list for a non-positive [brickSize], and stops at + /// [maxBlocks] bricks. static List renko( List candles, { required double brickSize, @@ -105,11 +110,13 @@ abstract final class CandleTransforms { // A reversal has to clear two bricks: one to undo the last, one to lay // the first of the other side. - while (candle.close >= level + brickSize * (direction < 0 ? 2 : 1)) { + while (bricks.length < maxBlocks && + candle.close >= level + brickSize * (direction < 0 ? 2 : 1)) { final open = direction < 0 ? level + brickSize : level; lay(open, open + brickSize); } - while (candle.close <= level - brickSize * (direction > 0 ? 2 : 1)) { + while (bricks.length < maxBlocks && + candle.close <= level - brickSize * (direction > 0 ? 2 : 1)) { final open = direction > 0 ? level - brickSize : level; lay(open, open - brickSize); } @@ -427,7 +434,8 @@ abstract final class CandleTransforms { /// is drawn — a bar is at least [range] tall, and taller where the candles it /// covered reached further. /// - /// Returns an empty list for a non-positive [range]. + /// Returns an empty list for a non-positive [range], and stops at + /// [maxBlocks] bars. static List rangeBars( List candles, { required double range, @@ -451,7 +459,7 @@ abstract final class CandleTransforms { // One candle may carry price through several bars' worth of range, so the // move is drained a bar at a time. Each pass moves the open a whole // range towards the close, so the loop always gets shorter. - while ((close - open).abs() >= range) { + while (bars.length < maxBlocks && (close - open).abs() >= range) { final up = close > open; final end = up ? open + range : open - range; diff --git a/lib/src/utils/plot_scaffold.dart b/lib/src/utils/plot_scaffold.dart new file mode 100644 index 0000000..64f3654 --- /dev/null +++ b/lib/src/utils/plot_scaffold.dart @@ -0,0 +1,291 @@ +/// The part every plot-style chart repeats: a box that finds its own size, an +/// entry animation, a pointer that is turned into one hit, and a tooltip +/// placed beside it. +library; + +import 'dart:math' as math; + +import 'package:flutter/widgets.dart'; + +import '../renderer/text_painter_cache.dart'; +import 'xy_plot.dart'; + +/// Paints the chart for a [PlotScaffold]. +/// +/// [plot] is the area the chart draws in, [animation] how far the entry +/// animation has got (0 to 1), and [hovered] what the pointer is over, or null. +typedef PlotPainterBuilder = + CustomPainter Function( + Rect plot, + double animation, + TextPainterCache text, + T? hovered, + ); + +/// A box that holds a chart's plot, its hover and its tooltip. +/// +/// The chart supplies how to paint, and what a pointer at a place is over; this +/// does the sizing, the animation and the touch plumbing every chart of the +/// kind would otherwise write out again. +class PlotScaffold extends StatefulWidget { + /// Creates a scaffold. + const PlotScaffold({ + super.key, + required this.restartKey, + required this.painter, + required this.hitTest, + required this.keyOf, + this.anchorOf, + this.tooltipBuilder, + this.onTouch, + this.animationDuration = Duration.zero, + this.animationCurve = Curves.easeOutCubic, + this.animateOnMount = true, + this.padding = EdgeInsets.zero, + this.insets = EdgeInsets.zero, + this.backgroundColor, + this.defaultHeight = 260, + this.tooltipMargin = 10, + this.semanticLabel, + }); + + /// A value that changes when — and only when — the data does, so the entry + /// animation runs again for new numbers and not for the same ones in a new + /// list. Usually `Object.hashAll` of the data. + final Object restartKey; + + /// Paints the chart. + final PlotPainterBuilder painter; + + /// What the pointer at [Offset] is over, in the plot [Rect]; null for + /// nothing. + final T? Function(Offset local, Rect plot) hitTest; + + /// What identifies a hit, so a pointer moving within one thing reports once. + final Object Function(T hit) keyOf; + + /// Where a hit is, for placing its tooltip; null puts none. + final Rect Function(T hit, Rect plot)? anchorOf; + + /// Builds a card for a hit; null shows none. + final Widget? Function(BuildContext context, T hit)? tooltipBuilder; + + /// Called as the pointer moves over things, and with null as it leaves. + final ValueChanged? onTouch; + + /// How long the entry animation takes; zero draws at once. + final Duration animationDuration; + + /// The easing of [animationDuration]. + final Curve animationCurve; + + /// Whether the first build animates. + final bool animateOnMount; + + /// Space kept clear around the chart. + final EdgeInsets padding; + + /// Room inside [padding] taken by axes and titles, so the plot is smaller + /// than the box by this much on each side. + final EdgeInsets insets; + + /// Painted behind the whole chart. + final Color? backgroundColor; + + /// The height taken in a box that sets none. + final double defaultHeight; + + /// How far the tooltip sits from what it is about. + final double tooltipMargin; + + /// What a screen reader announces. + final String? semanticLabel; + + @override + State> createState() => _PlotScaffoldState(); +} + +class _PlotScaffoldState extends State> + with SingleTickerProviderStateMixin { + late final AnimationController _animation; + final TextPainterCache _text = TextPainterCache(capacity: 96); + T? _hovered; + Object? _hoveredKey; + Rect _plot = Rect.zero; + + @override + void initState() { + super.initState(); + _animation = AnimationController( + vsync: this, + duration: widget.animationDuration, + value: 1, + )..addListener(() => setState(() {})); + if (widget.animateOnMount && widget.animationDuration > Duration.zero) { + _animation.forward(from: 0); + } + } + + @override + void didUpdateWidget(PlotScaffold oldWidget) { + super.didUpdateWidget(oldWidget); + _animation.duration = widget.animationDuration; + if (oldWidget.restartKey != widget.restartKey) { + _hovered = null; + _hoveredKey = null; + if (widget.animationDuration > Duration.zero) { + _animation.forward(from: 0); + } + } + } + + @override + void dispose() { + _text.dispose(); + _animation.dispose(); + super.dispose(); + } + + void _handle(Offset local) { + final hit = widget.hitTest(local, _plot); + final key = hit == null ? null : widget.keyOf(hit); + if (key == _hoveredKey) return; + setState(() { + _hovered = hit; + _hoveredKey = key; + }); + widget.onTouch?.call(hit); + } + + void _leave() { + if (_hoveredKey == null) return; + setState(() { + _hovered = null; + _hoveredKey = null; + }); + widget.onTouch?.call(null); + } + + @override + Widget build(BuildContext context) { + final t = widget.animationCurve.transform(_animation.value); + + Widget chart = LayoutBuilder( + builder: (context, constraints) { + final width = constraints.hasBoundedWidth + ? constraints.maxWidth + : MediaQuery.maybeSizeOf(context)?.width ?? 300; + final height = constraints.hasBoundedHeight + ? constraints.maxHeight + : widget.defaultHeight; + final box = widget.padding.deflateRect( + Offset.zero & Size(width, height), + ); + final inset = widget.insets; + _plot = Rect.fromLTRB( + box.left + inset.left, + box.top + inset.top, + math.max(box.left + inset.left, box.right - inset.right), + math.max(box.top + inset.top, box.bottom - inset.bottom), + ); + + final hovered = _hovered; + final anchor = widget.anchorOf; + final builder = widget.tooltipBuilder; + final tooltip = hovered == null || builder == null + ? null + : builder(context, hovered); + + return SizedBox( + width: width, + height: height, + child: MouseRegion( + onHover: (e) => _handle(e.localPosition), + onExit: (_) => _leave(), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: (d) => _handle(d.localPosition), + onTapUp: (_) => _leave(), + onTapCancel: _leave, + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned.fill( + child: CustomPaint( + painter: widget.painter(_plot, t, _text, hovered), + ), + ), + if (tooltip != null && hovered != null && anchor != null) + Positioned.fill( + child: IgnorePointer( + child: CustomSingleChildLayout( + delegate: XyTooltipLayout( + anchor: anchor(hovered, _plot), + margin: widget.tooltipMargin, + ), + // A card wider than the chart shrinks to fit it rather + // than overflowing. + child: FittedBox( + fit: BoxFit.scaleDown, + child: tooltip, + ), + ), + ), + ), + ], + ), + ), + ), + ); + }, + ); + + final label = widget.semanticLabel; + if (label != null) { + chart = Semantics(container: true, label: label, child: chart); + } + return chart; + } +} + +/// A painter that repaints whenever it is replaced. +/// +/// The scaffold builds a new painter for every frame it changes on, so a +/// comparison of fields would only repeat the scaffold's own bookkeeping. +abstract class PlotPainter extends CustomPainter { + /// Creates a painter over [plot]. + PlotPainter({ + required this.plot, + required this.animation, + required this.text, + this.backgroundColor, + }); + + /// Where the chart is drawn. + final Rect plot; + + /// How far the entry animation has got, from 0 to 1. + final double animation; + + /// Where labels are laid out. + final TextPainterCache text; + + /// Painted behind everything. + final Color? backgroundColor; + + /// Paints the chart itself; [size] is the whole box. + void paintPlot(Canvas canvas, Size size); + + @override + void paint(Canvas canvas, Size size) { + final background = backgroundColor; + if (background != null) { + canvas.drawRect(Offset.zero & size, Paint()..color = background); + } + if (plot.width <= 0 || plot.height <= 0) return; + paintPlot(canvas, size); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => true; +} diff --git a/lib/src/utils/xy_plot.dart b/lib/src/utils/xy_plot.dart new file mode 100644 index 0000000..58e03c7 --- /dev/null +++ b/lib/src/utils/xy_plot.dart @@ -0,0 +1,217 @@ +/// The parts the charts that plot points against two numeric axes share: how +/// a range is padded, how its ticks are written, and where a tooltip sits. +library; + +import 'dart:math' as math; + +import 'package:flutter/widgets.dart'; + +import '../renderer/text_painter_cache.dart'; +import '../series/series_axis.dart'; +import 'axis_ticks.dart'; + +/// Writes [value] with [formatter], or with at most two decimals. +String formatPlotNumber(double value, [String Function(double)? formatter]) { + if (formatter != null) return formatter(value); + if (!value.isFinite) return '–'; + final rounded = double.parse(value.toStringAsFixed(2)); + return rounded == rounded.roundToDouble() + ? rounded.toStringAsFixed(0) + : rounded.toString(); +} + +/// The range [values] cover, with [pad] of it left either side. +/// +/// Values that are not finite are ignored. Nothing to measure gives `0..1`, and +/// a single value is given a unit either side so there is something to draw +/// against. [includeZero] widens the range to hold zero. +({double min, double max}) paddedRange( + Iterable values, { + double pad = 0.1, + bool includeZero = false, +}) { + var min = double.infinity; + var max = double.negativeInfinity; + for (final value in values) { + if (!value.isFinite) continue; + min = math.min(min, value); + max = math.max(max, value); + } + if (!min.isFinite || !max.isFinite) return (min: 0, max: 1); + if (includeZero) { + min = math.min(min, 0); + max = math.max(max, 0); + } + if (min == max) return (min: min - 1, max: max + 1); + final room = (max - min) * pad; + return (min: min - room, max: max + room); +} + +/// Where [value] falls between [min] and [max], mapped onto [from]..[to]. +double mapAcross(double value, double min, double max, double from, double to) { + final span = max - min; + if (span == 0) return (from + to) / 2; + return from + (value - min) / span * (to - from); +} + +/// Draws the grid, the tick labels and the titles of a two-axis plot. +/// +/// The bottom axis is numeric unless [xLabels] is given, in which case a label +/// is written under each whole number — a column per category. +void paintXyAxes( + Canvas canvas, + TextPainterCache text, { + required Rect plot, + required double minX, + required double maxX, + required double minY, + required double maxY, + int tickCount = 5, + Color? gridColor, + bool showAxes = true, + String Function(double value)? xFormatter, + String Function(double value)? yFormatter, + TextStyle? labelStyle, + String? xTitle, + String? yTitle, + TextStyle? titleStyle, + double axisWidth = 44, + double axisHeight = 18, + List? xLabels, + bool logX = false, + bool logY = false, +}) { + final style = seriesAxisLabelStyle.merge(labelStyle); + final line = gridColor == null + ? null + : (Paint() + ..color = gridColor + ..strokeWidth = 1); + + final yTicks = logY + ? niceLogTicks(minY, maxY, target: tickCount) + : niceTicks(minY, maxY, target: tickCount); + for (final tick in yTicks) { + final y = logY + ? mapAcrossLog(tick, minY, maxY, plot.bottom, plot.top) + : mapAcross(tick, minY, maxY, plot.bottom, plot.top); + if (y == null) continue; + if (line != null) { + canvas.drawLine(Offset(plot.left, y), Offset(plot.right, y), line); + } + if (!showAxes) continue; + final tp = text.get(formatPlotNumber(tick, yFormatter), style); + final left = plot.left - 6 - tp.width; + if (left >= 0) tp.paint(canvas, Offset(left, y - tp.height / 2)); + } + + var written = -double.infinity; + void label(double x, String value) { + final tp = text.get(value, style); + final left = x - tp.width / 2; + if (left < written || left < 0 || left + tp.width > plot.right + 2) return; + tp.paint(canvas, Offset(left, plot.bottom + 3)); + written = left + tp.width + 4; + } + + if (xLabels != null) { + for (var i = 0; i < xLabels.length; i++) { + final x = mapAcross(i.toDouble(), minX, maxX, plot.left, plot.right); + if (line != null) { + canvas.drawLine(Offset(x, plot.top), Offset(x, plot.bottom), line); + } + if (showAxes) label(x, xLabels[i]); + } + } else { + final xTicks = logX + ? niceLogTicks(minX, maxX, target: tickCount) + : niceTicks(minX, maxX, target: tickCount); + for (final tick in xTicks) { + final x = logX + ? mapAcrossLog(tick, minX, maxX, plot.left, plot.right) + : mapAcross(tick, minX, maxX, plot.left, plot.right); + if (x == null) continue; + if (line != null) { + canvas.drawLine(Offset(x, plot.top), Offset(x, plot.bottom), line); + } + if (showAxes) label(x, formatPlotNumber(tick, xFormatter)); + } + } + + final titles = seriesAxisTitleStyle.merge(titleStyle); + if (xTitle != null && xTitle.isNotEmpty) { + final tp = text.get(xTitle, titles); + tp.paint( + canvas, + Offset(plot.center.dx - tp.width / 2, plot.bottom + axisHeight + 2), + ); + } + if (yTitle != null && yTitle.isNotEmpty) { + final tp = text.get(yTitle, titles); + canvas + ..save() + ..translate(plot.left - axisWidth + tp.height, plot.center.dy) + ..rotate(-math.pi / 2); + tp.paint(canvas, Offset(-tp.width / 2, -tp.height)); + canvas.restore(); + } +} + +/// Puts a tooltip beside [anchor] — to its right, or its left when there is no +/// room — and keeps it inside the chart. +class XyTooltipLayout extends SingleChildLayoutDelegate { + /// Creates a layout that places the tooltip beside [anchor]. + XyTooltipLayout({required this.anchor, required this.margin}); + + /// The mark the tooltip is about. + final Rect anchor; + + /// How far from it the tooltip sits. + final double margin; + + @override + BoxConstraints getConstraintsForChild(BoxConstraints constraints) => + BoxConstraints.loose(constraints.biggest); + + @override + Offset getPositionForChild(Size size, Size childSize) { + var left = anchor.right + margin; + if (left + childSize.width > size.width) { + left = anchor.left - margin - childSize.width; + } + return Offset( + left.clamp(0.0, math.max(0.0, size.width - childSize.width)), + (anchor.center.dy - childSize.height / 2).clamp( + 0.0, + math.max(0.0, size.height - childSize.height), + ), + ); + } + + @override + bool shouldRelayout(XyTooltipLayout oldDelegate) => + oldDelegate.anchor != anchor || oldDelegate.margin != margin; +} + +/// The base-10 logarithm of [value], or null where it has none — zero, a +/// negative number or one that is not finite. +double? log10OrNull(double value) { + if (!value.isFinite || value <= 0) return null; + return math.log(value) / math.ln10; +} + +/// Where [value] falls between [min] and [max] on a logarithmic scale, mapped +/// onto [from]..[to]; null for a value or range a logarithm cannot take. +double? mapAcrossLog( + double value, + double min, + double max, + double from, + double to, +) { + final v = log10OrNull(value); + final lo = log10OrNull(min); + final hi = log10OrNull(max); + if (v == null || lo == null || hi == null) return null; + return mapAcross(v, lo, hi, from, to); +} diff --git a/lib/src/violin/violin_chart.dart b/lib/src/violin/violin_chart.dart index 9914cad..1e5eb8e 100644 --- a/lib/src/violin/violin_chart.dart +++ b/lib/src/violin/violin_chart.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart' show listEquals; import 'dart:math' as math; import 'package:flutter/widgets.dart'; @@ -39,6 +40,17 @@ class DensityCurve { if (densities.length <= 1) return min; return min + (max - min) * index / (densities.length - 1); } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is DensityCurve && + other.min == min && + other.max == max && + listEquals(other.densities, densities); + + @override + int get hashCode => Object.hashAll([min, max, Object.hashAll(densities)]); } /// Estimates the shape of [samples] as a smooth curve. @@ -151,6 +163,27 @@ class ViolinSeries { /// Its quartiles, worked out if they were not given. BoxPlotStats resolvedStats() => stats ?? BoxPlotStats.fromSamples(samples); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ViolinSeries && + other.label == label && + listEquals(other.samples, samples) && + other.color == color && + other.curve == curve && + other.stats == stats && + other.tooltip == tooltip; + + @override + int get hashCode => Object.hashAll([ + label, + Object.hashAll(samples), + color, + curve, + stats, + tooltip, + ]); } /// Whether a [ViolinChart] draws violins or a ridgeline. @@ -657,7 +690,7 @@ class _ViolinChartState extends State void didUpdateWidget(ViolinChart oldWidget) { super.didUpdateWidget(oldWidget); _animation.duration = widget.animationDuration; - if (!identical(oldWidget.series, widget.series) && + if (!listEquals(oldWidget.series, widget.series) && widget.animationDuration > Duration.zero) { _animation.forward(from: 0); } diff --git a/lib/src/volatility/volatility_curve_chart.dart b/lib/src/volatility/volatility_curve_chart.dart index fd27b14..805084d 100644 --- a/lib/src/volatility/volatility_curve_chart.dart +++ b/lib/src/volatility/volatility_curve_chart.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart' show listEquals; import 'dart:math' as math; import 'package:flutter/widgets.dart'; @@ -21,6 +22,17 @@ class VolatilityPoint { /// Anything the app wants back when this reading is touched. final Object? data; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is VolatilityPoint && + other.x == x && + other.volatility == volatility && + other.data == data; + + @override + int get hashCode => Object.hashAll([x, volatility, data]); } /// One curve of a [VolatilityCurveChart] — an expiry's smile, or one term @@ -50,6 +62,20 @@ class VolatilitySlice { /// Anything the app wants back when this curve is touched. final Object? data; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is VolatilitySlice && + listEquals(other.points, points) && + other.label == label && + other.color == color && + other.dashed == dashed && + other.data == data; + + @override + int get hashCode => + Object.hashAll([Object.hashAll(points), label, color, dashed, data]); } /// Where one [VolatilitySlice] was laid out. @@ -464,7 +490,7 @@ class _VolatilityCurveChartState extends State void didUpdateWidget(VolatilityCurveChart oldWidget) { super.didUpdateWidget(oldWidget); _animation.duration = widget.animationDuration; - if (!identical(oldWidget.slices, widget.slices)) { + if (!listEquals(oldWidget.slices, widget.slices)) { _touchedX = null; if (widget.animationDuration > Duration.zero) { _animation.forward(from: 0); diff --git a/lib/src/volatility/volatility_surface_chart.dart b/lib/src/volatility/volatility_surface_chart.dart new file mode 100644 index 0000000..873739f --- /dev/null +++ b/lib/src/volatility/volatility_surface_chart.dart @@ -0,0 +1,327 @@ +import 'dart:math' as math; + +import 'package:flutter/widgets.dart'; + +import '../heatmap/heatmap_chart.dart'; +import '../heatmap/heatmap_data.dart'; + +/// The index of the strike nearest [spot] — the at-the-money column — or null +/// when there are no strikes or the spot is not a number. +int? atTheMoneyIndex(List strikes, double spot) { + if (!spot.isFinite) return null; + int? best; + var closest = double.infinity; + for (var i = 0; i < strikes.length; i++) { + if (!strikes[i].isFinite) continue; + final distance = (strikes[i] - spot).abs(); + if (distance < closest) { + closest = distance; + best = i; + } + } + return best; +} + +/// How far each strike's volatility in [row] sits from the one at [atmIndex]: +/// the skew, as a difference of fractions. Null where either is missing. +List surfaceSkew(List row, int? atmIndex) { + final atm = atmIndex != null && atmIndex < row.length ? row[atmIndex] : null; + return [ + for (final value in row) + value == null || atm == null || !value.isFinite || !atm.isFinite + ? null + : value - atm, + ]; +} + +/// What a touch on a [VolatilitySurfaceChart] landed on. +@immutable +class VolatilitySurfaceTouchDetails { + /// Creates the details of a touch on the square at [expiryIndex] and + /// [strikeIndex]. + const VolatilitySurfaceTouchDetails({ + required this.expiryIndex, + required this.strikeIndex, + required this.expiry, + required this.strike, + required this.volatility, + required this.atTheMoneyVolatility, + required this.rect, + }); + + /// Which expiry, counted from the top. + final int expiryIndex; + + /// Which strike, counted from the left. + final int strikeIndex; + + /// What the expiry is called. + final String expiry; + + /// The strike. + final double strike; + + /// The implied volatility there, as a fraction; null where there is none. + final double? volatility; + + /// The volatility at the money for the same expiry, as a fraction; null when + /// the chart has no spot or that reading is missing. + final double? atTheMoneyVolatility; + + /// The square, in the chart's local pixels. + final Rect rect; + + /// How far this strike sits above the at-the-money volatility, as a + /// difference of fractions; null when either is missing. + double? get skew { + final here = volatility; + final atm = atTheMoneyVolatility; + if (here == null || atm == null) return null; + return here - atm; + } +} + +/// Implied volatility across strike and expiry at once — a volatility surface +/// as a heatmap. +/// +/// ```dart +/// VolatilitySurfaceChart( +/// strikes: const [90, 95, 100, 105, 110], +/// expiries: const ['1W', '1M', '3M', '6M'], +/// volatilities: [ +/// [0.34, 0.30, 0.28, 0.29, 0.32], // one row per expiry +/// [0.31, 0.28, 0.26, 0.27, 0.29], +/// [0.29, 0.27, 0.25, 0.26, 0.28], +/// [0.28, 0.26, 0.25, 0.26, 0.27], +/// ], +/// spot: 100, +/// ); +/// ``` +/// +/// Squares run from [lowColor] through [midColor] to [highColor]. Volatilities +/// are fractions: 0.25 is 25%. A [spot] marks the at-the-money strike's column +/// in the tooltip's skew, and `null` entries are left empty. The chart fills the +/// box it is given, and is [defaultHeight] high in a box with no height of its +/// own. +class VolatilitySurfaceChart extends StatelessWidget { + /// Creates a surface of [volatilities], one row per entry of [expiries] and + /// one column per entry of [strikes]. + const VolatilitySurfaceChart({ + super.key, + required this.strikes, + required this.expiries, + required this.volatilities, + this.spot, + this.lowColor = const Color(0xFF3B6FD4), + this.midColor = const Color(0xFFE8C547), + this.highColor = const Color(0xFFD94841), + this.minVolatility, + this.maxVolatility, + this.showValues = true, + this.valueFormatter, + this.strikeFormatter, + this.labelStyle, + this.axisLabelStyle, + this.expiryAxisWidth = 44, + this.strikeAxisHeight = 20, + this.spacing = 1, + this.radius = const BorderRadius.all(Radius.circular(2)), + this.onTouch, + this.tooltipBuilder, + this.animationDuration = Duration.zero, + this.animationCurve = Curves.easeOutCubic, + this.animateOnMount = true, + this.padding = EdgeInsets.zero, + this.backgroundColor, + this.defaultHeight = 240, + this.semanticLabel, + }); + + /// The strikes along the bottom, left to right. + final List strikes; + + /// What each expiry is called, top to bottom. + final List expiries; + + /// The implied volatilities as fractions, a row per expiry holding a value + /// per strike. A row shorter than [strikes], or a null, leaves squares empty. + final List> volatilities; + + /// The price of the underlying; null reads no at-the-money strike. + final double? spot; + + /// The colour of the lowest volatility. + final Color lowColor; + + /// The colour half way up. + final Color midColor; + + /// The colour of the highest. + final Color highColor; + + /// The volatility painted in [lowColor]; null takes the lowest on the + /// surface. + final double? minVolatility; + + /// The volatility painted in [highColor]; null takes the highest. + final double? maxVolatility; + + /// Whether each square's volatility is written in it. + final bool showValues; + + /// Writes a volatility; null writes a percentage with no decimals. + final String Function(double volatility)? valueFormatter; + + /// Writes a strike; null writes at most two decimals. + final String Function(double strike)? strikeFormatter; + + /// Style of the values in the squares; null picks black or white. + final TextStyle? labelStyle; + + /// Style of the strike and expiry names. + final TextStyle? axisLabelStyle; + + /// Room held for the expiry names. + final double expiryAxisWidth; + + /// Room held for the strikes. + final double strikeAxisHeight; + + /// The gap between squares. + final double spacing; + + /// The rounding of each square's corners. + final BorderRadius radius; + + /// Called as a touch moves over the squares, and with null when it leaves. + final ValueChanged? onTouch; + + /// Builds a card shown beside the touched square; null shows none. + final Widget? Function( + BuildContext context, + VolatilitySurfaceTouchDetails details, + )? + tooltipBuilder; + + /// How long the squares take to fade in; zero draws them at once. + final Duration animationDuration; + + /// The easing of [animationDuration]. + final Curve animationCurve; + + /// Whether the first build animates. + final bool animateOnMount; + + /// Space kept clear around the chart. + final EdgeInsets padding; + + /// Painted behind the whole chart. + final Color? backgroundColor; + + /// The height taken in a box that sets none. + final double defaultHeight; + + /// What a screen reader announces for the chart. + final String? semanticLabel; + + /// The lowest and highest volatility drawn. + ({double min, double max}) get volatilityRange { + var low = double.infinity; + var high = double.negativeInfinity; + for (final row in volatilities) { + for (final value in row) { + if (value == null || !value.isFinite) continue; + low = math.min(low, value); + high = math.max(high, value); + } + } + if (!low.isFinite) { + return (min: minVolatility ?? 0, max: maxVolatility ?? 1); + } + return (min: minVolatility ?? low, max: maxVolatility ?? high); + } + + @override + Widget build(BuildContext context) { + final cells = [ + for (var e = 0; e < expiries.length && e < volatilities.length; e++) + for (var s = 0; s < strikes.length && s < volatilities[e].length; s++) + if (volatilities[e][s] case final value? + when value.isFinite && strikes[s].isFinite) + HeatmapCell(x: s, y: e, value: value), + ]; + + final range = volatilityRange; + final atm = spot == null ? null : atTheMoneyIndex(strikes, spot!); + final format = valueFormatter ?? (v) => '${(v * 100).round()}%'; + + VolatilitySurfaceTouchDetails details(HeatmapTouchDetails touch) { + final row = touch.y < volatilities.length + ? volatilities[touch.y] + : const []; + final atmValue = atm != null && atm < row.length ? row[atm] : null; + return VolatilitySurfaceTouchDetails( + expiryIndex: touch.y, + strikeIndex: touch.x, + expiry: touch.y < expiries.length ? expiries[touch.y] : '', + strike: touch.x < strikes.length ? strikes[touch.x] : double.nan, + volatility: touch.value, + atTheMoneyVolatility: atmValue != null && atmValue.isFinite + ? atmValue + : null, + rect: touch.rect, + ); + } + + String strikeLabel(int s) { + if (s >= strikes.length) return ''; + final value = strikes[s]; + if (strikeFormatter != null) return strikeFormatter!(value); + final rounded = double.parse(value.toStringAsFixed(2)); + return rounded == rounded.roundToDouble() + ? rounded.toStringAsFixed(0) + : rounded.toString(); + } + + final touch = onTouch; + final builder = tooltipBuilder; + return HeatmapChart( + cells: cells, + columns: strikes.length, + rows: expiries.length, + scale: HeatmapGradientScale(colors: [lowColor, midColor, highColor]), + minValue: range.min, + maxValue: range.max, + xAxis: HeatmapAxis( + labelBuilder: strikeLabel, + size: strikeAxisHeight, + style: axisLabelStyle, + interval: strikes.length > 12 ? 2 : 1, + ), + yAxis: HeatmapAxis( + labels: expiries, + size: expiryAxisWidth, + style: axisLabelStyle, + ), + spacing: spacing, + radius: radius, + labelBuilder: showValues + ? (cell) => cell.isEmpty ? null : format(cell.value!) + : null, + labelStyle: labelStyle, + onTouch: touch == null + ? null + : (t) => touch(t == null ? null : details(t)), + tooltipBuilder: builder == null + ? null + : (context, t) => builder(context, details(t)), + animationDuration: animationDuration, + animationCurve: animationCurve, + animateOnMount: animateOnMount, + padding: padding, + backgroundColor: backgroundColor, + defaultHeight: defaultHeight, + semanticLabel: semanticLabel, + ); + } +} diff --git a/lib/src/waffle/waffle_chart.dart b/lib/src/waffle/waffle_chart.dart index 8b33ced..15fa859 100644 --- a/lib/src/waffle/waffle_chart.dart +++ b/lib/src/waffle/waffle_chart.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart' show listEquals; import 'dart:math' as math; import 'package:flutter/widgets.dart'; @@ -26,6 +27,18 @@ class WaffleSlice { /// Shown when one of its cells is touched; null shows the label and share. final String? tooltip; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is WaffleSlice && + other.label == label && + other.value == value && + other.color == color && + other.tooltip == tooltip; + + @override + int get hashCode => Object.hashAll([label, value, color, tooltip]); } /// How the cells of a [WaffleChart] are handed out. @@ -356,7 +369,7 @@ class _WaffleChartState extends State void didUpdateWidget(WaffleChart oldWidget) { super.didUpdateWidget(oldWidget); _animation.duration = widget.animationDuration; - if (!identical(oldWidget.slices, widget.slices) && + if (!listEquals(oldWidget.slices, widget.slices) && widget.animationDuration > Duration.zero) { _animation.forward(from: 0); } diff --git a/lib/src/waterfall/waterfall_chart.dart b/lib/src/waterfall/waterfall_chart.dart index e8a05d6..4ebad6e 100644 --- a/lib/src/waterfall/waterfall_chart.dart +++ b/lib/src/waterfall/waterfall_chart.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart' show listEquals; import 'dart:math' as math; import 'package:flutter/widgets.dart'; @@ -61,6 +62,19 @@ class WaterfallStep { /// Anything the app wants back when this step is touched. final Object? data; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is WaterfallStep && + other.value == value && + other.label == label && + other.kind == kind && + other.color == color && + other.data == data; + + @override + int get hashCode => Object.hashAll([value, label, kind, color, data]); } /// Where one [WaterfallStep] was laid out. @@ -411,7 +425,7 @@ class _WaterfallChartState extends State void didUpdateWidget(WaterfallChart oldWidget) { super.didUpdateWidget(oldWidget); _animation.duration = widget.animationDuration; - if (!identical(oldWidget.steps, widget.steps)) { + if (!listEquals(oldWidget.steps, widget.steps)) { if (_touched != null && _touched! >= widget.steps.length) { _touched = null; } diff --git a/pubspec.yaml b/pubspec.yaml index 47ba5d9..f787422 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,9 +1,8 @@ name: ohlcv_chart 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.1 + Candlestick charts for Flutter: 31 indicators, 29 drawing tools and 55 more + chart types. Pure CustomPainter, no WebView. +version: 3.2.0 homepage: https://github.com/CtrlAltDevelop repository: https://github.com/CtrlAltDevelop/ohlcv_chart issue_tracker: https://github.com/CtrlAltDevelop/ohlcv_chart/issues diff --git a/screenshots/activity-clock.png b/screenshots/activity-clock.png new file mode 100644 index 0000000..6851170 Binary files /dev/null and b/screenshots/activity-clock.png differ diff --git a/screenshots/benchmark-matrix.png b/screenshots/benchmark-matrix.png new file mode 100644 index 0000000..b9dc327 Binary files /dev/null and b/screenshots/benchmark-matrix.png differ diff --git a/screenshots/efficient-frontier.png b/screenshots/efficient-frontier.png new file mode 100644 index 0000000..04a4bd1 Binary files /dev/null and b/screenshots/efficient-frontier.png differ diff --git a/screenshots/eval-breakdown.png b/screenshots/eval-breakdown.png new file mode 100644 index 0000000..b56b38b Binary files /dev/null and b/screenshots/eval-breakdown.png differ diff --git a/screenshots/futures-curve.png b/screenshots/futures-curve.png new file mode 100644 index 0000000..575d83e Binary files /dev/null and b/screenshots/futures-curve.png differ diff --git a/screenshots/latency-band.png b/screenshots/latency-band.png new file mode 100644 index 0000000..c294e5c Binary files /dev/null and b/screenshots/latency-band.png differ diff --git a/screenshots/mae-mfe.png b/screenshots/mae-mfe.png new file mode 100644 index 0000000..870fe32 Binary files /dev/null and b/screenshots/mae-mfe.png differ diff --git a/screenshots/model-pareto.png b/screenshots/model-pareto.png new file mode 100644 index 0000000..69c2fa1 Binary files /dev/null and b/screenshots/model-pareto.png differ diff --git a/screenshots/progress-card.png b/screenshots/progress-card.png new file mode 100644 index 0000000..cb7707c Binary files /dev/null and b/screenshots/progress-card.png differ diff --git a/screenshots/progress.png b/screenshots/progress.png new file mode 100644 index 0000000..95ac82d Binary files /dev/null and b/screenshots/progress.png differ diff --git a/screenshots/r-multiple.png b/screenshots/r-multiple.png index 206061a..ad963d3 100644 Binary files a/screenshots/r-multiple.png and b/screenshots/r-multiple.png differ diff --git a/screenshots/scaling-law.png b/screenshots/scaling-law.png new file mode 100644 index 0000000..76ee1a8 Binary files /dev/null and b/screenshots/scaling-law.png differ diff --git a/screenshots/sweep.png b/screenshots/sweep.png new file mode 100644 index 0000000..b8afb27 Binary files /dev/null and b/screenshots/sweep.png differ diff --git a/screenshots/token-heatmap.png b/screenshots/token-heatmap.png new file mode 100644 index 0000000..ae9475f Binary files /dev/null and b/screenshots/token-heatmap.png differ diff --git a/screenshots/token-usage.png b/screenshots/token-usage.png new file mode 100644 index 0000000..205966c Binary files /dev/null and b/screenshots/token-usage.png differ diff --git a/screenshots/training-curve.png b/screenshots/training-curve.png new file mode 100644 index 0000000..c800764 Binary files /dev/null and b/screenshots/training-curve.png differ diff --git a/screenshots/training-timeline.png b/screenshots/training-timeline.png new file mode 100644 index 0000000..51263ba Binary files /dev/null and b/screenshots/training-timeline.png differ diff --git a/screenshots/var.png b/screenshots/var.png new file mode 100644 index 0000000..c520171 Binary files /dev/null and b/screenshots/var.png differ diff --git a/screenshots/volatility-surface.png b/screenshots/volatility-surface.png new file mode 100644 index 0000000..2ad30a0 Binary files /dev/null and b/screenshots/volatility-surface.png differ diff --git a/test/activity_clock_test.dart b/test/activity_clock_test.dart new file mode 100644 index 0000000..9cd8ad5 --- /dev/null +++ b/test/activity_clock_test.dart @@ -0,0 +1,175 @@ +import 'dart:math' as math; +import 'dart:ui' show PointerDeviceKind; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:ohlcv_chart/ohlcv_chart.dart'; + +void main() { + group('the layout', () { + const bounds = Rect.fromLTWH(0, 0, 200, 200); + + test('has a segment per ring and hour', () { + final segments = layOutClock(7, 24, bounds); + + expect(segments, hasLength(7 * 24)); + }); + + test('the dial is the largest circle in the box, centred', () { + final segments = layOutClock( + 7, + 24, + const Rect.fromLTWH(0, 0, 300, 200), + innerFraction: 0, + ringGap: 0, + ); + + final outer = segments.map((s) => s.outerRadius).reduce(math.max); + expect(outer, closeTo(100, 1e-9)); + }); + + test('hours run clockwise from twelve o\'clock', () { + final segments = layOutClock(1, 24, bounds, angleGap: 0); + + expect(segments.first.startAngle, closeTo(-math.pi / 2, 1e-9)); + // Hour 6 starts at three o'clock, angle 0. + expect(segments[6].startAngle, closeTo(0, 1e-9)); + // Hour 12 starts at six o'clock. + expect(segments[12].startAngle, closeTo(math.pi / 2, 1e-9)); + }); + + test('rings run outward, none overlapping', () { + final segments = layOutClock(7, 24, bounds, ringGap: 2); + final rings = [ + for (var r = 0; r < 7; r++) segments.firstWhere((s) => s.row == r), + ]; + + for (var r = 1; r < 7; r++) { + expect( + rings[r].innerRadius, + greaterThanOrEqualTo(rings[r - 1].outerRadius), + ); + } + }); + + test('nothing, or no room, lays nothing out', () { + expect(layOutClock(0, 24, bounds), isEmpty); + expect(layOutClock(7, 0, bounds), isEmpty); + expect(layOutClock(7, 24, Rect.zero), isEmpty); + }); + }); + + group('hit testing', () { + final segments = layOutClock( + 2, + 4, + const Rect.fromLTWH(0, 0, 200, 200), + innerFraction: 0.2, + ringGap: 0, + angleGap: 0, + ); + const center = Offset(100, 100); + + test('a point finds the ring and hour it is in', () { + // Just right of twelve o'clock, on the inner ring: hour 0. + final top = clockSegmentAt(segments, center, const Offset(110, 70)); + expect(top?.row, 0); + expect(top?.column, 0); + + // Three to six o'clock, on the outer ring: hour 1. + final right = clockSegmentAt(segments, center, const Offset(180, 110)); + expect(right?.row, 1); + expect(right?.column, 1); + + // Six to nine, inner ring: hour 2. + final bottom = clockSegmentAt(segments, center, const Offset(90, 140)); + expect(bottom?.row, 0); + expect(bottom?.column, 2); + + // Nine to twelve, outer ring: hour 3. + final left = clockSegmentAt(segments, center, const Offset(20, 90)); + expect(left?.row, 1); + expect(left?.column, 3); + }); + + test('the hole and the outside are nothing', () { + expect(clockSegmentAt(segments, center, center), isNull); + expect(clockSegmentAt(segments, center, const Offset(2, 2)), isNull); + expect(clockSegmentAt(const [], center, center), isNull); + }); + }); + + group('the widget', () { + final samples = [ + // Monday 2024-01-01 at 09:00 and 10:00; Tuesday at 14:00. + SeasonalSample(time: DateTime.utc(2024, 1, 1, 9), value: 2), + SeasonalSample(time: DateTime.utc(2024, 1, 1, 9, 30), value: 4), + SeasonalSample(time: DateTime.utc(2024, 1, 1, 10), value: -1), + SeasonalSample(time: DateTime.utc(2024, 1, 2, 14), value: 5), + ]; + + Widget host(Widget chart) => MaterialApp( + home: Scaffold(body: SizedBox(width: 400, height: 400, child: chart)), + ); + + testWidgets('reports the weekday, hour, value and count of a touch', ( + tester, + ) async { + ActivityClockTouchDetails? touched; + await tester.pumpWidget( + host( + ActivityClockChart( + samples: samples, + hourLabelStep: 0, + showWeekdayLabels: false, + onTouch: (d) => touched = d, + ), + ), + ); + + // The dial: 400 box less 20 px of label margin all round: radius 180, + // centre (200, 200). Hour 9 is 9/24 of the way round from twelve. + const radius = 180.0; + const inner = radius * 0.28; + const pitch = (radius - inner) / 7; + final angle = -math.pi / 2 + (9 + 0.5) * 2 * math.pi / 24; + // Monday is the innermost ring. + const ringMid = inner + pitch * 0.5; + final at = const Offset(200, 200) + Offset.fromDirection(angle, ringMid); + + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: Offset.zero); + addTearDown(gesture.removePointer); + final origin = tester.getTopLeft(find.byType(ActivityClockChart)); + await gesture.moveTo(origin + at); + + expect(touched, isNotNull); + expect(touched!.weekday, 0); + expect(touched!.hour, 9); + expect(touched!.count, 2); + expect(touched!.value, closeTo(3, 1e-9)); + expect(touched!.weekdayLabel, 'Mon'); + + await gesture.moveTo(origin + const Offset(200, 200)); + expect(touched, isNull); + }); + + testWidgets('survives no samples and no room', (tester) async { + await tester.pumpWidget(host(const ActivityClockChart(samples: []))); + expect(tester.takeException(), isNull); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 10, + height: 10, + child: ActivityClockChart(samples: samples), + ), + ), + ), + ); + expect(tester.takeException(), isNull); + }); + }); +} diff --git a/test/audit_fixes_test.dart b/test/audit_fixes_test.dart new file mode 100644 index 0000000..822d5da --- /dev/null +++ b/test/audit_fixes_test.dart @@ -0,0 +1,148 @@ +import 'dart:ui'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:ohlcv_chart/ohlcv_chart.dart'; +import 'package:ohlcv_chart/src/indicators/series_math.dart'; + +import 'test_utils.dart'; + +void main() { + group('option break-evens', () { + test('a payoff that crosses zero on a strike is reported once', () { + final legs = [const OptionLeg(kind: OptionKind.underlying, strike: 100)]; + + expect(optionBreakEvens(legs, 50, 150), [100]); + }); + }); + + group('market profile', () { + test('a candle with no range on a level edge still counts', () { + final data = [ + candle(100, high: 100, low: 100), + candle(105, high: 105, low: 105), + candle(110, high: 110, low: 110), + ]; + + final profile = buildMarketProfile(data, tickSize: 1); + final counts = [for (final row in profile.rows) row.count]; + + expect(counts.reduce((a, b) => a + b), 3); + }); + + test('a tick that is not positive is read as none', () { + final data = [candle(100, high: 101, low: 99), candle(102)]; + + expect(() => buildMarketProfile(data, tickSize: 0), returnsNormally); + expect(() => buildMarketProfile(data, tickSize: -1), returnsNormally); + }); + }); + + group('histogram bins', () { + test('a decimal bin width does not earn an extra empty bin', () { + final bins = histogramBins( + [0.0, 0.35, 0.7], + binWidth: 0.1, + min: 0, + max: 0.7, + ); + + expect(bins, hasLength(7)); + }); + }); + + group('time buckets', () { + test('weeks start on a Monday', () { + const week = Duration(days: 7); + // 2026-01-05 is a Monday; the Sunday before it closes the previous week. + final sunday = timeBucket(DateTime.utc(2026, 1, 4, 23), week); + final monday = timeBucket(DateTime.utc(2026, 1, 5), week); + final friday = timeBucket(DateTime.utc(2026, 1, 9), week); + + expect(monday, isNot(sunday)); + expect(friday, monday); + }); + + test('half a year is six months, not three', () { + const half = Duration(days: 180); + final jan = timeBucket(DateTime.utc(2026, 1, 15), half); + final may = timeBucket(DateTime.utc(2026, 5, 15), half); + final jul = timeBucket(DateTime.utc(2026, 7, 15), half); + + expect(may, jan); + expect(jul, isNot(jan)); + }); + }); + + group('treemap', () { + test('an item with an infinite value is left out, not spread over all', () { + final tiles = layOutTreemap(const [ + TreemapItem(value: double.infinity), + TreemapItem(value: 2), + TreemapItem(value: 2), + ], const Rect.fromLTWH(0, 0, 100, 100)); + + expect(tiles, hasLength(2)); + expect(tiles.every((t) => t.rect.width.isFinite), isTrue); + }); + }); + + group('pair hedge ratio', () { + test('finds the slope of prices quoted in tiny numbers', () { + final points = [ + for (var i = 0; i < 20; i++) + PairPoint( + time: DateTime.utc(2024).add(Duration(days: i)), + a: 2e-7 * (i + 1), + b: 1e-7 * (i + 1), + ), + ]; + + expect(pairHedgeRatio(points), closeTo(2, 1e-6)); + }); + }); + + group('moving average', () { + test('one candle with no close leaves only its own windows empty', () { + final data = candles([10, 20, double.nan, 40, 50, 60, 70]); + + final average = smaSeries(data, 3); + + // The three windows holding the NaN have no average; the ones after it + // are as good as ever instead of being NaN from then on. + expect(average[2], isNull); + expect(average[3], isNull); + expect(average[4], isNull); + expect(average[5], closeTo(50, 1e-9)); + expect(average[6], closeTo(60, 1e-9)); + }); + }); + + group('candle transforms', () { + test('a brick far smaller than the move stops at the limit', () { + final data = [candle(100), candle(1e9)]; + + final bricks = CandleTransforms.renko(data, brickSize: 1e-6); + + expect(bricks.length, CandleTransforms.maxBlocks); + }); + + test('range bars stop at the limit too', () { + final data = [candle(100), candle(1e9)]; + + final bars = CandleTransforms.rangeBars(data, range: 1e-6); + + expect(bars.length, CandleTransforms.maxBlocks); + }); + }); + + group('replay interval', () { + test('a zero interval is held to a millisecond', () { + final replay = ChartReplayController(interval: Duration.zero); + expect(replay.interval, const Duration(milliseconds: 1)); + + replay.setInterval(const Duration(milliseconds: -5)); + expect(replay.interval, const Duration(milliseconds: 1)); + replay.dispose(); + }); + }); +} diff --git a/test/axis_ticks_test.dart b/test/axis_ticks_test.dart index 6fccfb2..8b6fc79 100644 --- a/test/axis_ticks_test.dart +++ b/test/axis_ticks_test.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:flutter_test/flutter_test.dart'; import 'package:ohlcv_chart/ohlcv_chart.dart'; @@ -205,6 +207,43 @@ void main() { ); }); }); + + group('very large numbers', () { + test('a log axis over 1e18..1e24 ticks on whole powers of ten', () { + final ticks = niceLogTicks(1e18, 1e24, target: 6); + + expect(ticks, isNotEmpty); + for (final tick in ticks) { + expect(tick, greaterThanOrEqualTo(1e18)); + expect(tick, lessThanOrEqualTo(1e24)); + } + expect(ticks, contains(1e21)); + }); + + test('a log axis over many decades ticks on powers of ten only', () { + final ticks = niceLogTicks(1e17, 1e26, target: 6); + + for (final tick in ticks) { + final decade = (math.log(tick) / math.ln10).round(); + expect(tick, closeTo(math.pow(10.0, decade), tick * 1e-9)); + } + expect(ticks.length, lessThanOrEqualTo(10)); + expect(ticks.length, greaterThanOrEqualTo(2)); + }); + + test('a few decades keep the 2s and the 5s', () { + final ticks = niceLogTicks(1, 1000, target: 5); + + expect(ticks, containsAll([1.0, 2.0, 5.0, 10.0, 20.0, 50.0, 100.0])); + }); + + test('a linear step of a span of 1e20 is a round number of that size', () { + final step = niceStep(1e20, 5); + + expect(step, greaterThan(1e19)); + expect(step, lessThan(1e21)); + }); + }); } /// The largest power of ten no bigger than [value]. diff --git a/test/benchmark_matrix_test.dart b/test/benchmark_matrix_test.dart new file mode 100644 index 0000000..5ef0cf9 --- /dev/null +++ b/test/benchmark_matrix_test.dart @@ -0,0 +1,158 @@ +import 'dart:ui' show PointerDeviceKind; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:ohlcv_chart/ohlcv_chart.dart'; + +const _models = ['A', 'B', 'C']; +const _benchmarks = ['MMLU', 'GSM8K', 'PPL']; +const List> _scores = [ + [86.0, 92.0, 5.0], + [80.0, 88.0, 6.0], + [74.0, null, 7.0], +]; + +void main() { + group('quality', () { + test('runs from the worst of a column to the best', () { + final q = benchmarkQuality(_scores); + + expect(q[0][0], 1); + expect(q[2][0], 0); + expect(q[1][0], closeTo(0.5, 1e-9)); + expect(q[2][1], isNull); + }); + + test('a lower-is-better column is turned over', () { + final q = benchmarkQuality(_scores, lowerIsBetter: {2}); + + expect(q[0][2], 1); + expect(q[2][2], 0); + }); + + test('a column everyone scored alike is the middle', () { + final q = benchmarkQuality(const [ + [5.0], + [5.0], + ]); + + expect(q[0][0], 0.5); + expect(q[1][0], 0.5); + }); + + test('nothing is nothing', () { + expect(benchmarkQuality(const []), isEmpty); + expect(benchmarkAverages(const []), isEmpty); + expect(benchmarkLeaders(const []), isEmpty); + }); + }); + + group('averages and leaders', () { + test('an average is over the scores a model has', () { + final q = benchmarkQuality(_scores, lowerIsBetter: {2}); + final avg = benchmarkAverages(q); + + expect(avg[0], 1); + expect(avg[2], 0); + expect(avg[1]! > avg[2]!, isTrue); + expect( + benchmarkAverages(const [ + [null, null], + ]).single, + isNull, + ); + }); + + test('the leader of a column, turned over where lower wins', () { + expect(benchmarkLeaders(_scores, lowerIsBetter: {2}), [0, 0, 2 - 2]); + expect(benchmarkLeaders(_scores), [0, 0, 2]); + }); + }); + + group('the widget', () { + Widget host(Widget chart) => MaterialApp( + home: Scaffold(body: SizedBox(width: 500, height: 300, child: chart)), + ); + + testWidgets('stars each leader and orders models by average', ( + tester, + ) async { + await tester.pumpWidget( + host( + const BenchmarkMatrixChart( + // Given worst first, to see it put the best on top. + models: ['C', 'B', 'A'], + benchmarks: _benchmarks, + scores: [ + [74.0, null, 7.0], + [80.0, 88.0, 6.0], + [86.0, 92.0, 5.0], + ], + lowerIsBetter: {2}, + ), + ), + ); + + final heatmap = tester.widget(find.byType(HeatmapChart)); + expect(heatmap.yAxis.labels, ['A', 'B', 'C']); + final starred = heatmap.cells.where((c) => c.label!.startsWith('★')); + expect(starred, hasLength(3)); + expect(heatmap.columns, 4); + }); + + testWidgets('reports a score, its standing and whether it leads', ( + tester, + ) async { + BenchmarkTouchDetails? touched; + await tester.pumpWidget( + host( + BenchmarkMatrixChart( + models: _models, + benchmarks: _benchmarks, + scores: _scores, + lowerIsBetter: const {2}, + onTouch: (d) => touched = d, + ), + ), + ); + + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: Offset.zero); + addTearDown(gesture.removePointer); + final origin = tester.getTopLeft(find.byType(BenchmarkMatrixChart)); + // First square: past the 88 px model axis, in the first of three rows + // above the 22 px benchmark axis. + await gesture.moveTo(origin + const Offset(88 + 20, 20)); + + expect(touched, isNotNull); + expect(touched!.model, 'A'); + expect(touched!.benchmark, 'MMLU'); + expect(touched!.score, 86); + expect(touched!.isLeader, isTrue); + expect(touched!.quality, 1); + }); + + testWidgets('survives no models and ragged rows', (tester) async { + await tester.pumpWidget( + host( + const BenchmarkMatrixChart(models: [], benchmarks: [], scores: []), + ), + ); + expect(tester.takeException(), isNull); + + await tester.pumpWidget( + host( + const BenchmarkMatrixChart( + models: ['A', 'B'], + benchmarks: ['x', 'y', 'z'], + scores: [ + [1], + [2, 3, 4, 5], + ], + ), + ), + ); + expect(tester.takeException(), isNull); + }); + }); +} diff --git a/test/chart_link_test.dart b/test/chart_link_test.dart index d0cc031..ce5ae2f 100644 --- a/test/chart_link_test.dart +++ b/test/chart_link_test.dart @@ -1,5 +1,6 @@ import 'dart:typed_data'; +import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:ohlcv_chart/ohlcv_chart.dart'; @@ -248,6 +249,34 @@ void main() { expect(b.host.asked, isEmpty); }); + testWidgets( + 'a chart removed before the frame ends no longer carries the rest', + (tester) async { + final a = chart(); + final b = chart(); + final link = ChartLink() + ..add(a.controller) + ..add(b.controller); + + // Moving a chart while building defers the spread to after the frame; + // taking it off the link in between must cancel it. + await tester.pumpWidget( + Builder( + builder: (context) { + if (b.host.first == 0) { + a.host.scrollTo(100, 119); + link.remove(a.controller); + } + return const SizedBox(); + }, + ), + ); + await tester.pump(); + + expect(b.host.first, 0); + }, + ); + test('adding the same chart twice changes nothing', () { final a = chart(); final b = chart(); diff --git a/test/data_util_test.dart b/test/data_util_test.dart index 6bce561..55e34a6 100644 --- a/test/data_util_test.dart +++ b/test/data_util_test.dart @@ -107,6 +107,15 @@ void main() { }); group('calcWR', () { + test('looks back over exactly `period` candles', () { + // Highs sit a point above the close and lows a point below, so the three + // candles ending at 40 span 19..41 — the 10 before them is out of reach. + final data = candles([10, 20, 30, 40]); + DataUtil.calcWR(data, 3); + + expect(data[3].r, closeTo(-100 * (41 - 40) / (41 - 19), 1e-9)); + }); + test('stays within the -100..0 band', () { final data = candles(rampThenFall(50)); DataUtil.calcWR(data); diff --git a/test/degenerate_input_test.dart b/test/degenerate_input_test.dart new file mode 100644 index 0000000..3db27ff --- /dev/null +++ b/test/degenerate_input_test.dart @@ -0,0 +1,633 @@ +import 'dart:math' as math; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:ohlcv_chart/ohlcv_chart.dart'; + +/// The numbers every chart is fed in turn: nothing, one, and the kinds a real +/// feed produces on a bad day. +final Map> _inputs = { + 'empty': [], + 'one': [5], + 'zeros': [0, 0, 0], + 'same': [7, 7, 7, 7], + 'negative': [-3, -1, -2], + 'mixed': [3, -2, 0, 5, -7, 1], + 'nan': [double.nan, 1, 2, double.nan], + 'infinite': [double.infinity, 1, double.negativeInfinity, 2], + 'huge': [1e300, -1e300, 1e300], + 'tiny': [1e-300, 2e-300, 3e-300], +}; + +DateTime _at(int i) => DateTime.utc(2024).add(Duration(days: i)); + +List _candles(List v) => [ + for (var i = 0; i < v.length; i++) + KLineEntity.fromCustom( + open: v[i], + high: v[i] + 1, + low: v[i] - 1, + close: v[i], + vol: v[i].abs(), + dateTime: _at(i), + ), +]; + +typedef _Build = Widget Function(List v); + +final Map _charts = { + 'BubbleChart': (v) => BubbleChart( + points: [ + for (var i = 0; i < v.length; i++) + BubblePoint(x: i.toDouble(), y: v[i], size: v[i]), + ], + ), + 'BulletChart': (v) => BulletChart( + rows: [ + for (final x in v) BulletRow(label: 'a', value: x, target: x, max: x), + ], + ), + 'CalendarChart': (v) => CalendarChart( + days: [ + for (var i = 0; i < v.length; i++) CalendarDay(date: _at(i), value: v[i]), + ], + ), + 'ChordChart': (v) => ChordChart( + nodes: const [ + ChordNode(label: 'A'), + ChordNode(label: 'B'), + ], + flows: [ + for (final x in v) ...[ + ChordFlow(from: 'A', to: 'B', value: x), + ChordFlow(from: 'B', to: 'A', value: x), + ChordFlow(from: 'A', to: 'A', value: x), + ChordFlow(from: 'A', to: 'missing', value: x), + ], + ], + ), + 'CumulativeDeltaChart': (v) => CumulativeDeltaChart( + bars: [ + for (var i = 0; i < v.length; i++) + DeltaBar(time: _at(i), delta: v[i], price: v[i]), + ], + ), + 'BoxPlotChart': (v) => + BoxPlotChart(entries: [BoxPlotEntry.fromSamples(v, label: 'a')]), + 'HistogramChart': (v) => HistogramChart(bins: histogramBins(v)), + 'RMultipleChart': (v) => RMultipleChart(results: v), + 'VarChart': (v) => VarChart(returns: v, confidence: 0.9), + 'MaeMfeChart': (v) => MaeMfeChart( + trades: [ + for (final x in v) TradeExcursion(pnl: x, mae: x.abs(), mfe: x.abs() * 2), + ], + xReferenceLines: const [1], + ), + 'DumbbellChart': (v) => DumbbellChart( + rows: [for (final x in v) DumbbellRow(label: 'a', from: x, to: x * 2)], + ), + 'EquityCurveChart': (v) => EquityCurveChart( + points: [ + for (var i = 0; i < v.length; i++) + EquityPoint(time: _at(i), equity: v[i]), + ], + ), + 'MonteCarloChart': (v) => + MonteCarloChart(result: runMonteCarlo(v, pathCount: 20, steps: 10)), + 'FootprintChart': (v) => FootprintChart( + tickSize: 1, + bars: [ + for (var i = 0; i < v.length; i++) + FootprintBar( + time: _at(i), + open: v[i], + high: v[i] + 1, + low: v[i] - 1, + close: v[i], + levels: [ + FootprintLevel(price: v[i], bidVolume: v[i], askVolume: v[i]), + ], + ), + ], + ), + 'FunnelChart': (v) => FunnelChart( + stages: [for (final x in v) FunnelStage(value: x, label: 'a')], + ), + 'GaugeChart': (v) => GaugeChart(value: v.isEmpty ? 0 : v.first), + 'HeatmapChart': (v) => HeatmapChart( + cells: [ + for (var i = 0; i < v.length; i++) + HeatmapCell(x: i, y: i % 2, value: v[i]), + ], + ), + 'SeasonalityChart': (v) => SeasonalityChart( + samples: [ + for (var i = 0; i < v.length; i++) + SeasonalSample(time: _at(i * 40), value: v[i]), + ], + ), + 'LiquidityMapChart': (v) => LiquidityMapChart( + bins: [ + for (var i = 0; i < v.length; i++) + LiquidityBin( + from: i.toDouble(), + to: i + 1.0, + longSize: v[i], + shortSize: v[i], + levels: [ + LiquidityLevel( + price: i + 0.5, + size: v[i], + side: LiquiditySide.long, + ), + ], + ), + ], + ), + 'MarimekkoChart': (v) => MarimekkoChart( + columns: [ + for (final x in v) + MarimekkoColumn( + label: 'a', + cells: [MarimekkoCell(label: 'b', value: x)], + ), + ], + ), + 'OpenInterestChart': (v) => OpenInterestChart( + points: [ + for (var i = 0; i < v.length; i++) + OpenInterestPoint( + time: i, + openInterest: v[i], + funding: v[i], + price: v[i], + ), + ], + ), + 'OptionPayoffChart': (v) => OptionPayoffChart( + legs: [ + for (final x in v) + OptionLeg(kind: OptionKind.call, strike: x, premium: x), + ], + ), + 'PairSpreadChart': (v) => PairSpreadChart( + points: [ + for (var i = 0; i < v.length; i++) + PairPoint(time: _at(i), a: v[i], b: v[v.length - 1 - i]), + ], + ), + 'ParallelChart': (v) => ParallelChart( + axes: const [ + ParallelAxis(label: 'x'), + ParallelAxis(label: 'y'), + ], + lines: [ + for (final x in v) ParallelLine(label: 'a', values: [x, x * 2]), + ], + ), + 'PieChart': (v) => + PieChart(sections: [for (final x in v) PieSection(value: x)]), + 'MarketProfileChart': (v) => + MarketProfileChart(profile: buildMarketProfile(_candles(v))), + 'RadarChart': (v) => RadarChart( + features: const ['a', 'b', 'c'], + series: [RadarSeries(values: v)], + ), + 'SankeyChart': (v) => SankeyChart( + nodes: const [ + SankeyNode(id: 'a'), + SankeyNode(id: 'b'), + SankeyNode(id: 'c'), + ], + links: [ + for (final x in v) ...[ + SankeyLink(source: 'a', target: 'b', value: x), + SankeyLink(source: 'b', target: 'c', value: x), + SankeyLink(source: 'c', target: 'a', value: x), + SankeyLink(source: 'a', target: 'a', value: x), + ], + ], + ), + 'SeriesChart': (v) => SeriesChart( + series: [ + LineSeries( + points: [ + for (var i = 0; i < v.length; i++) SeriesPoint(i.toDouble(), v[i]), + ], + ), + BarSeries( + points: [ + for (var i = 0; i < v.length; i++) SeriesPoint(i.toDouble(), v[i]), + ], + ), + ScatterSeries( + points: [ + for (var i = 0; i < v.length; i++) SeriesPoint(i.toDouble(), v[i]), + ], + ), + ], + ), + 'SlopeChart': (v) => SlopeChart( + series: [ + for (final x in v) SlopeSeries(label: 'a', values: [x, -x]), + ], + ), + 'SparklineGrid': (v) => SparklineGrid( + tiles: [SparklineTile(label: 'a', values: v)], + ), + 'StreamChart': (v) => StreamChart( + series: [StreamSeries(label: 'a', values: v)], + ), + 'SunburstChart': (v) => SunburstChart( + items: [ + TreemapItem.group( + label: 'g', + children: [for (final x in v) TreemapItem(value: x, label: 'a')], + ), + ], + ), + 'TreemapChart': (v) => TreemapChart( + items: [ + TreemapItem.group( + label: 'g', + children: [for (final x in v) TreemapItem(value: x, label: 'a')], + ), + for (final x in v) TreemapItem(value: x, label: 'b'), + ], + ), + 'TradeTimelineChart': (v) => TradeTimelineChart( + trades: [ + for (var i = 0; i < v.length; i++) + TimelineTrade( + entryTime: _at(i), + exitTime: i.isEven ? _at(i + 1) : null, + pnl: v[i], + side: TradeSide.buy, + lane: 'x', + ), + ], + ), + 'ViolinChart': (v) => ViolinChart( + series: [ViolinSeries(label: 'a', samples: v)], + ), + 'VolatilityCurveChart': (v) => VolatilityCurveChart( + slices: [ + VolatilitySlice( + points: [ + for (var i = 0; i < v.length; i++) + VolatilityPoint(x: i.toDouble(), volatility: v[i]), + ], + ), + ], + ), + 'VolatilitySurfaceChart': (v) => VolatilitySurfaceChart( + strikes: [for (var i = 0; i < v.length; i++) 90.0 + i * 5], + expiries: const ['1M', '3M'], + volatilities: [v, v.reversed.toList()], + spot: 100, + ), + 'EfficientFrontierChart': (v) => EfficientFrontierChart( + points: [ + for (var i = 0; i < v.length; i++) + PortfolioPoint(risk: v[i].abs(), expectedReturn: v[v.length - 1 - i]), + ], + current: PortfolioPoint(risk: v.isEmpty ? 0 : v.first, expectedReturn: 1), + riskFreeRate: 0.01, + ), + 'FuturesCurveChart': (v) => FuturesCurveChart( + curves: [ + FuturesCurve( + label: 'now', + contracts: [ + for (var i = 0; i < v.length; i++) + FuturesContract(label: 'M$i', price: v[i]), + ], + ), + FuturesCurve( + label: 'then', + dashed: true, + contracts: [ + for (var i = 0; i < v.length; i++) + FuturesContract(label: 'M$i', price: v[v.length - 1 - i]), + ], + ), + ], + ), + 'ActivityClockChart': (v) => ActivityClockChart( + samples: [ + for (var i = 0; i < v.length; i++) + SeasonalSample( + time: DateTime.utc(2024).add(Duration(hours: i * 7)), + value: v[i], + ), + ], + aggregate: SeasonalityAggregate.sum, + ), + 'TrainingCurveChart': (v) => TrainingCurveChart( + runs: [ + TrainingRun( + label: 'train', + points: [ + for (var i = 0; i < v.length; i++) TrainingPoint(i * 10.0, v[i]), + ], + ), + TrainingRun( + label: 'val', + dashed: true, + points: [ + for (var i = 0; i < v.length; i++) + TrainingPoint(i * 10.0, v[v.length - 1 - i]), + ], + ), + ], + smoothing: 0.7, + gapBetween: (0, 1), + ), + 'TrainingCurveChart (log)': (v) => TrainingCurveChart( + runs: [ + TrainingRun( + label: 'loss', + points: [ + for (var i = 0; i < v.length; i++) TrainingPoint(i.toDouble(), v[i]), + ], + ), + ], + logY: true, + lowerIsBetter: false, + ), + 'ScalingLawChart': (v) => ScalingLawChart( + points: [ + for (var i = 0; i < v.length; i++) + ScalingPoint(scale: 1e18 * (i + 1), loss: v[i], label: 'm$i'), + ], + extrapolateTo: 1e24, + targetLoss: 1.5, + ), + 'ScalingLawChart (fit from)': (v) => ScalingLawChart( + points: [ + for (var i = 0; i < v.length; i++) + ScalingPoint(scale: v[i], loss: v[v.length - 1 - i].abs() + 1), + ], + fitFrom: 0.5, + ), + 'SweepChart': (v) => SweepChart( + trials: [ + for (var i = 0; i < v.length; i++) + SweepTrial(x: (i % 3).toDouble(), y: (i ~/ 3).toDouble(), score: v[i]), + ], + ), + 'SweepChart (higher is better)': (v) => SweepChart( + lowerIsBetter: false, + showMarginals: false, + trials: [ + for (var i = 0; i < v.length; i++) + SweepTrial(x: v[i], y: v[v.length - 1 - i], score: v[i]), + ], + ), + 'TrainingTimelineChart': (v) => TrainingTimelineChart( + jobs: [ + for (var i = 0; i < v.length; i++) + TrainingJob( + name: 'j$i', + node: 'n${i % 2}', + start: DateTime.utc(2026).add(Duration(hours: i * 2)), + end: i.isEven + ? DateTime.utc(2026).add(Duration(hours: i * 2 + 3)) + : null, + status: JobStatus.values[i % JobStatus.values.length], + ), + ], + ), + 'BenchmarkMatrixChart': (v) => BenchmarkMatrixChart( + models: [for (var i = 0; i < v.length; i++) 'm$i'], + benchmarks: const ['a', 'b'], + scores: [ + for (var i = 0; i < v.length; i++) [v[i], v[v.length - 1 - i]], + ], + lowerIsBetter: const {1}, + ), + 'ModelParetoChart': (v) => ModelParetoChart( + models: [ + for (var i = 0; i < v.length; i++) + ModelPoint( + name: 'm$i', + cost: v[i], + score: v[v.length - 1 - i], + size: v[i].abs(), + group: i.isEven ? 'a' : 'b', + ), + ], + ), + 'ModelParetoChart (linear)': (v) => ModelParetoChart( + logCost: false, + labelAll: true, + models: [ + for (var i = 0; i < v.length; i++) + ModelPoint(name: 'm$i', cost: v[i], score: v[i]), + ], + ), + 'LatencyBandChart': (v) => LatencyBandChart( + slo: 1, + samples: [ + for (var i = 0; i < v.length; i++) + LatencySample( + time: DateTime.utc(2026).add(Duration(minutes: i)), + p50: v[i], + p95: v[i] + 1, + p99: v[i] + 2, + ), + ], + ), + 'LatencyBandChart (log)': (v) => LatencyBandChart( + logY: true, + sloPercentile: LatencyPercentile.p99, + slo: 10, + samples: [ + for (var i = 0; i < v.length; i++) + LatencySample( + time: DateTime.utc(2026).add(Duration(hours: i * 20)), + p50: v[i], + p95: v[v.length - 1 - i], + p99: v[i].abs() * 3, + ), + ], + ), + 'TokenUsageChart': (v) => TokenUsageChart( + buckets: [ + for (var i = 0; i < v.length; i++) + UsageBucket( + time: DateTime.utc(2026).add(Duration(days: i)), + inputTokens: v[i], + outputTokens: v[v.length - 1 - i], + cachedTokens: v[i].abs(), + cost: v[i], + ), + ], + ), + 'TokenUsageChart (no cost)': (v) => TokenUsageChart( + showCost: false, + buckets: [ + for (var i = 0; i < v.length; i++) + UsageBucket( + time: DateTime.utc(2026).add(Duration(hours: i)), + inputTokens: v[i], + ), + ], + ), + 'EvalBreakdownChart': (v) => EvalBreakdownChart( + threshold: 0.8, + categories: [ + for (var i = 0; i < v.length; i++) + EvalCategory( + name: 'c$i', + passed: v[i].isFinite ? v[i].abs().round().clamp(0, 1000) : 0, + failed: i, + skipped: i % 2, + ), + ], + ), + 'TokenHeatmap': (v) => TokenHeatmap( + showSummary: true, + tokens: [ + for (var i = 0; i < v.length; i++) + TokenScore(i % 3 == 0 ? ' tok$i\n' : ' tok$i', v[i]), + ], + ), + 'TokenHeatmap (every option)': (v) => TokenHeatmap( + showSummary: true, + showStrip: true, + showWhitespace: true, + highlightTop: 3, + focusAbove: 0.5, + selectedIndex: 1, + tokens: [ + for (var i = 0; i < v.length; i++) + TokenScore( + i % 4 == 0 ? '\n' : ' tok$i', + v[i], + probability: v[i].isFinite ? 0.25 : null, + alternatives: const [TokenAlternative(' other', 0.1)], + ), + ], + ), + 'TokenHeatmap (long)': (v) => TokenHeatmap( + tokens: [ + for (var i = 0; i < 400; i++) + TokenScore(' w$i', v.isEmpty ? 0 : v[i % v.length]), + ], + ), + 'WaffleChart': (v) => WaffleChart( + slices: [ + for (final x in v) + WaffleSlice(label: 'a', value: x, color: const Color(0xFF00FF00)), + ], + ), + 'WaterfallChart': (v) => WaterfallChart( + steps: [for (final x in v) WaterfallStep(value: x, label: 'a')], + ), + 'BookHeatmapChart': (v) => BookHeatmapChart( + tickSize: 1, + snapshots: [ + for (var i = 0; i < v.length; i++) + BookSnapshot( + time: _at(i), + mid: v[i], + levels: [ + BookLevel(price: v[i], size: v[i], side: BookSide.bid), + BookLevel(price: v[i] + 1, size: v[i], side: BookSide.ask), + ], + ), + ], + ), + 'DepthChart': (v) => DepthChart( + [for (final x in v) DepthEntity(x, x.abs())], + [for (final x in v) DepthEntity(x + 1, x.abs())], + ), + 'KChartWidget': (v) { + final data = _candles(v); + DataUtil.calculate(data); + return KChartWidget( + data, + ChartColors(), + isTrendLine: false, + timeFrame: const Duration(minutes: 15), + indicators: [ + MaIndicator(), + BollIndicator(), + MacdIndicator(), + RsiIndicator(), + KdjIndicator(), + SupertrendIndicator(), + IchimokuIndicator(), + ZigZagIndicator(), + VolumeProfileIndicator(), + ], + ); + }, +}; + +void main() { + for (final MapEntry(key: name, value: build) in _charts.entries) { + for (final MapEntry(key: input, value: values) in _inputs.entries) { + for (final (label, width, height) in [ + ('300x200', 300.0, 200.0), + ('1x1', 1.0, 1.0), + ('0x0', 0.0, 0.0), + // Boxes of other shapes, where a label or a legend can run out of + // room: small, narrow and tall, short and wide. Overflow is an error + // here, so a chart that does not fit its box fails. Three kinds of + // data are enough to move the labels about. + if (const {'mixed', 'one', 'huge'}.contains(input)) ...[ + ('120x90', 120.0, 90.0), + ('60x300', 60.0, 300.0), + ('200x400', 200.0, 400.0), + ('800x100', 800.0, 100.0), + ('360x240', 360.0, 240.0), + ], + ]) { + testWidgets('$name survives $input data in a $label box', ( + tester, + ) async { + final errors = []; + final original = FlutterError.onError; + FlutterError.onError = errors.add; + String report() => errors + .map( + (e) => + '${e.exception}\n' + '${e.stack.toString().split('\n').take(7).join('\n')}', + ) + .join('\n---\n'); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: width, + height: height, + child: build(values), + ), + ), + ), + ), + ); + await tester.pump(const Duration(milliseconds: 400)); + FlutterError.onError = original; + expect(errors, isEmpty, reason: report()); + + FlutterError.onError = errors.add; + // A touch in the middle must not throw either. + final gesture = await tester.startGesture( + Offset(400 - width / 2 + math.min(width, 5), 300), + ); + await tester.pump(); + await gesture.up(); + await tester.pump(); + FlutterError.onError = original; + expect(errors, isEmpty, reason: report()); + }); + } + } + } +} diff --git a/test/efficient_frontier_test.dart b/test/efficient_frontier_test.dart new file mode 100644 index 0000000..3ab172e --- /dev/null +++ b/test/efficient_frontier_test.dart @@ -0,0 +1,154 @@ +import 'dart:ui' show PointerDeviceKind; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:ohlcv_chart/ohlcv_chart.dart'; + +const _points = [ + PortfolioPoint(risk: 0.10, expectedReturn: 0.04, label: 'bonds'), + PortfolioPoint(risk: 0.12, expectedReturn: 0.03, label: 'beaten'), + PortfolioPoint(risk: 0.16, expectedReturn: 0.08, label: 'mix'), + PortfolioPoint(risk: 0.22, expectedReturn: 0.09, label: 'stocks'), + PortfolioPoint(risk: 0.30, expectedReturn: 0.085, label: 'also beaten'), +]; + +void main() { + group('the frontier', () { + test('keeps the portfolios nothing beats, by rising risk', () { + expect(efficientFrontierIndices(_points), [0, 2, 3]); + }); + + test('at the same risk only the larger return counts', () { + final frontier = efficientFrontierIndices(const [ + PortfolioPoint(risk: 0.1, expectedReturn: 0.02), + PortfolioPoint(risk: 0.1, expectedReturn: 0.05), + PortfolioPoint(risk: 0.2, expectedReturn: 0.06), + ]); + + expect(frontier, [1, 2]); + }); + + test('skips what is not a number', () { + final frontier = efficientFrontierIndices(const [ + PortfolioPoint(risk: double.nan, expectedReturn: 0.5), + PortfolioPoint(risk: 0.1, expectedReturn: double.infinity), + PortfolioPoint(risk: 0.1, expectedReturn: 0.03), + ]); + + expect(frontier, [2]); + expect(efficientFrontierIndices(const []), isEmpty); + }); + }); + + group('the markers', () { + test('the best Sharpe ratio is above the risk-free rate', () { + // 0.04/0.10 = 0.4, 0.08/0.16 = 0.5, 0.09/0.22 = 0.41. + expect(maxSharpeIndex(_points), 2); + // With a 3% risk-free rate: 0.1, 0.31, 0.27. + expect(maxSharpeIndex(_points, riskFree: 0.03), 2); + // Adding a large constant to every return favours the low-risk end. + expect(maxSharpeIndex(_points, riskFree: -1), 0); + }); + + test('there is none without any risk to measure against', () { + expect( + maxSharpeIndex(const [PortfolioPoint(risk: 0, expectedReturn: 0.1)]), + isNull, + ); + expect(maxSharpeIndex(const []), isNull); + }); + + test('the least risky portfolio, the larger return breaking a tie', () { + expect(minRiskIndex(_points), 0); + expect( + minRiskIndex(const [ + PortfolioPoint(risk: 0.1, expectedReturn: 0.02), + PortfolioPoint(risk: 0.1, expectedReturn: 0.05), + ]), + 1, + ); + expect(minRiskIndex(const []), isNull); + }); + + test('sharpe needs a positive risk', () { + expect( + const PortfolioPoint(risk: 0.2, expectedReturn: 0.1).sharpe(0.02), + closeTo(0.4, 1e-9), + ); + expect( + const PortfolioPoint(risk: 0, expectedReturn: 0.1).sharpe(), + isNull, + ); + }); + }); + + test('the range starts risk at zero and holds the risk-free rate', () { + final range = frontierRange(_points, riskFree: 0.0); + + expect(range.minRisk, 0); + expect(range.maxRisk, greaterThan(0.30)); + expect(range.minReturn, lessThanOrEqualTo(0)); + expect(range.maxReturn, greaterThan(0.09)); + }); + + group('the widget', () { + Widget host(Widget chart) => MaterialApp( + home: Scaffold(body: SizedBox(width: 400, height: 300, child: chart)), + ); + + testWidgets('names the best and the least risky portfolio by role', ( + tester, + ) async { + FrontierTouchDetails? touched; + await tester.pumpWidget( + host( + EfficientFrontierChart( + points: _points, + riskFreeRate: 0.0, + current: const PortfolioPoint(risk: 0.2, expectedReturn: 0.05), + onTouch: (d) => touched = d, + ), + ), + ); + + final range = frontierRange([ + ..._points, + const PortfolioPoint(risk: 0.2, expectedReturn: 0.05), + ]); + Offset at(PortfolioPoint p) => Offset( + 44 + p.risk / range.maxRisk * (400 - 44), + (300 - 32) - + (p.expectedReturn - range.minReturn) / + (range.maxReturn - range.minReturn) * + (300 - 32), + ); + + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: Offset.zero); + addTearDown(gesture.removePointer); + final origin = tester.getTopLeft(find.byType(EfficientFrontierChart)); + + await gesture.moveTo(origin + at(_points[2])); + expect(touched?.dot.role, FrontierRole.maxSharpe); + expect(touched?.point.label, 'mix'); + expect(touched?.sharpe, closeTo(0.5, 1e-9)); + + await gesture.moveTo(origin + at(_points[0])); + expect(touched?.dot.role, FrontierRole.minRisk); + + await gesture.moveTo(origin + at(_points[1])); + expect(touched?.dot.role, FrontierRole.portfolio); + + await gesture.moveTo( + origin + at(const PortfolioPoint(risk: 0.2, expectedReturn: 0.05)), + ); + expect(touched?.dot.role, FrontierRole.current); + }); + + testWidgets('survives no portfolios', (tester) async { + await tester.pumpWidget(host(const EfficientFrontierChart(points: []))); + + expect(tester.takeException(), isNull); + }); + }); +} diff --git a/test/eval_breakdown_test.dart b/test/eval_breakdown_test.dart new file mode 100644 index 0000000..19b4829 --- /dev/null +++ b/test/eval_breakdown_test.dart @@ -0,0 +1,195 @@ +import 'dart:ui' show PointerDeviceKind; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:ohlcv_chart/ohlcv_chart.dart'; + +const _categories = [ + EvalCategory(name: 'Safety', passed: 96, failed: 4), + EvalCategory(name: 'Math', passed: 71, failed: 29), + EvalCategory(name: 'Coding', passed: 88, failed: 12, skipped: 3), + EvalCategory(name: 'Empty', passed: 0, failed: 0, skipped: 5), +]; + +void main() { + group('a category', () { + test('counts the tasks that ran, apart from those that did not', () { + const c = EvalCategory(name: 'x', passed: 8, failed: 2, skipped: 4); + + expect(c.attempted, 10); + expect(c.total, 14); + expect(c.passRate, 0.8); + }); + + test('has no pass rate when nothing ran', () { + expect( + const EvalCategory( + name: 'x', + passed: 0, + failed: 0, + skipped: 3, + ).passRate, + isNull, + ); + }); + + test('a negative count is read as none', () { + const c = EvalCategory(name: 'x', passed: -4, failed: 5); + + expect(c.attempted, 5); + expect(c.passRate, 0); + }); + }); + + group('the numbers', () { + test('the overall rate is over every task that ran', () { + expect(overallPassRate(_categories), closeTo(255 / 300, 1e-9)); + expect(overallPassRate(const []), isNull); + }); + + test('sorting puts the worst first and the empty last', () { + final names = [for (final c in sortedByPassRate(_categories)) c.name]; + + expect(names, ['Math', 'Coding', 'Safety', 'Empty']); + }); + + test('sorting is stable for equal rates', () { + final sorted = sortedByPassRate(const [ + EvalCategory(name: 'a', passed: 5, failed: 5), + EvalCategory(name: 'b', passed: 1, failed: 1), + ]); + + expect([for (final c in sorted) c.name], ['a', 'b']); + }); + + test('the Wilson interval holds the rate and stays inside 0..1', () { + final (low, high) = wilsonInterval(80, 100); + + expect(low, lessThan(0.8)); + expect(high, greaterThan(0.8)); + expect(low, greaterThan(0.7)); + expect(high, lessThan(0.88)); + + final (l3, h3) = wilsonInterval(3, 3); + expect(h3, closeTo(1, 1e-9)); + expect(l3, lessThan(0.5)); + + final (l0, h0) = wilsonInterval(0, 5); + expect(l0, 0); + expect(h0, lessThan(0.6)); + }); + + test('with nothing attempted the interval is everything', () { + expect(wilsonInterval(0, 0), (0.0, 1.0)); + expect(wilsonInterval(5, 0), (0.0, 1.0)); + }); + + test('a bigger sample gives a tighter interval', () { + final small = wilsonInterval(8, 10); + final large = wilsonInterval(800, 1000); + + expect(large.$2 - large.$1, lessThan(small.$2 - small.$1)); + }); + }); + + group('the bars', () { + test('grow to fill the box, up to a limit', () { + const chart = EvalBreakdownChart(categories: []); + + // 200 px for four rows: 200 - 26 header over 4, less the 8 gap. + expect(chart.barHeightFor(200, 4, 26), closeTo(34, 1e-9)); + // Plenty of room: held at the largest. + expect(chart.barHeightFor(900, 4, 26), 34); + }); + + test('shrink to a least height when there are too many', () { + const chart = EvalBreakdownChart(categories: []); + + expect(chart.barHeightFor(100, 40, 26), 18); + }); + + test('with nothing to draw they keep the least height', () { + const chart = EvalBreakdownChart(categories: []); + + expect(chart.barHeightFor(200, 0, 26), 18); + }); + }); + + group('the widget', () { + Widget host(Widget chart) => MaterialApp( + home: Scaffold(body: SizedBox(width: 500, height: 300, child: chart)), + ); + + testWidgets('reports a row, worst first, with its interval', ( + tester, + ) async { + EvalTouchDetails? touched; + await tester.pumpWidget( + host( + EvalBreakdownChart( + categories: _categories, + onTouch: (d) => touched = d, + ), + ), + ); + + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: Offset.zero); + addTearDown(gesture.removePointer); + final origin = tester.getTopLeft(find.byType(EvalBreakdownChart)); + // Header 26 px, then bars of 18 with 8 between: the first row's centre. + await gesture.moveTo(origin + const Offset(200, 26 + 9)); + + expect(touched, isNotNull); + expect(touched!.category.name, 'Math'); + expect(touched!.index, 0); + expect(touched!.low, lessThan(0.71)); + expect(touched!.high, greaterThan(0.71)); + }); + + testWidgets('keeps the given order when asked', (tester) async { + EvalTouchDetails? touched; + await tester.pumpWidget( + host( + EvalBreakdownChart( + categories: _categories, + sortWorstFirst: false, + threshold: 0.8, + onTouch: (d) => touched = d, + ), + ), + ); + + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: Offset.zero); + addTearDown(gesture.removePointer); + final origin = tester.getTopLeft(find.byType(EvalBreakdownChart)); + await gesture.moveTo(origin + const Offset(200, 26 + 9)); + + expect(touched?.category.name, 'Safety'); + }); + + testWidgets('survives nothing, long names and more rows than fit', ( + tester, + ) async { + await tester.pumpWidget(host(const EvalBreakdownChart(categories: []))); + expect(tester.takeException(), isNull); + + await tester.pumpWidget( + host( + EvalBreakdownChart( + categories: [ + for (var i = 0; i < 40; i++) + EvalCategory( + name: 'A very long category name number $i', + passed: i, + failed: 40 - i, + ), + ], + ), + ), + ); + expect(tester.takeException(), isNull); + }); + }); +} diff --git a/test/futures_curve_test.dart b/test/futures_curve_test.dart new file mode 100644 index 0000000..b3dc3f8 --- /dev/null +++ b/test/futures_curve_test.dart @@ -0,0 +1,206 @@ +import 'dart:ui' show PointerDeviceKind; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:ohlcv_chart/ohlcv_chart.dart'; + +List _curve(List prices) => [ + for (var i = 0; i < prices.length; i++) + FuturesContract(label: 'M$i', price: prices[i]), +]; + +void main() { + group('the shape', () { + test('rising prices are contango', () { + expect(curveShape(_curve([70, 71, 72.5])), CurveShape.contango); + }); + + test('falling prices are backwardation', () { + expect(curveShape(_curve([75, 73, 72])), CurveShape.backwardation); + }); + + test('equal prices are flat', () { + expect(curveShape(_curve([70, 70, 70])), CurveShape.flat); + }); + + test('a curve that rises and falls is mixed', () { + expect(curveShape(_curve([70, 72, 71])), CurveShape.mixed); + }); + + test('fewer than two prices is unknown', () { + expect(curveShape(_curve([70])), CurveShape.unknown); + expect(curveShape(const []), CurveShape.unknown); + expect(curveShape(_curve([70, double.nan])), CurveShape.unknown); + }); + + test('a step under the tolerance reads as flat', () { + // 70.00 -> 70.02 is 0.03% of the price. + expect( + curveShape(_curve([70, 70.02, 72]), tolerance: 0.001), + CurveShape.contango, + ); + expect( + curveShape(_curve([70, 70.02, 70.01, 72]), tolerance: 0.001), + CurveShape.contango, + ); + expect( + curveShape(_curve([70, 70.02, 70.01]), tolerance: 0.001), + CurveShape.flat, + ); + }); + + test('a missing price is skipped, not bridged as a zero', () { + expect(curveShape(_curve([70, double.nan, 72])), CurveShape.contango); + }); + }); + + group('the slope and the spreads', () { + test('the slope is the last price over the first', () { + expect(curveSlope(_curve([100, 102, 105])), closeTo(0.05, 1e-9)); + expect(curveSlope(_curve([100])), isNull); + expect(curveSlope(_curve([0, 5])), isNull); + }); + + test('a calendar spread is a price less the one before', () { + final spreads = calendarSpreads(_curve([70, 71.5, double.nan, 73])); + + expect(spreads[0], isNull); + expect(spreads[1], closeTo(1.5, 1e-9)); + expect(spreads[2], isNull); + expect(spreads[3], isNull); + }); + }); + + group('the layout', () { + const bounds = Rect.fromLTWH(0, 0, 300, 100); + + test('the same contract sits at the same place on every curve', () { + final curves = [ + FuturesCurve(label: 'now', contracts: _curve([70, 71, 72])), + FuturesCurve(label: 'then', contracts: _curve([68, 69, 70])), + ]; + + final dots = layOutFutures(curves, bounds, minPrice: 60, maxPrice: 80); + + expect(dots, hasLength(6)); + expect(dots[0].center.dx, dots[3].center.dx); + expect(dots[1].center.dx, dots[4].center.dx); + // Three slots across 300 px: the middle of each. + expect(dots[0].center.dx, 50); + expect(dots[2].center.dx, 250); + }); + + test('prices run up the plot', () { + final dots = layOutFutures( + [ + FuturesCurve(label: 'a', contracts: _curve([60, 70, 80])), + ], + bounds, + minPrice: 60, + maxPrice: 80, + ); + + expect(dots[0].center.dy, 100); + expect(dots[1].center.dy, 50); + expect(dots[2].center.dy, 0); + }); + + test('a contract with no price has no dot, and keeps its place', () { + final dots = layOutFutures( + [ + FuturesCurve(label: 'a', contracts: _curve([60, double.nan, 80])), + ], + bounds, + minPrice: 60, + maxPrice: 80, + ); + + expect(dots.map((d) => d.contractIndex), [0, 2]); + }); + + test('a shorter curve leaves the far slots empty', () { + expect( + futuresSlots([ + FuturesCurve(label: 'a', contracts: _curve([1, 2])), + FuturesCurve(label: 'b', contracts: _curve([1, 2, 3, 4])), + ]), + 4, + ); + }); + + test('nothing to lay out, or no room, lays nothing out', () { + expect( + layOutFutures(const [], bounds, minPrice: 0, maxPrice: 1), + isEmpty, + ); + expect( + layOutFutures( + [ + FuturesCurve(label: 'a', contracts: _curve([1, 2])), + ], + Rect.zero, + minPrice: 0, + maxPrice: 1, + ), + isEmpty, + ); + }); + }); + + group('the widget', () { + Widget host(Widget chart) => MaterialApp( + home: Scaffold(body: SizedBox(width: 400, height: 300, child: chart)), + ); + + testWidgets('reports a contract and its spread on touch', (tester) async { + FuturesTouchDetails? touched; + final curve = FuturesCurve( + label: 'now', + contracts: _curve([70, 71.5, 73]), + ); + await tester.pumpWidget( + host(FuturesCurveChart(curves: [curve], onTouch: (d) => touched = d)), + ); + + final range = futuresPriceRange([curve]); + // A 400x300 box with 48 px of price axis and 18 px of contract axis. + const plotLeft = 48.0; + const plotRight = 400.0; + const plotBottom = 300.0 - 18; + final x = plotLeft + (1 + 0.5) / 3 * (plotRight - plotLeft); + final y = + plotBottom - + (71.5 - range.min) / (range.max - range.min) * plotBottom; + + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: Offset.zero); + addTearDown(gesture.removePointer); + final origin = tester.getTopLeft(find.byType(FuturesCurveChart)); + await gesture.moveTo(origin + Offset(x, y)); + + expect(touched?.contract.label, 'M1'); + expect(touched?.spread, closeTo(1.5, 1e-9)); + + await gesture.moveTo(origin + const Offset(1, 1)); + expect(touched, isNull); + }); + + testWidgets('survives nothing to draw and a single contract', ( + tester, + ) async { + await tester.pumpWidget(host(const FuturesCurveChart(curves: []))); + expect(tester.takeException(), isNull); + + await tester.pumpWidget( + host( + FuturesCurveChart( + curves: [ + FuturesCurve(label: 'a', contracts: _curve([70])), + ], + ), + ), + ); + expect(tester.takeException(), isNull); + }); + }); +} diff --git a/test/latency_band_test.dart b/test/latency_band_test.dart new file mode 100644 index 0000000..a159dbf --- /dev/null +++ b/test/latency_band_test.dart @@ -0,0 +1,128 @@ +import 'dart:ui' show PointerDeviceKind; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:ohlcv_chart/ohlcv_chart.dart'; + +final _t0 = DateTime.utc(2026, 9, 1); + +List _samples() => [ + for (var i = 0; i < 12; i++) + LatencySample( + time: _t0.add(Duration(minutes: i * 5)), + p50: 400.0 + i * 5, + p95: 900.0 + (i == 5 ? 700 : 0) + i * 10, + p99: 1500.0 + (i == 5 ? 1500 : 0) + i * 20, + ), +]; + +void main() { + group('breaches', () { + test('are the intervals whose percentile is over the objective', () { + expect(sloBreaches(_samples(), 1200), [5]); + }); + + test('follow the percentile the objective is set on', () { + expect( + sloBreaches(_samples(), 1200, percentile: LatencyPercentile.p99), + hasLength(12), + ); + expect( + sloBreaches(_samples(), 1200, percentile: LatencyPercentile.p50), + isEmpty, + ); + }); + + test('a value that is not a number is not a breach', () { + final samples = [ + LatencySample(time: _t0, p50: 1, p95: double.nan, p99: 3), + ]; + + expect(sloBreaches(samples, 0.5), isEmpty); + expect(sloBreaches(const [], 100), isEmpty); + }); + + test('an interval exactly on the objective is within it', () { + final samples = [LatencySample(time: _t0, p50: 1, p95: 1000, p99: 2)]; + + expect(sloBreaches(samples, 1000), isEmpty); + }); + }); + + group('the widget', () { + Widget host(Widget chart) => MaterialApp( + home: Scaffold(body: SizedBox(width: 400, height: 300, child: chart)), + ); + + testWidgets('reports a sample and whether it broke the objective', ( + tester, + ) async { + LatencyTouchDetails? touched; + await tester.pumpWidget( + host( + LatencyBandChart( + samples: _samples(), + slo: 1200, + onTouch: (d) => touched = d, + ), + ), + ); + + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: Offset.zero); + addTearDown(gesture.removePointer); + final origin = tester.getTopLeft(find.byType(LatencyBandChart)); + // Sample 5 of 12, over a plot from 52 to 400 px. + await gesture.moveTo(origin + Offset(52 + (400 - 52) * 5 / 11, 100)); + + expect(touched, isNotNull); + expect(touched!.index, 5); + expect(touched!.breached, isTrue); + + await gesture.moveTo(origin + Offset(52 + (400 - 52) * 2 / 11, 100)); + expect(touched!.index, 2); + expect(touched!.breached, isFalse); + }); + + testWidgets('draws logarithmic, with no objective', (tester) async { + await tester.pumpWidget( + host(LatencyBandChart(samples: _samples(), logY: true)), + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('survives no samples, one sample and bad values', ( + tester, + ) async { + await tester.pumpWidget(host(const LatencyBandChart(samples: []))); + expect(tester.takeException(), isNull); + + await tester.pumpWidget( + host( + LatencyBandChart( + samples: [LatencySample(time: _t0, p50: 1, p95: 2, p99: 3)], + slo: 2.5, + ), + ), + ); + expect(tester.takeException(), isNull); + + await tester.pumpWidget( + host( + LatencyBandChart( + samples: [ + LatencySample(time: _t0, p50: double.nan, p95: 2, p99: 3), + LatencySample( + time: _t0.add(const Duration(minutes: 1)), + p50: 1, + p95: double.infinity, + p99: 3, + ), + ], + ), + ), + ); + expect(tester.takeException(), isNull); + }); + }); +} diff --git a/test/mae_mfe_test.dart b/test/mae_mfe_test.dart new file mode 100644 index 0000000..80ef8fe --- /dev/null +++ b/test/mae_mfe_test.dart @@ -0,0 +1,209 @@ +import 'dart:ui' show PointerDeviceKind; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:ohlcv_chart/ohlcv_chart.dart'; + +const _trades = [ + TradeExcursion(pnl: 2, mae: 0.5, mfe: 2.5, label: 'a'), + TradeExcursion(pnl: -1, mae: 1.2, mfe: 0.3, label: 'b'), + TradeExcursion(pnl: 1, mae: 1.5, mfe: 1.8, label: 'c'), + TradeExcursion(pnl: 0, mae: 0, mfe: 0, label: 'd'), +]; + +void main() { + group('the statistics', () { + test('count winners, losers and the averages', () { + final stats = ExcursionStats.fromTrades(_trades); + + expect(stats.count, 4); + expect(stats.winners, 2); + expect(stats.losers, 1); + expect(stats.averageAdverse, closeTo((0.5 + 1.2 + 1.5) / 4, 1e-9)); + expect(stats.averageFavourable, closeTo((2.5 + 0.3 + 1.8) / 4, 1e-9)); + }); + + test('the edge ratio is the favourable move over the adverse one', () { + final stats = ExcursionStats.fromTrades(_trades); + + expect(stats.edgeRatio, closeTo(4.6 / 3.2, 1e-9)); + expect( + ExcursionStats.fromTrades(const [ + TradeExcursion(pnl: 1, mae: 0, mfe: 1), + ]).edgeRatio, + double.infinity, + ); + expect(ExcursionStats.fromTrades(const []).edgeRatio, 0); + }); + + test('a trade that is not a number is skipped', () { + final stats = ExcursionStats.fromTrades(const [ + TradeExcursion(pnl: double.nan, mae: 1, mfe: 1), + TradeExcursion(pnl: 1, mae: 1, mfe: 2), + ]); + + expect(stats.count, 1); + }); + + test('winnersStoppedAt counts the winners a stop would have cost', () { + expect(winnersStoppedAt(_trades, 1.0), 1); + expect(winnersStoppedAt(_trades, 0.5), 2); + expect(winnersStoppedAt(_trades, 5), 0); + }); + }); + + group('the layout', () { + const bounds = Rect.fromLTWH(0, 0, 200, 100); + + test('the range runs from zero to the longest excursion', () { + final range = excursionRange(_trades, ExcursionKind.adverse); + + expect(range.minX, 0); + expect(range.maxX, closeTo(1.5 * 1.08, 1e-9)); + expect(range.minY, lessThan(-1)); + expect(range.maxY, greaterThan(2)); + }); + + test('a dot sits at its excursion across and its result up', () { + final dots = layOutExcursions( + _trades, + bounds, + kind: ExcursionKind.adverse, + minX: 0, + maxX: 2, + minY: -2, + maxY: 2, + ); + + expect(dots, hasLength(4)); + expect(dots[0].center, const Offset(50, 0)); + expect(dots[1].center, const Offset(120, 75)); + }); + + test('the favourable kind reads the other excursion', () { + final dots = layOutExcursions( + _trades, + bounds, + kind: ExcursionKind.favourable, + minX: 0, + maxX: 2.5, + minY: -2, + maxY: 2, + ); + + expect(dots[0].center.dx, 200); + }); + + test('a trade that is not finite is left out, keeping its index', () { + final dots = layOutExcursions( + const [ + TradeExcursion(pnl: 1, mae: double.nan, mfe: 1), + TradeExcursion(pnl: 1, mae: 1, mfe: 1), + ], + bounds, + kind: ExcursionKind.adverse, + minX: 0, + maxX: 2, + minY: 0, + maxY: 2, + ); + + expect(dots.single.index, 1); + }); + + test('nothing to lay out, or no room, lays nothing out', () { + expect( + layOutExcursions( + const [], + bounds, + kind: ExcursionKind.adverse, + minX: 0, + maxX: 1, + minY: 0, + maxY: 1, + ), + isEmpty, + ); + expect( + layOutExcursions( + _trades, + Rect.zero, + kind: ExcursionKind.adverse, + minX: 0, + maxX: 1, + minY: 0, + maxY: 1, + ), + isEmpty, + ); + }); + + test('the nearest dot within reach is picked', () { + final dots = layOutExcursions( + _trades, + bounds, + kind: ExcursionKind.adverse, + minX: 0, + maxX: 2, + minY: -2, + maxY: 2, + ); + + expect(excursionDotAt(dots, const Offset(52, 3))?.index, 0); + expect(excursionDotAt(dots, const Offset(0, 100), reach: 5), isNull); + }); + }); + + group('the widget', () { + Widget host(Widget chart) => MaterialApp( + home: Scaffold(body: SizedBox(width: 400, height: 300, child: chart)), + ); + + testWidgets('draws, and reports a touch on a dot', (tester) async { + ExcursionTouchDetails? touched; + await tester.pumpWidget( + host( + MaeMfeChart( + trades: _trades, + xReferenceLines: const [1], + onTouch: (d) => touched = d, + tooltipBuilder: (context, d) => Text('${d.trade.label}'), + ), + ), + ); + + // The first trade's dot, worked out the way the chart lays it out: a + // 400x300 box, 44 px of side axis and 32 px of bottom axis and title. + final range = excursionRange(_trades, ExcursionKind.adverse); + final x = 44 + 0.5 / range.maxX * (400 - 44); + final y = + (300 - 32) - + (2 - range.minY) / (range.maxY - range.minY) * (300 - 32); + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: Offset.zero); + addTearDown(gesture.removePointer); + final origin = tester.getTopLeft(find.byType(MaeMfeChart)); + await gesture.moveTo(origin + Offset(x, y)); + + expect(touched?.trade.label, 'a'); + await tester.pump(); + expect(find.text('a'), findsOneWidget); + + await gesture.moveTo(origin + const Offset(1, 299)); + await tester.pump(); + expect(touched, isNull); + }); + + testWidgets('fills a box with no height of its own', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ListView(children: [MaeMfeChart(trades: _trades)]), + ), + ), + ); + + expect(tester.getSize(find.byType(MaeMfeChart)).height, 260); + }); + }); +} diff --git a/test/model_pareto_test.dart b/test/model_pareto_test.dart new file mode 100644 index 0000000..7ddc390 --- /dev/null +++ b/test/model_pareto_test.dart @@ -0,0 +1,136 @@ +import 'dart:ui' show PointerDeviceKind; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:ohlcv_chart/ohlcv_chart.dart'; + +const _models = [ + ModelPoint(name: 'Large', cost: 15, score: 88, size: 200000, group: 'A'), + ModelPoint(name: 'Mid', cost: 3, score: 82, size: 128000, group: 'A'), + ModelPoint(name: 'Small', cost: 0.25, score: 71, size: 128000, group: 'A'), + ModelPoint(name: 'Open 70B', cost: 0.9, score: 79, group: 'B'), + ModelPoint(name: 'Dated', cost: 10, score: 70, group: 'B'), +]; + +void main() { + group('the frontier', () { + test('keeps the models nothing beats, by rising cost', () { + // Small (0.25, 71), Open (0.9, 79), Mid (3, 82), Large (15, 88); Dated is + // dearer and worse than Mid. + expect(paretoIndices(_models), [2, 3, 1, 0]); + }); + + test('a model that costs more for the same score is off it', () { + final frontier = paretoIndices(const [ + ModelPoint(name: 'a', cost: 1, score: 80), + ModelPoint(name: 'b', cost: 2, score: 80), + ]); + + expect(frontier, [0]); + }); + + test('at the same cost only the better one counts', () { + final frontier = paretoIndices(const [ + ModelPoint(name: 'a', cost: 1, score: 70), + ModelPoint(name: 'b', cost: 1, score: 90), + ]); + + expect(frontier, [1]); + }); + + test('skips what is not a number', () { + final frontier = paretoIndices(const [ + ModelPoint(name: 'a', cost: double.nan, score: 90), + ModelPoint(name: 'b', cost: 1, score: double.infinity), + ModelPoint(name: 'c', cost: 1, score: 50), + ]); + + expect(frontier, [2]); + expect(paretoIndices(const []), isEmpty); + }); + }); + + group('the widget', () { + Widget host(Widget chart) => MaterialApp( + home: Scaffold(body: SizedBox(width: 500, height: 320, child: chart)), + ); + + testWidgets('says what beats a model that is off the frontier', ( + tester, + ) async { + ModelTouchDetails? touched; + await tester.pumpWidget( + host( + ModelParetoChart( + models: _models, + labelAll: true, + onTouch: (d) => touched = d, + ), + ), + ); + + // Sweep the plot until the pointer meets the dated model. + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: Offset.zero); + addTearDown(gesture.removePointer); + final origin = tester.getTopLeft(find.byType(ModelParetoChart)); + outer: + for (var y = 5.0; y < 270; y += 6) { + for (var x = 50.0; x < 490; x += 6) { + await gesture.moveTo(origin + Offset(x, y)); + if (touched?.model.name == 'Dated') break outer; + } + } + + expect(touched, isNotNull); + expect(touched!.model.name, 'Dated'); + expect(touched!.onFrontier, isFalse); + expect(touched!.beatenBy?.name, 'Mid'); + }); + + testWidgets('draws linear, with no sizes and no groups', (tester) async { + await tester.pumpWidget( + host( + const ModelParetoChart( + logCost: false, + models: [ + ModelPoint(name: 'a', cost: 1, score: 50), + ModelPoint(name: 'b', cost: 2, score: 60), + ], + ), + ), + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('a model costing nothing is left off a log axis', ( + tester, + ) async { + await tester.pumpWidget( + host( + const ModelParetoChart( + models: [ + ModelPoint(name: 'free', cost: 0, score: 60), + ModelPoint(name: 'paid', cost: 1, score: 70), + ], + ), + ), + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('survives no models and a single one', (tester) async { + await tester.pumpWidget(host(const ModelParetoChart(models: []))); + expect(tester.takeException(), isNull); + + await tester.pumpWidget( + host( + const ModelParetoChart( + models: [ModelPoint(name: 'only', cost: 2, score: 5)], + ), + ), + ); + expect(tester.takeException(), isNull); + }); + }); +} diff --git a/test/progress_bars_test.dart b/test/progress_bars_test.dart new file mode 100644 index 0000000..2732d30 --- /dev/null +++ b/test/progress_bars_test.dart @@ -0,0 +1,258 @@ +import 'package:material_ui/material_ui.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:ohlcv_chart/ohlcv_chart.dart'; + +const _items = [ + ProgressItem(label: 'Profit', value: 72, target: 60), + ProgressItem(label: 'Trades', value: 150, total: 100), + ProgressItem(label: 'Drawdown', value: 0), +]; + +void main() { + progressCardTests(); + group('an item', () { + test('fills to its share of the total, and no further', () { + expect(_items[0].fraction, closeTo(0.72, 1e-9)); + expect(_items[1].fraction, 1); + expect(_items[2].fraction, 0); + }); + + test('a total that cannot be divided by fills nothing', () { + expect(const ProgressItem(label: 'x', value: 5, total: 0).fraction, 0); + expect(const ProgressItem(label: 'x', value: double.nan).fraction, 0); + }); + + test('the target sits on the same scale as the value', () { + expect(_items[0].targetFraction, closeTo(0.6, 1e-9)); + expect(_items[2].targetFraction, isNull); + }); + }); + + group('the layout', () { + test('a row is a header, a gap and a bar, stacked with a gap between', () { + final layout = layOutProgress( + _items, + size: const Size(300, 400), + barHeight: 10, + headerHeight: 18, + headerGap: 6, + rowGap: 16, + ); + expect(layout.rows, hasLength(3)); + final first = layout.rows[0]; + expect(first.headerRect.height, 18); + expect(first.trackRect.top, 24); + expect(first.trackRect.height, 10); + expect(layout.rows[1].rowRect.top, 50); + }); + + test('the fill runs from the left edge by the share done', () { + final layout = layOutProgress(_items, size: const Size(300, 400)); + final row = layout.rows.first; + expect(row.fillRect.left, row.trackRect.left); + expect(row.fillRect.width, closeTo(300 * 0.72, 1e-6)); + expect(row.targetX, closeTo(300 * 0.6, 1e-6)); + }); + + test('progress shortens the fills but leaves the targets alone', () { + final half = layOutProgress( + _items, + size: const Size(300, 400), + progress: 0.5, + ); + final full = layOutProgress(_items, size: const Size(300, 400)); + expect( + half.rows.first.fillRect.width, + closeTo(full.rows.first.fillRect.width / 2, 1e-6), + ); + expect(half.rows.first.targetX, full.rows.first.targetX); + }); + + test('rows shrink to fit a box too short for them', () { + final layout = layOutProgress(_items, size: const Size(300, 60)); + expect(layout.rows.last.trackRect.bottom, lessThanOrEqualTo(60.001)); + }); + + test('nothing to show lays out nothing', () { + expect( + layOutProgress(const [], size: const Size(300, 100)).isEmpty, + true, + ); + expect(layOutProgress(_items, size: Size.zero).isEmpty, true); + }); + + test('a point finds its row, and nothing past the last', () { + final layout = layOutProgress(_items, size: const Size(300, 400)); + expect(layout.rowAt(const Offset(100, 10))?.index, 0); + expect(layout.rowAt(const Offset(100, 60))?.index, 1); + expect(layout.rowAt(const Offset(100, 300)), isNull); + }); + }); + + group('the widget', () { + testWidgets('draws every shape without an error', (tester) async { + for (final shape in ProgressShape.values) { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 300, + height: 200, + child: ProgressBars(items: _items, shape: shape), + ), + ), + ), + ); + expect(tester.takeException(), isNull); + } + }); + + testWidgets('reports the row under the finger', (tester) async { + ProgressItem? touched; + var cleared = false; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 300, + height: 200, + child: ProgressBars( + items: _items, + onItemTap: (item) { + if (item == null) { + cleared = true; + } else { + touched = item; + } + }, + ), + ), + ), + ), + ); + final gesture = await tester.startGesture( + tester.getTopLeft(find.byType(ProgressBars)) + const Offset(100, 28), + ); + await tester.pump(); + expect(touched?.label, 'Profit'); + await gesture.up(); + await tester.pump(); + expect(cleared, true); + }); + + testWidgets('takes its own height in an unbounded box', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SingleChildScrollView(child: ProgressBars(items: _items)), + ), + ), + ); + // Three rows of 18 + 6 + 10, and two gaps of 16. + expect(tester.getSize(find.byType(ProgressBars)).height, 3 * 34 + 2 * 16); + }); + + testWidgets('animates the fills in', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 300, + height: 200, + child: ProgressBars( + items: _items, + animationDuration: const Duration(milliseconds: 200), + ), + ), + ), + ), + ); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }); + }); +} + +void progressCardTests() { + group('a progress card', () { + test('progress outside 0 to 1, or not a number, is held to it', () { + const child = SizedBox(); + expect(const ProgressCard(progress: 0.4, child: child).fraction, 0.4); + expect(const ProgressCard(progress: 7, child: child).fraction, 1); + expect(const ProgressCard(progress: -2, child: child).fraction, 0); + expect( + const ProgressCard(progress: double.nan, child: child).fraction, + 0, + ); + }); + + testWidgets('sweeps a wedge of that share round the card', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Center( + child: SizedBox( + width: 200, + child: ProgressCard( + progress: 0.25, + animationDuration: Duration.zero, + child: Text('Course'), + ), + ), + ), + ), + ); + final paint = tester.widget( + find + .descendant( + of: find.byType(ProgressCard), + matching: find.byType(CustomPaint), + ) + .first, + ); + // ignore: avoid_dynamic_calls + expect((paint.painter! as dynamic).fraction, 0.25); + expect((paint.painter! as dynamic).mode, ProgressCardMode.sweep); + expect(find.text('Course'), findsOneWidget); + }); + + testWidgets('linear mode fills along the card instead', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: ProgressCard( + progress: 0.5, + mode: ProgressCardMode.linear, + animationDuration: Duration.zero, + child: Text('Course'), + ), + ), + ); + final paint = tester.widget( + find + .descendant( + of: find.byType(ProgressCard), + matching: find.byType(CustomPaint), + ) + .first, + ); + // ignore: avoid_dynamic_calls + expect((paint.painter! as dynamic).mode, ProgressCardMode.linear); + }); + + testWidgets('draws every mode without an error', (tester) async { + for (final mode in ProgressCardMode.values) { + await tester.pumpWidget( + MaterialApp( + home: ProgressCard( + progress: 0.4, + mode: mode, + animationDuration: Duration.zero, + child: const Text('Course'), + ), + ), + ); + expect(tester.takeException(), isNull); + } + }); + }); +} diff --git a/test/r_multiple_test.dart b/test/r_multiple_test.dart index a9deda7..a6b3092 100644 --- a/test/r_multiple_test.dart +++ b/test/r_multiple_test.dart @@ -77,6 +77,27 @@ void main() { expect(find.text('2.00'), findsOneWidget); }); + testWidgets('the histogram takes the height the figures leave', ( + tester, + ) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: SizedBox( + width: 400, + height: 380, + child: RMultipleChart(results: _results), + ), + ), + ), + ); + + // The figures are one short row; the rest of the box is the histogram, + // not half of it. + final histogram = tester.getSize(find.byType(HistogramChart)); + expect(histogram.height, greaterThan(380 * 0.7)); + }); + testWidgets('takes its own figures, or none', (tester) async { await tester.pumpWidget( MaterialApp( diff --git a/test/rebuild_animation_test.dart b/test/rebuild_animation_test.dart new file mode 100644 index 0000000..838ac7f --- /dev/null +++ b/test/rebuild_animation_test.dart @@ -0,0 +1,141 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:ohlcv_chart/ohlcv_chart.dart'; + +import 'test_utils.dart'; + +/// A parent that rebuilds its chart from a fresh list every time [version] +/// moves, the way an app does when it makes its data inside `build`. +Widget _host(ValueNotifier version, Widget Function(int version) chart) { + return MaterialApp( + home: Scaffold( + body: SizedBox( + width: 400, + height: 300, + child: ValueListenableBuilder( + valueListenable: version, + builder: (context, value, _) => chart(value), + ), + ), + ), + ); +} + +const _duration = Duration(milliseconds: 300); + +/// Pumps [chart] to rest, then rebuilds it with [next], and says whether that +/// set an animation going. +Future _animatesOnRebuild( + WidgetTester tester, + Widget Function(int version) chart, { + required int next, +}) async { + final version = ValueNotifier(0); + addTearDown(version.dispose); + await tester.pumpWidget(_host(version, chart)); + await tester.pump(const Duration(seconds: 1)); + await tester.pump(const Duration(seconds: 1)); + expect(tester.hasRunningAnimations, isFalse, reason: 'it settled'); + + version.value = next; + await tester.pump(); + return tester.hasRunningAnimations; +} + +void main() { + // `version` only decides which numbers go into the list. 0 and 1 make the + // same values in a new list; 2 makes different ones. + double value(int version, int i) => (version == 2 ? 50 : 0) + i * 3.0 - 10; + + final charts = { + 'CumulativeDeltaChart': (v) => CumulativeDeltaChart( + animationDuration: _duration, + bars: [ + for (var i = 0; i < 8; i++) + DeltaBar( + time: DateTime.utc(2024, 1, 1 + i), + delta: value(v, i), + price: 100 + i.toDouble(), + ), + ], + ), + 'WaterfallChart': (v) => WaterfallChart( + animationDuration: _duration, + steps: [ + for (var i = 0; i < 6; i++) + WaterfallStep(value: value(v, i), label: 'step $i'), + ], + ), + 'TreemapChart': (v) => TreemapChart( + animationDuration: _duration, + items: [ + for (var i = 1; i < 6; i++) + TreemapItem(value: value(v, i).abs() + 1, label: 'item $i'), + ], + ), + 'PieChart': (v) => PieChart( + animationDuration: _duration, + sections: [ + for (var i = 1; i < 5; i++) + PieSection( + value: value(v, i).abs() + 1, + // A widget made inline is new on every build. + badge: const SizedBox(width: 4, height: 4), + ), + ], + ), + 'HeatmapChart': (v) => HeatmapChart( + animationDuration: _duration, + cells: [ + for (var i = 0; i < 6; i++) HeatmapCell(x: i, y: 0, value: value(v, i)), + ], + ), + 'ParallelChart': (v) => ParallelChart( + animationDuration: _duration, + // An inline closure, new on every build. + axes: [ + ParallelAxis(label: 'a', formatter: (x) => x.toStringAsFixed(1)), + ParallelAxis(label: 'b', formatter: (x) => '$x'), + ], + lines: [ + for (var i = 0; i < 4; i++) + ParallelLine(label: 'l$i', values: [value(v, i), value(v, i + 1)]), + ], + ), + 'BoxPlotChart': (v) => BoxPlotChart( + animationDuration: _duration, + entries: [ + BoxPlotEntry.fromSamples([ + for (var i = 0; i < 12; i++) value(v, i), + ], label: 'a'), + ], + ), + 'MonteCarloChart': (v) => MonteCarloChart( + animationDuration: _duration, + result: runMonteCarlo( + [for (var i = 0; i < 10; i++) value(v, i) / 100], + pathCount: 30, + seed: 7, + ), + ), + 'MarketProfileChart': (v) => MarketProfileChart( + animationDuration: _duration, + profile: buildMarketProfile([ + for (var i = 0; i < 20; i++) + candle(100 + value(v, i), high: 102 + value(v, i), minute: i), + ], tickSize: 1), + ), + }; + + for (final entry in charts.entries) { + testWidgets('${entry.key} keeps still when rebuilt with the same data', ( + tester, + ) async { + expect(await _animatesOnRebuild(tester, entry.value, next: 1), isFalse); + }); + + testWidgets('${entry.key} animates when the data changes', (tester) async { + expect(await _animatesOnRebuild(tester, entry.value, next: 2), isTrue); + }); + } +} diff --git a/test/replay_test.dart b/test/replay_test.dart index 2e7fa88..0365746 100644 --- a/test/replay_test.dart +++ b/test/replay_test.dart @@ -41,6 +41,68 @@ List _data([int count = 60]) { void main() { group('the controller', () { + test('reports how far it has got', () { + final replay = ChartReplayController()..reportLength(40); + expect(replay.progress, 1); + + replay.start(at: 10); + expect(replay.progress, 0.25); + + replay.stop(); + expect(replay.progress, 1); + replay.dispose(); + }); + + test('a series shrunk to nothing still leaves one candle in play', () { + final replay = ChartReplayController()..reportLength(40); + replay.start(at: 10); + + replay.reportLength(0); + expect(replay.position, 1); + replay.dispose(); + }); + + testWidgets('tells its listeners when playback catches up and stops', ( + tester, + ) async { + final replay = ChartReplayController( + interval: const Duration(milliseconds: 100), + )..reportLength(10); + var notified = 0; + replay.addListener(() => notified++); + + replay.start(at: 9); + replay.play(); + expect(replay.isPlaying, isTrue); + + // The series shrinks under the running replay, so the next tick finds + // nothing to advance and has to announce that playback has ended. + replay.reportLength(9); + final before = notified; + await tester.pump(const Duration(milliseconds: 150)); + + expect(replay.isPlaying, isFalse); + expect(notified, greaterThan(before)); + replay.dispose(); + }); + + testWidgets('play(interval:) changes the pace of a running playback', ( + tester, + ) async { + final replay = ChartReplayController( + interval: const Duration(seconds: 10), + )..reportLength(100); + + replay.start(at: 10); + replay.play(); + replay.play(interval: const Duration(milliseconds: 100)); + expect(replay.interval, const Duration(milliseconds: 100)); + + await tester.pump(const Duration(milliseconds: 350)); + expect(replay.position, 13); + replay.dispose(); + }); + test('starts inactive and hands the whole series over', () { final replay = ChartReplayController(); diff --git a/test/scaling_law_test.dart b/test/scaling_law_test.dart new file mode 100644 index 0000000..8adfb1b --- /dev/null +++ b/test/scaling_law_test.dart @@ -0,0 +1,155 @@ +import 'dart:math' as math; +import 'dart:ui' show PointerDeviceKind; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:ohlcv_chart/ohlcv_chart.dart'; + +/// Points exactly on loss = 8 × scale^-0.05, over six decades. +List _law() => [ + for (var e = 18; e <= 23; e++) + ScalingPoint( + scale: math.pow(10.0, e).toDouble(), + loss: 8 * math.pow(math.pow(10.0, e), -0.05).toDouble(), + label: '1e$e', + ), +]; + +void main() { + group('the fit', () { + test('recovers the law the points lie on', () { + final fit = fitPowerLaw(_law())!; + + expect(fit.exponent, closeTo(-0.05, 1e-9)); + expect(fit.coefficient, closeTo(8, 1e-6)); + expect(fit.rSquared, closeTo(1, 1e-9)); + expect(fit.logResidual, closeTo(0, 1e-9)); + expect(fit.count, 6); + }); + + test('predicts a loss at a scale, and a scale for a loss', () { + final fit = fitPowerLaw(_law())!; + + expect(fit.predict(1e24), closeTo(8 * math.pow(1e24, -0.05), 1e-6)); + final scale = fit.scaleFor(2.0)!; + expect(fit.predict(scale), closeTo(2.0, 1e-9)); + }); + + test('noisy points give a worse fit, with a residual', () { + final noisy = [ + for (var i = 0; i < _law().length; i++) + ScalingPoint( + scale: _law()[i].scale, + loss: _law()[i].loss * (i.isEven ? 1.1 : 0.9), + ), + ]; + final fit = fitPowerLaw(noisy)!; + + expect(fit.rSquared, lessThan(1)); + expect(fit.logResidual, greaterThan(0.02)); + }); + + test('needs two points at two scales', () { + expect(fitPowerLaw(const []), isNull); + expect(fitPowerLaw(const [ScalingPoint(scale: 10, loss: 2)]), isNull); + expect( + fitPowerLaw(const [ + ScalingPoint(scale: 10, loss: 2), + ScalingPoint(scale: 10, loss: 3), + ]), + isNull, + ); + }); + + test('skips points a logarithm cannot take', () { + final fit = fitPowerLaw([ + ..._law(), + const ScalingPoint(scale: 0, loss: 3), + const ScalingPoint(scale: 1e20, loss: -1), + const ScalingPoint(scale: double.nan, loss: 3), + const ScalingPoint(scale: 1e20, loss: double.infinity), + ])!; + + expect(fit.count, 6); + }); + + test('a flat line has no scale that reaches a different loss', () { + final fit = fitPowerLaw(const [ + ScalingPoint(scale: 10, loss: 2), + ScalingPoint(scale: 100, loss: 2), + ])!; + + expect(fit.exponent, closeTo(0, 1e-12)); + expect(fit.scaleFor(1), isNull); + }); + }); + + group('the widget', () { + Widget host(Widget chart) => MaterialApp( + home: Scaffold(body: SizedBox(width: 400, height: 300, child: chart)), + ); + + testWidgets('reports a point and how far it is above the fit', ( + tester, + ) async { + ScalingTouchDetails? touched; + final points = [ + ..._law().take(5), + // The last model is 10% worse than the law predicts. + ScalingPoint( + scale: 1e23, + loss: 8 * math.pow(1e23, -0.05) * 1.1, + label: 'off', + ), + ]; + await tester.pumpWidget( + host( + ScalingLawChart( + points: points, + fitFrom: 1e18, + showLabels: false, + onTouch: (d) => touched = d, + ), + ), + ); + + // Find the point by sweeping the right edge of the plot, where the + // largest model sits. + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: Offset.zero); + addTearDown(gesture.removePointer); + final origin = tester.getTopLeft(find.byType(ScalingLawChart)); + for (var y = 5.0; y < 250 && touched == null; y += 3) { + await gesture.moveTo(origin + Offset(380, y)); + } + + expect(touched, isNotNull); + expect(touched!.point.label, 'off'); + expect(touched!.ratio, isNotNull); + }); + + testWidgets('draws a target, an extrapolation and no fit', (tester) async { + await tester.pumpWidget( + host( + ScalingLawChart(points: _law(), extrapolateTo: 1e26, targetLoss: 1.5), + ), + ); + expect(tester.takeException(), isNull); + + await tester.pumpWidget( + host(const ScalingLawChart(points: [], targetLoss: 2)), + ); + expect(tester.takeException(), isNull); + + await tester.pumpWidget( + host( + const ScalingLawChart( + points: [ScalingPoint(scale: 1e20, loss: 2)], + extrapolateTo: 1e22, + ), + ), + ); + expect(tester.takeException(), isNull); + }); + }); +} diff --git a/test/sweep_test.dart b/test/sweep_test.dart new file mode 100644 index 0000000..448bf78 --- /dev/null +++ b/test/sweep_test.dart @@ -0,0 +1,119 @@ +import 'dart:ui' show PointerDeviceKind; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:ohlcv_chart/ohlcv_chart.dart'; + +const _trials = [ + SweepTrial(x: 1e-4, y: 32, score: 2.4), + SweepTrial(x: 3e-4, y: 32, score: 2.1), + SweepTrial(x: 1e-3, y: 32, score: 2.9), + SweepTrial(x: 1e-4, y: 64, score: 2.3), + SweepTrial(x: 3e-4, y: 64, score: 1.9), + SweepTrial(x: 1e-3, y: 64, score: double.nan), +]; + +void main() { + group('the grid', () { + test('gathers distinct settings, ascending', () { + final grid = sweepGrid(_trials); + + expect(grid.xs, [1e-4, 3e-4, 1e-3]); + expect(grid.ys, [32, 64]); + expect(grid.scores[0], [2.4, 2.1, 2.9]); + expect(grid.scores[1], [2.3, 1.9, null]); + }); + + test('the best is the lowest score, or the highest', () { + final low = sweepGrid(_trials); + expect(low.bestX, 1); + expect(low.bestY, 1); + expect(low.best, 1.9); + + final high = sweepGrid(_trials, lowerIsBetter: false); + expect(high.bestX, 2); + expect(high.bestY, 0); + expect(high.best, 2.9); + }); + + test('trials on the same combination are averaged', () { + final grid = sweepGrid(const [ + SweepTrial(x: 1, y: 1, score: 2), + SweepTrial(x: 1, y: 1, score: 4), + ]); + + expect(grid.scores.single.single, 3); + expect(grid.counts.single.single, 2); + }); + + test('the marginals are means of what was tried', () { + final grid = sweepGrid(_trials); + + expect(grid.columnMeans[0], closeTo(2.35, 1e-9)); + expect(grid.columnMeans[2], closeTo(2.9, 1e-9)); + expect(grid.rowMeans[1], closeTo((2.3 + 1.9) / 2, 1e-9)); + }); + + test('nothing, or nothing finite, is an empty grid', () { + final empty = sweepGrid(const []); + expect(empty.xs, isEmpty); + expect(empty.best, isNull); + + final bad = sweepGrid(const [ + SweepTrial(x: double.nan, y: 1, score: 1), + SweepTrial(x: 1, y: 1, score: double.infinity), + ]); + expect(bad.xs, isEmpty); + }); + }); + + test('settings are written compactly', () { + expect(SweepChart.formatSetting(0.0003), '3e-4'); + expect(SweepChart.formatSetting(128), '128'); + expect(SweepChart.formatSetting(0.5), '0.5'); + expect(SweepChart.formatSetting(0), '0'); + expect(SweepChart.formatSetting(100000), '1e5'); + }); + + group('the widget', () { + Widget host(Widget chart) => MaterialApp( + home: Scaffold(body: SizedBox(width: 400, height: 300, child: chart)), + ); + + testWidgets('stars the best and reports it on touch', (tester) async { + SweepTouchDetails? touched; + await tester.pumpWidget( + host(SweepChart(trials: _trials, onTouch: (d) => touched = d)), + ); + + expect(find.byType(HeatmapChart), findsOneWidget); + final heatmap = tester.widget(find.byType(HeatmapChart)); + final labels = [for (final c in heatmap.cells) c.label]; + expect(labels.where((l) => l!.startsWith('★')), hasLength(1)); + expect(labels, contains('★ 1.90')); + + // Four columns (three and the mean), three rows (two and the mean): + // the best sits in column 1, row 1. + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: Offset.zero); + addTearDown(gesture.removePointer); + final origin = tester.getTopLeft(find.byType(SweepChart)); + const gridW = 400 - 56.0; + const gridH = 300 - 20.0; + await gesture.moveTo( + origin + const Offset(56 + gridW / 4 * 1.5, gridH / 3 * 1.5), + ); + + expect(touched, isNotNull); + expect(touched!.isBest, isTrue); + expect(touched!.x, 3e-4); + expect(touched!.y, 64); + expect(touched!.score, 1.9); + }); + + testWidgets('survives no trials', (tester) async { + await tester.pumpWidget(host(const SweepChart(trials: []))); + expect(tester.takeException(), isNull); + }); + }); +} diff --git a/test/token_heatmap_test.dart b/test/token_heatmap_test.dart new file mode 100644 index 0000000..c392b7d --- /dev/null +++ b/test/token_heatmap_test.dart @@ -0,0 +1,522 @@ +import 'dart:math' as math; +import 'dart:ui' show PointerDeviceKind; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:ohlcv_chart/ohlcv_chart.dart'; + +/// Ten pixels a character, twenty high. +Size _measure(String token) => Size(token.length * 10.0, 20); + +List _tokens(List words) => [ + for (final w in words) TokenScore(w, 1), +]; + +void main() { + group('flowing tokens', () { + test('sit side by side until the line is full', () { + final flow = flowTokens(_tokens(['aaaa', 'bbbb', 'cc']), 100, _measure); + + expect(flow.boxes[0].rect, const Rect.fromLTWH(0, 0, 40, 20)); + expect(flow.boxes[1].rect, const Rect.fromLTWH(40, 0, 40, 20)); + // Eighty used; two more characters fit exactly. + expect(flow.boxes[2].rect, const Rect.fromLTWH(80, 0, 20, 20)); + expect(flow.height, 20); + }); + + test('wrap to the next line when a token does not fit', () { + final flow = flowTokens( + _tokens(['aaaa', 'bbbb', 'cccc']), + 100, + _measure, + lineGap: 4, + ); + + expect(flow.boxes[2].rect, const Rect.fromLTWH(0, 24, 40, 20)); + expect(flow.height, 44); + }); + + test('a newline ends the line, and is not drawn', () { + final flow = flowTokens( + [const TokenScore('a\n', 1), const TokenScore('b', 1)], + 200, + _measure, + lineGap: 4, + ); + + expect(flow.boxes[0].rect.width, 10); + expect(flow.boxes[1].rect.top, 24); + expect(flow.boxes[1].rect.left, 0); + }); + + test('a blank line has a height of its own', () { + final flow = flowTokens( + [ + const TokenScore('a\n', 1), + const TokenScore('\n', 1), + const TokenScore('b', 1), + ], + 200, + _measure, + lineGap: 4, + ); + + expect(flow.boxes, hasLength(2)); + expect(flow.boxes[1].rect.top, greaterThan(24)); + }); + + test('a token wider than the line sits alone', () { + final flow = flowTokens( + _tokens(['a', 'bbbbbbbbbbbb', 'c']), + 50, + _measure, + ); + + expect(flow.boxes[1].rect.left, 0); + expect(flow.boxes[1].rect.top, greaterThan(0)); + expect(flow.boxes[2].rect.top, greaterThan(flow.boxes[1].rect.top)); + }); + + test('indices point back at the tokens, skipping empty ones', () { + final flow = flowTokens(_tokens(['a', '', 'b']), 100, _measure); + + expect([for (final b in flow.boxes) b.index], [0, 2]); + }); + + test('nothing is no height', () { + expect(flowTokens(const [], 100, _measure).height, 0); + expect(flowTokens(const [], 100, _measure).boxes, isEmpty); + }); + + test('a size that is not finite is read as zero', () { + final flow = flowTokens( + _tokens(['a']), + 100, + (_) => const Size(double.nan, double.infinity), + ); + + expect(flow.boxes.single.rect.width, 0); + }); + + test('a box is found by position', () { + final flow = flowTokens(_tokens(['aaaa', 'bbbb']), 100, _measure); + + expect(tokenBoxAt(flow.boxes, const Offset(50, 5))?.index, 1); + expect(tokenBoxAt(flow.boxes, const Offset(95, 5)), isNull); + }); + }); + + group('displaying a token', () { + test('keeps a leading space as a no-break one, and drops the newline', () { + expect(tokenDisplay(' the\n'), '\u00A0the'); + }); + + test('can show the whitespace as it is', () { + expect(tokenDisplay(' a b\n', showWhitespace: true), '·a·b↵'); + }); + + test('a token of only a newline takes a box when whitespace is shown', () { + final tokens = [const TokenScore('a', 1), const TokenScore('\n', 1)]; + + expect(flowTokens(tokens, 100, _measure).boxes, hasLength(1)); + expect( + flowTokens(tokens, 100, _measure, showWhitespace: true).boxes, + hasLength(2), + ); + }); + }); + + group('the spacing', () { + // Ten pixels a character, so padding and gaps are easy to read off. + final tokens = [ + const TokenScore('The', 1), + const TokenScore(' cap', 1), + const TokenScore('ital', 1), + ]; + + test('a leading space is the gap before the box, not part of it', () { + final flow = flowTokens( + tokens, + 500, + _measure, + trimSpaces: true, + horizontalPadding: 5, + wordGap: 8, + tokenGap: 2, + ); + + // "The": 30 + 10 padding. " cap" starts a word, so 8 px after it. + expect(flow.boxes[0].rect, const Rect.fromLTWH(0, 0, 40, 20)); + expect(flow.boxes[1].rect.left, 48); + expect(flow.boxes[1].rect.width, 40); + // "ital" carries the word on: 2 px after "cap". + expect(flow.boxes[2].rect.left, 90); + }); + + test('no gap is left at the start of a line', () { + final flow = flowTokens( + tokens, + 60, + _measure, + trimSpaces: true, + horizontalPadding: 5, + wordGap: 8, + tokenGap: 2, + ); + + // " cap" does not fit after "The", so it begins the next line flush left. + expect(flow.boxes[1].rect.left, 0); + expect(flow.boxes[1].rect.top, greaterThan(0)); + }); + + test('tokenText has no leading space, and keeps inner ones', () { + expect(tokenText(' hello world\n'), 'hello\u00A0world'); + expect(tokenText(' a', showWhitespace: true), '·a'); + }); + + test('the defaults leave tokens as they were', () { + final flow = flowTokens(tokens, 500, _measure); + + expect(flow.boxes[1].rect.left, 30); + }); + }); + + group('ranks', () { + test('rank by score, highest first, equal scores sharing a rank', () { + final ranks = tokenRanks(const [ + TokenScore('a', 1), + TokenScore('b', 5), + TokenScore('c', 5), + TokenScore('d', 3), + TokenScore('e', double.nan), + ]); + + expect(ranks, [4, 1, 1, 3, null]); + }); + + test('the top tokens are the highest, earlier first on a tie', () { + final top = topTokens(const [ + TokenScore('a', 1), + TokenScore('b', 5), + TokenScore('c', 5), + TokenScore('d', 3), + TokenScore('e', double.nan), + ], 3); + + expect(top, [1, 2, 3]); + expect(topTokens(const [TokenScore('a', 1)], 0), isEmpty); + expect(topTokens(const [TokenScore('a', 1)], 5), [0]); + }); + }); + + group('the strip', () { + test('takes the highest score of each slice', () { + final bins = binTokenScores([ + for (final v in [1.0, 4.0, 2.0, 2.0, 9.0, 1.0]) TokenScore('t', v), + ], 3); + + expect(bins, [4.0, 2.0, 9.0]); + }); + + test('has fewer bins than tokens when there are fewer tokens', () { + final bins = binTokenScores(const [TokenScore('a', 1)], 4); + + expect(bins.where((b) => b != null), hasLength(1)); + }); + + test('a slice with nothing scored is null', () { + final bins = binTokenScores(const [ + TokenScore('a', double.nan), + TokenScore('b', 2), + ], 2); + + expect(bins, [null, 2.0]); + expect(binTokenScores(const [], 3), [null, null, null]); + expect(binTokenScores(const [TokenScore('a', 1)], 0), isEmpty); + }); + }); + + group('scores', () { + test('a log-probability gives the probability too', () { + final t = TokenScore.fromLogProb('the', -0.6931471805599453); + + expect(t.probability, closeTo(0.5, 1e-9)); + }); + + test('tokens with the same alternatives are equal', () { + const a = TokenScore('x', 1, alternatives: [TokenAlternative('y', 0.2)]); + const b = TokenScore('x', 1, alternatives: [TokenAlternative('y', 0.2)]); + const c = TokenScore('x', 1, alternatives: [TokenAlternative('z', 0.2)]); + + expect(a, b); + expect(a.hashCode, b.hashCode); + expect(a, isNot(c)); + }); + + test('a log-probability is scored as its surprisal', () { + final t = TokenScore.fromLogProb('the', -2.5); + + expect(t.value, 2.5); + }); + + test('the summary has a mean, a maximum and its place', () { + final s = summarizeTokens(const [ + TokenScore('a', 1), + TokenScore('b', 5), + TokenScore('c', double.nan), + TokenScore('d', 3), + ]); + + expect(s.count, 3); + expect(s.mean, closeTo(3, 1e-9)); + expect(s.max, 5); + expect(s.maxIndex, 1); + expect(s.perplexity, closeTo(math.exp(3), 1e-9)); + }); + + test('with nothing scored the summary is empty', () { + final s = summarizeTokens(const [TokenScore('a', double.nan)]); + + expect(s.count, 0); + expect(s.maxIndex, isNull); + expect(summarizeTokens(const []).count, 0); + }); + }); + + group('the widget', () { + Widget host(Widget chart, {double height = 200}) => MaterialApp( + home: Scaffold( + body: SizedBox(width: 400, height: height, child: chart), + ), + ); + + testWidgets('reports the token under the pointer', (tester) async { + TokenTouchDetails? touched; + await tester.pumpWidget( + host( + TokenHeatmap( + tokens: const [ + TokenScore('The', 0.2), + TokenScore(' capital', 1.1), + TokenScore(' is', 0.1), + TokenScore(' Paris', 4.2), + ], + onTouch: (d) => touched = d, + ), + ), + ); + + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: Offset.zero); + addTearDown(gesture.removePointer); + final origin = tester.getTopLeft(find.byType(TokenHeatmap)); + // Inside the first token's rectangle. + await gesture.moveTo(origin + const Offset(3, 8)); + + expect(touched, isNotNull); + expect(touched!.index, 0); + expect(touched!.token.token, 'The'); + + await gesture.moveTo(origin + const Offset(390, 150)); + expect(touched, isNull); + }); + + testWidgets('opens a readout with the score, rank and alternatives', ( + tester, + ) async { + await tester.pumpWidget( + host( + TokenHeatmap( + tokens: [ + const TokenScore('The', 0.2), + TokenScore.fromLogProb( + ' Paris', + -2.3, + alternatives: const [ + TokenAlternative(' Lyon', 0.4), + TokenAlternative(' Nice', 0.05), + ], + ), + ], + ), + ), + ); + + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: Offset.zero); + addTearDown(gesture.removePointer); + final origin = tester.getTopLeft(find.byType(TokenHeatmap)); + // Past "The", inside " Paris". + await gesture.moveTo(origin + const Offset(60, 8)); + await tester.pump(); + + expect(find.text('·Paris'), findsOneWidget); + expect(find.textContaining('rank 1 of 2'), findsOneWidget); + expect(find.textContaining('probability 10%'), findsOneWidget); + expect(find.text('instead'), findsOneWidget); + expect(find.textContaining('Lyon'), findsOneWidget); + expect(find.textContaining('40%'), findsOneWidget); + }); + + testWidgets('the readout can be turned off, or replaced', (tester) async { + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: const Offset(399, 299)); + addTearDown(gesture.removePointer); + + Future hover() async { + final origin = tester.getTopLeft(find.byType(TokenHeatmap)); + await gesture.moveTo(origin + const Offset(3, 8)); + await tester.pump(); + } + + await tester.pumpWidget( + host( + const TokenHeatmap( + tokens: [TokenScore('Hello', 1)], + showReadout: false, + ), + ), + ); + await hover(); + expect(find.textContaining('surprisal'), findsNothing); + + // Off the token and back on, so the new chart sees a fresh hover. + await gesture.moveTo(const Offset(399, 299)); + await tester.pumpWidget( + host( + TokenHeatmap( + tokens: const [TokenScore('Hello', 1)], + tooltipBuilder: (context, d) => const Text('mine'), + ), + ), + ); + await hover(); + expect(find.text('mine'), findsOneWidget); + }); + + testWidgets('a readout wider than the chart shrinks to fit it', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 60, + height: 120, + child: TokenHeatmap( + tokens: [ + TokenScore.fromLogProb( + 'tokenizer', + -3, + alternatives: const [ + TokenAlternative(' another long alternative', 0.3), + ], + ), + ], + ), + ), + ), + ), + ), + ); + + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: Offset.zero); + addTearDown(gesture.removePointer); + await gesture.moveTo(const Offset(5, 8)); + await tester.pump(); + + expect(find.text('instead'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('a strip adds height, and the extras draw', (tester) async { + Future heightOf(TokenHeatmap chart) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: ListView(children: [chart])), + ), + ); + return tester.getSize(find.byType(TokenHeatmap)).height; + } + + final tokens = [ + for (var i = 0; i < 30; i++) TokenScore(' w$i', (i % 7).toDouble()), + ]; + final plain = await heightOf(TokenHeatmap(tokens: tokens)); + final strip = await heightOf( + TokenHeatmap(tokens: tokens, showStrip: true), + ); + expect(strip, greaterThan(plain)); + + await tester.pumpWidget( + host( + TokenHeatmap( + tokens: tokens, + showStrip: true, + highlightTop: 3, + focusAbove: 4, + showWhitespace: true, + selectedIndex: 5, + ), + ), + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('takes the height its text needs in a box with no height', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ListView( + children: [ + TokenHeatmap( + tokens: [ + for (var i = 0; i < 80; i++) TokenScore(' word$i', i / 10), + ], + ), + ], + ), + ), + ), + ); + + final size = tester.getSize(find.byType(TokenHeatmap)); + expect(size.height, greaterThan(40)); + expect(tester.takeException(), isNull); + }); + + testWidgets('survives nothing, unscored tokens and a tiny box', ( + tester, + ) async { + await tester.pumpWidget(host(const TokenHeatmap(tokens: []))); + expect(tester.takeException(), isNull); + + await tester.pumpWidget( + host( + const TokenHeatmap( + tokens: [ + TokenScore('a', double.nan), + TokenScore('\n', double.infinity), + TokenScore('b', 1), + ], + showSummary: true, + ), + ), + ); + expect(tester.takeException(), isNull); + + await tester.pumpWidget( + host( + const TokenHeatmap(tokens: [TokenScore('hello world', 1)]), + height: 4, + ), + ); + expect(tester.takeException(), isNull); + }); + }); +} diff --git a/test/token_usage_test.dart b/test/token_usage_test.dart new file mode 100644 index 0000000..75d62ac --- /dev/null +++ b/test/token_usage_test.dart @@ -0,0 +1,142 @@ +import 'dart:ui' show PointerDeviceKind; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:ohlcv_chart/ohlcv_chart.dart'; + +final _day = DateTime.utc(2026, 9, 1); + +List _buckets() => [ + for (var i = 0; i < 7; i++) + UsageBucket( + time: _day.add(Duration(days: i)), + inputTokens: 1e6 * (i + 1), + outputTokens: 4e5 * (i + 1), + cachedTokens: 5e5, + cost: 12.0 + i, + ), +]; + +void main() { + group('a bucket', () { + test('adds up every kind of token', () { + final b = UsageBucket( + time: _day, + inputTokens: 100, + outputTokens: 50, + cachedTokens: 25, + ); + + expect(b.total, 175); + }); + + test('the cache hit rate is cached over the whole prompt', () { + final b = UsageBucket(time: _day, inputTokens: 300, cachedTokens: 700); + + expect(b.cacheHitRate, closeTo(0.7, 1e-9)); + expect(UsageBucket(time: _day).cacheHitRate, 0); + }); + + test('a value that is not finite counts as nothing', () { + final b = UsageBucket( + time: _day, + inputTokens: double.nan, + outputTokens: -5, + cachedTokens: double.infinity, + ); + + expect(b.total, 0); + }); + }); + + group('totals', () { + test('add the buckets, costs only where there is one', () { + final totals = usageTotals([ + UsageBucket(time: _day, inputTokens: 10, outputTokens: 5, cost: 2), + UsageBucket(time: _day, inputTokens: 20, cachedTokens: 30), + UsageBucket(time: _day, cost: double.nan), + ]); + + expect(totals.input, 30); + expect(totals.output, 5); + expect(totals.cached, 30); + expect(totals.tokens, 65); + expect(totals.cost, 2); + expect(totals.cacheHitRate, closeTo(0.5, 1e-9)); + }); + + test('nothing adds up to nothing', () { + final totals = usageTotals(const []); + + expect(totals.tokens, 0); + expect(totals.cacheHitRate, 0); + }); + }); + + test('counts are written compactly', () { + expect(compactCount(950), '950'); + expect(compactCount(12400), '12.4k'); + expect(compactCount(3100000), '3.1M'); + expect(compactCount(2e9), '2B'); + expect(compactCount(1500), '1.5k'); + expect(compactCount(0), '0'); + expect(compactCount(double.nan), '–'); + }); + + group('the widget', () { + Widget host(Widget chart) => MaterialApp( + home: Scaffold(body: SizedBox(width: 400, height: 300, child: chart)), + ); + + testWidgets('reports the column under the pointer', (tester) async { + UsageTouchDetails? touched; + await tester.pumpWidget( + host(TokenUsageChart(buckets: _buckets(), onTouch: (d) => touched = d)), + ); + + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: Offset.zero); + addTearDown(gesture.removePointer); + final origin = tester.getTopLeft(find.byType(TokenUsageChart)); + // Seven columns across a plot from 44 px to 400 - 44 = 356 px. + const width = (400 - 44 - 44) / 7; + await gesture.moveTo(origin + const Offset(44 + width * 3.5, 100)); + + expect(touched, isNotNull); + expect(touched!.index, 3); + expect(touched!.bucket.inputTokens, 4e6); + + await gesture.moveTo(origin + const Offset(1, 100)); + expect(touched, isNull); + }); + + testWidgets('draws without a cost, and hourly', (tester) async { + await tester.pumpWidget( + host( + TokenUsageChart( + showCost: false, + buckets: [ + for (var i = 0; i < 24; i++) + UsageBucket( + time: _day.add(Duration(hours: i)), + inputTokens: 1000.0 + i, + outputTokens: 300, + ), + ], + ), + ), + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('survives nothing, zeros and one bucket', (tester) async { + await tester.pumpWidget(host(const TokenUsageChart(buckets: []))); + expect(tester.takeException(), isNull); + + await tester.pumpWidget( + host(TokenUsageChart(buckets: [UsageBucket(time: _day)])), + ); + expect(tester.takeException(), isNull); + }); + }); +} diff --git a/test/training_curve_test.dart b/test/training_curve_test.dart new file mode 100644 index 0000000..bda956a --- /dev/null +++ b/test/training_curve_test.dart @@ -0,0 +1,178 @@ +import 'dart:ui' show PointerDeviceKind; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:ohlcv_chart/ohlcv_chart.dart'; + +List _points(List values) => [ + for (var i = 0; i < values.length; i++) TrainingPoint(i * 100.0, values[i]), +]; + +void main() { + group('smoothing', () { + test('a weight of zero leaves the values alone', () { + expect(smoothValues([3, 1, 2], 0), [3, 1, 2]); + }); + + test('the first reading is not dragged toward zero', () { + final out = smoothValues([10, 10, 10, 10], 0.9); + + for (final v in out) { + expect(v, closeTo(10, 1e-9)); + } + }); + + test('a spike is spread, not erased', () { + final out = smoothValues([1, 1, 1, 9, 1, 1], 0.8); + + expect(out[3]!, lessThan(9)); + expect(out[3]!, greaterThan(1)); + expect(out[4]!, greaterThan(1)); + }); + + test( + 'a value that is not a number is skipped, and the average goes on', + () { + final out = smoothValues([2, double.nan, 2], 0.5); + + expect(out[1], isNull); + expect(out[2], closeTo(2, 1e-9)); + }, + ); + + test('a weight outside 0..1 is held to the range', () { + expect(smoothValues([4, 4], -1), [4, 4]); + expect(smoothValues([4, 4], 5).every((v) => v!.isFinite), isTrue); + expect(smoothValues(const [], 0.5), isEmpty); + }); + }); + + group('the best checkpoint', () { + test('is the lowest loss, or the highest accuracy', () { + expect(bestIndex([3, 1, 2]), 1); + expect(bestIndex([3, 1, 2], lowerIsBetter: false), 0); + }); + + test('skips values that are not finite, and may be none', () { + expect(bestIndex([double.nan, 5, double.infinity]), 1); + expect(bestIndex([double.nan]), isNull); + expect(bestIndex(const []), isNull); + }); + + test('the first of equals wins', () { + expect(bestIndex([2, 1, 1]), 1); + }); + }); + + group('divergence', () { + test('a loss that climbs past a multiple of its lowest', () { + expect(divergenceIndex([2.0, 1.5, 1.0, 1.1, 4.0, 9.0]), 4); + }); + + test('a loss that becomes NaN after training', () { + expect(divergenceIndex([2.0, 1.0, double.nan, 1.0]), 2); + }); + + test('a healthy curve never diverges', () { + expect(divergenceIndex([3.0, 2.5, 2.0, 2.2, 1.9]), isNull); + expect(divergenceIndex(const []), isNull); + }); + + test('NaN before any real value is not a divergence', () { + expect(divergenceIndex([double.nan, 2.0, 1.0]), isNull); + }); + + test('the factor is the caller\'s to set', () { + expect(divergenceIndex([1.0, 1.6], factor: 1.5), 1); + expect(divergenceIndex([1.0, 1.6], factor: 2), isNull); + }); + }); + + group('the widget', () { + Widget host(Widget chart) => MaterialApp( + home: Scaffold(body: SizedBox(width: 400, height: 300, child: chart)), + ); + + testWidgets('reports every run\'s reading at the touched step', ( + tester, + ) async { + TrainingTouchDetails? touched; + await tester.pumpWidget( + host( + TrainingCurveChart( + runs: [ + TrainingRun(label: 'train', points: _points([3, 2, 1.5, 1.2, 1])), + TrainingRun( + label: 'val', + points: _points([3.2, 2.4, 2, 1.9, 1.95]), + dashed: true, + ), + ], + gapBetween: (0, 1), + onTouch: (d) => touched = d, + ), + ), + ); + + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: Offset.zero); + addTearDown(gesture.removePointer); + final origin = tester.getTopLeft(find.byType(TrainingCurveChart)); + // Half way along the plot: past the 48 px axis, over steps 0..400. + await gesture.moveTo(origin + const Offset(48 + (400 - 48) / 2, 120)); + + expect(touched, isNotNull); + expect(touched!.step, 200); + expect(touched!.readings, hasLength(2)); + expect(touched!.readings[0].point?.value, 1.5); + expect(touched!.readings[1].point?.value, 2); + + await gesture.moveTo(origin + const Offset(2, 2)); + expect(touched, isNull); + }); + + testWidgets('draws smoothed, logarithmic, and with a diverged run', ( + tester, + ) async { + await tester.pumpWidget( + host( + TrainingCurveChart( + runs: [ + TrainingRun( + label: 'ok', + points: _points([10, 3, 1, 0.3, 0.1, 0.05]), + ), + TrainingRun( + label: 'blew up', + points: _points([10, 3, 1, 0.5, 8, double.nan]), + ), + ], + smoothing: 0.6, + logY: true, + ), + ), + ); + + expect(tester.takeException(), isNull); + }); + + testWidgets('survives no runs, empty runs and a single point', ( + tester, + ) async { + await tester.pumpWidget(host(const TrainingCurveChart(runs: []))); + expect(tester.takeException(), isNull); + + await tester.pumpWidget( + host( + const TrainingCurveChart( + runs: [ + TrainingRun(label: 'a', points: []), + TrainingRun(label: 'b', points: [TrainingPoint(0, 1)]), + ], + ), + ), + ); + expect(tester.takeException(), isNull); + }); + }); +} diff --git a/test/training_timeline_test.dart b/test/training_timeline_test.dart new file mode 100644 index 0000000..4a34416 --- /dev/null +++ b/test/training_timeline_test.dart @@ -0,0 +1,163 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:ohlcv_chart/ohlcv_chart.dart'; + +final _start = DateTime.utc(2026, 9, 1, 8); + +List _jobs() => [ + TrainingJob( + name: 'sft-8b', + node: 'pod-a', + start: _start, + end: _start.add(const Duration(hours: 6)), + status: JobStatus.completed, + gpus: 64, + ), + TrainingJob( + name: 'sft-70b', + node: 'pod-b', + start: _start.add(const Duration(hours: 1)), + end: _start.add(const Duration(hours: 3)), + status: JobStatus.failed, + ), + TrainingJob( + name: 'rl', + node: 'pod-a', + start: _start.add(const Duration(hours: 7)), + status: JobStatus.running, + ), +]; + +void main() { + Widget host(Widget chart) => MaterialApp( + home: Scaffold(body: SizedBox(width: 500, height: 300, child: chart)), + ); + + test('each status has a colour of its own', () { + const chart = TrainingTimelineChart(jobs: []); + final colors = {for (final s in JobStatus.values) chart.colorOf(s)}; + + expect(colors, hasLength(JobStatus.values.length)); + }); + + testWidgets('draws the jobs as a trade timeline coloured by status', ( + tester, + ) async { + await tester.pumpWidget(host(TrainingTimelineChart(jobs: _jobs()))); + + final timeline = tester.widget( + find.byType(TradeTimelineChart), + ); + expect(timeline.trades, hasLength(3)); + expect(timeline.trades[0].color, const Color(0xFF2F9E44)); + expect(timeline.trades[1].color, const Color(0xFFE03131)); + expect(timeline.trades[2].exitTime, isNull); + expect(timeline.trades[0].lane, 'pod-a'); + expect(tester.takeException(), isNull); + }); + + testWidgets('lists the statuses in use, and only those', (tester) async { + await tester.pumpWidget(host(TrainingTimelineChart(jobs: _jobs()))); + + expect(find.text('Completed'), findsOneWidget); + expect(find.text('Failed'), findsOneWidget); + expect(find.text('Running'), findsOneWidget); + expect(find.text('Preempted'), findsNothing); + }); + + testWidgets('the legend can go, and the statuses can be renamed', ( + tester, + ) async { + await tester.pumpWidget( + host(TrainingTimelineChart(jobs: _jobs(), showLegend: false)), + ); + expect(find.text('Completed'), findsNothing); + + await tester.pumpWidget( + host( + TrainingTimelineChart( + jobs: _jobs(), + statusLabels: const {JobStatus.failed: 'Crashed'}, + ), + ), + ); + expect(find.text('Crashed'), findsOneWidget); + }); + + testWidgets('a box too small for a legend draws the chart alone', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: 1, + height: 1, + child: TrainingTimelineChart(jobs: _jobs()), + ), + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + expect(find.text('Completed'), findsNothing); + }); + + testWidgets('a running job is open whatever end it was given', ( + tester, + ) async { + await tester.pumpWidget( + host( + TrainingTimelineChart( + jobs: [ + TrainingJob( + name: 'x', + start: _start, + end: _start.add(const Duration(hours: 1)), + status: JobStatus.running, + ), + ], + ), + ), + ); + + final timeline = tester.widget( + find.byType(TradeTimelineChart), + ); + expect(timeline.trades.single.exitTime, isNull); + }); + + test('a job\'s duration runs to now while it is going', () { + final job = _jobs().last; + final details = TrainingJobTouchDetails(job: job, time: _start); + + expect( + details.duration(_start.add(const Duration(hours: 10))), + const Duration(hours: 3), + ); + }); + + testWidgets('survives no jobs, and a job that ends before it starts', ( + tester, + ) async { + await tester.pumpWidget(host(const TrainingTimelineChart(jobs: []))); + expect(tester.takeException(), isNull); + + await tester.pumpWidget( + host( + TrainingTimelineChart( + jobs: [ + TrainingJob( + name: 'odd', + start: _start, + end: _start.subtract(const Duration(hours: 1)), + ), + ], + ), + ), + ); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/var_chart_test.dart b/test/var_chart_test.dart new file mode 100644 index 0000000..7e0a357 --- /dev/null +++ b/test/var_chart_test.dart @@ -0,0 +1,132 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:ohlcv_chart/ohlcv_chart.dart'; + +/// One hundred returns, -0.50 to +0.49 in steps of 0.01. +List _hundred() => [for (var i = 0; i < 100; i++) (i - 50) / 100]; + +void main() { + group('value at risk', () { + test('is the return only a fraction of the history fell below', () { + final risk = ValueAtRisk.fromReturns(_hundred(), confidence: 0.95); + + // The 5th percentile of -0.50..0.49 by interpolation. + expect(risk.valueAtRisk, closeTo(-0.5 + 0.05 * 99 / 100, 1e-9)); + expect(risk.count, 100); + expect(risk.worst, -0.5); + expect(risk.mean, closeTo(-0.005, 1e-9)); + }); + + test('expected shortfall is the average of the tail, never above it', () { + final risk = ValueAtRisk.fromReturns(_hundred(), confidence: 0.95); + + expect(risk.expectedShortfall, lessThanOrEqualTo(risk.valueAtRisk)); + // The five returns -0.50 .. -0.46 sit at or below the threshold. + expect(risk.expectedShortfall, closeTo(-0.48, 1e-9)); + }); + + test('a higher confidence reaches further into the tail', () { + final at95 = ValueAtRisk.fromReturns(_hundred(), confidence: 0.95); + final at99 = ValueAtRisk.fromReturns(_hundred(), confidence: 0.99); + + expect(at99.valueAtRisk, lessThan(at95.valueAtRisk)); + expect(at99.expectedShortfall, lessThanOrEqualTo(at95.expectedShortfall)); + }); + + test('skips what is not a number, and says zero for nothing', () { + final risk = ValueAtRisk.fromReturns(const [ + double.nan, + -0.1, + double.infinity, + 0.1, + ]); + expect(risk.count, 2); + + final empty = ValueAtRisk.fromReturns(const []); + expect(empty.count, 0); + expect(empty.valueAtRisk, 0); + expect(empty.expectedShortfall, 0); + }); + + test('one return is its own value at risk', () { + final risk = ValueAtRisk.fromReturns(const [-0.02]); + + expect(risk.valueAtRisk, -0.02); + expect(risk.expectedShortfall, -0.02); + expect(risk.standardDeviation, 0); + }); + + test('the standard deviation is the sample one', () { + final risk = ValueAtRisk.fromReturns(const [1, 2, 3, 4]); + + expect(risk.standardDeviation, closeTo(1.2909944487, 1e-9)); + }); + }); + + group('the bins', () { + test('those in the tail carry the tail colour', () { + const tail = Color(0xFFFF0000); + final bins = varBins( + _hundred(), + valueAtRisk: -0.45, + tailColor: tail, + binWidth: 0.1, + ); + + expect(bins.first.color, tail); + expect(bins.last.color, isNull); + final tinted = bins.where((b) => b.color == tail).length; + expect(tinted, inInclusiveRange(1, 2)); + }); + }); + + group('the widget', () { + Widget host(Widget chart, {double height = 300}) => MaterialApp( + home: Scaffold( + body: SizedBox(width: 400, height: height, child: chart), + ), + ); + + testWidgets('writes the figures and draws a histogram', (tester) async { + await tester.pumpWidget(host(VarChart(returns: _hundred()))); + + expect(find.byType(HistogramChart), findsOneWidget); + expect(find.text('VaR 95%'), findsOneWidget); + expect(find.text('CVaR 95%'), findsOneWidget); + expect(find.text('100'), findsOneWidget); + }); + + testWidgets('showStats: false leaves only the histogram', (tester) async { + await tester.pumpWidget( + host(VarChart(returns: _hundred(), showStats: false)), + ); + + expect(find.text('VaR 95%'), findsNothing); + expect(find.byType(HistogramChart), findsOneWidget); + }); + + testWidgets('a box too short for the figures drops them', (tester) async { + await tester.pumpWidget(host(VarChart(returns: _hundred()), height: 90)); + + expect(find.text('VaR 95%'), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('the histogram takes the height the figures leave', ( + tester, + ) async { + await tester.pumpWidget(host(VarChart(returns: _hundred()), height: 380)); + + // The figures are one short row; the rest of the box is the histogram, + // not half of it. + final histogram = tester.getSize(find.byType(HistogramChart)); + expect(histogram.height, greaterThan(380 * 0.7)); + }); + + testWidgets('survives no returns', (tester) async { + await tester.pumpWidget(host(const VarChart(returns: []))); + + expect(tester.takeException(), isNull); + }); + }); +} diff --git a/test/volatility_surface_test.dart b/test/volatility_surface_test.dart new file mode 100644 index 0000000..f730da8 --- /dev/null +++ b/test/volatility_surface_test.dart @@ -0,0 +1,115 @@ +import 'dart:ui' show PointerDeviceKind; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:ohlcv_chart/ohlcv_chart.dart'; + +const _strikes = [90.0, 100.0, 110.0]; +const _expiries = ['1M', '3M']; +const List> _vols = [ + [0.34, 0.28, 0.31], + [0.30, 0.26, null], +]; + +void main() { + group('the at-the-money strike', () { + test('is the one nearest the spot', () { + expect(atTheMoneyIndex(_strikes, 99), 1); + expect(atTheMoneyIndex(_strikes, 104), 1); + expect(atTheMoneyIndex(_strikes, 106), 2); + expect(atTheMoneyIndex(_strikes, 0), 0); + }); + + test('is none without strikes or with a spot that is not a number', () { + expect(atTheMoneyIndex(const [], 100), isNull); + expect(atTheMoneyIndex(_strikes, double.nan), isNull); + }); + }); + + group('the skew', () { + test('is each strike against the at-the-money one', () { + final skew = surfaceSkew(_vols[0], 1); + + expect(skew[0], closeTo(0.06, 1e-9)); + expect(skew[1], closeTo(0, 1e-9)); + expect(skew[2], closeTo(0.03, 1e-9)); + }); + + test('is empty where either side is missing', () { + expect(surfaceSkew(_vols[1], 2), everyElement(isNull)); + expect(surfaceSkew(_vols[0], null), everyElement(isNull)); + expect(surfaceSkew(const [null, 0.3], 1)[0], isNull); + }); + }); + + group('the widget', () { + Widget host(Widget chart) => MaterialApp( + home: Scaffold(body: SizedBox(width: 400, height: 300, child: chart)), + ); + + testWidgets('draws every reading as a percentage', (tester) async { + await tester.pumpWidget( + host( + const VolatilitySurfaceChart( + strikes: _strikes, + expiries: _expiries, + volatilities: _vols, + ), + ), + ); + + expect(find.byType(HeatmapChart), findsOneWidget); + final heatmap = tester.widget(find.byType(HeatmapChart)); + // Five readings: the missing one leaves its square empty. + expect(heatmap.cells, hasLength(5)); + expect(heatmap.minValue, 0.26); + expect(heatmap.maxValue, 0.34); + }); + + testWidgets('reports the expiry, strike and skew of a touch', ( + tester, + ) async { + VolatilitySurfaceTouchDetails? touched; + await tester.pumpWidget( + host( + VolatilitySurfaceChart( + strikes: _strikes, + expiries: _expiries, + volatilities: _vols, + spot: 100, + onTouch: (d) => touched = d, + ), + ), + ); + + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: Offset.zero); + addTearDown(gesture.removePointer); + final box = tester.getRect(find.byType(VolatilitySurfaceChart)); + // First square of the first row: past the 44 px expiry axis, in the top + // row of a grid whose bottom 20 px are the strikes. + await gesture.moveTo(box.topLeft + const Offset(60, 20)); + + expect(touched, isNotNull); + expect(touched!.expiry, '1M'); + expect(touched!.strike, 90); + expect(touched!.volatility, 0.34); + expect(touched!.atTheMoneyVolatility, 0.28); + expect(touched!.skew, closeTo(0.06, 1e-9)); + }); + + testWidgets('survives nothing to draw', (tester) async { + await tester.pumpWidget( + host( + const VolatilitySurfaceChart( + strikes: [], + expiries: [], + volatilities: [], + ), + ), + ); + + expect(tester.takeException(), isNull); + }); + }); +}