|
| 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