Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
25217e7
docs: §14 says what is built, and the roadmap it carried is drawn
timzifer Sep 23, 2026
e3b41af
docs: features.md names the marks, axes and labels it had left out
timzifer Sep 23, 2026
43299fc
docs: milestones.md tells the twenty-one records after the locus
timzifer Sep 23, 2026
6084ef9
docs: ADR 0084 proposes what v1.0 promises and how it is checked
timzifer Sep 23, 2026
c863072
docs: ADR 0084 refuses a method added to an interface, and names ColorAt
timzifer Sep 23, 2026
57b9b6a
docs: ADR 0084 checks against the published manifest, on every platform
timzifer Sep 23, 2026
83b9af7
docs: ADR 0084 records six changes before the freeze, from a third re…
timzifer Sep 23, 2026
489acb4
ir: a downward triangle marker, for the sell beside the buy
timzifer Sep 28, 2026
071fa47
stat: trailing windows, OHLC resampling and a weighted bin
timzifer Sep 28, 2026
01e7911
examples/market: candles, volume and a volume profile from existing m…
timzifer Sep 28, 2026
1a1837d
docs: ADR 0085 catalogues what a market chart needs
timzifer Sep 28, 2026
68f6909
docs: ADR 0086 proposes a trading calendar that produces folds
timzifer Sep 28, 2026
033b46a
geom: Orient lays a bar, a histogram and a boxplot on their side
timzifer Sep 28, 2026
166008c
docs: ADR 0087 records orientation; the market profile is a bar
timzifer Sep 28, 2026
0fd1cc4
docs: ADR 0086 makes the calendar general rather than a market's
timzifer Sep 28, 2026
c737a29
docs: ADR 0086 composes a calendar from rules about days
timzifer Sep 28, 2026
50499ed
scale: a Calendar says which stretches of each day count
timzifer Sep 28, 2026
56ad623
scale: weeks start where the calendar says, and a day tick leaves a fold
timzifer Sep 28, 2026
dae8164
stat: OHLCAt buckets between a calendar's edges
timzifer Sep 28, 2026
a1b9a72
docs: ADR 0086 is built; the market example folds from a calendar
timzifer Sep 28, 2026
9629c6d
docs: ADR 0088 proposes geom.Candle
timzifer Sep 28, 2026
631f5eb
stat, figure: a candle knows where its period ends; CandleTable
timzifer Sep 28, 2026
b2f1f61
geom: Candle reads four values and decides its own direction
timzifer Sep 28, 2026
abe1ee6
docs: ADR 0088 is built; the market example draws geom.Candle
timzifer Sep 28, 2026
8243624
docs: ADR 0089 proposes a fitted value axis and a revisable last row
timzifer Sep 28, 2026
4061598
scale: FitView fits a value axis to what the other axis shows
timzifer Sep 28, 2026
9dfc1d2
render, geom, live: a fitting axis trains on the rows in view
timzifer Sep 28, 2026
3ae1569
data, stat: ReplaceLast revises the open row; Resampler folds ticks
timzifer Sep 28, 2026
9147e8d
docs: ADR 0089 is built; examples/market gains a live mode
timzifer Sep 28, 2026
26a6ab8
docs: ADR 0090 proposes that an axis describes the frame it is drawn in
timzifer Sep 28, 2026
84b72b7
scale: Retrainer forgets a trained range; HighWater keeps one
timzifer Sep 28, 2026
71b694a
render: every render derives its trained ranges again
timzifer Sep 28, 2026
fb28755
docs: ADR 0090 is built; examples/stream's window now slides
timzifer Sep 28, 2026
2fbd411
docs: ADR 0091 proposes direction as a colour channel
timzifer Sep 28, 2026
bfa78dd
geom: DirectionBy colours any mark by which way its row went
timzifer Sep 28, 2026
424de98
docs: ADR 0091 is built; the market volume bars take DirectionBy
timzifer Sep 28, 2026
73f1dca
docs: ADR 0092 proposes axis tags, a snapping crosshair and RSI/VWAP
timzifer Sep 28, 2026
f874fb2
stat: RSI and VWAP, the indicators a composition gets wrong
timzifer Sep 29, 2026
0cf816e
scale: ValueLabelOf writes a value read off an axis
timzifer Sep 29, 2026
1bb03a6
render, geom: axis tags, LastValue and a snapping crosshair
timzifer Sep 29, 2026
4f875e6
docs: ADR 0092 is built; the market example tags its last price
timzifer Sep 29, 2026
0fca46a
docs: ADR 0093 proposes faceted tracks and relative series
timzifer Sep 29, 2026
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
226 changes: 94 additions & 132 deletions CONCEPT.md

