From 25217e7f2ce004710cbdc92e4fc132b11dfabcee Mon Sep 17 00:00:00 2001 From: Tim <6703801+timzifer@users.noreply.github.com> Date: Wed, 23 Sep 2026 08:58:16 +0200 Subject: [PATCH 01/42] =?UTF-8?q?docs:=20=C2=A714=20says=20what=20is=20bui?= =?UTF-8?q?lt,=20and=20the=20roadmap=20it=20carried=20is=20drawn?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Everything "What is next" listed — the tidy tree, probability paper, the statistical instruments, the raster, the horizon chart, the bivariate channel, the map — has shipped. The section now groups what exists by the question each answers and keeps a short list of what is genuinely open: v1.0, a mark that fills a closed ring, a conic projection, labels that avoid other layers', a keyframe timeline. The status note at the top said the seams were still being reshaped; they are not. Co-Authored-By: Claude Opus 5.5 (1M context) --- CONCEPT.md | 226 ++++++++++++++++++++++------------------------------- 1 file changed, 94 insertions(+), 132 deletions(-) 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 From e3b41af73a22590a4fbbf3d97bad5466d604f89c Mon Sep 17 00:00:00 2001 From: Tim <6703801+timzifer@users.noreply.github.com> Date: Wed, 23 Sep 2026 09:00:46 +0200 Subject: [PATCH 02/42] docs: features.md names the marks, axes and labels it had left out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Text, ErrorBar, QQ, Graph, Contour, Raster and Horizon; axis breaks, label formats and locales; second axes and tracks; label avoidance and callouts; and nulls in text and time columns. Each shipped with its ADR and none reached the one-list summary CONCEPT §14 now points to. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/features.md | 55 ++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 51 insertions(+), 4 deletions(-) diff --git a/docs/features.md b/docs/features.md index 7aeda78..ad8f557 100644 --- a/docs/features.md +++ b/docs/features.md @@ -22,6 +22,15 @@ 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 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 @@ -31,13 +40,25 @@ The feature surface in one list: scales, marks, coordinate systems, layout, outp 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 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 @@ -45,7 +66,7 @@ The feature surface in one list: scales, marks, coordinate systems, layout, outp the summary rather than on the rows ([ADR 0028](adr/0028-distribution-stats.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 +85,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 +250,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 +341,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 +352,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 From 43299fc4ab493d18ea13ef63bc7b3cfa16c304ed Mon Sep 17 00:00:00 2001 From: Tim <6703801+timzifer@users.noreply.github.com> Date: Wed, 23 Sep 2026 09:03:45 +0200 Subject: [PATCH 03/42] docs: milestones.md tells the twenty-one records after the locus The record stopped at the gantt chart while ternary charts, horizons, rasters, the bivariate channel, probability paper, the tidy tree and the instruments came before it and hatching through axis breaks came after. Each entry is drawn from its ADR and its amendments, in the order they were implemented. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/milestones.md | 1133 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1133 insertions(+) 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) From 6084ef9079779cd319bf515d841b2433c13171ca Mon Sep 17 00:00:00 2001 From: Tim <6703801+timzifer@users.noreply.github.com> Date: Wed, 23 Sep 2026 09:03:53 +0200 Subject: [PATCH 04/42] docs: ADR 0084 proposes what v1.0 promises and how it is checked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CONCEPT §15 says v1.0.0 returns when the surface settles and nothing says what settled means. The record makes it four stdlib-only checks and a second audit: a test that applies the growth rule, with a point, an interval or a rectangle counted as one value; a committed API baseline CI diffs; a JSON corpus that is only ever added to; and the first audit's verdicts extended over what arrived since. Four interfaces that break the rule as clarified — stat.Family, coord.Exploder, geom.LabelPlacer, scale.Breaker — change before the tag. Proposed rather than accepted: nothing in it is implemented yet. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/adr/0084-what-v1-promises.md | 183 ++++++++++++++++++++++++++++++ docs/adr/README.md | 1 + 2 files changed, 184 insertions(+) create mode 100644 docs/adr/0084-what-v1-promises.md diff --git a/docs/adr/0084-what-v1-promises.md b/docs/adr/0084-what-v1-promises.md new file mode 100644 index 0000000..643df47 --- /dev/null +++ b/docs/adr/0084-what-v1-promises.md @@ -0,0 +1,183 @@ +# 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 or a pair of +columns — shapes that are fixed by what they are, the way 0060 argued the +tuple-returning coord calls are fixed. 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 four checks are green and a second audit has no open +CHANGE BEFORE V1 row — 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 fixed geometric shape as one value + +The rule in CONCEPT §15 is amended by one sentence: **a point, a pair of points, +an interval, a rectangle, or a pair of parallel columns counts as one +parameter**, because its arity is the geometry'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 under the reading above, and fails on any it cannot account for. +The exceptions are an allowlist in the test with a reason beside each — +`ir.Backend`'s drawing calls and the geometric shapes — so that adding one is a +visible line in a diff rather than a silence. A new interface that breaks the +rule fails CI the day it is written, which is the only day it is free to fix. + +### 3. The exported surface has a baseline, and CI diffs it + +`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`, and CI fails when the file does not match the code. + +Before v1 the diff is a review aid: a change to the surface is a change to a +committed file, and a reviewer reads it. **From v1 the check also refuses a +removed or changed line**; an added line is the only kind that passes without a +major version. 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, and the check it performs is a sorted text comparison. + +### 4. The JSON dialect has a corpus that is never edited + +`spec/testdata/v1/` holds one document per mark, coord, scale kind and guide, +written by the code at the tag. A test parses each, renders it, and compares the +result with its golden file. After v1 **the corpus is only ever added to**: a +document 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. + +### 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/` baseline 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. + +### Order of work + +1. This record, and the amendment to CONCEPT §15. +2. The growth-rule test, failing on the four methods; the four request structs, + making it pass. +3. `apicheck` and the committed baseline. +4. The second audit, working from the baseline; 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. `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 geometric shapes is written down, so the next + `Area`-shaped method needs no argument — and a method that is *not* one of + those shapes cannot borrow the exception by resembling one, because the test + names the methods it excuses. + +## 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 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 + fixed shape — are a rule; three is a list, and the rule should be restated + rather than extended. +- The baseline 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/README.md b/docs/adr/README.md index a00edec..2fc4c20 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -93,6 +93,7 @@ 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 | — | 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 From c86307245c064460036e084cdaa60c033dd5bb4a Mon Sep 17 00:00:00 2001 From: Tim <6703801+timzifer@users.noreply.github.com> Date: Wed, 23 Sep 2026 09:18:05 +0200 Subject: [PATCH 05/42] docs: ADR 0084 refuses a method added to an interface, and names ColorAt Review of the proposal. The baseline check let every added line through after v1, which is the one addition that breaks every outside implementation; it now refuses a method added to an exported interface and leaves a new field to the reviewer's zero-value question. The exception to the growth rule is a value of fixed arity rather than a geometric shape, which is what ColorAt's bivariate reading is. The test is said to be an allowlist by name and to read every exported interface, the corpus pins documents rather than pixels, and the Go floor is out of scope. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/adr/0084-what-v1-promises.md | 90 ++++++++++++++++++++++--------- 1 file changed, 66 insertions(+), 24 deletions(-) diff --git a/docs/adr/0084-what-v1-promises.md b/docs/adr/0084-what-v1-promises.md index 643df47..79124f9 100644 --- a/docs/adr/0084-what-v1-promises.md +++ b/docs/adr/0084-what-v1-promises.md @@ -44,9 +44,14 @@ 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 or a pair of -columns — shapes that are fixed by what they are, the way 0060 argued the -tuple-returning coord calls are fixed. A rule that a test cannot apply without +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. @@ -62,12 +67,13 @@ CHANGE BEFORE V1 row — 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 fixed geometric shape as one value +### 1. The growth rule counts a value of fixed arity as one parameter -The rule in CONCEPT §15 is amended by one sentence: **a point, a pair of points, -an interval, a rectangle, or a pair of parallel columns counts as one -parameter**, because its arity is the geometry's rather than a description that -can grow. Everything else counts as what it is. `ir.Backend`'s drawing calls +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 @@ -82,32 +88,62 @@ and `geom`. 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 under the reading above, and fails on any it cannot account for. -The exceptions are an allowlist in the test with a reason beside each — -`ir.Backend`'s drawing calls and the geometric shapes — so that adding one is a -visible line in a diff rather than a silence. A new interface that breaks the +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. ### 3. The exported surface has a baseline, and CI diffs it `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`, and CI fails when the file does not match the code. +`api/.txt`, and CI fails when the file does not match the code. 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. Before v1 the diff is a review aid: a change to the surface is a change to a -committed file, and a reviewer reads it. **From v1 the check also refuses a -removed or changed line**; an added line is the only kind that passes without a -major version. It is a command of its own, beside `releasecheck`, rather than +committed file, and a reviewer reads it. From v1 the check sorts a change into +three kinds: + +- **A removed or changed line is refused.** That is a major version. +- **A method added to an exported interface 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. +- **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. 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, and the check it performs is a sorted text comparison. ### 4. The JSON dialect has a corpus that is never edited -`spec/testdata/v1/` holds one document per mark, coord, scale kind and guide, -written by the code at the tag. A test parses each, renders it, and compares the -result with its golden file. After v1 **the corpus is only ever added to**: a -document 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. +`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 @@ -154,10 +190,12 @@ first audit could not ask them: 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 geometric shapes is written down, so the next +- 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 shapes cannot borrow the exception by resembling one, because the test + those values cannot borrow the exception by resembling one, because the test names the methods it excuses. +- 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 @@ -170,6 +208,10 @@ first audit could not ask them: 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. @@ -177,7 +219,7 @@ first audit could not ask them: ## Revisit if - The growth-rule test wants a third kind of exception. Two — the ink and a - fixed shape — are a rule; three is a list, and the rule should be restated + value of fixed arity — are a rule; three is a list, and the rule should be restated rather than extended. - The baseline 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. From 57b9b6a52fe355e48176e792edb2884adec432c7 Mon Sep 17 00:00:00 2001 From: Tim <6703801+timzifer@users.noreply.github.com> Date: Wed, 23 Sep 2026 09:33:25 +0200 Subject: [PATCH 06/42] docs: ADR 0084 checks against the published manifest, on every platform Second review. A committed manifest compared with the code refuses nothing, because a change updates both; compatibility is now checked against the manifest at the latest release tag, so a declaration added in a minor is protected from then on. The manifest records whether each struct is comparable, since a slice field breaks == and map keys with no change in behaviour; covers js/wasm, where backend/canvas and Live.Bind exist; and unkeyed struct literals are stated outside the promise. The release tooling's Major: 0 lock from ADR 0059 is lifted as a step of its own rather than found at the tag. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/adr/0084-what-v1-promises.md | 119 ++++++++++++++++++++++-------- 1 file changed, 90 insertions(+), 29 deletions(-) diff --git a/docs/adr/0084-what-v1-promises.md b/docs/adr/0084-what-v1-promises.md index 79124f9..9c0f7f4 100644 --- a/docs/adr/0084-what-v1-promises.md +++ b/docs/adr/0084-what-v1-promises.md @@ -23,9 +23,9 @@ 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). +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 @@ -62,8 +62,9 @@ review burden; after it, it is a broken promise that ships. ## Decision -**v1.0.0 is tagged when four checks are green and a second audit has no open -CHANGE BEFORE V1 row — not when the surface feels settled.** Every check is +**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. @@ -101,23 +102,57 @@ 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. +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. -### 3. The exported surface has a baseline, and CI diffs it +### 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`, and CI fails when the file does not match the code. 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. - -Before v1 the diff is a review aid: a change to the surface is a change to a -committed file, and a reviewer reads it. From v1 the check sorts a change into -three kinds: - -- **A removed or changed line is refused.** That is a major version. +`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. + +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.** From `v1.0.0`, the + reference is the manifest as it stands at the **latest release tag** of the + same major version, 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 same comparison against the tag it is about + to create. + +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 exported interface 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 @@ -125,9 +160,16 @@ three kinds: - **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. 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, and the check it performs is a sorted text comparison. + 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 @@ -151,7 +193,7 @@ 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/` baseline rather than `go doc`, so that nothing it reads can be +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: @@ -168,17 +210,32 @@ first audit could not ask them: 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. + ### Order of work -1. This record, and the amendment to CONCEPT §15. +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; the four request structs, making it pass. -3. `apicheck` and the committed baseline. -4. The second audit, working from the baseline; its CHANGE BEFORE V1 rows, each +3. `apicheck` — every configuration, comparability, both checks — and the + committed manifest. +4. The second audit, working from the manifest; 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. `releasecheck` over every module, the status lines in README and CONCEPT, and +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. @@ -194,6 +251,10 @@ first audit could not ask them: `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. - 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. @@ -219,7 +280,7 @@ first audit could not ask them: ## 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 baseline diff is routinely committed without being read. Then the check + 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. From 83b9af76543d2d1189ad5f6fb18563c5001be45a Mon Sep 17 00:00:00 2001 From: Tim <6703801+timzifer@users.noreply.github.com> Date: Wed, 23 Sep 2026 10:04:16 +0200 Subject: [PATCH 07/42] docs: ADR 0084 records six changes before the freeze, from a third review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review reproduced two silent round-trip losses — a parallel coord reading an undescribable dimension back as linear, and every size scale reading back as the built-in area mapping — and found four things v1 would freeze by accident: a window's positional callbacks, registry names that can collide with a later built-in, tuning budgets exported as constants, and comparability on request types that exist to grow. Each is recorded as a change the second audit starts from. The checks are sharpened to match: the growth-rule test reads callbacks and every module that tags v1; the compatibility reference is the latest earlier release, never the tag under test, and v1.0.0 has none; a method is refused only on an interface already published; and a declaration gaining a platform is an addition. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/adr/0084-what-v1-promises.md | 113 +++++++++++++++++++++++++----- 1 file changed, 97 insertions(+), 16 deletions(-) diff --git a/docs/adr/0084-what-v1-promises.md b/docs/adr/0084-what-v1-promises.md index 9c0f7f4..07e7c77 100644 --- a/docs/adr/0084-what-v1-promises.md +++ b/docs/adr/0084-what-v1-promises.md @@ -105,6 +105,14 @@ 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 @@ -132,7 +140,9 @@ 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. +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: @@ -141,22 +151,29 @@ It is checked twice, because one comparison cannot do both jobs: 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.** From `v1.0.0`, the - reference is the manifest as it stands at the **latest release tag** of the - same major version, 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 same comparison against the tag it is about - to create. +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 exported interface 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 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 @@ -222,16 +239,77 @@ 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; the four request structs, - making it pass. +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; its CHANGE BEFORE V1 rows, each - in its own commit. +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. @@ -255,6 +333,9 @@ no suffix on the module path, so no import changes. 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. From 489acb4410ca9d572393fd9a8afa966082ef5b7c Mon Sep 17 00:00:00 2001 From: Tim <6703801+timzifer@users.noreply.github.com> Date: Mon, 28 Sep 2026 07:25:21 +0200 Subject: [PATCH 08/42] ir: a downward triangle marker, for the sell beside the buy MarkerTriangleDown is appended to the marker set, as its doc comment requires, and mirrors the upward triangle through its centroid so a pair drawn at one point occupies the same box turned over. The dialect spells it "triangle-down", Vega-Lite's own name. The theme's redundant-encoding ladder is left alone: it is a separate decision and every golden draws it. Generated with AI Co-Authored-By: AI --- ir/marker.go | 6 ++++++ ir/marker_test.go | 43 +++++++++++++++++++++++++++++++++++++++++++ ir/style.go | 4 ++++ spec/spec_test.go | 1 + spec/vocab.go | 6 ++++-- 5 files changed, 58 insertions(+), 2 deletions(-) create mode 100644 ir/marker_test.go 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/spec/spec_test.go b/spec/spec_test.go index 10b5bb8..1625e5a 100644 --- a/spec/spec_test.go +++ b/spec/spec_test.go @@ -81,6 +81,7 @@ 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))}, + {"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))}, diff --git a/spec/vocab.go b/spec/vocab.go index 3967efe..e1c1bc8 100644 --- a/spec/vocab.go +++ b/spec/vocab.go @@ -419,9 +419,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 +433,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 From 071fa47cb2cc6bc7765eaf662394ada407a847e0 Mon Sep 17 00:00:00 2001 From: Tim <6703801+timzifer@users.noreply.github.com> Date: Mon, 28 Sep 2026 07:25:21 +0200 Subject: [PATCH 09/42] stat: trailing windows, OHLC resampling and a weighted bin MovingAverage is centred, which is wrong at the right-hand edge of a live chart: its last value moves when the next row arrives. TrailingMean, EMA, RollingStdDev, RollingMin, RollingMax and Cumsum look only backwards, so appending a row changes nothing already drawn, and a test says so. RollingStdDev is the population form a Bollinger band is defined with, summed twice per window for the cancellation StdDev avoids. OHLC summarises ticks into candles of a fixed width from an origin and leaves an interval nobody traded in without one. BinWeighted is Bin summing weights, with Bin's own edges, so the buys and the sells of a volume profile line up. Every Append form is allocation-free into a warm slice. Generated with AI Co-Authored-By: AI --- stat/alloc_test.go | 28 +++++ stat/bin.go | 74 ++++++++++++++ stat/ohlc.go | 93 +++++++++++++++++ stat/ohlc_test.go | 80 +++++++++++++++ stat/rolling.go | 236 +++++++++++++++++++++++++++++++++++++++++++ stat/rolling_test.go | 167 ++++++++++++++++++++++++++++++ 6 files changed, 678 insertions(+) create mode 100644 stat/ohlc.go create mode 100644 stat/ohlc_test.go create mode 100644 stat/rolling.go create mode 100644 stat/rolling_test.go diff --git a/stat/alloc_test.go b/stat/alloc_test.go index 9fbff3c..9ede745 100644 --- a/stat/alloc_test.go +++ b/stat/alloc_test.go @@ -94,3 +94,31 @@ 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) + 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) }, + "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/ohlc.go b/stat/ohlc.go new file mode 100644 index 0000000..f048f52 --- /dev/null +++ b/stat/ohlc.go @@ -0,0 +1,93 @@ +package stat + +import "math" + +// 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 + 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, 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 +} diff --git a/stat/ohlc_test.go b/stat/ohlc_test.go new file mode 100644 index 0000000..4b93752 --- /dev/null +++ b/stat/ohlc_test.go @@ -0,0 +1,80 @@ +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, Open: 10, High: 12, Low: 8, Close: 11, Volume: 10, Count: 4}, + {Start: 35, 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) + } + } +} 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) + } + } +} From 01e791145aefd2578ba2cd7ebe71a72e14b3cc1f Mon Sep 17 00:00:00 2001 From: Tim <6703801+timzifer@users.noreply.github.com> Date: Mon, 28 Sep 2026 07:25:21 +0200 Subject: [PATCH 10/42] examples/market: candles, volume and a volume profile from existing marks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Rect body over an ErrorBar wick, a Bollinger band as an Area, an EMA and an SMA, crossings as upward and downward triangles, daily volume in a bottom track sharing time, and the volume at each price in a right track sharing price, on a time axis with the weekends folded. Each place the recipe costs the caller a line — the direction column, the weekend spans, the profile's hand-stacked rects — is a gap ADR 0085 ranks. Generated with AI Co-Authored-By: AI --- examples/market/main.go | 280 +++++++++++++++++++++++++++++++++++ examples/market/main_test.go | 63 ++++++++ 2 files changed, 343 insertions(+) create mode 100644 examples/market/main.go create mode 100644 examples/market/main_test.go diff --git a/examples/market/main.go b/examples/market/main.go new file mode 100644 index 0000000..302974f --- /dev/null +++ b/examples/market/main.go @@ -0,0 +1,280 @@ +// 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. +// +// Every part of it is a recipe over marks that already exist, which is what +// docs/adr/0085-what-a-market-chart-needs.md catalogues. A candle is a rect for +// the body and an error bar without caps for the wick; 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 rect in a right track that shares the price +// axis. What the recipe costs the caller is visible below and is the list that +// record ranks: the direction of each candle is a column computed by hand, the +// weekends are spans computed by hand, and the profile's bars are rects because +// a bar does not lie on its side. +// +// 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") + flag.Parse() + if err := run(*out); 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, 560), + figure.Title("FIG — daily, weekends folded"), + ) + // The weekends are left out of the axis rather than drawn empty. A trading + // calendar is a list of spans until the library has a calendar; see the + // record's first gap. + p.X(scale.Time(scale.TimeFold(weekends(start, start.AddDate(0, 0, calendarDays))...))) + 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σ"))) + + // A candle is two marks on the same rows: the wick from low to high, and + // the body from open to close over it. The body is a rect because its two + // ends are two columns; which one is higher is the direction, and the + // colour is a column because a mark cannot compute one from two others. + dir := scale.Named(map[string]ir.Color{"up": up, "down": down}) + p.Add(geom.ErrorBar(candles, geom.X("t"), geom.Y("low"), geom.Y2("high"), + geom.Caps(false), geom.ColorBy("dir", dir))) + p.Add(geom.Rect(candles, geom.X("t"), geom.Y("open"), geom.Y2("close"), + geom.BarWidth(0.7), geom.ColorBy("dir", dir))) + + 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. + 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.ColorBy("dir", dir), geom.Opacity(0.6))) + + // The volume traded at each price, beside the price and on the same price + // axis. Buys from zero out, sells stacked after them, so the bar's length + // is the total and its split is the balance. They are rects rather than + // bars because a bar grows up its Y axis and this one has to grow across. + p.Track(figure.Right, figure.TrackSize(140), figure.TrackScale(scale.Linear(scale.Zero())), figure.TrackAxis(true)). + Add(geom.Rect(profile(m), geom.X("from"), geom.X2("to"), geom.Y("lo"), geom.Y2("hi"), + 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 + +// 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 wd := day.Weekday(); wd == time.Saturday || wd == time.Sunday { + 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) + dir := make([]string, 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 + dir[i] = "down" + if d.Up() { + dir[i] = "up" + } + } + + // 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)) + 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). + String("dir", dir). + Float64("ema", ema).Float64("mid", mid). + 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, as the rects of 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 from, to, blo, bhi []float64 + var side []string + for i := range b { + from = append(from, 0, b[i].Sum) + to = append(to, b[i].Sum, b[i].Sum+s[i].Sum) + blo = append(blo, b[i].Lo, s[i].Lo) + bhi = append(bhi, b[i].Hi, s[i].Hi) + side = append(side, "buy", "sell") + } + return figure.NewTable(). + Float64("from", from).Float64("to", to). + Float64("lo", blo).Float64("hi", bhi). + String("side", side) +} + +// weekends is every Saturday-to-Monday span between from and to, as the folds +// of a time axis. +func weekends(from, to time.Time) []scale.TimeSpan { + var out []scale.TimeSpan + for d := from; d.Before(to); d = d.AddDate(0, 0, 1) { + if d.Weekday() == time.Saturday { + out = append(out, scale.TimeSpan{From: d, To: d.AddDate(0, 0, 2)}) + } + } + return out +} + +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..290c2f2 --- /dev/null +++ b/examples/market/main_test.go @@ -0,0 +1,63 @@ +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 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 { + t.Fatalf("%d candles, want one per weekday of %d calendar days", len(days), calendarDays) + } + folds := weekends(start, start.AddDate(0, 0, calendarDays)) + 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()) + } +} From 1a1837d295d53b8669b2120cf7f41746db4ddf10 Mon Sep 17 00:00:00 2001 From: Tim <6703801+timzifer@users.noreply.github.com> Date: Mon, 28 Sep 2026 07:25:21 +0200 Subject: [PATCH 11/42] docs: ADR 0085 catalogues what a market chart needs A catalogue in the manner of 0058: the candle, the trading calendar, the combined panels, indicators, annotations and interaction, each with what exists, what is a recipe and what is missing, ranked by what a reader loses. The trading calendar is first, which is 0083's deferred business-day axis; the candle as one mark second. The Stat interface stays refused, because an indicator is numbers in and numbers out drawn by an ordinary mark. The easy wins landed with the record. Generated with AI Co-Authored-By: AI --- docs/adr/0085-what-a-market-chart-needs.md | 245 +++++++++++++++++++++ docs/adr/README.md | 14 ++ docs/chart-types.md | 4 +- docs/features.md | 11 +- 4 files changed, 272 insertions(+), 2 deletions(-) create mode 100644 docs/adr/0085-what-a-market-chart-needs.md 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..4bba658 --- /dev/null +++ b/docs/adr/0085-what-a-market-chart-needs.md @@ -0,0 +1,245 @@ +# 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 two rects per bucket, stacked across, from + `stat.BinWeighted` over one interval. + +**Missing.** + +- **A bar on its side.** `geom.Orient` is read by the tree and by the gradient, + not by `geom.Bar`, so the profile's bars are rects with a hand-stacked + `X`/`X2`. It works, and it is the one place the example is more code than + the chart deserves. +- **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.** The profile works as rects; this is convenience. +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. +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. +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. +5. The rest, as asked for. + +## 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/README.md b/docs/adr/README.md index 2fc4c20..6218633 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -94,6 +94,7 @@ depends on. | [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 | 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 @@ -329,3 +330,16 @@ 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. diff --git a/docs/chart-types.md b/docs/chart-types.md index ae52434..b2a60f7 100644 --- a/docs/chart-types.md +++ b/docs/chart-types.md @@ -71,7 +71,9 @@ 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 | `Rect` for open..close, an `ErrorBar` with `Caps(false)` for low..high, colour by `stat.Candle.Up`; `stat.OHLC` resamples ticks into candles, `scale.TimeFold` takes the weekends out — see [ADR 0085](adr/0085-what-a-market-chart-needs.md) and `examples/market` | +| Volume under a price / volume profile beside it | a `Bar` in a bottom `Track` sharing time; a `Rect` per bucket 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` | | 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 ad8f557..d713340 100644 --- a/docs/features.md +++ b/docs/features.md @@ -33,7 +33,7 @@ The feature surface in one list: scales, marks, coordinate systems, layout, outp 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 @@ -65,6 +65,15 @@ The feature surface in one list: scales, marks, coordinate systems, layout, outp 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** — a candle is a `Rect` body over an `ErrorBar` wick, + volume is a `Bar` in a bottom track sharing time, and a volume profile is a + `Rect` 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. `examples/market` draws all of + it, with the weekends folded + ([ADR 0085](adr/0085-what-a-market-chart-needs.md)). - **Relational and hierarchical marks** — **`Treemap`**, **`Icicle`**, **`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 From 68f690946d1c29ddf9b770de2430b0cc8f471e93 Mon Sep 17 00:00:00 2001 From: Tim <6703801+timzifer@users.noreply.github.com> Date: Mon, 28 Sep 2026 08:09:19 +0200 Subject: [PATCH 12/42] docs: ADR 0086 proposes a trading calendar that produces folds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A calendar is a value in scale — sessions per weekday in a location, holidays and short days as the caller's lists — whose Folds(from, to) the time axis takes like any others, so the dialect is unchanged. The one amendment to 0083: a tick at a calendar step of a day or longer that lands in a fold moves to the next open, because with the nights folded every midnight is inside one and the axis has no dates. Opens(from, to) gives stat.OHLCAt the session edges a fixed width cannot across DST. Generated with AI Co-Authored-By: AI --- ...calendar-is-a-value-that-produces-folds.md | 190 ++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 docs/adr/0086-a-trading-calendar-is-a-value-that-produces-folds.md diff --git a/docs/adr/0086-a-trading-calendar-is-a-value-that-produces-folds.md b/docs/adr/0086-a-trading-calendar-is-a-value-that-produces-folds.md new file mode 100644 index 0000000..3da8c90 --- /dev/null +++ b/docs/adr/0086-a-trading-calendar-is-a-value-that-produces-folds.md @@ -0,0 +1,190 @@ +# 0086 — A trading calendar is a value that produces folds, and a tick that lands in one moves to the next open + +**Status:** Proposed · **Date:** 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, and a caller can already pass the spans a calendar produces as +folds."* Its *Revisit if* named the case, and +[ADR 0085](0085-what-a-market-chart-needs.md) ranked it first among the gaps of +a market chart, because a candle chart with the weekends drawn empty is a +chart of the calendar rather than of the market. + +`examples/market` shows what the caller pays today. Twelve weeks of daily +candles need twelve weekend spans, computed in a loop. That is cheap. What is +not cheap is everything the loop leaves out: + +- **Nights.** An intraday chart over a month is twenty-two sessions and + twenty-one nights and four weekends, and the night before a Monday is part + of the weekend. The caller merges them or 0083's merge does, but the caller + has to produce them first, one per day, in the market's own time zone. +- **Daylight saving.** A session that opens at 09:30 in New York opens at + 13:30 UTC for half the year and 14:30 for the other half. A loop that adds + 24 hours to the last open is an hour wrong from the second Sunday in March, + and the chart does not say so: the fold is merely an hour off, and an hour + of real trading disappears into it. +- **Holidays and short days.** A market is closed on days no rule predicts + and closes early on others. Both are a list, and the list is the market's. +- **The labels.** This is the part a loop cannot fix. 0083 §5 walks one tick + step through each kept piece and forbids a tick strictly inside a cut. On an + intraday axis 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. + +So the calendar is two things: a value that knows when a market is open, and +a rule about where a tick goes when the step it was chosen at lands in the +market's closed time. + +## Decision + +**A calendar is a value in `scale` that says when a market is open and +produces the folds for an interval. The time axis takes those folds exactly as +it takes any others. A tick at a calendar step of a day or longer that lands +in a fold moves to the fold's far edge, which is the next open.** Six claims. + +### 1. A calendar is a value, not a scale + +```go +cal := scale.Sessions(nyc, 9*time.Hour+30*time.Minute, 16*time.Hour). + Holidays(closed...). + Early(date(2026, 11, 27), 13*time.Hour) + +p.X(scale.Time(scale.In(nyc), scale.TimeFold(cal.Folds(from, to)...))) +``` + +`scale.Calendar` holds a location, the sessions of each weekday as offsets +from local midnight, a set of closed dates and a set of dates whose sessions +differ. Its constructors are the three cases that cover almost every chart: + +- `scale.Weekdays(loc)` — open all day Monday to Friday. The daily chart. +- `scale.Sessions(loc, open, close)` — one session a weekday. The intraday + chart of one exchange. +- `scale.Week(loc, [7][]Session)` — anything else: a session that spans + midnight, a lunch break, a market open on Sunday evening. + +It is not a new scale kind, and that is the decision the rest follows from. A +calendar changes nothing about how a time axis maps, inverts or ticks that a +fold does not already change, and 0083 has already said everything about how +a fold does that. A `scale.Business` kind would be a second time scale whose +only difference from the first is where its folds come from — a second +`Train`, a second `Ticks`, a second dialect word — to carry a list the first +already takes. + +### 2. The calendar produces folds for an interval, and the caller names it + +`cal.Folds(from, to)` is the complement of the sessions over `[from, to)`: +every closed stretch, sorted and merged, with a weekend and the nights on +either side of it one fold. It is computed day by day in the calendar's +location with `time.Date`, which is what makes it right across a daylight +saving change: an open is a local clock time, not a multiple of 24 hours from +the last one. + +The interval is the caller's, rather than the scale producing folds for +whatever domain it trains to, for 0083's reason — *the document a chart is +written into holds the spans, not the query*. A folded axis in the JSON +dialect is a list of `"folds"`, readable by any consumer, and a document that +held a calendar instead would need every consumer to agree on what a calendar +is, which exchange's holidays it has, and in which year. So the dialect is +unchanged: a calendar is a Go value that writes folds, and the folds are what +is serialised. + +The cost is a live chart whose data outgrows the interval it was folded for. +A caller streaming ticks folds far enough ahead — a calendar is cheap to ask +for a year — or refolds when the stream crosses the end; see *Revisit if*. + +### 3. Holidays are the caller's list + +`Holidays(dates...)` closes a date all day; `Early(date, close)` and +`Late(date, open)` shorten one session; `Special(date, sessions...)` replaces +one day entirely. The library ships no exchange's holidays. They change every +year, they are announced by the exchange rather than derived from a rule, and +a table in a charting library would be out of date by the release after the +one that shipped it — which, for a chart that silently draws a closed day as +a flat candle-less gap, is worse than no table. A caller already has the list, +because the same list decides when their data arrives. + +### 4. A tick that lands in a fold moves to the next open + +This amends 0083 §5 for one case. When the time step chosen for the kept +length is a calendar unit of a day or longer — a day, a week, a month — and a +tick of that step lands strictly inside a fold, it moves to the fold's upper +edge and keeps its label. A day tick at midnight becomes a tick at 09:30 +labelled with the day; a month tick on a Saturday becomes a tick at Monday's +open labelled with the month. + +It is only for calendar steps, because only they name a day rather than an +instant. A tick labelled 12:00 that moved to 09:30 would be a label that lies; +a tick labelled *3 Mar* at 09:30 on 3 March is true. It never moves a tick +past the next tick of the same step, and two ticks that land in the same fold +become one, the later label winning — a weekend 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 rather than of calendars, so it applies +to folds from `SpansWhere` too: a machine log with the nights folded gets day +labels for the same reason. + +### 5. A calendar is also where a day's candles start + +`stat.OHLC` buckets by a fixed width from an origin, which is right for a +minute and for an hour and wrong for a day across a daylight saving change, +where the day is 23 or 25 hours long. `cal.Opens(from, to)` returns the +session opens as a column of edges, and `stat.OHLCAt(ts, ps, vs, edges)` +buckets between consecutive edges — a candle per session, however long the +session was. `stat` stays numbers in and numbers out: it takes edges, not a +calendar. + +### 6. Where it is refused + +- **On an axis that is not a time axis.** A calendar is dates. +- **On a log time axis**, for 0083's reason that no coord yet cuts one. +- **With a session longer than a day or ending before it opens** outside + `Week`, which is where an overnight session is spelled explicitly. + +## Consequences + +- A daily chart with the weekends and holidays out is one line, and an + intraday chart with the nights out is the same line with a session. +- An intraday axis with folded nights has dates on it. Without claim 4 it + cannot. +- The dialect is unchanged, and 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: a binary search per row and no + allocation. +- The slash marks on the axis line are one per fold. On a year of intraday + data they are drawn once where closer together than their own size, which + 0083 §6 already does. + +## Not in scope + +- **Exchange holiday tables.** Claim 3. +- **An index axis** — every session the same width regardless of its length, + as some trading platforms draw one. That is an ordinal axis over session + numbers with time labels, which a caller can build on `scale.Ordinal` now; + it gives up the property that an hour is an hour, which is the property a + shortened session is visible by. +- **Session colouring** — pre-market and after-hours drawn as tinted bands. + That is `VBand` over the spans a second calendar produces. +- **A calendar in the dialect.** Claim 2. + +## Revisit if + +- A live chart needs folds that follow the stream without the caller + refolding — which is a scale that derives cuts from its trained domain, and + would reopen 0083's reason for keeping cuts fixed at construction. +- Someone needs the index axis above with time-aware ticks, which is a scale + kind of its own. + +## Order of work + +1. `scale.Calendar`, its three constructors, `Holidays`, `Early`, `Late`, + `Special`, `Folds` and `Opens`, with tests across both daylight saving + changes of one location and a session spanning midnight. +2. Claim 4 in the time scale's tick walk, with a golden of an intraday week. +3. `stat.OHLCAt` and its Append pair. +4. `examples/market` folds from a calendar instead of its own loop, and gains + an intraday panel. From 033b46acceefa1ae078a0bd06e3ef39f9462bb01 Mon Sep 17 00:00:00 2001 From: Tim <6703801+timzifer@users.noreply.github.com> Date: Mon, 28 Sep 2026 08:09:19 +0200 Subject: [PATCH 13/42] geom: Orient lays a bar, a histogram and a boxplot on their side Under Horizontal the slots are the Y column and the measurement the X one, with X2 and Y2 swapping along. A mark reads its columns through config.roles, trains and maps through config.axes, and turns once where it emits with config.box and config.point, so the coord, explode, extrude and hit-testing are unchanged. A dodged group lists its first series on top. The dialect writes "orientation" on these marks, puts a horizontal stack on the x channel, and reads Vega-Lite's "orient" on a bar or a boxplot as the same thing. Generated with AI Co-Authored-By: AI --- geom/bar.go | 53 +++++++---- geom/boxplot.go | 46 +++++---- geom/geom.go | 60 +++++++++++- geom/histogram.go | 19 ++-- geom/orient_test.go | 220 ++++++++++++++++++++++++++++++++++++++++++++ spec/decode.go | 2 +- spec/encode.go | 26 ++++-- spec/spec.go | 6 +- spec/spec_test.go | 12 ++- spec/v07_test.go | 58 ++++++++++++ spec/vocab.go | 15 +++ 11 files changed, 456 insertions(+), 61 deletions(-) create mode 100644 geom/orient_test.go diff --git a/geom/bar.go b/geom/bar.go index c8b2f4f..3692d73 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,7 +91,7 @@ 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 { + 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() { @@ -146,12 +154,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 +170,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 +189,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 +206,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 +353,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/geom.go b/geom/geom.go index 9128d3a..0006bcb 100644 --- a/geom/geom.go +++ b/geom/geom.go @@ -830,9 +830,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 +851,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. @@ -2135,6 +2180,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/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/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/spec/decode.go b/spec/decode.go index a7fa745..5bf4ee7 100644 --- a/spec/decode.go +++ b/spec/decode.go @@ -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), diff --git a/spec/encode.go b/spec/encode.go index 32a138f..ded995b 100644 --- a/spec/encode.go +++ b/spec/encode.go @@ -475,6 +475,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 +537,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 +569,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 +621,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 @@ -860,9 +868,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..591e032 100644 --- a/spec/spec.go +++ b/spec/spec.go @@ -346,9 +346,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. diff --git a/spec/spec_test.go b/spec/spec_test.go index 1625e5a..f8f4456 100644 --- a/spec/spec_test.go +++ b/spec/spec_test.go @@ -81,6 +81,9 @@ 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))}, @@ -122,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 e1c1bc8..f2fccd8 100644 --- a/spec/vocab.go +++ b/spec/vocab.go @@ -727,6 +727,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 { From 166008cc17e327db1ed7007bdd7f894321d41bf7 Mon Sep 17 00:00:00 2001 From: Tim <6703801+timzifer@users.noreply.github.com> Date: Mon, 28 Sep 2026 08:09:19 +0200 Subject: [PATCH 14/42] docs: ADR 0087 records orientation; the market profile is a bar The volume profile in examples/market is now a stacked horizontal Bar grouped by side, drawing the same picture as the hand-stacked rects it replaces. ADR 0085's fourth rank is marked landed, and 0053's note that bars and boxplots had no orientation points to 0087. Generated with AI Co-Authored-By: AI --- docs/adr/0053-tidy-tree-layout.md | 3 +- docs/adr/0085-what-a-market-chart-needs.md | 15 +- ...-the-encoding-read-a-quarter-turn-round.md | 132 ++++++++++++++++++ docs/adr/README.md | 12 ++ docs/chart-types.md | 2 +- docs/features.md | 6 +- examples/market/main.go | 35 +++-- 7 files changed, 175 insertions(+), 30 deletions(-) create mode 100644 docs/adr/0087-an-orientation-is-the-encoding-read-a-quarter-turn-round.md 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/0085-what-a-market-chart-needs.md b/docs/adr/0085-what-a-market-chart-needs.md index 4bba658..030ab90 100644 --- a/docs/adr/0085-what-a-market-chart-needs.md +++ b/docs/adr/0085-what-a-market-chart-needs.md @@ -95,15 +95,16 @@ of the axis it runs along: 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 two rects per bucket, stacked across, from - `stat.BinWeighted` over one interval. + 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` is read by the tree and by the gradient, - not by `geom.Bar`, so the profile's bars are rects with a hand-stacked - `X`/`X2`. It works, and it is the one place the example is more code than - the chart deserves. +- **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 @@ -192,7 +193,7 @@ By what a reader loses without it, as ADR 0058 ranks: 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.** The profile works as rects; this is convenience. +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. 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..18be076 --- /dev/null +++ b/docs/adr/0087-an-orientation-is-the-encoding-read-a-quarter-turn-round.md @@ -0,0 +1,132 @@ +# 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:** `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/README.md b/docs/adr/README.md index 6218633..23bd928 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -95,6 +95,8 @@ depends on. | [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-trading-calendar-is-a-value-that-produces-folds.md) | A trading calendar is a value that produces folds, and a tick that lands in one moves to the next open | Proposed | 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" | 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 @@ -343,3 +345,13 @@ 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 trading calendar 0085 ranked +first, *Proposed*: a calendar is a value in `scale` that produces folds for an +interval, so the dialect and the time scale are untouched, and the one change +to 0083 is where a tick goes when a day step lands in a fold — to the next +open, with its date, because on an intraday axis 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. diff --git a/docs/chart-types.md b/docs/chart-types.md index b2a60f7..49dbaa5 100644 --- a/docs/chart-types.md +++ b/docs/chart-types.md @@ -72,7 +72,7 @@ and it turns eight charts into recipes. | 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, an `ErrorBar` with `Caps(false)` for low..high, colour by `stat.Candle.Up`; `stat.OHLC` resamples ticks into candles, `scale.TimeFold` takes the weekends out — see [ADR 0085](adr/0085-what-a-market-chart-needs.md) and `examples/market` | -| Volume under a price / volume profile beside it | a `Bar` in a bottom `Track` sharing time; a `Rect` per bucket in a right `Track` sharing price, from `stat.BinWeighted` — see `examples/market` | +| 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` | | 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 | diff --git a/docs/features.md b/docs/features.md index d713340..fcc514b 100644 --- a/docs/features.md +++ b/docs/features.md @@ -38,7 +38,9 @@ The feature surface in one list: scales, marks, coordinate systems, layout, outp **`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 @@ -67,7 +69,7 @@ The feature surface in one list: scales, marks, coordinate systems, layout, outp ([ADR 0028](adr/0028-distribution-stats.md)). - **Market charts** — a candle is a `Rect` body over an `ErrorBar` wick, volume is a `Bar` in a bottom track sharing time, and a volume profile is a - `Rect` per price bucket in a right track sharing price. The indicators are + 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 diff --git a/examples/market/main.go b/examples/market/main.go index 302974f..08d4a23 100644 --- a/examples/market/main.go +++ b/examples/market/main.go @@ -7,11 +7,10 @@ // docs/adr/0085-what-a-market-chart-needs.md catalogues. A candle is a rect for // the body and an error bar without caps for the wick; 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 rect in a right track that shares the price -// axis. What the recipe costs the caller is visible below and is the list that -// record ranks: the direction of each candle is a column computed by hand, the -// weekends are spans computed by hand, and the profile's bars are rects because -// a bar does not lie on its side. +// time axis; the profile is a horizontal bar in a right track that shares the +// price axis. What the recipe costs the caller is visible below and is the +// list that record ranks: the direction of each candle is a column computed by +// hand, and the weekends are spans computed by hand. // // It is executed by a test so that it cannot silently stop compiling or stop // producing a chart. @@ -107,11 +106,12 @@ func run(out string) error { geom.BarWidth(0.7), geom.ColorBy("dir", dir), geom.Opacity(0.6))) // The volume traded at each price, beside the price and on the same price - // axis. Buys from zero out, sells stacked after them, so the bar's length - // is the total and its split is the balance. They are rects rather than - // bars because a bar grows up its Y axis and this one has to grow across. + // 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.Rect(profile(m), geom.X("from"), geom.X2("to"), geom.Y("lo"), geom.Y2("hi"), + 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))) @@ -232,9 +232,9 @@ func signals(days []stat.Candle) (buys, sells *data.Table) { figure.NewTable().Time("t", st).Float64("at", sa) } -// profile is the volume at each price, bought and sold, as the rects of a -// stacked horizontal bar. The two sides are binned over one interval — the -// whole price column's — so that their buckets line up. +// 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 { @@ -244,18 +244,15 @@ func profile(m market) *data.Table { b := stat.BinWeighted(m.ps, m.buys, lo, hi, bins) s := stat.BinWeighted(m.ps, m.sells, lo, hi, bins) - var from, to, blo, bhi []float64 + var price, volume []float64 var side []string for i := range b { - from = append(from, 0, b[i].Sum) - to = append(to, b[i].Sum, b[i].Sum+s[i].Sum) - blo = append(blo, b[i].Lo, s[i].Lo) - bhi = append(bhi, b[i].Hi, s[i].Hi) + 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("from", from).Float64("to", to). - Float64("lo", blo).Float64("hi", bhi). + Float64("price", price).Float64("volume", volume). String("side", side) } From 0fd1cc4445b5469e2044ed89e825d0bf4d886027 Mon Sep 17 00:00:00 2001 From: Tim <6703801+timzifer@users.noreply.github.com> Date: Mon, 28 Sep 2026 08:17:53 +0200 Subject: [PATCH 15/42] docs: ADR 0086 makes the calendar general rather than a market's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A calendar is now an interface — Location, WeekStart, and the stretches of one day that count — so a roster, a term list or a calendar computed in another calendar system is a caller's type. Workweek is the configurable value for the rule-shaped ones, with no default set of days: Monday to Friday, Monday to Saturday and Sunday to Thursday are all spelled out. The week start is the calendar's and the axis's, via scale.WeekStart; week ticks are Monday today only because Go truncates from its zero time, which was a Monday. Generated with AI Co-Authored-By: AI --- docs/adr/0085-what-a-market-chart-needs.md | 5 +- .../0086-a-calendar-says-when-time-counts.md | 261 ++++++++++++++++++ ...calendar-is-a-value-that-produces-folds.md | 190 ------------- docs/adr/README.md | 16 +- 4 files changed, 275 insertions(+), 197 deletions(-) create mode 100644 docs/adr/0086-a-calendar-says-when-time-counts.md delete mode 100644 docs/adr/0086-a-trading-calendar-is-a-value-that-produces-folds.md diff --git a/docs/adr/0085-what-a-market-chart-needs.md b/docs/adr/0085-what-a-market-chart-needs.md index 030ab90..9e6c761 100644 --- a/docs/adr/0085-what-a-market-chart-needs.md +++ b/docs/adr/0085-what-a-market-chart-needs.md @@ -205,7 +205,10 @@ By what a reader loses without it, as ADR 0058 ranks: 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. +2. **A trading calendar**, as a record extending ADR 0083. — **Proposed** 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. 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. 4. **Price autoscale and a replaceable last row**, as one record, because both 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..af432dd --- /dev/null +++ b/docs/adr/0086-a-calendar-says-when-time-counts.md @@ -0,0 +1,261 @@ +# 0086 — A calendar says when time counts; the axis takes the rest as folds and starts its weeks where the calendar does + +**Status:** Proposed · **Date:** 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 a +configurable value implements it for every rule-shaped calendar. 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. The value that implements it is a working week, not a market + +```go +cal := scale.Workweek(time.Local, time.Monday, time.Tuesday, time.Wednesday, + time.Thursday, time.Friday, time.Saturday). // which days are open + Hours(6*time.Hour, 22*time.Hour). // and when, on every open day + On(time.Saturday, scale.Span{From: 6 * time.Hour, To: 14 * time.Hour}). + WeekStarts(time.Monday). + Closed(christmas, newYear). // whole days + Except(inventory, scale.Span{From: 6 * time.Hour, To: 10 * time.Hour}) +``` + +- `scale.Workweek(loc, days...)` opens the named weekdays all day and closes + the others. It has no default set of days, because there is no global one: + Monday to Friday, Monday to Saturday and Sunday to Thursday are all spelled + out, and the code says which one the chart means. `scale.Always(loc)` is the + calendar that is never closed — the hospital, and the identity a caller can + add `Closed` dates to. +- `Hours(from, to, ...)` sets the stretches of every open day; `On(day, + spans...)` sets one weekday's, which is how a short Saturday or a lunch break + is written. `Hours` with `from > to` is the overnight shift of claim 1, + written the way a person says it. +- `WeekStarts(d)` sets the week start. The default is Monday, ISO 8601's, and it + is a default of the value 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. +- `Closed(dates...)` closes whole dates, and `Except(date, spans...)` replaces + one date's stretches — an early close, a late start, a Saturday worked. A + date is a year, month and day in the calendar's location; the time of day of + the argument is ignored. + +The value is immutable: each method returns a new calendar, so one base week +can be 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 type with three methods. +- 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 + +1. `scale.Calendar`, `scale.Span`, `scale.Folds`, `scale.Opens`, and the + `Workweek` / `Always` value with `Hours`, `On`, `WeekStarts`, `Closed` and + `Except`. Tested across both daylight saving changes of one location, an + overnight shift, a Sunday-to-Thursday week, and a caller-written calendar. +2. `scale.WeekStart` and `scale.TimeCalendar`, with the week-tick walk aligned + to a weekday instead of to Go's zero time, and a test that the default is + still Monday. +3. Claim 5 in the time scale's tick walk, with a golden of an intraday week. +4. `stat.OHLCAt` and its Append pair. +5. `examples/market` folds from a calendar instead of its own loop. diff --git a/docs/adr/0086-a-trading-calendar-is-a-value-that-produces-folds.md b/docs/adr/0086-a-trading-calendar-is-a-value-that-produces-folds.md deleted file mode 100644 index 3da8c90..0000000 --- a/docs/adr/0086-a-trading-calendar-is-a-value-that-produces-folds.md +++ /dev/null @@ -1,190 +0,0 @@ -# 0086 — A trading calendar is a value that produces folds, and a tick that lands in one moves to the next open - -**Status:** Proposed · **Date:** 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, and a caller can already pass the spans a calendar produces as -folds."* Its *Revisit if* named the case, and -[ADR 0085](0085-what-a-market-chart-needs.md) ranked it first among the gaps of -a market chart, because a candle chart with the weekends drawn empty is a -chart of the calendar rather than of the market. - -`examples/market` shows what the caller pays today. Twelve weeks of daily -candles need twelve weekend spans, computed in a loop. That is cheap. What is -not cheap is everything the loop leaves out: - -- **Nights.** An intraday chart over a month is twenty-two sessions and - twenty-one nights and four weekends, and the night before a Monday is part - of the weekend. The caller merges them or 0083's merge does, but the caller - has to produce them first, one per day, in the market's own time zone. -- **Daylight saving.** A session that opens at 09:30 in New York opens at - 13:30 UTC for half the year and 14:30 for the other half. A loop that adds - 24 hours to the last open is an hour wrong from the second Sunday in March, - and the chart does not say so: the fold is merely an hour off, and an hour - of real trading disappears into it. -- **Holidays and short days.** A market is closed on days no rule predicts - and closes early on others. Both are a list, and the list is the market's. -- **The labels.** This is the part a loop cannot fix. 0083 §5 walks one tick - step through each kept piece and forbids a tick strictly inside a cut. On an - intraday axis 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. - -So the calendar is two things: a value that knows when a market is open, and -a rule about where a tick goes when the step it was chosen at lands in the -market's closed time. - -## Decision - -**A calendar is a value in `scale` that says when a market is open and -produces the folds for an interval. The time axis takes those folds exactly as -it takes any others. A tick at a calendar step of a day or longer that lands -in a fold moves to the fold's far edge, which is the next open.** Six claims. - -### 1. A calendar is a value, not a scale - -```go -cal := scale.Sessions(nyc, 9*time.Hour+30*time.Minute, 16*time.Hour). - Holidays(closed...). - Early(date(2026, 11, 27), 13*time.Hour) - -p.X(scale.Time(scale.In(nyc), scale.TimeFold(cal.Folds(from, to)...))) -``` - -`scale.Calendar` holds a location, the sessions of each weekday as offsets -from local midnight, a set of closed dates and a set of dates whose sessions -differ. Its constructors are the three cases that cover almost every chart: - -- `scale.Weekdays(loc)` — open all day Monday to Friday. The daily chart. -- `scale.Sessions(loc, open, close)` — one session a weekday. The intraday - chart of one exchange. -- `scale.Week(loc, [7][]Session)` — anything else: a session that spans - midnight, a lunch break, a market open on Sunday evening. - -It is not a new scale kind, and that is the decision the rest follows from. A -calendar changes nothing about how a time axis maps, inverts or ticks that a -fold does not already change, and 0083 has already said everything about how -a fold does that. A `scale.Business` kind would be a second time scale whose -only difference from the first is where its folds come from — a second -`Train`, a second `Ticks`, a second dialect word — to carry a list the first -already takes. - -### 2. The calendar produces folds for an interval, and the caller names it - -`cal.Folds(from, to)` is the complement of the sessions over `[from, to)`: -every closed stretch, sorted and merged, with a weekend and the nights on -either side of it one fold. It is computed day by day in the calendar's -location with `time.Date`, which is what makes it right across a daylight -saving change: an open is a local clock time, not a multiple of 24 hours from -the last one. - -The interval is the caller's, rather than the scale producing folds for -whatever domain it trains to, for 0083's reason — *the document a chart is -written into holds the spans, not the query*. A folded axis in the JSON -dialect is a list of `"folds"`, readable by any consumer, and a document that -held a calendar instead would need every consumer to agree on what a calendar -is, which exchange's holidays it has, and in which year. So the dialect is -unchanged: a calendar is a Go value that writes folds, and the folds are what -is serialised. - -The cost is a live chart whose data outgrows the interval it was folded for. -A caller streaming ticks folds far enough ahead — a calendar is cheap to ask -for a year — or refolds when the stream crosses the end; see *Revisit if*. - -### 3. Holidays are the caller's list - -`Holidays(dates...)` closes a date all day; `Early(date, close)` and -`Late(date, open)` shorten one session; `Special(date, sessions...)` replaces -one day entirely. The library ships no exchange's holidays. They change every -year, they are announced by the exchange rather than derived from a rule, and -a table in a charting library would be out of date by the release after the -one that shipped it — which, for a chart that silently draws a closed day as -a flat candle-less gap, is worse than no table. A caller already has the list, -because the same list decides when their data arrives. - -### 4. A tick that lands in a fold moves to the next open - -This amends 0083 §5 for one case. When the time step chosen for the kept -length is a calendar unit of a day or longer — a day, a week, a month — and a -tick of that step lands strictly inside a fold, it moves to the fold's upper -edge and keeps its label. A day tick at midnight becomes a tick at 09:30 -labelled with the day; a month tick on a Saturday becomes a tick at Monday's -open labelled with the month. - -It is only for calendar steps, because only they name a day rather than an -instant. A tick labelled 12:00 that moved to 09:30 would be a label that lies; -a tick labelled *3 Mar* at 09:30 on 3 March is true. It never moves a tick -past the next tick of the same step, and two ticks that land in the same fold -become one, the later label winning — a weekend 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 rather than of calendars, so it applies -to folds from `SpansWhere` too: a machine log with the nights folded gets day -labels for the same reason. - -### 5. A calendar is also where a day's candles start - -`stat.OHLC` buckets by a fixed width from an origin, which is right for a -minute and for an hour and wrong for a day across a daylight saving change, -where the day is 23 or 25 hours long. `cal.Opens(from, to)` returns the -session opens as a column of edges, and `stat.OHLCAt(ts, ps, vs, edges)` -buckets between consecutive edges — a candle per session, however long the -session was. `stat` stays numbers in and numbers out: it takes edges, not a -calendar. - -### 6. Where it is refused - -- **On an axis that is not a time axis.** A calendar is dates. -- **On a log time axis**, for 0083's reason that no coord yet cuts one. -- **With a session longer than a day or ending before it opens** outside - `Week`, which is where an overnight session is spelled explicitly. - -## Consequences - -- A daily chart with the weekends and holidays out is one line, and an - intraday chart with the nights out is the same line with a session. -- An intraday axis with folded nights has dates on it. Without claim 4 it - cannot. -- The dialect is unchanged, and 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: a binary search per row and no - allocation. -- The slash marks on the axis line are one per fold. On a year of intraday - data they are drawn once where closer together than their own size, which - 0083 §6 already does. - -## Not in scope - -- **Exchange holiday tables.** Claim 3. -- **An index axis** — every session the same width regardless of its length, - as some trading platforms draw one. That is an ordinal axis over session - numbers with time labels, which a caller can build on `scale.Ordinal` now; - it gives up the property that an hour is an hour, which is the property a - shortened session is visible by. -- **Session colouring** — pre-market and after-hours drawn as tinted bands. - That is `VBand` over the spans a second calendar produces. -- **A calendar in the dialect.** Claim 2. - -## Revisit if - -- A live chart needs folds that follow the stream without the caller - refolding — which is a scale that derives cuts from its trained domain, and - would reopen 0083's reason for keeping cuts fixed at construction. -- Someone needs the index axis above with time-aware ticks, which is a scale - kind of its own. - -## Order of work - -1. `scale.Calendar`, its three constructors, `Holidays`, `Early`, `Late`, - `Special`, `Folds` and `Opens`, with tests across both daylight saving - changes of one location and a session spanning midnight. -2. Claim 4 in the time scale's tick walk, with a golden of an intraday week. -3. `stat.OHLCAt` and its Append pair. -4. `examples/market` folds from a calendar instead of its own loop, and gains - an intraday panel. diff --git a/docs/adr/README.md b/docs/adr/README.md index 23bd928..9d297ee 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -95,7 +95,7 @@ depends on. | [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-trading-calendar-is-a-value-that-produces-folds.md) | A trading calendar is a value that produces folds, and a tick that lands in one moves to the next open | Proposed | 0083's business-day deferral | +| [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 | Proposed | 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" | Nothing in §17 is open any more. **§17.7**, the third-party geom and backend @@ -346,11 +346,15 @@ 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 trading calendar 0085 ranked -first, *Proposed*: a calendar is a value in `scale` that produces folds for an -interval, so the dialect and the time scale are untouched, and the one change -to 0083 is where a tick goes when a day step lands in a fold — to the next -open, with its date, because on an intraday axis with the nights folded every +**0086 and 0087 follow from 0085.** 0086 is the calendar 0085 ranked first, +*Proposed*, 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 +configurable `Workweek` value for the rule-shaped ones 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 From c737a290029ae638535505c8df3b5bb20ba671e4 Mon Sep 17 00:00:00 2001 From: Tim <6703801+timzifer@users.noreply.github.com> Date: Mon, 28 Sep 2026 08:31:50 +0200 Subject: [PATCH 16/42] docs: ADR 0086 composes a calendar from rules about days MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workweek takes a DayRule — func(day time.Time) bool — and the hours of an open day, Days takes each weekday's own hours, and CalendarFunc takes the whole per-day question. The library ships the rules (Weekdays, Dates, EveryNth, Between, And, Or, Not) and three wrappers for any calendar (Closed, Special, StartWeek) instead of a builder on one type. The rule is about a day rather than an instant because a predicate over instants cannot give the exact edges a fold needs. Generated with AI Co-Authored-By: AI --- .../0086-a-calendar-says-when-time-counts.md | 132 +++++++++++++----- docs/adr/README.md | 3 +- 2 files changed, 98 insertions(+), 37 deletions(-) diff --git a/docs/adr/0086-a-calendar-says-when-time-counts.md b/docs/adr/0086-a-calendar-says-when-time-counts.md index af432dd..a79f701 100644 --- a/docs/adr/0086-a-calendar-says-when-time-counts.md +++ b/docs/adr/0086-a-calendar-says-when-time-counts.md @@ -46,8 +46,8 @@ Two things in the code make it more than a list of spans. ## Decision -**A calendar is an interface with one question — when is this day open — and a -configurable value implements it for every rule-shaped calendar. Its closed +**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. @@ -89,40 +89,97 @@ 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. The value that implements it is a working week, not a market +### 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 -cal := scale.Workweek(time.Local, time.Monday, time.Tuesday, time.Wednesday, - time.Thursday, time.Friday, time.Saturday). // which days are open - Hours(6*time.Hour, 22*time.Hour). // and when, on every open day - On(time.Saturday, scale.Span{From: 6 * time.Hour, To: 14 * time.Hour}). - WeekStarts(time.Monday). - Closed(christmas, newYear). // whole days - Except(inventory, scale.Span{From: 6 * time.Hour, To: 10 * time.Hour}) +// 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...)) ``` -- `scale.Workweek(loc, days...)` opens the named weekdays all day and closes - the others. It has no default set of days, because there is no global one: - Monday to Friday, Monday to Saturday and Sunday to Thursday are all spelled - out, and the code says which one the chart means. `scale.Always(loc)` is the - calendar that is never closed — the hospital, and the identity a caller can - add `Closed` dates to. -- `Hours(from, to, ...)` sets the stretches of every open day; `On(day, - spans...)` sets one weekday's, which is how a short Saturday or a lunch break - is written. `Hours` with `from > to` is the overnight shift of claim 1, - written the way a person says it. -- `WeekStarts(d)` sets the week start. The default is Monday, ISO 8601's, and it - is a default of the value 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. -- `Closed(dates...)` closes whole dates, and `Except(date, spans...)` replaces - one date's stretches — an early close, a late start, a Saturday worked. A - date is a year, month and day in the calendar's location; the time of day of - the argument is ignored. - -The value is immutable: each method returns a new calendar, so one base week -can be shared by several charts and specialised per chart. +**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 @@ -214,7 +271,9 @@ calendar. 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 type with three methods. + 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. @@ -249,9 +308,10 @@ calendar. ## Order of work -1. `scale.Calendar`, `scale.Span`, `scale.Folds`, `scale.Opens`, and the - `Workweek` / `Always` value with `Hours`, `On`, `WeekStarts`, `Closed` and - `Except`. Tested across both daylight saving changes of one location, an +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`. Tested across both daylight saving changes of one location, an overnight shift, a Sunday-to-Thursday week, and a caller-written calendar. 2. `scale.WeekStart` and `scale.TimeCalendar`, with the week-tick walk aligned to a weekday instead of to Go's zero time, and a test that the default is diff --git a/docs/adr/README.md b/docs/adr/README.md index 9d297ee..705d382 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -350,7 +350,8 @@ triangle for the sell. Each ranked mechanism gets a record of its own. *Proposed*, 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 -configurable `Workweek` value for the rule-shaped ones and no holiday tables. +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 From 50499ede69d6d9b619948700641b7e827fbde070 Mon Sep 17 00:00:00 2001 From: Tim <6703801+timzifer@users.noreply.github.com> Date: Mon, 28 Sep 2026 08:52:01 +0200 Subject: [PATCH 17/42] scale: a Calendar says which stretches of each day count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Calendar is an interface — Location, WeekStart, and Open for one day — so a roster, a term list or a calendar computed in another calendar system is a caller's type or a CalendarFunc. Workweek and Days build the rule-shaped ones from DayRules the package ships (Weekdays, Dates, EveryNth, Between, And, Or, Not), and Closed, Special and StartWeek wrap any calendar. Folds turns the closed time between two instants into a time axis's folds and Opens gives the start of every open stretch. Spans are wall clock, read through time.Date, so an 09:30 open stays at 09:30 across a daylight saving change; an overnight span is split at midnight. An invalid answer from a calendar is ErrCalendar. Generated with AI Co-Authored-By: AI --- scale/calendar.go | 435 +++++++++++++++++++++++++++++++++++++++++ scale/calendar_test.go | 344 ++++++++++++++++++++++++++++++++ 2 files changed, 779 insertions(+) create mode 100644 scale/calendar.go create mode 100644 scale/calendar_test.go 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..85ab81e --- /dev/null +++ b/scale/calendar_test.go @@ -0,0 +1,344 @@ +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 + } +} From 56ad623d2693702f77de960e563a40f50c7beb0b Mon Sep 17 00:00:00 2001 From: Tim <6703801+timzifer@users.noreply.github.com> Date: Mon, 28 Sep 2026 08:52:01 +0200 Subject: [PATCH 18/42] scale: weeks start where the calendar says, and a day tick leaves a fold WeekStart sets the weekday a week tick falls on, walked by date rather than truncated from Go's zero time, which is why every weekly axis was Monday until now; Monday stays the default. TimeCalendar gives an axis a calendar's week start and zone. The dialect writes "weekStart". On a folded axis a tick at a day, week, month or year step that lands inside a fold moves to the fold's upper edge and is labelled for where it stands, so an intraday axis with its nights folded has dates. A tick inside a break is still dropped. Generated with AI Co-Authored-By: AI --- breaks_test.go | 31 ++++++++ scale/breaks.go | 11 +++ scale/describe.go | 25 ++++++ scale/time.go | 106 +++++++++++++++++++++++-- spec/calendar_test.go | 36 +++++++++ spec/decode.go | 2 +- spec/encode.go | 2 +- spec/spec.go | 3 + testdata/golden/calendar-fortnight.svg | 53 +++++++++++++ 9 files changed, 262 insertions(+), 7 deletions(-) create mode 100644 spec/calendar_test.go create mode 100644 testdata/golden/calendar-fortnight.svg 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/scale/breaks.go b/scale/breaks.go index 2e64f9b..399b99e 100644 --- a/scale/breaks.go +++ b/scale/breaks.go @@ -343,6 +343,17 @@ 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 strictly inside: its upper edge, whether it +// is a fold, and false when v falls inside none. +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/describe.go b/scale/describe.go index e99f4ff..2993037 100644 --- a/scale/describe.go +++ b/scale/describe.go @@ -2,6 +2,7 @@ package scale import ( "fmt" + "strings" "time" "github.com/timzifer/figure/ir" @@ -95,6 +96,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 @@ -236,6 +240,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...) @@ -306,6 +317,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 +575,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/time.go b/scale/time.go index 2bf8477..85ce5a4 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 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/spec/calendar_test.go b/spec/calendar_test.go new file mode 100644 index 0000000..7dad0c3 --- /dev/null +++ b/spec/calendar_test.go @@ -0,0 +1,36 @@ +package spec_test + +import ( + "testing" + "time" + + "github.com/timzifer/figure/geom" + "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) + } +} diff --git a/spec/decode.go b/spec/decode.go index 5bf4ee7..ac27d57 100644 --- a/spec/decode.go +++ b/spec/decode.go @@ -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" } diff --git a/spec/encode.go b/spec/encode.go index ded995b..33d9ba9 100644 --- a/spec/encode.go +++ b/spec/encode.go @@ -161,7 +161,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. diff --git a/spec/spec.go b/spec/spec.go index 591e032..e93aa08 100644 --- a/spec/spec.go +++ b/spec/spec.go @@ -754,6 +754,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/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 From dae8164f2c9b7c580b702155a16012c683a0c652 Mon Sep 17 00:00:00 2001 From: Tim <6703801+timzifer@users.noreply.github.com> Date: Mon, 28 Sep 2026 08:52:01 +0200 Subject: [PATCH 19/42] stat: OHLCAt buckets between a calendar's edges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For periods that are not a fixed width — a session, a shift, a day across a clock change. Each row finds its period by binary search, and the Append form is allocation-free into a warm slice. Generated with AI Co-Authored-By: AI --- stat/alloc_test.go | 2 ++ stat/ohlc.go | 80 +++++++++++++++++++++++++++++++++++++++++++++- stat/ohlc_test.go | 25 +++++++++++++++ 3 files changed, 106 insertions(+), 1 deletion(-) diff --git a/stat/alloc_test.go b/stat/alloc_test.go index 9ede745..f796128 100644 --- a/stat/alloc_test.go +++ b/stat/alloc_test.go @@ -107,6 +107,7 @@ func TestTheTrailingFormsDoNotAllocateIntoAWarmSlice(t *testing.T) { 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) }, @@ -115,6 +116,7 @@ func TestTheTrailingFormsDoNotAllocateIntoAWarmSlice(t *testing.T) { "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) }, "bin": func() { buckets = stat.AppendBinWeighted(buckets, ys, xs, 0, 0, 24) }, } { if got := testing.AllocsPerRun(10, f); got != 0 { diff --git a/stat/ohlc.go b/stat/ohlc.go index f048f52..12be1cd 100644 --- a/stat/ohlc.go +++ b/stat/ohlc.go @@ -1,6 +1,9 @@ package stat -import "math" +import ( + "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. @@ -91,3 +94,78 @@ func AppendOHLC(dst []Candle, ts, ps, vs []float64, origin, width float64) []Can } 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) + } + cur, at, open = Candle{Start: edges[k], Open: p, High: p, Low: p}, k, 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 +} diff --git a/stat/ohlc_test.go b/stat/ohlc_test.go index 4b93752..b1262ff 100644 --- a/stat/ohlc_test.go +++ b/stat/ohlc_test.go @@ -78,3 +78,28 @@ func TestTwoWeightedBinsOverOneIntervalLineUp(t *testing.T) { } } } + +// 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, Open: 1, High: 3, Low: 1, Close: 3, Volume: 2, Count: 2}, + {Start: 13, Open: 4, High: 4, Low: 2, Close: 2, Volume: 2, Count: 2}, + {Start: 30, 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) + } +} From a1b9a72e8f76962822ce4ebfdb88b95278b02201 Mon Sep 17 00:00:00 2001 From: Tim <6703801+timzifer@users.noreply.github.com> Date: Mon, 28 Sep 2026 08:52:01 +0200 Subject: [PATCH 20/42] docs: ADR 0086 is built; the market example folds from a calendar examples/market takes its trading days and its folds from one calendar, Monday to Friday with a holiday closed. ADR 0086 is Accepted, amended: spans are wall clock, Dates reads a date as written, a closed day is closed midnight to midnight, Opens merges across midnight, and a tick moves out of a fold but not out of a break. Generated with AI Co-Authored-By: AI --- docs/adr/0085-what-a-market-chart-needs.md | 4 +- .../0086-a-calendar-says-when-time-counts.md | 54 ++++++++++++++++--- docs/adr/README.md | 4 +- docs/chart-types.md | 2 +- docs/features.md | 9 +++- examples/market/main.go | 44 ++++++++------- examples/market/main_test.go | 17 ++++-- 7 files changed, 96 insertions(+), 38 deletions(-) diff --git a/docs/adr/0085-what-a-market-chart-needs.md b/docs/adr/0085-what-a-market-chart-needs.md index 9e6c761..327cdf7 100644 --- a/docs/adr/0085-what-a-market-chart-needs.md +++ b/docs/adr/0085-what-a-market-chart-needs.md @@ -205,10 +205,10 @@ By what a reader loses without it, as ADR 0058 ranks: 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. — **Proposed** as +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. + 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. 4. **Price autoscale and a replaceable last row**, as one record, because both diff --git a/docs/adr/0086-a-calendar-says-when-time-counts.md b/docs/adr/0086-a-calendar-says-when-time-counts.md index a79f701..f79850d 100644 --- a/docs/adr/0086-a-calendar-says-when-time-counts.md +++ b/docs/adr/0086-a-calendar-says-when-time-counts.md @@ -1,6 +1,6 @@ # 0086 — A calendar says when time counts; the axis takes the rest as folds and starts its weeks where the calendar does -**Status:** Proposed · **Date:** 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 +**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 @@ -308,14 +308,52 @@ calendar. ## 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`. Tested across both daylight saving changes of one location, an - overnight shift, a Sunday-to-Thursday week, and a caller-written calendar. + 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 instead of to Go's zero time, and a test that the default is - still Monday. -3. Claim 5 in the time scale's tick walk, with a golden of an intraday week. -4. `stat.OHLCAt` and its Append pair. -5. `examples/market` folds from a calendar instead of its own loop. + 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. diff --git a/docs/adr/README.md b/docs/adr/README.md index 705d382..f6ced11 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -95,7 +95,7 @@ depends on. | [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 | Proposed | 0083's business-day deferral | +| [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" | Nothing in §17 is open any more. **§17.7**, the third-party geom and backend @@ -347,7 +347,7 @@ 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, -*Proposed*, and it is not a market's: a calendar is an interface that says +*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`, diff --git a/docs/chart-types.md b/docs/chart-types.md index 49dbaa5..417f3a7 100644 --- a/docs/chart-types.md +++ b/docs/chart-types.md @@ -71,7 +71,7 @@ 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, an `ErrorBar` with `Caps(false)` for low..high, colour by `stat.Candle.Up`; `stat.OHLC` resamples ticks into candles, `scale.TimeFold` takes the weekends out — see [ADR 0085](adr/0085-what-a-market-chart-needs.md) and `examples/market` | +| Candlestick / OHLC | `Rect` for open..close, an `ErrorBar` with `Caps(false)` for low..high, colour by `stat.Candle.Up`; `stat.OHLC` resamples ticks into candles, `scale.Folds` of a `scale.Calendar` takes the closed days out ([ADR 0086](adr/0086-a-calendar-says-when-time-counts.md)) — see [ADR 0085](adr/0085-what-a-market-chart-needs.md) and `examples/market` | | 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` | | Waterfall | `Rect` with per-row `y0`/`y1` from a running total, through `Y` and `Y2` | diff --git a/docs/features.md b/docs/features.md index fcc514b..8653799 100644 --- a/docs/features.md +++ b/docs/features.md @@ -27,6 +27,13 @@ The feature surface in one list: scales, marks, coordinate systems, layout, outp 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 @@ -74,7 +81,7 @@ The feature surface in one list: scales, marks, coordinate systems, layout, outp `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. `examples/market` draws all of - it, with the weekends folded + it, with the weekends and a holiday folded from one calendar ([ADR 0085](adr/0085-what-a-market-chart-needs.md)). - **Relational and hierarchical marks** — **`Treemap`**, **`Icicle`**, **`Sankey`**, **`Arc`**, **`Tree`**, **`Graph`** and **`NodeLink`**, which read an edge table rather than a pair of diff --git a/examples/market/main.go b/examples/market/main.go index 08d4a23..49e3b55 100644 --- a/examples/market/main.go +++ b/examples/market/main.go @@ -10,7 +10,8 @@ // time axis; the profile is a horizontal bar in a right track that shares the // price axis. What the recipe costs the caller is visible below and is the // list that record ranks: the direction of each candle is a column computed by -// hand, and the weekends are spans computed by hand. +// hand. The calendar is not: which days the market trades is a scale.Calendar, +// and the axis folds what it says is closed (docs/adr/0086). // // It is executed by a test so that it cannot silently stop compiling or stop // producing a chart. @@ -64,12 +65,16 @@ func run(out string) error { p := figure.New( figure.Size(1000, 560), - figure.Title("FIG — daily, weekends folded"), + figure.Title("FIG — daily, weekends and holidays folded"), ) - // The weekends are left out of the axis rather than drawn empty. A trading - // calendar is a list of spans until the library has a calendar; see the - // record's first gap. - p.X(scale.Time(scale.TimeFold(weekends(start, start.AddDate(0, 0, calendarDays))...))) + // 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. @@ -125,6 +130,19 @@ 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 { @@ -141,7 +159,7 @@ func simulate() market { price := 100.0 for d := range calendarDays { day := start.AddDate(0, 0, d) - if wd := day.Weekday(); wd == time.Saturday || wd == time.Sunday { + if len(trading.Open(nil, day)) == 0 { continue } // A drift that changes sign every few weeks, so the averages cross. @@ -256,18 +274,6 @@ func profile(m market) *data.Table { String("side", side) } -// weekends is every Saturday-to-Monday span between from and to, as the folds -// of a time axis. -func weekends(from, to time.Time) []scale.TimeSpan { - var out []scale.TimeSpan - for d := from; d.Before(to); d = d.AddDate(0, 0, 1) { - if d.Weekday() == time.Saturday { - out = append(out, scale.TimeSpan{From: d, To: d.AddDate(0, 0, 2)}) - } - } - return out -} - func ys(ps []stat.Point) []float64 { out := make([]float64, len(ps)) for i, p := range ps { diff --git a/examples/market/main_test.go b/examples/market/main_test.go index 290c2f2..671452e 100644 --- a/examples/market/main_test.go +++ b/examples/market/main_test.go @@ -24,7 +24,7 @@ func TestExampleRuns(t *testing.T) { } got := string(b) for _, want := range []string{ - "", + "", } { if !strings.Contains(got, want) { t.Errorf("output is missing %q", want) @@ -33,14 +33,21 @@ func TestExampleRuns(t *testing.T) { } // Every candle falls on a trading day, so none of them is inside a fold — a -// candle drawn into a weekend would be drawn into a gap the axis took out. +// 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 { - t.Fatalf("%d candles, want one per weekday of %d calendar days", len(days), calendarDays) + 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)) } - folds := weekends(start, start.AddDate(0, 0, calendarDays)) for _, d := range days { at := scale.FromNanos(d.Start + float64(12*time.Hour)) for _, f := range folds { From 9629c6d352f3b530ac54abfb645a1a46123e72b7 Mon Sep 17 00:00:00 2001 From: Tim <6703801+timzifer@users.noreply.github.com> Date: Mon, 28 Sep 2026 09:05:09 +0200 Subject: [PATCH 21/42] docs: ADR 0088 proposes geom.Candle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One mark reading a position and the four values through one option, geom.OHLC, deciding each row's direction itself (since its open, or since the previous close), and drawing wick and body in four calls whatever the row count. Direction is drawn twice — theme colours that survive a colour-vision deficiency, and hollow or filled, on by default under redundant encoding. The OHLC bar is a style of the same mark. stat.Candle gains End and figure.CandleTable turns candles into the table the mark reads. The dialect gains "candlestick" with open, high, low and close channels rather than spending y and y2 on two of them. Generated with AI Co-Authored-By: AI --- docs/adr/0085-what-a-market-chart-needs.md | 5 +- ...ndle-is-one-mark-that-reads-four-values.md | 207 ++++++++++++++++++ docs/adr/README.md | 9 + 3 files changed, 220 insertions(+), 1 deletion(-) create mode 100644 docs/adr/0088-a-candle-is-one-mark-that-reads-four-values.md diff --git a/docs/adr/0085-what-a-market-chart-needs.md b/docs/adr/0085-what-a-market-chart-needs.md index 327cdf7..4bb7814 100644 --- a/docs/adr/0085-what-a-market-chart-needs.md +++ b/docs/adr/0085-what-a-market-chart-needs.md @@ -210,7 +210,10 @@ By what a reader loses without it, as ADR 0058 ranks: 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. + and whether the OHLC bar is an option on it or a mark beside it. — + **Proposed** 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. 5. The rest, as asked for. 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..a2be65b --- /dev/null +++ b/docs/adr/0088-a-candle-is-one-mark-that-reads-four-values.md @@ -0,0 +1,207 @@ +# 0088 — A candle is one mark that reads four values and decides its own direction + +**Status:** Proposed · **Date:** 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: two calls per direction, four for the layer, and +two more for the outlines of hollow bodies. 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. +- A chart of more candles than its width can hold asks for the exact merge + above. + +## Order of work + +1. `stat.Candle.End`, set by `OHLC` and `OHLCAt`; `figure.CandleTable`. +2. `geom.Candle` with `OHLC`, `Direction`, `Rising`, `Falling`, `Hollow` and + `CandleStyle`; the theme's `Rising` and `Falling`; tests that it draws in + four calls, that a doji is visible, that inconsistent data is drawn as given + and that the row is reported at its close. +3. The dialect's `"candlestick"` mark and its four channels, with a round trip. +4. `examples/market` draws its candles with the mark, and a golden of both + styles. diff --git a/docs/adr/README.md b/docs/adr/README.md index f6ced11..ecaf163 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -97,6 +97,7 @@ depends on. | [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 | Proposed | 0085's second rank | 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 @@ -360,3 +361,11 @@ 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**, *Proposed*: `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. From 631f5eb0ffe62ffa3af2f7d76f597714a5f7c0bb Mon Sep 17 00:00:00 2001 From: Tim <6703801+timzifer@users.noreply.github.com> Date: Mon, 28 Sep 2026 09:32:44 +0200 Subject: [PATCH 22/42] stat, figure: a candle knows where its period ends; CandleTable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit stat.Candle gains End — the next period's start for OHLC and OHLCAt, and the last row's time for OHLCAt's still-open last candle. figure.CandleTable turns candles into the table geom.Candle reads. Generated with AI Co-Authored-By: AI --- candles.go | 36 ++++++++++++++++++++++++++++ candles_test.go | 60 +++++++++++++++++++++++++++++++++++++++++++++++ stat/ohlc.go | 17 +++++++++++--- stat/ohlc_test.go | 10 ++++---- 4 files changed, 115 insertions(+), 8 deletions(-) create mode 100644 candles.go create mode 100644 candles_test.go 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/stat/ohlc.go b/stat/ohlc.go index 12be1cd..e34c06b 100644 --- a/stat/ohlc.go +++ b/stat/ohlc.go @@ -12,7 +12,11 @@ import ( // 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 + 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. @@ -81,7 +85,7 @@ func AppendOHLC(dst []Candle, ts, ps, vs []float64, origin, width float64) []Can if open { dst = append(dst, cur) } - cur, open = Candle{Start: start, Open: p, High: p, Low: p}, true + 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) @@ -156,7 +160,14 @@ func AppendOHLCAt(dst []Candle, ts, ps, vs, edges []float64) []Candle { if open { dst = append(dst, cur) } - cur, at, open = Candle{Start: edges[k], Open: p, High: p, Low: p}, k, true + 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) diff --git a/stat/ohlc_test.go b/stat/ohlc_test.go index b1262ff..102b2da 100644 --- a/stat/ohlc_test.go +++ b/stat/ohlc_test.go @@ -14,8 +14,8 @@ func TestOHLCSummarisesEachInterval(t *testing.T) { vs := []float64{1, 2, 3, 4, 5, 6} got := stat.OHLC(ts, ps, vs, 5, 10) want := []stat.Candle{ - {Start: 5, Open: 10, High: 12, Low: 8, Close: 11, Volume: 10, Count: 4}, - {Start: 35, Open: 20, High: 20, Low: 19, Close: 19, Volume: 11, Count: 2}, + {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)) @@ -87,9 +87,9 @@ func TestOHLCAtBucketsBetweenEdges(t *testing.T) { ps := []float64{9, 1, 3, 4, 2, 7, 8} got := stat.OHLCAt(ts, ps, nil, edges) want := []stat.Candle{ - {Start: 10, Open: 1, High: 3, Low: 1, Close: 3, Volume: 2, Count: 2}, - {Start: 13, Open: 4, High: 4, Low: 2, Close: 2, Volume: 2, Count: 2}, - {Start: 30, Open: 7, High: 8, Low: 7, Close: 8, Volume: 2, Count: 2}, + {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) From b2f1f6144f49506ffa7dd32239386a27008a5bf0 Mon Sep 17 00:00:00 2001 From: Tim <6703801+timzifer@users.noreply.github.com> Date: Mon, 28 Sep 2026 09:32:44 +0200 Subject: [PATCH 23/42] geom: Candle reads four values and decides its own direction geom.Candle takes a position and geom.OHLC(open, high, low, close), and draws wick and body as one mark: wicks of a colour one stroked path, bodies one filled path, so a thousand candles are four calls. A row rises when its close is at or above its open, or with Direction(SincePrevious) its previous close; a doji is at least a pixel tall. Rising and Falling set each direction's colour and label, with theme defaults of blue and vermilion; Hollow outlines rising bodies and is on under redundant encoding; CandleStyle(Ticks) is the OHLC bar. Inconsistent data is drawn as given and the row is reported at its close. The dialect writes "candlestick" with open, high, low and close channels. A tick at the lower edge of a fold now moves with ticks inside it, so a day tick at a weekend's Saturday midnight is labelled Monday. Generated with AI Co-Authored-By: AI --- geom/candle.go | 469 ++++++++++++++++++++++++++++++ geom/candle_test.go | 267 +++++++++++++++++ geom/describe.go | 36 +++ geom/geom.go | 10 + scale/breaks.go | 10 +- scale/time.go | 4 +- spec/calendar_test.go | 51 ++++ spec/decode.go | 24 ++ spec/encode.go | 30 ++ spec/spec.go | 24 ++ spec/vocab.go | 6 + testdata/golden/candles-ticks.svg | 44 +++ testdata/golden/candles.svg | 46 +++ theme/theme.go | 7 + theme/tokens.go | 3 + 15 files changed, 1026 insertions(+), 5 deletions(-) create mode 100644 geom/candle.go create mode 100644 geom/candle_test.go create mode 100644 testdata/golden/candles-ticks.svg create mode 100644 testdata/golden/candles.svg diff --git a/geom/candle.go b/geom/candle.go new file mode 100644 index 0000000..49c1fe0 --- /dev/null +++ b/geom/candle.go @@ -0,0 +1,469 @@ +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) + + for i, fine := range ok { + if !fine { + continue + } + x.Train(g.s.x[i]) + if g.x2 != nil { + x.Train(g.x2[i]) + } + // 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) + + g.gap, g.gaps = smallestGap(g.gaps, g.s.x) + 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..bbb258a 100644 --- a/geom/describe.go +++ b/geom/describe.go @@ -121,6 +121,11 @@ 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" + // 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 +434,18 @@ 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 + 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 +523,8 @@ 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 MarkArea: return Area(d.Source, opts...), nil case MarkStep: @@ -635,6 +654,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)) @@ -861,6 +888,15 @@ func (c config) describeStacking(mark Mark, def Stacking) Desc { ErrorCol: c.errCol, ErrorXCol: c.errXCol, Caps: c.caps, + OHLC: c.ohlc, + 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/geom.go b/geom/geom.go index 0006bcb..8338812 100644 --- a/geom/geom.go +++ b/geom/geom.go @@ -250,6 +250,16 @@ 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 + sizeCol string sizeScale scale.SizeScale diff --git a/scale/breaks.go b/scale/breaks.go index 399b99e..0d950ce 100644 --- a/scale/breaks.go +++ b/scale/breaks.go @@ -343,12 +343,16 @@ 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 strictly inside: its upper edge, whether it -// is a fold, and false when v falls inside none. +// 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 { + if j < b.i1 && v >= cs.c[j].lo { return cs.c[j].hi, cs.c[j].fold, true } return 0, false, false diff --git a/scale/time.go b/scale/time.go index 85ce5a4..01c6cca 100644 --- a/scale/time.go +++ b/scale/time.go @@ -349,8 +349,8 @@ 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 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 + // 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 { diff --git a/spec/calendar_test.go b/spec/calendar_test.go index 7dad0c3..42870d8 100644 --- a/spec/calendar_test.go +++ b/spec/calendar_test.go @@ -1,10 +1,13 @@ 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" ) @@ -34,3 +37,51 @@ func TestAWeekStartSurvivesTheDocument(t *testing.T) { 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) + } +} diff --git a/spec/decode.go b/spec/decode.go index ac27d57..4136ed5 100644 --- a/spec/decode.go +++ b/spec/decode.go @@ -353,6 +353,29 @@ func decodeLayer(l Layer, shared data.Source) (geom.Geom, error) { if l.Mark.Caps != nil { d.Caps = *l.Mark.Caps } + 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 +471,7 @@ 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.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 33d9ba9..baf0c8a 100644 --- a/spec/encode.go +++ b/spec/encode.go @@ -672,6 +672,27 @@ func writeMarkProps(m *Mark, d geom.Desc) { if d.MarkerSet { m.Shape = shapeName(d.Marker) } + 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() @@ -832,6 +853,15 @@ func encodeLayerEncoding(d geom.Desc, axes axisKinds) (*Encoding, error) { if d.ErrorXCol != "" { enc.ErrorX = &Channel{Field: d.ErrorXCol} } + 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"} } diff --git a/spec/spec.go b/spec/spec.go index e93aa08..dadd702 100644 --- a/spec/spec.go +++ b/spec/spec.go @@ -214,6 +214,21 @@ 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"` + // 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 @@ -612,6 +627,15 @@ 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"` } // Channel is one encoding: a column, or a literal value, and the scale behind diff --git a/spec/vocab.go b/spec/vocab.go index f2fccd8..b82da8d 100644 --- a/spec/vocab.go +++ b/spec/vocab.go @@ -132,6 +132,10 @@ 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.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 +202,8 @@ 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 "voronoi": return geom.MarkVoronoi, nil case "treemap": 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, From abe1ee63ee424fb38450b4d1c11b72d10fec76ab Mon Sep 17 00:00:00 2001 From: Tim <6703801+timzifer@users.noreply.github.com> Date: Mon, 28 Sep 2026 09:32:44 +0200 Subject: [PATCH 24/42] docs: ADR 0088 is built; the market example draws geom.Candle examples/market replaces its Rect-and-ErrorBar recipe with one Candle layer. ADR 0088 is Accepted, amended; 0086 records the fold-edge tick, 0087 lists Candle under marks never oriented, and 0085's second rank is marked landed. Generated with AI Co-Authored-By: AI --- docs/adr/0085-what-a-market-chart-needs.md | 2 +- .../0086-a-calendar-says-when-time-counts.md | 5 ++ ...-the-encoding-read-a-quarter-turn-round.md | 3 +- ...ndle-is-one-mark-that-reads-four-values.md | 49 +++++++++++++++---- docs/adr/README.md | 4 +- docs/chart-types.md | 2 +- docs/features.md | 6 ++- examples/market/main.go | 26 +++++----- 8 files changed, 66 insertions(+), 31 deletions(-) diff --git a/docs/adr/0085-what-a-market-chart-needs.md b/docs/adr/0085-what-a-market-chart-needs.md index 4bb7814..049aa7c 100644 --- a/docs/adr/0085-what-a-market-chart-needs.md +++ b/docs/adr/0085-what-a-market-chart-needs.md @@ -211,7 +211,7 @@ By what a reader loses without it, as ADR 0058 ranks: 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. — - **Proposed** as + **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 diff --git a/docs/adr/0086-a-calendar-says-when-time-counts.md b/docs/adr/0086-a-calendar-says-when-time-counts.md index f79850d..113ef59 100644 --- a/docs/adr/0086-a-calendar-says-when-time-counts.md +++ b/docs/adr/0086-a-calendar-says-when-time-counts.md @@ -357,3 +357,8 @@ Every step is built. 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 index 18be076..faefb58 100644 --- 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 @@ -87,7 +87,8 @@ draws one in. 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:** `Line` and `Scatter`, which have no slot — a line lying down is +- **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 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 index a2be65b..462a78f 100644 --- 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 @@ -1,6 +1,6 @@ # 0088 — A candle is one mark that reads four values and decides its own direction -**Status:** Proposed · **Date:** 2026-09-28 · **Ranked by:** [ADR 0085](0085-what-a-market-chart-needs.md), rank 2 +**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 @@ -103,8 +103,8 @@ 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: two calls per direction, four for the layer, and -two more for the outlines of hollow bodies. A thousand candles are the same +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. @@ -197,11 +197,40 @@ defaults, and the rising and falling colours and labels. ## Order of work +Every step is built. + 1. `stat.Candle.End`, set by `OHLC` and `OHLCAt`; `figure.CandleTable`. -2. `geom.Candle` with `OHLC`, `Direction`, `Rising`, `Falling`, `Hollow` and - `CandleStyle`; the theme's `Rising` and `Falling`; tests that it draws in - four calls, that a doji is visible, that inconsistent data is drawn as given - and that the row is reported at its close. -3. The dialect's `"candlestick"` mark and its four channels, with a round trip. -4. `examples/market` draws its candles with the mark, and a golden of both - styles. +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/README.md b/docs/adr/README.md index ecaf163..55888ac 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -97,7 +97,7 @@ depends on. | [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 | Proposed | 0085's second rank | +| [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 | 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 @@ -362,7 +362,7 @@ 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**, *Proposed*: `geom.Candle` reads a position and +**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 diff --git a/docs/chart-types.md b/docs/chart-types.md index 417f3a7..aa94ae5 100644 --- a/docs/chart-types.md +++ b/docs/chart-types.md @@ -71,7 +71,7 @@ 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, an `ErrorBar` with `Caps(false)` for low..high, colour by `stat.Candle.Up`; `stat.OHLC` resamples ticks into candles, `scale.Folds` of a `scale.Calendar` takes the closed days out ([ADR 0086](adr/0086-a-calendar-says-when-time-counts.md)) — see [ADR 0085](adr/0085-what-a-market-chart-needs.md) and `examples/market` | +| 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` | | Waterfall | `Rect` with per-row `y0`/`y1` from a running total, through `Y` and `Y2` | diff --git a/docs/features.md b/docs/features.md index 8653799..94a8397 100644 --- a/docs/features.md +++ b/docs/features.md @@ -74,7 +74,11 @@ The feature surface in one list: scales, marks, coordinate systems, layout, outp 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** — a candle is a `Rect` body over an `ErrorBar` wick, +- **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`, diff --git a/examples/market/main.go b/examples/market/main.go index 49e3b55..47f3147 100644 --- a/examples/market/main.go +++ b/examples/market/main.go @@ -3,15 +3,15 @@ // 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. // -// Every part of it is a recipe over marks that already exist, which is what -// docs/adr/0085-what-a-market-chart-needs.md catalogues. A candle is a rect for -// the body and an error bar without caps for the wick; the band is an area +// 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. What the recipe costs the caller is visible below and is the -// list that record ranks: the direction of each candle is a column computed by -// hand. The calendar is not: which days the market trades is a scale.Calendar, -// and the axis folds what it says is closed (docs/adr/0086). +// price axis. Which days the market trades is a scale.Calendar, and the axis +// folds what it says is closed (docs/adr/0086). The one column still computed +// by hand is the direction the volume bars are coloured by: a candle decides +// its direction for itself, not for the layer beside it. // // It is executed by a test so that it cannot silently stop compiling or stop // producing a chart. @@ -81,15 +81,11 @@ func run(out string) error { 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σ"))) - // A candle is two marks on the same rows: the wick from low to high, and - // the body from open to close over it. The body is a rect because its two - // ends are two columns; which one is higher is the direction, and the - // colour is a column because a mark cannot compute one from two others. + // 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"))) dir := scale.Named(map[string]ir.Color{"up": up, "down": down}) - p.Add(geom.ErrorBar(candles, geom.X("t"), geom.Y("low"), geom.Y2("high"), - geom.Caps(false), geom.ColorBy("dir", dir))) - p.Add(geom.Rect(candles, geom.X("t"), geom.Y("open"), geom.Y2("close"), - geom.BarWidth(0.7), geom.ColorBy("dir", dir))) p.Add(geom.Line(candles, geom.X("t"), geom.Y("ema"), geom.Color(palette.Orange), geom.Label(fmt.Sprintf("EMA %d", fast)))) From 8243624a462fe9a7ed1674dc65885d105adb5ac1 Mon Sep 17 00:00:00 2001 From: Tim <6703801+timzifer@users.noreply.github.com> Date: Mon, 28 Sep 2026 09:40:37 +0200 Subject: [PATCH 25/42] docs: ADR 0089 proposes a fitted value axis and a revisable last row scale.FitView makes a value axis fit the rows the panel's pinned X axis shows: render hands each layer that interval as Training.Within in the one training pass, point, interval and connected marks restrict their value training to it, and a layer that cannot fits loosely rather than clipping. Live's wheel, rubber band and pan leave a fitting axis alone. data.Stream.ReplaceLast revises the newest row only, and stat.Resampler turns OHLC inside out for a feed that arrives a tick at a time. Generated with AI Co-Authored-By: AI --- docs/adr/0085-what-a-market-chart-needs.md | 3 +- ...alue-axis-fits-what-its-time-axis-shows.md | 232 ++++++++++++++++++ docs/adr/README.md | 10 + 3 files changed, 244 insertions(+), 1 deletion(-) create mode 100644 docs/adr/0089-a-value-axis-fits-what-its-time-axis-shows.md diff --git a/docs/adr/0085-what-a-market-chart-needs.md b/docs/adr/0085-what-a-market-chart-needs.md index 049aa7c..7601feb 100644 --- a/docs/adr/0085-what-a-market-chart-needs.md +++ b/docs/adr/0085-what-a-market-chart-needs.md @@ -215,7 +215,8 @@ By what a reader loses without it, as ADR 0058 ranks: [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. + are what a live market chart is and neither is useful alone. — **Proposed** + as [ADR 0089](0089-a-value-axis-fits-what-its-time-axis-shows.md). 5. The rest, as asked for. ## Consequences 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..1481a15 --- /dev/null +++ b/docs/adr/0089-a-value-axis-fits-what-its-time-axis-shows.md @@ -0,0 +1,232 @@ +# 0089 — A value axis can fit what the other axis shows, and a stream can revise its last row + +**Status:** Proposed · **Date:** 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 + +1. `scale.FitView`, `scale.LogFitView`, `scale.Pinner`, and `"fit"` in the + dialect. +2. `geom.Training.Within`; render passes it for a fitting panel with a pinned + X; `Line`, `Area`, `Step`, `Scatter`, `Text`, `Bar`, `Rect`, `ErrorBar` and + `Candle` read it. A test that a layer which does not read it still fits + loosely rather than clipping. +3. `Live.Wheel`, `ZoomTo` and `PanBy` skip a fitting axis; a test that a wheel + over a price chart moves time only and the next frame's price axis fits. +4. `data.Stream.ReplaceLast`, race-tested beside `Append`. +5. `stat.Resampler`, with a test that it produces `stat.OHLC`'s candles. +6. `examples/market` gains a live mode — a seeded tick feed through a + resampler into a stream, drawn on a fitted price axis. diff --git a/docs/adr/README.md b/docs/adr/README.md index 55888ac..efe1ccc 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -98,6 +98,7 @@ depends on. | [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 | Proposed | 0085's third rank | 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 @@ -369,3 +370,12 @@ 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**, *Proposed*: 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. From 4061598395c5e1ed14a78b5e40b59282789df9c9 Mon Sep 17 00:00:00 2001 From: Tim <6703801+timzifer@users.noreply.github.com> Date: Mon, 28 Sep 2026 09:55:52 +0200 Subject: [PATCH 26/42] scale: FitView fits a value axis to what the other axis shows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FitView and LogFitView declare that an axis is trained on the rows its panel's other axis shows. Fitter reports it, and Pinner — an interface of its own, since Zoomer is published — reports whether a domain is pinned, which is when a fit applies. The dialect writes "fit": "view". Generated with AI Co-Authored-By: AI --- scale/describe.go | 15 ++++++++--- scale/fit.go | 67 +++++++++++++++++++++++++++++++++++++++++++++++ scale/linear.go | 1 + scale/log.go | 1 + spec/decode.go | 2 +- spec/encode.go | 3 +++ spec/spec.go | 4 +++ 7 files changed, 88 insertions(+), 5 deletions(-) create mode 100644 scale/fit.go diff --git a/scale/describe.go b/scale/describe.go index 2993037..ac28452 100644 --- a/scale/describe.go +++ b/scale/describe.go @@ -55,8 +55,9 @@ 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 // 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 @@ -160,6 +161,9 @@ func FromDesc(d Desc) (Scale, error) { if d.Reverse { opts = append(opts, Reverse()) } + if d.Fit { + opts = append(opts, FitView()) + } if d.Fixed { opts = append(opts, Domain(d.Min, d.Max)) } @@ -181,6 +185,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)) } @@ -272,7 +279,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, Reverse: l.reverse, Fixed: l.fixed, Formatted: l.format != nil, Format: l.numFormat.spec, Locale: localeName(l.loc), } if l.fixed { @@ -287,7 +294,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), } 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..1ca4c5b 100644 --- a/scale/linear.go +++ b/scale/linear.go @@ -111,6 +111,7 @@ type linear struct { fixed bool pinned bool reverse bool + fit bool // see [FitView] 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 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/spec/decode.go b/spec/decode.go index 4136ed5..415e8e3 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", Base: s.Base, Threshold: s.Constant, Locale: s.Locale} d.MinorTicks = true if s.MinorTicks != nil { d.MinorTicks = *s.MinorTicks diff --git a/spec/encode.go b/spec/encode.go index baf0c8a..e9282a2 100644 --- a/spec/encode.go +++ b/spec/encode.go @@ -142,6 +142,9 @@ 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" + } switch d.Kind { case scale.KindLinear: // Reverse is written only for the kind that reads it back. On a diff --git a/spec/spec.go b/spec/spec.go index dadd702..1e01460 100644 --- a/spec/spec.go +++ b/spec/spec.go @@ -678,6 +678,10 @@ 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"` // 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. From 9dfc1d2544f7bbd20e38a9c518adb052c0a7e5bf Mon Sep 17 00:00:00 2001 From: Tim <6703801+timzifer@users.noreply.github.com> Date: Mon, 28 Sep 2026 09:55:52 +0200 Subject: [PATCH 27/42] render, geom, live: a fitting axis trains on the rows in view Render passes a pinned X domain as Training.Within to every layer whose Y fits, and resets each fitting axis once per frame first, because a trained domain only widens. Points train on rows in view, marks with width on rows whose span overlaps it, connected paths also on the value where they cross each edge, and stacks on a view padded by their reach. A layer that does not read Within trains on every row, fitting loosely rather than clipping. Live's wheel, rubber band and pan leave a fitting axis alone, and a View does not record one. Generated with AI Co-Authored-By: AI --- fit_test.go | 148 +++++++++++++++++++++++++++++++++++++++++++++++ geom/area.go | 24 +++++--- geom/bar.go | 15 ++++- geom/candle.go | 12 +++- geom/errorbar.go | 26 ++++++--- geom/fit.go | 129 +++++++++++++++++++++++++++++++++++++++++ geom/fit_test.go | 69 ++++++++++++++++++++++ geom/geom.go | 15 +++++ geom/group.go | 18 +++++- geom/line.go | 8 ++- geom/rect.go | 14 ++++- geom/scatter.go | 2 +- geom/step.go | 8 ++- geom/text.go | 11 +++- live.go | 15 +++++ render/render.go | 37 +++++++++++- view.go | 6 +- 17 files changed, 520 insertions(+), 37 deletions(-) create mode 100644 fit_test.go create mode 100644 geom/fit.go create mode 100644 geom/fit_test.go 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 3692d73..eb253e6 100644 --- a/geom/bar.go +++ b/geom/bar.go @@ -91,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. + // 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) diff --git a/geom/candle.go b/geom/candle.go index 49c1fe0..eb1c554 100644 --- a/geom/candle.go +++ b/geom/candle.go @@ -184,21 +184,29 @@ func (g *candleGeom) Train(t Training) error { } 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) - - g.gap, g.gaps = smallestGap(g.gaps, g.s.x) if _, band := x.(scale.Band); band || g.x2 != nil { return nil } 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 8338812..c8c74db 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. 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/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/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/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/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/render/render.go b/render/render.go index 8b2a51c..188c095 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. + refit(panels) 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 } } @@ -1652,3 +1653,37 @@ 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} +} + +// refit forgets what every fitting axis was trained on last frame, before any +// layer trains it again. A trained domain only ever widens, so an axis fitted +// to last frame's view would keep it after the view moved on; a fitting axis +// is derived from its view every frame, and this is where each frame starts. +// It runs once for all panels rather than per panel, because panels that share +// an axis share one scale, and resetting it between them would forget the +// first panel's rows. +func refit(panels []Panel) { + for _, p := range panels { + for _, g := range p.Layers { + x, y := p.axesOf(g) + if within(x, y) == nil { + continue + } + if z, ok := y.(scale.Zoomer); ok { + z.Autoscale() + } + } + } +} 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() From 3ae1569a69f506a59b08318e20ee10eab481b0f2 Mon Sep 17 00:00:00 2001 From: Tim <6703801+timzifer@users.noreply.github.com> Date: Mon, 28 Sep 2026 09:55:52 +0200 Subject: [PATCH 28/42] data, stat: ReplaceLast revises the open row; Resampler folds ticks Stream.ReplaceLast overwrites the newest row under Append's lock, the one revision a live source makes. stat.Resampler is OHLC for a feed that arrives a tick at a time: Add returns the candle as it stands and whether it is new, over a width or a calendar's edges, and refuses a tick for a candle that has closed. Generated with AI Co-Authored-By: AI --- data/stream.go | 38 ++++++++++++++++ data/stream_test.go | 77 +++++++++++++++++++++++++++++++ stat/ohlc.go | 108 ++++++++++++++++++++++++++++++++++++++++++++ stat/ohlc_test.go | 57 +++++++++++++++++++++++ 4 files changed, 280 insertions(+) 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/stat/ohlc.go b/stat/ohlc.go index e34c06b..a8d5b2a 100644 --- a/stat/ohlc.go +++ b/stat/ohlc.go @@ -1,6 +1,7 @@ package stat import ( + "errors" "math" "sort" ) @@ -180,3 +181,110 @@ func AppendOHLCAt(dst []Candle, ts, ps, vs, edges []float64) []Candle { } 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 index 102b2da..7859ae1 100644 --- a/stat/ohlc_test.go +++ b/stat/ohlc_test.go @@ -103,3 +103,60 @@ func TestOHLCAtBucketsBetweenEdges(t *testing.T) { 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") + } +} From 9147e8de03cbccb45ae18c8df157b9012adf05bb Mon Sep 17 00:00:00 2001 From: Tim <6703801+timzifer@users.noreply.github.com> Date: Mon, 28 Sep 2026 09:55:53 +0200 Subject: [PATCH 29/42] docs: ADR 0089 is built; examples/market gains a live mode -live folds a seeded tick feed into one-minute candles, revises the open one in place, and draws them on a fitted price axis and a fitted volume track; the reader wheels in at the midpoint and the host keeps the view on the newest candle. ADR 0089 is Accepted, amended: a fitting axis is reset every frame, stacks fit a padded view, and following the data stays the host's one line. Generated with AI Co-Authored-By: AI --- docs/adr/0085-what-a-market-chart-needs.md | 5 +- ...alue-axis-fits-what-its-time-axis-shows.md | 60 +++++++--- docs/adr/README.md | 4 +- docs/features.md | 6 +- docs/interaction.md | 5 + examples/market/live.go | 107 ++++++++++++++++++ examples/market/main.go | 9 +- examples/market/main_test.go | 21 ++++ 8 files changed, 198 insertions(+), 19 deletions(-) create mode 100644 examples/market/live.go diff --git a/docs/adr/0085-what-a-market-chart-needs.md b/docs/adr/0085-what-a-market-chart-needs.md index 7601feb..99fb373 100644 --- a/docs/adr/0085-what-a-market-chart-needs.md +++ b/docs/adr/0085-what-a-market-chart-needs.md @@ -215,8 +215,9 @@ By what a reader loses without it, as ADR 0058 ranks: [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. — **Proposed** - as [ADR 0089](0089-a-value-axis-fits-what-its-time-axis-shows.md). + 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. ## Consequences 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 index 1481a15..cbb02ee 100644 --- 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 @@ -1,6 +1,6 @@ # 0089 — A value axis can fit what the other axis shows, and a stream can revise its last row -**Status:** Proposed · **Date:** 2026-09-28 · **Ranked by:** [ADR 0085](0085-what-a-market-chart-needs.md), rank 3 +**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 @@ -218,15 +218,49 @@ as a stream never was. ## Order of work -1. `scale.FitView`, `scale.LogFitView`, `scale.Pinner`, and `"fit"` in the - dialect. -2. `geom.Training.Within`; render passes it for a fitting panel with a pinned - X; `Line`, `Area`, `Step`, `Scatter`, `Text`, `Bar`, `Rect`, `ErrorBar` and - `Candle` read it. A test that a layer which does not read it still fits - loosely rather than clipping. -3. `Live.Wheel`, `ZoomTo` and `PanBy` skip a fitting axis; a test that a wheel - over a price chart moves time only and the next frame's price axis fits. -4. `data.Stream.ReplaceLast`, race-tested beside `Append`. -5. `stat.Resampler`, with a test that it produces `stat.OHLC`'s candles. -6. `examples/market` gains a live mode — a seeded tick feed through a - resampler into a stream, drawn on a fitted price axis. +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. +- **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/README.md b/docs/adr/README.md index efe1ccc..3d17d1a 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -98,7 +98,7 @@ depends on. | [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 | Proposed | 0085's third 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 | 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 @@ -371,7 +371,7 @@ 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**, *Proposed*: the two halves of a live market +**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 diff --git a/docs/features.md b/docs/features.md index 94a8397..e6debe5 100644 --- a/docs/features.md +++ b/docs/features.md @@ -85,7 +85,11 @@ The feature surface in one list: scales, marks, coordinate systems, layout, outp `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. `examples/market` draws all of - it, with the weekends and a holiday folded from one calendar + 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`**, **`Graph`** and **`NodeLink`**, which read an edge table rather than a pair of diff --git a/docs/interaction.md b/docs/interaction.md index 85534c2..7516af8 100644 --- a/docs/interaction.md +++ b/docs/interaction.md @@ -95,6 +95,11 @@ 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)). + A runnable version is [`backend/window/cmd/demo`](../backend/window/cmd/demo): ```sh diff --git a/examples/market/live.go b/examples/market/live.go new file mode 100644 index 0000000..29d52cc --- /dev/null +++ b/examples/market/live.go @@ -0,0 +1,107 @@ +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/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))) + + live, err := p.Live(figure.SVGWriter(io.Discard)) + if err != nil { + return nil, nil, err + } + defer live.Close() + + 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 + } + } + st.Snapshot() + return x, y, p.Render(figure.SVG(out)) +} + +// 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 index 47f3147..a9072c8 100644 --- a/examples/market/main.go +++ b/examples/market/main.go @@ -36,8 +36,15 @@ import ( 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() - if err := run(*out); err != nil { + 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) } diff --git a/examples/market/main_test.go b/examples/market/main_test.go index 671452e..a66bac5 100644 --- a/examples/market/main_test.go +++ b/examples/market/main_test.go @@ -68,3 +68,24 @@ func TestTheWalkCrossesBothWays(t *testing.T) { 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) + } +} From 26a6ab83342e925e2a241ed61d345af3fcc5725c Mon Sep 17 00:00:00 2001 From: Tim <6703801+timzifer@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:26:54 +0200 Subject: [PATCH 30/42] docs: ADR 0090 proposes that an axis describes the frame it is drawn in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A probe shows a windowed stream's axes never shrink: a trained domain only widens and render keeps its scales between renders, so the time axis keeps the history the window dropped and examples/stream's sliding window never slid. Every render now forgets each trained range once before its layers train. A pinned domain is kept, so a zoom survives; a set of discovered names — a category's slot, a series' colour — is remembered as identity; continuous colour and size ramps follow the range rule; scale.HighWater opts back into accumulation. Generated with AI Co-Authored-By: AI --- ...alue-axis-fits-what-its-time-axis-shows.md | 3 +- ...axis-describes-the-frame-it-is-drawn-in.md | 170 ++++++++++++++++++ docs/adr/README.md | 11 ++ 3 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 docs/adr/0090-an-axis-describes-the-frame-it-is-drawn-in.md 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 index cbb02ee..e57479f 100644 --- 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 @@ -248,7 +248,8 @@ Every step is built. 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. + about every live chart, not this record's — and + [ADR 0090](0090-an-axis-describes-the-frame-it-is-drawn-in.md) asks it. - **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* 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..38b8e84 --- /dev/null +++ b/docs/adr/0090-an-axis-describes-the-frame-it-is-drawn-in.md @@ -0,0 +1,170 @@ +# 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:** Proposed · **Date:** 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 + +1. `scale.Retrainer` on linear, log, symlog, time, probability, the continuous + colour scales and the size scales; a no-op when pinned. `scale.HighWater`. +2. Render retrains every reachable scale once before training; `refit` from + 0089 is removed. A test with a windowed stream: X slides, Y describes the + window, a zoom survives, a series keeps its colour, a discovered category + keeps its slot. +3. `examples/stream` gains a test that its time axis slides. diff --git a/docs/adr/README.md b/docs/adr/README.md index 3d17d1a..9e1639e 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -99,6 +99,7 @@ depends on. | [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 | Proposed | 0089's open question | 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 @@ -379,3 +380,13 @@ 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**, *Proposed*: 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. From 84b72b7f8bb965417657bc32fe9beb5e12c200a0 Mon Sep 17 00:00:00 2001 From: Tim <6703801+timzifer@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:38:57 +0200 Subject: [PATCH 31/42] scale: Retrainer forgets a trained range; HighWater keeps one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retrain drops what a scale was trained on and keeps its pin, options and range — on linear, log, symlog, time and probability axes, the continuous and classed colour scales, both bivariate scales and the size scale. A pinned scale ignores it. Ordinal axes and discrete colour scales do not implement it: their names are identity. HighWater opts a linear axis back into keeping its extent across renders, written "highWater": true. Generated with AI Co-Authored-By: AI --- scale/describe.go | 8 +++- scale/linear.go | 4 +- scale/retrain.go | 110 ++++++++++++++++++++++++++++++++++++++++++++++ spec/decode.go | 2 +- spec/encode.go | 1 + spec/spec.go | 4 ++ 6 files changed, 126 insertions(+), 3 deletions(-) create mode 100644 scale/retrain.go diff --git a/scale/describe.go b/scale/describe.go index ac28452..d2af389 100644 --- a/scale/describe.go +++ b/scale/describe.go @@ -58,6 +58,9 @@ type Desc struct { // 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 @@ -164,6 +167,9 @@ func FromDesc(d Desc) (Scale, error) { 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)) } @@ -279,7 +285,7 @@ func FromDesc(d Desc) (Scale, error) { func (l *linear) Describe() Desc { d := Desc{ - Kind: KindLinear, Nice: l.nice, Zero: l.zero, Fit: l.fit, 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 { diff --git a/scale/linear.go b/scale/linear.go index 1ca4c5b..21d040b 100644 --- a/scale/linear.go +++ b/scale/linear.go @@ -112,7 +112,9 @@ type linear struct { pinned bool reverse bool fit bool // see [FitView] - format func(float64) string + // 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/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/spec/decode.go b/spec/decode.go index 415e8e3..85b2838 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, Fit: s.Fit == "view", 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 diff --git a/spec/encode.go b/spec/encode.go index e9282a2..8e153d2 100644 --- a/spec/encode.go +++ b/spec/encode.go @@ -145,6 +145,7 @@ func encodeScale(d scale.Desc) *Scale { 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 diff --git a/spec/spec.go b/spec/spec.go index 1e01460..a0509fa 100644 --- a/spec/spec.go +++ b/spec/spec.go @@ -682,6 +682,10 @@ type Scale struct { // 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. From 71b694af64fdcc4cad3fd9f4a5f1699ac2bca137 Mon Sep 17 00:00:00 2001 From: Tim <6703801+timzifer@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:38:58 +0200 Subject: [PATCH 32/42] render: every render derives its trained ranges again Before any layer trains, render retrains 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 a windowed stream's time axis kept the history the window had dropped and its value axis kept a peak long gone. A zoom is a pinned domain and survives; categories keep their slots and series their colours. This replaces ADR 0089's refit. Generated with AI Co-Authored-By: AI --- render/render.go | 36 +++++++----- retrain_test.go | 147 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 169 insertions(+), 14 deletions(-) create mode 100644 retrain_test.go diff --git a/render/render.go b/render/render.go index 188c095..fa590aa 100644 --- a/render/render.go +++ b/render/render.go @@ -417,7 +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. - refit(panels) + retrain(panels) 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 @@ -1667,22 +1667,30 @@ func within(x, y scale.Scale) *scale.Interval { return &scale.Interval{Lo: lo, Hi: hi} } -// refit forgets what every fitting axis was trained on last frame, before any -// layer trains it again. A trained domain only ever widens, so an axis fitted -// to last frame's view would keep it after the view moved on; a fitting axis -// is derived from its view every frame, and this is where each frame starts. -// It runs once for all panels rather than per panel, because panels that share -// an axis share one scale, and resetting it between them would forget the -// first panel's rows. -func refit(panels []Panel) { +// 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. +// +// 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) { for _, p := range panels { for _, g := range p.Layers { x, y := p.axesOf(g) - if within(x, y) == nil { - continue - } - if z, ok := y.(scale.Zoomer); ok { - z.Autoscale() + scale.Retrain(x) + scale.Retrain(y) + if d, ok := geom.Describe(g); ok { + if d.ColorScale != nil { + scale.Retrain(d.ColorScale) + } + if d.SizeScale != nil { + scale.Retrain(d.SizeScale) + } } } } 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) + } +} From fb287557eb31a65258d517bad12f642a3b9f6db9 Mon Sep 17 00:00:00 2001 From: Tim <6703801+timzifer@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:38:58 +0200 Subject: [PATCH 33/42] docs: ADR 0090 is built; examples/stream's window now slides examples/stream builds its chart in a function its new test shares, and the test checks deterministically that a full window's time axis starts where the window does. ADR 0090 is Accepted, amended. Generated with AI Co-Authored-By: AI --- ...alue-axis-fits-what-its-time-axis-shows.md | 3 +- ...axis-describes-the-frame-it-is-drawn-in.md | 40 +++++++++++++++---- docs/adr/README.md | 4 +- docs/interaction.md | 7 ++++ examples/stream/main.go | 40 +++++++++++-------- examples/stream/main_test.go | 36 +++++++++++++++++ 6 files changed, 103 insertions(+), 27 deletions(-) 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 index e57479f..68f3285 100644 --- 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 @@ -249,7 +249,8 @@ Every step is built. 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. + [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* 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 index 38b8e84..b89d8e9 100644 --- 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 @@ -1,6 +1,6 @@ # 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:** Proposed · **Date:** 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 +**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 @@ -161,10 +161,34 @@ that answers it should say that it does, which a default cannot. ## Order of work -1. `scale.Retrainer` on linear, log, symlog, time, probability, the continuous - colour scales and the size scales; a no-op when pinned. `scale.HighWater`. -2. Render retrains every reachable scale once before training; `refit` from - 0089 is removed. A test with a windowed stream: X slides, Y describes the - window, a zoom survives, a series keeps its colour, a discovered category - keeps its slot. -3. `examples/stream` gains a test that its time axis slides. +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/README.md b/docs/adr/README.md index 9e1639e..638ba1c 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -99,7 +99,7 @@ depends on. | [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 | Proposed | 0089's open question | +| [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 | 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 @@ -381,7 +381,7 @@ 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**, *Proposed*: a trained domain only +**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 diff --git a/docs/interaction.md b/docs/interaction.md index 7516af8..bc12d79 100644 --- a/docs/interaction.md +++ b/docs/interaction.md @@ -100,6 +100,13 @@ 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/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) + } +} From 2fbd411ecf4b3bcd50c4fd1400fb91cb055739f2 Mon Sep 17 00:00:00 2001 From: Tim <6703801+timzifer@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:51:03 +0200 Subject: [PATCH 34/42] docs: ADR 0091 proposes direction as a colour channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit geom.DirectionBy(from, to) colours each row by whether to is at or above from — or above the previous row's to — through the channel ColorBy feeds, so every mark with a colour column takes it and the two are exclusive. It paints in the candle's Rising and Falling colours and labels, so a volume track agrees with its candles, works on a numeric stream, and the legend's merge by label lists the directions once. The dialect gains direction and directionFrom channels. --- ...ndle-is-one-mark-that-reads-four-values.md | 4 +- ...n-is-a-colour-channel-any-mark-can-take.md | 151 ++++++++++++++++++ docs/adr/README.md | 9 ++ 3 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 docs/adr/0091-a-direction-is-a-colour-channel-any-mark-can-take.md 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 index 462a78f..87f717b 100644 --- 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 @@ -191,7 +191,9 @@ defaults, and the rising and falling colours and labels. - 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. + 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. 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..405c0a1 --- /dev/null +++ b/docs/adr/0091-a-direction-is-a-colour-channel-any-mark-can-take.md @@ -0,0 +1,151 @@ +# 0091 — A direction is a colour channel any mark can take, read from two columns and painted in the candle's colours + +**Status:** Proposed · **Date:** 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 + +1. `geom.DirectionBy`, feeding the colour channel, with the `ColorBy` + exclusion; the candle's `Rising` and `Falling` read by it. Tests: a bar, a + line and a scatter take it; a tie rises; the previous-row form matches a + candle's `SincePrevious`; a dark theme repaints it; the legend merges with a + candle's. +2. The dialect's `"direction"` and `"directionFrom"`, with a round trip. +3. `examples/market` drops its `dir` column; `-live` colours its volume track. diff --git a/docs/adr/README.md b/docs/adr/README.md index 638ba1c..6caa243 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -100,6 +100,7 @@ depends on. | [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 | Proposed | 0088's volume-bar revisit | 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 @@ -390,3 +391,11 @@ 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***, *Proposed*: 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. From bfa78dd4534c6b2f3b6690e8e5da719e68a8c739 Mon Sep 17 00:00:00 2001 From: Tim <6703801+timzifer@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:58:28 +0200 Subject: [PATCH 35/42] geom: DirectionBy colours any mark by which way its row went MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A row rises when its to column is at or above its from column, or — from empty — above the previous row's to; a tie rises. The direction is a discrete colour scale of two labels installed on the layer, so bars batch by it, paths colour in stretches, the legend names rising and falling, and a row missing a value is not drawn, with no mark knowing what a direction is. Colours and labels are the candle's Rising and Falling, or the theme's, bound by render's retrain pass. ColorBy and DirectionBy on one layer is ErrColorAndDirection. The dialect writes direction and directionFrom channels. --- direction_test.go | 61 ++++++++++++++ geom/describe.go | 21 +++-- geom/direction.go | 159 +++++++++++++++++++++++++++++++++++ geom/direction_test.go | 182 +++++++++++++++++++++++++++++++++++++++++ geom/geom.go | 20 +++++ render/render.go | 13 ++- spec/calendar_test.go | 22 +++++ spec/decode.go | 1 + spec/encode.go | 17 ++++ spec/spec.go | 7 ++ 10 files changed, 494 insertions(+), 9 deletions(-) create mode 100644 direction_test.go create mode 100644 geom/direction.go create mode 100644 geom/direction_test.go 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/geom/describe.go b/geom/describe.go index bbb258a..be45eaa 100644 --- a/geom/describe.go +++ b/geom/describe.go @@ -439,12 +439,16 @@ type Desc struct { // 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 - Direction CandleDirection - Rising, Falling *ir.Color - RisingLabel, FallingLabel string - Hollow, HollowSet bool - CandleStyle CandleStyleKind + 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 + 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 @@ -777,6 +781,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)) } @@ -889,6 +896,8 @@ func (c config) describeStacking(mark Mark, def Stacking) Desc { ErrorXCol: c.errXCol, Caps: c.caps, OHLC: c.ohlc, + DirectionFrom: c.dirFrom, + DirectionTo: c.dirTo, Direction: c.direction, Rising: c.rising.color, Falling: c.falling.color, 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/geom.go b/geom/geom.go index c8c74db..c938fad 100644 --- a/geom/geom.go +++ b/geom/geom.go @@ -275,6 +275,10 @@ type config struct { hollowSet bool candleStyle CandleStyleKind + // The two columns a [DirectionBy] layer compares, from empty for the + // previous row. + dirFrom, dirTo string + sizeCol string sizeScale scale.SizeScale @@ -1315,6 +1319,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 } @@ -1600,6 +1610,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 { @@ -1610,6 +1623,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 { diff --git a/render/render.go b/render/render.go index fa590aa..2599f56 100644 --- a/render/render.go +++ b/render/render.go @@ -417,7 +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) + 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 @@ -1672,13 +1672,15 @@ func within(x, y scale.Scale) *scale.Interval { // 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. +// 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) { +func retrain(panels []Panel, th theme.Theme) { for _, p := range panels { for _, g := range p.Layers { x, y := p.axesOf(g) @@ -1687,6 +1689,11 @@ func retrain(panels []Panel) { 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/spec/calendar_test.go b/spec/calendar_test.go index 42870d8..119d246 100644 --- a/spec/calendar_test.go +++ b/spec/calendar_test.go @@ -85,3 +85,25 @@ func TestACandleSurvivesTheDocument(t *testing.T) { 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) + } + }) + } +} diff --git a/spec/decode.go b/spec/decode.go index 85b2838..d63ea77 100644 --- a/spec/decode.go +++ b/spec/decode.go @@ -472,6 +472,7 @@ func decodeLayerEncoding(d *geom.Desc, enc *Encoding) error { 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 8e153d2..83f4fb7 100644 --- a/spec/encode.go +++ b/spec/encode.go @@ -402,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 { @@ -857,6 +868,12 @@ 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 == "" { diff --git a/spec/spec.go b/spec/spec.go index a0509fa..d1d7233 100644 --- a/spec/spec.go +++ b/spec/spec.go @@ -636,6 +636,13 @@ type Encoding struct { 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 From 424de982476f6cace740eaa1a26875acbe9e4cca Mon Sep 17 00:00:00 2001 From: Tim <6703801+timzifer@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:58:28 +0200 Subject: [PATCH 36/42] docs: ADR 0091 is built; the market volume bars take DirectionBy examples/market drops its hand-computed dir column and its Named scale, and the live chart's volume track is coloured by direction straight from the stream's numeric open and close. ADR 0091 is Accepted, amended. --- ...n-is-a-colour-channel-any-mark-can-take.md | 44 +++++++++++++++---- docs/adr/README.md | 4 +- docs/features.md | 5 ++- examples/market/live.go | 5 ++- examples/market/main.go | 22 ++++------ 5 files changed, 55 insertions(+), 25 deletions(-) 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 index 405c0a1..b3313d4 100644 --- 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 @@ -1,6 +1,6 @@ # 0091 — A direction is a colour channel any mark can take, read from two columns and painted in the candle's colours -**Status:** Proposed · **Date:** 2026-09-28 · **Revisits:** [ADR 0088](0088-a-candle-is-one-mark-that-reads-four-values.md)'s *Revisit if* +**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 @@ -142,10 +142,38 @@ defined, now written for any mark that carries a direction. ## Order of work -1. `geom.DirectionBy`, feeding the colour channel, with the `ColorBy` - exclusion; the candle's `Rising` and `Falling` read by it. Tests: a bar, a - line and a scatter take it; a tie rises; the previous-row form matches a - candle's `SincePrevious`; a dark theme repaints it; the legend merges with a - candle's. -2. The dialect's `"direction"` and `"directionFrom"`, with a round trip. -3. `examples/market` drops its `dir` column; `-live` colours its volume track. +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/README.md b/docs/adr/README.md index 6caa243..9c1c5a5 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -100,7 +100,7 @@ depends on. | [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 | Proposed | 0088's volume-bar revisit | +| [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 | 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 @@ -392,7 +392,7 @@ 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***, *Proposed*: volume bars coloured by +**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 diff --git a/docs/features.md b/docs/features.md index e6debe5..b5db2d3 100644 --- a/docs/features.md +++ b/docs/features.md @@ -84,7 +84,10 @@ The feature surface in one list: scales, marks, coordinate systems, layout, outp 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. `examples/market` draws all of + 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)). `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 diff --git a/examples/market/live.go b/examples/market/live.go index 29d52cc..a147cf7 100644 --- a/examples/market/live.go +++ b/examples/market/live.go @@ -42,7 +42,10 @@ func runLive(out string, ticks int) (x, y scale.Scale, err error) { // 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))) + 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"))) live, err := p.Live(figure.SVGWriter(io.Discard)) if err != nil { diff --git a/examples/market/main.go b/examples/market/main.go index a9072c8..4dc1b95 100644 --- a/examples/market/main.go +++ b/examples/market/main.go @@ -9,9 +9,9 @@ // 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 one column still computed -// by hand is the direction the volume bars are coloured by: a candle decides -// its direction for itself, not for the layer beside it. +// 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. @@ -92,7 +92,6 @@ func run(out string) error { // 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"))) - dir := scale.Named(map[string]ir.Color{"up": up, "down": down}) p.Add(geom.Line(candles, geom.X("t"), geom.Y("ema"), geom.Color(palette.Orange), geom.Label(fmt.Sprintf("EMA %d", fast)))) @@ -108,10 +107,13 @@ func run(out string) error { 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. + // 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.ColorBy("dir", dir), geom.Opacity(0.6))) + 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 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 @@ -192,16 +194,11 @@ func candleTable(days []stat.Candle) *data.Table { 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) - dir := make([]string, 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 - dir[i] = "down" - if d.Up() { - dir[i] = "up" - } } // The indicators run over the row index rather than the time: a trading @@ -219,7 +216,6 @@ func candleTable(days []stat.Candle) *data.Table { Time("t", t). Float64("open", o).Float64("high", h).Float64("low", l).Float64("close", c). Float64("volume", v). - String("dir", dir). Float64("ema", ema).Float64("mid", mid). Float64("lower", lower).Float64("upper", upper) } From 73f1dca8318587ba962bf6320ac9dbe5172c8d4e Mon Sep 17 00:00:00 2001 From: Tim <6703801+timzifer@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:03:12 +0200 Subject: [PATCH 37/42] docs: ADR 0092 proposes axis tags, a snapping crosshair and RSI/VWAP The last price and a crosshair readout are one mechanism: a value written on its axis, which nothing drew outside a panel before. 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 asks for one at the newest row. Crosshair snaps to the row nearest the pointer along X through Index.NearestAlong and tags its position. RSI and VWAP become stat functions because composing them gets Wilder's smoothing and the session reset wrong; MACD stays a recipe. --- docs/adr/0085-what-a-market-chart-needs.md | 5 +- ...at-a-trading-screen-reads-off-its-edges.md | 189 ++++++++++++++++++ docs/adr/README.md | 12 ++ 3 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 docs/adr/0092-what-a-trading-screen-reads-off-its-edges.md diff --git a/docs/adr/0085-what-a-market-chart-needs.md b/docs/adr/0085-what-a-market-chart-needs.md index 99fb373..f756ef1 100644 --- a/docs/adr/0085-what-a-market-chart-needs.md +++ b/docs/adr/0085-what-a-market-chart-needs.md @@ -218,7 +218,10 @@ By what a reader loses without it, as ADR 0058 ranks: 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. +5. The rest, as asked for. — **Proposed** 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 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..2f73707 --- /dev/null +++ b/docs/adr/0092-what-a-trading-screen-reads-off-its-edges.md @@ -0,0 +1,189 @@ +# 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:** Proposed · **Date:** 2026-09-28 · **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 + +1. `render` draws `AxisTag`s from `geom.Tagger` layers and from overlays, with + the tick labels they cover hidden and tags on one axis pushed apart. +2. `geom.LastValue`. +3. `interact.Index.NearestAlong`; `Crosshair.SnapX` and `Crosshair.Tags`. +4. `stat.RSI` and `stat.VWAP` with their Append pairs, tested against published + reference values; the MACD recipe in `docs/chart-types.md`. +5. `examples/market` gains an RSI track and a last-price tag, and `-live` a + snapping crosshair driven by a scripted pointer. diff --git a/docs/adr/README.md b/docs/adr/README.md index 9c1c5a5..63ef6d6 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -101,6 +101,7 @@ depends on. | [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 | Proposed | 0085's fifth rank | 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 @@ -399,3 +400,14 @@ 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**, *Proposed*, 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. From f874fb24667b452b2aa49cd00a5c450bd259735a Mon Sep 17 00:00:00 2001 From: Tim <6703801+timzifer@users.noreply.github.com> Date: Tue, 29 Sep 2026 06:52:43 +0200 Subject: [PATCH 38/42] stat: RSI and VWAP, the indicators a composition gets wrong MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RSI uses Wilder's averages — seeded with the simple mean of the first window of changes, then moved 1/window of the way — which an EMA composition does not; it is checked against the seed's own arithmetic and StockCharts' published example to its rounding. VWAP resets its sums at every session edge, which a Cumsum composition forgets. MACD stays a recipe: composing EMAs is its definition. --- stat/alloc_test.go | 2 + stat/indicator.go | 112 +++++++++++++++++++++++++++++++++++++++++ stat/indicator_test.go | 96 +++++++++++++++++++++++++++++++++++ 3 files changed, 210 insertions(+) create mode 100644 stat/indicator.go create mode 100644 stat/indicator_test.go diff --git a/stat/alloc_test.go b/stat/alloc_test.go index f796128..8b271c8 100644 --- a/stat/alloc_test.go +++ b/stat/alloc_test.go @@ -117,6 +117,8 @@ func TestTheTrailingFormsDoNotAllocateIntoAWarmSlice(t *testing.T) { "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 { 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) + } +} From 0cf816ef4b1ad3a95f84f107ab10510f39eaa447 Mon Sep 17 00:00:00 2001 From: Tim <6703801+timzifer@users.noreply.github.com> Date: Tue, 29 Sep 2026 06:52:43 +0200 Subject: [PATCH 39/42] scale: ValueLabelOf writes a value read off an axis A tick label has the tick step's precision, so a last price of 94.37 on an axis ticked in whole units would read 94, and a time axis could not write an arbitrary instant at all. ValueLabelOf gives a linear value as many decimals as it needs, up to two past the ticks, and a time value its date, with the time of day when it is not a midnight. --- scale/calendar_test.go | 32 +++++++++++++++++++++ scale/valuelabel.go | 63 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 scale/valuelabel.go diff --git a/scale/calendar_test.go b/scale/calendar_test.go index 85ab81e..1649065 100644 --- a/scale/calendar_test.go +++ b/scale/calendar_test.go @@ -342,3 +342,35 @@ func TestADayTickInAFoldMovesToTheNextOpen(t *testing.T) { 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/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) +} From 1bb03a6824cc4a77d1dc62e03ff7d335fa3b32e1 Mon Sep 17 00:00:00 2001 From: Tim <6703801+timzifer@users.noreply.github.com> Date: Tue, 29 Sep 2026 06:52:44 +0200 Subject: [PATCH 40/42] render, geom: axis tags, LastValue and a snapping crosshair An AxisTag is a value written on an axis in its gutter. Layers ask for tags through the optional geom.Tagger; render places them after ranging a panel, leaves out the tick labels they cover and draws them after the data, pushing tags on one axis apart. geom.LastValue draws a rule and a tag at the newest row, coloured by Color or DirectionBy, and trains its axis on that value only. Index.NearestAlong finds the row nearest a position along one axis; Crosshair.Snap uses it to cross at the candle under the pointer's time, and Crosshair.Tags writes its position on the axes through render.DrawAxisTags. The dialect writes lastValue and rule. --- geom/describe.go | 20 +++- geom/geom.go | 3 + geom/lastvalue.go | 132 +++++++++++++++++++++++++ geom/tag.go | 44 +++++++++ interact/interact.go | 44 +++++++++ overlay.go | 46 ++++++++- render/render.go | 20 ++++ render/tag.go | 225 ++++++++++++++++++++++++++++++++++++++++++ spec/calendar_test.go | 22 +++++ spec/decode.go | 1 + spec/encode.go | 8 ++ spec/spec.go | 5 + spec/vocab.go | 4 + tag_test.go | 118 ++++++++++++++++++++++ 14 files changed, 683 insertions(+), 9 deletions(-) create mode 100644 geom/lastvalue.go create mode 100644 geom/tag.go create mode 100644 render/tag.go create mode 100644 tag_test.go diff --git a/geom/describe.go b/geom/describe.go index be45eaa..3fa3998 100644 --- a/geom/describe.go +++ b/geom/describe.go @@ -126,6 +126,11 @@ const ( // 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 @@ -444,11 +449,13 @@ type Desc struct { // compares, DirectionFrom empty for the previous row. Its colours and // labels are Rising, Falling, RisingLabel and FallingLabel. DirectionFrom, DirectionTo string - Direction CandleDirection - Rising, Falling *ir.Color - RisingLabel, FallingLabel string - Hollow, HollowSet bool - CandleStyle CandleStyleKind + // 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 @@ -529,6 +536,8 @@ func FromDesc(d Desc) (Geom, error) { 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: @@ -897,6 +906,7 @@ func (c config) describeStacking(mark Mark, def Stacking) Desc { Caps: c.caps, OHLC: c.ohlc, DirectionFrom: c.dirFrom, + Rule: c.rule, DirectionTo: c.dirTo, Direction: c.direction, Rising: c.rising.color, diff --git a/geom/geom.go b/geom/geom.go index c938fad..12d2027 100644 --- a/geom/geom.go +++ b/geom/geom.go @@ -279,6 +279,9 @@ type config struct { // previous row. dirFrom, dirTo string + // rule is whether a [LastValue] draws its line across the panel. + rule bool + sizeCol string sizeScale scale.SizeScale 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/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/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/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 2599f56..11c4ca8 100644 --- a/render/render.go +++ b/render/render.go @@ -486,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) @@ -522,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 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/spec/calendar_test.go b/spec/calendar_test.go index 119d246..6349bfb 100644 --- a/spec/calendar_test.go +++ b/spec/calendar_test.go @@ -107,3 +107,25 @@ func TestADirectionSurvivesTheDocument(t *testing.T) { }) } } + +// 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 d63ea77..526f53b 100644 --- a/spec/decode.go +++ b/spec/decode.go @@ -353,6 +353,7 @@ 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 } diff --git a/spec/encode.go b/spec/encode.go index 83f4fb7..8de80a8 100644 --- a/spec/encode.go +++ b/spec/encode.go @@ -687,6 +687,14 @@ 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() diff --git a/spec/spec.go b/spec/spec.go index d1d7233..03afaba 100644 --- a/spec/spec.go +++ b/spec/spec.go @@ -229,6 +229,11 @@ type Mark struct { 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 diff --git a/spec/vocab.go b/spec/vocab.go index b82da8d..a4a0d07 100644 --- a/spec/vocab.go +++ b/spec/vocab.go @@ -136,6 +136,8 @@ func markType(m geom.Mark) (typ, orient string, err error) { // 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 @@ -204,6 +206,8 @@ func geomMark(m Mark, enc *Encoding) (geom.Mark, error) { return geom.MarkErrorBar, nil case "candlestick": return geom.MarkCandle, nil + case "lastValue": + return geom.MarkLastValue, nil case "voronoi": return geom.MarkVoronoi, nil case "treemap": 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) + } +} From 4f875e6b43d0abbb919a8480c5fcced7f46a15b0 Mon Sep 17 00:00:00 2001 From: Tim <6703801+timzifer@users.noreply.github.com> Date: Tue, 29 Sep 2026 06:52:44 +0200 Subject: [PATCH 41/42] docs: ADR 0092 is built; the market example tags its last price examples/market gains an RSI track and a last-price tag; -live tags the open candle's price and draws a crosshair snapping to the candle under a resting pointer into the file it writes. chart-types.md carries the RSI, MACD, VWAP, last-price and crosshair recipes. ADR 0092 is Accepted, amended. --- docs/adr/0085-what-a-market-chart-needs.md | 2 +- ...at-a-trading-screen-reads-off-its-edges.md | 51 +++++++++++++++---- docs/adr/README.md | 4 +- docs/chart-types.md | 5 ++ docs/features.md | 6 ++- examples/market/live.go | 31 ++++++++++- examples/market/main.go | 18 ++++++- 7 files changed, 101 insertions(+), 16 deletions(-) diff --git a/docs/adr/0085-what-a-market-chart-needs.md b/docs/adr/0085-what-a-market-chart-needs.md index f756ef1..8287502 100644 --- a/docs/adr/0085-what-a-market-chart-needs.md +++ b/docs/adr/0085-what-a-market-chart-needs.md @@ -218,7 +218,7 @@ By what a reader loses without it, as ADR 0058 ranks: 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. — **Proposed** as +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. 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 index 2f73707..2636c89 100644 --- 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 @@ -1,6 +1,6 @@ # 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:** Proposed · **Date:** 2026-09-28 · **Ranked by:** [ADR 0085](0085-what-a-market-chart-needs.md), rank 5 +**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 @@ -179,11 +179,44 @@ composition gives the wrong numbers. ## Order of work -1. `render` draws `AxisTag`s from `geom.Tagger` layers and from overlays, with - the tick labels they cover hidden and tags on one axis pushed apart. -2. `geom.LastValue`. -3. `interact.Index.NearestAlong`; `Crosshair.SnapX` and `Crosshair.Tags`. -4. `stat.RSI` and `stat.VWAP` with their Append pairs, tested against published - reference values; the MACD recipe in `docs/chart-types.md`. -5. `examples/market` gains an RSI track and a last-price tag, and `-live` a - snapping crosshair driven by a scripted pointer. +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/README.md b/docs/adr/README.md index 63ef6d6..78833ae 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -101,7 +101,7 @@ depends on. | [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 | Proposed | 0085's fifth rank | +| [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 | 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 @@ -401,7 +401,7 @@ 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**, *Proposed*, and it found that two of its three +**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 diff --git a/docs/chart-types.md b/docs/chart-types.md index aa94ae5..8b2a1bd 100644 --- a/docs/chart-types.md +++ b/docs/chart-types.md @@ -74,6 +74,11 @@ and it turns eight charts into recipes. | 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 b5db2d3..aff42e9 100644 --- a/docs/features.md +++ b/docs/features.md @@ -87,7 +87,11 @@ The feature surface in one list: scales, marks, coordinate systems, layout, outp 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)). `examples/market` draws all of + ([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 diff --git a/examples/market/live.go b/examples/market/live.go index a147cf7..4ae5783 100644 --- a/examples/market/live.go +++ b/examples/market/live.go @@ -9,6 +9,7 @@ import ( "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" ) @@ -47,11 +48,22 @@ func runLive(out string, ticks int) (x, y scale.Scale, err error) { // 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)) @@ -100,10 +112,27 @@ func runLive(out string, ticks int) (x, y scale.Scale, err error) { } 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() - return x, y, p.Render(figure.SVG(out)) + + // 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. diff --git a/examples/market/main.go b/examples/market/main.go index 4dc1b95..39b13ca 100644 --- a/examples/market/main.go +++ b/examples/market/main.go @@ -71,7 +71,7 @@ func run(out string) error { candles := candleTable(days) p := figure.New( - figure.Size(1000, 560), + 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 @@ -115,6 +115,19 @@ func run(out string) error { 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 @@ -207,6 +220,7 @@ func candleTable(days []stat.Candle) *data.Table { 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] @@ -216,7 +230,7 @@ func candleTable(days []stat.Candle) *data.Table { 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("ema", ema).Float64("mid", mid).Float64("rsi", rsi). Float64("lower", lower).Float64("upper", upper) } From 0fca46ad1242007afb5b64a12479422f9b9b9b03 Mon Sep 17 00:00:00 2001 From: Tim <6703801+timzifer@users.noreply.github.com> Date: Tue, 29 Sep 2026 07:26:08 +0200 Subject: [PATCH 42/42] docs: ADR 0093 proposes faceted tracks and relative series MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A watchlist is a facet by symbol whose panels each carry the plot's tracks — repeated per panel and subset by the facet like its layers, free where the panel is free — laid out as a block per facet cell with a fixed panel order. ErrTrackWithFacet, which refused a band spanning a facet, is retired; the spanning band stays unbuilt. A comparison is geom.Relative: each series divided by its first value in view, so the zero follows the view, on a percent axis whose NumberFormat gains a + flag. --- ...a-facet-whose-panels-carry-their-tracks.md | 187 ++++++++++++++++++ docs/adr/README.md | 9 + 2 files changed, 196 insertions(+) create mode 100644 docs/adr/0093-several-symbols-are-a-facet-whose-panels-carry-their-tracks.md 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 78833ae..03189b3 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -102,6 +102,7 @@ depends on. | [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 @@ -411,3 +412,11 @@ 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.