feat: initial release
Assisted-by: GLM 5.3 Flash
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package plot
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"sourcedock.dev/petrbalvin/tensor/internal/core"
|
||||
)
|
||||
|
||||
// The dtype census for plot: the series surface, probed with Bool, the
|
||||
// narrow integers and the Int anchor against a float64 baseline
|
||||
// carrying exactly the widened probe values. Line reads both axes
|
||||
// through the widening accessors by design, so every numeric dtype
|
||||
// plots exactly what Int plots.
|
||||
|
||||
var ptDtypes = []core.Dtype{core.Bool, core.Int8, core.Uint8, core.Int16, core.Uint16, core.Int32, core.Uint32, core.Int}
|
||||
|
||||
type ptMaker func(vals []float64, shape ...int) *core.Array
|
||||
|
||||
func ptCast(dt core.Dtype, v float64) float64 {
|
||||
switch dt {
|
||||
case core.Bool:
|
||||
if v != 0 {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
case core.Int8:
|
||||
return float64(int8(int64(v)))
|
||||
case core.Uint8:
|
||||
return float64(uint8(int64(v)))
|
||||
case core.Int16:
|
||||
return float64(int16(int64(v)))
|
||||
case core.Uint16:
|
||||
return float64(uint16(int64(v)))
|
||||
case core.Int32:
|
||||
return float64(int32(int64(v)))
|
||||
case core.Uint32:
|
||||
return float64(uint32(int64(v)))
|
||||
case core.Int:
|
||||
return float64(int64(v))
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
func ptMakers(t *testing.T, dt core.Dtype) (probe, base ptMaker) {
|
||||
t.Helper()
|
||||
castOf := func(vals []float64) []float64 {
|
||||
out := make([]float64, len(vals))
|
||||
for i, v := range vals {
|
||||
out[i] = ptCast(dt, v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
probe = func(vals []float64, shape ...int) *core.Array {
|
||||
cast := castOf(vals)
|
||||
var a *core.Array
|
||||
var err error
|
||||
switch dt {
|
||||
case core.Bool:
|
||||
bs := make([]bool, len(cast))
|
||||
for i, v := range cast {
|
||||
bs[i] = v != 0
|
||||
}
|
||||
a, err = core.FromBools(bs, shape...)
|
||||
case core.Int8:
|
||||
vs := make([]int8, len(cast))
|
||||
for i, v := range cast {
|
||||
vs[i] = int8(int64(v))
|
||||
}
|
||||
a, err = core.FromInt8s(vs, shape...)
|
||||
case core.Uint8:
|
||||
vs := make([]uint8, len(cast))
|
||||
for i, v := range cast {
|
||||
vs[i] = uint8(int64(v))
|
||||
}
|
||||
a, err = core.FromUint8s(vs, shape...)
|
||||
case core.Int16:
|
||||
vs := make([]int16, len(cast))
|
||||
for i, v := range cast {
|
||||
vs[i] = int16(int64(v))
|
||||
}
|
||||
a, err = core.FromInt16s(vs, shape...)
|
||||
case core.Uint16:
|
||||
vs := make([]uint16, len(cast))
|
||||
for i, v := range cast {
|
||||
vs[i] = uint16(int64(v))
|
||||
}
|
||||
a, err = core.FromUint16s(vs, shape...)
|
||||
case core.Int32:
|
||||
vs := make([]int32, len(cast))
|
||||
for i, v := range cast {
|
||||
vs[i] = int32(int64(v))
|
||||
}
|
||||
a, err = core.FromInt32s(vs, shape...)
|
||||
case core.Uint32:
|
||||
vs := make([]uint32, len(cast))
|
||||
for i, v := range cast {
|
||||
vs[i] = uint32(int64(v))
|
||||
}
|
||||
a, err = core.FromUint32s(vs, shape...)
|
||||
case core.Int:
|
||||
vs := make([]int64, len(cast))
|
||||
for i, v := range cast {
|
||||
vs[i] = int64(v)
|
||||
}
|
||||
a, err = core.FromInts(vs, shape...)
|
||||
default:
|
||||
a, err = core.FromFloats(cast, shape...)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("probe maker (%s): %v", dt, err)
|
||||
}
|
||||
return a
|
||||
}
|
||||
base = func(vals []float64, shape ...int) *core.Array {
|
||||
a, err := core.FromFloats(castOf(vals), shape...)
|
||||
if err != nil {
|
||||
t.Fatalf("baseline maker: %v", err)
|
||||
}
|
||||
return a
|
||||
}
|
||||
return probe, base
|
||||
}
|
||||
|
||||
// TestDtypesCensusPlot pins Line on every probe dtype against the
|
||||
// float64 baseline of the same widened values: identical points, no
|
||||
// panic, and the existing non-finite refusal preserved.
|
||||
func TestDtypesCensusPlot(t *testing.T) {
|
||||
xs := []float64{0, 1, 2, 3, 4}
|
||||
ys := []float64{3, 1, 4, 1, 5}
|
||||
for _, dt := range ptDtypes {
|
||||
t.Run("Line/"+dt.String(), func(t *testing.T) {
|
||||
probe, base := ptMakers(t, dt)
|
||||
ps, perr := Line("s", probe(xs, 5), probe(ys, 5))
|
||||
bs, berr := Line("s", base(xs, 5), base(ys, 5))
|
||||
if berr != nil {
|
||||
t.Fatalf("Line float baseline: %v", berr)
|
||||
}
|
||||
if perr != nil {
|
||||
t.Fatalf("Line(%s): %v; the float baseline of the same values succeeded", dt, perr)
|
||||
}
|
||||
if ps.Name != bs.Name || len(ps.Points) != len(bs.Points) {
|
||||
t.Fatalf("Line(%s): series %q with %d points, want %q with %d", dt, ps.Name, len(ps.Points), bs.Name, len(bs.Points))
|
||||
}
|
||||
for i := range ps.Points {
|
||||
if ps.Points[i] != bs.Points[i] {
|
||||
t.Fatalf("Line(%s): point %d = %+v, want %+v", dt, i, ps.Points[i], bs.Points[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
// The standing refusals keep their wording for the new dtypes too.
|
||||
t.Run("Line refusals", func(t *testing.T) {
|
||||
x8, err := core.FromInt8s([]int8{0, 1, 2, 3, 4}, 5)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
y8, err := core.FromInt8s([]int8{3, 1, 4, 1}, 4)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Line("s", x8, y8); err == nil {
|
||||
t.Fatal("Line accepted a length mismatch")
|
||||
}
|
||||
empty, _ := core.FromInt8s(nil, 0)
|
||||
if _, err := Line("s", empty, empty); err == nil {
|
||||
t.Fatal("Line accepted empty arrays")
|
||||
}
|
||||
okX, _ := core.FromFloats([]float64{0, 1, 2}, 3)
|
||||
nanY, _ := core.FromFloats([]float64{1, math.NaN(), 1}, 3)
|
||||
if _, err := Line("s", okX, nanY); err == nil {
|
||||
t.Fatal("Line accepted a non-finite point")
|
||||
}
|
||||
})
|
||||
}
|
||||
+240
@@ -0,0 +1,240 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package plot draws the deterministic SVG line charts a scientific
|
||||
// paper needs: linear axes, five ticks each, one legend line per
|
||||
// series, and nothing else. The output is deterministic by contract:
|
||||
// the same chart always renders byte for byte the same file, so a
|
||||
// figure in a paper can be regenerated and compared exactly like any
|
||||
// other computed number. The package is small by intent; it draws the
|
||||
// figures, it does not stage a cinema.
|
||||
package plot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"sourcedock.dev/petrbalvin/tensor/internal/base"
|
||||
"sourcedock.dev/petrbalvin/tensor/internal/core"
|
||||
)
|
||||
|
||||
// Point is one data point in axis units.
|
||||
type Point struct {
|
||||
X, Y float64
|
||||
}
|
||||
|
||||
// Series is one named polyline.
|
||||
type Series struct {
|
||||
Name string
|
||||
Points []Point
|
||||
}
|
||||
|
||||
// Chart is a linear-axis line chart.
|
||||
type Chart struct {
|
||||
Title string
|
||||
XLabel string
|
||||
YLabel string
|
||||
Width int
|
||||
Height int
|
||||
Series []Series
|
||||
// XRange and YRange are optional; a zero, inverted or non-finite
|
||||
// span falls back to the data's own bounds.
|
||||
XRange [2]float64
|
||||
YRange [2]float64
|
||||
}
|
||||
|
||||
// Line returns a series joining the points (xs[i], ys[i]) of two
|
||||
// arrays. Both arrays must be rank 1, of equal, non-zero length, and
|
||||
// hold only finite numbers; the values are read through the promotion
|
||||
// ladder, so any numeric dtype is accepted.
|
||||
func Line(name string, xs, ys *core.Array) (Series, error) {
|
||||
const op = "Plot"
|
||||
if xs == nil || ys == nil {
|
||||
return Series{}, base.Errf("%s: a nil array cannot make a series", op)
|
||||
}
|
||||
if xs.NDim() != 1 {
|
||||
return Series{}, base.Errf("%s: the x values must be rank 1, got shape %s", op, base.ShapeText(xs.Shape()))
|
||||
}
|
||||
if ys.NDim() != 1 {
|
||||
return Series{}, base.Errf("%s: the y values must be rank 1, got shape %s", op, base.ShapeText(ys.Shape()))
|
||||
}
|
||||
if xs.Len() != ys.Len() {
|
||||
return Series{}, base.Errf("%s: length mismatch, %d points of x against %d points of y",
|
||||
op, xs.Len(), ys.Len())
|
||||
}
|
||||
if xs.Len() == 0 {
|
||||
return Series{}, base.Errf("%s: an empty array cannot make a series", op)
|
||||
}
|
||||
pts := make([]Point, xs.Len())
|
||||
for i := range pts {
|
||||
x, y := xs.FloatAt(i), ys.FloatAt(i)
|
||||
if math.IsNaN(x) || math.IsInf(x, 0) || math.IsNaN(y) || math.IsInf(y, 0) {
|
||||
return Series{}, base.Errf("%s: non-finite point at index %d: (%g, %g)", op, i, x, y)
|
||||
}
|
||||
pts[i] = Point{X: x, Y: y}
|
||||
}
|
||||
return Series{Name: name, Points: pts}, nil
|
||||
}
|
||||
|
||||
// WriteSVG renders the chart into path. The output is deterministic:
|
||||
// the same chart always renders byte for byte the same file. Every
|
||||
// point of every series must be finite, the contract the Line
|
||||
// constructor enforces on the caller's behalf and this entry point
|
||||
// enforces for a series built by hand.
|
||||
func (c Chart) WriteSVG(path string) error {
|
||||
w := c.Width
|
||||
if w <= 0 {
|
||||
w = 720
|
||||
}
|
||||
h := c.Height
|
||||
if h <= 0 {
|
||||
h = 460
|
||||
}
|
||||
const (
|
||||
left = 64.0
|
||||
right = 16.0
|
||||
top = 40.0
|
||||
bottom = 52.0
|
||||
)
|
||||
all := make([]Point, 0, 256)
|
||||
for si, s := range c.Series {
|
||||
for pi, p := range s.Points {
|
||||
if math.IsNaN(p.X) || math.IsInf(p.X, 0) || math.IsNaN(p.Y) || math.IsInf(p.Y, 0) {
|
||||
return base.Errf("Plot: series %d (%s) holds the non-finite point %d: (%g, %g)", si, s.Name, pi, p.X, p.Y)
|
||||
}
|
||||
}
|
||||
all = append(all, s.Points...)
|
||||
}
|
||||
if len(all) < 2 {
|
||||
return base.Errf("Plot: the chart needs at least two points, has %d", len(all))
|
||||
}
|
||||
xr := c.XRange
|
||||
if !(xr[0] < xr[1]) || math.IsInf(xr[0], 0) || math.IsInf(xr[1], 0) {
|
||||
xr = bounds(all, true)
|
||||
}
|
||||
yr := c.YRange
|
||||
if !(yr[0] < yr[1]) || math.IsInf(yr[0], 0) || math.IsInf(yr[1], 0) {
|
||||
yr = bounds(all, false)
|
||||
}
|
||||
px := func(x float64) float64 {
|
||||
return left + (x-xr[0])/(xr[1]-xr[0])*(float64(w)-left-right)
|
||||
}
|
||||
py := func(y float64) float64 {
|
||||
return float64(h) - bottom - (y-yr[0])/(yr[1]-yr[0])*(float64(h)-top-bottom)
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString(xmlHeader)
|
||||
fmt.Fprintf(&b, "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"%d\" height=\"%d\" viewBox=\"0 0 %d %d\">\n", w, h, w, h)
|
||||
fmt.Fprintf(&b, "<rect width=\"%d\" height=\"%d\" fill=\"white\"/>\n", w, h)
|
||||
fmt.Fprintf(&b, "<text x=\"%g\" y=\"24\" font-family=\"sans-serif\" font-size=\"15\" fill=\"#111\">%s</text>\n",
|
||||
left, esc(c.Title))
|
||||
// Axes with five ticks each.
|
||||
for k := range 5 {
|
||||
t := xr[0] + (xr[1]-xr[0])*float64(k)/4
|
||||
x := px(t)
|
||||
fmt.Fprintf(&b, "<line x1=\"%g\" y1=\"%g\" x2=\"%g\" y2=\"%g\" stroke=\"#ccc\" stroke-width=\"1\"/>\n",
|
||||
x, top, x, float64(h)-bottom)
|
||||
fmt.Fprintf(&b, "<text x=\"%g\" y=\"%g\" font-family=\"sans-serif\" font-size=\"11\" fill=\"#333\" text-anchor=\"middle\">%s</text>\n",
|
||||
x, float64(h)-bottom+16, tick(t))
|
||||
}
|
||||
for k := range 5 {
|
||||
t := yr[0] + (yr[1]-yr[0])*float64(k)/4
|
||||
y := py(t)
|
||||
fmt.Fprintf(&b, "<line x1=\"%g\" y1=\"%g\" x2=\"%g\" y2=\"%g\" stroke=\"#ccc\" stroke-width=\"1\"/>\n",
|
||||
left, y, float64(w)-right, y)
|
||||
fmt.Fprintf(&b, "<text x=\"%g\" y=\"%g\" font-family=\"sans-serif\" font-size=\"11\" fill=\"#333\" text-anchor=\"end\">%s</text>\n",
|
||||
left-6, y+4, tick(t))
|
||||
}
|
||||
fmt.Fprintf(&b, "<line x1=\"%g\" y1=\"%g\" x2=\"%g\" y2=\"%g\" stroke=\"#111\" stroke-width=\"1\"/>\n",
|
||||
left, float64(h)-bottom, float64(w)-right, float64(h)-bottom)
|
||||
fmt.Fprintf(&b, "<line x1=\"%g\" y1=\"%g\" x2=\"%g\" y2=\"%g\" stroke=\"#111\" stroke-width=\"1\"/>\n",
|
||||
left, top, left, float64(h)-bottom)
|
||||
fmt.Fprintf(&b, "<text x=\"%g\" y=\"%g\" font-family=\"sans-serif\" font-size=\"12\" fill=\"#111\" text-anchor=\"middle\">%s</text>\n",
|
||||
(left+float64(w)-right)/2, float64(h)-12, esc(c.XLabel))
|
||||
fmt.Fprintf(&b, "<text x=\"16\" y=\"%g\" font-family=\"sans-serif\" font-size=\"12\" fill=\"#111\">%s</text>\n",
|
||||
top-12, esc(c.YLabel))
|
||||
for i, s := range c.Series {
|
||||
colour := colour(i)
|
||||
fmt.Fprintf(&b, "<polyline fill=\"none\" stroke=\"%s\" stroke-width=\"1.8\" points=\"", colour)
|
||||
for j, p := range s.Points {
|
||||
if j > 0 {
|
||||
b.WriteByte(' ')
|
||||
}
|
||||
fmt.Fprintf(&b, "%.2f,%.2f", px(p.X), py(p.Y))
|
||||
}
|
||||
b.WriteString("\"/>\n")
|
||||
ly := top + 16 + float64(i)*16
|
||||
fmt.Fprintf(&b, "<line x1=\"%g\" y1=\"%g\" x2=\"%g\" y2=\"%g\" stroke=\"%s\" stroke-width=\"1.8\"/>\n",
|
||||
float64(w)-230, ly, float64(w)-214, ly, colour)
|
||||
fmt.Fprintf(&b, "<text x=\"%g\" y=\"%g\" font-family=\"sans-serif\" font-size=\"11\" fill=\"#111\">%s</text>\n",
|
||||
float64(w)-208, ly+4, esc(s.Name))
|
||||
}
|
||||
b.WriteString("</svg>\n")
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return base.Errf("Plot: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(b.String()), 0o644); err != nil {
|
||||
return base.Errf("Plot: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func bounds(pts []Point, xAxis bool) [2]float64 {
|
||||
lo, hi := math.Inf(1), math.Inf(-1)
|
||||
for _, p := range pts {
|
||||
v := p.Y
|
||||
if xAxis {
|
||||
v = p.X
|
||||
}
|
||||
lo = math.Min(lo, v)
|
||||
hi = math.Max(hi, v)
|
||||
}
|
||||
if hi == lo {
|
||||
hi = lo + 1
|
||||
}
|
||||
pad := 0.05 * (hi - lo)
|
||||
return [2]float64{lo - pad, hi + pad}
|
||||
}
|
||||
|
||||
func tick(v float64) string {
|
||||
if v == math.Trunc(v) && math.Abs(v) < 1e15 {
|
||||
return fmt.Sprintf("%d", int64(v))
|
||||
}
|
||||
return fmt.Sprintf("%g", v)
|
||||
}
|
||||
|
||||
func esc(s string) string {
|
||||
r := strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """)
|
||||
return r.Replace(s)
|
||||
}
|
||||
|
||||
// seriesColours is the chart's fixed colour cycle: seven even samples
|
||||
// of the Viridis perceptual-uniform map (Nathaniel J. Smith, Stéfan
|
||||
// van der Walt and Eric Firing, released under CC0), read from the
|
||||
// map's 256-entry table at t = 0, 7/60, ..., 0.7 by linear
|
||||
// interpolation, each channel rounded to the nearest byte. The map's
|
||||
// light tail is left out on purpose: the
|
||||
// chart paints on white, and the pale yellows the full range ends in
|
||||
// drop far below a legible contrast at stroke width, while the
|
||||
// sampled range runs dark violet through blue and teal to green with
|
||||
// every stroke legible. The cycle is a constant, so the same chart
|
||||
// renders the same colours byte for byte, like everything else it
|
||||
// draws.
|
||||
var seriesColours = [7]string{
|
||||
"#440154",
|
||||
"#482a79",
|
||||
"#3d4d8a",
|
||||
"#2f6c8e",
|
||||
"#23888e",
|
||||
"#20a486",
|
||||
"#43bf71",
|
||||
}
|
||||
|
||||
func colour(i int) string {
|
||||
return seriesColours[i%len(seriesColours)]
|
||||
}
|
||||
|
||||
const xmlHeader = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
|
||||
@@ -0,0 +1,184 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package plot
|
||||
|
||||
import (
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sourcedock.dev/petrbalvin/tensor/internal/core"
|
||||
)
|
||||
|
||||
func sampleChart() Chart {
|
||||
pts1 := make([]Point, 0, 50)
|
||||
pts2 := make([]Point, 0, 50)
|
||||
for i := range 50 {
|
||||
x := float64(i) / 49
|
||||
pts1 = append(pts1, Point{X: x, Y: x * x})
|
||||
pts2 = append(pts2, Point{X: x, Y: math.Sqrt(x)})
|
||||
}
|
||||
return Chart{
|
||||
Title: "Test <chart>",
|
||||
XLabel: "x", YLabel: "y",
|
||||
Series: []Series{{Name: "quadratic", Points: pts1}, {Name: "sqrt", Points: pts2}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteSVG(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "out.svg")
|
||||
if err := sampleChart().WriteSVG(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(body)
|
||||
if !strings.HasPrefix(s, "<?xml") {
|
||||
t.Fatal("the SVG lacks the XML declaration")
|
||||
}
|
||||
if got := strings.Count(s, "<polyline"); got != 2 {
|
||||
t.Fatalf("%d polylines, want 2", got)
|
||||
}
|
||||
if strings.Count(s, "<") != 1 {
|
||||
t.Fatal("the title was not escaped")
|
||||
}
|
||||
// Determinism: the same chart renders byte for byte the same file.
|
||||
path2 := filepath.Join(dir, "out2.svg")
|
||||
if err := sampleChart().WriteSVG(path2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again, err := os.ReadFile(path2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(body) != string(again) {
|
||||
t.Fatal("the rendering is not deterministic")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteSVGErrors(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
one := Chart{Series: []Series{{Name: "only", Points: []Point{{X: 0, Y: 0}, {X: 1, Y: 1}}}}}
|
||||
one.Series[0].Points = one.Series[0].Points[:1]
|
||||
if err := one.WriteSVG(filepath.Join(dir, "bad.svg")); err == nil {
|
||||
t.Fatal("a single point must fail")
|
||||
}
|
||||
empty := Chart{Series: []Series{{Name: "none"}}}
|
||||
if err := empty.WriteSVG(filepath.Join(dir, "bad.svg")); err == nil {
|
||||
t.Fatal("an empty series must fail")
|
||||
}
|
||||
// A series built by hand is the caller's own: the constructor's
|
||||
// non-finite refusal still has to hold at the rendering door, or
|
||||
// the deterministic contract would publish a polyline carrying the
|
||||
// literal NaN no renderer draws.
|
||||
for _, pt := range []Point{{X: math.NaN(), Y: 0}, {X: 0, Y: math.Inf(1)}, {X: math.Inf(-1), Y: 1}} {
|
||||
nan := Chart{Series: []Series{{Name: "broken", Points: []Point{{X: 0, Y: 0}, pt, {X: 2, Y: 1}}}}}
|
||||
if err := nan.WriteSVG(filepath.Join(dir, "bad.svg")); err == nil {
|
||||
t.Fatalf("the non-finite point %+v was rendered", pt)
|
||||
}
|
||||
}
|
||||
// An axis range with an infinite or unordered span falls back to
|
||||
// the data's own bounds instead of mapping every point through a
|
||||
// division the format cannot evaluate.
|
||||
for _, r := range [][2]float64{{0, math.Inf(1)}, {math.Inf(-1), 0}, {2, 1}, {math.NaN(), 1}} {
|
||||
c := sampleChart()
|
||||
c.YRange = r
|
||||
path := filepath.Join(dir, "range.svg")
|
||||
if err := c.WriteSVG(path); err != nil {
|
||||
t.Fatalf("the range %v was refused instead of falling back: %v", r, err)
|
||||
}
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s := string(body); strings.Contains(s, "NaN") || strings.Contains(s, "Inf") {
|
||||
t.Fatalf("the range %v leaked a non-finite coordinate into the rendering", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mustFromFloats(t *testing.T, values []float64, shape ...int) *core.Array {
|
||||
t.Helper()
|
||||
a, err := core.FromFloats(values, shape...)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func TestLine(t *testing.T) {
|
||||
xs := mustFromFloats(t, []float64{0, 1, 2, 3}, 4)
|
||||
ys := mustFromFloats(t, []float64{0, 1, 4, 9}, 4)
|
||||
s, err := Line("quadratic", xs, ys)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Name != "quadratic" || len(s.Points) != 4 || s.Points[3] != (Point{X: 3, Y: 9}) {
|
||||
t.Fatalf("the series is wrong: %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLineValidation(t *testing.T) {
|
||||
xs := mustFromFloats(t, []float64{0, 1, 2}, 3)
|
||||
ys := mustFromFloats(t, []float64{0, 1, 4}, 3)
|
||||
if _, err := Line("mismatch", xs, mustFromFloats(t, []float64{0, 1}, 2)); err == nil {
|
||||
t.Fatal("a length mismatch must fail")
|
||||
}
|
||||
if _, err := Line("matrix", mustFromFloats(t, []float64{1, 2, 3, 4}, 2, 2), ys); err == nil {
|
||||
t.Fatal("a rank-2 array must fail")
|
||||
}
|
||||
if _, err := Line("empty", mustFromFloats(t, nil, 0), mustFromFloats(t, nil, 0)); err == nil {
|
||||
t.Fatal("an empty array must fail")
|
||||
}
|
||||
if _, err := Line("nil", nil, ys); err == nil {
|
||||
t.Fatal("a nil array must fail")
|
||||
}
|
||||
if _, err := Line("nan", xs, mustFromFloats(t, []float64{0, 1, math.NaN()}, 3)); err == nil {
|
||||
t.Fatal("a non-finite point must fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTick(t *testing.T) {
|
||||
if tick(2) != "2" {
|
||||
t.Fatalf("tick(2) = %q", tick(2))
|
||||
}
|
||||
if tick(0.25) != "0.25" {
|
||||
t.Fatalf("tick(0.25) = %q", tick(0.25))
|
||||
}
|
||||
if tick(1e-7) != "1e-07" {
|
||||
t.Fatalf("tick(1e-7) = %q", tick(1e-7))
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeriesColourCycle pins the colour cycle element for element: the
|
||||
// seven Viridis samples are part of the deterministic output, so an
|
||||
// accidental edit must fall over here, and the cycle must wrap at the
|
||||
// palette's own length.
|
||||
func TestSeriesColourCycle(t *testing.T) {
|
||||
want := [7]string{
|
||||
"#440154",
|
||||
"#482a79",
|
||||
"#3d4d8a",
|
||||
"#2f6c8e",
|
||||
"#23888e",
|
||||
"#20a486",
|
||||
"#43bf71",
|
||||
}
|
||||
if seriesColours != want {
|
||||
t.Fatalf("series colours = %v, want %v", seriesColours, want)
|
||||
}
|
||||
for i := range len(seriesColours) {
|
||||
if colour(i) != seriesColours[i] {
|
||||
t.Fatalf("colour(%d) = %s, want %s", i, colour(i), seriesColours[i])
|
||||
}
|
||||
if colour(i+len(seriesColours)) != seriesColours[i] {
|
||||
t.Fatalf("colour(%d) does not wrap onto colour(%d)", i+len(seriesColours), i)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user