All checks were successful
release-tag / release-image (push) Successful in 2m9s
357 lines
11 KiB
Go
357 lines
11 KiB
Go
package main
|
||
|
||
import (
|
||
"bytes"
|
||
"fmt"
|
||
"image"
|
||
"image/color"
|
||
"image/draw"
|
||
"image/png"
|
||
"math"
|
||
"sort"
|
||
"strings"
|
||
|
||
"golang.org/x/image/font"
|
||
"golang.org/x/image/font/basicfont"
|
||
"golang.org/x/image/math/fixed"
|
||
)
|
||
|
||
var (
|
||
chartBG = color.RGBA{0x10, 0x17, 0x2a, 0xff}
|
||
chartPanel = color.RGBA{0x11, 0x18, 0x27, 0xff}
|
||
chartText = color.RGBA{0xe5, 0xe7, 0xeb, 0xff}
|
||
chartMuted = color.RGBA{0x94, 0xa3, 0xb8, 0xff}
|
||
chartGrid = color.RGBA{0x33, 0x41, 0x55, 0xff}
|
||
chartCurrent = color.RGBA{0x38, 0xbd, 0xf8, 0xff}
|
||
chartPrevious = color.RGBA{0xf5, 0x9e, 0x0b, 0xff}
|
||
purposeColors = []color.RGBA{
|
||
{0x38, 0xbd, 0xf8, 0xff}, {0x22, 0xc5, 0x5e, 0xff}, {0xf5, 0x9e, 0x0b, 0xff}, {0xef, 0x44, 0x44, 0xff},
|
||
{0xa7, 0x8b, 0xfa, 0xff}, {0xf4, 0x72, 0xb6, 0xff},
|
||
}
|
||
)
|
||
|
||
type chartMetric struct {
|
||
Label string
|
||
Current float64
|
||
Previous float64
|
||
Format func(float64) string
|
||
}
|
||
|
||
type piePart struct {
|
||
Label string
|
||
Value float64
|
||
Color color.RGBA
|
||
}
|
||
|
||
func createTradingCharts(cur, prev *TradingSummary) (comparisonPNG []byte, purposePNG []byte, err error) {
|
||
comparisonPNG, err = buildTradingComparisonChart(cur, prev)
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
purposePNG, err = buildTradingPurposePie(cur)
|
||
if err != nil {
|
||
return comparisonPNG, nil, err
|
||
}
|
||
return comparisonPNG, purposePNG, nil
|
||
}
|
||
|
||
func buildTradingComparisonChart(cur, prev *TradingSummary) ([]byte, error) {
|
||
img := image.NewRGBA(image.Rect(0, 0, 1100, 700))
|
||
draw.Draw(img, img.Bounds(), &image.Uniform{chartBG}, image.Point{}, draw.Src)
|
||
fillRect(img, image.Rect(24, 24, 1076, 676), chartPanel)
|
||
text(img, 48, 62, "Trading-Vergleich zum Vormonat", chartText, true)
|
||
text(img, 48, 88, fmt.Sprintf("%02d/%d vs. %02d/%d", cur.Month, cur.Year, prev.Month, prev.Year), chartMuted, false)
|
||
legendX := 760
|
||
fillRect(img, image.Rect(legendX, 46, legendX+18, 64), chartCurrent)
|
||
text(img, legendX+26, 60, fmt.Sprintf("Aktuell %02d/%d", cur.Month, cur.Year), chartText, false)
|
||
fillRect(img, image.Rect(legendX, 72, legendX+18, 90), chartPrevious)
|
||
text(img, legendX+26, 86, fmt.Sprintf("Vormonat %02d/%d", prev.Month, prev.Year), chartText, false)
|
||
|
||
metrics := []chartMetric{
|
||
{Label: "Gewinn", Current: float64(cur.GrossProfit), Previous: float64(prev.GrossProfit), Format: func(v float64) string { return formatAUEC(int64(math.Round(v))) }},
|
||
{Label: "Orga-Anteil", Current: float64(cur.OrgShare), Previous: float64(prev.OrgShare), Format: func(v float64) string { return formatAUEC(int64(math.Round(v))) }},
|
||
{Label: "SCU", Current: cur.TotalSCU, Previous: prev.TotalSCU, Format: func(v float64) string { return formatAmount(v) }},
|
||
{Label: "Kosten", Current: float64(cur.TotalCosts), Previous: float64(prev.TotalCosts), Format: func(v float64) string { return formatAUEC(int64(math.Round(v))) }},
|
||
}
|
||
|
||
cardW, cardH := 490, 240
|
||
positions := []image.Point{{50, 120}, {560, 120}, {50, 390}, {560, 390}}
|
||
for idx, m := range metrics {
|
||
pos := positions[idx]
|
||
drawMetricCard(img, pos.X, pos.Y, cardW, cardH, m)
|
||
}
|
||
return encodePNG(img)
|
||
}
|
||
|
||
func drawMetricCard(img *image.RGBA, x, y, w, h int, m chartMetric) {
|
||
fillRect(img, image.Rect(x, y, x+w, y+h), color.RGBA{0x16, 0x21, 0x33, 0xff})
|
||
strokeRect(img, image.Rect(x, y, x+w, y+h), chartGrid)
|
||
text(img, x+16, y+24, m.Label, chartText, true)
|
||
maxV := math.Max(math.Abs(m.Current), math.Abs(m.Previous))
|
||
if maxV <= 0 {
|
||
maxV = 1
|
||
}
|
||
chartBottom := y + h - 44
|
||
chartTop := y + 54
|
||
chartH := chartBottom - chartTop
|
||
barW := 90
|
||
gap := 80
|
||
bar1X := x + 100
|
||
bar2X := bar1X + barW + gap
|
||
for i := 0; i <= 4; i++ {
|
||
gy := chartBottom - i*chartH/4
|
||
line(img, x+24, gy, x+w-24, gy, chartGrid)
|
||
text(img, x+28, gy-4, m.Format(maxV*float64(i)/4), chartMuted, false)
|
||
}
|
||
curH := int((math.Abs(m.Current) / maxV) * float64(chartH-10))
|
||
prevH := int((math.Abs(m.Previous) / maxV) * float64(chartH-10))
|
||
fillRect(img, image.Rect(bar1X, chartBottom-curH, bar1X+barW, chartBottom), chartCurrent)
|
||
fillRect(img, image.Rect(bar2X, chartBottom-prevH, bar2X+barW, chartBottom), chartPrevious)
|
||
textCentered(img, bar1X+barW/2, chartBottom+20, "Aktuell", chartText, false)
|
||
textCentered(img, bar2X+barW/2, chartBottom+20, "Vormonat", chartText, false)
|
||
textCentered(img, bar1X+barW/2, chartBottom-curH-8, m.Format(m.Current), chartText, false)
|
||
textCentered(img, bar2X+barW/2, chartBottom-prevH-8, m.Format(m.Previous), chartText, false)
|
||
text(img, x+16, y+h-14, deltaText(m.Current, m.Previous, m.Format), chartMuted, false)
|
||
}
|
||
|
||
func buildTradingPurposePie(cur *TradingSummary) ([]byte, error) {
|
||
img := image.NewRGBA(image.Rect(0, 0, 1100, 700))
|
||
draw.Draw(img, img.Bounds(), &image.Uniform{chartBG}, image.Point{}, draw.Src)
|
||
fillRect(img, image.Rect(24, 24, 1076, 676), chartPanel)
|
||
text(img, 48, 62, "Verteilung nach Zweck", chartText, true)
|
||
subtitle := fmt.Sprintf("Monat %02d/%d – Anteil nach Gewinn", cur.Month, cur.Year)
|
||
|
||
parts := []piePart{}
|
||
total := 0.0
|
||
for idx, p := range cur.PurposeSummary {
|
||
v := float64(p.Profit)
|
||
if v < 0 {
|
||
v = 0
|
||
}
|
||
if v <= 0 {
|
||
continue
|
||
}
|
||
parts = append(parts, piePart{Label: tradingPurposeLabel(p.Purpose), Value: v, Color: purposeColors[idx%len(purposeColors)]})
|
||
total += v
|
||
}
|
||
if total <= 0 {
|
||
subtitle = fmt.Sprintf("Monat %02d/%d – Anteil nach Anzahl Runs", cur.Month, cur.Year)
|
||
parts = parts[:0]
|
||
total = 0
|
||
for idx, p := range cur.PurposeSummary {
|
||
v := float64(p.Runs)
|
||
if v <= 0 {
|
||
continue
|
||
}
|
||
parts = append(parts, piePart{Label: tradingPurposeLabel(p.Purpose), Value: v, Color: purposeColors[idx%len(purposeColors)]})
|
||
total += v
|
||
}
|
||
}
|
||
if total <= 0 {
|
||
parts = []piePart{{Label: "Keine Daten", Value: 1, Color: chartMuted}}
|
||
total = 1
|
||
}
|
||
text(img, 48, 88, subtitle, chartMuted, false)
|
||
|
||
centerX, centerY := 320, 380
|
||
radius := 180
|
||
start := -math.Pi / 2
|
||
for _, p := range parts {
|
||
angle := 2 * math.Pi * (p.Value / total)
|
||
fillPieSlice(img, centerX, centerY, radius, start, start+angle, p.Color)
|
||
start += angle
|
||
}
|
||
fillCircle(img, centerX, centerY, 78, chartPanel) // donut hole
|
||
textCentered(img, centerX, centerY-6, "Gesamt", chartText, true)
|
||
textCentered(img, centerX, centerY+16, formatInt(int64(math.Round(total))), chartMuted, false)
|
||
|
||
legendX := 600
|
||
legendY := 150
|
||
for idx, p := range parts {
|
||
y := legendY + idx*74
|
||
fillRect(img, image.Rect(legendX, y, legendX+24, y+24), p.Color)
|
||
strokeRect(img, image.Rect(legendX, y, legendX+24, y+24), chartGrid)
|
||
text(img, legendX+36, y+18, p.Label, chartText, true)
|
||
pct := 0.0
|
||
if total > 0 {
|
||
pct = (p.Value / total) * 100
|
||
}
|
||
text(img, legendX+36, y+42, fmt.Sprintf("%s (%.1f%%)", formatInt(int64(math.Round(p.Value))), pct), chartMuted, false)
|
||
}
|
||
return encodePNG(img)
|
||
}
|
||
|
||
func fillPieSlice(img *image.RGBA, cx, cy, r int, start, end float64, c color.RGBA) {
|
||
for y := cy - r; y <= cy+r; y++ {
|
||
for x := cx - r; x <= cx+r; x++ {
|
||
dx := float64(x - cx)
|
||
dy := float64(y - cy)
|
||
if dx*dx+dy*dy > float64(r*r) {
|
||
continue
|
||
}
|
||
a := math.Atan2(dy, dx)
|
||
if a < start {
|
||
a += 2 * math.Pi
|
||
}
|
||
adjEnd := end
|
||
for adjEnd < start {
|
||
adjEnd += 2 * math.Pi
|
||
}
|
||
if a >= start && a <= adjEnd {
|
||
img.Set(x, y, c)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func fillCircle(img *image.RGBA, cx, cy, r int, c color.RGBA) {
|
||
for y := cy - r; y <= cy+r; y++ {
|
||
for x := cx - r; x <= cx+r; x++ {
|
||
dx := x - cx
|
||
dy := y - cy
|
||
if dx*dx+dy*dy <= r*r {
|
||
img.Set(x, y, c)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func encodePNG(img image.Image) ([]byte, error) {
|
||
var buf bytes.Buffer
|
||
if err := png.Encode(&buf, img); err != nil {
|
||
return nil, err
|
||
}
|
||
return buf.Bytes(), nil
|
||
}
|
||
|
||
func fillRect(img *image.RGBA, rect image.Rectangle, c color.RGBA) {
|
||
draw.Draw(img, rect, &image.Uniform{c}, image.Point{}, draw.Src)
|
||
}
|
||
|
||
func strokeRect(img *image.RGBA, rect image.Rectangle, c color.RGBA) {
|
||
line(img, rect.Min.X, rect.Min.Y, rect.Max.X-1, rect.Min.Y, c)
|
||
line(img, rect.Min.X, rect.Max.Y-1, rect.Max.X-1, rect.Max.Y-1, c)
|
||
line(img, rect.Min.X, rect.Min.Y, rect.Min.X, rect.Max.Y-1, c)
|
||
line(img, rect.Max.X-1, rect.Min.Y, rect.Max.X-1, rect.Max.Y-1, c)
|
||
}
|
||
|
||
func line(img *image.RGBA, x0, y0, x1, y1 int, c color.RGBA) {
|
||
dx := int(math.Abs(float64(x1 - x0)))
|
||
sx := -1
|
||
if x0 < x1 {
|
||
sx = 1
|
||
}
|
||
dy := -int(math.Abs(float64(y1 - y0)))
|
||
sy := -1
|
||
if y0 < y1 {
|
||
sy = 1
|
||
}
|
||
err := dx + dy
|
||
for {
|
||
img.Set(x0, y0, c)
|
||
if x0 == x1 && y0 == y1 {
|
||
break
|
||
}
|
||
e2 := 2 * err
|
||
if e2 >= dy {
|
||
err += dy
|
||
x0 += sx
|
||
}
|
||
if e2 <= dx {
|
||
err += dx
|
||
y0 += sy
|
||
}
|
||
}
|
||
}
|
||
|
||
func text(img *image.RGBA, x, y int, label string, col color.RGBA, bold bool) {
|
||
face := basicfont.Face7x13
|
||
d := &font.Drawer{Dst: img, Src: image.NewUniform(col), Face: face, Dot: fixed.P(x, y)}
|
||
if bold {
|
||
// simple faux bold
|
||
d.DrawString(label)
|
||
d.Dot = fixed.P(x+1, y)
|
||
}
|
||
d.DrawString(label)
|
||
}
|
||
|
||
func textCentered(img *image.RGBA, cx, y int, label string, col color.RGBA, bold bool) {
|
||
w := font.MeasureString(basicfont.Face7x13, label).Round()
|
||
text(img, cx-w/2, y, label, col, bold)
|
||
}
|
||
|
||
func deltaText(cur, prev float64, formatter func(float64) string) string {
|
||
diff := cur - prev
|
||
sign := "+"
|
||
if diff < 0 {
|
||
sign = ""
|
||
}
|
||
pctText := "n/a"
|
||
if math.Abs(prev) > 0.00001 {
|
||
pctText = fmt.Sprintf("%s%.1f%%", sign, (diff/prev)*100)
|
||
}
|
||
return fmt.Sprintf("Δ %s (%s)", formatter(diff), pctText)
|
||
}
|
||
|
||
func comparisonSummaryText(cur, prev *TradingSummary) string {
|
||
lines := []string{
|
||
compareLineMoney("Gewinn", cur.GrossProfit, prev.GrossProfit),
|
||
compareLineMoney("Orga-Anteil", cur.OrgShare, prev.OrgShare),
|
||
compareLineAmount("SCU", cur.TotalSCU, prev.TotalSCU),
|
||
compareLineMoney("Kosten", cur.TotalCosts, prev.TotalCosts),
|
||
}
|
||
return strings.Join(lines, "\n")
|
||
}
|
||
|
||
func compareLineMoney(label string, cur, prev int64) string {
|
||
return fmt.Sprintf("%s: %s vs. %s (%s)", label, formatAUEC(cur), formatAUEC(prev), diffWithPercent(float64(cur), float64(prev), true))
|
||
}
|
||
|
||
func compareLineAmount(label string, cur, prev float64) string {
|
||
return fmt.Sprintf("%s: %s vs. %s (%s)", label, formatAmount(cur), formatAmount(prev), diffWithPercent(cur, prev, false))
|
||
}
|
||
|
||
func diffWithPercent(cur, prev float64, money bool) string {
|
||
diff := cur - prev
|
||
sign := "+"
|
||
if diff < 0 {
|
||
sign = ""
|
||
}
|
||
var diffValue string
|
||
if money {
|
||
diffValue = signPrefix(diff) + formatAUEC(int64(math.Round(math.Abs(diff))))
|
||
} else {
|
||
diffValue = signPrefix(diff) + formatAmount(math.Abs(diff))
|
||
}
|
||
if math.Abs(prev) <= 0.00001 {
|
||
return diffValue + ", kein Vergleich"
|
||
}
|
||
return fmt.Sprintf("%s, %s%.1f%%", diffValue, sign, (diff/prev)*100)
|
||
}
|
||
|
||
func signPrefix(v float64) string {
|
||
if v > 0 {
|
||
return "+"
|
||
}
|
||
if v < 0 {
|
||
return "-"
|
||
}
|
||
return "±"
|
||
}
|
||
|
||
func previousMonth(month, year int) (int, int) {
|
||
if month <= 1 {
|
||
return 12, year - 1
|
||
}
|
||
return month - 1, year
|
||
}
|
||
|
||
func topTraderBarChartData(sum *TradingSummary) []TradingTraderSummary {
|
||
items := append([]TradingTraderSummary(nil), sum.TopTraders...)
|
||
sort.Slice(items, func(i, j int) bool { return items[i].Profit > items[j].Profit })
|
||
if len(items) > 3 {
|
||
items = items[:3]
|
||
}
|
||
return items
|
||
}
|