-
Notifications
You must be signed in to change notification settings - Fork 6
feat: add scatterplot svg library #12
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
2b98245
ce3c22d
89a79cf
7deb617
d672c38
b0d5413
0ec5293
2a1700e
4b25d1c
2bc5777
5069504
b0c0115
d0a6c07
d69d20b
9efd5ee
8efe040
ce78e78
052e8b1
abfd753
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,50 @@ | ||||||
| # GNOV Scatterplot | ||||||
|
|
||||||
| The `gnov` package allows you to render a scatter plot as an SVG image. It takes a list of `(x, y)` points and draws them as circles on a 2D canvas. You can also apply optional flags to display regression lines or curves. | ||||||
|
|
||||||
| ## Function | ||||||
|
|
||||||
| ```go | ||||||
| Testscatter(POINTS, "TITLE", "X_AXIS_TITLE", "Y_AXIS_TITLE", "FLAG") | ||||||
| ``` | ||||||
|
|
||||||
| ## ARGS | ||||||
|
leohhhn marked this conversation as resolved.
Outdated
|
||||||
|
|
||||||
| `POINTS` strcuture is set with the following arguments : | ||||||
| ```go | ||||||
| X, Y float64 | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add the
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. its done ! |
||||||
| Color string | ||||||
| Label string | ||||||
| ``` | ||||||
|
|
||||||
| `TITLE` is a string | ||||||
|
|
||||||
| `X_AXIS_TITLE` is a string | ||||||
|
|
||||||
| `Y_AXIS_TITLE` is a string | ||||||
|
|
||||||
|
|
||||||
| ## Structure | ||||||
|
|
||||||
| `Usecase` | ||||||
|
leohhhn marked this conversation as resolved.
Outdated
leohhhn marked this conversation as resolved.
Outdated
|
||||||
|
|
||||||
| ```go | ||||||
| ScatterPlot{ | ||||||
| Points: []Point{ | ||||||
| {X: 100, Y: 00, Label: "A"}, | ||||||
| {X: 101, Y: 20, Label: "B"}, | ||||||
| {X: 102, Y: 40, Label: "C"}, | ||||||
| {X: 103, Y: 60, Label: "D"}, | ||||||
| }, | ||||||
| Title: "Evolution of sales", | ||||||
| XAxis: "Années", | ||||||
|
leohhhn marked this conversation as resolved.
Outdated
|
||||||
| YAxis: "Ventes", | ||||||
| Flag: "re", | ||||||
| } | ||||||
| ``` | ||||||
|
|
||||||
|
|
||||||
| ## Flags | ||||||
|
|
||||||
| Actually there is one existing flags : `Lineary Regression flag` that display the `regression line` of the scatterplot can be actived by the flag `re`. | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| Each flag shows the `equation` of the regression in the top left of the Scatterplot. | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| module = "gno.land/p/pierre115/gnov" | ||
| gno = "0.9" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| package gnov | ||
|
|
||
| import ( | ||
| "math" | ||
|
|
||
| "gno.land/p/nt/ufmt" | ||
| ) | ||
|
|
||
| // Function to get slope and intercept with linear way | ||
| func LinearRegression(points []Point) (slope, intercept float64) { | ||
| n := float64(len(points)) | ||
| sumX := float64(0) | ||
| sumY := float64(0) | ||
| sumXY := float64(0) | ||
| sumX2 := float64(0) | ||
|
|
||
| for _, p := range points { | ||
| sumX += p.X | ||
| sumY += p.Y | ||
| sumXY += p.X * p.Y | ||
| sumX2 += p.X * p.X | ||
| } | ||
|
|
||
| slope = (n*sumXY - sumX*sumY) / (n*sumX2 - sumX*sumX) | ||
| intercept = (sumY - slope*sumX) / n | ||
| return | ||
| } | ||
|
|
||
| // Calcul the regression line and return as SVG string | ||
| func RenderReFlag(points []Point, minX, maxX, minY, maxY float64, canvasWidth, canvasHeight int) string { | ||
| slope, intercept := LinearRegression(points) | ||
|
|
||
| xStart := minX | ||
| xEnd := maxX | ||
| yStart := slope*xStart + intercept | ||
| yEnd := slope*xEnd + intercept | ||
|
|
||
| // Normalize with the canvas data | ||
| nx1 := 40 + (xStart-minX)/(maxX-minX)*float64(canvasWidth-60) | ||
| ny1 := float64(canvasHeight-40) - (yStart-minY)/(maxY-minY)*float64(canvasHeight-60) | ||
| nx2 := 40 + (xEnd-minX)/(maxX-minX)*float64(canvasWidth-60) | ||
| ny2 := float64(canvasHeight-40) - (yEnd-minY)/(maxY-minY)*float64(canvasHeight-60) | ||
|
|
||
| // calcul of angle | ||
| dx := nx2 - nx1 | ||
| dy := ny2 - ny1 | ||
| angleRad := math.Atan2(dy, dx) | ||
| angleDeg := angleRad * 180 / math.Pi | ||
|
|
||
| svgOut := "" | ||
|
|
||
| // SVG Rectangle as regression line | ||
| svgOut += ufmt.Sprintf( | ||
| `<rect x="%d" y="%d" width="%d" height="1" fill="black" transform="rotate(%.2f %d %d)"/>`, | ||
| int(nx1), int(ny1), | ||
| int(math.Hypot(nx2-nx1, ny2-ny1)), | ||
| angleDeg, int(nx1), int(ny1), | ||
| ) | ||
|
|
||
| // Equation of the line | ||
| equation := ufmt.Sprintf("y = %.2fx + %.2f", slope, intercept) | ||
| svgOut += ufmt.Sprintf( | ||
| `<text x="50" y="20" style="font-size:12px;font-family:'Inter var',sans-serif;" fill="black">Equation : %s</text>`, | ||
| equation, | ||
| ) | ||
|
|
||
| return svgOut | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,152 @@ | ||
| // Package gnov provides functionality to render a scatter plot as an SVG image. | ||
| // It takes a list of points (x,y) and draws them as circles on a 2D . | ||
| // You can also apply Flags. | ||
| package gnov | ||
|
|
||
| import ( | ||
| "math" | ||
|
|
||
| "gno.land/p/nt/ufmt" | ||
| ) | ||
|
|
||
| func niceStep(rangeVal float64, maxTicks int) float64 { | ||
| var niceBase float64 | ||
| rawStep := rangeVal / float64(maxTicks) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What if maxTicks == 0
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. there is no graduation this is why i decided to put 10 graduation for an axe (it can be change now in the scatterplot structure) |
||
| exponent := math.Floor(math.Log10(rawStep)) | ||
| fraction := rawStep / math.Pow(10, exponent) | ||
|
|
||
| switch { | ||
| case fraction < 1.5: | ||
| niceBase = 1 | ||
| case fraction < 3: | ||
| niceBase = 2 | ||
| case fraction < 7: | ||
| niceBase = 5 | ||
| default: | ||
| niceBase = 10 | ||
| } | ||
|
|
||
| return niceBase * math.Pow(10, exponent) | ||
| } | ||
|
|
||
| // Use the ideal steps for X and Y axis | ||
| func StepXY(maxX, maxY, minX, minY float64) (float64 ,float64, float64, float64, float64, float64) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Also all the code miss linting
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. linting ? |
||
|
|
||
| rangeX := maxX - minX | ||
| stepX := niceStep(rangeX, 10) | ||
| startX := math.Floor(minX/stepX) * stepX | ||
| endX := math.Ceil(maxX/stepX) * stepX | ||
| rangeY := maxY - minY | ||
| stepY := niceStep(rangeY, 10) | ||
| startY := math.Floor(minY/stepY) * stepY | ||
| endY := math.Ceil(maxY/stepY) * stepY | ||
|
|
||
| return stepX, startX, endX, stepY, startY, endY | ||
| } | ||
|
|
||
| // Draw axes and axes titles, return SVG strings | ||
| func RenderAxes(X_axe string, Y_axe string, Width, Height int, maxX, maxY, minX, minY float64) string { | ||
| svgOut := "" | ||
|
|
||
| // Axe Y | ||
| svgOut += ufmt.Sprintf(`<rect x="%d" y="%d" width="%d" height="%d" fill="black"/>`, 40, Height-40, Width-60, 1) | ||
| svgOut += ufmt.Sprintf( | ||
| `<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>`, | ||
| 10, Height/2, 12, 15, Height/2, Y_axe, | ||
| ) | ||
|
|
||
| // Axe X | ||
| svgOut += ufmt.Sprintf(`<rect x="%d" y="%d" width="%d" height="%d" fill="black"/>`, 40, 20, 1, Height-60) | ||
| svgOut += ufmt.Sprintf( | ||
| `<text x="%d" y="%d" style="font-family:'Inter var',sans-serif;font-size:%dpx;text-anchor:middle;" fill="black">%s</text>`, | ||
| Width/2, Height-10, 12, X_axe, | ||
| ) | ||
|
|
||
| // Scale helpers | ||
| scaleX := func(val float64) float64 { | ||
| return 40 + (val-minX)/(maxX-minX)*float64(Width-60) | ||
| } | ||
| scaleY := func(val float64) float64 { | ||
| return float64(Height-40) - (val-minY)/(maxY-minY)*float64(Height-60) | ||
| } | ||
|
|
||
| // Nicesteps calcul for graduation | ||
| stepX, startX, endX, stepY, startY, endY := StepXY(maxX, maxY, minX, minY) | ||
|
|
||
| // Graduation X | ||
| for val := startX; val <= endX; val += stepX { | ||
| nx := scaleX(val) | ||
| y := float64(Height - 40) | ||
| svgOut += ufmt.Sprintf(`<rect x="%d" y="%d" width="%d" height="%d" fill="black"/>`, int(nx), int(y), 1, 5) | ||
| 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) | ||
| } | ||
|
|
||
| // Graduation Y | ||
| for val := startY; val <= endY; val += stepY { | ||
| ny := scaleY(val) | ||
| x := float64(40) | ||
| svgOut += ufmt.Sprintf(`<rect x="%d" y="%d" width="%d" height="%d" fill="black"/>`, int(x)-5, int(ny), 5, 1) | ||
| 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) | ||
| } | ||
|
|
||
| return svgOut | ||
| } | ||
|
|
||
| // Returns an img svg markup as a string, including a markdown header if a non-empty title is provided. | ||
| // The function need a Point struct, two axes titles and a flag. | ||
| // You can see existings flags in the Readme.md | ||
| func (sp ScatterPlot) String() string { | ||
|
|
||
| const ( | ||
| Width = 750 | ||
| Height = 600 | ||
|
leohhhn marked this conversation as resolved.
Outdated
|
||
| pointRadius = 2 | ||
| ) | ||
|
|
||
| if len(sp.Points) == 0 { | ||
| return "\nscatterplot fails: no data provided" | ||
| } | ||
|
|
||
| // calcul min/max | ||
| minX, minY := sp.Points[0].X, sp.Points[0].Y | ||
| maxX, maxY := sp.Points[0].X, sp.Points[0].Y | ||
|
|
||
| for _, p := range sp.Points { | ||
| if p.X > maxX { maxX = p.X } | ||
| if p.X < minX { minX = p.X } | ||
| if p.Y > maxY { maxY = p.Y } | ||
| if p.Y < minY { minY = p.Y } | ||
| } | ||
|
|
||
| svgOut := "" | ||
| svgOut += RenderAxes(sp.XAxis, sp.YAxis, Width, Height, maxX, maxY, minX, minY) | ||
|
|
||
| // Draw Points and labels | ||
| for _, p := range sp.Points { | ||
| nx := 40 + (p.X-minX)/(maxX-minX)*float64(Width-60) | ||
| ny := float64(Height-40) - (p.Y-minY)/(maxY-minY)*float64(Height-60) | ||
|
|
||
| svgOut += ufmt.Sprintf(`<circle cx="%d" cy="%d" r="%d" fill="%s"/>`, int(nx), int(ny), pointRadius, p.Color) | ||
|
|
||
| if p.Label != "" { | ||
| svgOut += ufmt.Sprintf( | ||
| `<text x="%d" y="%d" style="font-family:'Inter var',sans-serif;font-size:10px;text-anchor:middle;" fill="#333">%s</text>`, | ||
| int(nx)+5, int(ny)+12, p.Label, | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| // Flags : | ||
| if sp.Flag == "re" { | ||
|
leohhhn marked this conversation as resolved.
Outdated
|
||
| svgOut += RenderReFlag(sp.Points, minX, maxX, minY, maxY, Width, Height) | ||
| } | ||
|
|
||
| // Draw Title | ||
| if sp.Title != "" { | ||
| 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>`, | ||
| Width/2, 20, sp.Title, | ||
| ) | ||
| } | ||
|
|
||
| return svgOut | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,61 @@ | ||
| package gnov | ||
|
|
||
| import ( | ||
| "strings" | ||
| "testing" | ||
| ) | ||
|
|
||
| // Test for basic scatter plot rendering | ||
| func TestScatterPlot(t *testing.T) { | ||
| sp := ScatterPlot{ | ||
| Points: []Point{ | ||
| {X: 0, Y: 0, Label: "A", Color: "red"}, | ||
| {X: 1, Y: 2, Label: "B", Color: "blue"}, | ||
| }, | ||
| Title: "Test Scatter", | ||
| XAxis: "X Axis", | ||
| YAxis: "Y Axis", | ||
| } | ||
|
|
||
| got := sp.String() | ||
|
|
||
| if got == "" { | ||
| t.Fatal("Render output is empty") | ||
| } | ||
| if !strings.Contains(got, "Test Scatter") { | ||
| t.Error("title not found in render output") | ||
| } | ||
| if !strings.Contains(got, "X Axis") { | ||
| t.Error("X axis label not found") | ||
| } | ||
| if !strings.Contains(got, "Y Axis") { | ||
| t.Error("Y axis label not found") | ||
| } | ||
| if !strings.Contains(got, "<circle") { | ||
| t.Error("expected at least one circle (point) in render output") | ||
| } | ||
| } | ||
|
|
||
| // Test for scatter plot rendering with regression flag | ||
| func TestScatterPlotReFlag(t *testing.T) { | ||
| sp := ScatterPlot{ | ||
| Points: []Point{ | ||
| {X: 0, Y: 0}, | ||
| {X: 1, Y: 2}, | ||
| {X: 2, Y: 4}, | ||
| }, | ||
| Title: "With Regression", | ||
| XAxis: "X", | ||
| YAxis: "Y", | ||
| Flag: "re", | ||
| } | ||
|
|
||
| got := sp.String() | ||
|
|
||
| if !strings.Contains(got, "Equation") { | ||
| t.Error("regression equation not found in render output") | ||
| } | ||
| if !strings.Contains(got, "<rect") { | ||
| t.Error("expected regression line rectangle in render output") | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| package gnov | ||
|
|
||
| // Points structure | ||
| type Point struct { | ||
| X, Y float64 | ||
| Color string | ||
| Label string | ||
| } | ||
|
|
||
| // Scatterplot structure | ||
| type ScatterPlot struct { | ||
| Points []Point | ||
| Title string | ||
| XAxis, YAxis string | ||
| Flag string | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What means the name
gnov?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Gno Visual aha but i thinks we can find a better name for this ...
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If you keep the name (which I think is alright), I think it's always great to highlight what it means, so the reader can better remind of it!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As @Davphla said; rename the H1 to the full name, GnoVisual, so that it makes sense to the reader. Then, you can chose to abbreviate it if you want. I get the point of shortening names, but you should ty naming your packages so that they're recognizable from the import path. A good name would be 'p/pierre115/scatterplot` :)