From 8f30a2ca333aca9f13497d74db199ad5dd231910 Mon Sep 17 00:00:00 2001 From: Tim <6703801+timzifer@users.noreply.github.com> Date: Tue, 22 Sep 2026 07:34:47 +0200 Subject: [PATCH] scale, coord, render: an axis break is marked or it is not drawn A linear or time axis can now leave intervals out. scale.Break and scale.TimeBreak take one interval out, for a chart where one value would flatten the rest. scale.Fold and scale.TimeFold take many out, for stretches the reader does not want to see, such as a machine's idle periods. figure.SpansWhere reads those periods out of a table. Map stays continuous. A value inside a cut maps across the gap and the panel's clip hides it, so Invert is exact and a bar across a break is still one bar with its middle missing. The gap is a device length that render sets through scale.Breaker. It is switched off under any coord that cannot mark a break, and on second axes, because a break without its mark lies about distances. Only Cartesian marks one: it cuts the clip into one rectangle per kept piece, splits axis and grid lines, and draws a slash or a zigzag over the data. A cut that reaches an end of the domain trims the axis. Ticks use one step for the length the axis still shows. Lookup is a binary search, so an axis with hundreds of folds allocates nothing per row (BenchmarkFolded1k/100k are in the allocation gate). The dialect spells cuts and folds as "cuts" and "folds". The accessible description names them. Charts without breaks draw exactly what they drew; every golden file and documentation figure is unchanged. See ADR 0083. Co-Authored-By: Claude Opus 5 (1M context) --- .github/scripts/allocgate.awk | 6 + AGENTS.md | 21 +- a11y/a11y.go | 40 ++ a11y/a11y_test.go | 20 + alloc_test.go | 32 ++ backend/gg/cmd/gallery/axisbreak.go | 77 +++ backend/gg/cmd/gallery/main.go | 2 +- breaks_test.go | 212 +++++++++ coord/breaks.go | 257 ++++++++++ coord/coord.go | 11 +- coord/furniture.go | 7 + ...83-an-axis-break-is-marked-or-not-drawn.md | 219 +++++++++ docs/adr/README.md | 1 + docs/chart-types.md | 1 + docs/gallery.md | 2 + docs/images/axis-break-zigzag.png | Bin 0 -> 18428 bytes docs/images/axis-break-zigzag.svg | 1 + docs/images/axis-break.png | Bin 0 -> 16762 bytes docs/images/axis-break.svg | 1 + docs/images/machine-folds.png | Bin 0 -> 16821 bytes docs/images/machine-folds.svg | 1 + fold.go | 159 +++++++ render/render.go | 56 ++- scale/breaks.go | 439 ++++++++++++++++++ scale/breaks_test.go | 256 ++++++++++ scale/describe.go | 17 +- scale/linear.go | 26 +- scale/time.go | 50 ++ spec/breaks_test.go | 74 +++ spec/decode.go | 28 ++ spec/encode.go | 22 + spec/spec.go | 10 + testdata/golden/axis-break-slash.svg | 42 ++ testdata/golden/axis-break-time.svg | 46 ++ testdata/golden/axis-break-zigzag.svg | 42 ++ testdata/golden/axis-folds.svg | 39 ++ theme/theme.go | 55 +++ theme/theme_test.go | 14 + theme/tokens.go | 6 + 39 files changed, 2282 insertions(+), 10 deletions(-) create mode 100644 backend/gg/cmd/gallery/axisbreak.go create mode 100644 breaks_test.go create mode 100644 coord/breaks.go create mode 100644 docs/adr/0083-an-axis-break-is-marked-or-not-drawn.md create mode 100644 docs/images/axis-break-zigzag.png create mode 100644 docs/images/axis-break-zigzag.svg create mode 100644 docs/images/axis-break.png create mode 100644 docs/images/axis-break.svg create mode 100644 docs/images/machine-folds.png create mode 100644 docs/images/machine-folds.svg create mode 100644 fold.go create mode 100644 scale/breaks.go create mode 100644 scale/breaks_test.go create mode 100644 spec/breaks_test.go create mode 100644 testdata/golden/axis-break-slash.svg create mode 100644 testdata/golden/axis-break-time.svg create mode 100644 testdata/golden/axis-break-zigzag.svg create mode 100644 testdata/golden/axis-folds.svg diff --git a/.github/scripts/allocgate.awk b/.github/scripts/allocgate.awk index f3ee6ce..42023ee 100644 --- a/.github/scripts/allocgate.awk +++ b/.github/scripts/allocgate.awk @@ -190,6 +190,12 @@ END { # because collecting them is the obvious way to write it. flat("BenchmarkGlobe1k", "BenchmarkGlobe100k", 8) + # A folded time axis. Each row is placed by a binary search over two + # hundred folds, resolved on the stack every call, so a pan needs nothing + # invalidated and a frame over a hundred thousand rows allocates what one + # over a thousand does. See docs/adr/0083-an-axis-break-is-marked-or-not-drawn.md. + flat("BenchmarkFolded1k", "BenchmarkFolded100k", 8) + # Row identity, added after v0.5. Tracking which source row is behind each # mark is opt-in, and what it is opt-in *for* is memory per mark — not # per-frame allocations. If that stops being true it is a buffer that diff --git a/AGENTS.md b/AGENTS.md index 62e3802..2c02ef7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -106,6 +106,23 @@ pan turns the axis back round. What reverses is the device range — see across a panel walks them in **screen** order for the same reason: a tick sequence is ascending by value, and on a reversed axis that is right to left. +**An axis break is off until the renderer switches it on, and a value inside +one is not missing.** `scale.Break`, `scale.Fold` and their time forms keep +`Map` continuous: a value in a cut maps linearly across the gap and the panel's +clip hides it, which is what keeps `Invert` exact and a bar across the break one +bar. Do not "fix" that by answering NaN or `Defined == false` — a point there +would vanish silently and the tall bar the break exists for would be cut. The +gap is a device length the scale cannot know, so `render.breakGaps` sets it +through `scale.Breaker` once per render, and sets it **off** under any coord +that is not `coord.Breakable` and on every second axis: a break drawn without +its mark is an axis whose ruler is wrong where nobody says so. The cuts are +resolved on the stack of every call rather than cached, so a pan needs nothing +invalidated and a snapshot shares nothing it writes; per piece, `place` and the +endpoint snap apply exactly as they do to a whole axis. With no active cut, +`framedCartesian.Clip` is the panel rectangle and its furniture is two-point +polylines — which is why no golden file moved, and why that branch must stay. +See [ADR 0083](docs/adr/0083-an-axis-break-is-marked-or-not-drawn.md). + **Scales place a value with one explicit rounding, and it is not redundant.** `scale.place` writes `rlo + float32(float32(t)*(rhi-rlo))`. The inner conversion looks like a no-op — `t` is already being converted — and it is not: @@ -1307,9 +1324,9 @@ Optional interfaces are how this codebase extends a type without breaking everyone who implements it: `scale.Definite`, `scale.Categorical`, `scale.Band`, `scale.Cloner`, `scale.Snapshotter`, `scale.Zoomer`, `scale.Describer`, `scale.ColorDescriber`, `scale.DiscreteColorScale`, -`scale.SizeDescriber`, `scale.Temporal`, `geom.Faceter`, `geom.Guided`, +`scale.SizeDescriber`, `scale.Temporal`, `scale.Breaker`, `geom.Faceter`, `geom.Guided`, `geom.Sized`, `geom.Legender`, `geom.Describer`, `coord.Describer`, -`coord.Exploder`, `ir.Partial`, `ir.Semantics`, `ir.Resizer`, +`coord.Exploder`, `coord.Breakable`, `ir.Partial`, `ir.Semantics`, `ir.Resizer`, `mathtext.Plainer`. Reach for one before adding a method to `Scale`, `Geom` or `Backend`. `geom.Legender` is the newest and the argument is worth keeping in view: a pie, a stack and a waffle contribute N legend entries from one layer, diff --git a/a11y/a11y.go b/a11y/a11y.go index 6a3eebe..0e1fa6a 100644 --- a/a11y/a11y.go +++ b/a11y/a11y.go @@ -342,6 +342,8 @@ func detail(c Chart, series []Series) string { } b.WriteString(".") } + b.WriteString(cutsPhrase("horizontal", c.X)) + b.WriteString(cutsPhrase("vertical", c.Y)) if c.Facet != "" { fmt.Fprintf(&b, " Split into one panel per value of %s.", c.Facet) } @@ -352,6 +354,44 @@ func detail(c Chart, series []Series) string { return b.String() } +// cutsPhrase says which intervals an axis leaves out. A sighted reader is told +// by the mark on the axis; a listener has to be told in words, or the chart +// says two distances are alike that are not. See +// docs/adr/0083-an-axis-break-is-marked-or-not-drawn.md. +func cutsPhrase(axis string, s scale.Scale) string { + d, ok := scale.Describe(s) + if !ok || len(d.Cuts)+len(d.Folds) == 0 { + return "" + } + spell := func(v float64) string { + if d.Kind == scale.KindTime { + return scale.InstantOf(s, v).UTC().Format(time.RFC3339) + } + return data.FormatNumber(v) + } + var b strings.Builder + write := func(what string, ivs []scale.Interval) { + if len(ivs) == 0 { + return + } + fmt.Fprintf(&b, " The %s axis %s ", axis, what) + for i, iv := range ivs { + switch { + case i == 0: + case i == len(ivs)-1: + b.WriteString(" and ") + default: + b.WriteString(", ") + } + fmt.Fprintf(&b, "%s to %s", spell(iv.Lo), spell(iv.Hi)) + } + b.WriteString(".") + } + write("is broken, leaving out", d.Cuts) + write("folds out", d.Folds) + return b.String() +} + func sentence(s Series) string { var b strings.Builder b.WriteString(s.Label) diff --git a/a11y/a11y_test.go b/a11y/a11y_test.go index 29abc9b..edc0eb3 100644 --- a/a11y/a11y_test.go +++ b/a11y/a11y_test.go @@ -237,3 +237,23 @@ func TestARasterIsReadAsItsReadingsAndNotAsAnImage(t *testing.T) { t.Errorf("the table mentions the image the mark happens to draw with:\n%s", tab) } } + +// A break is marked on the axis for a reader who can see it; a listener is +// told in words which stretch is missing, or the description says two +// distances are alike that are not. +func TestABrokenAxisIsDescribed(t *testing.T) { + c := a11y.Chart{ + X: scale.Linear(), + Y: scale.Linear(scale.Break(10, 88), scale.Fold(scale.Interval{Lo: 92, Hi: 94})), + Layers: []geom.Geom{geom.Line(table(), geom.X("x"), geom.Y("y"))}, + } + s := a11y.Describe(c) + for _, want := range []string{"vertical axis is broken, leaving out 10 to 88", "vertical axis folds out 92 to 94"} { + if !strings.Contains(s.Detail, want) { + t.Errorf("the description does not mention %q:\n%s", want, s.Detail) + } + } + if strings.Contains(s.Detail, "horizontal axis") { + t.Errorf("an unbroken axis was described as broken:\n%s", s.Detail) + } +} diff --git a/alloc_test.go b/alloc_test.go index c9847ab..78c8755 100644 --- a/alloc_test.go +++ b/alloc_test.go @@ -831,3 +831,35 @@ func spiral(rows int) *three.Plot { Add(three.Line3(src, geom.X("x"), geom.Y("y"), geom.Z("t"))) return three.New(three.Size(800, 600)).Scene(sc) } + +// shiftLog is a series over n rows with two hundred idle stretches folded +// off its axis — the machine chart a fold exists for, at a size where a walk +// over the folds per row would show. The axis is linear rather than time so +// that what is measured is the fold and not the conversion of a time column, +// which a time axis pays with or without one; the two scales share the code +// that places a value around a fold. +func shiftLog(n int) *figure.Plot { + xs := make([]float64, n) + vs := make([]float64, n) + for i := range n { + xs[i] = float64(i) / float64(n) * 1000 + vs[i] = math.Sin(float64(i) / 40) + } + folds := make([]scale.Interval, 200) + for i := range folds { + at := float64(i+1) * 1000 / 201 + folds[i] = scale.Interval{Lo: at - 1, Hi: at + 1} + } + p := figure.New(figure.Size(800, 400)) + p.X(scale.Linear(scale.Fold(folds...))) + p.Y(scale.Linear(scale.Nice())) + p.Add(geom.Line(figure.Float64Columns(map[string][]float64{"x": xs, "v": vs}), geom.X("x"), geom.Y("v"))) + return p +} + +// A folded axis, at a thousand rows and at a hundred thousand. Every row is +// mapped by a binary search over the folds, into nothing but the stack; what +// must not grow is the allocation count. See +// docs/adr/0083-an-axis-break-is-marked-or-not-drawn.md. +func BenchmarkFolded1k(b *testing.B) { onOnePGate(b); benchmarkPlot(b, shiftLog(1_000)) } +func BenchmarkFolded100k(b *testing.B) { onOnePGate(b); benchmarkPlot(b, shiftLog(100_000)) } diff --git a/backend/gg/cmd/gallery/axisbreak.go b/backend/gg/cmd/gallery/axisbreak.go new file mode 100644 index 0000000..d6d19ba --- /dev/null +++ b/backend/gg/cmd/gallery/axisbreak.go @@ -0,0 +1,77 @@ +package main + +import ( + "time" + + "github.com/timzifer/figure" + "github.com/timzifer/figure/geom" + "github.com/timzifer/figure/palette" + "github.com/timzifer/figure/scale" + "github.com/timzifer/figure/theme" +) + +// The plates for axis breaks and folds: one interval left out of an axis +// because a single value would flatten the rest, and many left out because +// the reader does not want to look at them. +// +// See docs/adr/0083-an-axis-break-is-marked-or-not-drawn.md. +func axisBreakFigures() []plate { + return []plate{ + axisBreakFigure("axis-break", "One site, thirty times the rest", theme.Light), + axisBreakFigure("axis-break-zigzag", "theme.AxisBreaks(theme.BreakZigzag, 0, 10)", + theme.Light.With(theme.AxisBreaks(theme.BreakZigzag, 0, 10))), + machineFoldsFigure(), + } +} + +// axisBreakFigure is the chart a break exists for. Drawn on one unbroken axis, +// the outlier leaves every other bar a sliver at the bottom; broken between +// 12 and 85, each side reads at its own resolution and the bar that crosses +// the break is drawn with its middle cut out. +func axisBreakFigure(name, title string, th theme.Theme) plate { + return plate{ + name: name, width: 620, high: 400, theme: th, title: title, + opts: []figure.Option{figure.YTitle("requests per second (thousands)")}, + build: func(p *figure.Plot) { + src := figure.NewTable(). + String("site", []string{"Berlin", "Hamburg", "Köln", "Frankfurt", "München", "Leipzig"}). + Float64("load", []float64{6.2, 8.9, 4.1, 94.5, 10.3, 3.2}) + p.X(scale.Ordinal()) + p.Y(scale.Linear(scale.Domain(0, 100), scale.Break(12, 85))) + p.Add(geom.Bar(src, geom.X("site"), geom.Y("load"), geom.Color(palette.SkyBlue))) + }, + } +} + +// machineFoldsFigure is a machine's state log over two shifts with every +// idle period folded off the time axis: the axis shows the time the machine +// was doing something, and a small slash marks each place the clock jumps. +func machineFoldsFigure() plate { + t0 := time.Date(2026, 3, 2, 6, 0, 0, 0, time.UTC) + at := func(h float64) time.Time { return t0.Add(time.Duration(h * float64(time.Hour))) } + hours := []float64{0, 1.5, 3, 3.5, 4.5, 6, 7.5, 8, 9.5, 11, 12.5, 13, 14.5, 16} + states := []string{"Aktiv", "Inaktiv", "Aktiv", "Störung", "Aktiv", "Inaktiv", "Rüsten", + "Aktiv", "Inaktiv", "Aktiv", "Störung", "Aktiv", "Inaktiv"} + start := make([]time.Time, len(states)) + end := make([]time.Time, len(states)) + for i := range states { + start[i], end[i] = at(hours[i]), at(hours[i+1]) + } + tbl := figure.NewTable().Time("start", start).Time("end", end).String("state", states) + return plate{ + name: "machine-folds", width: 800, high: 260, theme: theme.Light, + title: `Machine 7, "Inaktiv" folded out`, + build: func(p *figure.Plot) { + idle, err := figure.SpansWhere(tbl, "start", "end", "state", "Inaktiv") + if err != nil { + panic(err) + } + p.X(scale.Time(scale.TimeFold(idle...))) + p.Y(scale.Ordinal(scale.Categories("Aktiv", "Rüsten", "Störung"))) + p.Add(geom.Rect(tbl, + geom.X("start"), geom.X2("end"), geom.Y("state"), + geom.ColorBy("state", scale.Qualitative(palette.Default)), + )) + }, + } +} diff --git a/backend/gg/cmd/gallery/main.go b/backend/gg/cmd/gallery/main.go index 204419d..1d5da0d 100644 --- a/backend/gg/cmd/gallery/main.go +++ b/backend/gg/cmd/gallery/main.go @@ -1043,7 +1043,7 @@ func figures() []plate { add("saturation", func(x float64) float64 { return 0.45 + 0.3*math.Sin(x/3) }, 3) }, }, - }, hatchFigures()...) + }, append(hatchFigures(), axisBreakFigures()...)...) } // budgets is five teams: what each spent as a share of the total, where its diff --git a/breaks_test.go b/breaks_test.go new file mode 100644 index 0000000..16ac0ae --- /dev/null +++ b/breaks_test.go @@ -0,0 +1,212 @@ +package figure_test + +// Axis breaks and folds, end to end: a break cuts the panel's clip and splits +// its axis and grid lines, a mark is drawn over the data at each gap, a fold +// read out of a table folds the idle periods of a machine off a time axis, and +// under a coord that cannot mark a break the axis is drawn whole. See +// docs/adr/0083-an-axis-break-is-marked-or-not-drawn.md. + +import ( + "bytes" + "errors" + "strings" + "testing" + "time" + + "github.com/timzifer/figure" + "github.com/timzifer/figure/backend/svg" + "github.com/timzifer/figure/coord" + "github.com/timzifer/figure/data" + "github.com/timzifer/figure/facet" + "github.com/timzifer/figure/geom" + "github.com/timzifer/figure/palette" + "github.com/timzifer/figure/scale" + "github.com/timzifer/figure/theme" +) + +// outlier is the chart a break exists for: one bar thirty times the rest. +func outlier(opts ...figure.Option) *figure.Plot { + src := figure.NewTable(). + String("site", []string{"A", "B", "C", "D", "E"}). + Float64("load", []float64{4, 7, 3, 96, 6}) + p := figure.New(append([]figure.Option{figure.Size(560, 360), figure.YTitle("load")}, opts...)...) + p.X(scale.Ordinal()) + p.Y(scale.Linear(scale.Domain(0, 100), scale.Break(10, 88))) + p.Add(geom.Bar(src, geom.X("site"), geom.Y("load"), geom.Color(palette.SkyBlue))) + return p +} + +// idle is a machine's state log over one day, with three idle periods. +func idle() *data.Table { + t0 := time.Date(2026, 3, 2, 6, 0, 0, 0, time.UTC) + at := func(h float64) time.Time { return t0.Add(time.Duration(h * float64(time.Hour))) } + return figure.NewTable(). + Time("start", []time.Time{at(0), at(2), at(5), at(6), at(9), at(10.5), at(14)}). + Time("end", []time.Time{at(2), at(5), at(6), at(9), at(10.5), at(14), at(16)}). + String("state", []string{"Aktiv", "Inaktiv", "Aktiv", "Störung", "Inaktiv", "Aktiv", "Inaktiv"}) +} + +func folded(t *testing.T) *figure.Plot { + t.Helper() + tbl := idle() + spans, err := figure.SpansWhere(tbl, "start", "end", "state", "Inaktiv") + if err != nil { + t.Fatal(err) + } + p := figure.New(figure.Size(640, 220)) + p.X(scale.Time(scale.TimeFold(spans...))) + p.Y(scale.Ordinal()) + p.Add(geom.Rect(tbl, + geom.X("start"), geom.X2("end"), geom.Y("state"), + geom.ColorBy("state", scale.Qualitative(palette.Default)), + )) + return p +} + +func TestGoldenAxisBreakSlash(t *testing.T) { golden(t, "axis-break-slash", outlier()) } + +func TestGoldenAxisBreakZigzag(t *testing.T) { + golden(t, "axis-break-zigzag", outlier(figure.Theme(theme.Light.With(theme.AxisBreaks(theme.BreakZigzag, 0, 0))))) +} + +func TestGoldenAxisBreakTime(t *testing.T) { + t0 := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + var ts []time.Time + var vs []float64 + for d := 0; d < 60; d++ { + if d >= 12 && d < 45 { + continue // a gap in the record + } + ts = append(ts, t0.AddDate(0, 0, d)) + vs = append(vs, float64(d%7)) + } + src := figure.NewTable().Time("t", ts).Float64("v", vs) + p := figure.New(figure.Size(640, 300)) + p.X(scale.Time(scale.TimeBreak(t0.AddDate(0, 0, 13), t0.AddDate(0, 0, 44)))) + p.Y(scale.Linear(scale.Nice())) + p.Add(geom.Line(src, geom.X("t"), geom.Y("v"))) + golden(t, "axis-break-time", p) +} + +func TestGoldenAxisFolds(t *testing.T) { golden(t, "axis-folds", folded(t)) } + +// render draws p to an SVG string. +func renderSVG(t *testing.T, p *figure.Plot) string { + t.Helper() + var buf bytes.Buffer + if err := p.Render(figure.SVGWriter(&buf, svg.Pretty())); err != nil { + t.Fatal(err) + } + return buf.String() +} + +// TestABreakUnderAPolarCoordIsNotDrawn is the claim the record is named +// after: a coord that cannot mark a break does not get one, so a radial axis +// with a break draws exactly what it draws without. +func TestABreakUnderAPolarCoordIsNotDrawn(t *testing.T) { + build := func(s scale.Scale) *figure.Plot { + src := figure.NewTable().String("k", []string{"a", "b", "c"}).Float64("v", []float64{3, 5, 90}) + p := figure.New(figure.Size(400, 400), figure.Coord(coord.Polar())) + p.X(scale.Ordinal()) + p.Y(s) + p.Add(geom.Bar(src, geom.X("k"), geom.Y("v"))) + return p + } + with := renderSVG(t, build(scale.Linear(scale.Domain(0, 100), scale.Break(10, 80)))) + without := renderSVG(t, build(scale.Linear(scale.Domain(0, 100)))) + if with != without { + t.Error("a break under a polar coord changed the drawing") + } +} + +// TestAChartWithoutBreaksIsUnchanged guards the golden files by another road: +// a break wholly outside the domain resolves to nothing, and the chart is the +// chart without it, byte for byte. +func TestAChartWithoutBreaksIsUnchanged(t *testing.T) { + src := figure.Float64Columns(map[string][]float64{"x": {0, 1, 2}, "y": {1, 3, 2}}) + build := func(s scale.Scale) *figure.Plot { + p := figure.New(figure.Size(400, 300)) + p.X(scale.Linear(scale.Nice())).Y(s) + p.Add(geom.Line(src, geom.X("x"), geom.Y("y"))) + return p + } + with := renderSVG(t, build(scale.Linear(scale.Nice(), scale.Break(50, 60)))) + without := renderSVG(t, build(scale.Linear(scale.Nice()))) + if with != without { + t.Error("a break outside the domain changed the drawing") + } +} + +// TestABreakCutsTheClip checks that the data is clipped by one rectangle per +// kept piece rather than by the panel, so a bar crossing the break is drawn +// with its middle missing. +func TestABreakCutsTheClip(t *testing.T) { + out := renderSVG(t, outlier()) + i := strings.Index(out, "")] + if n := strings.Count(clip, "M"); n != 2 { + t.Errorf("the clip has %d subpaths, want 2:\n%s", n, clip) + } +} + +func TestSpansWhereMergesAndSkips(t *testing.T) { + tbl := idle() + spans, err := figure.SpansWhere(tbl, "start", "end", "state", "Inaktiv") + if err != nil { + t.Fatal(err) + } + if len(spans) != 3 { + t.Fatalf("got %d spans, want 3: %v", len(spans), spans) + } + for i := 1; i < len(spans); i++ { + if !spans[i-1].To.Before(spans[i].From) { + t.Errorf("spans %d and %d are not sorted and disjoint", i-1, i) + } + } + // Two touching rows are one span. + t0 := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + h := func(n int) time.Time { return t0.Add(time.Duration(n) * time.Hour) } + touch := figure.NewTable(). + Time("a", []time.Time{h(3), h(0), h(1)}). + Time("b", []time.Time{h(4), h(1), h(2)}). + String("s", []string{"x", "x", "x"}) + got, err := figure.SpansWhere(touch, "a", "b", "s", "x") + if err != nil { + t.Fatal(err) + } + if len(got) != 2 || !got[0].From.Equal(h(0)) || !got[0].To.Equal(h(2)) { + t.Errorf("got %v, want 0..2 and 3..4", got) + } + if _, err := figure.SpansWhere(touch, "a", "nope", "s", "x"); !errors.Is(err, figure.ErrSpanColumn) { + t.Errorf("a missing column gave %v", err) + } + if _, err := figure.SpansWhere(touch, "s", "b", "s", "x"); !errors.Is(err, figure.ErrSpanColumn) { + t.Errorf("a text bound column gave %v", err) + } +} + +// TestABrokenFacetDrawsTheSameInParallel: panels sharing a broken axis share +// its scale object, and on the parallel path each draws against a snapshot. +// The cuts are resolved on the stack of every call, so the two paths must +// produce the same bytes — ADR 0012's rule, with a break in it. +func TestABrokenFacetDrawsTheSameInParallel(t *testing.T) { + build := func(parallel bool) *figure.Plot { + src := figure.NewTable(). + String("g", []string{"a", "a", "a", "b", "b", "b", "c", "c", "c"}). + String("k", []string{"x", "y", "z", "x", "y", "z", "x", "y", "z"}). + Float64("v", []float64{3, 95, 5, 4, 6, 91, 97, 2, 7}) + p := figure.New(figure.Size(700, 300), figure.Parallel(parallel)) + p.X(scale.Ordinal()) + p.Y(scale.Linear(scale.Domain(0, 100), scale.Break(10, 88))) + p.Add(geom.Bar(src, geom.X("k"), geom.Y("v"))) + p.Facet(facet.Wrap("g", facet.Columns(3))) + return p + } + if renderSVG(t, build(true)) != renderSVG(t, build(false)) { + t.Error("a broken facet drew differently on the parallel path") + } +} diff --git a/coord/breaks.go b/coord/breaks.go new file mode 100644 index 0000000..9bececd --- /dev/null +++ b/coord/breaks.go @@ -0,0 +1,257 @@ +package coord + +import ( + "math" + + "github.com/timzifer/figure/ir" + "github.com/timzifer/figure/scale" +) + +// BreakMark is how a break on an axis is marked. A fold is always marked the +// same way, with a [BreakSlash] at its own size, whatever this says: a zigzag +// across the panel per fold would be a hatch. +type BreakMark uint8 + +const ( + // BreakSlash marks a break with two short parallel strokes across the + // axis line, one at each edge of the gap — the // of a hand-drawn axis. + BreakSlash BreakMark = iota + // BreakZigzag runs a zigzag across the whole panel along each edge of + // the gap, inside it, so that a bar crossing the break reads as cut. + BreakZigzag +) + +// Breakable is implemented by a coord that can draw an axis with intervals +// left out of it: one that cuts its clip, splits its axis and grid lines at +// the gaps, and marks them. It is an optional interface, like [Exploder]. +// +// Only [Cartesian] implements it. A break is a claim that the distance along +// an axis stops meaning what it means everywhere else, and a coord that +// cannot mark where is a coord that would draw the claim without saying so; +// under one the renderer switches every break off, and the axis is drawn +// whole. See docs/adr/0083-an-axis-break-is-marked-or-not-drawn.md. +type Breakable interface { + // MarksBreaks reports whether the coord marks the breaks on its axes. + MarksBreaks() bool +} + +// MarksBreaks reports whether c draws an axis break: true only for a coord +// that implements [Breakable] and says so. +func MarksBreaks(c Coord) bool { + b, ok := c.(Breakable) + return ok && b.MarksBreaks() +} + +func (cartesian) MarksBreaks() bool { return true } + +// minGap is the narrowest gap the clip is cut for. A fold squeezed to nothing +// still has its mark; cutting the clip for it would only put a seam in the +// raster. +const minGap = 0.5 + +// axisGaps is the gaps of one scale in device order, ascending by position +// rather than by value — on a Y axis and a reversed one the two run opposite +// ways. A nil breaker has none. +type axisGaps struct { + b scale.Breaker + n int + desc bool +} + +func gapsOf(s scale.Scale) axisGaps { + b, ok := s.(scale.Breaker) + if !ok { + return axisGaps{} + } + g := axisGaps{b: b, n: b.Gaps()} + if g.n > 0 { + lo, hi, _ := b.Gap(0) + g.desc = hi < lo + } + return g +} + +// at is the k-th gap in device order, smaller end first, and which side the +// lower value lies on: +1 when the lower side of the axis is at a. +func (g axisGaps) at(k int) (a, z float32, fold bool) { + i := k + if g.desc { + i = g.n - 1 - k + } + lo, hi, fold := g.b.Gap(i) + if lo > hi { + lo, hi = hi, lo + } + return lo, hi, fold +} + +// pieces calls fn with each stretch of [lo, hi] between the gaps, in device +// order, skipping gaps too narrow to cut. +func (g axisGaps) pieces(lo, hi float32, fn func(a, z float32)) { + a := lo + for k := 0; k < g.n; k++ { + ga, gz, _ := g.at(k) + if gz-ga < minGap || gz <= lo || ga >= hi { + continue + } + fn(a, ga) + a = gz + } + fn(a, hi) +} + +// Clip is the panel rectangle cut into one rectangle per pair of kept +// pieces, so that whatever falls into a gap is not drawn. With no gaps it is +// the rectangle itself, exactly as [cartesian.Clip] writes it — which is what +// lets a raster backend keep treating it as a scissor. +func (f framedCartesian) Clip(p *ir.Path, area ir.Rect) { + xg, yg := gapsOf(f.x), gapsOf(f.y) + if xg.n == 0 && yg.n == 0 { + p.Rect(area) + return + } + xg.pieces(area.Min.X, area.Max.X, func(x0, x1 float32) { + yg.pieces(area.Min.Y, area.Max.Y, func(y0, y1 float32) { + p.Rect(ir.Rect{Min: ir.Point{X: x0, Y: y0}, Max: ir.Point{X: x1, Y: y1}}) + }) + }) +} + +// Furniture is [cartesian.Furniture] with the axis and grid lines split at +// the gaps and the gaps marked. With no gaps it is exactly the unframed +// coord's, and nothing about a chart without breaks moves. +func (f framedCartesian) Furniture(dst *Furniture, req FurnitureRequest) { + f.cartesian.Furniture(dst, req) + xg, yg := gapsOf(f.x), gapsOf(f.y) + if xg.n == 0 && yg.n == 0 { + return + } + area, m := req.Area, req.Metrics + if xg.n > 0 { + splitAlong(&dst.AxisX, xg, area.Min.X, area.Max.X, true) + for i := range dst.GridY { + splitAlong(&dst.GridY[i], xg, area.Min.X, area.Max.X, true) + } + } + if yg.n > 0 { + splitAlong(&dst.AxisY, yg, area.Min.Y, area.Max.Y, false) + for i := range dst.GridX { + splitAlong(&dst.GridX[i], yg, area.Min.Y, area.Max.Y, false) + } + } + markGaps(&dst.Breaks, xg, area, m, true) + markGaps(&dst.Breaks, yg, area, m, false) +} + +// splitAlong rewrites a straight two-point shape running along one direction +// as one subpath per kept piece. A shape that is empty, or not a straight +// run, is left alone. +func splitAlong(s *Shape, g axisGaps, lo, hi float32, horizontal bool) { + if len(s.Pts) != 2 { + return + } + a, b := s.Pts[0], s.Pts[1] + s.Pts = s.Pts[:0] + s.Path.Reset() + g.pieces(lo, hi, func(p0, p1 float32) { + if horizontal { + s.Path.MoveTo(p0, a.Y).LineTo(p1, b.Y) + } else { + s.Path.MoveTo(a.X, p0).LineTo(b.X, p1) + } + }) +} + +// markGaps appends the marks of one axis's gaps to dst. Marks nearer each +// other than their own size would print as a smudge, so a run of folds that +// close together is marked once. +func markGaps(dst *Shape, g axisGaps, area ir.Rect, m Metrics, horizontal bool) { + last := float32(math.Inf(-1)) + for k := 0; k < g.n; k++ { + a, z, fold := g.at(k) + size := m.BreakSize + if fold { + size = m.FoldSize + } + if size <= 0 { + continue + } + if mid := (a + z) / 2; mid-last < size { + continue + } else { + last = mid + } + if !fold && m.BreakMark == BreakZigzag && z-a >= 1 { + zigzag(&dst.Path, a, z, size, area, horizontal) + continue + } + slash(&dst.Path, a, z, size, area, horizontal) + } +} + +// slash draws // across the axis line: one stroke at each edge of the gap, +// spread apart when the gap is narrower than a stroke is thick to read. +func slash(p *ir.Path, a, z, size float32, area ir.Rect, horizontal bool) { + if min := size * 0.4; z-a < min { + mid := (a + z) / 2 + a, z = mid-min/2, mid+min/2 + } + lean, reach := size*0.3, size*0.5 + for _, e := range [2]float32{a, z} { + if horizontal { + // Across the X axis along the bottom edge: leaning right. + y := area.Max.Y + p.MoveTo(e-lean, y+reach).LineTo(e+lean, y-reach) + } else { + // Across the Y axis up the left edge, leaning the same way. + x := area.Min.X + p.MoveTo(x-reach, e+lean).LineTo(x+reach, e-lean) + } + } +} + +// zigzag runs two parallel zigzags from one side of the panel to the other, +// one along each edge of the gap and folded into it, so that together they +// cover nothing that was not already hidden and read as the torn edges of +// one cut. +func zigzag(p *ir.Path, a, z, size float32, area ir.Rect, horizontal bool) { + amp := size / 2 + if third := (z - a) / 3; amp > third { + amp = third + } + half := size / 2 + // The first line runs between a and a+amp, the second is the same line + // moved up to finish at z: in phase, so the two never cross. + for _, base := range [2]float32{a, z - amp} { + lo, hi := area.Min.Y, area.Max.Y + if !horizontal { + lo, hi = area.Min.X, area.Max.X + } + up := false + for t := lo; ; t += half { + if t > hi { + t = hi + } + v := base + if up { + v += amp + } + if horizontal { + // A vertical zigzag across a gap in the X axis. + if t == lo { + p.MoveTo(v, t) + } else { + p.LineTo(v, t) + } + } else if t == lo { + p.MoveTo(t, v) + } else { + p.LineTo(t, v) + } + up = !up + if t >= hi { + break + } + } + } +} diff --git a/coord/coord.go b/coord/coord.go index 74ff576..d194d14 100644 --- a/coord/coord.go +++ b/coord/coord.go @@ -226,6 +226,11 @@ type Metrics struct { // LabelPad is the gap between the end of a full-length tick mark and the // label beyond it. LabelPad float32 + // BreakSize is how large the mark of an axis break is drawn, FoldSize the + // same for a fold, and BreakMark which mark a break gets. See + // [Breakable]. + BreakSize, FoldSize float32 + BreakMark BreakMark } // tickLen is how far tick t's mark reaches. @@ -262,7 +267,7 @@ func (cartesian) Frame(f Framing) Coord { // Y is flipped: larger values are higher on screen. y.SetRange(area.Max.Y, area.Min.Y) } - return framedCartesian{area: area} + return framedCartesian{area: area, x: x, y: y} } // Extent is empty until [cartesian.Frame] has been called: an unframed coord @@ -360,6 +365,10 @@ func (cartesian) Furniture(dst *Furniture, req FurnitureRequest) { type framedCartesian struct { cartesian area ir.Rect + // x and y are the scales it was framed against, kept for the one question + // a Cartesian coord asks of them after framing: whether either axis has + // intervals left out of it. See [Breakable]. + x, y scale.Scale } func (f framedCartesian) Extent() (x0, x1, y0, y1 float32) { diff --git a/coord/furniture.go b/coord/furniture.go index 95e2be0..6c248a2 100644 --- a/coord/furniture.go +++ b/coord/furniture.go @@ -158,6 +158,12 @@ type Furniture struct { // the reason the gate exists at all. FamiliesAreTheAxes bool + // Breaks are the marks of the gaps left in either axis, one shape with a + // subpath per stroke. render strokes them after the data, because a bar + // that crosses a break must not paint over the mark saying so. It is + // empty on every axis without a break. See [Breakable]. + Breaks Shape + // LabelsYFirst decides which axis keeps its labels where the two collide. // Labels that do not share a row are thinned by render against every // other label by their boxes, greedily in axis order — X first, unless @@ -171,6 +177,7 @@ type Furniture struct { func (f *Furniture) Reset() { f.AxisX.reset() f.AxisY.reset() + f.Breaks.reset() f.GridX, f.GridY = resetShapes(f.GridX), resetShapes(f.GridY) f.TickX, f.TickY = resetShapes(f.TickX), resetShapes(f.TickY) f.LabelX, f.LabelY = f.LabelX[:0], f.LabelY[:0] diff --git a/docs/adr/0083-an-axis-break-is-marked-or-not-drawn.md b/docs/adr/0083-an-axis-break-is-marked-or-not-drawn.md new file mode 100644 index 0000000..99aa51e --- /dev/null +++ b/docs/adr/0083-an-axis-break-is-marked-or-not-drawn.md @@ -0,0 +1,219 @@ +# 0083 — An axis break belongs to the scale, and it is marked or it is not drawn + +**Status:** Accepted · **Date:** 2026-09-22 · **Implemented:** 2026-09-22 + +## Context + +Two charts asked for the same thing from opposite ends. + +The first is the chart every plotting library is eventually asked for: six +sites, one of them thirty times the others. On one continuous axis the outlier +is the whole picture and every other bar is a sliver along the bottom; a log +axis would fix the proportion and cost a reader who counts requests rather +than orders of magnitude. What is wanted is an axis that leaves the empty +stretch between 12 and 85 out, and **says so** — the `//` a hand-drawn axis +carries, or the torn `/\/\/\` of a printed one. + +The second is a machine's state log. A shift timeline where "Inaktiv" is half +the day is a timeline of the machine standing still; what the reader wants is +the time it was doing something, with every idle stretch folded away. That is +not one interval chosen by eye, it is dozens read out of the same table the +chart draws, and they are a claim about which periods the reader does not want +to look at rather than about where the data runs out. + +Nothing like either existed. `scale`, `coord`, `theme` and the dialect had no +concept of an interval left out of an axis, and three nearby names were already +taken for other things: `"breaks"` in the JSON dialect is a threshold colour +scale's class boundaries, `geom.Gap` is the missing-data policy, and +`ir.HatchZigzag` is a fill pattern. + +## Decision + +**An interval left out of an axis is a property of the scale; the coord cuts +the panel and marks the gap; and an axis that cannot be marked is not broken.** +Eight claims. + +### 1. A break belongs to the scale, like a direction + +`scale.Break(lo, hi)` on a linear scale and `scale.TimeBreak(from, to)` on a +time one. The reason is [ADR 0075](0075-an-axis-has-a-direction.md)'s: what a +break changes is `Map`, `Invert` and `Ticks`, and everything that reads a +position — every geom, `interact`'s inversion, a track sharing the panel's +scale ([ADR 0031](0031-tracks.md)), every panel of a facet — then agrees by +construction rather than by each learning the rule. A break is configuration, +so `Clone` keeps it, `Snapshot` shares it, and `Describe` writes it down. + +The cuts are sorted and merged once, at construction, into a slice nothing +writes afterwards — `TickValues`' arrangement, and the reason a clone and a +snapshot can share it. + +### 2. `Map` stays continuous, and a value inside a break lands in the gap + +The kept pieces share the range less the gaps, in proportion to their length +in the domain, each through `place` with its own ends snapped — AGENTS.md's +two arithmetic rules applied per piece, with the piece offsets rounded +explicitly so no machine fuses them into a multiply-add. A value *inside* a +break is not refused: it maps linearly across the gap. + +That is the choice that keeps everything else ordinary. `Map` is monotone and +continuous, so `Invert` is an exact inverse with no NaN for a pointer to fall +into; `Defined` is unchanged, so no geom learns about breaks; and a bar from 0 +to 95 across a break from 10 to 88 is one bar, drawn with its middle missing, +because the panel's clip hides whatever falls into the gap. The alternative — +treating a value in the break as missing — would make a point there vanish +silently, and cut the tall bar that is the whole reason for the break. + +### 3. The gap is a length on the page, and the renderer sets it + +A scale does not know how wide a gap is, because it has no theme; neither does +a coord. `scale.Breaker.SetBreakGap(brk, fold)` is called once per render, on +the serial path and before anything is measured, from `theme.AxisBreakGap` and +`theme.AxisFoldGap` — which is how a size scale learns its range +([ADR 0027](0027-size-channel-and-the-guide-column.md)), for the same reason. +The resolution against the domain and the range is recomputed on the stack of +every call, so a pan, a zoom and a resize invalidate nothing, and a panel on +another goroutine never sees another panel's. + +### 4. An unmarked break is a lie, so a coord that cannot mark one does not get one + +A break says that equal distances along the axis stop meaning equal amounts. +Drawn without its mark, it is a chart whose ruler is wrong in a place the +reader is not told about. So a scale's cuts are **off until the renderer +switches them on**, and the renderer switches them on only under a coord that +implements `coord.Breakable` — today only `Cartesian`. Under polar, Smith, +ternary, a map or parallel coordinates the axis is drawn whole, silently, the +way `geom.Explode` under Cartesian draws what it drew +([ADR 0026](0026-breaking-a-mark-out.md)): an error would make an option's +validity depend on a coord chosen elsewhere. + +A second axis is the same case. `drawOppositeAxis` places no clip and draws no +grid, so a break on `Y2` or `X2` would hide nothing and mark nothing; its +cuts are switched off too. And a break that would take more than half the axis +is not drawn either — a squeezed break is a picture of nothing. + +A cut that reaches an end of the domain is the one case with no gap to mark: it +**trims** the axis, which then ends where the cut begins. That is what folding +the last idle hour of a shift has to mean, and it distorts no distance. + +### 5. Ticks: one step for the length the axis still shows, walked per piece + +A tick sequence is chosen for the *kept* length — `extendedWilkinson(0, kept)` +on a linear axis, `pick` over the kept duration on a time one — and walked +through each kept piece. A break from 10 to 90 over `[0, 100]` reads +0, 5, 10 | 90, 95, 100: one step, so the two sides read as one axis. Choosing a +sequence per piece was the other candidate and was rejected for two reasons: it +labels the two sides at different precisions, and it costs a tick search per +piece on an axis with hundreds of folds. No tick stands strictly inside a cut, +including one pinned with `TickValues`. The existing label collision pass thins +what remains. + +### 6. A fold is a break of another kind + +`scale.Fold(ivs...)` and `scale.TimeFold(spans...)` add cuts of kind *fold*: a +narrow gap (`theme.AxisFoldGap`, half a spacing unit) and a small slash on the +axis line at `theme.AxisFoldSize`, **never a zigzag** — a zigzag across the +panel per fold would be a hatch. Overlapping and touching cuts merge, and a +break that absorbs a fold stays a break, because the fold was the smaller +claim. When the folds do not fit in their share of the axis their gaps shrink +to nothing before anything is dropped: the axis is still folded and still +marked. Marks closer together than their own size are drawn once. + +The lookup is a binary search over the cuts, with running sums of their widths +and counts computed at construction, so a row costs `O(log n)` in the number of +folds and allocates nothing — `BenchmarkFolded1k` against `BenchmarkFolded100k` +is the gate. + +The folds are the caller's, and `figure.SpansWhere(src, from, to, col, value)` +reads them out of a table: every row whose `col` spells `value`, as a sorted, +merged list of `scale.TimeSpan`, with `SpansFunc` for any other test and +`IntervalsWhere`/`IntervalsFunc` for a numeric axis. It lives in the root +package because that is the one place holding both a table and a scale: `scale` +never reads a table and `data` never knows what an axis is. + +### 7. The coord cuts, and render strokes over the data + +The framed Cartesian coord keeps the two scales it was framed against and asks +them for their gaps in device space: + +- **`Clip`** is one rectangle per pair of kept pieces, so whatever falls into a + gap is not drawn. With no gaps it is the panel rectangle exactly as before — + which is what keeps a raster backend treating it as a scissor. +- **`Furniture`** splits the axis lines and the grid lines at the gaps into + subpaths, and fills a new `Furniture.Breaks` shape with the marks. With no + gaps it is the unframed coord's furniture unchanged, two-point polylines and + all, which is why no golden file moved. +- **render strokes `Breaks` after the data**, in the pass that already draws a + polar coord's axes over it, with the axis stroke — because a bar crossing the + break must not paint over the mark that says so. It is drawn whether or not + the theme shows axis lines: an axis whose rule is off is still broken. + +`theme.AxisBreakMark` chooses the mark: `BreakSlash`, two short parallel +strokes across the axis line at the edges of the gap, is the default; +`BreakZigzag` runs two parallel zigzags across the whole panel along the edges +of the gap, folded into it, so that together they cover only what the clip was +already hiding. `theme.AxisBreaks(mark, size, gap)` and +`theme.AxisFolds(size, gap)` set them; every length is scaled by +`theme.Scaled`, and the gaps by `theme.Density`. + +### 8. The dialect spells them `"cuts"` and `"folds"` + +```json +"y": {"field": "load", "scale": {"type": "linear", "domain": [0, 100], "cuts": [[12, 85]]}} +``` + +Each is a list of pairs written the way `domain` is — numbers, or timestamps on +a time axis, measured against the document's `origin`. `"breaks"` is the +threshold colour scale's word on the same struct and a different shape, and +ADR 0075's argument that the channel decides a word's meaning does not reach a +field whose type differs between the two. A document holds the intervals, never +the query they were computed with: a chart built from `SpansWhere` writes the +spans it found. + +## Consequences + +- A chart with no break — which is every chart there was — draws exactly what + it drew. `TestAChartWithoutBreaksIsUnchanged` compares the bytes of a chart + whose break lies outside its domain against one with no break at all, and + every existing golden file still matches. +- A broken chart's clip is a path of several rectangles, so a raster backend + masks rather than scissors it. That cost is paid only by charts with a break, + and every figure stays under `TestNoFigureIsDrawnWithTooManyCalls`. +- The accessible description names the omitted intervals — "The vertical axis + is broken, leaving out 10 to 88." A sighted reader is told by the mark; a + listener has to be told in words, or the description says two distances are + alike that are not. +- A pan that moves a break across the end of the view trims the axis to the far + side of it rather than leaving a gap at the edge. That is the rule in claim + 4; it means dragging a view through a break jumps the axis by the break's + width, which is what a break is. +- A layer drawn on a second axis is not cut by that axis's breaks, because + they are off. It is cut by the primary axes' breaks, because the clip is the + panel's. + +## Not in scope + +- **Breaks on a log, symlog or probability axis.** The arithmetic is the same + per piece, in the transformed space; nobody has asked, and each of those + scales writes its own mapping. +- **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. +- **Folds a layer derives by itself during `Train`.** An axis whose shape + depended on a layer's filter would change when a layer was added; the helper + keeps the question explicit and the answer in the document. +- **Folding a category off an ordinal axis.** That is filtering the rows. +- **A different scale on each side of a break** — ggbreak's `scales=`. A break + here leaves an interval out of one axis; it does not join two. +- **Marks on the panel's far edges.** A Cartesian panel draws its axis lines on + the left and the bottom, and the marks are where the lines are. + +## Revisit if + +- Someone needs a business-day time axis, which is claim 6's machinery plus a + calendar. +- A broken figure's raster clip shows up against the call-count wall: a union + of disjoint rectangles is something `backend/gg` could learn to scissor one + at a time. +- A coord other than Cartesian finds a way to mark a break — a radial axis has + a spoke to draw a slash across — at which point it implements + `coord.Breakable` and claim 4 lets it in. diff --git a/docs/adr/README.md b/docs/adr/README.md index 99ac735..a00edec 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -92,6 +92,7 @@ depends on. | [0080](0080-nearest-neighbour-cells.md) | A cell is the part of a panel nearest one row, and it is cut where the reader measures it | Accepted | — | | [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 | — | 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 diff --git a/docs/chart-types.md b/docs/chart-types.md index 558c446..ae52434 100644 --- a/docs/chart-types.md +++ b/docs/chart-types.md @@ -388,6 +388,7 @@ something else. | A chart in a language | **shipped** — [ADR 0035](adr/0035-label-format-and-locale.md) | `scale.Locale` and `figure.Locale`. The time ladder rendered through Go's English tables and `strconv` writes a decimal point; for a German reader the second is not foreign but wrong. | | A PDF in a script WinAnsi cannot hold | **shipped** — [ADR 0038](adr/0038-embedded-fonts.md) | `pdf.WithFont`. The PDF emitter named the base-14 Helvetica and encoded WinAnsi, so every rune outside Latin-1 became `?` — Greek, Cyrillic, Hebrew, Thai and every CJK script, in the format people send to customers. | | Absence in a text or temporal column | **shipped** — [ADR 0034](adr/0034-null-values.md) | `data.Column.Nulls`. A null read back as `""` was a band of its own on an ordinal axis and one read back as the zero time stretched a domain across two millennia. | +| An axis with an interval left out | **shipped** — [ADR 0083](adr/0083-an-axis-break-is-marked-or-not-drawn.md) | `scale.Break`, `scale.Fold` and `figure.SpansWhere`. One value thirty times the rest flattened every other bar to a sliver, and a machine's idle hours filled half of every timeline; a log axis answers the first by changing what a distance means everywhere, and nothing answered the second. The gap is marked, or the axis is not broken. | ## I — needs a locus — **shipped**, [ADR 0050](adr/0050-locus-annotations.md) diff --git a/docs/gallery.md b/docs/gallery.md index 998213c..ff03794 100644 --- a/docs/gallery.md +++ b/docs/gallery.md @@ -35,6 +35,8 @@ picture here cannot drift away from the code that produced it. | ![Sixty cars as a parallel-coordinates plot: four vertical axes in four different units, one line per car crossing all of them, coloured by origin](images/parallel.png) | ![A quarter of support tickets as a parallel-sets diagram: three columns of category boxes for channel, urgency and outcome, with ribbons between them as thick as the tickets that answer both that way, split into the solved and escalated share](images/parallelsets.png) | | ![Who worked with whom over a quarter, as a node-link diagram: three teams as three clusters, one person joining all of them, and a pair working on their own off to the side](images/network.png) | ![Last month's rainfall over twenty gauges, as the panel divided into the part nearest each gauge and filled with that gauge's reading, wettest in the north-west](images/voronoi.png) | | ![Mean July cloud cover over the whole world on an equal-area projection: the globe as an ellipse, one coloured cell per ten degrees, cloudy along the equator and over the midlatitude oceans and clear through the subtropics](images/map.png) | ![Edinburgh to Tokyo on a globe seen from over the pole: the great circle running close to the ice and the rhumb line bowing far south of it, with the graticule and the rim of the visible hemisphere](images/globe.png) | +| ![Requests per second by site as bars on an axis broken between 12 and 85, marked with a double slash, so the one site thirty times the rest and the five small ones all read](images/axis-break.png) | ![The same bars with the break marked by two parallel zigzags across the panel, the tall bar cut where they run](images/axis-break-zigzag.png) | +| ![A machine's state log over two shifts with every idle period folded off the time axis, each fold marked by a small slash on the axis line](images/machine-folds.png) | | --- diff --git a/docs/images/axis-break-zigzag.png b/docs/images/axis-break-zigzag.png new file mode 100644 index 0000000000000000000000000000000000000000..a473a293c551136594558be4e0066717db3ea3fb GIT binary patch literal 18428 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ì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 JKve(9{{X%wgD?O9 literal 0 HcmV?d00001 diff --git a/docs/images/axis-break-zigzag.svg b/docs/images/axis-break-zigzag.svg new file mode 100644 index 0000000..9ed9192 --- /dev/null +++ b/docs/images/axis-break-zigzag.svg @@ -0,0 +1 @@ +theme.AxisBreaks(theme.BreakZigzag, 0, 10)BerlinHamburgKölnFrankfurtMünchenLeipzig0510859095100theme.AxisBreaks(theme.BreakZigzag, 0, 10)requests per second (thousands) \ No newline at end of file diff --git a/docs/images/axis-break.png b/docs/images/axis-break.png new file mode 100644 index 0000000000000000000000000000000000000000..b948603c8d7d0b50b861f227707f7a96794c9308 GIT binary patch literal 16762 zcmd6Pby!qgyFMxk0@4yn2vX7|NJu$^G>C|#BHbNQDmfw|QqnR=mvoDQ2uKbg4FVz{ zUD9wKUf)l7zu);?*ZJ#whdtNK2KTJJ_F7Ng&;5v0RhB)6a{&hf1LNFHIfOa}#z`v- zj1w=gPr_f~RkA}cFeD{!A|y53;^z}A6?LX)8T6Z(esWt%zL#rh>uk*fn#80S76C zdcpFa{0Npoxm1mq@hdAUcVRMl2dYpJ`y=SoTuQarnfyHWH~O#$XyQ|=!?}pve?$Z` z{HVL-nMm)?bBGn_r_~jvEQq0n*xEm+tCI@2Z((WKm#25}bJ)a$VR*9<|AmVeE-Y3| z(+s*SHlLG}+FPzCC@Co^+AB!;7K}&~o}x!qE|Vsu%iV81{6Hu9YsuRS5ZT$;RaI4N ztgLls82!xnV@a5msYKm6O^T?696s8V-MMpTXyNDg@85s?us|XlwwA|xv+p>DPzIP| zC#z?E$+>$=Ln9)JZ2FyorDb;g_{{9A`|<~@zFZws-k5UJDBUTAXwKfA9zrP`T-+%K zcVlDY)yVq(g`fTOy8KtKN{5JgS`=utS`S&3e*XOV@#Du!wTEK5MoXhL)9ndtTwDev z7QG=-e#OPb@xsnoSy?|8tCp0VM*AmV!^Z9S`T6GuOR_UE%sf4ZO^PZ;Y{HvKzi@PT zPUh)bkf=D^zubpNL02!=;qqOe>3BgH)I-nVec|y|t&Fv`wT#TC#OoerhOyPvqDVwn ze?Nuydy@h!d3kvw-kADvH`t6DxlNs&oq2jy@(Kztd0E*;bc(Ekf`WNS96W;8?$40t z&+`mwN2eTmdwZp?SKi9*l}~+1xH^*(YBN;IvHdyhBiZ;wPv)(H%b}hYP1dy=Uvv@@ z5?CWy3!6TLTue}&F0t%0<59nTn~^NI{!EAmJXmUK>U2kv!8^}wxEBXmivm~8I!KvE z&FI9$#FsCc2ir{A*Aq`HD~dMzhvx zeraiG710&Uk*c$IV%RJN;_8jV^QojYh!VqO6Jv)CK&mFmnwy&ks$6Z&!l|jLd(HUo zohh>(O4DGS`|(37YT?HZb=FAu*;`X8epp8R-s-?e&GKd4 zGAh7NqLtT9guKj=d~!iI;65)e@60F%yH4q|`xKS3Rst9M)S1GGqyhp1mufagv+v~N zU_RO3-3*K+%(BWCSS90@C^SFqO-*H{iRbY& z)y;9X?`{g((us?YS(M=8OafQ+-Ms1FIgc8ydVw2)Dcx{d}>g{A} zu-b^iZ0~QF95M%Mhi_BRBqAqbSdQLiUivV_#>8xcoOaddEN^?V-N}37!LX;QdWq2Q zK!J&wxkf)qO9C$xI z7v)sXjIbGf^5NcF?c8WC1MkTYK~CLrRaMm&GfC8uz-2_`G?=*Y`qV*r`M)C%8H9Y2Um1U7!2G`bXt_e30ON^qw@GUzrE5(3j(4X z+8XtCXlq)eyLXbV#9#02_;>?-wU!@w@UkJA*rQT3!J_2h?#~7OwmxH-VV8G&+BF05EG7E(fr2epA0HWHQewT6#9s3SjXxKL zj`kXEozx+Vy$z2qw!aSAXTeR8=(&1xkXtOj=I7wIZ{I|gs`+?$B&GNT1qtx*Iw!mN z1q2)#?ndn|l#P`SHLZW;$MJtcxC#S|mKHWn@>~dMd`_LBM?!X!!!Vcv@66()#CYJ7 zvIXAmuQ9eb+0lbY^|?qhw;}@)m<%`xbOWcFRMTvCx`Fq$anV3LhqI~ysEp!Hy}=eEPc zZ=a?ShdXomvNY~~yLL5=lOQbaGs%GndbO?dW2L2~5|=KK;*?npIP~ecqVojpt44+| zD)Poy)d!rjXWLv~cN3)vks;YG!6=BN3@FfIUsza(=F~$?*%IINGyN78h>C5XZf47` z<`olbpY%mchp@I+h_kT;6B6T{Nl;D~#yph&q*ud$^KA4KQ?Qg#{iX_(4 zju&!FtE$>f2^B*jw^Q@;ZDDI@efI1nB~P@Sz#xk#p20l%Sni69tSpy)_14~6NB76- zTWV@yVPPxtMIBw3826H6^4Zj)jCXf;Jv=;&dGBsb-lYjmFDkl~s~KpU`_TkL3&oAs zjYb!|FkqT?s6fz%DW|8L#dt4udGD()!OTzH)fTQ=&z}RP;4X-fez($w7R0=EoJ>qi zyu9}N`}+gVT{Pq?7Q|2>2$TEhb$k9vvMP(AK~Tej?r?jk|#Z4{P*O)k8u;Zl-n} z4Fk7eKS8#LJ98eVVP|75Qa-g~`XVJ|Sj$ua<)F)spmWRT&oeaWStnRvlHdmZ6YvEF zf6eUFI0XdCySBEhtZ#bltiM#*P>suiw(keK?bWH))>a5VroMlF+Az_Nx8Qa8sZ{6n z)t*}_S0X>3XVdJ&%tP2SK4nwWMNY_*-a)y}L}rXFxmJzD?~T}$Y3Gg>JP6cawYIhG zXm9u4TWLH?S3voQ!|Fb+PQSL4j>31#!^DR_t_0RU{yjoDaYN+HL6rh?pz%K&vi_gG`_9{w zq0v$0ZYKu^2Uk~DW8+kor~FFO)6^M{K~i@?UUtbX;No zy#xE0=#7ny$;nCYT7&$)p#_(h484+4*pc#6%gf8IL8GIi5TuvmN~?*Ai$}|+Zb>2# z-?&nBIUAFf$Y4&ZtE>6-=!S-df`WoAp$Pd@(T>T*2g)icI=Py|{-PZa@lH2h`g^yj zz2JH0Hg|8n)>S4Rgf8!U~bOOtbgO_6)M>ZE2~%)vLENH72mggLT;* z3Yr?|k4|Qeih9H z0~vgY_WhW^%$Zp3HZg$={&liCt)h!SAY`um7QS^`dSRY<@nUw%^0}e8^hR$Y*UK;S z#TjT11W)`erZeKfGp=KY>-C}e_JZ`=KMom#q}InCWyJjdj~P4Df0u7rDs<9-i0cc@ zE^a)ZAFcev=O3i_Kcl1z;UTgvCPw;W8{SXu*K&XSCwY8WwlzUVK4e%eB7t@GSgF}A?g*&|L%uR7Xo*e z54)_a3~jbPHI<4&p^A!%N=r2pFw`#LARe<2o<>RZoI!#1;Ns#U5q11+KYsksU@a*u zoLJj_^5J#MlzQ2tzpf$;I;nOk78kw0zYj?-xKF!lRurH5qME?Ocon!VQYKG z_1B<(UB1H=q^YTCV{L7&-ELgZedf#=h)q>gRC;L|=$}fH2D6ZL!)FU>%VbgLN+2{~6g=Huy^^UrJ40_(#RnLsdq{`~pS!$TU;w-Fjj;EezG zf*g%>F-U-)-_zUM#n~C+0cTg&;HL5M`wt$Z2s8T;Nq0^U!^66|x_g7fLf5YKkBn@%iT)t_=h`);X+^Y|jE#==$O{V#LmK^{;ML%J z1do~`DM`c8$GRZN{=u|M6&Dv*bX3%ycWuz{*jVG_%J}%W0r~OS(4u0~a`W;6q>={) z2BxP?Aq5l=_yVf@Z;MA{U!mL5(qbWvP3K4IjN@y@P6l?e-K5Ck23XOf;N!#};o_%7 zbaZt1e)!ti`66%5LWP3ZST2H2EdW6YC&kwS5xBk?d2><5r%#?Jk^D6vg?%QhRc7Y> zH2)Yf6YW>#M<-FJ1oQ1frr2g98d_RY6BAD{gPS+c+M=qFt4yEcSbm-C7>prcX3R`X zJwK8lOUqRGE##y@gAzU5&u9;oZ0N(}(z~64XUNbTC>=yukd2zXOEAu(j37WV06Q?a z%n6W8czAf4d6wgGDGf4IduOP|8xx_f(j$;f2IA ze0qlY*DM5uOu45^6g_=I_ysIljBoYTEc|R(s7NeJZ>*`D9;fT+*j#))=B!8Kv$g1| zH%6<>FV1`cKad(fuzPlXW~Lvqn_dfQ8XA1elSe2JwlXR6Q<4Vc1i~J54ILAoF<=F2 zM=qts9>uJZn2{B$$TU{%etGkl`ovyDo$;lMQM4JUEJ;sK&&v8dy+$OVf^q%CI%X%y zuMs=`)-F4_fAPk(a(3au#o3GW@z{Yk0Na4 z_-k~26f`tqc3&SgPMTX;Jwwnt&>mTrzYUVmrJe1y>G|-^#YLOAHkY;UZ6j5#wGTJs z7f=1ZMuHy;p&&0`H@*x>LVX~?=F(V__473KD-{HPlo(A#$ic>@klqccAOD3ANPJ18 zs@+yl)z!L-jv~=R6~paQE}UhK6$vd1!&GIC!B3wK4i2KwGZ6dp;i57#&G&aU9DK+a ziIK9QG=MGkTa`Y2`m{Bcf`VdscOT%ew6wIh+ytmU^IAKLdWx#{-q*$^!p8g-OF>JU z^Z;W&SEo$l_DI2l#sm>p!-7{MHcRj`KQHS1L&LzpfSW?pd#_v+_xHp(*@L_x0a%~y zF5FQ95Jf!1rFp}>W)y%BijYs?%qpYh_VqSpkRVbCIT%5v3y79!N8)wQt>u@D3g7Ji zwU81{CnS5U1Uzc=?;@*Qm)`jtdOyc(I|yztlu1#;URjxxy?y&ea%b#SOHyg~<*y%B zr&=zDVWZC>1PMnZoK78`w5H6QQ5yB3>+S6P>QEs{(@|2X)P&nHhA7udgqpbK1G3`S~i@ zy+52P;SR9o0PqwP@FXZRcPRUK*B$~^QAnJr!Acgqf(jtNTAk6nDIF5aNsN-v#6_&Q zc@~@D1f4{0W1@|CVObUKo(*sA>FiY1(P1MuiEcA#Il$Mwd-pEjLE74t#w}#w&HTK) zFXb3CbF~=>lzl|_UPgj>z8cpCNgwAWzwZofJ}9X-#w8NPh7C`B4lOft2zQJ>pF%N( zXO@)Q)^yJfkYzHRgx-oIq48yUS2vnaWW|YV&`UOvvG1pnG#81C%eBEy~OH= z?Jos6{i>U&6n1v+;P9|jiT2cY_QCxcW>_>R905T#$IqI z{H-l|c7#f)>dNWeh6Uv#HX$-=^8JNKHkh zTV^u?ATwWNMqVB#3ya&q-u7sX#}rYaV{<#BNc6n!uM^j_dEH#ii%5ibHSI)wS*FX zbemH@=p7(^E0c|V`38Mv2b*^seVG3h(2xefKj-P!Xy^BJ@YvA&e)!X_QBoEZ7Q>UKsJ&32AY^5;$bdB#*Ok20T$)TGHKNWtJZk{U3I*BCsT`;97=nUQW zzudqtGrc3rTxr?I2{^-e-Dx_6y}kVxB@gPc{}4Q{!;JFHhQC4Jij3iL0soyPqj|KXEN82@7y?XAE^{wV*~{zOl%ATII{ zkq|!7M4c5QVn86#ESyZbuBu7dZG|~wDGkG}V?DIbYu6S}s}cTs?bFh;(%Y1I*w}C; zDBD_F8yDS;YdZ=%%wq?!{&mr5wNr>Bm4itf@;tlo-*4L!cgiF3?6!wpQKVo~r1$kN z;h_HC*C8j0_A>(fS+BT(riX{fr1Ud#9{rDQL`NrHZ7V1Oy2`RLW_k#dnVFfP(9prm zeJw#bv@vD$K+-<105KpCt*BxTx~++YFe^J zR@T;X<-N(tX&twlpP$e05>`8QpzkD@&6a_OH7sy)afv$c^YHej32g)- z@oQ|9-B1LBISfJBW0s&Nzg{ysK}_MzcGpCCc<%G=@7L-Za2;DVE}~xkHj5I-@-nfg zb{h+Pj=yadJUEmND?53gOG`yS6p0$N&eWegH-GpxBFCKAF%YNTdirB-PDfiiG0FGO1U-V<+uJ$W*?W3=f|@{W7>FrpXf)&g zY^}v3xZ%*yP?+4Cb)*7kG}J)`$Hu^SsOEDgv^a5gS13;N{yMQ$n*LFlXFj+li>meB zx3IARYiW_B9C7UEc}?P6p=3~cL3xY33(VT1( zuxUm~03e+8?(80aDl^u|&r=Q-8|!P6jiJ82r%L_^AZS3X*KSm^k^Xv`zm#O|KSw^t zi-y<*a*@s5>olQ3^NWiC_2Z+X`XvN^6yi(}gnVG3y1R>tiy7!mii8NU&0^F3@r-D7 zzRHS<=6|f9;=u4h;%060n5i0TUy!yexIl2TLBBzpQQI#v^n$5R{JDWnk@fwO7(PjbE;~z^ z99^Eh6iLDeZXC-k4Z=MyqMnQOh8%tm9}u>YJ-ZRm{IIZ?-Zw}T!5*Zg%K0JI!@hIm zLNkYBz4c38D zK|xskc;}U|`lU_V!Qv~-%&^_ei6W8m#`^k+EmOP{c#Wa7cQQ4c7T4DVQ>bd1JBq9v1dMx^%t3{xVu+08$Dc~ zK{tmpXJ=)V5BF%=ozVLJ@k3jDc?^l5kU44ChWo&Z`iz8DuY6mrC<#dS0n~Zolqc7SLOF@S6P6>sanVWlX*JbRT zSMjM|T#KU;M)VT|dDKyDd8ERkB-Ujzo~0c*@%xR_-w}n|1Y!UWg-r)VmB*6zRVhf% z_)FgJJS}KuVTr;$UC#{(i9noEeji(;{NOnZxu;1x%mbO&)~RQm3tJ8CWh};^kcjq= z%;=jlreNAmK&&xS;WxTY1Dpcs6EG=+Ph!YL84QoB*;d}{A*rKNqas%N`C`WK%5inO^ckRvnUbXwzR!9)f9Gl{W7f7 z+Zj1Y)VQ5nZ`N)7{7tB27l(d4^gVsFh5>J)5D%cGX9hgflb3T^%3KRkD+%Gm| zBvLfu-t!v|XD=Nuc${Upfa4GA{0ZEsjEpD^0o_NFqK(bXZu>u4;*TJa>#->VRM@ga zJ4R8UTe7gQkco+D=Gs3;VkN`|!L(Kqrxo+ms`b8Zeg6TG0NL?3wI?Cckn9-LdiTyc z;RjwOMnYTwVD`dLS+_ld+OY$TdDE%k>G?Ch$**5aFc258gTDde&&zAKMrJdry%ksX z0ulDtLGD4!=of@Qfuri`>e}1e12}2$+_9q9j4m;xXh)0_&`LVLj_D*3*Sx~Qm-xpS zL$MMl$mby{{fHz1yYDGho1QM4-)B2g=?uxI6VCA`^vurK2M-jV=|bIxYe&!e7`=R#$!{z!olAKpfgZ@bKIMx#}0l;Vu&&LE`<2KfZVl9%mJ#n zZmiZ87AUXdrI~}fDB$^kaZJhek%F}tT7cR(EAzoKO3ebf^1)- z1pDl=5Zf^g6>WQ@FxB&5axP5`jcOpAbSB?8fBw7^=NCi))6=VJIY>h#QcKiq36ro2 zSfCI0SU5ORsoy}=^y>#-XJ=D@622})QeFur1~9VRu_j63ZXjIN5?hq0Wx$-f^yQx-b305K-a!sY(`WCT6nY)+Ve z90hoc?(F!vwQU+vHwkQ{Op11{W~K%>#P;+vV4M`0!Zla{m)Zi--N zY3_X<9quwwOAbUdw5&4r7r#PJ= zL+B?6Nk91Girappkn!_h7y-z_)HHoK#$Cpg@iHJX z_<{8W4pZ`wa%#U|50_T+sBNP4$uAsQ@G4Dnyd$o-cX^bnEOrJklARptYiP!=2x@#; zQ5tdJaDaL75=$a91OWpza5!X>PgNJR8Gf~kyA9Ayifkwpb?Ui+X>zY<;5n5Ly*L8! z&8UFbM;W~q*8%#W54-00{aO9^tL&FQH#b}M^P6U!62-in@romHgJCm-Vk4x#R^I4%$i}4zO%d_f_-hT2+=}*QL>I8t-G7qcsK^z#-7p zIdEw}e~GfJvnT=bl#kx2xifqf%D|@FhOnQX)IJ4eUNXy!r(f%UCk}K%j>-8$~Xe7Jg2ey!F6GrNt~}Ce%RKXSbs0!- z<)jPxGUuG%mz`x%q3cqi8=E#;ISSFV^PJ}P*{f<6s2)TnQk^7~NnWW~P07j75CEd{ z#Gs5EWgH5vOqyNnV_5`?jsi6 z!WiBhCumEC<1el9?bESs@Q=?xvE0em_ga}~nCr{G9M%N(65=0OERx>Z0fh5!FoKhk zSy-@i3ZMmEDUd{MZ6%3f4c3Hf_EIvm&U;(-I~<+1kG*l%bzYC1+$Ym68j~U+T$q`$ ztI*{o4wEwi3DVQkb2xDfPGKUveR~58CND8$u1lLb>|_=1KhWco*}o0)m8N2!xJQH) ztcXwMwc_OLoU30WOcvaH`uB8>un{KZb#Of}--Eii9ZxaJV}*kr%p*|%XzV4rwR>pD zvTVrsk4l_WBMH)Gk9z$01m%&S=H@Y70%N{8KX5svqu6##tagm+@^2KBG{_qt4U%Uw zP&frf+mX;~eI_h#4pK03sZPmg{7R?JR9{YNr`gv-PpF;Z9+_3>mJV30QH}sv5}@Ua z0X)~Pv51d9#l53jK2dj`Jg?lY!JmyYB(FTjpjM15SULs0J#Pc(p9ZS~?H|NlEj|ND za{KmGU{d&gkhsKMbqRv`n5H&_4U+?3SR8U#@taK>a=1-W1AApIr)zc{l3nl<=Tj?^ z{vpX6vF+!jG_rdE{e`WQh1!-Hz}_GV-rrMspLx?f0F3pv#7-Bfl`i{Q{+$) zFP{o|2~-*tyCbF*QO6G@Cbd3i0(^Wja`LGoRMEZH{629ByCr01>X}U^KzLTOy6YIS zB6yV?Jv~K1Kp)3~k7gk5ItG{A2#QS;5)fb-(W@JOyNCTJp;jV1=P%XW-Q8u+{5gTe z6*Y`>_;vDb3325qz%e@&&!T)%_9yhe;6z@W<-e)Iy=+^S+)<15H>fZ~g6?U|UlO1B zdAD1zAkX#C|JOpa2uJ^OWm<&q)H`$j!5RKWa(*Kw|FaXZ^Qew(S5$-H%GTD_&n2jL z&AfmANc6-XxJj011W=Wc5hbQ@`Aj!a{6Ej?O_bX=*KFiqXefnWPBNt&A)>a3sQ7i_ zf3jlKyvdmv7E+w%4_@yggd=}MD2K-}!JH7qQo=Voc2OIEZ zz3g7m4k%wH#>T#!tg5y~2w#OVDnCCd67lV;m`iHT(@HNK4&v=0d+h^K?FD#c6 z{DYI%M&L%>+nKMc=fwA>T$Fr#YS%&;6UH!knSWGrCNM#W>0~u>FxSfP!;&~xGh0MN zy(Aaga236?uzjaU+`Xo-*cyv%~Zd~<>TpQxNk#X=ZE>l~O*)yN2 zN+pA-J%Qkx8S#z@#|{sN_W%1_r~eTl%Zw9YFrR>{s?mG+eyPsE_3THE^X_@Op|UJ{ zMASxBfzc9j5_K==P7vp(4ZoHAXsCKmazHJ>Njmuq4$YBCf}5>FUbqvf;` zt=~3Xbd5INCeUq{_cPnyVZH6kxvkks7q@9 znO|GvQP;0uAM@U|R#3pjd31axUT2V_5MO+24rd^q?MhXE44Vb-_;lCJiQ76le1H=^ zzp!wZ{R=Rx8N=jMp=;*PRWmP%ArRu`<&BMvWo2P`A)g8?7?x9iE?|*!x`})q5|Wvb zq5IBrpuXN$CIvbdPHvBF_S~B;tq*iXrrf|N8Zi(nD*= zv;q8En4b>_2!KQyoZs_F7Nj0ve?WAWysCKPhHuAo${_8f+6+nksuR7M*tag93McST zTez#Hou8c{f1~7(sk@s3>BoAg$KYG55U{JdaQB1YLFkiJ`MD@1oXYf1XW-&wGs+is ztXa8)A8Xg%gdVK+pTF&jo0bt|!G@`}l;1reoAEP8mGUrH;0}IW-eG+jS(46Lw~Baj z+j)i~pcy(kz;FYdDCDBx8BEOAt-OpJ97^nDMOy5nS~bu~^7O;K`Jpm`)0$kgUh821 z9{`w*lcWFS$rF4UQ8N>hfRoweuCANVAOUa}>iv&~uCBtIoOyq8eTm1BXDF>jvDn$! zHE!PqR?#cPwA55aoKvt&k35$}8K2(okGiXR&KD`oU4YF*_TeM_D=kct;CqH>J|*^} z4lIlcnXopK6zi|EW`N#UTBZzU1t_Nj(+^H$R8*FLC5|Y$9TFBsq*%n9HW2sfm1<^B zC&F$8E)c^`X>$xlm`-qbx{L!!gz)~%CnG>%PaElxdlLsnV zk^v(DY|Lu7T#Y^qKu^F(eIPGk*(L=b3Hb5h&4?2fbaZqdfauWBU|br&$jAspVgTDC zu)aerLQPGLoKyEbcMepF0h;6FREC(7i>srjrx zxi}L1(kBVVhsF%Jkn}X!G_a_F6tBl5ND73g+P&2lX65)7*Naymx`!|r`nJRm_Z)3) zm!ddyva+*ls;fKMG{q2tO=DxUr^)CaQdxJ7LT0MwtPf6b$??cR{dL$ z5nAu9`)82=q5=k@QD0qCgJyb53e_UOQxNpm13L}^3cK*==WbNIZoxS~i z4tDl(-8=+6!0u-W2%>BJTBaahXh5UGu5ASd1VG{4WxjuWdO9$+i4y1*u&ogHoqT)= zkP+xB8L-mR8-d0$JUl#ztbiAG7W1Tzjt+{z8&E4q%3!y!FfqAfS})5!-`d^1c=6($ z+qXxm-TCP0pR}2Dn-v3}?$xVT&!0c{^TTv@-T={rI|#*6it+P*9JiCYaRYg_84~cV zl}YtX$SdnwTdlmus==HT7i%aeWET}d^t#s?!-s>j>V@{}KjguKj2)7F8%xW}2bVx5 zSy@>R4i2EIR(r5x2~(4k3$m&}efsnXG;(HU26hP=eD>Em#9^usR72jSuBO)9+Nzr(*;)%th`2~#-mCZMU|N~Ao}p!p-YX@M5}fg*sBnR#w@ z7FcB4J39~E-LDD?mX(xHUw^0p$_HPEZj`<+UjPUgx4ZUHOjSdJzgEfH+Z+1*Vq*ul zw%n|&*q=}Oc+;+r6)FN}V#@9sEFmyvHdj{e^Tq&tjd!NLp@C6I$Q{(%%q*y58iK2; z%1QuF9vT^8*1v_FsQx)<31b$1f<0nlGJ%c?sbo7NBL+ss(_Sa#G?W>3CPT%6mkKrC zq5ghCEIUAP?Cjpg#N4&Cw3L^}v5&{~uPZOVwvO^&b`uqdYnxnAS6BZgg#z^h*smx~ z%hgp7ojZCRm*?|HPtV7D+}wBoJHcF`)Y9DEt_+=s=H{?~3=BKaiwH{$BDB7~&V0_2 z?aGygPoIeAnBMLG9D=L`=#)^ZWvLyGNlg51cWn_$cL4$Y>{C-y8mt@r1rH1hw)R1; zH+t_(?=RL34h;b-X4dJUogH^lM_*T$6s6GXw{Mprb%aTonX!Zj6p^l_Da8ea`3yX# zrlf41TzM7}!h;ts#{i8Ruqgm^&@l*$i9yKw5Ym3UdR;v|+u?E|3JNnLqmNS#=xI@z zpWgvhh13tAz#B2s&%pjhw`{DhTUl8_Kht?6f~5%iUmkHCt2x-2!yRPzL3;1Zro+CFk&%hgaPjcif1rF_RV5-O z_AWhr8lDxJ$Kb9M0lj^F>2KbYS5zcx@-g!9bweTvPPK88k%8egx15GIgN3>I?BwKS zvS46LlaP>Hrl*Ipq@#nw>e?C)-q73oHqIVWsVa3}zK|tJ>!%T16B25xWfI<9iqR9j zeEEiwQb@;ii?Zp*g2$6^6Cr5WP-Q7(&KMdP2$N#~+w)jr`At#lb7~QjsAe*x_x2R| zgv3iOIR;{2sAgpJmspa=h&ncbWEiG$P&J-AcMh_qzMQ+hu@7WqWDuc<=Pm)MfsV*S zUqoZ}n>T(kDX@6h{&fQsGC3EYO#&84SYWf<5KIn;iowHK#`QA_bRKI{n(G@IHyOGB zScD=e@IIkaQq_dW$Op4P%k%KS3sB7%;G94^4IL?tai6ZSYf^`;uYQ^5EHKJDI+%q& z0(lqL=2dY)fttGdV6k~Pf*xpSkk5FEiC?*Lg_qX>N=g~MiWMH4oM_i*SqT&$ae~Jp z)^ibIVVY}p_Y4hBHm<-wsTb2ZCBoiYesBO>t_g|ssg=NplPb&Gzle)d#Oyj7? z^Yl;wB5&hdn^;^N?d@%ta;UGXBPAt;Lez?MQ2XPTFNrP6UyiSBgGm6LwfvHUeEL*s zT)My1S_e!V;EYN95gcK1-zFA4*x0f`#X%RL!xj*ImBA3GGbCk6;P@AQeHx&v95r9{ZdGdq>FL2p)xRE}_q<;~{?5TiS0dBLtzMf@`6pZ96MWz@q6fr46##Drb%&CYUdX3q zQqJMwHBDamIzSZMfVh)cd(_|KiX5r`{Mn){_A2zTG2*mJrm6$49t<(;wCX(1wddly zoSRGctgXG~zHop!BYh8Egps(pzhBGCt7hJ!>4~A0)gDmdfd7S84|Y4KyXj=9%?N9l zoXv2#WV}0#m}e00s$W8ao{NhB57Wx@8iN{;6F@Wtt@`a2hlN0TAQO*pZv{&MR3d0w zEh;ShmZyM2oDgOvDfu`(-4x0;KpfT5qKHu(1;7j-g$uxO0`JQ5NR5Tm)N~qPJy#c(#o-DP|3i#O`Rh(Is&K)*d-tGo97WtKsSJGfoh2kBK$Yd>n!tT6 zuqPI=mkTdWPIh;75uZO_0!7{m5x)0@K5wD=TRfkn!TO7Xmw=$(hCVt9yZ~SY%Adq| zi+a&kE&Vuk3tA_^%tEM`uV2GTjEp)gN}axJdc#8XTbW#H*AWm{One site, thirty times the restBerlinHamburgKölnFrankfurtMünchenLeipzig0510859095100One site, thirty times the restrequests per second (thousands) \ No newline at end of file diff --git a/docs/images/machine-folds.png b/docs/images/machine-folds.png new file mode 100644 index 0000000000000000000000000000000000000000..3a828b24920ea8608617b321f042a78cab1a8024 GIT binary patch literal 16821 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 literal 0 HcmV?d00001 diff --git a/docs/images/machine-folds.svg b/docs/images/machine-folds.svg new file mode 100644 index 0000000..78202cd --- /dev/null +++ b/docs/images/machine-folds.svg @@ -0,0 +1 @@ +Machine 7, "Inaktiv" folded outMar 2 06:00Mar 2 09:00Mar 2 12:00Mar 2 15:00Mar 2 18:00AktivRüstenStörungMachine 7, "Inaktiv" folded outAktivInaktivStörungRüsten \ No newline at end of file diff --git a/fold.go b/fold.go new file mode 100644 index 0000000..376287e --- /dev/null +++ b/fold.go @@ -0,0 +1,159 @@ +package figure + +import ( + "errors" + "fmt" + "math" + "sort" + + "github.com/timzifer/figure/data" + "github.com/timzifer/figure/scale" +) + +// ErrSpanColumn is returned by [SpansWhere] and its siblings when a column +// they were told to read is not in the table, or is not of the kind the +// helper reads. +var ErrSpanColumn = errors.New("figure: span column missing or of the wrong kind") + +// SpansWhere reads the stretches of time a table's rows cover where column +// col spells value — the periods a machine's state column says "Inaktiv" — +// ready to be folded out of a time axis: +// +// idle, err := figure.SpansWhere(states, "start", "end", "state", "Inaktiv") +// p.X(scale.Time(scale.TimeFold(idle...))) +// +// from and to are time columns. A row missing either bound is skipped, and +// the spans come back sorted and with overlapping or touching ones merged, so +// the list is the axis's rather than the table's. +// +// It lives here rather than in scale because a scale never reads a table and +// a table never knows what an axis is: the chart is the one place that holds +// both. The document a chart is written into holds the spans, not the query. +// See docs/adr/0083-an-axis-break-is-marked-or-not-drawn.md. +func SpansWhere(src data.Source, from, to, col, value string) ([]scale.TimeSpan, error) { + c, ok := data.ColumnOf(src, col) + if !ok { + return nil, fmt.Errorf("%w: %q", ErrSpanColumn, col) + } + return SpansFunc(src, from, to, func(i int) bool { return !null(c, i) && c.Spell(i) == value }) +} + +// SpansFunc is [SpansWhere] for any test: keep is asked about each row by its +// index in src. +func SpansFunc(src data.Source, from, to string, keep func(i int) bool) ([]scale.TimeSpan, error) { + f, err := timeColumn(src, from) + if err != nil { + return nil, err + } + t, err := timeColumn(src, to) + if err != nil { + return nil, err + } + var out []scale.TimeSpan + for i := range f.Times { + if i >= len(t.Times) || null(f, i) || null(t, i) || !keep(i) { + continue + } + a, z := f.Times[i], t.Times[i] + if z.Before(a) { + a, z = z, a + } + if !a.Before(z) { + continue + } + out = append(out, scale.TimeSpan{From: a, To: z}) + } + sort.SliceStable(out, func(i, j int) bool { return out[i].From.Before(out[j].From) }) + merged := out[:0] + for _, s := range out { + if n := len(merged); n > 0 && !s.From.After(merged[n-1].To) { + if s.To.After(merged[n-1].To) { + merged[n-1].To = s.To + } + continue + } + merged = append(merged, s) + } + return merged, nil +} + +// IntervalsWhere is [SpansWhere] for a numeric axis: from and to are number +// columns, and the intervals are ready for [scale.Fold]. +func IntervalsWhere(src data.Source, from, to, col, value string) ([]scale.Interval, error) { + c, ok := data.ColumnOf(src, col) + if !ok { + return nil, fmt.Errorf("%w: %q", ErrSpanColumn, col) + } + return IntervalsFunc(src, from, to, func(i int) bool { return !null(c, i) && c.Spell(i) == value }) +} + +// IntervalsFunc is [IntervalsWhere] for any test. +func IntervalsFunc(src data.Source, from, to string, keep func(i int) bool) ([]scale.Interval, error) { + f, fok := numberColumn(src, from) + t, tok := numberColumn(src, to) + if !fok { + return nil, fmt.Errorf("%w: %q", ErrSpanColumn, from) + } + if !tok { + return nil, fmt.Errorf("%w: %q", ErrSpanColumn, to) + } + var out []scale.Interval + for i := range f { + if i >= len(t) || math.IsNaN(f[i]) || math.IsNaN(t[i]) || !keep(i) { + continue + } + a, z := f[i], t[i] + if z < a { + a, z = z, a + } + if !(a < z) { + continue + } + out = append(out, scale.Interval{Lo: a, Hi: z}) + } + sort.SliceStable(out, func(i, j int) bool { return out[i].Lo < out[j].Lo }) + merged := out[:0] + for _, s := range out { + if n := len(merged); n > 0 && s.Lo <= merged[n-1].Hi { + if s.Hi > merged[n-1].Hi { + merged[n-1].Hi = s.Hi + } + continue + } + merged = append(merged, s) + } + return merged, nil +} + +func null(c data.Column, i int) bool { return i < len(c.Nulls) && c.Nulls[i] } + +func timeColumn(src data.Source, name string) (data.Column, error) { + c, ok := data.ColumnOf(src, name) + if !ok || c.Kind != data.KindTime { + return data.Column{}, fmt.Errorf("%w: %q", ErrSpanColumn, name) + } + return c, nil +} + +// numberColumn reads a numeric column with its nulls written as NaN, which is +// how every reader in figure sees a missing number. +func numberColumn(src data.Source, name string) ([]float64, bool) { + c, ok := data.ColumnOf(src, name) + if !ok { + return nil, false + } + vs, ok := c.Numbers() + if !ok { + return nil, false + } + if len(c.Nulls) == 0 { + return vs, true + } + out := append([]float64(nil), vs...) + for i, n := range c.Nulls { + if n && i < len(out) { + out[i] = math.NaN() + } + } + return out, true +} diff --git a/render/render.go b/render/render.go index bd81d7a..8b2a51c 100644 --- a/render/render.go +++ b/render/render.go @@ -432,6 +432,11 @@ func Draw(b ir.Backend, c Chart) error { } } + // Before anything is measured, every scale that can leave intervals out of + // its axis is told how wide a gap is — or that it has none to leave, + // because its panel's coord cannot mark one. + breakGaps(c, panels, th) + // 2. Measure. Tick label *text* depends only on the domain, but // Scale.Ticks also reports positions, which need a range — so // measurePanels gives every scale a provisional unit range, and the @@ -490,9 +495,10 @@ func Draw(b ir.Backend, c Chart) error { cd.Furniture(fur, coord.FurnitureRequest{Area: area, Metrics: metricsOf(th), XTicks: xTicks, YTicks: yTicks}) drawPanelFill(b, area, th) drawGrid(b, th, p, fur, xTicks, yTicks) - if fur.AxesOverData { + if fur.AxesOverData || !fur.Breaks.Empty() { axesOver = true - } else { + } + if !fur.AxesOverData { drawAxes(b, th, p, fur, xTicks, yTicks) } drawSecondaryAxes(b, th, p, cd, area) @@ -789,6 +795,9 @@ func metricsOf(th theme.Theme) coord.Metrics { TickLen: th.TickLength, MinorTickLen: th.TickLength * minorTickScale, LabelPad: th.TickLabelPad, + BreakSize: th.AxisBreakSize, + FoldSize: th.AxisFoldSize, + BreakMark: coord.BreakMark(th.AxisBreakMark), } } @@ -1148,6 +1157,49 @@ func drawAxesOverData(b ir.Backend, c Chart, panels []Panel, areas []ir.Rect, th if fur.AxesOverData { drawAxes(b, th, p, fur, xTicks, yTicks) } + drawBreaks(b, th, fur) + } +} + +// drawBreaks strokes the marks of the gaps left in a panel's axes. It runs +// after the data, like an axis that lies over it, because the mark is the +// statement that the axis is broken and a bar crossing the break must not +// paint over it. It is drawn whether or not the axis lines are: an axis whose +// rule the theme turned off is still broken. +func drawBreaks(b ir.Backend, th theme.Theme, fur *coord.Furniture) { + if fur.Breaks.Empty() { + return + } + strokeShape(b, &fur.Breaks, ir.Stroke{Color: th.AxisColor, Width: th.AxisWidth, Cap: ir.CapButt}) +} + +// breakGaps tells every scale on every panel that can leave an interval out +// of its axis how wide a gap to leave. A scale under a coord that cannot mark +// a break is told it has none, and so is a second axis, which is drawn by a +// pass that places no clip and marks nothing: in both places a break would +// be drawn without its mark, which is a chart that lies about its distances. +// See docs/adr/0083-an-axis-break-is-marked-or-not-drawn.md. +func breakGaps(c Chart, panels []Panel, th theme.Theme) { + for _, s := range [...]scale.Scale{c.X2, c.Y2} { + if br, ok := s.(scale.Breaker); ok { + br.SetBreakGap(-1, 0) + } + } + for _, p := range panels { + brk := th.AxisBreakGap + if !coord.MarksBreaks(c.coordOf(p)) { + brk = -1 + } + for _, s := range [...]scale.Scale{p.X, p.Y} { + if br, ok := s.(scale.Breaker); ok { + br.SetBreakGap(brk, th.AxisFoldGap) + } + } + for _, s := range [...]scale.Scale{p.X2, p.Y2} { + if br, ok := s.(scale.Breaker); ok { + br.SetBreakGap(-1, 0) + } + } } } diff --git a/scale/breaks.go b/scale/breaks.go new file mode 100644 index 0000000..2e64f9b --- /dev/null +++ b/scale/breaks.go @@ -0,0 +1,439 @@ +package scale + +import ( + "math" + "sort" + "time" +) + +// Interval is a closed stretch of a numeric domain, Lo to Hi. +type Interval struct{ Lo, Hi float64 } + +// TimeSpan is a stretch of time, From to To. +type TimeSpan struct{ From, To time.Time } + +// Break leaves the interval lo to hi out of a linear axis. The values either +// side of it are drawn a small gap apart, and the gap is marked, so that a +// chart whose one tall bar would flatten everything else can show both. +// +// A break may be given more than once; overlapping and touching intervals +// merge. A break is only drawn where it is strictly inside the domain, so a +// zoom that moves one off the axis simply stops showing it. +// +// Nothing is removed from the data. A value inside the break maps into the +// gap and the panel's clip hides it, which is why a bar from 0 to 95 across a +// break from 10 to 90 is still one bar — drawn with its middle missing — and +// why [Scale.Invert] is exact everywhere. A break under a coord that cannot +// mark one is not drawn at all: the axis is unbroken, because a break without +// its mark is a chart that lies about its distances. See +// docs/adr/0083-an-axis-break-is-marked-or-not-drawn.md. +func Break(lo, hi float64) LinearOption { + return func(l *linear) { l.cuts = l.cuts.with(false, Interval{lo, hi}) } +} + +// Fold leaves each interval out of a linear axis the way [Break] does, marked +// as a fold rather than a break: a narrow gap and a small slash on the axis +// line, never a zigzag across the panel. It is for many stretches taken out +// of one axis because the caller does not want to look at them, rather than +// for one interval taken out because the data would not fit. +func Fold(ivs ...Interval) LinearOption { + return func(l *linear) { l.cuts = l.cuts.with(true, ivs...) } +} + +// TimeBreak leaves the stretch from to to out of a time axis. It is [Break] +// for time. +func TimeBreak(from, to time.Time) TimeOption { + return func(s *timeScale) { s.spans = append(s.spans, timeCut{TimeSpan{from, to}, false}) } +} + +// TimeFold leaves each span out of a time axis the way [Fold] does: the +// periods a machine stood idle, the nights of a trading week. See +// [github.com/timzifer/figure.SpansWhere] for reading them out of a table. +func TimeFold(spans ...TimeSpan) TimeOption { + return func(s *timeScale) { + for _, sp := range spans { + s.spans = append(s.spans, timeCut{sp, true}) + } + } +} + +// timeCut is a span waiting for the scale's origin: [Origin] may be given +// after the fold, and a domain value means nothing until the origin is known. +type timeCut struct { + span TimeSpan + fold bool +} + +// Breaker is implemented by a scale whose axis can leave intervals out. It is +// an optional interface, for the reason [Zoomer] is. +// +// The scale knows which intervals; it does not know how wide a gap is, because +// that is a length on the page and a scale has no theme. The renderer tells it +// once per render, before anything is mapped, exactly as it tells a size scale +// its range — and tells it off when the coord cannot mark a break, so that no +// axis is ever broken without saying so. +type Breaker interface { + // SetBreakGap sets the device length a break and a fold leave between the + // two sides of the axis. A negative brk switches every cut off, and the + // scale maps as though it had none. + SetBreakGap(brk, fold float32) + + // Gaps reports how many cuts are drawn on the axis at its current domain + // and range, and Gap the device interval of the i-th, ascending by value, + // and whether it is a fold. lo is where the lower side of the axis ends; + // on a Y axis or a reversed one it is the larger number. + Gaps() int + Gap(i int) (lo, hi float32, fold bool) +} + +// cut is one interval left out, and whether it is a fold rather than a break. +type cut struct { + lo, hi float64 + fold bool +} + +// cuts is an axis's intervals, ascending and disjoint, with running sums over +// them so that a lookup is a binary search rather than a walk. It is built +// once, at construction, and never written afterwards, which is why a Clone +// and a Snapshot share it. +type cuts struct { + c []cut + // width[i] is the total width of c[:i], and folds[i] how many of c[:i] + // are folds. Both have len(c)+1 entries. + width []float64 + folds []int +} + +// with returns the set with ivs added, sorted and merged. A break that +// absorbs a fold stays a break: the fold was the smaller claim. +func (cs cuts) with(fold bool, ivs ...Interval) cuts { + all := make([]cut, 0, len(cs.c)+len(ivs)) + all = append(all, cs.c...) + for _, iv := range ivs { + lo, hi := order(iv.Lo, iv.Hi) + if math.IsNaN(lo) || math.IsNaN(hi) || math.IsInf(lo, 0) || math.IsInf(hi, 0) || !(lo < hi) { + continue + } + all = append(all, cut{lo, hi, fold}) + } + sort.SliceStable(all, func(i, j int) bool { return all[i].lo < all[j].lo }) + out := all[:0] + for _, c := range all { + if n := len(out); n > 0 && c.lo <= out[n-1].hi { + last := &out[n-1] + last.hi = math.Max(last.hi, c.hi) + last.fold = last.fold && c.fold + continue + } + out = append(out, c) + } + r := cuts{c: out, width: make([]float64, len(out)+1), folds: make([]int, len(out)+1)} + for i, c := range out { + r.width[i+1] = r.width[i] + (c.hi - c.lo) + r.folds[i+1] = r.folds[i] + if c.fold { + r.folds[i+1]++ + } + } + return r +} + +func (cs cuts) intervals(fold bool) []Interval { + var out []Interval + for _, c := range cs.c { + if c.fold == fold { + out = append(out, Interval{c.lo, c.hi}) + } + } + return out +} + +// gaps is what the renderer told the scale. on is false until it has: a scale +// used anywhere but under a coord that marks breaks maps as an unbroken one. +type gaps struct { + brk, fold float32 + on bool +} + +func (g *gaps) set(brk, fold float32) { + if brk < 0 || math.IsNaN(float64(brk)) { + *g = gaps{} + return + } + if !(fold >= 0) { + fold = 0 + } + *g = gaps{brk: brk, fold: fold, on: true} +} + +// broken is an axis's cuts resolved against one domain and one device range. +// It lives on the stack of the call that needs it: nothing about it is kept, +// so a panel on another goroutine can never see another panel's. +type broken struct { + cs *cuts + i0, i1 int // the active cuts, cs.c[i0:i1] + lo, hi float64 + rlo, rhi float32 + sign float64 // +1 when the range ascends, -1 when it descends + unit float64 // device length per unit of kept domain, positive + brk, fld float64 // the gap a break and a fold leave, positive +} + +// resolve answers false when the axis draws as though it had no cuts: none +// are inside the domain, the renderer never switched them on, or the range is +// too short to spend on gaps. +func (cs *cuts) resolve(g gaps, lo, hi float64, rlo, rhi float32) (broken, bool) { + if !g.on || len(cs.c) == 0 || !(lo < hi) || rlo == rhi { + return broken{}, false + } + // A cut that reaches an end of the domain would be a gap with nothing on + // its far side, so it trims the axis instead: the domain ends where the + // cut begins. That is what folding the last idle hour of a shift has to + // mean, and it distorts no distance, so it needs no mark. + trimmed := false + if j := sort.Search(len(cs.c), func(i int) bool { return cs.c[i].hi > lo }); j < len(cs.c) && cs.c[j].lo <= lo { + lo, trimmed = cs.c[j].hi, true + } + if k := sort.Search(len(cs.c), func(i int) bool { return cs.c[i].lo >= hi }) - 1; k >= 0 && cs.c[k].hi >= hi { + hi, trimmed = cs.c[k].lo, true + } + if !(lo < hi) { + return broken{}, false + } + // What is left is strictly inside the domain. + i0 := sort.Search(len(cs.c), func(i int) bool { return cs.c[i].lo > lo }) + i1 := sort.Search(len(cs.c), func(i int) bool { return cs.c[i].hi >= hi }) + if i1 < i0 { + i1 = i0 + } + if i1 == i0 && !trimmed { + return broken{}, false + } + nf := cs.folds[i1] - cs.folds[i0] + nb := (i1 - i0) - nf + span := math.Abs(float64(rhi) - float64(rlo)) + brk, fld := float64(g.brk), float64(g.fold) + // Gaps may take at most half the axis. Folds give their room up first — + // a fold with no gap is still folded and still marked — and a break that + // still does not fit is not drawn, because a squeezed break is a picture + // of nothing. + if float64(nb)*brk+float64(nf)*fld > span/2 { + fld = 0 + if float64(nb)*brk > span/2 { + return broken{}, false + } + } + kept := (hi - lo) - (cs.width[i1] - cs.width[i0]) + if !(kept > 0) { + return broken{}, false + } + b := broken{ + cs: cs, i0: i0, i1: i1, lo: lo, hi: hi, rlo: rlo, rhi: rhi, + sign: 1, brk: brk, fld: fld, + } + if rhi < rlo { + b.sign = -1 + } + b.unit = (span - float64(float64(nb)*brk) - float64(float64(nf)*fld)) / kept + return b, true +} + +func (b *broken) gapOf(i int) float64 { + if b.cs.c[i].fold { + return b.fld + } + return b.brk +} + +// offset is how far along the axis, in device units and from rlo, the lower +// edge of cut i lies. The products are rounded explicitly so that no machine +// fuses them into the sums: see [place]. +func (b *broken) offset(i int) float64 { + cs := b.cs + kept := (cs.c[i].lo - b.lo) - (cs.width[i] - cs.width[b.i0]) + nf := cs.folds[i] - cs.folds[b.i0] + nb := (i - b.i0) - nf + return float64(kept*b.unit) + float64(float64(nb)*b.brk) + float64(float64(nf)*b.fld) +} + +func (b *broken) at(off float64) float32 { return b.rlo + float32(b.sign*off) } + +// edges is the device interval of cut i, lower side first. +func (b *broken) edges(i int) (float32, float32) { + off := b.offset(i) + return b.at(off), b.at(off + b.gapOf(i)) +} + +// segment is the stretch of domain v falls in — a kept piece or a cut — and +// the device interval it maps onto. The ends of every segment are computed one +// way whichever value asked, so the two sides of a gap meet the gap exactly. +func (b *broken) segment(v float64) (a, z float64, pa, pz float32) { + cs := b.cs + // j is the first active cut not wholly below v. + 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 { + pa, pz = b.edges(j) + return cs.c[j].lo, cs.c[j].hi, pa, pz + } + a, pa = b.lo, b.rlo + if j > b.i0 { + a = cs.c[j-1].hi + _, pa = b.edges(j - 1) + } + z, pz = b.hi, b.rhi + if j < b.i1 { + z = cs.c[j].lo + pz, _ = b.edges(j) + } + return a, z, pa, pz +} + +func (b *broken) mapTo(v float64) float32 { + a, z, pa, pz := b.segment(v) + switch v { + case a: + return pa + case z: + return pz + } + return place(pa, pz, (v-a)/(z-a)) +} + +func (b *broken) map64(v float64) float64 { + a, z, pa, pz := b.segment(v) + return place64(pa, pz, (v-a)/(z-a)) +} + +func (b *broken) invert(pos float32) float64 { + cs := b.cs + o := b.sign * (float64(pos) - float64(b.rlo)) + // j is the first active cut whose upper edge is not below o. + j := b.i0 + sort.Search(b.i1-b.i0, func(k int) bool { + i := b.i0 + k + return b.offset(i)+b.gapOf(i) >= o + }) + if j < b.i1 { + if s := b.offset(j); o >= s { + g := b.gapOf(j) + if g == 0 { + return cs.c[j].lo + } + return cs.c[j].lo + (o-s)/g*(cs.c[j].hi-cs.c[j].lo) + } + } + a, oa := b.lo, 0.0 + if j > b.i0 { + a, oa = cs.c[j-1].hi, b.offset(j-1)+b.gapOf(j-1) + } + return a + (o-oa)/b.unit +} + +// pieces calls fn with each kept stretch of the domain, ascending. +func (b *broken) pieces(fn func(a, z float64)) { + a := b.lo + for i := b.i0; i < b.i1; i++ { + fn(a, b.cs.c[i].lo) + a = b.cs.c[i].hi + } + fn(a, b.hi) +} + +// kept is the length of domain the axis still shows. +func (b *broken) kept() float64 { + return (b.hi - b.lo) - (b.cs.width[b.i1] - b.cs.width[b.i0]) +} + +// inside reports whether v falls strictly inside one of the active cuts, +// where no tick may stand. +func (b *broken) inside(v float64) 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 }) + return j < b.i1 && v > cs.c[j].lo +} + +// brokenValues is the tick sequence of a broken axis: one step, chosen for the +// length of domain the axis still shows, walked through each kept piece. One +// step is what keeps the numbers either side of a break reading as one axis, +// and walking the pieces rather than the whole domain is what keeps an axis +// with hundreds of folds as cheap as one with a single break. +func brokenValues(b *broken, want int) ([]float64, float64) { + step := extendedWilkinson(0, b.kept(), want, false).step + if !(step > 0) { + return nil, 0 + } + var out []float64 + const guard = 4096 + b.pieces(func(a, z float64) { + eps := 1e-9 * math.Max(math.Abs(a), math.Abs(z)) + for k := math.Ceil((a - eps) / step); len(out) < guard; k++ { + v := k * step + if v > z+eps { + break + } + if v == 0 { + v = 0 // no negative zero on an axis + } + out = append(out, v) + } + }) + return out, step +} + +// The implementations of [Breaker] share a shape: the cuts are resolved +// against the scale's own domain and device range, every time, so that a pan +// and a resize need nothing invalidated. + +func (l *linear) SetBreakGap(brk, fold float32) { l.gaps.set(brk, fold) } + +func (l *linear) broken() (broken, bool) { + lo, hi := l.effective() + rlo, rhi := l.device() + return l.cuts.resolve(l.gaps, lo, hi, rlo, rhi) +} + +func (l *linear) Gaps() int { + b, ok := l.broken() + if !ok { + return 0 + } + return b.i1 - b.i0 +} + +func (l *linear) Gap(i int) (float32, float32, bool) { + b, ok := l.broken() + if !ok || i < 0 || i >= b.i1-b.i0 { + return 0, 0, false + } + lo, hi := b.edges(b.i0 + i) + return lo, hi, b.cs.c[b.i0+i].fold +} + +func (s *timeScale) SetBreakGap(brk, fold float32) { s.gaps.set(brk, fold) } + +func (s *timeScale) broken() (broken, bool) { + lo, hi := s.span() + rlo, rhi := s.rangeOf() + return s.cuts.resolve(s.gaps, lo, hi, rlo, rhi) +} + +func (s *timeScale) Gaps() int { + b, ok := s.broken() + if !ok { + return 0 + } + return b.i1 - b.i0 +} + +func (s *timeScale) Gap(i int) (float32, float32, bool) { + b, ok := s.broken() + if !ok || i < 0 || i >= b.i1-b.i0 { + return 0, 0, false + } + lo, hi := b.edges(b.i0 + i) + return lo, hi, b.cs.c[b.i0+i].fold +} + +var ( + _ Breaker = (*linear)(nil) + _ Breaker = (*timeScale)(nil) +) diff --git a/scale/breaks_test.go b/scale/breaks_test.go new file mode 100644 index 0000000..295c609 --- /dev/null +++ b/scale/breaks_test.go @@ -0,0 +1,256 @@ +package scale + +import ( + "math" + "testing" + "time" +) + +// broke returns a linear scale over [0, 100] on a 0..220 pixel range with a +// break from 10 to 90 and a 20 pixel gap, so that each kept piece is 100 px. +func broke(opts ...LinearOption) Scale { + s := Linear(append([]LinearOption{Domain(0, 100), Break(10, 90)}, opts...)...) + s.SetRange(0, 220) + s.(Breaker).SetBreakGap(20, 4) + return s +} + +func TestABreakSplitsTheRangeBetweenTheKeptPieces(t *testing.T) { + s := broke() + for _, c := range []struct { + v float64 + want float32 + }{{0, 0}, {5, 50}, {10, 100}, {50, 110}, {90, 120}, {95, 170}, {100, 220}} { + if got := s.Map(c.v); math.Abs(float64(got-c.want)) > 1e-3 { + t.Errorf("Map(%v) = %v, want %v", c.v, got, c.want) + } + } + // The two edges of the gap are exact: they are what the clip and the mark + // are cut against. + if got := s.Map(10); got != 100 { + t.Errorf("Map(10) = %v, want exactly 100", got) + } + if got := s.Map(90); got != 120 { + t.Errorf("Map(90) = %v, want exactly 120", got) + } + lo, hi, fold := s.(Breaker).Gap(0) + if lo != 100 || hi != 120 || fold { + t.Errorf("Gap(0) = %v, %v, %v; want 100, 120, false", lo, hi, fold) + } +} + +func TestABrokenAxisInvertsExactlyAndMonotonically(t *testing.T) { + s := broke() + prev := float32(-1) + for v := 0.0; v <= 100; v += 0.5 { + p := s.Map(v) + if p < prev { + t.Fatalf("Map is not monotone at %v: %v after %v", v, p, prev) + } + prev = p + if back := s.Invert(p); math.Abs(back-v) > 1e-3 { + t.Errorf("Invert(Map(%v)) = %v", v, back) + } + } +} + +func TestABreakFollowsAReversedAxis(t *testing.T) { + s := broke(Reverse()) + if got := s.Map(0); got != 220 { + t.Errorf("Map(0) = %v, want 220", got) + } + if got := s.Map(10); got != 120 { + t.Errorf("Map(10) = %v, want 120", got) + } + if got := s.Map(90); got != 100 { + t.Errorf("Map(90) = %v, want 100", got) + } + lo, hi, _ := s.(Breaker).Gap(0) + if lo != 120 || hi != 100 { + t.Errorf("Gap(0) = %v, %v; want 120, 100", lo, hi) + } +} + +func TestABreakIsOffUntilTheRendererSwitchesItOn(t *testing.T) { + s := Linear(Domain(0, 100), Break(10, 90)) + s.SetRange(0, 100) + if got := s.Map(50); got != 50 { + t.Errorf("an unswitched break mapped 50 to %v", got) + } + if n := s.(Breaker).Gaps(); n != 0 { + t.Errorf("an unswitched break reports %d gaps", n) + } + s.(Breaker).SetBreakGap(10, 2) + s.(Breaker).SetBreakGap(-1, 0) + if got := s.Map(50); got != 50 { + t.Errorf("a break switched off mapped 50 to %v", got) + } +} + +func TestABreakOutsideTheDomainIsNotDrawn(t *testing.T) { + s := broke() + s.(Zoomer).SetDomain(20, 80) // wholly inside the break + if n := s.(Breaker).Gaps(); n != 0 { + t.Errorf("a domain inside the break reports %d gaps", n) + } +} + +// TestABreakAtAnEndTrimsTheAxis: a cut reaching an end of the domain has +// nothing on its far side to leave a gap before, so the axis ends where it +// begins — the last idle hour of a shift folded off the end of the chart. +func TestABreakAtAnEndTrimsTheAxis(t *testing.T) { + s := broke() + s.(Zoomer).SetDomain(50, 100) // the break covers the low end + if n := s.(Breaker).Gaps(); n != 0 { + t.Errorf("a break across the domain's end reports %d gaps", n) + } + if got := s.Map(90); got != 0 { + t.Errorf("Map(90) = %v, want 0: the axis starts where the break ends", got) + } + if got := s.Map(95); math.Abs(float64(got-110)) > 1e-3 { + t.Errorf("Map(95) = %v on a 90..100 axis over 220 px, want 110", got) + } + for _, tk := range s.Ticks(TickRequest{Want: 5}) { + if tk.Value < 90 { + t.Errorf("tick %v is inside the trimmed end", tk.Value) + } + } +} + +func TestABreakThatDoesNotFitIsNotDrawn(t *testing.T) { + s := Linear(Domain(0, 100), Break(10, 90)) + s.SetRange(0, 30) + s.(Breaker).SetBreakGap(20, 4) + if n := s.(Breaker).Gaps(); n != 0 { + t.Errorf("a 20 px gap on a 30 px axis was drawn") + } +} + +func TestFoldsGiveTheirRoomUpBeforeTheyAreDropped(t *testing.T) { + var ivs []Interval + for i := 0; i < 50; i++ { + ivs = append(ivs, Interval{float64(2*i) + 0.5, float64(2*i) + 1.5}) + } + s := Linear(Domain(0, 100), Fold(ivs...)) + s.SetRange(0, 100) + s.(Breaker).SetBreakGap(20, 4) // 50 folds × 4 px is twice the axis + b := s.(Breaker) + if n := b.Gaps(); n != 50 { + t.Fatalf("Gaps() = %d, want 50", n) + } + lo, hi, fold := b.Gap(3) + if lo != hi || !fold { + t.Errorf("a fold that did not fit kept a gap: %v..%v fold=%v", lo, hi, fold) + } +} + +func TestManyFoldsMapLikeAWalkOverThem(t *testing.T) { + var ivs []Interval + for i := 0; i < 500; i++ { + ivs = append(ivs, Interval{float64(10*i) + 3, float64(10*i) + 7}) + } + s := Linear(Domain(0, 5000), Fold(ivs...)) + s.SetRange(0, 10000) + s.(Breaker).SetBreakGap(8, 2) + // 2000 px of kept domain... every piece but the ends is 6 wide, the gaps + // 2 px: the reference walks them one at a time. + kept := 5000.0 - 500*4 + unit := (10000.0 - 500*2) / kept + ref := func(v float64) float64 { + off, at := 0.0, 0.0 + for _, iv := range ivs { + if v <= iv.Lo { + break + } + if v < iv.Hi { + return off + (iv.Lo-at)*unit + (v-iv.Lo)/(iv.Hi-iv.Lo)*2 + } + off += (iv.Lo-at)*unit + 2 + at = iv.Hi + } + return off + (v-at)*unit + } + for v := 0.0; v <= 5000; v += 1.25 { + if got, want := float64(s.Map(v)), ref(v); math.Abs(got-want) > 0.01 { + t.Fatalf("Map(%v) = %v, want %v", v, got, want) + } + } +} + +func TestABreakAbsorbsAFoldItOverlaps(t *testing.T) { + s := Linear(Domain(0, 100), Fold(Interval{20, 40}), Break(30, 60)) + d := s.(Describer).Describe() + if len(d.Cuts) != 1 || d.Cuts[0] != (Interval{20, 60}) || len(d.Folds) != 0 { + t.Errorf("cuts %v folds %v, want one break 20..60", d.Cuts, d.Folds) + } +} + +func TestTicksOnABrokenAxisShareOneStepAndAvoidTheGap(t *testing.T) { + s := broke() + ts := s.Ticks(TickRequest{Want: 5}) + var got []float64 + for _, tk := range ts { + got = append(got, tk.Value) + if tk.Value > 10 && tk.Value < 90 { + t.Errorf("tick at %v is inside the break", tk.Value) + } + } + want := []float64{0, 5, 10, 90, 95, 100} + if len(got) != len(want) { + t.Fatalf("ticks %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("ticks %v, want %v", got, want) + } + } +} + +func TestABreakSurvivesCloneSnapshotAndDescribe(t *testing.T) { + s := broke() + snap := s.(Snapshotter).Snapshot() + if snap.Map(95) != s.Map(95) { + t.Errorf("a snapshot maps 95 to %v, original to %v", snap.Map(95), s.Map(95)) + } + d := s.(Describer).Describe() + back, err := FromDesc(d) + if err != nil { + t.Fatal(err) + } + back.SetRange(0, 220) + back.(Breaker).SetBreakGap(20, 4) + if back.Map(95) != s.Map(95) { + t.Errorf("a round trip maps 95 to %v, original to %v", back.Map(95), s.Map(95)) + } + c := s.(Cloner).Clone() + c.SetRange(0, 220) + c.(Breaker).SetBreakGap(20, 4) + if c.Map(95) != s.Map(95) { + t.Errorf("a clone maps 95 to %v, original to %v", c.Map(95), s.Map(95)) + } +} + +func TestATimeFoldIsMeasuredFromTheOriginGivenAfterIt(t *testing.T) { + t0 := time.Date(2026, 1, 5, 0, 0, 0, 0, time.UTC) + day := 24 * time.Hour + s := Time(TimeFold(TimeSpan{t0.Add(day), t0.Add(3 * day)}), Origin(t0)) + s.(Zoomer).SetDomain(0, float64(5*day)) + s.SetRange(0, 304) + s.(Breaker).SetBreakGap(8, 4) + // Three days kept on 300 px: 100 px a day, and a 4 px fold after the first. + if got := s.Map(ValueOf(s, t0.Add(day))); got != 100 { + t.Errorf("fold starts at %v, want 100", got) + } + if got := s.Map(ValueOf(s, t0.Add(3*day))); got != 104 { + t.Errorf("fold ends at %v, want 104", got) + } + for _, tk := range s.Ticks(TickRequest{Want: 4}) { + if v := tk.Value; v > float64(day) && v < float64(3*day) { + t.Errorf("tick %v inside the fold", InstantOf(s, v)) + } + } + d := s.(Describer).Describe() + if len(d.Folds) != 1 || d.Folds[0] != (Interval{float64(day), float64(3 * day)}) { + t.Errorf("Folds = %v", d.Folds) + } +} diff --git a/scale/describe.go b/scale/describe.go index 2a30ba0..e99f4ff 100644 --- a/scale/describe.go +++ b/scale/describe.go @@ -64,6 +64,10 @@ type Desc struct { // TickValues is a linear scale's pinned tick sequence, ascending, and is // empty for an axis that chooses its own. See [TickValues]. TickValues []float64 + // Cuts are the intervals a linear or time axis leaves out with a break, + // and Folds those it leaves out with a fold, ascending and disjoint, in + // the scale's own domain space. See [Break] and [Fold]. + Cuts, Folds []Interval // Base is the log or symlog base, and Threshold the symlog linear region. Base, Threshold float64 // MinorTicks reports the unlabelled subdivisions of a log or symlog axis, @@ -158,7 +162,11 @@ func FromDesc(d Desc) (Scale, error) { if len(d.TickValues) > 0 { opts = append(opts, TickValues(d.TickValues...)) } - opts = append(opts, func(l *linear) { l.numFormat = f; l.loc = localeNamed(d.Locale) }) + opts = append(opts, func(l *linear) { + l.numFormat = f + l.loc = localeNamed(d.Locale) + l.cuts = l.cuts.with(false, d.Cuts...).with(true, d.Folds...) + }) return Linear(opts...), nil case KindLog: @@ -228,7 +236,10 @@ func FromDesc(d Desc) (Scale, error) { if d.Layout != "" { opts = append(opts, TimeLayout(d.Layout)) } - opts = append(opts, func(t *timeScale) { t.locale = localeNamed(d.Locale) }) + opts = append(opts, func(t *timeScale) { + t.locale = localeNamed(d.Locale) + t.cuts = t.cuts.with(false, d.Cuts...).with(true, d.Folds...) + }) s := Time(opts...) if d.Fixed { s.(Zoomer).SetDomain(d.Min, d.Max) @@ -259,6 +270,7 @@ func (l *linear) Describe() Desc { if len(l.ticks) > 0 { d.TickValues = append([]float64(nil), l.ticks...) } + d.Cuts, d.Folds = l.cuts.intervals(false), l.cuts.intervals(true) return d } @@ -297,6 +309,7 @@ func (s *timeScale) Describe() Desc { if s.fixed { d.Min, d.Max = s.dmin, s.dmax } + d.Cuts, d.Folds = s.cuts.intervals(false), s.cuts.intervals(true) return d } diff --git a/scale/linear.go b/scale/linear.go index aef0b31..ab906d5 100644 --- a/scale/linear.go +++ b/scale/linear.go @@ -125,6 +125,11 @@ type linear struct { numFormat numberFormat loc *Locale + // cuts are the intervals [Break] and [Fold] leave out, and gaps how wide + // the renderer said they are. See [Breaker]. + cuts cuts + gaps gaps + // cached nicing, invalidated whenever the domain changes nicedFor [2]float64 niced labelling @@ -200,6 +205,9 @@ func (l *linear) Map(v float64) float32 { if hi == lo { return rlo } + if b, ok := l.cuts.resolve(l.gaps, lo, hi, rlo, rhi); ok { + return b.mapTo(v) + } // Snap the endpoints. Without this, mapping the domain maximum lands a // float32 ulp short of the range end, and every "is this tick inside the // plot" test at the boundary answers no. @@ -220,6 +228,9 @@ func (l *linear) Map64(v float64) float64 { if hi == lo { return float64(rlo) } + if b, ok := l.cuts.resolve(l.gaps, lo, hi, rlo, rhi); ok { + return b.map64(v) + } return place64(rlo, rhi, (v-lo)/(hi-lo)) } @@ -229,6 +240,9 @@ func (l *linear) Invert(pos float32) float64 { if rhi == rlo { return lo } + if b, ok := l.cuts.resolve(l.gaps, lo, hi, rlo, rhi); ok { + return b.invert(pos) + } t := float64((pos - rlo) / (rhi - rlo)) return lo + t*(hi-lo) } @@ -239,11 +253,16 @@ func (l *linear) SetLocale(loc *Locale) { l.loc = loc } func (l *linear) Ticks(req TickRequest) []Tick { want := req.Want lo, hi := l.effective() + rlo, rhi := l.device() + b, broke := l.cuts.resolve(l.gaps, lo, hi, rlo, rhi) var vals []float64 step := 0.0 - if len(l.ticks) > 0 { + switch { + case len(l.ticks) > 0: vals, step = l.ticks, closestSpacing(l.ticks) - } else { + case broke: + vals, step = brokenValues(&b, want) + default: lab := extendedWilkinson(lo, hi, want, false) vals, step = lab.values(), lab.step } @@ -256,6 +275,9 @@ func (l *linear) Ticks(req TickRequest) []Tick { if v < lo-1e-9*math.Abs(hi-lo) || v > hi+1e-9*math.Abs(hi-lo) { continue } + if broke && b.inside(v) { + continue + } out = append(out, Tick{Value: v, Pos: l.Map(v), Label: fmtFn(v)}) } return out diff --git a/scale/time.go b/scale/time.go index 801c313..2bf8477 100644 --- a/scale/time.go +++ b/scale/time.go @@ -59,9 +59,20 @@ func Time(opts ...TimeOption) Scale { for _, o := range opts { o(t) } + t.resolveSpans() return t } +// resolveSpans turns the spans into cuts in the scale's own domain space. It +// runs after every option, because [Origin] decides what a domain value means +// and may have been given after a fold. +func (s *timeScale) resolveSpans() { + for _, sp := range s.spans { + s.cuts = s.cuts.with(sp.fold, Interval{s.Value(sp.span.From), s.Value(sp.span.To)}) + } + s.spans = nil +} + // Nanos converts a time to the float64 domain value a time scale uses. func Nanos(t time.Time) float64 { return float64(t.UnixNano()) } @@ -118,6 +129,14 @@ type timeScale struct { // language the names in it are written in. See [TimeLayout] and [Locale]. layout string locale *Locale + + // spans are the stretches [TimeBreak] and [TimeFold] leave out, as given; + // cuts are the same stretches in domain space, resolved once every option + // has run and the origin is known. gaps is how wide the renderer said a + // cut is. See [Breaker]. + spans []timeCut + cuts cuts + gaps gaps } // SetLocale implements [Localizer]. @@ -147,6 +166,9 @@ func (s *timeScale) Map(v float64) float32 { if hi == lo { return rlo } + if b, ok := s.cuts.resolve(s.gaps, lo, hi, rlo, rhi); ok { + return b.mapTo(v) + } switch v { case lo: return rlo @@ -163,6 +185,9 @@ func (s *timeScale) Map64(v float64) float64 { if hi == lo { return float64(rlo) } + if b, ok := s.cuts.resolve(s.gaps, lo, hi, rlo, rhi); ok { + return b.map64(v) + } return place64(rlo, rhi, (v-lo)/(hi-lo)) } @@ -172,6 +197,9 @@ func (s *timeScale) Invert(pos float32) float64 { if rhi == rlo { return lo } + if b, ok := s.cuts.resolve(s.gaps, lo, hi, rlo, rhi); ok { + return b.invert(pos) + } return lo + float64((pos-rlo)/(rhi-rlo))*(hi-lo) } @@ -229,6 +257,11 @@ func (s *timeScale) Ticks(req TickRequest) []Tick { return []Tick{{Value: lo, Pos: s.Map(lo), Label: s.label(start, timeUnits[3])}} } + rlo, rhi := s.rangeOf() + if b, ok := s.cuts.resolve(s.gaps, lo, hi, rlo, rhi); ok { + return s.brokenTicks(&b, want) + } + u := s.pick(span, want) times := s.walk(start, end, u) @@ -243,6 +276,23 @@ func (s *timeScale) Ticks(req TickRequest) []Tick { return out } +// brokenTicks is [brokenValues] for time: one unit, chosen for the time the +// 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) + 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) { + v := s.Value(t) + if v < a || v > z { + continue + } + out = append(out, Tick{Value: v, Pos: s.Map(v), Label: s.label(t, u)}) + } + }) + return out +} + // 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 { diff --git a/spec/breaks_test.go b/spec/breaks_test.go new file mode 100644 index 0000000..0f9f796 --- /dev/null +++ b/spec/breaks_test.go @@ -0,0 +1,74 @@ +package spec_test + +import ( + "strings" + "testing" + "time" + + "github.com/timzifer/figure/data" + "github.com/timzifer/figure/geom" + "github.com/timzifer/figure/scale" + "github.com/timzifer/figure/spec" + "github.com/timzifer/figure/theme" +) + +// Axis breaks and folds through the document. The words are `"cuts"` and +// `"folds"`, because `"breaks"` is already a threshold colour scale's class +// boundaries. See docs/adr/0083-an-axis-break-is-marked-or-not-drawn.md. + +func brokenChart() spec.Chart { + src := data.NewTable(). + String("site", []string{"a", "b", "c"}). + Float64("load", []float64{4, 96, 6}) + return spec.Chart{ + Width: 400, Height: 300, DPR: 1, Theme: theme.Light, + X: scale.Ordinal(), + Y: scale.Linear(scale.Domain(0, 100), scale.Break(10, 88), + scale.Fold(scale.Interval{Lo: 92, Hi: 94})), + Layers: []geom.Geom{geom.Bar(src, geom.X("site"), geom.Y("load"))}, + } +} + +func foldedTimeChart() spec.Chart { + t0 := time.Date(2026, 3, 2, 6, 0, 0, 0, time.UTC) + h := func(n int) time.Time { return t0.Add(time.Duration(n) * time.Hour) } + src := data.NewTable(). + Time("t", []time.Time{h(0), h(1), h(4), h(5), h(8)}). + Float64("v", []float64{1, 2, 3, 2, 1}) + return spec.Chart{ + Width: 400, Height: 300, DPR: 1, Theme: theme.Light, + X: scale.Time(scale.TimeFold(scale.TimeSpan{From: h(1), To: h(4)}), scale.Origin(t0)), + Y: scale.Linear(scale.Nice()), + Layers: []geom.Geom{geom.Line(src, geom.X("t"), geom.Y("v"))}, + } +} + +func TestABrokenAxisSurvivesTheRoundTrip(t *testing.T) { + for name, c := range map[string]spec.Chart{"linear": brokenChart(), "time": foldedTimeChart()} { + 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("%s: a broken axis did not survive the round trip\n%s", name, b) + } + } +} + +func TestABrokenAxisIsWrittenAsCutsAndFolds(t *testing.T) { + s, err := spec.Of(brokenChart()) + if err != nil { + t.Fatalf("Of: %v", err) + } + b, err := s.Marshal() + if err != nil { + t.Fatalf("Marshal: %v", err) + } + for _, w := range []string{`"cuts"`, `"folds"`} { + if !strings.Contains(string(b), w) { + t.Errorf("the document does not carry %s:\n%s", w, b) + } + } + if strings.Contains(string(b), `"breaks"`) { + t.Errorf("an axis break was written as colour classes:\n%s", b) + } +} diff --git a/spec/decode.go b/spec/decode.go index bc86fc1..a7fa745 100644 --- a/spec/decode.go +++ b/spec/decode.go @@ -196,6 +196,13 @@ func decodeScale(s Scale, channelType string) (scale.Desc, error) { // and an absent type is already "linear" by the time it gets here. d.Reverse = s.Reverse } + var err error + if d.Cuts, err = decodeCuts(s.Cuts, d.Origin); err != nil { + return scale.Desc{}, err + } + if d.Folds, err = decodeCuts(s.Folds, d.Origin); err != nil { + return scale.Desc{}, err + } if len(s.Domain) == 2 { lo, err := domainValue(s.Domain[0], d.Origin) if err != nil { @@ -212,6 +219,27 @@ func decodeScale(s Scale, channelType string) (scale.Desc, error) { return d, nil } +// decodeCuts reads a scale's breaks or folds: pairs of bounds, each read the +// way a domain bound is. +func decodeCuts(cs [][]any, origin int64) ([]scale.Interval, error) { + var out []scale.Interval + for _, c := range cs { + if len(c) != 2 { + return nil, fmt.Errorf("an axis cut needs two bounds, got %d", len(c)) + } + lo, err := domainValue(c[0], origin) + if err != nil { + return nil, err + } + hi, err := domainValue(c[1], origin) + if err != nil { + return nil, err + } + out = append(out, scale.Interval{Lo: lo, Hi: hi}) + } + return out, nil +} + // domainValue reads one domain bound. A bound written as a timestamp is // measured from origin, which is what makes the numbers on a rebased time axis // mean the same thing after a round trip as before it — see [scale.Origin]. diff --git a/spec/encode.go b/spec/encode.go index 8b104ab..32a138f 100644 --- a/spec/encode.go +++ b/spec/encode.go @@ -182,6 +182,8 @@ func encodeScale(d scale.Desc) *Scale { // travels under its own name. out.Type = string(d.Kind) } + out.Cuts = encodeCuts(d, d.Cuts) + out.Folds = encodeCuts(d, d.Folds) if d.Fixed { switch d.Kind { case scale.KindTime: @@ -201,6 +203,26 @@ func encodeScale(d scale.Desc) *Scale { return out } +// encodeCuts writes a scale's breaks or folds as pairs of bounds, spelled the +// way its domain is: instants on a time axis, for the reason the domain is. +func encodeCuts(d scale.Desc, ivs []scale.Interval) [][]any { + if len(ivs) == 0 { + return nil + } + out := make([][]any, len(ivs)) + for i, iv := range ivs { + if d.Kind == scale.KindTime { + out[i] = []any{ + time.Unix(0, d.Origin+int64(iv.Lo)).UTC().Format(timeLayout), + time.Unix(0, d.Origin+int64(iv.Hi)).UTC().Format(timeLayout), + } + continue + } + out[i] = []any{iv.Lo, iv.Hi} + } + return out +} + func encodeColorScale(cs scale.ColorScale) (*Scale, error) { d, ok := scale.DescribeColor(cs) if !ok { diff --git a/spec/spec.go b/spec/spec.go index 8756126..2eb65f9 100644 --- a/spec/spec.go +++ b/spec/spec.go @@ -666,6 +666,16 @@ type Scale struct { Range []string `json:"range,omitempty"` Reverse bool `json:"reverse,omitempty"` + // Cuts are the intervals a linear or time axis leaves out with a break, + // and Folds those it leaves out with a fold, each a pair of bounds + // written the way `domain` writes one — numbers, or timestamps on a time + // axis. Vega-Lite has no axis breaks, and `breaks` below is already a + // threshold colour scale's class boundaries, so the word is figure's own. + // A document holds the intervals, never the query a caller computed them + // with. See docs/adr/0083-an-axis-break-is-marked-or-not-drawn.md. + Cuts [][]any `json:"cuts,omitempty"` + Folds [][]any `json:"folds,omitempty"` + // Link is a probability axis's warp: "probit", "logit", "cloglog" or // "gumbel", and "probit" when absent. Vega-Lite has no probability scale, // so these are figure's names. A link written in Go has no name and diff --git a/testdata/golden/axis-break-slash.svg b/testdata/golden/axis-break-slash.svg new file mode 100644 index 0000000..eee26c2 --- /dev/null +++ b/testdata/golden/axis-break-slash.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + +A + +B + +C + +D + +E + +0 + +5 + +10 + +90 + +95 + +100 +load + + + + + \ No newline at end of file diff --git a/testdata/golden/axis-break-time.svg b/testdata/golden/axis-break-time.svg new file mode 100644 index 0000000..e72791e --- /dev/null +++ b/testdata/golden/axis-break-time.svg @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + +Jan 5 + +Jan 12 + +Feb 16 + +Feb 23 + +0 + +1 + +2 + +3 + +4 + +5 + +6 + + + + + \ No newline at end of file diff --git a/testdata/golden/axis-break-zigzag.svg b/testdata/golden/axis-break-zigzag.svg new file mode 100644 index 0000000..72fd49f --- /dev/null +++ b/testdata/golden/axis-break-zigzag.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + +A + +B + +C + +D + +E + +0 + +5 + +10 + +90 + +95 + +100 +load + + + + + \ No newline at end of file diff --git a/testdata/golden/axis-folds.svg b/testdata/golden/axis-folds.svg new file mode 100644 index 0000000..b929982 --- /dev/null +++ b/testdata/golden/axis-folds.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + +Mar 2 06:00 + +Mar 2 12:00 + +Mar 2 15:00 + +Mar 2 18:00 + +Aktiv + +Inaktiv + +Störung + + + + + + + +Aktiv + +Inaktiv + +Störung + \ No newline at end of file diff --git a/theme/theme.go b/theme/theme.go index 3e46614..d42b640 100644 --- a/theme/theme.go +++ b/theme/theme.go @@ -64,6 +64,17 @@ type Theme struct { TickCountHintX int TickCountHintY int + // Axis breaks and folds: how wide the gap is that an interval left out of + // an axis leaves behind, how large its mark is drawn, and which mark a + // break gets. A fold is always marked with a small slash. See + // [github.com/timzifer/figure/scale.Break] and + // docs/adr/0083-an-axis-break-is-marked-or-not-drawn.md. + AxisBreakGap float32 + AxisBreakSize float32 + AxisBreakMark BreakMark + AxisFoldGap float32 + AxisFoldSize float32 + // Legend. LegendSwatch float32 LegendPad float32 @@ -521,6 +532,44 @@ func TickCounts(x, y int) Option { } } +// BreakMark is how an axis break is marked. +type BreakMark uint8 + +const ( + // BreakSlash draws // across the axis line at the two edges of the gap. + BreakSlash BreakMark = iota + // BreakZigzag runs a zigzag across the panel along both edges of the gap. + BreakZigzag +) + +// AxisBreaks sets how an axis break is marked, how large its mark is and how +// wide a gap it leaves. A size or gap that is not positive keeps the theme's. +func AxisBreaks(mark BreakMark, size, gap float32) Option { + return func(t *Theme) { + t.AxisBreakMark = mark + if size > 0 { + t.AxisBreakSize = size + } + if gap > 0 { + t.AxisBreakGap = gap + } + } +} + +// AxisFolds sets how large a fold's mark is and how wide a gap it leaves. A +// gap of zero folds the axis without leaving any room, which is what an axis +// with hundreds of folds may want; a negative value keeps the theme's. +func AxisFolds(size, gap float32) Option { + return func(t *Theme) { + if size > 0 { + t.AxisFoldSize = size + } + if gap >= 0 { + t.AxisFoldGap = gap + } + } +} + // Density scales every spacing by f, tightening or loosening the whole chart // at once. Text sizes are left alone: a dense chart with unreadable labels is // not denser, it is worse. @@ -533,6 +582,8 @@ func Density(f float64) Option { t.TickLength *= s t.TickLabelPad *= s t.AxisTitlePad *= s + t.AxisBreakGap *= s + t.AxisFoldGap *= s t.LegendSwatch *= s t.LegendPad *= s t.LegendGap *= s @@ -583,6 +634,10 @@ func Scaled(f float64) Option { t.TickLength *= s t.TickLabelPad *= s t.AxisTitlePad *= s + t.AxisBreakGap *= s + t.AxisBreakSize *= s + t.AxisFoldGap *= s + t.AxisFoldSize *= s t.LegendSwatch *= s t.LegendPad *= s diff --git a/theme/theme_test.go b/theme/theme_test.go index 9bb6ea5..2dde8e0 100644 --- a/theme/theme_test.go +++ b/theme/theme_test.go @@ -313,3 +313,17 @@ func TestHatchSizeSetsEitherHalfAlone(t *testing.T) { t.Error("a negative size overwrote the theme's own") } } + +// A break's gap and mark are lengths like any other, so a chart drawn at half +// size draws them at half size too. +func TestScaledScalesTheAxisBreaks(t *testing.T) { + th := theme.Light.With(theme.Scaled(0.5)) + if th.AxisBreakGap != theme.Light.AxisBreakGap/2 || th.AxisBreakSize != theme.Light.AxisBreakSize/2 || + th.AxisFoldGap != theme.Light.AxisFoldGap/2 || th.AxisFoldSize != theme.Light.AxisFoldSize/2 { + t.Errorf("Scaled(0.5) left the axis breaks at %v/%v/%v/%v", + th.AxisBreakGap, th.AxisBreakSize, th.AxisFoldGap, th.AxisFoldSize) + } + if theme.Light.AxisBreakGap <= 0 || theme.Light.AxisFoldSize <= 0 { + t.Errorf("the default theme has no break gap or fold size") + } +} diff --git a/theme/tokens.go b/theme/tokens.go index e50f42e..a2d8980 100644 --- a/theme/tokens.go +++ b/theme/tokens.go @@ -116,6 +116,12 @@ func Build(t Tokens) Theme { TickCountHintX: 6, TickCountHintY: 5, + AxisBreakGap: 1.5 * u, + AxisBreakSize: 2 * u, + AxisBreakMark: BreakSlash, + AxisFoldGap: 0.5 * u, + AxisFoldSize: 1.5 * u, + LegendSwatch: 3 * u, LegendPad: 2 * u, LegendGap: 1.5 * u,