Add TickPlacement option to category axis for between/on tick positioning

This commit is contained in:
yordanov
2026-06-18 15:44:03 +03:00
parent 4f340ac0b2
commit cdc0f410bc
8 changed files with 275 additions and 7 deletions

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)
{

View File

@@ -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
{

View File

@@ -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
{