// Copyright (c) 2026 Petr Balvín (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, "\n", w, h, w, h) fmt.Fprintf(&b, "\n", w, h) fmt.Fprintf(&b, "%s\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, "\n", x, top, x, float64(h)-bottom) fmt.Fprintf(&b, "%s\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, "\n", left, y, float64(w)-right, y) fmt.Fprintf(&b, "%s\n", left-6, y+4, tick(t)) } fmt.Fprintf(&b, "\n", left, float64(h)-bottom, float64(w)-right, float64(h)-bottom) fmt.Fprintf(&b, "\n", left, top, left, float64(h)-bottom) fmt.Fprintf(&b, "%s\n", (left+float64(w)-right)/2, float64(h)-12, esc(c.XLabel)) fmt.Fprintf(&b, "%s\n", top-12, esc(c.YLabel)) for i, s := range c.Series { colour := colour(i) fmt.Fprintf(&b, " 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, "\n", float64(w)-230, ly, float64(w)-214, ly, colour) fmt.Fprintf(&b, "%s\n", float64(w)-208, ly+4, esc(s.Name)) } b.WriteString("\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 = "\n"