Large diffs are not rendered by default.

31 changes: 31 additions & 0 deletions breaks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -210,3 +210,34 @@ func TestABrokenFacetDrawsTheSameInParallel(t *testing.T) {
t.Error("a broken facet drew differently on the parallel path")
}
}

// A fortnight of a session calendar, nights and weekend folded: the axis has
// its dates at each morning's open rather than none at all (ADR 0086, claim 5).
func TestGoldenCalendarFortnight(t *testing.T) {
cal := scale.Workweek(time.UTC,
scale.Weekdays(time.Monday, time.Tuesday, time.Wednesday, time.Thursday, time.Friday),
scale.Span{From: 9*time.Hour + 30*time.Minute, To: 16 * time.Hour})
from := time.Date(2026, 6, 1, 9, 30, 0, 0, time.UTC)
to := time.Date(2026, 6, 12, 16, 0, 0, 0, time.UTC)
folds, err := scale.Folds(cal, from, to)
if err != nil {
t.Fatal(err)
}
var ts []time.Time
var vs []float64
opens, err := scale.Opens(cal, from, to)
if err != nil {
t.Fatal(err)
}
for i, o := range opens {
for k := 0; k <= 13; k++ {
ts = append(ts, o.Add(time.Duration(k)*30*time.Minute))
vs = append(vs, float64(i)+float64(k%5)/4)
}
}
p := figure.New(figure.Size(420, 240))
p.X(scale.Time(scale.TimeCalendar(cal), scale.TimeFold(folds...)))
p.Y(scale.Linear(scale.Nice()))
p.Add(geom.Line(figure.NewTable().Time("t", ts).Float64("v", vs), geom.X("t"), geom.Y("v")))
golden(t, "calendar-fortnight", p)
}
36 changes: 36 additions & 0 deletions candles.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package figure

import (
"github.com/timzifer/figure/data"
"github.com/timzifer/figure/stat"
)

// CandleTable is candles as the table [github.com/timzifer/figure/geom.Candle]
// reads: one row per candle, with the columns start, end, open, high, low,
// close, volume and count.
//
// days := stat.OHLC(ts, ps, vs, scale.Nanos(first), float64(24*time.Hour))
// p.Add(geom.Candle(figure.CandleTable(days), geom.X("start"), geom.X2("end"),
// geom.OHLC("open", "high", "low", "close")))
//
// The columns are the float64 values the candles were computed in. On a time
// axis those are [github.com/timzifer/figure/scale.Nanos], which is what a
// time scale without an [github.com/timzifer/figure/scale.Origin] reads.
//
// It lives here for the reason [SpansWhere] does: stat does not know a table,
// and data does not know a candle.
func CandleTable(cs []stat.Candle) *data.Table {
n := len(cs)
start, end := make([]float64, n), make([]float64, n)
o, h, l, c := make([]float64, n), make([]float64, n), make([]float64, n), make([]float64, n)
vol, count := make([]float64, n), make([]float64, n)
for i, k := range cs {
start[i], end[i] = k.Start, k.End
o[i], h[i], l[i], c[i] = k.Open, k.High, k.Low, k.Close
vol[i], count[i] = k.Volume, float64(k.Count)
}
return NewTable().
Float64("start", start).Float64("end", end).
Float64("open", o).Float64("high", h).Float64("low", l).Float64("close", c).
Float64("volume", vol).Float64("count", count)
}
60 changes: 60 additions & 0 deletions candles_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package figure_test

import (
"math"
"testing"
"time"

"github.com/timzifer/figure"
"github.com/timzifer/figure/data"
"github.com/timzifer/figure/geom"
"github.com/timzifer/figure/scale"
"github.com/timzifer/figure/stat"
)

// fortnight is ten trading days of hourly prices, summarised into daily
// candles, with the weekend between them folded.
func fortnight(t *testing.T, opts ...geom.Option) *figure.Plot {
t.Helper()
first := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
cal := scale.Workweek(time.UTC, scale.Weekdays(time.Monday, time.Tuesday, time.Wednesday, time.Thursday, time.Friday))
var ts, ps []float64
for d := range 12 {
day := first.AddDate(0, 0, d)
if len(cal.Open(nil, day)) == 0 {
continue
}
for h := range 8 {
x := float64(d*8 + h)
ts = append(ts, scale.Nanos(day.Add(time.Duration(9+h)*time.Hour)))
ps = append(ps, 100+8*math.Sin(x/9)+3*math.Sin(x*1.7))
}
}
days := stat.OHLC(ts, ps, nil, scale.Nanos(first), float64(24*time.Hour))
folds, err := scale.Folds(cal, first, first.AddDate(0, 0, 12))
if err != nil {
t.Fatal(err)
}
p := figure.New(figure.Size(520, 300))
p.X(scale.Time(scale.TimeCalendar(cal), scale.TimeFold(folds...)))
p.Y(scale.Linear(scale.Nice()))
p.Add(geom.Candle(figure.CandleTable(days), append([]geom.Option{
geom.X("start"), geom.X2("end"), geom.OHLC("open", "high", "low", "close"),
}, opts...)...))
return p
}

