Skip to content

Commit 9b50eb2

Browse files
divisio74leohhhn
andauthored
feat: add scatterplot svg library (#12)
* feat: add scatterplot svg library * [ADD]: README.md and increasing variables in ScatterPlot structure * [FIX]: type.gno * Feat: Better description of gnov * test CI PR * Fix: CI Checks fmt * Fix: Coding style * Fix: CI const -> type.gno * Fix: CI check type.gno * Fix: CI check scatterplot.gno * Fix: CI check scatterplot_test.gno * Add: godoc for niceStep function * Fix: Godoc format corrected * Name change and Godoc for RenderAxes * Fix: Name and README.md * Fix: Files name * Fix: Name of the package --------- Co-authored-by: Leon <[email protected]>
1 parent 0c23b1c commit 9b50eb2

File tree

6 files changed

+427
-0
lines changed

6 files changed

+427
-0
lines changed
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# GnoVisual
2+
3+
Package `gnovisual` renders scatter plots as SVG images. It takes a list of (x, y) points and draws them as circles on a 2D canvas, with optional support for displaying a linear regression line.
4+
5+
## Usage
6+
7+
```go
8+
import "gno.land/p/pierre115/gnovisual"
9+
10+
func main() {
11+
plot := scatterplot.ScatterPlot{
12+
Points: []scatterplot.Point{
13+
{X: 100, Y: 0, Label: "A"},
14+
{X: 101, Y: 20, Label: "B"},
15+
{X: 102, Y: 40, Label: "C"},
16+
{X: 103, Y: 60, Label: "D"},
17+
},
18+
Title: "Sales Growth",
19+
XAxis: "Years",
20+
YAxis: "Sales",
21+
FlagRe: true,
22+
Maxticks: 20,
23+
Width: 800,
24+
Height: 800,
25+
}
26+
27+
svg := plot.Render()
28+
// Use the SVG output
29+
}
30+
```
31+
32+
## API
33+
34+
### Point
35+
36+
```go
37+
type Point struct {
38+
X, Y float64 // Coordinates of the point
39+
Color string // Color of the point
40+
Label string // Label associated with the point
41+
}
42+
```
43+
44+
### ScatterPlot
45+
46+
```go
47+
type ScatterPlot struct {
48+
Points []Point // Data points to plot
49+
Title string // Plot title
50+
XAxis string // X-axis label
51+
YAxis string // Y-axis label
52+
FlagRe bool // Enable linear regression line (default: false)
53+
Maxticks int // Number of tick marks on axes
54+
Width int // Plot width in pixels
55+
Height int // Plot height in pixels
56+
}
57+
```
58+
59+
## Features
60+
61+
### Linear Regression
62+
63+
Set `FlagRe` to `true` to display a linear regression line fitted to your data points. The regression equation will be displayed in the top-left corner of the plot.
64+
65+
## Example
66+
67+
Here's a complete example showing sales growth over time:
68+
69+
```go
70+
plot := scatterplot.ScatterPlot{
71+
Points: []scatterplot.Point{
72+
{X: 2020, Y: 150, Label: "Q1"},
73+
{X: 2021, Y: 230, Label: "Q2"},
74+
{X: 2022, Y: 310, Label: "Q3"},
75+
{X: 2023, Y: 405, Label: "Q4"},
76+
},
77+
Title: "Quarterly Sales Performance",
78+
XAxis: "Year",
79+
YAxis: "Revenue (k$)",
80+
FlagRe: true,
81+
Maxticks: 10,
82+
Width: 600,
83+
Height: 400,
84+
}
85+
```
86+
87+
This will generate an SVG scatter plot with a regression line showing the trend in sales growth.
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
module = "gno.land/p/pierre115/gnovisual"
2+
gno = "0.9"
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package gnovisual
2+
3+
import (
4+
"math"
5+
6+
"gno.land/p/nt/ufmt"
7+
)
8+
9+
// Function to get slope and intercept with linear way
10+
func LinearRegression(points []Point) (slope, intercept float64) {
11+
n := float64(len(points))
12+
sumX := float64(0)
13+
sumY := float64(0)
14+
sumXY := float64(0)
15+
sumX2 := float64(0)
16+
17+
for _, p := range points {
18+
sumX += p.X
19+
sumY += p.Y
20+
sumXY += p.X * p.Y
21+
sumX2 += p.X * p.X
22+
}
23+
24+
slope = (n*sumXY - sumX*sumY) / (n*sumX2 - sumX*sumX)
25+
intercept = (sumY - slope*sumX) / n
26+
return
27+
}
28+
29+
// Calcul the regression line and return as SVG string
30+
func RenderReFlag(points []Point, minX, maxX, minY, maxY float64, canvasWidth, canvasHeight int) string {
31+
slope, intercept := LinearRegression(points)
32+
33+
xStart := minX
34+
xEnd := maxX
35+
yStart := slope*xStart + intercept
36+
yEnd := slope*xEnd + intercept
37+
38+
// Normalize with the canvas data
39+
nx1 := 40 + (xStart-minX)/(maxX-minX)*float64(canvasWidth-60)
40+
ny1 := float64(canvasHeight-40) - (yStart-minY)/(maxY-minY)*float64(canvasHeight-60)
41+
nx2 := 40 + (xEnd-minX)/(maxX-minX)*float64(canvasWidth-60)
42+
ny2 := float64(canvasHeight-40) - (yEnd-minY)/(maxY-minY)*float64(canvasHeight-60)
43+
44+
// calcul of angle
45+
dx := nx2 - nx1
46+
dy := ny2 - ny1
47+
angleRad := math.Atan2(dy, dx)
48+
angleDeg := angleRad * 180 / math.Pi
49+
50+
svgOut := ""
51+
52+
// SVG Rectangle as regression line
53+
svgOut += ufmt.Sprintf(
54+
`<rect x="%d" y="%d" width="%d" height="1" fill="black" transform="rotate(%.2f %d %d)"/>`,
55+
int(nx1), int(ny1),
56+
int(math.Hypot(nx2-nx1, ny2-ny1)),
57+
angleDeg, int(nx1), int(ny1),
58+
)
59+
60+
// Equation of the line
61+
equation := ufmt.Sprintf("y = %.2fx + %.2f", slope, intercept)
62+
svgOut += ufmt.Sprintf(
63+
`<text x="50" y="20" style="font-size:12px;font-family:'Inter var',sans-serif;" fill="black">Equation : %s</text>`,
64+
equation,
65+
)
66+
67+
return svgOut
68+
}
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
// Package gnovisual provides functionality to render a scatter plot as an SVG image.
2+
// It takes a list of points (x,y) and draws them as circles on a 2D .
3+
// You can also apply Flags.
4+
package gnovisual
5+
6+
import (
7+
"math"
8+
9+
"gno.land/p/nt/ufmt"
10+
)
11+
12+
const (
13+
DefaultWidth = 750
14+
DefaultHeight = 500
15+
DefaultMaxTicks = 10
16+
)
17+
18+
// Get max ticks for axis
19+
func (sp ScatterPlot) GetMaxTicks() int {
20+
if sp.maxTicks == 0 {
21+
return DefaultMaxTicks
22+
}
23+
return sp.maxTicks
24+
}
25+
26+
// Get width of the svg
27+
func (sp ScatterPlot) GetWidth() int {
28+
if sp.Width == 0 {
29+
return DefaultWidth
30+
}
31+
return sp.Width
32+
}
33+
34+
// Get height of the svg
35+
func (sp ScatterPlot) GetHeight() int {
36+
if sp.Height == 0 {
37+
return DefaultHeight
38+
}
39+
return sp.Height
40+
}
41+
42+
// NiceStep calculates a visually pleasing axis step size based on the data range and maximum number of ticks.
43+
func niceStep(sp ScatterPlot, rangeVal float64) float64 {
44+
var niceBase float64
45+
maxTicks := sp.GetMaxTicks()
46+
rawStep := rangeVal / float64(maxTicks)
47+
exponent := math.Floor(math.Log10(rawStep))
48+
fraction := rawStep / math.Pow(10, exponent)
49+
50+
switch {
51+
case fraction < 1.5:
52+
niceBase = 1
53+
case fraction < 3:
54+
niceBase = 2
55+
case fraction < 7:
56+
niceBase = 5
57+
default:
58+
niceBase = 10
59+
}
60+
61+
return niceBase * math.Pow(10, exponent)
62+
}
63+
64+
// StepXY use the ideal steps calculated by nicestep function for both axis
65+
func StepXY(sp ScatterPlot, maxX, maxY, minX, minY float64) (float64, float64, float64, float64, float64, float64) {
66+
67+
rangeX := maxX - minX
68+
stepX := niceStep(sp, rangeX)
69+
startX := math.Floor(minX/stepX) * stepX
70+
endX := math.Ceil(maxX/stepX) * stepX
71+
rangeY := maxY - minY
72+
stepY := niceStep(sp, rangeY)
73+
startY := math.Floor(minY/stepY) * stepY
74+
endY := math.Ceil(maxY/stepY) * stepY
75+
76+
return stepX, startX, endX, stepY, startY, endY
77+
}
78+
79+
// RenderAxes renders the chart axes and their corresponding titles, returning the result as SVG-formatted strings for display.
80+
func RenderAxes(sp ScatterPlot, Width, Height int, maxX, maxY, minX, minY float64) string {
81+
svgOut := ""
82+
83+
// Axe Y
84+
svgOut += ufmt.Sprintf(`<rect x="%d" y="%d" width="%d" height="%d" fill="black"/>`, 40, Height-40, Width-60, 1)
85+
svgOut += ufmt.Sprintf(
86+
`<text x="%d" y="%d" style="font-family:'Inter var',sans-serif;font-size:%dpx;text-anchor:middle;" transform="rotate(-90 %d %d)" fill="black">%s</text>`,
87+
10, Height/2, 12, 15, Height/2, sp.YAxis,
88+
)
89+
90+
// Axe X
91+
svgOut += ufmt.Sprintf(`<rect x="%d" y="%d" width="%d" height="%d" fill="black"/>`, 40, 20, 1, Height-60)
92+
svgOut += ufmt.Sprintf(
93+
`<text x="%d" y="%d" style="font-family:'Inter var',sans-serif;font-size:%dpx;text-anchor:middle;" fill="black">%s</text>`,
94+
Width/2, Height-10, 12, sp.XAxis,
95+
)
96+
97+
// Scale helpers
98+
scaleX := func(val float64) float64 {
99+
return 40 + (val-minX)/(maxX-minX)*float64(Width-60)
100+
}
101+
scaleY := func(val float64) float64 {
102+
return float64(Height-40) - (val-minY)/(maxY-minY)*float64(Height-60)
103+
}
104+
105+
// Nicesteps calcul for graduation
106+
stepX, startX, endX, stepY, startY, endY := StepXY(sp, maxX, maxY, minX, minY)
107+
108+
// Graduation X
109+
for val := startX; val <= endX; val += stepX {
110+
nx := scaleX(val)
111+
y := float64(Height - 40)
112+
svgOut += ufmt.Sprintf(`<rect x="%d" y="%d" width="%d" height="%d" fill="black"/>`, int(nx), int(y), 1, 5)
113+
svgOut += ufmt.Sprintf(`<text x="%d" y="%d" style="font-size:%dpx;text-anchor:middle;" fill="black">%.1f</text>`, int(nx), int(y)+18, 11, val)
114+
}
115+
116+
// Graduation Y
117+
for val := startY; val <= endY; val += stepY {
118+
ny := scaleY(val)
119+
x := float64(40)
120+
svgOut += ufmt.Sprintf(`<rect x="%d" y="%d" width="%d" height="%d" fill="black"/>`, int(x)-5, int(ny), 5, 1)
121+
svgOut += ufmt.Sprintf(`<text x="%d" y="%d" style="font-size:%dpx;text-anchor:end;" fill="black">%.1f</text>`, int(x)-8, int(ny)+4, 11, val)
122+
}
123+
124+
return svgOut
125+
}
126+
127+
// Returns an img svg markup as a string, including a markdown header if a non-empty title is provided.
128+
// The function need a Point struct, two axes titles and a flag.
129+
// You can see existings flags in the Readme.md
130+
func (sp ScatterPlot) String() string {
131+
132+
const (
133+
pointRadius = 2
134+
)
135+
136+
Width := sp.GetWidth()
137+
Height := sp.GetHeight()
138+
139+
if len(sp.Points) == 0 {
140+
return "\nscatterplot fails: no data provided"
141+
}
142+
143+
// calcul min/max
144+
minX, minY := sp.Points[0].X, sp.Points[0].Y
145+
maxX, maxY := sp.Points[0].X, sp.Points[0].Y
146+
147+
for _, p := range sp.Points {
148+
if p.X > maxX {
149+
maxX = p.X
150+
}
151+
if p.X < minX {
152+
minX = p.X
153+
}
154+
if p.Y > maxY {
155+
maxY = p.Y
156+
}
157+
if p.Y < minY {
158+
minY = p.Y
159+
}
160+
}
161+
162+
svgOut := ""
163+
svgOut += RenderAxes(sp, Width, Height, maxX, maxY, minX, minY)
164+
165+
// Draw Points and labels
166+
for _, p := range sp.Points {
167+
nx := 40 + (p.X-minX)/(maxX-minX)*float64(Width-60)
168+
ny := float64(Height-40) - (p.Y-minY)/(maxY-minY)*float64(Height-60)
169+
svgOut += ufmt.Sprintf(`<circle cx="%d" cy="%d" r="%d" fill="%s"/>`, int(nx), int(ny), pointRadius, p.Color)
170+
if p.Label != "" {
171+
svgOut += ufmt.Sprintf(
172+
`<text x="%d" y="%d" style="font-family:'Inter var',sans-serif;font-size:10px;text-anchor:middle;" fill="#333">%s</text>`,
173+
int(nx)+5, int(ny)+12, p.Label,
174+
)
175+
}
176+
}
177+
178+
// Flags :
179+
if sp.FlagRe == true {
180+
svgOut += RenderReFlag(sp.Points, minX, maxX, minY, maxY, Width, Height)
181+
}
182+
183+
// Draw Title
184+
if sp.Title != "" {
185+
svgOut += ufmt.Sprintf(`<text x="%d" y="%d" style="font-family:'Inter var',sans-serif;font-size:16px;text-anchor:middle;" fill="black">%s</text>`,
186+
Width/2, 20, sp.Title,
187+
)
188+
}
189+
190+
return svgOut
191+
}

0 commit comments

Comments
 (0)