Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/scripts/allocgate.awk
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 19 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down
40 changes: 40 additions & 0 deletions a11y/a11y.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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)
Expand Down
20 changes: 20 additions & 0 deletions a11y/a11y_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
32 changes: 32 additions & 0 deletions alloc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)) }
77 changes: 77 additions & 0 deletions backend/gg/cmd/gallery/axisbreak.go
Original file line number Diff line number Diff line change
@@ -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)),
))
},
}
}
2 changes: 1 addition & 1 deletion backend/gg/cmd/gallery/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading