diff --git a/Radzen.Blazor.Tests/BarSeriesMultipleAxesTests.cs b/Radzen.Blazor.Tests/BarSeriesMultipleAxesTests.cs new file mode 100644 index 000000000..7f892525d --- /dev/null +++ b/Radzen.Blazor.Tests/BarSeriesMultipleAxesTests.cs @@ -0,0 +1,176 @@ +using System; +using System.Linq; +using Bunit; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Radzen.Blazor.Tests; + +public class BarSeriesMultipleAxesTests +{ + private class Item + { + public string Month { get; set; } = ""; + public double Revenue { get; set; } + public double Count { get; set; } + } + + private static readonly Item[] Data = + { + new() { Month = "Jan", Revenue = 234000, Count = 320 }, + new() { Month = "Feb", Revenue = 269000, Count = 358 }, + new() { Month = "Mar", Revenue = 233000, Count = 302 }, + }; + + private static TestContext CreateChartContext() + { + var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + ctx.JSInterop.Setup("Radzen.createChart", _ => true) + .SetResult(new Radzen.Blazor.Rendering.Rect { Left = 0, Top = 0, Width = 600, Height = 400 }); + ctx.Services.AddScoped(); + return ctx; + } + + private static IRenderedComponent RenderChart(TestContext ctx) + { + return ctx.RenderComponent(parameters => parameters + .AddChildContent>(series => series + .Add(p => p.Title, "Revenue") + .Add(p => p.CategoryProperty, nameof(Item.Month)) + .Add(p => p.ValueProperty, nameof(Item.Revenue)) + .Add(p => p.Data, Data)) + .AddChildContent>(series => series + .Add(p => p.Title, "Order Count") + .Add(p => p.CategoryProperty, nameof(Item.Month)) + .Add(p => p.ValueProperty, nameof(Item.Count)) + .Add(p => p.ValueAxisName, "count") + .Add(p => p.Data, Data)) + .AddChildContent(a => a + .Add(p => p.Name, "count"))); + } + + [Fact] + public void Named_Axis_Scale_Is_Numeric_And_Horizontal() + { + using var ctx = CreateChartContext(); + var chart = RenderChart(ctx); + var instance = chart.Instance; + + var additionalScale = instance.GetValueScale("count"); + + Assert.IsNotType(additionalScale); + Assert.True(additionalScale.Input.End <= 500, $"Expected a numeric scale fitted to the count values but input was {additionalScale.Input.Start}..{additionalScale.Input.End}"); + Assert.Equal(instance.CategoryScale.Output.Start, additionalScale.Output.Start); + Assert.Equal(instance.CategoryScale.Output.End, additionalScale.Output.End); + } + + [Fact] + public void Primary_Scale_Does_Not_Include_Named_Series_Values() + { + using var ctx = CreateChartContext(); + var chart = RenderChart(ctx); + var instance = chart.Instance; + + var revenue = instance.Series.First(s => (s as IChartValueAxisSeries)?.ValueAxisName == null); + var count = instance.Series.First(s => (s as IChartValueAxisSeries)?.ValueAxisName == "count"); + + var revenueX = revenue.GetTooltipPosition(Data[1]).X; + var countX = count.GetTooltipPosition(Data[1]).X; + + Assert.True(countX > revenueX * 0.8, $"Expected the count bar (value 358) to scale on its own axis near the revenue bar (value 269000) but got countX={countX}, revenueX={revenueX}"); + } + + [Fact] + public void Named_Axis_Renders_Horizontally_On_Top_With_Value_Labels() + { + using var ctx = CreateChartContext(); + var chart = RenderChart(ctx); + + Assert.Empty(chart.FindAll("g.rz-value-axis-right")); + + var topAxisLabels = chart.FindAll("g.rz-value-axis-top .rz-tick-text") + .Select(t => t.TextContent.Trim()) + .Where(t => !string.IsNullOrEmpty(t)) + .ToList(); + + Assert.NotEmpty(topAxisLabels); + Assert.All(topAxisLabels, label => Assert.True(double.TryParse(label, out _), $"Expected numeric tick label but got '{label}'")); + + var categories = Data.Select(d => d.Month); + Assert.DoesNotContain(topAxisLabels, label => categories.Contains(label)); + } + + [Fact] + public void Shared_Category_Axis_Contains_All_Categories() + { + using var ctx = CreateChartContext(); + var chart = RenderChart(ctx); + var instance = chart.Instance; + + Assert.IsType(instance.ValueScale); + + var leftAxisLabels = chart.FindAll("g.rz-value-axis:not(.rz-value-axis-top) .rz-tick-text") + .Select(t => t.TextContent.Trim()) + .ToList(); + + foreach (var month in Data.Select(d => d.Month)) + { + Assert.Contains(month, leftAxisLabels); + } + } + + [Fact] + public void Named_Axis_Tooltip_Formats_With_Named_Axis() + { + using var ctx = CreateChartContext(); + + var chart = ctx.RenderComponent(parameters => parameters + .AddChildContent>(series => series + .Add(p => p.Title, "Revenue") + .Add(p => p.CategoryProperty, nameof(Item.Month)) + .Add(p => p.ValueProperty, nameof(Item.Revenue)) + .Add(p => p.Data, Data)) + .AddChildContent>(series => series + .Add(p => p.Title, "Order Count") + .Add(p => p.CategoryProperty, nameof(Item.Month)) + .Add(p => p.ValueProperty, nameof(Item.Count)) + .Add(p => p.ValueAxisName, "count") + .Add(p => p.Data, Data)) + .AddChildContent(a => a + .Add(p => p.Name, "count") + .Add(p => p.FormatString, "{0} orders"))); + + var count = chart.Instance.Series.First(s => (s as IChartValueAxisSeries)?.ValueAxisName == "count"); + var dataLabels = count.GetDataLabels(0, 0).ToList(); + + Assert.NotEmpty(dataLabels); + Assert.All(dataLabels, label => Assert.EndsWith("orders", label.Text)); + } + + [Fact] + public void Column_Series_Multiple_Axes_Still_Render_On_Right() + { + using var ctx = CreateChartContext(); + + var chart = ctx.RenderComponent(parameters => parameters + .AddChildContent>(series => series + .Add(p => p.CategoryProperty, nameof(Item.Month)) + .Add(p => p.ValueProperty, nameof(Item.Revenue)) + .Add(p => p.Data, Data)) + .AddChildContent>(series => series + .Add(p => p.CategoryProperty, nameof(Item.Month)) + .Add(p => p.ValueProperty, nameof(Item.Count)) + .Add(p => p.ValueAxisName, "count") + .Add(p => p.Data, Data)) + .AddChildContent(a => a + .Add(p => p.Name, "count"))); + + Assert.NotEmpty(chart.FindAll("g.rz-value-axis-right")); + Assert.Empty(chart.FindAll("g.rz-value-axis-top")); + + var additionalScale = chart.Instance.GetValueScale("count"); + Assert.Equal(chart.Instance.ValueScale.Output.Start, additionalScale.Output.Start); + Assert.Equal(chart.Instance.ValueScale.Output.End, additionalScale.Output.End); + } +} diff --git a/Radzen.Blazor/CartesianSeries.cs b/Radzen.Blazor/CartesianSeries.cs index 204954741..c18213adf 100644 --- a/Radzen.Blazor/CartesianSeries.cs +++ b/Radzen.Blazor/CartesianSeries.cs @@ -788,9 +788,10 @@ namespace Radzen.Blazor var vs = chart.GetValueScale(ValueAxisName); if (chart.ShouldInvertAxes()) { - var categoryAccessor = Category(vs); - X = e => chart.CategoryScale.Scale(Value(e)); - Y = e => vs.Scale(categoryAccessor(e)); + var invertedValueScale = chart.GetInvertedValueScale(ValueAxisName); + var categoryAccessor = Category(chart.ValueScale); + X = e => invertedValueScale.Scale(Value(e)); + Y = e => chart.ValueScale.Scale(categoryAccessor(e)); } else { @@ -833,11 +834,12 @@ namespace Radzen.Blazor var vs = chart.GetValueScale(ValueAxisName); if (chart.ShouldInvertAxes()) { - var categoryAccessor = Category(vs); + var invertedValueScale = chart.GetInvertedValueScale(ValueAxisName); + var categoryAccessor = Category(chart.ValueScale); foreach (var item in Items) { - var px = chart.CategoryScale.Scale(Value(item)); - var py = vs.Scale(categoryAccessor(item)); + var px = invertedValueScale.Scale(Value(item)); + var py = chart.ValueScale.Scale(categoryAccessor(item)); result.Add(new Point { X = px, Y = py }); } } diff --git a/Radzen.Blazor/RadzenBarSeries.razor b/Radzen.Blazor/RadzenBarSeries.razor index 960a66b20..33bbd49b0 100644 --- a/Radzen.Blazor/RadzenBarSeries.razor +++ b/Radzen.Blazor/RadzenBarSeries.razor @@ -18,10 +18,12 @@ return _ => { }; } - var value = ComposeValue(categoryScale); - var category = ComposeCategory(valueScale); - var ticks = chart.CategoryScale.Ticks(chart.ValueAxis.TickDistance); - var x0 = chart.CategoryScale.Scale(Math.Max(0, ticks.Start)); + var vs = chart.GetInvertedValueScale(ValueAxisName); + var va = chart.GetValueAxis(ValueAxisName); + var value = ComposeValue(vs); + var category = ComposeCategory(chart.ValueScale); + var ticks = vs.Ticks(va.TickDistance); + var x0 = vs.Scale(Math.Max(0, ticks.Start)); var style = $"clip-path: url(#{chart.ClipPath}); -webkit-clip-path: url(#{chart.ClipPath});"; var barSeries = VisibleBarSeries; diff --git a/Radzen.Blazor/RadzenBarSeries.razor.cs b/Radzen.Blazor/RadzenBarSeries.razor.cs index a2b69f547..dfcb47e6c 100644 --- a/Radzen.Blazor/RadzenBarSeries.razor.cs +++ b/Radzen.Blazor/RadzenBarSeries.razor.cs @@ -218,7 +218,7 @@ namespace Radzen.Blazor return 0; } - var value = chart.CategoryScale.Compose(Value); + var value = chart.GetInvertedValueScale(ValueAxisName).Compose(Value); var x = value(item); return x; @@ -233,7 +233,8 @@ namespace Radzen.Blazor return string.Empty; } - return chart.ValueAxis.Format(chart.CategoryScale, chart.CategoryScale.Value(Value(item))); + var scale = chart.GetInvertedValueScale(ValueAxisName); + return chart.GetValueAxis(ValueAxisName).Format(scale, scale.Value(Value(item))); } /// @@ -258,10 +259,11 @@ namespace Radzen.Blazor return (default!, new Point()); } - var value = ComposeValue(chart.CategoryScale); + var vs = chart.GetInvertedValueScale(ValueAxisName); + var value = ComposeValue(vs); var category = ComposeCategory(chart.ValueScale); - var ticks = chart.CategoryScale.Ticks(chart.ValueAxis.TickDistance); - var x0 = chart.CategoryScale.Scale(Math.Max(0, ticks.Start)); + var ticks = vs.Ticks(chart.GetValueAxis(ValueAxisName).TickDistance); + var x0 = vs.Scale(Math.Max(0, ticks.Start)); var barSeries = VisibleBarSeries; var index = barSeries.IndexOf(this); @@ -336,8 +338,9 @@ namespace Radzen.Blazor { const double gap = 8; const double verticalGap = 16; - var ticks = chart.CategoryScale.Ticks(chart.ValueAxis.TickDistance); - var x0 = chart.CategoryScale.Scale(Math.Max(0, ticks.Start)); + var vs = chart.GetInvertedValueScale(ValueAxisName); + var ticks = vs.Ticks(chart.GetValueAxis(ValueAxisName).TickDistance); + var x0 = vs.Scale(Math.Max(0, ticks.Start)); foreach (var d in Data) { @@ -361,7 +364,7 @@ namespace Radzen.Blazor Anchor = new Point() { X = end, Y = anchorY }, Value = value, TextAnchor = textAnchor, - Text = chart.ValueAxis.Format(chart.CategoryScale, value) + Text = chart.GetValueAxis(ValueAxisName).Format(vs, value) }); } } diff --git a/Radzen.Blazor/RadzenChart.razor.cs b/Radzen.Blazor/RadzenChart.razor.cs index b16f8edd2..992f83fdc 100644 --- a/Radzen.Blazor/RadzenChart.razor.cs +++ b/Radzen.Blazor/RadzenChart.razor.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Components; using System; using System.Collections.Generic; using System.Globalization; +using System.IO; using System.Threading.Tasks; using System.Linq; @@ -475,6 +476,17 @@ namespace Radzen.Blazor return AdditionalValueAxes.TryGetValue(name, out var axis) ? axis : ValueAxis; } + + internal ScaleBase GetInvertedValueScale(string? name) + { + if (string.IsNullOrEmpty(name)) + { + return CategoryScale; + } + + return AdditionalValueScales.TryGetValue(name, out var scale) ? scale : CategoryScale; + } + internal void AddSeries(IChartSeries series) { if (!Series.Contains(series)) @@ -557,6 +569,8 @@ namespace Radzen.Blazor visibleSeries.Add(invisibleSeries.Last()); } + var invertAxes = ShouldInvertAxes(); + // Partition series: primary axis vs named axes var primarySeries = new List(); var namedAxisSeries = new Dictionary>(); @@ -579,16 +593,30 @@ namespace Radzen.Blazor } } - // All series contribute to category scale + // All series contribute to the shared category scale (bar-family series swap their transforms when axes are inverted) foreach (var series in visibleSeries) { - CategoryScale = series.TransformCategoryScale(CategoryScale); + if (invertAxes) + { + ValueScale = series.TransformValueScale(ValueScale); + } + else + { + CategoryScale = series.TransformCategoryScale(CategoryScale); + } } // Primary series contribute to primary value scale foreach (var series in primarySeries) { - ValueScale = series.TransformValueScale(ValueScale); + if (invertAxes) + { + CategoryScale = series.TransformCategoryScale(CategoryScale); + } + else + { + ValueScale = series.TransformValueScale(ValueScale); + } } // Named-axis series contribute to their own scales @@ -630,7 +658,7 @@ namespace Radzen.Blazor foreach (var series in entry.Value) { - additionalScale = series.TransformValueScale(additionalScale); + additionalScale = invertAxes ? series.TransformCategoryScale(additionalScale) : series.TransformValueScale(additionalScale); } AdditionalValueScales[axisName] = additionalScale; @@ -648,7 +676,7 @@ namespace Radzen.Blazor AxisBase xAxis = CategoryAxis; AxisBase yAxis = ValueAxis; - if (ShouldInvertAxes()) + if (invertAxes) { xAxis = ValueAxis; yAxis = CategoryAxis; @@ -699,20 +727,29 @@ namespace Radzen.Blazor var valueAxisSize = ValueAxis.Measure(this); var categoryAxisSize = CategoryAxis.Measure(this); - // Measure additional axes for right margin + // Measure additional axes for the right margin (top margin when axes are inverted) double additionalAxesWidth = 0; + double additionalAxesHeight = 0; foreach (var entry in AdditionalValueAxes) { - additionalAxesWidth += entry.Value.Measure(this, GetValueScale(entry.Key)); + if (invertAxes) + { + additionalAxesHeight += entry.Value.MeasureHorizontal(GetValueScale(entry.Key)); + } + else + { + additionalAxesWidth += entry.Value.Measure(this, GetValueScale(entry.Key)); + } } if (!ShouldRenderAxes()) { valueAxisSize = categoryAxisSize = 0; additionalAxesWidth = 0; + additionalAxesHeight = 0; } - MarginTop = 32; + MarginTop = 32 + additionalAxesHeight; if (IsRTL) { @@ -746,7 +783,7 @@ namespace Radzen.Blazor { if (legendPosition == LegendPosition.Top) { - MarginTop = legendSize + 16; + MarginTop = legendSize + 16 + additionalAxesHeight; } else { @@ -816,11 +853,23 @@ namespace Radzen.Blazor foreach (var entry in AdditionalValueScales) { var axis = GetValueAxis(entry.Key); - entry.Value.Output = new ScaleRange + if (invertAxes) { - Start = axis.Inverted ? valueEnd : valueStart, - End = axis.Inverted ? valueStart : valueEnd - }; + var reversed = axis.Inverted != IsRTL; + entry.Value.Output = new ScaleRange + { + Start = reversed ? categoryEnd : categoryStart, + End = reversed ? categoryStart : categoryEnd + }; + } + else + { + entry.Value.Output = new ScaleRange + { + Start = axis.Inverted ? valueEnd : valueStart, + End = axis.Inverted ? valueStart : valueEnd + }; + } entry.Value.Fit(axis.TickDistance); } @@ -857,6 +906,20 @@ namespace Radzen.Blazor Start = catReversed ? ce : cs, End = catReversed ? cs : ce }; + + if (ShouldInvertAxes()) + { + foreach (var entry in AdditionalValueScales) + { + var axis = GetValueAxis(entry.Key); + var reversed = axis.Inverted != IsRTL; + entry.Value.Output = new ScaleRange + { + Start = reversed ? ce : cs, + End = reversed ? cs : ce + }; + } + } } if (height != Height) @@ -873,14 +936,17 @@ namespace Radzen.Blazor End = valReversed ? vs : ve }; - foreach (var entry in AdditionalValueScales) + if (!ShouldInvertAxes()) { - var axis = GetValueAxis(entry.Key); - entry.Value.Output = new ScaleRange + foreach (var entry in AdditionalValueScales) { - Start = axis.Inverted ? ve : vs, - End = axis.Inverted ? vs : ve - }; + var axis = GetValueAxis(entry.Key); + entry.Value.Output = new ScaleRange + { + Start = axis.Inverted ? ve : vs, + End = axis.Inverted ? vs : ve + }; + } } } @@ -1615,17 +1681,48 @@ namespace Radzen.Blazor /// The plot area and axes are included; legends and tooltips are HTML overlays and are not included. /// /// The download file name. Default is chart.png. - public async Task ToPng(string fileName = "chart.png") + /// The PNG width in pixels. When omitted the rendered size scaled by the device pixel ratio is used. + /// The PNG height in pixels. When omitted it is derived from preserving the aspect ratio. + public async Task ToPng(string fileName = "chart.png", int? width = null, int? height = null) { ArgumentNullException.ThrowIfNull(fileName); if (IsJSRuntimeAvailable && JSRuntime != null) { var svg = await ToSvg(); - await JSRuntime.InvokeVoidAsync("Radzen.downloadSvgAsPng", svg, fileName); + await JSRuntime.InvokeVoidAsync("Radzen.downloadSvgAsPng", svg, fileName, width, height); } } + /// + /// Renders the chart as a PNG image and returns the image data. + /// The plot area and axes are included; legends and tooltips are HTML overlays and are not included. + /// Use this to store the chart in a database, embed it in documents, or send it to an API instead of downloading it. + /// + /// The PNG width in pixels. When null the rendered size scaled by the device pixel ratio is used. + /// The PNG height in pixels. When null it is derived from preserving the aspect ratio. + /// + /// A representing the asynchronous operation. The task result contains the PNG image data. + /// + public async Task ToPng(int? width, int? height = null) + { + if (IsJSRuntimeAvailable && JSRuntime != null) + { + var svg = await ToSvg(); + + if (!string.IsNullOrEmpty(svg)) + { + await using var png = await JSRuntime.InvokeAsync("Radzen.svgToPng", svg, width, height); + using var stream = await png.OpenReadStreamAsync(maxAllowedSize: 32 * 1024 * 1024); + using var memoryStream = new MemoryStream(); + await stream.CopyToAsync(memoryStream); + return memoryStream.ToArray(); + } + } + + return Array.Empty(); + } + /// public override void Dispose() { @@ -1882,7 +1979,7 @@ namespace Radzen.Blazor var categoryCrosshair = CategoryAxis?.Crosshair; var hoveredValueAxis = GetValueAxis((HoveredSeries as IChartValueAxisSeries)?.ValueAxisName); - var hoveredValueScale = GetValueScale((HoveredSeries as IChartValueAxisSeries)?.ValueAxisName); + var hoveredValueScale = ShouldInvertAxes() ? ValueScale : GetValueScale((HoveredSeries as IChartValueAxisSeries)?.ValueAxisName); var valueCrosshair = hoveredValueAxis?.Crosshair; var snapX = categoryCrosshair?.Snap ?? true; diff --git a/Radzen.Blazor/RadzenValueAxis.razor b/Radzen.Blazor/RadzenValueAxis.razor index e7b946121..710792a11 100644 --- a/Radzen.Blazor/RadzenValueAxis.razor +++ b/Radzen.Blazor/RadzenValueAxis.razor @@ -35,6 +35,16 @@ return AxisMeasurer.YAxis(scale, this, Title); } + internal double MeasureHorizontal(ScaleBase scale) + { + if (Visible == false) + { + return 0; + } + + return AxisMeasurer.XAxis(scale, this, Title); + } + internal override double Size { get diff --git a/Radzen.Blazor/Rendering/ValueAxis.razor b/Radzen.Blazor/Rendering/ValueAxis.razor index 9679d330b..524aaab79 100644 --- a/Radzen.Blazor/Rendering/ValueAxis.razor +++ b/Radzen.Blazor/Rendering/ValueAxis.razor @@ -1,6 +1,40 @@ @inherits RadzenChartComponentBase -@if (YAxis?.Visible == true) +@if (YAxis?.Visible == true && renderHorizontal) +{ + + + @foreach (var idx in tickValues) + { + var value = scale?.Value(idx); + var x = scale?.Scale(idx) ?? 0; + var text = value != null && YAxis != null && scale != null ? YAxis.Format(scale, value) : ""; + + if (YAxis?.Ticks?.Template != null) + { + var context = new TickTemplateContext { Text = text, Value = value, X = x, Y = axisY }; + + + @YAxis.Ticks.Template(context) + + } + else if (!String.IsNullOrEmpty(text)) + { + + } + + if (YAxis?.GridLines?.Visible == true && idx > start) + { + + } + } + @if (!String.IsNullOrEmpty(YAxis?.Title?.Text)) + { + + } + +} +else if (YAxis?.Visible == true) { @@ -57,12 +91,17 @@ double y1; double y2; double axisX; + double axisY; double gridX1; double gridX2; + double gridY1; + double gridY2; double titleX; + double horizontalSize; ScaleBase? scale; bool isLogarithmic; bool isFlippedToRight; + bool renderHorizontal; private AxisBase? XAxis { get; set; } private AxisBase? YAxis { get; set; } @@ -73,6 +112,39 @@ return; } + renderHorizontal = IsRightSide && Chart.ShouldInvertAxes(); + + if (renderHorizontal) + { + var axis = Chart.GetValueAxis(AxisName); + YAxis = axis; + scale = Chart.GetValueScale(AxisName); + + var horizontalTicks = scale.Ticks(axis.TickDistance); + start = horizontalTicks.Start; + end = horizontalTicks.End; + step = horizontalTicks.Step; + tickValues = scale.TickValues(axis.TickDistance).ToList(); + + x1 = scale.Scale(start); + x2 = scale.Scale(end); + + var verticalTicks = Chart.ValueScale.Ticks(Chart.CategoryAxis.TickDistance); + gridY1 = Chart.ValueScale.Scale(verticalTicks.Start); + gridY2 = Chart.ValueScale.Scale(verticalTicks.End); + + axisY = 0; + foreach (var entry in Chart.AdditionalValueAxes) + { + if (entry.Key == AxisName) break; + axisY -= entry.Value.MeasureHorizontal(Chart.GetValueScale(entry.Key)); + } + + horizontalSize = axis.MeasureHorizontal(scale); + + return; + } + if (IsRightSide) { YAxis = Chart.GetValueAxis(AxisName);