func TestCandleTableCarriesEveryField(t *testing.T) {
tb := figure.CandleTable([]stat.Candle{{Start: 1, End: 2, Open: 3, High: 4, Low: 5, Close: 6, Volume: 7, Count: 8}})
for col, want := range map[string]float64{"start": 1, "end": 2, "open": 3, "high": 4, "low": 5, "close": 6, "volume": 7, "count": 8} {
vs, ok := data.Float64Column(tb, col)
if !ok || len(vs) != 1 || vs[0] != want {
t.Errorf("column %s = %v, want [%v]", col, vs, want)
}
}
}

func TestGoldenCandles(t *testing.T) { golden(t, "candles", fortnight(t)) }
func TestGoldenCandleTicks(t *testing.T) {
golden(t, "candles-ticks", fortnight(t, geom.CandleStyle(geom.Ticks)))
}
38 changes: 38 additions & 0 deletions data/stream.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,44 @@ func (s *Stream) Append(vals ...float64) error {
return nil
}

// ErrEmptyStream reports a [Stream.ReplaceLast] on a stream with no rows.
var ErrEmptyStream = fmt.Errorf("figure/data: no row to replace")

// ReplaceLast overwrites the most recent row with new values, positional in
// the order [NewStream] was given. It is the one revision a stream admits,
// because it is the one a live source makes: the newest row — a candle whose
// period has not closed, a reading still settling — is the only one still
// being measured. A row further back is history, and a chart whose past
// changed between two frames is one whose reader cannot trust what the first
// frame showed. A producer that revises older rows owns a table, not a stream.
//
// It is safe to call from any goroutine, under the lock [Stream.Append]
// takes, and allocates nothing. The row count does not change, so a window is
// unaffected. The new values reach the renderer on the next
// [Stream.Snapshot], as an appended row would. See
// docs/adr/0089-a-value-axis-fits-what-its-time-axis-shows.md.
func (s *Stream) ReplaceLast(vals ...float64) error {
if len(vals) != len(s.names) {
return fmt.Errorf("%w: %d values for %d columns", ErrColumnCount, len(vals), len(s.names))
}
s.mu.Lock()
defer s.mu.Unlock()
if s.n == 0 {
return ErrEmptyStream
}
last := s.n - 1
if s.limit > 0 {
// While a window fills, head is n mod limit; once it wraps, head is
// the slot the next row goes in. Either way the last row is the slot
// before it.
last = (s.head - 1 + s.limit) % s.limit
}
for i, v := range vals {
s.ring[i][last] = v
}
return nil
}

// AppendTime is [Stream.Append] with the first column given as a timestamp,
// which is what the first column of a live chart almost always is. The
// timestamp becomes its Unix nanoseconds, the domain a
Expand Down
77 changes: 77 additions & 0 deletions data/stream_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -273,3 +273,80 @@ func TestTheStreamAndItsViewAgreeAboutColumns(t *testing.T) {
t.Errorf("the view holds %d rows", got)
}
}

// The newest row is revised in place, unbounded, while a window fills, and
// once it has wrapped — the three places the last row can be.
func TestReplaceLastRevisesTheNewestRow(t *testing.T) {
for _, tc := range []struct {
name string
window int
rows int
want []float64
}{
{"unbounded", 0, 3, []float64{0, 1, 99}},
{"filling", 5, 3, []float64{0, 1, 99}},
{"wrapped", 4, 10, []float64{6, 7, 8, 99}},
{"exactly full", 4, 4, []float64{0, 1, 2, 99}},
} {
t.Run(tc.name, func(t *testing.T) {
s := data.NewStream("i").Window(tc.window)
for i := range tc.rows {
s.Append(float64(i))
}
if err := s.ReplaceLast(99); err != nil {
t.Fatal(err)
}
if got := s.Len(); got != len(tc.want) {
t.Errorf("%d rows after a replace, want %d", got, len(tc.want))
}
s.Snapshot()
col, _ := data.Float64Column(s.Source(), "i")
if !equal(col, tc.want) {
t.Errorf("rows = %v, want %v", col, tc.want)
}
// And the next row is appended after it, not over it.
s.Append(100)
s.Snapshot()
col, _ = data.Float64Column(s.Source(), "i")
if col[len(col)-1] != 100 || col[len(col)-2] != 99 {
t.Errorf("after a further append: %v", col)
}
})
}
}

