diff --git a/CONCEPT.md b/CONCEPT.md index f8e0789..bfda7a5 100644 --- a/CONCEPT.md +++ b/CONCEPT.md @@ -2,12 +2,12 @@ **A grammar-driven plotting library for Go: one model, many backends, runs everywhere — built on the GoGPU stack.** -> Status: **`v0.x`.** The model is settled and the API is not frozen: three -> seams that take positional scale arguments are being reshaped so that a chart -> can gain a dimension additively +> Status: **`v0.x`.** The model is settled and the API is not frozen. The +> three seams that took positional scale arguments have been reshaped so that a +> chart can gain a dimension additively > ([ADR 0056](docs/adr/0056-three-dimensional-charts.md), -> [ADR 0060](docs/adr/0060-parameter-structs-at-every-seam.md)), and `v1.0.0` -> returns when they are done. [§14](#14-what-is-built-and-what-is-next) says +> [ADR 0060](docs/adr/0060-parameter-structs-at-every-seam.md)); `v1.0.0` +> returns when the surface settles. [§14](#14-what-is-built-and-what-is-next) says > what exists and what is next. > **Implementation note.** Where this document and the code disagree, the code @@ -578,151 +578,113 @@ first sketched: Go has no sum types, and a handler signature per kind means Rendering is delegated to gg, so effort concentrates on the model, the data layer and interaction. -**What exists.** Linear, time, log, symlog and ordinal scales; the marks, +**What exists.** Linear, time, log, symlog, probability and ordinal scales, an +axis that runs either way and an axis that is broken and says so; the marks, coordinate systems, colour and size channels, facets and guides listed in [docs/features.md](docs/features.md); SVG, PDF, PNG, JPEG, a browser canvas, a native window and an opt-in GPU tier behind one `ir.Backend`; hit-testing, overlays, clickable guides, linked views, keyed transitions and streaming; typeset notation in labels; a described chart with a data table for a reader -that is not an eye; a JSON dialect a chart writes itself down as and reads -itself back from; and, in `figure/three`, a chart whose x, y and z are all -data — a surface, a trajectory, a field of bars and the contours of a field on -the floor beneath them, projected above the IR so that every backend draws one, -turned at a camera the host holds -([ADR 0056](docs/adr/0056-three-dimensional-charts.md), -[ADR 0057](docs/adr/0057-orbiting-a-chart.md), -[ADR 0064](docs/adr/0064-a-contour-and-its-lattice.md)); a contour plot of a -sampled field, whose lines are the same tracing whether they are drawn flat or -on a surface's floor; an overlay a host paints a selection over, in one -view or in every view of a scene at once -([ADR 0063](docs/adr/0063-an-overlay-over-a-scene.md)); and a locus — a family -of curves given by a formula rather than by data, of which `geom.HLine` is the -degenerate member, which is what a Nichols diagram's closed-loop contours are -and what the VSWR circles and constant-Q arcs -[ADR 0033](docs/adr/0033-smith-charts.md) declined are: not furniture, but -annotations defined in data space, so the coordinate stage draws them and -`render` keeps its two tick lists -([ADR 0050](docs/adr/0050-locus-annotations.md)). -A map: `coord.Geo` places a longitude and a latitude by one of four named -projections — Mollweide, where equal areas of the world cover equal ink, which -is the default because size on a map reads as quantity; Mercator for a bearing; -the equirectangular one a table of degrees is already in; and the orthographic -globe whose far side has no image at all — and every -mark draws on it unchanged, so a scatter is a point map, a line is a route and -a rect is a cell of a gridded field. It ships no geography and computes no -routes: a coastline and a great circle are rows in the caller's table -([ADR 0081](docs/adr/0081-a-map-projection.md)). -A schedule that is a schedule rather than a picture of one: a span filled as far -as the work has got, the constraints between spans drawn in all four linkages -from a link table of their own, and a critical path that is a column of that -table rather than a feature of the library -([ADR 0068](docs/adr/0068-gantt-charts.md)). -And a filled mark that is told apart by more than its colour: a hatch is what a -bar, a slice or a band has instead of the dash a line has and the shape a point -has, so `theme.Redundant` finally reaches the marks that fail hardest in -greyscale — with a ladder of patterns where the series are categories and a -ladder of densities where they are an order, drawn as clipped strokes rather -than declared to a backend, so that every backend and the GPU tier draw the -same thing ([ADR 0069](docs/adr/0069-hatching-as-the-third-redundant-channel.md)). +that is not an eye; and a JSON dialect a chart writes itself down as and reads +itself back from. Grouped by the question each answers: + +- **A third axis.** In `figure/three`, a chart whose x, y and z are all data — + a surface, a trajectory, a field of bars and the contours of a field on the + floor beneath them, projected above the IR so that every backend draws one, + turned at a camera the host holds, with the spherical scene that makes one + projection into antenna patterns, Poincaré and Bloch spheres, a stereonet and + the Smith sphere + ([ADR 0056](docs/adr/0056-three-dimensional-charts.md), + [ADR 0057](docs/adr/0057-orbiting-a-chart.md), + [ADR 0058](docs/adr/0058-what-3d-is-for.md)); an overlay a host paints a + selection over, in one view or in every view of a scene at once + ([ADR 0063](docs/adr/0063-an-overlay-over-a-scene.md)). +- **Coordinates.** Cartesian, polar, Smith, barycentric, parallel and + geographic. A map places a longitude and a latitude by one of four named + projections — Mollweide, where equal areas of the world cover equal ink, + which is the default because size on a map reads as quantity; Mercator for a + bearing; the equirectangular one a table of degrees is already in; and the + orthographic globe whose far side has no image at all — and every mark draws + on it unchanged. It ships no geography and computes no routes + ([ADR 0081](docs/adr/0081-a-map-projection.md)). +- **Curves given by a formula rather than by data.** A locus, of which + `geom.HLine` is the degenerate member: a Nichols diagram's closed-loop + contours, and the VSWR circles and constant-Q arcs + [ADR 0033](docs/adr/0033-smith-charts.md) declined — annotations defined in + data space, so the coordinate stage draws them and `render` keeps its two + tick lists ([ADR 0050](docs/adr/0050-locus-annotations.md)). A family drawn + as a mark writes its own levels along its curves + ([ADR 0073](docs/adr/0073-labels-on-a-curve.md)). +- **Fields.** A contour plot of a sampled field, whose lines are the same + tracing flat or on a surface's floor + ([ADR 0064](docs/adr/0064-a-contour-and-its-lattice.md)); a raster that draws + a spectrogram as one image rather than a million rectangles + ([ADR 0066](docs/adr/0066-a-raster-mark.md)); a horizon chart that fits forty + sensors on one screen ([ADR 0065](docs/adr/0065-horizon-charts.md)); and a + bivariate colour channel for a value and how well it is known + ([ADR 0067](docs/adr/0067-a-bivariate-colour-channel.md)). +- **Relations.** Treemap, icicle, sunburst, sankey, chord, a tidy tree + ([ADR 0053](docs/adr/0053-tidy-tree-layout.md)), a layered graph + ([ADR 0072](docs/adr/0072-layered-graph-layout.md)), a node-link diagram + placed by stress majorization + ([ADR 0077](docs/adr/0077-a-node-link-layout.md)), parallel sets + ([ADR 0079](docs/adr/0079-parallel-sets.md)), sets counted as an UpSet plot + or a Venn diagram of two or three + ([ADR 0074](docs/adr/0074-sets-are-counted.md), + [ADR 0076](docs/adr/0076-the-other-half-of-the-count.md)) and the + nearest-neighbour partition of a panel + ([ADR 0080](docs/adr/0080-nearest-neighbour-cells.md)). +- **Instruments.** Probability paper + ([ADR 0052](docs/adr/0052-probability-scales.md)); the Kaplan–Meier + estimator, SPC control limits, ACF and PACF, ROC and Lorenz + ([ADR 0054](docs/adr/0054-statistical-instruments.md)); and a schedule that + is a schedule rather than a picture of one + ([ADR 0068](docs/adr/0068-gantt-charts.md)). +- **Reading.** A filled mark told apart by more than its colour, hatched in a + ladder of patterns or densities + ([ADR 0069](docs/adr/0069-hatching-as-the-third-redundant-channel.md)); a + label that fits the shape of its box, or is moved, broken, shrunk or called + out of it before it is dropped + ([ADR 0040](docs/adr/0040-label-collision-avoidance.md), + [ADR 0082](docs/adr/0082-a-label-fits-its-box-or-is-called-out.md)); and an + axis break that is marked where it is drawn + ([ADR 0083](docs/adr/0083-an-axis-break-is-marked-or-not-drawn.md)). The record of how each of those arrived, and the argument that shaped it, is in [docs/milestones.md](docs/milestones.md). ### What is next -- Harden the GPU tier as GoGPU matures. -- **More coordinate systems.** A **barycentric coord** is the fourth, on the - same seam as Cartesian, polar and Smith and cheaper than any of them: the map - is affine, so an edge stays a straight line and only the clip changes - ([ADR 0051](docs/adr/0051-barycentric-coord.md)). A **geographic projection** - was the wider one, and it has been argued on its own evidence and built: - both objections against it — no linear interval underneath the transform, no - tick behind the graticule — turned out to have been paid for by charts nobody - thought of as maps, the first by the Smith chart's identity range and the - second by the ternary chart's third ladder. `coord.Geo` reads a panel's axes - as degrees, so the graticule *is* the two tick lists, and the only furniture - with no tick behind it is the edge of the map +The roadmap this section used to carry is drawn. What is left is short, and +most of it is waiting for a reason rather than for time. + +- **`v1.0.0`.** The seams that took positional arguments are parameter structs + ([ADR 0060](docs/adr/0060-parameter-structs-at-every-seam.md)), so what + remains is the audit §15 implies: every exported name, the JSON dialect and + the `Backend` interface read once more as surfaces that will not change, + before they are promised not to. +- **A mark that fills a closed ring of rows.** This is what a choropleth of + country shapes wants now that `coord.Geo` exists, and it has four questions + of its own — which row a region reports, how a ring is named, what a hole is, + whether a vertex is a mark — so it arrives with a record of its own. +- **A conic projection.** Albers and Lambert are configured by two standard + parallels, and `coord.Desc` carries no such field yet; adding one is additive ([ADR 0081](docs/adr/0081-a-map-projection.md)). -- **A tidy tree.** Reingold–Tilford in Buchheim's linear-time form is O(n), - deterministic, bounded and a pure function of its input, which is - `stat.Squarify`'s shape exactly — so a dendrogram, a phylogram, an org chart - and, under a polar coord, a radial dendrogram are one mark reading the - channels the relational family already defines - ([ADR 0053](docs/adr/0053-tidy-tree-layout.md)). The clustered heatmap falls - out of it. -- **Node-link diagrams** were the last of the relational family, and they are - drawn ([ADR 0077](docs/adr/0077-a-node-link-layout.md)). What draws them is - stress majorization: a named quantity — how far the drawn distances are from - the graph's own — descended one node at a time, where each step solves that - node's block of a quadratic sitting above the objective. The descent never - goes uphill, so a fixed count of sweeps costs quality and cannot cost - correctness, and [ADR 0012](docs/adr/0012-parallel-panels.md) is answered the - way `stat.Squarify` answers it. A force simulation could be made repeatable - too, with a fixed start and a fixed budget; what it has not got is an - objective, so the cost of its bound cannot be stated. -- **Sets are counted rather than laid out.** The other half of that sentence has - been answered: an UpSet plot is a matrix chart, so it needed a count in `stat` - and two ordinary marks, and a Venn diagram of two or three sets is a fixed - arrangement rather than the optimiser 0039 declined — which is still declined - from three sets on, where it has more region areas to hit than free numbers to - hit them with, and a fourth set with it, because that mark writes each count inside - its own region at one type size and a four-set diagram has regions that will - not hold one - ([ADR 0074](docs/adr/0074-sets-are-counted.md)). -- **A bucket of domain reductions** — the - Kaplan–Meier estimator, the SPC control-limit family, ACF and PACF, ROC and - Lorenz — none of which needs a shape figure does not already draw. The record - is mostly about where the line is, because "put the field's arithmetic in - `stat`" has no natural end: a reduction belongs there when its output is the - chart's geometry and there is no reading of it that is not the chart - ([ADR 0054](docs/adr/0054-statistical-instruments.md)). -- **A probability scale**, beside `Log` and `SymLog`: an axis warped by Φ⁻¹, the - logit, the complementary log-log or the Gumbel link, so that a distribution's - cumulative function plots straight. It is `geom.QQ` turned round — that mark - warps the sample and leaves the axis linear; this warps the axis and leaves - the sample alone — and both are worth having because a mark does one job and a - scale composes with every mark there is. Weibull, normal and extreme-value - probability paper cost no new mark at all: each is `geom.ECDF` on a warped - axis ([ADR 0052](docs/adr/0052-probability-scales.md)). -- **Automatic label avoidance** for every kind of mark. The opt-in, panel-local - form exists ([ADR 0040](docs/adr/0040-label-collision-avoidance.md)). +- **Labels that avoid other layers' labels.** The placer is opt-in and + panel-local, and a called-out label is placed beside it rather than through + it; routing callouts through the placer is ADR 0082's first "Revisit if". - **A keyframe timeline**, over more than two states. A sequence is a list of two-state transitions and building one is a host-side loop; an API that owned the sequence would be a real addition rather than sugar, and would be argued on the evidence of people writing that loop. -- **The rest of 3D.** The third axis and its camera are built - ([ADR 0056](docs/adr/0056-three-dimensional-charts.md), - [ADR 0057](docs/adr/0057-orbiting-a-chart.md)), and so is the spherical - scene that turns one projection into antenna patterns, Poincaré and Bloch - spheres, a stereonet and the Smith sphere - ([ADR 0058](docs/adr/0058-what-3d-is-for.md)). Nothing of the four records - is left. -- **A raster mark.** A heatmap is a recipe over `geom.Rect` and stops being one - at the size a measured field comes in: a spectrogram is two thousand frames - by five hundred bins, and one rectangle per cell is a million primitives - drawing cells smaller than a pixel. `ir.Backend.Image` is in the interface, - `stat.Grid.Raster` paints a grid into a reusable buffer and `stat.Lattice` - already resolves the table, so the mark is those three wired together — - reading the channels `geom.Contour` reads, so that a field and its own - isolines cannot disagree ([ADR 0066](docs/adr/0066-a-raster-mark.md)). -- **A horizon chart.** Every answer to scale here is an answer about rows; - this is the one about series. It folds the value range into bands drawn at - the panel's full height and tells them apart by colour, so forty sensors fit - on one screen at the resolution of one. The part that needed deciding is the - axis it gives up: after the fold the scale still describes one band honestly, - and *which* band is a classed colourbar rather than new furniture - ([ADR 0065](docs/adr/0065-horizon-charts.md)). -- **A bivariate colour channel.** `geom.ErrorBar` exists because a measurement - carries a claim about how well it is known and that half had nowhere to go; - the colour channel is where the omission misleads most, because a filled cell - reads as a measurement whether or not one was taken. One optional interface - beside `ColorScale` — the move `ClassedColorScale` already makes — gives a - value-suppressing uncertainty palette, a multi-class hexbin and the bivariate - square, the second of which is the missing half of the overplotting story: - decimation, the raster and the bin all report how many and none reports who - ([ADR 0067](docs/adr/0067-a-bivariate-colour-channel.md)). +- Harden the GPU tier as GoGPU matures. - A community plugin ecosystem. +Smaller additions that are recorded as not yet done rather than refused: a +horizontal colourbar under the plot, a PDF of more than one page, the combined +ZY overlay on a Smith chart, and Arrow's `float16`, decimal and extension +types. What is refused, and why, is listed in [AGENTS.md](AGENTS.md#scope). + --- ## 15. Versioning & stability diff --git a/breaks_test.go b/breaks_test.go index 16ac0ae..67d13c9 100644 --- a/breaks_test.go +++ b/breaks_test.go @@ -210,3 +210,34 @@ func TestABrokenFacetDrawsTheSameInParallel(t *testing.T) { t.Error("a broken facet drew differently on the parallel path") } } + +// A fortnight of a session calendar, nights and weekend folded: the axis has +// its dates at each morning's open rather than none at all (ADR 0086, claim 5). +func TestGoldenCalendarFortnight(t *testing.T) { + cal := scale.Workweek(time.UTC, + scale.Weekdays(time.Monday, time.Tuesday, time.Wednesday, time.Thursday, time.Friday), + scale.Span{From: 9*time.Hour + 30*time.Minute, To: 16 * time.Hour}) + from := time.Date(2026, 6, 1, 9, 30, 0, 0, time.UTC) + to := time.Date(2026, 6, 12, 16, 0, 0, 0, time.UTC) + folds, err := scale.Folds(cal, from, to) + if err != nil { + t.Fatal(err) + } + var ts []time.Time + var vs []float64 + opens, err := scale.Opens(cal, from, to) + if err != nil { + t.Fatal(err) + } + for i, o := range opens { + for k := 0; k <= 13; k++ { + ts = append(ts, o.Add(time.Duration(k)*30*time.Minute)) + vs = append(vs, float64(i)+float64(k%5)/4) + } + } + p := figure.New(figure.Size(420, 240)) + p.X(scale.Time(scale.TimeCalendar(cal), scale.TimeFold(folds...))) + p.Y(scale.Linear(scale.Nice())) + p.Add(geom.Line(figure.NewTable().Time("t", ts).Float64("v", vs), geom.X("t"), geom.Y("v"))) + golden(t, "calendar-fortnight", p) +} diff --git a/candles.go b/candles.go new file mode 100644 index 0000000..7e74cb7 --- /dev/null +++ b/candles.go @@ -0,0 +1,36 @@ +package figure + +import ( + "github.com/timzifer/figure/data" + "github.com/timzifer/figure/stat" +) + +// CandleTable is candles as the table [github.com/timzifer/figure/geom.Candle] +// reads: one row per candle, with the columns start, end, open, high, low, +// close, volume and count. +// +// days := stat.OHLC(ts, ps, vs, scale.Nanos(first), float64(24*time.Hour)) +// p.Add(geom.Candle(figure.CandleTable(days), geom.X("start"), geom.X2("end"), +// geom.OHLC("open", "high", "low", "close"))) +// +// The columns are the float64 values the candles were computed in. On a time +// axis those are [github.com/timzifer/figure/scale.Nanos], which is what a +// time scale without an [github.com/timzifer/figure/scale.Origin] reads. +// +// It lives here for the reason [SpansWhere] does: stat does not know a table, +// and data does not know a candle. +func CandleTable(cs []stat.Candle) *data.Table { + n := len(cs) + start, end := make([]float64, n), make([]float64, n) + o, h, l, c := make([]float64, n), make([]float64, n), make([]float64, n), make([]float64, n) + vol, count := make([]float64, n), make([]float64, n) + for i, k := range cs { + start[i], end[i] = k.Start, k.End + o[i], h[i], l[i], c[i] = k.Open, k.High, k.Low, k.Close + vol[i], count[i] = k.Volume, float64(k.Count) + } + return NewTable(). + Float64("start", start).Float64("end", end). + Float64("open", o).Float64("high", h).Float64("low", l).Float64("close", c). + Float64("volume", vol).Float64("count", count) +} diff --git a/candles_test.go b/candles_test.go new file mode 100644 index 0000000..cf9988d --- /dev/null +++ b/candles_test.go @@ -0,0 +1,60 @@ +package figure_test + +import ( + "math" + "testing" + "time" + + "github.com/timzifer/figure" + "github.com/timzifer/figure/data" + "github.com/timzifer/figure/geom" + "github.com/timzifer/figure/scale" + "github.com/timzifer/figure/stat" +) + +// fortnight is ten trading days of hourly prices, summarised into daily +// candles, with the weekend between them folded. +func fortnight(t *testing.T, opts ...geom.Option) *figure.Plot { + t.Helper() + first := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) + cal := scale.Workweek(time.UTC, scale.Weekdays(time.Monday, time.Tuesday, time.Wednesday, time.Thursday, time.Friday)) + var ts, ps []float64 + for d := range 12 { + day := first.AddDate(0, 0, d) + if len(cal.Open(nil, day)) == 0 { + continue + } + for h := range 8 { + x := float64(d*8 + h) + ts = append(ts, scale.Nanos(day.Add(time.Duration(9+h)*time.Hour))) + ps = append(ps, 100+8*math.Sin(x/9)+3*math.Sin(x*1.7)) + } + } + days := stat.OHLC(ts, ps, nil, scale.Nanos(first), float64(24*time.Hour)) + folds, err := scale.Folds(cal, first, first.AddDate(0, 0, 12)) + if err != nil { + t.Fatal(err) + } + p := figure.New(figure.Size(520, 300)) + p.X(scale.Time(scale.TimeCalendar(cal), scale.TimeFold(folds...))) + p.Y(scale.Linear(scale.Nice())) + p.Add(geom.Candle(figure.CandleTable(days), append([]geom.Option{ + geom.X("start"), geom.X2("end"), geom.OHLC("open", "high", "low", "close"), + }, opts...)...)) + return p +} + +func TestCandleTableCarriesEveryField(t *testing.T) { + tb := figure.CandleTable([]stat.Candle{{Start: 1, End: 2, Open: 3, High: 4, Low: 5, Close: 6, Volume: 7, Count: 8}}) + for col, want := range map[string]float64{"start": 1, "end": 2, "open": 3, "high": 4, "low": 5, "close": 6, "volume": 7, "count": 8} { + vs, ok := data.Float64Column(tb, col) + if !ok || len(vs) != 1 || vs[0] != want { + t.Errorf("column %s = %v, want [%v]", col, vs, want) + } + } +} + +func TestGoldenCandles(t *testing.T) { golden(t, "candles", fortnight(t)) } +func TestGoldenCandleTicks(t *testing.T) { + golden(t, "candles-ticks", fortnight(t, geom.CandleStyle(geom.Ticks))) +} diff --git a/data/stream.go b/data/stream.go index 6080b3c..c70b2a8 100644 --- a/data/stream.go +++ b/data/stream.go @@ -165,6 +165,44 @@ func (s *Stream) Append(vals ...float64) error { return nil } +// ErrEmptyStream reports a [Stream.ReplaceLast] on a stream with no rows. +var ErrEmptyStream = fmt.Errorf("figure/data: no row to replace") + +// ReplaceLast overwrites the most recent row with new values, positional in +// the order [NewStream] was given. It is the one revision a stream admits, +// because it is the one a live source makes: the newest row — a candle whose +// period has not closed, a reading still settling — is the only one still +// being measured. A row further back is history, and a chart whose past +// changed between two frames is one whose reader cannot trust what the first +// frame showed. A producer that revises older rows owns a table, not a stream. +// +// It is safe to call from any goroutine, under the lock [Stream.Append] +// takes, and allocates nothing. The row count does not change, so a window is +// unaffected. The new values reach the renderer on the next +// [Stream.Snapshot], as an appended row would. See +// docs/adr/0089-a-value-axis-fits-what-its-time-axis-shows.md. +func (s *Stream) ReplaceLast(vals ...float64) error { + if len(vals) != len(s.names) { + return fmt.Errorf("%w: %d values for %d columns", ErrColumnCount, len(vals), len(s.names)) + } + s.mu.Lock() + defer s.mu.Unlock() + if s.n == 0 { + return ErrEmptyStream + } + last := s.n - 1 + if s.limit > 0 { + // While a window fills, head is n mod limit; once it wraps, head is + // the slot the next row goes in. Either way the last row is the slot + // before it. + last = (s.head - 1 + s.limit) % s.limit + } + for i, v := range vals { + s.ring[i][last] = v + } + return nil +} + // AppendTime is [Stream.Append] with the first column given as a timestamp, // which is what the first column of a live chart almost always is. The // timestamp becomes its Unix nanoseconds, the domain a diff --git a/data/stream_test.go b/data/stream_test.go index 47be6da..d40deb4 100644 --- a/data/stream_test.go +++ b/data/stream_test.go @@ -273,3 +273,80 @@ func TestTheStreamAndItsViewAgreeAboutColumns(t *testing.T) { t.Errorf("the view holds %d rows", got) } } + +// The newest row is revised in place, unbounded, while a window fills, and +// once it has wrapped — the three places the last row can be. +func TestReplaceLastRevisesTheNewestRow(t *testing.T) { + for _, tc := range []struct { + name string + window int + rows int + want []float64 + }{ + {"unbounded", 0, 3, []float64{0, 1, 99}}, + {"filling", 5, 3, []float64{0, 1, 99}}, + {"wrapped", 4, 10, []float64{6, 7, 8, 99}}, + {"exactly full", 4, 4, []float64{0, 1, 2, 99}}, + } { + t.Run(tc.name, func(t *testing.T) { + s := data.NewStream("i").Window(tc.window) + for i := range tc.rows { + s.Append(float64(i)) + } + if err := s.ReplaceLast(99); err != nil { + t.Fatal(err) + } + if got := s.Len(); got != len(tc.want) { + t.Errorf("%d rows after a replace, want %d", got, len(tc.want)) + } + s.Snapshot() + col, _ := data.Float64Column(s.Source(), "i") + if !equal(col, tc.want) { + t.Errorf("rows = %v, want %v", col, tc.want) + } + // And the next row is appended after it, not over it. + s.Append(100) + s.Snapshot() + col, _ = data.Float64Column(s.Source(), "i") + if col[len(col)-1] != 100 || col[len(col)-2] != 99 { + t.Errorf("after a further append: %v", col) + } + }) + } +} + +func TestReplaceLastRefusesAnEmptyStreamAndAWrongRow(t *testing.T) { + s := data.NewStream("a", "b") + if err := s.ReplaceLast(1, 2); err != data.ErrEmptyStream { + t.Errorf("err = %v, want ErrEmptyStream", err) + } + s.Append(1, 2) + if err := s.ReplaceLast(1); err == nil { + t.Error("a row of the wrong length was accepted") + } +} + +// The producer may revise the open row while another goroutine appends; run +// under -race. +func TestReplaceLastIsSafeBesideAppend(t *testing.T) { + s := data.NewStream("y").Window(100) + s.Append(0) + var wg sync.WaitGroup + for g := range 4 { + wg.Add(1) + go func() { + defer wg.Done() + for i := range 200 { + if g%2 == 0 { + s.Append(float64(i)) + } else { + s.ReplaceLast(float64(-i)) + } + } + }() + } + wg.Wait() + if got := s.Len(); got != 100 { + t.Errorf("%d rows, want the window's 100", got) + } +} diff --git a/direction_test.go b/direction_test.go new file mode 100644 index 0000000..2107c3f --- /dev/null +++ b/direction_test.go @@ -0,0 +1,61 @@ +package figure_test + +import ( + "strings" + "testing" + + "github.com/timzifer/figure" + "github.com/timzifer/figure/geom" + "github.com/timzifer/figure/ir" + "github.com/timzifer/figure/theme" +) + +func candleDays(opts ...figure.Option) *figure.Plot { + src := figure.NewTable(). + Float64("t", []float64{0, 1, 2}). + Float64("o", []float64{10, 12, 13}). + Float64("h", []float64{13, 13, 14}). + Float64("l", []float64{9, 10, 12}). + Float64("c", []float64{12, 11, 13}). + Float64("v", []float64{5, 6, 7}) + p := figure.New(append([]figure.Option{figure.Size(500, 300)}, opts...)...) + p.Add(geom.Candle(src, geom.X("t"), geom.OHLC("o", "h", "l", "c"))) + p.Track(figure.Bottom, figure.TrackSize(60)). + Add(geom.Bar(src, geom.X("t"), geom.Y("v"), geom.DirectionBy("o", "c"))) + return p +} + +// A volume track under candles lists the directions once: the legend merges +// entries by label, and both layers take the same default labels. +func TestAVolumeTrackAddsNothingToTheCandlesLegend(t *testing.T) { + svg := renderString(t, candleDays(figure.Legend(true))) + for _, label := range []string{">rising<", ">falling<"} { + if n := strings.Count(svg, label); n != 1 { + t.Errorf("%s appears %d times in the legend, want once", label, n) + } + } +} + +// The direction's colours are the theme's, bound per render: a theme with +// other rising and falling colours paints the bars in them. +func TestADirectionTakesTheThemesColours(t *testing.T) { + th := theme.Light + th.Rising, th.Falling = ir.RGB(0x11, 0x22, 0x33), ir.RGB(0x44, 0x55, 0x66) + src := figure.NewTable(). + Float64("t", []float64{0, 1}).Float64("o", []float64{1, 5}). + Float64("c", []float64{2, 4}).Float64("v", []float64{3, 3}) + for _, tc := range []struct { + th theme.Theme + up, down string + }{ + {th, "#112233", "#445566"}, + {theme.Light, "#0072b2", "#d55e00"}, + } { + p := figure.New(figure.Size(300, 200), figure.Theme(tc.th)) + p.Add(geom.Bar(src, geom.X("t"), geom.Y("v"), geom.DirectionBy("o", "c"))) + svg := strings.ToLower(renderString(t, p)) + if !strings.Contains(svg, tc.up) || !strings.Contains(svg, tc.down) { + t.Errorf("the bars are not painted %s and %s", tc.up, tc.down) + } + } +} diff --git a/docs/adr/0053-tidy-tree-layout.md b/docs/adr/0053-tidy-tree-layout.md index e55f222..fde09ce 100644 --- a/docs/adr/0053-tidy-tree-layout.md +++ b/docs/adr/0053-tidy-tree-layout.md @@ -213,7 +213,8 @@ written no mark in `geom` had an orientation to borrow. `geom.Orient` is that option, with `geom.Vertical` and `geom.Horizontal`, and it is named for the library rather than for the tree so that the second mark to want it says it the same way — bars and boxplots still have none, and this is where that answer -will land. `geom.Tree` reads it by laying the tree out once and hanging it on +will land. (They have it now: see +[ADR 0087](0087-an-orientation-is-the-encoding-read-a-quarter-turn-round.md).) `geom.Tree` reads it by laying the tree out once and hanging it on the other pair of axes: `Value`, `Baseline` and `Branches` all mean what they meant, and the ordinal breadth and the continuous height swap which axis they are checked on. `examples/dendrogram` now draws the record's headline chart diff --git a/docs/adr/0084-what-v1-promises.md b/docs/adr/0084-what-v1-promises.md new file mode 100644 index 0000000..07e7c77 --- /dev/null +++ b/docs/adr/0084-what-v1-promises.md @@ -0,0 +1,367 @@ +# 0084 — v1.0 is tagged when a machine can say the surface did not move, and a point counts as one value + +**Status:** Proposed · **Date:** 2026-09-23 · **Revisits:** [0060](0060-parameter-structs-at-every-seam.md), the [v1 audit](../v1-api-audit.md) + +## Context + +The API was audited once, identifier by identifier, before the first attempt at +a freeze ([v1-api-audit.md](../v1-api-audit.md)). Every change that audit asked +for landed ([ADR 0029](0029-extension-model.md)), the library was renamed and +restarted at `v0.8.0` ([ADR 0059](0059-renaming-and-restarting-the-version.md)), +and the three seams that took positional scale arguments were widened into +parameter structs ([ADR 0060](0060-parameter-structs-at-every-seam.md)) so that +a third axis could arrive additively. It did: `figure/three` +([ADR 0056](0056-three-dimensional-charts.md)). The one question 0060 left open, +how `data.Source` grows, was settled by +[ADR 0061](0061-columns-are-one-value.md): a column is one value carrying a +`Kind`. + +CONCEPT §15 says `v1.0.0` returns "when the surface settles". Nothing says what +settled means, and three things make that worth writing down now rather than at +the tag. + +**The surface grew after the audit.** Twenty-seven records have been written +since 0056 — a third axis, a map, a coord with more axes than two, a dozen +marks, axis breaks and folds, callouts, hatching, a bivariate channel — and +none of them was read against the audit's verdicts. The core module now +exports about a thousand declarations (`go doc -short`, summed over the public +packages: `geom` 226, `scale` 137, `stat` 128, `.` 94, `coord` 73, `three` 68). + +**The growth rule is stated and not checked.** 0060 wrote it as enforceable: +an interface a third party implements never gains a method, and a method on one +takes no more than one parameter beyond its output destination. Scanning every +exported interface in the core against that sentence finds it broken in places +nobody decided to break it: + +| Interface | Method | Parameters beyond the destination | +|---|---|---| +| `stat.Family` | `Locus(xs, ys []float64, level float64, ext Extent)` | `level`, `ext` | +| `coord.Exploder` | `Explode(x0, y0, x1, y1 float32, by float64)` | an extent and `by` | +| `geom.LabelPlacer` | `PlaceLabel(run ir.TextRun, move bool)` | `run`, `move` | +| `scale.Breaker` | `SetBreakGap(brk, fold float32)` | `brk`, `fold` | + +and in places where the literal count is wrong but the design is right: +`coord.Coord.Points(dst, xs, ys)`, `Edge(p, from, to)` and +`Area(p, x0, y0, x1, y1)`; `scale.Scale.SetRange(lo, hi)`, +`scale.Zoomer.SetDomain(lo, hi)`; `scale.BivariateColorScale.ColorAt(v, u)`. +Those take a point, a pair of points, an interval, a rectangle, a pair of +columns or a bivariate reading — values whose arity is fixed by what they are, +the way 0060 argued the tuple-returning coord calls are fixed. `ColorAt` is the +clearest case: it is `ColorScale.Color(v)` for a mark that carries a second +reading, called once per row, and two readings are the definition of the +interface rather than a count that could grow — +[ADR 0067](0067-a-bivariate-colour-channel.md) refuses a third, because three +readings have no key a reader can decode. A rule that a test cannot apply without +a judgement per method is a rule that erodes one reasonable exception at a +time. + +**Nothing would notice the surface moving.** The golden files catch a chart +that changes; nothing catches an exported name that disappears, a signature +that changes shape, or a JSON field whose meaning drifts. Before v1 that is a +review burden; after it, it is a broken promise that ships. + +## Decision + +**v1.0.0 is tagged when the checks below are green, a second audit has no open +CHANGE BEFORE V1 row, and the release tooling has been unlocked for it — not +when the surface feels settled.** Every check is +stdlib-only, because the core's one rule applies to its tools as much as to its +code. + +### 1. The growth rule counts a value of fixed arity as one parameter + +The rule in CONCEPT §15 is amended by one sentence: **a value whose arity is +fixed by what it is counts as one parameter** — a point, a pair of points, an +interval, a rectangle, a pair of parallel columns, a bivariate reading — because +its parts are the value's rather than a description that can grow. Everything +else counts as what it is. `ir.Backend`'s drawing calls +remain the exception the rule already names. + +Under that reading `Points`, `Edge`, `Area`, `SetRange`, `SetDomain` and +`ColorAt` comply, and the four methods in the table above do not. Each is +changed before v1 to take a request struct — `stat.LocusRequest`, +`coord.ExplodeRequest`, `geom.LabelRequest`, `scale.BreakGaps` — named in the +style 0060 set (`FurnitureRequest`, `TickRequest`). All four are small: one +implementation or a handful in this repository, and the callers are `render` +and `geom`. + +### 2. A test applies the rule + +A test in the root package parses the public packages with `go/parser`, finds +every exported interface, counts each method's parameters beyond a leading +destination, and fails on any method with more than one it cannot account for. + +Syntax cannot tell a fixed-arity value from a description: `SetRange(lo, hi +float32)` and `SetBreakGap(brk, fold float32)` are the same shape to a parser. +So the exceptions are an **allowlist of methods by name**, each with its reason +beside it — `ir.Backend`'s drawing calls, and the values §1 names — and §1 is +the test for whether a method may be added to it. An entry is a visible line in +a diff rather than a silence. + +The test reads **every** exported interface, not only the ones §15 lists as +implemented by third parties. An optional interface such as `scale.Breaker` or +`geom.LabelPlacer` is asserted rather than required, but anything exported can +be implemented outside the module, and the day somebody does is not a day the +signature can still change. A new interface that breaks the rule fails CI the +day it is written, which is the only day it is free to fix. + +**A callback is a method by another name, and the test reads those too.** An +exported function type, and an exported struct field of function type, is a +signature somebody outside the module writes a body for — `backend/window`'s +`Handler` is a struct of eight of them. The same count applies to their +parameters. And the test runs over **every module that tags `v1`**, not only +the core: `backend/gg` and `backend/window` freeze with it (§15), so their +seams are held to the same rule. + +### 3. The exported surface has a manifest, and CI checks it twice + +`internal/cmd/apicheck` writes one line per exported declaration of every +public package in each module — the shape of Go's own `api/*.txt` — into +`api/.txt`. A function, a type, a method, **each method of an exported +interface, each exported field of a struct and the value of each constant** is a +line of its own, because each is something a caller can depend on separately. +It is a command of its own, beside `releasecheck`, rather than +`golang.org/x/exp/apidiff`: that would be the first dependency of the core's +tooling. It reads the code with `go/parser` and `go/types`, both standard +library. + +**A struct's comparability is a line too.** A struct that `==` accepts can be a +map key and can be compared by a caller, and adding a field of slice, map or +func type takes both away without changing what a nil field does. `ir.Surface` +is three numbers today and is exactly that case. So the manifest records, per +exported struct, whether it is comparable, as its own line; a field that +changes it changes that line, and a changed line is refused below. + +**The manifest covers every build configuration the module supports, not the +one it was generated on.** `backend/canvas` exists only under `js && wasm`, and +so does `Live.Bind` in the root package; a manifest written on a desktop would +leave that surface out and nothing would protect it. `apicheck` therefore +walks a named list of configurations — the default one, and `GOOS=js +GOARCH=wasm` — and writes the union, each line marked with the configurations +it exists under. A public file carrying a build constraint that no listed +configuration satisfies fails the check, so a new platform-specific surface is +either added to the list or noticed. A line's configurations are compared as a +set: a declaration that becomes available on **more** configurations is an +addition, and one that disappears from a configuration it had is a removal. + +It is checked twice, because one comparison cannot do both jobs: + +1. **The manifest matches the code.** Regenerated on every CI run and compared + with the committed file, before v1 and after. This is what makes a change to + the surface a change to a committed file, which a reviewer reads. It cannot + refuse anything, because a change that updates the code and the file together + passes it by construction. +2. **The code is compatible with what was published.** The reference is the + manifest at the **latest release tag of the same major version that is an + ancestor of the commit being checked and is not on that commit**, read with + `git show :api/.txt` — a file no commit on the branch can edit. + Since the reference moves with every release, a declaration added in v1.3 is + protected from v1.3 on exactly as one from v1.0 is. `releasecheck` runs the + comparison for the tag it is about to create, and CI runs it again on the + tagged commit afterwards; in both, the tag under test is excluded, so a + release is never its own reference. **`v1.0.0` has no reference**: the + check is inactive until the first `v1` tag exists, and what stands in for it + at that one release is the second audit (§5) and the freshly committed + manifest it walked. + +The second check sorts each difference into one of three kinds: + +- **A removed or changed line is refused** — including a struct's + comparability line and a constant's value. That is a major version. +- **A method added to an interface the reference already contains is + refused**, although it is an added line. Go has no default methods, so every + implementation outside the module stops compiling whatever the method does; a + new capability arrives as an optional interface beside it, as §15 already + says. A **new** interface is an addition, methods and all — that is what an + optional interface is. +- **Anything else added passes** — a function, a type, a constant, a field. A + new field is where the check stops and the reviewer starts: it is compatible + only if its zero value leaves the behaviour as it was, which is §15's rule for + a struct and a question no text comparison can answer. + +**A struct literal written without field names is not covered by the +promise**, and §15 says so. A positional literal of `ir.Surface` stops compiling +the day a field is added, and no additive rule can avoid that; Go's own +compatibility document draws the same line, and `go vet`'s composites check +already warns about such a literal of another package's type. The first audit's +rule 3 — "a struct with exported fields can gain fields" — was stated without +this condition or the comparability one, and the second audit corrects it +there rather than here. + +### 4. The JSON dialect has a corpus that is never edited + +`spec/testdata/v1/` holds one document per mark, per coord, per positional +scale kind and per colour and size scale kind, written by the code at the tag. +A test parses each, checks that it marshals back to an equivalent document, and +checks that the chart it builds describes itself as the document says — +`geom.Desc`, `scale.Desc` and `coord.Desc` are what the comparison reads. After +v1 **the documents are only ever added to**: one that stops reading, or reads as +a different chart, is a broken promise that the dialect's comment in +`spec.Schema` makes and that nothing tests today. + +What the corpus does not pin is the picture. Each document may also be rendered +against a golden file, and that file is regenerated with `-update` like any +other, because v1 does not promise pixels (below). The document is never +regenerated; that is the whole difference between the two. + +### 5. A second audit, recorded beside the first + +The audit is repeated over what arrived since it was taken, with the same three +verdicts and the same five rules, and its findings are appended to +[v1-api-audit.md](../v1-api-audit.md) as a dated section rather than a second +document — the two together are the reasoning behind one surface. It walks +the `api/` manifest rather than `go doc`, so that nothing it reads can be +missing from what CI later checks. Three questions it has to answer, because the +first audit could not ask them: + +- **`figure/three` freezes with the core.** It is a package of the core module, + and Go has no way to tag one package differently from its module. It was + built after 0060 and follows the rule from birth; the audit confirms that + rather than assumes it. +- **`stat` exports what a third party calls, and nothing only a geom calls.** It + has grown with every instrument and layout since the first audit, and a + helper exported so that `geom` could reach it across a package boundary is a + declaration frozen for nobody. Those move under `internal/`. +- **Every registry reaches every kind.** 0029 made `FromDesc` a registry lookup; + the audit checks that every coord, scale and mark added since round-trips + through `spec` and is reachable by name — a kind that only its constructor can + build is a chart that cannot be written down. + +### 6. The release tooling is unlocked on purpose, as its own step + +`internal/release` refuses a `v1` tag today: the three modules that tag with the +core carry `Major: 0` in `release.Modules`, and `CheckVersion` rejects any other +major. [ADR 0059](0059-renaming-and-restarting-the-version.md) set that lock so +that nothing could publish a `v1` before the freeze it promises. Lifting it is +therefore not a side effect of the tag but the step that says the freeze is +done: `Major: 1` for the core, `backend/gg` and `backend/window`, in a commit of +its own after every check above is green, with `releasecheck` run against the +`v1.0.0` tags it would create before any is pushed. A major version of 1 needs +no suffix on the module path, so no import changes. + +### 7. Changes already known before the second audit starts + +A review of this record found six places where the surface as it stands would +be frozen into something the library would regret. Each is a CHANGE BEFORE V1 +row of the second audit, recorded here so that the audit starts from them +rather than rediscovering them. + +- **A parallel coord drops a dimension it cannot describe.** + `parallel.Describe` leaves a dimension's scale description empty when the + scale implements no `scale.Describer`, and the document reads it back as a + linear scale: a log dimension goes in and a linear one comes out, with no + error. Every other path in `spec` fails rather than guesses. The fix is in + `spec` and needs no signature change: encoding a coord that implements + `coord.Dimensions` describes each dimension's scale itself and fails on the + first that cannot say what it is. +- **A size scale always reads back as the built-in one.** `scale.SizeDesc` + carries no kind and `SizeFromDesc` builds `scale.Size` whatever it is handed, + so a third-party size scale with a linear mapping comes back area-proportional + — 25 maps to 50 — without an error. Before v1 the contract is made explicit + and narrow: `spec` refuses to encode a size scale that is not the built-in + one. A `SizeKind` field whose zero value names the built-in scale, and a + `scale.RegisterSize` beside `Register` and `RegisterColor`, are additive + afterwards and wait for a second size scale somebody actually has. +- **A window's callbacks take positions as positional arguments.** + `Handler.Press(x, y)` and `Handler.Scroll(x, y, delta)` have no room for a + modifier key, a second button or a horizontal wheel, and after v1 each would + be a parallel callback field or a major version. They take event structs — + one for the pointer, one for the wheel — for 0060's reason. `Resize(w, h)` + and `Rescale(dpr)` stay: a size and a ratio are values of fixed arity. +- **An extension's name can collide with a later built-in one.** + `geom.Register`, `scale.Register`, `scale.RegisterColor` and `coord.Register` + accept any name that is not built in, and panic on one that is — so a mark an + extension registered as `"foo"` stops the program the release figure ships a + mark of that name, although that release only added. Before v1 the namespace + is split: a built-in name never contains a dot, and an extension's name must — + `"example.com/foo"` or `"acme.foo"` — and `Register` panics on one that does + not. No built-in name has a dot today, and the repository's own tests already + register theirs as `"test.lollipop"` and `"test.squared"`. Restricting the + names after v1 would itself be the incompatible change. + The audit applies the same question to `theme.Register`, + `palette.RegisterRamp` and `mathtext.RegisterSymbol`. +- **A tuning budget is not an API.** `stat.StressSweeps`, + `stat.StressPowerIterations`, `stat.LayeredSweeps`, `stat.SankeySweeps` and + `geom.VennSteps` are exported constants, so §3 would freeze their values and + a better iteration count would need a major version — although v1 explicitly + does not freeze output. They become unexported. The test the audit applies to + every exported constant is whether its value is a fact of a format or a + contract (`stat.MaxSets` is the width of a bitmask, `MaxVennSets` is the + Venn mark's promise) or a knob; knobs are unexported. `stat` keeps the + algorithms themselves — LTTB, the KDE, Kaplan–Meier — which are exported + because they are useful, not because a geom calls them. +- **Comparability is decided per type, not inherited.** `ir.Surface`, + `scale.TickRequest`, `mathtext.Request` and `scale.SizeDesc` are comparable + today, and §3 would make that permanent. For a value — `ir.Point`, + `ir.Rect`, `ir.Surface` — that is the right promise. For a request that + exists to grow — `TickRequest`, `coord.FurnitureRequest`, the `render` info + structs — it forbids the slice or func field it may one day need. Each is + decided before v1, and a request type that should stay free to grow is made + non-comparable now with a leading `_ [0]func()` field, which costs no space + and is the one change that cannot be made later. + +### Order of work + +1. This record, the amendment to CONCEPT §15, and the sentence on unkeyed + struct literals. +2. The growth-rule test, failing on the four methods and on `Handler`; the four + request structs and the two event structs, making it pass. +3. `apicheck` — every configuration, comparability, both checks — and the + committed manifest. +4. The second audit, working from the manifest and starting from §7; its + CHANGE BEFORE V1 rows, each in its own commit. +5. The JSON corpus, written after the audit so that it records the dialect the + audit left. +6. The release tooling moved to `Major: 1` (§6), in its own commit. +7. `releasecheck` over every module, the status lines in README and CONCEPT, and + the tag — `v1.0.0` for the core, `backend/gg` and `backend/window` together, + as §15 already says. + +## Consequences + +- Four interfaces change shape once more. Each is implemented outside the + repository by approximately nobody, which is the argument for doing it now: + after the tag, each would be a major version or a second method beside the + first. +- A change to the exported surface touches a committed file. That is friction + on purpose; it is the same friction a golden file puts on a changed chart. +- The rule's exception for fixed-arity values is written down, so the next + `Area`-shaped method needs no argument — and a method that is *not* one of + those values cannot borrow the exception by resembling one, because the test + names the methods it excuses. +- A struct that is comparable today stays comparable for as long as v1 lasts, + or the change waits for v2. That is a real constraint on `ir.Surface`, + `ir.Point` and the like, and it is the one a caller using them as map keys is + relying on. +- An extension registered under an undotted name stops working. Nothing + outside this repository is known to register one, which is why it is done + now. +- The zero-value rule for a new field stays a human judgement. The check makes + every new field a visible line; it cannot make the reviewer read it. + +## What this does not do + +- **It adds no feature.** The open items in CONCEPT §14 — a mark that fills a + ring, a conic projection, labels that avoid other layers', a keyframe + timeline — are additive and land in v1.x. +- **It does not touch `ir.Backend`.** Its drawing calls take the ink + ([ADR 0002](0002-ir-and-backend.md)). +- **It does not reopen `data.Source`.** [ADR 0061](0061-columns-are-one-value.md) + settled it. +- **It does not move `backend/gg/gpu` off `v0.x`.** The GPU tier is opt-in beta + ([ADR 0022](0022-gpu-tier.md)). +- **It does not freeze the Go floor.** The `go` line in each `go.mod` follows + [ADR 0005](0005-go-version.md) and may rise in a minor release, as it does in + the Go project's own modules: it is a statement about the toolchain rather + than about the API, and the promise here is to programs that build. +- **It does not promise output.** The golden files pin what a chart looks like + within a release; v1 promises that a program compiles and a document reads, + not that a later minor draws every pixel where an earlier one did. + +## Revisit if + +- The growth-rule test wants a third kind of exception. Two — the ink and a + value of fixed arity — are a rule; three is a list, and the rule should be + restated rather than extended. +- The manifest diff is routinely committed without being read. Then the check + is a formality, and the answer is to make the pre-v1 diff fail too. diff --git a/docs/adr/0085-what-a-market-chart-needs.md b/docs/adr/0085-what-a-market-chart-needs.md new file mode 100644 index 0000000..8287502 --- /dev/null +++ b/docs/adr/0085-what-a-market-chart-needs.md @@ -0,0 +1,257 @@ +# 0085 — A market chart is a recipe over marks that exist, and what it lacks is a calendar, a candle and statistics that only look back + +**Status:** Proposed · **Date:** 2026-09-28 · **Implemented in part:** the easy wins in the order of work, with `examples/market` + +## Context + +[docs/chart-types.md](../chart-types.md) has listed "Candlestick / OHLC" as a +recipe since `geom.Rect` shipped: *`Rect` for open..close, a rule for +low..high, colour by sign*. [ADR 0083](0083-an-axis-break-is-marked-or-not-drawn.md) +named the other half of a price chart in its own comment — `scale.TimeFold` +is for "the nights of a trading week" — and then deferred it: *"Revisit if +someone needs a business-day time axis, which is claim 6's machinery plus a +calendar."* Someone does. + +Nobody had assembled the parts. No example drew a candle, no test put a +volume under a price or a profile beside one, and the question a user of a +trading screen asks first — *can this library draw my chart* — had no answer +better than "probably, from pieces". A trading screen is also a harder reader +than most: it reads the right-hand edge of a live chart and acts on it, so a +line that moves when the next row arrives is not a smoothing choice but a +wrong answer. + +This record is a catalogue, in the manner of +[ADR 0058](0058-what-3d-is-for.md): what a market chart is made of, which +parts exist, which are a recipe, which are missing, and the order the missing +ones are worth building in. Each mechanism it ranks gets a record of its own +before it gets code — the rule in [the index](README.md) for decisions opened +after v1.0 — except the handful at the bottom of the order of work that are +additive, follow a pattern already in the package, and open no seam. Those +landed with this record. + +## Decision + +**A market chart is composed from marks that exist. What it cannot compose is +a calendar, a candle as one mark, and indicators that look only backwards; and +there is still no Stat interface.** Six parts, each with what exists, what is +a recipe and what is missing. + +### 1. The candle + +**Exists.** `geom.Rect` for the body — `Y("open")`, `Y2("close")` — which is +the reason its doc comment names a candle; `geom.ErrorBar` with `Caps(false)` +for the wick, `Y("low")`, `Y2("high")`. Both take `BarWidth`, both take +`ColorBy`, and an ErrorBar is drawn under its Rect by being added first. +`examples/market` draws it. + +**The recipe's cost.** The colour is a column the caller computes, because a +`data.Table` has no derived columns and a mark cannot read two columns to +decide a third. `stat.Candle.Up` makes the computation one call, not zero. + +**Missing.** + +- **A candle as one mark.** Four value columns — open, high, low, close — do + not fit an X/Y/Y2 vocabulary, which is why the recipe needs two layers and a + direction column. A `geom.Candle(src, X, Open, High, Low, Close)` mark would + read the four, decide the direction itself, and be one legend entry rather + than two. It is a new mark and a new dialect word, so it is a record of its + own; the recipe is the proof that it composes. +- **The OHLC bar** — a rule with a tick left at the open and right at the + close. Not a recipe today: no mark draws a tick off one side. +- **Hollow up, filled down** — the monochrome convention, which is + [ADR 0069](0069-hatching-as-the-third-redundant-channel.md)'s question about + redundant channels asked of a fill. +- **A data-driven rule.** `geom.Segment` takes four literals; the wick borrows + ErrorBar because nothing else draws a rule per row. The lollipop in + chart-types.md is the same gap. + +### 2. The trading calendar + +**Exists.** `scale.TimeFold(spans...)` leaves each span out of a time axis and +marks it as a fold, and `figure.SpansWhere` / `SpansFunc` read the spans from a +table. A weekend, a holiday or a night is a span, so a trading calendar is +already *drawable*: `examples/market` folds twelve weekends it computes itself. + +**Missing.** The calendar. Every span is passed explicitly, so an intraday +chart over a year is five hundred folds the caller generates, a market's +holidays are a list the caller keeps, and a session that closes at 16:00 in +New York is arithmetic in a time zone the caller gets right. A calendar is a +value — sessions per weekday, holidays, a location — that produces the spans +for an interval; it is claim 6 of ADR 0083 plus exactly that, as the record +said. Folds on a log axis stay out of scope, for 0083's reason. + +This is rank 1 below. A candle chart with the weekends drawn empty is a chart +of the calendar, not of the market. + +### 3. The combined panels + +**Exists, and it is the part that needed nothing.** `Plot.Track` +([ADR 0031](0031-tracks.md)) is a band at an edge that shares the scale object +of the axis it runs along: + +- **Volume under the price** is `Track(figure.Bottom, …, TrackScale(scale.Linear()))` + holding a `geom.Bar`. It shares the time axis, so a zoom on a live chart is + one zoom. Further indicator panels — an RSI, a MACD — are further bottom + tracks. +- **The volume profile, or an order book, beside the price** is + `Track(figure.Right, …)`, which shares the price axis. The buys and the sells + at each price are a horizontal `Bar` grouped by side and stacked across, + from `stat.BinWeighted` over one interval. + +**Missing.** + +- **A bar on its side.** `geom.Orient` was read by the tree and by the + gradient, not by `geom.Bar`, so the profile's bars were rects with a + hand-stacked `X`/`X2`. — **Landed since**, as + [ADR 0087](0087-an-orientation-is-the-encoding-read-a-quarter-turn-round.md): + the profile is now a stacked horizontal `Bar`. +- **Tracks the reader resizes.** A track's size is the caller's; dragging the + boundary between price and volume is a host concern today. +- **`Grid` panels that are live and linked.** `grid.go` says tracks are the + answer, and for one symbol they are. For several symbols compared, a track + per facet is refused (`ErrTrackWithFacet`); see *Revisit if*. + +### 4. Indicators, and the interface for them + +**There is no Stat interface, and this record does not add one.** +`stat/stat.go` says why — a pluggable stat would have to know which axis it +decides, how the layer treats a missing value and what the theme wants — and +[ADR 0028](0028-distribution-stats.md) and +[ADR 0029](0029-extension-model.md) record it. An indicator fits that rule +better than most summaries: it is numbers in and numbers out, a column of +prices becoming a column of values at the same rows. So the three ways in are +the three that already exist: + +1. **A function in `stat`**, with its Append pair, that the caller calls and + draws the result of with an ordinary mark — a `Line`, an `Area` between two + columns, a `Bar` in a track. This is the way for everything below. +2. **A geom of the caller's own**, registered with `geom.Register`, that calls + such a function in its `Train` — for an indicator that must retrain with the + data, as `geom.Trend` retrains its fit. +3. **An overlay**, `render.Overlay`, for what is drawn over the chart rather + than from its data: a crosshair, a measured range. + +**Exists.** `MovingAverage` — centred, so wrong for this reader, for the +reason in the context — and `SavitzkyGolay`, `Loess`, `StdDev`, the SPC +limits, `LTTB` and `MinMax` decimation. + +**Missing, and landed with this record.** The trailing forms: +`TrailingMean`, `EMA`, `RollingStdDev` (population, as a Bollinger band is +defined), `RollingMin` / `RollingMax` (a Donchian channel), and `Cumsum` (an +order book's depth). Each value is a function of its row and the rows before +it, so appending a row changes nothing already drawn; that property has a test. +A Bollinger band is `TrailingMean ± k · RollingStdDev`, drawn as an `Area` +between `Y` and `Y2` — three lines of the caller's, not a function. + +**Missing, and landed with this record.** Resampling: `stat.OHLC` turns a +column of ticks into candles of a fixed width from an origin, and leaves an +interval nobody traded in without a candle rather than inventing one. + +**Missing, and not landed.** RSI, MACD, VWAP, returns. Each is a composition of +the functions above — MACD is two EMAs and a third over their difference, VWAP +is two cumulative sums divided — and whether they earn names of their own is +a question for the record that ships the candle mark, when a user of the +dialect can say which ones a document needs to name. + +### 5. Annotations + +**Exists.** `HLine`, `HBand`, `Region`, `Note`, and `Text` with callouts +([ADR 0082](0082-a-label-fits-its-box-or-is-called-out.md)). A buy and a sell +are two `Scatter` layers. + +**Landed with this record.** `ir.MarkerTriangleDown` — `"triangle-down"` in +the dialect, Vega-Lite's own name — appended to the marker set as its doc +comment requires, so a sell points the way a sell goes. It is not added to +`theme.DefaultSeriesMarkers`: the redundant-encoding ladder is a separate +decision and every golden file draws it. + +**Missing.** A last-price label pinned to the axis at the right edge, which is +an `HLine` with a label in the gutter rather than in the panel — a small mark, +but it draws outside the panel, which no mark does today. + +### 6. Interaction + +**Exists.** `Live` with wheel zoom, pan, `View`, `Select`; the `Crosshair`, +`Tooltip`, `Highlight` and `Brush` overlays; `data.Stream` with a window. + +**Missing.** + +- **Zoom on time, autoscale on price.** A trading screen zooms the time axis + and fits the price axis to the candles that remain visible; `Live` zooms + what it is told and autoscales everything or nothing. +- **Updating the last row.** A live candle changes until its interval closes; + `data.Stream` appends and never replaces. +- **A crosshair that snaps to the candle** rather than following the pointer + between them. + +## Ranking + +By what a reader loses without it, as ADR 0058 ranks: + +1. **The trading calendar.** Without it the chart is of the calendar. +2. **The candle mark.** Without it every chart is two layers, a direction + column and two legend entries for one thing. +3. **Zoom on time, autoscale on price, and replacing the last row** — the two + halves of a live chart. +4. **A bar on its side.** — **Landed**, as ADR 0087. +5. **Named indicators** (RSI, MACD, VWAP), the last-price label, the snapping + crosshair, the OHLC bar. + +## Order of work + +1. **The easy wins.** — **Landed with this record.** `stat.TrailingMean`, + `EMA`, `RollingStdDev`, `RollingMin`, `RollingMax`, `Cumsum`, `OHLC` and + `BinWeighted`, each with its Append pair and allocation-free into a warm + slice; `ir.MarkerTriangleDown`; and `examples/market`, which draws every + part of section 3 from the recipes above, so that each gap in this record is + a line of that example rather than a claim. +2. **A trading calendar**, as a record extending ADR 0083. — **Landed** as + [ADR 0086](0086-a-calendar-says-when-time-counts.md), and general rather + than a market's: a market is one calendar among working weeks, rosters and + terms. `examples/market` folds from one. +3. **`geom.Candle`**, as a record: its columns, its dialect word, its legend, + and whether the OHLC bar is an option on it or a mark beside it. — + **Landed** as + [ADR 0088](0088-a-candle-is-one-mark-that-reads-four-values.md): one option + for the four columns, and the OHLC bar a style of the same mark. +4. **Price autoscale and a replaceable last row**, as one record, because both + are what a live market chart is and neither is useful alone. — **Landed** + as [ADR 0089](0089-a-value-axis-fits-what-its-time-axis-shows.md); + `examples/market -live` draws it. +5. The rest, as asked for. — **Landed** as + [ADR 0092](0092-what-a-trading-screen-reads-off-its-edges.md): axis tags + for the last price and the crosshair readout, a snapping crosshair, and RSI + and VWAP as functions. + +## Consequences + +- A market chart can be drawn today, and the example is the documentation of + how. +- The indicator functions look only backwards and are therefore safe on the + right-hand edge of a live chart; `MovingAverage` stays centred and says so. +- `stat` gains two types, `Candle` and `WeightedBucket`. Neither widens an + existing type: `Bucket.Count` stays an integer count of observations. +- The marker set is one longer, at the end. A backend that draws markers + through `ir.MarkerPath` draws the new one without change. + +## Not in scope + +- **A Stat interface, or a transform stage between data and layer.** For the + reason `stat/stat.go` gives, which an indicator does not weaken. +- **Data feeds, brokers, order entry.** The library draws a chart of a + market; it does not connect to one. +- **An indicator language** — Pine, or formulas in the dialect. A document + holds the columns an indicator produced, not the program. +- **Point-and-figure, Renko, Kagi.** Each is a chart whose X is not time, + built from bricks of price; each is a recipe over `Rect` on an ordinal X + once the bricks are computed, and none has been asked for. + +## Revisit if + +- An indicator needs state across frames that a function of the column cannot + carry — an adaptive average whose parameter is tuned live. +- Several symbols are compared on one screen, each with its volume, which is a + track per facet and today is `ErrTrackWithFacet`. +- A float64 of nanoseconds loses the sub-microsecond resolution a tick chart + of a fast market needs; `scale.Origin` is the answer for the axis, and + `stat.OHLC`'s origin for the candles. diff --git a/docs/adr/0086-a-calendar-says-when-time-counts.md b/docs/adr/0086-a-calendar-says-when-time-counts.md new file mode 100644 index 0000000..113ef59 --- /dev/null +++ b/docs/adr/0086-a-calendar-says-when-time-counts.md @@ -0,0 +1,364 @@ +# 0086 — A calendar says when time counts; the axis takes the rest as folds and starts its weeks where the calendar does + +**Status:** Accepted, amended · **Date:** 2026-09-28 · **Implemented:** 2026-09-28 · **Revisits:** [ADR 0083](0083-an-axis-break-is-marked-or-not-drawn.md)'s business-day deferral · **Ranked by:** [ADR 0085](0085-what-a-market-chart-needs.md), rank 1 + +## Context + +[ADR 0083](0083-an-axis-break-is-marked-or-not-drawn.md) gave a time axis +folds and left the calendar out of scope: *"Recurring breaks as a rule — every +weekend, market hours, a business-day axis. That is a calendar rather than an +interval list."* [ADR 0085](0085-what-a-market-chart-needs.md) ranked it first +among the gaps of a market chart. But a market is only the loudest customer. +The same question — *which stretches of time count, and which are left out* — +is asked by: + +- a factory running Monday to Saturday in three shifts, with a maintenance + window every second Sunday; +- an office in Tel Aviv or Riyadh, whose working week is Sunday to Thursday; +- a hospital ward that never closes, and a school that closes for its terms; +- a support desk on 09:00–17:00 in three time zones; +- a machine log whose idle nights are read out of a table + (`figure.SpansWhere`, 0083 §6); +- and an exchange, with sessions, holidays and early closes. + +A design that fits the exchange and asks the others to pretend to be one fails +most of them. "Weekdays" means Monday to Friday in one country and Monday to +Saturday in another; a week starts on Monday under ISO 8601 and in most of +Europe, on Sunday in the United States, Canada, Japan and Israel, and on +Saturday in much of the Middle East. And some calendars are not a rule at all: +a shift plan is a roster, a school year is a list of terms, a holiday falls on +a date only another calendar system can compute. + +Two things in the code make it more than a list of spans. + +- **The labels.** 0083 §5 walks one tick step through each kept piece and + forbids a tick strictly inside a cut. With the nights folded, every day tick + is at midnight, and midnight is inside a fold: the axis is correct and has + no dates on it. +- **The week.** A week tick today is `Truncate(7 * 24h)` in the scale's + zone (`scale/time.go`, `walk`). Go truncates from its zero time, 1 January + of year 1, which was a Monday — so every weekly axis in the library starts + its weeks on a Monday, in every locale, by an accident of the standard + library rather than by a decision. It is the ISO answer, and it cannot be + changed: an American or an Israeli week has no way to start on a Sunday. + A calendar makes the week step the common case — twelve weeks of daily data + is exactly where `pick` chooses it — so the accident becomes visible. + +## Decision + +**A calendar is an interface with one question — when is this day open — and +it is composed from rules about days that the library ships. Its closed +time becomes the axis's folds, its week start becomes the axis's weeks, and a +tick that lands in a fold moves to where time next counts.** Seven claims. + +### 1. A calendar is an interface, and anyone can write one + +```go +// A Calendar says when time counts. +type Calendar interface { + // Location is the zone the calendar's days and hours are in. + Location() *time.Location + // WeekStart is the day a week begins on. + WeekStart() time.Weekday + // Open appends the stretches of the day that count, as offsets from its + // local midnight, ascending and inside [0, 24h]. day is that midnight, + // in Location. A closed day appends nothing. + Open(dst []Span, day time.Time) []Span +} + +type Span struct{ From, To time.Duration } +``` + +It lives in `scale`, beside `TimeSpan`, because what it produces is a time +axis's folds and a time axis's weeks, and `scale` already owns both. + +The question is *per day* because that is the one granularity every calendar +answers without translation: a roster says who works on the 14th, a term list +says whether the 14th is in term, a lunar holiday is a date, a rule says what +Tuesdays look like. A calendar asked instead for "every closed span between two +instants" would make each implementation redo the walk over days, the time +zone arithmetic and the merging — which are the parts that are easy to get +wrong and identical everywhere. The day is given as its local midnight in the +calendar's own location, so an implementation never adds 24 hours to anything. + +A stretch that runs past midnight — a night shift from 22:00 to 06:00 — is two +answers, 22:00–24:00 on one day and 00:00–06:00 on the next, and the walk +joins them. That keeps the contract to one day without making overnight +calendars second-class. + +`Open` takes a destination slice for the reason every Append form in `stat` +does: a live chart refolds, and refolding must not allocate per day. + +### 2. A calendar is composed from rules about days, and the library ships the rules + +The calendars a caller writes are almost all one of two shapes: *which days +are open*, with the same hours on each, or *which hours each weekday has*. So +those are the two constructors, and the question of which days is a function +the caller composes rather than a builder the library anticipates: + +```go +// A rule says whether a date is open. day is its local midnight. +type DayRule func(day time.Time) bool + +// Open on the days the rule accepts, for the given stretches of each — all +// day when none are given. +func Workweek(loc *time.Location, open DayRule, hours ...Span) Calendar + +// Each weekday named with its own stretches; a weekday not named is closed. +func Days(loc *time.Location, days ...Day) Calendar +type Day struct { + Weekday time.Weekday + Hours []Span +} + +// Anything else: the whole per-day question, as a function. +func CalendarFunc(loc *time.Location, open func(dst []Span, day time.Time) []Span) Calendar +``` + +The rules are ordinary functions, and the library ships the ones every +calendar is made of: + +- `scale.Weekdays(days ...time.Weekday) DayRule` — open on the named weekdays. + There is no rule called "weekdays" without arguments, because there is no + global answer: Monday to Friday, Monday to Saturday and Sunday to Thursday + are all spelled out, and the code says which one the chart means. +- `scale.Dates(dates ...time.Time) DayRule` — the named dates, by year, month + and day in the calendar's location; the time of day of an argument is + ignored. +- `scale.EveryNth(n int, from time.Time) DayRule` — every n-th day counted from + a date: a fortnightly maintenance Sunday is + `scale.And(scale.Weekdays(time.Sunday), scale.EveryNth(14, first))`, a + four-on-four-off shift is `scale.EveryNth` over an eight-day cycle. +- `scale.Between(from, to time.Time) DayRule` — the dates of a term, a season, + a campaign. +- `scale.And`, `scale.Or`, `scale.Not` — the rest. + +Two wrappers change any calendar, the caller's included, rather than being +methods of one type: + +- `scale.Closed(cal, rule)` closes the days the rule accepts — the holidays. +- `scale.Special(cal, rule, spans...)` gives the days the rule accepts those + stretches instead — an early close, a late start, a Saturday worked. + +```go +// A plant: Monday to Saturday, 06:00 to 22:00, closed on public holidays and +// every other Saturday, short on the day before a holiday. +cal := scale.Workweek(berlin, + scale.And(scale.Weekdays(time.Monday, time.Tuesday, time.Wednesday, + time.Thursday, time.Friday, time.Saturday), + scale.Not(scale.And(scale.Weekdays(time.Saturday), scale.EveryNth(14, firstSaturday)))), + scale.Span{From: 6 * time.Hour, To: 22 * time.Hour}) +cal = scale.Closed(cal, scale.Dates(holidays...)) +cal = scale.Special(cal, scale.Dates(eves...), scale.Span{From: 6 * time.Hour, To: 14 * time.Hour}) + +// An exchange: the same two constructors, nothing of its own. +nyse := scale.Closed(scale.Workweek(newYork, + scale.Weekdays(time.Monday, time.Tuesday, time.Wednesday, time.Thursday, time.Friday), + scale.Span{From: 9*time.Hour + 30*time.Minute, To: 16 * time.Hour}), + scale.Dates(closed...)) +``` + +**The rule is asked about a day, not about an instant.** A `func(time.Time) +bool` over instants is the more obvious signature and cannot work: the axis +needs the exact edges of every closed stretch, and a predicate over instants +only answers yes or no at the instants it is asked about, so the edges would +have to be found by sampling or bisection — approximate, and a search per +edge. A rule about a day is exact because a day is discrete, and the hours of +the day are then data, `[]Span`, rather than a second question. + +A span with `From > To` is an overnight stretch written the way a person says +it, 22:00 to 06:00, and is split at midnight into the two answers claim 1 +requires. `scale.Always(loc)` is `Workweek(loc, nil)` — nil accepts every day — +the ward that never closes, and the base a caller closes dates on. + +**The week start** is `scale.StartWeek(cal, d)`, a third wrapper. Every +constructor defaults to Monday, ISO 8601's, and it is a default of the +calendar rather than of a locale: `scale.Locale` names days, it does not decide +which one a week begins on, and deriving one from the other would make a German +chart and a German chart about an American company's weeks disagree for no +reason the code shows. + +Every calendar is immutable and every wrapper returns a new one, so one base +week is shared by several charts and specialised per chart. + +No holiday table ships with the library, for any country or any exchange. +Holidays are announced rather than derived, differ by region inside one +country, and change; a table here would be wrong by the release after the one +that shipped it, and a closed day drawn open is a gap nobody notices. A +caller already has the list, because the same list decides when their data +arrives. A calendar whose holidays *are* a rule — a lunar or a solar-hijri +one — is an implementation of the interface, which is what it is for. + +### 3. The calendar produces folds, and the caller names the interval + +```go +p.X(scale.Time(scale.TimeCalendar(cal), scale.TimeFold(scale.Folds(cal, from, to)...))) +``` + +`scale.Folds(cal, from, to)` walks the days of `[from, to)` in the calendar's +location, asks `Open` for each, and returns the complement as `TimeSpan`s, +sorted and merged — a Friday night, a weekend and a Monday morning are one +fold. It is a function over the interface rather than a method, so every +calendar, written here or by a caller, gets the same walk. + +The interval is the caller's, not derived from whatever domain the scale +trains to, for 0083's reason: *the document holds the spans, not the query*. +In the JSON dialect a folded axis is a list of `"folds"` any consumer reads, +and a document that held a calendar would need every consumer to agree on +what the calendar is — which a caller-written implementation makes impossible. +So the calendar is a Go value that writes folds, and the folds are what is +serialised. A live chart folds ahead or refolds when its stream passes the end; +see *Revisit if*. + +### 4. A week starts where the calendar says, and nowhere else by accident + +`scale.WeekStart(d)` is a new time-scale option: week ticks are aligned to +local midnight on that weekday, by walking from the weekday rather than by +truncating a duration. Without it the default is Monday — what every weekly +axis already draws, now as a decision rather than as Go's zero time — so no +golden file changes. + +`scale.TimeCalendar(cal)` applies a calendar to a time axis's own conventions: +it sets the week start from `cal.WeekStart()` and the location from +`cal.Location()` unless `scale.In` names another. It does not fold: folding is +claim 3's, with its interval. The dialect writes `"weekStart"` on a time scale +when it is not Monday. + +### 5. A tick that lands in a fold moves to where time next counts + +This amends 0083 §5 for one case. When the step chosen for the kept length is +a calendar unit of a day or longer — a day, a week, a month, a year — and a +tick of that step lands strictly inside a fold, it moves to the fold's upper +edge and is labelled for where it now stands, in the step's own layout. A day +tick at midnight becomes a tick at the day's first open hour labelled with the +day; a Sunday-start week tick on a closed Sunday becomes a tick at Monday's +open labelled with Monday's date; a month tick on a closed first of the month +moves to the first open day and still reads as the month. + +It is only for calendar units, because only they name a day rather than an +instant: a tick labelled 12:00 moved to 09:30 would have to say 09:30, and then +it is a tick nobody asked for. A moved tick never passes the next tick of its +own step; two that land in one fold become one, the later winning — a closed +week that swallowed a month boundary is labelled with the new month. 0083's +rule that no tick stands strictly inside a cut still holds; this chooses where +the tick goes instead of dropping it. It is a property of folded time axes, +not of calendars, so a machine log folded from `SpansWhere` gets day labels +too. + +### 6. A calendar is also where a period starts + +`stat.OHLC` buckets by a fixed width from an origin, which is right for a +minute and an hour and wrong for a day across a daylight saving change, where +the day is 23 or 25 hours long — and wrong for a shift, which is whatever the +roster says. `scale.Opens(cal, from, to)` returns the start of every open +stretch as a column of edges, and `stat.OHLCAt(ts, ps, vs, edges)` buckets +between consecutive edges: one candle per session, one bar per shift, however +long each was. `stat` stays numbers in, numbers out: it takes edges, not a +calendar. + +### 7. Where it is refused + +- **On an axis that is not a time axis.** A calendar is days. +- **On a log time axis**, for 0083's reason that no coord yet cuts one. +- **An `Open` answer out of order, overlapping, or outside `[0, 24h]`** is an + error from `Folds`, naming the date: a calendar that cannot say when a day is + open is a bug in the calendar, and silently merging its answer would draw + someone's bug as their data. + +## Consequences + +- A chart of Monday-to-Saturday production, of a Sunday-to-Thursday office and + of an exchange with its nights folded are the same two lines with different + arguments, and none of them is a special case of another. +- A calendar the library could not anticipate — a roster, a term list, a + calendar computed in another calendar system — is a `DayRule` if its days are + all alike, a `CalendarFunc` if they are not, and a type with three methods if + it wants to be a type. +- A weekly axis can start its weeks on any day. The default stays Monday, so + nothing drawn today moves. +- An intraday axis with folded nights has dates on it. +- The dialect gains `"weekStart"` on a time scale and nothing else; a document + drawn from a calendar reads in any consumer that reads folds. +- A year of intraday folds is about three hundred cuts after merging, which is + 0083's `BenchmarkFolded1k` territory, and the walk that produces them is one + `Open` call per day into a reused slice. + +## Not in scope + +- **Holiday tables**, for any country or exchange. Claim 2. +- **A calendar in the dialect.** Claim 3. +- **Tick labels in another calendar system** — months that begin at the + Hijri or Hebrew month rather than the Gregorian one. The folds of such a + calendar are expressible now, through the interface; its *labels* are a + second tick walk and a locale question, and a record of their own. +- **An index axis** — every open stretch the same width regardless of its + length. That is an ordinal axis over period numbers, buildable on + `scale.Ordinal` today; it gives up the property that an hour is an hour, + which is what makes a short day visible. +- **Colouring the calendar** — pre-market, overtime, weekends drawn as tinted + bands rather than folded. That is `VBand` over `Folds` of a second calendar. + +## Revisit if + +- A live chart needs folds that follow its stream without the caller refolding + — a scale that derives cuts from its trained domain, which reopens 0083's + reason for fixing cuts at construction. +- Someone draws a calendar system other than the Gregorian one and needs its + months on the axis. + +## Order of work + +Every step is built. + +1. `scale.Calendar`, `scale.Span`, `scale.Folds`, `scale.Opens`; the + constructors `Workweek`, `Days`, `CalendarFunc` and `Always`; the rules + `Weekdays`, `Dates`, `EveryNth`, `Between`, `And`, `Or`, `Not`; and the + wrappers `Closed`, `Special` and `StartWeek` — `scale/calendar.go`, tested + across both of New York's daylight saving changes, an overnight shift, a + Sunday-to-Thursday week in Jerusalem and a caller-written roster. +2. `scale.WeekStart` and `scale.TimeCalendar`, with the week-tick walk aligned + to a weekday by date instead of truncated from Go's zero time; the default + is still Monday and no golden file moved. +3. Claim 5 in the time scale's tick walk, and `calendar-fortnight`, a golden of + two weeks of sessions with nights and weekend folded. +4. `stat.OHLCAt` and its Append pair, allocation-free into a warm slice. +5. `examples/market` takes its trading days and its folds from one calendar, + Monday to Friday with 3 July closed. + +## Amendment — what building it decided + +- **A span is wall clock.** `Span` offsets are read through `time.Date` as the + hour, minute and second of the day, not added to midnight as elapsed time. + That is what keeps an 09:30 open at 09:30 on the morning the clocks changed + at 02:00, and why `Span.To` of 24h is the next midnight on a 23- or 25-hour + day alike. +- **`Dates` reads a date as written.** Its year, month and day in whatever + location the argument was built in, rather than converted to the calendar's: + `time.Date(2026, 12, 25, 12, 0, 0, 0, elsewhere)` is Christmas in every + calendar. Converting would make a holiday list built in UTC close the wrong + day in any zone west of Greenwich. +- **A closed day is closed from midnight to midnight.** `Closed` cannot know + which morning belongs to which evening's shift, so a night shift that began + on the evening before a holiday stops at midnight; a calendar that must + finish its shifts is a `CalendarFunc`. `Special` replaces a whole day the + same way, and its stretches stop at midnight. +- **`Opens` returns the start of every uninterrupted open stretch**, merged + across midnight. A night shift is one period, and so is a week open all day + from Monday to Friday — which is why the market example, daily and open all + day, keeps `stat.OHLC` with a width of a day and uses the calendar for its + folds only. `OHLCAt` is for calendars with hours. +- **A tick moves out of a fold and is dropped from a break.** Claim 5 is about + folds; a break is a stretch the reader was told is missing, and a tick on + its far edge would be a date nobody chose standing against the break's + mark. `axis-break-time`'s golden says so by not changing. +- **The day step is rarer than the record assumed.** The tick search chooses a + step for the *kept* length, and on a fortnight of 6½-hour sessions at an + ordinary width that is twelve hours, whose labels carry the date already. + Claim 5 is what an axis narrow or long enough to choose a day step needs — + without it such an axis has no ticks at all — and the test asks for exactly + that case. +- **The lower edge of a fold counts as inside it** for claim 5. A weekend fold + starts at Saturday midnight, and a tick landing exactly there stands on the + end of Friday labelled with a day the axis took out. Found by ADR 0088's + goldens; the tick now moves to Monday with Monday's date. A tick at a + break's lower edge is still dropped, as any tick in a break is. diff --git a/docs/adr/0087-an-orientation-is-the-encoding-read-a-quarter-turn-round.md b/docs/adr/0087-an-orientation-is-the-encoding-read-a-quarter-turn-round.md new file mode 100644 index 0000000..faefb58 --- /dev/null +++ b/docs/adr/0087-an-orientation-is-the-encoding-read-a-quarter-turn-round.md @@ -0,0 +1,133 @@ +# 0087 — An orientation is the encoding read a quarter turn round, and a mark turns only where it emits + +**Status:** Accepted · **Date:** 2026-09-28 · **Implemented:** 2026-09-28 · **Revisits:** [ADR 0053](0053-tidy-tree-layout.md)'s amendment + +## Context + +[ADR 0053](0053-tidy-tree-layout.md)'s amendment gave the library an +orientation — `geom.Orient`, `geom.Vertical`, `geom.Horizontal` — and named it +for the library rather than for the tree, *"so that the second mark to want it +says it the same way — bars and boxplots still have none, and this is where +that answer will land."* Its doc comment said the rest: `Orient` on any mark +but the tree was accepted and ignored. + +[ADR 0085](0085-what-a-market-chart-needs.md) found the second mark. A volume +profile is a bar per price bucket growing across a right track that shares the +price axis, and without a horizontal bar `examples/market` drew it as rects +with a hand-stacked `X` and `X2` — the one place the example was more code +than the chart. The same gap is the long list of named categories that reads +best down the page, the population pyramid, the ranked bar chart, and a +distribution drawn beside the axis it is a distribution of. + +The option also already half-worked, which is worse than not working: the +gradient in `config.paint` read it for every filled mark, so a vertical bar +given `Orient(Horizontal)` and a `Gradient` was painted with a left-to-right +ramp. + +## Decision + +**Under `Horizontal` a mark's slots are its `Y` column and its measurement its +`X` one, the far ends swap with them, and the mark computes in slot and value +space and turns once, where it emits.** Five claims. + +### 1. The roles swap with the axes + +```go +geom.Bar(src, geom.Y("fruit"), geom.X("count"), geom.Orient(geom.Horizontal)) +``` + +The caller names the columns on the axes they are drawn on, as Vega-Lite and +the tree do: the categories on `Y`, the value on `X`. `X2` is then the value's +far end — a floating bar's other bound — and `Y2` the slot's far edge. That is +the vertical encoding read a quarter turn round, and it is spelled +`config.roles()`: a copy of the layer's config with the positional columns +renamed for the roles they play, used only to read columns. The layer's own +config is what it describes itself with, so a document holds the columns the +caller named, not the swapped ones. + +The orientation is stated rather than inferred. Vega-Lite infers it from which +channel is quantitative, and a figure layer does not know its scales' kinds +until it trains — and a bar of numbers against numbers, which is every bar on +a time axis, would have to guess. + +### 2. A mark turns only where it emits + +`config.axes(x, y)` splits the panel's scales into the slot scale and the value +scale; `config.box(s0, v0, s1, v1)` and `config.point(s, v)` turn a rectangle +or a point computed in slot and value space into the space the scales map +into. A mark reads its columns through `roles`, trains and maps through +`axes`, and calls `box` or `point` where it used to call `ir.R` or pass `x, y` +to the coord. Everything after that — the coord, `Explode`, `Extrude`, +hit-testing, the gradient — sees an ordinary rectangle and needs to know +nothing. The baseline is `baselineOn`, which is `baselinePos` or the +`baselineAcross` a set-size bar already had, whichever axis the value runs +along. + +That is the tree's own strategy — lay it out once, hang it on the other pair +of axes — made into three functions the next mark calls rather than a fourth +copy of the swap. + +### 3. A dodged group reads down the page + +Dodging places a slot's series in ascending device order. Across X that is +left to right, which is the order of the legend; up a Y axis it would be top +to bottom as well, so the first series is the top bar of its slot rather than +the bottom one a literal quarter turn would give. It is the order a reader +reads a horizontal grouped bar and its legend in, and the order Vega-Lite +draws one in. + +### 4. Which marks read it + +- **Read it:** `Tree` (0053), `Bar`, `Histogram` (it bins the `Y` column) and + `Boxplot` (it groups by `Y` and summarises `X`). +- **Need no option:** `Rect` and `ErrorBar`, because which edges a row names + already says which way they run; ErrorBar's doc comment refuses an + orientation option for exactly this reason, and keeps refusing it. +- **Next, when asked for:** `Violin`, `Beeswarm` and `ECDF`, each a slot or + value swap at its emit points; and `Area`, whose decimation reduces by pixel + column along X and so has to be told which axis is the independent one. + `Ridgeline` is already the horizontal density. +- **Not ever:** `Candle` ([ADR 0088](0088-a-candle-is-one-mark-that-reads-four-values.md)), + which nobody draws on its side. `Line` and `Scatter`, which have no slot — a line lying down is + a line with its columns exchanged. `Trend`, where a horizontal fit is a + regression of X on Y, a different statistic rather than the same one turned. + `Horizon`, whose fold replaces its vertical axis. `Step`, until someone needs + a staircase indexed by Y. + +`Orient` on a mark outside the first list stays accepted and ignored, as the +doc comment says, so that a layer built from a shared option list does not +fail. + +### 5. The dialect + +The mark writes `"orientation": "horizontal"`, figure's own name from 0053, on +the marks that read it and on no others. A horizontal stack is written on the +**X** channel, which is where Vega-Lite puts the stack of a bar lying down. +Reading, a Vega-Lite `"orient": "horizontal"` on a `bar` or a `boxplot` is +taken as the same thing: there, orient means exactly this. On a rule and a +rect it chooses between two marks instead, and is read where it always was. + +## Consequences + +- A horizontal bar chart, a population pyramid, a ranked list and a volume + profile are one option. `examples/market` draws its profile as a stacked + horizontal `Bar` from `stat.BinWeighted`, and the picture is identical to the + hand-stacked rects it replaces. +- The gradient on a horizontal bar ramps along the bar, which is the half of + the option that already worked finally meeting the other half. +- A mark added later turns by calling `axes`, `roles`, `box` and `point`, not + by writing its own swap. + +## Not in scope + +- **Inferring the orientation** from the scales. Claim 1. +- **A horizontal line or scatter.** Claim 4. +- **Orientation under polar coords as a named form.** A horizontal bar under + `coord.Polar` puts the value on the angle — a radial bar chart — and it + falls out of claim 2; it is not tested as a form of its own until someone + draws one. + +## Revisit if + +- A mark in the *next* list is asked for; each is a small change of the shape + this record describes, and Area's is the one that reaches into shared code. diff --git a/docs/adr/0088-a-candle-is-one-mark-that-reads-four-values.md b/docs/adr/0088-a-candle-is-one-mark-that-reads-four-values.md new file mode 100644 index 0000000..87f717b --- /dev/null +++ b/docs/adr/0088-a-candle-is-one-mark-that-reads-four-values.md @@ -0,0 +1,238 @@ +# 0088 — A candle is one mark that reads four values and decides its own direction + +**Status:** Accepted, amended · **Date:** 2026-09-28 · **Implemented:** 2026-09-28 · **Ranked by:** [ADR 0085](0085-what-a-market-chart-needs.md), rank 2 + +## Context + +[ADR 0085](0085-what-a-market-chart-needs.md) showed that a candlestick chart +composes from marks that exist, and `examples/market` draws one: an +`ErrorBar` with `Caps(false)` from low to high for the wick, a `Rect` from open +to close over it for the body, both coloured through `scale.Named` from a +direction column the caller computes. It ranked a mark of its own second, +after the calendar, because the recipe costs the same four things on every +chart: + +- **A column the data does not have.** Up or down is a function of two + columns, and a `data.Table` has no derived columns, so every caller writes + the loop that `stat.Candle.Up` shortens but cannot remove. +- **Two layers that must agree.** The wick and the body are two layers over + the same rows with the same X, the same slot, the same colour scale and the + same `BarWidth` story. A caller who changes one and not the other draws + bodies beside their wicks, and nothing says so. +- **Two legend entries for one thing, or none.** Each layer contributes its + own entry; the chart's legend lists the direction twice or the caller + suppresses both. +- **Four values in a vocabulary of two.** Open, high, low and close are all + values on the same axis. X/Y/Y2 names two of them; the recipe spends the + wick's Y/Y2 on low/high and the body's on open/close, and a document of the + recipe says *two intervals* where the reader sees *one candle*. + +Nothing in the recipe is wrong, which is why it stays documented. The mark is +for the chart that is drawn a thousand times. + +## Decision + +**`geom.Candle` reads a position and four values, decides each row's direction +itself, and draws the wick and the body as one mark with one legend.** Eight +claims. + +```go +p.Add(geom.Candle(src, geom.X("start"), geom.X2("end"), + geom.OHLC("open", "high", "low", "close"))) +``` + +### 1. The four values are one option + +`geom.OHLC(open, high, low, close)` names the four columns in the order the +name of the chart spells them. It is one option rather than four because the +four are one statement — a row is a candle only with all of them — and because +`geom.Open`, `geom.High`, `geom.Low` and `geom.Close` would be four names in a +package shared by every mark, three of which read as something other than a +column (`geom.Close` closes nothing). A layer without `OHLC` is an error at +`Train`, naming the option. + +The position is the ordinary one: `X` for where the candle stands, and `X2` +when the row names both edges of its period — which is what a candle out of +`stat.OHLC` knows (claim 7). With `X` alone the slot is the closest spacing in +the data or a band scale's own width, exactly as a `Bar`'s. + +### 2. The mark decides the direction + +A row is **rising** when its close is at or above its open and **falling** +otherwise — `stat.Candle.Up`, now read by the mark rather than by the caller. +A close equal to its open is a *doji*, rising by that rule, and its body is +drawn at least one device pixel tall so that it is a mark rather than nothing. + +`geom.Direction(geom.SincePrevious)` is the other convention some platforms +use: a row rises when its close is at or above the *previous row's* close, so a +gap-down day that rallied from its open still reads as down. The first row has +no previous close and falls back to its own open. The default is +`geom.SinceOpen`, the candle's own statement. + +### 3. Direction is drawn twice: colour, and hollow or filled + +`geom.Rising(color, label)` and `geom.Falling(color, label)` set each +direction's colour and legend label. Their defaults come from the theme — +two new theme fields, `Rising` and `Falling`, blue and vermilion in both +shipped themes, because the red and green of a trading screen is the pair the +most common colour-vision deficiency cannot tell apart — and the labels +default to "rising" and "falling". + +`geom.Hollow(true)` draws rising bodies as outlines and falling bodies filled, +the convention of a chart printed in one colour. It is on by default under a +theme with redundant encoding (`theme.Redundant`), which is +[ADR 0069](0069-hatching-as-the-third-redundant-channel.md)'s argument about a +second channel applied to the one a candle already has: a direction that +survives a greyscale print is one that does not depend on hue. + +`geom.ColorBy` replaces the direction's colours with a column's — a regime, a +volume decile — and leaves hollow and filled to carry the direction. A candle +coloured by something else still says which way it went. + +### 4. A candle is a body and a wick, or an OHLC bar + +`geom.CandleStyle(geom.Bodies)` is the default. `geom.CandleStyle(geom.Ticks)` +is the OHLC bar: a rule from low to high, a tick to the left at the open and +one to the right at the close, a tick as long as half the body would be wide. +It is a style of this mark rather than a mark of its own because everything +but the drawing is the same — the columns, the direction, the slot, the +legend, the rows a pointer lands on — and a second mark would be a second copy +of all of it that could drift. The document spells it as a property of the +mark (claim 8). + +### 5. It draws in four calls, whatever the row count + +The wicks of one direction are one stroked path and the bodies of one +direction one filled path — or, hollow, one stroked outline: two calls per +direction, four for the layer. A thousand candles are the same +four calls as ten. That is the batching `geom.Rect` does by colour, for the +reason [ADR 0007](0007-per-mark-colour.md) refuses a colour per mark in the IR. +A layer coloured by `ColorBy` batches by its colours instead, as `Rect` does. + +The body's width is `BarWidth` of the slot, default **0.7** rather than a +bar's 0.8: between two candles is where the eye separates their wicks, and a +bar has no wick to separate. The wick is `geom.Width` device pixels wide, +default 1. + +### 6. It trains, reports and refuses like the marks around it + +- **The value axis** is trained on each row's low and high, and on its open and + close as well, so a row whose data is inconsistent — a high below its close — + still has its whole drawing inside the axis. +- **Inconsistent data is drawn as given.** A body outside its own wick is + drawn outside it. Repairing it — stretching the wick to the body — would hide + an error in the data, and a candle is exactly where a reader checks one. +- **A row missing any of the four values is skipped**, and is an error under + `OnMissing(Error)`, as a bar's missing value is. +- **The row is reported at its close**, in the middle of its slot: the value a + reader means when they point at a candle, and the one a crosshair reads out. + A tooltip then has the whole row, four values and all. +- **`Orient` is not read.** A candle lying on its side is a chart nobody draws; + [ADR 0087](0087-an-orientation-is-the-encoding-read-a-quarter-turn-round.md)'s + list gains it under *not ever*. +- **Under a polar coord** it is drawn through the coord like a `Rect`, and not + tested as a form of its own. + +### 7. The output of `stat.OHLC` is a table in one call + +`stat.Candle` gains an `End` field: the start of the next period for +`stat.OHLC` (start plus the width) and `stat.OHLCAt` (the next edge), and the +time of its last row for the last candle of `OHLCAt`, whose period the data +has not closed. It is a field added to a type that has not been released. + +`figure.CandleTable(cs []stat.Candle)` returns a `*data.Table` with the +columns `start`, `end`, `open`, `high`, `low`, `close`, `volume` and `count`, +which is what `geom.Candle` with `X("start")`, `X2("end")` and +`OHLC("open", "high", "low", "close")` reads. It is in the root package for the +reason `SpansWhere` is: `stat` does not know a table and `data` does not know a +candle. The columns are float64 domain values; on a time axis they are +`scale.Nanos`, which is what a time scale without an origin reads. + +### 8. The dialect + +The mark is `"candlestick"`, figure's own word — Vega-Lite has no candle mark +and draws one as the same two-layer recipe. The four values are four +encoding channels, `"open"`, `"high"`, `"low"` and `"close"`, also figure's, +beside the ordinary `"x"` and `"x2"`. They are not spelled as `y` and `y2`, +because a consumer reading `y` as *the* value of a row would read a candle as +its open and nothing else. The mark's properties carry `"direction": +"previous"`, `"hollow"` and `"candleStyle": "ticks"` when they differ from the +defaults, and the rising and falling colours and labels. + +## Consequences + +- A candle chart is one layer, and the legend says rising and falling once. +- The direction column disappears from `examples/market`, and so does the + second layer. The recipe stays in `docs/chart-types.md` as what a candle is + made of. +- A chart printed in one colour keeps its direction, through hollow bodies. +- The theme gains two colours; every shipped theme sets them. +- Volume bars coloured by direction still need the direction as a column: the + candle decides it for itself, not for the layer beside it. See *Revisit if*. + +## Not in scope + +- **Heikin-Ashi and other derived candles.** They are a transform of the four + columns into four others — numbers in, numbers out — and belong in `stat` as + a function whose output this mark draws unchanged. +- **Renko, Kagi, point-and-figure.** Charts of price bricks on an X that is not + time; ADR 0085 left them out and this record does too. +- **Decimation.** Unlike a line's, a candle's aggregation is exact — the open of + the first, the close of the last, the highest high and the lowest low — so a + chart of more candles than pixels could merge them without inventing + anything. It is `stat.OHLC` at a coarser width today, the caller's to call; + whether the mark does it itself is a question for when a chart needs it. +- **Volume inside the candle** — width or opacity by volume. A second channel + on a mark whose reading is already four values; the volume bar in a track is + the reading people know. + +## Revisit if + +- Charts colour their volume bars by direction often enough that computing the + column is the recipe's cost all over again — which would be a `geom.Bar` + option reading the same four columns, or a `stat` function writing the + direction. — Revisited in + [ADR 0091](0091-a-direction-is-a-colour-channel-any-mark-can-take.md), as a + colour channel any mark can take. +- A chart of more candles than its width can hold asks for the exact merge + above. + +## Order of work + +Every step is built. + +1. `stat.Candle.End`, set by `OHLC` and `OHLCAt`; `figure.CandleTable`. +2. `geom.Candle` in `geom/candle.go`, with `OHLC`, `Direction`, `Rising`, + `Falling`, `Hollow` and `CandleStyle`, and the theme's `Rising` and + `Falling`; tested for four calls over a thousand rows, a visible doji, + inconsistent data drawn as given, the row at its close, a period's share + and the Desc round trip. +3. The dialect's `"candlestick"` with its four channels, round-tripped in + three configurations. +4. `examples/market` draws its candles with the mark; `candles` and + `candles-ticks` are goldens of both styles over a folded fortnight. + +## Amendment — what building it decided + +- **The mark is `geom.MarkCandle`, `"candle"`, and the document's word is + `"candlestick"`.** The package's names are its own and the dialect + translates them, as it does for every other mark. +- **A hollow batch is its own batch.** Batches are keyed by colour and by + whether the body is drawn hollow, so a layer coloured by a column under + `Hollow` still strokes its rising bodies and fills its falling ones. A + hollow batch strokes its bodies instead of filling them, so every batch is + still two calls — a direction-coloured layer is four, hollow or not — and a + layer coloured by a column is two per colour, never one per row. +- **The legend is two box swatches**, one per direction, or the colour + column's own entries under `ColorBy`. A hollow swatch would need a swatch + kind the legend does not have; the direction's label says what the outline + says. +- **A tick on the edge where a fold starts moves too.** Building the goldens + found the one case ADR 0086's claim 5 missed: a weekend fold starts at + Saturday midnight, a two-day tick landed exactly there, and it stood on the + end of Friday labelled with Saturday. A tick at a fold's lower edge now + moves to its upper one like a tick inside it; 0086's amendment records it. +- **A candle reaches the edge of its axis.** The value axis is trained on the + lowest low and the highest high and nothing more, so the extreme wicks touch + the panel's edge — the same fit a bar gets, and `scale.Nice` rounds the + ticks rather than the domain. diff --git a/docs/adr/0089-a-value-axis-fits-what-its-time-axis-shows.md b/docs/adr/0089-a-value-axis-fits-what-its-time-axis-shows.md new file mode 100644 index 0000000..68f3285 --- /dev/null +++ b/docs/adr/0089-a-value-axis-fits-what-its-time-axis-shows.md @@ -0,0 +1,268 @@ +# 0089 — A value axis can fit what the other axis shows, and a stream can revise its last row + +**Status:** Accepted, amended · **Date:** 2026-09-28 · **Implemented:** 2026-09-28 · **Ranked by:** [ADR 0085](0085-what-a-market-chart-needs.md), rank 3 + +## Context + +[ADR 0085](0085-what-a-market-chart-needs.md) ranked two gaps third, together, +because each is half of what makes a market chart *live* and neither is useful +alone. + +**Zoom on time, autoscale on price.** `Live.Wheel`, `ZoomTo` and `PanBy` move +every steerable axis of the panel, X and Y alike — that is the right default +for a scatter, where the reader zooms into a region of the plane. On a price +chart it is wrong twice over. Zooming into last week keeps the price axis of +the whole year, so a week that moved two percent is a flat line along the +middle of the panel; and wheeling over the chart zooms the price axis as well, +which no trader asked for. What a trading screen does is zoom and pan the time +axis alone, and fit the price axis to the candles still visible — every frame, +as the view moves. + +The same fit is wanted without a pointer. A static chart of one month cut out +of a year's table — its time axis pinned to the month with `Zoomer.SetDomain`, +or a numeric axis fixed with `scale.Domain` — has a value axis trained on the +whole year today. A volume track under a zoomed price +chart is trained on the tallest bar of the year. Both are the same question: +the value axis should describe the rows the other axis shows. + +**The candle that has not closed.** A live chart's last candle is not a row +that arrives once. It opens at the first tick of its period and changes on +every tick after — its close moves, its high and low widen — until the period +ends and the next candle opens. `data.Stream` appends and never replaces +(ADR 0016), so today a caller either appends a row per tick and draws a +hundred candles where there should be one, or keeps the open candle outside +the stream and draws it as a second layer that has to agree with the first. + +## Decision + +**A value scale can be told to fit the rows its panel's other axis shows; +render hands each layer that interval when it trains, and the layers that can +restrict their training do. A stream can replace its last row, and `stat` has +a resampler that says when a tick revises the open candle and when it opens the +next.** Seven claims. + +### 1. Fitting is a property of the value scale + +```go +p.X(scale.Time(...)) +p.Y(scale.Linear(scale.FitView())) +p.Track(figure.Bottom, figure.TrackScale(scale.Linear(scale.Zero(), scale.FitView()))) +``` + +`scale.FitView()` is a linear option, and `scale.LogFitView()` its log +counterpart, because a price axis is as often logarithmic as not. It declares +that the axis is trained on the rows the panel's *other* axis shows, and +nothing else about the scale changes: nicing, zero-forcing and breaks apply to +the fitted extent as they would to any other. + +It is a scale option rather than a mode of `Live` for the reason the context +gives: a static chart with a fixed time domain wants the same fit, a track +wants it on its own scale while the panel above it does not, and a scale +option is also the one place the dialect already writes an axis's behaviour +down (claim 7). A `Live` has nothing to add but the zoom that makes the fit +change. + +It is the vertical axis that fits the horizontal one. A value axis that is +horizontal — a bar chart lying down, +[ADR 0087](0087-an-orientation-is-the-encoding-read-a-quarter-turn-round.md) — +fitting a vertical one it is measured against is the same claim turned, and is +left until someone draws a zoomable horizontal chart. + +### 2. The interval is the other axis's domain, when it has been decided + +Render trains a panel's layers once. For a panel whose Y scale fits, the X +scale's domain is known *before* training exactly when something has pinned +it: a zoom or a pan, a caller's own `Zoomer.SetDomain`, a linear domain fixed +at construction with `scale.Domain`, or a view put back with `Live.SetView`. Then render passes +that interval to every layer as `Training.Within`, a field added to +`geom.Training` the way `Z` and `Dims` were — additively, and nil for every +panel that does not fit. + +When the X axis is not pinned it is trained from the same rows, so every row is +in view and `Within` stays nil: the fitted axis is the ordinary one. That is +what keeps this a single training pass. There is no case in which the X domain +depends on the fitted Y domain, so nothing has to be trained twice. + +Pinned needs asking, so the scales that implement `Zoomer` gain an optional +method, `Pinned() bool`, behind an interface of its own — `scale.Pinner` — +rather than a new method on `Zoomer`, which [ADR 0084](0084-what-v1-promises.md) +refuses on a published interface. + +### 3. A layer restricts what it trains on, and a layer that cannot, does not + +`Training.Within` is a request, not a filter render applies — render does not +know which of a layer's columns are positions. A layer that reads it trains its +value axis on: + +- **the rows whose position is inside the interval**, for point marks — + `Scatter`, `Text`; +- **the rows whose span overlaps it**, for marks with width — `Bar`, `Rect`, + `ErrorBar`, `Candle` — so a candle half in view still fits; +- **the rows inside it and the value interpolated at each edge**, for connected + marks — `Line`, `Area`, `Step` — because the stretch of a line between the + last row inside and the first outside is drawn, and it crosses the edge at a + value no row holds. A step holds its value to the edge instead of + interpolating it. + +It still trains its *position* axis on every row, so an axis that is not pinned +keeps its extent and one that is pinned ignores the training anyway. + +A layer that does not read `Within` trains on all its rows, as it does today. +The fitted axis is then wider than the view, never narrower: the failure is a +chart that fits less tightly, not one that clips its data. The distribution +and relational marks are in that list and stay in it — a histogram's axis is +its counts, not a view of rows. + +### 4. `Live` does not steer an axis that fits + +`Wheel`, `ZoomTo` and `PanBy` leave a fitting axis alone and move the others. +Zooming a price chart then zooms time, about the pointer, and the next frame's +training fits the price axis to what remains; panning slides time and the price +axis follows. `Autoscale` releases the time axis and the fit returns to the +whole table. + +A fitting axis is not pinned by any of them, so it never needs releasing: it is +derived every frame from the axis that is steered. A `View` records it as +unpinned and `SetView` leaves it so. A reader who wants to zoom the price axis +by hand is asking for an axis that does not fit — the host turns the option +off, which is a rebuild, not a gesture. + +### 5. A stream can replace its last row + +`Stream.ReplaceLast(vals...)` overwrites the most recent row with new values, in +the order `NewStream` named the columns, under the same lock as `Append`. It is +an error on an empty stream. It allocates nothing, and a `Window` is +unaffected — the row count does not change. + +It is the one revision a stream admits, because it is the one a live source +makes: the newest row is the only one still being measured. Any row further +back is history, and a chart whose past changed between two frames is a chart +whose reader can no longer trust what they saw on the first. A producer that +needs to revise older rows owns a table, not a stream. + +`Snapshot` is unchanged. A replaced row appears on the next snapshot, and +`Live.Draw`'s damage pass sees one candle that changed and repaints that, and +the axis if the fit moved it. + +### 6. `stat.Resampler` says when a tick opens a candle + +```go +r := stat.Resampler{Origin: scale.Nanos(open), Width: float64(time.Minute)} +for tick := range ticks { + c, fresh := r.Add(scale.Nanos(tick.At), tick.Price, tick.Size) + row := []float64{c.Start, c.End, c.Open, c.High, c.Low, c.Close, c.Volume} + if fresh { + stream.Append(row...) + } else { + stream.ReplaceLast(row...) + } +} +``` + +`stat.OHLC` summarises a column it is given whole. A live source gives it one +tick at a time, and the question each tick asks is the one `OHLC` answers +internally — *is this the same period as the last one* — so `Resampler` is that +loop turned inside out: a small struct with a `Width` and an `Origin`, or +`Edges` from a calendar's `Opens` ([ADR 0086](0086-a-calendar-says-when-time-counts.md)), +whose `Add` returns the candle as it now stands and whether it is new. Its +candles are exactly `OHLC`'s over the same ticks; a test says so. + +It is a struct with state rather than a function because the state is the +point — the open candle — and it has a `Reset`, as `stat.Contour` and +`stat.Lattice` do, so that a chart restarting its feed reuses it. `stat` stays +numbers in, numbers out; the stream is the caller's. + +A tick older than the open candle's start is refused (`Add` reports it and +changes nothing): the candle it belongs to is history, by claim 5. + +### 7. The dialect + +A scale that fits is written `"fit": "view"`, figure's own, on the scale; a +document read back fits the same way. `Training.Within` is not written: it is +derived from the other axis's domain, which the document already holds when it +is fixed. The stream and the resampler are Go values the document never sees, +as a stream never was. + +## Consequences + +- A price chart zooms and pans on time and fits its price axis to the visible + candles, with one option on the price scale; the volume track under it fits + its own axis with the same option. +- A static chart of a month cut from a year fits its value axis to the month. +- A live chart's last candle is one row that changes, not a row per tick, and + the frame that draws it repaints that candle. +- `geom.Training` gains `Within`; `scale` gains `FitView`, `LogFitView` and + `Pinner`; `data.Stream` gains `ReplaceLast`; `stat` gains `Resampler`. All are + additions. + +## Not in scope + +- **Following the newest data.** A trading screen's view slides right as + candles arrive, when the reader has not scrolled away. The host knows when + data arrives and where its end is, and `Live.SetView` or `PanBy` moves the + view there; the library cannot tell "the reader is looking at the end" from + "the reader is looking at a past that ends where the data did". +- **Easing the fit.** The price axis jumps to the new extent every frame. An + animated axis is [ADR 0045](0045-linked-views.md)-adjacent host work and a + transition, not a scale. +- **Fitting under a facet with free axes.** Each panel fits to its own X, which + falls out of claim 2 per panel; a shared Y fitting several X views at once is + a union of intervals, and waits for someone who wants it. +- **Revising any row but the last.** Claim 5. + +## Revisit if + +- A horizontal value axis should fit a zoomed vertical one (claim 1). +- A mark outside claim 3's list turns out to be drawn zoomed often enough that + its loose fit is noticed. + +## Order of work + +Every step is built. + +1. `scale.FitView`, `scale.LogFitView`, `scale.Fitter`, `scale.Pinner` and + `"fit": "view"` — `scale/fit.go`. +2. `geom.Training.Within`, passed by render; `Line`, `Area`, `Step`, `Scatter`, + `Text`, `Bar`, `Rect`, `ErrorBar` and `Candle` read it through three + helpers in `geom/fit.go` — points, spans, and connected paths with their edge + values — and a stack trains its totals in the view. A boxplot, which does + not read it, is tested to fit loosely rather than clip. +3. `Live.Wheel`, `ZoomTo` and `PanBy` skip a fitting axis, and a `View` does not + record one; tested end to end: a wheel zooms X only, the next frame's Y fits + it, a pan slides both, `Autoscale` returns to every row. +4. `data.Stream.ReplaceLast`, tested unbounded, filling, full and wrapped, and + under `-race` beside `Append`. +5. `stat.Resampler`, tested to produce `stat.OHLC`'s and `stat.OHLCAt`'s + candles over the same ticks. +6. `examples/market -live`: a seeded tick feed through a resampler into a + stream, one-minute candles and a volume track, both on fitted axes, wheeled + into the last half hour at the midpoint. + +## Amendment — what building it decided + +- **A fitting axis is reset every frame.** A trained domain only ever widens, + and render keeps a panel's scales across frames, so an axis fitted to last + frame's view kept it after the view moved on — the first end-to-end test + found a wheel that zoomed X and left Y where it was. Render now releases + every fitting axis whose X is pinned once, before any layer trains, and the + fit is derived from scratch. Once, for all panels, because panels that share + an axis share its scale. An axis that does not fit keeps the old behaviour; + whether an unpinned axis should forget last frame's rows too is a question + about every live chart, not this record's — and + [ADR 0090](0090-an-axis-describes-the-frame-it-is-drawn-in.md) asks it, and + answers it for every axis; `refit` is folded into its `retrain` pass. +- **Following the newest data is shown, not built.** The example's host keeps + the width the reader zoomed to and moves its right-hand edge to the open + candle with `Zoomer.SetDomain` each frame — one line, as *Not in scope* + predicted. Without it the view stays where the reader left it while the + stream's window moves on, and the chart ends showing one candle. +- **A stack fits a padded view.** A stacked area's totals are trained on the + rows within one spacing of the view, and a stacked bar's within half a + bar — looser than a line's edge interpolation, and in the direction a fit + may err. +- **Only a vertical value axis narrows.** A horizontal bar and a horizontal + error bar measure along X, so a fit of Y leaves them trained on every row, as + claim 1 said of horizontal value axes. +- **A floating bar still trains its value only.** `Bar` trained its Y and not + its Y2 before this record, and the fit keeps that rather than change what a + floating bar's axis covers as a side effect. diff --git a/docs/adr/0090-an-axis-describes-the-frame-it-is-drawn-in.md b/docs/adr/0090-an-axis-describes-the-frame-it-is-drawn-in.md new file mode 100644 index 0000000..b89d8e9 --- /dev/null +++ b/docs/adr/0090-an-axis-describes-the-frame-it-is-drawn-in.md @@ -0,0 +1,194 @@ +# 0090 — An axis describes the frame it is drawn in: a trained range is derived again every render, and a set of names is remembered + +**Status:** Accepted, amended · **Date:** 2026-09-28 · **Implemented:** 2026-09-28 · **Revisits:** [ADR 0016](0016-streaming-and-damage.md) · **Follows from:** [ADR 0089](0089-a-value-axis-fits-what-its-time-axis-shows.md)'s amendment + +## Context + +Building [ADR 0089](0089-a-value-axis-fits-what-its-time-axis-shows.md) found +that a fitted price axis stayed where it was after a zoom, and the reason was +not the fit. A scale's trained domain only ever widens — `domainRange.Train` +takes the minimum and maximum of what it has seen — and render keeps a panel's +scales across renders. So a scale trained on one frame's rows is trained on +the next frame's *as well*. 0089 fixed it for fitting axes by releasing them +before each frame, and recorded the question it left for every other axis. + +A probe answers it. A `data.Stream` with a window of ten rows, drawn by a +`Live`: + +| | rows in the window | X domain | Y domain | +|---|---|---|---| +| frame 1 | x 0…9, y 0…90 | [0, 9] | [0, 90] | +| frame 2 | x 20…29, y all 5 | **[0, 29]** | **[0, 90]** | +| `p.Render` after it | the same | [0, 29] | [0, 90] | + +The window has moved on and the axes have not. The time axis covers the whole +history the stream has already dropped, so the ten rows it holds are squeezed +into the right third of the panel; the value axis still reaches to a peak that +left the window nineteen rows ago, so a flat line at 5 is drawn along the +floor. `Plot.Render` called twice on a plot whose data changed does the same, +because it reuses the same scale objects. + +Nothing decided this. [ADR 0016](0016-streaming-and-damage.md) designed the +stream and its window and says what a window is for — *"a live chart shows the +last few thousand samples"* — and `examples/stream` says what its full window +does: *"one whose window is full slides, and sliding is what damage tracking is +good at."* It does not slide. It was never tested, because the example pins its +value axis (`scale.Domain(0, 120)`) and its tests check that frames are drawn, +not where. The behaviour is an accident of two correct pieces — a scale that +accumulates within a render, which is what lets several layers share an axis, +and a plot that keeps its scales between renders, which is what lets a zoom +survive the next frame — meeting in a case neither was written for. + +## Decision + +**Every render derives a trained range again from the rows it draws. A pinned +domain is not trained and is kept; a set of discovered names is identity and +is kept; and a scale that should remember its extent says so.** Six claims. + +### 1. A trained range is forgotten at the start of each render + +Before any layer trains, render asks every scale it will train — each panel's +X, Y, X2 and Y2, the tracks' own scales, and the colour and size scales its +layers describe (claim 4) — to forget what it was trained on, once per scale +object. Then the layers train as they always have, accumulating across layers +within the render. The domain an axis ends a render with is therefore the +extent of the rows that render drew, which is what a reader takes an axis to +say. + +"Once per scale object" is the same rule 0089's `refit` had, for the same +reason: panels that share an axis share one scale, and forgetting it between +two of them would forget the first panel's rows. + +The forgetting is a new optional method, `Retrain()`, behind an interface of +its own — `scale.Retrainer` — on the scales with a trained range. It is not +`Zoomer.Autoscale`: autoscale releases a pin, and a pinned domain must survive +(claim 2). `Train` keeps its documented contract, *calling Train repeatedly +accumulates*, because within a render that is exactly what is wanted. + +### 2. A pinned domain is kept + +A domain fixed at construction (`scale.Domain`, `LogDomain`), set by a zoom or +a pan, or set by a caller's `Zoomer.SetDomain` is not trained and is not +forgotten: `Retrain` on a pinned scale does nothing. That is what keeps a zoom +across frames, the one thing the old behaviour was quietly also providing — +and it provided it only because training a pinned scale is already a no-op, so +nothing about zooming changes. + +A fitting axis (0089) is unpinned by construction and so is simply one of the +scales this retrains. 0089's `refit` becomes this rule and is removed. + +### 3. A set of names is remembered + +An ordinal axis that discovers its categories, and a qualitative or named +colour scale that discovers its keys, are **not** retrained. A category's slot +on an axis and a series' colour are identity: a reader who learned that +"Störung" is the third row, or that the orange line is machine B, reads every +later frame by it. Rediscovering them per frame would reorder the slots and +repaint the series whenever the rows arrived in a different order, and a +colour that changes between frames is a colour that lies about which line is +which. + +So a name, once seen, keeps its slot and its colour until the plot is rebuilt; +a category that stopped appearing leaves its slot empty, which is visible and +true. A caller who wants the set to follow the data fixes it +(`scale.Categories`) or rebuilds. This is the one asymmetry in the record and +the reason it is stated as a claim: a *range* is a measurement of the rows, a +*set of names* is a vocabulary for them. + +### 4. Continuous colour and size scales follow the range rule + +A sequential or diverging colour ramp and a size scale have a trained range +like an axis, and the same accident: a heatmap of a stream keeps its colour bar +stretched to a peak the window dropped, and every cell after it is drawn in +the bottom third of the ramp. They are retrained with the axes. Render reaches +them through each layer's `Describe` — `Desc.ColorScale` and `Desc.SizeScale` +— which every layer in the package answers; a third-party layer that does not +describe itself keeps its colour scale's old behaviour, which is the old +behaviour and not a wrong one. + +A discrete colour scale is claim 3's, and a bivariate one retrains its range +and keeps its classes. + +### 5. A scale that should remember says so + +`scale.HighWater()` is a linear option for the chart that wants the old +behaviour on purpose: a monitor whose axis shows the worst load since it was +opened, a counter that must not appear to fall. It accumulates across renders +until `Autoscale`. It is an option rather than the default because it is the +rarer question — *what is the most this has ever been* — and because a chart +that answers it should say that it does, which a default cannot. + +### 6. What it costs + +- **An axis now moves when its data does.** A live chart with an unpinned value + axis relabels whenever the extent of its window changes — which is what it + claims to show, and what `examples/stream`'s comment argues for pinning + against. That example keeps its pinned axis; its time axis, unpinned, now + slides as its comment says, and the damage pass redraws what sliding moves. +- **A static render is unchanged.** One `Plot.Render` on fresh scales retrains + nothing that was trained, and no golden file changes — the claim a test + makes by running the golden suite untouched. +- **Retraining is O(scales), not O(rows)**: a scale forgets two numbers. + +## Consequences + +- A windowed stream's time axis slides, and its value axis describes the + window. +- `Plot.Render` called again after the data changed draws the data it has now. +- A zoom, a pan and a fixed domain are kept exactly as before. +- Series keep their colours and categories their slots across frames. +- `scale` gains `Retrainer` and `HighWater`; render gains one pass before + training; 0089's `refit` is folded into it. + +## Not in scope + +- **Easing an axis between frames.** A moving axis jumps; animating it is a + transition ([ADR 0063](0063-an-overlay-over-a-scene.md)-adjacent host work), + not a scale. +- **Forgetting names.** Claim 3. A vocabulary that should shrink is a caller's + `scale.Categories`, or a rebuild. +- **Three-dimensional scenes.** A `three.Scene` trains its own scales on its own + schedule — once per render, not once per view, by its own comment — and + follows the same rule by the same mechanism when it next needs to; it is not + changed here. + +## Revisit if + +- A category axis over a stream turns out to need forgetting often enough that + fixing its categories is the recipe's cost everywhere. +- A third-party layer's colour scale is reported to grow; that is a layer that + does not describe itself, and the answer is `Describer`, not a second route. + +## Order of work + +Every step is built. + +1. `scale.Retrainer` and `scale.Retrain` in `scale/retrain.go`, on linear, log, + symlog, time, probability, the continuous and classed colour scales, both + bivariate scales and the size scale; a no-op when pinned. `scale.HighWater`, + written `"highWater": true`. +2. Render's `retrain` pass before training, replacing 0089's `refit`. Tested + with the probe's stream: X slides to the window, Y drops the peak, a zoom + survives, `HighWater` remembers, a series keeps its colour and a category + its slot, and a continuous ramp describes the frame. +3. `examples/stream` gains `TestAFullWindowSlides`, deterministic rather than + raced against its producer, and its chart is built by a function the test + shares. + +## Amendment — what building it decided + +- **No identity check.** Render forgets every reachable scale, shared ones + perhaps several times, before any layer trains; forgetting is idempotent, so + there is no set of scales to build and no interface comparison — which would + panic on a third-party scale whose dynamic type is not comparable. +- **A classed scale forgets its sample, not its classes.** A quantile scale's + boundaries are cut from the values it was trained on, so they are forgotten + with the range and cut again; a threshold scale's boundaries are given and + stay. +- **A flat window is still padded.** A window whose values are all 5 trains a + domain of [4.75, 5.25], the padding every degenerate linear domain gets; what + the record promised is that the peak it dropped is gone, and that is what the + test checks. +- **Nothing else moved.** No golden file changed and every existing test + passed unchanged: a single render on fresh scales forgets nothing that was + trained, and a zoom was already a pinned domain. diff --git a/docs/adr/0091-a-direction-is-a-colour-channel-any-mark-can-take.md b/docs/adr/0091-a-direction-is-a-colour-channel-any-mark-can-take.md new file mode 100644 index 0000000..b3313d4 --- /dev/null +++ b/docs/adr/0091-a-direction-is-a-colour-channel-any-mark-can-take.md @@ -0,0 +1,179 @@ +# 0091 — A direction is a colour channel any mark can take, read from two columns and painted in the candle's colours + +**Status:** Accepted, amended · **Date:** 2026-09-28 · **Implemented:** 2026-09-28 · **Revisits:** [ADR 0088](0088-a-candle-is-one-mark-that-reads-four-values.md)'s *Revisit if* + +## Context + +[ADR 0088](0088-a-candle-is-one-mark-that-reads-four-values.md) gave the +candle its own direction: the mark reads open and close and decides, per row, +whether the row rose. It left one thing on its *Revisit if*: *"Charts colour +their volume bars by direction often enough that computing the column is the +recipe's cost all over again."* They do. Every chart in this branch that has a +volume track colours it by direction, and every one of them pays for it the way +the candle used to: + +- `examples/market` computes a `dir` column of `"up"` and `"down"` by hand and + colours the volume bars through a `scale.Named` built from the same two + colours the candle layer was given — two places that must agree, and nothing + that says so when they do not. +- `examples/market -live` cannot do it at all. Its rows come from a + `data.Stream`, which carries numbers only (ADR 0016), so the volume bars of + the live chart are one colour. +- `SincePrevious` has no volume counterpart. A candle told to compare with the + previous close colours a gap-down day falling; the volume bar under it, fed + a column computed from open and close, colours the same day rising. + +The question is not special to markets. A bar coloured by whether actual beat +plan, a dumbbell coloured by whether a value improved, a line whose segments +are drawn by whether the series is climbing — each is a row's colour decided +by comparing two of its values, and each today is a derived column the +`data.Table` does not have. + +## Decision + +**`geom.DirectionBy(from, to)` is a colour channel: each row rises when its +`to` is at or above its `from`, and is painted in the rising or falling colour +the candle uses. Any mark that takes `ColorBy` takes it, and the two are +exclusive.** Six claims. + +```go +p.Add(geom.Candle(src, geom.X("start"), geom.X2("end"), geom.OHLC("open", "high", "low", "close"))) +p.Track(figure.Bottom, …). + Add(geom.Bar(src, geom.X("start"), geom.Y("volume"), geom.DirectionBy("open", "close"))) +``` + +### 1. The direction is read from two columns, or from one and the row before + +`DirectionBy(from, to)` compares two numeric columns of the same row: the row +rises when `to ≥ from` — the candle's rule, a tie rising. `DirectionBy("", to)` +compares `to` with the previous row's `to`, which is the candle's +`SincePrevious`; the first row has nothing to compare with and rises, as the +candle's first row falls back to its own open. + +Two columns rather than a column of signs because the columns are what the +table has. A candle's source has an open and a close and no direction; a +stream has numbers and no strings; a plan-and-actual table has two figures. +Asking any of them for a third column is the recipe this record removes. + +The previous row is the previous row *of the layer*, in table order, which is +the order a stream appends in and the order `stat.OHLC` writes candles. A +layer that draws its rows out of order is a layer whose "previous" means +something else, and it should say so with two columns. + +### 2. It is a colour channel, so every mark that has one takes it + +The direction becomes each row's colour through the channel `ColorBy` already +feeds: a mark that paints per row from a colour column paints per row from the +direction, with no code of its own. That is `Bar`, `Rect`, `Scatter`, +`ErrorBar`, `Text`, `Area` and the paths — a `Line` or `Step` coloured by +direction draws each stretch rising or falling, which is the "trend line +coloured by slope" nobody could draw before. + +`ColorBy` and `DirectionBy` on one layer is an error at `Train`: a row has one +colour, and a layer that named two ways of choosing it has not said which. A +mark that does not take `ColorBy` — a histogram, a relational layout — does not +take this either, and says so the way it says so for `ColorBy`. + +### 3. The colours are the candle's + +`geom.Rising(color, label)` and `geom.Falling(color, label)` — the candle's two +options — set the colours and the labels; without them, the theme's `Rising` +and `Falling` and the labels "rising" and "falling". A volume track under a +candle chart that names neither therefore paints in exactly the candle's +colours, and one where both were given the same options paints in the same +ones — the agreement the hand-written `scale.Named` had to be kept in by hand. + +The colours resolve against the theme a frame is drawn in, as the candle's do, +so a chart switched to the dark theme repaints its volume bars with it. A row +whose `from` or `to` is missing is drawn in the colour a colour scale gives an +undefined value — it has no direction to show — and is an error under +`OnMissing(Error)`. + +### 4. The legend says rising and falling once + +A direction layer contributes two legend entries, labelled as claim 3 says. +Render already merges legend entries by label, so a volume track under a +candle layer with the same labels adds nothing to a legend that already reads +rising and falling; a layer whose labels differ adds its own two, which is +what a chart with two different notions of direction should show. + +### 5. The dialect + +The direction is two encoding channels, figure's own: `"direction"` for `to`, +and `"directionFrom"` for `from`, left out when it is the previous row. The +rising and falling colours and labels are the mark properties 0088 already +defined, now written for any mark that carries a direction. + +### 6. What it does not do + +- **Three states.** Unchanged is rising, as the candle has it. A chart that + wants "flat" as a third colour has a threshold, not a direction, and + `ColorBy` over a difference column is what it is. +- **A magnitude.** How far a row rose is a quantity, and a quantity is + `ColorBy` through a diverging ramp. Direction is the binary reading, and the + reason it earns a channel is that it needs no column. + +## Consequences + +- `examples/market`'s `dir` column and its `scale.Named` disappear; its volume + bars take `DirectionBy("open", "close")`. +- The live chart's volume track is coloured by direction for the first time, + straight from the stream's numeric columns. +- A candle using `SincePrevious` and a volume track using `DirectionBy("", + "close")` agree on every row. +- `geom` gains one option; the candle's `Rising` and `Falling` are read by + every mark that carries a direction; the dialect gains two channels. + +## Not in scope + +- **Derived columns in general.** `data.Table` still has none. A direction is + the one comparison common enough to be a channel; anything else — a spread, a + return, a ratio — is a column the caller computes, or a function in `stat`. +- **Direction on a mark without a colour channel.** Claim 2. +- **Hollow and filled on other marks.** The candle's second channel for + direction (0088 claim 3) is about bodies; a bar is already one filled + rectangle, and a hollow volume bar reads as missing rather than as rising. + +## Revisit if + +- A third comparison — "above the moving average", "inside the band" — is + wanted often enough to be a channel too, which would make this one of a + family rather than a special case. + +## Order of work + +Every step is built. + +1. `geom.DirectionBy` in `geom/direction.go`, with `ErrColorAndDirection`; + tested on a bar, a line and a scatter, for a tie, for the previous-row form, + against a candle in both directions, for the legend's two labels and for the + Desc round trip. At chart level: a volume track under candles adds nothing + to the legend, and a theme's `Rising` and `Falling` repaint the bars. +2. `"direction"` and `"directionFrom"` in the dialect, round-tripped on a bar, + a scatter with named colours and a line. +3. `examples/market` drops its `dir` column and its `scale.Named`; `-live` + colours its volume track from the stream's numbers. + +## Amendment — what building it decided + +- **A direction is a discrete colour scale of two labels.** `DirectionBy` + installs a scale whose labels are rising and falling and whose values are + each row's direction, so everything already written for a discrete colour — + batching bars by colour, a path coloured in stretches, one legend entry per + label, the undefined colour for a row with none — works unchanged. No mark + has a line of direction code. +- **The theme is bound in render's retrain pass.** The scale's colours are the + layer's options where given and the theme's otherwise, and a layer trains + without a theme — so render hands each colour scale that asks for one the + theme of the render it is in, in the same pass that retrains + ([ADR 0090](0090-an-axis-describes-the-frame-it-is-drawn-in.md)). A scale + never bound falls back to the shipped themes' blue and vermilion. +- **A row with no direction is not drawn.** The undefined colour of a colour + scale is transparent, so a row missing either value leaves a gap, which is + what claim 3 meant by "drawn in the colour a colour scale gives an undefined + value". +- **The first row of the previous-row form rises.** A candle under + `SincePrevious` falls back to its own open for its first row, so the two + agree there only when that first candle rose. The test compares them on data + where it did; a chart whose first candle fell sees its first volume bar in + the other colour. diff --git a/docs/adr/0092-what-a-trading-screen-reads-off-its-edges.md b/docs/adr/0092-what-a-trading-screen-reads-off-its-edges.md new file mode 100644 index 0000000..2636c89 --- /dev/null +++ b/docs/adr/0092-what-a-trading-screen-reads-off-its-edges.md @@ -0,0 +1,222 @@ +# 0092 — What a trading screen reads off its edges: a value is tagged on its axis, a crosshair snaps to a row, and an indicator earns a function only where composing it goes wrong + +**Status:** Accepted, amended · **Date:** 2026-09-28 · **Implemented:** 2026-09-29 · **Ranked by:** [ADR 0085](0085-what-a-market-chart-needs.md), rank 5 + +## Context + +[ADR 0085](0085-what-a-market-chart-needs.md) put three things last, together: +named indicators, a last-price label, and a crosshair that snaps to the +candle. Ranks 1 to 4 are built (ADRs 0086 to 0091), so a market chart now has +its calendar, its candles, a price axis that fits the zoom, a live last +candle and volume by direction. What it still lacks is what a trader reads off +the chart's *edges* rather than its middle, and the handful of indicators every +platform names. + +The three look unrelated. Two of them are not: + +- **The last price** is a number written on the price axis at the height of + the last close, in the colour of the last candle, with a thin rule across the + panel to it. Every trading screen draws it, because the right-hand edge is + where the reader looks and the axis is where they read a value. +- **A crosshair's readout** is the same thing at the pointer: the price at its + height tagged on the price axis, the time at its position tagged on the time + axis. `figure.Crosshair` draws the two rules and nothing else, clipped to the + panel so that a rule does not cross the axis it is read against. + +Both are a value written *on an axis*, over its tick labels, and nothing in the +library draws there. Every mark is clipped to its panel by render; the gutter +belongs to the axes. So this is one mechanism, needed twice. + +The third, snapping, is a question about rows rather than drawing. A +crosshair's `At` comes from the host's hover handler, and `interact.Index.At` +answers with the nearest mark in the plane — which, between two candles, is +the one the pointer is nearest to in both directions, and over a tall candle +may be a different one than the time the pointer is at. A trader means *the +candle at this time*: nearest along X, whatever the height. + +And the indicators: ADR 0085 left RSI, MACD and VWAP as compositions of the +trailing functions and asked whether they earn names. The answer differs per +indicator, which is why it is a decision. + +## Decision + +**A value can be tagged on its axis: render draws it in the axis's gutter, over +the tick labels it covers. The last value of a column is a mark that draws a +rule and a tag. A crosshair can tag its own position, and the index answers +which row is nearest along one axis. `stat` gains RSI and VWAP, which composing +gets wrong, and not MACD, which composing gets right.** Four parts. + +### 1. An axis tag is furniture that a layer or an overlay asks for + +```go +type AxisTag struct { + Axis Axis // X, Y, X2 or Y2 + Value float64 // in the axis's domain + Text string // empty: the axis's own tick formatter writes the value + Color ir.Color // the tag's fill; its text is chosen to read against it +} +``` + +A tag is a filled box on the axis line at `Value`, holding `Text`, drawn after +the axes and before the guides. Its text is the axis's own tick label format +for that value unless given, so a price tag reads `1,234.50` where the ticks +read `1,234` and a time tag reads the time the way the axis writes it. The box +reaches into the gutter by the width of its text; the tick labels it covers are +not drawn, because a tag is the more specific statement — the value *here* — +and two labels overprinting each other are two labels nobody can read. + +A layer asks for tags through an optional interface, `geom.Tagger { Tags(f +Frame) []AxisTag }`, rather than a method on `Geom`, which does not grow +([ADR 0084](0084-what-v1-promises.md)). An overlay asks through the same +struct, drawn by the overlay after render has laid the axes out. A tag at a +value the axis does not show — off a zoomed axis, inside a fold — is not drawn, +and a tag on an axis the coord does not draw — a polar chart — is not either. + +Tags on one axis are placed in value order and pushed apart until they do not +overlap, the way `Text` labels are placed ([ADR 0040](0040-label-collision-avoidance.md)), +so a last price and a crosshair readout a pixel apart are both legible. + +### 2. The last value is a mark + +```go +geom.LastValue(src, geom.X("start"), geom.Y("close"), geom.DirectionBy("", "close")) +``` + +`geom.LastValue` reads a position and a value column, takes the row with the +greatest position — the newest, whatever order the rows are in — and draws a +rule across the panel at its value and a tag on the value axis. Its colour is +`Color`, or its direction's through `DirectionBy` +([ADR 0091](0091-a-direction-is-a-colour-channel-any-mark-can-take.md)), so the +tag is blue when the last candle closed up. `geom.Dash` styles the rule and +`geom.Rule(false)` leaves the tag alone. On a stream it follows the open candle +by construction, because the open candle is the stream's last row +([ADR 0089](0089-a-value-axis-fits-what-its-time-axis-shows.md)). + +It is a mark rather than an option on `Candle` because the question is not a +candle's: a line of a sensor reading, a gauge's history and a portfolio value +all end at a number the reader wants on the axis. + +### 3. A crosshair snaps to a row and tags its axes + +`interact.Index.NearestAlong(panel int, axis Axis, at float32) (RowRef, bool)` +answers the row whose reported position is nearest `at` along one axis, over +the layers of a panel that track rows. It is the query a trader's crosshair +needs — the candle at this time — and a scatter's does not, which is why it is +a query beside `At` rather than a change to it. + +`Crosshair` gains `SnapX` and `Tags`: + +- **`SnapX`** — the host passes the pointer as before; the crosshair's vertical + rule is drawn at the X of the row `NearestAlong` finds, and its horizontal + rule at that row's reported position — for a candle, its close + ([ADR 0088](0088-a-candle-is-one-mark-that-reads-four-values.md) claim 6). + Without row tracking there is nothing to snap to and the crosshair follows + the pointer, as today. +- **`Tags`** — the crosshair tags its X and Y on their axes, through claim 1, + in the theme's annotation colour. + +Snapping is an option of the overlay rather than a host's arithmetic because +the overlay already has the frame and the index, and a host that has to invert +a device position, query the index and map the answer back is a host that gets +one of the three steps wrong. + +### 4. An indicator earns a function where composing it goes wrong + +- **RSI earns one.** It is not a composition of the functions `stat` has: its + averages are Wilder's, an exponential average with α = 1/n rather than the + 2/(n+1) `EMA` uses, seeded with the simple mean of the first n changes. A + reader told "RSI 14" expects that definition, and a composition from `EMA` + gives a different curve that is close enough to be trusted and wrong enough + to be noticed. `stat.RSI(xs, ys, window)` and its Append pair, in [0, 100]. +- **VWAP earns one.** It is two cumulative sums divided — but reset at the + start of every session, which is the part a composition from `Cumsum` + forgets: a VWAP that runs across the overnight is not the number any + platform shows. `stat.VWAP(ts, ps, vs, edges)` takes the session edges a + calendar's `Opens` gives ([ADR 0086](0086-a-calendar-says-when-time-counts.md)), + as `OHLCAt` does. +- **MACD does not.** It is `EMA(12) − EMA(26)` and a signal line `EMA(9)` of that + difference, and that composition is the definition — nothing in it is + seeded, reset or smoothed differently from what `stat.EMA` already does. It + is documented as a recipe, with its three columns drawn as two lines and a + histogram in a bottom track, and it earns a function the day a definition + detail turns out to differ. + +The rule is the one `stat`'s package comment already states — numbers in, +numbers out — sharpened into a test: a function exists when the obvious +composition gives the wrong numbers. + +## Consequences + +- A market chart can show its last price on the price axis, in the direction's + colour, following the live candle. +- A crosshair can snap to the candle under the pointer's time and read its time + and close off the axes. +- The first thing drawn outside a panel by something other than an axis is a + tag, and it has a rule for what it covers. +- `stat` gains `RSI` and `VWAP`; the RSI panel is a bottom track with an + `HBand` from 30 to 70, the MACD panel a bottom track of two lines and a bar. + +## Not in scope + +- **Other indicators** — stochastics, ATR, ADX, Ichimoku, Keltner. Each is a + question for the rule in claim 4 when someone asks for it; most are + compositions, and a few have a Wilder average inside, which `RSI`'s + implementation will have made a helper by then. +- **Drawing tools** — trend lines, Fibonacci retracements, a reader's own + annotations. They are state a reader creates, which the host owns, drawn + with the marks that exist. +- **A tag that is dragged** — an alert level set by moving its tag. Input is + the host's ([ADR 0045](0045-linked-views.md)); the tag is where it would be + drawn. +- **Snapping along Y.** `NearestAlong` takes either axis, but a crosshair's + `SnapX` is the case asked for; a `SnapY` waits for a chart that wants it. + +## Revisit if + +- Tags are wanted on an axis of a coord other than Cartesian. +- An indicator composition in the docs turns out to disagree with a platform's + numbers, which is claim 4's rule telling us it earns a function. + +## Order of work + +Every step is built. + +1. `geom.AxisTag`, `geom.TagAxis` and `geom.Tagger` in `geom/tag.go`; render + places a panel's tags after ranging it, blanks the primary tick labels they + cover, and draws them after the data (`render/tag.go`). + `render.DrawAxisTags` draws an overlay's. +2. `geom.LastValue` with `geom.Rule`, written `"lastValue"` with `"rule"`. +3. `interact.Index.NearestAlong` with `AlongX`/`AlongY`; `Crosshair.Snap`, + `Tags` and `TagColor`. +4. `stat.RSI` and `stat.VWAP` with their Append pairs, and the MACD, RSI and + VWAP recipes in `docs/chart-types.md`. +5. `examples/market` gains an RSI track and a last-price tag; `-live` a + last-price tag following the open candle, and a crosshair snapping to the + candle under a resting pointer, drawn into the file it writes. + +## Amendment — what building it decided + +- **A crosshair is handed its index.** Claim 3 said the overlay already had the + index; it does not — an `OverlayFrame` carries panels and a theme, and the + index is the `Live`'s. So `Crosshair.Snap` is a field the host sets to + `live.Index()`. The rows it snaps to are the current frame's, because the + overlay paints at the end of the render that reported them. +- **A value read off an axis gets its own label.** A tick label has the tick + step's precision, so `scale.LabelOf` would have written a last price of 94.37 + as "94", and a time axis is not a `Labeller` at all. `scale.ValueLabelOf` + writes a linear value with as many decimals as it needs, up to two more than + the ticks — 7.25, 94.37, 92.40 — and a time value as its date, with the time + of day when it is not a midnight. +- **An overlay's tags cover; a layer's tags hide.** A layer's tags are known + before the axes are drawn, so the tick labels they cover are left out. An + overlay draws after the axes, so its tags are opaque boxes over whatever is + there. Tags on a secondary axis cover rather than hide as well: the + secondary axes are drawn by a separate pass that does not take a tick list. +- **RSI is checked against the arithmetic, and the published page to its + rounding.** StockCharts' example computes from closes it prints rounded to + the cent, so its RSI sits a few hundredths from what the printed closes give + — 70.53 published, 70.46 from the printed data. The test checks the first + value exactly against the seed's own sums and the published ones to 0.1. +- **A last value trains its value axis on the newest value only.** It + annotates a series another layer draws; widening the axis to rows it does + not show would be a mark changing an axis it does not describe. diff --git a/docs/adr/0093-several-symbols-are-a-facet-whose-panels-carry-their-tracks.md b/docs/adr/0093-several-symbols-are-a-facet-whose-panels-carry-their-tracks.md new file mode 100644 index 0000000..aadadd7 --- /dev/null +++ b/docs/adr/0093-several-symbols-are-a-facet-whose-panels-carry-their-tracks.md @@ -0,0 +1,187 @@ +# 0093 — Several symbols are a facet whose panels carry their own tracks, and a comparison divides each series by its value where the view begins + +**Status:** Proposed · **Date:** 2026-09-29 · **Revisits:** `ErrTrackWithFacet`, and [ADR 0031](0031-tracks.md) + +## Context + +Everything [ADR 0085](0085-what-a-market-chart-needs.md) ranked is built, and +all of it is about one symbol. The two questions a screen with more than one +asks are not answerable yet. + +**Several symbols, each with its volume.** A watchlist of four stocks is four +candle panels, each with a volume track under it, on one time axis, zoomed +together. The library has both halves and refuses the pair: + +- `Plot.Facet` splits one plot by a column — the symbol — into panels that can + share or free their axes, and a `Live` over a faceted plot zooms them + together through the shared X scale object. +- `Plot.Track` gives a panel a band on a shared axis — the volume. +- A plot with both is `ErrTrackWithFacet`: *"A facet owns the grid's rows and + columns … a track needs a row of that grid to live in. A band spanning a + facet is a different feature with different questions to answer, so this is + refused rather than guessed at."* + +The refusal was right about the feature it named. A band *spanning* the facet +— one volume strip under all four symbols — is a different feature. But that +is not the one asked for. A watchlist wants the track **repeated**: each facet +panel with its own, exactly as each facet panel has its own copy of the +layers. The questions the refusal worried about are the ones repetition +answers already. + +`Grid` is the other multi-panel tool, and it does not help: a grid takes its +members' layers and scales and drops their tracks, and it has no `Live` +(*"Interaction on stacked plots is what a track inside one plot is for"*). + +**A comparison.** Four stocks on one axis are unreadable in their own units — +a share at 2,000 and one at 20 draw one line and one floor. Every platform's +comparison chart draws each series as its change since a common starting +point, in percent, and the starting point is the left edge of the view: pan to +last March, and every line starts at 0 % in last March. Nothing in the library +rebases a series, and because the starting point moves with the view, it +cannot be a column the caller computes once. + +## Decision + +**A track on a faceted plot is repeated in every facet panel, its layers +subset by the facet like the panel's own. A value layer can be drawn relative +— divided by its value where the view begins — so a comparison is a line per +symbol on one fitted axis written in percent.** Six claims. + +### 1. A track is repeated per facet panel + +```go +p := figure.New() +p.X(scale.Time(...)).Y(scale.Linear(scale.FitView())) +p.Add(geom.Candle(quotes, geom.X("start"), geom.X2("end"), geom.OHLC("open", "high", "low", "close"))) +p.Facet(facet.Wrap("symbol", facet.Columns(2), facet.FreeY())) +p.Track(figure.Bottom, figure.TrackFraction(0.2)). + Add(geom.Bar(quotes, geom.X("start"), geom.Y("volume"), geom.DirectionBy("open", "close"))) +``` + +With a facet, every track is laid out once per facet panel, on the same edge, +in the same order, and its layers are subset by the facet's column exactly as +the panel's layers are — through `Faceter`. The volume under the AAPL panel is +AAPL's volume. A track layer that is not a `Faceter` is refused with an error +naming it, as a panel layer that is not one already is. + +`ErrTrackWithFacet` is retired. The band spanning a whole facet that it +anticipated stays unbuilt, and is what *Not in scope* names. + +### 2. A track's scales follow the facet's + +The axis a track shares with its panel is that panel's — the same scale object, +so under a shared X one zoom moves every symbol and every volume strip, and +under `facet.FreeX` each block keeps its own. The axis a track owns follows the +facet's freedom on that direction: a bottom track's own Y is cloned per panel +when the facet frees Y, so each symbol's volume has its own scale, and shared +otherwise, so volumes are comparable across symbols. The rule is one sentence +because it is the one a reader expects: a track is free where its panel is. + +A fitting axis ([ADR 0089](0089-a-value-axis-fits-what-its-time-axis-shows.md)) +fits per panel, which is what that record predicted for a facet with free axes: +each symbol's price axis fits its own candles in the shared zoomed view. + +### 3. The layout is a block per facet panel, and the panel order is fixed + +Each facet cell becomes a block of rows and columns — the data panel with its +tracks round it, as a single tracked plot is laid out today (`Plot.tracked`) — +and the facet's grid is a grid of blocks. The strip that names a facet panel +sits above its block, not between the panel and a top track. Blocks in one row +of the facet have the same tracks, because the tracks are the plot's, so their +rows line up by construction. + +The panel order a `Hit` names and an `Observer` is told is: for each facet +panel in the facet's order, its data panel, then its tracks in the order they +were added. It is fixed here, as `Plot.tracked` fixed its own, because a +tooltip that knew "panel 2 is AAPL's volume" must not see it renumbered by a +symbol added to the data. + +### 4. A value layer can be drawn relative to where the view begins + +`geom.Relative()` draws each series as its change since its first value in +view: `y / y₀ − 1`, where `y₀` is the series' first finite value at or after +the start of the view, per group when the layer is grouped. The start of the +view is `Training.Within`'s lower end when the value axis fits a pinned X +([ADR 0089](0089-a-value-axis-fits-what-its-time-axis-shows.md)) and the first +row otherwise, so an unzoomed comparison starts every line at 0 % on the left, +and panning moves the zero with the view — the behaviour every platform's +comparison chart has, derived every frame by the retraining that already +happens ([ADR 0090](0090-an-axis-describes-the-frame-it-is-drawn-in.md)). + +```go +p.Y(scale.Linear(scale.FitView(), scale.NumberFormat("+#.1%"))) +p.Add(geom.Line(quotes, geom.X("t"), geom.Y("close"), geom.GroupBy("symbol"), geom.Relative())) +``` + +It is a layer option rather than a scale because the base is a property of each +series, not of the axis: four symbols on one axis have four bases, and an axis +that divided by one of them would draw three lines wrong. It is read by the +marks that draw a value — `Line`, `Step`, `Area`, `Scatter` — and by `Candle`, +whose four values are divided by one base, the series' first close in view. A +series whose base is zero or missing has no relative value and is not drawn. + +### 5. The axis says it is relative + +A relative layer's values are fractions; the axis writes them with +`scale.NumberFormat`'s percent style, which the caller chooses. A comparison is +read by its sign, so the spec gains one flag: a leading `+` writes positive +values with a plus — `"+#.1%"` is `+12.5%`, `-3.0%` and `0.0%` — which the +spec's grammar has had no way to say, and which a zero leaves unsigned because +no change has no direction. The record does not pick a default format, +because an axis cannot know that every layer on it is relative, and a +comparison with an absolute line on the same axis — an index level, a +benchmark — is a mistake the axis should not paper over. + +`geom.LastValue` over a relative layer tags the last value relative, in the +axis's own format, so each symbol's line ends in a tag reading its change. +That is `LastValue` reading `Relative` like any other mark — a claim about +composition, and a test. + +### 6. The dialect + +A facet with tracks writes both, as each is written today; a document that had +both was an error and is now a chart. `"relative": true` is a mark property, +figure's own. + +## Consequences + +- A watchlist is one plot: a facet by symbol, a volume track, a shared time + axis zoomed by one wheel and a price axis fitted per symbol. +- A comparison chart is one layer option and one number format, and its zero + follows the view. +- `ErrTrackWithFacet` is retired; `geom` gains `Relative`; `NumberFormat` + gains a `+` flag. +- Panel indices of a faceted plot with tracks are the block order of claim 3. + +## Not in scope + +- **A band spanning a facet** — one strip under all its panels. The feature + `ErrTrackWithFacet` was reserving; nobody has asked for it, and repetition is + what a watchlist wants. +- **`Grid` interaction.** A grid of plots of different kinds — a price beside a + macro series — stays a static render. The multi-symbol case is one plot, and + a grid's `Live` is a record of its own when a dashboard asks for one. +- **A relative scale.** Claim 4. +- **Rebasing on a chosen date** rather than on the view — "since the IPO". The + caller divides once; it is a column, because it does not move. +- **Correlation and spread charts** between symbols. They are computed series, + drawn with the marks that exist. + +## Revisit if + +- A dashboard needs linked zoom across plots of different kinds, which is + `Grid` with a `Live`. +- A comparison needs log-relative change (`ln(y / y₀)`) often enough to be an + option of `Relative` rather than a caller's column. + +## Order of work + +1. Facet × track: the block layout, the per-panel track subsets, the scale rule + of claim 2 and the panel order of claim 3; `ErrTrackWithFacet` removed; a + test that a wheel zooms every block and each fitted price axis fits its own + symbol. +2. `geom.Relative` on `Line`, `Step`, `Area`, `Scatter` and `Candle`, anchored + at `Training.Within` or the first row; `LastValue` over it; the dialect's + `"relative"`; the `+` flag of `NumberFormat`. +3. `examples/market` gains a watchlist — four symbols, faceted, with volume — + and a comparison of the same four on one relative axis. diff --git a/docs/adr/README.md b/docs/adr/README.md index a00edec..03189b3 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -93,6 +93,16 @@ depends on. | [0081](0081-a-map-projection.md) | A map projection is a coord that is handed degrees, and its graticule is the two tick lists a panel already has | Accepted | — | | [0082](0082-a-label-fits-its-box-or-is-called-out.md) | A label fits the shape of its box on screen, is moved, broken or shrunk before it is dropped, and may be called out of it | Accepted | — | | [0083](0083-an-axis-break-is-marked-or-not-drawn.md) | An axis break belongs to the scale, and it is marked or it is not drawn | Accepted | — | +| [0084](0084-what-v1-promises.md) | v1.0 is tagged when a machine can say the surface did not move, and a point counts as one value | Proposed | — | +| [0085](0085-what-a-market-chart-needs.md) | A market chart is a recipe over marks that exist, and what it lacks is a calendar, a candle and statistics that only look back | Proposed | 0083's business-day deferral, as a catalogue | +| [0086](0086-a-calendar-says-when-time-counts.md) | A calendar says when time counts; the axis takes the rest as folds and starts its weeks where the calendar does | Accepted, amended | 0083's business-day deferral | +| [0087](0087-an-orientation-is-the-encoding-read-a-quarter-turn-round.md) | An orientation is the encoding read a quarter turn round, and a mark turns only where it emits | Accepted | 0053's "bars and boxplots still have none" | +| [0088](0088-a-candle-is-one-mark-that-reads-four-values.md) | A candle is one mark that reads four values and decides its own direction | Accepted, amended | 0085's second rank | +| [0089](0089-a-value-axis-fits-what-its-time-axis-shows.md) | A value axis can fit what the other axis shows, and a stream can revise its last row | Accepted, amended | 0085's third rank | +| [0090](0090-an-axis-describes-the-frame-it-is-drawn-in.md) | An axis describes the frame it is drawn in: a trained range is derived again every render, and a set of names is remembered | Accepted, amended | 0089's open question | +| [0091](0091-a-direction-is-a-colour-channel-any-mark-can-take.md) | A direction is a colour channel any mark can take, read from two columns and painted in the candle's colours | Accepted, amended | 0088's volume-bar revisit | +| [0092](0092-what-a-trading-screen-reads-off-its-edges.md) | A value is tagged on its axis, a crosshair snaps to a row, and an indicator earns a function only where composing it goes wrong | Accepted, amended | 0085's fifth rank | +| [0093](0093-several-symbols-are-a-facet-whose-panels-carry-their-tracks.md) | Several symbols are a facet whose panels carry their own tracks, and a comparison divides each series by its value where the view begins | Proposed | `ErrTrackWithFacet` | Nothing in §17 is open any more. **§17.7**, the third-party geom and backend extension API, was the last, and it was held open on purpose until the @@ -328,3 +338,85 @@ implementation sharpened it, chiefly that the guide is a `scale.Threshold` over the fold's own boundaries rather than a `Quantize` asked to re-derive them, and that a row is reported at the band it ends in so that a hit test has one position per row to index. + +**0085 is a catalogue, the second after 0058**, and it is *Proposed* with its +first step built. It answers whether figure can draw a trading screen's chart +— candles on a calendar, volume under the price, the volume at each price +beside it, indicators over it — and the answer is that every part composes +from marks that exist, which `examples/market` draws. What it cannot compose it +ranks: a trading calendar first, which is 0083's deferred business-day axis; +a candle as one mark second; a live chart's price autoscale and replaceable +last row third. It keeps the Stat interface refused, because an indicator is +the case the refusal fits best — numbers in, numbers out, drawn by an ordinary +mark — and it landed the functions that case needs: the trailing windows, an +EMA, a rolling spread, OHLC resampling and a weighted bin, with a downward +triangle for the sell. Each ranked mechanism gets a record of its own. + +**0086 and 0087 follow from 0085.** 0086 is the calendar 0085 ranked first, +*Accepted, amended* and built, and it is not a market's: a calendar is an interface that says +which stretches of each day count — a working week of any days, a roster, a +term list, a calendar computed in another calendar system — with a +calendar composed from rules about days the library ships — `Weekdays`, +`Dates`, `EveryNth`, `And`, `Not` — and no holiday tables. +Its closed time becomes folds for an interval the caller names, so the dialect +only gains a week start; the week start becomes the axis's, which until now +was Monday by an accident of Go's zero time; and a day tick that lands in a +fold moves to where time next counts, because with the nights folded every +midnight is inside one. 0087 is 0085's fourth rank, *Accepted*: `geom.Orient` +now lays a bar, a histogram and a boxplot on their side, with the roles of the +columns swapped along with the axes and the turn made once, where each mark +emits. + +**0088 is 0085's second rank**, *Accepted, amended* and built: `geom.Candle` reads a position and +the four values through one option, `geom.OHLC`, decides each row's direction +itself, and draws the wick and the body as one mark with one legend — in four +calls whatever the row count. The OHLC bar is a style of it rather than a +mark beside it, direction is drawn twice (colour, and hollow or filled), and +the dialect gains `"candlestick"` with four channels of its own rather than +spending `y` and `y2` on two of the four values. + +**0089 is 0085's third rank**, *Accepted, amended* and built: the two halves of a live market +chart. `scale.FitView` makes a value axis fit the rows its panel's pinned X +axis shows, handed to each layer as `Training.Within` in the one training pass +there already is — a layer that cannot restrict its training fits loosely +rather than clipping — and `Live`'s wheel, rubber band and pan leave a fitting +axis alone. `data.Stream.ReplaceLast` is the one revision a stream admits, the +newest row, and `stat.Resampler` is `stat.OHLC` turned inside out for a feed +that arrives a tick at a time. + +**0090 is the question 0089 left open**, *Accepted, amended* and built: a trained domain only +widens and render keeps its scales between renders, so a windowed stream's +axes never shrank — its time axis kept the history the window had dropped, +and `examples/stream`'s claim that a full window slides was never true. Every +render now derives a trained range again, once per scale, before its layers +train; a pinned domain is kept, so a zoom survives as before; and a set of +discovered names — a category's slot, a series' colour — is remembered, +because it is identity rather than measurement. `scale.HighWater` is the +opt-in for an axis that should remember its extent. + +**0091 answers 0088's *Revisit if***, *Accepted, amended* and built: volume bars coloured by +direction were a hand-computed column again, and impossible on a stream, which +carries numbers only. `geom.DirectionBy(from, to)` is a colour channel any +mark with `ColorBy` takes — a row rises when `to` is at or above `from`, or +above the previous row's `to` — painted in the candle's `Rising` and `Falling` +colours, so a volume track agrees with its candles without being told to, and +the legend's merge by label says rising and falling once. + +**0092 is 0085's fifth rank**, *Accepted, amended* and built, and it found that two of its three +items are one mechanism: the last price and a crosshair's readout are both a +value written on an axis, and nothing drew outside a panel but the axes. An +`AxisTag` is furniture a layer (`geom.Tagger`) or an overlay asks for, drawn in +the gutter over the tick labels it covers; `geom.LastValue` is the mark that +asks for one; `Crosshair` snaps to the row nearest the pointer along X through +a new `Index.NearestAlong` and tags its own position. Of the indicators, RSI +and VWAP earn functions — Wilder's smoothing and the session reset are what a +composition gets wrong — and MACD stays a recipe, because composing it is its +definition. + +**0093 takes the market chart past one symbol**, *Proposed*. A watchlist is +a facet by symbol whose panels each carry the plot's tracks — repeated, not +spanning, which is why `ErrTrackWithFacet`'s refusal of a spanning band never +applied to it — with a track free where its panel is and a fixed block order +for panel indices. A comparison is `geom.Relative()`: each series divided by +its first value in view, so the zero follows a pan, written by a percent axis +that gains a `+` flag for the sign a comparison is read by. diff --git a/docs/chart-types.md b/docs/chart-types.md index ae52434..8b2a1bd 100644 --- a/docs/chart-types.md +++ b/docs/chart-types.md @@ -71,7 +71,14 @@ and it turns eight charts into recipes. | Status timeline | `Step` + `ColorBy` over `scale.Named` — see `examples/status` | | Calendar heatmap | `Rect` + a date→(week, weekday) helper | | Gantt / timeline | `Rect` on a time X against an ordinal Y; `ProgressBy` fills each bar, `Depends` draws the constraints, a `Scatter` of diamonds the milestones and a `VLine` today — see [ADR 0068](adr/0068-gantt-charts.md) and `examples/gantt` | -| Candlestick / OHLC | `Rect` for open..close, a rule for low..high, colour by sign | +| Candlestick / OHLC | `geom.Candle` with `geom.OHLC(open, high, low, close)` — one layer that decides each row's direction; `geom.CandleStyle(geom.Ticks)` for the OHLC bar; `stat.OHLC` resamples ticks and `figure.CandleTable` makes its table; `scale.Folds` of a `scale.Calendar` takes the closed days out — see [ADR 0088](adr/0088-a-candle-is-one-mark-that-reads-four-values.md), [ADR 0086](adr/0086-a-calendar-says-when-time-counts.md) and `examples/market`. The recipe it replaces is still true: a `Rect` from open to close over an `ErrorBar` without caps from low to high | +| Volume under a price / volume profile beside it | a `Bar` in a bottom `Track` sharing time; a horizontal `Bar` (`geom.Orient(geom.Horizontal)`, grouped by side) in a right `Track` sharing price, from `stat.BinWeighted` — see `examples/market` | +| Bollinger band / Donchian channel | `Area` between `Y` and `Y2`, from `stat.TrailingMean` ± k·`stat.RollingStdDev`, or `stat.RollingMin` / `RollingMax` | +| RSI panel | `stat.RSI` (Wilder's, not an EMA composition) as a `Line` in a bottom `Track` with `scale.Domain(0, 100)`, and an `HBand(30, 70)` — see `examples/market` and [ADR 0092](adr/0092-what-a-trading-screen-reads-off-its-edges.md) | +| MACD panel | a recipe, because composing it is its definition: `macd = EMA(12) − EMA(26)` of the closes, `signal = EMA(9)` of `macd`, both as `Line`s in a bottom `Track`, and `macd − signal` as a `Bar` with `DirectionBy("", "hist")` | +| VWAP | `stat.VWAP(ts, ps, vs, edges)` with a calendar's `scale.Opens` as the session edges, drawn as a `Line` over the candles | +| Last price | `geom.LastValue` — a rule and a tag on the price axis at the newest row, in its direction's colour | +| Snapping crosshair | `figure.Crosshair{Snap: live.Index(), Tags: true}` over a `Live` with `TrackRows(true)` — the candle at the pointer's time, its time and close tagged on the axes | | Waterfall | `Rect` with per-row `y0`/`y1` from a running total, through `Y` and `Y2` | | Bullet | `Rect` bands, a measure bar and a target rule | | Waffle | a `Rect` grid from counts | diff --git a/docs/features.md b/docs/features.md index 7aeda78..aff42e9 100644 --- a/docs/features.md +++ b/docs/features.md @@ -22,30 +22,84 @@ The feature surface in one list: scales, marks, coordinate systems, layout, outp line, labelled on the 0.1 / 1 / 5 … 95 / 99 / 99.9 % ladder. An ECDF on it is a normal probability plot; median ranks (`stat.MedianRank`) on `CLogLog` against a log axis are a Weibull plot. + `scale.Break` leaves an interval out of a linear axis and marks the gap, so + one tall bar and the short ones beside it can both be read; `scale.Fold` + takes many stretches out with a slash on the axis line instead. Nothing is + removed from the data, and a coord that cannot mark a break does not draw one + ([ADR 0083](adr/0083-an-axis-break-is-marked-or-not-drawn.md)). + A `scale.Calendar` says which stretches of each day count — a working week + of whichever days, a shift pattern, a roster, a market's sessions — composed + from `scale.Weekdays`, `Dates`, `EveryNth`, `And` and `Not`, or written as a + function; `scale.Folds` turns its closed time into the axis's folds, + `scale.WeekStart` starts the axis's weeks on any day, and a day tick that + lands in a fold moves to the next open with that day's date + ([ADR 0086](adr/0086-a-calendar-says-when-time-counts.md)). + A tick label is written by `scale.Format` (a Go function) or + `scale.NumberFormat` (a spec a document can hold), and `figure.Locale` sets + the separators, the percent sign and the month names every axis of a plot + writes in ([ADR 0035](adr/0035-label-format-and-locale.md)). - **Geoms** — `Line` (`geom.Curve` picks one of ten interpolation families — cardinal, monotone, natural, basis, bundle and their open and closed - variants; monotone is the one that cannot overshoot), `Scatter` (six marker + variants; monotone is the one that cannot overshoot), `Scatter` (seven marker shapes), `Bar`, **`Area`** (to a baseline, or a band between two series), **`Step`** (pre/mid/post), **`Boxplot`** (Tukey whiskers, type-7 quartiles, outliers), and **`Rect`** — one box per row, bounded by the row rather than by a baseline, which is what a heatmap, a gantt bar, a candle and a waterfall - step all are. `geom.ProgressBy` fills a cell as far as a column says it has + step all are. `geom.Orient(geom.Horizontal)` lays a `Bar`, a `Histogram` or a + `Boxplot` on its side, with the categories on Y + ([ADR 0087](adr/0087-an-orientation-is-the-encoding-read-a-quarter-turn-round.md)). `geom.ProgressBy` fills a cell as far as a column says it has got — a gantt bar that reads as a status rather than as a plan. +- **`Text`** — one label per row, read from a column by `geom.TextBy`: at the + row's point, or in the middle of the box the same options would draw as a + `Rect`, cut to fit it ([ADR 0032](adr/0032-text-as-a-mark.md)). + `geom.AvoidOverlap` moves colliding labels to one of eight nearby positions + before dropping them ([ADR 0040](adr/0040-label-collision-avoidance.md)), and + `geom.Callout` takes a label that does not fit a slice of a pie or a sunburst + out of the ring on a leader line — broken over lines or shrunk before it is + dropped ([ADR 0082](adr/0082-a-label-fits-its-box-or-is-called-out.md)). +- **`ErrorBar`** — the interval a measurement is known to within, as two bounds + or as a half-width about the value, vertical or horizontal by which channels + it names. Its bounds train the axis, so an interval never runs off the plot + ([ADR 0036](adr/0036-error-bars.md)). - **`Depends`** — the arrows between spans that a schedule's constraints are. It reads two tables, the plan and a list of links over it, joins them by `geom.KeyBy`, and draws all four linkages (`"fs"`, `"ss"`, `"ff"`, `"sf"`). `geom.ColorBy` over the link table is how a critical path is drawn ([ADR 0068](adr/0068-gantt-charts.md)). - **Distribution marks** — **`Histogram`**, **`Violin`**, **`Ridgeline`**, - **`Hexbin`**, **`Beeswarm`**, **`ECDF`** and **`Trend`**. Each is a pure + **`Hexbin`**, **`Beeswarm`**, **`ECDF`**, **`QQ`** and **`Trend`**. Each is a pure function in [`stat/`](../stat) — a 1-D binner, a Gaussian KDE with Silverman's bandwidth rule, a hexagonal lattice, an empirical CDF, locally weighted regression, a running mean, a Savitzky-Golay fit that keeps the height of a peak — with a determinism test, drawn by a mark that trains its axis on the summary rather than on the rows ([ADR 0028](adr/0028-distribution-stats.md)). +- **Market charts** — `geom.Candle` reads open, high, low and close through + one option and decides each row's direction itself, drawing wick and body — + or the OHLC bar — in a handful of calls whatever the row count + ([ADR 0088](adr/0088-a-candle-is-one-mark-that-reads-four-values.md)); the + recipe under it is a `Rect` body over an `ErrorBar` wick, + volume is a `Bar` in a bottom track sharing time, and a volume profile is a + horizontal `Bar` per price bucket in a right track sharing price. The indicators are + functions in `stat` that look only backwards — `TrailingMean`, `EMA`, + `RollingStdDev`, `RollingMin`/`RollingMax`, `Cumsum` — so appending a row + never moves a value already drawn; `stat.OHLC` resamples ticks into candles + and `stat.BinWeighted` sums volume by price. `geom.DirectionBy(from, to)` + colours any mark by which way its row went, in the candles' colours — the + volume bars under them, a line drawn rising and falling + ([ADR 0091](adr/0091-a-direction-is-a-colour-channel-any-mark-can-take.md)). + `geom.LastValue` tags the newest value on its axis, `figure.Crosshair` snaps + to the candle at the pointer's time and tags its axes, and `stat.RSI` and + `stat.VWAP` are the indicators a composition would get wrong + ([ADR 0092](adr/0092-what-a-trading-screen-reads-off-its-edges.md)). `examples/market` draws all of + it, with the weekends and a holiday folded from one calendar, and with + `-live` a tick feed folded into minute candles by `stat.Resampler`, the open + candle revised in place with `data.Stream.ReplaceLast`, and a price axis that + fits the zoomed time axis through `scale.FitView` + ([ADR 0089](adr/0089-a-value-axis-fits-what-its-time-axis-shows.md)) + ([ADR 0085](adr/0085-what-a-market-chart-needs.md)). - **Relational and hierarchical marks** — **`Treemap`**, **`Icicle`**, - **`Sankey`**, **`Arc`**, **`Tree`** and **`NodeLink`**, which read an edge table rather than a pair of + **`Sankey`**, **`Arc`**, **`Tree`**, **`Graph`** and **`NodeLink`**, which read an edge table rather than a pair of axes: `geom.From`/`geom.To` for a flow, `geom.ID`/`geom.Parent` for a hierarchy, and `geom.Value` for the magnitude of either. Each places its own layout in the unit square and hands it to the coordinate stage, so an @@ -64,7 +118,12 @@ The feature surface in one list: scales, marks, coordinate systems, layout, outp number of majorization sweeps after it, each of which lowers a named quantity and never raises it, so the picture is a pure function of the table and the cost of the bound is quality rather than correctness - ([ADR 0077](adr/0077-a-node-link-layout.md)). + ([ADR 0077](adr/0077-a-node-link-layout.md)). **`Graph`** is the directed + graph in ranks — a state chart, a dependency graph, a layered flowchart — + with each node in a box carrying its name. Unlike a sankey it draws a cycle + rather than refusing it, because a state machine that cannot return to an + earlier state is not a state machine + ([ADR 0072](adr/0072-layered-graph-layout.md)). - **Set charts** — **`Intersections`** and **`SetMatrix`** are the two halves of an **UpSet plot**: a bar per combination of sets over a matrix saying which combination that is, read off a membership table — one row per (element, set) @@ -224,6 +283,18 @@ The feature surface in one list: scales, marks, coordinate systems, layout, outp pan or a zoom moves it like any other chart. It ships no geography and computes no routes: a coastline and a great circle are both rows in the caller's table ([ADR 0081](adr/0081-a-map-projection.md)). +- **Fields** — three marks for a value sampled over a grid, all reading + `geom.X`, `geom.Y` and `geom.Z` through one `stat.Lattice`, so a field and + the lines drawn over it come from one reading of the table. + **`Contour`** strokes its level sets + ([ADR 0064](adr/0064-a-contour-and-its-lattice.md)); **`Raster`** draws it as + one image with a colourbar, which is what a spectrogram of a million cells + needs where a `Rect` per cell would be a million paths + ([ADR 0066](adr/0066-a-raster-mark.md)). **`Horizon`** is the answer to many + series rather than many rows: it folds a series' own axis into bands drawn at + the full height of a short panel and tells them apart by colour, so forty + sensors fit on one screen, with a classed colourbar standing for the ladder + the fold gave up ([ADR 0065](adr/0065-horizon-charts.md)). - **A locus** — `geom.Locus` draws a family of curves given by a formula rather than by data, at the levels the caller names. It is what a Nichols diagram's closed-loop contours are and what a Smith chart's constant-VSWR circles and @@ -303,7 +374,9 @@ The feature surface in one list: scales, marks, coordinate systems, layout, outp ([ADR 0027](adr/0027-size-channel-and-the-guide-column.md)). - **Missing data** — one explicit policy per layer (gap, interpolate, error), covering both `NaN`/`Inf` and values a scale has no position for, such as zero - on a log axis. + on a log axis. A text or time column states its nulls beside its values, + because `""` is a string somebody may have measured and the zero time is an + instant ([ADR 0034](adr/0034-null-values.md)). - **Annotations** — `HLine`, `VLine`, `HBand`, `VBand`, `Segment`, `Region` and `Note`. They take values rather than a data source, because there is no column behind "the SLO is 200ms", and they extend the axis so the threshold is in @@ -312,6 +385,13 @@ The feature surface in one list: scales, marks, coordinate systems, layout, outp plot by a column; `figure.NewGrid` puts different plots on one canvas. Both go through one constraint solver, so panels are the same size and their axes line up ([ADR 0010](adr/0010-panel-layout.md)). +- **Second axes and tracks** — `Plot.Y2` and `Plot.X2` give a chart a second + scale on the far side and `geom.OnY2`/`geom.OnX2` bind a layer to it; it draws + no grid lines of its own, and a zoom moves both + ([ADR 0037](adr/0037-secondary-axis.md)). `Plot.Track` attaches a band to an + edge of the plot area that shares the panel's own scale object for the axis + it runs along, so a state strip, a dendrogram or an UpSet matrix pans with the + chart by construction ([ADR 0031](adr/0031-tracks.md)). - **Chart furniture** — axes, grid, tick labels with collision avoidance, chart and axis titles, and one guide column carrying a legend, **colourbars** and **size keys**, stacked in that order and measured by one solver. A layer diff --git a/docs/interaction.md b/docs/interaction.md index 85534c2..bc12d79 100644 --- a/docs/interaction.md +++ b/docs/interaction.md @@ -95,6 +95,18 @@ in.Wheel(x, y, deltaY) // zoom about the pointer in.Resize(w, h) // lay out again at a new size ``` +A zoom moves every axis of the panel except one built with `scale.FitView()`: +that axis is fitted, every frame, to the rows the zoomed axis leaves in view — +the price axis of a chart zoomed in time +([ADR 0089](adr/0089-a-value-axis-fits-what-its-time-axis-shows.md)). + +Every frame describes the rows it draws: an axis that is not pinned is trained +again from them, so a windowed stream's time axis slides with the window and +its value axis forgets a peak the window dropped. A zoom is a pinned domain and +survives; a category keeps its slot and a series its colour; `scale.HighWater()` +keeps an axis's extent on purpose +([ADR 0090](adr/0090-an-axis-describes-the-frame-it-is-drawn-in.md)). + A runnable version is [`backend/window/cmd/demo`](../backend/window/cmd/demo): ```sh diff --git a/docs/milestones.md b/docs/milestones.md index 952cc89..cdd7468 100644 --- a/docs/milestones.md +++ b/docs/milestones.md @@ -1090,6 +1090,368 @@ writes in Go **draws but does not serialise**, which is [ADR 0041](adr/0041-qq-plots.md)'s rule for a quantile function applied to a curve. ✔ +### A ternary chart: three components on a triangle — **shipped** + +A ternary plot reads three components that sum to a constant — a soil's sand, +silt and clay, a rock's three oxides, a classifier's three-class probability — +as one point inside an equilateral triangle. It has been standard in petrology, +metallurgy and soil science for over a century, the tooling for it is thin +everywhere, and Go had none. Everything it wants apart from the coord had +shipped between v0.1 and v0.9, so `coord.Ternary` is the whole milestone +([ADR 0051](adr/0051-barycentric-coord.md)). + +**The third component is derived, not read.** X is the first component, Y the +second and the third is `Sum − x − y`; `coord.TernarySum(100)` is the +percentage spelling most tables arrive in. Three named columns were refused +because a coord transforms a *mapped pair* — that is what the coordinate stage +is built on — and because a row whose columns sum to 0.98 has to be normalised, +refused or ignored, and every answer is wrong for somebody. Deriving the third +makes the constraint hold by construction. + +**It is the cheapest coord in the package, because the map is affine.** A +barycentric point is a 2×2 matrix and a translation, so `Straight()` is true and +every geom draws what it drew under Cartesian; `Area` is four transformed +corners, which makes a `geom.Rect` a parallelogram and a ternary heatmap over +binned compositions cost no new mark; `Invert` is the inverse matrix, so a +tooltip reports a composition. `Clip` is the triangle, the one thing it does not +inherit. `Decimates()` is false by derivation rather than observation: a column +of screen is a band of constant *b − a*, not of constant *a*, so a reduction +over pixel columns does not measure what it was defined to. Both domains are +pinned to `[0, Sum]`, as a Smith chart's are — a triangle autoscaled to a tight +cluster is not the simplex — so a zoom relabels and moves nothing. + +**The third grid family is where the record earned its place.** A ternary has +three labelled ladders and a panel has two tick lists, the constraint +[ADR 0033](adr/0033-smith-charts.md) had named. The constant-c line at level v +pairs with the X tick at v, so it was first drawn as a second subpath inside +`GridX[i]`, taking grid ink, with `Furniture` unchanged — and its labels were +declined, to be spent once on the general problem rather than on two thirds of +it. [ADR 0070](adr/0070-a-third-labelled-family.md) spent them: the third +family is now a family of its own with its own labels, and each component is +read along its own edge, cyclically, which is how every printed ternary chart +is arranged. `examples/ternary` draws a soil texture triangle and a QFL diagram. +A `geom.Locus` was the alternative for that family and stays the escape hatch +for a fourth one; it was not the default because it takes annotation ink and +this is a grid line. + +Not in this milestone. **No ternary zoom**: three ranges have to stay mutually +consistent or the region stops being a triangle, which is its own decision. +**No three named columns and no normalisation**, per the argument above — a +caller whose table carries three divides by their sum. **No quaternary +diagram**: three free components is three dimensions, the 3D question and not +this one. The corner labels naming the components are still `geom.Note`, +because a ladder carries numbers and not a name. And a Piper diagram is two +ternary panels and a Cartesian one in a `Grid` — a recipe, not a feature. ✔ + +### A horizon chart: forty series in the room of one — **shipped** + +Every answer figure had to scale was an answer to *row count*: decimation, the +density raster, the hexbin. None helped with forty sensors at a thousand samples +each, which is not a big series but a lot of them, and the only answer in the +tree was faceting, which divides the space rather than reusing it. A horizon +chart cuts a series' range into bands of equal height, draws every band at the +panel's full height and tells them apart by colour, negative values mirrored +back from the other arm of the ramp; Heer, Kong and Agrawala measured it +reading better than a filled line below about forty pixels +([ADR 0065](adr/0065-horizon-charts.md)). + +**`geom.Horizon` is a mark, and the fold runs in `Train`.** `geom.Bands(k)` +cuts the trained domain into k bands; `geom.BandHeight(h)` gives the band in +the data's own units and wins where both are set, because 50 kW is the spelling +an engineer has and "a third of the maximum" changes when tomorrow's data +arrives. It is not a position adjustment: an adjustment *moves* a mark, where +the fold cuts one row into up to k clipped spans, all occupying the same +rectangle. And it runs in `Train` by [ADR 0028](adr/0028-distribution-stats.md)'s +rule — the fold does not describe the Y axis, it replaces it — which also puts +decimation after it, in the order that matters: LTTB dropping the sample that +decided a band would change the *colour* of a stretch of chart. + +**The colourbar is the ladder the chart gives up.** The folded Y axis describes +one band and is correct for every band on screen; what the reader is missing is +*which* band, and that is a colour. So the layer's guide is a classed colourbar +whose breaks are the fold's own boundaries, printed in the data's units — no new +furniture, no new guide kind, no change to `render`. The implementation made it +a `scale.Threshold` rather than the `Quantize` the record named, because a +quantize scale derives its boundaries from its domain and that would be two +computations of one fact; `geom.Horizon.breaks` is the single place the edges +exist. The ramp is `palette.BlueOrange`. + +Three smaller things came out of building it. A band nothing reaches into is not +drawn, so the fill count is a fact about the data rather than k empty runs per +arm per frame. The mark reports one position per row, at the band the row ends +in, which is where the reading stopped. And `Tension` is ignored, because a +spline through a clamped fraction overshoots the only interval a band has; the +floor is shared with `geom.Area` through `appendFloor` so the two cannot +disagree. `examples/horizon` is sixteen meters faceted, with the band height +pinned. + +Not in this milestone. **`GroupBy` is an error**, not an overlap: N series in +one panel are N full-height bands painted over each other, and a wall of them is +a facet or a `Plot.Track` per series. **No stacked horizon**: the fold destroys +the position a stack speaks through. **No band count chosen from the data**, +because a band that moves with the maximum makes two charts of one quantity +incomparable, which is the one thing the form is for. **No `Plot.Horizons` +sugar** until somebody has written the long form twice. ✔ + +### A raster: a measured field as one image — **shipped** + +A heatmap is `geom.Rect` with `ColorBy` over two band scales, and that is enough +for a few dozen categories by a few dozen. It stops being enough at the size a +measured field comes in: a minute's spectrogram is two thousand frames by five +hundred bins, and one `Rect` per cell is a million paths in an SVG no browser +will open, to draw cells smaller than a pixel. `ir.Backend.Image` was already +in every backend, `stat.Grid.Raster` already painted a grid into an +`*image.NRGBA`, and `stat.Lattice` already resolved a long `(x, y, v)` table +into a product grid. `geom.Raster` wires the three together +([ADR 0066](adr/0066-a-raster-mark.md)). + +**It reads the channels `geom.Contour` reads, for the contour's reason.** A +raster with isolines over it is the commonest form this mark takes, and two +resolvers that agree today disagree at the first duplicated position. So both +run `stat.Lattice`, and `geom.latticeError` is the one function both call to +turn a fault into a sentence — a test compares the two word for word. + +**The image is built at the panel's resolution, with nearest neighbour.** +Letting `Backend.Image` scale one pixel per cell was refused: a smooth upscale +invents colours between measurements, and the backends do not agree on what +scaling means. Downsampling is named rather than assumed — `geom.Resample` over +`geom.Nearest`, `geom.Mean` and `geom.Max`, a small closed family that +serialises. Nearest is the default because it never shows a number nobody +measured; Max is what a spectrogram wants so a peak survives. The pixel-to-cell +mapping is inverted through the scale per pixel column and row, so a log +frequency axis gives the low bins more pixels, and the mark costs the panel's +size rather than the field's. + +**A ramp read per pixel was the whole cost.** `palette.Lerp` blends in linear +light and measured 640 ns a colour — 150 ms for a 600 by 400 panel. `geom.ink` +reads the scale into a table once per domain, one colour per class or 1024 +samples along a continuous ramp, and the same panel costs 3.3 ms with no +allocations. `examples/spectrogram` is 1322 frames by 128 bins and one `` +where a rect per cell would be 169,216 paths. + +**And it has a colourbar**, which the hexbin cannot: a raster's values are known +in `Train`. It is the contour's `ColorGuide` rather than the shared helper, and +a raster naming no `ColorBy` still takes `palette.DefaultRamp` and still draws +its bar. Axes are trained to the cell edges, so the outer half cells are not +clipped. A non-finite cell is transparent. A hit reports a row per cell only +while a cell is at least a pixel across, because below that a pixel under +`Mean` is no one cell. + +Not in this milestone. **An unequally spaced lattice is refused** and the fault +names `Rect`, which draws it correctly one box per row. **A non-Cartesian coord +is `ErrWarpedRaster`**: a blit is axis-aligned, and a square image in a round +panel is a false chart. **`geom.Decimate` is an error**, because a raster's cell +count is its resolution. **No interpolation of scattered points onto a grid**, +which is an estimator rather than a way of drawing, **no bilinear upscaling**, +**no image-as-data** and **no per-cell borders**. ✔ + +### A colour that carries two readings — **shipped** + +Three charts wanted one missing piece and none was big enough to build it for +alone: a value-suppressing uncertainty palette, which gives a quantity fewer +distinguishable colours the less certain it is; a multi-class hexbin, coloured +by which class dominates a bin and how purely; and the 3×3 bivariate +choropleth. `scale.ColorScale` is one number in and one colour out, and +[ADR 0036](adr/0036-error-bars.md)'s sentence — a measurement and a claim about +how well it is known, whose second half had nowhere to go — applied to the +colour channel with more force than anywhere, because a filled cell reads as a +measurement whether or not anybody made one +([ADR 0067](adr/0067-a-bivariate-colour-channel.md)). + +**A bivariate scale rides `ColorScale` rather than widening it.** +`scale.BivariateColorScale` is the third rider, after `DiscreteColorScale` and +`ClassedColorScale`: `Color(v)` stays and answers at full certainty, so every +mark keeps working. The second column is `geom.UncertaintyBy(col)`, and naming +one against a scale that reads one number is `ErrNotBivariate` rather than a +column silently ignored. Building it added `TrainSecond`, which `geom` calls +beside `Train` when a layer read a second column, and `KeyCells`, the key as +rectangles in data space with a colour each — which is how one drawing function +draws a VSUP's tree and a matrix's square without asking which it has. + +**VSUP is a classed scale whose class count is a function of the second +reading.** `scale.VSUP(ramp, classes, layers)` is `layers` rows of `Quantize`, +each halving the classes and mixing at most seven tenths of the way toward a +light grey — resolution taken away, not the colour removed. +`scale.BivariateMatrix` takes its colours rather than two ramps, because the +record also refused blending two ramps and both cannot hold: a mixed colour +names no value. `palette.BivariateBlueRed` is Joshua Stevens' published 3×3, +registered by name. Both constructors are also `ClassedColorScale`s for the +first reading, so a layer with no second column draws the classed bar it would +have drawn, and only a layer naming both gets the square key — the fourth guide +kind, a constant and two functions as [ADR 0027](adr/0027-size-channel-and-the-guide-column.md) +priced it. + +**The multi-class hexbin is `geom.Hexbin` plus `geom.GroupBy`.** `stat.Hex` +counts per class, classes are named by first appearance, and the cell's colour +is the dominant class against impurity. Its classes are known in `Train` and its +counts are not, so it contributes ordinary legend entries and says how pure a +cell is through its colour. `examples/bivariate` draws all three charts. + +Not in this milestone. **A path given two readings is `ErrRampOnPath`**: a line +changes colour where its reading crosses a class boundary, and two readings +cross in different places. **No opacity as uncertainty**, the cheap version +VSUP's authors measured as misleading. **No N-variate channel** — three readings +have no key a reader can decode. **The key reports nothing to an observer**, +because a position in a square is a pair and a drag across it is the +two-dimensional brush left to the host. **The uncertainty is the caller's +column**; `stat` does not compute it. ✔ + +### Probability paper: the axis warps, not the sample — **shipped** + +Probability paper warps an axis so that one distribution's cumulative function +plots as a straight line, and the line's slope and intercept *are* the fitted +parameters. Weibull paper is the load-bearing kind — the standard method for +lifetime data in reliability engineering — beside normal, Gumbel and logit, and +no general-purpose plotting library draws it without an incantation or a +third-party package. `geom.QQ` looked like the same thing and is its opposite: +a QQ plot warps the *sample* and labels its axis in z-scores; probability paper +warps the *axis* and labels it 0.1 %, 1 %, 50 %, 99.9 %, which is what the +reader came for ([ADR 0052](adr/0052-probability-scales.md)). + +**One scale with four named links.** `scale.Probability` takes `scale.Probit`, +`scale.Logit`, `scale.CLogLog` or `scale.Gumbel`, and the four share a domain of +(0, 1) exclusive, a ladder, a `Definite` bound and every line of mapping but +one call, so four types would have been the naming rule followed off a cliff. +`scale.Link` is `Apply` and `Unapply`, open to a caller, and the named four +round-trip through `scale.LinkName` and `scale.LinkNamed`. A link written in Go +has an empty name in its `Desc`, which `scale.FromDesc` refuses with +`ErrUnknownKind` rather than substituting probit. That rule is now three +records old — a named member of a small closed family is written down, an +arbitrary Go function is not. + +**The charts cost no marks.** `Definite` covers the ends exactly as a log scale +covers zero, so `geom.ECDF` on a probit Y is a normal probability plot and on a +Gumbel Y is extreme-value paper. What the ECDF did need was to stop handing the +backend NaN: its staircase reaches 0 and 1, both unplaceable here — and the +bottom one already was on a log Y — so it now leaves out a vertex its axis +cannot place. + +**The ladder is a convention, and it is the scale's own.** 0.1 / 1 / 5 / 10 / +20 / 30 / 50 / 70 / 80 / 90 / 95 / 99 / 99.9 is no search's answer. It extends a +decade at a time into whichever tail the domain reaches, and thins by rank — +the median, then the decades, then 5 / 20 / 80 / 95, then 30 / 70 — so a short +axis loses rungs in mirrored pairs; a rung that loses its label stays a minor +tick unless `ProbabilityMinorTicks(false)`. Labels are per cent at the shortest +precision each rung needs, and `ProbabilityNumberFormat` replaces that outright. +A pan stops at the sixth decade of a tail. + +**The plotting position closed on `stat.MedianRank`.** Benard's rank is one +point per row and is read as points, where an ECDF is a staircase over distinct +values, so an option on the ECDF would have made one mark draw two objects. A +Weibull plot is `geom.Scatter` over median ranks on a `CLogLog` Y against a log +X, and `examples/weibull` recovers β from the ranks by regression in its test. + +Not in this milestone. **No fitting**: a scale that fitted a distribution would +assert the answer the chart exists to let a reader find, and the line is a +`geom.Segment` a caller who knows the parameters draws. **No confidence bounds** +on the plot, **no censored plotting positions** — that is +[ADR 0054](adr/0054-statistical-instruments.md)'s territory — and **no +probability colour scale**. `Nice` is not offered, because there is no round +number to round to. ✔ + +### A tidy tree, and the clustered heatmap it completes — **shipped** + +[ADR 0039](adr/0039-relational-layouts.md) had declined a node-link layout +because a force simulation runs until it settles. That argument binds *force* +layouts and not *tree* layouts, and a tree is where most of that category's +charts live. The Reingold–Tilford tidy tree in Buchheim, Jünger and Leipert's +linear form is O(n), deterministic, bounded and a pure function of its input — +`stat.Squarify`'s shape — with published invariants for oracles: no two +subtrees overlap, a parent is centred over its children, isomorphic subtrees +are drawn alike ([ADR 0053](adr/0053-tidy-tree-layout.md)). + +**`geom.Tree` is a fifth relational mark, in the unit square, reading 0039's +channels and adding none.** `geom.ID`, `geom.Parent` and `geom.Value` are what +it reads. Under `coord.Cartesian` it is a dendrogram or an org chart; under +`coord.Polar()` a radial dendrogram — the third time the unit square has turned +one mark into two charts. An edge is an elbow by default and a segment on +request, both as data-space points, so under a polar coord the bracket's +cross-piece becomes the arc. Leaf order is first appearance in the table; +reordering to reduce crossings is refused for 0039's reason, and `geom.Order` +is how a caller asks for another. + +**There are two layouts, and the height column chooses.** A tidy tree compacts +subtrees by depth, which is right only while depth is where a node is drawn. A +dendrogram's leaves all sit at height zero, so compaction would tuck a shallow +leaf above a deep neighbour's. `stat.Tidy` therefore has `Reset`, the tidy +tree, and `ResetLeaves`, one slot per leaf with each parent centred — used +exactly when `geom.Value` is present, which also lines a dendrogram up with a +heatmap's columns. It is a struct because the walk keeps a dozen per-node +buffers, runs without recursion, lays a forest out under a virtual root, and +exports `Leaves` so a caller can order a heatmap by it. Its tests include a +200 000-node chain and star. + +**The headline chart needed two things nobody had predicted.** A track shares +the panel's axis and a heatmap's is ordinal, so the refusal of an ordinal +breadth narrowed to the height axis: leaves are placed at their own names +through `scale.Categorical.Encode`. And a dendrogram on the *left* edge needs its +breadth on Y, which is `geom.Orient` with `geom.Vertical` and +`geom.Horizontal` — named for the library rather than for the tree, so the next +mark says it the same way. `Baseline(1)` mirrors the heights within their +extent, one rule for a depth tree and a dendrogram, and a root at the hub wants +`coord.Hole`, because the centre has no angle. `examples/dendrogram` draws a +clustered heatmap with a dendrogram on two edges, and a radial tree. + +Not in this milestone. **No force-directed layout and no general graph**: 0039's +refusal stands verbatim, and every property above depends on one parent per +node. **A cycle is `ErrCyclic`**, naming the node, where the icicle beside it +silently drops the nodes. **No optimal leaf ordering, no crossing minimisation, +no edge bundling**, and **no clustering**: a dendrogram's heights are the +caller's columns however they were produced. **A DAG** wants a layered layout +whose crossing reduction is the heuristic sort both records refuse. ✔ + +### The statistical instruments: survival, control, correlation, ranking — **shipped** + +Some charts had every mark they needed since v0.1 and none of the arithmetic: a +Kaplan–Meier curve is `Step` and `Area`, an SPC chart `Line` and `HLine`, a +correlogram `Bar`, ROC and Lorenz curves `Line`. Outside domain packages — +`survminer`, `lifelines`, `qcc`, `statsmodels` — nobody draws them, and in Go +nobody at all. The question [ADR 0054](adr/0054-statistical-instruments.md) +answers is not whether they are pure but **where the line is**, because "put +the field's arithmetic in `stat`" has no natural end. + +**The admission test: a reduction belongs in `stat` when its output is the +chart's geometry and has no reading that is not the chart.** An ECDF is its +staircase; a loess curve has no life off the chart. A hierarchical clustering, +a regression model and a meta-analysis all fail it, and the test admits five. + +**`stat.KaplanMeier` gets a mark.** `geom.Survival` runs it in `Train`, reading +X as the time and `geom.Event` as the indicator — a numeric column, non-zero +for an event, because `data` has no boolean and a 0/1 column is how every +survival dataset arrives. `stat.SurvivalPoint` carries the risk set and the +running Greenwood sum, and `Band(z)` is the log-log interval, the default in R +and lifelines because the plain one runs past 0 and 1 where a curve is read +hardest. `geom.Confidence` and `geom.CensorMarks` are the opt-ins. The layer +starts at time zero unless the axis is a time scale, where zero is an epoch. +The numbers-at-risk table is a `Plot.Track`, which a mark cannot make — +`examples/survival` is Freireich's remission trial with its table in one. + +**Control limits get no mark, and that is domain-correct.** Limits come from a +baseline and are then frozen; a mark recomputing them from the points it was +handed would be silently wrong for the principal use. So `stat.Limits` is the +result and `LimitsIMR`, `LimitsXbarR`, `LimitsNP` and `LimitsC` return it, +beside `AppendLimitsP` and `AppendLimitsU` for per-subgroup limits. +`stat.AppendRunRules` is Nelson's eight rules returning a selection, flagging +every point that completes a window so a derived column colours the whole run. +`examples/spc` is an individuals chart whose drift the rules catch. It found two +colourbar faults, fixed outside `stat`: a classed bar now labels a boundary with +the decimals it needs, and `geom.Guide(false)` lets a layer decline its bar. + +**`stat.ACF`, `stat.PACF`, `stat.ROC` and `stat.Lorenz` are stat only.** The +ACF is the biased estimator, which Durbin–Levinson needs, and a gap makes every +lag NaN rather than silently shifting the ones after it. ROC walks tied scores +as one diagonal step, so its area is the Mann–Whitney probability; a curve with +nothing to rank is empty with a NaN area, not 0.5. `Lorenz` returns the Gini +coefficient beside its curve. + +Not in this milestone. **No fitting, modelling or inference**, and **no +recomputed control limits**. **No censoring beyond right-censoring**, which +changes the estimator rather than the chart. **Forest, funnel, Pareto and +Bland–Altman plots are recipes** in `docs/charts.md` — a forest plot's pooled +estimate is a meta-analysis and fails the test on every clause. **No mark for +the ACF**, because bars, stems and points are all conventions and no reading +distinguishes them. ✔ + ### A schedule you can read: progress, constraints and milestones — **shipped** "Gantt / timeline" had been a row in [chart-types.md](chart-types.md) since @@ -1140,6 +1502,777 @@ it would report belongs to the link table and `Source` hands out the other one which is the first time in this library a layer has had two tables to be ambiguous about, and is ADR 0015's revisit clause rather than this record's. ✔ +### Hatching: the third redundant channel — **shipped** + +[ADR 0024](adr/0024-accessibility.md) quoted the promise of *"redundant +encoding (patterns/dashes)"* and shipped the dashes. `theme.Redundant(true)` +filled in a dash ladder and a marker ladder, and that was the whole of it. A +dash needs a stroke and a marker needs a point, and **a bar has neither** — so +the marks that fail hardest in greyscale, a stacked bar, a pie, a stacked area, +a treemap, were exactly the marks the one option did nothing for. Turning +`Redundant` on for a stacked bar chart changed nothing at all. + +**A hatch is an enumeration, like a marker.** `ir.Hatch` has fourteen values in +four families — lines, dots, wavering lines and tilings — and `geom.Hatch` +names one on a layer. An enumeration rather than a parameter struct because a +redundant encoding needs a *ladder* a layer index walks, and a ladder of +arbitrary angles is a ladder nobody can name a rung of. It is `ir.Marker` +again, including the rule that makes that safe: the set grows at the end, and a +value this release does not know leaves the mark with its plain fill. + +**The pattern says *different* and the period says *more*, and the two are kept +apart.** `Redundant` installs `theme.DefaultSeriesHatches`, where every rung is +a different shape and none is heavier than another, because a redundant +encoding must not invent an order the data does not have. +`theme.DensitySeriesHatches` is the other ladder, for a stack that really is an +order — severities, age bands — and `theme.Hatches` installs it by asking, since +guessing wrong means a chart that claims a magnitude nobody measured. How +coarse every pattern is belongs to the chart rather than to a series, so it is +`theme.HatchSize`. + +**It is drawn, not declared.** `ir.Backend` and `ir.Fill` are unchanged, and a +hatch never reaches a backend at all: `ir.FillHatched` fills the path, pushes it +as a clip and strokes the lines `ir.HatchPath` generates inside it. The frozen +interface is the first reason — a `Fill.Hatch` field would be ignored in +silence by every backend written before it, and a chart that quietly loses its +pattern still looks finished. The second is that the GPU tier's SDF accelerator +collapses any non-solid brush to a single colour, so a brush-based hatch would +vanish the moment `backend/gg/gpu` was imported. The third is that an SVG +``, a PDF tiling pattern, a canvas pattern and a gg brush are four +rasterisations of one idea, and lowering once means every backend draws the +same lines. + +**A hatch is not a mark.** `interact` indexes every stroked subpath, and hatch +lines would put dozens of phantom targets inside every bar. `ir.Decoration` is +the fix, in the shape every other extension of the backend contract has — an +optional interface beside `Partial`, `Resizer` and `Semantics`. `FillHatched` +brackets its pass in it, the probe stops indexing inside the bracket, and +`ir.Recorder` records it so a parallel render hit-tests as a serial one does. + +Three finishes came with it, none needing a backend to learn anything. +`geom.Corner` rounds rectangular marks, asking `Path.AsRect` whether a shape +*has* corners rather than asking the coord. `geom.Inset` strokes a mark's own +path at twice the border width clipped to itself, which is an inset outline +exactly with no path offsetting. And `geom.Gradient` gives a geom the linear +gradient `ir.Fill` had carried since v0.1, whose only caller had been the +colourbar. The first rung of the hatch ladder is `HatchNone`, so every existing +golden file is byte-identical, and `geom.Desc` carries `Hatch` beside +`HatchSet` for the reason `Dash` and `DashSet` do. + +Not in this milestone. **No hatch scale**: no `geom.HatchBy`, no hatch guide — +a pattern driven by data would be a fourth guide kind and a fifth scale +interface, and this is about a chart surviving a photocopier rather than about +a fifth variable. **An annotation is never hatched by a theme**, because a band +that is deliberately quiet should not acquire a pattern by default. **A layer +painted per row from a discrete colour scale wears one hatch**, since its marks +are batched by colour. And **no drop shadows**: there is no blur in the IR, and +an offset copy looks cheap in vector output. ✔ + +### A third labelled family: the ternary's missing ladder — **shipped** + +Three records had deferred the same seam. +[ADR 0033](adr/0033-smith-charts.md) declined the Smith chart's constant-|Γ| +circles because `render.drawAxes` takes label text from `t.Label`, so *"a +family with no tick behind it has nowhere to come from and nothing to be +labelled by"*, and named the trigger: a second chart wanting a grid family its +axes have no tick for. [ADR 0051](adr/0051-barycentric-coord.md) was that +chart. A ternary panel has three labelled ladders and two tick lists; it drew +the third family as a second subpath inside the X tick's shape and left it +**unlabelled** — which is the one edge carrying the clay fraction or the third +phase a reader is often after. + +**A family carries its own text, and that is the whole of the widening.** +`coord.Furniture` gained `Families`, each a `coord.Family` with a name, one +shape per level, a label position per level and the text each says. The +constraint 0033 named was never that a family had nowhere to be *drawn*; it was +that label text reached `render` only from `scale.Tick.Label`. A family that +says what its own levels are called removes that for every customer at once. +The slice is reset and regrown with the struct's other buffers, and a coord +that raises none costs nothing. + +**`render` applies three rules, and each is the one it is for a reason.** Lines +take grid ink and answer to `HideGrid` and the theme's grid stroke, but *not* +to `ShowGridX` or `ShowGridY` — a family is neither axis, and taking it down +with the horizontal grid would make the answer depend on which component the +caller put on X. Labels take tick ink and the tick font, because a ladder's +numbers are tick labels wherever they appear. And labels are **thinned last**: +a family is a third reading of the chart, and where its number collides with an +axis's the axis keeps it. + +`coord.Ternary` raises the constant-c family, and placing it made the one +visible change to existing charts: **each component is now read along its own +edge**, cyclically, which is how a soil texture triangle, a QFL diagram and a +phase diagram are printed. Before, the first two ladders both ran out of one +corner, which left the third nowhere to go but on top of them. The grid lines +did not move. + +A later amendment, from [ADR 0078](adr/0078-a-coord-with-more-than-two-axes.md), +split "when the panel writes tick labels at all" into its two switches. A +parallel-coordinates panel's families *are* its axes, and letting +`theme.ShowTicksX` govern them took the numbers off every dimension. So a coord +may set `Furniture.FamiliesAreTheAxes`; `coord.Parallel` does and +`coord.Ternary` does not. + +Not in this milestone. **It is not a caller-facing seam** — a coord fills +`Families` and `render` reads it; a fourth ladder is still `geom.Locus`, which +is an annotation and a layer. **It is not a spec field**: a family is derived +from the coord and its ticks, so a ternary chart serialises exactly as it did. +**It is not a third side** — a side has an axis line, tick marks and three +placement flags a family does not. **No hit-testing**, because furniture is not +indexed, and **no minor levels**, because there is no tick generator behind a +family. The Smith chart's families and Γ as input are each now a coord's own +arithmetic; none of them is built here. ✔ + +### A curve is chosen for the drawing, a smoother for the data — **shipped** + +A line had one smoothing knob. `geom.Tension` fitted a Catmull-Rom spline, and +zero meant a polyline. That was wrong for some data — **a cardinal spline +overshoots**, so through a cumulative total it dips below the previous reading +between two samples, ink at a value nobody measured — and the JSON dialect +already spoke Vega-Lite's `mark.interpolate`, writing `"cardinal"` beside the +tension and throwing the word away on the way back in. + +**A curve is a drawing, fitted after the coord.** `geom.Curve` names a +`CurveKind`, spelled as Vega-Lite spells them: linear, the cardinal three, +monotone, natural, the basis three and bundle. The fit runs on device points in +`geom/curve.go` and changes the path between the vertices and nothing else: no +axis is trained on it and `stat` never hears of it, which is what lets it be +chosen last, by whoever is looking at the picture. + +**Interpolating and approximating are two claims, and both are allowed.** +Linear, cardinal, monotone and natural pass through every vertex, so the ink +between two rows is a claim about what the quantity did between them. Basis and +bundle do not — the vertices are control points — and their doc comments say +they miss the data in those words. `CurveKind.Interpolating` makes the +difference a value a caller can branch on. What does not change is what a row +is: every family reports its vertices, so a basis curve that sails past a peak +still hands a tooltip the peak. + +**Monotone falls back to the polyline, not to the spline.** The family takes +whichever device axis is strictly monotone, so a series running down the page +works as well as one running across it. When neither is, no monotone fit +exists, and falling back to cardinal would silently deliver the overshoot the +family exists to prevent. A bent coord still takes the edges over from every +family, because a tangent fitted in device space under polar is smooth on the +screen and wrong about the data, and `geom.Horizon` still draws straight edges +through its clamped bands. + +**A smoother is a fit, and it lives in `stat`.** `stat.MovingAverage` and +`stat.SavitzkyGolay` run in data space in `Train`, and the axis is trained on +what they produce, beside `Loess` in `geom.Smoothing`. They are two because they +fail differently: a running mean can be checked by hand and flattens every +peak; a Savitzky-Golay fit keeps a peak's height and width, and the tests +assert that a quadratic survives an order-two fit untouched. Both take `Span` +as their width — a second knob spelling the same fraction in other units would +be the thing to regret. And the two halves are **not one option**, because +"what happened between two readings" and "what is happening under the noise" +are different questions and a reader needs to know which was answered. + +`Tension` alone is still `CurveCardinal` at that tension, so every existing +chart draws what it drew; `Desc` carries `CurveSet` because `CurveLinear` is +both the zero value and a choice. + +Not in this milestone. **No curve under a bent coord** — a rounded radar is a +reasonable thing to want, and it needs a spline fitted in scale space and +mapped per sample, a different mechanism to be decided as such. **No family +parameterised by arc length**, which `CurveCardinalClosed` approximates rather +than solves. **No Savitzky-Golay order on a layer**: `geom.Trend` fixes it at +two, the lowest degree with a curvature. And **no family may be spelled with a +`step` prefix**, because that is how the document tells a staircase from a +line. ✔ + +### A label on a curve: contours that read without a colourbar — **shipped** + +`geom.Contour`'s own doc comment deferred it, [ADR 0050](adr/0050-locus-annotations.md) +deferred it for "3 dB" along an M contour, and a contour chart was the one +chart in the catalogue that could not be read without a second guide: it says +*where* a crossing is, and which crossing it is came from a colourbar or from +counting rings. `geom.LabelLevels(true)` writes the level on the line, for +`Contour` and `Locus` both, and `geom.LevelFormat` says how +([ADR 0073](adr/0073-labels-on-a-curve.md)). + +**It is placed after the coord, and that is the second exception.** The label +is turned to the curve's tangent, and a tangent in data space is not the +tangent on screen: under `coord.Polar` a constant-radius arc is straight in the +scaled pair and bent on the panel, and under `coord.Smith` a straight sweep in +impedance is a circle. So the placement reads only the device points the mark +is about to stroke. It never sees a datum, which is why one helper serves a +traced lattice and a formula. + +**The curve offers the candidates; the placer only says yes or no.** A label +joins [ADR 0040](adr/0040-label-collision-avoidance.md)'s table with one +difference: **`move` is false**. A text label may be nudged, because near its +point it still names its point; a curve label moved off its curve names a level +it is not on, and that is a chart that lies rather than one that is crowded. +`curveLabelTries` positions spread along each run are scored by how far the +curve sags from the chord under the label, one bending more than +`curveLabelSag` font heights is no candidate at all, the flattest wins with ties +to the start of the run, and a refusal tries the next. The list is bounded and +the order total, so the picture is the same on one goroutine or eight. + +**A gap, not a halo.** A halo needs the background colour, which a layer does +not know over a transparent panel or a raster; a knockout needs a text +background, which would be the IR's first compositing decision. A gap is +geometry: the run is stroked up to the label and resumed after it, as two +subpaths of the path it was going to be anyway, with the ends interpolated so a +coarse lattice is not gapped for half a ring. The text is turned upright and +centred in its own gap, **once per run** — a level is many runs, each a separate +statement, and repeating along one would need a spacing knob in device units. + +**The text is decoration and the geometry is data.** Levels are settled in +`Train`; where a label sits is chosen in `Build` from what the backend says the +string measures, so two backends may gap a curve a few pixels apart and neither +moves a line. `ir`, `render`, `coord` and `scale` are unchanged, the gapped +halves go into buffers the layer keeps, and a contour drawn earlier wins the +label space from a later text layer, which is 0040's rule as written. + +Not in this milestone. **No text following the curve per glyph** — that is +paragraph layout by another name. **No repeated labels along one run**, **no +leader lines**, and **no thinning of which levels are written**: which levels a +chart shows is `Levels`' question. `LevelFormat` **does not serialise**, +[ADR 0041](adr/0041-qq-plots.md)'s rule for a Go function. **Filled bands +between levels** are still a polygon where this is a path. And a labelled +*furniture* family is not this: this labels a mark. ✔ + +### A layered graph: state charts and pipelines — **shipped** + +[ADR 0039](adr/0039-relational-layouts.md) refused node-link layouts, and +[ADR 0053](adr/0053-tidy-tree-layout.md) narrowed the refusal to force layouts, +shipped `geom.Tree`, and left a clause for a DAG: layered layout is bounded +too, but its crossing reduction is a heuristic sort, and *"a sort is where a +layout stops being a pure function of its input."* The DAG that turned up was a +state machine, and [ADR 0072](adr/0072-layered-graph-layout.md) is the answer. + +**A stable sort by a computed key, with the index as its tie-break, is a pure +function.** Barycentre sweeps sort each rank by where its neighbours are, and +equal keys are the common case — a node with one neighbour takes its position +exactly. The fix is to make the key total: the sort is stable, the incoming +order is the interning order and therefore the table's, the sweep count is the +constant `stat.LayeredSweeps`, and nothing reads a map's iteration order. What +does not survive is any claim to be optimal. Crossing minimisation is NP-hard; +a different row order draws a different, equally valid picture, and that is +written down as a documented input rather than smuggled in as quality. + +**Cycles are broken, not refused.** `geom.ErrCyclic` is right for a sankey and +a hierarchy, but a state machine that cannot return is not one. A depth-first +walk in index order marks the back edges, layering runs on what remains, and +the marked edges are drawn in their true direction against the rank order, +flagged by `stat.LayeredEdge.Back`. Five phases — break, longest-path rank, +dummy-node routing, ordering sweeps, placement — each bounded by one pass or by +the sweep constant. Longest-path rather than network simplex, whose pivot rule +is a tie-break that would need justifying all over again. + +**The layout never sees a label.** Graphviz sizes nodes from their text; doing +that here would give the SVG and the PNG of one document different geometry. +`stat.Layered` places nodes in the unit square knowing only the graph, and +`geom.Graph` sizes each box from `ir.Backend.Measure` at build time, as +decoration. Building it sharpened that: nodes on the outer ranks need an inset +of half the widest box, so a node's *position* does move with the font — as +every mark already does when the panel is fitted round measured labels. What is +a pure function of the graph is the **arrangement**, which node on which rank in +which order, and `TestAGraphsArrangementDoesNotDependOnTheFont` holds it. + +It fills the unit square, so the orientations are free: ranks up the panel +under Cartesian, down it with `geom.Baseline(1)`, concentric under +`coord.Polar()` — which is why there is no `rankdir`. Edges are `geom.From` and +`geom.To`, the flow channels unchanged. A node's box is a device rectangle built +with `ir.Path.RoundRect`, not an area handed to the coord, because a labelled +box must not become an annular sector. `stat.Layered` carries its own merge sort +because `sort.SliceStable` allocates per call, and `TestLayeringAgainDoesNotAllocate` +holds the gate. + +Not in this milestone. **No force-directed layout** — 0039's refusal stands. +**No clusters or subgraphs**, which would be a second solver, and **no ports**. +**No edge labels**: a transition's `event [guard] / action` needs the de-overlap +pass [ADR 0032](adr/0032-text-as-a-mark.md) deferred. **No `geom.NodeSep`**: +the record offered it, and in a layout that fills the unit square there is +nothing for it to widen, so a long name can still crowd its neighbour. **No +DOT frontend**, which is a separate record if it is ever written. And the cost +is the ranks an edge crosses rather than the edges, so this mark is for graphs +that are nearly layered already. ✔ + +### Sets are counted: an UpSet plot, a Venn diagram and the set sizes — **shipped** + +[ADR 0039](adr/0039-relational-layouts.md) had refused Venn and UpSet in one +sentence with two reasons in it, and the second was not a refusal at all: an +UpSet plot is a matrix chart rather than a relational layout, which makes it +cheaper than the neighbour it was refused beside. It has no geometry to solve. +Its columns are the combinations of sets that occur, its bars are how many +elements are in exactly each, its rows are the sets — a bar chart over a dot +matrix, both of which figure drew on day one. What was missing was the +arithmetic. + +**It is a count, and the count is a `stat`.** `stat.Intersections` counts a +membership list by which sets each element is in; a combination is a bit per +set in a `uint64`, so sixty-four sets (`stat.MaxSets`) is a mask rather than a +limit anybody meets. It never sees a string: the geom interns, the stat counts +indices. And it counts *exactly* — an element in A and B is counted once, under +{A, B}, and not again under {A} — so the counts partition the elements and add +up to how many there are, which is what a circle labelled with its own total is +not. A membership row is a bipartite edge, so `geom.From` names the element and +`geom.To` the set: 0039's channels, unchanged. + +**Two marks, one count, so the panels cannot disagree.** `geom.Intersections` +draws the bars and `geom.SetMatrix` the dots; both read the same table and run +the same count in `Train`, because a bar standing over the wrong column is the +one way this form can lie. `geom.Order` and `geom.Top` decide which columns +exist and must be handed to both, and ranked biggest-first is the default — the +only place in the package where appearance order is not, because an UpSet plot +*is* a ranking. That default is why `geom.Order` now records having been told. +The panels themselves are the caller's, because a mark cannot make one: the +two-panel form is `Plot.Track(figure.Bottom)` sharing the X scale object, and +the three-panel form is a `figure.Grid`. Nothing was added to `figure`, +`render`, `layout`, `coord`, `scale` or `ir` for either. + +**The third panel's count had been done by hand, and it was the wrong count.** +`examples/sets` built the set-size bars with a map and a loop that counted +*rows*, while the stat beside it counts *elements* — a join that hands back the +same (element, set) pair twice made the left panel disagree with the two beside +it, and the library held the right number with no way to ask for it. +`geom.SetSizes` reads `stat.Intersections`' `Sizes` from the same table +([ADR 0076](adr/0076-the-other-half-of-the-count.md)). It accepts `Order` and +`Top` and ignores them, because a set's total is over the whole table, and it +has no orientation option: which way its bars grow is the axis's business, which +is the next section. + +**A Venn diagram of two or three sets is a table, not a solver.** `geom.Venn` +draws one disc, two side by side, or three on the corners of an equilateral +triangle — the arrangement every printed diagram uses — so 0039's objection to +an optimiser does not reach it. See [ADR 0074](adr/0074-sets-are-counted.md). + +Not in this milestone. **No area-proportional diagram from three sets on**: two +circles have three free numbers against three region areas and a bisection +settles it, but three have six against seven, so a solver minimises an error it +cannot drive to zero and fails subtly. The two-set case is not refused, only +not built. **No fourth set** (`MaxVennSets` is 3): the reason first given was +geometric and was wrong — four ellipses do show every region — so the amendment +restates it as this mark's contract, a count written *into* its region at one +type size in a fixed arrangement, which a four-set diagram's slivers cannot +hold. **No row reported**, since a column and a region are aggregates. **No +lane order by set size**, which would decide what two marks draw and so needs a +record of its own. ✔ + +### An axis has a direction — **shipped** + +The set-size bars of a printed UpSet plot grow *away* from the matrix they +label, and `examples/sets` grew them towards it because there was no way to ask +for the other direction. [ADR 0039](adr/0039-relational-layouts.md) had named +the answer — a positional `scale.Reverse` that survives the round trip — and +said in passing that a reversed domain would not have survived it anyway. + +**The refusal was measured before it was answered, and the round trip was never +what stopped it.** Pinning `Domain(hi, lo)` mirrors `Map` and `Invert` exactly +and writes `"domain": [12, 0]`, which reads back unchanged. What stopped it was +three parts of one file, each right on its own terms: `Zero` and `Nice` handed +back an ascending pair, the containment test in `Ticks` found no value between +a descending `lo` and `hi` — so the axis drew its marks mirrored and carried *no +numbers at all*, even a sequence pinned with `TickValues` — and the first pan +turned it back round, because `Zoomer.SetDomain` orders what a drag hands it. +So `Domain`, `LogDomain` and `SymLogDomain` now order their bounds, which keeps +the invariant rather than patching it three times. + +**What reverses is the pixels, not the numbers.** `scale.Reverse()` sets one +field on the linear scale, and `linear.device` hands `Map`, `Map64` and +`Invert` their range the other way round. Everything that trains, frames, +searches or filters a domain still sees a low number and then a high one, which +is why a reversed axis pans, zooms, autoscales, clones, snapshots and describes +itself without another line written for any of them; no branch was added to a +drawing path. `Domain()` still reports ascending bounds and `Ticks` is still +ascending **by value** — it is the positions that descend. The dialect spells +it `"reverse"`, the word a colour scale already used, and it is written only by +the kind that reads it back. + +**Drawing the chart found the fourth thing.** `render.selectXLabels` drops a +label that would run into the one before it, and it swept in tick order — which +on a reversed axis is right to left. A 400-pixel chart came out with `0` on its +X axis and nothing else. The sweep now runs in screen order, and it is the only +change outside `scale` and `spec`: reading the code had said the work was a +comparison in `Ticks`. See [ADR 0075](adr/0075-an-axis-has-a-direction.md). + +Not in this milestone. **Log, symlog, time and probability axes** do not +reverse yet — the flip is `device()` and three call sites in each, and what is +missing is a chart asking and the tests with it; their pinned domains are +ordered either way, so none half-works meanwhile. **No ordinal reverse**, +because turning a band scale around is a question about category order, which +is `geom.Order`'s. **A projected scene presumably inherits it**, and presumably +is not a claim: nothing was tested against one. And writing a domain backwards +is refused rather than supported. ✔ + +### A node-link layout by stress majorization — **shipped** + +The last row of bucket E had been refused since +[ADR 0039](adr/0039-relational-layouts.md) with a sentence welding two claims +together: that a force layout cannot be a pure function at a bounded sweep +count, and that it cannot be one "that also looks good". The first was too +strong — a fixed start, step and count repeat exactly — and the second had never +been tested. What is true is narrower. **Stress is a named objective, and +majorization never goes uphill**, so the cost of stopping after +`stat.StressSweeps` (fifty) can be stated and measured; a simulation stopped +early is wherever it happened to be in its own swing. + +`stat.Stress` places the nodes and `geom.NodeLink` draws them, and three of the +claims behind it are measurements. + +**The sweep is in place, and the order is load-bearing.** Each node's block of +the majorizing quadratic is solved with the others held still and written back +before the next node reads it, which is block coordinate descent and does not +raise the stress. The first version computed every position from the previous +sweep's and called that order-independence a virtue; two nodes a target of 1 +apart, placed 1.02 apart, cycled 0.98, 1.02 for ever at constant stress. +`TestASweepNeverRaisesTheStress` and `TestTheSimultaneousSweepIsWhyThisOneIsNot` +hold both halves. + +**The start is classical scaling, chosen by eigenvalue value.** A circle start +folded a 4×4 grid in half. Power iteration finds the biggest eigenvalue by +magnitude, and graph distances are often not Euclidean — K(5,5)'s centred table +has a −5.5 that the iteration converged to and reported as 5.5 — so the +spectrum is shifted non-negative first. A table of rank one is an answer rather +than a failure: a path is a line, and keeping the one direction brought a +five-node chain from bowed by a seventh of its span to under a percent. + +**A fixed spiral does a random start's real job.** Two nodes with the same +neighbours sit on a saddle every sweep leaves alone; the gallery drew two team +members as one dot with two names. `Stress.scatter` pushes every node a +hundredth of the spread along its own direction, the golden angle apart. + +The layout runs in `Train` in the unit square, and `Build` fits it into the +largest square the panel holds, because what it matched is a distance and a +stretched axis would undo it. `stat.MaxStressNodes` is 250, where the hairball +and the quadratic cost arrive together; a bigger graph is `ErrTooManyNodes`. See +[ADR 0077](adr/0077-a-node-link-layout.md). + +Not in this milestone. **No force simulation** — not impossible, but it has no +objective, so the cost of its bound cannot be said. **The row order picks the +minimum**: the gallery's collaboration graph settles at a stress of 3.47 in its +own order and 2.30 under one relabelling, and a cleverer start only moved which +numbering was unlucky. **No multi-start**, because a trial was mixed — the +gallery's order gained a third, the relabelled one lost — and a default changes +on better evidence; fifty sweeps is a budget, not convergence. **No edge +weights as distances, no multi-level coarsening, no arrowheads or bundling, no +canonical rotation.** A node reports no row and an edge its own. ✔ + +### Parallel coordinates: a coord with more than two axes — **shipped** + +[chart-types.md](chart-types.md) had called parallel coordinates the widest +genuine gap on the page. A radar gets away with one scale because its spokes are +one quantity measured several times; a parallel plot exists because its axes are +*different* quantities, and squashing miles per gallon into the range of +kilograms says something false. What it needed was per-axis scales inside one +panel, and half of that had arrived unnoticed: +[ADR 0070](adr/0070-a-third-labelled-family.md)'s `Furniture.Families` are +ladders with their own lines and their own label text. + +**`coord.Parallel` is the first coord to hold scales.** Every other coord +borrows the panel's two and re-ranges them; this one carries one per dimension, +because N axes with N domains is what the panel *is*. `Frame` ranges each onto +the unit interval and pins the panel's own X and Y to mean which axis and how +far up it — `ternary.pin`'s move made N times. A dimension is a label and a +scale and never a column name: `geom.Dims` names the columns, the two are +matched in order, and a count mismatch is `geom.ErrDimensions` out of `Train`. +A mark reaches the scales through `geom.Training.Dims`, filled via the optional +`coord.Dimensions`, and at `Build` through `f.Coords()` — `Coord` gained no +method and `geom.Frame` is unchanged. + +**The axes are furniture, and nothing in `render` knows how many there are.** +Each dimension raises one `Family` — line, level marks, labels in its own units +and its name at the top — stroked and written by code that has been there since +0070. The panel's own ticks are silenced by the coord, since they would number +an axis index. One of 0070's rules had to be amended: a theme's +`theme.Ticks(false, false)` would have unlabelled the whole chart, so +`Furniture.FamiliesAreTheAxes` keeps the dimensions' numbers through it, and a +ternary chart, which does not set it, is unchanged. The labels sit inside the +panel, over the lines, because there is nowhere outside to put N ladders. + +**A row is a line, reported at every axis it crosses**, batched by colour with a +subpath per row so a thousand rows in three colours are three drawing calls. A +row missing a value gaps there rather than being joined across. And which way +up an axis reads is free: `scale.Linear(scale.Reverse())` gives the "better is +up" arrangement with nothing from the coord or the mark. See +[ADR 0078](adr/0078-a-coord-with-more-than-two-axes.md). + +Not in this milestone. **No categorical axes**, which are a second placement +rule and a half-step towards parallel sets. **No brushing or dragging axes**, +which are the host's. **No chosen axis order**, since an order out of an +optimiser is not a pure function of the input. **No curves between axes.** And +a gutter is still sized from the panel's own tick labels, so a chart that +leaves its theme alone reserves room it never writes into — `layout` knows +nothing about coords, [ADR 0018](adr/0018-coordinate-systems.md)'s deliberate +deferral, and this is the second chart to notice. ✔ + +### Parallel sets: the count between categorical columns — **shipped** + +[ADR 0078](adr/0078-a-coord-with-more-than-two-axes.md) left the table with +*categorical* columns drawn nowhere. A line per row is no answer: nine hundred +tickets over three such columns have twelve distinct paths between them, so the +picture shows which combinations occur and nothing about how many. And a caller +crossing neighbouring columns by hand into a `geom.Sankey` edge list is the +shape [ADR 0076](adr/0076-the-other-half-of-the-count.md) had just refused for +the set sizes. + +**It is a count, and not a coord.** `geom.ParallelSets` shares `geom.Dims` with +`geom.Parallel` and nothing else. A category has no value to place on a scale, +and the ribbons lie *between* the axes, so a coord that drew them would be +deciding a layout and drawing it. Both axes describe the unit square, as a +treemap's do. The arithmetic is `stat.Crosstab` — indices in, counts out, names +interned by the geom. + +**The layout is `stat.Sankey`'s, because the count is a flow.** The same total +passes through every column, the strongest form of what a flow layout needs. In +the busiest column the nodes fill the interval exactly, so the relaxation is a +no-op there and the plain stacked partition comes out; a sparser column spends +its slack standing nearer what it joins. One scale serves the diagram, so a +ribbon's thickness means the same thing everywhere. The column each category +stands in is *given*, through a new `Sankey.ResetColumns`: the longest-path rule +would send a category nothing reaches to the far left among the sources, which +`TestPinnedColumnsHoldWhereDerivedOnesWouldNot` pins. + +**The crossings are sorted once, and that is not the sort 0039 refuses.** A +crossing is a count over rows, not a row, so the table has no opinion about its +place; `Crosstab` orders by source category, target and class, a total order on +distinct keys and one sort of the answer rather than one per sweep. It stacks +ribbons in the order of the boxes at their far end, so they cross only where +the data does. A `geom.ColorBy` column subdivides the ribbons rather than +averaging a colour nobody named, and the boxes stay in the theme's label ink, +because asking the discrete scale for a box's colour would put every category +in a legend naming the classes. + +**A row missing a category is counted nowhere** — the one place an absent value +costs a row. Each column partitions the same total, and skipping only the +crossings beside a gap would leave one column adding up to less than the next. +See [ADR 0079](adr/0079-parallel-sets.md). + +Not in this milestone. **No labels on the boxes and no column titles**: a mark +that wrote text would need a policy for a box too small to hold its name. The +legend names the categories — `channel: phone`, joined by +`geom.DimensionSeparator`, so two columns' "yes" stay two — which makes this +the form that usually wants `figure.Legend(true)`. **No reordering to reduce +crossings**, the sankey's refusal unchanged. **No invented "missing" +category**, **no crosstab of every pair**, and **no row behind a ribbon**. An +alluvial diagram needs nothing that is not here. ✔ + +### Nearest-neighbour cells: a partition cut where the reader measures it — **shipped** + +The sweep of unusual forms in [chart-types.md](chart-types.md) had one row left +that was a decision rather than a recipe, and it had been left out for the +weakest reason in the file: nobody had asked. What people draw with it is a +rainfall map, a catchment, a coverage map of depots or gauges — and the only +architectural objection had gone when [ADR 0077](adr/0077-a-node-link-layout.md) +admitted a layout under a fixed budget. A nearest-neighbour partition is not +even that: it is an intersection of half-planes, arithmetic with an answer +rather than a search with a stopping rule. + +**The question no mark had answered is in what space a distance is measured.** +`geom.Voronoi` reads `X` and `Y` like a scatter, trains them like a scatter and +places its sites through `coord.Point` like a scatter. But a cell's whole +content is that its boundary is *halfway* between two dots, and there is no +distance between a millimetre of rain and a kilometre of easting. Cut in the +scaled pair and stretched into the panel, the partition goes through an +anisotropic map that takes perpendicular bisectors to lines that are not, and +every boundary on screen would be halfway between nothing. So `stat.Voronoi` +runs in `Build` against `Frame.Area` — the fourth stat to do so, after the +hexagonal lattice, the beeswarm and the treemap's squarify. The cost is stated +rather than hidden: the picture depends on the panel, and `examples/voronoi` +draws the same table with and without a colourbar to show the cells move. Under +`coord.Polar` the partition is clipped to the disc by the coord's own clip; the +mark asks the coord nothing. + +**It intersects half-planes; it does not sweep.** Each cell is the panel +rectangle clipped by one bisector per other site — quadratic where Fortune's +sweep is O(n log n), and chosen anyway, because a convex clip has one branch and +no topology to get wrong where a sweep decides its picture on circle-event +predicates a rounding away from a different answer. A site farther than twice +the cell's current radius is skipped with one distance. `stat.MaxVoronoiSites` +is 1000 and `geom.ErrTooManySites` refuses past it in `Train`, in +`geom.NodeLink`'s manner: a thousand cells in a 900×600 panel are twenty pixels +a side, and past that the answer is a raster. The result is a pure function of +its input in which even the row order does not matter, which no other layout +here can say. + +**A cell is a row, and it reports one** — the first layout-shaped mark for which +that is true. Two rows at the same device point have no bisector: the first +keeps the cell, the second draws and reports nothing but still cuts every other +cell. Colour is per cell, batched one call per colour with one subpath per +cell, which keeps [ADR 0007](adr/0007-per-mark-colour.md) intact and lets a +pointer land on the cell it is inside. Nothing below `geom` learned anything. +See [ADR 0080](adr/0080-nearest-neighbour-cells.md). + +Not in this milestone. **No dots and no labels** — `geom.Scatter` and +`geom.Text` over the same table do both. **`GroupBy` is ignored**, because one +partition over every row has no series inside it. **An outline only when both +`Fill` and `Color` are named**, `geom.Rect`'s rule for `interact`'s reason, and +**no padding**, because an inset cell is no longer the set of points nearest its +row. **No Lloyd relaxation, weighted diagram or Voronoi treemap** — each an +optimiser [ADR 0039](adr/0039-relational-layouts.md) refuses — and **no +Delaunay triangulation**, because interpolating between samples is a claim about +values where a cell is a claim about nearness. ✔ + +### A map projection: a coord handed degrees — **shipped** + +A geographic projection was deferred in one line in four places and argued in +none. [ADR 0018](adr/0018-coordinate-systems.md) had set the terms: a +projection has no linear interval underneath it and wants to be handed the data +domain rather than a mapped position, and that wider seam should be argued on +its own evidence. When the argument was finally written, both halves of the bill +turned out to have been paid already, by charts nobody thought of as maps. + +**The axes are degrees, because the Smith chart already did this.** `coord.Geo` +calls the same `identityRange` [ADR 0033](adr/0033-smith-charts.md) wrote for +the reflection coefficient: each scale is given its own domain as its range, so +`Map` is the identity and what reaches `Point` is a longitude and a latitude. +The axes want a **linear** scale for the Smith chart's reason. And three things +come free from the axes being ordinary: the graticule *is* the ticks — +`scale.TickValues(-180, -120, …)` asks for an atlas's graticule — a regional +map is a narrower domain, and a map **zooms**, because unlike a Smith chart its +picture is derived from the domain every `Frame`. + +**The graticule had a tick behind it all along.** Four records had circled the +claim that a graticule needs a labelled family with no tick behind it, and +[ADR 0070](adr/0070-a-third-labelled-family.md) spent the seam partly for it. +With degrees on the axes a meridian is a longitude tick's own shape, exactly as +a polar ring is a Y tick's, and goes into `Furniture.GridX`. What has no tick +behind it is the **edge of the map** — a globe's rim, a Mollweide's ellipse — +and that one unlabelled line is the family. Each ladder of numbers is written +along the longest line of the other, which reproduces the atlas on a +rectangular map and puts a Mollweide's longitudes on the equator. + +**The projection is a name, and the default is the equal-area one.** +`Mollweide`, `Mercator`, `PlateCarree` and `Orthographic` are a closed set of +`coord.Projection` values, so a document can name one; an unknown name is +`coord.ErrUnknownProjection` rather than the nearest guess. `DefaultProjection` +is `Mollweide` because on a map how much ground a thing covers reads as how much +of it there is — the other three are fine things to ask for and never a fine +thing to get by not choosing. The map keeps its shape with one scale factor for +both directions, fitted by walking a 33 × 33 lattice over the domain (plus the +rim for a globe) rather than by four sets of extremal formulas. A place with no +image is NaN, and the walk starts a new subpath where the image resumes; +Mercator is cut at ±85.051129°, and `Extent` reports the cut. Everything the +coord draws for itself is walked in longitudes measured from the centre +meridian, or a Pacific-centred map would draw its parallels straight back across +the world. Nothing in `geom`, `render` or `layout` changed. See +[ADR 0081](adr/0081-a-map-projection.md). + +Not in this milestone. **No geography**: a coastline is rows in the caller's +table, and a GeoJSON reader is a package allowed dependencies the core does not +have. **No spherical arithmetic** — a great circle and a rhumb line are two +claims about one pair of places, and `examples/map` draws both from its own +rows. **No antimeridian wrapping**, because 179° and −179° are two degrees apart +or three hundred and fifty-eight and the pair does not say which. **No filled +ring of rows** for a choropleth, which is a mark with four questions of its own. +**No conic projection**, whose two standard parallels `coord.Desc` has no field +for. And **no decimation**, because a pixel column is a band of longitude under +two projections and of nothing in particular under the other two. ✔ + +### A label fits its box, or is called out of it — **shipped** + +`geom.Text` has always dropped a box label that does not fit, because an +overrunning label reads as its neighbour's. What "fits" meant was one number, +the width of the box — under a polar coord the chord across the middle of the +slice — and a label is a width and a height laid out level in a box that is +there a wedge of an annulus. Donut and sunburst labels were drawn over the +edges of their slices, which is exactly what dropping exists to prevent. And a +pie's thin slices are where the number is most wanted, which is the leader line +[ADR 0040](adr/0040-label-collision-avoidance.md) deferred in its last line. + +**Fitting is decided on screen, by the coord.** The label's font box, laid out +as it will be drawn with a pixel and a half of padding, must lie inside the box, +and whether a device point does is what `coord.Coord.Invert` answers for every +coord. The test walks the font box's edges four points to a side, because a +donut's hole bows into a label whose corners are clear of it. Under Cartesian +the only change is that height now counts. + +**A label is moved, broken and shrunk before it is dropped, in that order.** +Under a coord with a middle — `coord.Exploder` again — it is also tried at a +fifth, a third, two thirds and four fifths along each span, because a sunburst +cell turns as it goes round; under Cartesian only the middle is tried and a bar +chart pays nothing. `geom.Slide(false)` pins it to the middle. `geom.Wrap` +breaks it at spaces over two or three lines, choosing the break whose widest +line is narrowest, and comes before shrinking because two lines at the layer's +size read better than one at three quarters of it. Then it is shrunk to the +largest size that fits, down to `geom.MinFontSize`, three quarters of the +layer's size by default — found by scaling metrics already measured and rounded +down to a quarter of a unit, so a chart asks for a few fonts rather than one per +label. + +**Below the floor, `geom.Callout` writes it outside.** The label leaves through +the edge furthest out along the box's bisector, runs out past everything the +coord draws, turns level on a short arm, and is stacked apart from its +neighbours a side at a time so that thin slices write a column. A label that +would overrun the panel is drawn in on a shorter arm first; the first version +dropped it outright and the gallery's donut lost its thinnest slice's label by +four pixels. The amendment went further: a called-out label is fitted to the +room beside the chart the way a box label is fitted to its box — one line, +then broken, then shrunk — because in a sunburst not much wider than its ring +the labels being dropped were the long names, the ones a reader could not guess +from the colour. A called-out label is outside the disc a polar panel clips to, +so `geom.Overhanger` asks `render` for the panel rectangle instead. A text layer +given `geom.ExplodeBy` moves with its slice, leader and all. See +[ADR 0082](adr/0082-a-label-fits-its-box-or-is-called-out.md). + +Not in this milestone. **No callouts under Cartesian**, for `Exploder`'s +reason: a bar has no outside that is not the next bar's inside, and a label +above a short bar would be a named option rather than a default. **Called-out +labels do not take part in `AvoidOverlap`** — they are stacked against each +other, and a label from another layer is not an obstacle to them. **A leader out +of an inner ring crosses the rings outside it**, which is the price of leaving +by the shortest way. And **no labels turned along a ring**: the fit takes a +rotation already, and what is missing is the rule that picks one. ✔ + +### An axis break is marked or not drawn — **shipped** + +Two charts asked for the same thing from opposite ends. Six sites, one thirty +times the others, where every other bar is a sliver unless the axis leaves out +the empty stretch between 12 and 85 — and says so with a `//`. And a machine's +state log, where the reader wants the time it was doing something and every +idle stretch folded away: not one interval chosen by eye but dozens read out of +the table the chart draws. + +**A break belongs to the scale, like a direction.** `scale.Break` and +`scale.TimeBreak` change `Map`, `Invert` and `Ticks`, which is +[ADR 0075](adr/0075-an-axis-has-a-direction.md)'s argument: every geom, +`interact`'s inversion, a track sharing the panel's scale and every facet panel +then agree by construction. It is configuration, so `Clone` keeps it, +`Snapshot` shares it and `Describe` writes it down. + +**`Map` stays continuous, and a value inside a break is not missing.** The kept +pieces share the range less the gaps, each through `place` with its own ends +snapped and its offsets rounded explicitly against a fused multiply-add. A value +in a cut maps linearly across the gap and the panel's clip hides it — so +`Invert` stays exact, `Defined` is unchanged, no geom learns about breaks, and a +bar from 0 to 95 across a break is one bar with its middle missing. Treating it +as missing would cut the tall bar the break exists for. + +**An unmarked break is a lie, so a coord that cannot mark one does not get +one.** The gap is a length on the page, which neither a scale nor a coord can +know; `scale.Breaker.SetBreakGap` is called once per render from +`theme.AxisBreakGap` and `theme.AxisFoldGap`, the way a size scale learns its +range. The cuts are off until then, and switched on only under a coord that +implements `coord.Breakable` — today only `Cartesian`, whose framed form clips +to one rectangle per pair of kept pieces and splits its axis and grid lines +into subpaths, with the marks in `Furniture.Breaks` stroked after the data. A +second axis, which has no clip, stays whole; so does a break that would take +more than half the axis. A cut reaching an end of the domain trims it instead. +Ticks are chosen once for the length the axis still shows and walked per piece, +so both sides read at one step and one precision. + +**A fold is a break of another kind.** `scale.Fold` and `scale.TimeFold` cut +with a narrow gap and a small slash, never a zigzag — a zigzag per fold would be +a hatch — and a binary search over running sums keeps a row at O(log n) with no +allocation, gated by `BenchmarkFolded1k` against `BenchmarkFolded100k`. +`figure.SpansWhere` reads the folds out of a table, living in the root package +because it is the one place holding both a table and a scale; the document +spells them `"cuts"` and `"folds"`, since `"breaks"` was taken, and writes the +spans rather than the query. With no break, every golden file still matches. +See [ADR 0083](adr/0083-an-axis-break-is-marked-or-not-drawn.md). + +Not in this milestone. **No breaks on a log, symlog or probability axis** — +the same arithmetic per piece, and nobody asked. **No recurring breaks as a +rule**, because a business-day axis is a calendar rather than an interval list, +and a calendar's spans can already be passed as folds. **No folds a layer +derives in `Train`**, because an axis would then change when a layer was added. +**No folding a category**, which is filtering rows. **No different scale on each +side of a break**: a break leaves an interval out of one axis and does not join +two. ✔ + --- **[README](../README.md)** · **[CONCEPT](../CONCEPT.md)** · **[ADRs](adr)** · [The gallery](gallery.md) · [Chart forms](charts.md) · [Interaction](interaction.md) · [A million rows](scale-out.md) · [Reading a chart](reading.md) · [JSON and Arrow](spec.md) · [Features](features.md) · [Chart-type catalogue](chart-types.md) · [Benchmarks](benchmarks.md) diff --git a/examples/market/live.go b/examples/market/live.go new file mode 100644 index 0000000..4ae5783 --- /dev/null +++ b/examples/market/live.go @@ -0,0 +1,139 @@ +package main + +import ( + "io" + "math" + "math/rand/v2" + "time" + + "github.com/timzifer/figure" + "github.com/timzifer/figure/data" + "github.com/timzifer/figure/geom" + "github.com/timzifer/figure/ir" + "github.com/timzifer/figure/scale" + "github.com/timzifer/figure/stat" +) + +// liveCandles is how many minutes of candles the live chart keeps. +const liveCandles = 120 + +// runLive is the chart as a trading screen redraws it: a feed of ticks folded +// into minute candles by a stat.Resampler, each tick either revising the open +// candle with Stream.ReplaceLast or opening the next with Stream.Append, drawn +// by a Live whose price axis fits whatever minutes are in view. Halfway +// through, the reader wheels into the last half hour; the time axis zooms, the +// host keeps that half hour's right-hand edge on the newest candle, and the +// price axis follows frame by frame without being zoomed itself. See +// docs/adr/0089-a-value-axis-fits-what-its-time-axis-shows.md. +// +// The frames are drawn to nowhere — a real host draws them to a window or a +// canvas — and the last one is written to out, so the artefact is the zoomed, +// fitted view the reader ended on. It returns the plot's axes for the test. +func runLive(out string, ticks int) (x, y scale.Scale, err error) { + st := data.NewStream("start", "end", "open", "high", "low", "close", "volume").Window(liveCandles) + + p := figure.New(figure.Size(900, 420), figure.Title("FIG — live, one-minute candles")) + x = scale.Time() + y = scale.Linear(scale.Nice(), scale.FitView()) + p.X(x).Y(y) + p.Add(geom.Candle(st.Source(), geom.X("start"), geom.X2("end"), + geom.OHLC("open", "high", "low", "close"), + geom.Rising(up, "up"), geom.Falling(down, "down"))) + // The volume fits its view too: the tallest bar of the session is not the + // scale of the last half hour. + p.Track(figure.Bottom, figure.TrackFraction(0.2), + figure.TrackScale(scale.Linear(scale.Zero(), scale.FitView())), figure.TrackAxis(true)). + Add(geom.Bar(st.Source(), geom.X("start"), geom.Y("volume"), geom.BarWidth(0.7), geom.Opacity(0.6), + // The stream carries numbers only, and the direction needs none + // other: it is read from the open and the close the candle has. + geom.DirectionBy("open", "close"), geom.Rising(up, "up"), geom.Falling(down, "down"))) + + // The last price, tagged on the price axis in the open candle's direction. + // On a stream it follows the candle being revised, because that candle is + // the stream's last row. + p.Add(geom.LastValue(st.Source(), geom.X("start"), geom.Y("close"), geom.Dash(3, 3), + geom.DirectionBy("open", "close"), geom.Rising(up, "up"), geom.Falling(down, "down"))) + + live, err := p.Live(figure.SVGWriter(io.Discard)) + if err != nil { + return nil, nil, err + } + defer live.Close() + // Rows are tracked so that a crosshair can snap to the candle under the + // pointer's time. + live.TrackRows(true) + cross := &figure.Crosshair{Snap: live.Index(), Tags: true, Panel: -1} + live.Overlay(cross) + + r := stat.Resampler{Origin: scale.Nanos(liveOpen), Width: float64(time.Minute)} + rng := rand.New(rand.NewPCG(3, 89)) + price := 100.0 + row := make([]float64, 7) + zoomed, width := false, 0.0 + for i := range ticks { + at := liveOpen.Add(time.Duration(i) * 2 * time.Second) + price *= 1 + 0.0001*math.Sin(float64(i)/400) + rng.NormFloat64()*0.0008 + c, fresh, err := r.Add(scale.Nanos(at), price, 1+rng.Float64()*9) + if err != nil { + return nil, nil, err + } + row[0], row[1], row[2], row[3], row[4], row[5], row[6] = c.Start, c.End, c.Open, c.High, c.Low, c.Close, c.Volume + if fresh { + err = st.Append(row...) + } else { + err = st.ReplaceLast(row...) + } + if err != nil { + return nil, nil, err + } + + // A frame every ten ticks, as a screen refreshes on its own clock + // rather than on the feed's. + if i%10 != 9 { + continue + } + st.Snapshot() + if zoomed { + // Following the newest data is the host's, not the library's: it + // keeps the width the reader zoomed to and moves the right-hand + // edge to the open candle, and the price axis fits whatever that + // leaves in view. + x.(scale.Zoomer).SetDomain(c.End-width, c.End) + } + if err := live.Draw(); err != nil { + return nil, nil, err + } + if !zoomed && i >= ticks/2 { + // The reader wheels in over the right-hand edge, where the newest + // candles are. Only time zooms; the price axis follows. + area := live.Index().Panels()[0].Area + if err := live.Wheel(float64(area.Max.X)-2, float64(area.Min.Y+area.Max.Y)/2, 0.25); err != nil { + return nil, nil, err + } + lo, hi := x.Domain() + width, zoomed = hi-lo, true + // The reader's pointer rests two thirds of the way across the + // panel; each frame the crosshair snaps to the candle there. + cross.At = ir.Point{X: area.Min.X + (area.Max.X-area.Min.X)*2/3, Y: (area.Min.Y + area.Max.Y) / 2} + cross.Show = true + } + } + st.Snapshot() + + // The artefact is the frame the reader ended on, crosshair and all, drawn + // once more to a file. One Draw is enough to snap: the overlay paints last, + // after the frame has reported its rows. + final, err := p.Live(figure.SVG(out)) + if err != nil { + return nil, nil, err + } + final.TrackRows(true) + final.Overlay(&figure.Crosshair{Snap: final.Index(), Tags: true, Show: true, Panel: -1, At: cross.At}) + if err := final.Draw(); err != nil { + return nil, nil, err + } + return x, y, final.Close() +} + +// liveOpen is when the simulated session opens. +var liveOpen = time.Date(2026, time.September, 28, 9, 30, 0, 0, time.UTC) diff --git a/examples/market/main.go b/examples/market/main.go new file mode 100644 index 0000000..39b13ca --- /dev/null +++ b/examples/market/main.go @@ -0,0 +1,296 @@ +// Command market renders a price chart the way a trading screen draws one: +// candles on a calendar with the weekends taken out, two moving averages and a +// Bollinger band over them, the crossings marked as buys and sells, the volume +// traded each day underneath, and the volume traded at each price beside. +// +// docs/adr/0085-what-a-market-chart-needs.md catalogues what such a chart is +// made of, and each part is one layer here: geom.Candle reads the four values +// and decides each day's direction itself (docs/adr/0088); the band is an area +// between two columns; the volume is a bar in a bottom track that shares the +// time axis; the profile is a horizontal bar in a right track that shares the +// price axis. Which days the market trades is a scale.Calendar, and the axis +// folds what it says is closed (docs/adr/0086). The volume bars are coloured +// by the same direction through geom.DirectionBy, in the candles' own colours +// (docs/adr/0091), so no column in the table is computed for the chart's sake. +// +// It is executed by a test so that it cannot silently stop compiling or stop +// producing a chart. +package main + +import ( + "flag" + "fmt" + "math" + "math/rand/v2" + "os" + "time" + + "github.com/timzifer/figure" + "github.com/timzifer/figure/data" + "github.com/timzifer/figure/geom" + "github.com/timzifer/figure/ir" + "github.com/timzifer/figure/palette" + "github.com/timzifer/figure/scale" + "github.com/timzifer/figure/stat" +) + +func main() { + out := flag.String("o", "market.svg", "output SVG path") + live := flag.Bool("live", false, "draw the live chart: a tick feed into minute candles, zoomed halfway") + flag.Parse() + var err error + if *live { + _, _, err = runLive(*out, 7200) + } else { + err = run(*out) + } + if err != nil { + fmt.Fprintln(os.Stderr, "market:", err) + os.Exit(1) + } +} + +// up and down are the two candle colours. Blue and vermilion rather than green +// and red, because the pair a trading screen uses is the pair the most common +// colour deficiency cannot tell apart; the body's position against the wick +// says the same thing a second time. +var ( + up = palette.Blue + down = palette.Vermilion +) + +// The indicator windows, in trading days. +const ( + fast = 9 // the EMA + slow = 20 // the simple mean the band is drawn about +) + +func run(out string) error { + m := simulate() + days := stat.OHLC(m.ts, m.ps, m.vs, scale.Nanos(start), float64(24*time.Hour)) + candles := candleTable(days) + + p := figure.New( + figure.Size(1000, 660), + figure.Title("FIG — daily, weekends and holidays folded"), + ) + // The days the market is closed are left out of the axis rather than + // drawn empty: the weekends and the holiday, from the calendar that also + // decided which days were simulated. + closed, err := scale.Folds(trading, start, end) + if err != nil { + return err + } + p.X(scale.Time(scale.TimeCalendar(trading), scale.TimeFold(closed...))) + p.Y(scale.Linear(scale.Nice())) + + // The band first, so everything else is drawn over it. + p.Add(geom.Area(candles, geom.X("t"), geom.Y("lower"), geom.Y2("upper"), + geom.Color(palette.Gray), geom.Opacity(0.18), geom.Label("Bollinger 20, 2σ"))) + + // One layer per candle chart: the mark reads the four values and draws the + // wick and the body in the direction it decides. + p.Add(geom.Candle(candles, geom.X("t"), geom.OHLC("open", "high", "low", "close"), + geom.Rising(up, "up"), geom.Falling(down, "down"))) + + p.Add(geom.Line(candles, geom.X("t"), geom.Y("ema"), + geom.Color(palette.Orange), geom.Label(fmt.Sprintf("EMA %d", fast)))) + p.Add(geom.Line(candles, geom.X("t"), geom.Y("mid"), + geom.Color(palette.Purple), geom.Label(fmt.Sprintf("SMA %d", slow)))) + + // Where the fast average crosses the slow one: a buy under the candle, a + // sell over it, pointing the way the price is expected to go. + buys, sells := signals(days) + p.Add(geom.Scatter(buys, geom.X("t"), geom.Y("at"), + geom.Shape(ir.MarkerTriangle), geom.Size(10), geom.Color(up), geom.Label("buy"))) + p.Add(geom.Scatter(sells, geom.X("t"), geom.Y("at"), + geom.Shape(ir.MarkerTriangleDown), geom.Size(10), geom.Color(down), geom.Label("sell"))) + + // The volume each day, under the price and on the same time axis: one + // scale object, so a zoom on a live chart moves both. Coloured by the day's + // direction with the candles' options, so the two agree and the legend + // names up and down once. + p.Track(figure.Bottom, figure.TrackFraction(0.2), figure.TrackScale(scale.Linear(scale.Zero())), figure.TrackAxis(true)). + Add(geom.Bar(candles, geom.X("t"), geom.Y("volume"), geom.BarWidth(0.7), + geom.DirectionBy("open", "close"), geom.Rising(up, "up"), geom.Falling(down, "down"), + geom.Opacity(0.6))) + + // The fourteen-day RSI, in a track of its own under the volume, with the + // band between 30 and 70 that the two thresholds a reader looks for bound. + // It is a function rather than a composition of EMAs because its averages + // are Wilder's (docs/adr/0092). + p.Track(figure.Bottom, figure.TrackFraction(0.16), figure.TrackScale(scale.Linear(scale.Domain(0, 100))), figure.TrackAxis(true)). + Add(geom.HBand(30, 70, geom.Label("RSI 30–70")), + geom.Line(candles, geom.X("t"), geom.Y("rsi"), geom.Color(palette.Purple), geom.Label("RSI 14"))) + + // The last close, on the price axis at its own precision, in its day's + // direction, with a rule across the panel to it. + p.Add(geom.LastValue(candles, geom.X("t"), geom.Y("close"), geom.Dash(3, 3), + geom.DirectionBy("open", "close"), geom.Rising(up, "up"), geom.Falling(down, "down"))) + + // The volume traded at each price, beside the price and on the same price + // axis: a bar lying on its side per price bucket, buys stacked first and + // sells after them, so the bar's length is the total and its split is the + // balance. The buckets are contiguous, so the bars fill their slots. + p.Track(figure.Right, figure.TrackSize(140), figure.TrackScale(scale.Linear(scale.Zero())), figure.TrackAxis(true)). + Add(geom.Bar(profile(m), geom.Y("price"), geom.X("volume"), + geom.Orient(geom.Horizontal), geom.BarWidth(1), geom.GroupBy("side"), + geom.ColorBy("side", scale.Named(map[string]ir.Color{"buy": up, "sell": down})), + geom.Opacity(0.7))) + + return p.Render(figure.SVG(out)) +} + +// start is the first calendar day of the chart: a Monday, so that the weeks +// line up with the folds and the picture is the same every run. +var start = time.Date(2026, time.June, 1, 0, 0, 0, 0, time.UTC) + +// calendarDays is how long the chart runs: twelve weeks. +const calendarDays = 7 * 12 + +// end is the day after the chart's last. +var end = start.AddDate(0, 0, calendarDays) + +// holiday is the one weekday the market is closed on in the chart: the Friday +// Independence Day is observed on in 2026. +var holiday = time.Date(2026, time.July, 3, 0, 0, 0, 0, time.UTC) + +// trading is the market's calendar: Monday to Friday, all day — a daily chart +// needs the days, not the hours — without the holiday. +var trading = scale.Closed( + scale.Workweek(time.UTC, scale.Weekdays(time.Monday, time.Tuesday, time.Wednesday, time.Thursday, time.Friday)), + scale.Dates(holiday)) + +// market is the ticks of the simulated trading: a time, a price, and the +// volume bought and sold at it. +type market struct { + ts, ps, vs []float64 + buys, sells []float64 +} + +// simulate is a seeded random walk, traded between 09:30 and 16:00 on +// weekdays. It is a walk rather than a recorded series so that the example +// needs no data file and draws the same picture every time. +func simulate() market { + r := rand.New(rand.NewPCG(7, 85)) + var m market + price := 100.0 + for d := range calendarDays { + day := start.AddDate(0, 0, d) + if len(trading.Open(nil, day)) == 0 { + continue + } + // A drift that changes sign every few weeks, so the averages cross. + drift := 0.04 * math.Sin(float64(d)/9) + for tick := range 40 { + at := day.Add(9*time.Hour + 30*time.Minute + time.Duration(tick)*585*time.Second) + price *= 1 + drift/40 + r.NormFloat64()*0.004 + v := 100 + r.Float64()*900 + bought := v * (0.5 + drift*4 + r.NormFloat64()*0.1) + bought = min(max(bought, 0), v) + m.ts = append(m.ts, scale.Nanos(at)) + m.ps = append(m.ps, price) + m.vs = append(m.vs, v) + m.buys = append(m.buys, bought) + m.sells = append(m.sells, v-bought) + } + } + return m +} + +// candleTable is the day candles as the columns the layers read, with the +// indicators beside them. A candle is drawn at the middle of its day, so that +// its slot is the day rather than straddling midnight into a fold. +func candleTable(days []stat.Candle) *data.Table { + n := len(days) + t := make([]time.Time, n) + xs := make([]float64, n) + closes := make([]float64, n) + o, h, l, c, v := make([]float64, n), make([]float64, n), make([]float64, n), make([]float64, n), make([]float64, n) + for i, d := range days { + t[i] = scale.FromNanos(d.Start + float64(12*time.Hour)) + xs[i] = float64(i) + closes[i] = d.Close + o[i], h[i], l[i], c[i], v[i] = d.Open, d.High, d.Low, d.Close, d.Volume + } + + // The indicators run over the row index rather than the time: a trading + // day is a row, and the weekend between two of them is not two days of + // average. + ema := ys(stat.EMA(xs, closes, fast)) + mid := ys(stat.TrailingMean(xs, closes, slow)) + sd := ys(stat.RollingStdDev(xs, closes, slow)) + rsi := ys(stat.RSI(xs, closes, 14)) + lower, upper := make([]float64, n), make([]float64, n) + for i := range n { + lower[i], upper[i] = mid[i]-2*sd[i], mid[i]+2*sd[i] + } + + return figure.NewTable(). + Time("t", t). + Float64("open", o).Float64("high", h).Float64("low", l).Float64("close", c). + Float64("volume", v). + Float64("ema", ema).Float64("mid", mid).Float64("rsi", rsi). + Float64("lower", lower).Float64("upper", upper) +} + +// signals is where the EMA crosses the simple mean: upwards is a buy, drawn +// under the day's low, and downwards a sell, drawn over its high. The first +// slow window is skipped — the mean is over fewer rows there than its name +// says, and a signal from it is a signal from a different indicator. +func signals(days []stat.Candle) (buys, sells *data.Table) { + xs := make([]float64, len(days)) + closes := make([]float64, len(days)) + for i, d := range days { + xs[i], closes[i] = float64(i), d.Close + } + ema := ys(stat.EMA(xs, closes, fast)) + mid := ys(stat.TrailingMean(xs, closes, slow)) + + var bt, st []time.Time + var ba, sa []float64 + for i := slow; i < len(days); i++ { + at := scale.FromNanos(days[i].Start + float64(12*time.Hour)) + pad := (days[i].High - days[i].Low) * 0.4 + switch { + case ema[i-1] <= mid[i-1] && ema[i] > mid[i]: + bt, ba = append(bt, at), append(ba, days[i].Low-pad) + case ema[i-1] >= mid[i-1] && ema[i] < mid[i]: + st, sa = append(st, at), append(sa, days[i].High+pad) + } + } + return figure.NewTable().Time("t", bt).Float64("at", ba), + figure.NewTable().Time("t", st).Float64("at", sa) +} + +// profile is the volume at each price, bought and sold, one row per bucket +// and side, for a stacked horizontal bar. The two sides are binned over one +// interval — the whole price column's — so that their buckets line up. +func profile(m market) *data.Table { + lo, hi := math.Inf(1), math.Inf(-1) + for _, p := range m.ps { + lo, hi = min(lo, p), max(hi, p) + } + const bins = 28 + b := stat.BinWeighted(m.ps, m.buys, lo, hi, bins) + s := stat.BinWeighted(m.ps, m.sells, lo, hi, bins) + + var price, volume []float64 + var side []string + for i := range b { + price = append(price, b[i].Mid(), s[i].Mid()) + volume = append(volume, b[i].Sum, s[i].Sum) + side = append(side, "buy", "sell") + } + return figure.NewTable(). + Float64("price", price).Float64("volume", volume). + String("side", side) +} + +func ys(ps []stat.Point) []float64 { + out := make([]float64, len(ps)) + for i, p := range ps { + out[i] = p.Y + } + return out +} diff --git a/examples/market/main_test.go b/examples/market/main_test.go new file mode 100644 index 0000000..a66bac5 --- /dev/null +++ b/examples/market/main_test.go @@ -0,0 +1,91 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/timzifer/figure/scale" + "github.com/timzifer/figure/stat" +) + +// TestExampleRuns executes the documented example, so that the chart a market +// is cannot stop compiling without anyone noticing. +func TestExampleRuns(t *testing.T) { + out := filepath.Join(t.TempDir(), "market.svg") + if err := run(out); err != nil { + t.Fatalf("run: %v", err) + } + b, err := os.ReadFile(out) + if err != nil { + t.Fatalf("no output was written: %v", err) + } + got := string(b) + for _, want := range []string{ + "", + } { + if !strings.Contains(got, want) { + t.Errorf("output is missing %q", want) + } + } +} + +// Every candle falls on a trading day, so none of them is inside a fold — a +// candle drawn into a weekend or the holiday would be drawn into a gap the +// axis took out. +func TestNoCandleFallsInAWeekend(t *testing.T) { + m := simulate() + days := stat.OHLC(m.ts, m.ps, m.vs, scale.Nanos(start), float64(24*time.Hour)) + if len(days) != calendarDays*5/7-1 { + t.Fatalf("%d candles, want one per weekday of %d calendar days but the holiday", len(days), calendarDays) + } + folds, err := scale.Folds(trading, start, end) + if err != nil { + t.Fatal(err) + } + if len(folds) != 12 { + t.Errorf("%d folds, want twelve weekends with the holiday joined to one", len(folds)) + } + for _, d := range days { + at := scale.FromNanos(d.Start + float64(12*time.Hour)) + for _, f := range folds { + if !at.Before(f.From) && at.Before(f.To) { + t.Errorf("candle at %v is inside the fold %v–%v", at, f.From, f.To) + } + } + } +} + +// The crossings exist in the simulated walk, so the markers are exercised +// rather than drawn from empty tables. +func TestTheWalkCrossesBothWays(t *testing.T) { + m := simulate() + days := stat.OHLC(m.ts, m.ps, m.vs, scale.Nanos(start), float64(24*time.Hour)) + buys, sells := signals(days) + if buys.Len() == 0 || sells.Len() == 0 { + t.Errorf("%d buys and %d sells, want at least one of each", buys.Len(), sells.Len()) + } +} + +// The live chart ends zoomed into its last candles, with the price axis +// fitted to them rather than zoomed: unpinned, and inside the full session's +// range. +func TestTheLiveChartFitsItsPriceAxisToTheZoom(t *testing.T) { + out := filepath.Join(t.TempDir(), "live.svg") + x, y, err := runLive(out, 7200) + if err != nil { + t.Fatalf("runLive: %v", err) + } + if !scale.Pinned(x) { + t.Error("the time axis is not zoomed") + } + if scale.Pinned(y) { + t.Error("the price axis was zoomed rather than fitted") + } + b, err := os.ReadFile(out) + if err != nil || !strings.Contains(string(b), "live, one-minute candles") { + t.Errorf("no live chart was written: %v", err) + } +} diff --git a/examples/stream/main.go b/examples/stream/main.go index dac2319..da1020a 100644 --- a/examples/stream/main.go +++ b/examples/stream/main.go @@ -47,22 +47,7 @@ const window = 600 func run(out string, frames int) (stats, error) { st := data.NewStream("t", "y", "load").Window(window) - - p := figure.New( - figure.Theme(theme.Dark), - figure.Size(900, 400), - figure.Title("Live throughput"), - figure.YTitle("rows/s"), - ) - p.X(scale.Time()) - // A pinned Y axis is what makes a live chart readable: an axis that - // rescales itself every frame turns every change into a redraw of - // everything, and makes two frames impossible to compare by eye. - p.Y(scale.Linear(scale.Domain(0, 120))) - p.Add( - geom.Line(st.Source(), geom.X("t"), geom.Y("y"), geom.Color(palette.SkyBlue), geom.Label("throughput")), - geom.HLine(100, geom.Label("capacity"), geom.Dash(6, 4)), - ) + p, _ := chart(st) // A tooltip's worth of wiring: what the pointer is over, whenever it moves. p.On(figure.Hover, func(ev figure.Event) { @@ -123,6 +108,29 @@ func run(out string, frames int) (stats, error) { return surface.stats(frames), nil } +// chart is the plot over the stream, and its time axis — which slides as the +// window does, because every render derives an axis's trained range from the +// rows it draws (docs/adr/0090). +func chart(st *data.Stream) (*figure.Plot, scale.Scale) { + p := figure.New( + figure.Theme(theme.Dark), + figure.Size(900, 400), + figure.Title("Live throughput"), + figure.YTitle("rows/s"), + ) + x := scale.Time() + p.X(x) + // A pinned Y axis is what makes a live chart readable: an axis that + // rescales itself every frame turns every change into a redraw of + // everything, and makes two frames impossible to compare by eye. + p.Y(scale.Linear(scale.Domain(0, 120))) + p.Add( + geom.Line(st.Source(), geom.X("t"), geom.Y("y"), geom.Color(palette.SkyBlue), geom.Label("throughput")), + geom.HLine(100, geom.Label("capacity"), geom.Dash(6, 4)), + ) + return p, x +} + // seed fills the window with history, so that the first frame is the frame a // chart that has been open for a while draws. func seed(st *data.Stream, n int) { diff --git a/examples/stream/main_test.go b/examples/stream/main_test.go index 27a0afb..59f47f6 100644 --- a/examples/stream/main_test.go +++ b/examples/stream/main_test.go @@ -1,10 +1,13 @@ package main import ( + "math/rand/v2" "os" "path/filepath" "strings" "testing" + + "github.com/timzifer/figure/data" ) // TestExampleRuns drives the documented streaming example, which is also the @@ -53,3 +56,36 @@ func TestSteadyFramesRepaintLess(t *testing.T) { t.Errorf("frames repainted %.0f%% of the canvas on average, want less than a full repaint", 100*f) } } + +// TestAFullWindowSlides is the claim run's comment makes about a full window: +// once rows fall out of it, the time axis starts where the window does rather +// than where the series did. It appends deterministically, without the +// producer goroutine, so the answer does not depend on a race. +func TestAFullWindowSlides(t *testing.T) { + st := data.NewStream("t", "y", "load").Window(window) + p, x := chart(st) + live, err := p.Live(&surface{w: 900, h: 400}) + if err != nil { + t.Fatal(err) + } + defer live.Close() + seed(st, window) + st.Snapshot() + if err := live.Draw(); err != nil { + t.Fatal(err) + } + r := rand.New(rand.NewPCG(1, 2)) + for i := window; i < window+100; i++ { + st.Append(sample(r, i)) + } + st.Snapshot() + if err := live.Draw(); err != nil { + t.Fatal(err) + } + lo, hi := x.Domain() + want, _, _ := sample(r, 100) + last, _, _ := sample(r, window+99) + if lo != want || hi != last { + t.Errorf("the time axis is [%v, %v], want the window's [%v, %v]", lo, hi, want, last) + } +} diff --git a/fit_test.go b/fit_test.go new file mode 100644 index 0000000..2885d90 --- /dev/null +++ b/fit_test.go @@ -0,0 +1,148 @@ +package figure_test + +import ( + "math" + "testing" + + "github.com/timzifer/figure" + "github.com/timzifer/figure/data" + "github.com/timzifer/figure/geom" + "github.com/timzifer/figure/internal/irtest" + "github.com/timzifer/figure/scale" +) + +// rampSource is y = x over 0..100, so the fitted extent of any window is the +// window. +func rampSource() *data.Table { + xs, ys := make([]float64, 101), make([]float64, 101) + for i := range xs { + xs[i], ys[i] = float64(i), float64(i) + } + return figure.NewTable().Float64("x", xs).Float64("y", ys) +} + +func drawn(t *testing.T, p *figure.Plot) *figure.Live { + t.Helper() + live, err := p.Live(irtest.New().Target()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { live.Close() }) + if err := live.Draw(); err != nil { + t.Fatal(err) + } + return live +} + +// A value axis that fits its view is trained on the rows its pinned X shows, +// and a line crossing the edge fits the value where it crosses. +func TestAValueAxisFitsThePinnedWindow(t *testing.T) { + x, y := scale.Linear(), scale.Linear(scale.FitView()) + x.(scale.Zoomer).SetDomain(20.5, 40.5) + p := figure.New(figure.Size(400, 300)) + p.X(x).Y(y) + p.Add(geom.Line(rampSource(), geom.X("x"), geom.Y("y"))) + drawn(t, p) + lo, hi := y.Domain() + if math.Abs(lo-20.5) > 1e-9 || math.Abs(hi-40.5) > 1e-9 { + t.Errorf("the fitted axis is [%v, %v], want the window [20.5, 40.5] — the line's value at each edge", lo, hi) + } +} + +// Without the option, or with X unpinned, nothing changes. +func TestAnAxisThatDoesNotFitSeesEveryRow(t *testing.T) { + x, y := scale.Linear(), scale.Linear() + x.(scale.Zoomer).SetDomain(20, 40) + p := figure.New(figure.Size(400, 300)) + p.X(x).Y(y) + p.Add(geom.Line(rampSource(), geom.X("x"), geom.Y("y"))) + drawn(t, p) + if lo, hi := y.Domain(); lo != 0 || hi != 100 { + t.Errorf("an ordinary axis is [%v, %v], want every row's [0, 100]", lo, hi) + } + + y = scale.Linear(scale.FitView()) + p = figure.New(figure.Size(400, 300)) + p.X(scale.Linear()).Y(y) + p.Add(geom.Line(rampSource(), geom.X("x"), geom.Y("y"))) + drawn(t, p) + if lo, hi := y.Domain(); lo != 0 || hi != 100 { + t.Errorf("a fitting axis over an unpinned X is [%v, %v], want every row", lo, hi) + } +} + +// A wheel over a fitting chart zooms X alone, and the next frame fits Y. +func TestAWheelZoomsTimeAndThePriceAxisFollows(t *testing.T) { + x, y := scale.Linear(), scale.Linear(scale.FitView()) + p := figure.New(figure.Size(400, 300)) + p.X(x).Y(y) + p.Add(geom.Line(rampSource(), geom.X("x"), geom.Y("y"))) + live := drawn(t, p) + area := live.Index().Panels()[0].Area + cx, cy := float64(area.Min.X+area.Max.X)/2, float64(area.Min.Y+area.Max.Y)/2 + if err := live.Wheel(cx, cy, 0.5); err != nil { + t.Fatal(err) + } + xlo, xhi := x.Domain() + if xhi-xlo > 60 { + t.Fatalf("X is [%v, %v] after a zoom by half", xlo, xhi) + } + if scale.Pinned(y) { + t.Error("the wheel pinned the fitting axis") + } + ylo, yhi := y.Domain() + if math.Abs(ylo-xlo) > 1e-6 || math.Abs(yhi-xhi) > 1e-6 { + t.Errorf("Y is [%v, %v], want it fitted to X's [%v, %v]", ylo, yhi, xlo, xhi) + } + + // A pan slides X, and Y follows again; Autoscale returns to every row. + live.Move(cx, cy) + if err := live.PanBy(-40, 0); err != nil { + t.Fatal(err) + } + xlo2, _ := x.Domain() + if ylo2, _ := y.Domain(); math.Abs(ylo2-xlo2) > 1e-6 || xlo2 == xlo { + t.Errorf("after a pan X starts at %v and Y at %v", xlo2, ylo2) + } + if err := live.Autoscale(); err != nil { + t.Fatal(err) + } + if lo, hi := y.Domain(); lo != 0 || hi != 100 { + t.Errorf("after Autoscale Y is [%v, %v], want every row", lo, hi) + } +} + +// A candle half in view still fits, and the candles wholly out of it do not. +func TestAFittedPriceAxisFitsTheVisibleCandles(t *testing.T) { + tb := figure.NewTable(). + Float64("t", []float64{0, 1, 2, 3, 4}). + Float64("o", []float64{10, 50, 51, 52, 90}). + Float64("h", []float64{11, 55, 56, 57, 95}). + Float64("l", []float64{9, 45, 46, 47, 85}). + Float64("c", []float64{10, 51, 52, 53, 91}) + x, y := scale.Linear(), scale.Linear(scale.FitView()) + x.(scale.Zoomer).SetDomain(1.2, 3) + p := figure.New(figure.Size(400, 300)) + p.X(x).Y(y) + p.Add(geom.Candle(tb, geom.X("t"), geom.OHLC("o", "h", "l", "c"))) + drawn(t, p) + // The candle at 1 reaches to 1.35 with a body of 0.7, so it is in view; + // those at 0 and 4 are not. + if lo, hi := y.Domain(); lo != 45 || hi != 57 { + t.Errorf("the price axis is [%v, %v], want the three candles in view's [45, 57]", lo, hi) + } +} + +// A layer that cannot restrict its training fits loosely, never tightly: the +// axis still holds everything it drew. +func TestALayerThatCannotFitIsLooseNotClipped(t *testing.T) { + x, y := scale.Linear(), scale.Linear(scale.FitView()) + x.(scale.Zoomer).SetDomain(20, 40) + p := figure.New(figure.Size(400, 300)) + p.X(x).Y(y) + p.Add(geom.Boxplot(rampSource(), geom.X("x"), geom.Y("y"))) + drawn(t, p) + if lo, hi := y.Domain(); lo > 0 || hi < 100 { + t.Errorf("a boxplot under a fitting axis trained to [%v, %v], want at least every row", lo, hi) + } +} diff --git a/geom/area.go b/geom/area.go index 94afd3f..5f7ad32 100644 --- a/geom/area.go +++ b/geom/area.go @@ -36,11 +36,12 @@ const areaFillOpacity = 0.25 const stackedFillOpacity = 1 type areaGeom struct { - src data.Source - cfg config - s series - gs groups - err error + src data.Source + cfg config + s series + gs groups + gaps []float64 // the buffer a fitted stack's spacing is measured out of + err error } func (g *areaGeom) Train(t Training) error { @@ -53,15 +54,24 @@ func (g *areaGeom) Train(t Training) error { return err } trainColumn(x, g.s.x) + // A stacked area's totals are trained on the rows in view and one spacing + // either side, which reaches the rows its edges are drawn towards. + g.gs.within = nil + if t.Within != nil { + gap, buf := smallestGap(g.gaps, g.s.x) + g.gaps = buf + g.gs.within = viewPadded(t.Within, gap) + } if g.err = g.gs.train(g.src, g.s, g.cfg, x, y, g.cfg.stackFor(StackZero)); g.err != nil { return g.err } if g.gs.stacked() { return nil } - trainColumn(y, g.s.y) + of := g.gs.ofRows() + trainConnected(y, t.Within, g.s.x, g.s.y, of, false) if g.s.y2 != nil { - trainColumn(y, g.s.y2) + trainConnected(y, t.Within, g.s.x, g.s.y2, of, false) return nil } // The baseline is part of the shape, so it has to be inside the domain or diff --git a/geom/bar.go b/geom/bar.go index c8b2f4f..eb253e6 100644 --- a/geom/bar.go +++ b/geom/bar.go @@ -19,6 +19,13 @@ import ( // pair is a slice's inner and outer radius: a donut whose slices reach // different distances is this layer with one more column, not another mark. // [Explode] and [ExplodeBy] then break a slice out of the ring. +// +// [Orient] with [Horizontal] lays the bar on its side: the categories or +// positions are the [Y] column, the value is the [X] one, and the bar grows +// from the baseline across X. [X2] is then the value's far end and [Y2] the +// slot's far edge — the roles swap with the axes, so a horizontal bar is the +// vertical one's encoding read a quarter turn round. Stacking, dodging, +// explode and extrude follow; a dodged group's first series is the top one. func Bar(src data.Source, opts ...Option) Geom { return &barGeom{src: src, cfg: newConfig(opts)} } @@ -37,12 +44,13 @@ type barGeom struct { } func (g *barGeom) Train(t Training) error { - x, y := t.X, t.Y - g.s, g.err = resolve(g.src, g.cfg, x, y) + x, y := g.cfg.axes(t.X, t.Y) + rc := g.cfg.roles() + g.s, g.err = resolve(g.src, rc, x, y) if g.err != nil { return g.err } - if err := g.s.checkMissing(g.cfg, x, y); err != nil { + if err := g.s.checkMissing(rc, x, y); err != nil { return err } if g.cfg.widthCol != "" { @@ -51,17 +59,17 @@ func (g *barGeom) Train(t Training) error { return g.err } if len(g.width) != len(g.s.x) { - g.err = errLength(g.cfg.xcol, g.cfg.widthCol, len(g.s.x), len(g.width)) + g.err = errLength(rc.xcol, rc.widthCol, len(g.s.x), len(g.width)) return g.err } } - if g.cfg.x2col != "" { - g.x2, g.err = column(g.src, g.cfg.x2col, x) + if rc.x2col != "" { + g.x2, g.err = column(g.src, rc.x2col, x) if g.err != nil { return g.err } if len(g.x2) != len(g.s.x) { - g.err = errLength(g.cfg.xcol, g.cfg.x2col, len(g.s.x), len(g.x2)) + g.err = errLength(rc.xcol, rc.x2col, len(g.s.x), len(g.x2)) return g.err } } @@ -83,11 +91,24 @@ func (g *barGeom) Train(t Training) error { // axis has to describe what will be drawn: a stacked bar reaches the // cumulative total, and an axis trained on the individual values would let // the tallest stack run off the top of it. - if g.err = g.gs.train(g.src, g.s, g.cfg, x, y, g.cfg.stackFor(StackZero)); g.err != nil { + // A horizontal bar's value runs across X, and a fit is Y fitting X, so + // only a vertical bar reads the view. + within := t.Within + if g.cfg.orient == Horizontal { + within = nil + } + g.gs.within = viewPadded(within, g.slot()*g.widthFraction()/2) + if g.err = g.gs.train(g.src, g.s, rc, x, y, g.cfg.stackFor(StackZero)); g.err != nil { return g.err } if !g.gs.stacked() { - trainColumn(y, g.s.y) + trainSpans(y, within, len(g.s.x), func(i int) (float64, float64) { + if g.x2 != nil { + return g.s.x[i], g.x2[i] + } + h := g.halfWidth(i) + return g.s.x[i] - h, g.s.x[i] + h + }, g.s.y) // A bar is read as the area between the baseline and the value, so the // baseline must be in the domain or the chart lies about magnitude. y.Train(g.cfg.baseline) @@ -146,12 +167,13 @@ func (g *barGeom) Build(b ir.Backend, f Frame) error { cd := f.Coords() brk := g.cfg.breaking(cd, g.pull) - base := baselinePos(f, g.cfg.baseline) + fs, fv := g.cfg.axes(f.X, f.Y) + base := baselineOn(f, g.cfg, g.cfg.baseline) // A stacked layer draws the bounds the adjustment gave it rather than the // column, and every traversal below — including which rows are holes — // then reads the adjusted series. s := g.gs.bounds(g.s) - ok := sc.plottable(s, f.X, f.Y) + ok := sc.plottable(s, fs, fv) // Collect the bars first, so that a layer coloured from a scale can batch // them by colour and one coloured uniformly can still emit a single path. @@ -161,18 +183,18 @@ func (g *barGeom) Build(b ir.Backend, f Frame) error { rects := sc.rects[:0] rows := sc.rows[:0] for i := range s.x { - if !ok[i] || (g.x2 != nil && !defined(f.X, g.x2[i])) { + if !ok[i] || (g.x2 != nil && !defined(fs, g.x2[i])) { continue } // A row that named both of its edges gets exactly those; one that named // a single position gets its share of the slot around it. - x0, x1 := spanOn(f.X, s.x, g.x2, i, g.halfWidth(i), true) + x0, x1 := spanOn(fs, s.x, g.x2, i, g.halfWidth(i), true) if g.cfg.dodge { x0, x1 = dodgeSpan(x0, x1, g.gs.slotIndex(i), g.gs.count(), g.cfg.dodgePad) } - y0, y1 := f.Y.Map(s.y[i]), base + y0, y1 := fv.Map(s.y[i]), base if s.y2 != nil { - y1 = f.Y.Map(s.y2[i]) + y1 = fv.Map(s.y2[i]) } if y1 < y0 { y0, y1 = y1, y0 @@ -180,7 +202,7 @@ func (g *barGeom) Build(b ir.Backend, f Frame) error { // The pair is the mark's extent in the space the scales map into. Under // a Cartesian coord that is the rectangle it draws; under a polar one // it is the angles and radii the coord turns into an annular sector. - rects = append(rects, ir.R(x0, y0, x1, y1)) + rects = append(rects, g.cfg.box(x0, y0, x1, y1)) rows = append(rows, i) } sc.rects, sc.rows = rects, rows @@ -197,16 +219,22 @@ func (g *barGeom) Build(b ir.Backend, f Frame) error { if f.tracking() { sc.pts = grow(sc.pts, len(rects)) for i, r := range rects { - at := barTop(r, base) + // Read the rectangle back in slot and value space, so the row is + // found the same way either way round. + sv := r + if g.cfg.orient == Horizontal { + sv = ir.R(r.Min.Y, r.Min.X, r.Max.Y, r.Max.X) + } + at := barTop(sv, base) if s.y2 != nil { - at = (r.Min.Y + r.Max.Y) / 2 + at = (sv.Min.Y + sv.Max.Y) / 2 } // The position is worked out in the space the scales map into and // placed by the coord, so a slice of a pie reports the middle of // its arc rather than a point the transform never visited — and // then moves with the mark, because a broken-out slice's row is // where the slice is rather than where it would have been. - p := cd.Point((r.Min.X+r.Max.X)/2, at) + p := cd.Point(g.cfg.point((sv.Min.X+sv.Max.X)/2, at)) d := offsetAt(offs, i) sc.pts[i] = ir.Point{X: p.X + d.X, Y: p.Y + d.Y} } @@ -338,5 +366,5 @@ func (g *barGeom) Legend(f Frame) (LegendEntry, bool) { if g.cfg.fill != nil { col = *g.cfg.fill } - return g.cfg.boxSwatch(f, g.cfg.labelFor(), col), true + return g.cfg.boxSwatch(f, g.cfg.roles().labelFor(), col), true } diff --git a/geom/boxplot.go b/geom/boxplot.go index c6722e0..54a34ee 100644 --- a/geom/boxplot.go +++ b/geom/boxplot.go @@ -23,6 +23,11 @@ import ( // Whiskers stop at an observation, never at the theoretical fence. A whisker // drawn out to 1.5·IQR when the data stops well short of it would be claiming // a reading that does not exist. +// +// [Orient] with [Horizontal] groups by the [Y] column and summarises the [X] +// one, so the boxes lie across the panel: the form a long list of named +// categories reads best in, because their names are written along the axis +// rather than under it. func Boxplot(src data.Source, opts ...Option) Geom { return &boxGeom{src: src, cfg: newConfig(opts)} } @@ -46,12 +51,13 @@ type boxGroup struct { } func (g *boxGeom) Train(t Training) error { - x, y := t.X, t.Y - g.s, g.err = resolve(g.src, g.cfg, x, y) + x, y := g.cfg.axes(t.X, t.Y) + rc := g.cfg.roles() + g.s, g.err = resolve(g.src, rc, x, y) if g.err != nil { return g.err } - if err := g.s.checkMissing(g.cfg, x, y); err != nil { + if err := g.s.checkMissing(rc, x, y); err != nil { return err } g.groups = summarise(g.s, x, y, g.cfg.whisker) @@ -103,18 +109,20 @@ func (g *boxGeom) Build(b ir.Backend, f Frame) error { fill := g.cfg.fillFor(f, boxFillOpacity) half := g.slot() * g.widthFraction() / 2 cd := f.Coords() + fs, fv := g.cfg.axes(f.X, f.Y) + // at places a point given in slot and value space, whichever way round + // the layer is drawn. + at := func(s, v float32) ir.Point { return cd.Point(g.cfg.point(s, v)) } var box, run ir.Path for _, grp := range g.groups { - x0, x1 := markSpan(f, grp.at, half) + x0, x1 := slotOn(fs, grp.at, half) mid := (x0 + x1) / 2 - q1, q3 := f.Y.Map(grp.q1), f.Y.Map(grp.q3) - if q3 > q1 { - q1, q3 = q3, q1 - } + q1, q3 := fv.Map(grp.q1), fv.Map(grp.q3) + lo, hi := min(q1, q3), max(q1, q3) box.Reset() - areaRound(&box, cd, ir.R(x0, q3, x1, q1), ir.Point{}, g.cfg.corner) + areaRound(&box, cd, g.cfg.box(x0, lo, x1, hi), ir.Point{}, g.cfg.corner) if fill.A != 0 { g.cfg.fillMark(b, &box, f, 0, fill) } @@ -125,7 +133,7 @@ func (g *boxGeom) Build(b ir.Backend, f Frame) error { // The median is the one number a reader takes off a boxplot without // measuring, so it is drawn heavier than the box around it. - med := f.Y.Map(grp.median) + med := fv.Map(grp.median) g.span(b, cd, &run, x0, x1, med, ir.Stroke{ Color: stroke.Color, Width: stroke.Width * 2, @@ -135,12 +143,12 @@ func (g *boxGeom) Build(b ir.Backend, f Frame) error { // Whiskers, each with a cap a quarter of the box wide on each side. cap0, cap1 := mid-(x1-x0)/4, mid+(x1-x0)/4 for _, w := range [2]float64{grp.loWhisker, grp.hiWhisk} { - end := f.Y.Map(w) + end := fv.Map(w) from := q1 if w > grp.median { from = q3 } - strokeRun(b, cd, &run, []ir.Point{cd.Point(mid, from), cd.Point(mid, end)}, stroke, false) + strokeRun(b, cd, &run, []ir.Point{at(mid, from), at(mid, end)}, stroke, false) g.span(b, cd, &run, cap0, cap1, end, stroke) } @@ -149,7 +157,7 @@ func (g *boxGeom) Build(b ir.Backend, f Frame) error { } pts := make([]ir.Point, 0, len(grp.outliers)) for _, v := range grp.outliers { - pts = append(pts, cd.Point(mid, f.Y.Map(v))) + pts = append(pts, at(mid, fv.Map(v))) } b.Markers(g.cfg.marker, pts, ir.MarkerStyle{ Size: pick(g.cfg.size, f.Theme.MarkerSize*0.7), @@ -159,11 +167,11 @@ func (g *boxGeom) Build(b ir.Backend, f Frame) error { return nil } -// span strokes the run from x0 to x1 at a constant y, which is what a median -// line and a whisker cap both are. It goes through the coord because a run at a -// constant y is a straight line only where the coord says so. -func (g *boxGeom) span(b ir.Backend, cd coord.Coord, p *ir.Path, x0, x1, y float32, st ir.Stroke) { - strokeRun(b, cd, p, []ir.Point{cd.Point(x0, y), cd.Point(x1, y)}, st, false) +// span strokes the run from s0 to s1 across the slot at a constant value, +// which is what a median line and a whisker cap both are. It goes through the +// coord because such a run is a straight line only where the coord says so. +func (g *boxGeom) span(b ir.Backend, cd coord.Coord, p *ir.Path, s0, s1, v float32, st ir.Stroke) { + strokeRun(b, cd, p, []ir.Point{cd.Point(g.cfg.point(s0, v)), cd.Point(g.cfg.point(s1, v))}, st, false) } // boxFillOpacity is how much of the layer's colour an inherited box fill @@ -175,7 +183,7 @@ func (g *boxGeom) Legend(f Frame) (LegendEntry, bool) { if g.err != nil { return LegendEntry{}, false } - return g.cfg.boxSwatch(f, g.cfg.labelFor(), g.cfg.colorFor(f)), true + return g.cfg.boxSwatch(f, g.cfg.roles().labelFor(), g.cfg.colorFor(f)), true } // summarise groups the rows by X value and reduces each group to its diff --git a/geom/candle.go b/geom/candle.go new file mode 100644 index 0000000..eb1c554 --- /dev/null +++ b/geom/candle.go @@ -0,0 +1,477 @@ +package geom + +import ( + "errors" + "fmt" + + "github.com/timzifer/figure/coord" + "github.com/timzifer/figure/data" + "github.com/timzifer/figure/ir" + "github.com/timzifer/figure/scale" +) + +// Candle draws a candlestick per row: a wick from the row's low to its high, +// and a body from its open to its close, coloured by which way the row went. +// +// geom.Candle(src, geom.X("start"), geom.X2("end"), +// geom.OHLC("open", "high", "low", "close")) +// +// The four values are one option, [OHLC], because a row is a candle only with +// all four of them. The position is [X], with [X2] when the row names both +// edges of its period — which is what a candle out of +// [github.com/timzifer/figure/stat.OHLC] knows, and what +// [github.com/timzifer/figure.CandleTable] writes. With X alone the slot is the +// closest spacing in the data or a band scale's width, as a [Bar]'s is, and +// the body takes [BarWidth] of it — 0.7 by default rather than a bar's 0.8, +// because the gap between two candles is where their wicks are told apart. +// +// # Direction +// +// A row is rising when its close is at or above its open, and falling +// otherwise; [Direction] with [SincePrevious] compares with the previous row's +// close instead. A close equal to its open is rising, and its body is drawn at +// least a device pixel tall. The direction is drawn twice: in colour — +// [Rising] and [Falling], defaulting to the theme's — and, with [Hollow], as +// an outlined body for a rising row and a filled one for a falling row. Hollow +// is on by default under a theme with redundant encoding, so the direction +// survives a greyscale print. [ColorBy] replaces the direction's colours with +// a column's, and leaves hollow and filled to say which way the row went. +// +// # What it draws, and what it does not repair +// +// The wicks of one colour are one stroked path and the bodies one filled path, +// so a thousand candles are the same handful of drawing calls as ten. A row +// missing any of its four values is skipped, and is an error under +// OnMissing(Error). A row whose data contradicts itself — a high below its +// close — is drawn as given: repairing it would hide an error in the data, +// and a candle is where a reader looks for one. +// +// [CandleStyle] with [Ticks] draws the OHLC bar instead: a rule from low to +// high with a tick left at the open and right at the close. See +// docs/adr/0088-a-candle-is-one-mark-that-reads-four-values.md. +func Candle(src data.Source, opts ...Option) Geom { + return &candleGeom{src: src, cfg: newConfig(append([]Option{BarWidth(defaultCandleWidth)}, opts...))} +} + +// defaultCandleWidth is the share of its slot a candle's body takes. +const defaultCandleWidth = 0.7 + +// OHLC names a candle's four value columns, in the order the name spells +// them. +func OHLC(open, high, low, close string) Option { + return func(c *config) { c.ohlc = [4]string{open, high, low, close} } +} + +// CandleDirection is what a candle's direction is measured against. +type CandleDirection uint8 + +const ( + // SinceOpen is the candle's own statement: it rose if it closed at or + // above where it opened. + SinceOpen CandleDirection = iota + // SincePrevious compares a row's close with the previous row's, so a day + // that opened low and rallied still reads as down if it closed below + // yesterday. The first row has no previous close and uses its own open. + SincePrevious +) + +// Direction sets what a [Candle]'s direction is measured against. The default +// is [SinceOpen]. +func Direction(d CandleDirection) Option { return func(c *config) { c.direction = d } } + +// candleSide is one direction's colour and legend label, as [Rising] and +// [Falling] set them. +type candleSide struct { + color *ir.Color + label string +} + +// Rising sets the colour and the legend label of a [Candle]'s rising rows. +// The defaults are the theme's Rising colour and "rising"; an empty label +// keeps the default. +func Rising(col ir.Color, label string) Option { + return func(c *config) { c.rising = candleSide{&col, label} } +} + +// Falling is [Rising] for the falling rows. The defaults are the theme's +// Falling colour and "falling". +func Falling(col ir.Color, label string) Option { + return func(c *config) { c.falling = candleSide{&col, label} } +} + +// Hollow draws a [Candle]'s rising bodies as outlines and its falling bodies +// filled, the convention of a chart printed in one colour. The default is on +// under a theme with redundant encoding and off otherwise. +func Hollow(on bool) Option { return func(c *config) { c.hollow, c.hollowSet = on, true } } + +// CandleStyleKind is how a [Candle] draws a row. +type CandleStyleKind uint8 + +const ( + // Bodies is the candlestick: a wick and a body. + Bodies CandleStyleKind = iota + // Ticks is the OHLC bar: a rule from low to high, a tick to the left at + // the open and one to the right at the close. + Ticks +) + +// CandleStyle sets how a [Candle] draws a row. The default is [Bodies]. +func CandleStyle(s CandleStyleKind) Option { return func(c *config) { c.candleStyle = s } } + +// ErrNoOHLC reports a [Candle] layer that was not told its four columns. +var ErrNoOHLC = errors.New("figure/geom: a candle needs geom.OHLC(open, high, low, close)") + +type candleGeom struct { + src data.Source + cfg config + // s is the position and the close, with the colour column and the row + // bookkeeping a series carries; o, h and l are the other three values. + s series + x2 []float64 + o, h, l []float64 + up []bool + gap float64 + gaps []float64 + err error +} + +func (g *candleGeom) Train(t Training) error { + x, y := t.X, t.Y + c := g.cfg + for _, col := range c.ohlc { + if col == "" { + g.err = ErrNoOHLC + return g.err + } + } + rc := c + rc.ycol, rc.y2col = c.ohlc[3], "" + if g.s, g.err = resolve(g.src, rc, x, y); g.err != nil { + return g.err + } + n := len(g.s.x) + read := func(col string, s scale.Scale) ([]float64, error) { + vs, err := column(g.src, col, s) + if err == nil && len(vs) != n { + err = errLength(c.xcol, col, n, len(vs)) + } + return vs, err + } + if g.o, g.err = read(c.ohlc[0], y); g.err != nil { + return g.err + } + if g.h, g.err = read(c.ohlc[1], y); g.err != nil { + return g.err + } + if g.l, g.err = read(c.ohlc[2], y); g.err != nil { + return g.err + } + g.x2 = nil + if c.x2col != "" { + if g.x2, g.err = read(c.x2col, x); g.err != nil { + return g.err + } + } + + ok := g.plottable(x, y) + if c.missing == Error { + for i, fine := range ok { + if !fine { + g.err = fmt.Errorf("figure/geom: candle row %d has no position on the scales and OnMissing(Error) is set", i) + return g.err + } + } + } + g.up = g.directions(ok) + + g.gap, g.gaps = smallestGap(g.gaps, g.s.x) + half := g.gap * g.widthFraction() / 2 + w := t.Within + for i, fine := range ok { + if !fine { + continue + } + x.Train(g.s.x[i]) + a, b := g.s.x[i]-half, g.s.x[i]+half + if g.x2 != nil { + x.Train(g.x2[i]) + a, b = min(g.s.x[i], g.x2[i]), max(g.s.x[i], g.x2[i]) + } + // A candle is in view when any of its period is, so one half in view + // still fits. + if w != nil && (b < w.Lo || a > w.Hi) { + continue + } + // All four, so that a row whose data contradicts itself still has its + // whole drawing inside the axis. + y.Train(g.o[i], g.h[i], g.l[i], g.s.y[i]) + } + g.cfg.trainColors(g.s) + if _, band := x.(scale.Band); band || g.x2 != nil { + return nil + } + widen(x, g.s.x, g.gap/2*g.widthFraction()) + return nil +} + +// plottable is which rows have all four values and a position. +func (g *candleGeom) plottable(x, y scale.Scale) []bool { + ok := make([]bool, len(g.s.x)) + for i := range ok { + ok[i] = defined(x, g.s.x[i]) && defined(y, g.o[i]) && defined(y, g.h[i]) && + defined(y, g.l[i]) && defined(y, g.s.y[i]) && (g.x2 == nil || defined(x, g.x2[i])) + } + return ok +} + +// directions is which rows rose, by the layer's [CandleDirection]. +func (g *candleGeom) directions(ok []bool) []bool { + up := make([]bool, len(ok)) + prev, have := 0.0, false + for i, fine := range ok { + if !fine { + continue + } + ref := g.o[i] + if g.cfg.direction == SincePrevious && have { + ref = prev + } + up[i] = g.s.y[i] >= ref + prev, have = g.s.y[i], true + } + return up +} + +func (g *candleGeom) widthFraction() float64 { + if g.cfg.barWidth <= 0 || g.cfg.barWidth > 1 { + return defaultCandleWidth + } + return g.cfg.barWidth +} + +// sideColor is one direction's colour: the layer's own, or the theme's. +func (g *candleGeom) sideColor(f Frame, up bool) ir.Color { + side, def := g.cfg.falling, f.Theme.Falling + if up { + side, def = g.cfg.rising, f.Theme.Rising + } + if side.color != nil { + return *side.color + } + if def.A == 0 { + // A theme built before candles had colours of its own. + if up { + return candleRising + } + return candleFalling + } + return def +} + +// The direction colours a theme without its own falls back to: Okabe–Ito's +// blue and vermilion, the pair the most common colour deficiency still tells +// apart. +var ( + candleRising = ir.RGB(0x00, 0x72, 0xB2) + candleFalling = ir.RGB(0xD5, 0x5E, 0x00) +) + +func (g *candleGeom) sideLabel(up bool) string { + if up { + if g.cfg.rising.label != "" { + return g.cfg.rising.label + } + return "rising" + } + if g.cfg.falling.label != "" { + return g.cfg.falling.label + } + return "falling" +} + +func (g *candleGeom) hollowIn(f Frame) bool { + if g.cfg.hollowSet { + return g.cfg.hollow + } + return len(f.Theme.SeriesDashes) > 0 +} + +// candleMark is one row, resolved to device space. +type candleMark struct { + row int + x0, mid, x1 float32 + open, hi, lo, cls float32 + top, bot float32 + col ir.Color + up bool +} + +func (g *candleGeom) Build(b ir.Backend, f Frame) error { + if g.err != nil { + return g.err + } + sc := acquire(f) + defer sc.release() + cd := f.Coords() + ok := g.plottable(f.X, f.Y) + + rows := sc.rows[:0] + for i, fine := range ok { + if fine { + rows = append(rows, i) + } + } + sc.rows = rows + if len(rows) == 0 { + return nil + } + cols := sc.colorsFor(g.cfg, g.s, rows) + frac := g.widthFraction() + half := g.gap * frac / 2 + + marks := make([]candleMark, 0, len(rows)) + for k, i := range rows { + x0, x1 := spanOn(f.X, g.s.x, g.x2, i, half, true) + if g.x2 != nil { + // The row named its period; the body is its share of it. + w := (x1 - x0) * float32(frac) + c := (x0 + x1) / 2 + x0, x1 = c-w/2, c+w/2 + } + m := candleMark{ + row: i, x0: x0, x1: x1, mid: (x0 + x1) / 2, + open: f.Y.Map(g.o[i]), hi: f.Y.Map(g.h[i]), lo: f.Y.Map(g.l[i]), cls: f.Y.Map(g.s.y[i]), + up: g.up[i], + } + m.top, m.bot = min(m.open, m.cls), max(m.open, m.cls) + if m.bot-m.top < 1 { + // A doji is a mark, not nothing. + c := (m.top + m.bot) / 2 + m.top, m.bot = c-0.5, c+0.5 + } + if cols != nil { + m.col = cols[k] + } else { + m.col = g.sideColor(f, m.up) + } + marks = append(marks, m) + } + + if f.tracking() { + sc.pts = grow(sc.pts, len(marks)) + for k, m := range marks { + sc.pts[k] = cd.Point(m.mid, m.cls) + } + f.Marks(MarkRows{At: sc.pts, Rows: sc.sourceRows(g.s, rows)}) + } + + hollow := g.hollowIn(f) + stroke := func(col ir.Color) ir.Stroke { + return ir.Stroke{Color: col, Width: pick(g.cfg.width, 1), Cap: ir.CapButt} + } + // One batch per colour and direction, in the order they first appear: the + // wicks of a batch are one stroked path and its bodies one filled path. + type batch struct { + col ir.Color + up bool + } + var batches []batch + for _, m := range marks { + key := batch{m.col, m.up && hollow} + seen := false + for _, bt := range batches { + if bt == key { + seen = true + break + } + } + if !seen { + batches = append(batches, key) + } + } + var lines, bodies ir.Path + for _, bt := range batches { + lines.Reset() + bodies.Reset() + for _, m := range marks { + if (batch{m.col, m.up && hollow}) != bt { + continue + } + if g.cfg.candleStyle == Ticks { + segment(&lines, cd, m.mid, m.hi, m.mid, m.lo) + segment(&lines, cd, m.x0, m.open, m.mid, m.open) + segment(&lines, cd, m.mid, m.cls, m.x1, m.cls) + continue + } + // The wick in two halves, so a hollow body is empty inside. + segment(&lines, cd, m.mid, min(m.hi, m.lo), m.mid, m.top) + segment(&lines, cd, m.mid, m.bot, m.mid, max(m.hi, m.lo)) + areaRound(&bodies, cd, ir.R(m.x0, m.top, m.x1, m.bot), ir.Point{}, g.cfg.corner) + } + if !lines.Empty() { + b.StrokePath(&lines, stroke(bt.col)) + } + if bodies.Empty() { + continue + } + if bt.up { + b.StrokePath(&bodies, stroke(bt.col)) + } else { + g.cfg.fillMark(b, &bodies, f, 0, bt.col) + } + } + return nil +} + +// segment appends a straight run from (x0, y0) to (x1, y1), placed by the +// coord at both ends. A wick is short, so it is drawn straight under any coord +// rather than bent along it. +func segment(p *ir.Path, cd coord.Coord, x0, y0, x1, y1 float32) { + if y0 == y1 && x0 == x1 { + return + } + a, z := cd.Point(x0, y0), cd.Point(x1, y1) + p.MoveTo(a.X, a.Y).LineTo(z.X, z.Y) +} + +func (g *candleGeom) Legends(f Frame) []LegendEntry { + if g.err != nil { + return nil + } + if g.cfg.varying(g.s) { + var none groups + return g.cfg.legends(f, &none, g.s, SwatchBox) + } + return []LegendEntry{ + g.cfg.boxSwatch(f, g.sideLabel(true), g.sideColor(f, true)), + g.cfg.boxSwatch(f, g.sideLabel(false), g.sideColor(f, false)), + } +} + +func (g *candleGeom) Legend(f Frame) (LegendEntry, bool) { + es := g.Legends(f) + if len(es) == 0 { + return LegendEntry{}, false + } + return es[0], true +} + +func (g *candleGeom) ColorGuide() (ColorGuide, bool) { return g.cfg.colorGuide(g.s, g.err) } + +func (g *candleGeom) Source() data.Source { return g.src } +func (g *candleGeom) Subset(rows []int) Geom { + return &candleGeom{src: data.Rows(g.src, rows), cfg: g.cfg} +} + +func (g *candleGeom) Describe() Desc { + d := g.cfg.describe(MarkCandle) + d.Source = g.src + return d +} + +var ( + _ Describer = (*candleGeom)(nil) + _ Faceter = (*candleGeom)(nil) + _ Guided = (*candleGeom)(nil) + _ Legender = (*candleGeom)(nil) +) diff --git a/geom/candle_test.go b/geom/candle_test.go new file mode 100644 index 0000000..0174c81 --- /dev/null +++ b/geom/candle_test.go @@ -0,0 +1,267 @@ +package geom_test + +import ( + "errors" + "math" + "testing" + + "github.com/timzifer/figure/data" + "github.com/timzifer/figure/geom" + "github.com/timzifer/figure/internal/irtest" + "github.com/timzifer/figure/ir" + "github.com/timzifer/figure/scale" + "github.com/timzifer/figure/theme" +) + +// candles is a small market: two rising rows, one falling, and a doji. +func candles() *data.Table { + return data.NewTable(). + Float64("t", []float64{0, 1, 2, 3}). + Float64("o", []float64{10, 12, 15, 13}). + Float64("h", []float64{13, 16, 16, 14}). + Float64("l", []float64{9, 11, 12, 12}). + Float64("c", []float64{12, 15, 13, 13}) +} + +var ohlc = geom.OHLC("o", "h", "l", "c") + +func count(rec *irtest.Recorder, op string) int { return rec.Count(op) } + +// A thousand candles are the same handful of calls as four: two per +// direction, a stroke for the wicks and a fill for the bodies. +func TestACandleLayerDrawsInFourCallsWhateverItsRows(t *testing.T) { + const n = 1000 + ts, o, h, l, c := make([]float64, n), make([]float64, n), make([]float64, n), make([]float64, n), make([]float64, n) + for i := range n { + v := 100 + 10*math.Sin(float64(i)/7) + ts[i], o[i], c[i] = float64(i), v, v+math.Cos(float64(i)) + h[i], l[i] = max(o[i], c[i])+1, min(o[i], c[i])-1 + } + src := data.NewTable().Float64("t", ts).Float64("o", o).Float64("h", h).Float64("l", l).Float64("c", c) + g := geom.Candle(src, geom.X("t"), ohlc) + rec, f := frameOn(t, g, scale.Linear(), scale.Linear(), 800, 400) + if err := g.Build(rec, f); err != nil { + t.Fatal(err) + } + if s, fl := count(rec, "StrokePath"), count(rec, "FillPath"); s != 2 || fl != 2 { + t.Errorf("%d strokes and %d fills for %d candles, want two of each", s, fl, n) + } +} + +// The direction is the mark's: rising rows in the rising colour, falling in +// the falling one, with no column computed by the caller. +func TestACandleDecidesItsOwnDirection(t *testing.T) { + up, down := ir.RGB(1, 2, 3), ir.RGB(4, 5, 6) + g := geom.Candle(candles(), geom.X("t"), ohlc, geom.Rising(up, "up"), geom.Falling(down, "down")) + rec, f := frameOn(t, g, scale.Linear(), scale.Linear(), 400, 300) + if err := g.Build(rec, f); err != nil { + t.Fatal(err) + } + fills := map[ir.Color]int{} + for _, c := range rec.Calls { + if c.Op == "FillPath" { + fills[c.Fill.Color] += len(rectsOf(t, c)) + } + } + // Rows 0, 1 and the doji rose; row 2 fell. + if fills[up] != 3 || fills[down] != 1 { + t.Errorf("bodies by colour %v, want three rising and one falling", fills) + } + es := geom.Legends(g, f) + if len(es) != 2 || es[0].Label != "up" || es[1].Label != "down" || es[0].Color != up { + t.Errorf("legend %+v, want the two directions once", es) + } +} + +// Since the previous close, a row that closed under the one before falls even +// though it rose from its own open. +func TestSincePreviousComparesWithTheCloseBefore(t *testing.T) { + src := data.NewTable(). + Float64("t", []float64{0, 1}). + Float64("o", []float64{10, 5}).Float64("h", []float64{12, 9}). + Float64("l", []float64{9, 4}).Float64("c", []float64{11, 8}) + up, down := ir.RGB(1, 2, 3), ir.RGB(4, 5, 6) + g := geom.Candle(src, geom.X("t"), ohlc, geom.Direction(geom.SincePrevious), + geom.Rising(up, ""), geom.Falling(down, "")) + rec, f := frameOn(t, g, scale.Linear(), scale.Linear(), 400, 300) + if err := g.Build(rec, f); err != nil { + t.Fatal(err) + } + var cols []ir.Color + for _, c := range rec.Calls { + if c.Op == "FillPath" { + cols = append(cols, c.Fill.Color) + } + } + // Row 0 has no previous close and rose from its open; row 1 rose from its + // open but closed under row 0. + if len(cols) != 2 || cols[0] != up || cols[1] != down { + t.Errorf("body colours %v, want rising then falling", cols) + } +} + +// A doji's body is a mark rather than nothing. +func TestADojiIsVisible(t *testing.T) { + g := geom.Candle(candles(), geom.X("t"), ohlc) + rec, f := frameOn(t, g, scale.Linear(), scale.Linear(), 400, 300) + if err := g.Build(rec, f); err != nil { + t.Fatal(err) + } + for _, r := range allRects(t, rec) { + if r.Max.Y-r.Min.Y < 1-1e-4 { + t.Errorf("a body %v less than a pixel tall", r) + } + } +} + +// Hollow draws the rising bodies as outlines: no rising fill, and a stroke +// call more. +func TestHollowOutlinesTheRisingBodies(t *testing.T) { + up := ir.RGB(1, 2, 3) + g := geom.Candle(candles(), geom.X("t"), ohlc, geom.Rising(up, ""), geom.Hollow(true)) + rec, f := frameOn(t, g, scale.Linear(), scale.Linear(), 400, 300) + if err := g.Build(rec, f); err != nil { + t.Fatal(err) + } + for _, c := range rec.Calls { + if c.Op == "FillPath" && c.Fill.Color == up { + t.Error("a rising body is filled") + } + } + if n := count(rec, "StrokePath"); n != 3 { + t.Errorf("%d strokes, want two for the wicks and one for the hollow bodies", n) + } + + // And it is on by default under redundant encoding. + g = geom.Candle(candles(), geom.X("t"), ohlc, geom.Rising(up, "")) + rec, f = frameOn(t, g, scale.Linear(), scale.Linear(), 400, 300) + f.Theme = theme.Light.With(theme.Redundant(true)) + if err := g.Build(rec, f); err != nil { + t.Fatal(err) + } + for _, c := range rec.Calls { + if c.Op == "FillPath" && c.Fill.Color == up { + t.Error("a rising body is filled under redundant encoding") + } + } +} + +// The OHLC bar is a style of the same mark: rules and ticks, nothing filled. +func TestTheTickStyleDrawsNoBodies(t *testing.T) { + g := geom.Candle(candles(), geom.X("t"), ohlc, geom.CandleStyle(geom.Ticks)) + rec, f := frameOn(t, g, scale.Linear(), scale.Linear(), 400, 300) + if err := g.Build(rec, f); err != nil { + t.Fatal(err) + } + if n := count(rec, "FillPath"); n != 0 { + t.Errorf("%d fills, want none", n) + } + if n := count(rec, "StrokePath"); n != 2 { + t.Errorf("%d strokes, want one per direction", n) + } +} + +// The row is reported at its close, in the middle of its slot. +func TestACandlesRowIsAtItsClose(t *testing.T) { + g := geom.Candle(candles(), geom.X("t"), ohlc) + rec, f := frameOn(t, g, scale.Linear(), scale.Linear(), 400, 300) + at := map[int]ir.Point{} + f.Rows = rowsFunc(func(pts []ir.Point, rows []int) { + for i, r := range rows { + at[r] = pts[i] + } + }) + if err := g.Build(rec, f); err != nil { + t.Fatal(err) + } + closes := []float64{12, 15, 13, 13} + for i, c := range closes { + p, ok := at[i] + if !ok { + t.Fatalf("row %d not reported", i) + } + if want := f.Y.Map(c); math.Abs(float64(p.Y-want)) > 1e-3 { + t.Errorf("row %d at y %v, want its close at %v", i, p.Y, want) + } + if want := f.X.Map(float64(i)); math.Abs(float64(p.X-want)) > 1e-3 { + t.Errorf("row %d at x %v, want the middle of its slot at %v", i, p.X, want) + } + } +} + +// A row whose high is under its close is drawn as given, and the axis still +// holds all of it. +func TestInconsistentDataIsDrawnAsGiven(t *testing.T) { + src := data.NewTable().Float64("t", []float64{0, 1}). + Float64("o", []float64{10, 10}).Float64("h", []float64{11, 11}). + Float64("l", []float64{9, 9}).Float64("c", []float64{11, 20}) + g := geom.Candle(src, geom.X("t"), ohlc) + y := scale.Linear() + if err := g.Train(geom.Training{X: scale.Linear(), Y: y}); err != nil { + t.Fatal(err) + } + if _, hi := y.Domain(); hi < 20 { + t.Errorf("the axis stops at %v, under the close of 20", hi) + } +} + +func TestACandleWithoutItsFourColumnsIsRefused(t *testing.T) { + g := geom.Candle(candles(), geom.X("t")) + if err := g.Train(geom.Training{X: scale.Linear(), Y: scale.Linear()}); !errors.Is(err, geom.ErrNoOHLC) { + t.Errorf("err = %v, want ErrNoOHLC", err) + } +} + +// A row missing a value is skipped, and refused under OnMissing(Error). +func TestAMissingValueSkipsTheRow(t *testing.T) { + src := data.NewTable().Float64("t", []float64{0, 1}). + Float64("o", []float64{10, 10}).Float64("h", []float64{12, math.NaN()}). + Float64("l", []float64{9, 9}).Float64("c", []float64{11, 11}) + g := geom.Candle(src, geom.X("t"), ohlc) + rec, f := frameOn(t, g, scale.Linear(), scale.Linear(), 400, 300) + if err := g.Build(rec, f); err != nil { + t.Fatal(err) + } + if n := len(allRects(t, rec)); n != 1 { + t.Errorf("%d bodies, want the one complete row", n) + } + strict := geom.Candle(src, geom.X("t"), ohlc, geom.OnMissing(geom.Error)) + if err := strict.Train(geom.Training{X: scale.Linear(), Y: scale.Linear()}); err == nil { + t.Error("a missing high was not refused under OnMissing(Error)") + } +} + +// X2 names the period's far edge, and the body is its share of it. +func TestACandleWithAPeriodTakesItsShareOfIt(t *testing.T) { + src := data.NewTable().Float64("s", []float64{0, 10}).Float64("e", []float64{10, 20}). + Float64("o", []float64{1, 2}).Float64("h", []float64{3, 4}). + Float64("l", []float64{0, 1}).Float64("c", []float64{2, 3}) + g := geom.Candle(src, geom.X("s"), geom.X2("e"), ohlc, geom.BarWidth(0.5)) + rec, f := frameOn(t, g, scale.Linear(), scale.Linear(), 400, 300) + if err := g.Build(rec, f); err != nil { + t.Fatal(err) + } + for _, r := range allRects(t, rec) { + if w := r.Max.X - r.Min.X; math.Abs(float64(w-100)) > 0.5 { + t.Errorf("a body %v wide, want half of a 200-pixel period", w) + } + } +} + +func TestACandleIsWrittenDown(t *testing.T) { + up := ir.RGB(1, 2, 3) + g := geom.Candle(candles(), geom.X("t"), ohlc, geom.Direction(geom.SincePrevious), + geom.Rising(up, "bull"), geom.Hollow(true), geom.CandleStyle(geom.Ticks)) + d := g.(geom.Describer).Describe() + back, err := geom.FromDesc(d) + if err != nil { + t.Fatal(err) + } + again := back.(geom.Describer).Describe() + if again.Mark != geom.MarkCandle || again.OHLC != [4]string{"o", "h", "l", "c"} || + again.Direction != geom.SincePrevious || again.Rising == nil || *again.Rising != up || + again.RisingLabel != "bull" || !again.Hollow || !again.HollowSet || + again.CandleStyle != geom.Ticks || again.BarWidth != 0.7 { + t.Errorf("read back as %+v", again) + } +} diff --git a/geom/describe.go b/geom/describe.go index 3f89a1c..3fa3998 100644 --- a/geom/describe.go +++ b/geom/describe.go @@ -121,6 +121,16 @@ const ( // at each end and a marker at the measurement. MarkErrorBar Mark = "errorbar" + // MarkCandle is the candlestick: a wick from low to high and a body from + // open to close, one per row, told apart by which way the row went. See + // docs/adr/0088-a-candle-is-one-mark-that-reads-four-values.md. + MarkCandle Mark = "candle" + + // MarkLastValue is the newest value of a column written on its axis, with + // a rule across the panel to it — a trading screen's last price. See + // docs/adr/0092-what-a-trading-screen-reads-off-its-edges.md. + MarkLastValue Mark = "last-value" + // MarkLocus is a family of curves given by a formula rather than by a tick: // a Nichols chart's M and N contours, a Smith chart's VSWR circles. It is // an annotation because none of those is at a value of either axis — see @@ -429,6 +439,24 @@ type Desc struct { Rotation float64 Extend bool + // OHLC names a [Candle]'s open, high, low and close columns, and + // Direction, Rising, Falling, Hollow and CandleStyle are its other + // options. Rising and Falling are the colours the layer named, nil for the + // theme's, with the labels beside them; HollowSet is whether the layer + // chose hollow bodies rather than taking the theme's redundant encoding. + OHLC [4]string + // DirectionFrom and DirectionTo are the columns a [DirectionBy] layer + // compares, DirectionFrom empty for the previous row. Its colours and + // labels are Rising, Falling, RisingLabel and FallingLabel. + DirectionFrom, DirectionTo string + // Rule is whether a [LastValue] draws its line across the panel. + Rule bool + Direction CandleDirection + Rising, Falling *ir.Color + RisingLabel, FallingLabel string + Hollow, HollowSet bool + CandleStyle CandleStyleKind + // Extra is what a third-party mark's own options set — see [Extra]. It is // nil for a layer configured entirely from this package's options, and // what the JSON spec carries as the mark's own properties for a mark this @@ -506,6 +534,10 @@ func FromDesc(d Desc) (Geom, error) { return Scatter(d.Source, opts...), nil case MarkBar: return Bar(d.Source, opts...), nil + case MarkCandle: + return Candle(d.Source, opts...), nil + case MarkLastValue: + return LastValue(d.Source, append(opts, Rule(d.Rule))...), nil case MarkArea: return Area(d.Source, opts...), nil case MarkStep: @@ -635,6 +667,14 @@ func (d Desc) options() []Option { Wrap(d.Wrap), Slide(!d.Pinned), Link(d.Linkage), + Direction(d.Direction), + CandleStyle(d.CandleStyle), + func(c *config) { + c.ohlc = d.OHLC + c.rising = candleSide{d.Rising, d.RisingLabel} + c.falling = candleSide{d.Falling, d.FallingLabel} + c.hollow, c.hollowSet = d.Hollow, d.HollowSet + }, } if d.StackSet { opts = append(opts, Stack(d.Stack)) @@ -750,6 +790,9 @@ func (d Desc) options() []Option { if d.ColorCol != "" && d.ColorScale != nil { opts = append(opts, ColorBy(d.ColorCol, d.ColorScale)) } + if d.DirectionTo != "" { + opts = append(opts, DirectionBy(d.DirectionFrom, d.DirectionTo)) + } if d.SizeCol != "" && d.SizeScale != nil { opts = append(opts, SizeBy(d.SizeCol, d.SizeScale)) } @@ -861,6 +904,18 @@ func (c config) describeStacking(mark Mark, def Stacking) Desc { ErrorCol: c.errCol, ErrorXCol: c.errXCol, Caps: c.caps, + OHLC: c.ohlc, + DirectionFrom: c.dirFrom, + Rule: c.rule, + DirectionTo: c.dirTo, + Direction: c.direction, + Rising: c.rising.color, + Falling: c.falling.color, + RisingLabel: c.rising.label, + FallingLabel: c.falling.label, + Hollow: c.hollow, + HollowSet: c.hollowSet, + CandleStyle: c.candleStyle, Decimate: c.decimate, Budget: c.budget, CellSize: c.cellSize, diff --git a/geom/direction.go b/geom/direction.go new file mode 100644 index 0000000..4b62f60 --- /dev/null +++ b/geom/direction.go @@ -0,0 +1,159 @@ +package geom + +import ( + "errors" + "fmt" + "math" + + "github.com/timzifer/figure/data" + "github.com/timzifer/figure/ir" + "github.com/timzifer/figure/theme" +) + +// DirectionBy colours each row by which way it went: rising when its to +// column is at or above its from column, falling otherwise — a candle's rule, +// with a tie rising. With from empty, a row is compared with the previous +// row's to, which is a candle's [SincePrevious]; the first row rises. +// +// geom.Bar(src, geom.X("start"), geom.Y("volume"), geom.DirectionBy("open", "close")) +// +// It is a colour channel, so every mark that takes [ColorBy] takes it — a +// bar, a rect, a scatter, an error bar, a label, an area, and a line or step, +// whose stretches are drawn rising or falling — and a layer given both is an +// error: a row has one colour. The colours and labels are a candle's: +// [Rising] and [Falling], or the theme's and "rising" and "falling", so a +// volume track under a candle layer paints in the candle's colours without +// being told them, and the legend, which merges entries by label, lists the +// directions once. A row missing either value has no direction and is drawn in +// no colour. See docs/adr/0091-a-direction-is-a-colour-channel-any-mark-can-take.md. +func DirectionBy(from, to string) Option { + return func(c *config) { c.dirFrom, c.dirTo = from, to } +} + +// ErrColorAndDirection reports a layer given both [ColorBy] and +// [DirectionBy]. +var ErrColorAndDirection = errors.New("figure/geom: a layer is coloured by ColorBy or by DirectionBy, not both") + +// directionScale is the colour scale a [DirectionBy] layer paints through: a +// discrete scale of two labels, rising and falling, so that everything written +// for a discrete colour — batching by colour, a path coloured in stretches, +// one legend entry per label — works without knowing what a direction is. Its +// colours are the layer's own where it named them, and otherwise the theme's, +// bound once per render by [directionScale.BindTheme]. +type directionScale struct { + rising, falling candleSide + theme struct{ rising, falling ir.Color } +} + +func newDirectionScale(c config) *directionScale { + d := &directionScale{rising: c.rising, falling: c.falling} + d.theme.rising, d.theme.falling = candleRising, candleFalling + return d +} + +// BindTheme takes the direction colours of the theme a render draws in. Render +// calls it before training, so a chart switched to another theme repaints its +// directions with it. +func (d *directionScale) BindTheme(t theme.Theme) { + if t.Rising.A != 0 { + d.theme.rising = t.Rising + } + if t.Falling.A != 0 { + d.theme.falling = t.Falling + } +} + +// The codes a row's direction is carried in, as its colour value. +const ( + codeRising = 0 + codeFalling = 1 +) + +func (d *directionScale) Train(...float64) {} +func (d *directionScale) Domain() (float64, float64) { return codeRising, codeFalling } + +func (d *directionScale) Color(v float64) ir.Color { + switch v { + case codeRising: + if d.rising.color != nil { + return *d.rising.color + } + return d.theme.rising + case codeFalling: + if d.falling.color != nil { + return *d.falling.color + } + return d.theme.falling + } + return ir.Transparent +} + +func (d *directionScale) labels() (rising, falling string) { + rising, falling = d.rising.label, d.falling.label + if rising == "" { + rising = "rising" + } + if falling == "" { + falling = "falling" + } + return rising, falling +} + +func (d *directionScale) Encode(label string) float64 { + r, f := d.labels() + switch label { + case r: + return codeRising + case f: + return codeFalling + } + return math.NaN() +} + +func (d *directionScale) ColorOf(label string) ir.Color { return d.Color(d.Encode(label)) } + +func (d *directionScale) Labels() []string { + r, f := d.labels() + return []string{r, f} +} + +// directionCodes is each row's direction as a colour value: codeRising, +// codeFalling, or NaN for a row missing either value. +func (c config) directionCodes(src data.Source, n int) ([]float64, error) { + to, err := column(src, c.dirTo, nil) + if err != nil { + return nil, err + } + var from []float64 + if c.dirFrom != "" { + if from, err = column(src, c.dirFrom, nil); err != nil { + return nil, err + } + } + if len(to) != n || (from != nil && len(from) != n) { + return nil, fmt.Errorf("figure/geom: direction columns %q and %q have %d and %d rows, and the plotted columns %d", + c.dirFrom, c.dirTo, len(from), len(to), n) + } + out := make([]float64, n) + prev, have := 0.0, false + for i, v := range to { + ref := prev + if from != nil { + ref = from[i] + } + switch { + case !finite(v) || (from != nil && !finite(ref)): + out[i] = math.NaN() + case from == nil && !have: + out[i] = codeRising // the first row has nothing to fall from + case v >= ref: + out[i] = codeRising + default: + out[i] = codeFalling + } + if finite(v) { + prev, have = v, true + } + } + return out, nil +} diff --git a/geom/direction_test.go b/geom/direction_test.go new file mode 100644 index 0000000..f67f75a --- /dev/null +++ b/geom/direction_test.go @@ -0,0 +1,182 @@ +package geom_test + +import ( + "errors" + "testing" + + "github.com/timzifer/figure/data" + "github.com/timzifer/figure/geom" + "github.com/timzifer/figure/ir" + "github.com/timzifer/figure/palette" + "github.com/timzifer/figure/scale" +) + +var ( + upColor = ir.RGB(1, 2, 3) + downColor = ir.RGB(4, 5, 6) +) + +// days is four rows: rising, falling, a tie, and falling again, with a close +// column that rises and falls against the previous row differently. +func days() *data.Table { + return data.NewTable(). + Float64("t", []float64{0, 1, 2, 3}). + Float64("o", []float64{10, 12, 13, 15}). + Float64("c", []float64{12, 11, 13, 14}). + Float64("v", []float64{5, 6, 7, 8}) +} + +// fillColours are the colours a layer's fill calls paint, one per rectangle, +// in drawing order. +func fillColours(t *testing.T, g geom.Geom) []ir.Color { + t.Helper() + rec, f := frameOn(t, g, scale.Linear(), scale.Linear(), 400, 300) + if err := g.Build(rec, f); err != nil { + t.Fatal(err) + } + var out []ir.Color + for _, c := range rec.Calls { + if c.Op != "FillPath" { + continue + } + for range rectsOf(t, c) { + out = append(out, c.Fill.Color) + } + } + return out +} + +func colourCount(cols []ir.Color, c ir.Color) int { + n := 0 + for _, x := range cols { + if x == c { + n++ + } + } + return n +} + +// A bar takes the direction through its colour channel: rows 0 and 2 (a tie) +// rise, rows 1 and 3 fall. +func TestABarIsColouredByDirection(t *testing.T) { + g := geom.Bar(days(), geom.X("t"), geom.Y("v"), geom.DirectionBy("o", "c"), + geom.Rising(upColor, ""), geom.Falling(downColor, "")) + cols := fillColours(t, g) + if colourCount(cols, upColor) != 2 || colourCount(cols, downColor) != 2 { + t.Errorf("bar colours %v, want two rising and two falling", cols) + } +} + +// With from empty, a row is compared with the previous close: 12 is the first +// and rises, 11 falls, 13 rises, 14 rises. +func TestTheDirectionSinceThePreviousRow(t *testing.T) { + g := geom.Bar(days(), geom.X("t"), geom.Y("v"), geom.DirectionBy("", "c"), + geom.Rising(upColor, ""), geom.Falling(downColor, "")) + cols := fillColours(t, g) + if colourCount(cols, upColor) != 3 || colourCount(cols, downColor) != 1 { + t.Errorf("bar colours %v, want three rising and one falling", cols) + } +} + +// It agrees with a candle told the same: the candle's bodies and the bars +// under them are painted alike, row by row. +func TestADirectionAgreesWithACandle(t *testing.T) { + src := data.NewTable(). + Float64("t", []float64{0, 1, 2, 3}). + Float64("o", []float64{10, 12, 13, 15}). + Float64("h", []float64{13, 13, 14, 16}). + Float64("l", []float64{9, 10, 12, 13}). + Float64("c", []float64{12, 11, 13, 14}). + Float64("v", []float64{5, 6, 7, 8}) + for _, since := range []geom.CandleDirection{geom.SinceOpen, geom.SincePrevious} { + from := "o" + if since == geom.SincePrevious { + from = "" + } + candle := fillColours(t, geom.Candle(src, geom.X("t"), geom.OHLC("o", "h", "l", "c"), + geom.Direction(since), geom.Rising(upColor, ""), geom.Falling(downColor, ""))) + bars := fillColours(t, geom.Bar(src, geom.X("t"), geom.Y("v"), geom.DirectionBy(from, "c"), + geom.Rising(upColor, ""), geom.Falling(downColor, ""))) + if colourCount(candle, upColor) != colourCount(bars, upColor) || colourCount(candle, downColor) != colourCount(bars, downColor) { + t.Errorf("direction %v: candles %v, bars %v", since, candle, bars) + } + } +} + +// A line takes it as it takes a discrete colour: its stretches are drawn in +// the two colours. +func TestALineIsDrawnInStretchesOfItsDirection(t *testing.T) { + g := geom.Line(days(), geom.X("t"), geom.Y("c"), geom.DirectionBy("", "c"), + geom.Rising(upColor, ""), geom.Falling(downColor, "")) + rec, f := frameOn(t, g, scale.Linear(), scale.Linear(), 400, 300) + if err := g.Build(rec, f); err != nil { + t.Fatal(err) + } + seen := map[ir.Color]bool{} + for _, c := range rec.Calls { + if c.Op == "Polyline" || c.Op == "StrokePath" { + seen[c.Stroke.Color] = true + } + } + if !seen[upColor] || !seen[downColor] { + t.Errorf("stroke colours %v, want both directions", seen) + } +} + +func TestAScatterTakesTheDirection(t *testing.T) { + g := geom.Scatter(days(), geom.X("t"), geom.Y("v"), geom.DirectionBy("o", "c"), + geom.Rising(upColor, ""), geom.Falling(downColor, "")) + rec, f := frameOn(t, g, scale.Linear(), scale.Linear(), 400, 300) + if err := g.Build(rec, f); err != nil { + t.Fatal(err) + } + seen := map[ir.Color]int{} + for _, c := range rec.Calls { + if c.Op == "Markers" { + seen[c.Style.Fill] += len(c.Points) + } + } + if seen[upColor] != 2 || seen[downColor] != 2 { + t.Errorf("markers by colour %v, want two of each", seen) + } +} + +// A row has one colour, so a layer that names two ways of choosing it is +// refused. +func TestColorByAndDirectionByAreExclusive(t *testing.T) { + g := geom.Bar(days(), geom.X("t"), geom.Y("v"), geom.DirectionBy("o", "c"), + geom.ColorBy("v", scale.Sequential(palette.Viridis))) + err := g.Train(geom.Training{X: scale.Linear(), Y: scale.Linear()}) + if !errors.Is(err, geom.ErrColorAndDirection) { + t.Errorf("err = %v, want ErrColorAndDirection", err) + } +} + +// The legend names the two directions, with the candle's labels. +func TestADirectionLayersLegendNamesTheTwoDirections(t *testing.T) { + g := geom.Bar(days(), geom.X("t"), geom.Y("v"), geom.DirectionBy("o", "c"), + geom.Rising(upColor, "up"), geom.Falling(downColor, "down")) + _, f := frameOn(t, g, scale.Linear(), scale.Linear(), 400, 300) + es := geom.Legends(g, f) + if len(es) != 2 || es[0].Label != "up" || es[0].Color != upColor || es[1].Label != "down" { + t.Errorf("legend %+v", es) + } + g = geom.Bar(days(), geom.X("t"), geom.Y("v"), geom.DirectionBy("o", "c")) + _, f = frameOn(t, g, scale.Linear(), scale.Linear(), 400, 300) + if es := geom.Legends(g, f); len(es) != 2 || es[0].Label != "rising" || es[1].Label != "falling" { + t.Errorf("default legend %+v", es) + } +} + +func TestADirectionIsWrittenDown(t *testing.T) { + g := geom.Bar(days(), geom.X("t"), geom.Y("v"), geom.DirectionBy("", "c"), geom.Rising(upColor, "up")) + d := g.(geom.Describer).Describe() + back, err := geom.FromDesc(d) + if err != nil { + t.Fatal(err) + } + again := back.(geom.Describer).Describe() + if again.DirectionTo != "c" || again.DirectionFrom != "" || again.RisingLabel != "up" || again.ColorCol != "" { + t.Errorf("read back as %+v", again) + } +} diff --git a/geom/errorbar.go b/geom/errorbar.go index c95800f..a183e64 100644 --- a/geom/errorbar.go +++ b/geom/errorbar.go @@ -128,15 +128,6 @@ func (g *errorGeom) Train(t Training) error { return g.err } - trainColumn(x, g.s.x) - trainColumn(y, g.s.y) - trainColumn(along, g.lo) - trainColumn(along, g.hi) - if g.mid != nil { - trainColumn(along, g.mid) - } - g.cfg.trainColors(g.s) - // A cap has width the domain does not know about, so the outermost ones // would be clipped in half by an axis that stops at the data. A band scale // reserves its slot already; a continuous one is widened, exactly as a @@ -146,6 +137,23 @@ func (g *errorGeom) Train(t Training) error { across, at = y, g.s.y } g.half = g.halfSlot(across, at) + + trainColumn(x, g.s.x) + if g.vertical { + // A vertical interval is in view when its cap is: its value axis is Y, + // which is the axis a fit narrows. + trainSpans(y, t.Within, len(g.s.x), func(i int) (float64, float64) { + return g.s.x[i] - g.half, g.s.x[i] + g.half + }, g.s.y, g.lo, g.hi, g.mid) + } else { + trainColumn(y, g.s.y) + trainColumn(along, g.lo) + trainColumn(along, g.hi) + if g.mid != nil { + trainColumn(along, g.mid) + } + } + g.cfg.trainColors(g.s) widen(across, at, g.half) // The series index is what paints and dodges the rows; nothing here is diff --git a/geom/fit.go b/geom/fit.go new file mode 100644 index 0000000..879f746 --- /dev/null +++ b/geom/fit.go @@ -0,0 +1,129 @@ +package geom + +import ( + "math" + + "github.com/timzifer/figure/scale" +) + +// The training a layer does for a value axis that fits its view: the three +// shapes [Training.Within] asks for, written once so that each mark states +// which shape it is rather than how to be one. Each falls back to training on +// every row when the window is nil, which is every panel that does not fit. +// See docs/adr/0089-a-value-axis-fits-what-its-time-axis-shows.md. + +// trainPoints trains y on the rows whose position is in view: a scatter, a +// label. +func trainPoints(y scale.Scale, w *scale.Interval, xs []float64, ys ...[]float64) { + if w == nil { + for _, col := range ys { + trainColumn(y, col) + } + return + } + for i, x := range xs { + if x < w.Lo || x > w.Hi { + continue + } + for _, col := range ys { + if i < len(col) { + y.Train(col[i]) + } + } + } +} + +// trainSpans trains y on the rows whose extent along x overlaps the view, so +// a bar or a candle half in view still fits. span answers a row's extent. +func trainSpans(y scale.Scale, w *scale.Interval, n int, span func(i int) (float64, float64), ys ...[]float64) { + if w == nil { + for _, col := range ys { + trainColumn(y, col) + } + return + } + for i := range n { + a, b := span(i) + if a > b { + a, b = b, a + } + if b < w.Lo || a > w.Hi { + continue + } + for _, col := range ys { + if i < len(col) { + y.Train(col[i]) + } + } + } +} + +// trainConnected trains y on the rows in view and on the value where the path +// crosses each edge of it, because the stretch between the last row inside and +// the first outside is drawn and crosses the edge at a value no row holds. +// +// Rows are joined in row order within their group — of is each row's group, or +// nil for one — which is the order a path is drawn in. hold is a staircase, +// which crosses the edge at one of its two rows' values rather than between +// them; both are trained, which is at worst the height of one step looser. +func trainConnected(y scale.Scale, w *scale.Interval, xs, ys []float64, of []int, hold bool) { + if w == nil { + trainColumn(y, ys) + return + } + n := min(len(xs), len(ys)) + groups := 1 + for _, g := range of { + groups = max(groups, g+1) + } + last := make([]int, groups) + for g := range last { + last[g] = -1 + } + for i := range n { + x, v := xs[i], ys[i] + if !finite(x) || !finite(v) { + continue + } + if x >= w.Lo && x <= w.Hi { + y.Train(v) + } + g := 0 + if of != nil && i < len(of) { + g = of[i] + } + if j := last[g]; j >= 0 { + edgeValues(y, w, xs[j], ys[j], x, v, hold) + } + last[g] = i + } +} + +// edgeValues trains the values at which the segment from (x0, v0) to (x1, v1) +// crosses the edges of the view. +func edgeValues(y scale.Scale, w *scale.Interval, x0, v0, x1, v1 float64, hold bool) { + lo, hi := math.Min(x0, x1), math.Max(x0, x1) + for _, e := range [2]float64{w.Lo, w.Hi} { + if e <= lo || e >= hi { + continue + } + if hold { + y.Train(v0, v1) + continue + } + y.Train(v0 + (v1-v0)*(e-x0)/(x1-x0)) + } +} + +// viewPadded is the view widened by pad on each side, for a stack whose segments +// have width or whose neighbours the path reaches: trained on the rows in the +// wider window it fits loosely, which is the direction a fit may err in. +func viewPadded(w *scale.Interval, pad float64) *scale.Interval { + if w == nil { + return nil + } + if !(pad > 0) || math.IsInf(pad, 0) { + pad = 0 + } + return &scale.Interval{Lo: w.Lo - pad, Hi: w.Hi + pad} +} diff --git a/geom/fit_test.go b/geom/fit_test.go new file mode 100644 index 0000000..37c097d --- /dev/null +++ b/geom/fit_test.go @@ -0,0 +1,69 @@ +package geom_test + +import ( + "testing" + + "github.com/timzifer/figure/data" + "github.com/timzifer/figure/geom" + "github.com/timzifer/figure/scale" +) + +// fitted trains g with the view [lo, hi] and returns the value axis's extent. +func fitted(t *testing.T, g geom.Geom, lo, hi float64) (float64, float64) { + t.Helper() + y := scale.Linear() + if err := g.Train(geom.Training{X: scale.Linear(), Y: y, Within: &scale.Interval{Lo: lo, Hi: hi}}); err != nil { + t.Fatal(err) + } + return y.Domain() +} + +func stairs() *data.Table { + return data.NewTable(). + Float64("x", []float64{0, 10, 20, 30}). + Float64("y", []float64{1, 5, 9, 2}). + String("g", []string{"a", "a", "b", "b"}) +} + +func TestAPointMarkFitsTheRowsInView(t *testing.T) { + if lo, hi := fitted(t, geom.Scatter(stairs(), geom.X("x"), geom.Y("y")), 5, 25); lo != 5 || hi != 9 { + t.Errorf("[%v, %v], want the two points in view", lo, hi) + } +} + +// A staircase holds its value to the edge, so it fits both values of the step +// it crosses on rather than one between them. +func TestAStepFitsTheStepsItCrossesOn(t *testing.T) { + if lo, hi := fitted(t, geom.Step(stairs(), geom.X("x"), geom.Y("y")), 12, 18); lo != 5 || hi != 9 { + t.Errorf("[%v, %v], want the step from 5 to 9 it crosses", lo, hi) + } +} + +// A line joins rows within their group only, so no value is interpolated +// between the last row of one series and the first of the next. +func TestALineInterpolatesWithinItsGroupOnly(t *testing.T) { + g := geom.Line(stairs(), geom.X("x"), geom.Y("y"), geom.GroupBy("g")) + // Joined across the groups, the rows at 10 and 20 would put 5.8 and 7.4 + // into the axis; an axis nothing trained reports its default instead. + if lo, hi := fitted(t, g, 12, 18); hi >= 5 { + t.Errorf("[%v, %v], want nothing in view — the gap between the series is not a line", lo, hi) + } +} + +// A bar is in view when its width is. +func TestABarHalfInViewFits(t *testing.T) { + if lo, hi := fitted(t, geom.Bar(stairs(), geom.X("x"), geom.Y("y"), geom.BarWidth(0.8)), 23, 26); lo != 0 || hi != 9 { + t.Errorf("[%v, %v], want the bar at 20 — reaching to 24 — and the baseline", lo, hi) + } +} + +// A stack fits its totals in view. +func TestAStackFitsItsTotalsInView(t *testing.T) { + tb := data.NewTable(). + Float64("x", []float64{0, 0, 10, 10}). + Float64("y", []float64{1, 2, 50, 50}). + String("s", []string{"a", "b", "a", "b"}) + if lo, hi := fitted(t, geom.Bar(tb, geom.X("x"), geom.Y("y"), geom.GroupBy("s")), -2, 2); lo != 0 || hi != 3 { + t.Errorf("[%v, %v], want the first stack's total of 3", lo, hi) + } +} diff --git a/geom/geom.go b/geom/geom.go index 9128d3a..12d2027 100644 --- a/geom/geom.go +++ b/geom/geom.go @@ -162,6 +162,21 @@ type Training struct { // mark that never heard of a third axis reads X and Y and is right to. // See docs/adr/0078-a-coord-with-more-than-two-axes.md. Dims []scale.Scale + + // Within is the interval of X a layer's Y should be trained on, and the + // third field this struct has been widened by. It is nil unless Y fits + // its panel's view ([scale.FitView]) and X's domain is pinned, which is + // the one case in which some rows are out of view before training + // starts. + // + // It is a request, not a filter: render does not know which of a layer's + // columns are positions. A layer that reads it trains Y on the rows in + // view — for a mark with width, those whose span overlaps it; for a + // connected one, also the value interpolated at each edge — and still + // trains X on every row. A layer that does not read it trains Y on every + // row, and the fitted axis is wider than the view, never narrower. See + // docs/adr/0089-a-value-axis-fits-what-its-time-axis-shows.md. + Within *scale.Interval } // Geom is a layer of marks. @@ -250,6 +265,23 @@ type config struct { secondCol string + // A candle's four value columns and how it reads and draws them. See + // [Candle]. + ohlc [4]string + direction CandleDirection + rising candleSide + falling candleSide + hollow bool + hollowSet bool + candleStyle CandleStyleKind + + // The two columns a [DirectionBy] layer compares, from empty for the + // previous row. + dirFrom, dirTo string + + // rule is whether a [LastValue] draws its line across the panel. + rule bool + sizeCol string sizeScale scale.SizeScale @@ -830,9 +862,10 @@ func Thickness(f float64) Option { return func(c *config) { c.thickness = f } } // // It is the library-wide question [Tree] asked first — ADR 0053's amendment — // and the answer lands here rather than in one mark's own option, so that the -// second mark to want it says it the same way. Today [Tree] is the only mark -// that reads it; [Orient] on any other is accepted and ignored, which is what -// [Decimate] on a projected surface already is. +// second mark to want it says it the same way. [Tree], [Bar], [Histogram] and +// [Boxplot] read it; [Orient] on any other is accepted and ignored, which is +// what [Decimate] on a projected surface already is. [Rect] and [ErrorBar] +// need no option: which edges a row names already says which way they run. type Orientation uint8 const ( @@ -850,6 +883,50 @@ const ( // [Vertical]. See [Orientation] for which marks read it. func Orient(o Orientation) Option { return func(c *config) { c.orient = o } } +// axes splits a panel's two scales into the one a mark's slots run along and +// the one its measurement does: X and Y as the mark has always drawn them, and +// the other way round under [Horizontal]. +func (c config) axes(x, y scale.Scale) (slot, value scale.Scale) { + if c.orient == Horizontal { + return y, x + } + return x, y +} + +// roles is the config with its positional columns renamed for the roles they +// play, so that a mark written for the vertical case reads a horizontal layer +// unchanged: under [Horizontal] the slot is the [Y] column and its far edge +// [Y2], and the measurement is the [X] column and its far end [X2]. It is used +// only to read columns; the layer's own config is what it describes itself +// with, because the document holds the columns the caller named. +func (c config) roles() config { + if c.orient == Horizontal { + c.xcol, c.ycol = c.ycol, c.xcol + c.x2col, c.y2col = c.y2col, c.x2col + } + return c +} + +// box is the rectangle a mark computed in slot and value space, in the space +// the scales map into: the slot across X and the value up Y, or turned a +// quarter under [Horizontal]. It is the one place a mark written for the +// vertical case turns, which is why everything after it — the coord, explode, +// extrude — needs to know nothing about orientation. +func (c config) box(s0, v0, s1, v1 float32) ir.Rect { + if c.orient == Horizontal { + return ir.R(v0, s0, v1, s1) + } + return ir.R(s0, v0, s1, v1) +} + +// point is [config.box] for a single position. +func (c config) point(s, v float32) (x, y float32) { + if c.orient == Horizontal { + return v, s + } + return s, v +} + // Opacity scales the fill alpha, in [0, 1]. The default is 1 for an explicit // [Fill] colour and 0.25 for an area that takes its colour from the palette — // a filled band has to sit behind the lines it belongs to. @@ -1245,6 +1322,12 @@ func newConfig(opts []Option) config { for _, o := range opts { o(&c) } + // A direction is a colour channel with a scale of its own, built once the + // options that name its colours have all been applied. A layer that also + // named a colour column keeps that one, and resolve refuses the pair. + if c.dirTo != "" && c.colorCol == "" { + c.colorScale = newDirectionScale(c) + } return c } @@ -1530,6 +1613,9 @@ func resolve(src data.Source, c config, x, y scale.Scale) (series, error) { } s.y2 = v } + if c.colorCol != "" && c.dirTo != "" { + return series{}, ErrColorAndDirection + } if c.colorCol != "" { v, err := colorColumn(src, c) if err != nil { @@ -1540,6 +1626,13 @@ func resolve(src data.Source, c config, x, y scale.Scale) (series, error) { } s.c = v } + if c.dirTo != "" { + v, err := c.directionCodes(src, len(xs)) + if err != nil { + return series{}, err + } + s.c = v + } if c.secondCol != "" { v, err := c.secondColumn(src, len(xs)) if err != nil { @@ -2135,6 +2228,15 @@ func slotOn(s scale.Scale, v, halfWidth float64) (float32, float32) { return a, b } +// baselineOn is [baselinePos] or [baselineAcross], whichever axis the value +// runs along. +func baselineOn(f Frame, c config, v float64) float32 { + if c.orient == Horizontal { + return baselineAcross(f, v) + } + return baselinePos(f, v) +} + // baselinePos maps the value a bar or area grows from. // // A log axis has no position for zero, which is the default baseline. Falling diff --git a/geom/group.go b/geom/group.go index 74c1851..61713a3 100644 --- a/geom/group.go +++ b/geom/group.go @@ -209,6 +209,12 @@ type groups struct { lo, hi []float64 // the stacked bounds per row, NaN for a row not drawn stack Stacking + // within is the view a stack trains its totals on, already widened by + // whatever its layer's marks reach past a row's position; nil trains on + // every row. It is set by the layer before train, per + // [Training.Within]. + within *scale.Interval + // The working buffers. They are reused, never handed out. at map[string]int posAt map[float64]int @@ -233,6 +239,15 @@ type groups struct { // ordinary layer's code path exactly what it was. func (gs *groups) grouped() bool { return gs != nil && len(gs.keys) > 0 } +// ofRows is each row's group, or nil for an ungrouped layer — the answer a +// path joins its rows by. +func (gs *groups) ofRows() []int { + if !gs.grouped() { + return nil + } + return gs.of +} + // stacked reports whether the rows carry adjusted bounds. func (gs *groups) stacked() bool { return gs.grouped() && gs.stack != NoStack } @@ -313,8 +328,7 @@ func (gs *groups) train(src data.Source, s series, c config, x, y scale.Scale, s // The axis has to describe the totals, so it is trained on the bounds // rather than on the column. The baseline goes in as well: a stack that // starts at zero is read as the distance from zero. - trainColumn(y, gs.lo) - trainColumn(y, gs.hi) + trainPoints(y, gs.within, s.x, gs.lo, gs.hi) y.Train(0) return nil } diff --git a/geom/histogram.go b/geom/histogram.go index ffd1ab2..550e44a 100644 --- a/geom/histogram.go +++ b/geom/histogram.go @@ -25,6 +25,10 @@ import ( // over the data's own extent with the Freedman–Diaconis rule, falling back to // Sturges's where the column has no interquartile range to measure. // +// [Orient] with [Horizontal] bins the [Y] column instead and grows the bars +// across X — the distribution drawn up a price axis, beside the price, is the +// case it is for: a histogram in a right track shares the panel's Y. +// // # Groups // // A histogram ignores [GroupBy]. Two distributions drawn as two overlapping @@ -46,8 +50,8 @@ type histGeom struct { } func (g *histGeom) Train(t Training) error { - x, y := t.X, t.Y - g.vals, g.err = column(g.src, g.cfg.xcol, x) + x, y := g.cfg.axes(t.X, t.Y) + g.vals, g.err = column(g.src, g.cfg.roles().xcol, x) if g.err != nil { return g.err } @@ -103,21 +107,22 @@ func (g *histGeom) Build(b ir.Backend, f Frame) error { defer sc.release() cd := f.Coords() - base := baselinePos(f, g.cfg.baseline) + fs, fv := g.cfg.axes(f.X, f.Y) + base := baselineOn(f, g.cfg, g.cfg.baseline) sc.fill.Reset() for _, bu := range g.buckets { if bu.Count == 0 { continue } - x0, x1 := f.X.Map(bu.Lo), f.X.Map(bu.Hi) + x0, x1 := fs.Map(bu.Lo), fs.Map(bu.Hi) if x1 < x0 { x0, x1 = x1, x0 } - y0, y1 := f.Y.Map(float64(bu.Count)), base + y0, y1 := fv.Map(float64(bu.Count)), base if y1 < y0 { y0, y1 = y1, y0 } - areaRound(&sc.fill, cd, ir.R(x0, y0, x1, y1), ir.Point{}, g.cfg.corner) + areaRound(&sc.fill, cd, g.cfg.box(x0, y0, x1, y1), ir.Point{}, g.cfg.corner) } if sc.fill.Empty() { return nil @@ -141,7 +146,7 @@ func (g *histGeom) Legend(f Frame) (LegendEntry, bool) { if g.cfg.fill != nil { col = *g.cfg.fill } - return g.cfg.boxSwatch(f, g.cfg.labelForX(), col), true + return g.cfg.boxSwatch(f, g.cfg.roles().labelForX(), col), true } func (g *histGeom) Source() data.Source { return g.src } diff --git a/geom/lastvalue.go b/geom/lastvalue.go new file mode 100644 index 0000000..7f04c38 --- /dev/null +++ b/geom/lastvalue.go @@ -0,0 +1,132 @@ +package geom + +import ( + "github.com/timzifer/figure/data" + "github.com/timzifer/figure/ir" +) + +// LastValue writes the newest value of a column on its axis: a tag on the +// value axis at the value of the row with the greatest position, and a rule +// across the panel to it — a trading screen's last price. +// +// geom.LastValue(src, geom.X("start"), geom.Y("close"), geom.DirectionBy("", "close")) +// +// The newest row is the one furthest along X, whatever order the rows are in, +// so a layer over a stream follows its open row as it is revised. The colour +// is [Color], or the row's direction through [DirectionBy] — the tag is the +// rising colour when the last candle closed up — or the layer's palette +// colour. [Dash] styles the rule and [Rule] with false leaves the tag alone. A +// layer on [OnY2] tags the secondary axis. +// +// It trains the value axis on the newest value only: it annotates a series +// another layer draws, and should not widen the axis to rows it does not show. +// See docs/adr/0092-what-a-trading-screen-reads-off-its-edges.md. +func LastValue(src data.Source, opts ...Option) Geom { + return &lastGeom{src: src, cfg: newConfig(append([]Option{Rule(true)}, opts...))} +} + +// Rule turns the line a [LastValue] draws across the panel on or off. The +// default is on. +func Rule(on bool) Option { return func(c *config) { c.rule = on } } + +type lastGeom struct { + src data.Source + cfg config + s series + last int // the newest row, or -1 + err error +} + +func (g *lastGeom) Train(t Training) error { + x, y := t.X, t.Y + g.s, g.err = resolve(g.src, g.cfg, x, y) + if g.err != nil { + return g.err + } + g.last = -1 + for i := range g.s.x { + if !defined(x, g.s.x[i]) || !defined(y, g.s.y[i]) { + continue + } + if g.last < 0 || g.s.x[i] >= g.s.x[g.last] { + g.last = i + } + } + if g.last >= 0 { + y.Train(g.s.y[g.last]) + } + return nil +} + +// color is the tag's and the rule's colour. +func (g *lastGeom) color(f Frame) ir.Color { + if g.cfg.color != nil { + return *g.cfg.color + } + if g.cfg.varying(g.s) && g.last >= 0 { + if c := g.cfg.colorScale.Color(g.s.c[g.last]); c.A != 0 { + return c + } + } + return g.cfg.colorFor(f) +} + +func (g *lastGeom) Build(b ir.Backend, f Frame) error { + if g.err != nil || g.last < 0 || !g.cfg.rule { + return g.err + } + v := g.s.y[g.last] + if !defined(f.Y, v) { + return nil + } + lo, hi := f.X.Domain() + if lo > hi { + lo, hi = hi, lo + } + cd := f.Coords() + y := f.Y.Map(v) + var run ir.Path + strokeRun(b, cd, &run, []ir.Point{cd.Point(f.X.Map(lo), y), cd.Point(f.X.Map(hi), y)}, ir.Stroke{ + Color: g.color(f), + Width: pick(g.cfg.width, 1), + Dash: g.cfg.dash, + }, false) + return nil +} + +func (g *lastGeom) Tags(f Frame) []AxisTag { + if g.err != nil || g.last < 0 { + return nil + } + axis := TagY + if g.cfg.onY2 { + axis = TagY2 + } + return []AxisTag{{Axis: axis, Value: g.s.y[g.last], Color: g.color(f)}} +} + +// Legend is only for a layer that was given a label: a last value annotates a +// series another layer names. +func (g *lastGeom) Legend(f Frame) (LegendEntry, bool) { + if g.err != nil || g.cfg.label == "" { + return LegendEntry{}, false + } + return LegendEntry{Label: g.cfg.label, Color: g.color(f), Kind: SwatchLine, Width: pick(g.cfg.width, 1)}, true +} + +func (g *lastGeom) Source() data.Source { return g.src } +func (g *lastGeom) Subset(rows []int) Geom { + return &lastGeom{src: data.Rows(g.src, rows), cfg: g.cfg} +} + +func (g *lastGeom) Describe() Desc { + d := g.cfg.describe(MarkLastValue) + d.Source = g.src + return d +} + +var ( + _ Describer = (*lastGeom)(nil) + _ Faceter = (*lastGeom)(nil) + _ Tagger = (*lastGeom)(nil) +) diff --git a/geom/line.go b/geom/line.go index a4696d9..e5e311a 100644 --- a/geom/line.go +++ b/geom/line.go @@ -38,12 +38,14 @@ func (g *lineGeom) Train(t Training) error { return g.err } trainColumn(x, g.s.x) - trainColumn(y, g.s.y) g.cfg.trainColors(g.s) // A line does not stack: two series drawn on top of each other are two // readings, and adding them would invent a third nobody measured. - g.err = g.gs.train(g.src, g.s, g.cfg, x, y, NoStack) - return g.err + if g.err = g.gs.train(g.src, g.s, g.cfg, x, y, NoStack); g.err != nil { + return g.err + } + trainConnected(y, t.Within, g.s.x, g.s.y, g.gs.ofRows(), false) + return nil } func (g *lineGeom) Build(b ir.Backend, f Frame) error { diff --git a/geom/orient_test.go b/geom/orient_test.go new file mode 100644 index 0000000..4735701 --- /dev/null +++ b/geom/orient_test.go @@ -0,0 +1,220 @@ +package geom_test + +import ( + "math" + "sort" + "testing" + + "github.com/timzifer/figure/data" + "github.com/timzifer/figure/geom" + "github.com/timzifer/figure/ir" + "github.com/timzifer/figure/scale" +) + +// The panel the orientation tests draw on. It is square so that a rectangle +// turned a quarter is compared in the same units it was drawn in. +const side = 300 + +// unit is a rectangle read back in slot and value space as fractions of the +// panel: where along its slot axis it sits, and how far up its value axis it +// reaches. A vertical and a horizontal layer of the same data give the same +// units, which is the whole claim of the option. +type unit struct{ s0, s1, v0, v1 float64 } + +func vertical(r ir.Rect) unit { + return unit{ + s0: float64(r.Min.X) / side, s1: float64(r.Max.X) / side, + v0: 1 - float64(r.Max.Y)/side, v1: 1 - float64(r.Min.Y)/side, + } +} + +func horizontal(r ir.Rect) unit { + return unit{ + s0: 1 - float64(r.Max.Y)/side, s1: 1 - float64(r.Min.Y)/side, + v0: float64(r.Min.X) / side, v1: float64(r.Max.X) / side, + } +} + +func sortedUnits(us []unit) []unit { + sort.Slice(us, func(i, j int) bool { + if us[i].s0 != us[j].s0 { + return us[i].s0 < us[j].s0 + } + return us[i].v0 < us[j].v0 + }) + return us +} + +// turned builds the same layer both ways round — the horizontal one with its +// columns exchanged, as a caller writes it — on scales made fresh for each, and +// compares the rectangles in slot and value space. +func turned(t *testing.T, up, across geom.Geom, slot, value func() scale.Scale) { + t.Helper() + recUp, fUp := frameOn(t, up, slot(), value(), side, side) + if err := up.Build(recUp, fUp); err != nil { + t.Fatalf("Build vertical: %v", err) + } + recAcross, fAcross := frameOn(t, across, value(), slot(), side, side) + if err := across.Build(recAcross, fAcross); err != nil { + t.Fatalf("Build horizontal: %v", err) + } + var a, b []unit + for _, r := range allRects(t, recUp) { + a = append(a, vertical(r)) + } + for _, r := range allRects(t, recAcross) { + b = append(b, horizontal(r)) + } + if len(a) == 0 || len(a) != len(b) { + t.Fatalf("%d rectangles vertical and %d horizontal", len(a), len(b)) + } + a, b = sortedUnits(a), sortedUnits(b) + for i := range a { + d := math.Max(math.Max(math.Abs(a[i].s0-b[i].s0), math.Abs(a[i].s1-b[i].s1)), + math.Max(math.Abs(a[i].v0-b[i].v0), math.Abs(a[i].v1-b[i].v1))) + if d > 1e-4 { + t.Errorf("rectangle %d: %+v vertical, %+v horizontal", i, a[i], b[i]) + } + } +} + +func linear() scale.Scale { return scale.Linear() } +func ordinal() scale.Scale { return scale.Ordinal() } + +func TestAHorizontalBarIsTheVerticalOneTurned(t *testing.T) { + src := data.NewTable(). + String("fruit", []string{"apple", "pear", "plum"}). + Float64("n", []float64{3, 7, 5}) + turned(t, + geom.Bar(src, geom.X("fruit"), geom.Y("n")), + geom.Bar(src, geom.Y("fruit"), geom.X("n"), geom.Orient(geom.Horizontal)), + ordinal, linear) +} + +func TestAHorizontalBarOnAContinuousSlotAxisIsTurnedToo(t *testing.T) { + src := data.NewTable(). + Float64("t", []float64{0, 1, 2, 4}). + Float64("v", []float64{2, -1, 3, 1}) + turned(t, + geom.Bar(src, geom.X("t"), geom.Y("v"), geom.BarWidth(0.6)), + geom.Bar(src, geom.Y("t"), geom.X("v"), geom.BarWidth(0.6), geom.Orient(geom.Horizontal)), + linear, linear) +} + +func TestAHorizontalStackAndDodgeAreTurned(t *testing.T) { + turned(t, + geom.Bar(longTable(), geom.X("t"), geom.Y("v"), geom.GroupBy("series")), + geom.Bar(longTable(), geom.Y("t"), geom.X("v"), geom.GroupBy("series"), geom.Orient(geom.Horizontal)), + linear, linear) +} + +// A dodged horizontal group reads down the page in the order its legend does: +// the first series is the top bar of its slot, not the bottom one a literal +// quarter turn would give. +func TestAHorizontalDodgeListsItsFirstSeriesOnTop(t *testing.T) { + g := geom.Bar(longTable(), geom.Y("t"), geom.X("v"), geom.GroupBy("series"), + geom.Dodge(0.1), geom.Orient(geom.Horizontal)) + rec, f := frameOn(t, g, scale.Linear(), scale.Linear(), side, side) + if err := g.Build(rec, f); err != nil { + t.Fatal(err) + } + // One fill call per series, in series order; the first slot (t = 0) is + // the bottom one, so each series' bar there is its lowest. + var tops []float32 + for _, c := range rec.Calls { + if c.Op != "FillPath" { + continue + } + low := float32(-1) + for _, r := range rectsOf(t, c) { + if r.Max.Y > low { + low = r.Max.Y + } + } + tops = append(tops, low) + } + if len(tops) != 3 || !(tops[0] < tops[1] && tops[1] < tops[2]) { + t.Errorf("the first slot's bars end at y = %v, want series a above b above c", tops) + } +} + +// A floating bar names both ends of its value. Under Horizontal the far end +// is X2, because the value runs across X; Y2 would be the slot's far edge. +func TestAHorizontalFloatingBarReadsItsFarEndFromX2(t *testing.T) { + src := data.NewTable(). + Float64("t", []float64{0, 1, 2}). + Float64("lo", []float64{1, 2, 0}). + Float64("hi", []float64{4, 3, 5}) + turned(t, + geom.Bar(src, geom.X("t"), geom.Y("lo"), geom.Y2("hi")), + geom.Bar(src, geom.Y("t"), geom.X("lo"), geom.X2("hi"), geom.Orient(geom.Horizontal)), + linear, linear) +} + +// A horizontal bar's baseline is on the value axis, so it is the value axis +// that is trained to include it. +func TestAHorizontalBarTrainsItsBaselineAcross(t *testing.T) { + src := data.NewTable().Float64("t", []float64{0, 1}).Float64("v", []float64{5, 8}) + g := geom.Bar(src, geom.Y("t"), geom.X("v"), geom.Orient(geom.Horizontal)) + x, y := scale.Linear(), scale.Linear() + if err := g.Train(geom.Training{X: x, Y: y}); err != nil { + t.Fatal(err) + } + if lo, _ := x.Domain(); lo > 0 { + t.Errorf("the value axis starts at %v, want the baseline 0 in it", lo) + } + if lo, _ := y.Domain(); lo >= 0 { + t.Errorf("the slot axis starts at %v, want it widened by half a slot", lo) + } +} + +// The orientation is written down, so a document of a horizontal bar reads +// back as one — with the columns the caller named, not the swapped roles. +func TestABarsOrientationIsWrittenDown(t *testing.T) { + src := data.NewTable().Float64("t", []float64{0, 1}).Float64("v", []float64{5, 8}) + g := geom.Bar(src, geom.Y("t"), geom.X("v"), geom.Orient(geom.Horizontal)) + d := g.(geom.Describer).Describe() + if d.Orient != geom.Horizontal || d.X != "v" || d.Y != "t" { + t.Fatalf("Describe: orient %v, X %q, Y %q", d.Orient, d.X, d.Y) + } + back, err := geom.FromDesc(d) + if err != nil { + t.Fatal(err) + } + if again := back.(geom.Describer).Describe(); again.Orient != geom.Horizontal { + t.Errorf("the rebuilt bar reports orientation %v", again.Orient) + } +} + +func TestAHorizontalHistogramBinsTheYColumn(t *testing.T) { + src := data.NewTable().Float64("v", []float64{1, 2, 2, 3, 3, 3, 4, 4, 5, 9}) + turned(t, + geom.Histogram(src, geom.X("v"), geom.Bins(5)), + geom.Histogram(src, geom.Y("v"), geom.Bins(5), geom.Orient(geom.Horizontal)), + linear, linear) +} + +func TestAHorizontalBoxplotIsTheVerticalOneTurned(t *testing.T) { + src := data.NewTable(). + String("g", []string{"a", "a", "a", "a", "a", "b", "b", "b", "b", "b"}). + Float64("v", []float64{1, 2, 3, 4, 20, 5, 6, 6, 7, 8}) + turned(t, + geom.Boxplot(src, geom.X("g"), geom.Y("v")), + geom.Boxplot(src, geom.Y("g"), geom.X("v"), geom.Orient(geom.Horizontal)), + ordinal, linear) + + // The whiskers and the outlier are turned with the box: the outlier at 20 + // is the rightmost thing drawn, not the topmost. + g := geom.Boxplot(src, geom.Y("g"), geom.X("v"), geom.Orient(geom.Horizontal)) + rec, f := frameOn(t, g, scale.Linear(), scale.Ordinal(), side, side) + if err := g.Build(rec, f); err != nil { + t.Fatal(err) + } + for _, c := range rec.Calls { + if c.Op == "Markers" { + if len(c.Points) != 1 || c.Points[0].X < side*0.95 { + t.Errorf("outliers at %v, want one at the right-hand end", c.Points) + } + } + } +} diff --git a/geom/rect.go b/geom/rect.go index 4206824..08a8716 100644 --- a/geom/rect.go +++ b/geom/rect.go @@ -81,13 +81,21 @@ func (g *rectGeom) Train(t Training) error { return err } trainColumn(x, g.s.x) - trainColumn(y, g.s.y) if g.x2 != nil { trainColumn(x, g.x2) } - if g.s.y2 != nil { - trainColumn(y, g.s.y2) + // A cell is in view when any of it is: its own edges, or its share of the + // slot around its position. + var half float64 + if t.Within != nil && g.x2 == nil { + half = g.halfWidth(g.s.x) } + trainSpans(y, t.Within, len(g.s.x), func(i int) (float64, float64) { + if g.x2 != nil { + return g.s.x[i], g.x2[i] + } + return g.s.x[i] - half, g.s.x[i] + half + }, g.s.y, g.s.y2) g.cfg.trainColors(g.s) // An edge that comes from the slot rather than from a column has width the diff --git a/geom/scatter.go b/geom/scatter.go index 58d2fef..6032437 100644 --- a/geom/scatter.go +++ b/geom/scatter.go @@ -39,7 +39,7 @@ func (g *scatterGeom) Train(t Training) error { return err } trainColumn(x, g.s.x) - trainColumn(y, g.s.y) + trainPoints(y, t.Within, g.s.x, g.s.y) g.cfg.trainColors(g.s) g.cfg.trainSizes(g.s) // Independent marks have nothing to stack: two points at one X are two diff --git a/geom/step.go b/geom/step.go index 56c2705..105810b 100644 --- a/geom/step.go +++ b/geom/step.go @@ -38,12 +38,14 @@ func (g *stepGeom) Train(t Training) error { return g.err } trainColumn(x, g.s.x) - trainColumn(y, g.s.y) g.cfg.trainColors(g.s) // A staircase is a reading held over time, and two of them do not add up // any more than two lines do. - g.err = g.gs.train(g.src, g.s, g.cfg, x, y, NoStack) - return g.err + if g.err = g.gs.train(g.src, g.s, g.cfg, x, y, NoStack); g.err != nil { + return g.err + } + trainConnected(y, t.Within, g.s.x, g.s.y, g.gs.ofRows(), true) + return nil } func (g *stepGeom) Build(b ir.Backend, f Frame) error { diff --git a/geom/tag.go b/geom/tag.go new file mode 100644 index 0000000..8832c67 --- /dev/null +++ b/geom/tag.go @@ -0,0 +1,44 @@ +package geom + +import "github.com/timzifer/figure/ir" + +// TagAxis is which axis an [AxisTag] is written on. +type TagAxis uint8 + +// The axes a tag can be written on: the panel's primary vertical and +// horizontal, and the secondary ones a layer on [OnY2] or [OnX2] reads. +const ( + TagY TagAxis = iota + TagX + TagY2 + TagX2 +) + +// AxisTag is a value written on an axis: a filled box on the axis line at +// Value, holding Text, drawn in the axis's gutter over the tick labels it +// covers. It is what a trading screen's last price is, and a crosshair's +// readout. +// +// Text empty is the value as the axis writes a value read off it — +// [github.com/timzifer/figure/scale.ValueLabelOf]: its own format, with the precision a value between +// two ticks needs. Color is the box; the text is chosen to read against it. +// A tag at a value the axis does not show, or on an axis the panel's coord +// does not draw straight, is not drawn. See +// docs/adr/0092-what-a-trading-screen-reads-off-its-edges.md. +type AxisTag struct { + Axis TagAxis + Value float64 + Text string + Color ir.Color +} + +// Tagger is implemented by a layer that writes values on its panel's axes. +// It is an optional interface, like [Legender]: [Geom] does not grow, and a +// layer that has nothing to tag need not say so. +// +// Render asks for the tags after the layer has trained and the panel's scales +// are ranged, so f's scales place a value exactly where the layer drew it. A +// hidden layer is not asked. +type Tagger interface { + Tags(f Frame) []AxisTag +} diff --git a/geom/text.go b/geom/text.go index 6e38832..e9d7caa 100644 --- a/geom/text.go +++ b/geom/text.go @@ -137,13 +137,18 @@ func (g *textGeom) Train(t Training) error { return err } trainColumn(x, g.s.x) - trainColumn(y, g.s.y) if g.x2 != nil { trainColumn(x, g.x2) } - if g.s.y2 != nil { - trainColumn(y, g.s.y2) + // A label with a box is in view when its box is; one at a point, when the + // point is. + span := func(i int) (float64, float64) { + if g.x2 != nil { + return g.s.x[i], g.x2[i] + } + return g.s.x[i], g.s.x[i] } + trainSpans(y, t.Within, len(g.s.x), span, g.s.y, g.s.y2) if g.pull, g.err = g.cfg.trainBreakOut(g.src, len(g.s.x)); g.err != nil { return g.err } diff --git a/interact/interact.go b/interact/interact.go index 5cc64bb..afae364 100644 --- a/interact/interact.go +++ b/interact/interact.go @@ -682,6 +682,50 @@ func (ix *Index) RowsOf(panel, layer int, dst []RowRef) []RowRef { return dst } +// Along is which device axis [Index.NearestAlong] measures along. +type Along uint8 + +// The two directions a nearest row can be measured along. +const ( + AlongX Along = iota + AlongY +) + +// NearestAlong reports the row of a panel whose reported position is nearest +// at along one device axis, whatever its distance along the other. +// +// It is the question a trader's crosshair asks — the candle at this time — +// which [Index.At] does not answer: At finds the nearest mark in the plane, +// and between two candles, or over a tall one, that is not the one at the +// pointer's time. Rows at the same distance go to the one painted first, so +// the candle a panel draws before its indicator lines wins a tie with them. +// +// It needs row tracking — see [Index.TrackRows] — and reports false for a +// panel with no rows. See docs/adr/0092-what-a-trading-screen-reads-off-its-edges.md. +func (ix *Index) NearestAlong(panel int, along Along, at float32) (RowRef, bool) { + best, found := -1, false + var dist float32 + for i, r := range ix.rows { + if r.panel != panel { + continue + } + d := r.at.X - at + if along == AlongY { + d = r.at.Y - at + } + if d < 0 { + d = -d + } + if !found || d < dist { + best, dist, found = i, d, true + } + } + if !found { + return RowRef{}, false + } + return ix.refOf(ix.rows[best]), true +} + // RowsIn appends every row that landed inside r to dst and returns it, in // paint order. // diff --git a/ir/marker.go b/ir/marker.go index da898d3..659643b 100644 --- a/ir/marker.go +++ b/ir/marker.go @@ -27,6 +27,12 @@ func MarkerPath(p *Path, m Marker, size float32) { h := r * 1.5 w := r * 1.2990381 // r * 1.5 * sqrt(3)/2 p.MoveTo(0, -h*2/3).LineTo(w, h/3).LineTo(-w, h/3).Close() + case MarkerTriangleDown: + // The same triangle mirrored through its centroid, so a pair sits on + // the same point rather than one of them a third of its height off. + h := r * 1.5 + w := r * 1.2990381 + p.MoveTo(0, h*2/3).LineTo(-w, -h/3).LineTo(w, -h/3).Close() case MarkerCross: arm := r * 0.28 d := (r - arm) * 0.7071068 diff --git a/ir/marker_test.go b/ir/marker_test.go new file mode 100644 index 0000000..cd54919 --- /dev/null +++ b/ir/marker_test.go @@ -0,0 +1,43 @@ +package ir_test + +import ( + "math" + "testing" + + "github.com/timzifer/figure/ir" +) + +// The downward triangle is the upward one mirrored through its centroid, so a +// buy and a sell drawn at the same point occupy the same box turned over. +func TestTheDownwardTriangleMirrorsTheUpwardOne(t *testing.T) { + var up, down ir.Path + ir.MarkerPath(&up, ir.MarkerTriangle, 10) + ir.MarkerPath(&down, ir.MarkerTriangleDown, 10) + + u, d := up.Bounds(), down.Bounds() + const eps = 1e-5 + near := func(a, b float32) bool { return math.Abs(float64(a-b)) < eps } + if !near(u.Min.X, d.Min.X) || !near(u.Max.X, d.Max.X) { + t.Errorf("widths differ: up %v, down %v", u, d) + } + if !near(u.Min.Y, -d.Max.Y) || !near(u.Max.Y, -d.Min.Y) { + t.Errorf("down %v is not up %v mirrored through the centre", d, u) + } + + // The apex is the point that tells the two apart: above the centre for + // one, below it for the other, in a y-down device space. + var upApex, downApex ir.Point + up.Walk(func(op ir.PathOp, pts []ir.Point) { + if op == ir.OpMoveTo { + upApex = pts[0] + } + }) + down.Walk(func(op ir.PathOp, pts []ir.Point) { + if op == ir.OpMoveTo { + downApex = pts[0] + } + }) + if upApex.Y >= 0 || downApex.Y <= 0 { + t.Errorf("apexes at %v and %v, want one above and one below the centre", upApex, downApex) + } +} diff --git a/ir/style.go b/ir/style.go index bd05fe9..8557a89 100644 --- a/ir/style.go +++ b/ir/style.go @@ -129,6 +129,10 @@ const ( MarkerTriangle MarkerCross MarkerPlus + // MarkerTriangleDown is [MarkerTriangle] turned over. It is the second + // half of a pair rather than a shape of its own: a buy and a sell, a rise + // and a fall, read as one mark pointing two ways. + MarkerTriangleDown ) // MarkerStyle is the paint applied to every instance of a Markers call. diff --git a/live.go b/live.go index 3500f5a..d711ffc 100644 --- a/live.go +++ b/live.go @@ -839,7 +839,16 @@ func (l *Live) panelIndex(pt ir.Point) int { // is what makes one implementation cover every scale: the pointer stays over // the same value on a log axis and a time axis for the same reason it does on // a linear one, without this knowing which it has. +// fitted reports an axis a gesture must leave alone: one that fits the rows +// its panel's other axis shows, and is derived from that axis every frame +// rather than moved by the reader. A wheel over a price chart zooms time, and +// the price axis follows. See docs/adr/0089-a-value-axis-fits-what-its-time-axis-shows.md. +func fitted(s scale.Scale) bool { return scale.FitsView(s) } + func zoomAxis(s scale.Scale, lo, hi, at float32, factor float64) { + if fitted(s) { + return + } z, ok := s.(scale.Zoomer) if !ok { return @@ -849,6 +858,9 @@ func zoomAxis(s scale.Scale, lo, hi, at float32, factor float64) { } func panAxis(s scale.Scale, lo, hi, delta float32) { + if fitted(s) { + return + } z, ok := s.(scale.Zoomer) if !ok { return @@ -857,6 +869,9 @@ func panAxis(s scale.Scale, lo, hi, delta float32) { } func setDomain(s scale.Scale, a, b float32) { + if fitted(s) { + return + } z, ok := s.(scale.Zoomer) if !ok { return diff --git a/overlay.go b/overlay.go index d618c50..58cee82 100644 --- a/overlay.go +++ b/overlay.go @@ -1,6 +1,8 @@ package figure import ( + "github.com/timzifer/figure/geom" + "github.com/timzifer/figure/interact" "github.com/timzifer/figure/ir" "github.com/timzifer/figure/render" "github.com/timzifer/figure/theme" @@ -78,6 +80,20 @@ type Crosshair struct { // wants only the vertical one. NoVertical bool NoHorizontal bool + + // Snap is the index the crosshair snaps to — [Live.Index] — or nil to + // follow the pointer. With it, the lines cross at the row nearest At along + // X, where that row was reported: for a candle, the middle of its slot at + // its close. It needs row tracking ([Live.TrackRows]); without rows there + // is nothing to snap to and the crosshair follows the pointer. See + // docs/adr/0092-what-a-trading-screen-reads-off-its-edges.md. + Snap *interact.Index + + // Tags writes the crosshair's position on the panel's axes: its X on the + // horizontal axis and its Y on the vertical one, in each axis's own + // format, in TagColor or the theme's annotation colour. + Tags bool + TagColor ir.Color } // DrawOverlay implements [render.Overlay]. @@ -89,6 +105,16 @@ func (c *Crosshair) DrawOverlay(b ir.Backend, f OverlayFrame) { if !ok { return } + at := c.At + if c.Snap != nil { + if r, ok := c.Snap.NearestAlong(p.Index, interact.AlongX, at.X); ok { + at = r.At + } + } + if c.Tags { + // Drawn once the lines are, outside the clip: a tag sits on the axis. + defer c.drawTags(b, f, p, at) + } stroke := ir.Stroke{ Color: orElse(c.Color, fade(f.Theme.AxisColor, 0.55)), Width: orElseF(c.Width, 1), @@ -103,18 +129,30 @@ func (c *Crosshair) DrawOverlay(b ir.Backend, f OverlayFrame) { if !c.NoVertical { b.Polyline([]ir.Point{ - {X: c.At.X, Y: p.Area.Min.Y}, - {X: c.At.X, Y: p.Area.Max.Y}, + {X: at.X, Y: p.Area.Min.Y}, + {X: at.X, Y: p.Area.Max.Y}, }, stroke) } if !c.NoHorizontal { b.Polyline([]ir.Point{ - {X: p.Area.Min.X, Y: c.At.Y}, - {X: p.Area.Max.X, Y: c.At.Y}, + {X: p.Area.Min.X, Y: at.Y}, + {X: p.Area.Max.X, Y: at.Y}, }, stroke) } } +// drawTags writes the crosshair's position on the axes its lines cross. +func (c *Crosshair) drawTags(b ir.Backend, f OverlayFrame, p OverlayPanel, at ir.Point) { + var tags []geom.AxisTag + if !c.NoVertical && p.X != nil { + tags = append(tags, geom.AxisTag{Axis: geom.TagX, Value: p.X.Invert(at.X), Color: c.TagColor}) + } + if !c.NoHorizontal && p.Y != nil { + tags = append(tags, geom.AxisTag{Axis: geom.TagY, Value: p.Y.Invert(at.Y), Color: c.TagColor}) + } + render.DrawAxisTags(b, f.Theme, p.Area, render.TagScales{X: p.X, Y: p.Y, X2: p.X2, Y2: p.Y2}, tags) +} + // Highlight rings a set of marks, to say "these ones". // // It is what a chart linked to another draws: the first chart reports which row diff --git a/render/render.go b/render/render.go index 8b2a51c..11c4ca8 100644 --- a/render/render.go +++ b/render/render.go @@ -417,6 +417,7 @@ func Draw(b ir.Backend, c Chart) error { // 1. Train the scales. Tick labels depend on the domain, and the layout // depends on the tick labels, so this has to happen before anything is // measured. + retrain(panels, th) for _, p := range panels { // A panel with more axes than two hands its layers the scales of all // of them: a parallel-coordinates panel has one per dimension, and the @@ -426,7 +427,7 @@ func Draw(b ir.Backend, c Chart) error { dims := coord.Scales(coord.Dimensions(c.coordOf(p))) for _, g := range p.Layers { x, y := p.axesOf(g) - if err := g.Train(geom.Training{X: x, Y: y, Dims: dims}); err != nil { + if err := g.Train(geom.Training{X: x, Y: y, Dims: dims, Within: within(x, y)}); err != nil { return err } } @@ -485,12 +486,29 @@ func Draw(b ir.Backend, c Chart) error { fur := acquireFurniture() axesOver := false + // The tags the layers write on their axes, per panel. They are placed + // before the axes are drawn, so the tick labels they cover can be left out, + // and painted after the data, over the gutter. See + // docs/adr/0092-what-a-trading-screen-reads-off-its-edges.md. + var tags [][]placedTag for i, p := range panels { area := lay.Areas[i] cd, xTicks, yTicks := p.rangeTo(c.coordOf(p), area, th) if coords != nil { coords[i] = cd } + if cd.Straight() { + if placed := placeTags(b, th, area, TagScales{X: p.X, Y: p.Y, X2: p.X2, Y2: p.Y2}, + layerTags(p, area, cd, th, c.Hidden)); len(placed) > 0 { + if tags == nil { + tags = make([][]placedTag, len(panels)) + } + tags[i] = placed + half := float32(th.TickSize) / 2 + xTicks = hideCovered(xTicks, placed, geom.TagX, half) + yTicks = hideCovered(yTicks, placed, geom.TagY, half) + } + } fur.Reset() cd.Furniture(fur, coord.FurnitureRequest{Area: area, Metrics: metricsOf(th), XTicks: xTicks, YTicks: yTicks}) drawPanelFill(b, area, th) @@ -521,6 +539,9 @@ func Draw(b ir.Backend, c Chart) error { if axesOver { drawAxesOverData(b, c, panels, lay.Areas, th) } + for _, placed := range tags { + drawTags(b, th, placed) + } // The solver reserves one box per guide, in order, so these are parallel. // The legend is rebuilt against the real plot rectangle: an entry can @@ -1652,3 +1673,52 @@ func drawSwatch(b ir.Backend, e geom.LegendEntry, th theme.Theme, x, cy float32) }) } } + +// within is the interval of x a layer's y is fitted to: x's domain, when y +// fits its view and x's is pinned, and nil otherwise. A pinned domain is known +// before any layer trains, so the fit costs no second pass; an unpinned one is +// trained from the same rows, so every row is in view and there is nothing to +// fit. See docs/adr/0089-a-value-axis-fits-what-its-time-axis-shows.md. +func within(x, y scale.Scale) *scale.Interval { + if !scale.FitsView(y) || !scale.Pinned(x) { + return nil + } + lo, hi := x.Domain() + return &scale.Interval{Lo: lo, Hi: hi} +} + +// retrain forgets every trained range the coming training will set: each +// panel's axes and the colour and size scales its layers describe. A trained +// domain only widens and a plot keeps its scales between renders, so without +// this an axis describes every row it has ever been shown rather than the rows +// this render draws. Pinned domains, discovered categories and discrete +// colour keys are not forgotten — see [scale.Retrainer] for why. A colour +// scale whose colours are the theme's — a direction's (ADR 0091) — is given +// this render's theme in the same pass. +// +// It runs over every panel before any layer trains, so an axis shared by +// several panels is forgotten, perhaps more than once, before the first of +// them trains it; forgetting twice is forgetting once. See +// docs/adr/0090-an-axis-describes-the-frame-it-is-drawn-in.md. +func retrain(panels []Panel, th theme.Theme) { + for _, p := range panels { + for _, g := range p.Layers { + x, y := p.axesOf(g) + scale.Retrain(x) + scale.Retrain(y) + if d, ok := geom.Describe(g); ok { + if d.ColorScale != nil { + scale.Retrain(d.ColorScale) + // A scale whose colours come from the theme — a + // direction's — takes this render's. + if b, ok := d.ColorScale.(interface{ BindTheme(theme.Theme) }); ok { + b.BindTheme(th) + } + } + if d.SizeScale != nil { + scale.Retrain(d.SizeScale) + } + } + } + } +} diff --git a/render/tag.go b/render/tag.go new file mode 100644 index 0000000..ed0288d --- /dev/null +++ b/render/tag.go @@ -0,0 +1,225 @@ +package render + +import ( + "math" + "slices" + + "github.com/timzifer/figure/coord" + "github.com/timzifer/figure/geom" + "github.com/timzifer/figure/ir" + "github.com/timzifer/figure/scale" + "github.com/timzifer/figure/theme" +) + +// TagScales is the four axes a panel's tags can be written on. A nil scale is +// an axis the panel does not have, and a tag on it is not drawn. +type TagScales struct{ X, Y, X2, Y2 scale.Scale } + +func (s TagScales) of(a geom.TagAxis) scale.Scale { + switch a { + case geom.TagX: + return s.X + case geom.TagX2: + return s.X2 + case geom.TagY2: + return s.Y2 + } + return s.Y +} + +func vertical(a geom.TagAxis) bool { return a == geom.TagY || a == geom.TagY2 } + +// placedTag is a tag resolved to the panel: where on its axis it stands, the +// text it holds and the box it fills. +type placedTag struct { + tag geom.AxisTag + at float32 // device position along the axis + text string + box ir.Rect +} + +// placeTags resolves tags against a panel's area and scales: each is given its +// text and its position, those the axis does not show are dropped, and those +// on one axis that would overlap are pushed apart, in value order, the way a +// stack of labels is. The boxes reach from the axis line into the gutter. +func placeTags(m interface { + Measure(ir.TextRun) ir.TextMetrics +}, th theme.Theme, area ir.Rect, sc TagScales, tags []geom.AxisTag) []placedTag { + if len(tags) == 0 { + return nil + } + font := th.Font(th.TickSize) + pad := th.TickLabelPad + out := make([]placedTag, 0, len(tags)) + for _, t := range tags { + s := sc.of(t.Axis) + if s == nil || math.IsNaN(t.Value) || math.IsInf(t.Value, 0) { + continue + } + lo, hi := s.Domain() + if t.Value < math.Min(lo, hi) || t.Value > math.Max(lo, hi) { + continue + } + text := t.Text + if text == "" { + text = scale.ValueLabelOf(s, t.Value) + } + if text == "" { + continue + } + out = append(out, placedTag{tag: t, at: s.Map(t.Value), text: text}) + } + + // One box height apart at least, per axis, keeping each tag as near its + // value as the ones beside it allow. + h := float32(font.Size)*1.3 + pad + for _, a := range [4]geom.TagAxis{geom.TagY, geom.TagX, geom.TagY2, geom.TagX2} { + var on []int + for i := range out { + if out[i].tag.Axis == a { + on = append(on, i) + } + } + slices.SortFunc(on, func(i, j int) int { + switch { + case out[i].at < out[j].at: + return -1 + case out[i].at > out[j].at: + return 1 + } + return 0 + }) + gap := h + if !vertical(a) { + gap = 0 // measured per tag below: a horizontal tag is as wide as its text + } + for k := 1; k < len(on); k++ { + prev, cur := &out[on[k-1]], &out[on[k]] + need := gap + if !vertical(a) { + need = (textWidth(m, prev.text, font)+textWidth(m, cur.text, font))/2 + 2*pad + } + if cur.at-prev.at < need { + cur.at = prev.at + need + } + } + } + + for i := range out { + p := &out[i] + w := textWidth(m, p.text, font) + 2*pad + switch p.tag.Axis { + case geom.TagY: + p.box = ir.R(area.Min.X-w, p.at-h/2, area.Min.X, p.at+h/2) + case geom.TagY2: + p.box = ir.R(area.Max.X, p.at-h/2, area.Max.X+w, p.at+h/2) + case geom.TagX: + p.box = ir.R(p.at-w/2, area.Max.Y, p.at+w/2, area.Max.Y+h) + case geom.TagX2: + p.box = ir.R(p.at-w/2, area.Min.Y-h, p.at+w/2, area.Min.Y) + } + } + return out +} + +func textWidth(m interface { + Measure(ir.TextRun) ir.TextMetrics +}, text string, font ir.FontRef) float32 { + return m.Measure(ir.TextRun{Text: text, Font: font}).Advance +} + +// drawTags paints placed tags: the box in the tag's colour, the text centred +// in it in whichever of the theme's ink and the background reads against the +// box. +func drawTags(b ir.Backend, th theme.Theme, tags []placedTag) { + font := th.Font(th.TickSize) + for _, t := range tags { + col := t.tag.Color + if col.A == 0 { + col = th.AnnotationColor + } + var p ir.Path + p.Rect(t.box) + b.FillPath(&p, ir.Solid(col), ir.NonZero) + b.Text(ir.TextRun{ + Text: t.text, + Font: font, + At: ir.Point{X: (t.box.Min.X + t.box.Max.X) / 2, Y: (t.box.Min.Y + t.box.Max.Y) / 2}, + H: ir.AlignCenter, + V: ir.AlignMiddle, + Color: inkOn(col), + }) + } +} + +// inkOn is black or white, whichever reads against col: the WCAG relative +// luminance against the midpoint of the two. +func inkOn(col ir.Color) ir.Color { + lin := func(c uint8) float64 { + v := float64(c) / 255 + if v <= 0.04045 { + return v / 12.92 + } + return math.Pow((v+0.055)/1.055, 2.4) + } + l := 0.2126*lin(col.R) + 0.7152*lin(col.G) + 0.0722*lin(col.B) + if l > 0.179 { + return ir.RGB(0x11, 0x11, 0x11) + } + return ir.RGB(0xFF, 0xFF, 0xFF) +} + +// hideCovered blanks the labels of the ticks a tag covers on its axis, so that +// the tag — the more specific statement — is not overprinted by a tick label. +// It copies the tick list only when something is hidden. +func hideCovered(ticks []scale.Tick, tags []placedTag, axis geom.TagAxis, half float32) []scale.Tick { + var out []scale.Tick + for i, tk := range ticks { + if tk.Label == "" { + continue + } + for _, t := range tags { + if t.tag.Axis != axis { + continue + } + lo, hi := t.box.Min.Y, t.box.Max.Y + if !vertical(axis) { + lo, hi = t.box.Min.X, t.box.Max.X + } + if tk.Pos >= lo-half && tk.Pos <= hi+half { + if out == nil { + out = slices.Clone(ticks) + } + out[i].Label = "" + break + } + } + } + if out == nil { + return ticks + } + return out +} + +// layerTags asks a panel's visible layers for their tags, against the frame +// each is drawn in. +func layerTags(p Panel, area ir.Rect, cd coord.Coord, th theme.Theme, hidden []bool) []geom.AxisTag { + var out []geom.AxisTag + for i, g := range p.Layers { + t, ok := g.(geom.Tagger) + if !ok || isHidden(hidden, i) { + continue + } + x, y := p.axesOf(g) + out = append(out, t.Tags(geom.Frame{Area: area, X: x, Y: y, Coord: cd, Theme: th, Index: i})...) + } + return out +} + +// DrawAxisTags draws tags on a panel's axes, for an overlay: the same boxes a +// layer's tags get, placed and pushed apart the same way. It covers what is +// under it rather than hiding tick labels, because an overlay draws after the +// axes have been painted. +func DrawAxisTags(b ir.Backend, th theme.Theme, area ir.Rect, sc TagScales, tags []geom.AxisTag) { + drawTags(b, th, placeTags(b, th, area, sc, tags)) +} diff --git a/retrain_test.go b/retrain_test.go new file mode 100644 index 0000000..6a58844 --- /dev/null +++ b/retrain_test.go @@ -0,0 +1,147 @@ +package figure_test + +import ( + "io" + "testing" + + "github.com/timzifer/figure" + "github.com/timzifer/figure/data" + "github.com/timzifer/figure/geom" + "github.com/timzifer/figure/palette" + "github.com/timzifer/figure/scale" +) + +// windowed is a stream of ten rows, drawn by a Live, and the two frames +// ADR 0090's probe drew: 0..9 rising to 90, then 20..29 flat at 5. +func windowed(t *testing.T, x, y scale.Scale) (*data.Stream, *figure.Live) { + t.Helper() + st := data.NewStream("x", "y").Window(10) + p := figure.New(figure.Size(400, 300)) + p.X(x).Y(y) + p.Add(geom.Line(st.Source(), geom.X("x"), geom.Y("y"))) + for i := range 10 { + st.Append(float64(i), float64(i*10)) + } + st.Snapshot() + live, err := p.Live(figure.SVGWriter(io.Discard)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { live.Close() }) + if err := live.Draw(); err != nil { + t.Fatal(err) + } + return st, live +} + +func advance(t *testing.T, st *data.Stream, live *figure.Live) { + t.Helper() + for i := 10; i < 30; i++ { + st.Append(float64(i), 5) + } + st.Snapshot() + if err := live.Draw(); err != nil { + t.Fatal(err) + } +} + +// The window moves on and the axes follow it: time slides, and the value +// axis describes the rows in the window rather than a peak it has dropped. +func TestAWindowedStreamsAxesDescribeTheWindow(t *testing.T) { + x, y := scale.Linear(), scale.Linear() + st, live := windowed(t, x, y) + advance(t, st, live) + if lo, hi := x.Domain(); lo != 20 || hi != 29 { + t.Errorf("X is [%v, %v], want the window's [20, 29]", lo, hi) + } + // A flat window is padded to a domain a reader can see a line in; what + // matters is that the peak of 90 it dropped is gone. + if lo, hi := y.Domain(); lo > 5 || hi < 5 || hi-lo > 1 { + t.Errorf("Y is [%v, %v], want the window's flat 5", lo, hi) + } +} + +// A zoom is a pinned domain, and a pinned domain is kept. +func TestAZoomSurvivesTheNextFrame(t *testing.T) { + x, y := scale.Linear(), scale.Linear() + st, live := windowed(t, x, y) + x.(scale.Zoomer).SetDomain(22, 25) + advance(t, st, live) + if lo, hi := x.Domain(); lo != 22 || hi != 25 { + t.Errorf("X is [%v, %v], want the zoom's [22, 25]", lo, hi) + } +} + +// HighWater keeps the old behaviour on purpose. +func TestAHighWaterAxisKeepsItsExtent(t *testing.T) { + x, y := scale.Linear(), scale.Linear(scale.HighWater()) + st, live := windowed(t, x, y) + advance(t, st, live) + if lo, hi := y.Domain(); lo != 0 || hi != 90 { + t.Errorf("a high-water Y is [%v, %v], want the most it has been, [0, 90]", lo, hi) + } + if lo, _ := x.Domain(); lo != 20 { + t.Errorf("X starts at %v; only the high-water axis remembers", lo) + } +} + +// A discovered category keeps its slot, and a series keeps its colour, when +// the rows stop naming it. +func TestNamesAreRememberedAcrossFrames(t *testing.T) { + src := func(names []string, vs []float64) *data.Table { + return figure.NewTable().String("k", names).Float64("v", vs) + } + cat := scale.Ordinal() + colors := scale.Qualitative(palette.OkabeIto) + var layer *data.Table = src([]string{"a", "b", "c"}, []float64{1, 2, 3}) + build := func(tb *data.Table) *figure.Plot { + p := figure.New(figure.Size(400, 300)) + p.X(cat).Y(scale.Linear()) + p.Add(geom.Bar(tb, geom.X("k"), geom.Y("v"), geom.ColorBy("k", colors))) + return p + } + if err := build(layer).Render(figure.SVGWriter(io.Discard)); err != nil { + t.Fatal(err) + } + before := colors.Color(1) + slot := cat.Map(1) + + // The next frame names only "b" and "c", in the other order. + if err := build(src([]string{"c", "b"}, []float64{3, 2})).Render(figure.SVGWriter(io.Discard)); err != nil { + t.Fatal(err) + } + if got := colors.Color(1); got != before { + t.Errorf("series b changed colour from %v to %v", before, got) + } + if got := cat.Map(1); got != slot { + t.Errorf("category b moved from %v to %v", slot, got) + } +} + +// A continuous colour ramp follows the range rule: a heatmap of a stream does +// not keep its colour bar stretched to a peak the window dropped. +func TestAColourRampDescribesTheFrame(t *testing.T) { + ramp := scale.Sequential(palette.Viridis) + st := data.NewStream("x", "y", "v").Window(3) + p := figure.New(figure.Size(400, 300)) + p.X(scale.Linear()).Y(scale.Linear()) + p.Add(geom.Scatter(st.Source(), geom.X("x"), geom.Y("y"), geom.ColorBy("v", ramp))) + for i := range 3 { + st.Append(float64(i), 0, 100) + } + st.Snapshot() + live, err := p.Live(figure.SVGWriter(io.Discard)) + if err != nil { + t.Fatal(err) + } + defer live.Close() + live.Draw() + for i := 3; i < 6; i++ { + st.Append(float64(i), 0, float64(i)) + } + st.Snapshot() + live.Draw() + if lo, hi := ramp.Domain(); lo != 3 || hi != 5 { + t.Errorf("the ramp is [%v, %v], want the window's [3, 5]", lo, hi) + } +} diff --git a/scale/breaks.go b/scale/breaks.go index 2e64f9b..0d950ce 100644 --- a/scale/breaks.go +++ b/scale/breaks.go @@ -343,6 +343,21 @@ func (b *broken) kept() float64 { return (b.hi - b.lo) - (b.cs.width[b.i1] - b.cs.width[b.i0]) } +// past reports the cut v falls in, counting its lower edge but not its upper +// one: its upper edge, whether it is a fold, and false when v falls in none. +// +// The lower edge counts because it is where open time ends. A day tick at the +// Saturday midnight a weekend fold starts on stands on the end of Friday and +// is labelled with Saturday, a day the axis has taken out. +func (b *broken) past(v float64) (hi float64, fold, in bool) { + cs := b.cs + j := b.i0 + sort.Search(b.i1-b.i0, func(k int) bool { return cs.c[b.i0+k].hi > v }) + if j < b.i1 && v >= cs.c[j].lo { + return cs.c[j].hi, cs.c[j].fold, true + } + return 0, false, false +} + // inside reports whether v falls strictly inside one of the active cuts, // where no tick may stand. func (b *broken) inside(v float64) bool { diff --git a/scale/calendar.go b/scale/calendar.go new file mode 100644 index 0000000..70c14d1 --- /dev/null +++ b/scale/calendar.go @@ -0,0 +1,435 @@ +package scale + +import ( + "errors" + "fmt" + "slices" + "time" +) + +// Calendar says when time counts: which stretches of each day are open, in +// which zone, and which day a week begins on. Its closed time is what a time +// axis folds out ([Folds]), and its week start is where the axis starts its +// weeks ([TimeCalendar]). +// +// It is an interface because the calendars people chart do not share a rule: +// a working week, a shift roster, a school's terms, an exchange's sessions, a +// calendar computed in another calendar system. The library ships the parts +// the rule-shaped ones are made of — [Workweek], [Days], the [DayRule]s and the +// wrappers [Closed], [Special] and [StartWeek] — and anything else is a type +// with these three methods, or a function handed to [CalendarFunc]. +// +// The question is asked a day at a time because that is the one granularity +// every calendar answers without translation, and because it leaves the walk +// over days, the zone arithmetic and the merging — the parts that are easy to +// get wrong and identical everywhere — to [Folds]. See +// docs/adr/0086-a-calendar-says-when-time-counts.md. +type Calendar interface { + // Location is the zone the calendar's days and hours are in. + Location() *time.Location + // WeekStart is the day a week begins on. + WeekStart() time.Weekday + // Open appends the stretches of the day that count to dst and returns + // it. day is the day's local midnight in Location. The stretches are + // wall-clock offsets from that midnight, ascending, not overlapping and + // inside [0, 24h]; a closed day appends nothing. + Open(dst []Span, day time.Time) []Span +} + +// Span is a stretch of one day, as wall-clock offsets from its midnight: +// 9h30m to 16h is half past nine to four, on the day the clocks change as on +// any other. +// +// Given to [Workweek], [Days] or [Special], a span whose From is after its To +// runs overnight — 22h to 6h is a night shift — and is split at midnight into +// the evening of one day and the morning of the next. Out of a [Calendar]'s +// Open, every span is within its own day. +type Span struct{ From, To time.Duration } + +// DayRule says whether a day is open. day is its local midnight in the +// calendar's location. A nil rule accepts every day. +type DayRule func(day time.Time) bool + +// Day is one weekday's stretches, for [Days]. +type Day struct { + Weekday time.Weekday + Hours []Span +} + +// ErrCalendar is returned by [Folds] and [Opens] when a calendar answers a +// day with stretches that are out of order, overlap, or leave the day. +var ErrCalendar = errors.New("figure/scale: calendar answered a day with invalid stretches") + +// day is how long a wall-clock day is. A day the clocks change on is 23 or 25 +// hours of elapsed time and still 24 of wall clock, which is the unit [Span] +// is written in. +const day = 24 * time.Hour + +// Workweek is the calendar open on the days rule accepts, for the given +// stretches of each — all day when none are given. +// +// There is no default set of days, because there is no global one: a working +// week is Monday to Friday in one country, Monday to Saturday in another and +// Sunday to Thursday in a third, and the code says which one the chart means: +// +// scale.Workweek(berlin, scale.Weekdays(time.Monday, time.Tuesday, +// time.Wednesday, time.Thursday, time.Friday), +// scale.Span{From: 9 * time.Hour, To: 17 * time.Hour}) +// +// A span from 22h to 6h is a night shift: it belongs to the day the rule +// accepted, and runs into the next whether or not the next is open itself. +// The week starts on Monday; see [StartWeek]. +func Workweek(loc *time.Location, rule DayRule, hours ...Span) Calendar { + if len(hours) == 0 { + hours = []Span{{0, day}} + } + h := slices.Clone(hours) + return &ruled{loc: zone(loc), week: time.Monday, open: func(d time.Time) []Span { + if rule == nil || rule(d) { + return h + } + return nil + }} +} + +// Always is the calendar that never closes: a ward, a data centre, and the +// base a caller closes dates on with [Closed]. +func Always(loc *time.Location) Calendar { return Workweek(loc, nil) } + +// Days is the calendar whose weekdays each have their own stretches — a short +// Saturday, a lunch break on weekdays, an evening session on Thursdays. A +// weekday not named is closed; one named twice has the stretches of both. +func Days(loc *time.Location, days ...Day) Calendar { + var by [7][]Span + for _, d := range days { + by[d.Weekday] = append(by[d.Weekday], d.Hours...) + } + return &ruled{loc: zone(loc), week: time.Monday, open: func(d time.Time) []Span { + return by[d.Weekday()] + }} +} + +// CalendarFunc is the calendar whose days open answers, for a calendar no +// rule describes: a roster, a term list, a calendar computed in another +// calendar system. open appends the stretches of one day to dst exactly as +// [Calendar.Open] does, and is given the same local midnight. The week starts +// on Monday; see [StartWeek]. +func CalendarFunc(loc *time.Location, open func(dst []Span, day time.Time) []Span) Calendar { + return &funcCalendar{loc: zone(loc), week: time.Monday, open: open} +} + +// Closed is cal with the days rule accepts closed: the holidays. +// +// A closed day is closed from midnight to midnight, so a night shift that began +// the evening before stops at midnight. A calendar whose shifts must finish is +// a [CalendarFunc], which knows which shift a morning belongs to. +func Closed(cal Calendar, rule DayRule) Calendar { + return &wrapped{Calendar: cal, week: cal.WeekStart(), open: func(dst []Span, d time.Time) []Span { + if rule == nil || rule(d) { + return dst + } + return cal.Open(dst, d) + }} +} + +// Special is cal with the days rule accepts given these stretches instead: +// an early close, a late start, a Saturday worked. No stretches closes the +// days, as [Closed] does. The stretches replace the whole day and stay inside +// it: a span running past midnight stops there. +func Special(cal Calendar, rule DayRule, hours ...Span) Calendar { + h := slices.Clone(hours) + return &wrapped{Calendar: cal, week: cal.WeekStart(), open: func(dst []Span, d time.Time) []Span { + if rule == nil || rule(d) { + return appendDay(dst, h, nil) + } + return cal.Open(dst, d) + }} +} + +// StartWeek is cal with its weeks beginning on d. +// +// Every constructor here starts the week on Monday, ISO 8601's, and that is a +// default of the calendar rather than of a [Locale]: a locale names the days, +// it does not decide which one a week begins on, and deriving one from the +// other would make two charts in one language disagree about an American +// company's weeks for no reason the code shows. +func StartWeek(cal Calendar, d time.Weekday) Calendar { + return &wrapped{Calendar: cal, week: d, open: cal.Open} +} + +// Weekdays accepts the named weekdays. There is no rule for "the weekdays" +// without naming them: which ones they are is the question. +func Weekdays(days ...time.Weekday) DayRule { + var in [7]bool + for _, d := range days { + in[d] = true + } + return func(d time.Time) bool { return in[d.Weekday()] } +} + +// Dates accepts the named dates: their year, month and day as written, in +// whatever location each was built in. The time of day is ignored, so +// time.Date(2026, 12, 25, 0, 0, 0, 0, time.UTC) is Christmas in any calendar. +func Dates(dates ...time.Time) DayRule { + in := make(map[int64]bool, len(dates)) + for _, t := range dates { + in[civil(t)] = true + } + return func(d time.Time) bool { return in[civil(d)] } +} + +// Between accepts every date from from to to, both included: a term, a +// season, a campaign. Dates are read as [Dates] reads them. +func Between(from, to time.Time) DayRule { + a, z := civil(from), civil(to) + if z < a { + a, z = z, a + } + return func(d time.Time) bool { c := civil(d); return c >= a && c <= z } +} + +// EveryNth accepts every n-th date counted from from, which it accepts: a +// fortnightly Sunday is And(Weekdays(time.Sunday), EveryNth(14, aSunday)), +// the first of a four-on-four-off shift's working days is EveryNth(8, first). +// Days are counted as dates, not as 24-hour intervals, so a daylight saving +// change does not shift the cycle. n < 1 accepts nothing. +func EveryNth(n int, from time.Time) DayRule { + a := civil(from) + return func(d time.Time) bool { + if n < 1 { + return false + } + k := (civil(d) - a) % int64(n) + return k == 0 + } +} + +// And accepts a day every rule accepts. +func And(rules ...DayRule) DayRule { + return func(d time.Time) bool { + for _, r := range rules { + if r != nil && !r(d) { + return false + } + } + return true + } +} + +// Or accepts a day any rule accepts. +func Or(rules ...DayRule) DayRule { + return func(d time.Time) bool { + for _, r := range rules { + if r == nil || r(d) { + return true + } + } + return false + } +} + +// Not accepts a day rule does not. +func Not(rule DayRule) DayRule { + return func(d time.Time) bool { return rule != nil && !rule(d) } +} + +// Folds returns the time cal is closed between from and to, as the folds of a +// time axis: every closed stretch, sorted, with a weekend and the nights on +// either side of it one span. +// +// p.X(scale.Time(scale.TimeCalendar(cal), scale.TimeFold(folds...))) +// +// The days are walked in the calendar's location and each stretch is placed +// on its day's wall clock, which is what keeps an 09:30 open at 09:30 on the +// day the clocks change. The interval is the caller's rather than the axis's +// domain for ADR 0083's reason: a document holds the folds, not the calendar +// that produced them. +func Folds(cal Calendar, from, to time.Time) ([]TimeSpan, error) { + var out []TimeSpan + cursor := from + err := walkOpen(cal, from, to, func(a, z time.Time) { + if a.After(cursor) { + out = append(out, TimeSpan{From: cursor, To: a}) + } + if z.After(cursor) { + cursor = z + } + }) + if err != nil { + return nil, err + } + if to.After(cursor) { + out = append(out, TimeSpan{From: cursor, To: to}) + } + return out, nil +} + +// Opens returns the instant each open stretch of cal between from and to +// begins: the edges [github.com/timzifer/figure/stat.OHLCAt] buckets between, +// so that a period is a session or a shift however long it was. +// +// A stretch is the whole of an uninterrupted open time, so a night shift that +// runs over midnight is one stretch, and so is a working week that is open all +// day from Monday to Friday. A caller who wants days on such a calendar wants +// a fixed width, which [github.com/timzifer/figure/stat.OHLC] already is. +func Opens(cal Calendar, from, to time.Time) ([]time.Time, error) { + var out []time.Time + var last time.Time + started := false + err := walkOpen(cal, from, to, func(a, z time.Time) { + if !started || a.After(last) { + out = append(out, a) + } + started = true + if z.After(last) { + last = z + } + }) + if err != nil { + return nil, err + } + return out, nil +} + +// walkOpen calls fn with every open stretch of cal that overlaps [from, to), +// clipped to it, in order. It asks for the day before from as well, so that a +// stretch a calendar reports as the morning after an overnight shift is found +// even when the interval starts inside it. +func walkOpen(cal Calendar, from, to time.Time, fn func(a, z time.Time)) error { + if !to.After(from) { + return nil + } + loc := zone(cal.Location()) + f, t := from.In(loc), to.In(loc) + y, m, d := f.Date() + var buf []Span + for cur := time.Date(y, m, d-1, 0, 0, 0, 0, loc); cur.Before(t); { + cy, cm, cd := cur.Date() + buf = cal.Open(buf[:0], cur) + if err := validSpans(buf, cur); err != nil { + return err + } + for _, s := range buf { + a, z := wall(cy, cm, cd, s.From, loc), wall(cy, cm, cd, s.To, loc) + if a.Before(from) { + a = from + } + if z.After(to) { + z = to + } + if z.After(a) { + fn(a, z) + } + } + cur = time.Date(cy, cm, cd+1, 0, 0, 0, 0, loc) + } + return nil +} + +func validSpans(ss []Span, d time.Time) error { + prev := time.Duration(-1) + for _, s := range ss { + if s.From < 0 || s.To > day || s.From >= s.To || s.From < prev { + return fmt.Errorf("%w: %s: %v", ErrCalendar, d.Format(time.DateOnly), ss) + } + prev = s.To + } + return nil +} + +// wall is the instant off after midnight on the given date by the wall clock: +// time.Date normalises the hour, so 24h is the next midnight and 9h30m is half +// past nine whatever the clocks did at two. +func wall(y int, m time.Month, d int, off time.Duration, loc *time.Location) time.Time { + return time.Date(y, m, d, 0, 0, 0, int(off), loc) +} + +// ruled is a calendar whose days a function answers with overnight spans +// allowed: the evening half of a span belongs to its day and the morning half +// to the next, so a day's answer is its own evenings and the day before's +// mornings. +type ruled struct { + loc *time.Location + week time.Weekday + open func(day time.Time) []Span +} + +func (r *ruled) Location() *time.Location { return r.loc } +func (r *ruled) WeekStart() time.Weekday { return r.week } +func (r *ruled) Open(dst []Span, d time.Time) []Span { + y, m, dd := d.Date() + prev := r.open(time.Date(y, m, dd-1, 0, 0, 0, 0, r.loc)) + return appendDay(dst, r.open(d), prev) +} + +// appendDay appends one day's stretches from the spans given for it and the +// overnight tails of the spans given for the day before, sorted and merged. +func appendDay(dst []Span, today, yesterday []Span) []Span { + start := len(dst) + for _, s := range yesterday { + if s.From > s.To && s.To > 0 { + dst = append(dst, Span{0, s.To}) + } + } + for _, s := range today { + switch { + case s.From > s.To: + dst = append(dst, Span{s.From, day}) + case s.From < s.To: + dst = append(dst, Span{max(s.From, 0), min(s.To, day)}) + } + } + ds := dst[start:] + slices.SortFunc(ds, func(a, b Span) int { return int(a.From - b.From) }) + out := ds[:0] + for _, s := range ds { + if s.From >= s.To { + continue + } + if n := len(out); n > 0 && s.From <= out[n-1].To { + out[n-1].To = max(out[n-1].To, s.To) + continue + } + out = append(out, s) + } + return dst[:start+len(out)] +} + +type funcCalendar struct { + loc *time.Location + week time.Weekday + open func(dst []Span, day time.Time) []Span +} + +func (c *funcCalendar) Location() *time.Location { return c.loc } +func (c *funcCalendar) WeekStart() time.Weekday { return c.week } +func (c *funcCalendar) Open(dst []Span, d time.Time) []Span { + if c.open == nil { + return dst + } + return c.open(dst, d) +} + +// wrapped is a calendar with its days or its week start changed. +type wrapped struct { + Calendar + week time.Weekday + open func(dst []Span, day time.Time) []Span +} + +func (w *wrapped) WeekStart() time.Weekday { return w.week } +func (w *wrapped) Open(dst []Span, d time.Time) []Span { return w.open(dst, d) } + +// civil is a date's day number: its year, month and day as written, counted +// from the epoch. Two times on the same date in different zones share it. +func civil(t time.Time) int64 { + y, m, d := t.Date() + return time.Date(y, m, d, 0, 0, 0, 0, time.UTC).Unix() / 86400 +} + +func zone(loc *time.Location) *time.Location { + if loc == nil { + return time.UTC + } + return loc +} diff --git a/scale/calendar_test.go b/scale/calendar_test.go new file mode 100644 index 0000000..1649065 --- /dev/null +++ b/scale/calendar_test.go @@ -0,0 +1,376 @@ +package scale_test + +import ( + "errors" + "testing" + "time" + + "github.com/timzifer/figure/scale" +) + +func mustZone(t *testing.T, name string) *time.Location { + t.Helper() + loc, err := time.LoadLocation(name) + if err != nil { + t.Skipf("no time zone data for %s: %v", name, err) + } + return loc +} + +func hm(h, m int) time.Duration { return time.Duration(h)*time.Hour + time.Duration(m)*time.Minute } + +var monFri = scale.Weekdays(time.Monday, time.Tuesday, time.Wednesday, time.Thursday, time.Friday) + +func folds(t *testing.T, cal scale.Calendar, from, to time.Time) []scale.TimeSpan { + t.Helper() + fs, err := scale.Folds(cal, from, to) + if err != nil { + t.Fatalf("Folds: %v", err) + } + return fs +} + +// A working week open all day folds exactly its weekends, from Saturday's +// midnight to Monday's. +func TestAWorkweekFoldsItsWeekends(t *testing.T) { + cal := scale.Workweek(time.UTC, monFri) + from := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) // a Monday + got := folds(t, cal, from, from.AddDate(0, 0, 14)) + want := []scale.TimeSpan{ + {From: time.Date(2026, 6, 6, 0, 0, 0, 0, time.UTC), To: time.Date(2026, 6, 8, 0, 0, 0, 0, time.UTC)}, + {From: time.Date(2026, 6, 13, 0, 0, 0, 0, time.UTC), To: time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC)}, + } + if len(got) != len(want) { + t.Fatalf("got %v, want %v", got, want) + } + for i := range want { + if !got[i].From.Equal(want[i].From) || !got[i].To.Equal(want[i].To) { + t.Errorf("fold %d: %v – %v, want %v – %v", i, got[i].From, got[i].To, want[i].From, want[i].To) + } + } +} + +// The week a chart means is the one it names: Sunday to Thursday folds Friday +// and Saturday, and nothing else. +func TestASundayToThursdayWeekFoldsFridayAndSaturday(t *testing.T) { + loc := mustZone(t, "Asia/Jerusalem") + cal := scale.Workweek(loc, scale.Weekdays(time.Sunday, time.Monday, time.Tuesday, time.Wednesday, time.Thursday)) + from := time.Date(2026, 6, 7, 0, 0, 0, 0, loc) // a Sunday + got := folds(t, cal, from, from.AddDate(0, 0, 7)) + if len(got) != 1 || got[0].From.Weekday() != time.Friday || got[0].To.Weekday() != time.Sunday || + got[0].To.Sub(got[0].From) != 48*time.Hour { + t.Errorf("got %v, want Friday 00:00 to Sunday 00:00", got) + } +} + +// Hours are read off the wall clock, so an 09:30 open is at 09:30 on the +// Monday after each daylight saving change, and the nights between are 17½ +// hours of wall clock however much elapsed time that is. +func TestSessionsKeepTheirWallClockAcrossBothChanges(t *testing.T) { + loc := mustZone(t, "America/New_York") + cal := scale.Workweek(loc, monFri, scale.Span{From: hm(9, 30), To: hm(16, 0)}) + for _, from := range []time.Time{ + time.Date(2026, 3, 5, 0, 0, 0, 0, loc), // the week clocks go forward, 8 March + time.Date(2026, 10, 29, 0, 0, 0, 0, loc), // and back, 1 November + } { + opens, err := scale.Opens(cal, from, from.AddDate(0, 0, 8)) + if err != nil { + t.Fatal(err) + } + if len(opens) != 6 { + t.Fatalf("%d opens from %v, want six sessions", len(opens), from) + } + for _, o := range opens { + if h, m, _ := o.In(loc).Clock(); h != 9 || m != 30 { + t.Errorf("a session opens at %v, want 09:30 local", o.In(loc)) + } + } + for _, f := range folds(t, cal, from, from.AddDate(0, 0, 8)) { + if f.From.Equal(from) { + continue // the interval opens on a closed morning + } + if h, m, _ := f.From.In(loc).Clock(); h != 16 || m != 0 { + t.Errorf("a fold starts at %v, want 16:00 local", f.From.In(loc)) + } + } + } +} + +// A night shift runs over midnight: it is one open stretch, not two, and the +// morning it runs into is open even on a day the rule closes. +func TestANightShiftRunsOverMidnight(t *testing.T) { + cal := scale.Workweek(time.UTC, scale.Weekdays(time.Friday), scale.Span{From: 22 * time.Hour, To: 6 * time.Hour}) + from := time.Date(2026, 6, 5, 0, 0, 0, 0, time.UTC) // a Friday + to := from.AddDate(0, 0, 2) + opens, err := scale.Opens(cal, from, to) + if err != nil { + t.Fatal(err) + } + if len(opens) != 1 || !opens[0].Equal(from.Add(22*time.Hour)) { + t.Fatalf("opens %v, want one at Friday 22:00", opens) + } + got := folds(t, cal, from, to) + if len(got) != 2 || !got[0].To.Equal(from.Add(22*time.Hour)) || !got[1].From.Equal(from.Add(30*time.Hour)) { + t.Errorf("folds %v, want everything but Friday 22:00 to Saturday 06:00", got) + } + + // The interval may start inside the shift; the morning half is still found. + late := from.Add(26 * time.Hour) + got = folds(t, cal, late, to) + if len(got) != 1 || !got[0].From.Equal(from.Add(30*time.Hour)) { + t.Errorf("from 02:00 on Saturday: folds %v, want one from 06:00", got) + } +} + +// Each weekday its own hours: a short Saturday and a lunch break. +func TestDaysGiveEachWeekdayItsOwnHours(t *testing.T) { + cal := scale.Days(time.UTC, + scale.Day{Weekday: time.Monday, Hours: []scale.Span{{From: hm(8, 0), To: hm(12, 0)}, {From: hm(13, 0), To: hm(17, 0)}}}, + scale.Day{Weekday: time.Saturday, Hours: []scale.Span{{From: hm(8, 0), To: hm(12, 0)}}}) + mon := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) + got := cal.Open(nil, mon) + if len(got) != 2 || got[1].From != hm(13, 0) { + t.Errorf("Monday %v, want two stretches with a lunch break", got) + } + if got := cal.Open(nil, mon.AddDate(0, 0, 1)); len(got) != 0 { + t.Errorf("Tuesday %v, want closed", got) + } + if got := cal.Open(nil, mon.AddDate(0, 0, 5)); len(got) != 1 || got[0].To != hm(12, 0) { + t.Errorf("Saturday %v, want the morning", got) + } +} + +// The rules compose, and the wrappers apply to any calendar. +func TestRulesAndWrappersCompose(t *testing.T) { + first := time.Date(2026, 6, 6, 0, 0, 0, 0, time.UTC) // a Saturday + christmas := time.Date(2026, 12, 25, 12, 0, 0, 0, time.FixedZone("elsewhere", 5*3600)) + rule := scale.And( + scale.Weekdays(time.Monday, time.Tuesday, time.Wednesday, time.Thursday, time.Friday, time.Saturday), + scale.Not(scale.And(scale.Weekdays(time.Saturday), scale.EveryNth(14, first)))) + cal := scale.Closed(scale.Workweek(time.UTC, rule), scale.Dates(christmas)) + cal = scale.Special(cal, scale.Dates(time.Date(2026, 12, 24, 0, 0, 0, 0, time.UTC)), scale.Span{From: 6 * time.Hour, To: 14 * time.Hour}) + cal = scale.StartWeek(cal, time.Sunday) + + open := func(y int, m time.Month, d int) []scale.Span { + return cal.Open(nil, time.Date(y, m, d, 0, 0, 0, 0, time.UTC)) + } + if len(open(2026, 6, 6)) != 0 { + t.Error("the first fortnightly Saturday is open") + } + if len(open(2026, 6, 13)) != 1 { + t.Error("the Saturday between is closed") + } + if len(open(2026, 6, 20)) != 0 { + t.Error("the next fortnightly Saturday is open") + } + if len(open(2026, 12, 25)) != 0 { + t.Error("Christmas, named in another zone, is open") + } + if s := open(2026, 12, 24); len(s) != 1 || s[0].To != 14*time.Hour { + t.Errorf("Christmas Eve %v, want the short day", s) + } + if cal.WeekStart() != time.Sunday || cal.Location() != time.UTC { + t.Errorf("week start %v, location %v", cal.WeekStart(), cal.Location()) + } + if !scale.Between(time.Date(2026, 1, 5, 0, 0, 0, 0, time.UTC), time.Date(2026, 1, 9, 0, 0, 0, 0, time.UTC))(time.Date(2026, 1, 9, 0, 0, 0, 0, time.UTC)) { + t.Error("Between leaves out its last date") + } + if !scale.Or(scale.Weekdays(time.Monday), scale.Weekdays(time.Sunday))(first.AddDate(0, 0, 1)) { + t.Error("Or refuses a Sunday") + } +} + +// A calendar the library could not anticipate is a function: here, a roster +// that is open on the days it lists. +func TestACallersCalendarIsAFunction(t *testing.T) { + roster := map[string]scale.Span{ + "2026-06-02": {From: 6 * time.Hour, To: 14 * time.Hour}, + "2026-06-04": {From: 14 * time.Hour, To: 22 * time.Hour}, + } + cal := scale.CalendarFunc(time.UTC, func(dst []scale.Span, d time.Time) []scale.Span { + if s, ok := roster[d.Format(time.DateOnly)]; ok { + return append(dst, s) + } + return dst + }) + from := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) + opens, err := scale.Opens(cal, from, from.AddDate(0, 0, 7)) + if err != nil { + t.Fatal(err) + } + if len(opens) != 2 || opens[1].Hour() != 14 { + t.Errorf("opens %v, want the two rostered shifts", opens) + } +} + +// A calendar that cannot say when a day is open is a bug in the calendar, and +// it is reported rather than drawn. +func TestAnInvalidAnswerIsAnError(t *testing.T) { + bad := scale.CalendarFunc(time.UTC, func(dst []scale.Span, _ time.Time) []scale.Span { + return append(dst, scale.Span{From: 10 * time.Hour, To: 9 * time.Hour}) + }) + from := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) + if _, err := scale.Folds(bad, from, from.AddDate(0, 0, 1)); !errors.Is(err, scale.ErrCalendar) { + t.Errorf("err = %v, want ErrCalendar", err) + } +} + +func TestAlwaysNeverFolds(t *testing.T) { + from := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) + if got := folds(t, scale.Always(time.UTC), from, from.AddDate(0, 1, 0)); len(got) != 0 { + t.Errorf("folds %v, want none", got) + } +} + +// weekTicks trains a time axis over ten weeks and asks for about ten ticks, +// which is where the tick search chooses a week. +func weekTicks(t *testing.T, s scale.Scale, loc *time.Location) []time.Time { + t.Helper() + from := time.Date(2026, 2, 18, 12, 0, 0, 0, loc) + s.Train(scale.ValueOf(s, from), scale.ValueOf(s, from.AddDate(0, 0, 70))) + s.SetRange(0, 1000) + var out []time.Time + for _, tk := range s.Ticks(scale.TickRequest{Want: 11}) { + out = append(out, scale.InstantOf(s, tk.Value).In(loc)) + } + if len(out) < 5 { + t.Fatalf("%d ticks, want a weekly axis", len(out)) + } + return out +} + +func TestWeeksStartOnMondayUnlessToldOtherwise(t *testing.T) { + for _, tk := range weekTicks(t, scale.Time(), time.UTC) { + if tk.Weekday() != time.Monday || tk.Hour() != 0 { + t.Errorf("a default week tick at %v, want Monday midnight", tk) + } + } + for _, tk := range weekTicks(t, scale.Time(scale.WeekStart(time.Sunday)), time.UTC) { + if tk.Weekday() != time.Sunday { + t.Errorf("a Sunday-start week tick at %v", tk) + } + } +} + +// A calendar's week start and zone reach the axis, and an explicit option +// wins over the calendar whichever order they are given in. +func TestTimeCalendarGivesTheAxisItsWeekAndZone(t *testing.T) { + loc := mustZone(t, "Asia/Jerusalem") + cal := scale.StartWeek(scale.Workweek(loc, nil), time.Sunday) + for _, tk := range weekTicks(t, scale.Time(scale.TimeCalendar(cal)), loc) { + if tk.Weekday() != time.Sunday || tk.Hour() != 0 { + t.Errorf("a calendar week tick at %v, want Sunday midnight in Jerusalem", tk) + } + } + for _, opts := range [][]scale.TimeOption{ + {scale.WeekStart(time.Saturday), scale.TimeCalendar(cal)}, + {scale.TimeCalendar(cal), scale.WeekStart(time.Saturday)}, + } { + for _, tk := range weekTicks(t, scale.Time(opts...), loc) { + if tk.Weekday() != time.Saturday { + t.Errorf("an explicit Saturday start lost to the calendar: %v", tk) + } + } + } +} + +// Weeks are walked by date, so a week tick stays at local midnight across a +// daylight saving change instead of drifting to 23:00 or 01:00. +func TestAWeekTickStaysAtMidnightAcrossAClockChange(t *testing.T) { + loc := mustZone(t, "Europe/Berlin") + for _, tk := range weekTicks(t, scale.Time(scale.In(loc)), loc) { + if tk.Hour() != 0 || tk.Weekday() != time.Monday { + t.Errorf("a week tick at %v, want Monday midnight local", tk) + } + } +} + +func TestAWeekStartIsWrittenDown(t *testing.T) { + s := scale.Time(scale.WeekStart(time.Sunday)) + d := s.(scale.Describer).Describe() + if d.WeekStart != "sunday" { + t.Fatalf("Describe wrote week start %q", d.WeekStart) + } + back, err := scale.FromDesc(d) + if err != nil { + t.Fatal(err) + } + if again := back.(scale.Describer).Describe(); again.WeekStart != "sunday" { + t.Errorf("read back as %q", again.WeekStart) + } + if d := scale.Time().(scale.Describer).Describe(); d.WeekStart != "" { + t.Errorf("a Monday week is written as %q, want it left out", d.WeekStart) + } +} + +// With the nights folded every midnight is inside a fold, and a day tick +// moves to the morning's open with that morning's date, instead of the axis +// having no dates at all. +func TestADayTickInAFoldMovesToTheNextOpen(t *testing.T) { + cal := scale.Workweek(time.UTC, monFri, scale.Span{From: hm(9, 30), To: hm(16, 0)}) + from := time.Date(2026, 6, 1, 9, 30, 0, 0, time.UTC) // Monday's open + to := time.Date(2026, 6, 12, 16, 0, 0, 0, time.UTC) // the next Friday's close + fs, err := scale.Folds(cal, from, to) + if err != nil { + t.Fatal(err) + } + s := scale.Time(scale.TimeCalendar(cal), scale.TimeFold(fs...)) + s.Train(scale.ValueOf(s, from), scale.ValueOf(s, to)) + s.SetRange(0, 1000) + s.(scale.Breaker).SetBreakGap(8, 4) + + // Four ticks over the 65 open hours is where the search chooses a day. + ticks := s.Ticks(scale.TickRequest{Want: 4}) + if len(ticks) < 4 { + t.Fatalf("%d ticks on a folded fortnight: %v", len(ticks), ticks) + } + seen := map[string]bool{} + for _, tk := range ticks { + at := scale.InstantOf(s, tk.Value).UTC() + if h, m, _ := at.Clock(); h != 9 || m != 30 { + t.Errorf("a day tick at %v, want a morning open", at) + } + if at.Weekday() == time.Saturday || at.Weekday() == time.Sunday { + t.Errorf("a tick on a closed %v", at.Weekday()) + } + if want := at.Format("Jan 2"); tk.Label != want { + t.Errorf("a tick at %v is labelled %q, want %q — the date it stands on", at, tk.Label, want) + } + if seen[tk.Label] { + t.Errorf("two ticks labelled %q", tk.Label) + } + seen[tk.Label] = true + } +} + +// A value read off an axis carries the precision a value between ticks needs: +// two decimals more than a linear axis's ticks, and the time of day on a time +// axis when the instant is not a midnight. +func TestAValueLabelIsFinerThanATickLabel(t *testing.T) { + l := scale.Linear() + l.Train(0, 100) + l.SetRange(0, 400) + if got := scale.ValueLabelOf(l, 94.37); got != "94.37" { + t.Errorf("a value between whole-number ticks reads %q, want 94.37", got) + } + ts := scale.Time() + day := time.Date(2026, 6, 3, 0, 0, 0, 0, time.UTC) + ts.Train(scale.ValueOf(ts, day), scale.ValueOf(ts, day.AddDate(0, 0, 10))) + if got := scale.ValueLabelOf(ts, scale.ValueOf(ts, day)); got != "Jun 3" { + t.Errorf("a midnight reads %q, want the date", got) + } + if got := scale.ValueLabelOf(ts, scale.ValueOf(ts, day.Add(14*time.Hour+5*time.Minute))); got != "Jun 3 14:05" { + t.Errorf("an afternoon reads %q, want the date and the time", got) + } +} + +// A value that needs more decimals than two beyond the ticks is rounded to +// two beyond them, not written out in full. +func TestAValueLabelStopsTwoDecimalsPastTheTicks(t *testing.T) { + l := scale.Linear(scale.Nice()) + l.Train(89.98, 98.1) + l.SetRange(300, 0) + if got := scale.ValueLabelOf(l, 92.4028); got != "92.40" { + t.Errorf("92.4028 on an axis ticked in twos reads %q, want 92.40", got) + } +} diff --git a/scale/describe.go b/scale/describe.go index e99f4ff..d2af389 100644 --- a/scale/describe.go +++ b/scale/describe.go @@ -2,6 +2,7 @@ package scale import ( "fmt" + "strings" "time" "github.com/timzifer/figure/ir" @@ -54,8 +55,12 @@ type Desc struct { // or [SymLogDomain], or afterwards by [Zoomer.SetDomain]. Fixed bool - // Nice and Zero are the linear and log framing options. - Nice, Zero bool + // Nice and Zero are the linear and log framing options, and Fit whether + // the axis fits the rows its panel's other axis shows. See [FitView]. + Nice, Zero, Fit bool + // HighWater reports a linear axis that keeps its trained extent across + // renders. See [HighWater]. + HighWater bool // Reverse reports a positional scale drawn the other way round: the low // end of the domain at the high end of the range. It is a linear scale's // [Reverse], and it is not the colour reversal in [ColorDesc] — that one @@ -95,6 +100,9 @@ type Desc struct { // Location is a time scale's zone, by IANA name. Location string + // WeekStart is the weekday a time scale's weeks begin on, by its English + // name in lower case — "sunday" — and empty for Monday, the default. + WeekStart string // Formatted reports a scale carrying a formatter that a Desc cannot hold. Formatted bool @@ -156,6 +164,12 @@ func FromDesc(d Desc) (Scale, error) { if d.Reverse { opts = append(opts, Reverse()) } + if d.Fit { + opts = append(opts, FitView()) + } + if d.HighWater { + opts = append(opts, HighWater()) + } if d.Fixed { opts = append(opts, Domain(d.Min, d.Max)) } @@ -177,6 +191,9 @@ func FromDesc(d Desc) (Scale, error) { if d.Nice { opts = append(opts, LogNice()) } + if d.Fit { + opts = append(opts, LogFitView()) + } if d.Fixed { opts = append(opts, LogDomain(d.Min, d.Max)) } @@ -236,6 +253,13 @@ func FromDesc(d Desc) (Scale, error) { if d.Layout != "" { opts = append(opts, TimeLayout(d.Layout)) } + if d.WeekStart != "" { + wd, ok := weekdayNamed(d.WeekStart) + if !ok { + return nil, fmt.Errorf("figure/scale: week start %q is not a weekday", d.WeekStart) + } + opts = append(opts, WeekStart(wd)) + } opts = append(opts, func(t *timeScale) { t.locale = localeNamed(d.Locale) t.cuts = t.cuts.with(false, d.Cuts...).with(true, d.Folds...) @@ -261,7 +285,7 @@ func FromDesc(d Desc) (Scale, error) { func (l *linear) Describe() Desc { d := Desc{ - Kind: KindLinear, Nice: l.nice, Zero: l.zero, Reverse: l.reverse, Fixed: l.fixed, + Kind: KindLinear, Nice: l.nice, Zero: l.zero, Fit: l.fit, HighWater: l.highWater, Reverse: l.reverse, Fixed: l.fixed, Formatted: l.format != nil, Format: l.numFormat.spec, Locale: localeName(l.loc), } if l.fixed { @@ -276,7 +300,7 @@ func (l *linear) Describe() Desc { func (l *logScale) Describe() Desc { d := Desc{ - Kind: KindLog, Base: l.base, Nice: l.nice, Fixed: l.fixed, + Kind: KindLog, Base: l.base, Nice: l.nice, Fit: l.fit, Fixed: l.fixed, MinorTicks: l.minor, Formatted: l.format != nil, Format: l.numFormat.spec, Locale: localeName(l.loc), } @@ -306,6 +330,9 @@ func (s *timeScale) Describe() Desc { if s.loc != nil { d.Location = s.loc.String() } + if s.week != time.Monday { + d.WeekStart = strings.ToLower(s.week.String()) + } if s.fixed { d.Min, d.Max = s.dmin, s.dmax } @@ -561,3 +588,14 @@ func (c *colorScale) DescribeColor() ColorDesc { } var _ ColorDescriber = (*colorScale)(nil) + +// weekdayNamed reads a weekday back from the name [timeScale.Describe] writes, +// in any case. +func weekdayNamed(name string) (time.Weekday, bool) { + for d := time.Sunday; d <= time.Saturday; d++ { + if strings.EqualFold(d.String(), name) { + return d, true + } + } + return 0, false +} diff --git a/scale/fit.go b/scale/fit.go new file mode 100644 index 0000000..b3e777a --- /dev/null +++ b/scale/fit.go @@ -0,0 +1,67 @@ +package scale + +// FitView trains a linear axis on the rows its panel's other axis shows, +// rather than on every row: the price axis of a chart zoomed into one week +// fits that week, and a volume track under it fits the bars in view. +// +// Nothing else about the scale changes — [Nice], [Zero] and breaks apply to +// the fitted extent as to any other. It takes effect when the other axis's +// domain is pinned, by a zoom, a pan or a caller's [Zoomer.SetDomain]; while +// the other axis is trained from the data every row is in view anyway. A +// fitting axis is never steered by a zoom itself: it is derived, every frame, +// from the axis that is. See docs/adr/0089-a-value-axis-fits-what-its-time-axis-shows.md. +func FitView() LinearOption { return func(l *linear) { l.fit = true } } + +// LogFitView is [FitView] for a log axis — a price axis is as often +// logarithmic as not. +func LogFitView() LogOption { return func(l *logScale) { l.fit = true } } + +// Fitter is implemented by a scale that can be told to fit the rows its +// panel's other axis shows. It is an optional interface, like [Zoomer]: a +// scale that does not implement it trains on every row. +type Fitter interface { + // FitsView reports whether the axis fits the other axis's view. + FitsView() bool +} + +// Pinner is implemented by a scale that can report whether its domain is +// pinned — fixed at construction, or set by [Zoomer.SetDomain] — rather than +// trained. A fitting axis is fitted to the other axis's domain exactly when +// that domain is pinned, which is the question this asks. It is an interface +// of its own rather than a method on Zoomer, because a published interface +// does not grow (ADR 0084). +type Pinner interface { + // Pinned reports whether the domain is pinned. + Pinned() bool +} + +// FitsView reports whether s is a scale told to fit its panel's other axis. +func FitsView(s Scale) bool { + f, ok := s.(Fitter) + return ok && f.FitsView() +} + +// Pinned reports whether s's domain is pinned. A scale that cannot say is not. +func Pinned(s Scale) bool { + p, ok := s.(Pinner) + return ok && p.Pinned() +} + +func (l *linear) FitsView() bool { return l.fit } +func (l *logScale) FitsView() bool { return l.fit } + +func (l *linear) Pinned() bool { return l.fixed } +func (l *logScale) Pinned() bool { return l.fixed } +func (s *symlogScale) Pinned() bool { return s.fixed } +func (s *timeScale) Pinned() bool { return s.fixed } +func (s *probScale) Pinned() bool { return s.fixed } + +var ( + _ Fitter = (*linear)(nil) + _ Fitter = (*logScale)(nil) + _ Pinner = (*linear)(nil) + _ Pinner = (*logScale)(nil) + _ Pinner = (*symlogScale)(nil) + _ Pinner = (*timeScale)(nil) + _ Pinner = (*probScale)(nil) +) diff --git a/scale/linear.go b/scale/linear.go index ab906d5..21d040b 100644 --- a/scale/linear.go +++ b/scale/linear.go @@ -111,7 +111,10 @@ type linear struct { fixed bool pinned bool reverse bool - format func(float64) string + fit bool // see [FitView] + // highWater keeps the trained extent across renders. See [HighWater]. + highWater bool + format func(float64) string // ticks is the sequence [TickValues] pinned, ascending, or nil for an axis // that chooses its own. It is written once at construction and never // afterwards, which is why Clone and Snapshot share it rather than copying diff --git a/scale/log.go b/scale/log.go index 8158959..3c0b302 100644 --- a/scale/log.go +++ b/scale/log.go @@ -67,6 +67,7 @@ type logScale struct { domainRange base float64 nice bool + fit bool // see [LogFitView] fixed bool pinned bool minor bool diff --git a/scale/retrain.go b/scale/retrain.go new file mode 100644 index 0000000..f5f3ef3 --- /dev/null +++ b/scale/retrain.go @@ -0,0 +1,110 @@ +package scale + +// Retrainer is implemented by a scale whose trained range can be forgotten, +// so that the next training describes only what it is shown. +// +// Render calls it once per scale at the start of every render, before any +// layer trains: a trained domain only widens, and a plot keeps its scales +// between renders, so without it an axis keeps every row it has ever been +// shown — a windowed stream's time axis kept the history the window had +// dropped. Within a render [Scale.Train] still accumulates, which is what lets +// several layers share an axis. +// +// A pinned domain is not trained and is not forgotten, so a zoom, a pan or a +// fixed domain survives. A scale that discovers a set of names — an ordinal +// axis, a qualitative or named colour scale — does not implement it: a +// category's slot and a series' colour are identity, and rediscovering them +// every frame would move them. See +// docs/adr/0090-an-axis-describes-the-frame-it-is-drawn-in.md. +type Retrainer interface { + // Retrain forgets the trained range, keeping everything else. + Retrain() +} + +// Retrain forgets s's trained range if s can forget one, and does nothing +// otherwise. +func Retrain(s any) { + if r, ok := s.(Retrainer); ok { + r.Retrain() + } +} + +// HighWater keeps a linear axis's extent across renders, the way every axis +// kept it before [Retrainer]: an axis that shows the most this has ever been +// since the chart opened — the worst load on a monitor, a counter that must not +// appear to fall. [Zoomer.Autoscale] releases it. It is an option rather than +// the default because it answers the rarer question, and a chart that answers +// it should say so. +func HighWater() LinearOption { return func(l *linear) { l.highWater = true } } + +// forget drops the trained range and keeps the range the scale maps into. +func (d *domainRange) forget() { d.dmin, d.dmax, d.trained = 0, 0, false } + +func (l *linear) Retrain() { + if l.fixed || l.highWater { + return + } + l.forget() + l.hasNiced = false +} + +func (l *logScale) Retrain() { + if !l.fixed { + l.forget() + } +} + +func (s *symlogScale) Retrain() { + if !s.fixed { + s.forget() + } +} + +func (s *timeScale) Retrain() { + if !s.fixed { + s.forget() + } +} + +func (s *probScale) Retrain() { + if !s.fixed { + s.forget() + } +} + +func (c *colorScale) Retrain() { + if !c.fixed { + c.forget() + } +} + +// Retrain on a classed scale forgets its base range and, for a quantile scale, +// the sample its boundaries are cut from; a threshold scale's given boundaries +// are its classes and stay. +func (c *classed) Retrain() { + c.base.Retrain() + c.sample, c.cuts = c.sample[:0], c.cuts[:0] +} + +// A bivariate scale forgets both readings' ranges and keeps its classes. +func (s *vsup) Retrain() { s.classed.Retrain(); s.second.forget() } +func (s *matrix) Retrain() { s.classed.Retrain(); s.second.forget() } + +func (s *sizeScale) Retrain() { + if !s.fixed { + s.forget() + } +} + +var ( + _ Retrainer = (*linear)(nil) + _ Retrainer = (*logScale)(nil) + _ Retrainer = (*symlogScale)(nil) + _ Retrainer = (*timeScale)(nil) + _ Retrainer = (*probScale)(nil) + _ Retrainer = (*colorScale)(nil) + _ Retrainer = (*classed)(nil) + _ Retrainer = (*vsup)(nil) + _ Retrainer = (*matrix)(nil) + _ Retrainer = (*sizeScale)(nil) +) diff --git a/scale/time.go b/scale/time.go index 2bf8477..01c6cca 100644 --- a/scale/time.go +++ b/scale/time.go @@ -15,7 +15,37 @@ type TimeOption func(*timeScale) func In(loc *time.Location) TimeOption { return func(t *timeScale) { if loc != nil { - t.loc = loc + t.loc, t.locSet = loc, true + } + } +} + +// WeekStart sets the day a week tick falls on: weeks are walked from local +// midnight on that weekday. The default is Monday, ISO 8601's — which is what +// every weekly axis drew before this option existed, as an accident of Go +// truncating from its zero time, a Monday, rather than as a decision. +func WeekStart(d time.Weekday) TimeOption { + return func(t *timeScale) { t.week, t.weekSet = d, true } +} + +// TimeCalendar applies a calendar's conventions to a time axis: its week start, +// and its location unless [In] names another. It does not fold anything — the +// closed time is [Folds] of the calendar over an interval the caller names, +// given to [TimeFold]: +// +// p.X(scale.Time(scale.TimeCalendar(cal), scale.TimeFold(folds...))) +// +// See docs/adr/0086-a-calendar-says-when-time-counts.md. +func TimeCalendar(cal Calendar) TimeOption { + return func(t *timeScale) { + if cal == nil { + return + } + if !t.weekSet { + t.week = cal.WeekStart() + } + if !t.locSet { + t.loc = zone(cal.Location()) } } } @@ -55,7 +85,7 @@ func TimeFormat(fn func(t time.Time, unit time.Duration) string) TimeOption { // Scale interface as every other scale and geoms need no special case. Use // [Nanos] and [FromNanos] to convert. func Time(opts ...TimeOption) Scale { - t := &timeScale{loc: time.UTC} + t := &timeScale{loc: time.UTC, week: time.Monday} for _, o := range opts { o(t) } @@ -121,9 +151,14 @@ func InstantOf(s Scale, v float64) time.Time { type timeScale struct { domainRange loc *time.Location + locSet bool fixed bool - origin int64 // the instant the domain is measured from, in Unix nanoseconds - format func(time.Time, time.Duration) string + // week is the day a week tick falls on, and weekSet whether [WeekStart] + // named it rather than a calendar. + week time.Weekday + weekSet bool + origin int64 // the instant the domain is measured from, in Unix nanoseconds + format func(time.Time, time.Duration) string // layout is the declarative half of the same choice and locale the // language the names in it are written in. See [TimeLayout] and [Locale]. @@ -203,6 +238,10 @@ func (s *timeScale) Invert(pos float32) float64 { return lo + float64((pos-rlo)/(rhi-rlo))*(hi-lo) } +// week is the week tick's nominal step. It is walked by date rather than by +// this duration; see [timeScale.walk]. +const week = 7 * 24 * time.Hour + // timeUnit is a candidate tick spacing, with the label format that suits it. type timeUnit struct { every time.Duration // 0 for calendar units handled specially @@ -232,7 +271,7 @@ var timeUnits = []timeUnit{ {every: 12 * time.Hour, layout: "Jan 2 15:04"}, {every: 24 * time.Hour, layout: "Jan 2"}, {every: 48 * time.Hour, layout: "Jan 2"}, - {every: 7 * 24 * time.Hour, layout: "Jan 2"}, + {every: week, layout: "Jan 2"}, {months: 1, layout: "Jan 2006"}, {months: 3, layout: "Jan 2006"}, {months: 6, layout: "Jan 2006"}, @@ -280,6 +319,9 @@ func (s *timeScale) Ticks(req TickRequest) []Tick { // axis still shows, walked through each kept piece. func (s *timeScale) brokenTicks(b *broken, want int) []Tick { u := s.pick(time.Duration(b.kept()), want) + if u.calendar() { + return s.movedTicks(b, u) + } var out []Tick b.pieces(func(a, z float64) { for _, t := range s.walk(s.Instant(a).In(s.loc), s.Instant(z).In(s.loc), u) { @@ -293,6 +335,46 @@ func (s *timeScale) brokenTicks(b *broken, want int) []Tick { return out } +// movedTicks is the tick walk of a broken axis whose step names a day rather +// than an instant — a day, a week, a month, a year. Walked per piece, such a +// step loses every tick that falls in a cut, and on an axis with its nights +// folded that is every midnight: the axis would have no dates on it. So the +// step is walked across the whole domain instead, and a tick that lands +// strictly inside a cut moves to the cut's upper edge — where time next +// counts — and is labelled for where it now stands. A tick in a break is +// still dropped. Two that land in one cut +// become one, the later winning, so a closed week that swallowed the first of +// a month is labelled with the new month. See ADR 0086, claim 5. +func (s *timeScale) movedTicks(b *broken, u timeUnit) []Tick { + var out []Tick + for _, t := range s.walk(s.Instant(b.lo).In(s.loc), s.Instant(b.hi).In(s.loc), u) { + v := s.Value(t) + // A tick in a fold — or on the edge where one starts — moves on; one + // in a break is dropped, as 0083 has it. A break is a stretch the reader was told is missing, and a tick + // on its far edge would be a date nobody chose, standing against the + // break's mark. + if hi, fold, in := b.past(v); in { + if !fold { + continue + } + v, t = hi, s.Instant(hi).In(s.loc) + } + if v < b.lo || v > b.hi { + continue + } + tk := Tick{Value: v, Pos: s.Map(v), Label: s.label(t, u)} + if n := len(out); n > 0 && out[n-1].Value == v { + out[n-1] = tk + continue + } + out = append(out, tk) + } + return out +} + +// calendar reports whether a unit names a day rather than an instant. +func (u timeUnit) calendar() bool { return u.years > 0 || u.months > 0 || u.every >= 24*time.Hour } + // approx reports a unit's nominal duration, used only to tell a custom // formatter how coarse the axis is. func (u timeUnit) approx() time.Duration { @@ -339,6 +421,20 @@ func (s *timeScale) walk(start, end time.Time, u timeUnit) []time.Time { out = append(out, t) } } + case u.every == week: + // A week is walked from a weekday rather than truncated from a + // duration: truncating aligns to whatever day Go's zero time was, and + // steps of seven times 24 hours drift an hour off midnight at every + // daylight saving change. + y, m, d := start.Date() + back := (int(start.Weekday()) - int(s.week) + 7) % 7 + for t := time.Date(y, m, d-back, 0, 0, 0, 0, s.loc); !t.After(end) && len(out) < guard; { + if !t.Before(start) { + out = append(out, t) + } + ty, tm, td := t.Date() + t = time.Date(ty, tm, td+7, 0, 0, 0, 0, s.loc) + } case u.months > 0: m := (int(start.Month()) - 1) / u.months * u.months for t := time.Date(start.Year(), time.Month(m+1), 1, 0, 0, 0, 0, s.loc); !t.After(end) && len(out) < guard; t = t.AddDate(0, u.months, 0) { diff --git a/scale/valuelabel.go b/scale/valuelabel.go new file mode 100644 index 0000000..e3f95bb --- /dev/null +++ b/scale/valuelabel.go @@ -0,0 +1,63 @@ +package scale + +import ( + "math" + "time" +) + +// ValueLabelOf writes v the way s would write a value read off the axis at an +// arbitrary point — a price tag, a crosshair's readout — rather than at a tick. +// +// A tick label has the precision of the tick step, which is right for a tick +// and wrong for a value between two of them: a last price of 94.37 on an axis +// ticked every whole unit is not "94". So a linear axis writes the value with +// as many decimals as it needs, up to two more than its ticks, and a time axis +// writes the date and, when +// the instant is not a midnight, the time of day. An axis given a Go formatter +// or a layout is written with it, as its ticks are. Any other scale falls back +// to [LabelOf]. See docs/adr/0092-what-a-trading-screen-reads-off-its-edges.md. +func ValueLabelOf(s Scale, v float64) string { + switch x := s.(type) { + case *linear: + return x.valueLabel(v) + case *timeScale: + return x.valueLabel(v) + } + return LabelOf(s, v) +} + +func (l *linear) valueLabel(v float64) string { + if l.format != nil { + return l.format(v) + } + // As few decimals as the value needs, from the ticks' own to two more: 7.25 + // on an axis ticked in halves reads 7.25, not 7.250, and 94.37 on one ticked + // in whole units reads 94.37, not 94. + auto := autoFor(l.step()) + if auto.mode == 'f' { + for k, most := auto.decimals, auto.decimals+2; k <= most; k++ { + auto.decimals = k + m := math.Pow(10, float64(k)) + if math.Abs(math.Round(v*m)-v*m) < 1e-6*math.Max(1, math.Abs(v*m)) { + break + } + } + } + return l.numFormat.label(v, auto, l.loc) +} + +// valueUnits are the layouts a value off a time axis is written in: a date +// alone for a midnight, and a date with a time otherwise. +var ( + valueDay = timeUnit{every: 24 * time.Hour, layout: "Jan 2"} + valueInstant = timeUnit{every: time.Minute, layout: "Jan 2 15:04"} +) + +func (s *timeScale) valueLabel(v float64) string { + t := s.Instant(v).In(s.loc) + u := valueInstant + if h, m, sec := t.Clock(); h == 0 && m == 0 && sec == 0 { + u = valueDay + } + return s.label(t, u) +} diff --git a/spec/calendar_test.go b/spec/calendar_test.go new file mode 100644 index 0000000..6349bfb --- /dev/null +++ b/spec/calendar_test.go @@ -0,0 +1,131 @@ +package spec_test + +import ( + "strings" + "testing" + "time" + + "github.com/timzifer/figure/data" + "github.com/timzifer/figure/geom" + "github.com/timzifer/figure/ir" + "github.com/timzifer/figure/scale" + "github.com/timzifer/figure/spec" +) + +// A calendar is not written down — its folds are — but the week start it gave +// the axis is, and reads back. +func TestAWeekStartSurvivesTheDocument(t *testing.T) { + c := chartOf(geom.Line(longTable(), geom.X("t"), geom.Y("v"))) + c.X = scale.Time(scale.WeekStart(time.Sunday)) + s, err := spec.Of(c) + if err != nil { + t.Fatal(err) + } + b, err := s.Marshal() + if err != nil { + t.Fatal(err) + } + back, err := spec.Parse(b) + if err != nil { + t.Fatal(err) + } + out, err := back.Chart() + if err != nil { + t.Fatal(err) + } + if d := out.X.(scale.Describer).Describe(); d.WeekStart != "sunday" { + t.Errorf("read back with week start %q\n%s", d.WeekStart, b) + } +} + +func candleTable() *data.Table { + return data.NewTable(). + Float64("t", []float64{0, 1, 2, 3}). + Float64("o", []float64{10, 12, 15, 13}). + Float64("h", []float64{13, 16, 16, 14}). + Float64("l", []float64{9, 11, 12, 12}). + Float64("c", []float64{12, 15, 13, 13}) +} + +// A candlestick survives the document: its mark word, its four channels and +// every option that differs from the default. +func TestACandleSurvivesTheDocument(t *testing.T) { + src := candleTable() + ohlc := geom.OHLC("o", "h", "l", "c") + for name, layer := range map[string]geom.Geom{ + "plain": geom.Candle(src, geom.X("t"), ohlc), + "styled": geom.Candle(src, geom.X("t"), ohlc, geom.Direction(geom.SincePrevious), geom.Hollow(true), geom.CandleStyle(geom.Ticks), geom.BarWidth(0.5)), + "named": geom.Candle(src, geom.X("t"), ohlc, geom.Rising(ir.RGB(1, 2, 3), "bull"), geom.Falling(ir.RGB(4, 5, 6), "bear")), + } { + t.Run(name, func(t *testing.T) { + c := chartOf(layer) + want, got := draw(t, c), draw(t, roundTrip(t, c)) + if strings.Join(want, "\n") != strings.Join(got, "\n") { + s, _ := spec.Of(c) + b, _ := s.Marshal() + t.Errorf("the candle did not survive the round trip\n%s", b) + } + }) + } + + s, err := spec.Of(chartOf(geom.Candle(src, geom.X("t"), ohlc))) + if err != nil { + t.Fatal(err) + } + if len(s.Layer) != 1 { + t.Fatalf("%d layers", len(s.Layer)) + } + l := s.Layer[0] + enc := l.Encoding + if l.Mark.Type != "candlestick" || enc == nil || enc.Open == nil || enc.Open.Field != "o" || + enc.High.Field != "h" || enc.Low.Field != "l" || enc.Close == nil || enc.Close.Field != "c" { + t.Errorf("mark %q, encoding %+v", l.Mark.Type, enc) + } + if enc != nil && (enc.Y != nil || enc.Y2 != nil) { + t.Errorf("a candle spent y on one of its values: %+v %+v", enc.Y, enc.Y2) + } +} + +// A direction survives the document on the marks that take it, with and +// without a from column and with the colours it was given. +func TestADirectionSurvivesTheDocument(t *testing.T) { + src := candleTable() + for name, layer := range map[string]geom.Geom{ + "bar": geom.Bar(src, geom.X("t"), geom.Y("h"), geom.DirectionBy("o", "c")), + "previous": geom.Bar(src, geom.X("t"), geom.Y("h"), geom.DirectionBy("", "c")), + "named": geom.Scatter(src, geom.X("t"), geom.Y("c"), geom.DirectionBy("o", "c"), geom.Rising(ir.RGB(1, 2, 3), "up"), geom.Falling(ir.RGB(4, 5, 6), "down")), + "line": geom.Line(src, geom.X("t"), geom.Y("c"), geom.DirectionBy("", "c")), + } { + t.Run(name, func(t *testing.T) { + c := chartOf(layer) + want, got := draw(t, c), draw(t, roundTrip(t, c)) + if strings.Join(want, "\n") != strings.Join(got, "\n") { + s, _ := spec.Of(c) + b, _ := s.Marshal() + t.Errorf("the direction did not survive the round trip\n%s", b) + } + }) + } +} + +// A last value survives the document with its rule, its direction and its +// colour. +func TestALastValueSurvivesTheDocument(t *testing.T) { + src := candleTable() + for name, layer := range map[string]geom.Geom{ + "plain": geom.LastValue(src, geom.X("t"), geom.Y("c")), + "no rule": geom.LastValue(src, geom.X("t"), geom.Y("c"), geom.Rule(false)), + "direction": geom.LastValue(src, geom.X("t"), geom.Y("c"), geom.DirectionBy("o", "c"), geom.Dash(4, 2)), + "coloured": geom.LastValue(src, geom.X("t"), geom.Y("c"), geom.Color(ir.RGB(9, 8, 7))), + } { + t.Run(name, func(t *testing.T) { + c := chartOf(geom.Line(src, geom.X("t"), geom.Y("c")), layer) + want, got := draw(t, c), draw(t, roundTrip(t, c)) + if strings.Join(want, "\n") != strings.Join(got, "\n") { + s, _ := spec.Of(c) + b, _ := s.Marshal() + t.Errorf("the last value did not survive the round trip\n%s", b) + } + }) + } +} diff --git a/spec/decode.go b/spec/decode.go index a7fa745..526f53b 100644 --- a/spec/decode.go +++ b/spec/decode.go @@ -134,7 +134,7 @@ func decodeScale(s Scale, channelType string) (scale.Desc, error) { typ = "linear" } } - d := scale.Desc{Nice: s.Nice, Zero: s.Zero, Base: s.Base, Threshold: s.Constant, Locale: s.Locale} + d := scale.Desc{Nice: s.Nice, Zero: s.Zero, Fit: s.Fit == "view", HighWater: s.HighWater, Base: s.Base, Threshold: s.Constant, Locale: s.Locale} d.MinorTicks = true if s.MinorTicks != nil { d.MinorTicks = *s.MinorTicks @@ -161,7 +161,7 @@ func decodeScale(s Scale, channelType string) (scale.Desc, error) { d.Link = "probit" } case "time", "utc": - d.Kind, d.Location = scale.KindTime, s.TimeZone + d.Kind, d.Location, d.WeekStart = scale.KindTime, s.TimeZone, s.WeekStart if typ == "utc" && d.Location == "" { d.Location = "UTC" } @@ -323,7 +323,7 @@ func decodeLayer(l Layer, shared data.Source) (geom.Geom, error) { LevelCount: l.Mark.LevelCount, Resample: resampling(l.Mark.Resample), Branch: branch(l.Mark.Branch), - Orient: orientation(l.Mark.Orientation), + Orient: markOrientation(l.Mark), Bandwidth: l.Mark.Bandwidth, Span: l.Mark.Span, Smooth: smoothing(l.Mark.Method), @@ -353,6 +353,30 @@ func decodeLayer(l Layer, shared data.Source) (geom.Geom, error) { if l.Mark.Caps != nil { d.Caps = *l.Mark.Caps } + d.Rule = l.Mark.Rule == nil || *l.Mark.Rule + if l.Mark.Direction == "previous" { + d.Direction = geom.SincePrevious + } + if l.Mark.Hollow != nil { + d.Hollow, d.HollowSet = *l.Mark.Hollow, true + } + if l.Mark.CandleStyle == "ticks" { + d.CandleStyle = geom.Ticks + } + for _, side := range []struct { + hex string + to **ir.Color + }{{l.Mark.Rising, &d.Rising}, {l.Mark.Falling, &d.Falling}} { + if side.hex == "" { + continue + } + c, err := parseColor(side.hex) + if err != nil { + return nil, err + } + *side.to = &c + } + d.RisingLabel, d.FallingLabel = l.Mark.RisingLabel, l.Mark.FallingLabel d.Confidence, d.CensorMarks = l.Mark.Confidence, l.Mark.CensorMarks d.HideGuide = l.Mark.Guide != nil && !*l.Mark.Guide d.Extrude = l.Mark.Extrude @@ -448,6 +472,8 @@ func decodeLayerEncoding(d *geom.Desc, enc *Encoding) error { d.Key = fieldOf(enc.Key) d.ExplodeCol = fieldOf(enc.Explode) d.MidCol, d.ErrorCol, d.ErrorXCol = fieldOf(enc.Mid), fieldOf(enc.Error), fieldOf(enc.ErrorX) + d.OHLC = [4]string{fieldOf(enc.Open), fieldOf(enc.High), fieldOf(enc.Low), fieldOf(enc.Close)} + d.DirectionTo, d.DirectionFrom = fieldOf(enc.Direction), fieldOf(enc.DirectionFrom) d.From, d.To = fieldOf(enc.From), fieldOf(enc.To) for i := range enc.Dims { d.Dims = append(d.Dims, enc.Dims[i].Field) diff --git a/spec/encode.go b/spec/encode.go index 32a138f..8de80a8 100644 --- a/spec/encode.go +++ b/spec/encode.go @@ -142,6 +142,10 @@ func channelType(k scale.Kind) string { func encodeScale(d scale.Desc) *Scale { out := &Scale{Nice: d.Nice, Zero: d.Zero, Format: d.Format, Locale: d.Locale} + if d.Fit { + out.Fit = "view" + } + out.HighWater = d.HighWater switch d.Kind { case scale.KindLinear: // Reverse is written only for the kind that reads it back. On a @@ -161,7 +165,7 @@ func encodeScale(d scale.Desc) *Scale { out.Type, out.Link = "probability", d.Link out.MinorTicks = boolPtr(d.MinorTicks) case scale.KindTime: - out.Type, out.TimeZone = "time", d.Location + out.Type, out.TimeZone, out.WeekStart = "time", d.Location, d.WeekStart // A time scale's declarative format is a layout, and it travels in // the same field a numeric scale's number format does: the type says // which of the two this is. @@ -398,6 +402,17 @@ func writeMarkProps(m *Mark, d geom.Desc) { if d.HideGuide { m.Guide = boolPtr(false) } + // A direction's colours and labels are the candle's properties, written for + // any mark that carries one (ADR 0091). + if d.DirectionTo != "" { + if d.Rising != nil { + m.Rising = colorHex(*d.Rising) + } + if d.Falling != nil { + m.Falling = colorHex(*d.Falling) + } + m.RisingLabel, m.FallingLabel = d.RisingLabel, d.FallingLabel + } stroke := func() { m.StrokeWidth = d.Width if d.DashSet { @@ -475,6 +490,13 @@ func writeMarkProps(m *Mark, d geom.Desc) { density := func() { m.Bandwidth = d.Bandwidth } + // Written only by the marks that read it, for the same reason: a + // horizontal line would be a document claiming a line can lie down. + orientation := func() { + if d.Orient == geom.Horizontal { + m.Orientation = "horizontal" + } + } // A contour that joins back to its start is a property of the connected // marks and of nothing else, so it is written by those three and not by @@ -530,6 +552,7 @@ func writeMarkProps(m *Mark, d geom.Desc) { m.BarWidth, m.Origin = float64Ptr(d.BarWidth), d.Baseline m.Explode = d.Explode m.Extrude = d.Extrude + orientation() case geom.MarkRect: stroke() fill() @@ -561,11 +584,13 @@ func writeMarkProps(m *Mark, d geom.Desc) { rows() m.BarWidth = float64Ptr(d.BarWidth) m.Extent, m.Outliers = d.Whisker, boolPtr(d.Outliers) + orientation() case geom.MarkHistogram: stroke() fill() binning() m.Origin = d.Baseline + orientation() case geom.MarkViolin: stroke() fill() @@ -611,9 +636,7 @@ func writeMarkProps(m *Mark, d geom.Desc) { if d.Branch == geom.Straight { m.Branch = "straight" } - if d.Orient == geom.Horizontal { - m.Orientation = "horizontal" - } + orientation() case geom.MarkNodeLink: // Dots and lines: a fill for the discs, a stroke for the edges, and the // mark's size is a disc's diameter. There is no ranking, no direction @@ -664,6 +687,35 @@ func writeMarkProps(m *Mark, d geom.Desc) { if d.MarkerSet { m.Shape = shapeName(d.Marker) } + case geom.MarkLastValue: + stroke() + if d.Color != nil { + m.Color = colorHex(*d.Color) + } + if !d.Rule { + m.Rule = boolPtr(false) + } + case geom.MarkCandle: + stroke() + fill() + rows() + m.BarWidth = float64Ptr(d.BarWidth) + if d.Direction == geom.SincePrevious { + m.Direction = "previous" + } + if d.HollowSet { + m.Hollow = boolPtr(d.Hollow) + } + if d.CandleStyle == geom.Ticks { + m.CandleStyle = "ticks" + } + if d.Rising != nil { + m.Rising = colorHex(*d.Rising) + } + if d.Falling != nil { + m.Falling = colorHex(*d.Falling) + } + m.RisingLabel, m.FallingLabel = d.RisingLabel, d.FallingLabel case geom.MarkErrorBar: stroke() rows() @@ -824,6 +876,21 @@ func encodeLayerEncoding(d geom.Desc, axes axisKinds) (*Encoding, error) { if d.ErrorXCol != "" { enc.ErrorX = &Channel{Field: d.ErrorXCol} } + if d.DirectionTo != "" { + enc.Direction = &Channel{Field: d.DirectionTo, Type: "quantitative"} + if d.DirectionFrom != "" { + enc.DirectionFrom = &Channel{Field: d.DirectionFrom, Type: "quantitative"} + } + } + if d.Mark == geom.MarkCandle { + ch := func(f string) *Channel { + if f == "" { + return nil + } + return &Channel{Field: f, Type: "quantitative"} + } + enc.Open, enc.High, enc.Low, enc.Close = ch(d.OHLC[0]), ch(d.OHLC[1]), ch(d.OHLC[2]), ch(d.OHLC[3]) + } if d.From != "" { enc.From = &Channel{Field: d.From, Type: "nominal"} } @@ -860,9 +927,15 @@ func encodeLayerEncoding(d geom.Desc, axes axisKinds) (*Encoding, error) { } // The stack is a property of the axis the groups are stacked along, // which is the Y axis for every mark that has one — so it goes on the - // Y channel, where Vega-Lite puts it and where a reader will look. - if d.StackSet && enc.Y != nil { - enc.Y.Stack = stackName(d.Stack) + // Y channel, where Vega-Lite puts it and where a reader will look. A + // horizontal layer stacks across X, and Vega-Lite puts it there too. + if along := enc.Y; d.StackSet { + if d.Orient == geom.Horizontal { + along = enc.X + } + if along != nil { + along.Stack = stackName(d.Stack) + } } if enc.empty() { return nil, nil diff --git a/spec/spec.go b/spec/spec.go index 2eb65f9..03afaba 100644 --- a/spec/spec.go +++ b/spec/spec.go @@ -214,6 +214,26 @@ type Mark struct { // is the point-range look. Caps *bool `json:"caps,omitempty"` + // Direction, Hollow, CandleStyle, Rising and Falling are a candlestick's: + // what its direction is measured against ("previous", or left out for its + // own open), whether rising bodies are outlines — a pointer, because the + // default follows the theme and a document that says nothing must keep + // that — its style ("ticks" for the OHLC bar, or left out for bodies), and + // each direction's colour and legend label when the layer named them. All + // are figure's; Vega-Lite has no candle mark. + Direction string `json:"direction,omitempty"` + Hollow *bool `json:"hollow,omitempty"` + CandleStyle string `json:"candleStyle,omitempty"` + Rising string `json:"rising,omitempty"` + Falling string `json:"falling,omitempty"` + RisingLabel string `json:"risingLabel,omitempty"` + FallingLabel string `json:"fallingLabel,omitempty"` + + // Rule is whether a last-value layer draws its line across the panel: a + // pointer, because the default is true and a document that says nothing + // must keep it. It is figure's. + Rule *bool `json:"rule,omitempty"` + // XAxis and YAxis name the scales this layer's values are read against: // "x2" and "y2" for the chart's secondary axes, and empty for its primary // ones. They are two fields rather than one because the two directions are @@ -346,9 +366,9 @@ type Mark struct { // figure's names. Branch string `json:"branch,omitempty"` // Orientation is which way round a mark reads its two axes: "vertical", - // the default and so left out, or "horizontal". Only a tree reads it - // today. Vega-Lite spells the same idea as two mark types, so this is - // figure's own name. + // the default and so left out, or "horizontal". A tree, a bar, a + // histogram and a boxplot read it. Vega-Lite infers the same idea from + // which channel is quantitative, so this is figure's own name. Orientation string `json:"orientation,omitempty"` // Confidence is the level of a survival curve's pointwise band, and // CensorMarks whether it ticks its censored times. Both are figure's. @@ -612,6 +632,22 @@ type Encoding struct { Mid *Channel `json:"mid,omitempty"` Error *Channel `json:"error,omitempty"` ErrorX *Channel `json:"errorX,omitempty"` + + // Open, High, Low and Close are a candlestick's four values. They are + // figure's own channels rather than y and y2, because a consumer reading + // y as the value of a row would read a candle as its open and nothing + // else. + Open *Channel `json:"open,omitempty"` + High *Channel `json:"high,omitempty"` + Low *Channel `json:"low,omitempty"` + Close *Channel `json:"close,omitempty"` + + // Direction and DirectionFrom are a direction's two columns: a row rises + // when Direction is at or above DirectionFrom, or — DirectionFrom left out + // — above the previous row's Direction. Both are figure's; see + // [github.com/timzifer/figure/geom.DirectionBy]. + Direction *Channel `json:"direction,omitempty"` + DirectionFrom *Channel `json:"directionFrom,omitempty"` } // Channel is one encoding: a column, or a literal value, and the scale behind @@ -654,6 +690,14 @@ type Scale struct { Domain []any `json:"domain,omitempty"` Nice bool `json:"nice,omitempty"` Zero bool `json:"zero,omitempty"` + // Fit is "view" for a value axis that fits the rows its panel's other axis + // shows, and left out for one trained on every row. It is figure's; see + // [github.com/timzifer/figure/scale.FitView]. + Fit string `json:"fit,omitempty"` + // HighWater is a linear axis that keeps its extent across renders, the + // monitor's "most it has ever been". It is figure's; see + // [github.com/timzifer/figure/scale.HighWater]. + HighWater bool `json:"highWater,omitempty"` // TickValues pins a linear axis's tick positions. Vega-Lite spells this // `axis.values`; figure has no axis object on a channel, and `values` on a // scale would read as a domain, so it is named for what it pins. @@ -754,6 +798,9 @@ type Scale struct { Center *float64 `json:"center,omitempty"` Undefined string `json:"undefined,omitempty"` TimeZone string `json:"timeZone,omitempty"` + // WeekStart is the weekday a time scale's weeks begin on — "sunday" — + // left out for Monday. It is figure's; Vega-Lite's weeks are its locale's. + WeekStart string `json:"weekStart,omitempty"` // Origin is the instant a time scale measures its domain from, written as // a timestamp. It is figure's own — Vega-Lite has no equivalent because diff --git a/spec/spec_test.go b/spec/spec_test.go index 10b5bb8..f8f4456 100644 --- a/spec/spec_test.go +++ b/spec/spec_test.go @@ -81,6 +81,10 @@ func TestEveryMarkSurvivesTheRoundTrip(t *testing.T) { }{ {"line", geom.Line(src, geom.X("x"), geom.Y("y"), geom.Tension(0.4), geom.Dash(4, 2))}, {"scatter", geom.Scatter(src, geom.X("x"), geom.Y("y"), geom.Shape(ir.MarkerDiamond), geom.Size(9))}, + {"bar-horizontal", geom.Bar(src, geom.Y("x"), geom.X("y"), geom.Orient(geom.Horizontal))}, + {"histogram-horizontal", geom.Histogram(src, geom.Y("y"), geom.Bins(4), geom.Orient(geom.Horizontal))}, + {"boxplot-horizontal", geom.Boxplot(src, geom.Y("region"), geom.X("y"), geom.Orient(geom.Horizontal))}, + {"scatter-triangle-down", geom.Scatter(src, geom.X("x"), geom.Y("y"), geom.Shape(ir.MarkerTriangleDown))}, {"bar", geom.Bar(src, geom.X("x"), geom.Y("y"), geom.BarWidth(0.5), geom.Baseline(1))}, {"area", geom.Area(src, geom.X("x"), geom.Y("y"), geom.Y2("z"), geom.Opacity(0.4))}, {"step", geom.Step(src, geom.X("x"), geom.Y("y"), geom.Steps(geom.StepPre))}, @@ -121,13 +125,16 @@ func TestEveryMarkSurvivesTheRoundTrip(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - x := scale.Linear(scale.Nice()) - if tc.name == "boxplot" { + x, y := scale.Scale(scale.Linear(scale.Nice())), scale.Scale(scale.Linear(scale.Nice())) + switch tc.name { + case "boxplot": x = scale.Ordinal() + case "boxplot-horizontal": + y = scale.Ordinal() } c := spec.Chart{ Width: 400, Height: 300, DPR: 1, Theme: theme.Light, - X: x, Y: scale.Linear(scale.Nice()), + X: x, Y: y, Layers: []geom.Geom{tc.layer}, } want := draw(t, c) diff --git a/spec/v07_test.go b/spec/v07_test.go index 8de39f3..56784f5 100644 --- a/spec/v07_test.go +++ b/spec/v07_test.go @@ -49,6 +49,10 @@ func TestTheV07MarksAndOptionsSurviveTheRoundTrip(t *testing.T) { geom.GroupBy("series"), geom.Stack(geom.StackFill))}, {"dodged-bar", geom.Bar(src, geom.X("t"), geom.Y("v"), geom.GroupBy("series"), geom.Dodge(0.25))}, + {"horizontal-filled-bar", geom.Bar(src, geom.Y("t"), geom.X("v"), + geom.GroupBy("series"), geom.Stack(geom.StackFill), geom.Orient(geom.Horizontal))}, + {"horizontal-dodged-bar", geom.Bar(src, geom.Y("t"), geom.X("v"), + geom.GroupBy("series"), geom.Dodge(0.25), geom.Orient(geom.Horizontal))}, {"marimekko", geom.Bar(src, geom.X("t"), geom.Y("v"), geom.GroupBy("series"), geom.WidthBy("w"), geom.Stack(geom.StackFill))}, {"streamgraph", geom.Area(src, geom.X("t"), geom.Y("v"), @@ -176,3 +180,57 @@ func TestAQualitativeColourScaleIsNamedInTheDocument(t *testing.T) { t.Error("a discovered category set was written down as a domain") } } + +// A horizontal stack is written on the X channel, which is where Vega-Lite +// looks for it on a bar lying down. +func TestAHorizontalStackIsWrittenOnX(t *testing.T) { + c := chartOf(geom.Bar(longTable(), geom.Y("t"), geom.X("v"), + geom.GroupBy("series"), geom.Stack(geom.StackFill), geom.Orient(geom.Horizontal))) + s, err := spec.Of(c) + if err != nil { + t.Fatal(err) + } + if len(s.Layer) != 1 || s.Layer[0].Encoding == nil { + t.Fatalf("the document has %d layers", len(s.Layer)) + } + l := s.Layer[0] + if l.Mark.Orientation != "horizontal" { + t.Errorf("mark orientation %q, want horizontal", l.Mark.Orientation) + } + enc := l.Encoding + if enc.X == nil || enc.X.Stack == "" { + t.Errorf("the stack is not on the x channel: x = %+v", enc.X) + } + if enc.Y != nil && enc.Y.Stack != "" { + t.Errorf("the y channel carries the stack %q too", enc.Y.Stack) + } +} + +// A Vega-Lite document lays a bar down with the mark's own "orient", and that +// is read as the same thing figure writes as "orientation". +func TestVegaLitesOrientLaysABarDown(t *testing.T) { + doc := `{ + "data": {"values": [{"n": 3, "fruit": "apple"}, {"n": 7, "fruit": "pear"}]}, + "layer": [{ + "mark": {"type": "bar", "orient": "horizontal"}, + "encoding": { + "x": {"field": "n", "type": "quantitative"}, + "y": {"field": "fruit", "type": "nominal"} + } + }] + }` + s, err := spec.Parse([]byte(doc)) + if err != nil { + t.Fatal(err) + } + c, err := s.Chart() + if err != nil { + t.Fatal(err) + } + if len(c.Layers) != 1 { + t.Fatalf("%d layers", len(c.Layers)) + } + if d := c.Layers[0].(geom.Describer).Describe(); d.Orient != geom.Horizontal { + t.Errorf("the bar reads back with orientation %v, want horizontal", d.Orient) + } +} diff --git a/spec/vocab.go b/spec/vocab.go index 3967efe..a4a0d07 100644 --- a/spec/vocab.go +++ b/spec/vocab.go @@ -132,6 +132,12 @@ func markType(m geom.Mark) (typ, orient string, err error) { // Both are a rect and neither is oriented, so the type does not tell // them apart — the encoding does. See [geomMark]. return "rect", "", nil + case geom.MarkCandle: + // Vega-Lite has no candle mark and draws one as a rule and a bar, so + // the word is figure's. + return "candlestick", "", nil + case geom.MarkLastValue: + return "lastValue", "", nil case geom.MarkErrorBar: // Vega-Lite spells it "errorbar" too, and reaches it with an aggregate // transform; figure's reads the bounds from columns, so the name is @@ -198,6 +204,10 @@ func geomMark(m Mark, enc *Encoding) (geom.Mark, error) { return geom.MarkLocus, nil case "errorbar": return geom.MarkErrorBar, nil + case "candlestick": + return geom.MarkCandle, nil + case "lastValue": + return geom.MarkLastValue, nil case "voronoi": return geom.MarkVoronoi, nil case "treemap": @@ -419,9 +429,10 @@ func curveOf(name string) (geom.CurveKind, bool) { return geom.CurveLinear, false } -// Marker shapes. The first five are Vega-Lite's own shape names; "plus" is +// Marker shapes. All but "plus" are Vega-Lite's own shape names; "plus" is // figure's, because Vega-Lite's "cross" is already the shape figure calls a -// cross and there is no second name to borrow. +// cross and there is no second name to borrow. "triangle" stays the spelling +// of the upward one, which Vega-Lite also accepts as "triangle-up". var shapes = []struct { marker ir.Marker name string @@ -432,6 +443,7 @@ var shapes = []struct { {ir.MarkerTriangle, "triangle"}, {ir.MarkerCross, "cross"}, {ir.MarkerPlus, "plus"}, + {ir.MarkerTriangleDown, "triangle-down"}, } // Hatch patterns. There is no Vega-Lite vocabulary to borrow — Vega-Lite has @@ -725,6 +737,21 @@ func orientation(name string) geom.Orientation { return geom.Vertical } +// markOrientation is a layer's orientation: figure's own "orientation" where +// the document wrote one, and otherwise Vega-Lite's "orient" on the two marks +// whose orient means the same thing — a bar and a boxplot lying on their side. +// A rule's and a rect's orient choose between two marks instead, and are read +// where the mark is. +func markOrientation(m Mark) geom.Orientation { + if m.Orientation != "" { + return orientation(m.Orientation) + } + if (m.Type == "bar" || m.Type == "boxplot") && m.Orient == "horizontal" { + return geom.Horizontal + } + return geom.Vertical +} + // branch is a tree's branch shape, read back from its name. Anything but // "straight" is the elbow, which is the default a document leaves out. func branch(name string) geom.Branch { diff --git a/stat/alloc_test.go b/stat/alloc_test.go index 9fbff3c..8b271c8 100644 --- a/stat/alloc_test.go +++ b/stat/alloc_test.go @@ -94,3 +94,35 @@ func TestLayeringAgainDoesNotAllocate(t *testing.T) { t.Errorf("layering again allocated %.0f times, want none", got) } } + +// The market forms are redrawn every frame of a live chart, so their Append +// forms must fit into the slice they were given. +func TestTheTrailingFormsDoNotAllocateIntoAWarmSlice(t *testing.T) { + const n = 500 + xs := make([]float64, n) + ys := make([]float64, n) + for i := range n { + xs[i], ys[i] = float64(i), 100+math.Sin(float64(i)/9)*5 + } + dst := make([]stat.Point, 0, n) + candles := make([]stat.Candle, 0, n) + buckets := make([]stat.WeightedBucket, 0, 32) + edges := []float64{0, 100, 250, 400} + for name, f := range map[string]func(){ + "mean": func() { dst = stat.AppendTrailingMean(dst, xs, ys, 20) }, + "ema": func() { dst = stat.AppendEMA(dst, xs, ys, 20) }, + "sd": func() { dst = stat.AppendRollingStdDev(dst, xs, ys, 20) }, + "min": func() { dst = stat.AppendRollingMin(dst, xs, ys, 20) }, + "max": func() { dst = stat.AppendRollingMax(dst, xs, ys, 20) }, + "cumsum": func() { dst = stat.AppendCumsum(dst, xs, ys) }, + "ohlc": func() { candles = stat.AppendOHLC(candles, xs, ys, nil, 0, 10) }, + "ohlcAt": func() { candles = stat.AppendOHLCAt(candles, xs, ys, nil, edges) }, + "rsi": func() { dst = stat.AppendRSI(dst, xs, ys, 14) }, + "vwap": func() { dst = stat.AppendVWAP(dst, xs, ys, xs, edges) }, + "bin": func() { buckets = stat.AppendBinWeighted(buckets, ys, xs, 0, 0, 24) }, + } { + if got := testing.AllocsPerRun(10, f); got != 0 { + t.Errorf("%s allocated %.0f times, want none", name, got) + } + } +} diff --git a/stat/bin.go b/stat/bin.go index f1a0eed..000eb50 100644 --- a/stat/bin.go +++ b/stat/bin.go @@ -75,6 +75,80 @@ func AppendBin(dst []Bucket, vs []float64, lo, hi float64, n int) []Bucket { return dst } +// WeightedBucket is one bin of a weighted histogram: the interval it covers +// and the sum of the weights that fell in it. +// +// It is a type of its own rather than a float Count on [Bucket] because a +// count is a number of observations and a sum of weights is not: a volume +// profile's bar is shares traded, and a caller who reads Count expecting rows +// should not get shares. +type WeightedBucket struct { + Lo, Hi float64 + Sum float64 +} + +// Mid is the bucket's centre, where a histogram bar is positioned. +func (b WeightedBucket) Mid() float64 { return b.Lo + (b.Hi-b.Lo)/2 } + +// BinWeighted is [Bin] summing ws rather than counting vs: each value adds its +// weight to the bucket it falls in. +// +// It is the volume profile's summary — prices binned, volumes summed — and the +// buckets are exactly Bin's, over the same interval and with the same edges, +// so two calls over the same lo, hi and n (the buys, then the sells) give two +// profiles whose bars line up. Pass lo >= hi to take the interval from vs, as +// Bin does; a caller splitting one column into two should take it once, from +// the whole column, and pass it to both, or the two halves bin differently. +// +// A value is skipped when it or its weight is not finite. n <= 0 chooses the +// count by [Sturges] over the values, as Bin does. The shorter column wins. +func BinWeighted(vs, ws []float64, lo, hi float64, n int) []WeightedBucket { + return AppendBinWeighted(nil, vs, ws, lo, hi, n) +} + +// AppendBinWeighted is [BinWeighted] writing into a caller-owned slice. dst is +// truncated first. +func AppendBinWeighted(dst []WeightedBucket, vs, ws []float64, lo, hi float64, n int) []WeightedBucket { + dst = dst[:0] + m := min(len(vs), len(ws)) + vs, ws = vs[:m], ws[:m] + if lo >= hi { + var ok bool + lo, hi, ok = finiteExtent(vs) + if !ok { + return dst + } + } + if n <= 0 { + n = Sturges(countFinite(vs)) + } + if n <= 0 || !finite(lo) || !finite(hi) { + return dst + } + if lo == hi { + pad := math.Abs(lo) * 0.05 + if pad == 0 { + pad = 0.5 + } + lo, hi, n = lo-pad, hi+pad, 1 + } + + w := (hi - lo) / float64(n) + for i := range n { + dst = append(dst, WeightedBucket{Lo: lo + float64(i)*w, Hi: lo + float64(i+1)*w}) + } + dst[n-1].Hi = hi + + for i, v := range vs { + if !finite(v) || !finite(ws[i]) || v < lo || v > hi { + continue + } + j := int((v - lo) / w) + dst[min(max(j, 0), n-1)].Sum += ws[i] + } + return dst +} + func countFinite(vs []float64) int { n := 0 for _, v := range vs { diff --git a/stat/indicator.go b/stat/indicator.go new file mode 100644 index 0000000..831c149 --- /dev/null +++ b/stat/indicator.go @@ -0,0 +1,112 @@ +package stat + +import ( + "math" + "sort" +) + +// The named indicators. Each is here because the obvious composition of the +// functions beside it gives the wrong numbers — docs/adr/0092 states the rule +// — and each takes the contract the trailing windows take: xs ascending, the +// columns finite, the shorter one wins, and an Append form that allocates +// nothing into a warm slice. + +// RSI is Wilder's relative strength index over a window of rows, in [0, 100]. +// +// It is not a composition of [EMA]. Its averages of gains and of losses are +// Wilder's: seeded with the simple mean of the first window changes, then +// moved 1/window of the way towards each new change — not the 2/(window+1) +// an EMA moves. A reader told "RSI 14" expects that definition, and an RSI +// built from EMA is a curve close enough to be trusted and wrong enough to be +// noticed. +// +// The first row has no change and is NaN. Until a window of changes has +// arrived, the averages are the simple mean of the changes so far, as the +// trailing windows compute their first rows over what exists. A stretch with +// no losses is 100, one with no gains 0, and one with neither 50. +func RSI(xs, ys []float64, window int) []Point { return AppendRSI(nil, xs, ys, window) } + +// AppendRSI is [RSI] writing into a caller-owned slice. dst is truncated first. +func AppendRSI(dst []Point, xs, ys []float64, window int) []Point { + dst = dst[:0] + n := min(len(xs), len(ys)) + if n == 0 { + return dst + } + w := trailingWindow(window, n) + dst = append(dst, Point{X: xs[0], Y: math.NaN()}) + var gain, loss float64 + for i := 1; i < n; i++ { + d := ys[i] - ys[i-1] + g, l := math.Max(d, 0), math.Max(-d, 0) + if k := float64(i); i <= w { + // The seed: the simple mean of the changes so far. + gain += (g - gain) / k + loss += (l - loss) / k + } else { + gain += (g - gain) / float64(w) + loss += (l - loss) / float64(w) + } + dst = append(dst, Point{X: xs[i], Y: rsiOf(gain, loss)}) + } + return dst +} + +func rsiOf(gain, loss float64) float64 { + switch { + case gain == 0 && loss == 0: + return 50 + case loss == 0: + return 100 + } + return 100 - 100/(1+gain/loss) +} + +// VWAP is the volume-weighted average price of each session so far: at each +// row, the sum of price times volume over the sum of volume since the session +// the row falls in began. +// +// It is two cumulative sums divided, and it is here because of the one thing +// that composition forgets: the sums start again at every session. A VWAP +// that runs across the overnight is not the number any platform shows. +// edges are the session starts — a calendar's opens, +// [github.com/timzifer/figure/scale.Opens], in the axis's units, as +// [OHLCAt] takes them — ascending; rows before the first edge are NaN, and a +// nil edges is one session from the first row. A session that has traded no +// volume yet is NaN. +func VWAP(ts, ps, vs, edges []float64) []Point { return AppendVWAP(nil, ts, ps, vs, edges) } + +// AppendVWAP is [VWAP] writing into a caller-owned slice. dst is truncated +// first. +func AppendVWAP(dst []Point, ts, ps, vs, edges []float64) []Point { + dst = dst[:0] + n := min(len(ts), len(ps), len(vs)) + session := -2 + var pv, v float64 + for i := range n { + k := -1 + if len(edges) > 0 { + k = sort.SearchFloat64s(edges, ts[i]) + if k == len(edges) || edges[k] != ts[i] { + k-- + } + if k < 0 { + dst = append(dst, Point{X: ts[i], Y: math.NaN()}) + continue + } + } + if k != session { + session, pv, v = k, 0, 0 + } + if finite(ps[i]) && finite(vs[i]) { + pv += ps[i] * vs[i] + v += vs[i] + } + y := math.NaN() + if v > 0 { + y = pv / v + } + dst = append(dst, Point{X: ts[i], Y: y}) + } + return dst +} diff --git a/stat/indicator_test.go b/stat/indicator_test.go new file mode 100644 index 0000000..2331574 --- /dev/null +++ b/stat/indicator_test.go @@ -0,0 +1,96 @@ +package stat_test + +import ( + "math" + "testing" + + "github.com/timzifer/figure/stat" +) + +// StockCharts' published RSI example: fourteen periods over these closes, +// whose first RSI, at the fifteenth close, is published as 70.53. The page +// computes from closes it prints rounded to the cent, so its numbers sit a few +// hundredths from what these rounded closes give; the first value is checked +// exactly against the arithmetic — nine gains summing to 3.34 and four losses +// to 1.40 over fourteen changes — and the published ones to that rounding. +func TestRSIMatchesWildersPublishedExample(t *testing.T) { + closes := []float64{ + 44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10, 45.42, 45.84, 46.08, + 45.89, 46.03, 45.61, 46.28, 46.28, 46.00, 46.03, 46.41, 46.22, 45.64, + } + xs := make([]float64, len(closes)) + for i := range xs { + xs[i] = float64(i) + } + got := stat.RSI(xs, closes, 14) + want := map[int]float64{14: 70.53, 15: 66.32, 16: 66.55, 17: 69.41, 18: 66.36, 19: 57.97} + for i, w := range want { + if math.Abs(got[i].Y-w) > 0.1 { + t.Errorf("RSI at row %d = %.2f, want %.2f", i, got[i].Y, w) + } + } + if exact := 100 - 100/(1+3.34/1.40); math.Abs(got[14].Y-exact) > 1e-9 { + t.Errorf("RSI at row 14 = %v, want the seed's %v exactly", got[14].Y, exact) + } + if !math.IsNaN(got[0].Y) { + t.Errorf("row 0 has no change and is %v, want NaN", got[0].Y) + } +} + +// Composing an RSI from EMA gives a different curve, which is why RSI is a +// function: the test that says the function is not that composition. +func TestRSIIsNotAnEMAComposition(t *testing.T) { + xs, ys := wobble(60) + gains, losses := make([]float64, len(ys)), make([]float64, len(ys)) + for i := 1; i < len(ys); i++ { + d := ys[i] - ys[i-1] + gains[i], losses[i] = math.Max(d, 0), math.Max(-d, 0) + } + g, l := stat.EMA(xs, gains, 14), stat.EMA(xs, losses, 14) + rsi := stat.RSI(xs, ys, 14) + differs := false + for i := 20; i < len(ys); i++ { + naive := 100 - 100/(1+g[i].Y/l[i].Y) + if math.Abs(naive-rsi[i].Y) > 0.5 { + differs = true + } + } + if !differs { + t.Error("an EMA-built RSI matches Wilder's, so the function would be a composition") + } +} + +func TestRSIOfAFlatOrOneWaySeries(t *testing.T) { + xs := []float64{0, 1, 2, 3} + if got := stat.RSI(xs, []float64{5, 5, 5, 5}, 2); got[3].Y != 50 { + t.Errorf("a flat series is %v, want 50", got[3].Y) + } + if got := stat.RSI(xs, []float64{1, 2, 3, 4}, 2); got[3].Y != 100 { + t.Errorf("a rising series is %v, want 100", got[3].Y) + } + if got := stat.RSI(xs, []float64{4, 3, 2, 1}, 2); got[3].Y != 0 { + t.Errorf("a falling series is %v, want 0", got[3].Y) + } +} + +// The sums start again at every session. +func TestVWAPStartsAgainEachSession(t *testing.T) { + ts := []float64{0, 1, 2, 10, 11} + ps := []float64{10, 20, 30, 100, 200} + vs := []float64{1, 1, 2, 1, 3} + got := stat.VWAP(ts, ps, vs, []float64{0, 10}) + want := []float64{10, 15, (10 + 20 + 60) / 4.0, 100, (100 + 600) / 4.0} + for i, w := range want { + if math.Abs(got[i].Y-w) > 1e-12 { + t.Errorf("row %d: %v, want %v", i, got[i].Y, w) + } + } + // Without edges it is one session. + if all := stat.VWAP(ts, ps, vs, nil); math.Abs(all[4].Y-(10+20+60+100+600)/8.0) > 1e-12 { + t.Errorf("one session: %v", all[4].Y) + } + // Before the first edge there is no session. + if early := stat.VWAP(ts, ps, vs, []float64{1}); !math.IsNaN(early[0].Y) { + t.Errorf("a row before the first session is %v, want NaN", early[0].Y) + } +} diff --git a/stat/ohlc.go b/stat/ohlc.go new file mode 100644 index 0000000..a8d5b2a --- /dev/null +++ b/stat/ohlc.go @@ -0,0 +1,290 @@ +package stat + +import ( + "errors" + "math" + "sort" +) + +// Candle is one interval of a price column: where it opened and closed, how +// far it ranged, and how much traded in it. +// +// Start is the interval's own left edge, not the time of its first trade: a +// candle is drawn at the interval it summarises, and two candles for adjacent +// minutes should sit a minute apart whichever second their first trade fell in. +type Candle struct { + Start float64 + // End is where the interval stops: the start of the next one for [OHLC] + // and [OHLCAt], and for the last candle of OHLCAt — whose period the data + // has not closed — the time of its last row. + End float64 + Open, High, Low, Close float64 + // Volume is the sum of the weights the rows in it carried, or their count + // when the call had no weights. + Volume float64 + // Count is how many rows fell in the interval. + Count int +} + +// Up reports whether the candle closed at or above where it opened — the +// question its colour answers. A candle that closed where it opened is up, so +// that every candle is one of the two colours rather than a third nobody +// assigned. +func (c Candle) Up() bool { return c.Close >= c.Open } + +// OHLC summarises a price column into candles of a fixed width. +// +// ts are the times of the rows, ascending, in whatever unit the caller's axis +// uses — nanoseconds for a [github.com/timzifer/figure/scale.Time] axis, which +// is what [github.com/timzifer/figure/scale.Nanos] converts to. ps are the +// prices and vs the volumes; pass nil for vs to have each candle's Volume be +// its row count. The shortest column wins. +// +// The intervals are [origin + k·width, origin + (k+1)·width). origin is what +// aligns them to something a reader recognises: the Unix epoch aligns an hour +// to the clock hour, but a day to UTC midnight, and a market that opens at +// 09:30 in New York wants its days to start there. Pass the session's first +// open and every candle starts where a session does. +// +// An interval nobody traded in has no candle. Inventing one — at the previous +// close, with no range — would be a price the market never printed, and a gap +// in the candles is also the gap a reader expects to see over a halt. +// +// Rows that are not finite are skipped. A column with rows out of order is +// summarised in the order given, which puts a late row into a candle of its +// own; sorting would need a copy of the column, for the reason [Loess] gives. +func OHLC(ts, ps, vs []float64, origin, width float64) []Candle { + return AppendOHLC(nil, ts, ps, vs, origin, width) +} + +// AppendOHLC is [OHLC] writing into a caller-owned slice. dst is truncated +// first, so a live chart resampling every frame does it in the same memory. +func AppendOHLC(dst []Candle, ts, ps, vs []float64, origin, width float64) []Candle { + dst = dst[:0] + if !(width > 0) || !finite(width) || !finite(origin) { + return dst + } + n := min(len(ts), len(ps)) + if vs != nil { + n = min(n, len(vs)) + } + var cur Candle + open := false + for i := range n { + t, p := ts[i], ps[i] + if !finite(t) || !finite(p) { + continue + } + v := 1.0 + if vs != nil { + if v = vs[i]; !finite(v) { + continue + } + } + start := origin + math.Floor((t-origin)/width)*width + if !open || start != cur.Start { + if open { + dst = append(dst, cur) + } + cur, open = Candle{Start: start, End: start + width, Open: p, High: p, Low: p}, true + } + cur.High = max(cur.High, p) + cur.Low = min(cur.Low, p) + cur.Close = p + cur.Volume += v + cur.Count++ + } + if open { + dst = append(dst, cur) + } + return dst +} + +// OHLCAt summarises a price column into candles between consecutive edges: +// the k-th candle covers [edges[k], edges[k+1]) and the last one everything +// from the last edge on. +// +// It is [OHLC] for periods that are not a fixed width — a session, a shift, +// a day across a daylight saving change, which is 23 or 25 hours long. The +// edges are the caller's, typically the opens of a calendar: +// +// opens, _ := scale.Opens(cal, from, to) +// edges := make([]float64, len(opens)) +// for i, o := range opens { +// edges[i] = scale.Nanos(o) +// } +// candles := stat.OHLCAt(ts, ps, vs, edges) +// +// edges must be ascending; rows before the first edge are not counted. Each +// candle's Start is its edge. As in OHLC, a period nobody traded in has no +// candle, rows that are not finite are skipped, and vs may be nil. +func OHLCAt(ts, ps, vs, edges []float64) []Candle { + return AppendOHLCAt(nil, ts, ps, vs, edges) +} + +// AppendOHLCAt is [OHLCAt] writing into a caller-owned slice. dst is truncated +// first. +// +// Each row finds its period by a binary search over the edges, so a column out +// of order is still bucketed correctly, one candle per run of rows in the +// same period. +func AppendOHLCAt(dst []Candle, ts, ps, vs, edges []float64) []Candle { + dst = dst[:0] + if len(edges) == 0 { + return dst + } + n := min(len(ts), len(ps)) + if vs != nil { + n = min(n, len(vs)) + } + var cur Candle + at, open := -1, false + for i := range n { + t, p := ts[i], ps[i] + if !finite(t) || !finite(p) { + continue + } + v := 1.0 + if vs != nil { + if v = vs[i]; !finite(v) { + continue + } + } + k := sort.SearchFloat64s(edges, t) + if k == len(edges) || edges[k] != t { + k-- + } + if k < 0 { + continue + } + if !open || k != at { + if open { + dst = append(dst, cur) + } + end := t + if k+1 < len(edges) { + end = edges[k+1] + } + cur, at, open = Candle{Start: edges[k], End: end, Open: p, High: p, Low: p}, k, true + } + if k+1 == len(edges) { + cur.End = max(cur.End, t) + } + cur.High = max(cur.High, p) + cur.Low = min(cur.Low, p) + cur.Close = p + cur.Volume += v + cur.Count++ + } + if open { + dst = append(dst, cur) + } + return dst +} + +// Resampler is [OHLC] for a feed that arrives a tick at a time: each tick +// either revises the open candle or opens the next, and Add says which. +// +// r := stat.Resampler{Origin: scale.Nanos(open), Width: float64(time.Minute)} +// for tick := range ticks { +// c, fresh, err := r.Add(scale.Nanos(tick.At), tick.Price, tick.Size) +// if err != nil { +// continue // a tick for a candle that has already closed +// } +// row := []float64{c.Start, c.End, c.Open, c.High, c.Low, c.Close, c.Volume} +// if fresh { +// stream.Append(row...) +// } else { +// stream.ReplaceLast(row...) +// } +// } +// +// The periods are Width wide from Origin, as OHLC's are, or — when Edges is +// set — between consecutive edges, as [OHLCAt]'s are; Edges wins. Over the +// same ticks in the same order, the candles it produces are exactly those +// functions'. The zero value has no width and no edges and refuses every +// tick. +// +// It is a struct with state because the state is the point: the open candle. +// [Resampler.Reset] forgets it, so a chart restarting its feed reuses the +// value. See docs/adr/0089-a-value-axis-fits-what-its-time-axis-shows.md. +type Resampler struct { + Origin, Width float64 + Edges []float64 + + cur Candle + at int // the open candle's period: its index among Edges, or its multiple of Width + open bool +} + +// ErrLateTick is returned by [Resampler.Add] for a tick that belongs before +// the open candle — to a period that has closed. +var ErrLateTick = errors.New("figure/stat: tick belongs to a candle that has closed") + +// ErrNoPeriod is returned by [Resampler.Add] for a tick with no period: not +// finite, before the first edge, or on a resampler with no width and no edges. +var ErrNoPeriod = errors.New("figure/stat: tick has no period") + +// Add folds one tick into the candles and returns the candle it is now part +// of, and whether that candle is new. A tick for a period that has closed is +// [ErrLateTick] and changes nothing: that candle is history. v is the tick's +// volume; pass 1 to count ticks. +func (r *Resampler) Add(t, p, v float64) (Candle, bool, error) { + if !finite(t) || !finite(p) || !finite(v) { + return r.cur, false, ErrNoPeriod + } + k, start, end, ok := r.period(t) + if !ok { + return r.cur, false, ErrNoPeriod + } + if r.open && k < r.at { + return r.cur, false, ErrLateTick + } + fresh := !r.open || k != r.at + if fresh { + r.cur = Candle{Start: start, End: end, Open: p, High: p, Low: p} + r.at, r.open = k, true + } + r.cur.High = max(r.cur.High, p) + r.cur.Low = min(r.cur.Low, p) + r.cur.Close = p + r.cur.Volume += v + r.cur.Count++ + if len(r.Edges) > 0 && k == len(r.Edges)-1 { + // The last period has no edge after it, so it ends where its data + // does — OHLCAt's rule. + r.cur.End = max(r.cur.End, t) + } + return r.cur, fresh, nil +} + +// Current is the open candle, and false before the first tick. +func (r *Resampler) Current() (Candle, bool) { return r.cur, r.open } + +// Reset forgets the open candle, keeping the periods. +func (r *Resampler) Reset() { r.cur, r.at, r.open = Candle{}, 0, false } + +// period is the period t falls in: an index that orders periods, and its +// start and end. +func (r *Resampler) period(t float64) (int, float64, float64, bool) { + if n := len(r.Edges); n > 0 { + k := sort.SearchFloat64s(r.Edges, t) + if k == n || r.Edges[k] != t { + k-- + } + if k < 0 { + return 0, 0, 0, false + } + end := t + if k+1 < n { + end = r.Edges[k+1] + } + return k, r.Edges[k], end, true + } + if !(r.Width > 0) || !finite(r.Width) || !finite(r.Origin) { + return 0, 0, 0, false + } + m := math.Floor((t - r.Origin) / r.Width) + start := r.Origin + m*r.Width + return int(m), start, start + r.Width, true +} diff --git a/stat/ohlc_test.go b/stat/ohlc_test.go new file mode 100644 index 0000000..7859ae1 --- /dev/null +++ b/stat/ohlc_test.go @@ -0,0 +1,162 @@ +package stat_test + +import ( + "math" + "testing" + + "github.com/timzifer/figure/stat" +) + +func TestOHLCSummarisesEachInterval(t *testing.T) { + // Two intervals of width 10 from an origin of 5, and a gap between them. + ts := []float64{5, 7, 9, 14.9, 35, 36} + ps := []float64{10, 12, 8, 11, 20, 19} + vs := []float64{1, 2, 3, 4, 5, 6} + got := stat.OHLC(ts, ps, vs, 5, 10) + want := []stat.Candle{ + {Start: 5, End: 15, Open: 10, High: 12, Low: 8, Close: 11, Volume: 10, Count: 4}, + {Start: 35, End: 45, Open: 20, High: 20, Low: 19, Close: 19, Volume: 11, Count: 2}, + } + if len(got) != len(want) { + t.Fatalf("got %d candles %v, want %d — the empty interval between gets none", len(got), got, len(want)) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("candle %d: got %+v, want %+v", i, got[i], want[i]) + } + } + if !got[0].Up() || got[1].Up() { + t.Errorf("Up: %v, %v — want the first up and the second down", got[0].Up(), got[1].Up()) + } +} + +// Without volumes the candle's Volume is its row count, and a row before the +// origin still lands in the interval that contains it. +func TestOHLCCountsWithoutVolumesAndFloorsBeforeTheOrigin(t *testing.T) { + got := stat.OHLC([]float64{-3, -1, 2}, []float64{1, 2, 3}, nil, 0, 5) + if len(got) != 2 || got[0].Start != -5 || got[0].Volume != 2 || got[1].Start != 0 || got[1].Volume != 1 { + t.Errorf("got %+v", got) + } +} + +func TestOHLCSkipsWhatIsNotANumber(t *testing.T) { + nan := math.NaN() + got := stat.OHLC([]float64{0, 1, nan, 2}, []float64{1, nan, 5, 3}, nil, 0, 10) + if len(got) != 1 || got[0].Count != 2 || got[0].High != 3 { + t.Errorf("got %+v, want one candle of the two finite rows", got) + } + if got := stat.OHLC([]float64{0}, []float64{1}, nil, 0, 0); len(got) != 0 { + t.Errorf("a zero width gave %v", got) + } +} + +// Two calls over the same interval give buckets whose edges line up, which is +// what lets the buys and the sells be drawn as one profile. +func TestTwoWeightedBinsOverOneIntervalLineUp(t *testing.T) { + prices := []float64{10, 11, 12, 13, 14, 15} + buys := []float64{1, 0, 2, 0, 3, 0} + sells := []float64{0, 4, 0, 5, 0, 6} + b := stat.BinWeighted(prices, buys, 10, 16, 3) + s := stat.BinWeighted(prices, sells, 10, 16, 3) + if len(b) != 3 || len(s) != 3 { + t.Fatalf("got %d and %d buckets", len(b), len(s)) + } + wantB, wantS := []float64{1, 2, 3}, []float64{4, 5, 6} + for i := range b { + if b[i].Lo != s[i].Lo || b[i].Hi != s[i].Hi { + t.Errorf("bucket %d: %v and %v do not line up", i, b[i], s[i]) + } + if b[i].Sum != wantB[i] || s[i].Sum != wantS[i] { + t.Errorf("bucket %d: sums %v and %v, want %v and %v", i, b[i].Sum, s[i].Sum, wantB[i], wantS[i]) + } + } + + // And the edges are Bin's own. + for i, c := range stat.Bin(prices, 10, 16, 3) { + if c.Lo != b[i].Lo || c.Hi != b[i].Hi { + t.Errorf("bucket %d: weighted %v, counted %v", i, b[i], c) + } + } +} + +// Periods of different lengths, as a calendar's opens give them: a row falls +// in the period its edge starts, and one before the first edge in none. +func TestOHLCAtBucketsBetweenEdges(t *testing.T) { + edges := []float64{10, 13, 30} + ts := []float64{5, 10, 12, 13, 29, 31, 100} + ps := []float64{9, 1, 3, 4, 2, 7, 8} + got := stat.OHLCAt(ts, ps, nil, edges) + want := []stat.Candle{ + {Start: 10, End: 13, Open: 1, High: 3, Low: 1, Close: 3, Volume: 2, Count: 2}, + {Start: 13, End: 30, Open: 4, High: 4, Low: 2, Close: 2, Volume: 2, Count: 2}, + {Start: 30, End: 100, Open: 7, High: 8, Low: 7, Close: 8, Volume: 2, Count: 2}, + } + if len(got) != len(want) { + t.Fatalf("got %+v", got) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("candle %d: %+v, want %+v", i, got[i], want[i]) + } + } + if got := stat.OHLCAt(ts, ps, nil, nil); len(got) != 0 { + t.Errorf("no edges gave %v", got) + } +} + +// Over the same ticks in the same order, the resampler's candles are exactly +// OHLC's and OHLCAt's: the last candle each tick returns, collected at every +// fresh one. +func TestAResamplerProducesTheSameCandles(t *testing.T) { + ts := []float64{5, 7, 9, 14.9, 35, 36, 44.99, 45} + ps := []float64{10, 12, 8, 11, 20, 19, 22, 18} + vs := []float64{1, 2, 3, 4, 5, 6, 7, 8} + run := func(r *stat.Resampler) []stat.Candle { + var out []stat.Candle + for i := range ts { + c, fresh, err := r.Add(ts[i], ps[i], vs[i]) + if err != nil { + t.Fatalf("tick %d: %v", i, err) + } + if fresh { + out = append(out, c) + } else { + out[len(out)-1] = c + } + } + return out + } + check := func(name string, got, want []stat.Candle) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("%s: %d candles, want %d", name, len(got), len(want)) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("%s candle %d: %+v, want %+v", name, i, got[i], want[i]) + } + } + } + check("width", run(&stat.Resampler{Origin: 5, Width: 10}), stat.OHLC(ts, ps, vs, 5, 10)) + edges := []float64{5, 13, 30, 45} + check("edges", run(&stat.Resampler{Edges: edges}), stat.OHLCAt(ts, ps, vs, edges)) +} + +func TestAResamplerRefusesALateTickAndChangesNothing(t *testing.T) { + r := stat.Resampler{Width: 10} + r.Add(25, 1, 1) + before, _ := r.Current() + if _, _, err := r.Add(15, 99, 1); err != stat.ErrLateTick { + t.Errorf("err = %v, want ErrLateTick", err) + } + if after, _ := r.Current(); after != before { + t.Errorf("a late tick changed the open candle: %+v", after) + } + if _, _, err := (&stat.Resampler{}).Add(1, 1, 1); err != stat.ErrNoPeriod { + t.Errorf("a resampler with no width: err = %v", err) + } + r.Reset() + if _, open := r.Current(); open { + t.Error("Reset kept the open candle") + } +} diff --git a/stat/rolling.go b/stat/rolling.go new file mode 100644 index 0000000..8686c84 --- /dev/null +++ b/stat/rolling.go @@ -0,0 +1,236 @@ +package stat + +import "math" + +// The trailing windows. +// +// [MovingAverage] is centred: the value at a row is the mean of rows on both +// sides of it, which is right for a trend drawn after the fact and wrong for a +// quantity that is read at the right-hand edge of a live chart. A centred mean +// at the last row knows nothing of the row that has not arrived, so it moves +// when that row does — the line the reader acted on is not the line they see +// a minute later. Everything here looks only backwards: the value at a row is +// a function of that row and the ones before it, and appending a row never +// changes a value already drawn. That is the property a market indicator is +// defined by, and it is why these are functions of their own rather than an +// option on the centred one. +// +// All of them take the contract [Loess] takes — xs ascending, both columns +// finite, the shorter one wins — and all of them come in pairs with an Append +// form. The window is a count of rows, clamped to the column; pass window <= 0 +// for [DefaultWindow] of the rows. The first rows have fewer predecessors than +// the window and are computed over the part of it that exists, for the reason +// MovingAverage gives for its ends: dropping them would end the line short of +// the data, and a chart with an indicator that starts twenty rows in reads as a +// chart with twenty rows missing. + +// TrailingMean is the simple moving average of a market chart: at each row, +// the mean of that row and the window-1 rows before it. +func TrailingMean(xs, ys []float64, window int) []Point { + return AppendTrailingMean(nil, xs, ys, window) +} + +// AppendTrailingMean is [TrailingMean] writing into a caller-owned slice. dst +// is truncated first. +// +// The sum is carried from one row to the next, as in [AppendMovingAverage]. +func AppendTrailingMean(dst []Point, xs, ys []float64, window int) []Point { + dst = dst[:0] + n := min(len(xs), len(ys)) + if n == 0 { + return dst + } + w := trailingWindow(window, n) + sum := 0.0 + for i := range n { + sum += ys[i] + if i >= w { + sum -= ys[i-w] + } + dst = append(dst, Point{X: xs[i], Y: sum / float64(min(i+1, w))}) + } + return dst +} + +// EMA is the exponential moving average over a window of rows: each row moves +// the average a fraction 2/(window+1) of the way towards itself. +// +// That fraction is the market's convention rather than a statistician's, and +// it is the one a reader told "the 20-day EMA" is expecting: it gives the +// average the same centre of mass as a 20-row simple mean. The average starts +// at the first row's value rather than at the mean of the first window, so it +// has a value from the first row on, and the few rows where that start still +// shows are the rows where every other definition of the start disagrees too. +// +// The weights never reach zero, so unlike the trailing mean the window does not +// bound how far back a row is felt — only how quickly it fades. +func EMA(xs, ys []float64, window int) []Point { + return AppendEMA(nil, xs, ys, window) +} + +// AppendEMA is [EMA] writing into a caller-owned slice. dst is truncated first. +func AppendEMA(dst []Point, xs, ys []float64, window int) []Point { + dst = dst[:0] + n := min(len(xs), len(ys)) + if n == 0 { + return dst + } + alpha := 2 / (float64(trailingWindow(window, n)) + 1) + avg := ys[0] + for i := range n { + avg += alpha * (ys[i] - avg) + dst = append(dst, Point{X: xs[i], Y: avg}) + } + return dst +} + +// RollingStdDev is the population standard deviation of each trailing window: +// the spread a Bollinger band is drawn at a multiple of. +// +// Population rather than sample — divided by the window rather than by one less +// — because that is how the band is defined, and a band that is a few percent +// wider than the reader's own platform draws it is a band nobody trusts. The +// whole-column [StdDev] is the sample form, for the reason its own comment +// gives; the two answer different questions. +// +// A band is two more columns rather than a function here: +// +// mid := stat.TrailingMean(xs, ys, 20) +// sd := stat.RollingStdDev(xs, ys, 20) +// // lower[i] = mid[i].Y - 2*sd[i].Y, upper[i] = mid[i].Y + 2*sd[i].Y +// +// drawn with [github.com/timzifer/figure/geom.Area] between Y and Y2. +func RollingStdDev(xs, ys []float64, window int) []Point { + return AppendRollingStdDev(nil, xs, ys, window) +} + +// AppendRollingStdDev is [RollingStdDev] writing into a caller-owned slice. dst +// is truncated first. +// +// Each window is summed twice — once for its mean, once for the squared +// distances from it — rather than carried as a running sum of squares. That +// makes it linear in the window as well as the column, and it is the price of +// the two-pass form [StdDev] explains: a price column is large values with a +// small spread, which is exactly where the running identity cancels. An +// indicator window is tens of rows, not thousands. +func AppendRollingStdDev(dst []Point, xs, ys []float64, window int) []Point { + dst = dst[:0] + n := min(len(xs), len(ys)) + if n == 0 { + return dst + } + w := trailingWindow(window, n) + for i := range n { + lo := max(i-w+1, 0) + k := float64(i - lo + 1) + mean := 0.0 + for _, v := range ys[lo : i+1] { + mean += v + } + mean /= k + sum := 0.0 + for _, v := range ys[lo : i+1] { + d := v - mean + sum += d * d + } + dst = append(dst, Point{X: xs[i], Y: math.Sqrt(sum / k)}) + } + return dst +} + +// RollingMin is the least value of each trailing window: the lower edge of a +// Donchian channel, and the floor a trailing stop is set from. +func RollingMin(xs, ys []float64, window int) []Point { + return AppendRollingMin(nil, xs, ys, window) +} + +// AppendRollingMin is [RollingMin] writing into a caller-owned slice. dst is +// truncated first. +func AppendRollingMin(dst []Point, xs, ys []float64, window int) []Point { + return appendRollingExtreme(dst, xs, ys, window, func(a, b float64) bool { return a < b }) +} + +// RollingMax is the greatest value of each trailing window: the upper edge of +// a Donchian channel. +func RollingMax(xs, ys []float64, window int) []Point { + return AppendRollingMax(nil, xs, ys, window) +} + +// AppendRollingMax is [RollingMax] writing into a caller-owned slice. dst is +// truncated first. +func AppendRollingMax(dst []Point, xs, ys []float64, window int) []Point { + return appendRollingExtreme(dst, xs, ys, window, func(a, b float64) bool { return a > b }) +} + +// appendRollingExtreme carries the extreme and where it was, and rescans the +// window only when that row falls out of it. +// +// The textbook answer is a deque of candidate rows, which is linear in the +// worst case and needs a buffer the size of the window — an allocation per +// call, or a type with a Reset to hold it. Carrying one row is allocation-free +// and linear on the data a chart actually has: a rescan happens only when the +// extreme is the oldest row, which on a price column is rare. The worst case — +// a column that falls for its whole length, under RollingMax — is linear in the +// window times the column, which for an indicator window is a small constant. +func appendRollingExtreme(dst []Point, xs, ys []float64, window int, better func(a, b float64) bool) []Point { + dst = dst[:0] + n := min(len(xs), len(ys)) + if n == 0 { + return dst + } + w := trailingWindow(window, n) + at := 0 + for i := range n { + lo := max(i-w+1, 0) + switch { + case at < lo: + at = lo + for j := lo + 1; j <= i; j++ { + if !better(ys[at], ys[j]) { + at = j + } + } + case !better(ys[at], ys[i]): + // Ties move to the newer row, so the carried row is the last to + // leave the window of all the rows that share its value. + at = i + } + dst = append(dst, Point{X: xs[i], Y: ys[at]}) + } + return dst +} + +// Cumsum is the running total of ys: at each row, the sum of that row and every +// one before it. +// +// It is what turns the orders at each price into the depth of a book — the +// volume a market order of a given size would walk through — and returns into +// a growth curve. It is here rather than left to a loop because the Append form +// is the one a chart redrawn every frame wants, and because that loop is the +// one written with a running float in the wrong order often enough: this sums +// from the first row, so the last value is the column's total. +func Cumsum(xs, ys []float64) []Point { + return AppendCumsum(nil, xs, ys) +} + +// AppendCumsum is [Cumsum] writing into a caller-owned slice. dst is truncated +// first. +func AppendCumsum(dst []Point, xs, ys []float64) []Point { + dst = dst[:0] + n := min(len(xs), len(ys)) + sum := 0.0 + for i := range n { + sum += ys[i] + dst = append(dst, Point{X: xs[i], Y: sum}) + } + return dst +} + +// trailingWindow turns a caller's width into a count of rows. Unlike the +// centred [halfWindow] it need not be odd: a trailing window has no middle. +func trailingWindow(window, n int) int { + if window <= 0 { + window = int(float64(n) * DefaultWindow) + } + return min(max(window, 1), n) +} diff --git a/stat/rolling_test.go b/stat/rolling_test.go new file mode 100644 index 0000000..65ad12c --- /dev/null +++ b/stat/rolling_test.go @@ -0,0 +1,167 @@ +package stat_test + +import ( + "math" + "testing" + + "github.com/timzifer/figure/stat" +) + +// trailing returns the rows of the trailing window of width w ending at row i, +// the slow way, for the tests to compare the carried forms against. +func trailing(ys []float64, i, w int) []float64 { return ys[max(i-w+1, 0) : i+1] } + +func wobble(n int) (xs, ys []float64) { + return smoothRamp(n, func(x float64) float64 { return 100 + math.Sin(x/3)*10 + math.Cos(x/7)*4 }) +} + +// Every trailing form is compared against the window taken the slow way, at +// every row including the partial windows at the start. +func TestTrailingWindowsMatchTheDirectForm(t *testing.T) { + xs, ys := wobble(53) + direct := map[string]func(win []float64) float64{ + "mean": func(win []float64) float64 { + s := 0.0 + for _, v := range win { + s += v + } + return s / float64(len(win)) + }, + "sd": func(win []float64) float64 { + m := 0.0 + for _, v := range win { + m += v + } + m /= float64(len(win)) + s := 0.0 + for _, v := range win { + s += (v - m) * (v - m) + } + return math.Sqrt(s / float64(len(win))) + }, + "min": func(win []float64) float64 { + m := win[0] + for _, v := range win { + m = math.Min(m, v) + } + return m + }, + "max": func(win []float64) float64 { + m := win[0] + for _, v := range win { + m = math.Max(m, v) + } + return m + }, + } + fast := map[string]func(xs, ys []float64, w int) []stat.Point{ + "mean": stat.TrailingMean, + "sd": stat.RollingStdDev, + "min": stat.RollingMin, + "max": stat.RollingMax, + } + for name, f := range fast { + for _, w := range []int{1, 2, 5, 20, 100} { + got := f(xs, ys, w) + if len(got) != len(xs) { + t.Fatalf("%s window %d: %d points for %d rows", name, w, len(got), len(xs)) + } + for i := range xs { + want := direct[name](trailing(ys, i, min(w, len(xs)))) + if math.Abs(got[i].Y-want) > 1e-9 || got[i].X != xs[i] { + t.Fatalf("%s window %d, row %d: got %v, want (%v, %v)", name, w, i, got[i], xs[i], want) + } + } + } + } +} + +// The property the trailing forms exist for: a row appended to the column +// changes no value already computed. A centred mean fails it at its last rows, +// and the second half of the test says so. +func TestAppendingARowChangesNothingAlreadyDrawn(t *testing.T) { + xs, ys := wobble(40) + for name, f := range map[string]func(xs, ys []float64, w int) []stat.Point{ + "mean": stat.TrailingMean, + "ema": stat.EMA, + "sd": stat.RollingStdDev, + "min": stat.RollingMin, + "max": stat.RollingMax, + "sum": func(xs, ys []float64, _ int) []stat.Point { return stat.Cumsum(xs, ys) }, + } { + before := f(xs[:39], ys[:39], 9) + after := f(xs, ys, 9) + for i := range before { + if before[i] != after[i] { + t.Fatalf("%s: row %d moved from %v to %v when row 39 arrived", name, i, before[i], after[i]) + } + } + } + + before := stat.MovingAverage(xs[:39], ys[:39], 9) + after := stat.MovingAverage(xs, ys, 9) + if before[38] == after[38] { + t.Error("a centred mean's last row did not move either, so the test proves nothing about the trailing forms") + } +} + +// The EMA's fraction is the market's 2/(n+1), started at the first row. +func TestTheEMAMovesTwoOverNPlusOneOfTheWay(t *testing.T) { + xs := []float64{0, 1, 2, 3} + ys := []float64{10, 20, 20, 20} + got := stat.EMA(xs, ys, 3) // alpha = 0.5 + want := []float64{10, 15, 17.5, 18.75} + for i, w := range want { + if math.Abs(got[i].Y-w) > 1e-12 { + t.Fatalf("row %d: got %v, want %v", i, got[i].Y, w) + } + } +} + +// The case the carried extreme is slowest in, a column falling for its whole +// length under RollingMax, is still right. +func TestARollingMaxOverAFallingColumnRescansCorrectly(t *testing.T) { + xs, ys := smoothRamp(30, func(x float64) float64 { return -x }) + for i, p := range stat.RollingMax(xs, ys, 4) { + if want := ys[max(i-3, 0)]; p.Y != want { + t.Fatalf("row %d: got %v, want %v", i, p.Y, want) + } + } +} + +func TestCumsumEndsAtTheTotal(t *testing.T) { + xs, ys := smoothRamp(10, func(x float64) float64 { return x + 1 }) + got := stat.Cumsum(xs, ys) + if got[len(got)-1].Y != 55 { + t.Errorf("last value %v, want the column's total 55", got[len(got)-1].Y) + } +} + +// The spread is taken in two passes per window, so a price column far from +// zero with a tiny spread still has one — the case the running sum of squares +// cancels on. +func TestTheRollingSpreadSurvivesALargeOffset(t *testing.T) { + xs, ys := smoothRamp(50, func(x float64) float64 { return 1e9 + math.Mod(x, 2) }) + for i, p := range stat.RollingStdDev(xs, ys, 10) { + if i < 9 { + continue + } + if math.Abs(p.Y-0.5) > 1e-6 { + t.Fatalf("row %d: spread %v, want 0.5", i, p.Y) + } + } +} + +func TestEmptyColumnsGiveNoPoints(t *testing.T) { + for name, got := range map[string][]stat.Point{ + "mean": stat.TrailingMean(nil, nil, 5), + "ema": stat.EMA(nil, nil, 5), + "sd": stat.RollingStdDev(nil, nil, 5), + "min": stat.RollingMin(nil, nil, 5), + "sum": stat.Cumsum(nil, nil), + } { + if len(got) != 0 { + t.Errorf("%s: %v from no rows", name, got) + } + } +} diff --git a/tag_test.go b/tag_test.go new file mode 100644 index 0000000..6d39ff0 --- /dev/null +++ b/tag_test.go @@ -0,0 +1,118 @@ +package figure_test + +import ( + "math" + "strings" + "testing" + + "github.com/timzifer/figure" + "github.com/timzifer/figure/data" + "github.com/timzifer/figure/geom" + "github.com/timzifer/figure/internal/irtest" + "github.com/timzifer/figure/ir" + "github.com/timzifer/figure/scale" +) + +func lastPriced() *data.Table { + return figure.NewTable(). + Float64("t", []float64{0, 1, 2, 3}). + Float64("o", []float64{90, 92, 95, 93}). + Float64("c", []float64{92, 95, 93, 94.37}) +} + +// The last value is tagged on the price axis at its own precision, the rule +// runs across the panel to it, and the tick label it covers is not drawn. +func TestTheLastValueIsTaggedOnItsAxis(t *testing.T) { + p := figure.New(figure.Size(400, 300)) + p.X(scale.Linear()).Y(scale.Linear(scale.Domain(90, 100))) + p.Add(geom.Line(lastPriced(), geom.X("t"), geom.Y("c")), + geom.LastValue(lastPriced(), geom.X("t"), geom.Y("c"), geom.DirectionBy("o", "c"))) + svg := renderString(t, p) + if !strings.Contains(svg, ">94.37<") { + t.Error("the last value is not tagged at its own precision") + } + if strings.Contains(svg, ">94<") { + t.Error("the tick label under the tag was drawn as well") + } + // Rising — 94.37 closed above its open of 93 — so the tag is blue. + if !strings.Contains(strings.ToLower(svg), "#0072b2") { + t.Error("the tag is not in the rising colour") + } +} + +// The newest row is the one furthest along X, not the last in the table. +func TestTheLastValueIsTheNewestRow(t *testing.T) { + src := figure.NewTable().Float64("t", []float64{3, 1, 2}).Float64("y", []float64{7.25, 1, 2}) + p := figure.New(figure.Size(400, 300)) + p.X(scale.Linear()).Y(scale.Linear(scale.Domain(0, 10))) + p.Add(geom.LastValue(src, geom.X("t"), geom.Y("y"), geom.Rule(false))) + if svg := renderString(t, p); !strings.Contains(svg, ">7.25<") { + t.Error("the tag is not at the newest row's value") + } +} + +// A tag at a value the axis does not show is not drawn. +func TestATagOffTheAxisIsNotDrawn(t *testing.T) { + p := figure.New(figure.Size(400, 300)) + p.X(scale.Linear()).Y(scale.Linear(scale.Domain(0, 50))) + p.Add(geom.LastValue(lastPriced(), geom.X("t"), geom.Y("c"))) + if svg := renderString(t, p); strings.Contains(svg, ">94.37<") { + t.Error("a tag above a pinned axis was drawn") + } +} + +// A snapping crosshair crosses at the candle nearest the pointer's X, at its +// close, and tags its time and price on the axes. +func TestACrosshairSnapsToTheCandleAndTagsItsAxes(t *testing.T) { + src := figure.NewTable(). + Float64("t", []float64{0, 1, 2, 3}). + Float64("o", []float64{10, 12, 15, 13}). + Float64("h", []float64{13, 16, 16, 14}). + Float64("l", []float64{9, 11, 12, 12}). + Float64("c", []float64{12, 15, 13, 13.5}) + x, y := scale.Linear(), scale.Linear() + p := figure.New(figure.Size(400, 300)) + p.X(x).Y(y) + p.Add(geom.Candle(src, geom.X("t"), geom.OHLC("o", "h", "l", "c"))) + rec := irtest.New() + live, err := p.Live(rec.Target()) + if err != nil { + t.Fatal(err) + } + defer live.Close() + live.TrackRows(true) + if err := live.Draw(); err != nil { + t.Fatal(err) + } + cross := &figure.Crosshair{Snap: live.Index(), Tags: true, Show: true, Panel: -1} + live.Overlay(cross) + // Just right of the candle at 2, and high above its close. + cross.At = ir.Point{X: x.Map(2.3), Y: y.Map(15.9)} + rec.Reset() + if err := live.Draw(); err != nil { + t.Fatal(err) + } + var vertical, horizontal float32 = -1, -1 + var texts []string + for _, c := range rec.Calls { + switch { + case c.Op == "Polyline" && len(c.Points) == 2 && c.Points[0].X == c.Points[1].X: + vertical = c.Points[0].X + case c.Op == "Polyline" && len(c.Points) == 2 && c.Points[0].Y == c.Points[1].Y: + horizontal = c.Points[0].Y + case c.Op == "Text": + texts = append(texts, c.Text.Text) + } + } + if math.Abs(float64(vertical-x.Map(2))) > 0.5 { + t.Errorf("the vertical line is at %v, want the candle at %v", vertical, x.Map(2)) + } + if math.Abs(float64(horizontal-y.Map(13))) > 0.5 { + t.Errorf("the horizontal line is at %v, want the candle's close at %v", horizontal, y.Map(13)) + } + // The tags are the last two texts drawn: the snapped time on X and the + // close on Y, each in its axis's format. + if n := len(texts); n < 2 || !strings.HasPrefix(texts[n-2], "2") || !strings.HasPrefix(texts[n-1], "13") { + t.Errorf("the crosshair's tags read %v, want the candle's time 2 and its close 13", texts) + } +} diff --git a/testdata/golden/calendar-fortnight.svg b/testdata/golden/calendar-fortnight.svg new file mode 100644 index 0000000..073239c --- /dev/null +++ b/testdata/golden/calendar-fortnight.svg @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + +Jun 1 12:00 + + +Jun 3 12:00 + + +Jun 5 12:00 + + +Jun 9 12:00 + + +Jun 11 12:00 + + +0.0 + +2.5 + +5.0 + +7.5 + +10.0 + + + + + \ No newline at end of file diff --git a/testdata/golden/candles-ticks.svg b/testdata/golden/candles-ticks.svg new file mode 100644 index 0000000..b397829 --- /dev/null +++ b/testdata/golden/candles-ticks.svg @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + +Jun 2 + +Jun 4 + +Jun 8 + +Jun 10 + +Jun 12 + +90 + +95 + +100 + +105 + +110 + + + + + + \ No newline at end of file diff --git a/testdata/golden/candles.svg b/testdata/golden/candles.svg new file mode 100644 index 0000000..d2189aa --- /dev/null +++ b/testdata/golden/candles.svg @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + +Jun 2 + +Jun 4 + +Jun 8 + +Jun 10 + +Jun 12 + +90 + +95 + +100 + +105 + +110 + + + + + + + + \ No newline at end of file diff --git a/theme/theme.go b/theme/theme.go index d42b640..0d2bece 100644 --- a/theme/theme.go +++ b/theme/theme.go @@ -124,6 +124,13 @@ type Theme struct { Sequential palette.Ramp Diverging palette.Ramp + // Rising and Falling are the colours a candle is drawn in by which way its + // row went. They are blue and vermilion rather than a trading screen's + // green and red, because that pair is the one the most common colour + // deficiency cannot tell apart. See docs/adr/0088. + Rising ir.Color + Falling ir.Color + // SeriesDashes and SeriesMarkers are the redundant encoding: a second and // a third channel a layer is told apart by, so that colour is not the only // one. A layer that set neither [github.com/timzifer/figure/geom.Dash] diff --git a/theme/tokens.go b/theme/tokens.go index a2d8980..0179ba5 100644 --- a/theme/tokens.go +++ b/theme/tokens.go @@ -116,6 +116,9 @@ func Build(t Tokens) Theme { TickCountHintX: 6, TickCountHintY: 5, + Rising: palette.Blue, + Falling: palette.Vermilion, + AxisBreakGap: 1.5 * u, AxisBreakSize: 2 * u, AxisBreakMark: BreakSlash, diff --git a/view.go b/view.go index 38c717b..6a4fa70 100644 --- a/view.go +++ b/view.go @@ -96,8 +96,10 @@ func readAxis(s scale.Scale) axisView { return axisView{} } // Only a scale that can be pinned is worth reading: a domain nothing can - // put back is a number with no use. - if _, ok := s.(scale.Zoomer); !ok { + // put back is a number with no use. Nor is one that fits its view — it is + // derived every frame from the axis that is steered, and putting its + // domain back would pin it (ADR 0089). + if _, ok := s.(scale.Zoomer); !ok || scale.FitsView(s) { return axisView{} } min, max := s.Domain()