mirror of
https://github.com/radzenhq/radzen-blazor.git
synced 2026-07-08 18:16:08 +00:00
Add TickPlacement option to category axis for between/on tick positioning
This commit is contained in:
181
Radzen.Blazor.Tests/CategoryPlacementTests.cs
Normal file
181
Radzen.Blazor.Tests/CategoryPlacementTests.cs
Normal file
@@ -0,0 +1,181 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Bunit;
|
||||
using Xunit;
|
||||
using static Radzen.Blazor.Tests.ChartTestHelper;
|
||||
|
||||
namespace Radzen.Blazor.Tests
|
||||
{
|
||||
public class CategoryPlacementTests
|
||||
{
|
||||
static OrdinalScale OrdinalWith(int count, TickPlacement placement)
|
||||
{
|
||||
var data = new List<object>();
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
data.Add($"C{i}");
|
||||
}
|
||||
|
||||
return new OrdinalScale { Data = data, Placement = placement };
|
||||
}
|
||||
|
||||
// --- OrdinalScale domain (Ticks) and labels (TickValues) ---
|
||||
|
||||
[Fact]
|
||||
public void Between_ReservesHalfABandEachSide()
|
||||
{
|
||||
var scale = OrdinalWith(12, TickPlacement.Between);
|
||||
|
||||
Assert.Equal((-0.5, 11.5, 1.0), scale.Ticks(100));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void On_IsFlush()
|
||||
{
|
||||
var scale = OrdinalWith(12, TickPlacement.On);
|
||||
|
||||
Assert.Equal((0.0, 11.0, 1.0), scale.Ticks(100));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void On_SinglePoint_FallsBackToHalfBand()
|
||||
{
|
||||
// Flush on a single category would collapse the input to zero width; keep a half-band domain.
|
||||
var scale = OrdinalWith(1, TickPlacement.On);
|
||||
|
||||
Assert.Equal((-0.5, 0.5, 1.0), scale.Ticks(100));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(TickPlacement.Between)]
|
||||
[InlineData(TickPlacement.On)]
|
||||
public void TickValues_AlwaysIntegerCategories(TickPlacement placement)
|
||||
{
|
||||
var scale = OrdinalWith(4, placement);
|
||||
|
||||
Assert.Equal(new[] { 0.0, 1.0, 2.0, 3.0 }, scale.TickValues(100));
|
||||
}
|
||||
|
||||
// --- Chart-level: vertical-category (column) routes placement to CategoryScale ---
|
||||
|
||||
static async Task<RadzenChart> RenderColumn(TestContext ctx, TickPlacement placement)
|
||||
{
|
||||
var chart = ctx.RenderComponent<RadzenChart>(p => p
|
||||
.AddChildContent<RadzenCategoryAxis>(axis => axis.Add(x => x.TickPlacement, placement))
|
||||
.AddChildContent<RadzenColumnSeries<DataItem>>(s => s
|
||||
.Add(x => x.CategoryProperty, nameof(DataItem.Category))
|
||||
.Add(x => x.ValueProperty, nameof(DataItem.Value))
|
||||
.Add(x => x.Data, SampleData)));
|
||||
|
||||
await chart.InvokeAsync(() => chart.Instance.Resize(400, 300));
|
||||
return chart.Instance;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Column_Between_OrdinalIsCategoryScale_HalfBandDomain()
|
||||
{
|
||||
using var ctx = CreateChartContext();
|
||||
|
||||
var instance = await RenderColumn(ctx, TickPlacement.Between);
|
||||
var scale = Assert.IsType<OrdinalScale>(instance.CategoryScale);
|
||||
|
||||
Assert.Equal(TickPlacement.Between, scale.Placement);
|
||||
Assert.Equal(-0.5, scale.Input.Start);
|
||||
Assert.Equal(SampleData.Length - 0.5, scale.Input.End);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Column_On_IsFlush()
|
||||
{
|
||||
using var ctx = CreateChartContext();
|
||||
|
||||
var instance = await RenderColumn(ctx, TickPlacement.On);
|
||||
var scale = Assert.IsType<OrdinalScale>(instance.CategoryScale);
|
||||
|
||||
Assert.Equal(0, scale.Input.Start);
|
||||
Assert.Equal(SampleData.Length - 1, scale.Input.End);
|
||||
}
|
||||
|
||||
// --- Chart-level: bar routes placement to ValueScale (axes swapped) ---
|
||||
|
||||
static async Task<RadzenChart> RenderBar(TestContext ctx, TickPlacement placement)
|
||||
{
|
||||
var chart = ctx.RenderComponent<RadzenChart>(p => p
|
||||
.AddChildContent<RadzenCategoryAxis>(axis => axis.Add(x => x.TickPlacement, placement))
|
||||
.AddChildContent<RadzenBarSeries<DataItem>>(s => s
|
||||
.Add(x => x.CategoryProperty, nameof(DataItem.Category))
|
||||
.Add(x => x.ValueProperty, nameof(DataItem.Value))
|
||||
.Add(x => x.Data, SampleData)));
|
||||
|
||||
await chart.InvokeAsync(() => chart.Instance.Resize(400, 300));
|
||||
return chart.Instance;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bar_OrdinalIsValueScale_PlacementApplied()
|
||||
{
|
||||
using var ctx = CreateChartContext();
|
||||
|
||||
// Inverted axes: the ordinal scale is the ValueScale, not the CategoryScale.
|
||||
var instance = await RenderBar(ctx, TickPlacement.Between);
|
||||
var scale = Assert.IsType<OrdinalScale>(instance.ValueScale);
|
||||
|
||||
Assert.Equal(TickPlacement.Between, scale.Placement);
|
||||
Assert.Equal(-0.5, scale.Input.Start);
|
||||
Assert.Equal(SampleData.Length - 0.5, scale.Input.End);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(TickPlacement.Between)]
|
||||
[InlineData(TickPlacement.On)]
|
||||
public async Task Bar_CategoryLabels_AllRenderExactlyOnce(TickPlacement placement)
|
||||
{
|
||||
using var ctx = CreateChartContext();
|
||||
|
||||
var chart = ctx.RenderComponent<RadzenChart>(p => p
|
||||
.AddChildContent<RadzenCategoryAxis>(axis => axis.Add(x => x.TickPlacement, placement))
|
||||
.AddChildContent<RadzenBarSeries<DataItem>>(s => s
|
||||
.Add(x => x.CategoryProperty, nameof(DataItem.Category))
|
||||
.Add(x => x.ValueProperty, nameof(DataItem.Value))
|
||||
.Add(x => x.Data, SampleData)));
|
||||
|
||||
await chart.InvokeAsync(() => chart.Instance.Resize(400, 300));
|
||||
|
||||
// The vertical category axis must show each category once (no half-band rounding to dupes).
|
||||
foreach (var category in new[] { "A", "B", "C" })
|
||||
{
|
||||
Assert.Equal(1, System.Text.RegularExpressions.Regex.Matches(chart.Markup, $">{category}</text>").Count);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Bullet: ordinal on ValueScale even though the chart is not inverted ---
|
||||
|
||||
[Fact]
|
||||
public async Task Bullet_PlacementApplied_OnValueScale()
|
||||
{
|
||||
using var ctx = CreateChartContext();
|
||||
|
||||
var data = new[]
|
||||
{
|
||||
new DataItem { Category = "A", Value = 20, Target = 25, Max = 40 },
|
||||
new DataItem { Category = "B", Value = 18, Target = 22, Max = 40 },
|
||||
};
|
||||
|
||||
var chart = ctx.RenderComponent<RadzenChart>(p => p
|
||||
.AddChildContent<RadzenCategoryAxis>(axis => axis.Add(x => x.TickPlacement, TickPlacement.On))
|
||||
.AddChildContent<RadzenBulletSeries<DataItem>>(s => s
|
||||
.Add(x => x.CategoryProperty, nameof(DataItem.Category))
|
||||
.Add(x => x.ValueProperty, nameof(DataItem.Value))
|
||||
.Add(x => x.TargetProperty, nameof(DataItem.Target))
|
||||
.Add(x => x.MaxProperty, nameof(DataItem.Max))
|
||||
.Add(x => x.Data, data)));
|
||||
|
||||
await chart.InvokeAsync(() => chart.Instance.Resize(400, 300));
|
||||
|
||||
var scale = Assert.IsType<OrdinalScale>(chart.Instance.ValueScale);
|
||||
Assert.Equal(TickPlacement.On, scale.Placement);
|
||||
Assert.Equal(0, scale.Input.Start);
|
||||
Assert.Equal(1, scale.Input.End);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,13 @@ namespace Radzen.Blazor
|
||||
{
|
||||
public IList<object>? Data { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Controls the space reserved at each end of the axis. <see cref="TickPlacement.Between" />
|
||||
/// (the default) centers each category in its band, reserving half a band at each end;
|
||||
/// <see cref="TickPlacement.On" /> places categories on the ticks, flush to the plot edges.
|
||||
/// </summary>
|
||||
public TickPlacement Placement { get; set; } = TickPlacement.Between;
|
||||
|
||||
public override object Value(double value)
|
||||
{
|
||||
if (Data == null)
|
||||
@@ -26,11 +33,30 @@ namespace Radzen.Blazor
|
||||
|
||||
public override (double Start, double End, double Step) Ticks(int distance)
|
||||
{
|
||||
var start = -1;
|
||||
var end = Data?.Count ?? 0;
|
||||
var step = 1;
|
||||
var count = Data?.Count ?? 0;
|
||||
|
||||
return (start, end, step);
|
||||
// A single (or no) category cannot span a range edge-to-edge without collapsing the input
|
||||
// to zero width, so keep a symmetric half-band domain in that case.
|
||||
if (Placement == TickPlacement.On && count >= 2)
|
||||
{
|
||||
// Flush: categories sit on the ticks, first/last at the plot edges.
|
||||
return (0, count - 1, 1);
|
||||
}
|
||||
|
||||
// Between: half a band of slack on each side so categories are centered in their band.
|
||||
return (-0.5, count - 0.5, 1);
|
||||
}
|
||||
|
||||
public override IEnumerable<double> TickValues(int distance)
|
||||
{
|
||||
// Ticks define the domain extent (which may include half-band slack); labels and gridlines
|
||||
// always land on the integer category indices regardless of placement.
|
||||
var count = Data?.Count ?? 0;
|
||||
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
yield return i;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,21 @@
|
||||
[Parameter]
|
||||
public double Padding { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Specifies whether categories are placed between ticks (reserving half a band at each end) or
|
||||
/// on the ticks (flush to the plot edges). The default is <see cref="TickPlacement.Between" />.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public TickPlacement TickPlacement { get; set; } = TickPlacement.Between;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override bool ShouldRefreshChart(ParameterView parameters)
|
||||
{
|
||||
return base.ShouldRefreshChart(parameters)
|
||||
|| DidParameterChange(parameters, nameof(Padding), Padding)
|
||||
|| DidParameterChange(parameters, nameof(TickPlacement), TickPlacement);
|
||||
}
|
||||
|
||||
internal override double Size
|
||||
{
|
||||
get
|
||||
|
||||
@@ -646,6 +646,15 @@ namespace Radzen.Blazor
|
||||
CategoryScale.Padding = CategoryAxis.Padding;
|
||||
}
|
||||
|
||||
// The ordinal (category) scale is the CategoryScale normally, but bar-family series and
|
||||
// bullet swap the axes so it becomes the ValueScale. Apply the placement to whichever it is,
|
||||
// for both orientations.
|
||||
var ordinalScale = (CategoryScale as OrdinalScale) ?? (ValueScale as OrdinalScale);
|
||||
if (ordinalScale != null)
|
||||
{
|
||||
ordinalScale.Placement = CategoryAxis.TickPlacement;
|
||||
}
|
||||
|
||||
CategoryScale.Resize(xAxis.Min!, xAxis.Max!);
|
||||
|
||||
if (xAxis.Step != null)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
{
|
||||
<g class="rz-axis rz-value-axis @(RenderAsRightSide ? "rz-value-axis-right" : "")">
|
||||
<Line class="rz-line" X1="@axisX" Y1="@Math.Min(y1, y2)" X2="@axisX" Y2="@Math.Max(y1, y2)" Stroke="@YAxis.Stroke" StrokeWidth="@YAxis.StrokeWidth" LineType="@YAxis.LineType" />
|
||||
@for (var idx = start; idx <= end; idx = isLogarithmic ? idx * step : idx + step)
|
||||
@foreach (var idx in tickValues)
|
||||
{
|
||||
var value = scale?.Value(idx);
|
||||
var y = scale?.Scale(idx) ?? 0;
|
||||
@@ -51,6 +51,7 @@
|
||||
double start;
|
||||
double end;
|
||||
double step;
|
||||
System.Collections.Generic.List<double> tickValues = new();
|
||||
double x1;
|
||||
double x2;
|
||||
double y1;
|
||||
@@ -104,6 +105,10 @@
|
||||
start = ticks.Start;
|
||||
end = ticks.End;
|
||||
step = ticks.Step;
|
||||
// Enumerate via TickValues so ordinal (category) scales place labels on integer categories
|
||||
// rather than stepping across the domain (which may carry half-band slack). Equivalent to the
|
||||
// old start..end stepping for numeric/logarithmic value scales.
|
||||
tickValues = scale.TickValues(YAxis?.TickDistance ?? 0).ToList();
|
||||
|
||||
var categoryTicks = Chart.CategoryScale.Ticks(XAxis.TickDistance);
|
||||
x1 = Chart.CategoryScale.Scale(categoryTicks.Start);
|
||||
|
||||
@@ -31,6 +31,15 @@
|
||||
</Items>
|
||||
</RadzenSelectBar>
|
||||
</RadzenStack>
|
||||
<RadzenStack Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center" Gap="0.5rem">
|
||||
<RadzenLabel Text="Tick Placement" Component="tickPlacement" />
|
||||
<RadzenSelectBar @bind-Value="@tickPlacement" TValue="TickPlacement" Size="ButtonSize.Small" Name="tickPlacement">
|
||||
<Items>
|
||||
<RadzenSelectBarItem Value="TickPlacement.Between" Text="Between" />
|
||||
<RadzenSelectBarItem Value="TickPlacement.On" Text="On" />
|
||||
</Items>
|
||||
</RadzenSelectBar>
|
||||
</RadzenStack>
|
||||
</RadzenStack>
|
||||
</RadzenCard>
|
||||
<RadzenRow>
|
||||
@@ -43,6 +52,7 @@
|
||||
<RadzenBarSeries FillMode="@fillMode" Data="@revenue2023" CategoryProperty="Quarter" Title="2023" ValueProperty="Revenue">
|
||||
<RadzenSeriesDataLabels Visible="@showDataLabels" Position="@labelPosition" />
|
||||
</RadzenBarSeries>
|
||||
<RadzenCategoryAxis TickPlacement="@tickPlacement" />
|
||||
<RadzenValueAxis Formatter="@FormatAsUSD">
|
||||
<RadzenGridLines Visible="true" />
|
||||
<RadzenAxisTitle Text="Revenue in USD" />
|
||||
@@ -76,6 +86,7 @@
|
||||
bool showDataLabels = false;
|
||||
DataLabelPosition labelPosition = DataLabelPosition.Auto;
|
||||
FillMode fillMode = FillMode.Gradient;
|
||||
TickPlacement tickPlacement = TickPlacement.Between;
|
||||
|
||||
void OnSeriesClick(SeriesClickEventArgs args)
|
||||
{
|
||||
|
||||
@@ -31,6 +31,15 @@
|
||||
</Items>
|
||||
</RadzenSelectBar>
|
||||
</RadzenStack>
|
||||
<RadzenStack Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center" Gap="0.5rem">
|
||||
<RadzenLabel Text="Tick Placement" Component="tickPlacement" />
|
||||
<RadzenSelectBar @bind-Value="@tickPlacement" TValue="TickPlacement" Size="ButtonSize.Small" Name="tickPlacement">
|
||||
<Items>
|
||||
<RadzenSelectBarItem Value="TickPlacement.Between" Text="Between" />
|
||||
<RadzenSelectBarItem Value="TickPlacement.On" Text="On" />
|
||||
</Items>
|
||||
</RadzenSelectBar>
|
||||
</RadzenStack>
|
||||
</RadzenStack>
|
||||
</RadzenCard>
|
||||
<RadzenRow>
|
||||
@@ -44,13 +53,14 @@
|
||||
<RadzenSeriesDataLabels Visible="@showDataLabels" Position="@labelPosition" />
|
||||
</RadzenColumnSeries>
|
||||
<RadzenColumnOptions Radius="5" />
|
||||
<RadzenCategoryAxis TickPlacement="@tickPlacement" />
|
||||
<RadzenValueAxis Formatter="@FormatAsUSD">
|
||||
<RadzenGridLines Visible="true" />
|
||||
<RadzenAxisTitle Text="Revenue in USD" />
|
||||
</RadzenValueAxis>
|
||||
</RadzenChart>
|
||||
</RadzenColumn>
|
||||
|
||||
|
||||
<RadzenColumn Size="12">
|
||||
<h4>Custom size column series</h4>
|
||||
<RadzenChart Animate="true">
|
||||
@@ -93,6 +103,7 @@
|
||||
bool showDataLabels = false;
|
||||
DataLabelPosition labelPosition = DataLabelPosition.Auto;
|
||||
FillMode fillMode = FillMode.Gradient;
|
||||
TickPlacement tickPlacement = TickPlacement.Between;
|
||||
|
||||
class DataItem
|
||||
{
|
||||
|
||||
@@ -26,6 +26,15 @@
|
||||
<RadzenCheckBox @bind-Value="@sharedTooltip" Name="sharedToltip"></RadzenCheckBox>
|
||||
<RadzenLabel Text="Shared Tooltip" Component="sharedTooltip" />
|
||||
</RadzenStack>
|
||||
<RadzenStack Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center" Gap="0.5rem">
|
||||
<RadzenLabel Text="Tick Placement" Component="tickPlacement" />
|
||||
<RadzenSelectBar @bind-Value="@tickPlacement" TValue="TickPlacement" Size="ButtonSize.Small" Name="tickPlacement">
|
||||
<Items>
|
||||
<RadzenSelectBarItem Value="TickPlacement.Between" Text="Between" />
|
||||
<RadzenSelectBarItem Value="TickPlacement.On" Text="On" />
|
||||
</Items>
|
||||
</RadzenSelectBar>
|
||||
</RadzenStack>
|
||||
</RadzenStack>
|
||||
</RadzenCard>
|
||||
|
||||
@@ -39,7 +48,7 @@
|
||||
<RadzenMarkers Visible="@showMarkers" MarkerType="MarkerType.Circle" />
|
||||
<RadzenSeriesDataLabels Visible="@showDataLabels" Step="@labelStep" />
|
||||
</RadzenLineSeries>
|
||||
<RadzenCategoryAxis Padding="20">
|
||||
<RadzenCategoryAxis TickPlacement="@tickPlacement">
|
||||
<RadzenAxisCrosshair Visible="true" />
|
||||
</RadzenCategoryAxis>
|
||||
<RadzenValueAxis Formatter="@FormatAsUSD">
|
||||
@@ -55,6 +64,7 @@
|
||||
bool showDataLabels = false;
|
||||
int labelStep = 1;
|
||||
bool showMarkers = true;
|
||||
TickPlacement tickPlacement = TickPlacement.Between;
|
||||
|
||||
class DataItem
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user