func TestReplaceLastRefusesAnEmptyStreamAndAWrongRow(t *testing.T) {
s := data.NewStream("a", "b")
if err := s.ReplaceLast(1, 2); err != data.ErrEmptyStream {
t.Errorf("err = %v, want ErrEmptyStream", err)
}
s.Append(1, 2)
if err := s.ReplaceLast(1); err == nil {
t.Error("a row of the wrong length was accepted")
}
}

// The producer may revise the open row while another goroutine appends; run
// under -race.
func TestReplaceLastIsSafeBesideAppend(t *testing.T) {
s := data.NewStream("y").Window(100)
s.Append(0)
var wg sync.WaitGroup
for g := range 4 {
wg.Add(1)
go func() {
defer wg.Done()
for i := range 200 {
if g%2 == 0 {
s.Append(float64(i))
} else {
s.ReplaceLast(float64(-i))
}
}
}()
}
wg.Wait()
if got := s.Len(); got != 100 {
t.Errorf("%d rows, want the window's 100", got)
}
}
61 changes: 61 additions & 0 deletions direction_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package figure_test

import (
"strings"
"testing"

"github.com/timzifer/figure"
"github.com/timzifer/figure/geom"
"github.com/timzifer/figure/ir"
"github.com/timzifer/figure/theme"
)

func candleDays(opts ...figure.Option) *figure.Plot {
src := figure.NewTable().
Float64("t", []float64{0, 1, 2}).
Float64("o", []float64{10, 12, 13}).
Float64("h", []float64{13, 13, 14}).
Float64("l", []float64{9, 10, 12}).
Float64("c", []float64{12, 11, 13}).
Float64("v", []float64{5, 6, 7})
p := figure.New(append([]figure.Option{figure.Size(500, 300)}, opts...)...)
p.Add(geom.Candle(src, geom.X("t"), geom.OHLC("o", "h", "l", "c")))
p.Track(figure.Bottom, figure.TrackSize(60)).
Add(geom.Bar(src, geom.X("t"), geom.Y("v"), geom.DirectionBy("o", "c")))
return p
}

// A volume track under candles lists the directions once: the legend merges
// entries by label, and both layers take the same default labels.
func TestAVolumeTrackAddsNothingToTheCandlesLegend(t *testing.T) {
svg := renderString(t, candleDays(figure.Legend(true)))
for _, label := range []string{">rising<", ">falling<"} {
if n := strings.Count(svg, label); n != 1 {
t.Errorf("%s appears %d times in the legend, want once", label, n)
}
}
}

// The direction's colours are the theme's, bound per render: a theme with
// other rising and falling colours paints the bars in them.
func TestADirectionTakesTheThemesColours(t *testing.T) {
th := theme.Light
th.Rising, th.Falling = ir.RGB(0x11, 0x22, 0x33), ir.RGB(0x44, 0x55, 0x66)
src := figure.NewTable().
Float64("t", []float64{0, 1}).Float64("o", []float64{1, 5}).
Float64("c", []float64{2, 4}).Float64("v", []float64{3, 3})
for _, tc := range []struct {
th theme.Theme
up, down string
}{
{th, "#112233", "#445566"},
{theme.Light, "#0072b2", "#d55e00"},
} {
p := figure.New(figure.Size(300, 200), figure.Theme(tc.th))
p.Add(geom.Bar(src, geom.X("t"), geom.Y("v"), geom.DirectionBy("o", "c")))
svg := strings.ToLower(renderString(t, p))
if !strings.Contains(svg, tc.up) || !strings.Contains(svg, tc.down) {
t.Errorf("the bars are not painted %s and %s", tc.up, tc.down)
}
}
}
3 changes: 2 additions & 1 deletion docs/adr/0053-tidy-tree-layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,8 @@ written no mark in `geom` had an orientation to borrow. `geom.Orient` is that
option, with `geom.Vertical` and `geom.Horizontal`, and it is named for the
library rather than for the tree so that the second mark to want it says it the
same way — bars and boxplots still have none, and this is where that answer
will land. `geom.Tree` reads it by laying the tree out once and hanging it on
will land. (They have it now: see
[ADR 0087](0087-an-orientation-is-the-encoding-read-a-quarter-turn-round.md).) `geom.Tree` reads it by laying the tree out once and hanging it on
the other pair of axes: `Value`, `Baseline` and `Branches` all mean what they
meant, and the ordinal breadth and the continuous height swap which axis they
are checked on. `examples/dendrogram` now draws the record's headline chart
Expand Down
Loading
Loading