diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/DebugPages/AreaChartDebug.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/DebugPages/AreaChartDebug.razor new file mode 100644 index 0000000000..3191c85237 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/DebugPages/AreaChartDebug.razor @@ -0,0 +1,125 @@ +@page "/Charts/AreaChart/Debug" +@using Microsoft.FluentUI.AspNetCore.Components.Charts + +
+ + + + + @width + + + + + + @height + + + + + + + +
+ +
+ +
+ +@code { + private readonly IReadOnlyList data = new List + { + new AreaChartSeries { Legend = "legend1", + ChartData = new List + { + new AreaChartDataPoint { X = 20, Y = 7000 }, + new AreaChartDataPoint { X = 25, Y = 9000 }, + new AreaChartDataPoint { X = 30, Y = 13000 }, + new AreaChartDataPoint { X = 35, Y = 15000 }, + new AreaChartDataPoint { X = 40, Y = 11000 }, + new AreaChartDataPoint { X = 45, Y = 8760 }, + new AreaChartDataPoint { X = 50, Y = 3500 }, + new AreaChartDataPoint { X = 55, Y = 20000 }, + new AreaChartDataPoint { X = 60, Y = 17000 }, + new AreaChartDataPoint { X = 65, Y = 1000 }, + new AreaChartDataPoint { X = 70, Y = 12000 }, + new AreaChartDataPoint { X = 75, Y = 6876 }, + new AreaChartDataPoint { X = 80, Y = 12000 }, + new AreaChartDataPoint { X = 85, Y = 7000 }, + new AreaChartDataPoint { X = 90, Y = 10000 } + } + }, + new AreaChartSeries { Legend = "legend2", + ChartData = new List + { + new AreaChartDataPoint { X = 20, Y = 12000 }, + new AreaChartDataPoint { X = 25, Y = 14000 }, + new AreaChartDataPoint { X = 30, Y = 18000 }, + new AreaChartDataPoint { X = 35, Y = 20000 }, + new AreaChartDataPoint { X = 40, Y = 16000 }, + new AreaChartDataPoint { X = 45, Y = 13760 }, + new AreaChartDataPoint { X = 50, Y = 8500 }, + new AreaChartDataPoint { X = 55, Y = 25000 }, + new AreaChartDataPoint { X = 60, Y = 22000 }, + new AreaChartDataPoint { X = 65, Y = 6000 }, + new AreaChartDataPoint { X = 70, Y = 17000 }, + new AreaChartDataPoint { X = 75, Y = 11876 }, + new AreaChartDataPoint { X = 80, Y = 17000 }, + new AreaChartDataPoint { X = 85, Y = 12000 }, + new AreaChartDataPoint { X = 90, Y = 15000 } + } + }, + new AreaChartSeries { Legend = "legend3", + ChartData = new List + { + new AreaChartDataPoint { X = 20, Y = 14000 }, + new AreaChartDataPoint { X = 25, Y = 16000 }, + new AreaChartDataPoint { X = 30, Y = 20000 }, + new AreaChartDataPoint { X = 35, Y = 22000 }, + new AreaChartDataPoint { X = 40, Y = 18000 }, + new AreaChartDataPoint { X = 45, Y = 15760 }, + new AreaChartDataPoint { X = 50, Y = 10500 }, + new AreaChartDataPoint { X = 55, Y = 27000 }, + new AreaChartDataPoint { X = 60, Y = 24000 }, + new AreaChartDataPoint { X = 65, Y = 8000 }, + new AreaChartDataPoint { X = 70, Y = 19000 }, + new AreaChartDataPoint { X = 75, Y = 13876 }, + new AreaChartDataPoint { X = 80, Y = 19000 }, + new AreaChartDataPoint { X = 85, Y = 14000 }, + new AreaChartDataPoint { X = 90, Y = 17000 } + } + } + }; + + private int width = 400; + private int height = 400; + private bool gradient; + private bool roundCorners; + private bool hideLabels = true; + private bool hideLegends; + private bool hideTooltips; + private bool allowMultipleLegendSelection; +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/AreaChart/AreaChartAllNegativeYValues.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/AreaChart/AreaChartAllNegativeYValues.razor new file mode 100644 index 0000000000..abce0111af --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/AreaChart/AreaChartAllNegativeYValues.razor @@ -0,0 +1,71 @@ +@using Microsoft.FluentUI.AspNetCore.Components.Charts + + + + +@code { + + private readonly IReadOnlyList data = new List + { + new AreaChartSeries + { + Legend = "legend1", + Color = DataVizPalette.Color4, + ChartData = new List + { + new AreaChartDataPoint { X = 20, Y = -9}, + new AreaChartDataPoint { X = 25, Y = -14}, + new AreaChartDataPoint { X = 30, Y = -14}, + new AreaChartDataPoint { X = 35, Y = -23}, + new AreaChartDataPoint { X = 40, Y = -20}, + new AreaChartDataPoint { X = 45, Y = -31}, + new AreaChartDataPoint { X = 50, Y = -29}, + new AreaChartDataPoint { X = 55, Y = -27}, + new AreaChartDataPoint { X = 60, Y = -37}, + new AreaChartDataPoint { X = 65, Y = -51}, + } + }, + new AreaChartSeries + { + Legend = "legend2", + Color = DataVizPalette.Color5, + ChartData = new List + { + new AreaChartDataPoint { X = 20, Y = -21}, + new AreaChartDataPoint { X = 25, Y = -25}, + new AreaChartDataPoint { X = 30, Y = -10}, + new AreaChartDataPoint { X = 35, Y = -10}, + new AreaChartDataPoint { X = 40, Y = -14}, + new AreaChartDataPoint { X = 45, Y = -18}, + new AreaChartDataPoint { X = 50, Y = -9}, + new AreaChartDataPoint { X = 55, Y = -23}, + new AreaChartDataPoint { X = 60, Y = -7}, + new AreaChartDataPoint { X = 65, Y = -55}, + } + }, + new AreaChartSeries + { + Legend = "legend3", + Color = DataVizPalette.Color6, + ChartData = new List + { + new AreaChartDataPoint { X = 20, Y = -30}, + new AreaChartDataPoint { X = 25, Y = -35}, + new AreaChartDataPoint { X = 30, Y = -33}, + new AreaChartDataPoint { X = 35, Y = -40}, + new AreaChartDataPoint { X = 40, Y = -10}, + new AreaChartDataPoint { X = 45, Y = -40}, + new AreaChartDataPoint { X = 50, Y = -34}, + new AreaChartDataPoint { X = 55, Y = -40}, + new AreaChartDataPoint { X = 60, Y = -60}, + new AreaChartDataPoint { X = 65, Y = -40}, + } + } + }; +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/AreaChart/AreaChartAxisTitles.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/AreaChart/AreaChartAxisTitles.razor new file mode 100644 index 0000000000..fd7942467a --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/AreaChart/AreaChartAxisTitles.razor @@ -0,0 +1,33 @@ +@using Microsoft.FluentUI.AspNetCore.Components.Charts + + + +@code { + private readonly IReadOnlyList data = new List + { + new AreaChartSeries + { + Legend = "legend1", + ChartData = new List + { + new AreaChartDataPoint { X = 20, Y = 7000 }, + new AreaChartDataPoint { X = 25, Y = 9000 }, + new AreaChartDataPoint { X = 30, Y = 13000 }, + } + }, + new AreaChartSeries + { + Legend = "legend2", + ChartData = new List + { + new AreaChartDataPoint { X = 20, Y = 12000 }, + new AreaChartDataPoint { X = 25, Y = 14000 }, + new AreaChartDataPoint { X = 30, Y = 18000 }, + } + } + }; +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/AreaChart/AreaChartCulture.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/AreaChart/AreaChartCulture.razor new file mode 100644 index 0000000000..c18255efec --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/AreaChart/AreaChartCulture.razor @@ -0,0 +1,34 @@ +@using Microsoft.FluentUI.AspNetCore.Components.Charts + + + +@code { + private readonly System.Globalization.CultureInfo culture = new("de-DE"); + + private readonly IReadOnlyList data = new List + { + new AreaChartSeries + { + Legend = "legend1", + ChartData = new List + { + new AreaChartDataPoint { X = new DateTime(2024, 1, 1), Y = 7000 }, + new AreaChartDataPoint { X = new DateTime(2024, 2, 1), Y = 13000 }, + new AreaChartDataPoint { X = new DateTime(2024, 3, 1), Y = 9000 }, + } + }, + new AreaChartSeries + { + Legend = "legend2", + ChartData = new List + { + new AreaChartDataPoint { X = new DateTime(2024, 1, 1), Y = 12000 }, + new AreaChartDataPoint { X = new DateTime(2024, 2, 1), Y = 18000 }, + new AreaChartDataPoint { X = new DateTime(2024, 3, 1), Y = 14000 }, + } + } + }; +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/AreaChart/AreaChartCustomTooltip.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/AreaChart/AreaChartCustomTooltip.razor new file mode 100644 index 0000000000..025b6b9440 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/AreaChart/AreaChartCustomTooltip.razor @@ -0,0 +1,109 @@ +@using System.Text.Json +@using Microsoft.FluentUI.AspNetCore.Components.Charts + + + + @{ + var entries = GetTooltipEntries(ctx); + } + +
+
X: @ctx.XValue
+ + @if (entries.Count > 0) + { + foreach (var entry in entries) + { +
+
+ + @entry.Legend +
+ @entry.Value +
+ } + } + else + { +
+ + @ctx.Legend +
+
Value: @ctx.YValue
+ } +
+
+
+ +@code { + private readonly IReadOnlyList data = new List + { + new AreaChartSeries + { + Legend = "legend1", + ChartData = new List + { + new AreaChartDataPoint { X = 20, Y = 7000 }, + new AreaChartDataPoint { X = 25, Y = 9000 }, + new AreaChartDataPoint { X = 30, Y = 13000 }, + } + }, + new AreaChartSeries + { + Legend = "legend2", + ChartData = new List + { + new AreaChartDataPoint { X = 20, Y = 12000 }, + new AreaChartDataPoint { X = 25, Y = 14000 }, + new AreaChartDataPoint { X = 30, Y = 18000 }, + } + } + }; + + private static IReadOnlyList GetTooltipEntries(CartesianTooltipContext context) + { + if (string.IsNullOrWhiteSpace(context.RawJson)) + { + return []; + } + + try + { + using var json = JsonDocument.Parse(context.RawJson); + if (!json.RootElement.TryGetProperty("entries", out var entriesElement) || + entriesElement.ValueKind != JsonValueKind.Array) + { + return []; + } + + var result = new List(); + foreach (var item in entriesElement.EnumerateArray()) + { + var legend = item.TryGetProperty("legend", out var legendElement) + ? legendElement.GetString() + : null; + var value = item.TryGetProperty("value", out var valueElement) + ? valueElement.GetString() + : null; + var color = item.TryGetProperty("color", out var colorElement) + ? colorElement.GetString() + : null; + + if (!string.IsNullOrWhiteSpace(legend) && !string.IsNullOrWhiteSpace(value)) + { + result.Add(new TooltipEntry(legend, value, color ?? string.Empty)); + } + } + + return result; + } + catch + { + return []; + } + } + + private sealed record TooltipEntry(string Legend, string Value, string Color); +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/AreaChart/AreaChartDefault.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/AreaChart/AreaChartDefault.razor new file mode 100644 index 0000000000..e15dfbd772 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/AreaChart/AreaChartDefault.razor @@ -0,0 +1,82 @@ +@using Microsoft.FluentUI.AspNetCore.Components.Charts + + + +@code { + private readonly IReadOnlyList data = new List + { + + new AreaChartSeries { Legend = "legend1", + ChartData = new List + { + new AreaChartDataPoint { X = 20, Y = 7000 }, + new AreaChartDataPoint { X = 25, Y = 9000 }, + new AreaChartDataPoint { X = 30, Y = 13000 }, + new AreaChartDataPoint { X = 35, Y = 15000 }, + new AreaChartDataPoint { X = 40, Y = 11000 }, + new AreaChartDataPoint { X = 45, Y = 8760 }, + new AreaChartDataPoint { X = 50, Y = 3500 }, + new AreaChartDataPoint { X = 55, Y = 20000 }, + new AreaChartDataPoint { X = 60, Y = 17000 }, + new AreaChartDataPoint { X = 65, Y = 1000 }, + new AreaChartDataPoint { X = 70, Y = 12000 }, + new AreaChartDataPoint { X = 75, Y = 6876 }, + new AreaChartDataPoint { X = 80, Y = 12000 }, + new AreaChartDataPoint { X = 85, Y = 7000 }, + new AreaChartDataPoint { X = 90, Y = 10000 } + } + }, + new AreaChartSeries { Legend = "legend2", + ChartData = new List + { + new AreaChartDataPoint { X = 20, Y = 12000 }, + new AreaChartDataPoint { X = 25, Y = 14000 }, + new AreaChartDataPoint { X = 30, Y = 18000 }, + new AreaChartDataPoint { X = 35, Y = 20000 }, + new AreaChartDataPoint { X = 40, Y = 16000 }, + new AreaChartDataPoint { X = 45, Y = 13760 }, + new AreaChartDataPoint { X = 50, Y = 8500 }, + new AreaChartDataPoint { X = 55, Y = 25000 }, + new AreaChartDataPoint { X = 60, Y = 22000 }, + new AreaChartDataPoint { X = 65, Y = 6000 }, + new AreaChartDataPoint { X = 70, Y = 17000 }, + new AreaChartDataPoint { X = 75, Y = 11876 }, + new AreaChartDataPoint { X = 80, Y = 17000 }, + new AreaChartDataPoint { X = 85, Y = 12000 }, + new AreaChartDataPoint { X = 90, Y = 15000 } + } + }, + new AreaChartSeries { Legend = "legend3", + ChartData = new List + { + new AreaChartDataPoint { X = 20, Y = 14000 }, + new AreaChartDataPoint { X = 25, Y = 16000 }, + new AreaChartDataPoint { X = 30, Y = 20000 }, + new AreaChartDataPoint { X = 35, Y = 22000 }, + new AreaChartDataPoint { X = 40, Y = 18000 }, + new AreaChartDataPoint { X = 45, Y = 15760 }, + new AreaChartDataPoint { X = 50, Y = 10500 }, + new AreaChartDataPoint { X = 55, Y = 27000 }, + new AreaChartDataPoint { X = 60, Y = 24000 }, + new AreaChartDataPoint { X = 65, Y = 8000 }, + new AreaChartDataPoint { X = 70, Y = 19000 }, + new AreaChartDataPoint { X = 75, Y = 13876 }, + new AreaChartDataPoint { X = 80, Y = 19000 }, + new AreaChartDataPoint { X = 85, Y = 14000 }, + new AreaChartDataPoint { X = 90, Y = 17000 } + } + } + }; +} + + +@* +xaxis-title="Number of days" +y-axis-title="Variation of stock market prices" +x-axis-category-order="default" +hide-tick-overlap +mode="tonexty" +secondary-y-axis-title +*@ diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/AreaChart/AreaChartDefaultRTL.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/AreaChart/AreaChartDefaultRTL.razor new file mode 100644 index 0000000000..2c0ac8cc01 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/AreaChart/AreaChartDefaultRTL.razor @@ -0,0 +1,33 @@ +@using Microsoft.FluentUI.AspNetCore.Components.Charts + +
+ +
+ +@code { + private readonly IReadOnlyList data = new List + { + new AreaChartSeries + { + Legend = "legend1", + ChartData = new List + { + new AreaChartDataPoint { X = 20, Y = 7000 }, + new AreaChartDataPoint { X = 25, Y = 9000 }, + new AreaChartDataPoint { X = 30, Y = 13000 }, + } + }, + new AreaChartSeries + { + Legend = "legend2", + ChartData = new List + { + new AreaChartDataPoint { X = 20, Y = 12000 }, + new AreaChartDataPoint { X = 25, Y = 14000 }, + new AreaChartDataPoint { X = 30, Y = 18000 }, + } + } + }; +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/AreaChart/AreaChartEnableGradient.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/AreaChart/AreaChartEnableGradient.razor new file mode 100644 index 0000000000..935ed051e9 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/AreaChart/AreaChartEnableGradient.razor @@ -0,0 +1,32 @@ +@using Microsoft.FluentUI.AspNetCore.Components.Charts + + + +@code { + private readonly IReadOnlyList data = new List + { + new AreaChartSeries + { + Legend = "legend1", + ChartData = new List + { + new AreaChartDataPoint { X = 20, Y = 7000 }, + new AreaChartDataPoint { X = 25, Y = 9000 }, + new AreaChartDataPoint { X = 30, Y = 13000 }, + } + }, + new AreaChartSeries + { + Legend = "legend2", + ChartData = new List + { + new AreaChartDataPoint { X = 20, Y = 12000 }, + new AreaChartDataPoint { X = 25, Y = 14000 }, + new AreaChartDataPoint { X = 30, Y = 18000 }, + } + } + }; +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/AreaChart/AreaChartHideLegends.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/AreaChart/AreaChartHideLegends.razor new file mode 100644 index 0000000000..c3302bbe20 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/AreaChart/AreaChartHideLegends.razor @@ -0,0 +1,32 @@ +@using Microsoft.FluentUI.AspNetCore.Components.Charts + + + +@code { + private readonly IReadOnlyList data = new List + { + new AreaChartSeries + { + Legend = "legend1", + ChartData = new List + { + new AreaChartDataPoint { X = 20, Y = 7000 }, + new AreaChartDataPoint { X = 25, Y = 9000 }, + new AreaChartDataPoint { X = 30, Y = 13000 }, + } + }, + new AreaChartSeries + { + Legend = "legend2", + ChartData = new List + { + new AreaChartDataPoint { X = 20, Y = 12000 }, + new AreaChartDataPoint { X = 25, Y = 14000 }, + new AreaChartDataPoint { X = 30, Y = 18000 }, + } + } + }; +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/AreaChart/AreaChartHideTooltip.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/AreaChart/AreaChartHideTooltip.razor new file mode 100644 index 0000000000..b5cefdb4ba --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/AreaChart/AreaChartHideTooltip.razor @@ -0,0 +1,32 @@ +@using Microsoft.FluentUI.AspNetCore.Components.Charts + + + +@code { + private readonly IReadOnlyList data = new List + { + new AreaChartSeries + { + Legend = "legend1", + ChartData = new List + { + new AreaChartDataPoint { X = 20, Y = 7000 }, + new AreaChartDataPoint { X = 25, Y = 9000 }, + new AreaChartDataPoint { X = 30, Y = 13000 }, + } + }, + new AreaChartSeries + { + Legend = "legend2", + ChartData = new List + { + new AreaChartDataPoint { X = 20, Y = 12000 }, + new AreaChartDataPoint { X = 25, Y = 14000 }, + new AreaChartDataPoint { X = 30, Y = 18000 }, + } + } + }; +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/AreaChart/AreaChartLegendListLabel.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/AreaChart/AreaChartLegendListLabel.razor new file mode 100644 index 0000000000..73d3944b90 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/AreaChart/AreaChartLegendListLabel.razor @@ -0,0 +1,32 @@ +@using Microsoft.FluentUI.AspNetCore.Components.Charts + + + +@code { + private readonly IReadOnlyList data = new List + { + new AreaChartSeries + { + Legend = "legend1", + ChartData = new List + { + new AreaChartDataPoint { X = 20, Y = 7000 }, + new AreaChartDataPoint { X = 25, Y = 9000 }, + new AreaChartDataPoint { X = 30, Y = 13000 }, + } + }, + new AreaChartSeries + { + Legend = "legend2", + ChartData = new List + { + new AreaChartDataPoint { X = 20, Y = 12000 }, + new AreaChartDataPoint { X = 25, Y = 14000 }, + new AreaChartDataPoint { X = 30, Y = 18000 }, + } + } + }; +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/AreaChart/AreaChartMultipleLegendSelection.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/AreaChart/AreaChartMultipleLegendSelection.razor new file mode 100644 index 0000000000..9c7ad79494 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/AreaChart/AreaChartMultipleLegendSelection.razor @@ -0,0 +1,70 @@ +@using Microsoft.FluentUI.AspNetCore.Components.Charts + + + +@code { + + private readonly IReadOnlyList data = new List + { + new AreaChartSeries + { + Legend = "legend1", + Color = DataVizPalette.Color4, + ChartData = new List + { + new AreaChartDataPoint { X = 20, Y = 9 }, + new AreaChartDataPoint { X = 25, Y = 14 }, + new AreaChartDataPoint { X = 30, Y = 14 }, + new AreaChartDataPoint { X = 35, Y = 23 }, + new AreaChartDataPoint { X = 40, Y = 20 }, + new AreaChartDataPoint { X = 45, Y = 31 }, + new AreaChartDataPoint { X = 50, Y = 29 }, + new AreaChartDataPoint { X = 55, Y = 27 }, + new AreaChartDataPoint { X = 60, Y = 37 }, + new AreaChartDataPoint { X = 65, Y = 51 }, + + } + }, + new AreaChartSeries + { + Legend = "legend2", + Color = DataVizPalette.Color5, + ChartData = new List + { + new AreaChartDataPoint { X = 20, Y = 21 }, + new AreaChartDataPoint { X = 25, Y = 25 }, + new AreaChartDataPoint { X = 30, Y = 10 }, + new AreaChartDataPoint { X = 35, Y = 10 }, + new AreaChartDataPoint { X = 40, Y = 14 }, + new AreaChartDataPoint { X = 45, Y = 18 }, + new AreaChartDataPoint { X = 50, Y = 9 }, + new AreaChartDataPoint { X = 55, Y = 23 }, + new AreaChartDataPoint { X = 60, Y = 7 }, + new AreaChartDataPoint { X = 65, Y = 55 }, + } + }, + new AreaChartSeries + { + Legend = "legend3", + Color = DataVizPalette.Color6, + ChartData = new List + { + new AreaChartDataPoint { X = 20, Y = 30 }, + new AreaChartDataPoint { X = 25, Y = 35 }, + new AreaChartDataPoint { X = 30, Y = 33 }, + new AreaChartDataPoint { X = 35, Y = 40 }, + new AreaChartDataPoint { X = 40, Y = 10 }, + new AreaChartDataPoint { X = 45, Y = 40 }, + new AreaChartDataPoint { X = 50, Y = 34 }, + new AreaChartDataPoint { X = 55, Y = 40 }, + new AreaChartDataPoint { X = 60, Y = 60 }, + new AreaChartDataPoint { X = 65, Y = 40 }, + + } + } + + }; +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/AreaChart/AreaChartMultipleSeriesNegativeYValues.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/AreaChart/AreaChartMultipleSeriesNegativeYValues.razor new file mode 100644 index 0000000000..ce8e52e222 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/AreaChart/AreaChartMultipleSeriesNegativeYValues.razor @@ -0,0 +1,69 @@ +@using Microsoft.FluentUI.AspNetCore.Components.Charts + + + + +@code { + private readonly IReadOnlyList data = new List + { + new AreaChartSeries + { + Legend = "legend1", + Color = DataVizPalette.Color4, + ChartData = new List + { + new AreaChartDataPoint { X = 20, Y = -9}, + new AreaChartDataPoint { X = 25, Y = 14}, + new AreaChartDataPoint { X = 30, Y = -14}, + new AreaChartDataPoint { X = 35, Y = 23}, + new AreaChartDataPoint { X = 40, Y = -20}, + new AreaChartDataPoint { X = 45, Y = 31}, + new AreaChartDataPoint { X = 50, Y = -29}, + new AreaChartDataPoint { X = 55, Y = 27}, + new AreaChartDataPoint { X = 60, Y = -37}, + new AreaChartDataPoint { X = 65, Y = 51}, + } + }, + new AreaChartSeries + { + Legend = "legend2", + Color = DataVizPalette.Color5, + ChartData = new List + { + new AreaChartDataPoint { X = 20, Y = 21}, + new AreaChartDataPoint { X = 25, Y = -25}, + new AreaChartDataPoint { X = 30, Y = 10}, + new AreaChartDataPoint { X = 35, Y = -10}, + new AreaChartDataPoint { X = 40, Y = 14}, + new AreaChartDataPoint { X = 45, Y = -18}, + new AreaChartDataPoint { X = 50, Y = 9}, + new AreaChartDataPoint { X = 55, Y = -23}, + new AreaChartDataPoint { X = 60, Y = 7}, + new AreaChartDataPoint { X = 65, Y = -55}, + } + }, + new AreaChartSeries + { + Legend = "legend3", + Color = DataVizPalette.Color6, + ChartData = new List + { + new AreaChartDataPoint { X = 20, Y = 30}, + new AreaChartDataPoint { X = 25, Y = 35}, + new AreaChartDataPoint { X = 30, Y = -33}, + new AreaChartDataPoint { X = 35, Y = 40}, + new AreaChartDataPoint { X = 40, Y = 10}, + new AreaChartDataPoint { X = 45, Y = -40}, + new AreaChartDataPoint { X = 50, Y = 34}, + new AreaChartDataPoint { X = 55, Y = 40}, + new AreaChartDataPoint { X = 60, Y = -60}, + new AreaChartDataPoint { X = 65, Y = 40}, + } + } + }; +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/AreaChart/AreaChartNegativeYValues.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/AreaChart/AreaChartNegativeYValues.razor new file mode 100644 index 0000000000..16bf3a3afe --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/AreaChart/AreaChartNegativeYValues.razor @@ -0,0 +1,40 @@ +@using Microsoft.FluentUI.AspNetCore.Components.Charts + + + +@code { + private readonly IReadOnlyList data = new List + { + new AreaChartSeries + { + Legend = "legend1", + ChartData = new List + { + new AreaChartDataPoint { X = 20, Y = 7000 }, + new AreaChartDataPoint { X = 25, Y = -9000 }, + new AreaChartDataPoint { X = 30, Y = 13000 }, + new AreaChartDataPoint { X = 35, Y = -15000 }, + new AreaChartDataPoint { X = 40, Y = 11000 }, + new AreaChartDataPoint { X = 45, Y = -8760 }, + new AreaChartDataPoint { X = 50, Y = 3500 }, + new AreaChartDataPoint { X = 55, Y = -20000 }, + new AreaChartDataPoint { X = 60, Y = 17000 }, + new AreaChartDataPoint { X = 65, Y = -1000 }, + new AreaChartDataPoint { X = 70, Y = 12000 }, + new AreaChartDataPoint { X = 75, Y = -6876 }, + new AreaChartDataPoint { X = 80, Y = 12000 }, + new AreaChartDataPoint { X = 85, Y = -7000 }, + new AreaChartDataPoint { X = 90, Y = 10000 }, + new AreaChartDataPoint { X = 95, Y = 12000 }, + new AreaChartDataPoint { X = 100, Y = 18000 }, + new AreaChartDataPoint { X = 105, Y = 14000 }, + } + } + }; +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/AreaChart/AreaChartRoundedCorners.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/AreaChart/AreaChartRoundedCorners.razor new file mode 100644 index 0000000000..3fc869334f --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/AreaChart/AreaChartRoundedCorners.razor @@ -0,0 +1,32 @@ +@using Microsoft.FluentUI.AspNetCore.Components.Charts + + + +@code { + private readonly IReadOnlyList data = new List + { + new AreaChartSeries + { + Legend = "legend1", + ChartData = new List + { + new AreaChartDataPoint { X = 20, Y = 7000 }, + new AreaChartDataPoint { X = 25, Y = 9000 }, + new AreaChartDataPoint { X = 30, Y = 13000 }, + } + }, + new AreaChartSeries + { + Legend = "legend2", + ChartData = new List + { + new AreaChartDataPoint { X = 20, Y = 12000 }, + new AreaChartDataPoint { X = 25, Y = 14000 }, + new AreaChartDataPoint { X = 30, Y = 18000 }, + } + } + }; +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/AreaChart/AreaChartSecondaryAxis.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/AreaChart/AreaChartSecondaryAxis.razor new file mode 100644 index 0000000000..99b7964a48 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/AreaChart/AreaChartSecondaryAxis.razor @@ -0,0 +1,60 @@ +@using Microsoft.FluentUI.AspNetCore.Components.Charts + + + +@code { + + private readonly IReadOnlyList data = new List + { + new AreaChartSeries + { + Legend = "Prices", + ChartData = new List + { + new AreaChartDataPoint { X = 20, Y = 7000 }, + new AreaChartDataPoint { X = 25, Y = 9000 }, + new AreaChartDataPoint { X = 30, Y = 13000 }, + new AreaChartDataPoint { X = 35, Y = 15000 }, + new AreaChartDataPoint { X = 40, Y = 11000 }, + new AreaChartDataPoint { X = 45, Y = 8760 }, + new AreaChartDataPoint { X = 50, Y = 3500 }, + new AreaChartDataPoint { X = 55, Y = 20000 }, + new AreaChartDataPoint { X = 60, Y = 17000 }, + new AreaChartDataPoint { X = 65, Y = 1000 }, + new AreaChartDataPoint { X = 70, Y = 12000 }, + new AreaChartDataPoint { X = 75, Y = 6876 }, + new AreaChartDataPoint { X = 80, Y = 12000 }, + new AreaChartDataPoint { X = 85, Y = 7000 }, + new AreaChartDataPoint { X = 90, Y = 10000 }, + } + }, + new AreaChartSeries + { + Legend = "Gains", + UseSecondaryYScale = true, + ChartData = new List + { + new AreaChartDataPoint { X = 20, Y = 240 }, + new AreaChartDataPoint { X = 25, Y = 280 }, + new AreaChartDataPoint { X = 30, Y = 360 }, + new AreaChartDataPoint { X = 35, Y = 400 }, + new AreaChartDataPoint { X = 40, Y = 320 }, + new AreaChartDataPoint { X = 45, Y = 275 }, + new AreaChartDataPoint { X = 50, Y = 170 }, + new AreaChartDataPoint { X = 55, Y = 500 }, + new AreaChartDataPoint { X = 60, Y = 440 }, + new AreaChartDataPoint { X = 65, Y = 120 }, + new AreaChartDataPoint { X = 70, Y = 340 }, + new AreaChartDataPoint { X = 75, Y = 238 }, + new AreaChartDataPoint { X = 80, Y = 340 }, + new AreaChartDataPoint { X = 85, Y = 240 }, + new AreaChartDataPoint { X = 90, Y = 300 }, + } + } + }; +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/AreaChart/AreaChartSizing.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/AreaChart/AreaChartSizing.razor new file mode 100644 index 0000000000..8ee8afe860 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/AreaChart/AreaChartSizing.razor @@ -0,0 +1,40 @@ +@using Microsoft.FluentUI.AspNetCore.Components.Charts + + + + + + + + +@code { + private int _width = 700; + private int _height = 400; + + private readonly IReadOnlyList data = new List + { + new AreaChartSeries + { + Legend = "legend1", + ChartData = new List + { + new AreaChartDataPoint { X = 20, Y = 7000 }, + new AreaChartDataPoint { X = 25, Y = 9000 }, + new AreaChartDataPoint { X = 30, Y = 13000 }, + } + }, + new AreaChartSeries + { + Legend = "legend2", + ChartData = new List + { + new AreaChartDataPoint { X = 20, Y = 12000 }, + new AreaChartDataPoint { X = 25, Y = 14000 }, + new AreaChartDataPoint { X = 30, Y = 18000 }, + } + } + }; +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/AreaChart/AreaChartZeroYValues.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/AreaChart/AreaChartZeroYValues.razor new file mode 100644 index 0000000000..7f568be244 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/AreaChart/AreaChartZeroYValues.razor @@ -0,0 +1,83 @@ +@using Microsoft.FluentUI.AspNetCore.Components.Charts + + + + +@code { + private readonly IReadOnlyList data = new List + { + new AreaChartSeries + { + Legend = "legend1", + ChartData = new List + { + new AreaChartDataPoint { X = 20, Y = 7000 }, + new AreaChartDataPoint { X = 25, Y = 9000 }, + new AreaChartDataPoint { X = 30, Y = 13000 }, + new AreaChartDataPoint { X = 35, Y = 15000 }, + new AreaChartDataPoint { X = 40, Y = 11000 }, + new AreaChartDataPoint { X = 45, Y = 8760 }, + new AreaChartDataPoint { X = 50, Y = 3500 }, + new AreaChartDataPoint { X = 55, Y = 20000 }, + new AreaChartDataPoint { X = 60, Y = 17000 }, + new AreaChartDataPoint { X = 65, Y = 1000 }, + new AreaChartDataPoint { X = 70, Y = 12000 }, + new AreaChartDataPoint { X = 75, Y = 6876 }, + new AreaChartDataPoint { X = 80, Y = 12000 }, + new AreaChartDataPoint { X = 85, Y = 7000 }, + new AreaChartDataPoint { X = 90, Y = 10000 } + } + }, + new AreaChartSeries + { + Legend = "legend2", + ChartData = new List + { + new AreaChartDataPoint { X = 20, Y = 12000 }, + new AreaChartDataPoint { X = 25, Y = 14000 }, + new AreaChartDataPoint { X = 30, Y = 18000 }, + new AreaChartDataPoint { X = 35, Y = 20000 }, + new AreaChartDataPoint { X = 40, Y = 16000 }, + new AreaChartDataPoint { X = 45, Y = 13760 }, + new AreaChartDataPoint { X = 50, Y = 8500 }, + new AreaChartDataPoint { X = 55, Y = 25000 }, + new AreaChartDataPoint { X = 60, Y = 22000 }, + new AreaChartDataPoint { X = 65, Y = 6000 }, + new AreaChartDataPoint { X = 70, Y = 17000 }, + new AreaChartDataPoint { X = 75, Y = 11876 }, + new AreaChartDataPoint { X = 80, Y = 17000 }, + new AreaChartDataPoint { X = 85, Y = 12000 }, + new AreaChartDataPoint { X = 90, Y = 15000 } + } + }, + new AreaChartSeries + { + Legend = "legend3", + ChartData = new List + { + new AreaChartDataPoint { X = 20, Y = 14000 }, + new AreaChartDataPoint { X = 25, Y = 16000 }, + new AreaChartDataPoint { X = 30, Y = 20000 }, + new AreaChartDataPoint { X = 35, Y = 22000 }, + new AreaChartDataPoint { X = 40, Y = 18000 }, + new AreaChartDataPoint { X = 45, Y = 15760 }, + new AreaChartDataPoint { X = 50, Y = 10500 }, + new AreaChartDataPoint { X = 55, Y = 27000 }, + new AreaChartDataPoint { X = 60, Y = 24000 }, + new AreaChartDataPoint { X = 65, Y = 8000 }, + new AreaChartDataPoint { X = 70, Y = 19000 }, + new AreaChartDataPoint { X = 75, Y = 13876 }, + new AreaChartDataPoint { X = 80, Y = 19000 }, + new AreaChartDataPoint { X = 85, Y = 14000 }, + new AreaChartDataPoint { X = 90, Y = 17000 } + } + } + }; +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/DonutChart/DonutChartOutsideLabels.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/DonutChart/DonutChartOutsideLabels.razor deleted file mode 100644 index c60ceb7b2c..0000000000 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/DonutChart/DonutChartOutsideLabels.razor +++ /dev/null @@ -1,17 +0,0 @@ -@using Microsoft.FluentUI.AspNetCore.Components.Charts - - - -@code { - private readonly IReadOnlyList data = new List - { - new DonutDataPoint { Legend = "first", Data = 20000 }, - new DonutDataPoint { Legend = "second", Data = 39000 } - }; -} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/DonutChart/DonutChartSizing.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/DonutChart/DonutChartSizing.razor index f3d5a1e090..6d11d0d1fd 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/DonutChart/DonutChartSizing.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/DonutChart/DonutChartSizing.razor @@ -15,9 +15,9 @@ @code { - private int _width = 320; - private int _height = 320; - private int _innerRadius = 55; + private int _width = 300; + private int _height = 300; + private int _innerRadius = 100; private readonly IReadOnlyList data = new List { diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Pages/FluentAreaChartPage.md b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Pages/FluentAreaChartPage.md new file mode 100644 index 0000000000..c1df8abdfb --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Pages/FluentAreaChartPage.md @@ -0,0 +1,169 @@ +--- +title: Area Chart +route: /Charts/AreaChart +--- + +# Area Chart + +Area charts are graphical representations of data that display quantitative data points connected by lines and filled with colors to create a visual representation of trends and patterns. The area between the line and the x-axis is colored, which helps in emphasizing the cumulative total or the overall magnitude of the data. They are a slight variation of single line charts, and generally can be used interchangeably. + +Stacked area charts are great at communicating how multiple data series relate to the total value. It illustrates how each series compares to the other in their contributions to the total. The baseline is moving in stacked area charts, rather than sharing a common baseline in overlapping areas. + +## Layout + +Padding on the left and right of the chart is determined by the x-axis labels - it should start and end at or close to the first and last tick mark. The minimum padding is 8px. + +Area charts support both stacked (`mode="tonexty"`) and non-stacked (`mode="tozeroy"`) fill modes. + +## Content + +- Area line An area line represents a set of values from the same data set. Each line takes on a new swatch in the data visualization library to distinguish it from others. 2px wide. There is no rounding of joints to avoid data misrepresentation. +- Area fill Uses the same color family as the area line, but applies a 50% opacity. Note: the implemented stacked area components use transparency fills, but we cannot apply transparency in the Figma guidance + +## Accessibility + +Users "Enter" into the graph and can use both arrow and tab keys to navigate through. +The first tab stop will stop on the graph and give a description of what type of graph it is. +Each section of the graph is readable via screen readers. The user can navigate through the entire area plot by using Left and Right arrow keys. + +## Interaction + +The area chart is a highly performant visual. It uses a path-based rendering mechanism to render the area component. On hovering, the nearest x datapoint is identified and the corresponding point is hovered. + +## Customizing the chart + +Stacked area chart In stacked area chart, two or more data series are stacked vertically. It helps in easy comparison across different dimensions. The callout on hover for stacked chart displays multiple values from the stack. The callout can be customized to show single values or stacked values. Refer to the props onRenderCalloutPerDataPoint and onRenderCalloutPerStack using which custom content for the callout can be defined. +Custom accessibility Area chart provides a bunch of props to enable custom accessibility messages. Use xAxisCalloutAccessibilityData and callOutAccessibilityData to configure x axis and y axis accessibility messages, respectively. + +## Axis localization + +The chart axes support 2 ways of localization. + +1) JavaScript provided inbuilt localization for numeric and date axis. Specify the culture and dateLocalizeOptions for date axis to define target localization. Refer the Javascript localization guide for usage. +2) Custom locale definition: The consumer of the library can specify a custom locale definition as supported by d3 like this. The date axis will use the date range and the multiformat specified in the definition to determine the correct labels to show in the ticks. For example - If the date range is in days, then the axis will show hourly ticks. If the date range spans across months, then the axis will show months in tick labels and so on. Specify the custom locale definition in the timeFormatLocale prop. Refer to the Custom Locale Date Axis example in line chart for sample usage. + +## Do's + +- Remain consistent with one chart style if there are multiple instances of it on a page rather than using area and line charts interchangeably. + +## Dont's + +- Prefer line charts to plot trends. +- No more than 9 lines on a chart; fewer are better. +- Do not remove axis titles unless it is clear to the user what is being visualized. + +## Examples + +### Basic example + +The default example renders an area chart with 3 data series and a legend showing each category's data value. + +{{ AreaChartDefault }} + +### Multiple legend selection + +When `AllowMultipleLegendSelection` is enabled, multiple legend items can be selected at the same time. + +{{ AreaChartMultipleLegendSelection }} + +### Enable gradient + +Setting `EnableGradient` applies a gradient fill to the area series. + +{{ AreaChartEnableGradient }} + +### Negative Y values + +This example demonstrates how to handle negative values in the area chart. The area fill will extend below the x-axis for negative values. + +{{ AreaChartNegativeYValues }} + +### Multiple Series Negative Y values + +This example demonstrates how to handle multiple series with negative values in the area chart. The area fill will extend below the x-axis for negative values. + +{{ AreaChartMultipleSeriesNegativeYValues }} + +### All negative Y values + +This example demonstrates how to handle all negative values in the area chart. The area fill will extend below the x-axis for negative values. + +{{ AreaChartAllNegativeYValues }} + +### Zero Y (Non stacked) + +Non-stacked mode: each series fills independently from y=0 (equivalent to React's mode="tozeroy"). + +{{ AreaChartZeroYValues }} + +### Secondary axis + +Use `UseSecondaryYScale` on a series and configure the secondary axis visibility and label width. + +{{ AreaChartSecondaryAxis }} + +### Hide legend + +This example removes the legend list below the chart. + +{{ AreaChartHideLegends }} + +### Culture + +This example uses a specific culture to format the axis labels and tooltips. + +{{ AreaChartCulture }} + +### Rounded corners + +Enabling `RoundedCorners` applies a softer visual style to the legend indicators. + +{{ AreaChartRoundedCorners }} + +### With custom sizing + +Use the sliders to adjust the chart width and height at runtime. + +{{ AreaChartSizing }} + +### Hide tooltip + +Setting `HideTooltip` disables the hover callout. + +{{ AreaChartHideTooltip }} + +### Legend list label + +The `LegendListLabel` property sets the accessible heading for the legend list. + +{{ AreaChartLegendListLabel }} + +### Custom tooltip + +Use `CartesianTooltipTemplate` (or `TooltipTemplate`) to replace the default hover callout with custom Blazor markup. + +{{ AreaChartCustomTooltip }} + +### Axis titles + +The `XAxisTitle` and `YAxisTitle` properties label the chart axes. + +{{ AreaChartAxisTitles }} + +### RTL + +This example demonstrates the chart inside a right-to-left container. + +{{ AreaChartDefaultRTL }} + +## API Fluent Area Chart + +{{ API Type=FluentAreaChart }} + +## API Area Chart Data + +{{ API Type=AreaChartSeries Properties=All }} + +## API Area Chart Data Point + +{{ API Type=AreaChartDataPoint Properties=All }} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Pages/FluentDonutChartPage.md b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Pages/FluentDonutChartPage.md index 38b5c3b329..fc6008f431 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Pages/FluentDonutChartPage.md +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Pages/FluentDonutChartPage.md @@ -70,17 +70,17 @@ Enabling `RoundedCorners` applies a small border-radius to each segment arc, giv {{ DonutChartRoundedCorners }} -### With custom sizing +### Value inside donut -Use the sliders to adjust the chart's `Width`, `Height`, and `InnerRadius` at runtime, demonstrating how the donut ring scales with the available space. +The `ValueInsideDonut` property sets the text displayed in the center of the ring; use the text input to change the value and see the chart update in real time. -{{ DonutChartSizing }} +{{ DonutChartValueInsideDonut }} -### Outside labels +### With custom sizing -Enabling `ShowOutsideLabels` moves each segment's label outside the ring, avoiding overlap for charts with many small segments. +Use the sliders to adjust the chart's `Width`, `Height`, and `InnerRadius` at runtime, demonstrating how the donut ring scales with the available space. -{{ DonutChartOutsideLabels }} +{{ DonutChartSizing }} ### Hide tooltip @@ -106,12 +106,6 @@ When `AllowMultipleLegendSelection` is enabled, clicking a legend item highlight {{ DonutChartMultipleLegendSelection }} -### Value inside donut - -The `ValueInsideDonut` property sets the text displayed in the center of the ring; use the text input to change the value and see the chart update in real time. - -{{ DonutChartValueInsideDonut }} - ### RTL Demonstrates the right-to-left layout mode, where segment labels and the legend are mirrored for RTL language support. diff --git a/examples/Demo/FluentUI.Demo/Program.cs b/examples/Demo/FluentUI.Demo/Program.cs index 1802348295..f6ce094e4c 100644 --- a/examples/Demo/FluentUI.Demo/Program.cs +++ b/examples/Demo/FluentUI.Demo/Program.cs @@ -4,6 +4,7 @@ using FluentUI.Demo.Client; using Microsoft.FluentUI.AspNetCore.Components; +using Microsoft.FluentUI.AspNetCore.Components.Charts; var builder = WebApplication.CreateBuilder(args); @@ -27,6 +28,7 @@ // config.DefaultValues.ForAny>().Set(p => p.Width, "100%"); // config.DefaultValues.ForAny>().Set(p => p.Multiple, false); + config.DefaultValues.ForAny().Set(p => p.RoundedCorners, true); // Use a custom localizer config.Localizer = new FluentUI.Demo.MyLocalizer(); }); diff --git a/src/Charts.Scripts/package-lock.json b/src/Charts.Scripts/package-lock.json index 7c899ba9b7..2acb46d8cb 100644 --- a/src/Charts.Scripts/package-lock.json +++ b/src/Charts.Scripts/package-lock.json @@ -10,6 +10,7 @@ "@fluentui/web-components": "^3.0.0", "@microsoft/fast-element": "^3.0.1", "@microsoft/fast-web-utilities": "^6.0.0", + "d3-axis": "3.0.0", "d3-format": "^3.0.0", "d3-scale": "^4.0.0", "d3-selection": "^3.0.0", @@ -17,6 +18,7 @@ "d3-time-format": "^4.1.0" }, "devDependencies": { + "@types/d3-axis": "3.0.6", "@types/d3-format": "^3.0.0", "@types/d3-scale": "^4.0.0", "@types/d3-selection": "^3.0.0", @@ -527,6 +529,16 @@ "tslib": "^2.8.0" } }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha1-52DldluBiLHe+jK8i7YGL4Hkx5U=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, "node_modules/@types/d3-format": { "version": "3.0.4", "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/@types/d3-format/-/d3-format-3.0.4.tgz", @@ -629,6 +641,15 @@ "node": ">=12" } }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha1-xCpKE+gTHWN7dF/Clzgkz+r5MyI=", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-color": { "version": "3.1.0", "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/d3-color/-/d3-color-3.1.0.tgz", diff --git a/src/Charts.Scripts/package.json b/src/Charts.Scripts/package.json index 6ff238acd2..7d561ac626 100644 --- a/src/Charts.Scripts/package.json +++ b/src/Charts.Scripts/package.json @@ -25,6 +25,8 @@ "@types/d3-shape": "^3.0.0", "@types/d3-scale": "^4.0.0", "@types/d3-time-format": "^4.0.3", + "@types/d3-axis": "^3.0.6", + "@types/d3-array": "^3.2.2", "esbuild": "0.28.1", "esbuild-plugin-inline-css": "0.0.1", "glob": "^13.0.6", @@ -38,6 +40,8 @@ "d3-selection": "^3.0.0", "d3-shape": "^3.0.0", "d3-scale": "^4.0.0", - "d3-time-format": "^4.1.0" + "d3-time-format": "^4.1.0", + "d3-axis": "^3.0.0", + "d3-array": "^3.2.4" } } diff --git a/src/Charts.Scripts/src/FluentUIChartComponents.ts b/src/Charts.Scripts/src/FluentUIChartComponents.ts index c30f9194f3..b414cc11a6 100644 --- a/src/Charts.Scripts/src/FluentUIChartComponents.ts +++ b/src/Charts.Scripts/src/FluentUIChartComponents.ts @@ -5,6 +5,10 @@ export namespace Microsoft.FluentUI.Blazor.FluentUIChartComponents { export function defineComponents() { // Register Chart Web Components + defineOnce('fluentui:chart-components:area-chart', () => { + FluentUIComponents.AreaChart.define(FluentUIComponents.AreaChartDefinition); + }); + defineOnce('fluentui:chart-components:chart-legend', () => { FluentUIComponents.ChartLegend.define(FluentUIComponents.ChartLegendDefinition); }); diff --git a/src/Charts.Scripts/src/area-chart/area-chart.definition.ts b/src/Charts.Scripts/src/area-chart/area-chart.definition.ts new file mode 100644 index 0000000000..98ffbf698b --- /dev/null +++ b/src/Charts.Scripts/src/area-chart/area-chart.definition.ts @@ -0,0 +1,19 @@ +import { FluentDesignSystem } from '@fluentui/web-components'; +import { type PartialFASTElementDefinition } from '@microsoft/fast-element'; +import { styles } from './area-chart.styles.js'; +import { template } from './area-chart.template.js'; + +/** + * @public + * @remarks + * HTML Element: `` + */ +export const definition: PartialFASTElementDefinition = { + name: `${FluentDesignSystem.prefix}-area-chart`, + registry: FluentDesignSystem.registry, + template, + styles, + shadowOptions: { + delegatesFocus: true, + }, +}; diff --git a/src/Charts.Scripts/src/area-chart/area-chart.options.ts b/src/Charts.Scripts/src/area-chart/area-chart.options.ts new file mode 100644 index 0000000000..05919df25e --- /dev/null +++ b/src/Charts.Scripts/src/area-chart/area-chart.options.ts @@ -0,0 +1,32 @@ +import type { AccessibilityData } from '../utils/chart-options.js'; + +/** @public */ +export interface AreaChartDataPoint { + /** @public */ x: number | Date; + /** @public */ y: number; + /** Custom aria-label for the x-axis callout when hovering this data point. */ + xAxisCalloutAccessibilityData?: AccessibilityData; + /** Custom aria-label for the series callout entry when hovering this data point. */ + callOutAccessibilityData?: AccessibilityData; +} + +/** @public */ +export interface AreaChartSeries { + /** @public */ legend: string; + /** @public */ data: AreaChartDataPoint[]; + /** @public */ color?: string; + /** + * When true this series is plotted against the right-hand secondary Y axis rather than + * the primary (left) Y axis. The secondary axis has its own independent scale. + * @public + */ + useSecondaryYScale?: boolean; +} + +/** + * Controls how the area fill is computed. + * - `'tonexty'` (default): Stacked — each area fills from the top of the previous series' line up to its own line. + * - `'tozeroy'`: Non-stacked — each area fills independently from y=0 to its own line. + * @public + */ +export type AreaChartMode = 'tonexty' | 'tozeroy'; diff --git a/src/Charts.Scripts/src/area-chart/area-chart.styles.ts b/src/Charts.Scripts/src/area-chart/area-chart.styles.ts new file mode 100644 index 0000000000..127aced6fa --- /dev/null +++ b/src/Charts.Scripts/src/area-chart/area-chart.styles.ts @@ -0,0 +1,225 @@ +import type { ElementStyles } from '@microsoft/fast-element'; +import { css } from '@microsoft/fast-element'; +import { + colorNeutralForeground1, + colorNeutralForeground2, + colorNeutralStroke1, + display, + fontSizeBase500, + shadow4, + spacingHorizontalS, + spacingVerticalXS, + strokeWidthThick, + strokeWidthThickest, + typographyBody1StrongStyles, + typographyCaption1Styles, + typographySubtitle2StrongerStyles, +} from '@fluentui/web-components'; +import { tooltipBaseStyles } from '../utils/tooltip.styles.js'; + +export const styles: ElementStyles = css` + ${display('block')} + + :host { + display: grid; + grid-template-areas: + 'title' + 'chart' + 'legend'; + grid-template-columns: 1fr; + grid-template-rows: auto 1fr auto; + position: relative; + width: 100%; + } + + .chart-title { + grid-area: title; + margin-bottom: 8px; + ${typographyBody1StrongStyles} + text-align: start; + } + + .chart-container { + grid-area: chart; + min-width: 0; + } + + fluent-chart-legend { + grid-area: legend; + } + + :host([title-position='bottom']) { + grid-template-areas: + 'chart' + 'legend' + 'title'; + } + + :host([title-position='bottom']) .chart-title { + margin-bottom: 0; + margin-top: 8px; + } + + :host([legend-position='top']) { + grid-template-areas: + 'title' + 'legend' + 'chart'; + } + + :host([legend-position='start']) { + grid-template-areas: + 'title title' + 'legend chart'; + grid-template-columns: auto 1fr; + } + + :host([legend-position='end']) { + grid-template-areas: + 'title title' + 'chart legend'; + grid-template-columns: 1fr auto; + } + + :host([legend-position='start']) fluent-chart-legend, + :host([legend-position='end']) fluent-chart-legend { + align-self: start; + } + + :host([title-position='bottom'][legend-position='top']) { + grid-template-areas: + 'legend' + 'chart' + 'title'; + grid-template-columns: 1fr; + } + + :host([title-position='bottom'][legend-position='start']) { + grid-template-areas: + 'legend chart' + 'title title'; + grid-template-columns: auto 1fr; + } + + :host([title-position='bottom'][legend-position='end']) { + grid-template-areas: + 'chart legend' + 'title title'; + grid-template-columns: 1fr auto; + } + + :host([title-align='center']) .chart-title { + text-align: center; + } + + :host([title-align='end']) .chart-title { + text-align: end; + } + + .chart-svg { + display: block; + overflow: visible; + } + + .axis-domain, + .origin-line { + stroke: ${colorNeutralStroke1}; + stroke-width: 1; + opacity: 0.2; + } + + .axis-tick-line { + stroke: ${colorNeutralForeground1}; + stroke-width: 1; + opacity: 0.24; + } + + .y-axis-grid-line { + stroke: ${colorNeutralForeground1}; + stroke-width: 1; + opacity: 0.2; + pointer-events: none; + } + + .axis-text, + .y-axis-text, + .x-axis-title, + .y-axis-title { + ${typographyCaption1Styles} + fill: ${colorNeutralForeground2}; + font-size: 10px; + font-weight: 600; + } + + .area-line { + fill: none; + stroke-width: 3; + } + + .area-line.multi-series { + opacity: 0.3; + } + + .area-line.hovered { + stroke-width: 3; + opacity: 1; + } + + .area-path.inactive, + .area-line.inactive { + opacity: 0.1; + } + + .hover-line { + stroke: ${colorNeutralStroke1}; + stroke-width: 1; + stroke-dasharray: 5 3; + pointer-events: none; + } + + .hover-dot { + pointer-events: none; + } + + ${tooltipBaseStyles} + + .tooltip { + ${typographyCaption1Styles} + z-index: 999; + box-shadow: ${shadow4}; + border: ${strokeWidthThick}; + white-space: nowrap; + } + + .tooltip-header { + ${typographyCaption1Styles} + color: ${colorNeutralForeground2}; + opacity: 0.8; + } + + .tooltip-info { + margin-top: 11px; + padding-inline-start: ${spacingHorizontalS}; + border-inline-start: ${strokeWidthThickest} solid; + } + + .tooltip-legend-text { + ${typographyCaption1Styles} + color: ${colorNeutralForeground1}; + text-align: start; + margin-bottom: ${spacingVerticalXS}; + } + + .tooltip-primary-value { + ${typographySubtitle2StrongerStyles} + font-size: ${fontSizeBase500}; + direction: ltr; + unicode-bidi: isolate; + } + + @media (forced-colors: active) { + .tooltip-info { + forced-color-adjust: none; + } + } +`; diff --git a/src/Charts.Scripts/src/area-chart/area-chart.template.ts b/src/Charts.Scripts/src/area-chart/area-chart.template.ts new file mode 100644 index 0000000000..0cdb94fd99 --- /dev/null +++ b/src/Charts.Scripts/src/area-chart/area-chart.template.ts @@ -0,0 +1,54 @@ +import { ElementViewTemplate, html, ref, repeat, when } from '@microsoft/fast-element'; +import type { AreaChart, TooltipEntry } from './area-chart.js'; + +export function areaChartTemplate(): ElementViewTemplate { + return html` + + `; +} + +export const template: ElementViewTemplate = areaChartTemplate(); diff --git a/src/Charts.Scripts/src/area-chart/area-chart.ts b/src/Charts.Scripts/src/area-chart/area-chart.ts new file mode 100644 index 0000000000..318abc3d1a --- /dev/null +++ b/src/Charts.Scripts/src/area-chart/area-chart.ts @@ -0,0 +1,996 @@ +import { attr } from '@microsoft/fast-element'; +import { bisector, extent } from 'd3-array'; +import { axisBottom, axisLeft, axisRight, type Axis, type AxisDomain } from 'd3-axis'; +import { format, formatPrefix } from 'd3-format'; +import { scaleLinear, scaleTime, type ScaleLinear, type ScaleTime } from 'd3-scale'; +import { area as createArea, curveMonotoneX, line as createLine, stack as createStack } from 'd3-shape'; +import { timeFormat, utcFormat } from 'd3-time-format'; +import type { TooltipProps } from '../utils/chart-options.js'; +import { CartesianChartBase } from '../utils/cartesian-chart-base.js'; +import { + applyAxisTickConfig, + type AxisScaleLike, + computePreparedNumericYAxis, + DEFAULT_REACT_NUMERIC_Y_TICK_COUNT, + renderBottomAxisShared, + renderHorizontalGridLinesShared, + renderPrimaryYAxisShared, + renderSecondaryYAxisShared, + toAxisNumber as toNumber, + toOptionalAxisNumber as toOptionalNumber, +} from '../utils/cartesian-axis-shared.js'; +import { + escapeHtml, + formatLocaleNumber, + getColorFromToken, + getNextColor, + getRTL, + jsonConverter, + parseDateOrNumber, + SVG_NAMESPACE_URI, +} from '../utils/chart-helpers.js'; +import type { AreaChartDataPoint, AreaChartMode, AreaChartSeries } from './area-chart.options.js'; + +const createSvgElement = (tag: string): T => + document.createElementNS(SVG_NAMESPACE_URI, tag) as T; + +/** A single series entry shown in the multi-series hover tooltip. */ +export type TooltipEntry = { legend: string; color: string; value: string; callOutAriaLabel?: string }; + +type TooltipState = TooltipProps & { xLabel: string; xAxisAriaLabel?: string; entries: TooltipEntry[] }; +type XValue = number | Date; +type ContinuousScale = ScaleLinear | ScaleTime; +type NormalizedPoint = AreaChartDataPoint & { x: XValue; xLabel: string; cx: number; cy: number }; +type NormalizedSeries = { legend: string; color: string; data: NormalizedPoint[] }; + +const defaultMargins = { top: 40, right: 20, bottom: 50, left: 60 }; + +const formatNumberValue = (value: number, specifier: string | undefined, culture: string | undefined): string => { + if (specifier) { + try { + return format(specifier)(value); + } catch { + // Fall back to locale formatting below. + } + } + return formatLocaleNumber(value, culture); +}; + +/** + * Default y-axis tick formatter matching React charting's `defaultYAxisTickFormatter`. + * Uses d3 SI-prefix notation (e.g. 10k, 1.5M) for values ≥ 1 and general format for + * small values, keeping up to 2 significant digits and trimming trailing zeros. + */ +const defaultYAxisTickFormatter = (value: number): string => { + if (Math.abs(value) < 1) { + return format('.2~g')(value); + } + return formatPrefix('.2~', value)(value); +}; + +const formatDateValue = (chart: AreaChart, value: Date): string => { + if (chart.customDateTimeFormatter) { + return chart.customDateTimeFormatter(value); + } + if (chart.tickFormat) { + try { + return (chart.useUTC ? utcFormat(chart.tickFormat) : timeFormat(chart.tickFormat))(value); + } catch { + // Fall back to Intl below. + } + } + try { + return new Intl.DateTimeFormat(chart.culture, chart.dateLocalizeOptions).format(value); + } catch { + return new Intl.DateTimeFormat(undefined, chart.dateLocalizeOptions).format(value); + } +}; + +const getNormalizedXValue = (value: number | Date): XValue => { + const parsed = parseDateOrNumber(value as number | Date | string); + return parsed instanceof Date ? parsed : Number(parsed); +}; + +/** @public */ +export class AreaChart extends CartesianChartBase { + public declare tooltipProps: TooltipState; + + @attr({ converter: jsonConverter }) + public data!: AreaChartSeries[]; + + @attr({ attribute: 'enable-gradient', mode: 'boolean' }) + public enableGradient: boolean = false; + + /** + * Controls how areas are filled. + * - `'tonexty'` (default): Stacked — each area fills from the previous series up. + * - `'tozeroy'`: Non-stacked — each series fills independently from y=0. + */ + @attr() + public mode: AreaChartMode = 'tonexty'; + + /** Optional title for the secondary (right) Y axis. */ + @attr({ attribute: 'secondary-y-axis-title' }) + public secondaryYAxisTitle: string = ''; + + /** Max width in px for secondary y-axis tick labels before truncating with ellipsis. */ + @attr({ attribute: 'secondary-y-axis-tick-label-max-width' }) + public secondaryYAxisTickLabelMaxWidth?: number | string; + + protected override _enableResizeObserver = true; + + public connectedCallback() { + const self = this as Record; + const attrFields = [ + 'data', + 'enableGradient', + 'mode', + 'secondaryYAxisTitle', + 'secondaryYAxisTickLabelMaxWidth', + ] as const; + const saved: Partial> = {}; + + for (const field of attrFields) { + saved[field] = self[field]; + delete self[field]; + } + + super.connectedCallback(); + + for (const field of attrFields) { + if (self[field] === undefined && saved[field] !== undefined) { + self[field] = saved[field]; + } + } + + this.tooltipProps = { ...this.tooltipProps, xLabel: '', entries: [] } as TooltipState; + this._requestRender(); + } + + public get tooltipInlineTransform(): string { + // Position the tooltip to the right (LTR) or left (RTL) of the hover crosshair so it + // does not cover the indicator line and hover dots. React's Callout uses + // DirectionalHint.topAutoEdge anchored on the highlighted circle, which has the same + // visual result: the tooltip appears beside the data point, not on top of it. + return this._isRTL ? 'translateX(calc(-100% - 16px))' : 'translateX(16px)'; + } + + protected dataChanged(): void { + this._requestRender(); + } + + protected enableGradientChanged(): void { + this._requestRender(); + } + + protected modeChanged(): void { + this._requestRender(); + } + + protected secondaryYAxisTitleChanged(): void { + this._requestRender(); + } + + protected secondaryYAxisTickLabelMaxWidthChanged(): void { + this._requestRender(); + } + + protected override _clearTooltip(): void { + this.tooltipProps = { + isVisible: false, + legend: '', + xLabel: '', + xAxisAriaLabel: undefined, + yValue: '', + color: '', + xPos: 0, + yPos: 0, + entries: [], + }; + } + + protected override tooltipPropsChanged(old: TooltipProps, newValue: TooltipProps): void { + super.tooltipPropsChanged(old, newValue); + if (newValue.isVisible && !this.hideTooltip) { + const state = newValue as TooltipState; + const xText = state.xAxisAriaLabel ?? state.xLabel; + const parts = [xText, ...(state.entries ?? []).map(e => e.callOutAriaLabel ?? `${e.legend}: ${e.value}`)].filter( + Boolean, + ); + this.liveRegionText = parts.join('. '); + } + } + + protected override _buildDefaultTooltipHTML(): string { + const state = this.tooltipProps as TooltipState; + const header = `
${escapeHtml(state.xLabel ?? '')}
`; + const entries = (state.entries ?? []) + .map(e => + [ + `
`, + `
${escapeHtml(e.legend)}
`, + `
${escapeHtml(e.value)}
`, + `
`, + ].join(''), + ) + .join(''); + return header + entries; + } + + protected override _performRender(): void { + if (!this.$fastController.isConnected || !this.chartContainer) { + return; + } + + this._applyHostDimensions(this.width, this.height); + this._clearChart(); + + const seriesData = Array.isArray(this.data) ? this.data : []; + if (seriesData.length === 0) { + this.legends = []; + this._updateLegendInteractionState(); + this.elementInternals.ariaLabel = this._getHostAriaLabel(); + return; + } + + const flattened = seriesData.flatMap(series => series.data.map(point => getNormalizedXValue(point.x))); + const isDateAxis = flattened.some(value => value instanceof Date); + + const normalizedSeries: NormalizedSeries[] = seriesData.map((series, index) => { + const color = series.color ? getColorFromToken(series.color) : getNextColor(index, 0); + const data = series.data.map(point => { + const x = getNormalizedXValue(point.x); + return { + x, + y: point.y, + xLabel: + x instanceof Date ? formatDateValue(this, x) : formatNumberValue(x, this.xAxisTickFormat, this.culture), + cx: 0, + cy: 0, + }; + }); + return { legend: series.legend, color, data }; + }); + + // Determine which series are plotted on the secondary (right) Y axis. + const isSecondaryByIndex: boolean[] = seriesData.map(s => Boolean(s.useSecondaryYScale)); + const hasSecondaryY = isSecondaryByIndex.some(Boolean); + + const isRtl = getRTL(this); + const width = this.chartContainer.getBoundingClientRect().width || toNumber(this.width, 500); + const height = toNumber(this.height, 300); + // In RTL the primary Y axis moves to the right side and the secondary to the left, + // so the two margin sides swap compared to LTR. + const primaryAxisSpace = defaultMargins.left; // 60 px — space for Y axis ticks + labels + const secondaryAxisSpace = 70; // extra space when a secondary Y axis is present + const leftMargin = isRtl ? (hasSecondaryY ? secondaryAxisSpace : defaultMargins.right) : primaryAxisSpace; + const rightMargin = isRtl ? primaryAxisSpace : hasSecondaryY ? secondaryAxisSpace : defaultMargins.right; + const innerWidth = Math.max(width - leftMargin - rightMargin, 1); + const innerHeight = Math.max(height - defaultMargins.top - defaultMargins.bottom, 1); + + // Build a unified dataset keyed by x-value so d3Stack can compute stacked layers. + // Each entry holds all series values at that x position (missing values default to 0). + const legendKeys = normalizedSeries.map(s => s.legend); + // Only primary-axis series participate in stacking. + const primaryLegendKeys = legendKeys.filter((_, i) => !isSecondaryByIndex[i]); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const xEntryMap = new Map(); + normalizedSeries.forEach(series => { + series.data.forEach(point => { + const key = String(point.x instanceof Date ? point.x.getTime() : point.x); + if (!xEntryMap.has(key)) { + xEntryMap.set(key, { xVal: point.x, xLabel: point.xLabel }); + } + xEntryMap.get(key)[series.legend] = point.y; + }); + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const stackDataset: any[] = [...xEntryMap.values()].sort((a, b) => { + const ax = a.xVal instanceof Date ? a.xVal.getTime() : Number(a.xVal); + const bx = b.xVal instanceof Date ? b.xVal.getTime() : Number(b.xVal); + return ax - bx; + }); + // Ensure every entry has a value for every legend (fill missing with 0) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + stackDataset.forEach((dp: any) => + legendKeys.forEach(k => { + if (dp[k] === undefined) dp[k] = 0; + }), + ); + + // mode='tonexty' (default): stacked — d3Stack computes cumulative bands. + // mode='tozeroy': non-stacked — each series' area fills independently from y=0. + // Secondary-axis series always use tozeroy ([0, y]) regardless of mode. + // This mirrors React's _shouldFillToZeroY() / _getDataPoints() logic. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let primaryStackedLayers: any[]; + if (this.mode === 'tozeroy' || primaryLegendKeys.length === 0) { + primaryStackedLayers = primaryLegendKeys.map(key => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + stackDataset.map((dp: any) => { + const entry = [0, dp[key] as number] as any; + entry.data = dp; + return entry; + }), + ); + } else { + primaryStackedLayers = createStack().keys(primaryLegendKeys)(stackDataset); + } + // Secondary series always use [0, y] (independent scale, no stacking). + const secondaryLegendKeys = legendKeys.filter((_, i) => isSecondaryByIndex[i]); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const secondaryStackedLayers: any[] = secondaryLegendKeys.map(key => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + stackDataset.map((dp: any) => { + const entry = [0, dp[key] as number] as any; + entry.data = dp; + return entry; + }), + ); + // Merge back in original series order. + let primaryIdx = 0; + let secondaryIdx = 0; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const stackedLayers: any[] = legendKeys.map((_, i) => { + if (isSecondaryByIndex[i]) { + return secondaryStackedLayers[secondaryIdx++]; + } + return primaryStackedLayers[primaryIdx++]; + }); + + // Compute primary-axis raw and stacked ranges. + // React AreaChart uses raw min/max for the base range and a stacked `maxOfYVal` + // override in tonexty mode for the primary axis. + const primaryRawValues = normalizedSeries + .filter((_, i) => !isSecondaryByIndex[i]) + .flatMap(series => series.data.map(point => point.y)); + const rawPrimaryMin = primaryRawValues.length > 0 ? Math.min(...primaryRawValues) : 0; + const rawPrimaryMax = primaryRawValues.length > 0 ? Math.max(...primaryRawValues) : 1; + + // Compute the stacked upper/lower bounds for primary layers. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const primaryLayersForDomain = primaryStackedLayers.length > 0 ? primaryStackedLayers : secondaryStackedLayers; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const allPrimaryValues: number[] = (primaryLayersForDomain as any[]).flatMap(layer => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (layer as any[]).flatMap((d: any) => [d[0] as number, d[1] as number]), + ); + const stackedDataMin = allPrimaryValues.length > 0 ? Math.min(...allPrimaryValues) : 0; + const stackedDataMax = allPrimaryValues.length > 0 ? Math.max(...allPrimaryValues) : 1; + const maxOfYVal = hasSecondaryY ? rawPrimaryMax : stackedDataMax; + const yTickCount = toNumber(this.yAxisTickCount, DEFAULT_REACT_NUMERIC_Y_TICK_COUNT); + + let yMin = toOptionalNumber(this.yMinValue) ?? Math.min(0, rawPrimaryMin, stackedDataMin); + let yMax = toOptionalNumber(this.yMaxValue) ?? Math.max(0, maxOfYVal); + if (yMin === yMax) { + yMax += 1; + } + + const preparedPrimaryYAxis = computePreparedNumericYAxis({ + minValue: yMin, + maxValue: yMax, + tickCount: yTickCount, + roundedTicks: this.roundedTicks, + }); + + let xScale: ContinuousScale; + let xFormatter: (value: AxisDomain) => string; + if (isDateAxis) { + const dateValues = normalizedSeries + .flatMap(series => series.data.map(point => point.x)) + .filter((value): value is Date => value instanceof Date); + const rawExtent = extent(dateValues, value => value.getTime()); + const xMin = + this.xMinValue !== undefined + ? parseDateOrNumber(this.xMinValue as string | number) + : new Date(rawExtent[0] ?? 0); + const xMax = + this.xMaxValue !== undefined + ? parseDateOrNumber(this.xMaxValue as string | number) + : new Date(rawExtent[1] ?? 0); + const domainMin = xMin instanceof Date ? xMin : new Date(Number(xMin)); + const domainMax = xMax instanceof Date ? xMax : new Date(Number(xMax)); + xScale = scaleTime().domain([domainMin, domainMax]).range([0, innerWidth]); + if (this.roundedTicks) { + xScale.nice(); + } + xFormatter = value => formatDateValue(this, value as Date); + } else { + const xValues = normalizedSeries + .flatMap(series => series.data.map(point => point.x)) + .filter((value): value is number => typeof value === 'number'); + const rawExtent = extent(xValues); + let xMin = toOptionalNumber(this.xMinValue) ?? rawExtent[0] ?? 0; + let xMax = toOptionalNumber(this.xMaxValue) ?? rawExtent[1] ?? 1; + if (xMin === xMax) { + xMin -= 1; + xMax += 1; + } + xScale = scaleLinear().domain([xMin, xMax]).range([0, innerWidth]); + if (this.roundedTicks) { + xScale.nice(); + } + xFormatter = value => formatNumberValue(Number(value), this.xAxisTickFormat, this.culture); + } + + const yScale = scaleLinear() + .domain([preparedPrimaryYAxis.domainMin, preparedPrimaryYAxis.domainMax]) + .range([innerHeight, 0]); + + // Secondary Y axis scale: derived from secondary series raw values only. + let yScaleSecondary = yScale; // fallback: same as primary when no secondary series + let preparedSecondaryYAxis = preparedPrimaryYAxis; + if (hasSecondaryY) { + const secValues = seriesData + .flatMap((s, i) => (isSecondaryByIndex[i] ? s.data.map(p => p.y) : [])) + .filter(v => typeof v === 'number'); + const secMin = secValues.length > 0 ? Math.min(0, ...secValues) : 0; + let secMax = secValues.length > 0 ? Math.max(0, ...secValues) : 1; + if (secMin === secMax) { + secMax += 1; + } + preparedSecondaryYAxis = computePreparedNumericYAxis({ + minValue: secMin, + maxValue: secMax, + tickCount: yTickCount, + roundedTicks: this.roundedTicks, + }); + yScaleSecondary = scaleLinear() + .domain([preparedSecondaryYAxis.domainMin, preparedSecondaryYAxis.domainMax]) + .range([innerHeight, 0]); + } + + normalizedSeries.forEach((series, si) => { + const layer = stackedLayers[si]; + const scale = isSecondaryByIndex[si] ? yScaleSecondary : yScale; + series.data.forEach(point => { + point.cx = xScale(point.x as never) ?? 0; + // cy reflects the top of the stacked layer at this x-value (for tooltip placement) + const xKey = String(point.x instanceof Date ? point.x.getTime() : point.x); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const stackPoint = layer.find( + (d: any) => String(d.data.xVal instanceof Date ? d.data.xVal.getTime() : d.data.xVal) === xKey, + ); + point.cy = stackPoint ? scale(stackPoint[1]) : scale(point.y); + }); + }); + + const svg = createSvgElement('svg'); + svg.classList.add('chart-svg'); + svg.setAttribute('width', String(width)); + svg.setAttribute('height', String(height)); + svg.setAttribute('viewBox', `0 0 ${width} ${height}`); + + const defs = createSvgElement('defs'); + svg.appendChild(defs); + + const plotGroup = createSvgElement('g'); + plotGroup.setAttribute('transform', `translate(${leftMargin}, ${defaultMargins.top})`); + svg.appendChild(plotGroup); + + const xAxis = axisBottom(xScale).tickPadding(toNumber(this.tickPadding, 6)); + if (!isDateAxis) { + xAxis.ticks(6); + } + if (this.tickValues?.length) { + if (isDateAxis) { + xAxis.tickValues(this.tickValues.map(value => parseDateOrNumber(value as string | number | Date) as Date)); + } else { + xAxis.tickValues(this.tickValues.map(value => Number(value))); + } + } + + const yAxis = axisLeft(yScale).tickPadding(toNumber(this.tickPadding, 6)); + applyAxisTickConfig( + yAxis, + this.yAxisTickCount ?? DEFAULT_REACT_NUMERIC_Y_TICK_COUNT, + this.yAxisTickValues ?? preparedPrimaryYAxis.tickValues, + ); + + renderHorizontalGridLinesShared({ + plotGroup, + scale: yScale, + axis: yAxis as unknown as Axis, + innerWidth, + }); + + // Pre-compute a lookup map from x-value key → all series entries at that x. + // This mirrors React's calloutData() utility: group all series by their x value + // so the mousemove handler can retrieve every series in O(1) without re-iterating. + // Entries are stored as a SPARSE ARRAY indexed by series index so sparse x-values + // across series don't cause index mismatches. + type CalloutEntry = { + legend: string; + color: string; + y: number; + stackedY1: number; + isSecondaryY: boolean; + callOutAriaLabel?: string; + }; + type CalloutPoint = { xLabel: string; cx: number; xAxisAriaLabel?: string; entries: (CalloutEntry | undefined)[] }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const calloutPointsByX = new Map(); + normalizedSeries.forEach((series, si) => { + const layer = stackedLayers[si]; + series.data.forEach((point, di) => { + const key = point.x instanceof Date ? point.x.getTime() : Number(point.x); + if (!calloutPointsByX.has(key)) { + calloutPointsByX.set(key, { + xLabel: point.xLabel, + cx: point.cx, + xAxisAriaLabel: point.xAxisCalloutAccessibilityData?.ariaLabel, + entries: [], + }); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const stackedY1 = (layer[di] as any)?.[1] ?? point.y; + // Store at series index so sparse datasets don't misalign entries. + calloutPointsByX.get(key)!.entries[si] = { + legend: series.legend, + color: series.color, + y: point.y, + stackedY1, + isSecondaryY: isSecondaryByIndex[si], + callOutAriaLabel: point.callOutAccessibilityData?.ariaLabel, + }; + }); + }); + + // Bisector mirrors React's: bisector((d) => d.x).left on the first series data, + // then compare d0/d1 neighbors to pick the closer domain value. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const bisect = bisector((d: any) => d.xVal).left; + + const isMultiSeriesChart = normalizedSeries.length > 1; + + stackedLayers.forEach((layer: any, index: number) => { + const series = normalizedSeries[index]; + const scale = isSecondaryByIndex[index] ? yScaleSecondary : yScale; + if (this.enableGradient) { + const gradient = createSvgElement('linearGradient'); + gradient.id = `area-gradient-${index}`; + gradient.setAttribute('x1', '0%'); + gradient.setAttribute('x2', '0%'); + gradient.setAttribute('y1', '0%'); + gradient.setAttribute('y2', '100%'); + + const start = createSvgElement('stop'); + start.setAttribute('offset', '0%'); + start.setAttribute('stop-color', series.color); + start.setAttribute('stop-opacity', '0.8'); + gradient.appendChild(start); + + const end = createSvgElement('stop'); + end.setAttribute('offset', '100%'); + end.setAttribute('stop-color', series.color); + end.setAttribute('stop-opacity', '0.1'); + gradient.appendChild(end); + defs.appendChild(gradient); + } + + const areaPath = createSvgElement('path'); + areaPath.classList.add('area-path'); + areaPath.dataset.legend = series.legend; + areaPath.setAttribute('fill', this.enableGradient ? `url(#area-gradient-${index})` : series.color); + areaPath.setAttribute('fill-opacity', '0.7'); + areaPath.setAttribute( + 'd', + createArea() + .x((d: any) => xScale(d.data.xVal as never) ?? 0) + .y0((d: any) => scale(d[0])) + .y1((d: any) => scale(d[1])) + .curve(curveMonotoneX)(layer) ?? '', + ); + plotGroup.appendChild(areaPath); + + const linePath = createSvgElement('path'); + linePath.classList.add('area-line'); + if (isMultiSeriesChart) { + linePath.classList.add('multi-series'); + } + linePath.dataset.legend = series.legend; + linePath.setAttribute('fill', 'none'); + linePath.setAttribute('stroke', series.color); + linePath.setAttribute( + 'd', + createLine() + .x((d: any) => xScale(d.data.xVal as never) ?? 0) + .y((d: any) => scale(d[1])) + .curve(curveMonotoneX)(layer) ?? '', + ); + plotGroup.appendChild(linePath); + }); + + // Hover elements: vertical intercept line + one dot per series (matching React's behaviour). + const hoverLine = createSvgElement('line'); + hoverLine.classList.add('hover-line'); + const hoverLineY1 = defaultMargins.top / 2; + const hoverLineY2 = height - defaultMargins.bottom / 2; + hoverLine.setAttribute('y1', String(hoverLineY1)); + hoverLine.setAttribute('y2', String(hoverLineY2)); + hoverLine.style.display = 'none'; + svg.appendChild(hoverLine); + + const hoverDots = normalizedSeries.map(series => { + const dot = createSvgElement('circle'); + dot.classList.add('hover-dot'); + dot.setAttribute('r', '6'); + dot.setAttribute('fill', '#fff'); + dot.setAttribute('stroke', series.color); + dot.setAttribute('stroke-width', '2'); + dot.style.display = 'none'; + plotGroup.appendChild(dot); + return dot; + }); + + const focusablePoints: SVGCircleElement[] = []; + + const showPointTooltip = ( + event: FocusEvent, + series: NormalizedSeries, + point: NormalizedPoint, + element: SVGCircleElement, + ): void => { + if (this.hideTooltip) { + return; + } + + const hostRect = this.getBoundingClientRect(); + const targetRect = element.getBoundingClientRect(); + const anchorX = targetRect.left - hostRect.left + targetRect.width / 2; + const anchorY = targetRect.top - hostRect.top + targetRect.height / 2; + const value = formatNumberValue(point.y, this.yAxisTickFormat, this.culture); + + element.setAttribute('fill', '#fff'); + element.setAttribute('stroke', series.color); + element.setAttribute('stroke-width', '2'); + + this._currentTooltipDataPoint = { legend: series.legend, x: point.x, y: point.y }; + this.tooltipProps = { + isVisible: true, + legend: series.legend, + yValue: value, + color: series.color, + xLabel: point.xLabel, + xAxisAriaLabel: point.xAxisCalloutAccessibilityData?.ariaLabel, + entries: [ + { + legend: series.legend, + color: series.color, + value, + callOutAriaLabel: point.callOutAccessibilityData?.ariaLabel, + }, + ], + xPos: anchorX, + yPos: anchorY, + }; + this._positionTooltipFromAnchor(anchorX, anchorY, { outputAnchorX: true, preferredVertical: 'above' }); + }; + + const pointKeydown = (event: KeyboardEvent): void => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + return; + } + + const currentTarget = event.currentTarget as SVGCircleElement | null; + if (!currentTarget) { + return; + } + + let orderedPoints = focusablePoints; + if (event.key === 'ArrowUp' || event.key === 'ArrowDown') { + const xKey = currentTarget.getAttribute('data-x-key'); + if (!xKey) { + return; + } + + orderedPoints = Array.from( + this.shadowRoot?.querySelectorAll('.data-point-focus-target') ?? [], + ) + .filter(point => point.getAttribute('data-x-key') === xKey) + .sort( + (left, right) => Number(left.getAttribute('data-cy') ?? '0') - Number(right.getAttribute('data-cy') ?? '0'), + ); + + if (orderedPoints.length === 0) { + return; + } + } + + const currentIndex = orderedPoints.indexOf(currentTarget); + if (currentIndex === -1) { + return; + } + + const forward = event.key === 'ArrowRight' || event.key === 'ArrowDown'; + const backward = event.key === 'ArrowLeft' || event.key === 'ArrowUp'; + if (!forward && !backward) { + return; + } + + event.preventDefault(); + const nextIndex = forward + ? (currentIndex + 1) % orderedPoints.length + : (currentIndex - 1 + orderedPoints.length) % orderedPoints.length; + orderedPoints[currentIndex].tabIndex = -1; + orderedPoints[nextIndex].tabIndex = 0; + orderedPoints[nextIndex].focus(); + }; + + // Transparent overlay rect captures all mouse events across the plot area. + // Use fill="white" + fill-opacity="0" (not fill="transparent") so SVG hit-testing + // still fires even when the cursor is between visible paths. + const overlay = createSvgElement('rect'); + overlay.setAttribute('x', '0'); + overlay.setAttribute('y', '0'); + overlay.setAttribute('width', String(innerWidth)); + overlay.setAttribute('height', String(innerHeight)); + overlay.setAttribute('fill', 'white'); + overlay.setAttribute('fill-opacity', '0'); + overlay.setAttribute('pointer-events', 'all'); + plotGroup.appendChild(overlay); + + const onOverlayMouseMove = (event: MouseEvent) => { + const svgRect = svg.getBoundingClientRect(); + // localX is in plot-group coordinates (same coordinate space as xScale's range). + const localX = event.clientX - svgRect.left - leftMargin; + + // Invert pixel → domain value, then bisect by domain value. + // This is the exact same approach React uses: + // xOffset = xScale.invert(pointer(event)[0]) + // i = bisect(data, xOffset) → compare d0/d1 to find nearest + const xOffset = xScale.invert(localX); + const i = bisect(stackDataset, xOffset); + const d0 = stackDataset[i - 1] as (typeof stackDataset)[0] | undefined; + const d1 = stackDataset[i] as (typeof stackDataset)[0] | undefined; + + let nearestDataPoint: (typeof stackDataset)[0] | undefined; + if (d0 === undefined) { + nearestDataPoint = d1; + } else if (d1 === undefined) { + nearestDataPoint = d0; + } else if (isDateAxis) { + const x0 = (xOffset as Date).getTime(); + const p0 = (d0.xVal as Date).getTime(); + const p1 = (d1.xVal as Date).getTime(); + nearestDataPoint = Math.abs(x0 - p0) > Math.abs(x0 - p1) ? d1 : d0; + } else { + const x0 = xOffset as number; + const p0 = d0.xVal as number; + const p1 = d1.xVal as number; + nearestDataPoint = Math.abs(x0 - p0) > Math.abs(x0 - p1) ? d1 : d0; + } + + if (!nearestDataPoint) { + return; + } + + const xKey = + nearestDataPoint.xVal instanceof Date ? nearestDataPoint.xVal.getTime() : Number(nearestDataPoint.xVal); + const found = calloutPointsByX.get(xKey); + if (!found) { + return; + } + + const cx = xScale(nearestDataPoint.xVal as never) ?? 0; + + // Position the vertical intercept line. + const svgX = leftMargin + cx; + hoverLine.setAttribute('x1', String(svgX)); + hoverLine.setAttribute('x2', String(svgX)); + hoverLine.style.display = ''; + + // Position hover dots at the top of each stacked layer; collect tooltip entries. + let topY = innerHeight; + const hoverEntries: Array = []; + normalizedSeries.forEach((series, si) => { + const entry = found.entries[si]; + const active = entry && this._shouldShowTooltip(series.legend); + if (active) { + const dotScale = entry.isSecondaryY ? yScaleSecondary : yScale; + const cy = dotScale(entry.stackedY1); + hoverDots[si].setAttribute('cx', String(cx)); + hoverDots[si].setAttribute('cy', String(cy)); + hoverDots[si].style.display = ''; + topY = Math.min(topY, cy); + hoverEntries.push({ + legend: series.legend, + color: series.color, + value: formatNumberValue(entry.y, this.yAxisTickFormat, this.culture), + callOutAriaLabel: entry.callOutAriaLabel, + cy, + }); + } else { + hoverDots[si].style.display = 'none'; + } + }); + + // Keep tooltip rows aligned with visual stack order: top-most series first. + const entries: TooltipEntry[] = hoverEntries + .sort((left, right) => left.cy - right.cy) + .map(({ cy: _cy, ...tooltipEntry }) => tooltipEntry); + + // Apply .hovered class to series line paths. + plotGroup.querySelectorAll('.area-line').forEach(p => p.classList.add('hovered')); + + if (!this.hideTooltip && entries.length > 0) { + const hostRect = this.getBoundingClientRect(); + const anchorX = svgRect.left - hostRect.left + leftMargin + cx; + const anchorY = svgRect.top - hostRect.top + defaultMargins.top + topY; + this._currentTooltipDataPoint = { xLabel: found.xLabel, entries }; + this.tooltipProps = { + isVisible: true, + legend: entries[0].legend, + yValue: entries[0].value, + color: entries[0].color, + xLabel: found.xLabel, + xAxisAriaLabel: found.xAxisAriaLabel, + entries, + xPos: anchorX, + yPos: anchorY, + }; + this._positionTooltipFromAnchor(anchorX, anchorY, { outputAnchorX: true, preferredVertical: 'above' }); + } + }; + + const onOverlayMouseLeave = () => { + hoverLine.style.display = 'none'; + hoverDots.forEach(dot => { + dot.style.display = 'none'; + }); + plotGroup.querySelectorAll('.area-line').forEach(p => p.classList.remove('hovered')); + this._clearTooltip(); + }; + + overlay.addEventListener('mousemove', onOverlayMouseMove); + overlay.addEventListener('mouseleave', onOverlayMouseLeave); + + normalizedSeries.forEach(series => { + series.data.forEach(point => { + const pointCircle = createSvgElement('circle'); + pointCircle.classList.add('data-point-focus-target'); + pointCircle.setAttribute('cx', String(point.cx)); + pointCircle.setAttribute('cy', String(point.cy)); + pointCircle.setAttribute('r', '6'); + pointCircle.setAttribute('fill', 'transparent'); + pointCircle.setAttribute('stroke', 'transparent'); + pointCircle.setAttribute('stroke-width', '0'); + pointCircle.setAttribute('role', 'img'); + pointCircle.setAttribute( + 'aria-label', + point.callOutAccessibilityData?.ariaLabel ?? + `${point.xLabel}, ${series.legend}, ${formatNumberValue(point.y, this.yAxisTickFormat, this.culture)}.`, + ); + pointCircle.setAttribute('data-x-key', String(point.x instanceof Date ? point.x.getTime() : point.x)); + pointCircle.setAttribute('data-cy', String(point.cy)); + pointCircle.setAttribute('tabindex', focusablePoints.length === 0 ? '0' : '-1'); + pointCircle.setAttribute('pointer-events', 'none'); + pointCircle.addEventListener('focus', event => { + focusablePoints.forEach(pointEl => { + pointEl.tabIndex = pointEl === pointCircle ? 0 : -1; + }); + this._currentTooltipDataPoint = { legend: series.legend, x: point.x, y: point.y }; + pointCircle.setAttribute('fill', '#fff'); + pointCircle.setAttribute('stroke', series.color); + pointCircle.setAttribute('stroke-width', '2'); + showPointTooltip(event, series, point, pointCircle); + }); + pointCircle.addEventListener('blur', () => { + this._currentTooltipDataPoint = null; + pointCircle.setAttribute('fill', 'transparent'); + pointCircle.setAttribute('stroke', 'transparent'); + pointCircle.setAttribute('stroke-width', '0'); + this._clearTooltip(); + }); + pointCircle.addEventListener('keydown', pointKeydown); + focusablePoints.push(pointCircle); + plotGroup.appendChild(pointCircle); + }); + }); + + this._relocateFocusIfNeeded(focusablePoints); + + renderBottomAxisShared({ + svg, + scale: xScale as AxisScaleLike, + axis: xAxis as Axis, + formatter: xFormatter, + axisLeft: leftMargin, + axisTop: defaultMargins.top, + innerWidth, + innerHeight, + tickPadding: toNumber(this.tickPadding, 6), + rotateXAxisLabels: this.rotateXAxisLabels, + wrapXAxisLabels: this.wrapXAxisLabels, + hideTickOverlap: this.hideTickOverlap, + showXAxisLabelsTooltip: this.showXAxisLabelsTooltip, + xAxisTitle: this.xAxisTitle, + labelDominantBaseline: 'hanging', + }); + + const yFormatter = (value: number): string => { + if (this.yAxisTickFormat) { + try { + return format(this.yAxisTickFormat)(value); + } catch { + // Fall through to default. + } + } + return defaultYAxisTickFormatter(value); + }; + + // Skip left axis when all series are secondary (no primary data to scale against). + if (primaryStackedLayers.length > 0) { + renderPrimaryYAxisShared({ + svg, + scale: yScale as AxisScaleLike, + axis: yAxis as unknown as Axis, + formatter: yFormatter, + axisStartX: leftMargin, + axisTop: defaultMargins.top, + innerHeight, + innerWidth, + tickPadding: toNumber(this.tickPadding, 6), + isRTL: isRtl, + yAxisTitle: this.yAxisTitle, + }); + } + + if (hasSecondaryY) { + const yAxisSecondary = axisRight(yScaleSecondary).tickPadding(toNumber(this.tickPadding, 6)); + applyAxisTickConfig( + yAxisSecondary, + this.yAxisTickCount ?? DEFAULT_REACT_NUMERIC_Y_TICK_COUNT, + preparedSecondaryYAxis.tickValues, + ); + renderSecondaryYAxisShared({ + svg, + scale: yScaleSecondary as AxisScaleLike, + axis: yAxisSecondary as unknown as Axis, + formatter: yFormatter, + axisStartX: leftMargin, + axisTop: defaultMargins.top, + innerHeight, + innerWidth, + tickPadding: toNumber(this.tickPadding, 6), + isRTL: isRtl, + yAxisTitle: this.secondaryYAxisTitle, + tickLabelMaxWidth: toOptionalNumber(this.secondaryYAxisTickLabelMaxWidth), + }); + } + + this.chartContainer.appendChild(svg); + this.legends = normalizedSeries.map(series => ({ legend: series.legend, color: series.color })); + this._updateLegendInteractionState(); + this.elementInternals.ariaLabel = this._getHostAriaLabel(); + } + + protected override _applyActiveLegendState(): void { + if (!this.chartContainer) { + return; + } + const highlighted = this._getHighlightedLegends(); + const hasSelection = highlighted.length > 0; + this.chartContainer.querySelectorAll('.area-path, .area-line').forEach(element => { + const legend = element.dataset.legend ?? ''; + const isActive = !hasSelection || highlighted.includes(legend); + element.classList.toggle('inactive', !isActive); + element.setAttribute('opacity', isActive ? '1' : '0.1'); + }); + } + + protected override _getHostAriaLabel(): string { + const seriesCount = Array.isArray(this.data) ? this.data.length : 0; + if (seriesCount === 0) { + return this.chartTitle ? `${this.chartTitle}. No data.` : 'Area chart with no data.'; + } + return `${this.chartTitle || 'Area chart'}. ${seriesCount} series.`; + } + + private _clearChart(): void { + while (this.chartContainer.firstChild) { + this.chartContainer.firstChild.remove(); + } + } +} diff --git a/src/Charts.Scripts/src/area-chart/define.ts b/src/Charts.Scripts/src/area-chart/define.ts new file mode 100644 index 0000000000..38f4f19672 --- /dev/null +++ b/src/Charts.Scripts/src/area-chart/define.ts @@ -0,0 +1,5 @@ +import '../chart-legend/define.js'; +import { AreaChart } from './area-chart.js'; +import { definition } from './area-chart.definition.js'; + +AreaChart.define(definition); diff --git a/src/Charts.Scripts/src/area-chart/index.ts b/src/Charts.Scripts/src/area-chart/index.ts new file mode 100644 index 0000000000..c13d77b0d9 --- /dev/null +++ b/src/Charts.Scripts/src/area-chart/index.ts @@ -0,0 +1,7 @@ + +export { definition as AreaChartDefinition } from './area-chart.definition.js'; +export { AreaChart } from './area-chart.js'; +export { styles as AreaChartStyles } from './area-chart.styles.js'; +export { template as AreaChartTemplate } from './area-chart.template.js'; +export type { AreaChartDataPoint, AreaChartMode, AreaChartSeries } from './area-chart.options.js'; +export type { TooltipEntry } from './area-chart.js'; diff --git a/src/Charts.Scripts/src/chart-legend/chart-legend.styles.ts b/src/Charts.Scripts/src/chart-legend/chart-legend.styles.ts index 266b0b7610..8c147a8938 100644 --- a/src/Charts.Scripts/src/chart-legend/chart-legend.styles.ts +++ b/src/Charts.Scripts/src/chart-legend/chart-legend.styles.ts @@ -1,7 +1,11 @@ import type { ElementStyles } from '@microsoft/fast-element'; import { css } from '@microsoft/fast-element'; import { + borderRadiusSmall, + borderRadiusMedium, colorNeutralForeground1, + colorStrokeFocus2, + colorSubtleBackgroundHover, spacingHorizontalL, spacingHorizontalNone, spacingHorizontalS, @@ -9,6 +13,7 @@ import { spacingVerticalNone, spacingVerticalS, strokeWidthThin, + strokeWidthThick, typographyCaption1Styles, } from '@fluentui/web-components'; @@ -21,17 +26,25 @@ export const styles: ElementStyles = css` :host { display: flex; flex-direction: row; - flex-wrap: wrap; + flex-wrap: nowrap; + overflow-x: clip; + overflow-y: visible; + position: relative; + box-sizing: border-box; padding-top: ${spacingVerticalL}; + padding-inline-start: ${spacingHorizontalS}; width: 100%; align-items: center; - margin: -${spacingVerticalS} ${spacingHorizontalNone} ${spacingVerticalNone} -${spacingHorizontalS}; } :host([hidden]) { display: none; } + :host([center]) { + justify-content: center; + } + /* ── Position overrides ──────────────────────────────────────────── */ /* top: legend sits above chart — padding moves from top to bottom */ @@ -40,10 +53,11 @@ export const styles: ElementStyles = css` padding-bottom: ${spacingVerticalL}; } - /* start: legend sits inline-start of chart */ + /* start / end: vertical column layout — restore wrapping, no overflow detection */ :host([position='start']) { flex-direction: column; - flex-wrap: nowrap; + flex-wrap: wrap; + overflow: visible; width: auto; padding-top: 0; padding-inline-end: ${spacingHorizontalL}; @@ -51,10 +65,10 @@ export const styles: ElementStyles = css` margin: 0; } - /* end: legend sits inline-end of chart */ :host([position='end']) { flex-direction: column; - flex-wrap: nowrap; + flex-wrap: wrap; + overflow: visible; width: auto; padding-top: 0; padding-inline-start: ${spacingHorizontalL}; @@ -64,12 +78,23 @@ export const styles: ElementStyles = css` .legend { display: flex; + flex-shrink: 0; align-items: center; cursor: pointer; border: none; padding: ${spacingHorizontalS}; background: none; text-transform: capitalize; + border-radius: ${borderRadiusMedium}; + } + + .legend:hover { + background-color: ${colorSubtleBackgroundHover}; + } + + .legend:focus-visible { + outline: ${strokeWidthThick} solid ${colorStrokeFocus2}; + outline-offset: 1px; } .legend-rect { @@ -79,6 +104,14 @@ export const styles: ElementStyles = css` border: ${strokeWidthThin} solid; } + .legend-rect.rounded { + border-radius: ${borderRadiusSmall}; + } + +/* Same for overflow menu items */ +fluent-menu-item .legend-rect.rounded { + border-radius: ${borderRadiusSmall}; +} .legend-text { ${typographyCaption1Styles} color: ${colorNeutralForeground1}; @@ -92,6 +125,43 @@ export const styles: ElementStyles = css` opacity: 0.67; } + /* ── Overflow menu (fluent-menu/fluent-menu-item) ───────────────── */ + + fluent-menu { + flex-shrink: 0; + } + + /* + * fluent-menu-list sets data-indent="2" on all items when any item has + * role="menuitemcheckbox", which reserves a 20px column for the checkmark. + * We suppress the checkmark visually (empty ) and + * collapse that first column to 0px so no space is wasted. + * Outer-context author styles beat :host() shadow rules in the CSS cascade. + */ + fluent-menu-item { + grid-template-columns: 0 20px auto auto; + } + + fluent-menu-item .legend-rect { + width: 12px; + height: 12px; + border: ${strokeWidthThin} solid; + } + + fluent-menu-item.inactive .legend-rect { + opacity: 0.1; + } + + fluent-menu-item.inactive .legend-text { + opacity: 0.67; + } + + fluent-menu-item .legend-text { + ${typographyCaption1Styles} + color: ${colorNeutralForeground1}; + text-transform: capitalize; + } + @media (forced-colors: active) { .legend-rect { forced-color-adjust: none; diff --git a/src/Charts.Scripts/src/chart-legend/chart-legend.template.ts b/src/Charts.Scripts/src/chart-legend/chart-legend.template.ts index 610c007d1c..7b8fa30a72 100644 --- a/src/Charts.Scripts/src/chart-legend/chart-legend.template.ts +++ b/src/Charts.Scripts/src/chart-legend/chart-legend.template.ts @@ -1,6 +1,9 @@ -import { type ElementViewTemplate, html, repeat } from '@microsoft/fast-element'; +import { type ElementViewTemplate, html, repeat, when } from '@microsoft/fast-element'; +// NOTE: Item visibility is managed imperatively in ChartLegend._measure() — not +// through FAST style bindings — because repeat() does not propagate parent +// observable changes into inner bindings reactively. import type { ChartLegend } from './chart-legend.js'; -import type { Legend } from '../utils/chart.options.js'; +import type { Legend } from '../utils/chart-options.js'; import { getColorFromToken } from '../utils/chart-helpers.js'; /** @@ -33,7 +36,7 @@ export function chartLegendTemplate(): ElementViewTemplat @keydown="${(x, c) => c.parent._handleLegendKeydown(c.event as KeyboardEvent)}" >
@@ -41,6 +44,46 @@ export function chartLegendTemplate(): ElementViewTemplat `, )} + ${when( + x => x._overflowCount > 0, + html` + + + +${x => x._overflowCount} ${x => x.overflowText ?? 'more'} + + + ${repeat( + x => x._overflowItems, + html` + + +
+ ${x => x.legend} +
+ `, + )} +
+
+ `, + )} `; } diff --git a/src/Charts.Scripts/src/chart-legend/chart-legend.ts b/src/Charts.Scripts/src/chart-legend/chart-legend.ts index 9ca86e7bd3..94a9418fb7 100644 --- a/src/Charts.Scripts/src/chart-legend/chart-legend.ts +++ b/src/Charts.Scripts/src/chart-legend/chart-legend.ts @@ -1,5 +1,5 @@ import { FASTElement, attr, observable } from '@microsoft/fast-element'; -import type { ChartLegendPosition, Legend } from '../utils/chart.options.js'; +import type { ChartLegendPosition, Legend } from '../utils/chart-options.js'; import { jsonConverter } from '../utils/chart-helpers.js'; /** @@ -49,6 +49,42 @@ export class ChartLegend extends FASTElement { @attr public position?: ChartLegendPosition; + /** Label appended to the overflow count. Defaults to `'more'` → "+3 more". */ + @attr({ attribute: 'overflow-text' }) + public overflowText?: string; + + /** Centers the legend row horizontally within its host. */ + @attr({ attribute: 'center', mode: 'boolean' }) + public center = false; + + /** + * Enable rounded corners on legend colored boxes. + * When undefined, inherits from the parent chart's rounded-corners setting. + * When explicitly set, overrides the chart setting. + */ + @attr({ attribute: 'round-boxes', mode: 'boolean' }) + public roundBoxes?: boolean; + + /** + * Number of items that overflow the row. Drives the `when()` that renders + * the "+N more" button and the overflow popup. + */ + @observable + public _overflowCount: number = 0; + + /** + * The subset of `items` that are hidden in the row and shown in the popup. + * Updated imperatively by `_measure()` alongside DOM visibility changes. + */ + @observable + public _overflowItems: Legend[] = []; + + /** Index boundary used for imperative DOM hiding — not an observable. */ + private _visibleCount: number = Number.MAX_SAFE_INTEGER; + /** Cached offsetWidths so hidden items (offsetWidth=0) can still be measured. */ + private _itemWidths: number[] = []; + private _resizeObserver?: ResizeObserver; + connectedCallback() { // Class field initializers create own data properties that shadow the FAST // @attr and @observable reactive getter/setters on the prototype. Delete them @@ -57,8 +93,8 @@ export class ChartLegend extends FASTElement { // Save defaults first so we can restore them for fields that have no // corresponding HTML attribute (FAST won't call the setter in that case). const self = this as Record; - const attrFields = ['items', 'position'] as const; - const observableFields = ['highlighted', 'selected'] as const; + const attrFields = ['items', 'position', 'overflowText', 'center', 'roundBoxes'] as const; + const observableFields = ['highlighted', 'selected', '_overflowCount', '_overflowItems'] as const; const savedAttr: Partial> = {}; const savedObs: Partial> = {}; @@ -86,10 +122,118 @@ export class ChartLegend extends FASTElement { self[field] = savedAttr[field]; } } + + // Attach ResizeObserver — fires on every layout change of the host element. + this._resizeObserver = new ResizeObserver(() => this._measure()); + this._resizeObserver.observe(this); + + // Initial measurement after the first render frame so all item buttons + // are in the DOM and have layout (offsetWidth > 0). + requestAnimationFrame(() => this._measure()); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this._resizeObserver?.disconnect(); + this._resizeObserver = undefined; + } + + /** Called by FAST when `items` changes — reset state and re-measure. */ + itemsChanged() { + this._itemWidths = []; + this._visibleCount = Number.MAX_SAFE_INTEGER; + this._overflowCount = 0; + this._overflowItems = []; + requestAnimationFrame(() => this._measure()); } /** - * Roving tabindex handler for legend button keyboard navigation. + * Measures item widths, imperatively shows/hides individual buttons, and + * updates the `_overflowCount` / `_overflowItems` observables which control + * the "+N more" button and popup via FAST `when()` directives. + * + * This is deliberately imperative: FAST's `repeat()` does not propagate + * parent-observable changes down to inner style bindings reactively, so + * visibility is managed through direct `element.style.display` assignments. + */ + _measure() { + // Vertical positions (start / end) always stack — no overflow needed. + if (this.position === 'start' || this.position === 'end') { + this._visibleCount = Number.MAX_SAFE_INTEGER; + this._overflowCount = 0; + this._overflowItems = []; + + this.shadowRoot + ?.querySelectorAll('.legend:not(.overflow-button)') + ?.forEach(btn => (btn.style.display = '')); + return; + } + + const root = this.shadowRoot; + if (!root) return; + + const hostWidth = this.clientWidth; + if (hostWidth === 0) return; + + const buttons = Array.from(root.querySelectorAll('.legend:not(.overflow-button)')); + if (buttons.length === 0) return; + + // Make all buttons visible first so we can measure their natural widths. + for (const btn of buttons) { + btn.style.display = ''; + } + + // Build / refresh the width cache. + for (let i = 0; i < buttons.length; i++) { + const w = buttons[i].offsetWidth; + if (w > 0) this._itemWidths[i] = w; + } + + const totalWidth = this._itemWidths.slice(0, buttons.length).reduce((s, w) => s + (w ?? 0), 0); + + if (totalWidth <= hostWidth) { + // Everything fits — clear any previous overflow state. + this._visibleCount = buttons.length; + this._overflowCount = 0; + this._overflowItems = []; + return; + } + + // Reserve space for the overflow button. + // If it is already in the DOM we can measure it; otherwise use 80 px as a + // conservative estimate (the ResizeObserver will fire again after FAST adds + // the button to the DOM and the measurement will self-correct). + const overflowMenu = root.querySelector('fluent-menu'); + const overflowBtnWidth = overflowMenu ? overflowMenu.offsetWidth || 80 : 80; + + // Find how many items fit alongside the overflow button. + let used = 0; + let count = 0; + for (let i = 0; i < buttons.length; i++) { + const w = this._itemWidths[i] ?? 0; + if (used + w + overflowBtnWidth <= hostWidth) { + used += w; + count = i + 1; + } else { + break; + } + } + + // Always keep at least one item visible. + count = Math.max(count, 1); + this._visibleCount = count; + + // Imperatively hide overflow items — this is not driven through FAST bindings. + for (let i = 0; i < buttons.length; i++) { + buttons[i].style.display = i >= count ? 'none' : ''; + } + + this._overflowCount = buttons.length - count; + this._overflowItems = this.items.slice(count); + } + + /** + * Roving tabindex handler for visible legend button keyboard navigation. * Arrow keys move focus between legend items; all other keys are ignored. */ public _handleLegendKeydown(e: KeyboardEvent): boolean { @@ -99,15 +243,14 @@ export class ChartLegend extends FASTElement { return true; // Don't prevent default for Space, Enter, Tab, etc. } e.preventDefault(); - const buttons = Array.from(this.shadowRoot?.querySelectorAll('button.legend') ?? []); + // Navigate only among visible legend buttons (not the overflow trigger). + const buttons = Array.from( + this.shadowRoot?.querySelectorAll('.legend:not(.overflow-button)') ?? [], + ).filter(b => b.style.display !== 'none'); const count = buttons.length; - if (count === 0) { - return false; - } + if (count === 0) return false; const index = buttons.indexOf(e.currentTarget as HTMLButtonElement); - if (index === -1) { - return false; - } + if (index === -1) return false; const nextIndex = forward ? (index + 1) % count : (index - 1 + count) % count; buttons[index].tabIndex = -1; buttons[nextIndex].tabIndex = 0; diff --git a/src/Charts.Scripts/src/components.ts b/src/Charts.Scripts/src/components.ts index f0c57cf4a9..d43125c1c6 100644 --- a/src/Charts.Scripts/src/components.ts +++ b/src/Charts.Scripts/src/components.ts @@ -1,3 +1,9 @@ +export { + AreaChart, + AreaChartDefinition, + AreaChartStyles, + AreaChartTemplate +} from './area-chart/index.js'; export { ChartLegend, ChartLegendDefinition, diff --git a/src/Charts.Scripts/src/donut-chart/donut-chart.options.ts b/src/Charts.Scripts/src/donut-chart/donut-chart.options.ts index 33884ff4a2..61a266d809 100644 --- a/src/Charts.Scripts/src/donut-chart/donut-chart.options.ts +++ b/src/Charts.Scripts/src/donut-chart/donut-chart.options.ts @@ -1,4 +1,4 @@ -export interface DonutDataPoint { +export interface DonutChartDataPoint { /** * Legend text for the datapoint in the chart */ diff --git a/src/Charts.Scripts/src/donut-chart/donut-chart.styles.ts b/src/Charts.Scripts/src/donut-chart/donut-chart.styles.ts index 0d402f9e01..5c34f8e19a 100644 --- a/src/Charts.Scripts/src/donut-chart/donut-chart.styles.ts +++ b/src/Charts.Scripts/src/donut-chart/donut-chart.styles.ts @@ -40,6 +40,7 @@ export const styles = css` 'chart' 'legend'; grid-template-columns: 1fr; + grid-template-rows: auto 1fr auto; width: 100%; height: 100%; position: relative; diff --git a/src/Charts.Scripts/src/donut-chart/donut-chart.template.ts b/src/Charts.Scripts/src/donut-chart/donut-chart.template.ts index 0d6316811a..67de64c678 100644 --- a/src/Charts.Scripts/src/donut-chart/donut-chart.template.ts +++ b/src/Charts.Scripts/src/donut-chart/donut-chart.template.ts @@ -12,7 +12,12 @@ export function donutChartTemplate(): ElementViewTemplate<