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 }