diff --git a/docker-compose.dokploy.yaml b/docker-compose.dokploy.yaml index a523cfc5..e7ee8c24 100644 --- a/docker-compose.dokploy.yaml +++ b/docker-compose.dokploy.yaml @@ -33,7 +33,7 @@ services: - docker-volume-backup.stop-during-backup=true # https://hub.docker.com/r/offen/docker-volume-backup depends_on: aftermath-migrate: - condition: service_started + condition: service_completed_successfully networks: dokploy-network: diff --git a/file:/internal/database/migrations/20250119215341_init.down.sql b/file:/internal/database/migrations/20250119215341_init.down.sql deleted file mode 100644 index e69de29b..00000000 diff --git a/file:/internal/database/migrations/20250119215341_init.up.sql b/file:/internal/database/migrations/20250119215341_init.up.sql deleted file mode 100644 index e69de29b..00000000 diff --git a/internal/stats/client/v1/session.go b/internal/stats/client/v1/session.go index 129e4b9e..276b2376 100644 --- a/internal/stats/client/v1/session.go +++ b/internal/stats/client/v1/session.go @@ -16,7 +16,6 @@ import ( ) 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") @@ -46,7 +45,6 @@ func (c *client) EmptySessionCards(ctx context.Context, accountId string) (prepa } return cards, meta, nil - } func (c *client) SessionCards(ctx context.Context, accountId string, from time.Time, o ...options.RequestOption) (prepare.Cards, options.Metadata, error) { diff --git a/internal/stats/client/v2/session.go b/internal/stats/client/v2/session.go index 15716572..fa38d6e1 100644 --- a/internal/stats/client/v2/session.go +++ b/internal/stats/client/v2/session.go @@ -2,18 +2,149 @@ package client import ( "context" + "errors" + "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/common/v1" "github.com/cufee/aftermath/internal/stats/prepare/session/v1" + render "github.com/cufee/aftermath/internal/stats/render/session/v2" ) -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 (c *client) SessionCards(ctx context.Context, accountId string, from time.Time, o ...common.RequestOption) (session.Cards, common.Metadata, error) { + opts := common.RequestOptions(o).Options() + + meta := common.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()) + + return session.Cards{}, meta, common.ErrAccountNotTracked + } + if err != nil { + return session.Cards{}, meta, err + } + + printer, err := localization.NewPrinterWithFallback("stats", c.locale) + if err != nil { + return session.Cards{}, meta, err + } + + stop = meta.Timer("fetchClient#SessionStats") + if from.IsZero() { + from = time.Now() + } + sessionStats, careerStats, err := c.fetchClient.SessionStats(ctx, accountId, from, opts.FetchOpts()...) + stop() + if err != nil { + if errors.Is(err, fetch.ErrSessionNotFound) { + go recordAccountSnapshots(c.wargaming, c.database, accountId, opts.ReferenceID()) + } + return session.Cards{}, meta, err + } + meta.Stats["career"] = careerStats + meta.Stats["session"] = sessionStats + + stop = meta.Timer("prepare#GetVehicles") + var vehicles []string + for id := range sessionStats.RegularBattles.Vehicles { + vehicles = append(vehicles, id) + } + for id := range sessionStats.RatingBattles.Vehicles { + if !slices.Contains(vehicles, id) { + vehicles = append(vehicles, id) + } + } + for id := range careerStats.RegularBattles.Vehicles { + if !slices.Contains(vehicles, id) { + vehicles = append(vehicles, id) + } + } + for id := range careerStats.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 := c.database.GetVehicles(ctx, vehicles) + if err != nil { + return session.Cards{}, meta, err + } + stop() + + stop = meta.Timer("prepare#NewCards") + + cards, err := session.NewCards(sessionStats, careerStats, glossary, opts.PrepareOpts(printer, c.locale)...) + stop() + if err != nil { + return session.Cards{}, meta, err + } + + return cards, meta, nil } -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 (c *client) SessionImage(ctx context.Context, accountId string, from time.Time, o ...common.RequestOption) (common.Image, common.Metadata, error) { + opts := common.RequestOptions(o).Options() + + cards, meta, err := c.SessionCards(ctx, accountId, from, o...) + if err != nil { + return nil, meta, err + } + + printer, err := localization.NewPrinterWithFallback("stats", c.locale) + if err != nil { + return nil, meta, err + } + + stop := meta.Timer("render#CardsToImage") + image, err := render.CardsToImage(meta.Stats["session"], meta.Stats["career"], cards, opts.Subscriptions, opts.RenderOpts(printer)...) + stop() + if err != nil { + return nil, meta, err + } + + return &imageImp{image}, meta, err } -func (r *client) EmptySessionCards(ctx context.Context, accountId string) (session.Cards, common.Metadata, error) { - return r.v1.EmptySessionCards(ctx, accountId) + +func (c *client) EmptySessionCards(ctx context.Context, accountId string) (session.Cards, common.Metadata, error) { + meta := common.Metadata{Stats: make(map[string]fetch.AccountStatsOverPeriod)} + + stop := meta.Timer("database#GetAccountByID") + account, err := c.database.GetAccountByID(ctx, accountId) + stop() + if err != nil { + if database.IsNotFound(err) { + _, err := c.fetchClient.Account(ctx, accountId) // this will cache the account + if err != nil { + return session.Cards{}, meta, err + } + return session.Cards{}, meta, common.ErrAccountNotTracked + } + return session.Cards{}, meta, err + } + + printer, err := localization.NewPrinterWithFallback("stats", c.locale) + if err != nil { + return session.Cards{}, meta, err + } + + stop = meta.Timer("prepare#NewCards") + cards, err := session.NewCards(fetch.AccountStatsOverPeriod{Account: account}, fetch.AccountStatsOverPeriod{Account: account}, nil, prepare.WithPrinter(printer, c.locale)) + stop() + if err != nil { + return session.Cards{}, meta, err + } + + return cards, meta, nil } diff --git a/internal/stats/render/period/v2/overview.go b/internal/stats/render/period/v2/overview.go index b539c3f4..8360d98c 100644 --- a/internal/stats/render/period/v2/overview.go +++ b/internal/stats/render/period/v2/overview.go @@ -93,25 +93,3 @@ func newOverviewRatingBlock(blockStyle blockStyle, block prepare.StatsBlock[peri block.Label = common.GetRatingTierName(block.Value().Float()) return newOverviewBlockWithIcon(blockStyle, 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/session/v2/background.go b/internal/stats/render/session/v2/background.go new file mode 100644 index 00000000..b1286de7 --- /dev/null +++ b/internal/stats/render/session/v2/background.go @@ -0,0 +1,38 @@ +package session + +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/session/v2/cards.go b/internal/stats/render/session/v2/cards.go new file mode 100644 index 00000000..eb2addbd --- /dev/null +++ b/internal/stats/render/session/v2/cards.go @@ -0,0 +1,144 @@ +package session + +import ( + "strconv" + + prepare "github.com/cufee/aftermath/internal/stats/prepare/common/v1" + "github.com/cufee/aftermath/internal/stats/prepare/session/v1" + "github.com/cufee/facepaint/style" + "github.com/nao1215/imaging" + + "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/facepaint" +) + +func generateCards(session, career fetch.AccountStatsOverPeriod, cards session.Cards, _ []models.UserSubscription, opts common.Options) (*facepaint.Block, error) { + var ( + renderUnratedVehiclesCount = 3 // minimum number of vehicle cards + // primary cards + // when there are some unrated battles or no battles at all + shouldRenderUnratedOverview = session.RegularBattles.Battles > 0 || session.RatingBattles.Battles < 1 + // when there are 3 vehicle cards and no rating overview cards or there are 6 vehicle cards and some rating battles + shouldRenderUnratedHighlights = (session.RegularBattles.Battles > 0 && session.RatingBattles.Battles < 1 && len(cards.Unrated.Vehicles) > renderUnratedVehiclesCount) || + (session.RegularBattles.Battles > 0 && len(cards.Unrated.Vehicles) > 3) + shouldRenderRatingOverview = session.RatingBattles.Battles > 0 && opts.VehicleID == "" + // secondary cards + shouldRenderUnratedVehicles = session.RegularBattles.Battles > 0 && len(cards.Unrated.Vehicles) > 0 + ) + + // try to make the columns height roughly similar to primary column + if shouldRenderUnratedHighlights { + renderUnratedVehiclesCount += len(cards.Unrated.Highlights) + } + if shouldRenderRatingOverview { + renderUnratedVehiclesCount += 1 + } + + // calculate max overview block width to make all blocks the same size + var maxWidthOverviewColumn = make(map[string]float64) + for _, column := range cards.Unrated.Overview.Blocks { + for _, block := range column.Blocks { + switch block.Tag { + case prepare.TagWN8: + block.Label = common.GetWN8TierName(block.Value().Float()) + maxWidthOverviewColumn[string(column.Flavor)] = max(maxWidthOverviewColumn[string(column.Flavor)], iconSizeWN8) + } + maxWidthOverviewColumn[string(column.Flavor)] = max(maxWidthOverviewColumn[string(column.Flavor)], facepaint.MeasureString(block.Label, styledOverviewCard.styleBlock(block).label.Font).TotalWidth) + maxWidthOverviewColumn[string(column.Flavor)] = max(maxWidthOverviewColumn[string(column.Flavor)], facepaint.MeasureString(block.Value().String(), styledOverviewCard.styleBlock(block).value.Font).TotalWidth) + } + } + for _, column := range cards.Rating.Overview.Blocks { + for _, block := range column.Blocks { + switch block.Tag { + case prepare.TagRankedRating: + block.Label = common.GetRatingTierName(block.Value().Float()) + maxWidthOverviewColumn[string(column.Flavor)] = max(maxWidthOverviewColumn[string(column.Flavor)], iconSizeRating) + } + maxWidthOverviewColumn[string(column.Flavor)] = max(maxWidthOverviewColumn[string(column.Flavor)], facepaint.MeasureString(block.Label, styledOverviewCard.styleBlock(block).label.Font).TotalWidth) + maxWidthOverviewColumn[string(column.Flavor)] = max(maxWidthOverviewColumn[string(column.Flavor)], facepaint.MeasureString(block.Value().String(), styledOverviewCard.styleBlock(block).value.Font).TotalWidth) + } + } + + // calculate per block type width of highlight stats to make things even + var highlightBlockWidth = make(map[prepare.Tag]float64) + for _, highlight := range cards.Unrated.Highlights { + for _, block := range highlight.Blocks { + label := facepaint.MeasureString(block.Label, styledHighlightCard.blockLabel().Font).TotalWidth + value := facepaint.MeasureString(block.Value().String(), styledHighlightCard.blockValue().Font).TotalWidth + highlightBlockWidth[block.Tag] = max(highlightBlockWidth[block.Tag], label, value) + } + } + + // calculate per block type width of vehicle stats to make things even + var vehicleBlockWidth = make(map[prepare.Tag]float64) + for _, card := range cards.Unrated.Vehicles { + for _, block := range card.Blocks { + labelStyle := styledVehicleLegendPill() + label := facepaint.MeasureString(block.Label, labelStyle.Font).TotalWidth + labelStyle.PaddingLeft + labelStyle.PaddingRight + value := facepaint.MeasureString(block.Value().String(), styledVehicleCard.value(0).Font).TotalWidth + vehicleBlockWidth[block.Tag] = max(vehicleBlockWidth[block.Tag], label, value) + } + } + + var overviewCards = []*facepaint.Block{newPlayerNameCard(career.Account)} + // unrated overview + if shouldRenderUnratedOverview { + if card := newUnratedOverviewCard(cards.Unrated.Overview, maxWidthOverviewColumn); card != nil { + overviewCards = append(overviewCards, card) + } + } + // rating battles + if shouldRenderRatingOverview { + if card := newRatingOverviewCard(cards.Rating, maxWidthOverviewColumn); card != nil { + overviewCards = append(overviewCards, card) + } + } + // highlights + if shouldRenderUnratedHighlights { + for _, card := range cards.Unrated.Highlights { + overviewCards = append(overviewCards, newHighlightCard(card, highlightBlockWidth)) + } + } + + // vehicles + var vehicleCards []*facepaint.Block + if shouldRenderUnratedVehicles { + for i, card := range cards.Unrated.Vehicles { + if i == renderUnratedVehiclesCount { + break + } + vehicleCards = append(vehicleCards, newVehicleCard(card, vehicleBlockWidth)) + } + } + + var sectionBlocks []*facepaint.Block + sectionBlocks = append(sectionBlocks, facepaint.NewBlocksContent(style.NewStyle(style.Parent(styledCardsSection)), overviewCards...)) + if len(vehicleCards) > 0 { + vehicleCards = append(vehicleCards, newVehicleLegendCard(cards.Unrated.Vehicles[0], vehicleBlockWidth)) + sectionBlocks = append(sectionBlocks, facepaint.NewBlocksContent(style.NewStyle(style.Parent(styledCardsSection)), vehicleCards...)) + } + statsCardsBlock := facepaint.NewBlocksContent(style.NewStyle(style.Parent(styledCardsSectionsWrapper)), sectionBlocks...) + + cardsFrame := facepaint.NewBlocksContent(style.NewStyle(style.Parent(styledStatsFrame)), statsCardsBlock) + + // resize and place background + if opts.Background != nil { + cardsFrameSize := cardsFrame.Dimensions() + opts.Background = imaging.Fill(opts.Background, cardsFrameSize.Width, cardsFrameSize.Height, imaging.Center, imaging.Lanczos) + if !opts.BackgroundIsCustom { + seed, _ := strconv.Atoi(career.Account.ID) + opts.Background = addBackgroundBranding(opts.Background, session.RegularBattles.Vehicles, seed) + } + cardsFrame = facepaint.NewBlocksContent(style.NewStyle(), + facepaint.MustNewImageContent(styledCardsBackground, opts.Background), cardsFrame, + ) + } + + var frameCards []*facepaint.Block + frameCards = append(frameCards, cardsFrame) + frameCards = append(frameCards, newFooterCard(session, cards, opts)) + + return facepaint.NewBlocksContent(style.NewStyle(style.Parent(styledFinalFrame)), frameCards...), nil +} diff --git a/internal/stats/render/session/v2/highlight-style.go b/internal/stats/render/session/v2/highlight-style.go new file mode 100644 index 00000000..af4f74c4 --- /dev/null +++ b/internal/stats/render/session/v2/highlight-style.go @@ -0,0 +1,85 @@ +package session + +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 + statsWrapper 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, + + BackgroundColor: common.DefaultCardColor, + BlurBackground: cardBackgroundBlur, + + BorderRadiusTopLeft: common.BorderRadiusLG, + BorderRadiusTopRight: common.BorderRadiusLG, + BorderRadiusBottomLeft: common.BorderRadiusLG, + BorderRadiusBottomRight: common.BorderRadiusLG, + + GrowHorizontal: true, + Gap: 20, + + PaddingLeft: cardPaddingX / 1.5, + PaddingRight: cardPaddingX / 1.5, + PaddingTop: cardPaddingY / 1.5, + PaddingBottom: cardPaddingY / 1.5, + }, + titleWrapper: style.Style{ + // Debug: true, + + GrowHorizontal: true, + Direction: style.DirectionVertical, + }, + 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, + }, + statsWrapper: style.Style{ + // Debug: true, + + Direction: style.DirectionHorizontal, + AlignItems: style.AlignItemsCenter, + Gap: 10, + }, + 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/session/v2/highlight.go b/internal/stats/render/session/v2/highlight.go new file mode 100644 index 00000000..811d6ecf --- /dev/null +++ b/internal/stats/render/session/v2/highlight.go @@ -0,0 +1,29 @@ +package session + +import ( + prepare "github.com/cufee/aftermath/internal/stats/prepare/common/v1" + "github.com/cufee/aftermath/internal/stats/prepare/session/v1" + "github.com/cufee/facepaint" + "github.com/cufee/facepaint/style" +) + +func newHighlightCard(data session.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(), + leftSide, + facepaint.NewBlocksContent(styledHighlightCard.statsWrapper.Options(), rightSide...), + ) + +} diff --git a/internal/stats/render/session/v2/image.go b/internal/stats/render/session/v2/image.go new file mode 100644 index 00000000..2d6b255f --- /dev/null +++ b/internal/stats/render/session/v2/image.go @@ -0,0 +1,34 @@ +package session + +import ( + "image" + + "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/session/v1" +) + +type vehicleWN8 struct { + id string + wn8 frame.Value + sortKey int +} + +func CardsToImage(session, career fetch.AccountStatsOverPeriod, cards session.Cards, subs []models.UserSubscription, opts ...common.Option) (image.Image, error) { + o := common.DefaultOptions() + for _, apply := range opts { + apply(&o) + } + + // Generate cards + cardsBlock, err := generateCards(session, career, cards, subs, o) + if err != nil { + return nil, err + } + + // Render + return cardsBlock.Render() + +} diff --git a/internal/stats/render/session/v2/misc-style.go b/internal/stats/render/session/v2/misc-style.go new file mode 100644 index 00000000..b09fe7f8 --- /dev/null +++ b/internal/stats/render/session/v2/misc-style.go @@ -0,0 +1,148 @@ +package session + +import ( + "image/color" + + "github.com/cufee/aftermath/internal/render/common" + "github.com/cufee/facepaint/style" +) + +var ( + clanTagBackgroundColor = color.NRGBA{40, 40, 40, 100} + + cardBackgroundBlur = 20.0 + cardPaddingX = 35.0 + cardPaddingY = 30.0 +) + +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, + BlurBackground: cardBackgroundBlur, + + BorderRadiusTopLeft: common.BorderRadiusLG, + BorderRadiusTopRight: common.BorderRadiusLG, + BorderRadiusBottomLeft: common.BorderRadiusLG, + BorderRadiusBottomRight: common.BorderRadiusLG, + + PaddingLeft: 5, + PaddingRight: 5, + PaddingTop: 5, + PaddingBottom: 5, + + Height: 50, + + GrowHorizontal: true, + Gap: 20, +} + +var styledPlayerNameCard = style.Style{ + Direction: style.DirectionHorizontal, + AlignItems: style.AlignItemsCenter, + JustifyContent: style.JustifyContentSpaceAround, + + GrowHorizontal: 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, + + GrowVertical: true, + + PaddingLeft: 12, + PaddingRight: 12, + PaddingTop: 10, + PaddingBottom: 10, +} + +var styledCardsSection = style.Style{ + Debug: false, + + Direction: style.DirectionVertical, + AlignItems: style.AlignItemsCenter, + Gap: 10, +} + +var styledCardsSectionsWrapper = style.Style{ + Debug: false, + + Direction: style.DirectionHorizontal, + Gap: 20, +} + +var styledStatsFrame = style.Style{ + Debug: false, + + Direction: style.DirectionVertical, + Gap: 10, + + PaddingLeft: 30, + PaddingRight: 30, + PaddingTop: 30, + PaddingBottom: 30, +} + +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), +) + +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/session/v2/misc.go b/internal/stats/render/session/v2/misc.go new file mode 100644 index 00000000..6718d0c7 --- /dev/null +++ b/internal/stats/render/session/v2/misc.go @@ -0,0 +1,62 @@ +package session + +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/session/v1" + "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...) +} + +func newFooterCard(stats fetch.AccountStatsOverPeriod, cards session.Cards, opts common.Options) *facepaint.Block { + stl := styledFooterCard() + var footer []*facepaint.Block + if opts.VehicleID != "" { + footer = append(footer, facepaint.MustNewTextContent(stl.Options(), cards.Unrated.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...) +} diff --git a/internal/stats/render/session/v2/overview-style.go b/internal/stats/render/session/v2/overview-style.go new file mode 100644 index 00000000..9bc64490 --- /dev/null +++ b/internal/stats/render/session/v2/overview-style.go @@ -0,0 +1,134 @@ +package session + +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/session/v1" + "github.com/cufee/facepaint/style" +) + +const ( + debugOverviewCards = false + + iconSizeWN8 = 54.0 + iconSizeRating = 54.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 + styleBlock func(block prepare.StatsBlock[session.BlockData, string]) blockStyle +} + +// unrated + +var styledOverviewCard = overviewCardStyle{ + styleBlock: styleOverviewBlock, + card: style.Style{ + Debug: debugOverviewCards, + + Direction: style.DirectionHorizontal, + AlignItems: style.AlignItemsCenter, + JustifyContent: style.JustifyContentSpaceBetween, + + BackgroundColor: common.DefaultCardColor, + BlurBackground: cardBackgroundBlur, + + BorderRadiusTopLeft: common.BorderRadiusLG, + BorderRadiusTopRight: common.BorderRadiusLG, + BorderRadiusBottomLeft: common.BorderRadiusLG, + BorderRadiusBottomRight: common.BorderRadiusLG, + + GrowHorizontal: true, + Gap: 15, + + PaddingLeft: cardPaddingX, + PaddingRight: cardPaddingX, + PaddingTop: cardPaddingY, + PaddingBottom: cardPaddingY, + }, + column: style.Style{ + Debug: debugOverviewCards, + + Direction: style.DirectionVertical, + AlignItems: style.AlignItemsCenter, + JustifyContent: style.JustifyContentSpaceAround, + GrowVertical: true, + Gap: 10, + }, +} + +func styleOverviewBlock(block prepare.StatsBlock[session.BlockData, string]) blockStyle { + switch block.Tag { + case prepare.TagWN8, prepare.TagRankedRating: + return blockStyle{ + wrapper: style.Style{ + Debug: debugOverviewCards, + + Direction: style.DirectionVertical, + AlignItems: style.AlignItemsCenter, + JustifyContent: style.JustifyContentCenter, + Gap: 15, + }, + valueContainer: style.Style{ + Debug: debugOverviewCards, + + Direction: style.DirectionVertical, + AlignItems: style.AlignItemsCenter, + JustifyContent: style.JustifyContentEnd, + Gap: 0, + }, + value: style.Style{ + Debug: debugOverviewCards, + + PaddingTop: -6, + Color: common.TextPrimary, + Font: common.FontXL(), + }, + label: style.Style{ + Color: common.TextAlt, + Font: common.FontSmall(), + PaddingTop: -6, + }, + } + 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, + }, + } + } +} + +// wrapped around special block text and icon +var styledOverviewSpecialBlockWrapper = style.Style{ + Debug: debugOverviewCards, + + Direction: style.DirectionVertical, + AlignItems: style.AlignItemsCenter, + JustifyContent: style.JustifyContentCenter, + Gap: 10, +} diff --git a/internal/stats/render/session/v2/overview.go b/internal/stats/render/session/v2/overview.go new file mode 100644 index 00000000..379c5a00 --- /dev/null +++ b/internal/stats/render/session/v2/overview.go @@ -0,0 +1,96 @@ +package session + +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/session/v1" + "github.com/cufee/facepaint" + "github.com/cufee/facepaint/style" +) + +func newRatingOverviewCard(data session.RatingCards, columnWidth map[string]float64) *facepaint.Block { + if len(data.Overview.Blocks) == 0 { + return nil + } + + var columns []*facepaint.Block + for _, column := range data.Overview.Blocks { + columns = append(columns, newOverviewColumn(styledOverviewCard, column, columnWidth[string(column.Flavor)])) + } + // card + return facepaint.NewBlocksContent(styledOverviewCard.card.Options(), columns...) +} + +func newUnratedOverviewCard(data session.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(styledOverviewCard, column, columnWidth[string(column.Flavor)])) + } + // card + return facepaint.NewBlocksContent(styledOverviewCard.card.Options(), columns...) +} + +func newOverviewColumn(stl overviewCardStyle, data session.OverviewColumn, columnWidth float64) *facepaint.Block { + var columnBlocks []*facepaint.Block + for _, block := range data.Blocks { + switch block.Tag { + default: + columnBlocks = append(columnBlocks, newOverviewBlockWithIcon(stl.styleBlock(block), block, nil)) + case prepare.TagWN8: + columnBlocks = append(columnBlocks, newOverviewWN8Block(stl.styleBlock(block), block)) + case prepare.TagRankedRating: + columnBlocks = append(columnBlocks, newOverviewRatingBlock(stl.styleBlock(block), block)) + } + } + // column + return facepaint.NewBlocksContent(style.NewStyle( + style.Parent(stl.column), + style.SetWidth(columnWidth), + ), columnBlocks...) +} + +func newOverviewBlockWithIcon(blockStyle blockStyle, block prepare.StatsBlock[session.BlockData, string], icon *facepaint.Block) *facepaint.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 + 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(blockStyle blockStyle, block prepare.StatsBlock[session.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(blockStyle, block, icon) +} + +func newOverviewRatingBlock(blockStyle blockStyle, block prepare.StatsBlock[session.BlockData, string]) *facepaint.Block { + icon, _ := common.GetRatingIcon(block.V, iconSizeRating) + block.Label = common.GetRatingTierName(block.Value().Float()) + return newOverviewBlockWithIcon(blockStyle, block, icon) +} diff --git a/internal/stats/render/session/v2/vehicle-style.go b/internal/stats/render/session/v2/vehicle-style.go new file mode 100644 index 00000000..f7e73d0e --- /dev/null +++ b/internal/stats/render/session/v2/vehicle-style.go @@ -0,0 +1,105 @@ +package session + +import ( + "github.com/cufee/aftermath/internal/render/common" + "github.com/cufee/facepaint/style" +) + +const ( + debugVehicleCards = false + + vehicleIconSizeWN8 = 20.0 +) + +type vehicleCardStyle struct { + card style.StyleOptions + titleWrapper style.StyleOptions + titleText func() style.StyleOptions + + stats style.StyleOptions + value func(float64) *style.Style +} + +var styledVehicleLegendPillWrapper = style.NewStyle(style.Parent(style.Style{ + Direction: style.DirectionHorizontal, + JustifyContent: style.JustifyContentSpaceAround, + Gap: 5, +})) + +func styledVehicleLegendPill() *style.Style { + return &style.Style{ + Debug: debugVehicleCards, + + Color: common.TextAlt, + Font: common.FontSmall(), + + BackgroundColor: common.DefaultCardColor, + BlurBackground: cardBackgroundBlur, + + BorderRadiusTopLeft: common.BorderRadiusSM, + BorderRadiusTopRight: common.BorderRadiusSM, + BorderRadiusBottomLeft: common.BorderRadiusSM, + BorderRadiusBottomRight: common.BorderRadiusSM, + + PaddingLeft: 15, + PaddingRight: 15, + PaddingTop: 5, + PaddingBottom: 5, + } +} + +var styledVehicleCard = vehicleCardStyle{ + card: style.NewStyle(style.Parent(style.Style{ + Debug: debugVehicleCards, + + Direction: style.DirectionVertical, + + BackgroundColor: common.DefaultCardColor, + BlurBackground: cardBackgroundBlur, + + BorderRadiusTopLeft: common.BorderRadiusLG, + BorderRadiusTopRight: common.BorderRadiusLG, + BorderRadiusBottomLeft: common.BorderRadiusLG, + BorderRadiusBottomRight: common.BorderRadiusLG, + + GrowHorizontal: true, + Gap: 5, + + PaddingLeft: cardPaddingX / 1.5, + PaddingRight: cardPaddingX / 1.5, + PaddingTop: cardPaddingY / 2, + PaddingBottom: cardPaddingY / 2, + })), + + titleWrapper: style.NewStyle(style.Parent(style.Style{ + Debug: debugVehicleCards, + + GrowHorizontal: true, + Gap: 10, + JustifyContent: style.JustifyContentSpaceBetween, + })), + titleText: func() style.StyleOptions { + return style.NewStyle(style.Parent(style.Style{ + Color: common.TextSecondary, + Font: common.FontMedium(), + })) + }, + + stats: style.NewStyle(style.Parent(style.Style{ + Debug: debugVehicleCards, + + Direction: style.DirectionHorizontal, + JustifyContent: style.JustifyContentSpaceBetween, + GrowHorizontal: true, + Gap: 10, + })), + value: func(width float64) *style.Style { + return &style.Style{ + Width: width, + Color: common.TextPrimary, + Font: common.FontLarge(), + GrowHorizontal: true, + JustifyContent: style.JustifyContentCenter, + } + }, +} diff --git a/internal/stats/render/session/v2/vehicle.go b/internal/stats/render/session/v2/vehicle.go new file mode 100644 index 00000000..a916f3d9 --- /dev/null +++ b/internal/stats/render/session/v2/vehicle.go @@ -0,0 +1,57 @@ +package session + +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/session/v1" + "github.com/cufee/facepaint" + "github.com/cufee/facepaint/style" +) + +func newVehicleCard(data session.VehicleCard, blockWidth map[prepare.Tag]float64) *facepaint.Block { + title := facepaint.NewBlocksContent(styledVehicleCard.titleWrapper, + facepaint.MustNewTextContent(styledVehicleCard.titleText(), data.Title), + newVehicleWN8Icon(data), + ) + + var statsBlocks []*facepaint.Block + for _, block := range data.Blocks { + statsBlocks = append(statsBlocks, facepaint.MustNewTextContent(styledVehicleCard.value(blockWidth[block.Tag]).Options(), block.Value().String())) + } + + return facepaint.NewBlocksContent(styledVehicleCard.card, + title, + facepaint.NewBlocksContent(styledVehicleCard.stats, statsBlocks...), + ) + +} + +func newVehicleLegendCard(data session.VehicleCard, blockWidth map[prepare.Tag]float64) *facepaint.Block { + var legendBlocks []*facepaint.Block + for _, block := range data.Blocks { + legendBlocks = append(legendBlocks, facepaint.MustNewTextContent(styledVehicleLegendPill().Options(), block.Label)) + } + + return facepaint.NewBlocksContent(styledVehicleLegendPillWrapper, + facepaint.NewBlocksContent(styledVehicleCard.stats, legendBlocks...), + ) + +} + +func newVehicleWN8Icon(data session.VehicleCard) *facepaint.Block { + for _, block := range data.Blocks { + if block.Tag != prepare.TagWN8 { + continue + } + ratingColors := common.GetWN8Colors(block.Value().Float()) + if block.Value().Float() <= 0 { + ratingColors.Background = common.TextAlt + } + icon, _ := facepaint.NewImageContent( + style.NewStyle(style.SetWidth(vehicleIconSizeWN8)), + common.AftermathLogo(ratingColors.Background, common.SmallLogoOptions()), + ) + return icon + } + return facepaint.NewEmptyContent(style.NewStyle(style.SetWidth(vehicleIconSizeWN8))) +} diff --git a/render_v2_test.go b/render_v2_test.go index f480fdae..2fec6dec 100644 --- a/render_v2_test.go +++ b/render_v2_test.go @@ -2,12 +2,15 @@ package main import ( "context" + "image/png" "os" "testing" "time" common "github.com/cufee/aftermath/internal/stats/client/common" + options "github.com/cufee/aftermath/internal/stats/client/common" client "github.com/cufee/aftermath/internal/stats/client/v2" + session "github.com/cufee/aftermath/internal/stats/render/session/v1" "github.com/cufee/aftermath/tests" "github.com/cufee/aftermath/tests/env" "github.com/stretchr/testify/assert" @@ -72,3 +75,69 @@ func TestRenderPeriodV2(t *testing.T) { assert.NoError(t, err, "failed to encode a png image") }) } + +func TestRenderSessionV2(t *testing.T) { + env.LoadTestEnv(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(), options.WithWN8()) + assert.NoError(t, err, "failed to generate session cards") + + segments, err := session.CardsToSegments(meta.Stats["session"], meta.Stats["career"], cards, nil) + assert.NoError(t, err, "failed to render a session segments") + assert.NotNil(t, segments, "segments is nil") + + mask, err := segments.ContentMask() + assert.NoError(t, err, "failed to generate content mask") + + f, err := os.Create("tmp/render_test_session_full_small_mask.png") + assert.NoError(t, err, "failed to create a file") + defer f.Close() + + err = png.Encode(f, mask) + assert.NoError(t, err, "failed to encode a png image") + + _, err = segments.Render() + assert.NoError(t, err, "failed to render segments") + }) + + t.Run("render session image for small nickname", func(t *testing.T) { + 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") + + f, err := os.Create("tmp/render_test_session_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 session image for large nickname", func(t *testing.T) { + 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") + + f, err := os.Create("tmp/render_test_session_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 session image for large nickname and no vehicles", func(t *testing.T) { + 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") + + f, err := os.Create("tmp/render_test_session_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") + }) +}