mirror of
https://github.com/ppy/osu.git
synced 2024-11-06 06:17:23 +08:00
Merge pull request #10842 from EVAST9919/profile-overlay-graph-new
Implement history charts for Profile Overlay
This commit is contained in:
commit
d5a4d46c6d
181
osu.Game.Tests/Visual/Online/TestScenePlayHistorySubsection.cs
Normal file
181
osu.Game.Tests/Visual/Online/TestScenePlayHistorySubsection.cs
Normal file
@ -0,0 +1,181 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
using osu.Game.Overlays.Profile.Sections.Historical;
|
||||
using osu.Framework.Bindables;
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Game.Users;
|
||||
using NUnit.Framework;
|
||||
using osu.Game.Overlays;
|
||||
using osu.Framework.Allocation;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using osu.Framework.Testing;
|
||||
using osu.Framework.Graphics.Shapes;
|
||||
using static osu.Game.Users.User;
|
||||
|
||||
namespace osu.Game.Tests.Visual.Online
|
||||
{
|
||||
public class TestScenePlayHistorySubsection : OsuTestScene
|
||||
{
|
||||
[Cached]
|
||||
private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Red);
|
||||
|
||||
private readonly Bindable<User> user = new Bindable<User>();
|
||||
private readonly PlayHistorySubsection section;
|
||||
|
||||
public TestScenePlayHistorySubsection()
|
||||
{
|
||||
AddRange(new Drawable[]
|
||||
{
|
||||
new Box
|
||||
{
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
Colour = colourProvider.Background4
|
||||
},
|
||||
section = new PlayHistorySubsection(user)
|
||||
{
|
||||
Anchor = Anchor.Centre,
|
||||
Origin = Anchor.Centre
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestNullValues()
|
||||
{
|
||||
AddStep("Load user", () => user.Value = user_with_null_values);
|
||||
AddAssert("Section is hidden", () => section.Alpha == 0);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestEmptyValues()
|
||||
{
|
||||
AddStep("Load user", () => user.Value = user_with_empty_values);
|
||||
AddAssert("Section is hidden", () => section.Alpha == 0);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestOneValue()
|
||||
{
|
||||
AddStep("Load user", () => user.Value = user_with_one_value);
|
||||
AddAssert("Section is hidden", () => section.Alpha == 0);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestTwoValues()
|
||||
{
|
||||
AddStep("Load user", () => user.Value = user_with_two_values);
|
||||
AddAssert("Section is visible", () => section.Alpha == 1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestConstantValues()
|
||||
{
|
||||
AddStep("Load user", () => user.Value = user_with_constant_values);
|
||||
AddAssert("Section is visible", () => section.Alpha == 1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestConstantZeroValues()
|
||||
{
|
||||
AddStep("Load user", () => user.Value = user_with_zero_values);
|
||||
AddAssert("Section is visible", () => section.Alpha == 1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestFilledValues()
|
||||
{
|
||||
AddStep("Load user", () => user.Value = user_with_filled_values);
|
||||
AddAssert("Section is visible", () => section.Alpha == 1);
|
||||
AddAssert("Array length is the same", () => user_with_filled_values.MonthlyPlaycounts.Length == getChartValuesLength());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestMissingValues()
|
||||
{
|
||||
AddStep("Load user", () => user.Value = user_with_missing_values);
|
||||
AddAssert("Section is visible", () => section.Alpha == 1);
|
||||
AddAssert("Array length is 7", () => getChartValuesLength() == 7);
|
||||
}
|
||||
|
||||
private int getChartValuesLength() => this.ChildrenOfType<ProfileLineChart>().Single().Values.Length;
|
||||
|
||||
private static readonly User user_with_null_values = new User
|
||||
{
|
||||
Id = 1
|
||||
};
|
||||
|
||||
private static readonly User user_with_empty_values = new User
|
||||
{
|
||||
Id = 2,
|
||||
MonthlyPlaycounts = Array.Empty<UserHistoryCount>()
|
||||
};
|
||||
|
||||
private static readonly User user_with_one_value = new User
|
||||
{
|
||||
Id = 3,
|
||||
MonthlyPlaycounts = new[]
|
||||
{
|
||||
new UserHistoryCount { Date = new DateTime(2010, 5, 1), Count = 100 }
|
||||
}
|
||||
};
|
||||
|
||||
private static readonly User user_with_two_values = new User
|
||||
{
|
||||
Id = 4,
|
||||
MonthlyPlaycounts = new[]
|
||||
{
|
||||
new UserHistoryCount { Date = new DateTime(2010, 5, 1), Count = 1 },
|
||||
new UserHistoryCount { Date = new DateTime(2010, 6, 1), Count = 2 }
|
||||
}
|
||||
};
|
||||
|
||||
private static readonly User user_with_constant_values = new User
|
||||
{
|
||||
Id = 5,
|
||||
MonthlyPlaycounts = new[]
|
||||
{
|
||||
new UserHistoryCount { Date = new DateTime(2010, 5, 1), Count = 5 },
|
||||
new UserHistoryCount { Date = new DateTime(2010, 6, 1), Count = 5 },
|
||||
new UserHistoryCount { Date = new DateTime(2010, 7, 1), Count = 5 }
|
||||
}
|
||||
};
|
||||
|
||||
private static readonly User user_with_zero_values = new User
|
||||
{
|
||||
Id = 6,
|
||||
MonthlyPlaycounts = new[]
|
||||
{
|
||||
new UserHistoryCount { Date = new DateTime(2010, 5, 1), Count = 0 },
|
||||
new UserHistoryCount { Date = new DateTime(2010, 6, 1), Count = 0 },
|
||||
new UserHistoryCount { Date = new DateTime(2010, 7, 1), Count = 0 }
|
||||
}
|
||||
};
|
||||
|
||||
private static readonly User user_with_filled_values = new User
|
||||
{
|
||||
Id = 7,
|
||||
MonthlyPlaycounts = new[]
|
||||
{
|
||||
new UserHistoryCount { Date = new DateTime(2010, 5, 1), Count = 1000 },
|
||||
new UserHistoryCount { Date = new DateTime(2010, 6, 1), Count = 20 },
|
||||
new UserHistoryCount { Date = new DateTime(2010, 7, 1), Count = 20000 },
|
||||
new UserHistoryCount { Date = new DateTime(2010, 8, 1), Count = 30 },
|
||||
new UserHistoryCount { Date = new DateTime(2010, 9, 1), Count = 50 },
|
||||
new UserHistoryCount { Date = new DateTime(2010, 10, 1), Count = 2000 },
|
||||
new UserHistoryCount { Date = new DateTime(2010, 11, 1), Count = 2100 }
|
||||
}
|
||||
};
|
||||
|
||||
private static readonly User user_with_missing_values = new User
|
||||
{
|
||||
Id = 8,
|
||||
MonthlyPlaycounts = new[]
|
||||
{
|
||||
new UserHistoryCount { Date = new DateTime(2020, 1, 1), Count = 100 },
|
||||
new UserHistoryCount { Date = new DateTime(2020, 7, 1), Count = 200 }
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
@ -119,7 +119,11 @@ namespace osu.Game.Graphics.UserInterface
|
||||
|
||||
protected float GetYPosition(float value)
|
||||
{
|
||||
if (ActualMaxValue == ActualMinValue) return 0;
|
||||
if (ActualMaxValue == ActualMinValue)
|
||||
// show line at top if the only value on the graph is positive,
|
||||
// and at bottom if the only value on the graph is zero or negative.
|
||||
// just kind of makes most sense intuitively.
|
||||
return value > 1 ? 0 : 1;
|
||||
|
||||
return (ActualMaxValue - value) / (ActualMaxValue - ActualMinValue);
|
||||
}
|
||||
|
@ -0,0 +1,84 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using osu.Framework.Bindables;
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Framework.Graphics.Containers;
|
||||
using osu.Game.Users;
|
||||
using static osu.Game.Users.User;
|
||||
|
||||
namespace osu.Game.Overlays.Profile.Sections.Historical
|
||||
{
|
||||
public abstract class ChartProfileSubsection : ProfileSubsection
|
||||
{
|
||||
private ProfileLineChart chart;
|
||||
|
||||
protected ChartProfileSubsection(Bindable<User> user, string headerText)
|
||||
: base(user, headerText)
|
||||
{
|
||||
}
|
||||
|
||||
protected override Drawable CreateContent() => new Container
|
||||
{
|
||||
RelativeSizeAxes = Axes.X,
|
||||
AutoSizeAxes = Axes.Y,
|
||||
Padding = new MarginPadding
|
||||
{
|
||||
Top = 10,
|
||||
Left = 20,
|
||||
Right = 40
|
||||
},
|
||||
Child = chart = new ProfileLineChart()
|
||||
};
|
||||
|
||||
protected override void LoadComplete()
|
||||
{
|
||||
base.LoadComplete();
|
||||
User.BindValueChanged(onUserChanged, true);
|
||||
}
|
||||
|
||||
private void onUserChanged(ValueChangedEvent<User> e)
|
||||
{
|
||||
var values = GetValues(e.NewValue);
|
||||
|
||||
if (values == null || values.Length <= 1)
|
||||
{
|
||||
Hide();
|
||||
return;
|
||||
}
|
||||
|
||||
chart.Values = fillZeroValues(values);
|
||||
Show();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add entries for any missing months (filled with zero values).
|
||||
/// </summary>
|
||||
private UserHistoryCount[] fillZeroValues(UserHistoryCount[] historyEntries)
|
||||
{
|
||||
var filledHistoryEntries = new List<UserHistoryCount>();
|
||||
|
||||
foreach (var entry in historyEntries)
|
||||
{
|
||||
var lastFilled = filledHistoryEntries.LastOrDefault();
|
||||
|
||||
while (lastFilled?.Date.AddMonths(1) < entry.Date)
|
||||
{
|
||||
filledHistoryEntries.Add(lastFilled = new UserHistoryCount
|
||||
{
|
||||
Count = 0,
|
||||
Date = lastFilled.Date.AddMonths(1)
|
||||
});
|
||||
}
|
||||
|
||||
filledHistoryEntries.Add(entry);
|
||||
}
|
||||
|
||||
return filledHistoryEntries.ToArray();
|
||||
}
|
||||
|
||||
protected abstract UserHistoryCount[] GetValues(User user);
|
||||
}
|
||||
}
|
@ -0,0 +1,19 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
using osu.Framework.Bindables;
|
||||
using osu.Game.Users;
|
||||
using static osu.Game.Users.User;
|
||||
|
||||
namespace osu.Game.Overlays.Profile.Sections.Historical
|
||||
{
|
||||
public class PlayHistorySubsection : ChartProfileSubsection
|
||||
{
|
||||
public PlayHistorySubsection(Bindable<User> user)
|
||||
: base(user, "Play History")
|
||||
{
|
||||
}
|
||||
|
||||
protected override UserHistoryCount[] GetValues(User user) => user?.MonthlyPlaycounts;
|
||||
}
|
||||
}
|
@ -0,0 +1,259 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
using osu.Framework.Graphics.Containers;
|
||||
using osu.Framework.Graphics;
|
||||
using JetBrains.Annotations;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using osu.Game.Graphics.Sprites;
|
||||
using osu.Framework.Utils;
|
||||
using osu.Framework.Allocation;
|
||||
using osu.Game.Graphics;
|
||||
using osu.Framework.Graphics.Shapes;
|
||||
using osuTK;
|
||||
using static osu.Game.Users.User;
|
||||
|
||||
namespace osu.Game.Overlays.Profile.Sections.Historical
|
||||
{
|
||||
public class ProfileLineChart : CompositeDrawable
|
||||
{
|
||||
private UserHistoryCount[] values;
|
||||
|
||||
[NotNull]
|
||||
public UserHistoryCount[] Values
|
||||
{
|
||||
get => values;
|
||||
set
|
||||
{
|
||||
if (value.Length == 0)
|
||||
throw new ArgumentException("At least one value expected!", nameof(value));
|
||||
|
||||
graph.Values = values = value;
|
||||
|
||||
createRowTicks();
|
||||
createColumnTicks();
|
||||
}
|
||||
}
|
||||
|
||||
private readonly UserHistoryGraph graph;
|
||||
private readonly Container<TickText> rowTicksContainer;
|
||||
private readonly Container<TickText> columnTicksContainer;
|
||||
private readonly Container<TickLine> rowLinesContainer;
|
||||
private readonly Container<TickLine> columnLinesContainer;
|
||||
|
||||
public ProfileLineChart()
|
||||
{
|
||||
RelativeSizeAxes = Axes.X;
|
||||
Height = 250;
|
||||
InternalChild = new GridContainer
|
||||
{
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
ColumnDimensions = new[]
|
||||
{
|
||||
new Dimension(GridSizeMode.AutoSize),
|
||||
new Dimension()
|
||||
},
|
||||
RowDimensions = new[]
|
||||
{
|
||||
new Dimension(),
|
||||
new Dimension(GridSizeMode.AutoSize)
|
||||
},
|
||||
Content = new[]
|
||||
{
|
||||
new Drawable[]
|
||||
{
|
||||
rowTicksContainer = new Container<TickText>
|
||||
{
|
||||
RelativeSizeAxes = Axes.Y,
|
||||
AutoSizeAxes = Axes.X
|
||||
},
|
||||
new Container
|
||||
{
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
Children = new Drawable[]
|
||||
{
|
||||
new Container
|
||||
{
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
Children = new[]
|
||||
{
|
||||
rowLinesContainer = new Container<TickLine>
|
||||
{
|
||||
RelativeSizeAxes = Axes.Both
|
||||
},
|
||||
columnLinesContainer = new Container<TickLine>
|
||||
{
|
||||
RelativeSizeAxes = Axes.Both
|
||||
}
|
||||
}
|
||||
},
|
||||
graph = new UserHistoryGraph
|
||||
{
|
||||
RelativeSizeAxes = Axes.Both
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
new[]
|
||||
{
|
||||
Empty(),
|
||||
columnTicksContainer = new Container<TickText>
|
||||
{
|
||||
RelativeSizeAxes = Axes.X,
|
||||
AutoSizeAxes = Axes.Y,
|
||||
Padding = new MarginPadding { Top = 10 }
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private void createRowTicks()
|
||||
{
|
||||
rowTicksContainer.Clear();
|
||||
rowLinesContainer.Clear();
|
||||
|
||||
var min = values.Select(v => v.Count).Min();
|
||||
var max = values.Select(v => v.Count).Max();
|
||||
|
||||
var tickInterval = getTickInterval(max - min, 6);
|
||||
|
||||
for (long currentTick = 0; currentTick <= max; currentTick += tickInterval)
|
||||
{
|
||||
if (currentTick < min)
|
||||
continue;
|
||||
|
||||
float y;
|
||||
|
||||
// special-case the min == max case to match LineGraph.
|
||||
// lerp isn't really well-defined over a zero interval anyway.
|
||||
if (min == max)
|
||||
y = currentTick > 1 ? 1 : 0;
|
||||
else
|
||||
y = Interpolation.ValueAt(currentTick, 0, 1f, min, max);
|
||||
|
||||
// y axis is inverted in graph-like coordinates.
|
||||
addRowTick(-y, currentTick);
|
||||
}
|
||||
}
|
||||
|
||||
private void createColumnTicks()
|
||||
{
|
||||
columnTicksContainer.Clear();
|
||||
columnLinesContainer.Clear();
|
||||
|
||||
var totalMonths = values.Length;
|
||||
|
||||
int monthsPerTick = 1;
|
||||
|
||||
if (totalMonths > 80)
|
||||
monthsPerTick = 12;
|
||||
else if (totalMonths >= 45)
|
||||
monthsPerTick = 3;
|
||||
else if (totalMonths > 20)
|
||||
monthsPerTick = 2;
|
||||
|
||||
for (int i = 0; i < totalMonths; i += monthsPerTick)
|
||||
{
|
||||
var x = (float)i / (totalMonths - 1);
|
||||
addColumnTick(x, values[i].Date);
|
||||
}
|
||||
}
|
||||
|
||||
private void addRowTick(float y, double value)
|
||||
{
|
||||
rowTicksContainer.Add(new TickText
|
||||
{
|
||||
Anchor = Anchor.BottomRight,
|
||||
Origin = Anchor.CentreRight,
|
||||
RelativePositionAxes = Axes.Y,
|
||||
Margin = new MarginPadding { Right = 3 },
|
||||
Text = value.ToString("N0"),
|
||||
Font = OsuFont.GetFont(size: 12),
|
||||
Y = y
|
||||
});
|
||||
|
||||
rowLinesContainer.Add(new TickLine
|
||||
{
|
||||
Anchor = Anchor.BottomRight,
|
||||
Origin = Anchor.CentreRight,
|
||||
RelativeSizeAxes = Axes.X,
|
||||
RelativePositionAxes = Axes.Y,
|
||||
Height = 0.1f,
|
||||
EdgeSmoothness = Vector2.One,
|
||||
Y = y
|
||||
});
|
||||
}
|
||||
|
||||
private void addColumnTick(float x, DateTime value)
|
||||
{
|
||||
columnTicksContainer.Add(new TickText
|
||||
{
|
||||
Origin = Anchor.CentreLeft,
|
||||
RelativePositionAxes = Axes.X,
|
||||
Text = value.ToString("MMM yyyy"),
|
||||
Font = OsuFont.GetFont(size: 12, weight: FontWeight.SemiBold),
|
||||
Rotation = 45,
|
||||
X = x
|
||||
});
|
||||
|
||||
columnLinesContainer.Add(new TickLine
|
||||
{
|
||||
Origin = Anchor.TopCentre,
|
||||
RelativeSizeAxes = Axes.Y,
|
||||
RelativePositionAxes = Axes.X,
|
||||
Width = 0.1f,
|
||||
EdgeSmoothness = Vector2.One,
|
||||
X = x
|
||||
});
|
||||
}
|
||||
|
||||
private long getTickInterval(long range, int maxTicksCount)
|
||||
{
|
||||
// this interval is what would be achieved if the interval was divided perfectly evenly into maxTicksCount ticks.
|
||||
// can contain ugly fractional parts.
|
||||
var exactTickInterval = (float)range / (maxTicksCount - 1);
|
||||
|
||||
// the ideal ticks start with a 1, 2 or 5, and are multipliers of powers of 10.
|
||||
// first off, use log10 to calculate the number of digits in the "exact" interval.
|
||||
var numberOfDigits = Math.Floor(Math.Log10(exactTickInterval));
|
||||
var tickBase = Math.Pow(10, numberOfDigits);
|
||||
|
||||
// then see how the exact tick relates to the power of 10.
|
||||
var exactTickMultiplier = exactTickInterval / tickBase;
|
||||
|
||||
double tickMultiplier;
|
||||
|
||||
// round up the fraction to start with a 1, 2 or 5. closest match wins.
|
||||
if (exactTickMultiplier < 1.5)
|
||||
tickMultiplier = 1.0;
|
||||
else if (exactTickMultiplier < 3)
|
||||
tickMultiplier = 2.0;
|
||||
else if (exactTickMultiplier < 7)
|
||||
tickMultiplier = 5.0;
|
||||
else
|
||||
tickMultiplier = 10.0;
|
||||
|
||||
return Math.Max((long)(tickMultiplier * tickBase), 1);
|
||||
}
|
||||
|
||||
private class TickText : OsuSpriteText
|
||||
{
|
||||
[BackgroundDependencyLoader]
|
||||
private void load(OverlayColourProvider colourProvider)
|
||||
{
|
||||
Colour = colourProvider.Foreground1;
|
||||
}
|
||||
}
|
||||
|
||||
private class TickLine : Box
|
||||
{
|
||||
[BackgroundDependencyLoader]
|
||||
private void load(OverlayColourProvider colourProvider)
|
||||
{
|
||||
Colour = colourProvider.Background6;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,19 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
using osu.Framework.Bindables;
|
||||
using osu.Game.Users;
|
||||
using static osu.Game.Users.User;
|
||||
|
||||
namespace osu.Game.Overlays.Profile.Sections.Historical
|
||||
{
|
||||
public class ReplaysSubsection : ChartProfileSubsection
|
||||
{
|
||||
public ReplaysSubsection(Bindable<User> user)
|
||||
: base(user, "Replays Watched History")
|
||||
{
|
||||
}
|
||||
|
||||
protected override UserHistoryCount[] GetValues(User user) => user?.ReplaysWatchedCounts;
|
||||
}
|
||||
}
|
@ -18,8 +18,10 @@ namespace osu.Game.Overlays.Profile.Sections
|
||||
{
|
||||
Children = new Drawable[]
|
||||
{
|
||||
new PlayHistorySubsection(User),
|
||||
new PaginatedMostPlayedBeatmapContainer(User),
|
||||
new PaginatedScoreContainer(ScoreType.Recent, User, "Recent Plays (24h)", CounterVisibilityState.VisibleWhenZero),
|
||||
new ReplaysSubsection(User)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user