diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b6e661..f80784f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 of drawing a NaN population and returning a NaN point with no error. +**Signal and plots.** + +- `WriteSVG` keeps extreme but finite data and axis ranges drawable: + the padding, projection and tick arithmetic fall back to forms whose + terms stay in range, so the file never carries a NaN coordinate. + ## [1.0.0] - 2026-09-03 The initial release of Tensor, a scientific computing library in pure diff --git a/plot/plot.go b/plot/plot.go index 4602aee..85c4110 100644 --- a/plot/plot.go +++ b/plot/plot.go @@ -119,11 +119,37 @@ func (c Chart) WriteSVG(path string) error { if !(yr[0] < yr[1]) || math.IsInf(yr[0], 0) || math.IsInf(yr[1], 0) { yr = bounds(all, false) } + // project maps a value of the range [r0, r1] onto the plot span + // starting at plotLo. The normal form is the arithmetic the chart + // has always run; a span, or an offset from the range's start, that + // overflows float64 falls back to the halved form, whose every term + // a finite input keeps finite, so a range the data spans but the + // arithmetic cannot still draws instead of carrying a coordinate no + // renderer displays. + project := func(v, r0, r1, plotLo, plotSpan float64) float64 { + u := (v - r0) / (r1 - r0) + if math.IsNaN(u) || math.IsInf(u, 0) { + u = (v/2 - r0/2) / (r1/2 - r0/2) + } + return plotLo + u*plotSpan + } px := func(x float64) float64 { - return left + (x-xr[0])/(xr[1]-xr[0])*(float64(w)-left-right) + return project(x, xr[0], xr[1], left, float64(w)-left-right) } py := func(y float64) float64 { - return float64(h) - bottom - (y-yr[0])/(yr[1]-yr[0])*(float64(h)-top-bottom) + return float64(h) - bottom - project(y, yr[0], yr[1], 0, float64(h)-top-bottom) + } + // tickValue places the k-th of the five ticks. A span that overflows + // float64 makes the affine form NaN or Inf, so the tick falls back to + // the convex combination, which stays between the range's own finite + // ends. + tickValue := func(r0, r1 float64, k int) float64 { + t := r0 + (r1-r0)*float64(k)/4 + if math.IsNaN(t) || math.IsInf(t, 0) { + f := float64(k) / 4 + t = r0*(1-f) + r1*f + } + return t } var b strings.Builder b.WriteString(xmlHeader) @@ -133,7 +159,7 @@ func (c Chart) WriteSVG(path string) error { left, esc(c.Title)) // Axes with five ticks each. for k := range 5 { - t := xr[0] + (xr[1]-xr[0])*float64(k)/4 + t := tickValue(xr[0], xr[1], k) x := px(t) fmt.Fprintf(&b, "\n", x, top, x, float64(h)-bottom) @@ -141,7 +167,7 @@ func (c Chart) WriteSVG(path string) error { x, float64(h)-bottom+16, tick(t)) } for k := range 5 { - t := yr[0] + (yr[1]-yr[0])*float64(k)/4 + t := tickValue(yr[0], yr[1], k) y := py(t) fmt.Fprintf(&b, "\n", left, y, float64(w)-right, y) @@ -195,8 +221,14 @@ func bounds(pts []Point, xAxis bool) [2]float64 { if hi == lo { hi = lo + 1 } - pad := 0.05 * (hi - lo) - return [2]float64{lo - pad, hi + pad} + padLo, padHi := lo-0.05*(hi-lo), hi+0.05*(hi-lo) + if math.IsInf(padLo, 0) || math.IsInf(padHi, 0) { + // The padding, or the span it scales, overflows the range the + // finite data itself fits; the unpadded bounds keep every + // projection finite. + return [2]float64{lo, hi} + } + return [2]float64{padLo, padHi} } func tick(v float64) string { diff --git a/plot/plot_test.go b/plot/plot_test.go index 94c207a..8818c20 100644 --- a/plot/plot_test.go +++ b/plot/plot_test.go @@ -103,6 +103,39 @@ func TestWriteSVGErrors(t *testing.T) { } } +// TestWriteSVGExtremeFiniteValuesStayFinite pins the renderer against +// the extreme-but-finite corner: data, or an explicit axis range, whose +// span overflows float64 must not leak NaN or Inf coordinates into the +// drawing, because no renderer displays them and the non-finite +// refusal at the door guarantees every input point is finite. +func TestWriteSVGExtremeFiniteValuesStayFinite(t *testing.T) { + dir := t.TempDir() + padded := sampleChart() + padded.XRange = [2]float64{-1e308, 1e308} + padded.YRange = [2]float64{-1e308, 1e308} + charts := []struct { + name string + chart Chart + }{ + {"wide-data.svg", Chart{Series: []Series{{Name: "wide", + Points: []Point{{X: 0, Y: -1e308}, {X: 1, Y: 1e308}}}}}}, + {"wide-range.svg", padded}, + } + for _, tc := range charts { + path := filepath.Join(dir, tc.name) + if err := tc.chart.WriteSVG(path); err != nil { + t.Fatalf("%s: %v", tc.name, err) + } + body, err := os.ReadFile(path) + if err != nil { + t.Fatalf("%s: %v", tc.name, err) + } + if s := string(body); strings.Contains(s, "NaN") || strings.Contains(s, "Inf") { + t.Fatalf("%s: the rendering leaked a non-finite coordinate: %s", tc.name, s) + } + } +} + func mustFromFloats(t *testing.T, values []float64, shape ...int) *core.Array { t.Helper() a, err := core.FromFloats(values, shape...)