1
0
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:
Dean Herbert 2020-11-24 18:05:17 +09:00 committed by GitHub
commit d5a4d46c6d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 569 additions and 1 deletions

View 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 }
}
};
}
}

View File

@ -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);
}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}
}
}

View File

@ -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;
}
}

View File

@ -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)
};
}
}