feat: initial release
Assisted-by: GLM 5.3 Flash
This commit is contained in:
+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"
|
||||
Reference in New Issue
Block a user