From 3e1b3697f5b505a747d08a0a7d6cec1e8db33036 Mon Sep 17 00:00:00 2001 From: Vovko Date: Tue, 14 Jan 2025 13:05:16 -0500 Subject: [PATCH 01/39] removed realm --- internal/stats/render/period/v1/cards.go | 2 -- internal/stats/render/session/v1/cards.go | 2 -- 2 files changed, 4 deletions(-) diff --git a/internal/stats/render/period/v1/cards.go b/internal/stats/render/period/v1/cards.go index 460ce266..3628eadb 100644 --- a/internal/stats/render/period/v1/cards.go +++ b/internal/stats/render/period/v1/cards.go @@ -87,8 +87,6 @@ func generateCards(stats fetch.AccountStatsOverPeriod, cards period.Cards, subs var footer []string if opts.VehicleID != "" { footer = append(footer, cards.Overview.Title) - } else { - footer = append(footer, common.RealmLabel(stats.Realm)) } sessionTo := stats.PeriodEnd.Format("Jan 2, 2006") diff --git a/internal/stats/render/session/v1/cards.go b/internal/stats/render/session/v1/cards.go index f645ef56..e69ef13b 100644 --- a/internal/stats/render/session/v1/cards.go +++ b/internal/stats/render/session/v1/cards.go @@ -139,8 +139,6 @@ func cardsToSegments(session, _ fetch.AccountStatsOverPeriod, cards session.Card var footer []string if opts.VehicleID != "" { footer = append(footer, cards.Unrated.Overview.Title) - } else { - footer = append(footer, common.RealmLabel(session.Realm)) } if session.LastBattleTime.Unix() > 1 { sessionTo := session.PeriodEnd.Format("Jan 2") From 83ca3765c49cfdf8b3598bba032ef6320f69af44 Mon Sep 17 00:00:00 2001 From: Vovko Date: Tue, 14 Jan 2025 13:07:13 -0500 Subject: [PATCH 02/39] minor color/size tweaks --- internal/render/v1/rating.go | 2 +- internal/render/v1/wn8.go | 2 +- internal/stats/render/period/v1/constants.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/render/v1/rating.go b/internal/render/v1/rating.go index 75114f06..d224f063 100644 --- a/internal/render/v1/rating.go +++ b/internal/render/v1/rating.go @@ -49,7 +49,7 @@ func GetRatingTierName(rating float32) string { func GetRatingColors(rating float32) ratingColors { switch { case rating > 5000: - return ratingColors{color.NRGBA{181, 106, 181, 255}, color.White} + return ratingColors{color.NRGBA{181, 106, 181, 255}, color.Black} case rating > 4000: return ratingColors{color.NRGBA{154, 197, 219, 255}, color.Black} case rating > 3000: diff --git a/internal/render/v1/wn8.go b/internal/render/v1/wn8.go index f50b6e4d..5bd712d2 100644 --- a/internal/render/v1/wn8.go +++ b/internal/render/v1/wn8.go @@ -11,7 +11,7 @@ type ratingColors struct { func GetWN8Colors(r float32) ratingColors { if r > 0 && r < 301 { - return ratingColors{color.NRGBA{255, 0, 0, 255}, color.White} + return ratingColors{color.NRGBA{255, 0, 0, 255}, color.Black} } if r > 300 && r < 451 { return ratingColors{color.NRGBA{251, 83, 83, 255}, color.White} diff --git a/internal/stats/render/period/v1/constants.go b/internal/stats/render/period/v1/constants.go index 90fe84fc..432a4c6d 100644 --- a/internal/stats/render/period/v1/constants.go +++ b/internal/stats/render/period/v1/constants.go @@ -9,7 +9,7 @@ import ( ) const ( - wn8IconSize = 60.0 + wn8IconSize = 54.0 ratingIconSize = 60.0 ) From de3ad72230cad6c02020e0083fb5f45d42d1c716 Mon Sep 17 00:00:00 2001 From: Vovko Date: Tue, 14 Jan 2025 13:10:18 -0500 Subject: [PATCH 03/39] incorrect color --- internal/render/v1/wn8.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/render/v1/wn8.go b/internal/render/v1/wn8.go index 5bd712d2..e23a624a 100644 --- a/internal/render/v1/wn8.go +++ b/internal/render/v1/wn8.go @@ -11,7 +11,7 @@ type ratingColors struct { func GetWN8Colors(r float32) ratingColors { if r > 0 && r < 301 { - return ratingColors{color.NRGBA{255, 0, 0, 255}, color.Black} + return ratingColors{color.NRGBA{255, 0, 0, 255}, color.White} } if r > 300 && r < 451 { return ratingColors{color.NRGBA{251, 83, 83, 255}, color.White} @@ -38,7 +38,7 @@ func GetWN8Colors(r float32) ratingColors { return ratingColors{color.NRGBA{208, 108, 255, 255}, color.White} } if r > 2900 { - return ratingColors{color.NRGBA{142, 65, 177, 255}, color.White} + return ratingColors{color.NRGBA{142, 65, 177, 255}, color.Black} } return ratingColors{color.Transparent, color.Transparent} } From dafb92d62a68fc30c06e3b6a657b8356a3126a6b Mon Sep 17 00:00:00 2001 From: Vovko Date: Tue, 14 Jan 2025 13:18:47 -0500 Subject: [PATCH 04/39] remove color bombs, fixed footer sizing --- internal/stats/render/period/v1/block.go | 4 ---- internal/stats/render/period/v1/cards.go | 2 +- internal/stats/render/period/v1/constants.go | 9 ++------- 3 files changed, 3 insertions(+), 12 deletions(-) diff --git a/internal/stats/render/period/v1/block.go b/internal/stats/render/period/v1/block.go index baf2d275..55766f8d 100644 --- a/internal/stats/render/period/v1/block.go +++ b/internal/stats/render/period/v1/block.go @@ -48,7 +48,6 @@ func uniqueBlockWN8(style overviewStyle, stats prepare.StatsBlock[period.BlockDa ratingColors := common.GetWN8Colors(stats.Value().Float()) if stats.Value().Float() <= 0 { - ratingColors.Content = common.TextAlt ratingColors.Background = common.TextAlt } @@ -59,7 +58,6 @@ func uniqueBlockWN8(style overviewStyle, stats prepare.StatsBlock[period.BlockDa blocks = append(blocks, common.NewBlocksContent(style.blockContainer, iconBlockTop, valueBlock)) if stats.Value().Float() >= 0 { - labelStyle.FontColor = ratingColors.Content blocks = append(blocks, common.NewBlocksContent(overviewSpecialRatingPillStyle(ratingColors.Background), common.NewTextContent(labelStyle, common.GetWN8TierName(stats.Value().Float())))) } @@ -71,7 +69,6 @@ func uniqueBlockRating(style overviewStyle, stats prepare.StatsBlock[period.Bloc ratingColors := common.GetRatingColors(stats.Value().Float()) if stats.Value().Float() <= 0 { - ratingColors.Content = common.TextAlt ratingColors.Background = common.TextAlt } @@ -86,7 +83,6 @@ func uniqueBlockRating(style overviewStyle, stats prepare.StatsBlock[period.Bloc blocks = append(blocks, common.NewBlocksContent(style.blockContainer, valueBlocks...)) if stats.Value().Float() != frame.InvalidValue.Float() { - labelStyle.FontColor = ratingColors.Content blocks = append(blocks, common.NewBlocksContent(overviewSpecialRatingPillStyle(ratingColors.Background), common.NewTextContent(labelStyle, common.GetRatingTierName(stats.Value().Float())))) } diff --git a/internal/stats/render/period/v1/cards.go b/internal/stats/render/period/v1/cards.go index 3628eadb..53177f97 100644 --- a/internal/stats/render/period/v1/cards.go +++ b/internal/stats/render/period/v1/cards.go @@ -103,7 +103,7 @@ func generateCards(stats fetch.AccountStatsOverPeriod, cards period.Cards, subs } cardWidth = max(cardWidth, float64(footerImage.Bounds().Dx())) - segments.AddFooter(common.NewImageContent(common.Style{Width: cardWidth, Height: float64(footerImage.Bounds().Dy())}, footerImage)) + segments.AddFooter(common.NewImageContent(common.Style{}, footerImage)) } // Header card diff --git a/internal/stats/render/period/v1/constants.go b/internal/stats/render/period/v1/constants.go index 432a4c6d..19ee8d75 100644 --- a/internal/stats/render/period/v1/constants.go +++ b/internal/stats/render/period/v1/constants.go @@ -102,13 +102,8 @@ func overviewCardBlocksStyle(width float64) common.Style { return style } -func overviewSpecialRatingPillStyle(color color.Color) common.Style { - return common.Style{ - PaddingY: 2, - PaddingX: 7.5, - BorderRadius: common.BorderRadiusXS, - BackgroundColor: color, - } +func overviewSpecialRatingPillStyle(_ color.Color) common.Style { + return common.Style{} } func highlightCardStyle(containerStyle common.Style) highlightStyle { From e620123160b9464c9248b9ea47850a94bb832d1a Mon Sep 17 00:00:00 2001 From: Vovko Date: Tue, 14 Jan 2025 13:42:23 -0500 Subject: [PATCH 05/39] removed days option from career command --- cmd/discord/commands/options.go | 8 +- cmd/discord/commands/public/career.go | 159 ++++++++++++++++++++++++ cmd/discord/commands/public/my.go | 2 +- cmd/discord/commands/public/session.go | 2 +- cmd/discord/commands/public/stats.go | 147 ---------------------- internal/external/blitzstars/account.go | 49 -------- internal/external/blitzstars/client.go | 1 - internal/stats/client/v1/period.go | 2 +- internal/stats/fetch/v1/client.go | 1 - internal/stats/fetch/v1/convert.go | 43 ------- internal/stats/fetch/v1/multisource.go | 84 ------------- 11 files changed, 169 insertions(+), 329 deletions(-) create mode 100644 cmd/discord/commands/public/career.go delete mode 100644 cmd/discord/commands/public/stats.go delete mode 100644 internal/external/blitzstars/account.go diff --git a/cmd/discord/commands/options.go b/cmd/discord/commands/options.go index 7ba7c203..79cee810 100644 --- a/cmd/discord/commands/options.go +++ b/cmd/discord/commands/options.go @@ -45,13 +45,19 @@ var UserOption = builder.NewOption("user", discordgo.ApplicationCommandOptionUse builder.SetDescKey("common_option_stats_user_description"), ) -var DefaultStatsOptions = []builder.Option{ +var SessionStatsOptions = []builder.Option{ DaysOption, NicknameOption, VehicleOption, UserOption, } +var CareerStatsOptions = []builder.Option{ + NicknameOption, + VehicleOption, + UserOption, +} + type StatsOptions struct { PeriodStart time.Time Days int diff --git a/cmd/discord/commands/public/career.go b/cmd/discord/commands/public/career.go new file mode 100644 index 00000000..d5e25968 --- /dev/null +++ b/cmd/discord/commands/public/career.go @@ -0,0 +1,159 @@ +package public + +import ( + "bytes" + "context" + "errors" + "fmt" + + "github.com/bwmarrin/discordgo" + "github.com/cufee/aftermath/cmd/discord/commands" + "github.com/cufee/aftermath/cmd/discord/commands/builder" + "github.com/cufee/aftermath/cmd/discord/common" + "github.com/cufee/aftermath/cmd/discord/middleware" + "github.com/cufee/aftermath/internal/database" + "github.com/cufee/aftermath/internal/database/models" + "github.com/cufee/aftermath/internal/external/blitzstars" + "github.com/cufee/aftermath/internal/log" + "github.com/cufee/aftermath/internal/logic" + "github.com/cufee/aftermath/internal/permissions" + stats "github.com/cufee/aftermath/internal/stats/client/v1" + "github.com/cufee/aftermath/internal/utils" +) + +var ( + careerCommandMiddleware = []middleware.MiddlewareFunc{middleware.RequirePermissions(permissions.UseTextCommands, permissions.UseImageCommands)} + careerCommandParams = []builder.Param{builder.SetNameKey("command_stats_name"), builder.SetDescKey("command_stats_desc")} + careerCommandOptions = commands.CareerStatsOptions +) + +func careerCommandHandler(ctx common.Context) error { + options := commands.GetDefaultStatsOptions(ctx.Options()) + message, valid := options.Validate(ctx) + if !valid { + return ctx.Reply().Send(message) + } + + var accountID string + var opts = []stats.RequestOption{stats.WithWN8(), stats.WithVehicleID(options.TankID)} + + ioptions := statsOptions{StatsOptions: options} + + switch { + case options.UserID != "": + // mentioned another user, check if the user has an account linked + mentionedUser, _ := ctx.Core().Database().GetUserByID(ctx.Ctx(), options.UserID, database.WithConnections(), database.WithSubscriptions(), database.WithContent()) + defaultAccount, hasDefaultAccount := mentionedUser.Connection(models.ConnectionTypeWargaming, nil, utils.Pointer(true)) + if !hasDefaultAccount { + return ctx.Reply().Send("stats_error_connection_not_found_vague") + } + accountID = defaultAccount.ReferenceID + + if img, content, err := logic.GetAccountBackgroundImage(ctx.Ctx(), ctx.Core().Database(), accountID); err == nil { + opts = append(opts, stats.WithBackground(img, true)) + ioptions.BackgroundID = content.ID + } + + case options.AccountID != "": + // account selected from autocomplete + accountID = options.AccountID + + if img, content, err := logic.GetAccountBackgroundImage(ctx.Ctx(), ctx.Core().Database(), accountID); err == nil { + opts = append(opts, stats.WithBackground(img, true)) + ioptions.BackgroundID = content.ID + } + + case options.NicknameSearch != "" && options.AccountID == "": + // nickname provided, but user did not select an option from autocomplete + accounts, err := accountsFromBadInput(ctx.Ctx(), ctx.Core().Fetch(), options.NicknameSearch) + if err != nil { + return ctx.Err(err) + } + if len(accounts) == 0 { + return ctx.Reply().Send("stats_account_not_found") + } + + realms := make(map[string]struct{}) + for _, a := range accounts { + realms[a.Realm.String()] = struct{}{} + } + if len(realms) > 1 { + reply, err := realmSelectButtons(ctx, ctx.ID(), accounts) + if err != nil { + return ctx.Err(err) + } + return reply.Send() + } + + // one or more options on the same server - just pick the first one + message = "stats_bad_nickname_input_hint" + accountID = fmt.Sprint(accounts[0].ID) + + default: + defaultAccount, hasDefaultAccount := ctx.User().Connection(models.ConnectionTypeWargaming, nil, utils.Pointer(true)) + if !hasDefaultAccount { + return ctx.Reply().Send("command_stats_help_message") + } + // command used without options, but user has a default connection + accountID = defaultAccount.ReferenceID + + if img, content, err := logic.GetAccountBackgroundImage(ctx.Ctx(), ctx.Core().Database(), accountID); err == nil { + opts = append(opts, stats.WithBackground(img, true)) + ioptions.BackgroundID = content.ID + } else { + background, _ := ctx.User().Content(models.UserContentTypePersonalBackground) + if img, err := logic.UserContentToImage(background); err == nil { + opts = append(opts, stats.WithBackground(img, true)) + ioptions.BackgroundID = background.ID + } + } + } + + image, meta, err := ctx.Core().Stats(ctx.Locale()).PeriodImage(context.Background(), accountID, options.PeriodStart, opts...) + if errors.Is(err, blitzstars.ErrServiceUnavailable) { + return ctx.Reply(). + Hint(ctx.InteractionID()). + Component(discordgo.ActionsRow{Components: []discordgo.MessageComponent{common.ButtonJoinPrimaryGuild(ctx.Localize("buttons_have_a_question_question"))}}). + Send("blitz_stars_error_service_down") + } + if err != nil { + return ctx.Err(err) + } + + ioptions.AccountID = accountID + button, saveErr := ioptions.refreshButton(ctx, ctx.ID()) + if saveErr != nil { + // nil button will not cause an error and will be ignored + log.Err(err).Str("interactionId", ctx.ID()).Str("command", "session").Msg("failed to save discord interaction") + } + + var buf bytes.Buffer + err = image.PNG(&buf) + if err != nil { + return ctx.Err(err) + } + + var timings []string + if ctx.User().HasPermission(permissions.UseDebugFeatures) { + timings = append(timings, "```") + for name, duration := range meta.Timings { + timings = append(timings, fmt.Sprintf("%s: %v", name, duration.Milliseconds())) + } + timings = append(timings, "```") + } + + return ctx.Reply().WithAds().Hint(message).File(buf.Bytes(), "stats_command_by_aftermath.png").Component(button).Text(timings...).Send() +} + +func init() { + commands.LoadedPublic.Add(builder.NewCommand("stats"). + Middleware(careerCommandMiddleware...). + Options(careerCommandOptions...). + Params(careerCommandParams...). + Handler(careerCommandHandler)) + commands.LoadedPublic.Add(builder.NewCommand("career"). + Middleware(careerCommandMiddleware...). + Options(careerCommandOptions...). + Params(careerCommandParams...). + Handler(careerCommandHandler)) +} diff --git a/cmd/discord/commands/public/my.go b/cmd/discord/commands/public/my.go index b2748083..54e2cf49 100644 --- a/cmd/discord/commands/public/my.go +++ b/cmd/discord/commands/public/my.go @@ -120,7 +120,7 @@ func init() { if err != nil { return ctx.Err(err) } - return ctx.Reply().File(buf.Bytes(), "session_command_by_aftermath.png").Component(button).Send() + return ctx.Reply().WithAds().File(buf.Bytes(), "session_command_by_aftermath.png").Component(button).Send() }), ) } diff --git a/cmd/discord/commands/public/session.go b/cmd/discord/commands/public/session.go index 2007839c..40a8f2c5 100644 --- a/cmd/discord/commands/public/session.go +++ b/cmd/discord/commands/public/session.go @@ -24,7 +24,7 @@ func init() { commands.LoadedPublic.Add( builder.NewCommand("session"). Middleware(middleware.RequirePermissions(permissions.UseTextCommands, permissions.UseImageCommands)). - Options(commands.DefaultStatsOptions...). + Options(commands.SessionStatsOptions...). Handler(func(ctx common.Context) error { options := commands.GetDefaultStatsOptions(ctx.Options()) message, valid := options.Validate(ctx) diff --git a/cmd/discord/commands/public/stats.go b/cmd/discord/commands/public/stats.go deleted file mode 100644 index 99d09e68..00000000 --- a/cmd/discord/commands/public/stats.go +++ /dev/null @@ -1,147 +0,0 @@ -package public - -import ( - "bytes" - "context" - "errors" - "fmt" - - "github.com/bwmarrin/discordgo" - "github.com/cufee/aftermath/cmd/discord/commands" - "github.com/cufee/aftermath/cmd/discord/commands/builder" - "github.com/cufee/aftermath/cmd/discord/common" - "github.com/cufee/aftermath/cmd/discord/middleware" - "github.com/cufee/aftermath/internal/database" - "github.com/cufee/aftermath/internal/database/models" - "github.com/cufee/aftermath/internal/external/blitzstars" - "github.com/cufee/aftermath/internal/log" - "github.com/cufee/aftermath/internal/logic" - "github.com/cufee/aftermath/internal/permissions" - stats "github.com/cufee/aftermath/internal/stats/client/v1" - "github.com/cufee/aftermath/internal/utils" -) - -func init() { - commands.LoadedPublic.Add( - builder.NewCommand("stats"). - Middleware(middleware.RequirePermissions(permissions.UseTextCommands, permissions.UseImageCommands)). - Options(commands.DefaultStatsOptions...). - Handler(func(ctx common.Context) error { - options := commands.GetDefaultStatsOptions(ctx.Options()) - message, valid := options.Validate(ctx) - if !valid { - return ctx.Reply().Send(message) - } - - var accountID string - var opts = []stats.RequestOption{stats.WithWN8(), stats.WithVehicleID(options.TankID)} - - ioptions := statsOptions{StatsOptions: options} - - switch { - case options.UserID != "": - // mentioned another user, check if the user has an account linked - mentionedUser, _ := ctx.Core().Database().GetUserByID(ctx.Ctx(), options.UserID, database.WithConnections(), database.WithSubscriptions(), database.WithContent()) - defaultAccount, hasDefaultAccount := mentionedUser.Connection(models.ConnectionTypeWargaming, nil, utils.Pointer(true)) - if !hasDefaultAccount { - return ctx.Reply().Send("stats_error_connection_not_found_vague") - } - accountID = defaultAccount.ReferenceID - - if img, content, err := logic.GetAccountBackgroundImage(ctx.Ctx(), ctx.Core().Database(), accountID); err == nil { - opts = append(opts, stats.WithBackground(img, true)) - ioptions.BackgroundID = content.ID - } - - case options.AccountID != "": - // account selected from autocomplete - accountID = options.AccountID - - if img, content, err := logic.GetAccountBackgroundImage(ctx.Ctx(), ctx.Core().Database(), accountID); err == nil { - opts = append(opts, stats.WithBackground(img, true)) - ioptions.BackgroundID = content.ID - } - - case options.NicknameSearch != "" && options.AccountID == "": - // nickname provided, but user did not select an option from autocomplete - accounts, err := accountsFromBadInput(ctx.Ctx(), ctx.Core().Fetch(), options.NicknameSearch) - if err != nil { - return ctx.Err(err) - } - if len(accounts) == 0 { - return ctx.Reply().Send("stats_account_not_found") - } - - realms := make(map[string]struct{}) - for _, a := range accounts { - realms[a.Realm.String()] = struct{}{} - } - if len(realms) > 1 { - reply, err := realmSelectButtons(ctx, ctx.ID(), accounts) - if err != nil { - return ctx.Err(err) - } - return reply.Send() - } - - // one or more options on the same server - just pick the first one - message = "stats_bad_nickname_input_hint" - accountID = fmt.Sprint(accounts[0].ID) - - default: - defaultAccount, hasDefaultAccount := ctx.User().Connection(models.ConnectionTypeWargaming, nil, utils.Pointer(true)) - if !hasDefaultAccount { - return ctx.Reply().Send("command_stats_help_message") - } - // command used without options, but user has a default connection - accountID = defaultAccount.ReferenceID - - if img, content, err := logic.GetAccountBackgroundImage(ctx.Ctx(), ctx.Core().Database(), accountID); err == nil { - opts = append(opts, stats.WithBackground(img, true)) - ioptions.BackgroundID = content.ID - } else { - background, _ := ctx.User().Content(models.UserContentTypePersonalBackground) - if img, err := logic.UserContentToImage(background); err == nil { - opts = append(opts, stats.WithBackground(img, true)) - ioptions.BackgroundID = background.ID - } - } - } - - image, meta, err := ctx.Core().Stats(ctx.Locale()).PeriodImage(context.Background(), accountID, options.PeriodStart, opts...) - if errors.Is(err, blitzstars.ErrServiceUnavailable) { - return ctx.Reply(). - Hint(ctx.InteractionID()). - Component(discordgo.ActionsRow{Components: []discordgo.MessageComponent{common.ButtonJoinPrimaryGuild(ctx.Localize("buttons_have_a_question_question"))}}). - Send("blitz_stars_error_service_down") - } - if err != nil { - return ctx.Err(err) - } - - ioptions.AccountID = accountID - button, saveErr := ioptions.refreshButton(ctx, ctx.ID()) - if saveErr != nil { - // nil button will not cause an error and will be ignored - log.Err(err).Str("interactionId", ctx.ID()).Str("command", "session").Msg("failed to save discord interaction") - } - - var buf bytes.Buffer - err = image.PNG(&buf) - if err != nil { - return ctx.Err(err) - } - - var timings []string - if ctx.User().HasPermission(permissions.UseDebugFeatures) { - timings = append(timings, "```") - for name, duration := range meta.Timings { - timings = append(timings, fmt.Sprintf("%s: %v", name, duration.Milliseconds())) - } - timings = append(timings, "```") - } - - return ctx.Reply().WithAds().Hint(message).File(buf.Bytes(), "stats_command_by_aftermath.png").Component(button).Text(timings...).Send() - }), - ) -} diff --git a/internal/external/blitzstars/account.go b/internal/external/blitzstars/account.go deleted file mode 100644 index 7b988176..00000000 --- a/internal/external/blitzstars/account.go +++ /dev/null @@ -1,49 +0,0 @@ -package blitzstars - -import ( - "context" - "fmt" - "net/http" - - "github.com/cufee/aftermath/internal/json" - "github.com/pkg/errors" - - "github.com/cufee/am-wg-proxy-next/v2/types" -) - -type TankHistoryEntry struct { - TankID int `json:"tank_id"` - LastBattleTime int `json:"last_battle_time"` - BattlesLifeTime int `json:"battle_life_time"` - MarkOfMastery int `json:"mark_of_mastery"` - Stats types.StatsFrame `json:"all"` -} - -func (c client) AccountTankHistories(ctx context.Context, accountId string) (map[int][]TankHistoryEntry, error) { - req, err := http.NewRequest("GET", fmt.Sprintf("%s/tankhistories/for/%s", c.apiURL, accountId), nil) - if err != nil { - return nil, err - } - - res, err := c.http.Do(req.WithContext(ctx)) - if err != nil { - return nil, err - } - defer res.Body.Close() - if res.StatusCode != http.StatusOK { - return nil, errors.Wrapf(ErrServiceUnavailable, "bad status code: %d", res.StatusCode) - } - - var histories []TankHistoryEntry - err = json.NewDecoder(res.Body).Decode(&histories) - if err != nil { - return nil, err - } - - var historiesMap = make(map[int][]TankHistoryEntry, len(histories)) - for _, entry := range histories { - historiesMap[entry.TankID] = append(historiesMap[entry.TankID], entry) - } - - return historiesMap, nil -} diff --git a/internal/external/blitzstars/client.go b/internal/external/blitzstars/client.go index c226b71d..5aae1492 100644 --- a/internal/external/blitzstars/client.go +++ b/internal/external/blitzstars/client.go @@ -13,7 +13,6 @@ var ErrServiceUnavailable = errors.New("blitz stars unavailable") type Client interface { CurrentTankAverages(ctx context.Context) (map[string]frame.StatsFrame, error) - AccountTankHistories(ctx context.Context, accountId string) (map[int][]TankHistoryEntry, error) } // var _ Client = &client{} // just a marker to see if it is implemented correctly diff --git a/internal/stats/client/v1/period.go b/internal/stats/client/v1/period.go index b2c537ed..5669569c 100644 --- a/internal/stats/client/v1/period.go +++ b/internal/stats/client/v1/period.go @@ -36,7 +36,7 @@ func (r *client) PeriodCards(ctx context.Context, accountId string, from time.Ti }(accountId, opts.referenceID) stop := meta.Timer("fetchClient#PeriodStats") - stats, err := r.fetchClient.PeriodStats(ctx, accountId, from, opts.FetchOpts()...) + stats, err := r.fetchClient.CurrentStats(ctx, accountId, opts.FetchOpts()...) stop() if err != nil { return prepare.Cards{}, meta, err diff --git a/internal/stats/fetch/v1/client.go b/internal/stats/fetch/v1/client.go index 58e067bf..1cf47327 100644 --- a/internal/stats/fetch/v1/client.go +++ b/internal/stats/fetch/v1/client.go @@ -58,7 +58,6 @@ type Client interface { BroadSearch(ctx context.Context, nickname string, limit int) ([]AccountWithRealm, error) CurrentStats(ctx context.Context, id string, opts ...StatsOption) (AccountStatsOverPeriod, error) - PeriodStats(ctx context.Context, id string, from time.Time, opts ...StatsOption) (AccountStatsOverPeriod, error) SessionStats(ctx context.Context, id string, sessionStart time.Time, opts ...StatsOption) (AccountStatsOverPeriod, AccountStatsOverPeriod, error) ReplayRemote(ctx context.Context, fileURL string) (Replay, error) diff --git a/internal/stats/fetch/v1/convert.go b/internal/stats/fetch/v1/convert.go index 5f206079..51d77ca7 100644 --- a/internal/stats/fetch/v1/convert.go +++ b/internal/stats/fetch/v1/convert.go @@ -1,13 +1,11 @@ package fetch import ( - "slices" "strconv" "time" assets "github.com/cufee/aftermath-assets/types" "github.com/cufee/aftermath/internal/database/models" - "github.com/cufee/aftermath/internal/external/blitzstars" "github.com/cufee/aftermath/internal/stats/fetch/v1/replay" "github.com/cufee/aftermath/internal/stats/frame" "github.com/cufee/am-wg-proxy-next/v2/types" @@ -94,47 +92,6 @@ func WargamingVehiclesToFrame(wg []types.VehicleStatsFrame) map[string]frame.Veh return stats } -func blitzstarsToStats(vehicles map[string]frame.VehicleStatsFrame, histories map[int][]blitzstars.TankHistoryEntry, from time.Time) StatsWithVehicles { - stats := StatsWithVehicles{ - Vehicles: make(map[string]frame.VehicleStatsFrame), - } - - for _, vehicle := range vehicles { - if vehicle.LastBattleTime.Before(from) { - continue - } - - id, err := strconv.Atoi(vehicle.VehicleID) - if err != nil || id == 0 { - continue - } - - entries := histories[id] - // Sort entries by number of battles in descending order - slices.SortFunc(entries, func(i, j blitzstars.TankHistoryEntry) int { - return j.Stats.Battles - i.Stats.Battles - }) - - var selectedEntry blitzstars.TankHistoryEntry - for _, entry := range entries { - if entry.LastBattleTime < int(from.Unix()) { - selectedEntry = entry - break - } - } - - if selectedEntry.Stats.Battles < int(vehicle.Battles) { - selectedFrame := WargamingToFrame(selectedEntry.Stats) - vehicle.StatsFrame.Subtract(selectedFrame) - - stats.Vehicles[vehicle.VehicleID] = vehicle - stats.Add(*vehicle.StatsFrame) - } - } - - return stats -} - type Replay struct { Map assets.Map replay.Replay diff --git a/internal/stats/fetch/v1/multisource.go b/internal/stats/fetch/v1/multisource.go index 1765b3a8..01a76241 100644 --- a/internal/stats/fetch/v1/multisource.go +++ b/internal/stats/fetch/v1/multisource.go @@ -263,90 +263,6 @@ func (c *multiSourceClient) CurrentStats(ctx context.Context, id string, opts .. return stats, nil } -func (c *multiSourceClient) PeriodStats(ctx context.Context, id string, periodStart time.Time, opts ...StatsOption) (AccountStatsOverPeriod, error) { - var options statsOptions - for _, apply := range opts { - apply(&options) - } - - var histories retry.DataWithErr[map[int][]blitzstars.TankHistoryEntry] - var averages retry.DataWithErr[map[string]frame.StatsFrame] - var current retry.DataWithErr[AccountStatsOverPeriod] - - var group sync.WaitGroup - group.Add(1) - go func() { - defer group.Done() - - stats, err := c.CurrentStats(ctx, id, opts...) - current = retry.DataWithErr[AccountStatsOverPeriod]{Data: stats, Err: err} - - if err != nil || stats.RegularBattles.Battles < 1 || !options.withWN8 { - return - } - - var ids []string - for id := range stats.RegularBattles.Vehicles { - ids = append(ids, id) - } - a, err := c.database.GetVehicleAverages(ctx, ids) - averages = retry.DataWithErr[map[string]frame.StatsFrame]{Data: a, Err: err} - }() - - // TODO: lookup a session from the database first - // if a session exists in the database, we don't need BlitzStars and have better data - - // return career stats if stats are requested for 0 or 90+ days, we do not track that far - if days := time.Since(periodStart).Hours() / 24; days >= 91 || days < 1 { - group.Wait() - if current.Err != nil { - return AccountStatsOverPeriod{}, current.Err - } - if averages.Err != nil { - // not critical, this will only affect WN8 - log.Err(averages.Err).Msg("failed to get tank averages") - } - - stats := current.Data - if options.withWN8 { - stats.AddWN8(averages.Data) - } - return stats, nil - } - - group.Add(1) - go func() { - defer group.Done() - histories = retry.Retry(func() (map[int][]blitzstars.TankHistoryEntry, error) { - return c.blitzstars.AccountTankHistories(ctx, id) - }, c.retriesPerRequest, c.retrySleepInterval) - }() - - // wait for all requests to finish and check errors - group.Wait() - if current.Err != nil { - return AccountStatsOverPeriod{}, current.Err - } - if histories.Err != nil { - return AccountStatsOverPeriod{}, histories.Err - } - if averages.Err != nil { - // not critical, this will only affect WN8 - log.Err(averages.Err).Msg("failed to get tank averages") - } - - current.Data.PeriodEnd = time.Now() - current.Data.PeriodStart = periodStart - current.Data.RatingBattles = StatsWithVehicles{} // blitzstars do not provide rating battles stats - current.Data.RegularBattles = blitzstarsToStats(current.Data.RegularBattles.Vehicles, histories.Data, periodStart) - - stats := current.Data - if options.withWN8 { - stats.AddWN8(averages.Data) - } - return stats, nil -} - func (c *multiSourceClient) SessionStats(ctx context.Context, id string, sessionStart time.Time, opts ...StatsOption) (AccountStatsOverPeriod, AccountStatsOverPeriod, error) { var options = statsOptions{snapshotType: models.SnapshotTypeDaily, referenceID: id} for _, apply := range opts { From 3558209933bc2dacd97c6cc2ff3555f64cd70f81 Mon Sep 17 00:00:00 2001 From: Vovko Date: Tue, 14 Jan 2025 13:50:23 -0500 Subject: [PATCH 06/39] fixed strings --- cmd/discord/commands/public/career.go | 7 +++---- cmd/discord/commands/public/my.go | 4 ++-- static/localization/en/discord.yaml | 18 +++++++++--------- 3 files changed, 14 insertions(+), 15 deletions(-) diff --git a/cmd/discord/commands/public/career.go b/cmd/discord/commands/public/career.go index d5e25968..2aa910cb 100644 --- a/cmd/discord/commands/public/career.go +++ b/cmd/discord/commands/public/career.go @@ -23,7 +23,6 @@ import ( var ( careerCommandMiddleware = []middleware.MiddlewareFunc{middleware.RequirePermissions(permissions.UseTextCommands, permissions.UseImageCommands)} - careerCommandParams = []builder.Param{builder.SetNameKey("command_stats_name"), builder.SetDescKey("command_stats_desc")} careerCommandOptions = commands.CareerStatsOptions ) @@ -92,7 +91,7 @@ func careerCommandHandler(ctx common.Context) error { default: defaultAccount, hasDefaultAccount := ctx.User().Connection(models.ConnectionTypeWargaming, nil, utils.Pointer(true)) if !hasDefaultAccount { - return ctx.Reply().Send("command_stats_help_message") + return ctx.Reply().Send("command_career_help_message") } // command used without options, but user has a default connection accountID = defaultAccount.ReferenceID @@ -149,11 +148,11 @@ func init() { commands.LoadedPublic.Add(builder.NewCommand("stats"). Middleware(careerCommandMiddleware...). Options(careerCommandOptions...). - Params(careerCommandParams...). + Params(builder.SetDescKey("command_career_desc")). Handler(careerCommandHandler)) commands.LoadedPublic.Add(builder.NewCommand("career"). Middleware(careerCommandMiddleware...). Options(careerCommandOptions...). - Params(careerCommandParams...). + Params(builder.SetNameKey("command_career_name"), builder.SetDescKey("command_career_desc")). Handler(careerCommandHandler)) } diff --git a/cmd/discord/commands/public/my.go b/cmd/discord/commands/public/my.go index 54e2cf49..0e9a1579 100644 --- a/cmd/discord/commands/public/my.go +++ b/cmd/discord/commands/public/my.go @@ -27,8 +27,8 @@ func init() { Middleware(middleware.RequirePermissions(permissions.UseTextCommands, permissions.UseImageCommands)). Params(builder.SetNameKey("command_my_name"), builder.SetDescKey("command_my_description")). Options( - builder.NewOption("stats", discordgo.ApplicationCommandOptionSubCommand). - Params(builder.SetNameKey("command_my_stats_name"), builder.SetDescKey("command_my_stats_description")). + builder.NewOption("career", discordgo.ApplicationCommandOptionSubCommand). + Params(builder.SetNameKey("command_my_career_name"), builder.SetDescKey("command_my_career_description")). Options( commands.DaysOption, commands.VehicleOption, diff --git a/static/localization/en/discord.yaml b/static/localization/en/discord.yaml index af8f5b37..19d00f1f 100755 --- a/static/localization/en/discord.yaml +++ b/static/localization/en/discord.yaml @@ -10,12 +10,12 @@ value: "North America: ()\nEurope: ()\nAsia: ()" - key: commands_help_message_fmt value: "## :chart_with_upwards_trend: Track your progress\n### ○ /session\nGet an image with your current session stats. You can also mention another user to view their session.\n### Aftermath sessions will be reset at the following times:\n%s\n### ○ /stats\nGet an image with your career stats. You can also mention another user to view their stats.\n## :film_frames: Dive deeper into every battle\n### ○ /replay\nUpload your replay and get an overview of the battle you have played.\n## :link: Tell Aftermath your Blitz nickname\n### ○ /links\nYou can set a default account by using the _/links favorite_ command to use _/session_ and _/stats_ without providing a nickname and server.\n### ○ /links verify\nVerify your Wargaming account to unlock additional features.\n## :frame_photo: Add a splash of style\n### ○ /fancy\nUpload a custom background image for your session stats. Your unique style will be visible to everyone who views your stats.\n## :desktop: Show off on your stream\n### ○ /widget\nGet a link to a streaming widget for your stream overlay.\nThis widget will automatically update with your latest session stats.\n\n_Found a translation error? Let us know on Aftermath Official_\n## :heart: Share the love!" -# /stats -- key: command_stats_name - value: stats -- key: command_stats_description - value: Get an overview of your stats -- key: command_stats_help_message +# /career +- key: command_career_name + value: career +- key: command_career_desc + value: Get an overview of your career stats +- key: command_career_help_message value: "## View Career Stats for Your Account!\nTo get started, include a nickname and server, mention another user, or link an account using `/links add`!" # /session - key: command_session_name @@ -33,9 +33,9 @@ value: session - key: command_my_session_description value: View session stats for your linked account -- key: command_my_stats_name - value: stats -- key: command_my_stats_description +- key: command_my_career_name + value: career +- key: command_my_career_description value: View career stats for your linked accounts - key: command_option_my_account_name value: account From 9d08e3456aea73af895f6676c6a37b8106c12a8b Mon Sep 17 00:00:00 2001 From: Vovko Date: Tue, 14 Jan 2025 13:52:54 -0500 Subject: [PATCH 07/39] updated help message --- static/localization/en/discord.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/static/localization/en/discord.yaml b/static/localization/en/discord.yaml index 19d00f1f..49f3cf4d 100755 --- a/static/localization/en/discord.yaml +++ b/static/localization/en/discord.yaml @@ -9,7 +9,7 @@ - key: commands_help_refresh_times_fmt value: "North America: ()\nEurope: ()\nAsia: ()" - key: commands_help_message_fmt - value: "## :chart_with_upwards_trend: Track your progress\n### ○ /session\nGet an image with your current session stats. You can also mention another user to view their session.\n### Aftermath sessions will be reset at the following times:\n%s\n### ○ /stats\nGet an image with your career stats. You can also mention another user to view their stats.\n## :film_frames: Dive deeper into every battle\n### ○ /replay\nUpload your replay and get an overview of the battle you have played.\n## :link: Tell Aftermath your Blitz nickname\n### ○ /links\nYou can set a default account by using the _/links favorite_ command to use _/session_ and _/stats_ without providing a nickname and server.\n### ○ /links verify\nVerify your Wargaming account to unlock additional features.\n## :frame_photo: Add a splash of style\n### ○ /fancy\nUpload a custom background image for your session stats. Your unique style will be visible to everyone who views your stats.\n## :desktop: Show off on your stream\n### ○ /widget\nGet a link to a streaming widget for your stream overlay.\nThis widget will automatically update with your latest session stats.\n\n_Found a translation error? Let us know on Aftermath Official_\n## :heart: Share the love!" + value: "## :chart_with_upwards_trend: Track your progress\n### ○ /session\nGet an image with your current session stats. You can also mention another user to view their session.\n### Aftermath sessions will be reset at the following times:\n%s\n### ○ /career\nGet an image with your career stats. You can also mention another user to view their stats.\n## :film_frames: Dive deeper into every battle\n### ○ /replay\nUpload your replay and get an overview of the battle you have played.\n## :link: Tell Aftermath your Blitz nickname\n### ○ /links\nYou can set a default account by using the _/links favorite_ command to use _/session_ and _/career_ without providing a nickname and server.\n### ○ /links verify\nVerify your Wargaming account to unlock additional features.\n## :frame_photo: Add a splash of style\n### ○ /fancy\nUpload a custom background image for your session stats. Your unique style will be visible to everyone who views your stats.\n## :desktop: Show off on your stream\n### ○ /widget\nGet a link to a streaming widget for your stream overlay.\nThis widget will automatically update with your latest session stats.\n\n_Found a translation error? Let us know on Aftermath Official_\n## :heart: Share the love!" # /career - key: command_career_name value: career @@ -295,7 +295,7 @@ context: We were not tracking this account, but successfully started tracking it just now - key: session_error_no_session_for_period - value: "Aftermath does not yet have a session for this period. Try changing the number of days or using `/stats` instead." + value: "Aftermath does not yet have a session for this period. Try changing the number of days or using `/career` instead." context: This account is being tracked, but there is no session available - key: stats_refresh_interaction_error_expired From 7a8b09b39e42a754485b9e60b370965b63a2724e Mon Sep 17 00:00:00 2001 From: Vovko Date: Tue, 14 Jan 2025 14:05:04 -0500 Subject: [PATCH 08/39] removed session color bombing --- internal/stats/render/session/v1/blocks.go | 14 ++++---------- internal/stats/render/session/v1/cards.go | 8 ++++---- internal/stats/render/session/v1/constants.go | 15 ++++----------- 3 files changed, 12 insertions(+), 25 deletions(-) diff --git a/internal/stats/render/session/v1/blocks.go b/internal/stats/render/session/v1/blocks.go index 9fbe2c81..99b7fd44 100644 --- a/internal/stats/render/session/v1/blocks.go +++ b/internal/stats/render/session/v1/blocks.go @@ -18,7 +18,6 @@ func makeSpecialRatingColumn(block prepare.StatsBlock[session.BlockData, string] case prepare.TagWN8: ratingColors := common.GetWN8Colors(block.Value().Float()) if block.Value().Float() <= 0 { - ratingColors.Content = common.TextAlt ratingColors.Background = common.TextAlt } @@ -26,15 +25,11 @@ func makeSpecialRatingColumn(block prepare.StatsBlock[session.BlockData, string] iconTop := common.AftermathLogo(ratingColors.Background, common.DefaultLogoOptions()) column = append(column, common.NewImageContent(common.Style{Width: specialWN8IconSize, Height: specialWN8IconSize}, iconTop)) - pillColor := ratingColors.Background - if block.Value().Float() < 0 { - pillColor = color.Transparent - } column = append(column, common.NewBlocksContent(overviewColumnStyle(width), blockWithDoubleVehicleIcon(common.NewTextContent(blockStyle.session, block.Data.Session().String()), block.Data.Session(), block.Data.Career()), common.NewBlocksContent( - overviewSpecialRatingPillStyle(pillColor), - common.NewTextContent(overviewSpecialRatingLabelStyle(ratingColors.Content), common.GetWN8TierName(block.Value().Float())), + overviewSpecialRatingPillStyle(), + common.NewTextContent(overviewSpecialRatingLabelStyle(), common.GetWN8TierName(block.Value().Float())), ), )) return common.NewBlocksContent(specialRatingColumnStyle(), column...) @@ -46,12 +41,11 @@ func makeSpecialRatingColumn(block prepare.StatsBlock[session.BlockData, string] column = append(column, icon) } - ratingColors := common.GetRatingColors(block.Value().Float()) column = append(column, common.NewBlocksContent(overviewColumnStyle(width), blockWithDoubleVehicleIcon(common.NewTextContent(blockStyle.session, block.Data.Session().String()), block.Data.Session(), block.Data.Career()), common.NewBlocksContent( - overviewSpecialRatingPillStyle(ratingColors.Background), - common.NewTextContent(overviewSpecialRatingLabelStyle(ratingColors.Content), common.GetRatingTierName(block.Value().Float())), + overviewSpecialRatingPillStyle(), + common.NewTextContent(overviewSpecialRatingLabelStyle(), common.GetRatingTierName(block.Value().Float())), ), )) diff --git a/internal/stats/render/session/v1/cards.go b/internal/stats/render/session/v1/cards.go index e69ef13b..3106c93e 100644 --- a/internal/stats/render/session/v1/cards.go +++ b/internal/stats/render/session/v1/cards.go @@ -274,14 +274,14 @@ func overviewColumnBlocksWidth(blocks []prepare.StatsBlock[session.BlockData, st for _, block := range blocks { // adjust width if this column includes a special icon if block.Tag == prepare.TagWN8 { - tierNameSize := common.MeasureString(common.GetWN8TierName(block.Value().Float()), overviewSpecialRatingLabelStyle(nil).Font) - tierNameWithPadding := tierNameSize.TotalWidth + overviewSpecialRatingPillStyle(nil).PaddingX*2 + tierNameSize := common.MeasureString(common.GetWN8TierName(block.Value().Float()), overviewSpecialRatingLabelStyle().Font) + tierNameWithPadding := tierNameSize.TotalWidth + overviewSpecialRatingPillStyle().PaddingX*2 presetBlockWidth[block.Tag.String()] = max(presetBlockWidth[block.Tag.String()], specialWN8IconSize, tierNameWithPadding) contentWidth = max(contentWidth, tierNameWithPadding) } if block.Tag == prepare.TagRankedRating { - valueSize := common.MeasureString(common.GetRatingTierName(block.Value().Float()), overviewSpecialRatingLabelStyle(nil).Font) - tierNameWithPadding := valueSize.TotalWidth + overviewSpecialRatingPillStyle(nil).PaddingX*2 + valueSize := common.MeasureString(common.GetRatingTierName(block.Value().Float()), overviewSpecialRatingLabelStyle().Font) + tierNameWithPadding := valueSize.TotalWidth + overviewSpecialRatingPillStyle().PaddingX*2 presetBlockWidth[block.Tag.String()] = max(presetBlockWidth[block.Tag.String()], specialRatingIconSize, tierNameWithPadding) contentWidth = max(contentWidth, tierNameWithPadding) } diff --git a/internal/stats/render/session/v1/constants.go b/internal/stats/render/session/v1/constants.go index 3ea18f12..130a9fde 100644 --- a/internal/stats/render/session/v1/constants.go +++ b/internal/stats/render/session/v1/constants.go @@ -1,8 +1,6 @@ package session import ( - "image/color" - common "github.com/cufee/aftermath/internal/render/v1" ) @@ -41,17 +39,12 @@ func overviewStatsBlockStyle() blockStyle { } } -func overviewSpecialRatingLabelStyle(color color.Color) common.Style { - return common.Style{FontColor: color, Font: common.FontSmall()} +func overviewSpecialRatingLabelStyle() common.Style { + return common.Style{FontColor: common.TextAlt, Font: common.FontSmall()} } -func overviewSpecialRatingPillStyle(color color.Color) common.Style { - return common.Style{ - PaddingY: 2, - PaddingX: 7.5, - BorderRadius: common.BorderRadiusXS, - BackgroundColor: color, - } +func overviewSpecialRatingPillStyle() common.Style { + return common.Style{} } func overviewColumnStyle(width float64) common.Style { From 8f32945bfbfd07560c2ef2a85e6cea69ae36117c Mon Sep 17 00:00:00 2001 From: Vovko Date: Tue, 14 Jan 2025 14:10:33 -0500 Subject: [PATCH 09/39] cleanup, more consistent UI --- internal/stats/render/period/v1/block.go | 9 ++------- internal/stats/render/period/v1/cards.go | 2 +- internal/stats/render/period/v1/constants.go | 4 +--- internal/stats/render/session/v1/blocks.go | 14 ++++++++++++-- 4 files changed, 16 insertions(+), 13 deletions(-) diff --git a/internal/stats/render/period/v1/block.go b/internal/stats/render/period/v1/block.go index 55766f8d..a00b5baa 100644 --- a/internal/stats/render/period/v1/block.go +++ b/internal/stats/render/period/v1/block.go @@ -58,7 +58,7 @@ func uniqueBlockWN8(style overviewStyle, stats prepare.StatsBlock[period.BlockDa blocks = append(blocks, common.NewBlocksContent(style.blockContainer, iconBlockTop, valueBlock)) if stats.Value().Float() >= 0 { - blocks = append(blocks, common.NewBlocksContent(overviewSpecialRatingPillStyle(ratingColors.Background), common.NewTextContent(labelStyle, common.GetWN8TierName(stats.Value().Float())))) + blocks = append(blocks, common.NewBlocksContent(overviewSpecialRatingPillStyle(), common.NewTextContent(labelStyle, common.GetWN8TierName(stats.Value().Float())))) } return common.NewBlocksContent(common.Style{Direction: common.DirectionVertical, AlignItems: common.AlignItemsCenter, Gap: 0}, blocks...) @@ -67,11 +67,6 @@ func uniqueBlockWN8(style overviewStyle, stats prepare.StatsBlock[period.BlockDa func uniqueBlockRating(style overviewStyle, stats prepare.StatsBlock[period.BlockData, string]) common.Block { var blocks []common.Block - ratingColors := common.GetRatingColors(stats.Value().Float()) - if stats.Value().Float() <= 0 { - ratingColors.Background = common.TextAlt - } - var valueBlocks []common.Block iconTop, ok := common.GetRatingIcon(stats.V, ratingIconSize) if ok { @@ -83,7 +78,7 @@ func uniqueBlockRating(style overviewStyle, stats prepare.StatsBlock[period.Bloc blocks = append(blocks, common.NewBlocksContent(style.blockContainer, valueBlocks...)) if stats.Value().Float() != frame.InvalidValue.Float() { - blocks = append(blocks, common.NewBlocksContent(overviewSpecialRatingPillStyle(ratingColors.Background), common.NewTextContent(labelStyle, common.GetRatingTierName(stats.Value().Float())))) + blocks = append(blocks, common.NewBlocksContent(overviewSpecialRatingPillStyle(), common.NewTextContent(labelStyle, common.GetRatingTierName(stats.Value().Float())))) } return common.NewBlocksContent(common.Style{Direction: common.DirectionVertical, AlignItems: common.AlignItemsCenter, Gap: 0}, blocks...) diff --git a/internal/stats/render/period/v1/cards.go b/internal/stats/render/period/v1/cards.go index 53177f97..294efdda 100644 --- a/internal/stats/render/period/v1/cards.go +++ b/internal/stats/render/period/v1/cards.go @@ -48,7 +48,7 @@ func generateCards(stats fetch.AccountStatsOverPeriod, cards period.Cards, subs labelSize := common.MeasureString(label, labelStyle.Font) valueSize := common.MeasureString(block.Value().String(), valueStyle.Font) - overviewColumnWidth = max(overviewColumnWidth, max(labelSize.TotalWidth+overviewSpecialRatingPillStyle(nil).PaddingX*2, valueSize.TotalWidth)) + overviewColumnWidth = max(overviewColumnWidth, max(labelSize.TotalWidth+overviewSpecialRatingPillStyle().PaddingX*2, valueSize.TotalWidth)) } } diff --git a/internal/stats/render/period/v1/constants.go b/internal/stats/render/period/v1/constants.go index 19ee8d75..0deb5355 100644 --- a/internal/stats/render/period/v1/constants.go +++ b/internal/stats/render/period/v1/constants.go @@ -1,8 +1,6 @@ package period import ( - "image/color" - common "github.com/cufee/aftermath/internal/render/v1" prepare "github.com/cufee/aftermath/internal/stats/prepare/common/v1" "github.com/cufee/aftermath/internal/stats/prepare/period/v1" @@ -102,7 +100,7 @@ func overviewCardBlocksStyle(width float64) common.Style { return style } -func overviewSpecialRatingPillStyle(_ color.Color) common.Style { +func overviewSpecialRatingPillStyle() common.Style { return common.Style{} } diff --git a/internal/stats/render/session/v1/blocks.go b/internal/stats/render/session/v1/blocks.go index 99b7fd44..8f692d02 100644 --- a/internal/stats/render/session/v1/blocks.go +++ b/internal/stats/render/session/v1/blocks.go @@ -25,7 +25,12 @@ func makeSpecialRatingColumn(block prepare.StatsBlock[session.BlockData, string] iconTop := common.AftermathLogo(ratingColors.Background, common.DefaultLogoOptions()) column = append(column, common.NewImageContent(common.Style{Width: specialWN8IconSize, Height: specialWN8IconSize}, iconTop)) - column = append(column, common.NewBlocksContent(overviewColumnStyle(width), + column = append(column, common.NewBlocksContent(common.Style{ + Width: width, + AlignItems: common.AlignItemsCenter, + Direction: common.DirectionVertical, + JustifyContent: common.JustifyContentCenter, + }, blockWithDoubleVehicleIcon(common.NewTextContent(blockStyle.session, block.Data.Session().String()), block.Data.Session(), block.Data.Career()), common.NewBlocksContent( overviewSpecialRatingPillStyle(), @@ -41,7 +46,12 @@ func makeSpecialRatingColumn(block prepare.StatsBlock[session.BlockData, string] column = append(column, icon) } - column = append(column, common.NewBlocksContent(overviewColumnStyle(width), + column = append(column, common.NewBlocksContent(common.Style{ + Width: width, + AlignItems: common.AlignItemsCenter, + Direction: common.DirectionVertical, + JustifyContent: common.JustifyContentCenter, + }, blockWithDoubleVehicleIcon(common.NewTextContent(blockStyle.session, block.Data.Session().String()), block.Data.Session(), block.Data.Career()), common.NewBlocksContent( overviewSpecialRatingPillStyle(), From 078a8fc1c69e343f8e5e71cf795ec34554a8ae50 Mon Sep 17 00:00:00 2001 From: Vovko Date: Wed, 15 Jan 2025 16:31:40 -0500 Subject: [PATCH 10/39] fixed missing autocomplete match keys --- cmd/discord/commands/public/autocomplete.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/cmd/discord/commands/public/autocomplete.go b/cmd/discord/commands/public/autocomplete.go index 858ab6c9..b20b1e78 100644 --- a/cmd/discord/commands/public/autocomplete.go +++ b/cmd/discord/commands/public/autocomplete.go @@ -25,7 +25,7 @@ func init() { ComponentType(func(s string) bool { var keys []string keys = append(keys, "autocomplete_links_favorite_selected", "autocomplete_links_remove_selected") // links - keys = append(keys, "autocomplete_my_session_account", "autocomplete_my_stats_account") // my + keys = append(keys, "autocomplete_my_session_account", "autocomplete_my_career_account") // my keys = append(keys, "autocomplete_widget_account") // widget return slices.Contains(keys, s) }). @@ -63,8 +63,8 @@ func init() { builder.NewCommand("autocomplete_tank_search"). ComponentType(func(s string) bool { var keys []string - keys = append(keys, "autocomplete_stats_tank", "autocomplete_session_tank") // stats/session - keys = append(keys, "autocomplete_my_session_tank", "autocomplete_my_stats_tank") // my + keys = append(keys, "autocomplete_career_tank", "autocomplete_stats_tank", "autocomplete_session_tank") // stats/session + keys = append(keys, "autocomplete_my_session_tank", "autocomplete_my_career_tank") // my return slices.Contains(keys, s) }). Handler(func(ctx common.Context) error { @@ -100,9 +100,9 @@ func init() { builder.NewCommand("autocomplete_account_search"). ComponentType(func(s string) bool { var keys []string - keys = append(keys, "autocomplete_manage_accounts_search_nickname") // manage - keys = append(keys, "autocomplete_stats_nickname", "autocomplete_session_nickname") // stats/session - keys = append(keys, "autocomplete_links_add_nickname") // links + keys = append(keys, "autocomplete_manage_accounts_search_nickname") // manage + keys = append(keys, "autocomplete_career_nickname", "autocomplete_stats_nickname", "autocomplete_session_nickname") // stats/session + keys = append(keys, "autocomplete_links_add_nickname") // links return slices.Contains(keys, s) }). Handler(func(ctx common.Context) error { From 1f38c165effeb72d346d72255bb3b81f0656ba58 Mon Sep 17 00:00:00 2001 From: Vovko Date: Wed, 15 Jan 2025 16:36:58 -0500 Subject: [PATCH 11/39] fixed subcommand options --- cmd/discord/commands/public/interactions.go | 2 +- cmd/discord/commands/public/my.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/discord/commands/public/interactions.go b/cmd/discord/commands/public/interactions.go index 264f662b..bbd88cf4 100644 --- a/cmd/discord/commands/public/interactions.go +++ b/cmd/discord/commands/public/interactions.go @@ -315,7 +315,7 @@ func init() { var image stats.Image var meta stats.Metadata switch interaction.EventID { - case "stats": + case "career", "stats": img, mt, err := ctx.Core().Stats(ctx.Locale()).PeriodImage(context.Background(), ioptions.AccountID, ioptions.PeriodStart, opts...) if errors.Is(err, blitzstars.ErrServiceUnavailable) { return ctx.Reply(). diff --git a/cmd/discord/commands/public/my.go b/cmd/discord/commands/public/my.go index 0e9a1579..9ddf1c63 100644 --- a/cmd/discord/commands/public/my.go +++ b/cmd/discord/commands/public/my.go @@ -85,7 +85,7 @@ func init() { var err error var image stats.Image switch subcommand { - case "stats": + case "career": image, _, err = ctx.Core().Stats(ctx.Locale()).PeriodImage(context.Background(), accountID, options.PeriodStart, opts...) case "session": image, _, err = ctx.Core().Stats(ctx.Locale()).SessionImage(context.Background(), accountID, options.PeriodStart, opts...) From db9e259b5b672d3e37fa27474223939043706418 Mon Sep 17 00:00:00 2001 From: Vovko Date: Thu, 16 Jan 2025 14:31:27 -0500 Subject: [PATCH 12/39] cleaned up compose --- docker-compose.base.yaml | 1 - docker-compose.dokploy.yaml | 2 ++ docker-compose.yaml | 3 +++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docker-compose.base.yaml b/docker-compose.base.yaml index 2c934606..2f07c5a4 100644 --- a/docker-compose.base.yaml +++ b/docker-compose.base.yaml @@ -25,7 +25,6 @@ services: dockerfile: Dockerfile args: BRAND_FLAVOR: ${BRAND_FLAVOR} - restart: always volumes: - ${DATABASE_PATH}:/data env_file: diff --git a/docker-compose.dokploy.yaml b/docker-compose.dokploy.yaml index 002d2730..373b65b9 100644 --- a/docker-compose.dokploy.yaml +++ b/docker-compose.dokploy.yaml @@ -3,6 +3,7 @@ services: extends: file: docker-compose.base.yaml service: aftermath-collector-base + restart: always environment: - COLLECTOR_BACKEND_URL=aftermath-service:${PRIVATE_SERVER_PORT} networks: @@ -19,6 +20,7 @@ services: extends: file: docker-compose.base.yaml service: aftermath-service-base + restart: always environment: # the rest is imported from .env, which is going to be created by Dokploy automatically - PORT=3000 # the port does not matter, but it needs to match Traefik labels. we set it here explicitly in order to avoid any issues diff --git a/docker-compose.yaml b/docker-compose.yaml index 00de8bc7..67e32249 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -3,17 +3,20 @@ services: extends: file: docker-compose.base.yaml service: aftermath-collector-base + restart: no command: --backend=aftermath-service:${PRIVATE_SERVER_PORT} aftermath-migrate: extends: file: docker-compose.base.yaml service: aftermath-migrate-base + restart: no aftermath-service: extends: file: docker-compose.base.yaml service: aftermath-service-base + restart: no environment: # use the default port from .env - DATABASE_PATH=/data # this is the path inside a container and needs to match the volume mount From f6d14021e47c865b45b5666c4e6d96400e21125e Mon Sep 17 00:00:00 2001 From: Vovko Date: Fri, 17 Jan 2025 15:49:08 -0500 Subject: [PATCH 13/39] kinda works --- internal/render/v2/block.go | 87 +++++++ internal/render/v2/ceil.go | 7 + internal/render/v2/content-blocks.go | 289 ++++++++++++++++++++++ internal/render/v2/content-text.go | 151 +++++++++++ internal/render/v2/debug.go | 11 + internal/render/v2/internal/tests/root.go | 13 + internal/render/v2/measure.go | 51 ++++ internal/render/v2/style/font.go | 41 +++ internal/render/v2/style/options.go | 111 +++++++++ internal/render/v2/style/style.go | 83 +++++++ render_test.go | 47 ++++ 11 files changed, 891 insertions(+) create mode 100644 internal/render/v2/block.go create mode 100644 internal/render/v2/ceil.go create mode 100644 internal/render/v2/content-blocks.go create mode 100644 internal/render/v2/content-text.go create mode 100644 internal/render/v2/debug.go create mode 100644 internal/render/v2/internal/tests/root.go create mode 100644 internal/render/v2/measure.go create mode 100644 internal/render/v2/style/font.go create mode 100644 internal/render/v2/style/options.go create mode 100644 internal/render/v2/style/style.go diff --git a/internal/render/v2/block.go b/internal/render/v2/block.go new file mode 100644 index 00000000..1a61d9c1 --- /dev/null +++ b/internal/render/v2/block.go @@ -0,0 +1,87 @@ +package render + +import ( + "fmt" + "image" + + "github.com/cufee/aftermath/internal/render/v2/style" + "github.com/fogleman/gg" +) + +func NewBlock(content BlockContent) *Block { + return &Block{ + content: content, + } +} + +type blockContentType int + +func (t blockContentType) MarshalJSON() ([]byte, error) { + return []byte(fmt.Sprintf("%d", t)), nil +} +func (t blockContentType) String() string { + return fmt.Sprintf("%d", t) +} + +const ( + BlockContentTypeEmpty blockContentType = iota + BlockContentTypeBlocks + BlockContentTypeImage + BlockContentTypeText +) + +type Position struct { + X float64 + Y float64 +} + +type BlockContent interface { + Type() blockContentType + + // Renders the block onto an image + Render(*gg.Context, Position) error + + Style() style.StyleOptions + setStyle(style.StyleOptions) + + // returns final block image dimensions without rendering + dimensions() contentDimensions +} + +type Block struct { + content BlockContent +} + +func (b *Block) Style() style.StyleOptions { + return b.content.Style() +} + +func (b *Block) Type() blockContentType { + return b.content.Type() +} + +func (b *Block) Render() (image.Image, error) { + dimensions := b.content.dimensions() + ctx := gg.NewContext(dimensions.width, dimensions.height) + err := b.RenderTo(ctx, Position{0, 0}) + if err != nil { + return nil, err + } + return ctx.Image(), nil + +} + +func (b *Block) RenderTo(ctx *gg.Context, pos Position) error { + return b.content.Render(ctx, pos) +} + +func (b *Block) Dimensions() contentDimensions { + return b.content.dimensions() +} + +type contentDimensions struct { + width int + height int + paddingAndGapsX float64 + paddingAndGapsY float64 +} diff --git a/internal/render/v2/ceil.go b/internal/render/v2/ceil.go new file mode 100644 index 00000000..5c5a58b6 --- /dev/null +++ b/internal/render/v2/ceil.go @@ -0,0 +1,7 @@ +package render + +import "math" + +func ceil(value float64) int { + return int(math.Ceil(value)) +} diff --git a/internal/render/v2/content-blocks.go b/internal/render/v2/content-blocks.go new file mode 100644 index 00000000..15043d59 --- /dev/null +++ b/internal/render/v2/content-blocks.go @@ -0,0 +1,289 @@ +package render + +import ( + "errors" + "fmt" + + "github.com/cufee/aftermath/internal/render/v2/style" + "github.com/fogleman/gg" + "github.com/nao1215/imaging" +) + +var _ BlockContent = &contentBlocks{} + +func NewBlocksContent(style style.StyleOptions, value ...*Block) *Block { + return NewBlock(&contentBlocks{ + value: value, + style: style, + }) +} + +type contentBlocks struct { + style style.StyleOptions + value []*Block +} + +func (content *contentBlocks) setStyle(style style.StyleOptions) { + content.style = style +} + +func (content *contentBlocks) dimensions() contentDimensions { + if len(content.value) == 0 { + return contentDimensions{} + } + + computed := content.style.Computed() + dimensions := contentDimensions{ + width: ceil(computed.Width), + height: ceil(computed.Height), + paddingAndGapsX: computed.PaddingLeft + computed.PaddingRight, + paddingAndGapsY: computed.PaddingTop + computed.PaddingBottom, + } + + if dimensions.width > 0 && dimensions.height > 0 { + return dimensions + } + + // add content dimensions of each block to the total + var blockWidthTotal, blockWidthMax, blockHeightTotal, blockHeightMax int + for _, block := range content.value { + blockDimensions := block.content.dimensions() + + blockWidthTotal += blockDimensions.width + blockWidthMax = max(blockWidthMax, blockDimensions.width) + + blockHeightTotal += blockDimensions.height + blockHeightMax = max(blockHeightMax, blockDimensions.height) + } + + // calculate final block width if it was not set already + if dimensions.width == 0 { + dimensions.width = ceil(computed.PaddingLeft) + ceil(computed.PaddingRight) + + switch computed.Direction { + case style.DirectionHorizontal: + dimensions.width += blockWidthTotal + + gaps := computed.Gap * float64(len(content.value)-1) + dimensions.width += ceil(gaps) + dimensions.paddingAndGapsX += gaps + + case style.DirectionVertical: + dimensions.width += blockWidthMax + } + } + // calculate final block height if it was not set already + if dimensions.height == 0 { + dimensions.height = ceil(computed.PaddingTop + computed.PaddingBottom) + + switch computed.Direction { + case style.DirectionHorizontal: + dimensions.height += blockHeightMax + case style.DirectionVertical: + dimensions.height += blockHeightTotal + + gaps := computed.Gap * float64(len(content.value)-1) + dimensions.height += ceil(gaps) + dimensions.paddingAndGapsY += gaps + + } + } + + return dimensions +} + +func (content *contentBlocks) Type() blockContentType { + return BlockContentTypeBlocks +} + +func (content *contentBlocks) Style() style.StyleOptions { + return content.style +} + +func (content *contentBlocks) Render(ctx *gg.Context, pos Position) error { + computed := content.style.Computed() + dimensions := content.dimensions() + + if computed.Blur > 0 { + blur := computed.Blur + computed.Blur = 0 + // render the content onto a new image, blur it, render onto parent + child := gg.NewContext(dimensions.width, dimensions.height) + err := content.Render(child, Position{0, 0}) + if err != nil { + return err + } + img := imaging.Blur(ctx.Image(), blur) + ctx.DrawImage(img, ceil(pos.X), ceil(pos.Y)) + return nil + } + + var originX, originY float64 = pos.X + computed.PaddingLeft, pos.Y + computed.PaddingTop + if computed.Position == style.PositionAbsolute { + originX += computed.MarginLeft + originY += computed.MarginTop + } + + if computed.BackgroundColor != nil { + ctx.SetColor(computed.BackgroundColor) + ctx.DrawRectangle(originX, originY, float64(dimensions.width), float64(dimensions.height)) + ctx.Fill() + } + if computed.BackgroundImage != nil { + background := imaging.Fill(computed.BackgroundImage, dimensions.width, dimensions.height, imaging.Center, imaging.Lanczos) + ctx.DrawImage(background, ceil(originX), ceil(originY)) + } + + if computed.Debug { + ctx.SetColor(getDebugColor()) + ctx.DrawRectangle(pos.X, pos.Y, float64(dimensions.width), float64(dimensions.height)) + ctx.Stroke() + } + + applyGrowth(computed, dimensions, content.value...) + return renderBlocksContent(ctx, computed, dimensions, pos, content.value...) +} + +func renderBlocksContent(ctx *gg.Context, containerStyle style.Style, container contentDimensions, pos Position, blocks ...*Block) error { + if len(blocks) < 1 { + return errors.New("no blocks to render") + } + + var originX, originY = pos.X + containerStyle.PaddingLeft, pos.Y + containerStyle.PaddingTop + + var lastX, lastY float64 = originX, originY + var justifyOffsetX, justifyOffsetY float64 + + var freeSpaceX, freeSpaceY = float64(container.width) - container.paddingAndGapsX, float64(container.height) - container.paddingAndGapsY + + // Set correct gaps and offsets based on justify content + switch containerStyle.JustifyContent { + case style.JustifyContentCenter: + lastX += freeSpaceX / 2 + lastY += freeSpaceY / 2 + case style.JustifyContentEnd: + lastX += freeSpaceX + lastY += freeSpaceY + case style.JustifyContentSpaceBetween: + if len(blocks) > 0 { + justifyOffsetX = float64(freeSpaceX / float64(len(blocks)-1)) + justifyOffsetY = float64(freeSpaceY / float64(len(blocks)-1)) + } + case style.JustifyContentSpaceAround: + spacingX := float64(freeSpaceX / float64(len(blocks)+1)) + spacingY := float64(freeSpaceY / float64(len(blocks)+1)) + justifyOffsetX = spacingX + justifyOffsetY = spacingY + lastX += spacingX + lastY += spacingY + default: // JustifyContentStart + } + + for i, block := range blocks { + blockSize := block.content.dimensions() + posX, posY := lastX, lastY + + switch containerStyle.Direction { + case style.DirectionVertical: + if i > 0 { + posY += justifyOffsetY + containerStyle.Gap + } + lastY = posY + float64(blockSize.height) + + switch containerStyle.AlignItems { + case style.AlignItemsCenter: + posX = float64(container.width-blockSize.width) / 2 + case style.AlignItemsEnd: + posX = float64(container.width-blockSize.width) - containerStyle.PaddingRight + default: // AlignItemsStart + posX = containerStyle.PaddingLeft + } + default: // DirectionHorizontal + if i > 0 { + posX += justifyOffsetX + containerStyle.Gap + } + lastX = posX + float64(blockSize.width) + + switch containerStyle.AlignItems { + case style.AlignItemsCenter: + posY = float64(container.height-blockSize.height) / 2 + case style.AlignItemsEnd: + posY = float64(container.height-blockSize.height) - containerStyle.PaddingBottom + default: // AlignItemsStart + posY = containerStyle.PaddingTop + } + + } + + err := block.content.Render(ctx, Position{posX, posY}) + if err != nil { + return err + } + } + + return nil +} + +func applyGrowth(containerStyle style.Style, container contentDimensions, blocks ...*Block) { + // calculate content dimensions before growth + var blockWidthTotal, blockWidthMax, blockHeightTotal, blockHeightMax int + var growBlocksX, growBlocksY = 0, 0 + for _, block := range blocks { + blockDimensions := block.content.dimensions() + + blockWidthTotal += blockDimensions.width + blockWidthMax = max(blockWidthMax, blockDimensions.width) + + blockHeightTotal += blockDimensions.height + blockHeightMax = max(blockHeightMax, blockDimensions.height) + + style := block.Style().Computed() + if style.GrowHorizontal { + growBlocksX++ + } + if style.GrowVertical { + growBlocksY++ + } + } + + // calculate empty space blocks can use to grow + var growSpaceX, growSpaceY = 0, 0 + if growBlocksX > 0 { + switch containerStyle.Direction { + case style.DirectionHorizontal: + growSpaceX = container.width - ceil(container.paddingAndGapsX) - blockWidthTotal + case style.DirectionVertical: + growSpaceX = container.width - ceil(container.paddingAndGapsX) - blockWidthMax + } + } + if growBlocksY > 0 { + switch containerStyle.Direction { + case style.DirectionHorizontal: + growSpaceY = container.height - ceil(container.paddingAndGapsY) - blockHeightMax + case style.DirectionVertical: + growSpaceY = container.height - ceil(container.paddingAndGapsY) - blockWidthTotal + } + } + + fmt.Printf("grow x %v container %v %v blocks %v \n", growSpaceX, container.width, container.paddingAndGapsX, blockWidthTotal) + + // apply growth to blocks + if growBlocksX > 0 || growBlocksY > 0 { + var blockGrowX, blockGrowY = max(0, growSpaceX) / max(1, growBlocksX), max(0, growSpaceY) / max(1, growBlocksY) + for _, block := range blocks { + blockStyle := block.Style() + blockComputed := blockStyle.Computed() + + // update the block width + if blockComputed.GrowHorizontal { + blockStyle.Add(style.SetWidth(blockComputed.Width + float64(blockGrowX))) + block.content.setStyle(blockStyle) + } + // update the block height + if blockComputed.GrowVertical { + blockStyle.Add(style.SetHeight(blockComputed.Height + float64(blockGrowY))) + block.content.setStyle(blockStyle) + } + } + } +} diff --git a/internal/render/v2/content-text.go b/internal/render/v2/content-text.go new file mode 100644 index 00000000..38248334 --- /dev/null +++ b/internal/render/v2/content-text.go @@ -0,0 +1,151 @@ +package render + +import ( + "math" + + "github.com/cufee/aftermath/internal/render/v2/style" + "github.com/fogleman/gg" + "github.com/nao1215/imaging" + "github.com/pkg/errors" +) + +var _ BlockContent = &contentText{} + +func NewTextContent(style style.StyleOptions, value string) (*Block, error) { + if !style.Computed().Font.Valid() { + return nil, errors.New("invalid or missing font") + } + return NewBlock(&contentText{ + value: value, + style: style, + }), nil +} + +func MustNewTextContent(style style.StyleOptions, value string) *Block { + c, _ := NewTextContent(style, value) + return c +} + +type contentText struct { + style style.StyleOptions + value string + + dimensionsCache *contentDimensions // add cache to avoid parsing and rendering fonts repeatedly + sizeCache *StringSize // add cache to avoid parsing and rendering fonts repeatedly +} + +func (content *contentText) setStyle(style style.StyleOptions) { + content.dimensionsCache = nil + content.sizeCache = nil + content.style = style +} + +func (content *contentText) measure(font style.Font) StringSize { + size := MeasureString(content.value, font) + content.sizeCache = &size + return size +} + +func (content *contentText) dimensions() contentDimensions { + if content.dimensionsCache != nil { + return *content.dimensionsCache + } + + computed := content.style.Computed() + size := content.measure(computed.Font) + + var width, height = 0.0, 0.0 + if computed.Width > 0 { + width = computed.Width + } else { + width = size.TotalWidth + ((computed.PaddingLeft + computed.PaddingRight) * 2) + } + if computed.Height > 0 { + height = computed.Height + } else { + height = size.TotalHeight + ((computed.PaddingTop + computed.PaddingBottom) * 2) + } + + content.dimensionsCache = &contentDimensions{width: int(math.Ceil(width)), height: int(math.Ceil(height))} + return *content.dimensionsCache +} + +func (content *contentText) Type() blockContentType { + return BlockContentTypeText +} + +func (content *contentText) Style() style.StyleOptions { + return content.style +} + +func (content *contentText) Render(ctx *gg.Context, pos Position) error { + computed := content.style.Computed() + size := content.measure(computed.Font) + dimensions := content.dimensions() + + if computed.Blur > 0 { + blur := computed.Blur + computed.Blur = 0 + // render the content onto a new image, blur it, render onto parent + child := gg.NewContext(dimensions.width, dimensions.height) + err := content.Render(child, Position{0, 0}) + if err != nil { + return err + } + img := imaging.Blur(ctx.Image(), blur) + ctx.DrawImage(img, ceil(pos.X), ceil(pos.Y)) + return nil + } + + var originX, originY float64 = pos.X + computed.PaddingLeft, pos.Y + computed.PaddingTop + 1 + if computed.Position == style.PositionAbsolute { + originX += computed.MarginLeft + originY += computed.MarginTop + } + + if computed.BackgroundColor != nil { + ctx.SetColor(computed.BackgroundColor) + ctx.DrawRectangle(originX, originY, float64(dimensions.width), float64(dimensions.height)) + ctx.Fill() + } + if computed.BackgroundImage != nil { + background := imaging.Fill(computed.BackgroundImage, dimensions.width, dimensions.height, imaging.Center, imaging.Lanczos) + ctx.DrawImage(background, ceil(originX), ceil(originY)) + } + + if computed.Debug { + ctx.SetColor(getDebugColor()) + ctx.DrawRectangle(pos.X, pos.Y, float64(dimensions.width), float64(dimensions.height)) + ctx.Stroke() + } + + var lastX, lastY float64 = originX, originY + + switch computed.JustifyContent { + case style.JustifyContentEnd: + lastX += float64(dimensions.width) - size.TotalWidth + case style.JustifyContentCenter: + lastX += (float64(dimensions.width) - size.TotalWidth) / 2 + } + switch computed.AlignItems { + case style.AlignItemsEnd: + lastY += float64(dimensions.width) - size.TotalHeight + case style.AlignItemsCenter: + lastY += (float64(dimensions.width) - size.TotalHeight) / 2 + } + + // Render text + face, close := computed.Font.Face() + defer close() + + ctx.SetFontFace(face) + ctx.SetColor(computed.Color) + + // for _, str := range strings.Split(content.value, "\n") { + // lastY += size.LineHeight + // x, y := lastX, lastY-size.LineOffset + // ctx.DrawString(str, x, y) + // } + + return nil +} diff --git a/internal/render/v2/debug.go b/internal/render/v2/debug.go new file mode 100644 index 00000000..9dc8b248 --- /dev/null +++ b/internal/render/v2/debug.go @@ -0,0 +1,11 @@ +package render + +import ( + "image/color" + "time" +) + +func getDebugColor() color.Color { + ns := time.Now().Nanosecond() + return color.NRGBA{uint8(ns%120) + 120, uint8(ns % 200), uint8(ns % 200), 255} +} diff --git a/internal/render/v2/internal/tests/root.go b/internal/render/v2/internal/tests/root.go new file mode 100644 index 00000000..57990046 --- /dev/null +++ b/internal/render/v2/internal/tests/root.go @@ -0,0 +1,13 @@ +package tests + +import ( + "path/filepath" + "runtime" +) + +func Root() string { + _, b, _, _ := runtime.Caller(0) + basepath := filepath.Dir(b) + root, _ := filepath.Abs(filepath.Join(basepath, "../../")) + return root +} diff --git a/internal/render/v2/measure.go b/internal/render/v2/measure.go new file mode 100644 index 00000000..1ccc2400 --- /dev/null +++ b/internal/render/v2/measure.go @@ -0,0 +1,51 @@ +package render + +import ( + "strings" + + "github.com/cufee/aftermath/internal/render/v2/style" + "github.com/fogleman/gg" +) + +type StringSize struct { + TotalWidth float64 + TotalHeight float64 + LineOffset float64 + LineHeight float64 +} + +func MeasureString(text string, font style.Font) StringSize { + if !font.Valid() { + return StringSize{} + } + if text == "" { + return StringSize{} + } + + face, close := font.Face() + defer close() + + measureCtx := gg.NewContext(1, 1) + measureCtx.SetFontFace(face) + + var result StringSize + // Account for font descender height + result.LineOffset = float64(face.Metrics().Descent>>6) * 2 + + for _, line := range strings.Split(text, "\n") { + w, h := measureCtx.MeasureString(line) + h += result.LineOffset + w += 1 + + if w > result.TotalWidth { + result.TotalWidth = w + } + if h > result.LineHeight { + result.LineHeight = h + } + + result.TotalHeight += h + } + + return result +} diff --git a/internal/render/v2/style/font.go b/internal/render/v2/style/font.go new file mode 100644 index 00000000..1145c758 --- /dev/null +++ b/internal/render/v2/style/font.go @@ -0,0 +1,41 @@ +package style + +import ( + "sync" + + "github.com/golang/freetype/truetype" + "golang.org/x/image/font" +) + +type Font interface { + Size() float64 + Valid() bool + Face() (font.Face, func() error) +} + +type fontType struct { + size float64 + face font.Face + mx *sync.Mutex +} + +func (f *fontType) Size() float64 { + return f.size +} + +func (f *fontType) Valid() bool { + return f.face != nil +} + +func (f *fontType) Face() (font.Face, func() error) { + f.mx.Lock() + return f.face, func() error { f.mx.Unlock(); return nil } +} + +func NewFont(data []byte, size float64) Font { + ttf, _ := truetype.Parse(data) + face := truetype.NewFace(ttf, &truetype.Options{ + Size: size, + }) + return &fontType{size, face, &sync.Mutex{}} +} diff --git a/internal/render/v2/style/options.go b/internal/render/v2/style/options.go new file mode 100644 index 00000000..f374de63 --- /dev/null +++ b/internal/render/v2/style/options.go @@ -0,0 +1,111 @@ +package style + +func NewStyle(opts ...styleOption) StyleOptions { + return opts +} + +type styleOption func(s *Style) + +type StyleOptions []styleOption + +func (o StyleOptions) Computed() Style { + var s Style + for _, apply := range o { + apply(&s) + } + return s +} + +func (arr *StyleOptions) Add(opt styleOption) { + *arr = append(*arr, opt) +} + +func Parent(parent Style) styleOption { + return func(s *Style) { *s = parent } +} + +func SetFont(value Font) styleOption { + return func(s *Style) { s.Font = value } +} + +func SetDebug(value bool) styleOption { + return func(s *Style) { s.Debug = value } +} + +func SetWidth(value float64) styleOption { + return func(s *Style) { s.Width = value } +} +func SetHeight(value float64) styleOption { + return func(s *Style) { s.Height = value } +} + +func SetPadding(value float64) styleOption { + return func(s *Style) { + s.PaddingLeft = value + s.PaddingRight = value + s.PaddingTop = value + s.PaddingBottom = value + } +} +func SetPaddingX(value float64) styleOption { + return func(s *Style) { + s.PaddingLeft = value + s.PaddingRight = value + } +} +func SetPaddingY(value float64) styleOption { + return func(s *Style) { + s.PaddingTop = value + s.PaddingBottom = value + } +} + +func SetGrow(value bool) styleOption { + return func(s *Style) { + s.GrowHorizontal = value + s.GrowVertical = value + } +} +func SetGrowX(value bool) styleOption { + return func(s *Style) { + s.GrowHorizontal = value + } +} +func SetGrowY(value bool) styleOption { + return func(s *Style) { + s.GrowVertical = value + } +} + +func SetBorderRadius(value float64) styleOption { + return func(s *Style) { + s.BorderRadiusTopLeft = value + s.BorderRadiusTopRight = value + s.BorderRadiusBottomLeft = value + s.BorderRadiusBottomRight = value + } +} +func SetBorderRadiusLeft(value float64) styleOption { + return func(s *Style) { + s.BorderRadiusTopLeft = value + s.BorderRadiusBottomLeft = value + } +} +func SetBorderRadiusRight(value float64) styleOption { + return func(s *Style) { + s.BorderRadiusTopRight = value + s.BorderRadiusBottomRight = value + } +} +func SetBorderRadiusTop(value float64) styleOption { + return func(s *Style) { + s.BorderRadiusTopLeft = value + s.BorderRadiusTopRight = value + } +} +func SetBorderRadiusBottom(value float64) styleOption { + return func(s *Style) { + s.BorderRadiusBottomLeft = value + s.BorderRadiusBottomRight = value + } +} diff --git a/internal/render/v2/style/style.go b/internal/render/v2/style/style.go new file mode 100644 index 00000000..7f249bef --- /dev/null +++ b/internal/render/v2/style/style.go @@ -0,0 +1,83 @@ +package style + +import ( + "image" + "image/color" +) + +type alignItemsValue byte +type justifyContentValue byte + +const ( + AlignItemsStart alignItemsValue = iota + AlignItemsCenter + AlignItemsEnd + + JustifyContentStart justifyContentValue = iota + JustifyContentCenter + JustifyContentEnd + JustifyContentSpaceBetween // Spacing between each element is the same + JustifyContentSpaceAround // Spacing around all element is the same +) + +type directionValue byte + +const ( + DirectionHorizontal directionValue = iota + DirectionVertical +) + +type positionValue byte + +const ( + PositionRelative positionValue = iota + PositionAbsolute +) + +type overflowValue byte + +const ( + OverflowVisible overflowValue = iota + OverflowHidden +) + +type Style struct { + Debug bool + + Width float64 + Height float64 + + Blur float64 + + Font Font + + Color color.Color + BackgroundColor color.Color + BackgroundImage image.Image + + Overflow overflowValue + + JustifyContent justifyContentValue + AlignItems alignItemsValue // Depends on Direction + Direction directionValue + + Gap float64 + + PaddingLeft float64 + PaddingRight float64 + PaddingTop float64 + PaddingBottom float64 + + Position positionValue + // margin should generally be used for absolute position + MarginLeft float64 + MarginTop float64 + + GrowHorizontal bool + GrowVertical bool + + BorderRadiusTopLeft float64 + BorderRadiusTopRight float64 + BorderRadiusBottomLeft float64 + BorderRadiusBottomRight float64 +} diff --git a/render_test.go b/render_test.go index fedd364d..837d32f2 100644 --- a/render_test.go +++ b/render_test.go @@ -5,12 +5,15 @@ import ( "context" "image/png" "os" + "path/filepath" "slices" "testing" "time" "github.com/cufee/aftermath/internal/localization" rc "github.com/cufee/aftermath/internal/render/v1" + renderV2 "github.com/cufee/aftermath/internal/render/v2" + "github.com/cufee/aftermath/internal/render/v2/style" client "github.com/cufee/aftermath/internal/stats/client/v1" "github.com/cufee/aftermath/internal/stats/fetch/v1" "github.com/cufee/aftermath/internal/stats/prepare/common/v1" @@ -19,6 +22,7 @@ import ( session "github.com/cufee/aftermath/internal/stats/render/session/v1" "github.com/cufee/aftermath/tests" "github.com/cufee/aftermath/tests/env" + "github.com/cufee/aftermath/tests/path" "github.com/matryer/is" "github.com/nao1215/imaging" "github.com/rs/zerolog" @@ -232,3 +236,46 @@ func TestRenderReplay(t *testing.T) { }) } } + +func TestRenderV2(t *testing.T) { + is := is.New(t) + + text1, err := renderV2.NewTextContent(style.NewStyle( + style.SetDebug(true), + style.SetFont(rc.FontLarge()), + style.SetWidth(100), + ), "TEST - 1") + is.NoErr(err) + + text2, err := renderV2.NewTextContent(style.NewStyle( + style.SetDebug(true), + // style.SetGrowX(true), + style.SetWidth(100), + style.SetFont(rc.FontLarge()), + ), "TEST - 2") + is.NoErr(err) + + block1 := renderV2.NewBlocksContent(style.NewStyle( + style.SetDebug(true), + style.SetPadding(10), + // style.SetGrowX(true), + ), text2) + + block2 := renderV2.NewBlocksContent(style.NewStyle( + style.Parent(style.Style{ + Gap: 10, + }), + style.SetDebug(true), + style.SetPadding(10), + // style.SetWidth(400), + ), text1, block1) + + img, err := block2.Render() + is.NoErr(err) + + f, err := os.Create(filepath.Join(path.Root(), "tmp", "test_render_blocks.png")) + is.NoErr(err) + + err = png.Encode(f, img) + is.NoErr(err) +} From 9575584fb96a3ba6ac82dcb006e591643dcacdba Mon Sep 17 00:00:00 2001 From: Vovko Date: Fri, 17 Jan 2025 18:27:19 -0500 Subject: [PATCH 14/39] fixing buggos --- internal/render/v2/content-blocks.go | 109 +++++----- internal/render/v2/content-empty.go | 65 ++++++ internal/render/v2/content-text.go | 9 +- internal/render/v2/debug.go | 2 +- internal/render/v2/render_test.go | 297 +++++++++++++++++++++++++++ internal/render/v2/style/style.go | 11 +- render_test.go | 11 +- 7 files changed, 438 insertions(+), 66 deletions(-) create mode 100644 internal/render/v2/content-empty.go create mode 100644 internal/render/v2/render_test.go diff --git a/internal/render/v2/content-blocks.go b/internal/render/v2/content-blocks.go index 15043d59..868dae1a 100644 --- a/internal/render/v2/content-blocks.go +++ b/internal/render/v2/content-blocks.go @@ -2,7 +2,6 @@ package render import ( "errors" - "fmt" "github.com/cufee/aftermath/internal/render/v2/style" "github.com/fogleman/gg" @@ -118,20 +117,19 @@ func (content *contentBlocks) Render(ctx *gg.Context, pos Position) error { return nil } - var originX, originY float64 = pos.X + computed.PaddingLeft, pos.Y + computed.PaddingTop - if computed.Position == style.PositionAbsolute { - originX += computed.MarginLeft - originY += computed.MarginTop - } + // if computed.Position == style.PositionAbsolute { + // pos.X += computed.MarginLeft + // pos.Y += computed.MarginTop + // } if computed.BackgroundColor != nil { ctx.SetColor(computed.BackgroundColor) - ctx.DrawRectangle(originX, originY, float64(dimensions.width), float64(dimensions.height)) + ctx.DrawRectangle(pos.X, pos.Y, float64(dimensions.width), float64(dimensions.height)) ctx.Fill() } if computed.BackgroundImage != nil { background := imaging.Fill(computed.BackgroundImage, dimensions.width, dimensions.height, imaging.Center, imaging.Lanczos) - ctx.DrawImage(background, ceil(originX), ceil(originY)) + ctx.DrawImage(background, ceil(pos.X), ceil(pos.Y)) } if computed.Debug { @@ -141,7 +139,9 @@ func (content *contentBlocks) Render(ctx *gg.Context, pos Position) error { } applyGrowth(computed, dimensions, content.value...) - return renderBlocksContent(ctx, computed, dimensions, pos, content.value...) + + var originX, originY = pos.X + computed.PaddingLeft, pos.Y + computed.PaddingTop + return renderBlocksContent(ctx, computed, dimensions, Position{X: originX, Y: originY}, content.value...) } func renderBlocksContent(ctx *gg.Context, containerStyle style.Style, container contentDimensions, pos Position, blocks ...*Block) error { @@ -149,36 +149,7 @@ func renderBlocksContent(ctx *gg.Context, containerStyle style.Style, container return errors.New("no blocks to render") } - var originX, originY = pos.X + containerStyle.PaddingLeft, pos.Y + containerStyle.PaddingTop - - var lastX, lastY float64 = originX, originY - var justifyOffsetX, justifyOffsetY float64 - - var freeSpaceX, freeSpaceY = float64(container.width) - container.paddingAndGapsX, float64(container.height) - container.paddingAndGapsY - - // Set correct gaps and offsets based on justify content - switch containerStyle.JustifyContent { - case style.JustifyContentCenter: - lastX += freeSpaceX / 2 - lastY += freeSpaceY / 2 - case style.JustifyContentEnd: - lastX += freeSpaceX - lastY += freeSpaceY - case style.JustifyContentSpaceBetween: - if len(blocks) > 0 { - justifyOffsetX = float64(freeSpaceX / float64(len(blocks)-1)) - justifyOffsetY = float64(freeSpaceY / float64(len(blocks)-1)) - } - case style.JustifyContentSpaceAround: - spacingX := float64(freeSpaceX / float64(len(blocks)+1)) - spacingY := float64(freeSpaceY / float64(len(blocks)+1)) - justifyOffsetX = spacingX - justifyOffsetY = spacingY - lastX += spacingX - lastY += spacingY - default: // JustifyContentStart - } - + var lastX, lastY float64 = pos.X, pos.Y for i, block := range blocks { blockSize := block.content.dimensions() posX, posY := lastX, lastY @@ -186,31 +157,57 @@ func renderBlocksContent(ctx *gg.Context, containerStyle style.Style, container switch containerStyle.Direction { case style.DirectionVertical: if i > 0 { - posY += justifyOffsetY + containerStyle.Gap + posY += containerStyle.Gap } - lastY = posY + float64(blockSize.height) + // align content vertically + switch containerStyle.JustifyContent { + case style.JustifyContentCenter: + posY += float64(container.height-blockSize.height) / 2 + case style.JustifyContentEnd: + posY += float64(container.height - blockSize.height) + case style.JustifyContentSpaceAround: + posY += float64((container.height - blockSize.height) / (len(blocks) + 1)) + case style.JustifyContentSpaceBetween: + if len(blocks) > 1 { + posY += float64((container.height - blockSize.height) / (len(blocks) - 1)) + } + } + + // align content horizontally + posX = pos.X switch containerStyle.AlignItems { case style.AlignItemsCenter: - posX = float64(container.width-blockSize.width) / 2 + posX += float64(container.width-blockSize.width) / 2 case style.AlignItemsEnd: - posX = float64(container.width-blockSize.width) - containerStyle.PaddingRight - default: // AlignItemsStart - posX = containerStyle.PaddingLeft + posX += float64(blockSize.width) } default: // DirectionHorizontal if i > 0 { - posX += justifyOffsetX + containerStyle.Gap + posX += containerStyle.Gap } - lastX = posX + float64(blockSize.width) + // align content horizontally + switch containerStyle.JustifyContent { + case style.JustifyContentCenter: + posX += float64(container.width-blockSize.width) / 2 + case style.JustifyContentEnd: + posX += float64(container.width - blockSize.width) + case style.JustifyContentSpaceAround: + posX += float64((container.width - blockSize.width) / (len(blocks) + 1)) + case style.JustifyContentSpaceBetween: + if len(blocks) > 1 { + posX += float64((container.width - blockSize.width) / (len(blocks) - 1)) + } + } + + // align content vertically + posY = pos.Y switch containerStyle.AlignItems { case style.AlignItemsCenter: - posY = float64(container.height-blockSize.height) / 2 + posY += (float64(container.height-blockSize.height) / 2) case style.AlignItemsEnd: - posY = float64(container.height-blockSize.height) - containerStyle.PaddingBottom - default: // AlignItemsStart - posY = containerStyle.PaddingTop + posY += float64(blockSize.height) } } @@ -219,6 +216,14 @@ func renderBlocksContent(ctx *gg.Context, containerStyle style.Style, container if err != nil { return err } + + // save the position we rendered at + switch containerStyle.Direction { + case style.DirectionVertical: + lastY = posY + float64(blockSize.height) + default: + lastX = posX + float64(blockSize.width) + } } return nil @@ -265,8 +270,6 @@ func applyGrowth(containerStyle style.Style, container contentDimensions, blocks } } - fmt.Printf("grow x %v container %v %v blocks %v \n", growSpaceX, container.width, container.paddingAndGapsX, blockWidthTotal) - // apply growth to blocks if growBlocksX > 0 || growBlocksY > 0 { var blockGrowX, blockGrowY = max(0, growSpaceX) / max(1, growBlocksX), max(0, growSpaceY) / max(1, growBlocksY) diff --git a/internal/render/v2/content-empty.go b/internal/render/v2/content-empty.go new file mode 100644 index 00000000..404ba280 --- /dev/null +++ b/internal/render/v2/content-empty.go @@ -0,0 +1,65 @@ +package render + +import ( + "github.com/cufee/aftermath/internal/render/v2/style" + "github.com/fogleman/gg" +) + +var _ BlockContent = &contentEmpty{} + +func NewEmptyContent(style style.StyleOptions) *Block { + return NewBlock(&contentEmpty{ + style: style, + }) +} + +type contentEmpty struct { + style style.StyleOptions +} + +func (content *contentEmpty) setStyle(style style.StyleOptions) { + content.style = style +} + +func (content *contentEmpty) dimensions() contentDimensions { + computed := content.Style().Computed() + return contentDimensions{ + width: int(computed.Width), + height: int(computed.Height), + paddingAndGapsY: computed.PaddingTop + computed.PaddingBottom, + paddingAndGapsX: computed.PaddingLeft + computed.PaddingRight, + } +} + +func (content *contentEmpty) Type() blockContentType { + return BlockContentTypeEmpty +} + +func (content *contentEmpty) Style() style.StyleOptions { + return content.style +} + +func (content *contentEmpty) Render(ctx *gg.Context, pos Position) error { + computed := content.style.Computed() + dimensions := content.dimensions() + + var originX, originY float64 = pos.X + computed.PaddingLeft, pos.Y + computed.PaddingTop + + // if computed.Position == style.PositionAbsolute { + // originX += computed.MarginLeft + // originY += computed.MarginTop + // } + + if computed.BackgroundColor != nil { + ctx.SetColor(computed.BackgroundColor) + ctx.DrawRectangle(originX, originY, float64(dimensions.width), float64(dimensions.height)) + ctx.Fill() + } + + if computed.Debug { + ctx.SetColor(getDebugColor()) + ctx.DrawRectangle(pos.X, pos.Y, float64(dimensions.width), float64(dimensions.height)) + ctx.Stroke() + } + return nil +} diff --git a/internal/render/v2/content-text.go b/internal/render/v2/content-text.go index 38248334..90d4ac5a 100644 --- a/internal/render/v2/content-text.go +++ b/internal/render/v2/content-text.go @@ -98,10 +98,11 @@ func (content *contentText) Render(ctx *gg.Context, pos Position) error { } var originX, originY float64 = pos.X + computed.PaddingLeft, pos.Y + computed.PaddingTop + 1 - if computed.Position == style.PositionAbsolute { - originX += computed.MarginLeft - originY += computed.MarginTop - } + + // if computed.Position == style.PositionAbsolute { + // originX += computed.MarginLeft + // originY += computed.MarginTop + // } if computed.BackgroundColor != nil { ctx.SetColor(computed.BackgroundColor) diff --git a/internal/render/v2/debug.go b/internal/render/v2/debug.go index 9dc8b248..6d34050b 100644 --- a/internal/render/v2/debug.go +++ b/internal/render/v2/debug.go @@ -7,5 +7,5 @@ import ( func getDebugColor() color.Color { ns := time.Now().Nanosecond() - return color.NRGBA{uint8(ns%120) + 120, uint8(ns % 200), uint8(ns % 200), 255} + return color.NRGBA{uint8(ns%120) + 120, uint8(ns%100) + 50, uint8(ns%100) + 50, 255} } diff --git a/internal/render/v2/render_test.go b/internal/render/v2/render_test.go new file mode 100644 index 00000000..d573a17e --- /dev/null +++ b/internal/render/v2/render_test.go @@ -0,0 +1,297 @@ +package render + +import ( + "image" + "image/color" + "image/png" + "os" + "path/filepath" + "testing" + + "github.com/cufee/aftermath/internal/render/v2/style" + "github.com/cufee/aftermath/tests/path" + "github.com/matryer/is" +) + +var _ = saveImage + +var contentSize = 12.0 +var contentColorValue uint32 +var contentColor = color.RGBA{255, 255, 255, 255} + +func init() { + _, _, _, a := contentColor.RGBA() + contentColorValue = a +} + +func TestApplyPadding(t *testing.T) { + is := is.New(t) + + content := NewEmptyContent(style.NewStyle(style.Parent(style.Style{Width: contentSize, Height: contentSize, BackgroundColor: contentColor}))) + + t.Run("uniform", func(t *testing.T) { + is := is.New(t) + + wrapper := NewBlocksContent(style.NewStyle( + style.SetPadding(10), + ), content) + + d := wrapper.Dimensions() + is.True(d.width == ceil(contentSize)+20) + is.True(d.height == ceil(contentSize)+20) + + img, err := wrapper.Render() + is.NoErr(err) + + { + _, _, _, a := img.At(9, 9).RGBA() + is.True(a == 0) + } + { + _, _, _, a := img.At(10, 10).RGBA() + is.True(a == contentColorValue) + } + }) + + t.Run("X", func(t *testing.T) { + wrapper := NewBlocksContent(style.NewStyle( + style.SetPaddingX(10), + ), content) + + d := wrapper.Dimensions() + is.True(d.width == ceil(contentSize)+20) + is.True(d.height == ceil(contentSize)) + }) + + t.Run("Y", func(t *testing.T) { + wrapper := NewBlocksContent(style.NewStyle( + style.SetPaddingY(10), + ), content) + + d := wrapper.Dimensions() + is.True(d.width == ceil(contentSize)) + is.True(d.height == ceil(contentSize)+20) + }) + + t.Run("overwrite", func(t *testing.T) { + wrapper := NewBlocksContent(style.NewStyle( + style.SetPadding(10), + style.SetPadding(0), + ), content) + + d := wrapper.Dimensions() + is.True(d.width == ceil(contentSize)) + is.True(d.height == ceil(contentSize)) + }) + + t.Run("left", func(t *testing.T) { + wrapper := NewBlocksContent(style.NewStyle( + style.Parent(style.Style{ + PaddingLeft: 10, + }), + ), content) + + d := wrapper.Dimensions() + is.True(d.width == ceil(contentSize)+10) + is.True(d.height == ceil(contentSize)) + + img, err := wrapper.Render() + is.NoErr(err) + + { + _, _, _, a := img.At(9, 0).RGBA() + is.True(a == 0) + } + { + _, _, _, a := img.At(10, 0).RGBA() + is.True(a == contentColorValue) + } + }) + + t.Run("top", func(t *testing.T) { + wrapper := NewBlocksContent(style.NewStyle( + style.Parent(style.Style{ + PaddingTop: 10, + }), + ), content) + + d := wrapper.Dimensions() + is.True(d.width == ceil(contentSize)) + is.True(d.height == ceil(contentSize)+10) + + img, err := wrapper.Render() + is.NoErr(err) + + { + _, _, _, a := img.At(0, 9).RGBA() + is.True(a == 0) + } + { + _, _, _, a := img.At(0, 10).RGBA() + is.True(a == contentColorValue) + } + }) +} + +func TestRenderJustify(t *testing.T) { + is := is.New(t) + + content := NewEmptyContent(style.NewStyle(style.Parent(style.Style{Width: contentSize, Height: contentSize, BackgroundColor: contentColor}))) + + t.Run("horizontal", func(t *testing.T) { + t.Run("start", func(t *testing.T) { + is := is.New(t) + + wrapper := NewBlocksContent(style.NewStyle( + style.SetWidth(contentSize*2), + ), content) + + img, err := wrapper.Render() + is.NoErr(err) + + { + _, _, _, imgA := img.At(0, 0).RGBA() + is.True(imgA == contentColorValue) + } + { + _, _, _, imgA := img.At(int(contentSize*2-1), 0).RGBA() + is.True(imgA == 0) + } + }) + + t.Run("center", func(t *testing.T) { + is := is.New(t) + + wrapper := NewBlocksContent(style.NewStyle( + style.Parent(style.Style{ + JustifyContent: style.JustifyContentCenter, + }), + style.SetWidth(contentSize*2), + ), content) + + img, err := wrapper.Render() + is.NoErr(err) + + { + _, _, _, imgA := img.At(int(contentSize/3), 0).RGBA() + is.True(imgA == 0) + } + { + _, _, _, imgA := img.At(int(contentSize), 0).RGBA() + is.True(imgA == contentColorValue) + } + { + _, _, _, imgA := img.At(int(contentSize*2-contentSize/3), 0).RGBA() + is.True(imgA == 0) + } + }) + + t.Run("end", func(t *testing.T) { + is := is.New(t) + + wrapper := NewBlocksContent(style.NewStyle( + style.Parent(style.Style{ + JustifyContent: style.JustifyContentEnd, + }), + style.SetWidth(contentSize*2), + ), content) + + img, err := wrapper.Render() + is.NoErr(err) + + { + _, _, _, imgA := img.At(int(contentSize-1), 0).RGBA() + is.True(imgA == 0) + } + { + _, _, _, imgA := img.At(int(contentSize*2-1), 0).RGBA() + is.True(imgA == contentColorValue) + } + }) + }) + + t.Run("vertical", func(t *testing.T) { + t.Run("start", func(t *testing.T) { + is := is.New(t) + + wrapper := NewBlocksContent(style.NewStyle( + style.Parent(style.Style{ + Direction: style.DirectionVertical, + }), + style.SetHeight(contentSize*2), + ), content) + + img, err := wrapper.Render() + is.NoErr(err) + + { + _, _, _, imgA := img.At(0, 0).RGBA() + is.True(imgA == contentColorValue) + } + { + _, _, _, imgA := img.At(0, int(contentSize*2-1)).RGBA() + is.True(imgA == 0) + } + }) + + t.Run("center", func(t *testing.T) { + is := is.New(t) + + wrapper := NewBlocksContent(style.NewStyle( + style.Parent(style.Style{ + JustifyContent: style.JustifyContentCenter, + Direction: style.DirectionVertical, + }), + style.SetHeight(contentSize*2), + ), content) + + img, err := wrapper.Render() + is.NoErr(err) + + { + _, _, _, imgA := img.At(0, int(contentSize/4)).RGBA() + is.True(imgA == 0) + } + { + _, _, _, imgA := img.At(0, int(contentSize)).RGBA() + is.True(imgA == contentColorValue) + } + { + _, _, _, imgA := img.At(0, int(contentSize*2-contentSize/4)).RGBA() + is.True(imgA == 0) + } + }) + + t.Run("end", func(t *testing.T) { + is := is.New(t) + + wrapper := NewBlocksContent(style.NewStyle( + style.Parent(style.Style{ + JustifyContent: style.JustifyContentEnd, + Direction: style.DirectionVertical, + }), + style.SetHeight(contentSize*2), + ), content) + + img, err := wrapper.Render() + is.NoErr(err) + + { + _, _, _, imgA := img.At(0, int(contentSize-1)).RGBA() + is.True(imgA == 0) + } + { + _, _, _, imgA := img.At(0, int(contentSize*2-1)).RGBA() + is.True(imgA == contentColorValue) + } + }) + }) +} + +func saveImage(is *is.I, img image.Image) { + f, err := os.Create(filepath.Join(path.Root(), "tmp", "test_render_blocks.png")) + is.NoErr(err) + + err = png.Encode(f, img) + is.NoErr(err) +} diff --git a/internal/render/v2/style/style.go b/internal/render/v2/style/style.go index 7f249bef..0f2734a2 100644 --- a/internal/render/v2/style/style.go +++ b/internal/render/v2/style/style.go @@ -31,7 +31,7 @@ type positionValue byte const ( PositionRelative positionValue = iota - PositionAbsolute + // PositionAbsolute ) type overflowValue byte @@ -60,6 +60,7 @@ type Style struct { JustifyContent justifyContentValue AlignItems alignItemsValue // Depends on Direction Direction directionValue + Position positionValue Gap float64 @@ -68,10 +69,10 @@ type Style struct { PaddingTop float64 PaddingBottom float64 - Position positionValue - // margin should generally be used for absolute position - MarginLeft float64 - MarginTop float64 + // MarginLeft float64 + // MarginRight float64 + // MarginTop float64 + // MarginBottom float64 GrowHorizontal bool GrowVertical bool diff --git a/render_test.go b/render_test.go index 837d32f2..cf82f408 100644 --- a/render_test.go +++ b/render_test.go @@ -244,20 +244,25 @@ func TestRenderV2(t *testing.T) { style.SetDebug(true), style.SetFont(rc.FontLarge()), style.SetWidth(100), + style.SetGrowX(true), + style.SetGrowY(true), ), "TEST - 1") is.NoErr(err) text2, err := renderV2.NewTextContent(style.NewStyle( style.SetDebug(true), // style.SetGrowX(true), - style.SetWidth(100), + style.SetWidth(50), style.SetFont(rc.FontLarge()), ), "TEST - 2") is.NoErr(err) block1 := renderV2.NewBlocksContent(style.NewStyle( + style.Parent(style.Style{ + // JustifyContent: style.JustifyContentCenter, + }), style.SetDebug(true), - style.SetPadding(10), + style.SetPadding(20), // style.SetGrowX(true), ), text2) @@ -267,7 +272,7 @@ func TestRenderV2(t *testing.T) { }), style.SetDebug(true), style.SetPadding(10), - // style.SetWidth(400), + style.SetWidth(300), ), text1, block1) img, err := block2.Render() From e8e5a91ad8b8c4206d787250a711ccc3ac8ec001 Mon Sep 17 00:00:00 2001 From: Vovko Date: Fri, 17 Jan 2025 20:23:33 -0500 Subject: [PATCH 15/39] fixed grow with gaps --- internal/render/v2/content-blocks.go | 78 ++++++++++++++-------------- 1 file changed, 39 insertions(+), 39 deletions(-) diff --git a/internal/render/v2/content-blocks.go b/internal/render/v2/content-blocks.go index 868dae1a..5da529a8 100644 --- a/internal/render/v2/content-blocks.go +++ b/internal/render/v2/content-blocks.go @@ -39,6 +39,13 @@ func (content *contentBlocks) dimensions() contentDimensions { paddingAndGapsY: computed.PaddingTop + computed.PaddingBottom, } + switch computed.Direction { + case style.DirectionHorizontal: + dimensions.paddingAndGapsX += computed.Gap * float64(len(content.value)-1) + case style.DirectionVertical: + dimensions.paddingAndGapsY += computed.Gap * float64(len(content.value)-1) + } + if dimensions.width > 0 && dimensions.height > 0 { return dimensions } @@ -63,10 +70,6 @@ func (content *contentBlocks) dimensions() contentDimensions { case style.DirectionHorizontal: dimensions.width += blockWidthTotal - gaps := computed.Gap * float64(len(content.value)-1) - dimensions.width += ceil(gaps) - dimensions.paddingAndGapsX += gaps - case style.DirectionVertical: dimensions.width += blockWidthMax } @@ -80,11 +83,6 @@ func (content *contentBlocks) dimensions() contentDimensions { dimensions.height += blockHeightMax case style.DirectionVertical: dimensions.height += blockHeightTotal - - gaps := computed.Gap * float64(len(content.value)-1) - dimensions.height += ceil(gaps) - dimensions.paddingAndGapsY += gaps - } } @@ -138,7 +136,7 @@ func (content *contentBlocks) Render(ctx *gg.Context, pos Position) error { ctx.Stroke() } - applyGrowth(computed, dimensions, content.value...) + applyBlocksGrowth(computed, dimensions, content.value...) var originX, originY = pos.X + computed.PaddingLeft, pos.Y + computed.PaddingTop return renderBlocksContent(ctx, computed, dimensions, Position{X: originX, Y: originY}, content.value...) @@ -229,7 +227,7 @@ func renderBlocksContent(ctx *gg.Context, containerStyle style.Style, container return nil } -func applyGrowth(containerStyle style.Style, container contentDimensions, blocks ...*Block) { +func applyBlocksGrowth(containerStyle style.Style, container contentDimensions, blocks ...*Block) { // calculate content dimensions before growth var blockWidthTotal, blockWidthMax, blockHeightTotal, blockHeightMax int var growBlocksX, growBlocksY = 0, 0 @@ -251,42 +249,44 @@ func applyGrowth(containerStyle style.Style, container contentDimensions, blocks } } - // calculate empty space blocks can use to grow - var growSpaceX, growSpaceY = 0, 0 - if growBlocksX > 0 { - switch containerStyle.Direction { - case style.DirectionHorizontal: - growSpaceX = container.width - ceil(container.paddingAndGapsX) - blockWidthTotal - case style.DirectionVertical: - growSpaceX = container.width - ceil(container.paddingAndGapsX) - blockWidthMax - } - } - if growBlocksY > 0 { - switch containerStyle.Direction { - case style.DirectionHorizontal: - growSpaceY = container.height - ceil(container.paddingAndGapsY) - blockHeightMax - case style.DirectionVertical: - growSpaceY = container.height - ceil(container.paddingAndGapsY) - blockWidthTotal - } - } - // apply growth to blocks if growBlocksX > 0 || growBlocksY > 0 { - var blockGrowX, blockGrowY = max(0, growSpaceX) / max(1, growBlocksX), max(0, growSpaceY) / max(1, growBlocksY) + blockGrowX := max(0, container.width-ceil(container.paddingAndGapsX)-blockWidthTotal) / max(1, growBlocksX) + blockGrowY := max(0, container.height-ceil(container.paddingAndGapsY)-blockHeightTotal) / max(1, growBlocksY) + for _, block := range blocks { blockStyle := block.Style() blockComputed := blockStyle.Computed() - // update the block width - if blockComputed.GrowHorizontal { - blockStyle.Add(style.SetWidth(blockComputed.Width + float64(blockGrowX))) - block.content.setStyle(blockStyle) + if !blockComputed.GrowHorizontal && !blockComputed.GrowVertical { + continue } - // update the block height - if blockComputed.GrowVertical { - blockStyle.Add(style.SetHeight(blockComputed.Height + float64(blockGrowY))) - block.content.setStyle(blockStyle) + + switch containerStyle.Direction { + case style.DirectionHorizontal: + // update the block width + if blockComputed.GrowHorizontal { + blockStyle.Add(style.SetWidth(blockComputed.Width + float64(blockGrowX))) + block.content.setStyle(blockStyle) + } + // update the block height + if blockComputed.GrowVertical { + blockStyle.Add(style.SetHeight(float64(blockHeightMax))) + block.content.setStyle(blockStyle) + } + case style.DirectionVertical: + // update the block width + if blockComputed.GrowHorizontal { + blockStyle.Add(style.SetWidth(float64(blockWidthMax))) + block.content.setStyle(blockStyle) + } + // update the block height + if blockComputed.GrowVertical { + blockStyle.Add(style.SetHeight(blockComputed.Height + float64(blockGrowY))) + block.content.setStyle(blockStyle) + } } + } } } From 462f368a8e820a48c02b378197d2f03acc3d8eb6 Mon Sep 17 00:00:00 2001 From: Vovko Date: Fri, 17 Jan 2025 21:14:31 -0500 Subject: [PATCH 16/39] fixed more grow bugs --- internal/render/v2/content-blocks.go | 9 +++- internal/render/v2/content-text.go | 36 ++++++++------ internal/render/v2/internal/tests/font.go | 14 ++++++ internal/render/v2/internal/tests/font.ttf | Bin 0 -> 171656 bytes internal/render/v2/render_test.go | 53 +++++++++++++++++++++ internal/render/v2/style/options.go | 12 ++++- internal/render/v2/style/style.go | 2 +- render_test.go | 52 -------------------- 8 files changed, 106 insertions(+), 72 deletions(-) create mode 100644 internal/render/v2/internal/tests/font.go create mode 100644 internal/render/v2/internal/tests/font.ttf diff --git a/internal/render/v2/content-blocks.go b/internal/render/v2/content-blocks.go index 5da529a8..2d79a493 100644 --- a/internal/render/v2/content-blocks.go +++ b/internal/render/v2/content-blocks.go @@ -55,6 +55,10 @@ func (content *contentBlocks) dimensions() contentDimensions { for _, block := range content.value { blockDimensions := block.content.dimensions() + if block.Style().Computed().Position == style.PositionAbsolute { + continue + } + blockWidthTotal += blockDimensions.width blockWidthMax = max(blockWidthMax, blockDimensions.width) @@ -257,6 +261,7 @@ func applyBlocksGrowth(containerStyle style.Style, container contentDimensions, for _, block := range blocks { blockStyle := block.Style() blockComputed := blockStyle.Computed() + blockSize := block.Dimensions() if !blockComputed.GrowHorizontal && !blockComputed.GrowVertical { continue @@ -266,7 +271,7 @@ func applyBlocksGrowth(containerStyle style.Style, container contentDimensions, case style.DirectionHorizontal: // update the block width if blockComputed.GrowHorizontal { - blockStyle.Add(style.SetWidth(blockComputed.Width + float64(blockGrowX))) + blockStyle.Add(style.SetWidth(float64(blockSize.width) + float64(blockGrowX))) block.content.setStyle(blockStyle) } // update the block height @@ -282,7 +287,7 @@ func applyBlocksGrowth(containerStyle style.Style, container contentDimensions, } // update the block height if blockComputed.GrowVertical { - blockStyle.Add(style.SetHeight(blockComputed.Height + float64(blockGrowY))) + blockStyle.Add(style.SetHeight(float64(blockSize.height) + float64(blockGrowY))) block.content.setStyle(blockStyle) } } diff --git a/internal/render/v2/content-text.go b/internal/render/v2/content-text.go index 90d4ac5a..2502df5b 100644 --- a/internal/render/v2/content-text.go +++ b/internal/render/v2/content-text.go @@ -2,6 +2,7 @@ package render import ( "math" + "strings" "github.com/cufee/aftermath/internal/render/v2/style" "github.com/fogleman/gg" @@ -41,6 +42,10 @@ func (content *contentText) setStyle(style style.StyleOptions) { } func (content *contentText) measure(font style.Font) StringSize { + if content.sizeCache != nil { + return *content.sizeCache + } + size := MeasureString(content.value, font) content.sizeCache = &size return size @@ -58,12 +63,12 @@ func (content *contentText) dimensions() contentDimensions { if computed.Width > 0 { width = computed.Width } else { - width = size.TotalWidth + ((computed.PaddingLeft + computed.PaddingRight) * 2) + width = size.TotalWidth + (computed.PaddingLeft + computed.PaddingRight) } if computed.Height > 0 { height = computed.Height } else { - height = size.TotalHeight + ((computed.PaddingTop + computed.PaddingBottom) * 2) + height = size.TotalHeight + (computed.PaddingTop + computed.PaddingBottom) } content.dimensionsCache = &contentDimensions{width: int(math.Ceil(width)), height: int(math.Ceil(height))} @@ -84,21 +89,22 @@ func (content *contentText) Render(ctx *gg.Context, pos Position) error { dimensions := content.dimensions() if computed.Blur > 0 { - blur := computed.Blur - computed.Blur = 0 + // reset blur + current := content.Style() + current.Add(style.SetBlur(0)) + content.setStyle(current) + // render the content onto a new image, blur it, render onto parent child := gg.NewContext(dimensions.width, dimensions.height) err := content.Render(child, Position{0, 0}) if err != nil { return err } - img := imaging.Blur(ctx.Image(), blur) + img := imaging.Blur(child.Image(), computed.Blur) ctx.DrawImage(img, ceil(pos.X), ceil(pos.Y)) return nil } - var originX, originY float64 = pos.X + computed.PaddingLeft, pos.Y + computed.PaddingTop + 1 - // if computed.Position == style.PositionAbsolute { // originX += computed.MarginLeft // originY += computed.MarginTop @@ -106,12 +112,12 @@ func (content *contentText) Render(ctx *gg.Context, pos Position) error { if computed.BackgroundColor != nil { ctx.SetColor(computed.BackgroundColor) - ctx.DrawRectangle(originX, originY, float64(dimensions.width), float64(dimensions.height)) + ctx.DrawRectangle(pos.X, pos.Y, float64(dimensions.width), float64(dimensions.height)) ctx.Fill() } if computed.BackgroundImage != nil { background := imaging.Fill(computed.BackgroundImage, dimensions.width, dimensions.height, imaging.Center, imaging.Lanczos) - ctx.DrawImage(background, ceil(originX), ceil(originY)) + ctx.DrawImage(background, ceil(pos.X), ceil(pos.Y)) } if computed.Debug { @@ -120,7 +126,7 @@ func (content *contentText) Render(ctx *gg.Context, pos Position) error { ctx.Stroke() } - var lastX, lastY float64 = originX, originY + var lastX, lastY float64 = pos.X + computed.PaddingLeft, pos.Y + computed.PaddingTop + 1 switch computed.JustifyContent { case style.JustifyContentEnd: @@ -142,11 +148,11 @@ func (content *contentText) Render(ctx *gg.Context, pos Position) error { ctx.SetFontFace(face) ctx.SetColor(computed.Color) - // for _, str := range strings.Split(content.value, "\n") { - // lastY += size.LineHeight - // x, y := lastX, lastY-size.LineOffset - // ctx.DrawString(str, x, y) - // } + for _, str := range strings.Split(content.value, "\n") { + lastY += size.LineHeight + x, y := lastX, lastY-size.LineOffset + ctx.DrawString(str, x, y) + } return nil } diff --git a/internal/render/v2/internal/tests/font.go b/internal/render/v2/internal/tests/font.go new file mode 100644 index 00000000..24131c8a --- /dev/null +++ b/internal/render/v2/internal/tests/font.go @@ -0,0 +1,14 @@ +package tests + +import ( + _ "embed" + + "github.com/cufee/aftermath/internal/render/v2/style" +) + +//go:embed font.ttf +var fontData []byte + +func Font() style.Font { + return style.NewFont(fontData, 24) +} diff --git a/internal/render/v2/internal/tests/font.ttf b/internal/render/v2/internal/tests/font.ttf new file mode 100644 index 0000000000000000000000000000000000000000..f714a514d94e495095e2f1e525a341eade187c17 GIT binary patch literal 171656 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 Date: Fri, 17 Jan 2025 21:21:16 -0500 Subject: [PATCH 17/39] fixed width calculation bug with gaps --- internal/render/v2/content-blocks.go | 4 ++-- internal/render/v2/content-text.go | 6 +++++- internal/render/v2/render_test.go | 30 ++++++++++++---------------- 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/internal/render/v2/content-blocks.go b/internal/render/v2/content-blocks.go index 2d79a493..bdf633a7 100644 --- a/internal/render/v2/content-blocks.go +++ b/internal/render/v2/content-blocks.go @@ -68,7 +68,7 @@ func (content *contentBlocks) dimensions() contentDimensions { // calculate final block width if it was not set already if dimensions.width == 0 { - dimensions.width = ceil(computed.PaddingLeft) + ceil(computed.PaddingRight) + dimensions.width = ceil(dimensions.paddingAndGapsX) switch computed.Direction { case style.DirectionHorizontal: @@ -80,7 +80,7 @@ func (content *contentBlocks) dimensions() contentDimensions { } // calculate final block height if it was not set already if dimensions.height == 0 { - dimensions.height = ceil(computed.PaddingTop + computed.PaddingBottom) + dimensions.height = ceil(dimensions.paddingAndGapsY) switch computed.Direction { case style.DirectionHorizontal: diff --git a/internal/render/v2/content-text.go b/internal/render/v2/content-text.go index 2502df5b..dcbdee09 100644 --- a/internal/render/v2/content-text.go +++ b/internal/render/v2/content-text.go @@ -13,9 +13,13 @@ import ( var _ BlockContent = &contentText{} func NewTextContent(style style.StyleOptions, value string) (*Block, error) { - if !style.Computed().Font.Valid() { + computed := style.Computed() + if !computed.Font.Valid() { return nil, errors.New("invalid or missing font") } + if computed.Color == nil { + return nil, errors.New("text requires a non nil color") + } return NewBlock(&contentText{ value: value, style: style, diff --git a/internal/render/v2/render_test.go b/internal/render/v2/render_test.go index aefe4573..20a8b652 100644 --- a/internal/render/v2/render_test.go +++ b/internal/render/v2/render_test.go @@ -17,12 +17,12 @@ import ( var _ = saveImage var contentSize = 12.0 -var contentColorValue uint32 +var contentColorAlphaValue uint32 var contentColor = color.RGBA{255, 255, 255, 255} func init() { _, _, _, a := contentColor.RGBA() - contentColorValue = a + contentColorAlphaValue = a } func TestRenderV2(t *testing.T) { @@ -64,17 +64,13 @@ func TestRenderV2(t *testing.T) { }), style.SetDebug(true), style.SetPadding(10), - style.SetWidth(300), + // style.SetWidth(300), ), text1, block1) img, err := block2.Render() is.NoErr(err) - f, err := os.Create(filepath.Join(path.Root(), "tmp", "test_render_blocks.png")) - is.NoErr(err) - - err = png.Encode(f, img) - is.NoErr(err) + saveImage(is, img) } func TestApplyPadding(t *testing.T) { @@ -102,7 +98,7 @@ func TestApplyPadding(t *testing.T) { } { _, _, _, a := img.At(10, 10).RGBA() - is.True(a == contentColorValue) + is.True(a == contentColorAlphaValue) } }) @@ -157,7 +153,7 @@ func TestApplyPadding(t *testing.T) { } { _, _, _, a := img.At(10, 0).RGBA() - is.True(a == contentColorValue) + is.True(a == contentColorAlphaValue) } }) @@ -181,7 +177,7 @@ func TestApplyPadding(t *testing.T) { } { _, _, _, a := img.At(0, 10).RGBA() - is.True(a == contentColorValue) + is.True(a == contentColorAlphaValue) } }) } @@ -204,7 +200,7 @@ func TestRenderJustify(t *testing.T) { { _, _, _, imgA := img.At(0, 0).RGBA() - is.True(imgA == contentColorValue) + is.True(imgA == contentColorAlphaValue) } { _, _, _, imgA := img.At(int(contentSize*2-1), 0).RGBA() @@ -231,7 +227,7 @@ func TestRenderJustify(t *testing.T) { } { _, _, _, imgA := img.At(int(contentSize), 0).RGBA() - is.True(imgA == contentColorValue) + is.True(imgA == contentColorAlphaValue) } { _, _, _, imgA := img.At(int(contentSize*2-contentSize/3), 0).RGBA() @@ -258,7 +254,7 @@ func TestRenderJustify(t *testing.T) { } { _, _, _, imgA := img.At(int(contentSize*2-1), 0).RGBA() - is.True(imgA == contentColorValue) + is.True(imgA == contentColorAlphaValue) } }) }) @@ -279,7 +275,7 @@ func TestRenderJustify(t *testing.T) { { _, _, _, imgA := img.At(0, 0).RGBA() - is.True(imgA == contentColorValue) + is.True(imgA == contentColorAlphaValue) } { _, _, _, imgA := img.At(0, int(contentSize*2-1)).RGBA() @@ -307,7 +303,7 @@ func TestRenderJustify(t *testing.T) { } { _, _, _, imgA := img.At(0, int(contentSize)).RGBA() - is.True(imgA == contentColorValue) + is.True(imgA == contentColorAlphaValue) } { _, _, _, imgA := img.At(0, int(contentSize*2-contentSize/4)).RGBA() @@ -335,7 +331,7 @@ func TestRenderJustify(t *testing.T) { } { _, _, _, imgA := img.At(0, int(contentSize*2-1)).RGBA() - is.True(imgA == contentColorValue) + is.True(imgA == contentColorAlphaValue) } }) }) From 0e6d93a43f7bc3f8b8e3f49fde8e0f67a6d51802 Mon Sep 17 00:00:00 2001 From: Vovko Date: Fri, 17 Jan 2025 21:53:49 -0500 Subject: [PATCH 18/39] absolute positioning --- internal/render/v2/content-blocks.go | 152 ++++++++++++++++----------- internal/render/v2/content-empty.go | 6 -- internal/render/v2/content-text.go | 5 - internal/render/v2/render_test.go | 12 ++- internal/render/v2/style/options.go | 4 + internal/render/v2/style/style.go | 10 +- 6 files changed, 108 insertions(+), 81 deletions(-) diff --git a/internal/render/v2/content-blocks.go b/internal/render/v2/content-blocks.go index bdf633a7..46b3ff4f 100644 --- a/internal/render/v2/content-blocks.go +++ b/internal/render/v2/content-blocks.go @@ -39,11 +39,19 @@ func (content *contentBlocks) dimensions() contentDimensions { paddingAndGapsY: computed.PaddingTop + computed.PaddingBottom, } + var gapCount = 0 + for _, block := range content.value { + switch block.Style().Computed().Position { + case style.PositionRelative: + gapCount++ + } + } + switch computed.Direction { case style.DirectionHorizontal: - dimensions.paddingAndGapsX += computed.Gap * float64(len(content.value)-1) + dimensions.paddingAndGapsX += max(0, computed.Gap*float64(gapCount-1)) case style.DirectionVertical: - dimensions.paddingAndGapsY += computed.Gap * float64(len(content.value)-1) + dimensions.paddingAndGapsY += max(0, computed.Gap*float64(gapCount-1)) } if dimensions.width > 0 && dimensions.height > 0 { @@ -53,7 +61,7 @@ func (content *contentBlocks) dimensions() contentDimensions { // add content dimensions of each block to the total var blockWidthTotal, blockWidthMax, blockHeightTotal, blockHeightMax int for _, block := range content.value { - blockDimensions := block.content.dimensions() + blockDimensions := block.Dimensions() if block.Style().Computed().Position == style.PositionAbsolute { continue @@ -105,6 +113,19 @@ func (content *contentBlocks) Render(ctx *gg.Context, pos Position) error { computed := content.style.Computed() dimensions := content.dimensions() + if computed.Position == style.PositionAbsolute { + if computed.Left != 0 { + pos.X += computed.Left + } else if computed.Right != 0 { + pos.X += float64(dimensions.width) - computed.Right + } + if computed.Top != 0 { + pos.Y += computed.Top + } else if computed.Bottom != 0 { + pos.Y += float64(dimensions.height) - computed.Bottom + } + } + if computed.Blur > 0 { blur := computed.Blur computed.Blur = 0 @@ -119,11 +140,6 @@ func (content *contentBlocks) Render(ctx *gg.Context, pos Position) error { return nil } - // if computed.Position == style.PositionAbsolute { - // pos.X += computed.MarginLeft - // pos.Y += computed.MarginTop - // } - if computed.BackgroundColor != nil { ctx.SetColor(computed.BackgroundColor) ctx.DrawRectangle(pos.X, pos.Y, float64(dimensions.width), float64(dimensions.height)) @@ -152,16 +168,27 @@ func renderBlocksContent(ctx *gg.Context, containerStyle style.Style, container } var lastX, lastY float64 = pos.X, pos.Y - for i, block := range blocks { - blockSize := block.content.dimensions() + for _, block := range blocks { + blockStyle := block.Style().Computed() + blockSize := block.Dimensions() posX, posY := lastX, lastY - switch containerStyle.Direction { - case style.DirectionVertical: - if i > 0 { - posY += containerStyle.Gap + // apply absolute position margins + if blockStyle.Position == style.PositionAbsolute { + if blockStyle.Left != 0 { + posX += blockStyle.Left + } else if blockStyle.Right != 0 { + posX += float64(container.width-int(container.paddingAndGapsX)-blockSize.width) - blockStyle.Right + } + if blockStyle.Top != 0 { + posY += blockStyle.Top + } else if blockStyle.Bottom != 0 { + posY += float64(container.height-int(container.paddingAndGapsY)-blockSize.height) - blockStyle.Bottom } + } + switch containerStyle.Direction { + case style.DirectionVertical: // align content vertically switch containerStyle.JustifyContent { case style.JustifyContentCenter: @@ -177,7 +204,6 @@ func renderBlocksContent(ctx *gg.Context, containerStyle style.Style, container } // align content horizontally - posX = pos.X switch containerStyle.AlignItems { case style.AlignItemsCenter: posX += float64(container.width-blockSize.width) / 2 @@ -185,10 +211,6 @@ func renderBlocksContent(ctx *gg.Context, containerStyle style.Style, container posX += float64(blockSize.width) } default: // DirectionHorizontal - if i > 0 { - posX += containerStyle.Gap - } - // align content horizontally switch containerStyle.JustifyContent { case style.JustifyContentCenter: @@ -204,7 +226,6 @@ func renderBlocksContent(ctx *gg.Context, containerStyle style.Style, container } // align content vertically - posY = pos.Y switch containerStyle.AlignItems { case style.AlignItemsCenter: posY += (float64(container.height-blockSize.height) / 2) @@ -219,12 +240,16 @@ func renderBlocksContent(ctx *gg.Context, containerStyle style.Style, container return err } + if block.Style().Computed().Position == style.PositionAbsolute { + continue + } + // save the position we rendered at switch containerStyle.Direction { case style.DirectionVertical: - lastY = posY + float64(blockSize.height) + lastY = posY + float64(blockSize.height) + containerStyle.Gap default: - lastX = posX + float64(blockSize.width) + lastX = posX + float64(blockSize.width) + containerStyle.Gap } } @@ -236,7 +261,7 @@ func applyBlocksGrowth(containerStyle style.Style, container contentDimensions, var blockWidthTotal, blockWidthMax, blockHeightTotal, blockHeightMax int var growBlocksX, growBlocksY = 0, 0 for _, block := range blocks { - blockDimensions := block.content.dimensions() + blockDimensions := block.Dimensions() blockWidthTotal += blockDimensions.width blockWidthMax = max(blockWidthMax, blockDimensions.width) @@ -244,54 +269,59 @@ func applyBlocksGrowth(containerStyle style.Style, container contentDimensions, blockHeightTotal += blockDimensions.height blockHeightMax = max(blockHeightMax, blockDimensions.height) - style := block.Style().Computed() - if style.GrowHorizontal { + blockStyle := block.Style().Computed() + switch { + case blockStyle.Position == style.PositionAbsolute: + // absolute blocks do not "consume" grow space + case blockStyle.GrowHorizontal: growBlocksX++ - } - if style.GrowVertical { + case blockStyle.GrowVertical: growBlocksY++ } } + blockGrowX := max(0, container.width-ceil(container.paddingAndGapsX)-blockWidthTotal) / max(1, growBlocksX) + blockGrowY := max(0, container.height-ceil(container.paddingAndGapsY)-blockHeightTotal) / max(1, growBlocksY) + // apply growth to blocks - if growBlocksX > 0 || growBlocksY > 0 { - blockGrowX := max(0, container.width-ceil(container.paddingAndGapsX)-blockWidthTotal) / max(1, growBlocksX) - blockGrowY := max(0, container.height-ceil(container.paddingAndGapsY)-blockHeightTotal) / max(1, growBlocksY) + for _, block := range blocks { + blockStyle := block.Style() + blockComputed := blockStyle.Computed() + blockSize := block.Dimensions() - for _, block := range blocks { - blockStyle := block.Style() - blockComputed := blockStyle.Computed() - blockSize := block.Dimensions() + if !blockComputed.GrowHorizontal && !blockComputed.GrowVertical { + continue + } - if !blockComputed.GrowHorizontal && !blockComputed.GrowVertical { - continue + switch containerStyle.Direction { + case style.DirectionHorizontal: + // update the block width + if blockComputed.GrowHorizontal && blockComputed.Position == style.PositionAbsolute { + blockStyle.Add(style.SetWidth(float64(container.width) - containerStyle.PaddingLeft - containerStyle.PaddingRight)) + block.content.setStyle(blockStyle) + } else if blockComputed.GrowHorizontal { + blockStyle.Add(style.SetWidth(float64(blockSize.width) + float64(blockGrowX))) + block.content.setStyle(blockStyle) } - - switch containerStyle.Direction { - case style.DirectionHorizontal: - // update the block width - if blockComputed.GrowHorizontal { - blockStyle.Add(style.SetWidth(float64(blockSize.width) + float64(blockGrowX))) - block.content.setStyle(blockStyle) - } - // update the block height - if blockComputed.GrowVertical { - blockStyle.Add(style.SetHeight(float64(blockHeightMax))) - block.content.setStyle(blockStyle) - } - case style.DirectionVertical: - // update the block width - if blockComputed.GrowHorizontal { - blockStyle.Add(style.SetWidth(float64(blockWidthMax))) - block.content.setStyle(blockStyle) - } - // update the block height - if blockComputed.GrowVertical { - blockStyle.Add(style.SetHeight(float64(blockSize.height) + float64(blockGrowY))) - block.content.setStyle(blockStyle) - } + // update the block height + if blockComputed.GrowVertical { + blockStyle.Add(style.SetHeight(float64(blockHeightMax))) + block.content.setStyle(blockStyle) + } + case style.DirectionVertical: + // update the block width + if blockComputed.GrowHorizontal { + blockStyle.Add(style.SetWidth(float64(blockWidthMax))) + block.content.setStyle(blockStyle) + } + // update the block height + if blockComputed.GrowVertical && blockComputed.Position == style.PositionAbsolute { + blockStyle.Add(style.SetWidth(float64(container.height) - containerStyle.PaddingTop - containerStyle.PaddingBottom)) + block.content.setStyle(blockStyle) + } else if blockComputed.GrowVertical { + blockStyle.Add(style.SetHeight(float64(blockSize.height) + float64(blockGrowY))) + block.content.setStyle(blockStyle) } - } } } diff --git a/internal/render/v2/content-empty.go b/internal/render/v2/content-empty.go index 404ba280..7b9159ff 100644 --- a/internal/render/v2/content-empty.go +++ b/internal/render/v2/content-empty.go @@ -44,12 +44,6 @@ func (content *contentEmpty) Render(ctx *gg.Context, pos Position) error { dimensions := content.dimensions() var originX, originY float64 = pos.X + computed.PaddingLeft, pos.Y + computed.PaddingTop - - // if computed.Position == style.PositionAbsolute { - // originX += computed.MarginLeft - // originY += computed.MarginTop - // } - if computed.BackgroundColor != nil { ctx.SetColor(computed.BackgroundColor) ctx.DrawRectangle(originX, originY, float64(dimensions.width), float64(dimensions.height)) diff --git a/internal/render/v2/content-text.go b/internal/render/v2/content-text.go index dcbdee09..00cc6a1a 100644 --- a/internal/render/v2/content-text.go +++ b/internal/render/v2/content-text.go @@ -109,11 +109,6 @@ func (content *contentText) Render(ctx *gg.Context, pos Position) error { return nil } - // if computed.Position == style.PositionAbsolute { - // originX += computed.MarginLeft - // originY += computed.MarginTop - // } - if computed.BackgroundColor != nil { ctx.SetColor(computed.BackgroundColor) ctx.DrawRectangle(pos.X, pos.Y, float64(dimensions.width), float64(dimensions.height)) diff --git a/internal/render/v2/render_test.go b/internal/render/v2/render_test.go index 20a8b652..7f0cf2ed 100644 --- a/internal/render/v2/render_test.go +++ b/internal/render/v2/render_test.go @@ -29,18 +29,20 @@ func TestRenderV2(t *testing.T) { is := is.New(t) text1, err := NewTextContent(style.NewStyle( + style.Parent(style.Style{ + Right: -5, + Top: -5, + }), style.SetDebug(true), + style.SetPosition(style.PositionAbsolute), style.SetFont(tests.Font(), color.Black), // style.SetWidth(100), - style.SetGrowX(true), - style.SetGrowY(true), + // style.SetGrowX(true), + // style.SetGrowY(true), ), "TEST - 1") is.NoErr(err) text2, err := NewTextContent(style.NewStyle( - style.Parent(style.Style{ - Blur: 2, - }), // style.SetDebug(true), // style.SetGrowX(true), style.SetGrowY(true), diff --git a/internal/render/v2/style/options.go b/internal/render/v2/style/options.go index 694393ee..782ae94d 100644 --- a/internal/render/v2/style/options.go +++ b/internal/render/v2/style/options.go @@ -34,6 +34,10 @@ func SetDebug(value bool) styleOption { return func(s *Style) { s.Debug = value } } +func SetPosition(value positionValue) styleOption { + return func(s *Style) { s.Position = value } +} + func SetWidth(value float64) styleOption { return func(s *Style) { s.Width = value } } diff --git a/internal/render/v2/style/style.go b/internal/render/v2/style/style.go index 616075a2..460a97da 100644 --- a/internal/render/v2/style/style.go +++ b/internal/render/v2/style/style.go @@ -69,10 +69,10 @@ type Style struct { PaddingTop float64 PaddingBottom float64 - // MarginLeft float64 - // MarginRight float64 - // MarginTop float64 - // MarginBottom float64 + Left float64 + Right float64 + Top float64 + Bottom float64 GrowHorizontal bool GrowVertical bool @@ -81,4 +81,6 @@ type Style struct { BorderRadiusTopRight float64 BorderRadiusBottomLeft float64 BorderRadiusBottomRight float64 + + ZIndex int } From f6e0832af347bf134c871abb4aa16b3bc7dbf49a Mon Sep 17 00:00:00 2001 From: Vovko Date: Fri, 17 Jan 2025 22:29:02 -0500 Subject: [PATCH 19/39] added zIndex --- internal/render/v2/block.go | 24 ++++++++++++--- internal/render/v2/content-blocks.go | 41 +++++++++++++++++--------- internal/render/v2/content-empty.go | 11 +++++-- internal/render/v2/content-text.go | 44 ++++++++++++++++++---------- internal/render/v2/layer-context.go | 43 +++++++++++++++++++++++++++ internal/render/v2/render_test.go | 9 ++++-- 6 files changed, 134 insertions(+), 38 deletions(-) create mode 100644 internal/render/v2/layer-context.go diff --git a/internal/render/v2/block.go b/internal/render/v2/block.go index 1a61d9c1..a6fff4fc 100644 --- a/internal/render/v2/block.go +++ b/internal/render/v2/block.go @@ -1,6 +1,7 @@ package render import ( + "errors" "fmt" "image" @@ -39,11 +40,13 @@ type BlockContent interface { Type() blockContentType // Renders the block onto an image - Render(*gg.Context, Position) error + Render(layerContext, Position) error Style() style.StyleOptions setStyle(style.StyleOptions) + Layers() map[int]struct{} + // returns final block image dimensions without rendering dimensions() contentDimensions } @@ -52,6 +55,10 @@ type Block struct { content BlockContent } +func (b *Block) Layers() map[int]struct{} { + return b.content.Layers() +} + func (b *Block) Style() style.StyleOptions { return b.content.Style() } @@ -61,8 +68,14 @@ func (b *Block) Type() blockContentType { } func (b *Block) Render() (image.Image, error) { - dimensions := b.content.dimensions() - ctx := gg.NewContext(dimensions.width, dimensions.height) + dimensions := b.Dimensions() + + layers := b.Layers() + ctx := make(layerContext, len(layers)) + for idx := range layers { + ctx[idx] = gg.NewContext(dimensions.width, dimensions.height) + } + err := b.RenderTo(ctx, Position{0, 0}) if err != nil { return nil, err @@ -71,7 +84,10 @@ func (b *Block) Render() (image.Image, error) { } -func (b *Block) RenderTo(ctx *gg.Context, pos Position) error { +func (b *Block) RenderTo(ctx layerContext, pos Position) error { + if ctx == nil { + return errors.New("layer context cannot be nil") + } return b.content.Render(ctx, pos) } diff --git a/internal/render/v2/content-blocks.go b/internal/render/v2/content-blocks.go index 46b3ff4f..a4494016 100644 --- a/internal/render/v2/content-blocks.go +++ b/internal/render/v2/content-blocks.go @@ -105,13 +105,27 @@ func (content *contentBlocks) Type() blockContentType { return BlockContentTypeBlocks } +func (content *contentBlocks) Layers() map[int]struct{} { + var layers = make(map[int]struct{}, len(content.value)) + for _, block := range content.value { + for i, v := range block.Layers() { + layers[i] = v + } + } + return layers +} + func (content *contentBlocks) Style() style.StyleOptions { return content.style } -func (content *contentBlocks) Render(ctx *gg.Context, pos Position) error { +func (content *contentBlocks) Render(layers layerContext, pos Position) error { computed := content.style.Computed() dimensions := content.dimensions() + ctx, err := layers.layer(computed.ZIndex) + if err != nil { + return err + } if computed.Position == style.PositionAbsolute { if computed.Left != 0 { @@ -127,17 +141,16 @@ func (content *contentBlocks) Render(ctx *gg.Context, pos Position) error { } if computed.Blur > 0 { - blur := computed.Blur - computed.Blur = 0 - // render the content onto a new image, blur it, render onto parent - child := gg.NewContext(dimensions.width, dimensions.height) - err := content.Render(child, Position{0, 0}) - if err != nil { - return err - } - img := imaging.Blur(ctx.Image(), blur) - ctx.DrawImage(img, ceil(pos.X), ceil(pos.Y)) - return nil + // replace the context + parentPosition := pos + pos = Position{X: 0, Y: 0} + ctx = gg.NewContext(dimensions.width, dimensions.height) + defer func() { + // blur the result and paste onto the parent layer + parent, _ := layers.layer(computed.ZIndex) + img := imaging.Blur(ctx.Image(), computed.Blur) + parent.DrawImage(img, ceil(parentPosition.X), ceil(parentPosition.Y)) + }() } if computed.BackgroundColor != nil { @@ -159,10 +172,10 @@ func (content *contentBlocks) Render(ctx *gg.Context, pos Position) error { applyBlocksGrowth(computed, dimensions, content.value...) var originX, originY = pos.X + computed.PaddingLeft, pos.Y + computed.PaddingTop - return renderBlocksContent(ctx, computed, dimensions, Position{X: originX, Y: originY}, content.value...) + return renderBlocksContent(layers, computed, dimensions, Position{X: originX, Y: originY}, content.value...) } -func renderBlocksContent(ctx *gg.Context, containerStyle style.Style, container contentDimensions, pos Position, blocks ...*Block) error { +func renderBlocksContent(ctx layerContext, containerStyle style.Style, container contentDimensions, pos Position, blocks ...*Block) error { if len(blocks) < 1 { return errors.New("no blocks to render") } diff --git a/internal/render/v2/content-empty.go b/internal/render/v2/content-empty.go index 7b9159ff..5e3ddaa6 100644 --- a/internal/render/v2/content-empty.go +++ b/internal/render/v2/content-empty.go @@ -2,7 +2,6 @@ package render import ( "github.com/cufee/aftermath/internal/render/v2/style" - "github.com/fogleman/gg" ) var _ BlockContent = &contentEmpty{} @@ -35,13 +34,21 @@ func (content *contentEmpty) Type() blockContentType { return BlockContentTypeEmpty } +func (content *contentEmpty) Layers() map[int]struct{} { + return map[int]struct{}{content.style.Computed().ZIndex: {}} +} + func (content *contentEmpty) Style() style.StyleOptions { return content.style } -func (content *contentEmpty) Render(ctx *gg.Context, pos Position) error { +func (content *contentEmpty) Render(layers layerContext, pos Position) error { computed := content.style.Computed() dimensions := content.dimensions() + ctx, err := layers.layer(computed.ZIndex) + if err != nil { + return err + } var originX, originY float64 = pos.X + computed.PaddingLeft, pos.Y + computed.PaddingTop if computed.BackgroundColor != nil { diff --git a/internal/render/v2/content-text.go b/internal/render/v2/content-text.go index 00cc6a1a..6f269e7c 100644 --- a/internal/render/v2/content-text.go +++ b/internal/render/v2/content-text.go @@ -83,30 +83,42 @@ func (content *contentText) Type() blockContentType { return BlockContentTypeText } +func (content *contentText) Layers() map[int]struct{} { + return map[int]struct{}{content.style.Computed().ZIndex: {}} +} + func (content *contentText) Style() style.StyleOptions { return content.style } -func (content *contentText) Render(ctx *gg.Context, pos Position) error { +func (content *contentText) Render(layers layerContext, pos Position) error { computed := content.style.Computed() - size := content.measure(computed.Font) dimensions := content.dimensions() + if computed.Color == nil { + return errors.New("color cannot be nil") + } + if computed.Font == nil { + return errors.New("font cannot be nil") + } + ctx, err := layers.layer(computed.ZIndex) + if err != nil { + return err + } + + size := content.measure(computed.Font) + if computed.Blur > 0 { - // reset blur - current := content.Style() - current.Add(style.SetBlur(0)) - content.setStyle(current) - - // render the content onto a new image, blur it, render onto parent - child := gg.NewContext(dimensions.width, dimensions.height) - err := content.Render(child, Position{0, 0}) - if err != nil { - return err - } - img := imaging.Blur(child.Image(), computed.Blur) - ctx.DrawImage(img, ceil(pos.X), ceil(pos.Y)) - return nil + // replace the context + parentPosition := pos + pos = Position{X: 0, Y: 0} + ctx = gg.NewContext(dimensions.width, dimensions.height) + defer func() { + // blur the result and paste onto the parent layer + parent, _ := layers.layer(computed.ZIndex) + img := imaging.Blur(ctx.Image(), computed.Blur) + parent.DrawImage(img, ceil(parentPosition.X), ceil(parentPosition.Y)) + }() } if computed.BackgroundColor != nil { diff --git a/internal/render/v2/layer-context.go b/internal/render/v2/layer-context.go new file mode 100644 index 00000000..4e3d7a89 --- /dev/null +++ b/internal/render/v2/layer-context.go @@ -0,0 +1,43 @@ +package render + +import ( + "image" + "slices" + + "github.com/fogleman/gg" + "github.com/pkg/errors" +) + +type layerContext map[int]*gg.Context + +func (ctx layerContext) layer(idx int) (*gg.Context, error) { + layer := ctx[idx] + if layer == nil { + return nil, errors.New("layer context is nil") + } + return layer, nil +} + +func (ctx layerContext) Image() image.Image { + var layers []int + for idx := range ctx { + layers = append(layers, idx) + } + slices.Sort(layers) + + var frame *gg.Context + for _, idx := range layers { + layer := ctx[idx] + if layer == nil { + continue + } + if frame == nil { + frame = layer + continue + } + + frame.DrawImage(layer.Image(), 0, 0) + } + + return frame.Image() +} diff --git a/internal/render/v2/render_test.go b/internal/render/v2/render_test.go index 7f0cf2ed..0237dbce 100644 --- a/internal/render/v2/render_test.go +++ b/internal/render/v2/render_test.go @@ -26,12 +26,17 @@ func init() { } func TestRenderV2(t *testing.T) { + if os.Getenv("CI") == "true" { + return // this is a local test for visual debugging + } + is := is.New(t) text1, err := NewTextContent(style.NewStyle( style.Parent(style.Style{ - Right: -5, - Top: -5, + Left: -5, + Top: -5, + // Blur: 1, 1, }), style.SetDebug(true), style.SetPosition(style.PositionAbsolute), From b557b6858ba39487a6f3b7b4504823fca8def411 Mon Sep 17 00:00:00 2001 From: Vovko Date: Fri, 17 Jan 2025 22:34:29 -0500 Subject: [PATCH 20/39] removed unusable method --- internal/render/v2/block.go | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/internal/render/v2/block.go b/internal/render/v2/block.go index a6fff4fc..9667216d 100644 --- a/internal/render/v2/block.go +++ b/internal/render/v2/block.go @@ -1,7 +1,6 @@ package render import ( - "errors" "fmt" "image" @@ -76,19 +75,11 @@ func (b *Block) Render() (image.Image, error) { ctx[idx] = gg.NewContext(dimensions.width, dimensions.height) } - err := b.RenderTo(ctx, Position{0, 0}) + err := b.content.Render(ctx, Position{0, 0}) if err != nil { return nil, err } return ctx.Image(), nil - -} - -func (b *Block) RenderTo(ctx layerContext, pos Position) error { - if ctx == nil { - return errors.New("layer context cannot be nil") - } - return b.content.Render(ctx, pos) } func (b *Block) Dimensions() contentDimensions { From 908541688d181615bd2a9f6c8d6ec4417bff0ea4 Mon Sep 17 00:00:00 2001 From: Vovko Date: Fri, 17 Jan 2025 22:39:53 -0500 Subject: [PATCH 21/39] moved some useful common logic out --- internal/render/common/background.go | 160 +++++++++++++++++++++++++++ internal/render/common/colors.go | 5 + internal/render/common/init.go | 30 +++++ internal/render/common/logo.go | 96 ++++++++++++++++ internal/render/v2/render_test.go | 3 +- 5 files changed, 293 insertions(+), 1 deletion(-) create mode 100644 internal/render/common/background.go create mode 100644 internal/render/common/colors.go create mode 100644 internal/render/common/init.go create mode 100644 internal/render/common/logo.go diff --git a/internal/render/common/background.go b/internal/render/common/background.go new file mode 100644 index 00000000..156a5b0e --- /dev/null +++ b/internal/render/common/background.go @@ -0,0 +1,160 @@ +package render + +import ( + "image" + "image/color" + "math" + "math/rand" + "sync" + + "github.com/fogleman/gg" + "github.com/nao1215/imaging" +) + +var DefaultBackgroundBlur float64 = 5 +var GlassEffectBackgroundBlur float64 = DefaultBackgroundBlur * 5 + +var globalLogoCacheMx sync.Mutex +var globalLogoCache = make(map[color.Color]image.Image) + +func AddDefaultBrandedOverlay(background image.Image, colors []color.Color, seed int, colorChance float32) image.Image { + if len(colors) < 1 { + colors = DefaultLogoColorOptions + } + + source := rand.NewSource(int64(seed)) + r := rand.New(source) + for i := range colors { + if r.Float32() > colorChance { + colors[i] = TextSecondary + } + } + + size := 15 + overlay := NewBrandedBackground(background.Bounds().Dx()*2, background.Bounds().Dy()*2, size, -size/2, colors, seed) + return imaging.OverlayCenter(background, overlay, 0.5) +} + +func NewBrandedBackground(width, height, logoSize, padding int, colors []color.Color, hashSeed int) image.Image { + // 1/3 of the image should be left for logos + rows := max((height-padding*2)/3/logoSize, 2) + cols := max((width-padding*2)/3/logoSize, 2) + // the rest is gaps + xGapsTotal := width - padding*2 - (cols)*logoSize + yGapsTotal := height - padding*2 - (rows)*logoSize + xGap := xGapsTotal / (cols - 1) + yGap := yGapsTotal / (rows - 1) + + localLogoCache := make(map[color.Color]image.Image) + getLogo := func(color color.Color) image.Image { + if img := localLogoCache[color]; img != nil { + return img + } + + logo, ok := globalLogoCache[color] + if !ok { + logo = AftermathLogo(color, SmallLogoOptions()) + globalLogoCacheMx.Lock() + globalLogoCache[color] = logo + globalLogoCacheMx.Unlock() + } + logo = imaging.Fill(logo, logoSize, logoSize, imaging.Center, imaging.Lanczos) + localLogoCache[color] = logo // mx is already locked inside the caller + return logo + } + + ctx := gg.NewContext(width, height) + var makeFn []func() + var mx sync.Mutex + var wg sync.WaitGroup + + for c := range cols { + for r := range rows { + wg.Add(1) + + makeFn = append(makeFn, func() { + defer wg.Done() + + posX := float64(padding + c*(logoSize+xGap)) + posY := float64(padding + r*(logoSize+yGap)) + source := rand.NewSource(int64(hashSeed) + int64(posX)*51 + int64(posY)*37) + rnd := rand.New(source) + + if n := rnd.Float32(); n < 0.5 { + return + } + + clr := pickColor(colors, rnd) + scale := pickScaleFactor(rnd) + rotation := pickRotationRad(rnd) + + mx.Lock() + logo := getLogo(clr) + mx.Unlock() + + logoAdjusted := imaging.New(logoSize, logoSize, color.Transparent) + logoAdjusted = imaging.PasteCenter(logoAdjusted, logo) + logoAdjusted = imaging.Rotate(logoAdjusted, rotation, color.Transparent) + logoAdjusted = imaging.Resize(logoAdjusted, int(float64(logoSize)*scale), int(float64(logoSize)*scale), imaging.Linear) + + xJ, yJ := pickPositionJitter(rnd) + posX += xJ + posY += yJ + + mx.Lock() + ctx.DrawImage(logoAdjusted, int(posX), int(posY)) + mx.Unlock() + }) + } + } + for _, fn := range makeFn { + go fn() // wg.Done is called inside already, wg.Add was called already + } + wg.Wait() + + return ctx.Image() +} + +// pickColor function that includes hashSeed in the hash calculation +func pickColor(colors []color.Color, r *rand.Rand) color.Color { + if len(colors) < 1 { + return color.White + } + + index := r.Intn(len(colors)) + return colors[index] +} + +// pickScaleFactor function that generates a scale factor clamped between 0.8 and 1.2, influenced by image size +func pickScaleFactor(r *rand.Rand) float64 { + // Clamp the scale factor between 0.5 and 1.5 + scaleFactor := 0.5 + (r.Float64()) + return scaleFactor +} + +// pickPositionJitter function that generates an x,y position offset based on the hash seed +func pickPositionJitter(r *rand.Rand) (float64, float64) { + // Clamp between 0.5 and 1.5 + xJitter := -0.5 + r.Float64() + yJitter := -0.5 + r.Float64() + return xJitter, yJitter +} + +func pickRotationRad(r *rand.Rand) float64 { + // Clamp the rotation angle between -2.5π and 2.5π radians + rotationRad := -2.5*math.Pi + (r.Float64() * (5 * math.Pi)) + return rotationRad +} + +func BlurWithMask(content image.Image, mask *image.Alpha, blur, maskBlur float64) (image.Image, error) { + ctx := gg.NewContext(content.Bounds().Dx(), content.Bounds().Dy()) + err := ctx.SetMask(mask) + if err != nil { + return nil, err + } + ctx.DrawImage(imaging.Blur(content, maskBlur), 0, 0) + + content = imaging.Blur(content, blur) + content = imaging.OverlayCenter(content, ctx.Image(), 1) // paste masked content on top of content + return content, nil +} diff --git a/internal/render/common/colors.go b/internal/render/common/colors.go new file mode 100644 index 00000000..56a4a49b --- /dev/null +++ b/internal/render/common/colors.go @@ -0,0 +1,5 @@ +package render + +import "image/color" + +var DefaultLogoColorOptions = []color.Color{color.NRGBA{50, 50, 50, 180}, color.NRGBA{200, 200, 200, 180}} diff --git a/internal/render/common/init.go b/internal/render/common/init.go new file mode 100644 index 00000000..f83fc4c6 --- /dev/null +++ b/internal/render/common/init.go @@ -0,0 +1,30 @@ +package render + +import ( + "image/color" +) + +var DiscordBackgroundColor = color.NRGBA{49, 51, 56, 255} + +var ( + TextPrimary = color.NRGBA{255, 255, 255, 255} + TextSecondary = color.NRGBA{204, 204, 204, 255} + TextAlt = color.NRGBA{150, 150, 150, 255} + + TextSubscriptionPlus = color.NRGBA{72, 167, 250, 255} + TextSubscriptionPremium = color.NRGBA{255, 223, 0, 255} + + DefaultCardColor = color.NRGBA{10, 10, 10, 180} + DefaultCardColorNoAlpha = color.NRGBA{10, 10, 10, 255} + ClanTagBackgroundColor = color.NRGBA{10, 10, 10, 120} + + ColorAftermathRed = color.NRGBA{255, 0, 120, 255} + ColorAftermathBlue = color.NRGBA{72, 167, 250, 255} + ColorAftermathYellow = color.NRGBA{255, 223, 0, 255} + + BorderRadiusXL = 30.0 + BorderRadiusLG = 25.0 + BorderRadiusMD = 20.0 + BorderRadiusSM = 15.0 + BorderRadiusXS = 10.0 +) diff --git a/internal/render/common/logo.go b/internal/render/common/logo.go new file mode 100644 index 00000000..c95d8cc0 --- /dev/null +++ b/internal/render/common/logo.go @@ -0,0 +1,96 @@ +package render + +import ( + "image" + "image/color" + "math" + + "github.com/fogleman/gg" +) + +type LogoSizingOptions struct { + Lines int + Gap float64 + BaseWidth float64 +} + +func (opts LogoSizingOptions) LineHeightAt(i int) float64 { + h := opts.BaseWidth + (opts.BaseWidth / 2 * math.Pow(float64(i+1), 1.75)) + + if i > opts.Lines/2 { + h = opts.LineHeightAt(opts.Lines - i - 1) + } + return h +} +func (opts LogoSizingOptions) LineWidthAt(i int) float64 { + return opts.BaseWidth +} +func (opts LogoSizingOptions) LineOffsetAt(i int) float64 { + o := opts.BaseWidth * math.Pow(float64(i), 1.25) + if i > opts.Lines/2 { + o = opts.LineOffsetAt(opts.Lines - i - 1) + } + return o +} + +func (opts LogoSizingOptions) Height() int { + var maxHeight int + for line := range opts.Lines { + maxHeight = max(maxHeight, int(math.RoundToEven(opts.LineHeightAt(line)+opts.LineOffsetAt(line)))) + } + return maxHeight +} +func (opts LogoSizingOptions) Width() int { + var totalWidth int = int(math.RoundToEven(opts.Gap * float64(opts.Lines-1))) + for line := range opts.Lines { + totalWidth += int(math.RoundToEven(opts.LineWidthAt(line))) + } + return totalWidth +} + +func DefaultLogoOptions() LogoSizingOptions { + return LogoSizingOptions{ + Lines: 7, + BaseWidth: 6, + Gap: 3, + } +} + +func SmallLogoOptions() LogoSizingOptions { + return LogoSizingOptions{ + Lines: 5, + BaseWidth: 6, + Gap: 3, + } +} + +func LargeLogoOptions() LogoSizingOptions { + return LogoSizingOptions{ + Lines: 7, + BaseWidth: 60, + Gap: 30, + } +} + +func AftermathLogo(fillColor color.Color, opts LogoSizingOptions) image.Image { + ctx := gg.NewContext(opts.Width(), opts.Height()) + for line := range opts.Lines { + var xPos = 0.0 + for prev := range line { + xPos += (opts.LineWidthAt(prev) + opts.Gap) + } + height := opts.LineHeightAt(line) + ctx.DrawRoundedRectangle( + xPos, // x + float64(opts.Height())-opts.LineOffsetAt(line)-height, // y + opts.LineWidthAt(line), // w + opts.LineHeightAt(line), // h + opts.BaseWidth/2, // border radius + ) + ctx.SetColor(fillColor) + ctx.Fill() + ctx.ClearPath() + } + + return ctx.Image() +} diff --git a/internal/render/v2/render_test.go b/internal/render/v2/render_test.go index 0237dbce..a8a2c92a 100644 --- a/internal/render/v2/render_test.go +++ b/internal/render/v2/render_test.go @@ -36,7 +36,8 @@ func TestRenderV2(t *testing.T) { style.Parent(style.Style{ Left: -5, Top: -5, - // Blur: 1, 1, + // Blur: 1, + ZIndex: 1, }), style.SetDebug(true), style.SetPosition(style.PositionAbsolute), From 9df1344ae5a55866f3032aebe3bcdddd325d283c Mon Sep 17 00:00:00 2001 From: Vovko Date: Sat, 18 Jan 2025 13:28:51 -0500 Subject: [PATCH 22/39] made cards more transparent --- internal/render/v1/init.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/render/v1/init.go b/internal/render/v1/init.go index 2f7e1e8a..fb81a535 100644 --- a/internal/render/v1/init.go +++ b/internal/render/v1/init.go @@ -20,9 +20,9 @@ var ( TextSubscriptionPlus = color.NRGBA{72, 167, 250, 255} TextSubscriptionPremium = color.NRGBA{255, 223, 0, 255} - DefaultCardColor = color.NRGBA{10, 10, 10, 180} + DefaultCardColor = color.NRGBA{10, 10, 10, 150} DefaultCardColorNoAlpha = color.NRGBA{10, 10, 10, 255} - ClanTagBackgroundColor = color.NRGBA{10, 10, 10, 120} + ClanTagBackgroundColor = color.NRGBA{10, 10, 10, 100} ColorAftermathRed = color.NRGBA{255, 0, 120, 255} ColorAftermathBlue = color.NRGBA{72, 167, 250, 255} From 00ac2a5fc31e36e2601b659c83c5b167683a88d3 Mon Sep 17 00:00:00 2001 From: Vovko Date: Sat, 18 Jan 2025 13:43:40 -0500 Subject: [PATCH 23/39] moved render package out --- internal/render/v2/block.go | 94 ------ internal/render/v2/ceil.go | 7 - internal/render/v2/content-blocks.go | 340 -------------------- internal/render/v2/content-empty.go | 66 ---- internal/render/v2/content-text.go | 169 ---------- internal/render/v2/debug.go | 11 - internal/render/v2/internal/tests/font.go | 14 - internal/render/v2/internal/tests/font.ttf | Bin 171656 -> 0 bytes internal/render/v2/internal/tests/root.go | 13 - internal/render/v2/layer-context.go | 43 --- internal/render/v2/measure.go | 51 --- internal/render/v2/render_test.go | 354 --------------------- internal/render/v2/style/font.go | 41 --- internal/render/v2/style/options.go | 123 ------- internal/render/v2/style/style.go | 86 ----- 15 files changed, 1412 deletions(-) delete mode 100644 internal/render/v2/block.go delete mode 100644 internal/render/v2/ceil.go delete mode 100644 internal/render/v2/content-blocks.go delete mode 100644 internal/render/v2/content-empty.go delete mode 100644 internal/render/v2/content-text.go delete mode 100644 internal/render/v2/debug.go delete mode 100644 internal/render/v2/internal/tests/font.go delete mode 100644 internal/render/v2/internal/tests/font.ttf delete mode 100644 internal/render/v2/internal/tests/root.go delete mode 100644 internal/render/v2/layer-context.go delete mode 100644 internal/render/v2/measure.go delete mode 100644 internal/render/v2/render_test.go delete mode 100644 internal/render/v2/style/font.go delete mode 100644 internal/render/v2/style/options.go delete mode 100644 internal/render/v2/style/style.go diff --git a/internal/render/v2/block.go b/internal/render/v2/block.go deleted file mode 100644 index 9667216d..00000000 --- a/internal/render/v2/block.go +++ /dev/null @@ -1,94 +0,0 @@ -package render - -import ( - "fmt" - "image" - - "github.com/cufee/aftermath/internal/render/v2/style" - "github.com/fogleman/gg" -) - -func NewBlock(content BlockContent) *Block { - return &Block{ - content: content, - } -} - -type blockContentType int - -func (t blockContentType) MarshalJSON() ([]byte, error) { - return []byte(fmt.Sprintf("%d", t)), nil -} -func (t blockContentType) String() string { - return fmt.Sprintf("%d", t) -} - -const ( - BlockContentTypeEmpty blockContentType = iota - BlockContentTypeBlocks - BlockContentTypeImage - BlockContentTypeText -) - -type Position struct { - X float64 - Y float64 -} - -type BlockContent interface { - Type() blockContentType - - // Renders the block onto an image - Render(layerContext, Position) error - - Style() style.StyleOptions - setStyle(style.StyleOptions) - - Layers() map[int]struct{} - - // returns final block image dimensions without rendering - dimensions() contentDimensions -} - -type Block struct { - content BlockContent -} - -func (b *Block) Layers() map[int]struct{} { - return b.content.Layers() -} - -func (b *Block) Style() style.StyleOptions { - return b.content.Style() -} - -func (b *Block) Type() blockContentType { - return b.content.Type() -} - -func (b *Block) Render() (image.Image, error) { - dimensions := b.Dimensions() - - layers := b.Layers() - ctx := make(layerContext, len(layers)) - for idx := range layers { - ctx[idx] = gg.NewContext(dimensions.width, dimensions.height) - } - - err := b.content.Render(ctx, Position{0, 0}) - if err != nil { - return nil, err - } - return ctx.Image(), nil -} - -func (b *Block) Dimensions() contentDimensions { - return b.content.dimensions() -} - -type contentDimensions struct { - width int - height int - paddingAndGapsX float64 - paddingAndGapsY float64 -} diff --git a/internal/render/v2/ceil.go b/internal/render/v2/ceil.go deleted file mode 100644 index 5c5a58b6..00000000 --- a/internal/render/v2/ceil.go +++ /dev/null @@ -1,7 +0,0 @@ -package render - -import "math" - -func ceil(value float64) int { - return int(math.Ceil(value)) -} diff --git a/internal/render/v2/content-blocks.go b/internal/render/v2/content-blocks.go deleted file mode 100644 index a4494016..00000000 --- a/internal/render/v2/content-blocks.go +++ /dev/null @@ -1,340 +0,0 @@ -package render - -import ( - "errors" - - "github.com/cufee/aftermath/internal/render/v2/style" - "github.com/fogleman/gg" - "github.com/nao1215/imaging" -) - -var _ BlockContent = &contentBlocks{} - -func NewBlocksContent(style style.StyleOptions, value ...*Block) *Block { - return NewBlock(&contentBlocks{ - value: value, - style: style, - }) -} - -type contentBlocks struct { - style style.StyleOptions - value []*Block -} - -func (content *contentBlocks) setStyle(style style.StyleOptions) { - content.style = style -} - -func (content *contentBlocks) dimensions() contentDimensions { - if len(content.value) == 0 { - return contentDimensions{} - } - - computed := content.style.Computed() - dimensions := contentDimensions{ - width: ceil(computed.Width), - height: ceil(computed.Height), - paddingAndGapsX: computed.PaddingLeft + computed.PaddingRight, - paddingAndGapsY: computed.PaddingTop + computed.PaddingBottom, - } - - var gapCount = 0 - for _, block := range content.value { - switch block.Style().Computed().Position { - case style.PositionRelative: - gapCount++ - } - } - - switch computed.Direction { - case style.DirectionHorizontal: - dimensions.paddingAndGapsX += max(0, computed.Gap*float64(gapCount-1)) - case style.DirectionVertical: - dimensions.paddingAndGapsY += max(0, computed.Gap*float64(gapCount-1)) - } - - if dimensions.width > 0 && dimensions.height > 0 { - return dimensions - } - - // add content dimensions of each block to the total - var blockWidthTotal, blockWidthMax, blockHeightTotal, blockHeightMax int - for _, block := range content.value { - blockDimensions := block.Dimensions() - - if block.Style().Computed().Position == style.PositionAbsolute { - continue - } - - blockWidthTotal += blockDimensions.width - blockWidthMax = max(blockWidthMax, blockDimensions.width) - - blockHeightTotal += blockDimensions.height - blockHeightMax = max(blockHeightMax, blockDimensions.height) - } - - // calculate final block width if it was not set already - if dimensions.width == 0 { - dimensions.width = ceil(dimensions.paddingAndGapsX) - - switch computed.Direction { - case style.DirectionHorizontal: - dimensions.width += blockWidthTotal - - case style.DirectionVertical: - dimensions.width += blockWidthMax - } - } - // calculate final block height if it was not set already - if dimensions.height == 0 { - dimensions.height = ceil(dimensions.paddingAndGapsY) - - switch computed.Direction { - case style.DirectionHorizontal: - dimensions.height += blockHeightMax - case style.DirectionVertical: - dimensions.height += blockHeightTotal - } - } - - return dimensions -} - -func (content *contentBlocks) Type() blockContentType { - return BlockContentTypeBlocks -} - -func (content *contentBlocks) Layers() map[int]struct{} { - var layers = make(map[int]struct{}, len(content.value)) - for _, block := range content.value { - for i, v := range block.Layers() { - layers[i] = v - } - } - return layers -} - -func (content *contentBlocks) Style() style.StyleOptions { - return content.style -} - -func (content *contentBlocks) Render(layers layerContext, pos Position) error { - computed := content.style.Computed() - dimensions := content.dimensions() - ctx, err := layers.layer(computed.ZIndex) - if err != nil { - return err - } - - if computed.Position == style.PositionAbsolute { - if computed.Left != 0 { - pos.X += computed.Left - } else if computed.Right != 0 { - pos.X += float64(dimensions.width) - computed.Right - } - if computed.Top != 0 { - pos.Y += computed.Top - } else if computed.Bottom != 0 { - pos.Y += float64(dimensions.height) - computed.Bottom - } - } - - if computed.Blur > 0 { - // replace the context - parentPosition := pos - pos = Position{X: 0, Y: 0} - ctx = gg.NewContext(dimensions.width, dimensions.height) - defer func() { - // blur the result and paste onto the parent layer - parent, _ := layers.layer(computed.ZIndex) - img := imaging.Blur(ctx.Image(), computed.Blur) - parent.DrawImage(img, ceil(parentPosition.X), ceil(parentPosition.Y)) - }() - } - - if computed.BackgroundColor != nil { - ctx.SetColor(computed.BackgroundColor) - ctx.DrawRectangle(pos.X, pos.Y, float64(dimensions.width), float64(dimensions.height)) - ctx.Fill() - } - if computed.BackgroundImage != nil { - background := imaging.Fill(computed.BackgroundImage, dimensions.width, dimensions.height, imaging.Center, imaging.Lanczos) - ctx.DrawImage(background, ceil(pos.X), ceil(pos.Y)) - } - - if computed.Debug { - ctx.SetColor(getDebugColor()) - ctx.DrawRectangle(pos.X, pos.Y, float64(dimensions.width), float64(dimensions.height)) - ctx.Stroke() - } - - applyBlocksGrowth(computed, dimensions, content.value...) - - var originX, originY = pos.X + computed.PaddingLeft, pos.Y + computed.PaddingTop - return renderBlocksContent(layers, computed, dimensions, Position{X: originX, Y: originY}, content.value...) -} - -func renderBlocksContent(ctx layerContext, containerStyle style.Style, container contentDimensions, pos Position, blocks ...*Block) error { - if len(blocks) < 1 { - return errors.New("no blocks to render") - } - - var lastX, lastY float64 = pos.X, pos.Y - for _, block := range blocks { - blockStyle := block.Style().Computed() - blockSize := block.Dimensions() - posX, posY := lastX, lastY - - // apply absolute position margins - if blockStyle.Position == style.PositionAbsolute { - if blockStyle.Left != 0 { - posX += blockStyle.Left - } else if blockStyle.Right != 0 { - posX += float64(container.width-int(container.paddingAndGapsX)-blockSize.width) - blockStyle.Right - } - if blockStyle.Top != 0 { - posY += blockStyle.Top - } else if blockStyle.Bottom != 0 { - posY += float64(container.height-int(container.paddingAndGapsY)-blockSize.height) - blockStyle.Bottom - } - } - - switch containerStyle.Direction { - case style.DirectionVertical: - // align content vertically - switch containerStyle.JustifyContent { - case style.JustifyContentCenter: - posY += float64(container.height-blockSize.height) / 2 - case style.JustifyContentEnd: - posY += float64(container.height - blockSize.height) - case style.JustifyContentSpaceAround: - posY += float64((container.height - blockSize.height) / (len(blocks) + 1)) - case style.JustifyContentSpaceBetween: - if len(blocks) > 1 { - posY += float64((container.height - blockSize.height) / (len(blocks) - 1)) - } - } - - // align content horizontally - switch containerStyle.AlignItems { - case style.AlignItemsCenter: - posX += float64(container.width-blockSize.width) / 2 - case style.AlignItemsEnd: - posX += float64(blockSize.width) - } - default: // DirectionHorizontal - // align content horizontally - switch containerStyle.JustifyContent { - case style.JustifyContentCenter: - posX += float64(container.width-blockSize.width) / 2 - case style.JustifyContentEnd: - posX += float64(container.width - blockSize.width) - case style.JustifyContentSpaceAround: - posX += float64((container.width - blockSize.width) / (len(blocks) + 1)) - case style.JustifyContentSpaceBetween: - if len(blocks) > 1 { - posX += float64((container.width - blockSize.width) / (len(blocks) - 1)) - } - } - - // align content vertically - switch containerStyle.AlignItems { - case style.AlignItemsCenter: - posY += (float64(container.height-blockSize.height) / 2) - case style.AlignItemsEnd: - posY += float64(blockSize.height) - } - - } - - err := block.content.Render(ctx, Position{posX, posY}) - if err != nil { - return err - } - - if block.Style().Computed().Position == style.PositionAbsolute { - continue - } - - // save the position we rendered at - switch containerStyle.Direction { - case style.DirectionVertical: - lastY = posY + float64(blockSize.height) + containerStyle.Gap - default: - lastX = posX + float64(blockSize.width) + containerStyle.Gap - } - } - - return nil -} - -func applyBlocksGrowth(containerStyle style.Style, container contentDimensions, blocks ...*Block) { - // calculate content dimensions before growth - var blockWidthTotal, blockWidthMax, blockHeightTotal, blockHeightMax int - var growBlocksX, growBlocksY = 0, 0 - for _, block := range blocks { - blockDimensions := block.Dimensions() - - blockWidthTotal += blockDimensions.width - blockWidthMax = max(blockWidthMax, blockDimensions.width) - - blockHeightTotal += blockDimensions.height - blockHeightMax = max(blockHeightMax, blockDimensions.height) - - blockStyle := block.Style().Computed() - switch { - case blockStyle.Position == style.PositionAbsolute: - // absolute blocks do not "consume" grow space - case blockStyle.GrowHorizontal: - growBlocksX++ - case blockStyle.GrowVertical: - growBlocksY++ - } - } - - blockGrowX := max(0, container.width-ceil(container.paddingAndGapsX)-blockWidthTotal) / max(1, growBlocksX) - blockGrowY := max(0, container.height-ceil(container.paddingAndGapsY)-blockHeightTotal) / max(1, growBlocksY) - - // apply growth to blocks - for _, block := range blocks { - blockStyle := block.Style() - blockComputed := blockStyle.Computed() - blockSize := block.Dimensions() - - if !blockComputed.GrowHorizontal && !blockComputed.GrowVertical { - continue - } - - switch containerStyle.Direction { - case style.DirectionHorizontal: - // update the block width - if blockComputed.GrowHorizontal && blockComputed.Position == style.PositionAbsolute { - blockStyle.Add(style.SetWidth(float64(container.width) - containerStyle.PaddingLeft - containerStyle.PaddingRight)) - block.content.setStyle(blockStyle) - } else if blockComputed.GrowHorizontal { - blockStyle.Add(style.SetWidth(float64(blockSize.width) + float64(blockGrowX))) - block.content.setStyle(blockStyle) - } - // update the block height - if blockComputed.GrowVertical { - blockStyle.Add(style.SetHeight(float64(blockHeightMax))) - block.content.setStyle(blockStyle) - } - case style.DirectionVertical: - // update the block width - if blockComputed.GrowHorizontal { - blockStyle.Add(style.SetWidth(float64(blockWidthMax))) - block.content.setStyle(blockStyle) - } - // update the block height - if blockComputed.GrowVertical && blockComputed.Position == style.PositionAbsolute { - blockStyle.Add(style.SetWidth(float64(container.height) - containerStyle.PaddingTop - containerStyle.PaddingBottom)) - block.content.setStyle(blockStyle) - } else if blockComputed.GrowVertical { - blockStyle.Add(style.SetHeight(float64(blockSize.height) + float64(blockGrowY))) - block.content.setStyle(blockStyle) - } - } - } -} diff --git a/internal/render/v2/content-empty.go b/internal/render/v2/content-empty.go deleted file mode 100644 index 5e3ddaa6..00000000 --- a/internal/render/v2/content-empty.go +++ /dev/null @@ -1,66 +0,0 @@ -package render - -import ( - "github.com/cufee/aftermath/internal/render/v2/style" -) - -var _ BlockContent = &contentEmpty{} - -func NewEmptyContent(style style.StyleOptions) *Block { - return NewBlock(&contentEmpty{ - style: style, - }) -} - -type contentEmpty struct { - style style.StyleOptions -} - -func (content *contentEmpty) setStyle(style style.StyleOptions) { - content.style = style -} - -func (content *contentEmpty) dimensions() contentDimensions { - computed := content.Style().Computed() - return contentDimensions{ - width: int(computed.Width), - height: int(computed.Height), - paddingAndGapsY: computed.PaddingTop + computed.PaddingBottom, - paddingAndGapsX: computed.PaddingLeft + computed.PaddingRight, - } -} - -func (content *contentEmpty) Type() blockContentType { - return BlockContentTypeEmpty -} - -func (content *contentEmpty) Layers() map[int]struct{} { - return map[int]struct{}{content.style.Computed().ZIndex: {}} -} - -func (content *contentEmpty) Style() style.StyleOptions { - return content.style -} - -func (content *contentEmpty) Render(layers layerContext, pos Position) error { - computed := content.style.Computed() - dimensions := content.dimensions() - ctx, err := layers.layer(computed.ZIndex) - if err != nil { - return err - } - - var originX, originY float64 = pos.X + computed.PaddingLeft, pos.Y + computed.PaddingTop - if computed.BackgroundColor != nil { - ctx.SetColor(computed.BackgroundColor) - ctx.DrawRectangle(originX, originY, float64(dimensions.width), float64(dimensions.height)) - ctx.Fill() - } - - if computed.Debug { - ctx.SetColor(getDebugColor()) - ctx.DrawRectangle(pos.X, pos.Y, float64(dimensions.width), float64(dimensions.height)) - ctx.Stroke() - } - return nil -} diff --git a/internal/render/v2/content-text.go b/internal/render/v2/content-text.go deleted file mode 100644 index 6f269e7c..00000000 --- a/internal/render/v2/content-text.go +++ /dev/null @@ -1,169 +0,0 @@ -package render - -import ( - "math" - "strings" - - "github.com/cufee/aftermath/internal/render/v2/style" - "github.com/fogleman/gg" - "github.com/nao1215/imaging" - "github.com/pkg/errors" -) - -var _ BlockContent = &contentText{} - -func NewTextContent(style style.StyleOptions, value string) (*Block, error) { - computed := style.Computed() - if !computed.Font.Valid() { - return nil, errors.New("invalid or missing font") - } - if computed.Color == nil { - return nil, errors.New("text requires a non nil color") - } - return NewBlock(&contentText{ - value: value, - style: style, - }), nil -} - -func MustNewTextContent(style style.StyleOptions, value string) *Block { - c, _ := NewTextContent(style, value) - return c -} - -type contentText struct { - style style.StyleOptions - value string - - dimensionsCache *contentDimensions // add cache to avoid parsing and rendering fonts repeatedly - sizeCache *StringSize // add cache to avoid parsing and rendering fonts repeatedly -} - -func (content *contentText) setStyle(style style.StyleOptions) { - content.dimensionsCache = nil - content.sizeCache = nil - content.style = style -} - -func (content *contentText) measure(font style.Font) StringSize { - if content.sizeCache != nil { - return *content.sizeCache - } - - size := MeasureString(content.value, font) - content.sizeCache = &size - return size -} - -func (content *contentText) dimensions() contentDimensions { - if content.dimensionsCache != nil { - return *content.dimensionsCache - } - - computed := content.style.Computed() - size := content.measure(computed.Font) - - var width, height = 0.0, 0.0 - if computed.Width > 0 { - width = computed.Width - } else { - width = size.TotalWidth + (computed.PaddingLeft + computed.PaddingRight) - } - if computed.Height > 0 { - height = computed.Height - } else { - height = size.TotalHeight + (computed.PaddingTop + computed.PaddingBottom) - } - - content.dimensionsCache = &contentDimensions{width: int(math.Ceil(width)), height: int(math.Ceil(height))} - return *content.dimensionsCache -} - -func (content *contentText) Type() blockContentType { - return BlockContentTypeText -} - -func (content *contentText) Layers() map[int]struct{} { - return map[int]struct{}{content.style.Computed().ZIndex: {}} -} - -func (content *contentText) Style() style.StyleOptions { - return content.style -} - -func (content *contentText) Render(layers layerContext, pos Position) error { - computed := content.style.Computed() - dimensions := content.dimensions() - - if computed.Color == nil { - return errors.New("color cannot be nil") - } - if computed.Font == nil { - return errors.New("font cannot be nil") - } - ctx, err := layers.layer(computed.ZIndex) - if err != nil { - return err - } - - size := content.measure(computed.Font) - - if computed.Blur > 0 { - // replace the context - parentPosition := pos - pos = Position{X: 0, Y: 0} - ctx = gg.NewContext(dimensions.width, dimensions.height) - defer func() { - // blur the result and paste onto the parent layer - parent, _ := layers.layer(computed.ZIndex) - img := imaging.Blur(ctx.Image(), computed.Blur) - parent.DrawImage(img, ceil(parentPosition.X), ceil(parentPosition.Y)) - }() - } - - if computed.BackgroundColor != nil { - ctx.SetColor(computed.BackgroundColor) - ctx.DrawRectangle(pos.X, pos.Y, float64(dimensions.width), float64(dimensions.height)) - ctx.Fill() - } - if computed.BackgroundImage != nil { - background := imaging.Fill(computed.BackgroundImage, dimensions.width, dimensions.height, imaging.Center, imaging.Lanczos) - ctx.DrawImage(background, ceil(pos.X), ceil(pos.Y)) - } - - if computed.Debug { - ctx.SetColor(getDebugColor()) - ctx.DrawRectangle(pos.X, pos.Y, float64(dimensions.width), float64(dimensions.height)) - ctx.Stroke() - } - - var lastX, lastY float64 = pos.X + computed.PaddingLeft, pos.Y + computed.PaddingTop + 1 - - switch computed.JustifyContent { - case style.JustifyContentEnd: - lastX += float64(dimensions.width) - size.TotalWidth - case style.JustifyContentCenter: - lastX += (float64(dimensions.width) - size.TotalWidth) / 2 - } - switch computed.AlignItems { - case style.AlignItemsEnd: - lastY += float64(dimensions.width) - size.TotalHeight - case style.AlignItemsCenter: - lastY += (float64(dimensions.width) - size.TotalHeight) / 2 - } - - // Render text - face, close := computed.Font.Face() - defer close() - - ctx.SetFontFace(face) - ctx.SetColor(computed.Color) - - for _, str := range strings.Split(content.value, "\n") { - lastY += size.LineHeight - x, y := lastX, lastY-size.LineOffset - ctx.DrawString(str, x, y) - } - - return nil -} diff --git a/internal/render/v2/debug.go b/internal/render/v2/debug.go deleted file mode 100644 index 6d34050b..00000000 --- a/internal/render/v2/debug.go +++ /dev/null @@ -1,11 +0,0 @@ -package render - -import ( - "image/color" - "time" -) - -func getDebugColor() color.Color { - ns := time.Now().Nanosecond() - return color.NRGBA{uint8(ns%120) + 120, uint8(ns%100) + 50, uint8(ns%100) + 50, 255} -} diff --git a/internal/render/v2/internal/tests/font.go b/internal/render/v2/internal/tests/font.go deleted file mode 100644 index 24131c8a..00000000 --- a/internal/render/v2/internal/tests/font.go +++ /dev/null @@ -1,14 +0,0 @@ -package tests - -import ( - _ "embed" - - "github.com/cufee/aftermath/internal/render/v2/style" -) - -//go:embed font.ttf -var fontData []byte - -func Font() style.Font { - return style.NewFont(fontData, 24) -} diff --git a/internal/render/v2/internal/tests/font.ttf b/internal/render/v2/internal/tests/font.ttf deleted file mode 100644 index f714a514d94e495095e2f1e525a341eade187c17..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 171656 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 * 2 - - for _, line := range strings.Split(text, "\n") { - w, h := measureCtx.MeasureString(line) - h += result.LineOffset - w += 1 - - if w > result.TotalWidth { - result.TotalWidth = w - } - if h > result.LineHeight { - result.LineHeight = h - } - - result.TotalHeight += h - } - - return result -} diff --git a/internal/render/v2/render_test.go b/internal/render/v2/render_test.go deleted file mode 100644 index a8a2c92a..00000000 --- a/internal/render/v2/render_test.go +++ /dev/null @@ -1,354 +0,0 @@ -package render - -import ( - "image" - "image/color" - "image/png" - "os" - "path/filepath" - "testing" - - "github.com/cufee/aftermath/internal/render/v2/internal/tests" - "github.com/cufee/aftermath/internal/render/v2/style" - "github.com/cufee/aftermath/tests/path" - "github.com/matryer/is" -) - -var _ = saveImage - -var contentSize = 12.0 -var contentColorAlphaValue uint32 -var contentColor = color.RGBA{255, 255, 255, 255} - -func init() { - _, _, _, a := contentColor.RGBA() - contentColorAlphaValue = a -} - -func TestRenderV2(t *testing.T) { - if os.Getenv("CI") == "true" { - return // this is a local test for visual debugging - } - - is := is.New(t) - - text1, err := NewTextContent(style.NewStyle( - style.Parent(style.Style{ - Left: -5, - Top: -5, - // Blur: 1, - ZIndex: 1, - }), - style.SetDebug(true), - style.SetPosition(style.PositionAbsolute), - style.SetFont(tests.Font(), color.Black), - // style.SetWidth(100), - // style.SetGrowX(true), - // style.SetGrowY(true), - ), "TEST - 1") - is.NoErr(err) - - text2, err := NewTextContent(style.NewStyle( - // style.SetDebug(true), - // style.SetGrowX(true), - style.SetGrowY(true), - style.SetPadding(10), - style.SetFont(tests.Font(), color.Black), - ), "TEST - 2") - is.NoErr(err) - - block1 := NewBlocksContent(style.NewStyle( - style.Parent(style.Style{ - // JustifyContent: style.JustifyContentCenter, - }), - style.SetDebug(true), - style.SetPadding(20), - // style.SetGrowX(true), - ), text2) - - block2 := NewBlocksContent(style.NewStyle( - style.Parent(style.Style{ - Gap: 10, - }), - style.SetDebug(true), - style.SetPadding(10), - // style.SetWidth(300), - ), text1, block1) - - img, err := block2.Render() - is.NoErr(err) - - saveImage(is, img) -} - -func TestApplyPadding(t *testing.T) { - is := is.New(t) - - content := NewEmptyContent(style.NewStyle(style.Parent(style.Style{Width: contentSize, Height: contentSize, BackgroundColor: contentColor}))) - - t.Run("uniform", func(t *testing.T) { - is := is.New(t) - - wrapper := NewBlocksContent(style.NewStyle( - style.SetPadding(10), - ), content) - - d := wrapper.Dimensions() - is.True(d.width == ceil(contentSize)+20) - is.True(d.height == ceil(contentSize)+20) - - img, err := wrapper.Render() - is.NoErr(err) - - { - _, _, _, a := img.At(9, 9).RGBA() - is.True(a == 0) - } - { - _, _, _, a := img.At(10, 10).RGBA() - is.True(a == contentColorAlphaValue) - } - }) - - t.Run("X", func(t *testing.T) { - wrapper := NewBlocksContent(style.NewStyle( - style.SetPaddingX(10), - ), content) - - d := wrapper.Dimensions() - is.True(d.width == ceil(contentSize)+20) - is.True(d.height == ceil(contentSize)) - }) - - t.Run("Y", func(t *testing.T) { - wrapper := NewBlocksContent(style.NewStyle( - style.SetPaddingY(10), - ), content) - - d := wrapper.Dimensions() - is.True(d.width == ceil(contentSize)) - is.True(d.height == ceil(contentSize)+20) - }) - - t.Run("overwrite", func(t *testing.T) { - wrapper := NewBlocksContent(style.NewStyle( - style.SetPadding(10), - style.SetPadding(0), - ), content) - - d := wrapper.Dimensions() - is.True(d.width == ceil(contentSize)) - is.True(d.height == ceil(contentSize)) - }) - - t.Run("left", func(t *testing.T) { - wrapper := NewBlocksContent(style.NewStyle( - style.Parent(style.Style{ - PaddingLeft: 10, - }), - ), content) - - d := wrapper.Dimensions() - is.True(d.width == ceil(contentSize)+10) - is.True(d.height == ceil(contentSize)) - - img, err := wrapper.Render() - is.NoErr(err) - - { - _, _, _, a := img.At(9, 0).RGBA() - is.True(a == 0) - } - { - _, _, _, a := img.At(10, 0).RGBA() - is.True(a == contentColorAlphaValue) - } - }) - - t.Run("top", func(t *testing.T) { - wrapper := NewBlocksContent(style.NewStyle( - style.Parent(style.Style{ - PaddingTop: 10, - }), - ), content) - - d := wrapper.Dimensions() - is.True(d.width == ceil(contentSize)) - is.True(d.height == ceil(contentSize)+10) - - img, err := wrapper.Render() - is.NoErr(err) - - { - _, _, _, a := img.At(0, 9).RGBA() - is.True(a == 0) - } - { - _, _, _, a := img.At(0, 10).RGBA() - is.True(a == contentColorAlphaValue) - } - }) -} - -func TestRenderJustify(t *testing.T) { - is := is.New(t) - - content := NewEmptyContent(style.NewStyle(style.Parent(style.Style{Width: contentSize, Height: contentSize, BackgroundColor: contentColor}))) - - t.Run("horizontal", func(t *testing.T) { - t.Run("start", func(t *testing.T) { - is := is.New(t) - - wrapper := NewBlocksContent(style.NewStyle( - style.SetWidth(contentSize*2), - ), content) - - img, err := wrapper.Render() - is.NoErr(err) - - { - _, _, _, imgA := img.At(0, 0).RGBA() - is.True(imgA == contentColorAlphaValue) - } - { - _, _, _, imgA := img.At(int(contentSize*2-1), 0).RGBA() - is.True(imgA == 0) - } - }) - - t.Run("center", func(t *testing.T) { - is := is.New(t) - - wrapper := NewBlocksContent(style.NewStyle( - style.Parent(style.Style{ - JustifyContent: style.JustifyContentCenter, - }), - style.SetWidth(contentSize*2), - ), content) - - img, err := wrapper.Render() - is.NoErr(err) - - { - _, _, _, imgA := img.At(int(contentSize/3), 0).RGBA() - is.True(imgA == 0) - } - { - _, _, _, imgA := img.At(int(contentSize), 0).RGBA() - is.True(imgA == contentColorAlphaValue) - } - { - _, _, _, imgA := img.At(int(contentSize*2-contentSize/3), 0).RGBA() - is.True(imgA == 0) - } - }) - - t.Run("end", func(t *testing.T) { - is := is.New(t) - - wrapper := NewBlocksContent(style.NewStyle( - style.Parent(style.Style{ - JustifyContent: style.JustifyContentEnd, - }), - style.SetWidth(contentSize*2), - ), content) - - img, err := wrapper.Render() - is.NoErr(err) - - { - _, _, _, imgA := img.At(int(contentSize-1), 0).RGBA() - is.True(imgA == 0) - } - { - _, _, _, imgA := img.At(int(contentSize*2-1), 0).RGBA() - is.True(imgA == contentColorAlphaValue) - } - }) - }) - - t.Run("vertical", func(t *testing.T) { - t.Run("start", func(t *testing.T) { - is := is.New(t) - - wrapper := NewBlocksContent(style.NewStyle( - style.Parent(style.Style{ - Direction: style.DirectionVertical, - }), - style.SetHeight(contentSize*2), - ), content) - - img, err := wrapper.Render() - is.NoErr(err) - - { - _, _, _, imgA := img.At(0, 0).RGBA() - is.True(imgA == contentColorAlphaValue) - } - { - _, _, _, imgA := img.At(0, int(contentSize*2-1)).RGBA() - is.True(imgA == 0) - } - }) - - t.Run("center", func(t *testing.T) { - is := is.New(t) - - wrapper := NewBlocksContent(style.NewStyle( - style.Parent(style.Style{ - JustifyContent: style.JustifyContentCenter, - Direction: style.DirectionVertical, - }), - style.SetHeight(contentSize*2), - ), content) - - img, err := wrapper.Render() - is.NoErr(err) - - { - _, _, _, imgA := img.At(0, int(contentSize/4)).RGBA() - is.True(imgA == 0) - } - { - _, _, _, imgA := img.At(0, int(contentSize)).RGBA() - is.True(imgA == contentColorAlphaValue) - } - { - _, _, _, imgA := img.At(0, int(contentSize*2-contentSize/4)).RGBA() - is.True(imgA == 0) - } - }) - - t.Run("end", func(t *testing.T) { - is := is.New(t) - - wrapper := NewBlocksContent(style.NewStyle( - style.Parent(style.Style{ - JustifyContent: style.JustifyContentEnd, - Direction: style.DirectionVertical, - }), - style.SetHeight(contentSize*2), - ), content) - - img, err := wrapper.Render() - is.NoErr(err) - - { - _, _, _, imgA := img.At(0, int(contentSize-1)).RGBA() - is.True(imgA == 0) - } - { - _, _, _, imgA := img.At(0, int(contentSize*2-1)).RGBA() - is.True(imgA == contentColorAlphaValue) - } - }) - }) -} - -func saveImage(is *is.I, img image.Image) { - f, err := os.Create(filepath.Join(path.Root(), "tmp", "test_render_blocks.png")) - is.NoErr(err) - - err = png.Encode(f, img) - is.NoErr(err) -} diff --git a/internal/render/v2/style/font.go b/internal/render/v2/style/font.go deleted file mode 100644 index 1145c758..00000000 --- a/internal/render/v2/style/font.go +++ /dev/null @@ -1,41 +0,0 @@ -package style - -import ( - "sync" - - "github.com/golang/freetype/truetype" - "golang.org/x/image/font" -) - -type Font interface { - Size() float64 - Valid() bool - Face() (font.Face, func() error) -} - -type fontType struct { - size float64 - face font.Face - mx *sync.Mutex -} - -func (f *fontType) Size() float64 { - return f.size -} - -func (f *fontType) Valid() bool { - return f.face != nil -} - -func (f *fontType) Face() (font.Face, func() error) { - f.mx.Lock() - return f.face, func() error { f.mx.Unlock(); return nil } -} - -func NewFont(data []byte, size float64) Font { - ttf, _ := truetype.Parse(data) - face := truetype.NewFace(ttf, &truetype.Options{ - Size: size, - }) - return &fontType{size, face, &sync.Mutex{}} -} diff --git a/internal/render/v2/style/options.go b/internal/render/v2/style/options.go deleted file mode 100644 index 782ae94d..00000000 --- a/internal/render/v2/style/options.go +++ /dev/null @@ -1,123 +0,0 @@ -package style - -import "image/color" - -func NewStyle(opts ...styleOption) StyleOptions { - return opts -} - -type styleOption func(s *Style) - -type StyleOptions []styleOption - -func (o StyleOptions) Computed() Style { - var s Style - for _, apply := range o { - apply(&s) - } - return s -} - -func (arr *StyleOptions) Add(opt styleOption) { - *arr = append(*arr, opt) -} - -func Parent(parent Style) styleOption { - return func(s *Style) { *s = parent } -} - -func SetFont(value Font, color color.Color) styleOption { - return func(s *Style) { s.Font = value; s.Color = color } -} - -func SetDebug(value bool) styleOption { - return func(s *Style) { s.Debug = value } -} - -func SetPosition(value positionValue) styleOption { - return func(s *Style) { s.Position = value } -} - -func SetWidth(value float64) styleOption { - return func(s *Style) { s.Width = value } -} -func SetHeight(value float64) styleOption { - return func(s *Style) { s.Height = value } -} - -func SetPadding(value float64) styleOption { - return func(s *Style) { - s.PaddingLeft = value - s.PaddingRight = value - s.PaddingTop = value - s.PaddingBottom = value - } -} -func SetPaddingX(value float64) styleOption { - return func(s *Style) { - s.PaddingLeft = value - s.PaddingRight = value - } -} -func SetPaddingY(value float64) styleOption { - return func(s *Style) { - s.PaddingTop = value - s.PaddingBottom = value - } -} - -func SetGrow(value bool) styleOption { - return func(s *Style) { - s.GrowHorizontal = value - s.GrowVertical = value - } -} -func SetGrowX(value bool) styleOption { - return func(s *Style) { - s.GrowHorizontal = value - } -} -func SetGrowY(value bool) styleOption { - return func(s *Style) { - s.GrowVertical = value - } -} - -func SetBlur(value float64) styleOption { - return func(s *Style) { - s.Blur = value - } -} - -func SetBorderRadius(value float64) styleOption { - return func(s *Style) { - s.BorderRadiusTopLeft = value - s.BorderRadiusTopRight = value - s.BorderRadiusBottomLeft = value - s.BorderRadiusBottomRight = value - } -} -func SetBorderRadiusLeft(value float64) styleOption { - return func(s *Style) { - s.BorderRadiusTopLeft = value - s.BorderRadiusBottomLeft = value - } -} -func SetBorderRadiusRight(value float64) styleOption { - return func(s *Style) { - s.BorderRadiusTopRight = value - s.BorderRadiusBottomRight = value - } -} -func SetBorderRadiusTop(value float64) styleOption { - return func(s *Style) { - s.BorderRadiusTopLeft = value - s.BorderRadiusTopRight = value - } -} -func SetBorderRadiusBottom(value float64) styleOption { - return func(s *Style) { - s.BorderRadiusBottomLeft = value - s.BorderRadiusBottomRight = value - } -} diff --git a/internal/render/v2/style/style.go b/internal/render/v2/style/style.go deleted file mode 100644 index 460a97da..00000000 --- a/internal/render/v2/style/style.go +++ /dev/null @@ -1,86 +0,0 @@ -package style - -import ( - "image" - "image/color" -) - -type alignItemsValue byte -type justifyContentValue byte - -const ( - AlignItemsStart alignItemsValue = iota - AlignItemsCenter - AlignItemsEnd - - JustifyContentStart justifyContentValue = iota - JustifyContentCenter - JustifyContentEnd - JustifyContentSpaceBetween // Spacing between each element is the same - JustifyContentSpaceAround // Spacing around all element is the same -) - -type directionValue byte - -const ( - DirectionHorizontal directionValue = iota - DirectionVertical -) - -type positionValue byte - -const ( - PositionRelative positionValue = iota - PositionAbsolute -) - -type overflowValue byte - -const ( - OverflowVisible overflowValue = iota - OverflowHidden -) - -type Style struct { - Debug bool - - Width float64 - Height float64 - - Blur float64 - - Font Font - - Color color.Color - BackgroundColor color.Color - BackgroundImage image.Image - - Overflow overflowValue - - JustifyContent justifyContentValue - AlignItems alignItemsValue // Depends on Direction - Direction directionValue - Position positionValue - - Gap float64 - - PaddingLeft float64 - PaddingRight float64 - PaddingTop float64 - PaddingBottom float64 - - Left float64 - Right float64 - Top float64 - Bottom float64 - - GrowHorizontal bool - GrowVertical bool - - BorderRadiusTopLeft float64 - BorderRadiusTopRight float64 - BorderRadiusBottomLeft float64 - BorderRadiusBottomRight float64 - - ZIndex int -} From 595977010bf241c8d7ee0284f47ed60da9886c78 Mon Sep 17 00:00:00 2001 From: Vovko Date: Sat, 18 Jan 2025 18:23:13 -0500 Subject: [PATCH 24/39] added v2 rendering package --- cmd/discord/commands/public/career.go | 2 +- cmd/discord/commands/public/interactions.go | 2 +- cmd/discord/commands/public/my.go | 2 +- cmd/discord/commands/public/replay.go | 2 +- cmd/discord/commands/public/session.go | 2 +- cmd/frontend/routes/api/widget/account.templ | 2 +- cmd/frontend/routes/widget/live.templ | 4 +- go.mod | 15 +- go.sum | 15 +- internal/render/common/background.go | 2 +- internal/render/common/border-radius.go | 9 + internal/render/common/colors.go | 21 +- internal/render/common/font.go | 29 ++ internal/render/common/init.go | 37 +-- internal/render/common/logo.go | 2 +- internal/render/{v1 => common}/options.go | 2 +- internal/render/common/rating.go | 248 ++++++++++++++++++ internal/render/common/wn8.go | 78 ++++++ internal/render/v1/segments.go | 19 +- .../stats/client/{v1 => common}/errors.go | 2 +- internal/stats/client/common/image.go | 9 + .../stats/client/{v1 => common}/metadata.go | 2 +- .../stats/client/{v1 => common}/options.go | 24 +- internal/stats/client/v1/client.go | 15 +- internal/stats/client/v1/image.go | 15 -- internal/stats/client/v1/period.go | 25 +- internal/stats/client/v1/replay.go | 20 +- internal/stats/client/v1/session.go | 36 ++- internal/stats/client/v2/client.go | 43 +++ internal/stats/client/v2/image.go | 20 ++ internal/stats/client/v2/period.go | 92 +++++++ internal/stats/client/v2/replay.go | 16 ++ internal/stats/client/v2/session.go | 19 ++ internal/stats/client/v2/utils.go | 27 ++ internal/stats/render/period/v1/cards.go | 71 ++--- internal/stats/render/period/v1/image.go | 5 +- internal/stats/render/period/v2/background.go | 38 +++ internal/stats/render/period/v2/cards.go | 132 ++++++++++ internal/stats/render/period/v2/constants.go | 90 +++++++ internal/stats/render/period/v2/image.go | 57 ++++ .../stats/render/period/v2/overview-style.go | 158 +++++++++++ internal/stats/render/period/v2/overview.go | 105 ++++++++ internal/stats/render/replay/v1/image.go | 5 +- internal/stats/render/session/v1/cards.go | 137 +++++----- internal/stats/render/session/v1/image.go | 5 +- main.go | 5 + render_test.go | 21 +- render_v2_test.go | 74 ++++++ 48 files changed, 1506 insertions(+), 255 deletions(-) create mode 100644 internal/render/common/border-radius.go create mode 100644 internal/render/common/font.go rename internal/render/{v1 => common}/options.go (98%) create mode 100644 internal/render/common/rating.go create mode 100644 internal/render/common/wn8.go rename internal/stats/client/{v1 => common}/errors.go (87%) create mode 100644 internal/stats/client/common/image.go rename internal/stats/client/{v1 => common}/metadata.go (97%) rename internal/stats/client/{v1 => common}/options.go (87%) create mode 100644 internal/stats/client/v2/client.go create mode 100644 internal/stats/client/v2/image.go create mode 100644 internal/stats/client/v2/period.go create mode 100644 internal/stats/client/v2/replay.go create mode 100644 internal/stats/client/v2/session.go create mode 100644 internal/stats/client/v2/utils.go create mode 100644 internal/stats/render/period/v2/background.go create mode 100644 internal/stats/render/period/v2/cards.go create mode 100644 internal/stats/render/period/v2/constants.go create mode 100644 internal/stats/render/period/v2/image.go create mode 100644 internal/stats/render/period/v2/overview-style.go create mode 100644 internal/stats/render/period/v2/overview.go create mode 100644 render_v2_test.go diff --git a/cmd/discord/commands/public/career.go b/cmd/discord/commands/public/career.go index 2aa910cb..a279a8af 100644 --- a/cmd/discord/commands/public/career.go +++ b/cmd/discord/commands/public/career.go @@ -17,7 +17,7 @@ import ( "github.com/cufee/aftermath/internal/log" "github.com/cufee/aftermath/internal/logic" "github.com/cufee/aftermath/internal/permissions" - stats "github.com/cufee/aftermath/internal/stats/client/v1" + stats "github.com/cufee/aftermath/internal/stats/client/common" "github.com/cufee/aftermath/internal/utils" ) diff --git a/cmd/discord/commands/public/interactions.go b/cmd/discord/commands/public/interactions.go index bbd88cf4..b1cb5a66 100644 --- a/cmd/discord/commands/public/interactions.go +++ b/cmd/discord/commands/public/interactions.go @@ -28,7 +28,7 @@ import ( "golang.org/x/sync/errgroup" "golang.org/x/text/language" - stats "github.com/cufee/aftermath/internal/stats/client/v1" + stats "github.com/cufee/aftermath/internal/stats/client/common" "github.com/cufee/aftermath/internal/log" "github.com/pkg/errors" diff --git a/cmd/discord/commands/public/my.go b/cmd/discord/commands/public/my.go index 9ddf1c63..b0742f0e 100644 --- a/cmd/discord/commands/public/my.go +++ b/cmd/discord/commands/public/my.go @@ -15,7 +15,7 @@ import ( "github.com/cufee/aftermath/internal/log" "github.com/cufee/aftermath/internal/logic" "github.com/cufee/aftermath/internal/permissions" - stats "github.com/cufee/aftermath/internal/stats/client/v1" + stats "github.com/cufee/aftermath/internal/stats/client/common" "github.com/cufee/aftermath/internal/stats/fetch/v1" "github.com/cufee/aftermath/internal/utils" "github.com/pkg/errors" diff --git a/cmd/discord/commands/public/replay.go b/cmd/discord/commands/public/replay.go index c73ee393..e55add25 100644 --- a/cmd/discord/commands/public/replay.go +++ b/cmd/discord/commands/public/replay.go @@ -12,7 +12,7 @@ import ( "github.com/cufee/aftermath/cmd/discord/common" "github.com/cufee/aftermath/cmd/discord/middleware" "github.com/cufee/aftermath/internal/permissions" - stats "github.com/cufee/aftermath/internal/stats/client/v1" + stats "github.com/cufee/aftermath/internal/stats/client/common" "github.com/cufee/aftermath/internal/stats/fetch/v1/replay" "github.com/pkg/errors" ) diff --git a/cmd/discord/commands/public/session.go b/cmd/discord/commands/public/session.go index 40a8f2c5..8462e3d2 100644 --- a/cmd/discord/commands/public/session.go +++ b/cmd/discord/commands/public/session.go @@ -14,7 +14,7 @@ import ( "github.com/cufee/aftermath/internal/log" "github.com/cufee/aftermath/internal/logic" "github.com/cufee/aftermath/internal/permissions" - stats "github.com/cufee/aftermath/internal/stats/client/v1" + stats "github.com/cufee/aftermath/internal/stats/client/common" "github.com/cufee/aftermath/internal/stats/fetch/v1" "github.com/cufee/aftermath/internal/utils" "github.com/pkg/errors" diff --git a/cmd/frontend/routes/api/widget/account.templ b/cmd/frontend/routes/api/widget/account.templ index a41df837..7fbd1ee5 100644 --- a/cmd/frontend/routes/api/widget/account.templ +++ b/cmd/frontend/routes/api/widget/account.templ @@ -5,7 +5,7 @@ import ( "github.com/cufee/aftermath/cmd/frontend/components/widget" "github.com/cufee/aftermath/cmd/frontend/handler" "github.com/cufee/aftermath/internal/database/models" - "github.com/cufee/aftermath/internal/stats/client/v1" + client "github.com/cufee/aftermath/internal/stats/client/common" "golang.org/x/text/language" "net/http" "slices" diff --git a/cmd/frontend/routes/widget/live.templ b/cmd/frontend/routes/widget/live.templ index 3586c184..2db447fd 100644 --- a/cmd/frontend/routes/widget/live.templ +++ b/cmd/frontend/routes/widget/live.templ @@ -14,15 +14,15 @@ import ( "github.com/cufee/aftermath/internal/log" backend "github.com/cufee/aftermath/internal/logic" "github.com/cufee/aftermath/internal/realtime" - "github.com/cufee/aftermath/internal/stats/client/v1" + client "github.com/cufee/aftermath/internal/stats/client/common" "github.com/cufee/aftermath/internal/stats/fetch/v1" "github.com/cufee/aftermath/internal/stats/prepare/session/v1" "github.com/pkg/errors" "golang.org/x/text/language" "strconv" + "time" "net/http" - "time" ) type widgetEndpointResponse struct { diff --git a/go.mod b/go.mod index 9d7346b9..a845350a 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,11 @@ module github.com/cufee/aftermath -go 1.23 - -toolchain go1.23.4 +go 1.23.5 require github.com/go-jet/jet/v2 v2.12.0 +replace github.com/cufee/facepaint => ../facepaint + require ( github.com/Kodeworks/golang-image-ico v0.0.0-20141118225523-73f0f4cfade9 github.com/PuerkitoBio/goquery v1.10.1 @@ -13,6 +13,7 @@ require ( github.com/bwmarrin/discordgo v0.28.1 github.com/cufee/aftermath-assets v0.1.0 github.com/cufee/am-wg-proxy-next/v2 v2.2.6 + github.com/cufee/facepaint v0.0.2 github.com/fogleman/gg v1.3.0 github.com/go-co-op/gocron v1.37.0 github.com/goccy/go-json v0.10.4 @@ -33,7 +34,7 @@ require ( github.com/rs/zerolog v1.33.0 github.com/servusdei2018/shards/v2 v2.5.0 github.com/stretchr/testify v1.10.0 - github.com/tdewolff/minify/v2 v2.21.2 + github.com/tdewolff/minify/v2 v2.21.3 go.dedis.ch/protobuf v1.0.11 golang.org/x/image v0.23.0 golang.org/x/sync v0.10.0 @@ -52,14 +53,14 @@ require ( github.com/jackc/pgtype v1.14.4 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.17.11 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_model v0.6.1 // indirect - github.com/prometheus/common v0.61.0 // indirect + github.com/prometheus/common v0.62.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect github.com/robfig/cron/v3 v3.0.1 // indirect github.com/rs/xid v1.6.0 // indirect @@ -68,5 +69,5 @@ require ( golang.org/x/crypto v0.32.0 // indirect golang.org/x/net v0.34.0 // indirect golang.org/x/sys v0.29.0 // indirect - google.golang.org/protobuf v1.36.1 // indirect + google.golang.org/protobuf v1.36.3 // indirect ) diff --git a/go.sum b/go.sum index f0546d75..3adbe3f9 100644 --- a/go.sum +++ b/go.sum @@ -147,8 +147,9 @@ github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= @@ -179,8 +180,8 @@ github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+ github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= -github.com/prometheus/common v0.61.0 h1:3gv/GThfX0cV2lpO7gkTUwZru38mxevy90Bj8YFSRQQ= -github.com/prometheus/common v0.61.0/go.mod h1:zr29OCN/2BsJRaFwG8QOBr41D6kkchKbpeNH7pAjb/s= +github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= +github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= @@ -221,8 +222,8 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/tdewolff/minify/v2 v2.21.2 h1:VfTvmGVtBYhMTlUAeHtXM7XOsW0JT/6uMwUPPqgUs9k= -github.com/tdewolff/minify/v2 v2.21.2/go.mod h1:Olje3eHdBnrMjINKffDsil/3NV98Iv7MhWf7556WQVg= +github.com/tdewolff/minify/v2 v2.21.3 h1:KmhKNGrN/dGcvb2WDdB5yA49bo37s+hcD8RiF+lioV8= +github.com/tdewolff/minify/v2 v2.21.3/go.mod h1:iGxHaGiONAnsYuo8CRyf8iPUcqRJVB/RhtEcTpqS7xw= github.com/tdewolff/parse/v2 v2.7.19 h1:7Ljh26yj+gdLFEq/7q9LT4SYyKtwQX4ocNrj45UCePg= github.com/tdewolff/parse/v2 v2.7.19/go.mod h1:3FbJWZp3XT9OWVN3Hmfp0p/a08v4h8J9W1aghka0soA= github.com/tdewolff/test v1.0.11-0.20231101010635-f1265d231d52/go.mod h1:6DAvZliBAAnD7rhVgwaM7DE5/d9NMOAJ09SqYqeK4QE= @@ -371,8 +372,8 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk= -google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +google.golang.org/protobuf v1.36.3 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU= +google.golang.org/protobuf v1.36.3/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= diff --git a/internal/render/common/background.go b/internal/render/common/background.go index 156a5b0e..630cea1f 100644 --- a/internal/render/common/background.go +++ b/internal/render/common/background.go @@ -1,4 +1,4 @@ -package render +package common import ( "image" diff --git a/internal/render/common/border-radius.go b/internal/render/common/border-radius.go new file mode 100644 index 00000000..2a47ed54 --- /dev/null +++ b/internal/render/common/border-radius.go @@ -0,0 +1,9 @@ +package common + +var ( + BorderRadiusXL = 30.0 + BorderRadiusLG = 25.0 + BorderRadiusMD = 20.0 + BorderRadiusSM = 15.0 + BorderRadiusXS = 10.0 +) diff --git a/internal/render/common/colors.go b/internal/render/common/colors.go index 56a4a49b..db8b228b 100644 --- a/internal/render/common/colors.go +++ b/internal/render/common/colors.go @@ -1,5 +1,24 @@ -package render +package common import "image/color" +var DiscordBackgroundColor = color.NRGBA{49, 51, 56, 255} + var DefaultLogoColorOptions = []color.Color{color.NRGBA{50, 50, 50, 180}, color.NRGBA{200, 200, 200, 180}} + +var ( + TextPrimary = color.NRGBA{255, 255, 255, 255} + TextSecondary = color.NRGBA{204, 204, 204, 255} + TextAlt = color.NRGBA{150, 150, 150, 255} + + TextSubscriptionPlus = color.NRGBA{72, 167, 250, 255} + TextSubscriptionPremium = color.NRGBA{255, 223, 0, 255} + + DefaultCardColor = color.NRGBA{10, 10, 10, 150} + DefaultCardColorNoAlpha = color.NRGBA{10, 10, 10, 255} + ClanTagBackgroundColor = color.NRGBA{10, 10, 10, 100} + + ColorAftermathRed = color.NRGBA{255, 0, 120, 255} + ColorAftermathBlue = color.NRGBA{72, 167, 250, 255} + ColorAftermathYellow = color.NRGBA{255, 223, 0, 255} +) diff --git a/internal/render/common/font.go b/internal/render/common/font.go new file mode 100644 index 00000000..84d5fc7d --- /dev/null +++ b/internal/render/common/font.go @@ -0,0 +1,29 @@ +package common + +import "github.com/cufee/facepaint/style" + +var defaultFont []byte +var fonts = make(map[float64]style.Font) + +func Font2XL() style.Font { + return getFont(36) +} +func FontXL() style.Font { + return getFont(32) +} +func FontLarge() style.Font { + return getFont(24) +} +func FontMedium() style.Font { + return getFont(18) +} +func FontSmall() style.Font { + return getFont(14) +} + +func getFont(size float64) style.Font { + if fonts[size] == nil { + fonts[size] = style.NewFont(defaultFont, size) + } + return fonts[size] +} diff --git a/internal/render/common/init.go b/internal/render/common/init.go index f83fc4c6..20301299 100644 --- a/internal/render/common/init.go +++ b/internal/render/common/init.go @@ -1,30 +1,17 @@ -package render +package common import ( - "image/color" -) - -var DiscordBackgroundColor = color.NRGBA{49, 51, 56, 255} - -var ( - TextPrimary = color.NRGBA{255, 255, 255, 255} - TextSecondary = color.NRGBA{204, 204, 204, 255} - TextAlt = color.NRGBA{150, 150, 150, 255} + "errors" - TextSubscriptionPlus = color.NRGBA{72, 167, 250, 255} - TextSubscriptionPremium = color.NRGBA{255, 223, 0, 255} - - DefaultCardColor = color.NRGBA{10, 10, 10, 180} - DefaultCardColorNoAlpha = color.NRGBA{10, 10, 10, 255} - ClanTagBackgroundColor = color.NRGBA{10, 10, 10, 120} + "github.com/cufee/aftermath/internal/render/assets" +) - ColorAftermathRed = color.NRGBA{255, 0, 120, 255} - ColorAftermathBlue = color.NRGBA{72, 167, 250, 255} - ColorAftermathYellow = color.NRGBA{255, 223, 0, 255} +func InitLoadedAssets() error { + fontData, ok := assets.GetLoadedFontFace("default") + if !ok { + return errors.New("default font not found") + } + defaultFont = fontData - BorderRadiusXL = 30.0 - BorderRadiusLG = 25.0 - BorderRadiusMD = 20.0 - BorderRadiusSM = 15.0 - BorderRadiusXS = 10.0 -) + return nil +} diff --git a/internal/render/common/logo.go b/internal/render/common/logo.go index c95d8cc0..861fbc72 100644 --- a/internal/render/common/logo.go +++ b/internal/render/common/logo.go @@ -1,4 +1,4 @@ -package render +package common import ( "image" diff --git a/internal/render/v1/options.go b/internal/render/common/options.go similarity index 98% rename from internal/render/v1/options.go rename to internal/render/common/options.go index 35d1fdec..c09ad864 100644 --- a/internal/render/v1/options.go +++ b/internal/render/common/options.go @@ -1,4 +1,4 @@ -package render +package common import ( "image" diff --git a/internal/render/common/rating.go b/internal/render/common/rating.go new file mode 100644 index 00000000..2ebaef76 --- /dev/null +++ b/internal/render/common/rating.go @@ -0,0 +1,248 @@ +package common + +import ( + "image/color" + "math" + "strings" + + "github.com/cufee/aftermath/internal/render/assets" + "github.com/cufee/aftermath/internal/stats/frame" + "github.com/cufee/facepaint" + "github.com/cufee/facepaint/style" + "github.com/fogleman/gg" +) + +var iconsCache = make(map[string]*facepaint.Block, 6) + +func GetRatingIconName(rating float32) string { + switch { + case rating > 5000: + return "diamond" + case rating > 4000: + return "platinum" + case rating > 3000: + return "gold" + case rating > 2000: + return "silver" + case rating > 0: + return "bronze" + default: + return "calibration" + } +} + +func GetRatingTierName(rating float32) string { + switch { + case rating > 5000: + return "Diamond" + case rating > 4000: + return "Platinum" + case rating > 3000: + return "Gold" + case rating > 2000: + return "Silver" + case rating > 0: + return "Bronze" + default: + return "" + } +} + +func GetRatingColors(rating float32) ratingColors { + switch { + case rating > 5000: + return ratingColors{color.NRGBA{181, 106, 181, 255}, color.Black} + case rating > 4000: + return ratingColors{color.NRGBA{154, 197, 219, 255}, color.Black} + case rating > 3000: + return ratingColors{color.NRGBA{255, 215, 0, 255}, color.Black} + case rating > 2000: + return ratingColors{color.NRGBA{234, 237, 240, 255}, color.Black} + case rating > 0: + return ratingColors{color.NRGBA{192, 105, 105, 255}, color.White} + default: + return ratingColors{color.Transparent, color.Transparent} + } +} + +func GetRatingIcon(rating frame.Value, size float64) (*facepaint.Block, bool) { + style := style.Style{Width: size, Height: 0} + if rating.Float() < 0 { + style.BackgroundColor = TextAlt + } + name := "rating-" + GetRatingIconName(rating.Float()) + + if b, ok := iconsCache[name]; ok { + return b, true + } + + img, ok := assets.GetLoadedImage(name) + if !ok { + return nil, false + } + + block, err := facepaint.NewImageContent(style.Options(), img) + if err != nil { + return nil, false + } + + iconsCache[name] = block + return iconsCache[name], true +} + +type ratingIcon struct { + Name string + Color color.Color + Fill string +} + +var RatingIconSettings = map[string]ratingIcon{ + "bronze": {Name: "bronze", Color: GetRatingColors(1).Background, Fill: ` +_______ +_______ +___x___ +___x___ +__xxx__ +__xxx__ +__xxx__ +__xxx__ +___x___ +___x___ +_______ +_______ +`}, + "silver": {Name: "silver", Color: GetRatingColors(2001).Background, Fill: ` +_______ +_______ +___x___ +___x___ +_x_x_x_ +_x___x_ +_xx_xx_ +__xxx__ +__xxx__ +___x___ +___x___ +_______ +`}, + "gold": {Name: "gold", Color: GetRatingColors(3001).Background, Fill: ` +_______ +___x___ +__xxx__ +_xxxxx_ +_x_x_x_ +_x___x_ +_xx_xx_ +__xxx__ +__xxx__ +___x___ +___x___ +_______ +`}, + "platinum": {Name: "platinum", Color: GetRatingColors(4001).Background, Fill: ` +_______ +___x___ +x_xxx_x +xxxxxxx +xx_x_xx +_x___x_ +_xx_xx_ +__xxx__ +__xxx__ +___x___ +___x___ +_______ +`}, + "diamond": {Name: "diamond", Color: GetRatingColors(5001).Background, Fill: ` +___x___ +x__x__x +x_xxx_x +xxxxxxx +xx_x_xx +_x___x_ +_xx_xx_ +__xxx__ +__xxx__ +___x___ +___x___ +_______ +`}, +} + +func init() { + RatingIconSettings["calibration"] = ratingIcon{ + Color: TextAlt, + Name: "calibration", + Fill: RatingIconSettings["bronze"].Fill, + } +} + +func NewRatingIcon(rating frame.Value) (*facepaint.Block, bool) { + settings, ok := RatingIconSettings[GetRatingTierName(rating.Float())] + if !ok { + return facepaint.NewEmptyContent(style.NewStyle(style.SetWidth(1), style.SetHeight(1))), false + } + return RenderRatingIcon(settings) +} + +func RenderRatingIcon(settings ratingIcon) (*facepaint.Block, bool) { + var ratingIconLineWidth = 8 + + rows := strings.Split(strings.TrimSpace(settings.Fill), "\n") + + iconHeight := len(rows) * (ratingIconLineWidth) + iconWidth := (len(rows[0]) * ratingIconLineWidth) + ((len(rows[0]) - 1) * (ratingIconLineWidth / 2)) + + ctx := gg.NewContext(iconWidth, iconHeight) + ctx.SetColor(settings.Color) + + for rowI, row := range rows { + positionY := float64(rowI * ratingIconLineWidth) + for itemI, item := range strings.Split(row, "") { + if strings.ToLower(item) != "x" { + continue + } + + positionX := itemI*ratingIconLineWidth + max(0, (itemI)*(ratingIconLineWidth/2)) + + var topRounded bool = true + var bottomRounded bool = true + if rowI-1 >= 0 { + topRounded = strings.ToLower(strings.Split(rows[rowI-1], "")[itemI]) != "x" + } + if rowI+1 < len(rows) { + bottomRounded = strings.ToLower(strings.Split(rows[rowI+1], "")[itemI]) != "x" + } + + if !topRounded && !bottomRounded { + ctx.DrawRectangle(float64(positionX), positionY, float64(ratingIconLineWidth), float64(ratingIconLineWidth)) + ctx.Fill() + } + + // draw top part + if topRounded { + ctx.DrawArc(float64(positionX)+float64(ratingIconLineWidth/2), positionY+float64(ratingIconLineWidth)/2, float64(ratingIconLineWidth)/2, -math.Pi, 0) + ctx.Fill() + } else { + ctx.DrawRectangle(float64(positionX), positionY, float64(ratingIconLineWidth), float64(ratingIconLineWidth)/2) + ctx.Fill() + } + + // draw bottom part + if bottomRounded { + + ctx.DrawArc(float64(positionX)+float64(ratingIconLineWidth/2), positionY+float64(ratingIconLineWidth/2), float64(ratingIconLineWidth)/2, math.Pi, 0) + ctx.Fill() + } else { + ctx.DrawRectangle(float64(positionX), positionY+float64(ratingIconLineWidth/2), float64(ratingIconLineWidth), float64(ratingIconLineWidth)/2) + ctx.Fill() + } + } + } + + block, err := facepaint.NewImageContent(style.NewStyle(), ctx.Image()) + if err != nil { + return nil, false + } + return block, true +} diff --git a/internal/render/common/wn8.go b/internal/render/common/wn8.go new file mode 100644 index 00000000..0cf13e88 --- /dev/null +++ b/internal/render/common/wn8.go @@ -0,0 +1,78 @@ +package common + +import ( + "image/color" +) + +type ratingColors struct { + Background color.Color + Content color.Color +} + +func GetWN8Colors(r float32) ratingColors { + if r > 0 && r < 301 { + return ratingColors{color.NRGBA{255, 0, 0, 255}, color.White} + } + if r > 300 && r < 451 { + return ratingColors{color.NRGBA{251, 83, 83, 255}, color.White} + } + if r > 450 && r < 651 { + return ratingColors{color.NRGBA{255, 160, 49, 255}, color.White} + } + if r > 650 && r < 901 { + return ratingColors{color.NRGBA{255, 244, 65, 255}, color.Black} + } + if r > 900 && r < 1201 { + return ratingColors{color.NRGBA{149, 245, 62, 255}, color.Black} + } + if r > 1200 && r < 1601 { + return ratingColors{color.NRGBA{103, 190, 51, 255}, color.Black} + } + if r > 1600 && r < 2001 { + return ratingColors{color.NRGBA{106, 236, 255, 255}, color.Black} + } + if r > 2000 && r < 2451 { + return ratingColors{color.NRGBA{46, 174, 193, 255}, color.White} + } + if r > 2450 && r < 2901 { + return ratingColors{color.NRGBA{208, 108, 255, 255}, color.White} + } + if r > 2900 { + return ratingColors{color.NRGBA{142, 65, 177, 255}, color.Black} + } + return ratingColors{color.Transparent, color.Transparent} +} + +func GetWN8TierName(r float32) string { + if r > 0 && r < 301 { + return "Very Bad" + } + if r > 300 && r < 451 { + return "Bad" + } + if r > 450 && r < 651 { + return "Below Average" + } + if r > 650 && r < 901 { + return "Average" + } + if r > 900 && r < 1201 { + return "Above Average" + } + if r > 1200 && r < 1601 { + return "Good" + } + if r > 1600 && r < 2001 { + return "Very Good" + } + if r > 2000 && r < 2451 { + return "Great" + } + if r > 2450 && r < 2901 { + return "Unicum" + } + if r > 2900 { + return "Super Unicum" + } + return "" +} diff --git a/internal/render/v1/segments.go b/internal/render/v1/segments.go index c5e40b86..554dc1ab 100644 --- a/internal/render/v1/segments.go +++ b/internal/render/v1/segments.go @@ -5,6 +5,7 @@ import ( "image/color" "sync" + "github.com/cufee/aftermath/internal/render/common" "github.com/cufee/aftermath/internal/retry" "github.com/pkg/errors" ) @@ -33,9 +34,9 @@ func (s *Segments) AddFooter(blocks ...Block) { s.footer = append(s.footer, blocks...) } -func (s *Segments) ContentBounds(opts ...Option) (image.Rectangle, error) { +func (s *Segments) ContentBounds(opts ...common.Option) (image.Rectangle, error) { if s.rendered.content == nil { - options := DefaultOptions() + options := common.DefaultOptions() for _, apply := range opts { apply(&options) } @@ -50,9 +51,9 @@ func (s *Segments) ContentBounds(opts ...Option) (image.Rectangle, error) { return s.rendered.content.Bounds(), nil } -func (s *Segments) ContentMask(opts ...Option) (*image.Alpha, error) { +func (s *Segments) ContentMask(opts ...common.Option) (*image.Alpha, error) { if s.rendered.content == nil { - options := DefaultOptions() + options := common.DefaultOptions() for _, apply := range opts { apply(&options) } @@ -95,7 +96,7 @@ func (s *Segments) ContentMask(opts ...Option) (*image.Alpha, error) { return mask, nil } -func (s *Segments) renderHeader(_ Options) (image.Image, error) { +func (s *Segments) renderHeader(_ common.Options) (image.Image, error) { header := NewBlocksContent( Style{ Direction: DirectionVertical, @@ -105,7 +106,7 @@ func (s *Segments) renderHeader(_ Options) (image.Image, error) { return header.Render() } -func (s *Segments) renderFooter(_ Options) (image.Image, error) { +func (s *Segments) renderFooter(_ common.Options) (image.Image, error) { footer := NewBlocksContent( Style{ Direction: DirectionVertical, @@ -115,7 +116,7 @@ func (s *Segments) renderFooter(_ Options) (image.Image, error) { return footer.Render() } -func (s *Segments) renderContent(_ Options) (image.Image, error) { +func (s *Segments) renderContent(_ common.Options) (image.Image, error) { mainSegment := NewBlocksContent( Style{ Direction: DirectionVertical, @@ -127,12 +128,12 @@ func (s *Segments) renderContent(_ Options) (image.Image, error) { return mainSegment.Render() } -func (s *Segments) Render(opts ...Option) (image.Image, error) { +func (s *Segments) Render(opts ...common.Option) (image.Image, error) { if len(s.content) < 1 { return nil, errors.New("segments.content cannot be empty") } - options := DefaultOptions() + options := common.DefaultOptions() for _, apply := range opts { apply(&options) } diff --git a/internal/stats/client/v1/errors.go b/internal/stats/client/common/errors.go similarity index 87% rename from internal/stats/client/v1/errors.go rename to internal/stats/client/common/errors.go index d61d7179..6d14ff68 100644 --- a/internal/stats/client/v1/errors.go +++ b/internal/stats/client/common/errors.go @@ -1,4 +1,4 @@ -package client +package common import ( "github.com/pkg/errors" diff --git a/internal/stats/client/common/image.go b/internal/stats/client/common/image.go new file mode 100644 index 00000000..6dcb4369 --- /dev/null +++ b/internal/stats/client/common/image.go @@ -0,0 +1,9 @@ +package common + +import ( + "io" +) + +type Image interface { + PNG(io.Writer) error +} diff --git a/internal/stats/client/v1/metadata.go b/internal/stats/client/common/metadata.go similarity index 97% rename from internal/stats/client/v1/metadata.go rename to internal/stats/client/common/metadata.go index 0c5488b2..fb3f1be5 100644 --- a/internal/stats/client/v1/metadata.go +++ b/internal/stats/client/common/metadata.go @@ -1,4 +1,4 @@ -package client +package common import ( "time" diff --git a/internal/stats/client/v1/options.go b/internal/stats/client/common/options.go similarity index 87% rename from internal/stats/client/v1/options.go rename to internal/stats/client/common/options.go index b132d9fe..17d0add8 100644 --- a/internal/stats/client/v1/options.go +++ b/internal/stats/client/common/options.go @@ -1,10 +1,10 @@ -package client +package common import ( "image" "github.com/cufee/aftermath/internal/database/models" - common "github.com/cufee/aftermath/internal/render/v1" + common "github.com/cufee/aftermath/internal/render/common" "github.com/cufee/aftermath/internal/stats/fetch/v1" prepare "github.com/cufee/aftermath/internal/stats/prepare/common/v1" "golang.org/x/text/language" @@ -19,17 +19,33 @@ type requestOptions struct { promoText []string vehicleID string withWN8 bool - subscriptions []models.UserSubscription + Subscriptions []models.UserSubscription vehicleTags []prepare.Tag ratingColumns []prepare.TagColumn[string] unratedColumns []prepare.TagColumn[string] } +func (o requestOptions) ReferenceID() string { + return o.referenceID +} +func (o requestOptions) VehicleID() string { + return o.vehicleID +} + type RequestOption func(o *requestOptions) +type RequestOptions []RequestOption + +func (o RequestOptions) Options() requestOptions { + var opts = requestOptions{} + for _, apply := range o { + apply(&opts) + } + return opts +} func WithSubscriptions(subs []models.UserSubscription) RequestOption { - return func(o *requestOptions) { o.subscriptions = subs } + return func(o *requestOptions) { o.Subscriptions = subs } } func WithWN8() RequestOption { return func(o *requestOptions) { o.withWN8 = true } diff --git a/internal/stats/client/v1/client.go b/internal/stats/client/v1/client.go index 7468dc37..7f623504 100644 --- a/internal/stats/client/v1/client.go +++ b/internal/stats/client/v1/client.go @@ -6,6 +6,7 @@ import ( "github.com/cufee/aftermath/internal/database" "github.com/cufee/aftermath/internal/external/wargaming" + "github.com/cufee/aftermath/internal/stats/client/common" "github.com/cufee/aftermath/internal/stats/fetch/v1" period "github.com/cufee/aftermath/internal/stats/prepare/period/v1" "github.com/cufee/aftermath/internal/stats/prepare/replay/v1" @@ -23,15 +24,15 @@ type client struct { } type Client interface { - PeriodCards(ctx context.Context, accountId string, from time.Time, opts ...RequestOption) (period.Cards, Metadata, error) - PeriodImage(ctx context.Context, accountId string, from time.Time, opts ...RequestOption) (Image, Metadata, error) + PeriodCards(ctx context.Context, accountId string, from time.Time, opts ...common.RequestOption) (period.Cards, common.Metadata, error) + PeriodImage(ctx context.Context, accountId string, from time.Time, opts ...common.RequestOption) (common.Image, common.Metadata, error) - SessionCards(ctx context.Context, accountId string, from time.Time, opts ...RequestOption) (session.Cards, Metadata, error) - SessionImage(ctx context.Context, accountId string, from time.Time, opts ...RequestOption) (Image, Metadata, error) - EmptySessionCards(ctx context.Context, accountId string) (session.Cards, Metadata, error) + SessionCards(ctx context.Context, accountId string, from time.Time, opts ...common.RequestOption) (session.Cards, common.Metadata, error) + SessionImage(ctx context.Context, accountId string, from time.Time, opts ...common.RequestOption) (common.Image, common.Metadata, error) + EmptySessionCards(ctx context.Context, accountId string) (session.Cards, common.Metadata, error) - ReplayCards(ctx context.Context, replayURL string, o ...RequestOption) (replay.Cards, Metadata, error) - ReplayImage(ctx context.Context, replayURL string, o ...RequestOption) (Image, Metadata, error) + ReplayCards(ctx context.Context, replayURL string, o ...common.RequestOption) (replay.Cards, common.Metadata, error) + ReplayImage(ctx context.Context, replayURL string, o ...common.RequestOption) (common.Image, common.Metadata, error) } func NewClient(fetch fetch.Client, database database.Client, wargaming wargaming.Client, locale language.Tag) Client { diff --git a/internal/stats/client/v1/image.go b/internal/stats/client/v1/image.go index 1b5d09be..2afa18eb 100644 --- a/internal/stats/client/v1/image.go +++ b/internal/stats/client/v1/image.go @@ -6,27 +6,12 @@ import ( "io" "github.com/pkg/errors" - - "github.com/cufee/aftermath/internal/render/v1" ) -type Image interface { - AddBackground(image.Image, render.Style) error - PNG(io.Writer) error -} - type imageImp struct { image.Image } -func (i *imageImp) AddBackground(bg image.Image, style render.Style) error { - if bg == nil { - return errors.New("background cannot be nil") - } - i.Image = render.AddBackground(i, bg, style) - return nil -} - func (i *imageImp) PNG(w io.Writer) error { if i.Image == nil { return errors.New("image cannot be nil") diff --git a/internal/stats/client/v1/period.go b/internal/stats/client/v1/period.go index 5669569c..d0303d21 100644 --- a/internal/stats/client/v1/period.go +++ b/internal/stats/client/v1/period.go @@ -7,18 +7,16 @@ import ( "github.com/cufee/aftermath/internal/database" "github.com/cufee/aftermath/internal/localization" + "github.com/cufee/aftermath/internal/stats/client/common" "github.com/cufee/aftermath/internal/stats/fetch/v1" prepare "github.com/cufee/aftermath/internal/stats/prepare/period/v1" render "github.com/cufee/aftermath/internal/stats/render/period/v1" ) -func (r *client) PeriodCards(ctx context.Context, accountId string, from time.Time, o ...RequestOption) (prepare.Cards, Metadata, error) { - var opts = requestOptions{} - for _, apply := range o { - apply(&opts) - } +func (r *client) PeriodCards(ctx context.Context, accountId string, from time.Time, o ...common.RequestOption) (prepare.Cards, common.Metadata, error) { + opts := common.RequestOptions(o).Options() - meta := Metadata{Stats: make(map[string]fetch.AccountStatsOverPeriod)} + meta := common.Metadata{Stats: make(map[string]fetch.AccountStatsOverPeriod)} printer, err := localization.NewPrinterWithFallback("stats", r.locale) if err != nil { @@ -33,7 +31,7 @@ func (r *client) PeriodCards(ctx context.Context, accountId string, from time.Ti return } recordAccountSnapshots(r.wargaming, r.database, id, reference) - }(accountId, opts.referenceID) + }(accountId, opts.ReferenceID()) stop := meta.Timer("fetchClient#PeriodStats") stats, err := r.fetchClient.CurrentStats(ctx, accountId, opts.FetchOpts()...) @@ -53,8 +51,8 @@ func (r *client) PeriodCards(ctx context.Context, accountId string, from time.Ti vehicles = append(vehicles, id) } } - if opts.vehicleID != "" && !slices.Contains(vehicles, opts.vehicleID) { - vehicles = append(vehicles, opts.vehicleID) + if opts.VehicleID() != "" && !slices.Contains(vehicles, opts.VehicleID()) { + vehicles = append(vehicles, opts.VehicleID()) } glossary, err := r.database.GetVehicles(ctx, vehicles) @@ -70,11 +68,8 @@ func (r *client) PeriodCards(ctx context.Context, accountId string, from time.Ti return cards, meta, err } -func (r *client) PeriodImage(ctx context.Context, accountId string, from time.Time, o ...RequestOption) (Image, Metadata, error) { - var opts = requestOptions{} - for _, apply := range o { - apply(&opts) - } +func (r *client) PeriodImage(ctx context.Context, accountId string, from time.Time, o ...common.RequestOption) (common.Image, common.Metadata, error) { + opts := common.RequestOptions(o).Options() cards, meta, err := r.PeriodCards(ctx, accountId, from, o...) if err != nil { @@ -87,7 +82,7 @@ func (r *client) PeriodImage(ctx context.Context, accountId string, from time.Ti } stop := meta.Timer("render#CardsToImage") - image, err := render.CardsToImage(meta.Stats["period"], cards, opts.subscriptions, opts.RenderOpts(printer)...) + image, err := render.CardsToImage(meta.Stats["period"], cards, opts.Subscriptions, opts.RenderOpts(printer)...) stop() if err != nil { return nil, meta, err diff --git a/internal/stats/client/v1/replay.go b/internal/stats/client/v1/replay.go index caff8972..1e5adc01 100644 --- a/internal/stats/client/v1/replay.go +++ b/internal/stats/client/v1/replay.go @@ -7,18 +7,16 @@ import ( "github.com/cufee/aftermath/internal/database" "github.com/cufee/aftermath/internal/localization" "github.com/cufee/aftermath/internal/logic" + "github.com/cufee/aftermath/internal/stats/client/common" "github.com/cufee/aftermath/internal/stats/fetch/v1" prepare "github.com/cufee/aftermath/internal/stats/prepare/replay/v1" render "github.com/cufee/aftermath/internal/stats/render/replay/v1" ) -func (r *client) ReplayCards(ctx context.Context, replayURL string, o ...RequestOption) (prepare.Cards, Metadata, error) { - var opts = requestOptions{} - for _, apply := range o { - apply(&opts) - } +func (r *client) ReplayCards(ctx context.Context, replayURL string, o ...common.RequestOption) (prepare.Cards, common.Metadata, error) { + opts := common.RequestOptions(o).Options() - meta := Metadata{Stats: make(map[string]fetch.AccountStatsOverPeriod)} + meta := common.Metadata{Stats: make(map[string]fetch.AccountStatsOverPeriod)} printer, err := localization.NewPrinterWithFallback("stats", r.locale) if err != nil { @@ -59,12 +57,7 @@ func (r *client) ReplayCards(ctx context.Context, replayURL string, o ...Request return cards, meta, err } -func (r *client) ReplayImage(ctx context.Context, replayURL string, o ...RequestOption) (Image, Metadata, error) { - var opts = requestOptions{} - for _, apply := range o { - apply(&opts) - } - +func (r *client) ReplayImage(ctx context.Context, replayURL string, o ...common.RequestOption) (common.Image, common.Metadata, error) { cards, meta, err := r.ReplayCards(ctx, replayURL, o...) if err != nil { return nil, meta, err @@ -76,8 +69,9 @@ func (r *client) ReplayImage(ctx context.Context, replayURL string, o ...Request } if img, _, err := logic.GetAccountBackgroundImage(ctx, r.database, meta.Replay.Protagonist.ID); err == nil { - opts.backgroundImage = img + o = append(o, common.WithBackground(img, true)) } + opts := common.RequestOptions(o).Options() stop := meta.Timer("render#CardsToImage") image, err := render.CardsToImage(meta.Replay, cards, opts.RenderOpts(printer)...) diff --git a/internal/stats/client/v1/session.go b/internal/stats/client/v1/session.go index 9eaae10a..129e4b9e 100644 --- a/internal/stats/client/v1/session.go +++ b/internal/stats/client/v1/session.go @@ -8,14 +8,16 @@ import ( "github.com/cufee/aftermath/internal/database" "github.com/cufee/aftermath/internal/localization" + options "github.com/cufee/aftermath/internal/stats/client/common" "github.com/cufee/aftermath/internal/stats/fetch/v1" "github.com/cufee/aftermath/internal/stats/prepare/common/v1" prepare "github.com/cufee/aftermath/internal/stats/prepare/session/v1" render "github.com/cufee/aftermath/internal/stats/render/session/v1" ) -func (c *client) EmptySessionCards(ctx context.Context, accountId string) (prepare.Cards, Metadata, error) { - meta := Metadata{Stats: make(map[string]fetch.AccountStatsOverPeriod)} +func (c *client) EmptySessionCards(ctx context.Context, accountId string) (prepare.Cards, options.Metadata, error) { + + meta := options.Metadata{Stats: make(map[string]fetch.AccountStatsOverPeriod)} stop := meta.Timer("database#GetAccountByID") account, err := c.database.GetAccountByID(ctx, accountId) @@ -26,7 +28,7 @@ func (c *client) EmptySessionCards(ctx context.Context, accountId string) (prepa if err != nil { return prepare.Cards{}, meta, err } - return prepare.Cards{}, meta, ErrAccountNotTracked + return prepare.Cards{}, meta, options.ErrAccountNotTracked } return prepare.Cards{}, meta, err } @@ -47,22 +49,19 @@ func (c *client) EmptySessionCards(ctx context.Context, accountId string) (prepa } -func (c *client) SessionCards(ctx context.Context, accountId string, from time.Time, o ...RequestOption) (prepare.Cards, Metadata, error) { - var opts = requestOptions{} - for _, apply := range o { - apply(&opts) - } +func (c *client) SessionCards(ctx context.Context, accountId string, from time.Time, o ...options.RequestOption) (prepare.Cards, options.Metadata, error) { + opts := options.RequestOptions(o).Options() - meta := Metadata{Stats: make(map[string]fetch.AccountStatsOverPeriod)} + meta := options.Metadata{Stats: make(map[string]fetch.AccountStatsOverPeriod)} stop := meta.Timer("database#GetAccountByID") _, err := c.database.GetAccountByID(ctx, accountId) stop() if database.IsNotFound(err) { // record a session in the background - go recordAccountSnapshots(c.wargaming, c.database, accountId, opts.referenceID) + go recordAccountSnapshots(c.wargaming, c.database, accountId, opts.ReferenceID()) - return prepare.Cards{}, meta, ErrAccountNotTracked + return prepare.Cards{}, meta, options.ErrAccountNotTracked } if err != nil { return prepare.Cards{}, meta, err @@ -81,7 +80,7 @@ func (c *client) SessionCards(ctx context.Context, accountId string, from time.T stop() if err != nil { if errors.Is(err, fetch.ErrSessionNotFound) { - go recordAccountSnapshots(c.wargaming, c.database, accountId, opts.referenceID) + go recordAccountSnapshots(c.wargaming, c.database, accountId, opts.ReferenceID()) } return prepare.Cards{}, meta, err } @@ -108,8 +107,8 @@ func (c *client) SessionCards(ctx context.Context, accountId string, from time.T vehicles = append(vehicles, id) } } - if opts.vehicleID != "" && !slices.Contains(vehicles, opts.vehicleID) { - vehicles = append(vehicles, opts.vehicleID) + if opts.VehicleID() != "" && !slices.Contains(vehicles, opts.VehicleID()) { + vehicles = append(vehicles, opts.VehicleID()) } glossary, err := c.database.GetVehicles(ctx, vehicles) @@ -129,11 +128,8 @@ func (c *client) SessionCards(ctx context.Context, accountId string, from time.T return cards, meta, nil } -func (c *client) SessionImage(ctx context.Context, accountId string, from time.Time, o ...RequestOption) (Image, Metadata, error) { - var opts = requestOptions{} - for _, apply := range o { - apply(&opts) - } +func (c *client) SessionImage(ctx context.Context, accountId string, from time.Time, o ...options.RequestOption) (options.Image, options.Metadata, error) { + opts := options.RequestOptions(o).Options() cards, meta, err := c.SessionCards(ctx, accountId, from, o...) if err != nil { @@ -146,7 +142,7 @@ func (c *client) SessionImage(ctx context.Context, accountId string, from time.T } stop := meta.Timer("render#CardsToImage") - image, err := render.CardsToImage(meta.Stats["session"], meta.Stats["career"], cards, opts.subscriptions, opts.RenderOpts(printer)...) + image, err := render.CardsToImage(meta.Stats["session"], meta.Stats["career"], cards, opts.Subscriptions, opts.RenderOpts(printer)...) stop() if err != nil { return nil, meta, err diff --git a/internal/stats/client/v2/client.go b/internal/stats/client/v2/client.go new file mode 100644 index 00000000..6ef0a4c0 --- /dev/null +++ b/internal/stats/client/v2/client.go @@ -0,0 +1,43 @@ +package client + +import ( + "context" + "time" + + "github.com/cufee/aftermath/internal/database" + "github.com/cufee/aftermath/internal/external/wargaming" + "github.com/cufee/aftermath/internal/stats/client/common" + v1 "github.com/cufee/aftermath/internal/stats/client/v1" + "github.com/cufee/aftermath/internal/stats/fetch/v1" + period "github.com/cufee/aftermath/internal/stats/prepare/period/v1" + "github.com/cufee/aftermath/internal/stats/prepare/replay/v1" + "github.com/cufee/aftermath/internal/stats/prepare/session/v1" + "golang.org/x/text/language" +) + +var _ Client = &client{} + +type client struct { + v1 v1.Client + + fetchClient fetch.Client + wargaming wargaming.Client + database database.Client + locale language.Tag +} + +type Client interface { + PeriodCards(ctx context.Context, accountId string, from time.Time, opts ...common.RequestOption) (period.Cards, common.Metadata, error) + PeriodImage(ctx context.Context, accountId string, from time.Time, opts ...common.RequestOption) (common.Image, common.Metadata, error) + + SessionCards(ctx context.Context, accountId string, from time.Time, opts ...common.RequestOption) (session.Cards, common.Metadata, error) + SessionImage(ctx context.Context, accountId string, from time.Time, opts ...common.RequestOption) (common.Image, common.Metadata, error) + EmptySessionCards(ctx context.Context, accountId string) (session.Cards, common.Metadata, error) + + ReplayCards(ctx context.Context, replayURL string, o ...common.RequestOption) (replay.Cards, common.Metadata, error) + ReplayImage(ctx context.Context, replayURL string, o ...common.RequestOption) (common.Image, common.Metadata, error) +} + +func NewClient(fetch fetch.Client, database database.Client, wargaming wargaming.Client, locale language.Tag) Client { + return &client{v1.NewClient(fetch, database, wargaming, locale), fetch, wargaming, database, locale} +} diff --git a/internal/stats/client/v2/image.go b/internal/stats/client/v2/image.go new file mode 100644 index 00000000..2afa18eb --- /dev/null +++ b/internal/stats/client/v2/image.go @@ -0,0 +1,20 @@ +package client + +import ( + "image" + "image/png" + "io" + + "github.com/pkg/errors" +) + +type imageImp struct { + image.Image +} + +func (i *imageImp) PNG(w io.Writer) error { + if i.Image == nil { + return errors.New("image cannot be nil") + } + return png.Encode(w, i.Image) +} diff --git a/internal/stats/client/v2/period.go b/internal/stats/client/v2/period.go new file mode 100644 index 00000000..39d438a9 --- /dev/null +++ b/internal/stats/client/v2/period.go @@ -0,0 +1,92 @@ +package client + +import ( + "context" + "slices" + "time" + + "github.com/cufee/aftermath/internal/database" + "github.com/cufee/aftermath/internal/localization" + "github.com/cufee/aftermath/internal/stats/client/common" + "github.com/cufee/aftermath/internal/stats/fetch/v1" + prepare "github.com/cufee/aftermath/internal/stats/prepare/period/v1" + render "github.com/cufee/aftermath/internal/stats/render/period/v2" +) + +func (r *client) PeriodCards(ctx context.Context, accountId string, from time.Time, o ...common.RequestOption) (prepare.Cards, common.Metadata, error) { + opts := common.RequestOptions(o).Options() + + meta := common.Metadata{Stats: make(map[string]fetch.AccountStatsOverPeriod)} + + printer, err := localization.NewPrinterWithFallback("stats", r.locale) + if err != nil { + return prepare.Cards{}, meta, err + } + + // cache account and record session snapshots + go func(id, reference string) { + _, err = r.database.GetAccountByID(ctx, id) + if !database.IsNotFound(err) { + // account was found or some other error happened - no need to do anything here + return + } + recordAccountSnapshots(r.wargaming, r.database, id, reference) + }(accountId, opts.ReferenceID()) + + stop := meta.Timer("fetchClient#PeriodStats") + stats, err := r.fetchClient.CurrentStats(ctx, accountId, opts.FetchOpts()...) + stop() + if err != nil { + return prepare.Cards{}, meta, err + } + meta.Stats["period"] = stats + + stop = meta.Timer("prepare#GetVehicles") + var vehicles []string + for id := range stats.RegularBattles.Vehicles { + vehicles = append(vehicles, id) + } + for id := range stats.RatingBattles.Vehicles { + if !slices.Contains(vehicles, id) { + vehicles = append(vehicles, id) + } + } + if opts.VehicleID() != "" && !slices.Contains(vehicles, opts.VehicleID()) { + vehicles = append(vehicles, opts.VehicleID()) + } + + glossary, err := r.database.GetVehicles(ctx, vehicles) + if err != nil { + return prepare.Cards{}, meta, err + } + stop() + + stop = meta.Timer("prepare#NewCards") + cards, err := prepare.NewCards(stats, glossary, opts.PrepareOpts(printer, r.locale)...) + stop() + + return cards, meta, err +} + +func (r *client) PeriodImage(ctx context.Context, accountId string, from time.Time, o ...common.RequestOption) (common.Image, common.Metadata, error) { + opts := common.RequestOptions(o).Options() + + cards, meta, err := r.PeriodCards(ctx, accountId, from, o...) + if err != nil { + return nil, meta, err + } + + printer, err := localization.NewPrinterWithFallback("stats", r.locale) + if err != nil { + return nil, meta, err + } + + stop := meta.Timer("render#CardsToImage") + image, err := render.CardsToImage(meta.Stats["period"], cards, opts.Subscriptions, opts.RenderOpts(printer)...) + stop() + if err != nil { + return nil, meta, err + } + + return &imageImp{image}, meta, err +} diff --git a/internal/stats/client/v2/replay.go b/internal/stats/client/v2/replay.go new file mode 100644 index 00000000..8bae635b --- /dev/null +++ b/internal/stats/client/v2/replay.go @@ -0,0 +1,16 @@ +package client + +import ( + "context" + + "github.com/cufee/aftermath/internal/stats/client/common" + prepare "github.com/cufee/aftermath/internal/stats/prepare/replay/v1" +) + +func (r *client) ReplayCards(ctx context.Context, replayURL string, o ...common.RequestOption) (prepare.Cards, common.Metadata, error) { + return r.v1.ReplayCards(ctx, replayURL, o...) +} + +func (r *client) ReplayImage(ctx context.Context, replayURL string, o ...common.RequestOption) (common.Image, common.Metadata, error) { + return r.v1.ReplayImage(ctx, replayURL, o...) +} diff --git a/internal/stats/client/v2/session.go b/internal/stats/client/v2/session.go new file mode 100644 index 00000000..15716572 --- /dev/null +++ b/internal/stats/client/v2/session.go @@ -0,0 +1,19 @@ +package client + +import ( + "context" + "time" + + "github.com/cufee/aftermath/internal/stats/client/common" + "github.com/cufee/aftermath/internal/stats/prepare/session/v1" +) + +func (r *client) SessionCards(ctx context.Context, accountId string, from time.Time, opts ...common.RequestOption) (session.Cards, common.Metadata, error) { + return r.v1.SessionCards(ctx, accountId, from, opts...) +} +func (r *client) SessionImage(ctx context.Context, accountId string, from time.Time, opts ...common.RequestOption) (common.Image, common.Metadata, error) { + return r.v1.SessionImage(ctx, accountId, from, opts...) +} +func (r *client) EmptySessionCards(ctx context.Context, accountId string) (session.Cards, common.Metadata, error) { + return r.v1.EmptySessionCards(ctx, accountId) +} diff --git a/internal/stats/client/v2/utils.go b/internal/stats/client/v2/utils.go new file mode 100644 index 00000000..8f7c91fe --- /dev/null +++ b/internal/stats/client/v2/utils.go @@ -0,0 +1,27 @@ +package client + +import ( + "context" + "time" + + "github.com/cufee/aftermath/internal/database" + "github.com/cufee/aftermath/internal/external/wargaming" + "github.com/cufee/aftermath/internal/log" + "github.com/cufee/aftermath/internal/logic" +) + +func recordAccountSnapshots(wargaming wargaming.Client, database database.Client, accountID, referenceID string) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*15) + defer cancel() + + realm, ok := wargaming.RealmFromID(accountID) + if !ok { + log.Error().Str("accountId", accountID).Msg("invalid account realm") + return + } + + _, err := logic.RecordAccountSnapshots(ctx, wargaming, database, realm, logic.WithReference(accountID, referenceID)) + if err != nil { + log.Err(err).Str("accountId", accountID).Msg("failed to record account snapshot") + } +} diff --git a/internal/stats/render/period/v1/cards.go b/internal/stats/render/period/v1/cards.go index 294efdda..0b468cc9 100644 --- a/internal/stats/render/period/v1/cards.go +++ b/internal/stats/render/period/v1/cards.go @@ -6,7 +6,8 @@ import ( "github.com/pkg/errors" "github.com/cufee/aftermath/internal/database/models" - common "github.com/cufee/aftermath/internal/render/v1" + "github.com/cufee/aftermath/internal/render/common" + v1 "github.com/cufee/aftermath/internal/render/v1" "github.com/cufee/aftermath/internal/stats/fetch/v1" prepare "github.com/cufee/aftermath/internal/stats/prepare/common/v1" "github.com/cufee/aftermath/internal/stats/prepare/period/v1" @@ -14,22 +15,22 @@ import ( "github.com/cufee/aftermath/internal/log" ) -func generateCards(stats fetch.AccountStatsOverPeriod, cards period.Cards, subs []models.UserSubscription, opts common.Options) (common.Segments, error) { +func generateCards(stats fetch.AccountStatsOverPeriod, cards period.Cards, subs []models.UserSubscription, opts common.Options) (v1.Segments, error) { if len(cards.Overview.Blocks) == 0 && len(cards.Highlights) == 0 { log.Error().Msg("player cards slice is 0 length, this should not happen") - return common.Segments{}, errors.New("no cards provided") + return v1.Segments{}, errors.New("no cards provided") } - var segments common.Segments + var segments v1.Segments // Calculate minimal card width to fit all the content var cardWidth float64 overviewColumnWidth := float64(common.DefaultLogoOptions().Width()) { { - titleStyle := common.DefaultPlayerTitleStyle(stats.Account.Nickname, titleCardStyle(cardWidth)) - clanSize := common.MeasureString(stats.Account.ClanTag, titleStyle.ClanTag.Font) - nameSize := common.MeasureString(stats.Account.Nickname, titleStyle.Nickname.Font) + titleStyle := v1.DefaultPlayerTitleStyle(stats.Account.Nickname, titleCardStyle(cardWidth)) + clanSize := v1.MeasureString(stats.Account.ClanTag, titleStyle.ClanTag.Font) + nameSize := v1.MeasureString(stats.Account.Nickname, titleStyle.Nickname.Font) cardWidth = max(cardWidth, titleStyle.TotalPaddingAndGaps()+nameSize.TotalWidth+clanSize.TotalWidth*2) } { @@ -45,8 +46,8 @@ func generateCards(stats fetch.AccountStatsOverPeriod, cards period.Cards, subs if block.Tag == prepare.TagRankedRating { label = common.GetRatingTierName(block.Value().Float()) } - labelSize := common.MeasureString(label, labelStyle.Font) - valueSize := common.MeasureString(block.Value().String(), valueStyle.Font) + labelSize := v1.MeasureString(label, labelStyle.Font) + valueSize := v1.MeasureString(block.Value().String(), valueStyle.Font) overviewColumnWidth = max(overviewColumnWidth, max(labelSize.TotalWidth+overviewSpecialRatingPillStyle().PaddingX*2, valueSize.TotalWidth)) } @@ -64,15 +65,15 @@ func generateCards(stats fetch.AccountStatsOverPeriod, cards period.Cards, subs var highlightBlocksMaxCount, highlightTitleMaxWidth, highlightBlockMaxSize float64 for _, highlight := range cards.Highlights { // Title and tank name - metaSize := common.MeasureString(highlight.Meta, highlightStyle.cardTitle.Font) - titleSize := common.MeasureString(highlight.Title, highlightStyle.tankName.Font) + metaSize := v1.MeasureString(highlight.Meta, highlightStyle.cardTitle.Font) + titleSize := v1.MeasureString(highlight.Title, highlightStyle.tankName.Font) highlightTitleMaxWidth = max(highlightTitleMaxWidth, metaSize.TotalWidth, titleSize.TotalWidth) // Blocks highlightBlocksMaxCount = max(highlightBlocksMaxCount, float64(len(highlight.Blocks))) for _, block := range highlight.Blocks { - labelSize := common.MeasureString(block.Label, highlightStyle.blockLabel.Font) - valueSize := common.MeasureString(block.Value().String(), highlightStyle.blockValue.Font) + labelSize := v1.MeasureString(block.Label, highlightStyle.blockLabel.Font) + valueSize := v1.MeasureString(block.Value().String(), highlightStyle.blockValue.Font) highlightBlockMaxSize = max(highlightBlockMaxSize, valueSize.TotalWidth, labelSize.TotalWidth) } } @@ -96,27 +97,27 @@ func generateCards(stats fetch.AccountStatsOverPeriod, cards period.Cards, subs } else { footer = append(footer, sessionFrom+" - "+sessionTo) } - footerBlock := common.NewFooterCard(strings.Join(footer, " • ")) + footerBlock := v1.NewFooterCard(strings.Join(footer, " • ")) footerImage, err := footerBlock.Render() if err != nil { return segments, err } cardWidth = max(cardWidth, float64(footerImage.Bounds().Dx())) - segments.AddFooter(common.NewImageContent(common.Style{}, footerImage)) + segments.AddFooter(v1.NewImageContent(v1.Style{}, footerImage)) } // Header card - if headerCard, headerCardExists := common.NewHeaderCard(cardWidth, subs, opts.PromoText); headerCardExists { + if headerCard, headerCardExists := v1.NewHeaderCard(cardWidth, subs, opts.PromoText); headerCardExists { segments.AddHeader(headerCard) } // Player Title card - segments.AddContent(common.NewPlayerTitleCard(common.DefaultPlayerTitleStyle(stats.Account.Nickname, titleCardStyle(cardWidth)), stats.Account.Nickname, stats.Account.ClanTag, subs)) + segments.AddContent(v1.NewPlayerTitleCard(v1.DefaultPlayerTitleStyle(stats.Account.Nickname, titleCardStyle(cardWidth)), stats.Account.Nickname, stats.Account.ClanTag, subs)) // Overview Card if len(cards.Overview.Blocks) > 0 { - var overviewStatsBlocks []common.Block + var overviewStatsBlocks []v1.Block for _, column := range cards.Overview.Blocks { columnBlock, err := statsBlocksToColumnBlock(getOverviewStyle(overviewColumnWidth), column.Blocks) if err != nil { @@ -124,14 +125,14 @@ func generateCards(stats fetch.AccountStatsOverPeriod, cards period.Cards, subs } overviewStatsBlocks = append(overviewStatsBlocks, columnBlock) } - var overviewCardBlocks []common.Block - overviewCardBlocks = append(overviewCardBlocks, common.NewBlocksContent(overviewCardBlocksStyle(cardWidth), overviewStatsBlocks...)) - segments.AddContent(common.NewBlocksContent(overviewCardStyle(), overviewCardBlocks...)) + var overviewCardBlocks []v1.Block + overviewCardBlocks = append(overviewCardBlocks, v1.NewBlocksContent(overviewCardBlocksStyle(cardWidth), overviewStatsBlocks...)) + segments.AddContent(v1.NewBlocksContent(overviewCardStyle(), overviewCardBlocks...)) } // Rating Card -- only when player has current season rating if cards.Rating.Meta { - var ratingStatsBlocks []common.Block + var ratingStatsBlocks []v1.Block for _, column := range cards.Rating.Blocks { columnBlock, err := statsBlocksToColumnBlock(getOverviewStyle(overviewColumnWidth), column.Blocks) if err != nil { @@ -139,9 +140,9 @@ func generateCards(stats fetch.AccountStatsOverPeriod, cards period.Cards, subs } ratingStatsBlocks = append(ratingStatsBlocks, columnBlock) } - var ratingCardBlocks []common.Block - ratingCardBlocks = append(ratingCardBlocks, common.NewBlocksContent(overviewCardBlocksStyle(cardWidth), ratingStatsBlocks...)) - segments.AddContent(common.NewBlocksContent(overviewCardStyle(), ratingCardBlocks...)) + var ratingCardBlocks []v1.Block + ratingCardBlocks = append(ratingCardBlocks, v1.NewBlocksContent(overviewCardBlocksStyle(cardWidth), ratingStatsBlocks...)) + segments.AddContent(v1.NewBlocksContent(overviewCardStyle(), ratingCardBlocks...)) } // Highlights @@ -155,24 +156,24 @@ func generateCards(stats fetch.AccountStatsOverPeriod, cards period.Cards, subs return segments, nil } -func newHighlightCard(style highlightStyle, card period.VehicleCard) common.Block { +func newHighlightCard(style highlightStyle, card period.VehicleCard) v1.Block { titleBlock := - common.NewBlocksContent(common.Style{ - Direction: common.DirectionVertical, + v1.NewBlocksContent(v1.Style{ + Direction: v1.DirectionVertical, }, - common.NewTextContent(style.cardTitle, card.Meta), - common.NewTextContent(style.tankName, card.Title), + v1.NewTextContent(style.cardTitle, card.Meta), + v1.NewTextContent(style.tankName, card.Title), ) - var contentRow []common.Block + var contentRow []v1.Block for _, block := range card.Blocks { - contentRow = append(contentRow, common.NewBlocksContent(common.Style{Direction: common.DirectionVertical, AlignItems: common.AlignItemsCenter}, - common.NewTextContent(style.blockValue, block.Value().String()), - common.NewTextContent(style.blockLabel, block.Label), + contentRow = append(contentRow, v1.NewBlocksContent(v1.Style{Direction: v1.DirectionVertical, AlignItems: v1.AlignItemsCenter}, + v1.NewTextContent(style.blockValue, block.Value().String()), + v1.NewTextContent(style.blockLabel, block.Label), )) } - return common.NewBlocksContent(style.container, titleBlock, common.NewBlocksContent(common.Style{ + return v1.NewBlocksContent(style.container, titleBlock, v1.NewBlocksContent(v1.Style{ Gap: style.container.Gap, }, contentRow...)) } diff --git a/internal/stats/render/period/v1/image.go b/internal/stats/render/period/v1/image.go index d8719270..d1f8c9ac 100644 --- a/internal/stats/render/period/v1/image.go +++ b/internal/stats/render/period/v1/image.go @@ -8,7 +8,8 @@ import ( "time" "github.com/cufee/aftermath/internal/database/models" - common "github.com/cufee/aftermath/internal/render/v1" + "github.com/cufee/aftermath/internal/render/common" + v1 "github.com/cufee/aftermath/internal/render/v1" "github.com/cufee/aftermath/internal/stats/fetch/v1" "github.com/cufee/aftermath/internal/stats/frame" "github.com/cufee/aftermath/internal/stats/prepare/period/v1" @@ -69,7 +70,7 @@ func CardsToImage(stats fetch.AccountStatsOverPeriod, cards period.Cards, subs [ return segments.Render(func(op *common.Options) { op.Background = o.Background; op.BackgroundIsCustom = o.BackgroundIsCustom }) } -func CardsToSegments(stats fetch.AccountStatsOverPeriod, cards period.Cards, subs []models.UserSubscription, opts ...common.Option) (*common.Segments, error) { +func CardsToSegments(stats fetch.AccountStatsOverPeriod, cards period.Cards, subs []models.UserSubscription, opts ...common.Option) (*v1.Segments, error) { o := common.DefaultOptions() for _, apply := range opts { apply(&o) diff --git a/internal/stats/render/period/v2/background.go b/internal/stats/render/period/v2/background.go new file mode 100644 index 00000000..3038fd85 --- /dev/null +++ b/internal/stats/render/period/v2/background.go @@ -0,0 +1,38 @@ +package period + +import ( + "image" + "image/color" + "slices" + "time" + + "github.com/cufee/aftermath/internal/render/common" + "github.com/cufee/aftermath/internal/stats/frame" +) + +func addBackgroundBranding(background image.Image, vehicles map[string]frame.VehicleStatsFrame, patternSeed int) image.Image { + var values []vehicleWN8 + for _, vehicle := range vehicles { + if wn8 := vehicle.WN8(); !frame.InvalidValue.Equals(wn8) { + values = append(values, vehicleWN8{vehicle.VehicleID, wn8, int(vehicle.LastBattleTime.Unix())}) + } + } + slices.SortFunc(values, func(a, b vehicleWN8) int { return b.sortKey - a.sortKey }) + if len(values) >= 10 { + values = values[:9] + } + + var accentColors []color.Color + for _, value := range values { + c := common.GetWN8Colors(value.wn8.Float()).Background + if _, _, _, a := c.RGBA(); a > 0 { + accentColors = append(accentColors, c) + } + } + + if patternSeed == 0 { + patternSeed = int(time.Now().Unix()) + } + + return common.AddDefaultBrandedOverlay(background, accentColors, patternSeed, 0.5) +} diff --git a/internal/stats/render/period/v2/cards.go b/internal/stats/render/period/v2/cards.go new file mode 100644 index 00000000..4b6275d3 --- /dev/null +++ b/internal/stats/render/period/v2/cards.go @@ -0,0 +1,132 @@ +package period + +import ( + "errors" + + prepare "github.com/cufee/aftermath/internal/stats/prepare/common/v1" + "github.com/cufee/facepaint/style" + + "github.com/cufee/aftermath/internal/database/models" + "github.com/cufee/aftermath/internal/log" + "github.com/cufee/aftermath/internal/render/common" + "github.com/cufee/aftermath/internal/stats/fetch/v1" + "github.com/cufee/aftermath/internal/stats/prepare/period/v1" + "github.com/cufee/facepaint" +) + +func generateCards(stats fetch.AccountStatsOverPeriod, cards period.Cards, subs []models.UserSubscription, opts common.Options) (*facepaint.Block, error) { + if len(cards.Overview.Blocks) == 0 && len(cards.Highlights) == 0 { + log.Error().Msg("player cards slice is 0 length, this should not happen") + return nil, errors.New("no cards provided") + } + + // calculate max overview block width to make all blocks the same size + var maxWidthOverviewBlock float64 + for _, column := range append(cards.Overview.Blocks, cards.Rating.Blocks...) { + for _, block := range column.Blocks { + switch block.Tag { + case prepare.TagWN8: + block.Label = common.GetWN8TierName(block.Value().Float()) + maxWidthOverviewBlock = max(maxWidthOverviewBlock, iconSizeWN8) + + case prepare.TagRankedRating: + block.Label = common.GetRatingTierName(block.Value().Float()) + maxWidthOverviewBlock = max(maxWidthOverviewBlock, iconSizeRating) + } + maxWidthOverviewBlock = max(maxWidthOverviewBlock, facepaint.MeasureString(block.Label, styledOverviewCard.styleBlock(block).label.Font).TotalWidth) + maxWidthOverviewBlock = max(maxWidthOverviewBlock, facepaint.MeasureString(block.Value().String(), styledOverviewCard.styleBlock(block).value.Font).TotalWidth) + } + } + + // { + // highlightStyle := highlightCardStyle(defaultCardStyle(0)) + // var highlightBlocksMaxCount, highlightTitleMaxWidth, highlightBlockMaxSize float64 + // for _, highlight := range cards.Highlights { + // // Title and tank name + // metaSize := common.MeasureString(highlight.Meta, highlightStyle.cardTitle.Font) + // titleSize := common.MeasureString(highlight.Title, highlightStyle.tankName.Font) + // highlightTitleMaxWidth = max(highlightTitleMaxWidth, metaSize.TotalWidth, titleSize.TotalWidth) + + // // Blocks + // highlightBlocksMaxCount = max(highlightBlocksMaxCount, float64(len(highlight.Blocks))) + // for _, block := range highlight.Blocks { + // labelSize := common.MeasureString(block.Label, highlightStyle.blockLabel.Font) + // valueSize := common.MeasureString(block.Value().String(), highlightStyle.blockValue.Font) + // highlightBlockMaxSize = max(highlightBlockMaxSize, valueSize.TotalWidth, labelSize.TotalWidth) + // } + // } + + // highlightCardWidthMax := (highlightStyle.container.PaddingX * 2) + (highlightStyle.container.Gap * highlightBlocksMaxCount) + (highlightBlockMaxSize * highlightBlocksMaxCount) + highlightTitleMaxWidth + // cardWidth = max(cardWidth, highlightCardWidthMax) + // } + // } + + var finalCards []*facepaint.Block + + // // We first render a footer in order to calculate the minimum required width + // { + // var footer []string + // if opts.VehicleID != "" { + // footer = append(footer, cards.Overview.Title) + // } + + // sessionTo := stats.PeriodEnd.Format("Jan 2, 2006") + // sessionFrom := stats.PeriodStart.Format("Jan 2, 2006") + // if sessionFrom == sessionTo { + // footer = append(footer, sessionTo) + // } else { + // footer = append(footer, sessionFrom+" - "+sessionTo) + // } + // footerBlock := common.NewFooterCard(strings.Join(footer, " • ")) + // footerImage, err := footerBlock.Render() + // if err != nil { + // return segments, err + // } + + // cardWidth = max(cardWidth, float64(footerImage.Bounds().Dx())) + // segments.AddFooter(common.NewImageContent(common.Style{}, footerImage)) + // } + + // // Header card + // if headerCard, headerCardExists := common.NewHeaderCard(cardWidth, subs, opts.PromoText); headerCardExists { + // segments.AddHeader(headerCard) + // } + + // // Player Title card + // segments.AddContent(common.NewPlayerTitleCard(common.DefaultPlayerTitleStyle(stats.Account.Nickname, titleCardStyle(cardWidth)), stats.Account.Nickname, stats.Account.ClanTag, subs)) + + // unrated overview card + if card := newOverviewCard(cards.Overview, maxWidthOverviewBlock); card != nil { + finalCards = append(finalCards, card) + } + + // // Rating Card -- only when player has current season rating + // if cards.Rating.Meta { + // var ratingStatsBlocks []common.Block + // for _, column := range cards.Rating.Blocks { + // columnBlock, err := statsBlocksToColumnBlock(getOverviewStyle(overviewColumnWidth), column.Blocks) + // if err != nil { + // return segments, err + // } + // ratingStatsBlocks = append(ratingStatsBlocks, columnBlock) + // } + // var ratingCardBlocks []common.Block + // ratingCardBlocks = append(ratingCardBlocks, common.NewBlocksContent(overviewCardBlocksStyle(cardWidth), ratingStatsBlocks...)) + // segments.AddContent(common.NewBlocksContent(overviewCardStyle(), ratingCardBlocks...)) + // } + + // // Highlights + // for i, card := range cards.Highlights { + // if i > 0 && cards.Rating.Meta { + // break // only show 1 highlight when rating battles card is visible + // } + // segments.AddContent(newHighlightCard(highlightCardStyle(defaultCardStyle(cardWidth)), card)) + // } + + if len(finalCards) == 0 { + return nil, errors.New("no cards to render") + } + + return facepaint.NewBlocksContent(style.NewStyle(style.Parent(styledCardsFrame)), finalCards...), nil + +} diff --git a/internal/stats/render/period/v2/constants.go b/internal/stats/render/period/v2/constants.go new file mode 100644 index 00000000..c4bf7816 --- /dev/null +++ b/internal/stats/render/period/v2/constants.go @@ -0,0 +1,90 @@ +package period + +// type highlightStyle struct { +// container common.Style +// cardTitle common.Style +// tankName common.Style +// blockLabel common.Style +// blockValue common.Style +// } + +// func (s *overviewStyle) block(block prepare.StatsBlock[period.BlockData, string]) (common.Style, common.Style) { +// switch block.Data.Flavor { +// case period.BlockFlavorSpecial: +// return common.Style{FontColor: common.TextPrimary, Font: common.FontXL()}, common.Style{FontColor: common.TextAlt, Font: common.FontSmall()} +// case period.BlockFlavorSecondary: +// return common.Style{FontColor: common.TextSecondary, Font: common.FontMedium()}, common.Style{FontColor: common.TextAlt, Font: common.FontSmall()} +// default: +// return common.Style{FontColor: common.TextPrimary, Font: common.FontLarge()}, common.Style{FontColor: common.TextAlt, Font: common.FontSmall()} +// } +// } + +// func defaultCardStyle(width float64) common.Style { +// style := common.Style{ +// JustifyContent: common.JustifyContentCenter, +// AlignItems: common.AlignItemsCenter, +// Direction: common.DirectionVertical, +// BackgroundColor: common.DefaultCardColor, +// BorderRadius: common.BorderRadiusLG, +// PaddingY: 10, +// PaddingX: 20, +// Gap: 20, +// Width: width, +// // Debug: true, +// } +// return style +// } + +// func titleCardStyle(width float64) common.Style { +// style := defaultCardStyle(width) +// style.PaddingX = style.PaddingY +// style.Gap = style.PaddingY +// // style.Debug = true +// return style +// } + +// func overviewCardStyle() common.Style { +// // style := defaultCardStyle(0) +// style := common.Style{} +// style.Direction = common.DirectionVertical +// style.AlignItems = common.AlignItemsCenter +// style.JustifyContent = common.JustifyContentCenter +// style.PaddingY = 0 +// style.PaddingX = 0 +// style.Gap = 5 +// // style.Debug = true +// return style +// } + +// func overviewCardBlocksStyle(width float64) common.Style { +// style := defaultCardStyle(width) +// style.AlignItems = common.AlignItemsCenter +// style.Direction = common.DirectionHorizontal +// style.JustifyContent = common.JustifyContentSpaceAround +// style.PaddingY = 20 +// style.PaddingX = 10 +// style.Gap = 5 +// // style.Debug = true +// return style +// } + +// func overviewSpecialRatingPillStyle() common.Style { +// return common.Style{} +// } + +// func highlightCardStyle(containerStyle common.Style) highlightStyle { +// container := containerStyle +// container.Gap = 10 +// container.PaddingX = 20 +// container.PaddingY = 15 +// container.Direction = common.DirectionHorizontal +// container.JustifyContent = common.JustifyContentSpaceBetween + +// return highlightStyle{ +// container: container, +// cardTitle: common.Style{Font: common.FontSmall(), FontColor: common.TextSecondary}, +// tankName: common.Style{Font: common.FontMedium(), FontColor: common.TextPrimary}, +// blockValue: common.Style{Font: common.FontMedium(), FontColor: common.TextPrimary}, +// blockLabel: common.Style{Font: common.FontSmall(), FontColor: common.TextAlt}, +// } +// } diff --git a/internal/stats/render/period/v2/image.go b/internal/stats/render/period/v2/image.go new file mode 100644 index 00000000..a036bb64 --- /dev/null +++ b/internal/stats/render/period/v2/image.go @@ -0,0 +1,57 @@ +package period + +import ( + "image" + "strconv" + + "github.com/cufee/aftermath/internal/database/models" + "github.com/cufee/aftermath/internal/render/common" + "github.com/cufee/aftermath/internal/stats/fetch/v1" + "github.com/cufee/aftermath/internal/stats/frame" + "github.com/cufee/aftermath/internal/stats/prepare/period/v1" + "github.com/cufee/facepaint" + "github.com/cufee/facepaint/style" +) + +type vehicleWN8 struct { + id string + wn8 frame.Value + sortKey int +} + +func CardsToImage(stats fetch.AccountStatsOverPeriod, cards period.Cards, subs []models.UserSubscription, opts ...common.Option) (image.Image, error) { + o := common.DefaultOptions() + for _, apply := range opts { + apply(&o) + } + + // Generate cards + cardsBlock, err := generateCards(stats, cards, subs, o) + if err != nil { + return nil, err + } + + if o.Background == nil { + return cardsBlock.Render() + } + + if !o.BackgroundIsCustom { + seed, _ := strconv.Atoi(stats.Account.ID) + o.Background = addBackgroundBranding(o.Background, stats.RegularBattles.Vehicles, seed) + } + + contentSize := cardsBlock.Dimensions() + withBackground := facepaint.NewBlocksContent(style.NewStyle(), + facepaint.MustNewImageContent( + style.NewStyle( + style.SetWidth(float64(contentSize.Width)), + style.SetHeight(float64(contentSize.Height)), + style.SetBlur(common.DefaultBackgroundBlur), + style.SetPosition(style.PositionAbsolute), + style.SetZIndex(-99), + ), o.Background), + cardsBlock, + ) + return withBackground.Render() + +} diff --git a/internal/stats/render/period/v2/overview-style.go b/internal/stats/render/period/v2/overview-style.go new file mode 100644 index 00000000..3b229784 --- /dev/null +++ b/internal/stats/render/period/v2/overview-style.go @@ -0,0 +1,158 @@ +package period + +import ( + "github.com/cufee/aftermath/internal/render/common" + prepare "github.com/cufee/aftermath/internal/stats/prepare/common/v1" + "github.com/cufee/aftermath/internal/stats/prepare/period/v1" + "github.com/cufee/facepaint/style" +) + +const ( + debugOverviewCards = false + + iconSizeWN8 = 54.0 + iconSizeRating = 60.0 +) + +type blockStyle struct { + wrapper style.Style + valueContainer style.Style + value style.Style + label style.Style +} + +type overviewCardStyle struct { + card style.Style + column style.Style +} + +func (s *overviewCardStyle) styleBlock(block prepare.StatsBlock[period.BlockData, string]) blockStyle { + switch block.Data.Flavor { + case period.BlockFlavorSpecial: + return blockStyle{ + wrapper: style.Style{ + Debug: debugOverviewCards, + + Direction: style.DirectionVertical, + AlignItems: style.AlignItemsCenter, + JustifyContent: style.JustifyContentSpaceAround, + GrowVertical: true, + Gap: 10, + }, + valueContainer: style.Style{ + Debug: debugOverviewCards, + + Direction: style.DirectionVertical, + AlignItems: style.AlignItemsCenter, + JustifyContent: style.JustifyContentEnd, + // GrowVertical: true, + }, + value: style.Style{ + Debug: debugOverviewCards, + + Color: common.TextPrimary, + Font: common.FontXL(), + }, + label: style.Style{ + Color: common.TextAlt, + Font: common.FontSmall(), + PaddingTop: -6, + }, + } + case period.BlockFlavorSecondary: + return blockStyle{ + wrapper: style.Style{}, + valueContainer: style.Style{ + Debug: debugOverviewCards, + + Direction: style.DirectionVertical, + AlignItems: style.AlignItemsCenter, + JustifyContent: style.JustifyContentCenter, + }, + value: style.Style{ + Debug: debugOverviewCards, + + Color: common.TextSecondary, + Font: common.FontMedium(), + }, + label: style.Style{ + Color: common.TextAlt, + Font: common.FontSmall(), + PaddingTop: -3, + }, + } + default: + return blockStyle{ + wrapper: style.Style{}, + valueContainer: style.Style{ + Debug: debugOverviewCards, + + Direction: style.DirectionVertical, + AlignItems: style.AlignItemsCenter, + JustifyContent: style.JustifyContentCenter, + }, + value: style.Style{ + Debug: debugOverviewCards, + + Color: common.TextPrimary, + Font: common.FontLarge(), + }, + label: style.Style{ + Color: common.TextAlt, + Font: common.FontSmall(), + PaddingTop: -5, + }, + } + } +} + +var styledOverviewCard = overviewCardStyle{ + card: style.Style{ + Debug: debugOverviewCards, + + Direction: style.DirectionHorizontal, + AlignItems: style.AlignItemsCenter, + JustifyContent: style.JustifyContentSpaceAround, + BackgroundColor: common.DefaultCardColor, + BorderRadiusTopLeft: common.BorderRadiusLG, + BorderRadiusTopRight: common.BorderRadiusLG, + BorderRadiusBottomLeft: common.BorderRadiusLG, + BorderRadiusBottomRight: common.BorderRadiusLG, + GrowHorizontal: true, + Gap: 10, + PaddingLeft: 20, + PaddingRight: 20, + PaddingTop: 20, + PaddingBottom: 20, + }, + column: style.Style{ + Debug: debugOverviewCards, + + Direction: style.DirectionVertical, + AlignItems: style.AlignItemsCenter, + JustifyContent: style.JustifyContentCenter, + GrowVertical: true, + Gap: 10, + }, +} + +// wrapped around special block text and icon +var styledOverviewSpecialBlockWrapper = style.Style{ + Debug: debugOverviewCards, + + Direction: style.DirectionVertical, + AlignItems: style.AlignItemsCenter, + JustifyContent: style.JustifyContentCenter, + Gap: 10, +} + +var styledCardsFrame = style.Style{ + Debug: false, + + Direction: style.DirectionVertical, + Gap: 10, + PaddingLeft: 20, + PaddingRight: 20, + PaddingTop: 20, + PaddingBottom: 20, +} diff --git a/internal/stats/render/period/v2/overview.go b/internal/stats/render/period/v2/overview.go new file mode 100644 index 00000000..ca3130c3 --- /dev/null +++ b/internal/stats/render/period/v2/overview.go @@ -0,0 +1,105 @@ +package period + +import ( + "github.com/cufee/aftermath/internal/render/common" + prepare "github.com/cufee/aftermath/internal/stats/prepare/common/v1" + "github.com/cufee/aftermath/internal/stats/prepare/period/v1" + "github.com/cufee/facepaint" + "github.com/cufee/facepaint/style" +) + +func newOverviewCard(data period.OverviewCard, columnWidth float64) *facepaint.Block { + if len(data.Blocks) == 0 { + return nil + } + + var columns []*facepaint.Block + for _, column := range data.Blocks { + columns = append(columns, newOverviewColumn(column, columnWidth)) + } + // card + return facepaint.NewBlocksContent(styledOverviewCard.card.Options(), columns...) +} + +func newOverviewColumn(data period.OverviewColumn, columnWidth float64) *facepaint.Block { + var columnBlocks []*facepaint.Block + for _, block := range data.Blocks { + switch block.Tag { + default: + columnBlocks = append(columnBlocks, newOverviewBlockWithIcon(block, nil)) + case prepare.TagWN8: + columnBlocks = append(columnBlocks, newOverviewWN8Block(block)) + case prepare.TagRankedRating: + columnBlocks = append(columnBlocks, newOverviewRatingBlock(block)) + } + } + // column + return facepaint.NewBlocksContent(style.NewStyle( + style.Parent(styledOverviewCard.column), + style.SetWidth(columnWidth), + ), columnBlocks...) +} + +func newOverviewBlockWithIcon(block prepare.StatsBlock[period.BlockData, string], icon *facepaint.Block) *facepaint.Block { + blockStyle := styledOverviewCard.styleBlock(block) + if icon == nil { + // block + return facepaint.NewBlocksContent(blockStyle.valueContainer.Options(), + // value + facepaint.MustNewTextContent(blockStyle.value.Options(), block.V.String()), + // label + facepaint.MustNewTextContent(blockStyle.label.Options(), block.Label), + ) + } + // wrapper + return facepaint.NewBlocksContent(blockStyle.wrapper.Options(), + icon, + // block + facepaint.NewBlocksContent(blockStyle.valueContainer.Options(), + // value + facepaint.MustNewTextContent(blockStyle.value.Options(), block.V.String()), + // label + facepaint.MustNewTextContent(blockStyle.label.Options(), block.Label), + )) +} + +func newOverviewWN8Block(block prepare.StatsBlock[period.BlockData, string]) *facepaint.Block { + ratingColors := common.GetWN8Colors(block.Value().Float()) + if block.Value().Float() <= 0 { + ratingColors.Background = common.TextAlt + } + icon, _ := facepaint.NewImageContent( + style.NewStyle(style.SetWidth(iconSizeWN8)), + common.AftermathLogo(ratingColors.Background, common.DefaultLogoOptions()), + ) + block.Label = common.GetWN8TierName(block.Value().Float()) + return newOverviewBlockWithIcon(block, icon) +} + +func newOverviewRatingBlock(block prepare.StatsBlock[period.BlockData, string]) *facepaint.Block { + icon, _ := common.GetRatingIcon(block.V, iconSizeRating) + block.Label = common.GetRatingTierName(block.Value().Float()) + return newOverviewBlockWithIcon(block, icon) +} + +// func newHighlightCard(style highlightStyle, card period.VehicleCard) common.Block { +// titleBlock := +// common.NewBlocksContent(common.Style{ +// Direction: common.DirectionVertical, +// }, +// common.NewTextContent(style.cardTitle, card.Meta), +// common.NewTextContent(style.tankName, card.Title), +// ) + +// var contentRow []common.Block +// for _, block := range card.Blocks { +// contentRow = append(contentRow, common.NewBlocksContent(common.Style{Direction: common.DirectionVertical, AlignItems: common.AlignItemsCenter}, +// common.NewTextContent(style.blockValue, block.Value().String()), +// common.NewTextContent(style.blockLabel, block.Label), +// )) +// } + +// return common.NewBlocksContent(style.container, titleBlock, common.NewBlocksContent(common.Style{ +// Gap: style.container.Gap, +// }, contentRow...)) +// } diff --git a/internal/stats/render/replay/v1/image.go b/internal/stats/render/replay/v1/image.go index 4de6a394..a7e87dbf 100644 --- a/internal/stats/render/replay/v1/image.go +++ b/internal/stats/render/replay/v1/image.go @@ -7,7 +7,8 @@ import ( "strconv" "time" - common "github.com/cufee/aftermath/internal/render/v1" + "github.com/cufee/aftermath/internal/render/common" + v1 "github.com/cufee/aftermath/internal/render/v1" "github.com/cufee/aftermath/internal/stats/fetch/v1" "github.com/cufee/aftermath/internal/stats/frame" "github.com/cufee/aftermath/internal/stats/prepare/replay/v1" @@ -63,7 +64,7 @@ func CardsToImage(replay fetch.Replay, cards replay.Cards, opts ...common.Option return segments.Render(func(op *common.Options) { op.Background = o.Background }) } -func CardsToSegments(replay fetch.Replay, cards replay.Cards, opts ...common.Option) (*common.Segments, error) { +func CardsToSegments(replay fetch.Replay, cards replay.Cards, opts ...common.Option) (*v1.Segments, error) { o := common.DefaultOptions() for _, apply := range opts { apply(&o) diff --git a/internal/stats/render/session/v1/cards.go b/internal/stats/render/session/v1/cards.go index 3106c93e..fed91648 100644 --- a/internal/stats/render/session/v1/cards.go +++ b/internal/stats/render/session/v1/cards.go @@ -5,14 +5,15 @@ import ( "strings" "github.com/cufee/aftermath/internal/database/models" - common "github.com/cufee/aftermath/internal/render/v1" + "github.com/cufee/aftermath/internal/render/common" + v1 "github.com/cufee/aftermath/internal/render/v1" "github.com/cufee/aftermath/internal/stats/fetch/v1" "github.com/cufee/aftermath/internal/stats/frame" prepare "github.com/cufee/aftermath/internal/stats/prepare/common/v1" "github.com/cufee/aftermath/internal/stats/prepare/session/v1" ) -func cardsToSegments(session, _ fetch.AccountStatsOverPeriod, cards session.Cards, subs []models.UserSubscription, opts common.Options) (common.Segments, error) { +func cardsToSegments(session, _ fetch.AccountStatsOverPeriod, cards session.Cards, subs []models.UserSubscription, opts common.Options) (v1.Segments, error) { var ( renderUnratedVehiclesCount = 3 // minimum number of vehicle cards // primary cards @@ -34,9 +35,9 @@ func cardsToSegments(session, _ fetch.AccountStatsOverPeriod, cards session.Card renderUnratedVehiclesCount += 1 } - var segments common.Segments - var primaryColumn []common.Block - var secondaryColumn []common.Block + var segments v1.Segments + var primaryColumn []v1.Block + var secondaryColumn []v1.Block // Calculate minimal card width to fit all the content overviewColumnSizes := make(map[string]float64) @@ -47,14 +48,14 @@ func cardsToSegments(session, _ fetch.AccountStatsOverPeriod, cards session.Card var secondaryCardWidth, totalFrameWidth float64 { - titleStyle := common.DefaultPlayerTitleStyle(session.Account.Nickname, playerNameCardStyle(0)) - clanSize := common.MeasureString(session.Account.ClanTag, titleStyle.ClanTag.Font) - nameSize := common.MeasureString(session.Account.Nickname, titleStyle.Nickname.Font) + titleStyle := v1.DefaultPlayerTitleStyle(session.Account.Nickname, playerNameCardStyle(0)) + clanSize := v1.MeasureString(session.Account.ClanTag, titleStyle.ClanTag.Font) + nameSize := v1.MeasureString(session.Account.Nickname, titleStyle.Nickname.Font) primaryCardWidth = max(primaryCardWidth, titleStyle.TotalPaddingAndGaps()+nameSize.TotalWidth+clanSize.TotalWidth*2) } { for _, text := range opts.PromoText { - size := common.MeasureString(text, promoTextStyle().Font) + size := v1.MeasureString(text, promoTextStyle().Font) totalFrameWidth = max(size.TotalWidth, totalFrameWidth) } } @@ -102,8 +103,8 @@ func cardsToSegments(session, _ fetch.AccountStatsOverPeriod, cards session.Card // highlighted vehicles go on the primary block if shouldRenderUnratedHighlights { for _, card := range cards.Unrated.Highlights { - labelSize := common.MeasureString(card.Meta, highlightCardTitleTextStyle().Font) - titleSize := common.MeasureString(card.Title, highlightVehicleNameTextStyle().Font) + labelSize := v1.MeasureString(card.Meta, highlightCardTitleTextStyle().Font) + titleSize := v1.MeasureString(card.Title, highlightVehicleNameTextStyle().Font) style := vehicleBlockStyle() presetBlockWidth, contentWidth := highlightedVehicleBlocksWidth(card.Blocks, style.session, style.career, style.label, highlightedVehicleBlockRowStyle(0)) @@ -126,7 +127,7 @@ func cardsToSegments(session, _ fetch.AccountStatsOverPeriod, cards session.Card presetBlockWidth, contentWidth := vehicleBlocksWidth(card.Blocks, styleWithIconOffset.session, styleWithIconOffset.career, styleWithIconOffset.label, vehicleBlocksRowStyle(0)) contentWidth += vehicleBlocksRowStyle(0).Gap*float64(len(card.Blocks)-1) + vehicleCardStyle(0).PaddingX*2 - titleSize := common.MeasureString(card.Title, vehicleCardTitleTextStyle().Font) + titleSize := v1.MeasureString(card.Title, vehicleCardTitleTextStyle().Font) secondaryCardWidth = max(secondaryCardWidth, contentWidth, titleSize.TotalWidth+vehicleCardStyle(0).PaddingX*2) for key, width := range presetBlockWidth { @@ -151,7 +152,7 @@ func cardsToSegments(session, _ fetch.AccountStatsOverPeriod, cards session.Card } if len(footer) > 0 { - segments.AddFooter(common.NewFooterCard(strings.Join(footer, " • "))) + segments.AddFooter(v1.NewFooterCard(strings.Join(footer, " • "))) } } @@ -162,13 +163,13 @@ func cardsToSegments(session, _ fetch.AccountStatsOverPeriod, cards session.Card totalFrameWidth = max(totalFrameWidth, frameWidth) // header card - if headerCard, headerCardExists := common.NewHeaderCard(totalFrameWidth, subs, opts.PromoText); headerCardExists { + if headerCard, headerCardExists := v1.NewHeaderCard(totalFrameWidth, subs, opts.PromoText); headerCardExists { segments.AddHeader(headerCard) } // player title primaryColumn = append(primaryColumn, - common.NewPlayerTitleCard(common.DefaultPlayerTitleStyle(session.Account.Nickname, playerNameCardStyle(primaryCardWidth)), session.Account.Nickname, session.Account.ClanTag, subs), + v1.NewPlayerTitleCard(v1.DefaultPlayerTitleStyle(session.Account.Nickname, playerNameCardStyle(primaryCardWidth)), session.Account.Nickname, session.Account.ClanTag, subs), ) // overview cards @@ -198,38 +199,38 @@ func cardsToSegments(session, _ fetch.AccountStatsOverPeriod, cards session.Card } } - columns := []common.Block{common.NewBlocksContent(overviewColumnStyle(primaryCardWidth), primaryColumn...)} + columns := []v1.Block{v1.NewBlocksContent(overviewColumnStyle(primaryCardWidth), primaryColumn...)} if len(secondaryColumn) > 0 { - columns = append(columns, common.NewBlocksContent(overviewColumnStyle(secondaryCardWidth), secondaryColumn...)) + columns = append(columns, v1.NewBlocksContent(overviewColumnStyle(secondaryCardWidth), secondaryColumn...)) } - segments.AddContent(common.NewBlocksContent(frameStyle(), columns...)) + segments.AddContent(v1.NewBlocksContent(frameStyle(), columns...)) return segments, nil } -func vehicleBlocksWidth(blocks []prepare.StatsBlock[session.BlockData, string], sessionStyle, careerStyle, labelStyle, containerStyle common.Style) (map[string]float64, float64) { +func vehicleBlocksWidth(blocks []prepare.StatsBlock[session.BlockData, string], sessionStyle, careerStyle, labelStyle, containerStyle v1.Style) (map[string]float64, float64) { presetBlockWidth := make(map[string]float64) var contentWidth float64 var maxBlockWidth float64 for _, block := range blocks { var width float64 { - size := common.MeasureString(block.Data.Session().String(), sessionStyle.Font) + size := v1.MeasureString(block.Data.Session().String(), sessionStyle.Font) width = max(width, size.TotalWidth+sessionStyle.PaddingX*2) } { - size := common.MeasureString(block.Data.Career().String(), careerStyle.Font) + size := v1.MeasureString(block.Data.Career().String(), careerStyle.Font) width = max(width, size.TotalWidth+careerStyle.PaddingX*2) } { - size := common.MeasureString(block.Label, labelStyle.Font) + size := v1.MeasureString(block.Label, labelStyle.Font) width = max(width, size.TotalWidth+labelStyle.PaddingX*2+vehicleLegendLabelContainer.PaddingX*2) } maxBlockWidth = max(maxBlockWidth, width) presetBlockWidth[block.Tag.String()] = max(presetBlockWidth[block.Tag.String()], width) } - if containerStyle.Direction == common.DirectionHorizontal { + if containerStyle.Direction == v1.DirectionHorizontal { for _, w := range presetBlockWidth { contentWidth += w } @@ -240,25 +241,25 @@ func vehicleBlocksWidth(blocks []prepare.StatsBlock[session.BlockData, string], return presetBlockWidth, contentWidth } -func highlightedVehicleBlocksWidth(blocks []prepare.StatsBlock[session.BlockData, string], sessionStyle, _, labelStyle, containerStyle common.Style) (map[string]float64, float64) { +func highlightedVehicleBlocksWidth(blocks []prepare.StatsBlock[session.BlockData, string], sessionStyle, _, labelStyle, containerStyle v1.Style) (map[string]float64, float64) { presetBlockWidth := make(map[string]float64) var contentWidth float64 var maxBlockWidth float64 for _, block := range blocks { var width float64 { - size := common.MeasureString(block.Data.Session().String(), sessionStyle.Font) + size := v1.MeasureString(block.Data.Session().String(), sessionStyle.Font) width = max(width, size.TotalWidth+sessionStyle.PaddingX*2) } { - size := common.MeasureString(block.Label, labelStyle.Font) + size := v1.MeasureString(block.Label, labelStyle.Font) width = max(width, size.TotalWidth+labelStyle.PaddingX*2) } maxBlockWidth = max(maxBlockWidth, width) presetBlockWidth[block.Tag.String()] = max(presetBlockWidth[block.Tag.String()], width) } - if containerStyle.Direction == common.DirectionHorizontal { + if containerStyle.Direction == v1.DirectionHorizontal { for _, w := range presetBlockWidth { contentWidth += w } @@ -269,18 +270,18 @@ func highlightedVehicleBlocksWidth(blocks []prepare.StatsBlock[session.BlockData return presetBlockWidth, contentWidth } -func overviewColumnBlocksWidth(blocks []prepare.StatsBlock[session.BlockData, string], sessionStyle, careerStyle, labelStyle, containerStyle common.Style) (map[string]float64, float64) { +func overviewColumnBlocksWidth(blocks []prepare.StatsBlock[session.BlockData, string], sessionStyle, careerStyle, labelStyle, containerStyle v1.Style) (map[string]float64, float64) { presetBlockWidth, contentWidth := vehicleBlocksWidth(blocks, sessionStyle, careerStyle, labelStyle, containerStyle) for _, block := range blocks { // adjust width if this column includes a special icon if block.Tag == prepare.TagWN8 { - tierNameSize := common.MeasureString(common.GetWN8TierName(block.Value().Float()), overviewSpecialRatingLabelStyle().Font) + tierNameSize := v1.MeasureString(v1.GetWN8TierName(block.Value().Float()), overviewSpecialRatingLabelStyle().Font) tierNameWithPadding := tierNameSize.TotalWidth + overviewSpecialRatingPillStyle().PaddingX*2 presetBlockWidth[block.Tag.String()] = max(presetBlockWidth[block.Tag.String()], specialWN8IconSize, tierNameWithPadding) contentWidth = max(contentWidth, tierNameWithPadding) } if block.Tag == prepare.TagRankedRating { - valueSize := common.MeasureString(common.GetRatingTierName(block.Value().Float()), overviewSpecialRatingLabelStyle().Font) + valueSize := v1.MeasureString(v1.GetRatingTierName(block.Value().Float()), overviewSpecialRatingLabelStyle().Font) tierNameWithPadding := valueSize.TotalWidth + overviewSpecialRatingPillStyle().PaddingX*2 presetBlockWidth[block.Tag.String()] = max(presetBlockWidth[block.Tag.String()], specialRatingIconSize, tierNameWithPadding) contentWidth = max(contentWidth, tierNameWithPadding) @@ -289,21 +290,21 @@ func overviewColumnBlocksWidth(blocks []prepare.StatsBlock[session.BlockData, st return presetBlockWidth, contentWidth } -func makeVehicleCard(vehicle session.VehicleCard, blockSizes map[string]float64, cardWidth float64) common.Block { +func makeVehicleCard(vehicle session.VehicleCard, blockSizes map[string]float64, cardWidth float64) v1.Block { var vehicleWN8 frame.Value = frame.InvalidValue - var content []common.Block + var content []v1.Block for _, block := range vehicle.Blocks { style := vehicleBlockStyle() - var blockContent []common.Block + var blockContent []v1.Block if blockShouldHaveCompareIcon(block) { - blockContent = append(blockContent, blockWithVehicleIcon(common.NewTextContent(style.session, block.Data.Session().String()), block.Data.Session(), block.Data.Career())) + blockContent = append(blockContent, blockWithVehicleIcon(v1.NewTextContent(style.session, block.Data.Session().String()), block.Data.Session(), block.Data.Career())) } else { - blockContent = append(blockContent, common.NewTextContent(style.session, block.Data.Session().String())) + blockContent = append(blockContent, v1.NewTextContent(style.session, block.Data.Session().String())) } containerStyle := statsBlockStyle(blockSizes[block.Tag.String()]) content = append(content, - common.NewBlocksContent(containerStyle, blockContent...), + v1.NewBlocksContent(containerStyle, blockContent...), ) if block.Tag == prepare.TagWN8 { @@ -313,81 +314,81 @@ func makeVehicleCard(vehicle session.VehicleCard, blockSizes map[string]float64, titleStyle := vehicleCardTitleContainerStyle(cardWidth) titleStyle.Width -= vehicleCardStyle(0).PaddingX * 2 - return common.NewBlocksContent(vehicleCardStyle(cardWidth), - common.NewBlocksContent(titleStyle, - common.NewTextContent(vehicleCardTitleTextStyle(), fmt.Sprintf("%s %s", vehicle.Meta, vehicle.Title)), // name and tier + return v1.NewBlocksContent(vehicleCardStyle(cardWidth), + v1.NewBlocksContent(titleStyle, + v1.NewTextContent(vehicleCardTitleTextStyle(), fmt.Sprintf("%s %s", vehicle.Meta, vehicle.Title)), // name and tier vehicleWN8Icon(vehicleWN8), ), - common.NewBlocksContent(vehicleBlocksRowStyle(0), content...), + v1.NewBlocksContent(vehicleBlocksRowStyle(0), content...), ) } -func makeVehicleHighlightCard(vehicle session.VehicleCard, blockSizes map[string]float64, cardWidth float64) common.Block { - var content []common.Block +func makeVehicleHighlightCard(vehicle session.VehicleCard, blockSizes map[string]float64, cardWidth float64) v1.Block { + var content []v1.Block style := vehicleBlockStyle() for _, block := range vehicle.Blocks { content = append(content, - common.NewBlocksContent(statsBlockStyle(blockSizes[block.Tag.String()]), - common.NewTextContent(style.session, block.Data.Session().String()), - common.NewTextContent(style.label, block.Label), + v1.NewBlocksContent(statsBlockStyle(blockSizes[block.Tag.String()]), + v1.NewTextContent(style.session, block.Data.Session().String()), + v1.NewTextContent(style.label, block.Label), ), ) } - return common.NewBlocksContent(highlightedVehicleCardStyle(cardWidth), - common.NewBlocksContent(common.Style{Direction: common.DirectionVertical}, - common.NewTextContent(highlightCardTitleTextStyle(), vehicle.Meta), - common.NewTextContent(highlightVehicleNameTextStyle(), vehicle.Title), + return v1.NewBlocksContent(highlightedVehicleCardStyle(cardWidth), + v1.NewBlocksContent(v1.Style{Direction: v1.DirectionVertical}, + v1.NewTextContent(highlightCardTitleTextStyle(), vehicle.Meta), + v1.NewTextContent(highlightVehicleNameTextStyle(), vehicle.Title), ), - common.NewBlocksContent(highlightedVehicleBlockRowStyle(0), content...), + v1.NewBlocksContent(highlightedVehicleBlockRowStyle(0), content...), ) } -func makeVehicleLegendCard(reference session.VehicleCard, blockSizes map[string]float64, cardWidth float64) common.Block { - var content []common.Block +func makeVehicleLegendCard(reference session.VehicleCard, blockSizes map[string]float64, cardWidth float64) v1.Block { + var content []v1.Block style := vehicleBlockStyle() for _, block := range reference.Blocks { - label := common.NewBlocksContent(vehicleLegendLabelContainer, common.NewTextContent(style.label, block.Label)) + label := v1.NewBlocksContent(vehicleLegendLabelContainer, v1.NewTextContent(style.label, block.Label)) if blockShouldHaveCompareIcon(block) { - label = blockWithVehicleIcon(common.NewBlocksContent(vehicleLegendLabelContainer, common.NewTextContent(style.label, block.Label)), frame.InvalidValue, frame.InvalidValue) + label = blockWithVehicleIcon(v1.NewBlocksContent(vehicleLegendLabelContainer, v1.NewTextContent(style.label, block.Label)), frame.InvalidValue, frame.InvalidValue) } containerStyle := statsBlockStyle(blockSizes[block.Tag.String()]) content = append(content, - common.NewBlocksContent(containerStyle, label), + v1.NewBlocksContent(containerStyle, label), ) } containerStyle := vehicleCardStyle(cardWidth) containerStyle.BackgroundColor = nil containerStyle.PaddingY = 0 containerStyle.PaddingX = 0 - return common.NewBlocksContent(containerStyle, common.NewBlocksContent(vehicleBlocksRowStyle(0), content...)) + return v1.NewBlocksContent(containerStyle, v1.NewBlocksContent(vehicleBlocksRowStyle(0), content...)) } -func makeOverviewCard(card session.OverviewCard, columnSizes map[string]float64, style common.Style) common.Block { +func makeOverviewCard(card session.OverviewCard, columnSizes map[string]float64, style v1.Style) v1.Block { // made all columns the same width for things to be centered - var content []common.Block // add a blank block to balance the offset added from icons + var content []v1.Block // add a blank block to balance the offset added from icons blockStyle := vehicleBlockStyle() for _, column := range card.Blocks { - var columnContent []common.Block + var columnContent []v1.Block for _, block := range column.Blocks { - var col common.Block + var col v1.Block blockWidth := columnSizes[string(column.Flavor)] // fit the block to column width to make things look even if block.Tag == prepare.TagWN8 || block.Tag == prepare.TagRankedRating { col = makeSpecialRatingColumn(block, blockWidth) } else if blockShouldHaveCompareIcon(block) { - col = common.NewBlocksContent(statsBlockStyle(blockWidth), - blockWithDoubleVehicleIcon(common.NewTextContent(blockStyle.session, block.Data.Session().String()), block.Data.Session(), block.Data.Career()), - common.NewTextContent(blockStyle.label, block.Label), + col = v1.NewBlocksContent(statsBlockStyle(blockWidth), + blockWithDoubleVehicleIcon(v1.NewTextContent(blockStyle.session, block.Data.Session().String()), block.Data.Session(), block.Data.Career()), + v1.NewTextContent(blockStyle.label, block.Label), ) } else { - col = common.NewBlocksContent(statsBlockStyle(blockWidth), - common.NewTextContent(blockStyle.session, block.Data.Session().String()), - common.NewTextContent(blockStyle.label, block.Label), + col = v1.NewBlocksContent(statsBlockStyle(blockWidth), + v1.NewTextContent(blockStyle.session, block.Data.Session().String()), + v1.NewTextContent(blockStyle.label, block.Label), ) } columnContent = append(columnContent, col) } - content = append(content, common.NewBlocksContent(overviewColumnStyle(0), columnContent...)) + content = append(content, v1.NewBlocksContent(overviewColumnStyle(0), columnContent...)) } - return common.NewBlocksContent(style, content...) + return v1.NewBlocksContent(style, content...) } diff --git a/internal/stats/render/session/v1/image.go b/internal/stats/render/session/v1/image.go index a0123f43..7582d9af 100644 --- a/internal/stats/render/session/v1/image.go +++ b/internal/stats/render/session/v1/image.go @@ -8,7 +8,8 @@ import ( "time" "github.com/cufee/aftermath/internal/database/models" - common "github.com/cufee/aftermath/internal/render/v1" + "github.com/cufee/aftermath/internal/render/common" + v1 "github.com/cufee/aftermath/internal/render/v1" "github.com/cufee/aftermath/internal/stats/fetch/v1" "github.com/cufee/aftermath/internal/stats/frame" "github.com/cufee/aftermath/internal/stats/prepare/session/v1" @@ -69,7 +70,7 @@ func CardsToImage(session, career fetch.AccountStatsOverPeriod, cards session.Ca return segments.Render(func(opt *common.Options) { opt.Background = o.Background }) } -func CardsToSegments(session, career fetch.AccountStatsOverPeriod, cards session.Cards, subs []models.UserSubscription, opts ...common.Option) (*common.Segments, error) { +func CardsToSegments(session, career fetch.AccountStatsOverPeriod, cards session.Cards, subs []models.UserSubscription, opts ...common.Option) (*v1.Segments, error) { o := common.DefaultOptions() for _, apply := range opts { apply(&o) diff --git a/main.go b/main.go index 082a08d2..a6daac80 100644 --- a/main.go +++ b/main.go @@ -46,6 +46,7 @@ import ( "github.com/cufee/aftermath/internal/log" "github.com/cufee/aftermath/internal/render/assets" + "github.com/cufee/aftermath/internal/render/common" render "github.com/cufee/aftermath/internal/render/v1" _ "github.com/joho/godotenv/autoload" @@ -389,6 +390,10 @@ func loadStaticAssets(static fs.FS) { if err != nil { log.Fatal().Msgf("render#InitLoadedAssets failed %s", err) } + err = common.InitLoadedAssets() + if err != nil { + log.Fatal().Msgf("common#InitLoadedAssets failed %s", err) + } err = localization.LoadAssets(static, "static/localization") if err != nil { log.Fatal().Msgf("localization#LoadAssets failed %s", err) diff --git a/render_test.go b/render_test.go index fedd364d..8b1523a4 100644 --- a/render_test.go +++ b/render_test.go @@ -10,7 +10,8 @@ import ( "time" "github.com/cufee/aftermath/internal/localization" - rc "github.com/cufee/aftermath/internal/render/v1" + rc "github.com/cufee/aftermath/internal/render/common" + options "github.com/cufee/aftermath/internal/stats/client/common" client "github.com/cufee/aftermath/internal/stats/client/v1" "github.com/cufee/aftermath/internal/stats/fetch/v1" "github.com/cufee/aftermath/internal/stats/prepare/common/v1" @@ -45,7 +46,7 @@ func TestStressRenderSession(t *testing.T) { var group errgroup.Group for range 100 { group.Go(func() error { - _, _, err := stats.SessionImage(context.Background(), tests.DefaultAccountNA, time.Now(), client.WithBackgroundURL(bgImage, bgIsCustom), client.WithWN8()) + _, _, err := stats.SessionImage(context.Background(), tests.DefaultAccountNA, time.Now(), options.WithBackgroundURL(bgImage, bgIsCustom), options.WithWN8()) return err }) } @@ -58,7 +59,7 @@ func TestRenderSession(t *testing.T) { stats := client.NewClient(tests.StaticTestingFetch(), tests.StaticTestingDatabase(), nil, language.English) t.Run("generate content mask before generating image", func(t *testing.T) { - cards, meta, err := stats.SessionCards(context.Background(), tests.DefaultAccountNAShort, time.Now(), client.WithWN8()) + cards, meta, err := stats.SessionCards(context.Background(), tests.DefaultAccountNAShort, time.Now(), options.WithWN8()) assert.NoError(t, err, "failed to generate session cards") segments, err := session.CardsToSegments(meta.Stats["session"], meta.Stats["career"], cards, nil) @@ -80,7 +81,7 @@ func TestRenderSession(t *testing.T) { }) t.Run("render session image for small nickname", func(t *testing.T) { - image, _, err := stats.SessionImage(context.Background(), tests.DefaultAccountNAShort, time.Now(), client.WithWN8()) + image, _, err := stats.SessionImage(context.Background(), tests.DefaultAccountNAShort, time.Now(), options.WithWN8()) assert.NoError(t, err, "failed to render a session image") assert.NotNil(t, image, "image is nil") @@ -93,7 +94,7 @@ func TestRenderSession(t *testing.T) { }) t.Run("render session image for large nickname", func(t *testing.T) { - image, _, err := stats.SessionImage(context.Background(), tests.DefaultAccountNA, time.Now(), client.WithBackgroundURL(bgImage, bgIsCustom), client.WithWN8()) + image, _, err := stats.SessionImage(context.Background(), tests.DefaultAccountNA, time.Now(), options.WithBackgroundURL(bgImage, bgIsCustom), options.WithWN8()) assert.NoError(t, err, "failed to render a session image") assert.NotNil(t, image, "image is nil") @@ -106,7 +107,7 @@ func TestRenderSession(t *testing.T) { }) t.Run("render session image for large nickname and no vehicles", func(t *testing.T) { - image, _, err := stats.SessionImage(context.Background(), tests.DefaultAccountNA, time.Now(), client.WithBackgroundURL(bgImage, bgIsCustom), client.WithVehicleID("0"), client.WithWN8()) + image, _, err := stats.SessionImage(context.Background(), tests.DefaultAccountNA, time.Now(), options.WithBackgroundURL(bgImage, bgIsCustom), options.WithVehicleID("0"), options.WithWN8()) assert.NoError(t, err, "failed to render a session image") assert.NotNil(t, image, "image is nil") @@ -124,7 +125,7 @@ func TestRenderPeriod(t *testing.T) { stats := client.NewClient(tests.StaticTestingFetch(), tests.StaticTestingDatabase(), nil, language.English) t.Run("render period image for small nickname", func(t *testing.T) { - image, _, err := stats.PeriodImage(context.Background(), tests.DefaultAccountNAShort, time.Now(), client.WithWN8()) + image, _, err := stats.PeriodImage(context.Background(), tests.DefaultAccountNAShort, time.Now(), options.WithWN8()) assert.NoError(t, err, "failed to render a period image") assert.NotNil(t, image, "image is nil") @@ -137,7 +138,7 @@ func TestRenderPeriod(t *testing.T) { }) t.Run("render period image for large nickname", func(t *testing.T) { - image, _, err := stats.PeriodImage(context.Background(), tests.DefaultAccountNA, time.Now(), client.WithBackgroundURL(bgImage, bgIsCustom), client.WithWN8()) + image, _, err := stats.PeriodImage(context.Background(), tests.DefaultAccountNA, time.Now(), options.WithBackgroundURL(bgImage, bgIsCustom), options.WithWN8()) assert.NoError(t, err, "failed to render a session image") assert.NotNil(t, image, "image is nil") @@ -150,7 +151,7 @@ func TestRenderPeriod(t *testing.T) { }) t.Run("render period image with large name no highlights", func(t *testing.T) { - image, _, err := stats.PeriodImage(context.Background(), tests.DefaultAccountNA, time.Now(), client.WithBackgroundURL(bgImage, bgIsCustom), client.WithVehicleID("0"), client.WithWN8()) + image, _, err := stats.PeriodImage(context.Background(), tests.DefaultAccountNA, time.Now(), options.WithBackgroundURL(bgImage, bgIsCustom), options.WithVehicleID("0"), options.WithWN8()) assert.NoError(t, err, "failed to render a session image") assert.NotNil(t, image, "image is nil") @@ -163,7 +164,7 @@ func TestRenderPeriod(t *testing.T) { }) t.Run("render period image with small name and no highlights", func(t *testing.T) { - image, _, err := stats.PeriodImage(context.Background(), tests.DefaultAccountNAShort, time.Now(), client.WithBackgroundURL(bgImage, bgIsCustom), client.WithVehicleID("0"), client.WithWN8()) + image, _, err := stats.PeriodImage(context.Background(), tests.DefaultAccountNAShort, time.Now(), options.WithBackgroundURL(bgImage, bgIsCustom), options.WithVehicleID("0"), options.WithWN8()) assert.NoError(t, err, "failed to render a session image") assert.NotNil(t, image, "image is nil") diff --git a/render_v2_test.go b/render_v2_test.go new file mode 100644 index 00000000..f480fdae --- /dev/null +++ b/render_v2_test.go @@ -0,0 +1,74 @@ +package main + +import ( + "context" + "os" + "testing" + "time" + + common "github.com/cufee/aftermath/internal/stats/client/common" + client "github.com/cufee/aftermath/internal/stats/client/v2" + "github.com/cufee/aftermath/tests" + "github.com/cufee/aftermath/tests/env" + "github.com/stretchr/testify/assert" + "golang.org/x/text/language" + + _ "github.com/joho/godotenv/autoload" +) + +func TestRenderPeriodV2(t *testing.T) { + env.LoadTestEnv(t) + stats := client.NewClient(tests.StaticTestingFetch(), tests.StaticTestingDatabase(), nil, language.English) + + t.Run("render period image for small nickname", func(t *testing.T) { + image, _, err := stats.PeriodImage(context.Background(), tests.DefaultAccountNAShort, time.Now(), common.WithWN8()) + assert.NoError(t, err, "failed to render a period image") + assert.NotNil(t, image, "image is nil") + + f, err := os.Create("tmp/render_test_period_v2_full_small.png") + assert.NoError(t, err, "failed to create a file") + defer f.Close() + + err = image.PNG(f) + assert.NoError(t, err, "failed to encode a png image") + }) + + t.Run("render period image for large nickname", func(t *testing.T) { + image, _, err := stats.PeriodImage(context.Background(), tests.DefaultAccountNA, time.Now(), common.WithBackgroundURL(bgImage, bgIsCustom), common.WithWN8()) + assert.NoError(t, err, "failed to render a session image") + assert.NotNil(t, image, "image is nil") + + f, err := os.Create("tmp/render_test_period_v2_full_large.png") + assert.NoError(t, err, "failed to create a file") + defer f.Close() + + err = image.PNG(f) + assert.NoError(t, err, "failed to encode a png image") + }) + + t.Run("render period image with large name no highlights", func(t *testing.T) { + image, _, err := stats.PeriodImage(context.Background(), tests.DefaultAccountNA, time.Now(), common.WithBackgroundURL(bgImage, bgIsCustom), common.WithVehicleID("0"), common.WithWN8()) + assert.NoError(t, err, "failed to render a session image") + assert.NotNil(t, image, "image is nil") + + f, err := os.Create("tmp/render_test_period_v2_single_large.png") + assert.NoError(t, err, "failed to create a file") + defer f.Close() + + err = image.PNG(f) + assert.NoError(t, err, "failed to encode a png image") + }) + + t.Run("render period image with small name and no highlights", func(t *testing.T) { + image, _, err := stats.PeriodImage(context.Background(), tests.DefaultAccountNAShort, time.Now(), common.WithBackgroundURL(bgImage, bgIsCustom), common.WithVehicleID("0"), common.WithWN8()) + assert.NoError(t, err, "failed to render a session image") + assert.NotNil(t, image, "image is nil") + + f, err := os.Create("tmp/render_test_period_v2_single_small.png") + assert.NoError(t, err, "failed to create a file") + defer f.Close() + + err = image.PNG(f) + assert.NoError(t, err, "failed to encode a png image") + }) +} From 11f7ba15fbfb83be9778e0c3ba78584678f4665f Mon Sep 17 00:00:00 2001 From: Vovko Date: Sat, 18 Jan 2025 19:48:13 -0500 Subject: [PATCH 25/39] added player name --- go.mod | 2 +- internal/render/common/border-radius.go | 11 +- internal/render/common/font.go | 2 +- internal/stats/render/period/v2/cards.go | 67 ++++++---- .../stats/render/period/v2/highlight-style.go | 1 + internal/stats/render/period/v2/highlight.go | 1 + internal/stats/render/period/v2/image.go | 27 +--- internal/stats/render/period/v2/misc-style.go | 101 ++++++++++++++ internal/stats/render/period/v2/misc.go | 37 ++++++ .../stats/render/period/v2/overview-style.go | 123 +++++++++++------- internal/stats/render/period/v2/overview.go | 40 ++++-- 11 files changed, 293 insertions(+), 119 deletions(-) create mode 100644 internal/stats/render/period/v2/highlight-style.go create mode 100644 internal/stats/render/period/v2/highlight.go create mode 100644 internal/stats/render/period/v2/misc-style.go create mode 100644 internal/stats/render/period/v2/misc.go diff --git a/go.mod b/go.mod index a845350a..0d77f952 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( github.com/bwmarrin/discordgo v0.28.1 github.com/cufee/aftermath-assets v0.1.0 github.com/cufee/am-wg-proxy-next/v2 v2.2.6 - github.com/cufee/facepaint v0.0.2 + github.com/cufee/facepaint v0.0.3 github.com/fogleman/gg v1.3.0 github.com/go-co-op/gocron v1.37.0 github.com/goccy/go-json v0.10.4 diff --git a/internal/render/common/border-radius.go b/internal/render/common/border-radius.go index 2a47ed54..6a26d29c 100644 --- a/internal/render/common/border-radius.go +++ b/internal/render/common/border-radius.go @@ -1,9 +1,10 @@ package common var ( - BorderRadiusXL = 30.0 - BorderRadiusLG = 25.0 - BorderRadiusMD = 20.0 - BorderRadiusSM = 15.0 - BorderRadiusXS = 10.0 + BorderRadius2XL = 42.5 + BorderRadiusXL = 30.0 + BorderRadiusLG = 25.0 + BorderRadiusMD = 20.0 + BorderRadiusSM = 15.0 + BorderRadiusXS = 10.0 ) diff --git a/internal/render/common/font.go b/internal/render/common/font.go index 84d5fc7d..5b746d61 100644 --- a/internal/render/common/font.go +++ b/internal/render/common/font.go @@ -23,7 +23,7 @@ func FontSmall() style.Font { func getFont(size float64) style.Font { if fonts[size] == nil { - fonts[size] = style.NewFont(defaultFont, size) + fonts[size], _ = style.NewFont(defaultFont, size) } return fonts[size] } diff --git a/internal/stats/render/period/v2/cards.go b/internal/stats/render/period/v2/cards.go index 4b6275d3..3d57661a 100644 --- a/internal/stats/render/period/v2/cards.go +++ b/internal/stats/render/period/v2/cards.go @@ -2,9 +2,11 @@ package period import ( "errors" + "strconv" prepare "github.com/cufee/aftermath/internal/stats/prepare/common/v1" "github.com/cufee/facepaint/style" + "github.com/nao1215/imaging" "github.com/cufee/aftermath/internal/database/models" "github.com/cufee/aftermath/internal/log" @@ -22,19 +24,26 @@ func generateCards(stats fetch.AccountStatsOverPeriod, cards period.Cards, subs // calculate max overview block width to make all blocks the same size var maxWidthOverviewBlock float64 - for _, column := range append(cards.Overview.Blocks, cards.Rating.Blocks...) { + for _, column := range cards.Overview.Blocks { for _, block := range column.Blocks { switch block.Tag { case prepare.TagWN8: block.Label = common.GetWN8TierName(block.Value().Float()) maxWidthOverviewBlock = max(maxWidthOverviewBlock, iconSizeWN8) - + } + maxWidthOverviewBlock = max(maxWidthOverviewBlock, facepaint.MeasureString(block.Label, styledUnratedOverviewCard.styleBlock(block).label.Font).TotalWidth) + maxWidthOverviewBlock = max(maxWidthOverviewBlock, facepaint.MeasureString(block.Value().String(), styledUnratedOverviewCard.styleBlock(block).value.Font).TotalWidth) + } + } + for _, column := range cards.Rating.Blocks { + for _, block := range column.Blocks { + switch block.Tag { case prepare.TagRankedRating: block.Label = common.GetRatingTierName(block.Value().Float()) maxWidthOverviewBlock = max(maxWidthOverviewBlock, iconSizeRating) } - maxWidthOverviewBlock = max(maxWidthOverviewBlock, facepaint.MeasureString(block.Label, styledOverviewCard.styleBlock(block).label.Font).TotalWidth) - maxWidthOverviewBlock = max(maxWidthOverviewBlock, facepaint.MeasureString(block.Value().String(), styledOverviewCard.styleBlock(block).value.Font).TotalWidth) + maxWidthOverviewBlock = max(maxWidthOverviewBlock, facepaint.MeasureString(block.Label, styledRatingOverviewCard.styleBlock(block).label.Font).TotalWidth) + maxWidthOverviewBlock = max(maxWidthOverviewBlock, facepaint.MeasureString(block.Value().String(), styledRatingOverviewCard.styleBlock(block).value.Font).TotalWidth) } } @@ -61,7 +70,9 @@ func generateCards(stats fetch.AccountStatsOverPeriod, cards period.Cards, subs // } // } - var finalCards []*facepaint.Block + var statsCards []*facepaint.Block + + statsCards = append(statsCards, newPlayerNameCard(stats.Account)) // // We first render a footer in order to calculate the minimum required width // { @@ -95,25 +106,12 @@ func generateCards(stats fetch.AccountStatsOverPeriod, cards period.Cards, subs // // Player Title card // segments.AddContent(common.NewPlayerTitleCard(common.DefaultPlayerTitleStyle(stats.Account.Nickname, titleCardStyle(cardWidth)), stats.Account.Nickname, stats.Account.ClanTag, subs)) - // unrated overview card - if card := newOverviewCard(cards.Overview, maxWidthOverviewBlock); card != nil { - finalCards = append(finalCards, card) + if card := newUnratedOverviewCard(cards.Overview, maxWidthOverviewBlock); card != nil { + statsCards = append(statsCards, card) + } + if card := newRatingOverviewCard(cards.Rating, maxWidthOverviewBlock); card != nil { + statsCards = append(statsCards, card) } - - // // Rating Card -- only when player has current season rating - // if cards.Rating.Meta { - // var ratingStatsBlocks []common.Block - // for _, column := range cards.Rating.Blocks { - // columnBlock, err := statsBlocksToColumnBlock(getOverviewStyle(overviewColumnWidth), column.Blocks) - // if err != nil { - // return segments, err - // } - // ratingStatsBlocks = append(ratingStatsBlocks, columnBlock) - // } - // var ratingCardBlocks []common.Block - // ratingCardBlocks = append(ratingCardBlocks, common.NewBlocksContent(overviewCardBlocksStyle(cardWidth), ratingStatsBlocks...)) - // segments.AddContent(common.NewBlocksContent(overviewCardStyle(), ratingCardBlocks...)) - // } // // Highlights // for i, card := range cards.Highlights { @@ -123,10 +121,29 @@ func generateCards(stats fetch.AccountStatsOverPeriod, cards period.Cards, subs // segments.AddContent(newHighlightCard(highlightCardStyle(defaultCardStyle(cardWidth)), card)) // } - if len(finalCards) == 0 { + if len(statsCards) == 0 { return nil, errors.New("no cards to render") } - return facepaint.NewBlocksContent(style.NewStyle(style.Parent(styledCardsFrame)), finalCards...), nil + cardsFrame := facepaint.NewBlocksContent(style.NewStyle(style.Parent(styledCardsFrame)), statsCards...) + + // add background branding + if opts.Background != nil && !opts.BackgroundIsCustom { + cardsFrameSize := cardsFrame.Dimensions() + seed, _ := strconv.Atoi(stats.Account.ID) + opts.Background = imaging.Resize(opts.Background, cardsFrameSize.Width, cardsFrameSize.Height, imaging.Lanczos) + opts.Background = addBackgroundBranding(opts.Background, stats.RegularBattles.Vehicles, seed) + } + // add background + if opts.Background != nil { + cardsFrame = facepaint.NewBlocksContent(style.NewStyle(), + facepaint.MustNewImageContent(styledCardsBackground, opts.Background), cardsFrame, + ) + } + + var frameCards []*facepaint.Block + frameCards = append(frameCards, cardsFrame) + + return facepaint.NewBlocksContent(style.NewStyle(style.Parent(styledFinalFrame)), frameCards...), nil } diff --git a/internal/stats/render/period/v2/highlight-style.go b/internal/stats/render/period/v2/highlight-style.go new file mode 100644 index 00000000..035a18a4 --- /dev/null +++ b/internal/stats/render/period/v2/highlight-style.go @@ -0,0 +1 @@ +package period diff --git a/internal/stats/render/period/v2/highlight.go b/internal/stats/render/period/v2/highlight.go new file mode 100644 index 00000000..035a18a4 --- /dev/null +++ b/internal/stats/render/period/v2/highlight.go @@ -0,0 +1 @@ +package period diff --git a/internal/stats/render/period/v2/image.go b/internal/stats/render/period/v2/image.go index a036bb64..0ee41a61 100644 --- a/internal/stats/render/period/v2/image.go +++ b/internal/stats/render/period/v2/image.go @@ -2,15 +2,12 @@ package period import ( "image" - "strconv" "github.com/cufee/aftermath/internal/database/models" "github.com/cufee/aftermath/internal/render/common" "github.com/cufee/aftermath/internal/stats/fetch/v1" "github.com/cufee/aftermath/internal/stats/frame" "github.com/cufee/aftermath/internal/stats/prepare/period/v1" - "github.com/cufee/facepaint" - "github.com/cufee/facepaint/style" ) type vehicleWN8 struct { @@ -31,27 +28,7 @@ func CardsToImage(stats fetch.AccountStatsOverPeriod, cards period.Cards, subs [ return nil, err } - if o.Background == nil { - return cardsBlock.Render() - } - - if !o.BackgroundIsCustom { - seed, _ := strconv.Atoi(stats.Account.ID) - o.Background = addBackgroundBranding(o.Background, stats.RegularBattles.Vehicles, seed) - } - - contentSize := cardsBlock.Dimensions() - withBackground := facepaint.NewBlocksContent(style.NewStyle(), - facepaint.MustNewImageContent( - style.NewStyle( - style.SetWidth(float64(contentSize.Width)), - style.SetHeight(float64(contentSize.Height)), - style.SetBlur(common.DefaultBackgroundBlur), - style.SetPosition(style.PositionAbsolute), - style.SetZIndex(-99), - ), o.Background), - cardsBlock, - ) - return withBackground.Render() + // Render + return cardsBlock.Render() } diff --git a/internal/stats/render/period/v2/misc-style.go b/internal/stats/render/period/v2/misc-style.go new file mode 100644 index 00000000..f2c68202 --- /dev/null +++ b/internal/stats/render/period/v2/misc-style.go @@ -0,0 +1,101 @@ +package period + +import ( + "image/color" + + "github.com/cufee/aftermath/internal/render/common" + "github.com/cufee/facepaint/style" +) + +var ( + clanTagBackgroundColor = color.NRGBA{60, 60, 60, 100} +) + +func styledPlayerName() style.Style { + return style.Style{ + Color: common.TextPrimary, + Font: common.FontMedium(), + } +} + +func styledPlayerClanTag() style.Style { + return style.Style{ + Color: common.TextSecondary, + Font: common.FontSmall(), + } +} + +var styledPlayerNameWrapper = style.Style{ + Direction: style.DirectionHorizontal, + AlignItems: style.AlignItemsCenter, + + BackgroundColor: common.DefaultCardColor, + + BorderRadiusTopLeft: common.BorderRadiusLG, + BorderRadiusTopRight: common.BorderRadiusLG, + BorderRadiusBottomLeft: common.BorderRadiusLG, + BorderRadiusBottomRight: common.BorderRadiusLG, + + PaddingLeft: 5, + PaddingRight: 5, + PaddingTop: 5, + PaddingBottom: 5, + + GrowHorizontal: true, + Gap: 20, +} + +var styledPlayerNameCard = style.Style{ + Direction: style.DirectionHorizontal, + AlignItems: style.AlignItemsCenter, + JustifyContent: style.JustifyContentSpaceAround, + + GrowHorizontal: true, + // GrowVertical: true, +} + +var styledPlayerClanTagCard = style.Style{ + Direction: style.DirectionHorizontal, + AlignItems: style.AlignItemsCenter, + JustifyContent: style.JustifyContentSpaceAround, + + BackgroundColor: clanTagBackgroundColor, + + BorderRadiusTopLeft: common.BorderRadiusMD, + BorderRadiusTopRight: common.BorderRadiusMD, + BorderRadiusBottomLeft: common.BorderRadiusMD, + BorderRadiusBottomRight: common.BorderRadiusMD, + + PaddingLeft: 12, + PaddingRight: 12, + PaddingTop: 10, + PaddingBottom: 10, +} + +var styledCardsFrame = style.Style{ + Debug: false, + + Direction: style.DirectionVertical, + AlignItems: style.AlignItemsCenter, + Gap: 10, + + PaddingLeft: 20, + PaddingRight: 20, + PaddingTop: 20, + PaddingBottom: 20, +} + +var styledFinalFrame = style.Style{ + Debug: false, + + Direction: style.DirectionVertical, + AlignItems: style.AlignItemsCenter, + Gap: 5, +} + +var styledCardsBackground = style.NewStyle( + style.SetBorderRadius(common.BorderRadius2XL), + style.SetBlur(common.DefaultBackgroundBlur), + style.SetPosition(style.PositionAbsolute), + style.SetZIndex(-99), +) diff --git a/internal/stats/render/period/v2/misc.go b/internal/stats/render/period/v2/misc.go new file mode 100644 index 00000000..6b2a14dc --- /dev/null +++ b/internal/stats/render/period/v2/misc.go @@ -0,0 +1,37 @@ +package period + +import ( + "github.com/cufee/aftermath/internal/database/models" + "github.com/cufee/facepaint" + "github.com/cufee/facepaint/style" +) + +func newPlayerNameCard(account models.Account) *facepaint.Block { + var blocks []*facepaint.Block + + // clan tag + var clanTagBlock *facepaint.Block + if account.ClanTag != "" { + stl := styledPlayerClanTag() + clanTagBlock = facepaint.NewBlocksContent(styledPlayerClanTagCard.Options(), facepaint.MustNewTextContent(stl.Options(), account.ClanTag)) + blocks = append(blocks, clanTagBlock) + } + + // nickname + stl := styledPlayerName() + blocks = append(blocks, facepaint.NewBlocksContent(styledPlayerNameCard.Options(), + facepaint.MustNewTextContent(stl.Options(), account.Nickname), + )) + + // spacer + if clanTagBlock != nil { + size := clanTagBlock.Dimensions() + stl := style.Style{ + Width: float64(size.Width), + Height: 1, + } + blocks = append(blocks, facepaint.NewEmptyContent(stl.Options())) + } + + return facepaint.NewBlocksContent(styledPlayerNameWrapper.Options(), blocks...) +} diff --git a/internal/stats/render/period/v2/overview-style.go b/internal/stats/render/period/v2/overview-style.go index 3b229784..fd29fcfa 100644 --- a/internal/stats/render/period/v2/overview-style.go +++ b/internal/stats/render/period/v2/overview-style.go @@ -11,7 +11,7 @@ const ( debugOverviewCards = false iconSizeWN8 = 54.0 - iconSizeRating = 60.0 + iconSizeRating = 54.0 ) type blockStyle struct { @@ -22,11 +22,77 @@ type blockStyle struct { } type overviewCardStyle struct { - card style.Style - column style.Style + card style.Style + column style.Style + styleBlock func(block prepare.StatsBlock[period.BlockData, string]) blockStyle } -func (s *overviewCardStyle) styleBlock(block prepare.StatsBlock[period.BlockData, string]) blockStyle { +// rating + +var styledRatingOverviewCard = overviewCardStyle{ + styleBlock: styleRatingOverviewBlock, + card: styledUnratedOverviewCard.card, + column: style.Style{ + Debug: debugOverviewCards, + + Direction: style.DirectionVertical, + AlignItems: style.AlignItemsCenter, + JustifyContent: style.JustifyContentCenter, + GrowVertical: false, + Gap: 10, + }, +} + +func styleRatingOverviewBlock(block prepare.StatsBlock[period.BlockData, string]) blockStyle { + stl := styleUnratedOverviewBlock(block) + if block.Data.Flavor != period.BlockFlavorSpecial { + return stl + } + stl.wrapper = style.Style{ + Debug: debugOverviewCards, + + Direction: style.DirectionVertical, + AlignItems: style.AlignItemsCenter, + JustifyContent: style.JustifyContentCenter, + Gap: 10, + } + return stl +} + +// unrated + +var styledUnratedOverviewCard = overviewCardStyle{ + styleBlock: styleUnratedOverviewBlock, + card: style.Style{ + Debug: debugOverviewCards, + + Direction: style.DirectionHorizontal, + AlignItems: style.AlignItemsCenter, + JustifyContent: style.JustifyContentSpaceAround, + BackgroundColor: common.DefaultCardColor, + BorderRadiusTopLeft: common.BorderRadiusLG, + BorderRadiusTopRight: common.BorderRadiusLG, + BorderRadiusBottomLeft: common.BorderRadiusLG, + BorderRadiusBottomRight: common.BorderRadiusLG, + GrowHorizontal: true, + Gap: 10, + PaddingLeft: 20, + PaddingRight: 20, + PaddingTop: 20, + PaddingBottom: 20, + }, + column: style.Style{ + Debug: debugOverviewCards, + + Direction: style.DirectionVertical, + AlignItems: style.AlignItemsCenter, + JustifyContent: style.JustifyContentCenter, + GrowVertical: true, + Gap: 10, + }, +} + +func styleUnratedOverviewBlock(block prepare.StatsBlock[period.BlockData, string]) blockStyle { switch block.Data.Flavor { case period.BlockFlavorSpecial: return blockStyle{ @@ -37,7 +103,7 @@ func (s *overviewCardStyle) styleBlock(block prepare.StatsBlock[period.BlockData AlignItems: style.AlignItemsCenter, JustifyContent: style.JustifyContentSpaceAround, GrowVertical: true, - Gap: 10, + Gap: 5, }, valueContainer: style.Style{ Debug: debugOverviewCards, @@ -46,12 +112,14 @@ func (s *overviewCardStyle) styleBlock(block prepare.StatsBlock[period.BlockData AlignItems: style.AlignItemsCenter, JustifyContent: style.JustifyContentEnd, // GrowVertical: true, + Gap: 5, }, value: style.Style{ Debug: debugOverviewCards, - Color: common.TextPrimary, - Font: common.FontXL(), + PaddingTop: -6, + Color: common.TextPrimary, + Font: common.FontXL(), }, label: style.Style{ Color: common.TextAlt, @@ -106,36 +174,6 @@ func (s *overviewCardStyle) styleBlock(block prepare.StatsBlock[period.BlockData } } -var styledOverviewCard = overviewCardStyle{ - card: style.Style{ - Debug: debugOverviewCards, - - Direction: style.DirectionHorizontal, - AlignItems: style.AlignItemsCenter, - JustifyContent: style.JustifyContentSpaceAround, - BackgroundColor: common.DefaultCardColor, - BorderRadiusTopLeft: common.BorderRadiusLG, - BorderRadiusTopRight: common.BorderRadiusLG, - BorderRadiusBottomLeft: common.BorderRadiusLG, - BorderRadiusBottomRight: common.BorderRadiusLG, - GrowHorizontal: true, - Gap: 10, - PaddingLeft: 20, - PaddingRight: 20, - PaddingTop: 20, - PaddingBottom: 20, - }, - column: style.Style{ - Debug: debugOverviewCards, - - Direction: style.DirectionVertical, - AlignItems: style.AlignItemsCenter, - JustifyContent: style.JustifyContentCenter, - GrowVertical: true, - Gap: 10, - }, -} - // wrapped around special block text and icon var styledOverviewSpecialBlockWrapper = style.Style{ Debug: debugOverviewCards, @@ -145,14 +183,3 @@ var styledOverviewSpecialBlockWrapper = style.Style{ JustifyContent: style.JustifyContentCenter, Gap: 10, } - -var styledCardsFrame = style.Style{ - Debug: false, - - Direction: style.DirectionVertical, - Gap: 10, - PaddingLeft: 20, - PaddingRight: 20, - PaddingTop: 20, - PaddingBottom: 20, -} diff --git a/internal/stats/render/period/v2/overview.go b/internal/stats/render/period/v2/overview.go index ca3130c3..b12b4d87 100644 --- a/internal/stats/render/period/v2/overview.go +++ b/internal/stats/render/period/v2/overview.go @@ -8,40 +8,52 @@ import ( "github.com/cufee/facepaint/style" ) -func newOverviewCard(data period.OverviewCard, columnWidth float64) *facepaint.Block { +func newRatingOverviewCard(data period.RatingOverviewCard, columnWidth float64) *facepaint.Block { if len(data.Blocks) == 0 { return nil } var columns []*facepaint.Block for _, column := range data.Blocks { - columns = append(columns, newOverviewColumn(column, columnWidth)) + columns = append(columns, newOverviewColumn(styledRatingOverviewCard, column, columnWidth)) } // card - return facepaint.NewBlocksContent(styledOverviewCard.card.Options(), columns...) + return facepaint.NewBlocksContent(styledRatingOverviewCard.card.Options(), columns...) } -func newOverviewColumn(data period.OverviewColumn, columnWidth float64) *facepaint.Block { +func newUnratedOverviewCard(data period.OverviewCard, columnWidth float64) *facepaint.Block { + if len(data.Blocks) == 0 { + return nil + } + + var columns []*facepaint.Block + for _, column := range data.Blocks { + columns = append(columns, newOverviewColumn(styledUnratedOverviewCard, column, columnWidth)) + } + // card + return facepaint.NewBlocksContent(styledUnratedOverviewCard.card.Options(), columns...) +} + +func newOverviewColumn(stl overviewCardStyle, data period.OverviewColumn, columnWidth float64) *facepaint.Block { var columnBlocks []*facepaint.Block for _, block := range data.Blocks { switch block.Tag { default: - columnBlocks = append(columnBlocks, newOverviewBlockWithIcon(block, nil)) + columnBlocks = append(columnBlocks, newOverviewBlockWithIcon(stl.styleBlock(block), block, nil)) case prepare.TagWN8: - columnBlocks = append(columnBlocks, newOverviewWN8Block(block)) + columnBlocks = append(columnBlocks, newOverviewWN8Block(stl.styleBlock(block), block)) case prepare.TagRankedRating: - columnBlocks = append(columnBlocks, newOverviewRatingBlock(block)) + columnBlocks = append(columnBlocks, newOverviewRatingBlock(stl.styleBlock(block), block)) } } // column return facepaint.NewBlocksContent(style.NewStyle( - style.Parent(styledOverviewCard.column), + style.Parent(stl.column), style.SetWidth(columnWidth), ), columnBlocks...) } -func newOverviewBlockWithIcon(block prepare.StatsBlock[period.BlockData, string], icon *facepaint.Block) *facepaint.Block { - blockStyle := styledOverviewCard.styleBlock(block) +func newOverviewBlockWithIcon(blockStyle blockStyle, block prepare.StatsBlock[period.BlockData, string], icon *facepaint.Block) *facepaint.Block { if icon == nil { // block return facepaint.NewBlocksContent(blockStyle.valueContainer.Options(), @@ -63,7 +75,7 @@ func newOverviewBlockWithIcon(block prepare.StatsBlock[period.BlockData, string] )) } -func newOverviewWN8Block(block prepare.StatsBlock[period.BlockData, string]) *facepaint.Block { +func newOverviewWN8Block(blockStyle blockStyle, block prepare.StatsBlock[period.BlockData, string]) *facepaint.Block { ratingColors := common.GetWN8Colors(block.Value().Float()) if block.Value().Float() <= 0 { ratingColors.Background = common.TextAlt @@ -73,13 +85,13 @@ func newOverviewWN8Block(block prepare.StatsBlock[period.BlockData, string]) *fa common.AftermathLogo(ratingColors.Background, common.DefaultLogoOptions()), ) block.Label = common.GetWN8TierName(block.Value().Float()) - return newOverviewBlockWithIcon(block, icon) + return newOverviewBlockWithIcon(blockStyle, block, icon) } -func newOverviewRatingBlock(block prepare.StatsBlock[period.BlockData, string]) *facepaint.Block { +func newOverviewRatingBlock(blockStyle blockStyle, block prepare.StatsBlock[period.BlockData, string]) *facepaint.Block { icon, _ := common.GetRatingIcon(block.V, iconSizeRating) block.Label = common.GetRatingTierName(block.Value().Float()) - return newOverviewBlockWithIcon(block, icon) + return newOverviewBlockWithIcon(blockStyle, block, icon) } // func newHighlightCard(style highlightStyle, card period.VehicleCard) common.Block { From 31e02eec6ecd96c654bb79c9804ebedc6e43db61 Mon Sep 17 00:00:00 2001 From: Vovko Date: Sat, 18 Jan 2025 19:49:09 -0500 Subject: [PATCH 26/39] removed mod replace --- go.mod | 4 +--- go.sum | 2 ++ 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 0d77f952..864b114e 100644 --- a/go.mod +++ b/go.mod @@ -4,8 +4,6 @@ go 1.23.5 require github.com/go-jet/jet/v2 v2.12.0 -replace github.com/cufee/facepaint => ../facepaint - require ( github.com/Kodeworks/golang-image-ico v0.0.0-20141118225523-73f0f4cfade9 github.com/PuerkitoBio/goquery v1.10.1 @@ -13,7 +11,7 @@ require ( github.com/bwmarrin/discordgo v0.28.1 github.com/cufee/aftermath-assets v0.1.0 github.com/cufee/am-wg-proxy-next/v2 v2.2.6 - github.com/cufee/facepaint v0.0.3 + github.com/cufee/facepaint v0.0.4 github.com/fogleman/gg v1.3.0 github.com/go-co-op/gocron v1.37.0 github.com/goccy/go-json v0.10.4 diff --git a/go.sum b/go.sum index 3adbe3f9..1211ba52 100644 --- a/go.sum +++ b/go.sum @@ -24,6 +24,8 @@ github.com/cufee/aftermath-assets v0.1.0 h1:r8p2mUN+h/cw1T6/oEX7bntD+lrL9Nz27GXO github.com/cufee/aftermath-assets v0.1.0/go.mod h1:6yCITCiz7POJnUMn1oohvadLA4z5YrFNo3p9EKgRdGU= github.com/cufee/am-wg-proxy-next/v2 v2.2.6 h1:6RAnPuYbPGtaLzOPhTk/N2Hx4KJx14x/c/cIik668xA= github.com/cufee/am-wg-proxy-next/v2 v2.2.6/go.mod h1:x6fkRfYry3l4Ykxl+v6pJAw5ISw+CuGzJzSkc5y5SYs= +github.com/cufee/facepaint v0.0.4 h1:QmMr4MuUDE/MYdYqAVkiurnh/meZhgfOqp+vkaadMi4= +github.com/cufee/facepaint v0.0.4/go.mod h1:7zR5lQMN3EO3qNtff0J8nzIhDb258UoYbRzhRToLQdg= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= From d00c878f95098502afb21cd00ea6a84664dc9edd Mon Sep 17 00:00:00 2001 From: Vovko Date: Sat, 18 Jan 2025 19:54:27 -0500 Subject: [PATCH 27/39] enabled v2 client --- cmd/core/client.go | 2 +- internal/render/common/rating.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/core/client.go b/cmd/core/client.go index 5d05b673..0637e09b 100644 --- a/cmd/core/client.go +++ b/cmd/core/client.go @@ -4,7 +4,7 @@ import ( "github.com/cufee/aftermath/internal/database" "github.com/cufee/aftermath/internal/external/wargaming" "github.com/cufee/aftermath/internal/realtime" - stats "github.com/cufee/aftermath/internal/stats/client/v1" + stats "github.com/cufee/aftermath/internal/stats/client/v2" "github.com/cufee/aftermath/internal/stats/fetch/v1" "golang.org/x/text/language" ) diff --git a/internal/render/common/rating.go b/internal/render/common/rating.go index 2ebaef76..56720ee0 100644 --- a/internal/render/common/rating.go +++ b/internal/render/common/rating.go @@ -68,7 +68,7 @@ func GetRatingColors(rating float32) ratingColors { func GetRatingIcon(rating frame.Value, size float64) (*facepaint.Block, bool) { style := style.Style{Width: size, Height: 0} if rating.Float() < 0 { - style.BackgroundColor = TextAlt + style.Color = TextAlt } name := "rating-" + GetRatingIconName(rating.Float()) From dbd5bb222eb4323f1324ead2e1685f712b431082 Mon Sep 17 00:00:00 2001 From: Vovko Date: Sat, 18 Jan 2025 22:15:23 -0500 Subject: [PATCH 28/39] added highlight cards --- internal/stats/render/period/v2/cards.go | 87 ++++++------------- .../stats/render/period/v2/highlight-style.go | 73 ++++++++++++++++ internal/stats/render/period/v2/highlight.go | 27 ++++++ internal/stats/render/period/v2/misc-style.go | 32 ++++++- internal/stats/render/period/v2/misc.go | 25 ++++++ 5 files changed, 181 insertions(+), 63 deletions(-) diff --git a/internal/stats/render/period/v2/cards.go b/internal/stats/render/period/v2/cards.go index 3d57661a..c4048665 100644 --- a/internal/stats/render/period/v2/cards.go +++ b/internal/stats/render/period/v2/cards.go @@ -47,84 +47,46 @@ func generateCards(stats fetch.AccountStatsOverPeriod, cards period.Cards, subs } } - // { - // highlightStyle := highlightCardStyle(defaultCardStyle(0)) - // var highlightBlocksMaxCount, highlightTitleMaxWidth, highlightBlockMaxSize float64 - // for _, highlight := range cards.Highlights { - // // Title and tank name - // metaSize := common.MeasureString(highlight.Meta, highlightStyle.cardTitle.Font) - // titleSize := common.MeasureString(highlight.Title, highlightStyle.tankName.Font) - // highlightTitleMaxWidth = max(highlightTitleMaxWidth, metaSize.TotalWidth, titleSize.TotalWidth) - - // // Blocks - // highlightBlocksMaxCount = max(highlightBlocksMaxCount, float64(len(highlight.Blocks))) - // for _, block := range highlight.Blocks { - // labelSize := common.MeasureString(block.Label, highlightStyle.blockLabel.Font) - // valueSize := common.MeasureString(block.Value().String(), highlightStyle.blockValue.Font) - // highlightBlockMaxSize = max(highlightBlockMaxSize, valueSize.TotalWidth, labelSize.TotalWidth) - // } - // } - - // highlightCardWidthMax := (highlightStyle.container.PaddingX * 2) + (highlightStyle.container.Gap * highlightBlocksMaxCount) + (highlightBlockMaxSize * highlightBlocksMaxCount) + highlightTitleMaxWidth - // cardWidth = max(cardWidth, highlightCardWidthMax) - // } - // } + // calculate per block type width of highlight stats to make things even + var highlightBlockWidth = make(map[prepare.Tag]float64) + for _, highlight := range cards.Highlights { + for _, block := range highlight.Blocks { + label := facepaint.MeasureString(block.Label, styledHighlightCard.blockLabel().Font).TotalWidth + value := facepaint.MeasureString(block.Value().String(), styledHighlightCard.blockLabel().Font).TotalWidth + highlightBlockWidth[block.Tag] = max(highlightBlockWidth[block.Tag], label, value) + } + } var statsCards []*facepaint.Block + // player name card statsCards = append(statsCards, newPlayerNameCard(stats.Account)) - // // We first render a footer in order to calculate the minimum required width - // { - // var footer []string - // if opts.VehicleID != "" { - // footer = append(footer, cards.Overview.Title) - // } - - // sessionTo := stats.PeriodEnd.Format("Jan 2, 2006") - // sessionFrom := stats.PeriodStart.Format("Jan 2, 2006") - // if sessionFrom == sessionTo { - // footer = append(footer, sessionTo) - // } else { - // footer = append(footer, sessionFrom+" - "+sessionTo) - // } - // footerBlock := common.NewFooterCard(strings.Join(footer, " • ")) - // footerImage, err := footerBlock.Render() - // if err != nil { - // return segments, err - // } - - // cardWidth = max(cardWidth, float64(footerImage.Bounds().Dx())) - // segments.AddFooter(common.NewImageContent(common.Style{}, footerImage)) - // } - - // // Header card - // if headerCard, headerCardExists := common.NewHeaderCard(cardWidth, subs, opts.PromoText); headerCardExists { - // segments.AddHeader(headerCard) - // } - - // // Player Title card - // segments.AddContent(common.NewPlayerTitleCard(common.DefaultPlayerTitleStyle(stats.Account.Nickname, titleCardStyle(cardWidth)), stats.Account.Nickname, stats.Account.ClanTag, subs)) - + // unrated battles if card := newUnratedOverviewCard(cards.Overview, maxWidthOverviewBlock); card != nil { statsCards = append(statsCards, card) } - if card := newRatingOverviewCard(cards.Rating, maxWidthOverviewBlock); card != nil { + + // rating battles + if card := newRatingOverviewCard(cards.Rating, maxWidthOverviewBlock); cards.Rating.Meta && card != nil { statsCards = append(statsCards, card) } - // // Highlights - // for i, card := range cards.Highlights { - // if i > 0 && cards.Rating.Meta { - // break // only show 1 highlight when rating battles card is visible - // } - // segments.AddContent(newHighlightCard(highlightCardStyle(defaultCardStyle(cardWidth)), card)) - // } + // highlights + for i, card := range cards.Highlights { + if i > 0 && cards.Rating.Meta { + break // only show 1 highlight when rating battles card is visible + } + statsCards = append(statsCards, newHighlightCard(card, highlightBlockWidth)) + } if len(statsCards) == 0 { return nil, errors.New("no cards to render") } + footer := newFooterCard(stats, cards, opts) + // footerSize := footer.Dimensions() + cardsFrame := facepaint.NewBlocksContent(style.NewStyle(style.Parent(styledCardsFrame)), statsCards...) // add background branding @@ -143,6 +105,7 @@ func generateCards(stats fetch.AccountStatsOverPeriod, cards period.Cards, subs var frameCards []*facepaint.Block frameCards = append(frameCards, cardsFrame) + frameCards = append(frameCards, footer) return facepaint.NewBlocksContent(style.NewStyle(style.Parent(styledFinalFrame)), frameCards...), nil diff --git a/internal/stats/render/period/v2/highlight-style.go b/internal/stats/render/period/v2/highlight-style.go index 035a18a4..16ffd121 100644 --- a/internal/stats/render/period/v2/highlight-style.go +++ b/internal/stats/render/period/v2/highlight-style.go @@ -1 +1,74 @@ package period + +import ( + "github.com/cufee/aftermath/internal/render/common" + "github.com/cufee/facepaint/style" +) + +var styledHighlightTitle = style.Style{} +var styledHighlightStatsRow = style.Style{} + +type highlightCardStyle struct { + card style.Style + titleWrapper style.Style + titleLabel func() *style.Style + titleVehicle func() *style.Style + stats style.Style + blockValue func() *style.Style + blockLabel func() *style.Style +} + +var styledHighlightCard = highlightCardStyle{ + card: style.Style{ + Direction: style.DirectionHorizontal, + AlignItems: style.AlignItemsCenter, + JustifyContent: style.JustifyContentSpaceAround, + + BackgroundColor: common.DefaultCardColor, + + BorderRadiusTopLeft: common.BorderRadiusLG, + BorderRadiusTopRight: common.BorderRadiusLG, + BorderRadiusBottomLeft: common.BorderRadiusLG, + BorderRadiusBottomRight: common.BorderRadiusLG, + + GrowHorizontal: true, + + PaddingLeft: 20, + PaddingRight: 20, + PaddingTop: 20, + PaddingBottom: 20, + }, + titleWrapper: style.Style{ + Direction: style.DirectionVertical, + JustifyContent: style.JustifyContentCenter, + }, + titleLabel: func() *style.Style { + return &style.Style{ + Color: common.TextSecondary, + Font: common.FontSmall(), + } + }, + titleVehicle: func() *style.Style { + return &style.Style{ + Color: common.TextPrimary, + Font: common.FontMedium(), + } + }, + stats: style.Style{ + Direction: style.DirectionVertical, + AlignItems: style.AlignItemsCenter, + JustifyContent: style.JustifyContentCenter, + }, + blockValue: func() *style.Style { + return &style.Style{ + Color: common.TextPrimary, + Font: common.FontMedium(), + } + }, + blockLabel: func() *style.Style { + return &style.Style{ + Color: common.TextAlt, + Font: common.FontSmall(), + } + }, +} diff --git a/internal/stats/render/period/v2/highlight.go b/internal/stats/render/period/v2/highlight.go index 035a18a4..a1116341 100644 --- a/internal/stats/render/period/v2/highlight.go +++ b/internal/stats/render/period/v2/highlight.go @@ -1 +1,28 @@ package period + +import ( + prepare "github.com/cufee/aftermath/internal/stats/prepare/common/v1" + "github.com/cufee/aftermath/internal/stats/prepare/period/v1" + "github.com/cufee/facepaint" + "github.com/cufee/facepaint/style" +) + +func newHighlightCard(data period.VehicleCard, blockSizes map[prepare.Tag]float64) *facepaint.Block { + leftSide := facepaint.NewBlocksContent(styledHighlightCard.titleWrapper.Options(), + facepaint.MustNewTextContent(styledHighlightCard.titleLabel().Options(), data.Meta), + facepaint.MustNewTextContent(styledHighlightCard.titleVehicle().Options(), data.Title), + ) + + var rightSide []*facepaint.Block + for _, block := range data.Blocks { + rightSide = append(rightSide, facepaint.NewBlocksContent(style.NewStyle(style.Parent(styledHighlightCard.stats), style.SetWidth(blockSizes[block.Tag])), + facepaint.MustNewTextContent(styledHighlightCard.blockValue().Options(), block.V.String()), + facepaint.MustNewTextContent(styledHighlightCard.blockLabel().Options(), block.Label), + )) + } + + return facepaint.NewBlocksContent(styledHighlightCard.card.Options(), + append([]*facepaint.Block{leftSide}, rightSide...)..., + ) + +} diff --git a/internal/stats/render/period/v2/misc-style.go b/internal/stats/render/period/v2/misc-style.go index f2c68202..596677de 100644 --- a/internal/stats/render/period/v2/misc-style.go +++ b/internal/stats/render/period/v2/misc-style.go @@ -41,6 +41,8 @@ var styledPlayerNameWrapper = style.Style{ PaddingTop: 5, PaddingBottom: 5, + Height: 50, + GrowHorizontal: true, Gap: 20, } @@ -51,7 +53,6 @@ var styledPlayerNameCard = style.Style{ JustifyContent: style.JustifyContentSpaceAround, GrowHorizontal: true, - // GrowVertical: true, } var styledPlayerClanTagCard = style.Style{ @@ -66,6 +67,8 @@ var styledPlayerClanTagCard = style.Style{ BorderRadiusBottomLeft: common.BorderRadiusMD, BorderRadiusBottomRight: common.BorderRadiusMD, + GrowVertical: true, + PaddingLeft: 12, PaddingRight: 12, PaddingTop: 10, @@ -79,6 +82,8 @@ var styledCardsFrame = style.Style{ AlignItems: style.AlignItemsCenter, Gap: 10, + GrowHorizontal: true, + PaddingLeft: 20, PaddingRight: 20, PaddingTop: 20, @@ -99,3 +104,28 @@ var styledCardsBackground = style.NewStyle( style.SetPosition(style.PositionAbsolute), style.SetZIndex(-99), ) + +var styledFooterWrapper = style.Style{ + Direction: style.DirectionHorizontal, + AlignItems: style.AlignItemsCenter, + Gap: 5, +} + +func styledFooterCard() style.Style { + return style.Style{ + Font: common.FontSmall(), + Color: common.TextAlt, + + BackgroundColor: common.DefaultCardColor, + + BorderRadiusTopLeft: common.BorderRadiusSM, + BorderRadiusTopRight: common.BorderRadiusSM, + BorderRadiusBottomLeft: common.BorderRadiusSM, + BorderRadiusBottomRight: common.BorderRadiusSM, + + PaddingLeft: 10, + PaddingRight: 10, + PaddingTop: 5, + PaddingBottom: 5, + } +} diff --git a/internal/stats/render/period/v2/misc.go b/internal/stats/render/period/v2/misc.go index 6b2a14dc..02cc73cf 100644 --- a/internal/stats/render/period/v2/misc.go +++ b/internal/stats/render/period/v2/misc.go @@ -2,6 +2,9 @@ package period import ( "github.com/cufee/aftermath/internal/database/models" + "github.com/cufee/aftermath/internal/render/common" + "github.com/cufee/aftermath/internal/stats/fetch/v1" + "github.com/cufee/aftermath/internal/stats/prepare/period/v1" "github.com/cufee/facepaint" "github.com/cufee/facepaint/style" ) @@ -35,3 +38,25 @@ func newPlayerNameCard(account models.Account) *facepaint.Block { return facepaint.NewBlocksContent(styledPlayerNameWrapper.Options(), blocks...) } + +func newFooterCard(stats fetch.AccountStatsOverPeriod, cards period.Cards, opts common.Options) *facepaint.Block { + stl := styledFooterCard() + var footer []*facepaint.Block + if opts.VehicleID != "" { + footer = append(footer, facepaint.MustNewTextContent(stl.Options(), cards.Overview.Title)) + } + + sessionTo := stats.PeriodEnd.Format("Jan 2, 2006") + sessionFromFormat := "Jan 2, 2006" + if stats.PeriodStart.Year() == stats.PeriodEnd.Year() { + sessionFromFormat = "Jan 2" + } + sessionFrom := stats.PeriodStart.Format(sessionFromFormat) + if stats.PeriodStart.IsZero() || sessionFrom == sessionTo { + footer = append(footer, facepaint.MustNewTextContent(stl.Options(), sessionTo)) + } else { + footer = append(footer, facepaint.MustNewTextContent(stl.Options(), sessionFrom+" - "+sessionTo)) + } + + return facepaint.NewBlocksContent(styledFooterWrapper.Options(), footer...) +} From 8c295d340321453f386d86c578d67a110eb8c23c Mon Sep 17 00:00:00 2001 From: Vovko Date: Sat, 18 Jan 2025 22:17:34 -0500 Subject: [PATCH 29/39] removed unused arg --- internal/stats/render/period/v2/cards.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/stats/render/period/v2/cards.go b/internal/stats/render/period/v2/cards.go index c4048665..77e2d7a5 100644 --- a/internal/stats/render/period/v2/cards.go +++ b/internal/stats/render/period/v2/cards.go @@ -16,7 +16,7 @@ import ( "github.com/cufee/facepaint" ) -func generateCards(stats fetch.AccountStatsOverPeriod, cards period.Cards, subs []models.UserSubscription, opts common.Options) (*facepaint.Block, error) { +func generateCards(stats fetch.AccountStatsOverPeriod, cards period.Cards, _ []models.UserSubscription, opts common.Options) (*facepaint.Block, error) { if len(cards.Overview.Blocks) == 0 && len(cards.Highlights) == 0 { log.Error().Msg("player cards slice is 0 length, this should not happen") return nil, errors.New("no cards provided") From a702973d8d8e401d62002d06a98d9581849c01ae Mon Sep 17 00:00:00 2001 From: Vovko Date: Sat, 18 Jan 2025 22:23:47 -0500 Subject: [PATCH 30/39] more accurate sizing --- internal/stats/render/period/v2/cards.go | 78 ++----------------- .../stats/render/period/v2/overview-style.go | 6 +- internal/stats/render/period/v2/overview.go | 8 +- 3 files changed, 14 insertions(+), 78 deletions(-) diff --git a/internal/stats/render/period/v2/cards.go b/internal/stats/render/period/v2/cards.go index 3d57661a..b0dacb3b 100644 --- a/internal/stats/render/period/v2/cards.go +++ b/internal/stats/render/period/v2/cards.go @@ -23,16 +23,16 @@ func generateCards(stats fetch.AccountStatsOverPeriod, cards period.Cards, subs } // calculate max overview block width to make all blocks the same size - var maxWidthOverviewBlock float64 + var maxWidthOverviewBlock = make(map[string]float64) for _, column := range cards.Overview.Blocks { for _, block := range column.Blocks { switch block.Tag { case prepare.TagWN8: block.Label = common.GetWN8TierName(block.Value().Float()) - maxWidthOverviewBlock = max(maxWidthOverviewBlock, iconSizeWN8) + maxWidthOverviewBlock[string(block.Data.Flavor)] = max(maxWidthOverviewBlock[string(block.Data.Flavor)], iconSizeWN8) } - maxWidthOverviewBlock = max(maxWidthOverviewBlock, facepaint.MeasureString(block.Label, styledUnratedOverviewCard.styleBlock(block).label.Font).TotalWidth) - maxWidthOverviewBlock = max(maxWidthOverviewBlock, facepaint.MeasureString(block.Value().String(), styledUnratedOverviewCard.styleBlock(block).value.Font).TotalWidth) + maxWidthOverviewBlock[string(block.Data.Flavor)] = max(maxWidthOverviewBlock[string(block.Data.Flavor)], facepaint.MeasureString(block.Label, styledUnratedOverviewCard.styleBlock(block).label.Font).TotalWidth) + maxWidthOverviewBlock[string(block.Data.Flavor)] = max(maxWidthOverviewBlock[string(block.Data.Flavor)], facepaint.MeasureString(block.Value().String(), styledUnratedOverviewCard.styleBlock(block).value.Font).TotalWidth) } } for _, column := range cards.Rating.Blocks { @@ -40,87 +40,23 @@ func generateCards(stats fetch.AccountStatsOverPeriod, cards period.Cards, subs switch block.Tag { case prepare.TagRankedRating: block.Label = common.GetRatingTierName(block.Value().Float()) - maxWidthOverviewBlock = max(maxWidthOverviewBlock, iconSizeRating) + maxWidthOverviewBlock[string(block.Data.Flavor)] = max(maxWidthOverviewBlock[string(block.Data.Flavor)], iconSizeRating) } - maxWidthOverviewBlock = max(maxWidthOverviewBlock, facepaint.MeasureString(block.Label, styledRatingOverviewCard.styleBlock(block).label.Font).TotalWidth) - maxWidthOverviewBlock = max(maxWidthOverviewBlock, facepaint.MeasureString(block.Value().String(), styledRatingOverviewCard.styleBlock(block).value.Font).TotalWidth) + maxWidthOverviewBlock[string(block.Data.Flavor)] = max(maxWidthOverviewBlock[string(block.Data.Flavor)], facepaint.MeasureString(block.Label, styledRatingOverviewCard.styleBlock(block).label.Font).TotalWidth) + maxWidthOverviewBlock[string(block.Data.Flavor)] = max(maxWidthOverviewBlock[string(block.Data.Flavor)], facepaint.MeasureString(block.Value().String(), styledRatingOverviewCard.styleBlock(block).value.Font).TotalWidth) } } - // { - // highlightStyle := highlightCardStyle(defaultCardStyle(0)) - // var highlightBlocksMaxCount, highlightTitleMaxWidth, highlightBlockMaxSize float64 - // for _, highlight := range cards.Highlights { - // // Title and tank name - // metaSize := common.MeasureString(highlight.Meta, highlightStyle.cardTitle.Font) - // titleSize := common.MeasureString(highlight.Title, highlightStyle.tankName.Font) - // highlightTitleMaxWidth = max(highlightTitleMaxWidth, metaSize.TotalWidth, titleSize.TotalWidth) - - // // Blocks - // highlightBlocksMaxCount = max(highlightBlocksMaxCount, float64(len(highlight.Blocks))) - // for _, block := range highlight.Blocks { - // labelSize := common.MeasureString(block.Label, highlightStyle.blockLabel.Font) - // valueSize := common.MeasureString(block.Value().String(), highlightStyle.blockValue.Font) - // highlightBlockMaxSize = max(highlightBlockMaxSize, valueSize.TotalWidth, labelSize.TotalWidth) - // } - // } - - // highlightCardWidthMax := (highlightStyle.container.PaddingX * 2) + (highlightStyle.container.Gap * highlightBlocksMaxCount) + (highlightBlockMaxSize * highlightBlocksMaxCount) + highlightTitleMaxWidth - // cardWidth = max(cardWidth, highlightCardWidthMax) - // } - // } - var statsCards []*facepaint.Block statsCards = append(statsCards, newPlayerNameCard(stats.Account)) - // // We first render a footer in order to calculate the minimum required width - // { - // var footer []string - // if opts.VehicleID != "" { - // footer = append(footer, cards.Overview.Title) - // } - - // sessionTo := stats.PeriodEnd.Format("Jan 2, 2006") - // sessionFrom := stats.PeriodStart.Format("Jan 2, 2006") - // if sessionFrom == sessionTo { - // footer = append(footer, sessionTo) - // } else { - // footer = append(footer, sessionFrom+" - "+sessionTo) - // } - // footerBlock := common.NewFooterCard(strings.Join(footer, " • ")) - // footerImage, err := footerBlock.Render() - // if err != nil { - // return segments, err - // } - - // cardWidth = max(cardWidth, float64(footerImage.Bounds().Dx())) - // segments.AddFooter(common.NewImageContent(common.Style{}, footerImage)) - // } - - // // Header card - // if headerCard, headerCardExists := common.NewHeaderCard(cardWidth, subs, opts.PromoText); headerCardExists { - // segments.AddHeader(headerCard) - // } - - // // Player Title card - // segments.AddContent(common.NewPlayerTitleCard(common.DefaultPlayerTitleStyle(stats.Account.Nickname, titleCardStyle(cardWidth)), stats.Account.Nickname, stats.Account.ClanTag, subs)) - if card := newUnratedOverviewCard(cards.Overview, maxWidthOverviewBlock); card != nil { statsCards = append(statsCards, card) } if card := newRatingOverviewCard(cards.Rating, maxWidthOverviewBlock); card != nil { statsCards = append(statsCards, card) } - - // // Highlights - // for i, card := range cards.Highlights { - // if i > 0 && cards.Rating.Meta { - // break // only show 1 highlight when rating battles card is visible - // } - // segments.AddContent(newHighlightCard(highlightCardStyle(defaultCardStyle(cardWidth)), card)) - // } - if len(statsCards) == 0 { return nil, errors.New("no cards to render") } diff --git a/internal/stats/render/period/v2/overview-style.go b/internal/stats/render/period/v2/overview-style.go index fd29fcfa..830f33c6 100644 --- a/internal/stats/render/period/v2/overview-style.go +++ b/internal/stats/render/period/v2/overview-style.go @@ -75,9 +75,9 @@ var styledUnratedOverviewCard = overviewCardStyle{ BorderRadiusBottomLeft: common.BorderRadiusLG, BorderRadiusBottomRight: common.BorderRadiusLG, GrowHorizontal: true, - Gap: 10, - PaddingLeft: 20, - PaddingRight: 20, + Gap: 15, + PaddingLeft: 30, + PaddingRight: 30, PaddingTop: 20, PaddingBottom: 20, }, diff --git a/internal/stats/render/period/v2/overview.go b/internal/stats/render/period/v2/overview.go index b12b4d87..b539c3f4 100644 --- a/internal/stats/render/period/v2/overview.go +++ b/internal/stats/render/period/v2/overview.go @@ -8,27 +8,27 @@ import ( "github.com/cufee/facepaint/style" ) -func newRatingOverviewCard(data period.RatingOverviewCard, columnWidth float64) *facepaint.Block { +func newRatingOverviewCard(data period.RatingOverviewCard, columnWidth map[string]float64) *facepaint.Block { if len(data.Blocks) == 0 { return nil } var columns []*facepaint.Block for _, column := range data.Blocks { - columns = append(columns, newOverviewColumn(styledRatingOverviewCard, column, columnWidth)) + columns = append(columns, newOverviewColumn(styledRatingOverviewCard, column, columnWidth[string(column.Flavor)])) } // card return facepaint.NewBlocksContent(styledRatingOverviewCard.card.Options(), columns...) } -func newUnratedOverviewCard(data period.OverviewCard, columnWidth float64) *facepaint.Block { +func newUnratedOverviewCard(data period.OverviewCard, columnWidth map[string]float64) *facepaint.Block { if len(data.Blocks) == 0 { return nil } var columns []*facepaint.Block for _, column := range data.Blocks { - columns = append(columns, newOverviewColumn(styledUnratedOverviewCard, column, columnWidth)) + columns = append(columns, newOverviewColumn(styledUnratedOverviewCard, column, columnWidth[string(column.Flavor)])) } // card return facepaint.NewBlocksContent(styledUnratedOverviewCard.card.Options(), columns...) From f13dd18ead20b46356f87e8089b5d5be783895d5 Mon Sep 17 00:00:00 2001 From: Vovko Date: Sat, 18 Jan 2025 22:33:29 -0500 Subject: [PATCH 31/39] fixed highlight card width --- internal/stats/render/period/v2/cards.go | 2 +- .../stats/render/period/v2/highlight-style.go | 21 +++++++++++++------ internal/stats/render/period/v2/highlight.go | 3 ++- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/internal/stats/render/period/v2/cards.go b/internal/stats/render/period/v2/cards.go index f08137a0..1a49146a 100644 --- a/internal/stats/render/period/v2/cards.go +++ b/internal/stats/render/period/v2/cards.go @@ -52,7 +52,7 @@ func generateCards(stats fetch.AccountStatsOverPeriod, cards period.Cards, _ []m for _, highlight := range cards.Highlights { for _, block := range highlight.Blocks { label := facepaint.MeasureString(block.Label, styledHighlightCard.blockLabel().Font).TotalWidth - value := facepaint.MeasureString(block.Value().String(), styledHighlightCard.blockLabel().Font).TotalWidth + value := facepaint.MeasureString(block.Value().String(), styledHighlightCard.blockValue().Font).TotalWidth highlightBlockWidth[block.Tag] = max(highlightBlockWidth[block.Tag], label, value) } } diff --git a/internal/stats/render/period/v2/highlight-style.go b/internal/stats/render/period/v2/highlight-style.go index 16ffd121..33e07f5d 100644 --- a/internal/stats/render/period/v2/highlight-style.go +++ b/internal/stats/render/period/v2/highlight-style.go @@ -13,6 +13,7 @@ type highlightCardStyle struct { titleWrapper style.Style titleLabel func() *style.Style titleVehicle func() *style.Style + statsWrapper style.Style stats style.Style blockValue func() *style.Style blockLabel func() *style.Style @@ -20,9 +21,10 @@ type highlightCardStyle struct { var styledHighlightCard = highlightCardStyle{ card: style.Style{ - Direction: style.DirectionHorizontal, - AlignItems: style.AlignItemsCenter, - JustifyContent: style.JustifyContentSpaceAround, + // Debug: true, + + Direction: style.DirectionHorizontal, + AlignItems: style.AlignItemsCenter, BackgroundColor: common.DefaultCardColor, @@ -35,12 +37,12 @@ var styledHighlightCard = highlightCardStyle{ PaddingLeft: 20, PaddingRight: 20, - PaddingTop: 20, - PaddingBottom: 20, + PaddingTop: 15, + PaddingBottom: 15, }, titleWrapper: style.Style{ + GrowHorizontal: true, Direction: style.DirectionVertical, - JustifyContent: style.JustifyContentCenter, }, titleLabel: func() *style.Style { return &style.Style{ @@ -59,6 +61,13 @@ var styledHighlightCard = highlightCardStyle{ AlignItems: style.AlignItemsCenter, JustifyContent: style.JustifyContentCenter, }, + statsWrapper: style.Style{ + // Debug: true, + + Direction: style.DirectionHorizontal, + AlignItems: style.AlignItemsCenter, + Gap: 10, + }, blockValue: func() *style.Style { return &style.Style{ Color: common.TextPrimary, diff --git a/internal/stats/render/period/v2/highlight.go b/internal/stats/render/period/v2/highlight.go index a1116341..a92d1ca2 100644 --- a/internal/stats/render/period/v2/highlight.go +++ b/internal/stats/render/period/v2/highlight.go @@ -22,7 +22,8 @@ func newHighlightCard(data period.VehicleCard, blockSizes map[prepare.Tag]float6 } return facepaint.NewBlocksContent(styledHighlightCard.card.Options(), - append([]*facepaint.Block{leftSide}, rightSide...)..., + leftSide, + facepaint.NewBlocksContent(styledHighlightCard.statsWrapper.Options(), rightSide...), ) } From 9195948a1fc5e7fe20c96383e56ff2eb1e43970f Mon Sep 17 00:00:00 2001 From: Vovko Date: Sat, 18 Jan 2025 22:34:37 -0500 Subject: [PATCH 32/39] added gap --- internal/stats/render/period/v2/highlight-style.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/stats/render/period/v2/highlight-style.go b/internal/stats/render/period/v2/highlight-style.go index 33e07f5d..f38a662b 100644 --- a/internal/stats/render/period/v2/highlight-style.go +++ b/internal/stats/render/period/v2/highlight-style.go @@ -34,6 +34,7 @@ var styledHighlightCard = highlightCardStyle{ BorderRadiusBottomRight: common.BorderRadiusLG, GrowHorizontal: true, + Gap: 20, PaddingLeft: 20, PaddingRight: 20, From 47ef5615b977a9dce5eeb1f6cbe4c07d578c2e0c Mon Sep 17 00:00:00 2001 From: Vovko Date: Sat, 18 Jan 2025 22:35:54 -0500 Subject: [PATCH 33/39] slightly bigger gaps --- .../stats/render/period/v2/highlight-style.go | 4 ++-- .../stats/render/period/v2/overview-style.go | 24 +++++++++++-------- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/internal/stats/render/period/v2/highlight-style.go b/internal/stats/render/period/v2/highlight-style.go index f38a662b..576c3a01 100644 --- a/internal/stats/render/period/v2/highlight-style.go +++ b/internal/stats/render/period/v2/highlight-style.go @@ -21,8 +21,6 @@ type highlightCardStyle struct { var styledHighlightCard = highlightCardStyle{ card: style.Style{ - // Debug: true, - Direction: style.DirectionHorizontal, AlignItems: style.AlignItemsCenter, @@ -42,6 +40,8 @@ var styledHighlightCard = highlightCardStyle{ PaddingBottom: 15, }, titleWrapper: style.Style{ + // Debug: true, + GrowHorizontal: true, Direction: style.DirectionVertical, }, diff --git a/internal/stats/render/period/v2/overview-style.go b/internal/stats/render/period/v2/overview-style.go index 830f33c6..6eeabdd2 100644 --- a/internal/stats/render/period/v2/overview-style.go +++ b/internal/stats/render/period/v2/overview-style.go @@ -66,20 +66,24 @@ var styledUnratedOverviewCard = overviewCardStyle{ card: style.Style{ Debug: debugOverviewCards, - Direction: style.DirectionHorizontal, - AlignItems: style.AlignItemsCenter, - JustifyContent: style.JustifyContentSpaceAround, - BackgroundColor: common.DefaultCardColor, + Direction: style.DirectionHorizontal, + AlignItems: style.AlignItemsCenter, + JustifyContent: style.JustifyContentSpaceAround, + + BackgroundColor: common.DefaultCardColor, + BorderRadiusTopLeft: common.BorderRadiusLG, BorderRadiusTopRight: common.BorderRadiusLG, BorderRadiusBottomLeft: common.BorderRadiusLG, BorderRadiusBottomRight: common.BorderRadiusLG, - GrowHorizontal: true, - Gap: 15, - PaddingLeft: 30, - PaddingRight: 30, - PaddingTop: 20, - PaddingBottom: 20, + + GrowHorizontal: true, + Gap: 20, + + PaddingLeft: 30, + PaddingRight: 30, + PaddingTop: 20, + PaddingBottom: 20, }, column: style.Style{ Debug: debugOverviewCards, From d06459ee318edec613166f09fb28bb673c0b24ac Mon Sep 17 00:00:00 2001 From: Vovko Date: Sat, 18 Jan 2025 22:38:18 -0500 Subject: [PATCH 34/39] more consistent cards --- internal/stats/render/period/v2/highlight-style.go | 8 ++++---- internal/stats/render/period/v2/overview-style.go | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/internal/stats/render/period/v2/highlight-style.go b/internal/stats/render/period/v2/highlight-style.go index 576c3a01..e1a8deaf 100644 --- a/internal/stats/render/period/v2/highlight-style.go +++ b/internal/stats/render/period/v2/highlight-style.go @@ -34,10 +34,10 @@ var styledHighlightCard = highlightCardStyle{ GrowHorizontal: true, Gap: 20, - PaddingLeft: 20, - PaddingRight: 20, - PaddingTop: 15, - PaddingBottom: 15, + PaddingLeft: 25, + PaddingRight: 25, + PaddingTop: 20, + PaddingBottom: 20, }, titleWrapper: style.Style{ // Debug: true, diff --git a/internal/stats/render/period/v2/overview-style.go b/internal/stats/render/period/v2/overview-style.go index 6eeabdd2..26bf27bb 100644 --- a/internal/stats/render/period/v2/overview-style.go +++ b/internal/stats/render/period/v2/overview-style.go @@ -68,7 +68,7 @@ var styledUnratedOverviewCard = overviewCardStyle{ Direction: style.DirectionHorizontal, AlignItems: style.AlignItemsCenter, - JustifyContent: style.JustifyContentSpaceAround, + JustifyContent: style.JustifyContentSpaceBetween, BackgroundColor: common.DefaultCardColor, @@ -80,8 +80,8 @@ var styledUnratedOverviewCard = overviewCardStyle{ GrowHorizontal: true, Gap: 20, - PaddingLeft: 30, - PaddingRight: 30, + PaddingLeft: 25, + PaddingRight: 25, PaddingTop: 20, PaddingBottom: 20, }, From 0cbd2314dce4c3d96d8dd36ceeddadf8a62c37e3 Mon Sep 17 00:00:00 2001 From: Vovko Date: Sat, 18 Jan 2025 22:38:52 -0500 Subject: [PATCH 35/39] darker pill --- internal/stats/render/period/v2/constants.go | 90 ------------------- internal/stats/render/period/v2/misc-style.go | 2 +- 2 files changed, 1 insertion(+), 91 deletions(-) delete mode 100644 internal/stats/render/period/v2/constants.go diff --git a/internal/stats/render/period/v2/constants.go b/internal/stats/render/period/v2/constants.go deleted file mode 100644 index c4bf7816..00000000 --- a/internal/stats/render/period/v2/constants.go +++ /dev/null @@ -1,90 +0,0 @@ -package period - -// type highlightStyle struct { -// container common.Style -// cardTitle common.Style -// tankName common.Style -// blockLabel common.Style -// blockValue common.Style -// } - -// func (s *overviewStyle) block(block prepare.StatsBlock[period.BlockData, string]) (common.Style, common.Style) { -// switch block.Data.Flavor { -// case period.BlockFlavorSpecial: -// return common.Style{FontColor: common.TextPrimary, Font: common.FontXL()}, common.Style{FontColor: common.TextAlt, Font: common.FontSmall()} -// case period.BlockFlavorSecondary: -// return common.Style{FontColor: common.TextSecondary, Font: common.FontMedium()}, common.Style{FontColor: common.TextAlt, Font: common.FontSmall()} -// default: -// return common.Style{FontColor: common.TextPrimary, Font: common.FontLarge()}, common.Style{FontColor: common.TextAlt, Font: common.FontSmall()} -// } -// } - -// func defaultCardStyle(width float64) common.Style { -// style := common.Style{ -// JustifyContent: common.JustifyContentCenter, -// AlignItems: common.AlignItemsCenter, -// Direction: common.DirectionVertical, -// BackgroundColor: common.DefaultCardColor, -// BorderRadius: common.BorderRadiusLG, -// PaddingY: 10, -// PaddingX: 20, -// Gap: 20, -// Width: width, -// // Debug: true, -// } -// return style -// } - -// func titleCardStyle(width float64) common.Style { -// style := defaultCardStyle(width) -// style.PaddingX = style.PaddingY -// style.Gap = style.PaddingY -// // style.Debug = true -// return style -// } - -// func overviewCardStyle() common.Style { -// // style := defaultCardStyle(0) -// style := common.Style{} -// style.Direction = common.DirectionVertical -// style.AlignItems = common.AlignItemsCenter -// style.JustifyContent = common.JustifyContentCenter -// style.PaddingY = 0 -// style.PaddingX = 0 -// style.Gap = 5 -// // style.Debug = true -// return style -// } - -// func overviewCardBlocksStyle(width float64) common.Style { -// style := defaultCardStyle(width) -// style.AlignItems = common.AlignItemsCenter -// style.Direction = common.DirectionHorizontal -// style.JustifyContent = common.JustifyContentSpaceAround -// style.PaddingY = 20 -// style.PaddingX = 10 -// style.Gap = 5 -// // style.Debug = true -// return style -// } - -// func overviewSpecialRatingPillStyle() common.Style { -// return common.Style{} -// } - -// func highlightCardStyle(containerStyle common.Style) highlightStyle { -// container := containerStyle -// container.Gap = 10 -// container.PaddingX = 20 -// container.PaddingY = 15 -// container.Direction = common.DirectionHorizontal -// container.JustifyContent = common.JustifyContentSpaceBetween - -// return highlightStyle{ -// container: container, -// cardTitle: common.Style{Font: common.FontSmall(), FontColor: common.TextSecondary}, -// tankName: common.Style{Font: common.FontMedium(), FontColor: common.TextPrimary}, -// blockValue: common.Style{Font: common.FontMedium(), FontColor: common.TextPrimary}, -// blockLabel: common.Style{Font: common.FontSmall(), FontColor: common.TextAlt}, -// } -// } diff --git a/internal/stats/render/period/v2/misc-style.go b/internal/stats/render/period/v2/misc-style.go index 596677de..8eb77ff1 100644 --- a/internal/stats/render/period/v2/misc-style.go +++ b/internal/stats/render/period/v2/misc-style.go @@ -8,7 +8,7 @@ import ( ) var ( - clanTagBackgroundColor = color.NRGBA{60, 60, 60, 100} + clanTagBackgroundColor = color.NRGBA{40, 40, 40, 100} ) func styledPlayerName() style.Style { From 2c97c44caf92794c0148904dbc5ecfe3e429cd33 Mon Sep 17 00:00:00 2001 From: Vovko Date: Sat, 18 Jan 2025 22:49:05 -0500 Subject: [PATCH 36/39] mod tidy --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 864b114e..3eba1506 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( github.com/bwmarrin/discordgo v0.28.1 github.com/cufee/aftermath-assets v0.1.0 github.com/cufee/am-wg-proxy-next/v2 v2.2.6 - github.com/cufee/facepaint v0.0.4 + github.com/cufee/facepaint v0.0.5 github.com/fogleman/gg v1.3.0 github.com/go-co-op/gocron v1.37.0 github.com/goccy/go-json v0.10.4 diff --git a/go.sum b/go.sum index 1211ba52..2c440823 100644 --- a/go.sum +++ b/go.sum @@ -24,8 +24,8 @@ github.com/cufee/aftermath-assets v0.1.0 h1:r8p2mUN+h/cw1T6/oEX7bntD+lrL9Nz27GXO github.com/cufee/aftermath-assets v0.1.0/go.mod h1:6yCITCiz7POJnUMn1oohvadLA4z5YrFNo3p9EKgRdGU= github.com/cufee/am-wg-proxy-next/v2 v2.2.6 h1:6RAnPuYbPGtaLzOPhTk/N2Hx4KJx14x/c/cIik668xA= github.com/cufee/am-wg-proxy-next/v2 v2.2.6/go.mod h1:x6fkRfYry3l4Ykxl+v6pJAw5ISw+CuGzJzSkc5y5SYs= -github.com/cufee/facepaint v0.0.4 h1:QmMr4MuUDE/MYdYqAVkiurnh/meZhgfOqp+vkaadMi4= -github.com/cufee/facepaint v0.0.4/go.mod h1:7zR5lQMN3EO3qNtff0J8nzIhDb258UoYbRzhRToLQdg= +github.com/cufee/facepaint v0.0.5 h1:ae83MF7+gsgeJ+zr76aDmuzc2rqnVx/XGjAVC7e2kTM= +github.com/cufee/facepaint v0.0.5/go.mod h1:7zR5lQMN3EO3qNtff0J8nzIhDb258UoYbRzhRToLQdg= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= From 7312873796ba4bfa327a65ecb3f2b91645bb1d99 Mon Sep 17 00:00:00 2001 From: Vovko Date: Sat, 18 Jan 2025 22:55:24 -0500 Subject: [PATCH 37/39] reduced min ga --- internal/stats/render/period/v2/overview-style.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/stats/render/period/v2/overview-style.go b/internal/stats/render/period/v2/overview-style.go index 26bf27bb..f0a93cfb 100644 --- a/internal/stats/render/period/v2/overview-style.go +++ b/internal/stats/render/period/v2/overview-style.go @@ -78,7 +78,7 @@ var styledUnratedOverviewCard = overviewCardStyle{ BorderRadiusBottomRight: common.BorderRadiusLG, GrowHorizontal: true, - Gap: 20, + Gap: 15, PaddingLeft: 25, PaddingRight: 25, From 0895114be9dbd9e801312815759f04cbda763775 Mon Sep 17 00:00:00 2001 From: Vovko Date: Sat, 18 Jan 2025 23:02:51 -0500 Subject: [PATCH 38/39] fixed custom background --- internal/stats/render/period/v2/cards.go | 13 ++++++------- render_test.go | 4 ++-- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/internal/stats/render/period/v2/cards.go b/internal/stats/render/period/v2/cards.go index 1a49146a..1c0fb617 100644 --- a/internal/stats/render/period/v2/cards.go +++ b/internal/stats/render/period/v2/cards.go @@ -87,15 +87,14 @@ func generateCards(stats fetch.AccountStatsOverPeriod, cards period.Cards, _ []m cardsFrame := facepaint.NewBlocksContent(style.NewStyle(style.Parent(styledCardsFrame)), statsCards...) - // add background branding - if opts.Background != nil && !opts.BackgroundIsCustom { + // resize and place background + if opts.Background != nil { cardsFrameSize := cardsFrame.Dimensions() - seed, _ := strconv.Atoi(stats.Account.ID) opts.Background = imaging.Resize(opts.Background, cardsFrameSize.Width, cardsFrameSize.Height, imaging.Lanczos) - opts.Background = addBackgroundBranding(opts.Background, stats.RegularBattles.Vehicles, seed) - } - // add background - if opts.Background != nil { + if !opts.BackgroundIsCustom { + seed, _ := strconv.Atoi(stats.Account.ID) + opts.Background = addBackgroundBranding(opts.Background, stats.RegularBattles.Vehicles, seed) + } cardsFrame = facepaint.NewBlocksContent(style.NewStyle(), facepaint.MustNewImageContent(styledCardsBackground, opts.Background), cardsFrame, ) diff --git a/render_test.go b/render_test.go index 8b1523a4..d52d8f07 100644 --- a/render_test.go +++ b/render_test.go @@ -30,8 +30,8 @@ import ( _ "github.com/joho/godotenv/autoload" ) -var bgImage = "static://bg-default" -var bgIsCustom = false +var bgImage = "static://test_user_background" +var bgIsCustom = true func init() { loadStaticAssets(static) From 2a25fe621be1029a51e07611eab7c8953a201364 Mon Sep 17 00:00:00 2001 From: Vovko Date: Sat, 18 Jan 2025 23:04:53 -0500 Subject: [PATCH 39/39] removed restart from collector --- docker-compose.dokploy.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.dokploy.yaml b/docker-compose.dokploy.yaml index 373b65b9..e7ee8c24 100644 --- a/docker-compose.dokploy.yaml +++ b/docker-compose.dokploy.yaml @@ -3,7 +3,7 @@ services: extends: file: docker-compose.base.yaml service: aftermath-collector-base - restart: always + restart: no environment: - COLLECTOR_BACKEND_URL=aftermath-service:${PRIVATE_SERVER_PORT} networks: