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 @@
[](https://github.com/CtrlAltDevelop/ohlcv_chart/actions/workflows/ci.yml)
[](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 Returns split at zero, profit bars, cash flows with a tooltip, and a sparkline. |
- Pie and radar A doughnut with its total in the centre, and a radar comparing two strategies. |
- Heatmap Six months of activity, and orders by hour and weekday. |
-
-
- Scatter One point per trade, with per-point shape and colour and a single-point readout. |
- Stacked horizontal bars Stacked bars in horizontal orientation, with titled axes. |
- Floating bars and bands A waterfall chart, and a forecast band with error bars. |
-
-
- Range selector Three series over five months, with a range selector below. |
- Synchronised crosshair Balance and profit panels sharing one crosshair. |
- Depth chart Cumulative bid and ask depth. |
-
-
- Depth chart modes Cumulative, histogram, combined and ladder modes. |
- The bid/ask ratio bar The balance of bid and ask volume, below the depth chart. |
- |
-
-
-
-### Order flow and derivatives
-
-
-
- Market profile Time at price as TPO letters, with the point of control and value area. |
- Footprint Bid and ask volume at every price, inside each candle. |
- Order-book heatmap Resting liquidity over time, with walls as bright lines. |
-
-
- Cumulative delta Running delta over a session, with buying and selling per bar. |
- Liquidity map Where the leveraged stops sit, longs below the price and shorts above. |
- Open interest and funding How much money is in the trade, and which side is paying. |
-
-
- Options payoff A bull call spread and a long straddle at expiry, with break-evens. |
- Volatility curves A smile at three expiries, and a term structure. |
- Pair spread The log ratio of two symbols, with bands and a z-score. |
-
-
-
-### Backtests and results
-
-
-
- Equity curve The curve, with the underwater drawdown panel beneath it. |
- Monte Carlo fan Simulated paths as percentile bands, with the odds of loss and ruin. |
- R-multiple distribution Results in R, with expectancy, profit factor and SQN. |
-
-
- Trade timeline Every trade from entry to exit, a lane per symbol. |
- Seasonality Returns by month and year, compounded, with totals and averages. |
- Calendar Daily profit and loss on real month panels. |
-
-
- Waterfall A balance built up step by step, from opening to closing. |
- |
- |
-
-
-
-### Dashboards and reports
-
-
-
- Treemap A market map of sectors and symbols, and portfolio weight. |
- Sunburst A hierarchy as rings, with the total in the middle. |
- Sankey Fee income flowing through revenue into costs and profit. |
-
-
- Chord Flow between venues, both ways round a ring. |
- Marimekko Two dimensions at once: column width and cell height. |
- Stream graph Two years of composition, on a wiggle baseline. |
-
-
- Parallel coordinates Five strategies compared on five measures at once. |
- Slope and bump Returns by quarter, by value and by changing rank. |
- Bubble Risk against return, sized by capital. |
-
-
- Box plot Quartiles, whiskers and outliers for four strategies. |
- Histogram Daily returns split at zero, and a spread of trade sizes. |
- Violin and ridgeline The shape of a distribution, side by side or stacked. |
-
-
- Dumbbell Two values a row, joined by a bar. |
- Bullet KPIs against their targets, on banded tracks. |
- Gauge A dial with coloured ranges, a half circle and a progress ring. |
-
-
- Waffle Shares as a hundred squares, counted rather than judged. |
- Funnel Where the people go, tapered or straight-sided. |
- Sparkline grid A watchlist of small multiples, a name and a shape each. |
-
-
-
-### Chart types
-
-
-
-### 31 indicators
-
-
-
-### Drawing tools and trading
-
-
-
- 29 drawing tools Labelled trend and horizontal lines, with snapping and persistence. |
- Rays, boxes and Fibonacci A ray, an arrow, a horizontal ray, a range box and a Fibonacci retracement. |
- The line editor Controls for the selected drawing, customisable through DrawingStyle. |
-
-
- Orders and positions A working order and an open position, each labelled on the axis. |
- Planning a trade A planned position with risk-to-reward, inside a channel. |
- Event marks Earnings, dividend, split and news markers below the candles. |
-
-
-
-### Interaction and navigation
-
-
-
-### Axes, sessions and layout
-
-
-
-## 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.
+
+
+
+
+
+## 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
+
+
+
+`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
+
+
+
+`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
+
+
+
+`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
+
+
+
+`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
+
+
+
+`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 Returns split at zero, profit bars, cash flows with a tooltip, and a sparkline. |
+ Pie and radar A doughnut with its total in the centre, and a radar comparing two strategies. |
+ Heatmap Six months of activity, and orders by hour and weekday. |
+
+
+ Scatter One point per trade, with per-point shape and colour and a single-point readout. |
+ Stacked horizontal bars Stacked bars in horizontal orientation, with titled axes. |
+ Floating bars and bands A waterfall chart, and a forecast band with error bars. |
+
+
+ Range selector Three series over five months, with a range selector below. |
+ Synchronised crosshair Balance and profit panels sharing one crosshair. |
+ Depth chart Cumulative bid and ask depth. |
+
+
+ Depth chart modes Cumulative, histogram, combined and ladder modes. |
+ The bid/ask ratio bar The balance of bid and ask volume, below the depth chart. |
+ |
+
+
+
+## Order flow and derivatives
+
+
+
+ Market profile Time at price as TPO letters, with the point of control and value area. |
+ Footprint Bid and ask volume at every price, inside each candle. |
+ Order-book heatmap Resting liquidity over time, with walls as bright lines. |
+
+
+ Cumulative delta Running delta over a session, with buying and selling per bar. |
+ Liquidity map Where the leveraged stops sit, longs below the price and shorts above. |
+ Open interest and funding How much money is in the trade, and which side is paying. |
+
+
+ Options payoff A bull call spread and a long straddle at expiry, with break-evens. |
+ Volatility curves A smile at three expiries, and a term structure. |
+ Pair spread The log ratio of two symbols, with bands and a z-score. |
+
+
+ Volatility surface Implied volatility across strike and expiry, with the skew against the money. |
+ Futures curve Prices by delivery month, with contango or backwardation read off. |
+ |
+
+
+
+## Backtests and results
+
+
+
+ Equity curve The curve, with the underwater drawdown panel beneath it. |
+ Monte Carlo fan Simulated paths as percentile bands, with the odds of loss and ruin. |
+ R-multiple distribution Results in R, with expectancy, profit factor and SQN. |
+
+
+ Trade timeline Every trade from entry to exit, a lane per symbol. |
+ Seasonality Returns by month and year, compounded, with totals and averages. |
+ Calendar Daily profit and loss on real month panels. |
+
+
+ Waterfall A balance built up step by step, from opening to closing. |
+ MAE / MFE scatter Every trade by how far it went against you or for you, and what it made. |
+ Value at risk Returns with the loss tail picked out, and VaR and expected shortfall marked. |
+
+
+ Efficient frontier Portfolios by risk and return, with the frontier and the best Sharpe ratio. |
+ Activity clock A week as a clock face: an hour round the dial, a weekday on each ring. |
+ |
+
+
+
+## Dashboards and reports
+
+
+
+ Treemap A market map of sectors and symbols, and portfolio weight. |
+ Sunburst A hierarchy as rings, with the total in the middle. |
+ Sankey Fee income flowing through revenue into costs and profit. |
+
+
+ Chord Flow between venues, both ways round a ring. |
+ Marimekko Two dimensions at once: column width and cell height. |
+ Stream graph Two years of composition, on a wiggle baseline. |
+
+
+ Parallel coordinates Five strategies compared on five measures at once. |
+ Slope and bump Returns by quarter, by value and by changing rank. |
+ Bubble Risk against return, sized by capital. |
+
+
+ Box plot Quartiles, whiskers and outliers for four strategies. |
+ Histogram Daily returns split at zero, and a spread of trade sizes. |
+ Violin and ridgeline The shape of a distribution, side by side or stacked. |
+
+
+ Dumbbell Two values a row, joined by a bar. |
+ Bullet KPIs against their targets, on banded tracks. |
+ Gauge A dial with coloured ranges, a half circle and a progress ring. |
+
+
+ Waffle Shares as a hundred squares, counted rather than judged. |
+ Funnel Where the people go, tapered or straight-sided. |
+ Sparkline grid A watchlist of small multiples, a name and a shape each. |
+
+
+
+## Progress and goals
+
+
+
+ Progress bars Goals as pills, blocks or cells, with a mark for where each should be. |
+ Progress card Course cards whose whole background is the progress: a sweep, a growing circle or a bar. |
+ |
+
+
+
+## Models and training
+
+
+
+ Training curves Loss and accuracy of runs, smoothed, with the best checkpoint and divergence marked. |
+ Scaling laws Loss against compute on log-log axes, with the power law fitted and extrapolated. |
+ Hyperparameter sweep A learning-rate by batch-size grid, the best combination starred. |
+
+
+ Training timeline Jobs by machine, coloured by how each finished. |
+ Benchmark matrix Models against benchmarks, coloured by standing, leaders starred. |
+ Model cost against quality Models by price and score, with the Pareto frontier. |
+
+
+ Latency bands p50, p95 and p99 over a day, with an objective and its breaches. |
+ Token usage Cached, input and output tokens, with the cost as a line. |
+ Evaluation breakdown Pass rate by category, worst first, with a threshold. |
+
+
+ Token heatmap A completion with each token shaded by its surprisal. |
+ |
+ |
+
+
+
+## Chart types
+
+
+
+## 31 indicators
+
+
+
+## Drawing tools and trading
+
+
+
+ 29 drawing tools Labelled trend and horizontal lines, with snapping and persistence. |
+ Rays, boxes and Fibonacci A ray, an arrow, a horizontal ray, a range box and a Fibonacci retracement. |
+ The line editor Controls for the selected drawing, customisable through DrawingStyle. |
+
+
+ Orders and positions A working order and an open position, each labelled on the axis. |
+ Planning a trade A planned position with risk-to-reward, inside a channel. |
+ Event marks Earnings, dividend, split and news markers below the candles. |
+
+
+
+## Interaction and navigation
+
+
+
+## Axes, sessions and layout
+
+
+
+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
+
+
+
+`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
+
+
+
+`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
+
+
+
+`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
+
+
+
+`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
+
+
+
+`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
+
+
+
+`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
+
+
+
+`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
+
+
+
+`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
+
+
+
+`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
+
+
+
+`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
+
+
+
+`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
+
+
+
+`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