diff --git a/.editorconfig b/.editorconfig index ba5e5c6f96..083520c941 100644 --- a/.editorconfig +++ b/.editorconfig @@ -405,6 +405,10 @@ dotnet_diagnostic.MA0026.severity = none # IDE0130: Namespace does not match folder structure dotnet_diagnostic.IDE0130.severity = none +[src/Charts/**.cs] +# IDE0130: Namespace does not match folder structure +dotnet_diagnostic.IDE0130.severity = none + [{tests/**.{cs,razor},examples/Tools/FluentUI.Demo.DocViewer.Tests/**.{cs,razor}}] # CA1018: Mark attributes with AttributeUsageAttribute dotnet_diagnostic.CA1018.severity = suggestion diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000000..d1a8453e0f --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,4 @@ +# Copilot Instructions + +## Project Guidelines +- Before debugging UI issues with Playwright, verify the Playwright connection first; if it is unavailable, close the browser or tab and retry before proceeding. diff --git a/.gitignore b/.gitignore index 61f81fb7a7..8ec35f9708 100644 --- a/.gitignore +++ b/.gitignore @@ -416,6 +416,7 @@ FodyWeavers.xsd *.cobertura.xml Microsoft.FluentUI.AspNetCore.Components.xml Microsoft.FluentUI.AspNetCore.McpServer.xml +Microsoft.FluentUI.AspNetCore.Components.Charts.xml /examples/Demo/FluentUI.Demo.Client/wwwroot/sources/ /examples/Demo/FluentUI.Demo.Client/wwwroot/documentation/ api-comments.json diff --git a/Microsoft.FluentUI-v5.slnx b/Microsoft.FluentUI-v5.slnx index 224d81cb29..3a80d94400 100644 --- a/Microsoft.FluentUI-v5.slnx +++ b/Microsoft.FluentUI-v5.slnx @@ -20,6 +20,13 @@ + + + + + + + diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/DebugPages/DonutChartDebug.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/DebugPages/DonutChartDebug.razor new file mode 100644 index 0000000000..44998f9323 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/DebugPages/DonutChartDebug.razor @@ -0,0 +1,43 @@ +@page "/Charts/DonutChart/Debug" +@using Microsoft.FluentUI.AspNetCore.Components.Charts + + + + + + + + + + + + + + + + +@code { + private readonly DonutChartData data = new() + { + ChartData = + [ + new DonutChartDataPoint { Legend = "first", Data = 20000 }, + new DonutChartDataPoint { Legend = "second", Data = 39000 }, + new DonutChartDataPoint { Legend = "third", Data = 15000 }, + ] + }; + + private bool roundCorners; + private bool hideLabels = true; + private bool hideLegends; + private bool showLabelsInPercent; + private bool allowMultipleLegendSelection; +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/DonutChartDefault.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/DonutChartDefault.razor new file mode 100644 index 0000000000..22ae3ce435 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/DonutChartDefault.razor @@ -0,0 +1,18 @@ +@using Microsoft.FluentUI.AspNetCore.Components.Charts + + + +@code { + DonutChartData data = new DonutChartData + { + ChartTitle = "Donut chart basic example", + ChartData = new List + { + new DonutChartDataPoint { Legend = "first", Data = 20000 }, + new DonutChartDataPoint { Legend = "second", Data = 39000 } + } + }; +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/DonutChartDefaultRTL.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/DonutChartDefaultRTL.razor new file mode 100644 index 0000000000..e1dc6e894f --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/DonutChartDefaultRTL.razor @@ -0,0 +1,20 @@ +@using Microsoft.FluentUI.AspNetCore.Components.Charts + + + + + +@code { + DonutChartData data = new DonutChartData + { + ChartTitle = "Donut chart basic example", + ChartData = new List + { + new DonutChartDataPoint { Legend = "first", Data = 20000 }, + new DonutChartDataPoint { Legend = "second", Data = 39000 } + } + }; +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/DonutChartHideLegends.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/DonutChartHideLegends.razor new file mode 100644 index 0000000000..ba526ddaf9 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/DonutChartHideLegends.razor @@ -0,0 +1,18 @@ +@using Microsoft.FluentUI.AspNetCore.Components.Charts + + + +@code { + private readonly DonutChartData data = new() + { + ChartData = + [ + new DonutChartDataPoint { Legend = "first", Data = 20000 }, + new DonutChartDataPoint { Legend = "second", Data = 39000 } + ] + }; +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/DonutChartLabels.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/DonutChartLabels.razor new file mode 100644 index 0000000000..6ddef9707e --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/DonutChartLabels.razor @@ -0,0 +1,24 @@ +@using Microsoft.FluentUI.AspNetCore.Components.Charts + + + + + + +@code { + DonutChartData data = new DonutChartData + { + + ChartData = new List + { + new DonutChartDataPoint { Legend = "first", Data = 20000 }, + new DonutChartDataPoint { Legend = "second", Data = 39000 } + } + }; +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/DonutChartRoundedCorners.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/DonutChartRoundedCorners.razor new file mode 100644 index 0000000000..29e1e3be1f --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/DonutChartRoundedCorners.razor @@ -0,0 +1,29 @@ +@using Microsoft.FluentUI.AspNetCore.Components.Charts + + + Rounded corners + Hide labels + + + + + + +@code { + private readonly DonutChartData data = new() + { + ChartData = + [ + new DonutChartDataPoint { Legend = "first", Data = 20000 }, + new DonutChartDataPoint { Legend = "second", Data = 39000 } + ] + }; + + private bool roundCorners; + private bool hideLabels; +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/DonutChartShowLabelsInPercent.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/DonutChartShowLabelsInPercent.razor new file mode 100644 index 0000000000..21c2f57da7 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/DonutChartShowLabelsInPercent.razor @@ -0,0 +1,18 @@ +@using Microsoft.FluentUI.AspNetCore.Components.Charts + + + +@code { + private readonly DonutChartData data = new() + { + ChartData = + [ + new DonutChartDataPoint { Legend = "first", Data = 20000 }, + new DonutChartDataPoint { Legend = "second", Data = 39000 } + ] + }; +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/DonutChartSizing.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/DonutChartSizing.razor new file mode 100644 index 0000000000..6151ca00a9 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/DonutChartSizing.razor @@ -0,0 +1,30 @@ +@using Microsoft.FluentUI.AspNetCore.Components.Charts + + + + + + + + + + +@code { + private int _width = 320; + private int _height = 320; + private int _innerRadius = 55; + + private readonly DonutChartData data = new() + { + ChartData = + [ + new DonutChartDataPoint { Legend = "first", Data = 20000 }, + new DonutChartDataPoint { Legend = "second", Data = 39000 } + ] + }; +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/HorizontalBarChartBenchmark.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/HorizontalBarChartBenchmark.razor new file mode 100644 index 0000000000..98699cc84e --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/HorizontalBarChartBenchmark.razor @@ -0,0 +1,40 @@ +@using Microsoft.FluentUI.AspNetCore.Components.Charts +@using Microsoft.FluentUI.AspNetCore.Components.Enums + + + + +@code { + IReadOnlyList ChartData = new List + { + new HorizontalBarChartSeries + { + ChartSeriesTitle = "one", + ChartData = new List + { + new HorizontalBarChartDataPoint { Legend = "one", Data = 10, Total = 100, Color = "#637cef" } + }, + BenchmarkData = 50 + }, + new HorizontalBarChartSeries + { + ChartSeriesTitle = "two", + ChartData = new List + { + new HorizontalBarChartDataPoint { Legend = "two", Data = 30, Total = 200, Color = "#e3008c" } + }, + BenchmarkData = 30 + }, + new HorizontalBarChartSeries + { + ChartSeriesTitle = "three", + ChartData = new List + { + new HorizontalBarChartDataPoint { Legend = "three", Data = 15, Total = 50, Color = "#2aa0a4" } + }, + BenchmarkData = 5 + } + }; +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/HorizontalBarChartDefault.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/HorizontalBarChartDefault.razor new file mode 100644 index 0000000000..986e090c6c --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/HorizontalBarChartDefault.razor @@ -0,0 +1,45 @@ +@using Microsoft.FluentUI.AspNetCore.Components.Charts + + + + +@code { + private IReadOnlyList ChartData = + [ + new HorizontalBarChartSeries + { + ChartSeriesTitle = "Monitored First", + ChartData = + [ + new HorizontalBarChartDataPoint { Legend = "Debit card numbers (EU and USA)", Data = 40, Color = "#0099BC" }, + new HorizontalBarChartDataPoint { Legend = "Passport numbers (USA)", Data = 23, Color = "#77004D" }, + new HorizontalBarChartDataPoint { Legend = "Social security numbers", Data = 35, Color = "#4F68ED" }, + new HorizontalBarChartDataPoint { Legend = "Credit card Numbers", Data = 87, Color = "#AE8C00" }, + new HorizontalBarChartDataPoint { Legend = "Tax identification numbers (USA)", Data = 87, Color = "#004E8C" } + ] + }, + new HorizontalBarChartSeries + { + ChartSeriesTitle = "Monitored Second", + ChartData = + [ + new HorizontalBarChartDataPoint { Legend = "Debit card numbers (EU and USA)", Data = 40, Color = "#0099BC" }, + new HorizontalBarChartDataPoint { Legend = "Passport numbers (USA)", Data = 56, Color = "#77004D" }, + new HorizontalBarChartDataPoint { Legend = "Social security numbers", Data = 35, Color = "#4F68ED" }, + new HorizontalBarChartDataPoint { Legend = "Credit card Numbers", Data = 92, Color = "#AE8C00" }, + new HorizontalBarChartDataPoint { Legend = "Tax identification numbers (USA)", Data = 87, Color = "#004E8C" } + ] + }, + new HorizontalBarChartSeries + { + ChartSeriesTitle = "Unmonitored", + ChartData = + [ + new HorizontalBarChartDataPoint { Legend = "Phone Numbers", Data = 40, Color = "#881798" }, + new HorizontalBarChartDataPoint { Legend = "Credit card Numbers", Data = 23, Color = "#AE8C00" } + ] + } + ]; +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/HorizontalBarChartDefaultRTL.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/HorizontalBarChartDefaultRTL.razor new file mode 100644 index 0000000000..6fcecd23a2 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/HorizontalBarChartDefaultRTL.razor @@ -0,0 +1,47 @@ +@using Microsoft.FluentUI.AspNetCore.Components.Charts + + + + + + +@code { + private IReadOnlyList ChartData = + [ + new HorizontalBarChartSeries + { + ChartSeriesTitle = "Monitored First", + ChartData = + [ + new HorizontalBarChartDataPoint { Legend = "Debit card numbers (EU and USA)", Data = 40, Color = "#0099BC" }, + new HorizontalBarChartDataPoint { Legend = "Passport numbers (USA)", Data = 23, Color = "#77004D" }, + new HorizontalBarChartDataPoint { Legend = "Social security numbers", Data = 35, Color = "#4F68ED" }, + new HorizontalBarChartDataPoint { Legend = "Credit card Numbers", Data = 87, Color = "#AE8C00" }, + new HorizontalBarChartDataPoint { Legend = "Tax identification numbers (USA)", Data = 87, Color = "#004E8C" } + ] + }, + new HorizontalBarChartSeries + { + ChartSeriesTitle = "Monitored Second", + ChartData = + [ + new HorizontalBarChartDataPoint { Legend = "Debit card numbers (EU and USA)", Data = 40, Color = "#0099BC" }, + new HorizontalBarChartDataPoint { Legend = "Passport numbers (USA)", Data = 56, Color = "#77004D" }, + new HorizontalBarChartDataPoint { Legend = "Social security numbers", Data = 35, Color = "#4F68ED" }, + new HorizontalBarChartDataPoint { Legend = "Credit card Numbers", Data = 92, Color = "#AE8C00" }, + new HorizontalBarChartDataPoint { Legend = "Tax identification numbers (USA)", Data = 87, Color = "#004E8C" } + ] + }, + new HorizontalBarChartSeries + { + ChartSeriesTitle = "Unmonitored", + ChartData = + [ + new HorizontalBarChartDataPoint { Legend = "Phone Numbers", Data = 40, Color = "#881798" }, + new HorizontalBarChartDataPoint { Legend = "Credit card Numbers", Data = 23, Color = "#AE8C00" } + ] + } + ]; +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/HorizontalBarChartSingleBar.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/HorizontalBarChartSingleBar.razor new file mode 100644 index 0000000000..004bfb53aa --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/HorizontalBarChartSingleBar.razor @@ -0,0 +1,78 @@ +@using Microsoft.FluentUI.AspNetCore.Components.Charts +@using Microsoft.FluentUI.AspNetCore.Components.Enums + + + + + +@code { + IReadOnlyList ChartData = new List + { + new HorizontalBarChartSeries + { + ChartSeriesTitle = "one", + ChartData = new List + { + new HorizontalBarChartDataPoint { Legend = "one", Data = 1543, Total = 15000, Color = "#637cef" } + } + }, + new HorizontalBarChartSeries + { + ChartSeriesTitle = "two", + ChartData = new List + { + new HorizontalBarChartDataPoint { Legend = "two", Data = 800, Total = 15000, Color = "#e3008c" } + } + }, + new HorizontalBarChartSeries + { + ChartSeriesTitle = "three", + ChartData = new List + { + new HorizontalBarChartDataPoint { Legend = "three", Data = 8888, Total = 15000, Color = "#2aa0a4" } + } + }, + new HorizontalBarChartSeries + { + ChartSeriesTitle = "four", + ChartData = new List + { + new HorizontalBarChartDataPoint { Legend = "four", Data = 15888, Total = 15000, Color = "#9373c0" } + } + }, + new HorizontalBarChartSeries + { + ChartSeriesTitle = "five", + ChartData = new List + { + new HorizontalBarChartDataPoint { Legend = "five", Data = 11444, Total = 15000, Color = "#13a10e" } + } + }, + new HorizontalBarChartSeries + { + ChartSeriesTitle = "six", + ChartData = new List + { + new HorizontalBarChartDataPoint { Legend = "six", Data = 14000, Total = 15000, Color = "#3a96dd" } + } + }, + new HorizontalBarChartSeries + { + ChartSeriesTitle = "seven", + ChartData = new List + { + new HorizontalBarChartDataPoint { Legend = "seven", Data = 9855, Total = 15000, Color = "#ca5010" } + } + }, + new HorizontalBarChartSeries + { + ChartSeriesTitle = "eight", + ChartData = new List + { + new HorizontalBarChartDataPoint { Legend = "eight", Data = 4250, Total = 15000, Color = "#57811b" } + } + } + }; +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/HorizontalBarChartSingleBarNMVariant.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/HorizontalBarChartSingleBarNMVariant.razor new file mode 100644 index 0000000000..eabb0daaf7 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/HorizontalBarChartSingleBarNMVariant.razor @@ -0,0 +1,78 @@ +@using Microsoft.FluentUI.AspNetCore.Components.Charts +@using Microsoft.FluentUI.AspNetCore.Components.Enums + + + + + +@code { + IReadOnlyList ChartData = new List + { + new HorizontalBarChartSeries + { + ChartSeriesTitle = "one", + ChartData = new List + { + new HorizontalBarChartDataPoint { Legend = "one", Data = 1543, Total = 15000, Color = "#637cef" } + } + }, + new HorizontalBarChartSeries + { + ChartSeriesTitle = "two", + ChartData = new List + { + new HorizontalBarChartDataPoint { Legend = "two", Data = 800, Total = 15000, Color = "#e3008c" } + } + }, + new HorizontalBarChartSeries + { + ChartSeriesTitle = "three", + ChartData = new List + { + new HorizontalBarChartDataPoint { Legend = "three", Data = 8888, Total = 15000, Color = "#2aa0a4" } + } + }, + new HorizontalBarChartSeries + { + ChartSeriesTitle = "four", + ChartData = new List + { + new HorizontalBarChartDataPoint { Legend = "four", Data = 15888, Total = 15000, Color = "#9373c0" } + } + }, + new HorizontalBarChartSeries + { + ChartSeriesTitle = "five", + ChartData = new List + { + new HorizontalBarChartDataPoint { Legend = "five", Data = 11444, Total = 15000, Color = "#13a10e" } + } + }, + new HorizontalBarChartSeries + { + ChartSeriesTitle = "six", + ChartData = new List + { + new HorizontalBarChartDataPoint { Legend = "six", Data = 14000, Total = 15000, Color = "#3a96dd" } + } + }, + new HorizontalBarChartSeries + { + ChartSeriesTitle = "seven", + ChartData = new List + { + new HorizontalBarChartDataPoint { Legend = "seven", Data = 9855, Total = 15000, Color = "#ca5010" } + } + }, + new HorizontalBarChartSeries + { + ChartSeriesTitle = "eight", + ChartData = new List + { + new HorizontalBarChartDataPoint { Legend = "eight", Data = 4250, Total = 15000, Color = "#57811b" } + } + } + }; +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/HorizontalBarChartSingleDataPoint.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/HorizontalBarChartSingleDataPoint.razor new file mode 100644 index 0000000000..d565c73b28 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/HorizontalBarChartSingleDataPoint.razor @@ -0,0 +1,21 @@ +@using Microsoft.FluentUI.AspNetCore.Components.Charts +@using Microsoft.FluentUI.AspNetCore.Components.Enums + + + + +@code { + IReadOnlyList ChartData = new List + { + new HorizontalBarChartSeries + { + ChartSeriesTitle = "one", + ChartData = new List + { + new HorizontalBarChartDataPoint { Legend = "one", Data = 1543, Total = 15000, Color = "#637cef", Gradient = new[] { "#637cef", "#e3008c" } } + } + } + }; +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/HorizontalBarChartWithAxisDefault.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/HorizontalBarChartWithAxisDefault.razor new file mode 100644 index 0000000000..40074201d1 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Examples/HorizontalBarChartWithAxisDefault.razor @@ -0,0 +1,50 @@ +@using Microsoft.FluentUI.AspNetCore.Components.Charts + + + + +@code { + private IReadOnlyList ChartData = + [ + new HorizontalBarChartWithAxisDataPoint { + X = 10000, + Y = "5000", + Legend = "Oranges", + YAxisCalloutData = "2020/04/30", + XAxisCalloutData = "10%", + }, + new HorizontalBarChartWithAxisDataPoint { + X = 20000, + Y = "50000", + Legend = "Dogs", +YAxisCalloutData = "2020/04/30", + XAxisCalloutData = "20%", + }, + new HorizontalBarChartWithAxisDataPoint { + X = 25000, + Y = "30000", + Legend = "Apples", +YAxisCalloutData = "2020/04/30", + XAxisCalloutData = "37%", + }, + new HorizontalBarChartWithAxisDataPoint { + X = 40000, + Y = "13000", + Legend = "Bananas", + YAxisCalloutData = "2020/04/30", + XAxisCalloutData = "88%", + }, + ]; +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/FluentUIChartsPage.md b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/FluentUIChartsPage.md new file mode 100644 index 0000000000..68a617f275 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/FluentUIChartsPage.md @@ -0,0 +1,15 @@ +--- +title: Charts +route: /Charts/[Default] +icon: ChartMultiple +--- + +# Charts + +The Fluent UI Charts are a set of components that allow you to easily create charts in your Blazor applications. + +Currently, the following chart types are available: + +- [Donut Chart](/Charts/DonutChart) +- [Horizontal Bar Chart](/Charts/HorizontalBarChart) +- [Horizontal Bar Chart with Axis](/Charts/HorizontalBarChartWithAxis) 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 new file mode 100644 index 0000000000..0ec4e494a5 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Pages/FluentDonutChartPage.md @@ -0,0 +1,79 @@ +--- +title: Donut Chart +route: /Charts/DonutChart +--- + +# Donut Chart + +Donut charts are used to show proportion, which expresses a partial value in comparison to a total value. These types of charts are best to show percentage of individual parts in comparison to a whole, where the change over time is not important to visualize. They are circular statistical graphics divided into slices to illustrate numerical proportion. + +## Layout + +- The donut chart’s behavior is simple in application. The data is ordered from largest to smallest in clockwise direction and users can single out individual segments for clarity. +- For high cardinality scenarios where the slices are very small, they can be grouped together to form a bigger slice to improve readability. +- The chart is centered in the available screen space. The default chart diameter is 140px and bar width is 16px. This matches the width of bars in bar charts to achieve balanced scale. The size can be adjusted with responsive chart behavior, where the size of the chart and bar diameter grows proportionally in units of 4px. +- Always try to balance the visual weight of the bars in relationship to the rest of the app. +- Segments are separated by a 2px gap to maximize readability. Segment labels should be always displayed for easier chart comprehension. +- Minimum padding around the chart is 16px. It also applies to the version with labels to accommodate space for labels. There is a 2px space between the chart and the label. The label is centered in relationship to the slice it describes. That can be offset if an overlap happens between 2 labels. + +## Content + +- The donut chart consists of segments arranged clockwise from large to small. The total circle equates to 100% of the data. The segments can use custom formatting, but all values must add up to 100%. Tiny segments may be grouped and shown visually as 'Others'. +- The label string inside the donut should be concise and contain numerical information with limited or no explanation. + +## Accessibility + +- Users "Enter" into the graph and can use both arrowing and tabbing to navigate through. +- The first tab stop will stop on the graph and give a description of what type of graph it is. +- Each segment can define its own accessibility label to help the user understand the data better. + +## Do's + +- For scenarios with lots of categories, consider changing the type of graph to a stacked horizontal bar chart. +- We recommend donut charts over pie charts as they are more readable. + +## Don'ts + +- Don't overuse donuts charts. They require a lot of space on the page and using more than one next to each other dilutes the intended message. + +## Examples + +### Basic example + +{{ DonutChartDefault }} + +### With labels + +{{ DonutChartLabels }} + +### With labels as percentages + +{{ DonutChartShowLabelsInPercent }} + +### Without legends + +{{ DonutChartHideLegends }} + +### Rounded corners + +{{ DonutChartRoundedCorners }} + +### With custom sizing + +{{ DonutChartSizing }} + +### RTL Donut Chart + +{{ DonutChartDefaultRTL }} + +## API Fluent Donut Chart + +{{ API Type=FluentDonutChart }} + +## API Donut Chart Data + +{{ API Type=DonutChartData Properties=All }} + +## API Donut Chart Data Point + +{{ API Type=DonutChartDataPoint Properties=All }} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Pages/FluentHorizontalBarChartPage.md b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Pages/FluentHorizontalBarChartPage.md new file mode 100644 index 0000000000..11174e0c32 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Pages/FluentHorizontalBarChartPage.md @@ -0,0 +1,85 @@ +--- +title: Horizontal Bar Chart +route: /Charts/HorizontalBarChart +--- + +# Horizontal Bar Chart + +A horizontal bar chart is a chart that presents categorical data with rectangular bars with lengths proportional to the values they represent. This type of chart is particularly useful when the intention is to show comparisons among various categories and the labels for those categories are long. + +## Layout + +Use a horizontal bar graph to compare between different values that are hierarchically equivalent. The rectangular bar length is proportional to the values they represent. There will always be a maximum data value (color) representing the total length. + +Horizontal bar chart can be of 2 types - + +- Absolute scale the length of the bar is proportional to the biggest value for the category. +- n/M scale the length of the bar is determined by the total/target value of the specific bar. As a result, 2 adjacent bars can have different data scales and not be comparable. This aspect should be kept in mind while using this chart type. See HorizontalBarChart benchmark example to see the behavior. Each bar has a different scale - 100, 200 and 50 units. + +## Content + +- Title/Label The label for the bar. It is displayed above the bar and can represent longer texts. +- Bar segment The bar segment represents the current value of the category. For n/M variant there is a placeholder segment to show the left-over values. +- Bar value The value of the bar is represented on the right side. This can be absolute or percentage format. This can also be in fractional form representing current value out of total value. See the chartDataMode property to use it. +- Benchmark The benchmark value is shown as an inverted triangle in the chart. + +## Accessibility + +- Bar graphs should be flexible to their containers. They will change widths to fit their environment. +- Each section of the bar chart is readable via screen readers. The user can navigate through the entire bar graph by using the tab keys. +- The chart reflows to accommodate zooming in to 400%. + +## Customizing the chart + +- Bar chart custom data This property allows customizing the right-side data part of the chart. See the usage of barChartCustomData prop in custom callout variant. +- Custom hover callout See onRenderCalloutPerHorizontalBar prop to customize the hover callout. Set the chartDataMode as number, fraction or percentage to specify how numerical values will be shown on the chart. +- Benchmark data Set the data attribute of IChartDataPoint to specify the benchmark value. The benchmark value is shown as an inverted triangle in the chart. +- AbsoluteScale variant The bar labels are shown by default in the absolute-scale variant. Set the hideLabels prop to hide them. + +## Do's + +- Use horizontal bar chart if the length of labels is longer. +- Numerical units on labels are represented through abbreviations. + +## Don'ts + +- Avoid having more than 20 bars in the chart. +- The n/M variant should be used only when a value has to be compared against its target value. + +## Examples + +### Default horizontal bar chart + +{{ HorizontalBarChartDefault }} + +### Single Bar + +{{ HorizontalBarChartSingleBar }} + +### Single Bar NM Variant + +{{ HorizontalBarChartSingleBarNMVariant }} + +### Benchmark + +{{ HorizontalBarChartBenchmark }} + +### Single Data Point + +{{ HorizontalBarChartSingleDataPoint }} + +### RTL + +{{ HorizontalBarChartDefaultRTL }} + +## API Fluent Horizontal Bar Chart + +{{ API Type=FluentHorizontalBarChart }} + +## API Horizontal Bar Chart Series + +{{ API Type=HorizontalBarChartSeries Properties=All }} + +## API Horizontal Bar Chart Data Point + +{{ API Type=HorizontalBarChartDataPoint Properties=All }} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Pages/FluentHorizontalBarChartWithAxis.md b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Pages/FluentHorizontalBarChartWithAxis.md new file mode 100644 index 0000000000..320b190e6d --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Charts/Pages/FluentHorizontalBarChartWithAxis.md @@ -0,0 +1,10 @@ +--- +title: Horizontal Bar Chart With Axis +route: /Charts/HorizontalBarChartWithAxis +--- + +# Horizontal Bar Chart With Axis + +A horizontal bar chart with axis is a chart that presents categorical data with rectangular bars with lengths proportional to the values they represent, along with an axis to provide context for the values. This type of chart is particularly useful when the intention is to show comparisons among various categories and the labels for those categories are long, while also providing a clear reference point for the values being compared. + +{{ HorizontalBarChartWithAxisDefault }} diff --git a/examples/Demo/FluentUI.Demo.Client/FluentUI.Demo.Client.csproj b/examples/Demo/FluentUI.Demo.Client/FluentUI.Demo.Client.csproj index 13f947b172..06dba6a381 100644 --- a/examples/Demo/FluentUI.Demo.Client/FluentUI.Demo.Client.csproj +++ b/examples/Demo/FluentUI.Demo.Client/FluentUI.Demo.Client.csproj @@ -45,6 +45,7 @@ + diff --git a/examples/Demo/FluentUI.Demo/FluentUI.Demo.csproj b/examples/Demo/FluentUI.Demo/FluentUI.Demo.csproj index 9e621ce7ed..99b6f80e36 100644 --- a/examples/Demo/FluentUI.Demo/FluentUI.Demo.csproj +++ b/examples/Demo/FluentUI.Demo/FluentUI.Demo.csproj @@ -10,6 +10,7 @@ + diff --git a/examples/Tools/FluentUI.Demo.DocViewer/Components/MarkdownViewer.razor b/examples/Tools/FluentUI.Demo.DocViewer/Components/MarkdownViewer.razor index 398d5d1944..5473758abf 100644 --- a/examples/Tools/FluentUI.Demo.DocViewer/Components/MarkdownViewer.razor +++ b/examples/Tools/FluentUI.Demo.DocViewer/Components/MarkdownViewer.razor @@ -1,6 +1,5 @@ @using FluentUI.Demo.DocViewer.Components.Markdown @using FluentUI.Demo.DocViewer.Models -@using FluentUI.Demo.DocViewer.Models.Mcp @using FluentUI.Demo.DocViewer.Services @using Microsoft.AspNetCore.Components.Web diff --git a/examples/Tools/FluentUI.Demo.DocViewer/Components/MarkdownViewer.razor.cs b/examples/Tools/FluentUI.Demo.DocViewer/Components/MarkdownViewer.razor.cs index 99be0c8dfb..beadfcd040 100644 --- a/examples/Tools/FluentUI.Demo.DocViewer/Components/MarkdownViewer.razor.cs +++ b/examples/Tools/FluentUI.Demo.DocViewer/Components/MarkdownViewer.razor.cs @@ -131,7 +131,7 @@ protected override async Task OnAfterRenderAsync(bool firstRender) var componentType = ""; // Convert "MyComponent" to ("MyComponent", "MyType") - var match = Regex.Match(name, @"(\w+)(<|<)(.+)(>|>)"); + var match = ComponentsRexEx().Match(name); if (match.Success) { componentName = match.Groups[1].Value; @@ -165,8 +165,7 @@ protected override async Task OnAfterRenderAsync(bool firstRender) } } - - result.InstanceTypes = listOfTypes.ToArray(); + result.InstanceTypes = [.. listOfTypes]; } return result; @@ -217,4 +216,7 @@ private static string GetLanguageClassName(string? file = null) _ => "language-plaintext" }; } + + [GeneratedRegex(@"(\w+)(<|<)(.+)(>|>)")] + private static partial Regex ComponentsRexEx(); } diff --git a/src/Charts.Scripts/.eslintrc.json b/src/Charts.Scripts/.eslintrc.json new file mode 100644 index 0000000000..c45c6edbaa --- /dev/null +++ b/src/Charts.Scripts/.eslintrc.json @@ -0,0 +1,12 @@ +{ + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaVersion": "latest", + "sourceType": "module" + }, + "plugins": [ "@typescript-eslint" ], + "root": true, + "rules": { + // Add rules here + } +} \ No newline at end of file diff --git a/src/Charts.Scripts/.npmrc b/src/Charts.Scripts/.npmrc new file mode 100644 index 0000000000..a8f33531f6 --- /dev/null +++ b/src/Charts.Scripts/.npmrc @@ -0,0 +1,3 @@ +registry=https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/ +always-auth=true + diff --git a/src/Charts.Scripts/Microsoft.FluentUI.AspNetCore.Components.Charts.Scripts.esproj b/src/Charts.Scripts/Microsoft.FluentUI.AspNetCore.Components.Charts.Scripts.esproj new file mode 100644 index 0000000000..4a6abcb4c0 --- /dev/null +++ b/src/Charts.Scripts/Microsoft.FluentUI.AspNetCore.Components.Charts.Scripts.esproj @@ -0,0 +1,16 @@ + + + dist\ + Microsoft.FluentUI.AspNetCore.Components.Charts + false + false + npm run build -- --build-mode=$(Configuration) + npm run clean + + + + + + + + \ No newline at end of file diff --git a/src/Charts.Scripts/esbuild.config.mjs b/src/Charts.Scripts/esbuild.config.mjs new file mode 100644 index 0000000000..f87a52f38c --- /dev/null +++ b/src/Charts.Scripts/esbuild.config.mjs @@ -0,0 +1,46 @@ +import * as esbuild from 'esbuild' +import { glob } from 'glob' +import { writeFile, unlink } from 'fs/promises'; +import pkg from './package.json' with { type: 'json' } +import fs from "fs"; +import path from "path"; + +// Generate BuildConstants.ts with BUILD_MODE constant based on --build-mode argument +const rawBuildMode = process.argv.find(a => a.startsWith('--build-mode='))?.split('=')[1]; +const buildMode = rawBuildMode === "Release" ? "Release" : "Debug"; + +const constantsFile = path.resolve("src/BuildConstants.ts"); +const constantsContent = `// This file is generated by a tool. Do not change!\nexport const BUILD_MODE = '${buildMode}';\n`; +const isNotCurrentMode = !fs.existsSync(constantsFile) || !fs.readFileSync(constantsFile, 'utf-8').includes(`BUILD_MODE = '${buildMode}'`); +if (isNotCurrentMode) { + fs.writeFileSync(constantsFile, constantsContent); +} + +// JS: Microsoft.FluentUI.AspNetCore.Components.Charts.lib.module.js +await esbuild.build({ + entryPoints: [pkg.source], + bundle: true, + minify: buildMode !== "Debug", + sourcemap: buildMode === "Debug", + logLevel: 'info', + target: 'es2022', + format: 'esm', + outfile: pkg.main, + legalComments: 'none' +}); + +// CSS: Microsoft.FluentUI.AspNetCore.Components.Charts.bundle.scp.css +//const allStyleFile = 'all-styles.css'; +//const files = await glob(pkg.cssFiles); +//const content = files.map(file => `@import "${file.replace(/\\/g, '/')}";`).join('\n'); + +//await writeFile(allStyleFile, content); +//await esbuild.build({ +// entryPoints: [allStyleFile], +// loader: { '.css': 'css' }, +// outfile: pkg.cssBundle, +// bundle: true, +// minify: true, +// sourcemap: false, +//}); +//await unlink(allStyleFile); diff --git a/src/Charts.Scripts/package-lock.json b/src/Charts.Scripts/package-lock.json new file mode 100644 index 0000000000..bf6505e3f1 --- /dev/null +++ b/src/Charts.Scripts/package-lock.json @@ -0,0 +1,890 @@ +{ + "name": "microsoft.fluentui.aspnetcore.components.charts.assets", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "microsoft.fluentui.aspnetcore.components.charts.assets", + "license": "ISC", + "dependencies": { + "@fluentui/web-components": "^3.0.0-rc.13", + "@microsoft/fast-web-utilities": "^6.0.0", + "d3-format": "^3.0.0", + "d3-selection": "^3.0.0", + "d3-shape": "^3.0.0" + }, + "devDependencies": { + "@microsoft/fast-element": "^2.0.0-beta.26 || ^2.0.0", + "@types/d3-format": "^3.0.0", + "@types/d3-selection": "^3.0.0", + "@types/d3-shape": "^3.0.0", + "esbuild": "0.27.4", + "esbuild-plugin-inline-css": "0.0.1", + "glob": "^13.0.6", + "rimraf": "6.1.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.4", + "resolved": "http://localhost:4873/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.4", + "resolved": "http://localhost:4873/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.4", + "resolved": "http://localhost:4873/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.4", + "resolved": "http://localhost:4873/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.4", + "resolved": "http://localhost:4873/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.4", + "resolved": "http://localhost:4873/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.4", + "resolved": "http://localhost:4873/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.4", + "resolved": "http://localhost:4873/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.4", + "resolved": "http://localhost:4873/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.4", + "resolved": "http://localhost:4873/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.4", + "resolved": "http://localhost:4873/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.4", + "resolved": "http://localhost:4873/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.4", + "resolved": "http://localhost:4873/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.4", + "resolved": "http://localhost:4873/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.4", + "resolved": "http://localhost:4873/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.4", + "resolved": "http://localhost:4873/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.4", + "resolved": "http://localhost:4873/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.4", + "resolved": "http://localhost:4873/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.4", + "resolved": "http://localhost:4873/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.4", + "resolved": "http://localhost:4873/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.4", + "resolved": "http://localhost:4873/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.4", + "resolved": "http://localhost:4873/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.4", + "resolved": "http://localhost:4873/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.4", + "resolved": "http://localhost:4873/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.4", + "resolved": "http://localhost:4873/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.4", + "resolved": "http://localhost:4873/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@fluentui/tokens": { + "version": "1.0.0-alpha.23", + "resolved": "http://localhost:4873/@fluentui/tokens/-/tokens-1.0.0-alpha.23.tgz", + "integrity": "sha1-T4RsHk/Ns8qA6zGALEo2bVWZsw4=", + "license": "MIT", + "dependencies": { + "@swc/helpers": "^0.5.1" + } + }, + "node_modules/@fluentui/web-components": { + "version": "3.0.0-rc.13", + "resolved": "http://localhost:4873/@fluentui/web-components/-/web-components-3.0.0-rc.13.tgz", + "integrity": "sha1-ATuXKxB4p/FDESanYDaRZzwnI9w=", + "license": "MIT", + "dependencies": { + "@fluentui/tokens": "1.0.0-alpha.23", + "@microsoft/fast-web-utilities": "^6.0.0", + "tabbable": "^6.2.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": "^22.0.0 || ^24.0.0" + }, + "peerDependencies": { + "@microsoft/fast-element": "^2.0.0" + } + }, + "node_modules/@microsoft/fast-element": { + "version": "2.0.0", + "resolved": "http://localhost:4873/@microsoft/fast-element/-/fast-element-2.0.0.tgz", + "integrity": "sha512-Tzv4dCGTg10NNyqWWGRy3bYG4vacQUapjy5gpdpmFbSgvNF8aOzJJMkG5CfVUDoC9gWxY1ZyGgjXX0p86ZXX5w==", + "license": "MIT" + }, + "node_modules/@microsoft/fast-web-utilities": { + "version": "6.0.0", + "resolved": "http://localhost:4873/@microsoft/fast-web-utilities/-/fast-web-utilities-6.0.0.tgz", + "integrity": "sha512-ckCA4Xn91ja1Qz+jhGGL1Q3ZeuRpA5VvYcRA7GzA1NP545sl14bwz3tbHCq8jIk+PL7mkSaIveGMYuJB2L4Izg==", + "license": "MIT", + "dependencies": { + "exenv-es6": "^1.1.1" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.21", + "resolved": "http://localhost:4873/@swc/helpers/-/helpers-0.5.21.tgz", + "integrity": "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "http://localhost:4873/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "http://localhost:4873/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "http://localhost:4873/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "http://localhost:4873/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "http://localhost:4873/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "http://localhost:4873/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/convert-hex": { + "version": "0.1.0", + "resolved": "http://localhost:4873/convert-hex/-/convert-hex-0.1.0.tgz", + "integrity": "sha512-w20BOb1PiR/sEJdS6wNrUjF5CSfscZFUp7R9NSlXH8h2wynzXVEPFPJECAnkNylZ+cvf3p7TyRUHggDmrwXT9A==", + "dev": true + }, + "node_modules/convert-string": { + "version": "0.1.0", + "resolved": "http://localhost:4873/convert-string/-/convert-string-0.1.0.tgz", + "integrity": "sha512-1KX9ESmtl8xpT2LN2tFnKSbV4NiarbVi8DVb39ZriijvtTklyrT+4dT1wsGMHKD3CJUjXgvJzstm9qL9ICojGA==", + "dev": true + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "http://localhost:4873/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "http://localhost:4873/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "http://localhost:4873/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "http://localhost:4873/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild": { + "version": "0.27.4", + "resolved": "http://localhost:4873/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" + } + }, + "node_modules/esbuild-plugin-inline-css": { + "version": "0.0.1", + "resolved": "http://localhost:4873/esbuild-plugin-inline-css/-/esbuild-plugin-inline-css-0.0.1.tgz", + "integrity": "sha512-CF2NPh3RbSdS6n3KgDmi67MhiBjjYJ+x+xi6Ha3ikMrO4owoS0Oj3+QQpIgwAOfTinLyNLbwhMOlht4QE/iqlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fs-extra": "^10.0.0", + "path": "^0.12.7", + "sha256": "^0.2.0" + } + }, + "node_modules/exenv-es6": { + "version": "1.1.1", + "resolved": "http://localhost:4873/exenv-es6/-/exenv-es6-1.1.1.tgz", + "integrity": "sha512-vlVu3N8d6yEMpMsEm+7sUBAI81aqYYuEvfK0jNqmdb/OPXzzH7QWDDnVjMvDSY47JdHEqx/dfC/q8WkfoTmpGQ==", + "license": "MIT" + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "http://localhost:4873/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "http://localhost:4873/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "http://localhost:4873/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/inherits": { + "version": "2.0.3", + "resolved": "http://localhost:4873/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "http://localhost:4873/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/lru-cache": { + "version": "11.3.5", + "resolved": "http://localhost:4873/lru-cache/-/lru-cache-11.3.5.tgz", + "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "http://localhost:4873/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "http://localhost:4873/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "http://localhost:4873/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/path": { + "version": "0.12.7", + "resolved": "http://localhost:4873/path/-/path-0.12.7.tgz", + "integrity": "sha512-aXXC6s+1w7otVF9UletFkFcDsJeO7lSZBPUQhtb5O0xJe8LtYhj/GxldoL09bBj9+ZmE2hNoHqQSFMN5fikh4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "process": "^0.11.1", + "util": "^0.10.3" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "http://localhost:4873/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "http://localhost:4873/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/rimraf": { + "version": "6.1.3", + "resolved": "http://localhost:4873/rimraf/-/rimraf-6.1.3.tgz", + "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "glob": "^13.0.3", + "package-json-from-dist": "^1.0.1" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sha256": { + "version": "0.2.0", + "resolved": "http://localhost:4873/sha256/-/sha256-0.2.0.tgz", + "integrity": "sha512-kTWMJUaez5iiT9CcMv8jSq6kMhw3ST0uRdcIWl3D77s6AsLXNXRp3heeqqfu5+Dyfu4hwpQnMzhqHh8iNQxw0w==", + "dev": true, + "dependencies": { + "convert-hex": "~0.1.0", + "convert-string": "~0.1.0" + } + }, + "node_modules/tabbable": { + "version": "6.4.0", + "resolved": "http://localhost:4873/tabbable/-/tabbable-6.4.0.tgz", + "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "http://localhost:4873/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "http://localhost:4873/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/util": { + "version": "0.10.4", + "resolved": "http://localhost:4873/util/-/util-0.10.4.tgz", + "integrity": "sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "2.0.3" + } + } + } +} diff --git a/src/Charts.Scripts/package.json b/src/Charts.Scripts/package.json new file mode 100644 index 0000000000..5815b8451d --- /dev/null +++ b/src/Charts.Scripts/package.json @@ -0,0 +1,39 @@ +{ + "private": true, + "name": "microsoft.fluentui.aspnetcore.components.charts.assets", + "source": "src/index.ts", + "main": "dist/Microsoft.FluentUI.AspNetCore.Components.Charts.lib.module.js", + "cssFiles": "../Charts/**/*.css", + "cssBundle": "dist/Microsoft.FluentUI.AspNetCore.Components.Charts.bundle.scp.css", + "scripts": { + "build": "node ./esbuild.config.mjs", + "type-check": "node ./scripts/type-check", + "compile": "node ./scripts/compile", + "clean": "rimraf ./dist", + "lint": "eslint . --ext .ts", + "lint:fix": "eslint . --ext .ts --fix", + "format": "prettier -w src/**/*.{ts,html} --ignore-path ../../.prettierignore", + "format:check": "yarn format -c" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "module", + "devDependencies": { + "@microsoft/fast-element": "^2.0.0-beta.26 || ^2.0.0", + "@types/d3-format": "^3.0.0", + "@types/d3-selection": "^3.0.0", + "@types/d3-shape": "^3.0.0", + "esbuild": "0.27.4", + "esbuild-plugin-inline-css": "0.0.1", + "glob": "^13.0.6", + "rimraf": "6.1.3" + }, + "dependencies": { + "@fluentui/web-components": "^3.0.0-rc.13", + "@microsoft/fast-web-utilities": "^6.0.0", + "d3-format": "^3.0.0", + "d3-selection": "^3.0.0", + "d3-shape": "^3.0.0" + } +} diff --git a/src/Charts.Scripts/src/BuildConstants.ts b/src/Charts.Scripts/src/BuildConstants.ts new file mode 100644 index 0000000000..d6b245f501 --- /dev/null +++ b/src/Charts.Scripts/src/BuildConstants.ts @@ -0,0 +1,2 @@ +// This file is generated by a tool. Do not change! +export const BUILD_MODE = 'Debug'; diff --git a/src/Charts.Scripts/src/FluentUIChartComponents.ts b/src/Charts.Scripts/src/FluentUIChartComponents.ts new file mode 100644 index 0000000000..dca9822a91 --- /dev/null +++ b/src/Charts.Scripts/src/FluentUIChartComponents.ts @@ -0,0 +1,25 @@ +import * as FluentUIWebComponents from '@fluentui/web-components'; +import * as FluentUIComponents from './components'; +import { defineOnce } from '@core/RegistrationState'; + + + +export namespace Microsoft.FluentUI.Blazor.FluentUIChartComponents { + + export function defineComponents() { + const registry = FluentUIWebComponents.FluentDesignSystem.registry; + + // Register Chart Web Components + defineOnce('fluentui:chart-components:donut-chart', () => { + FluentUIComponents.DonutChartDefinition.define(registry); + }); + + defineOnce('fluentui:chart-components:horizontal-bar-chart', () => { + FluentUIComponents.HorizontalBarChartDefinition.define(registry); + }); + + defineOnce('fluentui:chart-components:horizontal-bar-chart-with-axis', () => { + FluentUIComponents.HorizontalBarChartWithAxisDefinition.define(registry); + }); + } +} diff --git a/src/Charts.Scripts/src/components.ts b/src/Charts.Scripts/src/components.ts new file mode 100644 index 0000000000..509f5657f9 --- /dev/null +++ b/src/Charts.Scripts/src/components.ts @@ -0,0 +1,18 @@ +export { + DonutChart, + DonutChartDefinition, + DonutChartStyles, + DonutChartTemplate +} from './donut-chart/index.js'; +export { + HorizontalBarChart, + HorizontalBarChartDefinition, + HorizontalBarChartStyles, + HorizontalBarChartTemplate, +} from './horizontal-bar-chart/index.js'; +export { + HorizontalBarChartWithAxis, + HorizontalBarChartWithAxisDefinition, + HorizontalBarChartWithAxisStyles, + HorizontalBarChartWithAxisTemplate, +} from './horizontal-bar-chart-with-axis/index.js'; diff --git a/src/Charts.Scripts/src/donut-chart/define.ts b/src/Charts.Scripts/src/donut-chart/define.ts new file mode 100644 index 0000000000..1b8e20ac99 --- /dev/null +++ b/src/Charts.Scripts/src/donut-chart/define.ts @@ -0,0 +1,4 @@ +import { FluentDesignSystem } from '@fluentui/web-components'; +import { definition } from './donut-chart.definition.js'; + +definition.define(FluentDesignSystem.registry); diff --git a/src/Charts.Scripts/src/donut-chart/donut-chart.definition.ts b/src/Charts.Scripts/src/donut-chart/donut-chart.definition.ts new file mode 100644 index 0000000000..3cf608dc04 --- /dev/null +++ b/src/Charts.Scripts/src/donut-chart/donut-chart.definition.ts @@ -0,0 +1,18 @@ +import { FluentDesignSystem } from '@fluentui/web-components'; +import { DonutChart } from './donut-chart.js'; +import { styles } from './donut-chart.styles.js'; +import { template } from './donut-chart.template.js'; + +/** + * @public + * @remarks + * HTML Element: `` + */ +export const definition = DonutChart.compose({ + name: `${FluentDesignSystem.prefix}-donut-chart`, + template, + styles, + shadowOptions: { + delegatesFocus: true, + }, +}); diff --git a/src/Charts.Scripts/src/donut-chart/donut-chart.options.ts b/src/Charts.Scripts/src/donut-chart/donut-chart.options.ts new file mode 100644 index 0000000000..cbd4686c7f --- /dev/null +++ b/src/Charts.Scripts/src/donut-chart/donut-chart.options.ts @@ -0,0 +1,50 @@ +export interface ChartDataPoint { + /** + * Legend text for the datapoint in the chart + */ + legend: string; + + /** + * data the datapoint in the chart + */ + data: number; + + /** + * Color for the legend in the chart. If not provided, it will fallback on the default color palette. + */ + color?: string; + + /** + * Callout data for x axis + * This is an optional prop, If haven;t given legend will take + */ + xAxisCalloutData?: string; + + /** + * Callout data for y axis + * This is an optional prop, If haven't given data will take + */ + yAxisCalloutData?: string; + + /** + * Callout data shown in the center label when the segment is highlighted. + */ + calloutData?: string; +} + +export interface ChartProps { + /** + * chart title for the chart + */ + chartTitle?: string; + + /** + * data for the points in the chart + */ + chartData: ChartDataPoint[]; +} + +export type Legend = { + title: string; + color: string; +}; diff --git a/src/Charts.Scripts/src/donut-chart/donut-chart.spec.ts b/src/Charts.Scripts/src/donut-chart/donut-chart.spec.ts new file mode 100644 index 0000000000..8306a4a247 --- /dev/null +++ b/src/Charts.Scripts/src/donut-chart/donut-chart.spec.ts @@ -0,0 +1,636 @@ +import { test } from '@playwright/test'; +import { expect, fixtureURL } from '../helpers.tests.js'; +import type { DonutChart as FluentDonutChart } from './donut-chart.js'; +import type { ChartDataPoint, ChartProps } from './donut-chart.options.js'; + +const basicTitle = 'Donut chart basic example'; + +const points: ChartDataPoint[] = [ + { + legend: 'first', + data: 20000, + }, + { + legend: 'second', + data: 39000, + }, +]; + +const data: ChartProps = { + chartData: points, +}; + +test.describe('Donut-chart - Basic', () => { + test.beforeEach(async ({ page }) => { + await page.goto(fixtureURL('components-donutchart--basic')); + await page.setContent(/* html */ ` + + + + + `); + await page.waitForFunction(() => customElements.whenDefined('fluent-donut-chart')); + }); + + test('Should render chart properly', async ({ page }) => { + const element = page.locator('fluent-donut-chart'); + const legends = element.locator('.legend-text'); + await expect(legends.nth(0).getByText('first')).toBeVisible(); + await expect(legends.nth(1).getByText('second')).toBeVisible(); + await expect(element.getByText('39,000')).toBeVisible(); + await expect(element.locator('.arc-label')).toHaveCount(0); + }); + + test('Should render path with proper attributes and css', async ({ page }) => { + const element = page.locator('fluent-donut-chart'); + const arcList = element.locator('.arc'); + await expect(arcList).toHaveCount(2); + await expect(arcList.nth(0)).toHaveAttribute('fill', '#637cef'); + await expect(arcList.nth(0)).toHaveAttribute('aria-label', 'first, 20000.'); + await expect(arcList.nth(0)).toHaveAttribute( + 'd', + 'M-76.547,47.334A90,90,0,0,1,-1.055,-89.994L-1.055,-54.99A55,55,0,0,0,-46.993,28.577Z', + ); + await expect(arcList.nth(0)).toHaveCSS('fill', 'rgb(99, 124, 239)'); + await expect(arcList.nth(0)).toHaveCSS('--borderRadiusMedium', '4px'); + + await expect(arcList.nth(1)).toHaveAttribute('fill', '#e3008c'); + await expect(arcList.nth(1)).toHaveAttribute('aria-label', 'second, 39000.'); + await expect(arcList.nth(1)).toHaveAttribute( + 'd', + 'M1.055,-89.994A90,90,0,1,1,-75.417,49.115L-45.863,30.358A55,55,0,1,0,1.055,-54.99Z', + ); + await expect(arcList.nth(1)).toHaveCSS('fill', 'rgb(227, 0, 140)'); + await expect(arcList.nth(1)).toHaveCSS('--borderRadiusMedium', '4px'); + }); + + test('Should render legends data properly', async ({ page }) => { + const element = page.locator('fluent-donut-chart'); + const legends = element.getByRole('option'); + await expect(legends).toHaveCount(2); + const firstLegend = element.getByRole('option', { name: 'First' }); + const secondLegend = element.getByRole('option', { name: 'Second' }); + await expect(firstLegend).toBeVisible(); + await expect(firstLegend).toHaveText('first'); + await expect(firstLegend).toHaveCSS('--borderRadiusMedium', '4px'); + await expect(secondLegend).toBeVisible(); + await expect(secondLegend).toHaveText('second'); + await expect(secondLegend).toHaveCSS('--borderRadiusMedium', '4px'); + }); + + test('Should update path css values with mouse click event on legend', async ({ page }) => { + const element = page.locator('fluent-donut-chart'); + const firstPath = element.getByLabel('first,'); + const secondPath = element.getByLabel('second,'); + const firstLegend = element.getByRole('option', { name: 'First' }); + //mouse events + await firstLegend.click(); + await expect(firstPath).toHaveCSS('opacity', '1'); + await expect(secondPath).toHaveCSS('opacity', '0.1'); + await firstLegend.dispatchEvent('mouseout'); + await expect(firstPath).toHaveCSS('opacity', '1'); + await expect(secondPath).toHaveCSS('opacity', '0.1'); + await firstLegend.click(); + await expect(firstPath).toHaveCSS('opacity', '1'); + await expect(secondPath).toHaveCSS('opacity', '1'); + }); + + test('Should remove inactive arcs from the tab order when a legend is active', async ({ page }) => { + const element = page.locator('fluent-donut-chart'); + const firstPath = element.getByLabel('first,'); + const secondPath = element.getByLabel('second,'); + const firstLegend = element.getByRole('option', { name: 'First' }); + + await expect(firstPath).toHaveAttribute('tabindex', '0'); + await expect(secondPath).toHaveAttribute('tabindex', '0'); + + await firstLegend.dispatchEvent('mouseover'); + + await expect(firstPath).toHaveAttribute('tabindex', '0'); + await expect(secondPath).toHaveAttribute('tabindex', '-1'); + + await firstLegend.dispatchEvent('mouseout'); + + await expect(firstPath).toHaveAttribute('tabindex', '0'); + await expect(secondPath).toHaveAttribute('tabindex', '0'); + }); + + test('Should update path css values with mouse hover event on legend', async ({ page }) => { + const element = page.locator('fluent-donut-chart'); + const firstPath = element.getByLabel('first,'); + const secondPath = element.getByLabel('second,'); + const firstLegend = element.getByRole('option', { name: 'First' }); + //mouse events + await firstLegend.dispatchEvent('mouseover'); + await expect(firstPath).toHaveCSS('opacity', '1'); + await expect(secondPath).toHaveCSS('opacity', '0.1'); + await firstLegend.dispatchEvent('mouseout'); + await expect(firstPath).toHaveCSS('opacity', '1'); + await expect(secondPath).toHaveCSS('opacity', '1'); + }); + + test('Should show callout with mouse hover event on path', async ({ page }) => { + const element = page.locator('fluent-donut-chart'); + const firstPath = element.getByLabel('first,'); + const calloutRoot = element.locator('.tooltip'); + await expect(calloutRoot).toHaveCount(0); + await firstPath.dispatchEvent('mouseover'); + await expect(calloutRoot).toHaveCount(1); + await expect(calloutRoot).toHaveCSS('opacity', '1'); + const calloutLegendText = element.locator('.tooltip-legend-text'); + await expect(calloutLegendText).toHaveText('first'); + const calloutContentY = element.locator('.tooltip-content-y'); + await expect(calloutContentY).toHaveText('20000'); + await firstPath.dispatchEvent('mouseout'); + await expect(calloutRoot).not.toHaveCSS('opacity', '0'); + }); + + test('Should update callout data when mouse moved from one path to another path', async ({ page }) => { + const element = page.locator('fluent-donut-chart'); + const firstPath = element.getByLabel('first,'); + const calloutRoot = element.locator('.tooltip'); + await expect(calloutRoot).toHaveCount(0); + await firstPath.dispatchEvent('mouseover'); + await expect(calloutRoot).toHaveCSS('opacity', '1'); + const calloutLegendText = element.locator('.tooltip-legend-text'); + await expect(calloutLegendText).toHaveText('first'); + const calloutContentY = element.locator('.tooltip-content-y'); + await expect(calloutContentY).toHaveText('20000'); + const secondPath = element.getByLabel('second,'); + await secondPath.dispatchEvent('mouseover'); + await expect(calloutRoot).toHaveCSS('opacity', '1'); + await expect(calloutLegendText).toHaveText('second'); + await expect(calloutContentY).toHaveText('39000'); + }); +}); + +test.describe('Donut-chart - Reactive rerender', () => { + test('Should rerender when data attribute changes after initial render', async ({ page }) => { + await page.goto(fixtureURL('components-donutchart--basic')); + await page.setContent(/* html */ ` + + + + + `); + await page.waitForFunction(() => customElements.whenDefined('fluent-donut-chart')); + + const element = page.locator('fluent-donut-chart'); + await expect(element.locator('.arc')).toHaveCount(2); + + const newData: ChartProps = { + chartData: [ + { legend: 'alpha', data: 10000 }, + { legend: 'beta', data: 20000 }, + { legend: 'gamma', data: 30000 }, + ], + }; + + await element.evaluate((el, d) => { + el.setAttribute('chart-title', 'Updated chart'); + el.setAttribute('data', JSON.stringify(d)); + }, newData); + + await expect(element.locator('.arc')).toHaveCount(3); + await expect(element.locator('.legend-text').nth(0).getByText('alpha')).toBeVisible(); + await expect(element.locator('.legend-text').nth(1).getByText('beta')).toBeVisible(); + await expect(element.locator('.legend-text').nth(2).getByText('gamma')).toBeVisible(); + }); +}); + +test.describe('Donut-chart - hide-labels', () => { + test('Should keep center text visible and hide outside labels when hide-labels is set', async ({ page }) => { + await page.goto(fixtureURL('components-donutchart--basic')); + await page.setContent(/* html */ ` + + + + + `); + await page.waitForFunction(() => customElements.whenDefined('fluent-donut-chart')); + + const element = page.locator('fluent-donut-chart'); + await expect(element.locator('.text-inside-donut')).toHaveCount(1); + await expect(element.locator('.arc-label')).toHaveCount(0); + await expect(element.locator('.arc')).toHaveCount(2); + }); + + test('Should show outside labels when hide-labels is false', async ({ page }) => { + await page.goto(fixtureURL('components-donutchart--basic')); + await page.setContent(/* html */ ` + + + + + `); + await page.waitForFunction(() => customElements.whenDefined('fluent-donut-chart')); + + const element = page.locator('fluent-donut-chart'); + const firstArc = element.locator('.arc').first(); + const defaultPath = await firstArc.getAttribute('d'); + + await element.evaluate(el => { + (el as FluentDonutChart).hideLabels = false; + }); + + await expect(element.locator('.text-inside-donut')).toHaveCount(1); + await expect(element.locator('.arc-label')).toHaveCount(2); + await expect(firstArc).toHaveAttribute('d', defaultPath ?? ''); + }); + + test('Should react to hide-labels string attribute updates', async ({ page }) => { + await page.goto(fixtureURL('components-donutchart--basic')); + await page.setContent(/* html */ ` + + + + + `); + await page.waitForFunction(() => customElements.whenDefined('fluent-donut-chart')); + + const element = page.locator('fluent-donut-chart'); + await expect(element.locator('.arc-label')).toHaveCount(2); + + await element.evaluate(el => { + el.setAttribute('hide-labels', 'true'); + }); + + await expect(element.locator('.arc-label')).toHaveCount(0); + }); +}); + +test.describe('Donut-chart - outside labels', () => { + test('Should render outside labels for visible segments', async ({ page }) => { + await page.goto(fixtureURL('components-donutchart--basic')); + await page.setContent(/* html */ ` + + + + + `); + await page.waitForFunction(() => customElements.whenDefined('fluent-donut-chart')); + + const element = page.locator('fluent-donut-chart'); + await element.evaluate(el => { + (el as FluentDonutChart).hideLabels = false; + }); + const labels = element.locator('.arc-label'); + + await expect(labels).toHaveCount(2); + await expect(labels.nth(0)).toContainText('20'); + await expect(labels.nth(1)).toContainText('39'); + }); + + test('Should render percent outside labels when show-labels-in-percent is set', async ({ page }) => { + await page.goto(fixtureURL('components-donutchart--basic')); + await page.setContent(/* html */ ` + + + + + `); + await page.waitForFunction(() => customElements.whenDefined('fluent-donut-chart')); + + const element = page.locator('fluent-donut-chart'); + await element.evaluate(el => { + (el as FluentDonutChart).hideLabels = false; + }); + const labels = element.locator('.arc-label'); + await expect(labels).toHaveCount(2); + await expect(labels.nth(0)).toContainText('%'); + await expect(labels.nth(1)).toContainText('%'); + }); + + test('Should react to show-labels-in-percent string attribute updates', async ({ page }) => { + await page.goto(fixtureURL('components-donutchart--basic')); + await page.setContent(/* html */ ` + + + + + `); + await page.waitForFunction(() => customElements.whenDefined('fluent-donut-chart')); + + const element = page.locator('fluent-donut-chart'); + const labels = element.locator('.arc-label'); + await expect(labels).toHaveCount(2); + await expect(labels.nth(0)).not.toContainText('%'); + + await element.evaluate(el => { + el.setAttribute('show-labels-in-percent', 'true'); + }); + + await expect(labels.nth(0)).toContainText('%'); + await expect(labels.nth(1)).toContainText('%'); + }); +}); + +test.describe('Donut-chart - hide-tooltip', () => { + test('Should react to hide-tooltip string attribute updates', async ({ page }) => { + await page.goto(fixtureURL('components-donutchart--basic')); + await page.setContent(/* html */ ` + + + + + `); + await page.waitForFunction(() => customElements.whenDefined('fluent-donut-chart')); + + const element = page.locator('fluent-donut-chart'); + const firstPath = element.getByLabel('first,'); + + await firstPath.dispatchEvent('mouseover'); + await expect(element.locator('.tooltip')).toHaveCount(1); + + await element.evaluate(el => { + el.setAttribute('hide-tooltip', 'true'); + }); + + await expect(element.locator('.tooltip')).toHaveCount(0); + }); +}); + +test.describe('Donut-chart - hide-legends', () => { + test('Should react to hide-legends string attribute updates', async ({ page }) => { + await page.goto(fixtureURL('components-donutchart--basic')); + await page.setContent(/* html */ ` + + + + + `); + await page.waitForFunction(() => customElements.whenDefined('fluent-donut-chart')); + + const element = page.locator('fluent-donut-chart'); + const legendContainer = element.locator('.legend-container'); + await expect(legendContainer).toHaveCount(1); + await expect(legendContainer).not.toBeHidden(); + + await element.evaluate(el => { + el.setAttribute('hide-legends', 'true'); + }); + + await expect(legendContainer).toBeHidden(); + }); +}); + +test.describe('Donut-chart - allow-multiple-legend-selection', () => { + const multiData: ChartProps = { + chartData: [ + { legend: 'first', data: 20000 }, + { legend: 'second', data: 39000 }, + { legend: 'third', data: 15000 }, + ], + }; + + test.beforeEach(async ({ page }) => { + await page.goto(fixtureURL('components-donutchart--basic')); + await page.setContent(/* html */ ` + + + + + `); + await page.waitForFunction(() => customElements.whenDefined('fluent-donut-chart')); + }); + + test('Should highlight multiple arcs when multiple legends are selected', async ({ page }) => { + const element = page.locator('fluent-donut-chart'); + const firstPath = element.getByLabel('first,'); + const secondPath = element.getByLabel('second,'); + const thirdPath = element.getByLabel('third,'); + const firstLegend = element.getByRole('option', { name: 'first' }); + const secondLegend = element.getByRole('option', { name: 'second' }); + + await firstLegend.click(); + await secondLegend.click(); + + await expect(firstPath).toHaveCSS('opacity', '1'); + await expect(secondPath).toHaveCSS('opacity', '1'); + await expect(thirdPath).toHaveCSS('opacity', '0.1'); + }); + + test('Should deselect a legend on second click in multi-select mode', async ({ page }) => { + const element = page.locator('fluent-donut-chart'); + const firstPath = element.getByLabel('first,'); + const secondPath = element.getByLabel('second,'); + const thirdPath = element.getByLabel('third,'); + const firstLegend = element.getByRole('option', { name: 'first' }); + const secondLegend = element.getByRole('option', { name: 'second' }); + + await firstLegend.click(); + await secondLegend.click(); + await firstLegend.click(); // deselect first + + await expect(firstPath).toHaveCSS('opacity', '0.1'); + await expect(secondPath).toHaveCSS('opacity', '1'); + await expect(thirdPath).toHaveCSS('opacity', '0.1'); + }); + + test('Should restore all arcs when all selections are cleared', async ({ page }) => { + const element = page.locator('fluent-donut-chart'); + const firstPath = element.getByLabel('first,'); + const secondPath = element.getByLabel('second,'); + const thirdPath = element.getByLabel('third,'); + const firstLegend = element.getByRole('option', { name: 'first' }); + + await firstLegend.click(); + await firstLegend.click(); // deselect — all clear + + await expect(firstPath).toHaveCSS('opacity', '1'); + await expect(secondPath).toHaveCSS('opacity', '1'); + await expect(thirdPath).toHaveCSS('opacity', '1'); + }); + + test('Should set aria-selected on selected legends', async ({ page }) => { + const element = page.locator('fluent-donut-chart'); + const firstLegend = element.getByRole('option', { name: 'first' }); + const secondLegend = element.getByRole('option', { name: 'second' }); + const thirdLegend = element.getByRole('option', { name: 'third' }); + + await firstLegend.click(); + await secondLegend.click(); + + await expect(firstLegend).toHaveAttribute('aria-selected', 'true'); + await expect(secondLegend).toHaveAttribute('aria-selected', 'true'); + await expect(thirdLegend).toHaveAttribute('aria-selected', 'false'); + }); + + test('Should fall back to single-select when allow-multiple-legend-selection is removed', async ({ page }) => { + const element = page.locator('fluent-donut-chart'); + const firstPath = element.getByLabel('first,'); + const secondPath = element.getByLabel('second,'); + const firstLegend = element.getByRole('option', { name: 'first' }); + const secondLegend = element.getByRole('option', { name: 'second' }); + + await firstLegend.click(); + await secondLegend.click(); + + // disable multi-select → selectedLegends should be cleared + await element.evaluate(el => { + el.removeAttribute('allow-multiple-legend-selection'); + }); + + await expect(firstPath).toHaveCSS('opacity', '1'); + await expect(secondPath).toHaveCSS('opacity', '1'); + + // now single-select should work + await firstLegend.click(); + await expect(firstPath).toHaveCSS('opacity', '1'); + await expect(secondPath).toHaveCSS('opacity', '0.1'); + }); +}); + +test.describe('Donut-chart - round-corners', () => { + test('Should change arc geometry when round-corners is enabled', async ({ page }) => { + await page.goto(fixtureURL('components-donutchart--basic')); + await page.setContent(/* html */ ` + + + + + `); + await page.waitForFunction(() => customElements.whenDefined('fluent-donut-chart')); + + const element = page.locator('fluent-donut-chart'); + const firstArc = element.locator('.arc').first(); + const defaultPath = await firstArc.getAttribute('d'); + + await element.evaluate(el => { + el.setAttribute('round-corners', 'true'); + }); + + await expect(firstArc).not.toHaveAttribute('d', defaultPath ?? ''); + }); +}); + +test.describe('Donut-chart - order', () => { + const unorderedData: ChartProps = { + chartData: [ + { legend: 'small', data: 5000 }, + { legend: 'large', data: 39000 }, + { legend: 'medium', data: 15000 }, + ], + }; + + test('Should render legends in default order when order is not set', async ({ page }) => { + await page.goto(fixtureURL('components-donutchart--basic')); + await page.setContent(/* html */ ` + + + + + `); + await page.waitForFunction(() => customElements.whenDefined('fluent-donut-chart')); + + const element = page.locator('fluent-donut-chart'); + const legends = element.locator('.legend-text'); + await expect(legends.nth(0).getByText('small')).toBeVisible(); + await expect(legends.nth(1).getByText('large')).toBeVisible(); + await expect(legends.nth(2).getByText('medium')).toBeVisible(); + }); + + test('Should render legends in sorted (descending) order when order="sorted"', async ({ page }) => { + await page.goto(fixtureURL('components-donutchart--basic')); + await page.setContent(/* html */ ` + + + + + `); + await page.waitForFunction(() => customElements.whenDefined('fluent-donut-chart')); + + const element = page.locator('fluent-donut-chart'); + const legends = element.locator('.legend-text'); + // Sorted descending: large (39000), medium (15000), small (5000) + await expect(legends.nth(0).getByText('large')).toBeVisible(); + await expect(legends.nth(1).getByText('medium')).toBeVisible(); + await expect(legends.nth(2).getByText('small')).toBeVisible(); + }); + + test('uses chart-title attr and calloutData for highlighted center text', async ({ page }) => { + const calloutData: ChartProps = { + chartData: [ + { legend: 'first', data: 20000, calloutData: '20K highlighted' }, + { legend: 'second', data: 39000 }, + ], + }; + + await page.goto(fixtureURL('components-donutchart--basic')); + await page.setContent(/* html */ ` + + + + + `); + await page.waitForFunction(() => customElements.whenDefined('fluent-donut-chart')); + + const element = page.locator('fluent-donut-chart'); + await expect(element.getByText('Callout contract')).toBeVisible(); + + const firstPath = element.getByLabel('first,'); + await firstPath.dispatchEvent('mouseover'); + await expect(element.locator('.text-inside-donut')).toContainText('20K highlighted'); + }); +}); diff --git a/src/Charts.Scripts/src/donut-chart/donut-chart.stories.ts b/src/Charts.Scripts/src/donut-chart/donut-chart.stories.ts new file mode 100644 index 0000000000..8e63f098b8 --- /dev/null +++ b/src/Charts.Scripts/src/donut-chart/donut-chart.stories.ts @@ -0,0 +1,320 @@ +import { html } from '@microsoft/fast-element'; +import { + FieldDefinition, + FluentDesignSystem, + LabelDefinition, + SliderDefinition, + SwitchDefinition, +} from '@fluentui/web-components'; +import type { Meta, Story, StoryArgs } from '../helpers.stories.js'; +import { renderComponent } from '../helpers.stories.js'; +import { DonutChart as FluentDonutChart } from './donut-chart.js'; +import type { ChartDataPoint, ChartProps } from './donut-chart.options.js'; + +type FluentSliderElement = HTMLElement & { value: string }; +type FluentSwitchElement = HTMLElement & { checked: boolean }; + +const ensureDefinition = (tagName: string, define: () => void) => { + if (!customElements.get(tagName)) { + define(); + } +}; + +ensureDefinition('fluent-field', () => FieldDefinition.define(FluentDesignSystem.registry)); +ensureDefinition('fluent-label', () => LabelDefinition.define(FluentDesignSystem.registry)); +ensureDefinition('fluent-slider', () => SliderDefinition.define(FluentDesignSystem.registry)); +ensureDefinition('fluent-switch', () => SwitchDefinition.define(FluentDesignSystem.registry)); + +const controlsRowStyle = 'display:flex;flex-wrap:wrap;gap:16px 24px;align-items:end;'; +const sliderFieldStyle = 'min-width:220px;flex:1 1 220px;'; +const toggleFieldStyle = 'min-width:220px;'; + +const createSliderField = ( + labelText: string, + id: string, + value: number, + min: number, + max: number, + onChange: (nextValue: number) => void, +) => { + const field = document.createElement('fluent-field'); + field.setAttribute('label-position', 'above'); + field.setAttribute('style', sliderFieldStyle); + + const label = document.createElement('label'); + label.slot = 'label'; + label.htmlFor = id; + label.textContent = labelText; + field.appendChild(label); + + const slider = document.createElement('fluent-slider') as FluentSliderElement; + slider.slot = 'input'; + slider.id = id; + slider.setAttribute('min', `${min}`); + slider.setAttribute('max', `${max}`); + slider.value = `${value}`; + slider.setAttribute('value', `${value}`); + field.appendChild(slider); + + const message = document.createElement('fluent-label'); + message.slot = 'message'; + message.textContent = `${value}`; + field.appendChild(message); + + slider.addEventListener('change', () => onChange(Number(slider.value))); + + return { + element: field, + setValue: (nextValue: number) => { + slider.value = `${nextValue}`; + slider.setAttribute('value', `${nextValue}`); + message.textContent = `${nextValue}`; + }, + }; +}; + +const createSwitchField = ( + labelText: string, + id: string, + checked: boolean, + onChange: (nextChecked: boolean) => void, +) => { + const field = document.createElement('fluent-field'); + field.setAttribute('label-position', 'after'); + field.setAttribute('style', toggleFieldStyle); + + const label = document.createElement('label'); + label.slot = 'label'; + label.htmlFor = id; + label.textContent = labelText; + field.appendChild(label); + + const control = document.createElement('fluent-switch') as FluentSwitchElement; + control.slot = 'input'; + control.id = id; + control.checked = checked; + control.toggleAttribute('checked', checked); + control.addEventListener('change', () => onChange(control.checked)); + field.appendChild(control); + + return { + element: field, + setValue: (nextChecked: boolean) => { + control.checked = nextChecked; + control.toggleAttribute('checked', nextChecked); + }, + }; +}; + +const basicTitle = 'Donut chart basic example'; +const sortedTitle = 'Sorted donut chart example'; + +const points: ChartDataPoint[] = [ + { + legend: 'first', + data: 20000, + }, + { + legend: 'second', + data: 39000, + }, +]; + +const data: ChartProps = { + chartData: points, +}; + +const sortedPoints: ChartDataPoint[] = [ + { + legend: 'small', + data: 5000, + }, + { + legend: 'large', + data: 39000, + }, + { + legend: 'medium', + data: 15000, + }, +]; + +const sortedData: ChartProps = { + chartData: sortedPoints, +}; + +const storyTemplate = html>` + + +`; + +export default { + title: 'Components/DonutChart', +} as Meta; + +export const Basic: Story = renderComponent(storyTemplate).bind({}); + +export const OutsideLabels: Story = () => { + const chart = document.createElement('fluent-donut-chart') as FluentDonutChart; + chart.setAttribute('chart-title', 'Donut chart outside labels example'); + chart.setAttribute('data', JSON.stringify(data)); + chart.setAttribute('value-inside-donut', '39,000'); + chart.setAttribute('inner-radius', '85'); + chart.setAttribute('width', '320'); + chart.setAttribute('height', '320'); + chart.setAttribute('style', 'width:320px;height:320px'); + chart.hideLabels = false; + + return chart; +}; + +export const Sizing: Story = () => { + const container = document.createElement('div'); + const controls = document.createElement('div'); + controls.setAttribute('style', controlsRowStyle); + container.appendChild(controls); + const chartHost = document.createElement('div'); + chartHost.setAttribute('style', 'margin-top:20px;'); + container.appendChild(chartHost); + + let width = 320; + let height = 320; + let innerRadius = 55; + + const renderChart = () => { + const chart = document.createElement('fluent-donut-chart') as FluentDonutChart; + chart.setAttribute('chart-title', 'Donut chart sizing example'); + chart.setAttribute('data', JSON.stringify(data)); + chart.setAttribute('value-inside-donut', '39,000'); + chart.setAttribute('inner-radius', `${innerRadius}`); + chart.width = width; + chart.height = height; + chart.setAttribute('width', `${width}`); + chart.setAttribute('height', `${height}`); + chart.setAttribute('style', `width:${width}px;height:${height}px`); + + chartHost.replaceChildren(chart); + }; + + const widthControl = createSliderField('Width', 'donut-width', width, 200, 640, nextWidth => { + width = nextWidth; + widthControl.setValue(nextWidth); + renderChart(); + }); + controls.appendChild(widthControl.element); + + const heightControl = createSliderField('Height', 'donut-height', height, 200, 640, nextHeight => { + height = nextHeight; + heightControl.setValue(nextHeight); + renderChart(); + }); + controls.appendChild(heightControl.element); + + const innerRadiusControl = createSliderField( + 'Inner radius', + 'donut-inner-radius', + innerRadius, + 1, + 120, + nextRadius => { + innerRadius = nextRadius; + innerRadiusControl.setValue(nextRadius); + renderChart(); + }, + ); + controls.appendChild(innerRadiusControl.element); + + renderChart(); + + return container; +}; + +export const RoundedCorners: Story = () => { + const container = document.createElement('div'); + const controls = document.createElement('div'); + controls.setAttribute('style', controlsRowStyle); + container.appendChild(controls); + + let roundCorners = false; + let hideLabels = false; + + const chart = document.createElement('fluent-donut-chart') as FluentDonutChart; + chart.setAttribute('chart-title', 'Donut chart rounded corners example'); + chart.setAttribute('data', JSON.stringify(data)); + chart.setAttribute('value-inside-donut', '39,000'); + chart.setAttribute('inner-radius', '55'); + chart.setAttribute('style', 'width:320px;height:320px;margin-top:20px;'); + + const renderChart = () => { + chart.hideLabels = hideLabels; + chart.roundCorners = roundCorners; + chart.toggleAttribute('hide-labels', hideLabels); + chart.toggleAttribute('round-corners', roundCorners); + + if (!chart.isConnected) { + container.appendChild(chart); + } + }; + + const roundedCornersControl = createSwitchField( + 'Rounded corners', + 'donut-rounded-corners', + roundCorners, + nextChecked => { + roundCorners = nextChecked; + roundedCornersControl.setValue(nextChecked); + renderChart(); + }, + ); + controls.appendChild(roundedCornersControl.element); + + const hideLabelsControl = createSwitchField('Hide labels', 'donut-rounded-hide-labels', hideLabels, nextChecked => { + hideLabels = nextChecked; + hideLabelsControl.setValue(nextChecked); + renderChart(); + }); + controls.appendChild(hideLabelsControl.element); + + renderChart(); + + return container; +}; + +export const HideLegends: Story = renderComponent(html>` + + +`); + +export const ShowLabelsInPercent: Story = () => { + const chart = document.createElement('fluent-donut-chart') as FluentDonutChart; + chart.setAttribute('chart-title', 'Donut chart percent labels example'); + chart.setAttribute('data', JSON.stringify(data)); + chart.setAttribute('inner-radius', '55'); + chart.toggleAttribute('show-labels-in-percent', true); + chart.hideLabels = false; + + return chart; +}; + +export const RTL: Story = renderComponent(html>` + + + + +`); diff --git a/src/Charts.Scripts/src/donut-chart/donut-chart.styles.ts b/src/Charts.Scripts/src/donut-chart/donut-chart.styles.ts new file mode 100644 index 0000000000..9e08fd8a11 --- /dev/null +++ b/src/Charts.Scripts/src/donut-chart/donut-chart.styles.ts @@ -0,0 +1,186 @@ +import { css } from '@microsoft/fast-element'; +import { + borderRadiusMedium, + colorNeutralBackground1, + colorNeutralForeground1, + colorNeutralShadowAmbient, + colorNeutralShadowKey, + colorStrokeFocus1, + colorStrokeFocus2, + colorTransparentStroke, + display, + forcedColorsStylesheetBehavior, + spacingHorizontalL, + spacingHorizontalNone, + spacingHorizontalS, + spacingVerticalL, + spacingVerticalMNudge, + spacingVerticalNone, + spacingVerticalS, + strokeWidthThickest, + strokeWidthThin, + typographyBody1StrongStyles, + typographyBody1Styles, + typographyCaption1Styles, + typographyCaption1StrongStyles, + typographyTitle2Styles, + typographyTitle3Styles, +} from '@fluentui/web-components'; + +/** + * Styles for the DonutChart component. + * + * @public + */ +export const styles = css` + ${display('inline-block')} + + :host { + ${typographyBody1Styles} + align-items: center; + flex-direction: column; + width: 100%; + height: 100%; + position: relative; + } + + .chart { + box-sizing: content-box; + overflow: visible; + display: block; + } + + .chart-title { + ${typographyBody1StrongStyles} + color: ${colorNeutralForeground1}; + margin-bottom: ${spacingVerticalS}; + } + + .arc.inactive { + opacity: 0.1; + } + + .arc:focus { + outline: none; + stroke-width: ${strokeWidthThin}; + stroke: ${colorStrokeFocus1}; + } + + .arc-outline { + fill: none; + } + + .arc-outline:has(+ .arc:focus) { + stroke-width: ${strokeWidthThickest}; + stroke: ${colorStrokeFocus2}; + } + + .text-inside-donut { + ${typographyTitle3Styles} + fill: ${colorNeutralForeground1}; + } + + .arc-label { + ${typographyCaption1StrongStyles} + fill: ${colorNeutralForeground1}; + pointer-events: none; + user-select: none; + } + + .arc-label.inactive { + opacity: 0.1; + } + + .legend-container { + padding-top: ${spacingVerticalL}; + white-space: nowrap; + width: 100%; + align-items: center; + margin: -${spacingVerticalS} ${spacingHorizontalNone} ${spacingVerticalNone} -${spacingHorizontalS}; + flex-wrap: wrap; + display: flex; + } + + .legend-container[hidden] { + display: none; + } + + .legend { + display: flex; + align-items: center; + cursor: pointer; + border: none; + padding: ${spacingHorizontalS}; + background: none; + text-transform: capitalize; + } + + .legend-rect { + width: 12px; + height: 12px; + margin-inline-end: ${spacingHorizontalS}; + border: ${strokeWidthThin} solid; + } + + .legend-text { + ${typographyCaption1Styles} + color: ${colorNeutralForeground1}; + } + + .legend.inactive .legend-rect { + background-color: transparent !important; + } + + .legend.inactive .legend-text { + opacity: 0.67; + } + + .tooltip { + display: grid; + overflow: hidden; + padding: ${spacingVerticalMNudge} ${spacingHorizontalL}; + background-color: ${colorNeutralBackground1}; + background-blend-mode: normal, luminosity; + border-radius: ${borderRadiusMedium}; + border: 1px solid ${colorTransparentStroke}; + filter: drop-shadow(0 0 2px ${colorNeutralShadowAmbient}) drop-shadow(0 8px 16px ${colorNeutralShadowKey}); + position: absolute; + z-index: 1; + pointer-events: none; + } + + .tooltip-body { + padding-inline-start: ${spacingHorizontalS}; + color: ${colorNeutralForeground1}; + border-inline-start: 4px solid; + } + + .tooltip-legend-text { + ${typographyCaption1Styles} + } + + .tooltip-content-y { + ${typographyTitle2Styles} + } +`.withBehaviors( + forcedColorsStylesheetBehavior(css` + .text-inside-donut { + fill: CanvasText; + } + + .arc-label { + fill: CanvasText; + } + + .legend-rect, + .tooltip-body { + forced-color-adjust: none; + } + + .tooltip-legend-text, + .tooltip-content-y { + forced-color-adjust: auto; + color: CanvasText; + } + `), +); diff --git a/src/Charts.Scripts/src/donut-chart/donut-chart.template.ts b/src/Charts.Scripts/src/donut-chart/donut-chart.template.ts new file mode 100644 index 0000000000..d9223dd739 --- /dev/null +++ b/src/Charts.Scripts/src/donut-chart/donut-chart.template.ts @@ -0,0 +1,65 @@ +import { ElementViewTemplate, html, ref, repeat, when } from '@microsoft/fast-element'; +import type { DonutChart } from './donut-chart.js'; +import type { Legend } from './donut-chart.options.js'; + +/** + * Generates a template for the DonutChart component. + * + * @public + */ +export function donutChartTemplate(): ElementViewTemplate { + return html` + + ${when(x => !!x.chartTitle, html`${x => x.chartTitle}`)} + + + + + + x.hideLegends}" role="listbox" aria-label="${x => x.legendListLabel}"> + ${repeat( + x => x.legends, + html` c.parent.handleLegendMouseoverAndFocus(x.title)}" + @mouseout="${(x, c) => c.parent.handleLegendMouseoutAndBlur()}" + @focus="${(x, c) => c.parent.handleLegendMouseoverAndFocus(x.title)}" + @blur="${(x, c) => c.parent.handleLegendMouseoutAndBlur()}" + @click="${(x, c) => c.parent.handleLegendClick(x.title)}" + > + + ${x => x.title} + `, + )} + + ${when( + x => !x.hideTooltip && x.tooltipProps.isVisible, + html` + + + ${x => x.tooltipProps.legend} + + ${x => x.tooltipProps.yValue} + + + + `, + )} + + `; +} + +/** + * @internal + */ +export const template: ElementViewTemplate = donutChartTemplate(); diff --git a/src/Charts.Scripts/src/donut-chart/donut-chart.ts b/src/Charts.Scripts/src/donut-chart/donut-chart.ts new file mode 100644 index 0000000000..2c430b6ad7 --- /dev/null +++ b/src/Charts.Scripts/src/donut-chart/donut-chart.ts @@ -0,0 +1,762 @@ +import { attr, FASTElement, nullableNumberConverter, observable } from '@microsoft/fast-element'; +import { format as d3Format } from 'd3-format'; +import { arc as d3Arc, pie as d3Pie, PieArcDatum } from 'd3-shape'; +import { + booleanStringConverter, + getColorFromToken, + getNextColor, + getRTL, + jsonConverter, + SVG_NAMESPACE_URI, + validateChartProps, + wrapText, +} from '../utils/chart-helpers.js'; +import type { ChartDataPoint, ChartProps, Legend } from './donut-chart.options.js'; + +export class DonutChart extends FASTElement { + @observable + public tooltipProps = { + isVisible: false, + legend: '', + yValue: '', + color: '', + xPos: 0, + yPos: 0, + }; + + @observable + public legends: Legend[] = []; + + @observable + public activeLegend: string = ''; + protected activeLegendChanged(oldValue: string, newValue: string) { + if (this._isSettingActiveLegend) { + return; + } + + this._updateLegendInteractionState(); + } + + @observable + public isLegendSelected: boolean = false; + + @observable + public selectedLegends: string[] = []; + + + @attr({ attribute: 'chart-title' }) + public chartTitle?: string; + + @attr({ converter: nullableNumberConverter }) + public height: number = 200; + + @attr({ converter: nullableNumberConverter }) + public width: number = 200; + + @attr({ attribute: 'hide-legends', mode: 'boolean' }) + public hideLegends: boolean = false; + + @attr({ attribute: 'hide-tooltip', mode: 'boolean' }) + public hideTooltip: boolean = false; + + @attr({ attribute: 'hide-labels', mode: 'boolean' }) + public hideLabels: boolean = true; + + @attr({ attribute: 'show-labels-in-percent', mode: 'boolean' }) + public showLabelsInPercent: boolean = false; + + @attr({ attribute: 'round-corners', mode: 'boolean' }) + public roundCorners: boolean = false; + + @attr({ converter: jsonConverter }) + public data!: ChartProps; + + @attr({ attribute: 'inner-radius', converter: nullableNumberConverter }) + public innerRadius: number = 1; + + @attr({ attribute: 'value-inside-donut' }) + public valueInsideDonut?: string; + + @attr({ attribute: 'legend-list-label' }) + public legendListLabel?: string; + + @attr + public order: 'default' | 'sorted' = 'default'; + + @attr + public culture?: string; + + @attr({ attribute: 'allow-multiple-legend-selection', mode: 'boolean' }) + public allowMultipleLegendSelection: boolean = false; + + public chartContainer!: HTMLDivElement; + public group!: SVGGElement; + public elementInternals: ElementInternals = this.attachInternals(); + + private _arcs: SVGPathElement[] = []; + private _arcLabels: SVGTextElement[] = []; + private _isRTL: boolean = false; + private _isSettingActiveLegend: boolean = false; + private _isSettingTooltipProps: boolean = false; + private _textInsideDonut?: SVGTextElement; + private _tooltip?: HTMLDivElement; + + private readonly _handleMouseLeave = () => { + this._setTooltipProps({ isVisible: false, legend: '', yValue: '', color: '', xPos: 0, yPos: 0 }); + }; + + constructor() { + super(); + + this.elementInternals.role = 'region'; + } + + protected tooltipPropsChanged(oldValue: any, newValue: any) { + if (this._isSettingTooltipProps) { + return; + } + + this._updateTooltipState(); + } + + public handleLegendMouseoverAndFocus(legendTitle: string) { + if (this.allowMultipleLegendSelection) { + if (this.selectedLegends.length > 0) { + return; + } + } else { + if (this.isLegendSelected) { + return; + } + } + + this._setActiveLegend(legendTitle); + } + + public handleLegendMouseoutAndBlur() { + if (this.allowMultipleLegendSelection) { + if (this.selectedLegends.length > 0) { + return; + } + } else { + if (this.isLegendSelected) { + return; + } + } + + this._setActiveLegend(''); + } + + public handleLegendClick(legendTitle: string) { + if (this.allowMultipleLegendSelection) { + const nextSelection = this.selectedLegends.includes(legendTitle) + ? this.selectedLegends.filter(legend => legend !== legendTitle) + : [...this.selectedLegends, legendTitle]; + this.selectedLegends = nextSelection; + if (nextSelection.length === 0) { + this._setActiveLegend(''); + } else if (!nextSelection.includes(this.activeLegend)) { + this._setActiveLegend(nextSelection[nextSelection.length - 1]); + } else { + this._updateLegendInteractionState(); + } + return; + } + + if (this.isLegendSelected && this.activeLegend === legendTitle) { + this._setActiveLegend(''); + this.isLegendSelected = false; + } else { + this._setActiveLegend(legendTitle); + this.isLegendSelected = true; + } + } + + public isLegendItemSelected(legendTitle: string) { + return Array.isArray(this.selectedLegends) && this.selectedLegends.includes(legendTitle); + } + + public isLegendItemDimmed(legendTitle: string) { + const highlighted = this._getHighlightedLegends(); + return highlighted.length > 0 && !highlighted.includes(legendTitle); + } + + connectedCallback() { + this._initializeFromAttributes(); + + const initialChartData = this.data ? this._prepareChartData() : undefined; + + super.connectedCallback(); + + this.addEventListener('mouseleave', this._handleMouseLeave); + + if (!this.data || !initialChartData) { + return; + } + + this._isRTL = getRTL(this); + this._render(initialChartData); + } + + public disconnectedCallback() { + this.removeEventListener('mouseleave', this._handleMouseLeave); + super.disconnectedCallback(); + } + + attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null) { + super.attributeChangedCallback(name, oldValue, newValue); + + if (oldValue === newValue) { + return; + } + + const booleanValue = newValue !== null && newValue !== 'false'; + + if (name === 'round-corners') { + this.roundCorners = booleanValue; + } + if (name === 'hide-labels') { + this.hideLabels = booleanValue; + } + if (name === 'hide-legends') { + this.hideLegends = booleanValue; + } + if (name === 'show-labels-in-percent') { + this.showLabelsInPercent = booleanValue; + } + if (name === 'hide-tooltip') { + this.hideTooltip = booleanValue; + } + if (name === 'allow-multiple-legend-selection') { + this.allowMultipleLegendSelection = booleanValue; + } + } + + protected roundCornersChanged() { + this._scheduleRender(); + } + + protected dataChanged(_oldValue: ChartProps, newValue: ChartProps) { + if (newValue) { + this._scheduleRender(); + } + } + + protected chartTitleChanged() { + this._scheduleRender(); + } + + protected widthChanged() { + this._scheduleRender(); + } + + protected heightChanged() { + this._scheduleRender(); + } + + protected innerRadiusChanged() { + this._scheduleRender(); + } + + protected valueInsideDonutChanged() { + this._scheduleRender(); + } + + protected hideLabelsChanged() { + this._scheduleRender(); + } + + protected hideLegendsChanged(_oldValue: boolean, newValue: boolean) { + this.shadowRoot?.querySelector('.legend-container')?.toggleAttribute('hidden', newValue); + } + + protected hideTooltipChanged() { + this._updateTooltip(); + } + + protected showLabelsInPercentChanged() { + this._scheduleRender(); + } + + protected cultureChanged() { + this._scheduleRender(); + } + + protected orderChanged() { + this._scheduleRender(); + } + + protected allowMultipleLegendSelectionChanged() { + if (!this.allowMultipleLegendSelection) { + this.selectedLegends = []; + this._setActiveLegend(''); + this.isLegendSelected = false; + return; + } + + this._updateLegendInteractionState(); + } + + protected selectedLegendsChanged() { + this._updateLegendInteractionState(); + } + + private _renderPending = false; + + /** + * Schedules a single re-render deferred to the next event-loop task, + * batching all attribute changes from a single Blazor render batch + * (which may span multiple microtask checkpoints due to async JS interop) + * into one render pass. + * Interactive-state changes (activeLegend, tooltipProps) bypass this and + * update immediately. + */ + private _scheduleRender(): void { + if (this._renderPending) { + return; + } + this._renderPending = true; + setTimeout(() => { + this._renderPending = false; + this._rerender(); + }, 0); + } + + private _rerender() { + if (!this.$fastController.isConnected || !this.data) { + return; + } + + this._clearChart(); + this._initializeAndRender(); + } + + private _clearChart() { + if (this.group) { + while (this.group.firstChild) { + this.group.removeChild(this.group.firstChild); + } + } + + this._arcs = []; + this._arcLabels = []; + this._textInsideDonut = undefined; + } + + private _initializeFromAttributes() { + const setString = (name: string, assign: (value: string) => void) => { + const value = this.getAttribute(name); + if (value !== null) { + assign(value); + } + }; + + const setBoolean = (name: string, assign: (value: boolean) => void) => { + const value = this.getAttribute(name); + if (value !== null) { + assign(booleanStringConverter.fromView(value)); + } + }; + + setString('chart-title', value => { + this.chartTitle = value; + }); + setString('height', value => { + this.height = nullableNumberConverter.fromView(value) ?? this.height; + }); + setString('width', value => { + this.width = nullableNumberConverter.fromView(value) ?? this.width; + }); + setString('data', value => { + this.data = jsonConverter.fromView(value) as ChartProps; + }); + setString('inner-radius', value => { + this.innerRadius = nullableNumberConverter.fromView(value) ?? this.innerRadius; + }); + setString('value-inside-donut', value => { + this.valueInsideDonut = value; + }); + setString('legend-list-label', value => { + this.legendListLabel = value; + }); + setString('order', value => { + this.order = value as 'default' | 'sorted'; + }); + setString('culture', value => { + this.culture = value; + }); + + setBoolean('hide-legends', value => { + this.hideLegends = value; + }); + setBoolean('hide-tooltip', value => { + this.hideTooltip = value; + }); + setBoolean('hide-labels', value => { + this.hideLabels = value; + }); + setBoolean('show-labels-in-percent', value => { + this.showLabelsInPercent = value; + }); + setBoolean('round-corners', value => { + this.roundCorners = value; + }); + setBoolean('allow-multiple-legend-selection', value => { + this.allowMultipleLegendSelection = value; + }); + } + + private _initializeAndRender() { + const chartData = this._prepareChartData(); + + this._isRTL = getRTL(this); + + this._render(chartData); + } + + private _prepareChartData(): ChartDataPoint[] { + validateChartProps(this.data, 'data'); + + const chartData = this._resolveChartData(); + + this.legends = this._getLegends(chartData); + this.elementInternals.ariaLabel = + this.chartTitle || this.data.chartTitle || `Donut chart with ${chartData.length} segments.`; + + return chartData; + } + + private _resolveChartData(): ChartDataPoint[] { + const sourceData = + this.order === 'sorted' ? [...this.data.chartData].sort((a, b) => b.data - a.data) : this.data.chartData; + const totalValue = sourceData.reduce((sum, point) => sum + (point.data ?? 0), 0); + const minimumValue = totalValue * 0.01; + + return sourceData.map((dataPoint, index) => { + const color = dataPoint.color ? getColorFromToken(dataPoint.color) : getNextColor(index); + const resolvedData = minimumValue > dataPoint.data && dataPoint.data > 0 ? minimumValue : dataPoint.data; + + return { + ...dataPoint, + color, + data: resolvedData, + yAxisCalloutData: + resolvedData !== dataPoint.data + ? dataPoint.yAxisCalloutData ?? dataPoint.data.toLocaleString(this.culture || undefined) + : dataPoint.yAxisCalloutData, + }; + }); + } + + private _render(chartData: ChartDataPoint[]) { + const totalValue = chartData.reduce((sum, point) => sum + (point.data ?? 0), 0); + const outerRadius = Math.max(0, (Math.min(this.height, this.width) - 20) / 2); + const cornerRadius = this.roundCorners ? 3 : 0; + const pie = d3Pie() + .value(d => d.data) + .padAngle(0.02); + const arc = d3Arc>() + .innerRadius(this.innerRadius) + .outerRadius(outerRadius) + .cornerRadius(cornerRadius); + + pie(chartData).forEach(arcDatum => { + const arcGroup = document.createElementNS(SVG_NAMESPACE_URI, 'g'); + this.group.appendChild(arcGroup); + + const pathOutline = document.createElementNS(SVG_NAMESPACE_URI, 'path'); + arcGroup.appendChild(pathOutline); + pathOutline.classList.add('arc-outline'); + pathOutline.setAttribute('d', arc(arcDatum)!); + + const path = document.createElementNS(SVG_NAMESPACE_URI, 'path'); + arcGroup.appendChild(path); + this._arcs.push(path); + path.classList.add('arc'); + path.setAttribute('d', arc(arcDatum)!); + path.setAttribute('fill', arcDatum.data.color!); + path.setAttribute('data-id', arcDatum.data.legend); + path.setAttribute('tabindex', '0'); + path.setAttribute('aria-label', `${arcDatum.data.legend}, ${arcDatum.data.data}.`); + path.setAttribute('role', 'img'); + + path.addEventListener('mouseover', event => { + if (this.activeLegend !== '' && this.activeLegend !== arcDatum.data.legend) { + return; + } + + const bounds = this.getBoundingClientRect(); + + this._setTooltipProps({ + isVisible: true, + legend: arcDatum.data.legend, + yValue: `${arcDatum.data.data}`, + color: arcDatum.data.color!, + xPos: this._isRTL ? bounds.right - event.clientX : event.clientX - bounds.left, + yPos: event.clientY - bounds.top - 85, + }); + }); + path.addEventListener('focus', event => { + if (this.activeLegend !== '' && this.activeLegend !== arcDatum.data.legend) { + return; + } + + const rootBounds = this.getBoundingClientRect(); + const arcBounds = path.getBoundingClientRect(); + + this._setTooltipProps({ + isVisible: true, + legend: arcDatum.data.legend, + yValue: `${arcDatum.data.data}`, + color: arcDatum.data.color!, + xPos: this._isRTL + ? rootBounds.right - arcBounds.left - arcBounds.width / 2 + : arcBounds.left + arcBounds.width / 2 - rootBounds.left, + yPos: arcBounds.top - rootBounds.top - 85, + }); + }); + path.addEventListener('blur', event => { + this._setTooltipProps({ isVisible: false, legend: '', yValue: '', color: '', xPos: 0, yPos: 0 }); + }); + + const label = this._createArcLabel(arc, arcDatum, totalValue, outerRadius); + if (label) { + arcGroup.appendChild(label); + this._arcLabels.push(label); + } + }); + + this._applyActiveLegendState(); + this._applyLegendButtonState(); + + if (this.valueInsideDonut) { + this._textInsideDonut = document.createElementNS(SVG_NAMESPACE_URI, 'text'); + this.group.appendChild(this._textInsideDonut); + this._textInsideDonut.classList.add('text-inside-donut'); + this._textInsideDonut.setAttribute('x', '0'); + this._textInsideDonut.setAttribute('y', '0'); + this._textInsideDonut.setAttribute('text-anchor', 'middle'); + this._textInsideDonut.setAttribute('dominant-baseline', 'middle'); + this._updateTextInsideDonut(); + } + + this._updateTooltip(); + } + + private _getLegends(chartData: ChartDataPoint[]): Legend[] { + return chartData.map(d => ({ + title: d.legend, + color: d.color!, + })); + } + + private _getHighlightedLegends(): string[] { + if (this.allowMultipleLegendSelection) { + if (Array.isArray(this.selectedLegends) && this.selectedLegends.length > 0) { + return this.selectedLegends; + } + return this.activeLegend ? [this.activeLegend] : []; + } + return this.activeLegend ? [this.activeLegend] : []; + } + + private _applyActiveLegendState() { + if (!this._arcs || !this._arcLabels) { + return; + } + + const highlighted = this._getHighlightedLegends(); + + if (highlighted.length === 0) { + this._arcs.forEach(arc => { + arc.classList.remove('inactive'); + arc.setAttribute('tabindex', '0'); + }); + this._arcLabels.forEach(label => label.classList.remove('inactive')); + return; + } + + this._arcs.forEach(arc => { + const legendId = arc.getAttribute('data-id'); + const isActive = legendId !== null && highlighted.includes(legendId); + arc.classList.toggle('inactive', !isActive); + arc.setAttribute('tabindex', isActive ? '0' : '-1'); + }); + this._arcLabels.forEach(label => { + const legendId = label.getAttribute('data-id'); + label.classList.toggle('inactive', legendId === null || !highlighted.includes(legendId)); + }); + } + + private _updateLegendInteractionState() { + this._applyActiveLegendState(); + this._applyLegendButtonState(); + this._updateTextInsideDonut(); + } + + private _setActiveLegend(value: string) { + this._isSettingActiveLegend = true; + this.activeLegend = value; + this._isSettingActiveLegend = false; + this._updateLegendInteractionState(); + } + + private _applyLegendButtonState() { + const legends = this.shadowRoot?.querySelectorAll('.legend'); + if (!legends) { + return; + } + + const highlighted = this._getHighlightedLegends(); + legends.forEach(button => { + const title = button.querySelector('.legend-text')?.textContent ?? ''; + const isActive = highlighted.length === 0 || highlighted.includes(title); + button.classList.toggle('inactive', !isActive); + button.setAttribute('aria-selected', `${highlighted.includes(title)}`); + }); + } + + private _updateTooltip() { + if (!this.shadowRoot) { + return; + } + + if (this.hideTooltip || !this.tooltipProps.isVisible) { + this._tooltip?.remove(); + this._tooltip = undefined; + return; + } + + if (!this._tooltip || !this._tooltip.isConnected) { + this._tooltip = this.shadowRoot.querySelector('.tooltip') ?? document.createElement('div'); + + if (!this._tooltip.classList.contains('tooltip')) { + this._tooltip.classList.add('tooltip'); + } + + if (!this._tooltip.isConnected) { + const body = document.createElement('div'); + body.classList.add('tooltip-body'); + + const legendText = document.createElement('div'); + legendText.classList.add('tooltip-legend-text'); + body.appendChild(legendText); + + const contentY = document.createElement('div'); + contentY.classList.add('tooltip-content-y'); + body.appendChild(contentY); + + this._tooltip.appendChild(body); + this.shadowRoot.appendChild(this._tooltip); + } + } + + this._tooltip.style.insetInlineStart = `${this.tooltipProps.xPos}px`; + this._tooltip.style.top = `${this.tooltipProps.yPos}px`; + + const body = this._tooltip.querySelector('.tooltip-body'); + const legendText = this._tooltip.querySelector('.tooltip-legend-text'); + const contentY = this._tooltip.querySelector('.tooltip-content-y'); + + body?.style.setProperty('border-color', this.tooltipProps.color); + if (legendText) { + legendText.textContent = this.tooltipProps.legend; + } + if (contentY) { + contentY.style.setProperty('color', this.tooltipProps.color); + contentY.textContent = this.tooltipProps.yValue; + } + } + + private _updateTooltipState() { + this._updateTextInsideDonut(); + this._updateTooltip(); + } + + private _setTooltipProps(value: typeof this.tooltipProps) { + this._isSettingTooltipProps = true; + this.tooltipProps = value; + this._isSettingTooltipProps = false; + this._updateTooltipState(); + } + + private _createArcLabel( + arc: ReturnType>>, + arcDatum: PieArcDatum, + totalValue: number, + outerRadius: number, + ) { + if (this.hideLabels || Math.abs(arcDatum.endAngle - arcDatum.startAngle) < Math.PI / 12) { + return undefined; + } + + const [base, perp] = arc.centroid(arcDatum); + const hypotenuse = Math.sqrt(base * base + perp * perp); + const labelRadius = Math.max(this.innerRadius, outerRadius) + 2; + const angle = (arcDatum.startAngle + arcDatum.endAngle) / 2; + const label = document.createElementNS(SVG_NAMESPACE_URI, 'text'); + + label.classList.add('arc-label'); + label.setAttribute('data-id', arcDatum.data.legend); + label.setAttribute('x', `${(hypotenuse === 0 ? 0 : base / hypotenuse) * labelRadius}`); + label.setAttribute('y', `${(hypotenuse === 0 ? 0 : perp / hypotenuse) * labelRadius}`); + label.setAttribute('text-anchor', angle > Math.PI !== this._isRTL ? 'end' : 'start'); + label.setAttribute('dominant-baseline', angle > Math.PI / 2 && angle < (3 * Math.PI) / 2 ? 'hanging' : 'auto'); + label.setAttribute('aria-hidden', 'true'); + label.textContent = this.showLabelsInPercent + ? d3Format('.0%')(totalValue === 0 ? 0 : arcDatum.value / totalValue) + : this._formatArcLabelValue(arcDatum.value); + + return label; + } + + private _formatArcLabelValue(value: number) { + const formatted = new Intl.NumberFormat(this.culture || undefined, { + maximumFractionDigits: value >= 1000 ? 1 : 2, + notation: value >= 1000 ? 'compact' : 'standard', + }).format(value); + + return formatted.endsWith('K') ? `${formatted.slice(0, -1)}k` : formatted; + } + + private _getTextInsideDonut(valueInsideDonut: string) { + let textInsideDonut = valueInsideDonut; + + const highlighted = this._getHighlightedLegends(); + const singleHighlight = + highlighted.length === 1 + ? highlighted[0] + : this.tooltipProps.isVisible + ? this.tooltipProps.legend + : null; + + if (valueInsideDonut && singleHighlight) { + const highlightedDataPoint = this.data.chartData.find( + dataPoint => dataPoint.legend === singleHighlight, + ); + if (highlightedDataPoint) { + textInsideDonut = + highlightedDataPoint.yAxisCalloutData ?? + highlightedDataPoint.calloutData ?? + highlightedDataPoint.data.toLocaleString(this.culture || undefined); + } + } + + return textInsideDonut; + } + + private _updateTextInsideDonut() { + if (!this._textInsideDonut || !this.valueInsideDonut) { + return; + } + + this._textInsideDonut.textContent = this._getTextInsideDonut(this.valueInsideDonut); + const lineHeight = this._textInsideDonut.getBoundingClientRect().height; + wrapText(this._textInsideDonut, 2 * this.innerRadius); + const lines = this._textInsideDonut.getElementsByTagName('tspan'); + const start = -1 * Math.trunc((lines.length - 1) / 2); + for (let i = 0; i < lines.length; i++) { + lines[i].setAttribute('dy', `${(start + i) * lineHeight}`); + } + } +} diff --git a/src/Charts.Scripts/src/donut-chart/index.ts b/src/Charts.Scripts/src/donut-chart/index.ts new file mode 100644 index 0000000000..60e54e1a59 --- /dev/null +++ b/src/Charts.Scripts/src/donut-chart/index.ts @@ -0,0 +1,4 @@ +export { definition as DonutChartDefinition } from './donut-chart.definition.js'; +export { DonutChart } from './donut-chart.js'; +export { styles as DonutChartStyles } from './donut-chart.styles.js'; +export { template as DonutChartTemplate } from './donut-chart.template.js'; diff --git a/src/Charts.Scripts/src/helpers.stories.ts b/src/Charts.Scripts/src/helpers.stories.ts new file mode 100644 index 0000000000..a568c4b82b --- /dev/null +++ b/src/Charts.Scripts/src/helpers.stories.ts @@ -0,0 +1,101 @@ +import type { ElementViewTemplate, FASTElement, ViewTemplate } from '@microsoft/fast-element'; +import type { AnnotatedStoryFn, Args, ComponentAnnotations, Renderer, StoryAnnotations } from 'storybook/internal/csf'; + +/** + * A helper that returns a function to bind a Storybook story to a ViewTemplate. + * + * @param template - The ViewTemplate to render + * @returns - a function to bind a Storybook story + */ +export function renderComponent(template: ViewTemplate): (args: TArgs) => Element | DocumentFragment { + return function (args) { + const storyFragment = new DocumentFragment(); + template.render(args, storyFragment); + if (storyFragment.childElementCount === 1) { + return storyFragment.firstElementChild!; + } + return storyFragment; + }; +} + +export declare interface FASTComponentsRenderer extends Renderer { + canvasElement: FASTElement; + component: typeof FASTElement | string; + storyResult: string | Node | DocumentFragment | ElementViewTemplate; +} + +/** + * A helper that returns a function to bind a Storybook story to a ViewTemplate. + */ +export type FASTFramework = Renderer & { + component: typeof FASTElement; + storyResult: FASTElement | Element | DocumentFragment; +}; + +/** + * Metadata to configure the stories for a component. + */ +export declare type Meta = ComponentAnnotations>; + +/** + * Story object that represents a CSFv3 component example. + * + * @see [Named Story exports](https://storybook.js.org/docs/formats/component-story-format/#named-story-exports) + */ +export declare type StoryObj = StoryAnnotations>; + +/** + * Story function that represents a CSFv2 component example. + */ +export declare type StoryFn = AnnotatedStoryFn; + +/** + * Story function that represents a CSFv2 component example. + * + * NOTE that in Storybook 7.0, this type will be renamed to `StoryFn` and replaced by the current `StoryObj` type. + */ +export declare type Story = StoryFn>; + +/** + * Combined Storybook story args. + */ +export type StoryArgs = Partial> & Args; + +export function generateImage({ + width, + height = width, + backgroundColor = 'rgb(204, 204, 204)', + color = 'rgb(150, 150, 150)', + text = `${width} x ${height}`, +}: { + width: number; + height?: number; + backgroundColor?: string; + color?: string; + text?: string; +}): string { + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d') as CanvasRenderingContext2D; + + canvas.width = width; + canvas.height = height; + + // Clear the canvas. + context.clearRect(0, 0, canvas.width, canvas.height); + + // get the font size to fit the text + context.font = '1px sans-serif'; + const maxFontSize = Math.max(width / context.measureText(text).width / 2, 7); + + // Draw the background + context.fillStyle = backgroundColor; + context.fillRect(0, 0, canvas.width, canvas.height); + + context.font = `${maxFontSize}px Helvetica, Arial, sans-serif`; + context.textAlign = 'center'; + context.textBaseline = 'middle'; + context.fillStyle = color; + context.fillText(text, canvas.width / 2, canvas.height / 2); + + return canvas.toDataURL('image/png'); +} diff --git a/src/Charts.Scripts/src/horizontal-bar-chart-with-axis/define.ts b/src/Charts.Scripts/src/horizontal-bar-chart-with-axis/define.ts new file mode 100644 index 0000000000..e04c840516 --- /dev/null +++ b/src/Charts.Scripts/src/horizontal-bar-chart-with-axis/define.ts @@ -0,0 +1,4 @@ +import { FluentDesignSystem } from '@fluentui/web-components'; +import { definition } from './horizontal-bar-chart-with-axis.definition.js'; + +definition.define(FluentDesignSystem.registry); diff --git a/src/Charts.Scripts/src/horizontal-bar-chart-with-axis/horizontal-bar-chart-with-axis.bench.ts b/src/Charts.Scripts/src/horizontal-bar-chart-with-axis/horizontal-bar-chart-with-axis.bench.ts new file mode 100644 index 0000000000..31f68df997 --- /dev/null +++ b/src/Charts.Scripts/src/horizontal-bar-chart-with-axis/horizontal-bar-chart-with-axis.bench.ts @@ -0,0 +1,12 @@ +import { FluentDesignSystem } from '@fluentui/web-components'; +import { definition } from './horizontal-bar-chart-with-axis.definition.js'; + +definition.define(FluentDesignSystem.registry); + +const itemRenderer = () => { + const chart = document.createElement('fluent-horizontal-bar-chart-with-axis'); + return chart; +}; + +export default itemRenderer; +export { tests } from '../utils/benchmark-wrapper.js'; diff --git a/src/Charts.Scripts/src/horizontal-bar-chart-with-axis/horizontal-bar-chart-with-axis.definition.ts b/src/Charts.Scripts/src/horizontal-bar-chart-with-axis/horizontal-bar-chart-with-axis.definition.ts new file mode 100644 index 0000000000..013acb2e30 --- /dev/null +++ b/src/Charts.Scripts/src/horizontal-bar-chart-with-axis/horizontal-bar-chart-with-axis.definition.ts @@ -0,0 +1,18 @@ +import { FluentDesignSystem } from '@fluentui/web-components'; +import { HorizontalBarChartWithAxis } from './horizontal-bar-chart-with-axis.js'; +import { styles } from './horizontal-bar-chart-with-axis.styles.js'; +import { template } from './horizontal-bar-chart-with-axis.template.js'; + +/** + * @public + * @remarks + * HTML Element: `` + */ +export const definition = HorizontalBarChartWithAxis.compose({ + name: `${FluentDesignSystem.prefix}-horizontal-bar-chart-with-axis`, + template, + styles, + shadowOptions: { + delegatesFocus: true, + }, +}); diff --git a/src/Charts.Scripts/src/horizontal-bar-chart-with-axis/horizontal-bar-chart-with-axis.options.ts b/src/Charts.Scripts/src/horizontal-bar-chart-with-axis/horizontal-bar-chart-with-axis.options.ts new file mode 100644 index 0000000000..7838178b45 --- /dev/null +++ b/src/Charts.Scripts/src/horizontal-bar-chart-with-axis/horizontal-bar-chart-with-axis.options.ts @@ -0,0 +1,38 @@ +export interface AccessibilityData { + ariaLabel?: string; +} + +export interface HorizontalBarChartWithAxisDataPoint { + x: number; + y: number | string; + legend?: string; + color?: string; + gradient?: [string, string]; + xAxisCalloutData?: string; + yAxisCalloutData?: string; + onClick?: VoidFunction; + callOutAccessibilityData?: AccessibilityData; +} + +export type AxisCategoryOrder = + | 'default' + | 'data' + | 'category ascending' + | 'category descending' + | 'total ascending' + | 'total descending' + | 'min ascending' + | 'min descending' + | 'max ascending' + | 'max descending' + | 'sum ascending' + | 'sum descending' + | 'mean ascending' + | 'mean descending' + | 'median ascending' + | 'median descending'; + +export interface HorizontalBarChartWithAxisLegend { + legend: string; + color: string; +} diff --git a/src/Charts.Scripts/src/horizontal-bar-chart-with-axis/horizontal-bar-chart-with-axis.styles.ts b/src/Charts.Scripts/src/horizontal-bar-chart-with-axis/horizontal-bar-chart-with-axis.styles.ts new file mode 100644 index 0000000000..376f23d78b --- /dev/null +++ b/src/Charts.Scripts/src/horizontal-bar-chart-with-axis/horizontal-bar-chart-with-axis.styles.ts @@ -0,0 +1,178 @@ +import type { ElementStyles } from '@microsoft/fast-element'; +import { css } from '@microsoft/fast-element'; +import { + colorNeutralBackground1, + colorNeutralForeground1, + colorNeutralForeground2, + colorNeutralStroke1, + colorNeutralStrokeAccessible, + display, + fontSizeHero700, + forcedColorsStylesheetBehavior, + shadow4, + spacingHorizontalL, + spacingHorizontalNone, + spacingHorizontalS, + spacingVerticalL, + spacingVerticalMNudge, + spacingVerticalNone, + spacingVerticalS, + spacingVerticalXS, + strokeWidthThick, + strokeWidthThickest, + strokeWidthThin, + typographyBody1StrongStyles, + typographyBody1Styles, + typographyCaption1Styles, + typographySubtitle2StrongerStyles, +} from '@fluentui/web-components'; + +export const styles: ElementStyles = css` + ${display('inline-block')} + + :host { + position: relative; + width: 100%; + } + + .chart-title { + ${typographyBody1StrongStyles} + margin-bottom: ${spacingVerticalS}; + } + + .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; + } + + .axis-text, + .y-axis-text { + ${typographyCaption1Styles} + fill: ${colorNeutralForeground2}; + font-size: 10px; + font-weight: 600; + } + + .bar { + opacity: 1; + } + + .bar.inactive { + opacity: 0.1; + } + + .bar:focus { + outline: none; + stroke-width: ${strokeWidthThick}; + stroke: black; + } + + .bar-label { + ${typographyBody1StrongStyles} + fill: ${colorNeutralForeground1}; + direction: ltr; + unicode-bidi: isolate; + } + + .legend-container { + display: flex; + flex-direction: row; + flex-wrap: wrap; + padding-top: ${spacingVerticalL}; + width: 100%; + align-items: center; + margin: -${spacingVerticalS} ${spacingHorizontalNone} ${spacingVerticalNone} -${spacingHorizontalS}; + } + + .legend { + display: flex; + align-items: center; + cursor: pointer; + border: none; + padding: ${spacingHorizontalS}; + background: none; + } + + .legend-rect { + width: 12px; + height: 12px; + margin-inline-end: ${spacingHorizontalS}; + border: ${strokeWidthThin} solid; + } + + .legend-text { + ${typographyCaption1Styles} + color: ${colorNeutralForeground1}; + } + + .legend.inactive .legend-rect { + background-color: transparent !important; + } + + .legend.inactive .legend-text { + opacity: 0.67; + } + + .tooltip { + ${typographyCaption1Styles} + position: absolute; + z-index: 999; + display: grid; + overflow: hidden; + padding: ${spacingVerticalMNudge} ${spacingHorizontalL}; + background: ${colorNeutralBackground1}; + box-shadow: ${shadow4}; + border: ${strokeWidthThick}; + pointer-events: none; + } + + .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: ${fontSizeHero700}; + direction: ltr; + unicode-bidi: isolate; + } +`.withBehaviors( + forcedColorsStylesheetBehavior(css` + .legend-rect, + .tooltip-info { + forced-color-adjust: none; + } + + .bar-label { + fill: CanvasText !important; + } + `), +); diff --git a/src/Charts.Scripts/src/horizontal-bar-chart-with-axis/horizontal-bar-chart-with-axis.template.ts b/src/Charts.Scripts/src/horizontal-bar-chart-with-axis/horizontal-bar-chart-with-axis.template.ts new file mode 100644 index 0000000000..b14afdacf6 --- /dev/null +++ b/src/Charts.Scripts/src/horizontal-bar-chart-with-axis/horizontal-bar-chart-with-axis.template.ts @@ -0,0 +1,62 @@ +import { ElementViewTemplate, html, ref, repeat, when } from '@microsoft/fast-element'; +import type { HorizontalBarChartWithAxis } from './horizontal-bar-chart-with-axis.js'; +import type { HorizontalBarChartWithAxisLegend } from './horizontal-bar-chart-with-axis.options.js'; + +export function horizontalBarChartWithAxisTemplate(): ElementViewTemplate { + return html` + + ${when(x => !!x.chartTitle, html`${x => x.chartTitle}`)} + + ${when( + x => !x.hideLegends, + html` + + ${repeat( + x => x.uniqueLegends, + html` + c.parent.handleLegendMouseoverAndFocus(x.legend)}" + @mouseout="${(x, c) => c.parent.handleLegendMouseoutAndBlur()}" + @focus="${(x, c) => c.parent.handleLegendMouseoverAndFocus(x.legend)}" + @blur="${(x, c) => c.parent.handleLegendMouseoutAndBlur()}" + @click="${(x, c) => c.parent.handleLegendClick(x.legend)}" + > + + ${x => x.legend} + + `, + )} + + `, + )} + ${when( + x => !x.hideTooltip && x.tooltipProps.isVisible, + html` + + ${x => x.tooltipProps.yValue} + + ${x => x.tooltipProps.legend} + + ${x => x.tooltipProps.xValue} + + + + `, + )} + + `; +} + +export const template: ElementViewTemplate = horizontalBarChartWithAxisTemplate(); diff --git a/src/Charts.Scripts/src/horizontal-bar-chart-with-axis/horizontal-bar-chart-with-axis.ts b/src/Charts.Scripts/src/horizontal-bar-chart-with-axis/horizontal-bar-chart-with-axis.ts new file mode 100644 index 0000000000..2d5627cc30 --- /dev/null +++ b/src/Charts.Scripts/src/horizontal-bar-chart-with-axis/horizontal-bar-chart-with-axis.ts @@ -0,0 +1,1197 @@ +import { attr, FASTElement, observable } from '@microsoft/fast-element'; +import { + getColorFromToken, + getNextColor, + getRTL, + jsonConverter, + booleanStringConverter, + SVG_NAMESPACE_URI, +} from '../utils/chart-helpers.js'; +import type { + AxisCategoryOrder, + HorizontalBarChartWithAxisDataPoint, + HorizontalBarChartWithAxisLegend, +} from './horizontal-bar-chart-with-axis.options.js'; + +type TooltipProps = { + isVisible: boolean; + legend: string; + xLabel: string; + xValue: string; + yLabel: string; + yValue: string; + color: string; + xPos: number; + yPos: number; +}; + +type GroupedSeries = { + key: string; + rawY: number | string; + points: HorizontalBarChartWithAxisDataPoint[]; +}; + +type RenderedBar = { + legend?: string; + element: SVGRectElement; +}; + +type PlotLayout = { + barHeight: number; + margins: { + top: number; + right: number; + bottom: number; + left: number; + }; + innerHeight: number; +}; + +const X_AXIS_LABEL = 'X'; +const Y_AXIS_LABEL = 'Y'; +const DEFAULT_HEIGHT = 320; +const DEFAULT_BAR_HEIGHT = 32; +const DEFAULT_X_TICK_COUNT = 6; +const DEFAULT_Y_TICK_COUNT = 4; +const DEFAULT_Y_AXIS_PADDING = 0.5; + +const MIN_DOMAIN_MARGIN = 8; +const STACKED_SEGMENT_GAP = 2; +const createSvgElement = (tag: string): T => { + return document.createElementNS(SVG_NAMESPACE_URI, tag) as T; +}; + +const toNumber = (value: number | string | undefined, fallback: number): number => { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +}; + +const toOptionalNumber = (value: number | string | undefined): number | undefined => { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +}; + +const formatCompactNumber = (value: number, culture?: string) => { + return new Intl.NumberFormat(culture || undefined, { + maximumFractionDigits: Math.abs(value) >= 1000 ? 1 : 2, + notation: Math.abs(value) >= 1000 ? 'compact' : 'standard', + }).format(value); +}; + +const formatAxisNumber = (value: number, culture?: string) => { + return new Intl.NumberFormat(culture || undefined, { + maximumFractionDigits: 2, + }).format(value); +}; + +const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max); + +const getMedian = (values: number[]) => { + if (values.length === 0) { + return 0; + } + const sorted = [...values].sort((left, right) => left - right); + const middle = Math.floor(sorted.length / 2); + return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle]; +}; + +const lightenColor = (color: string, ratio: number) => { + const normalized = color.replace('#', ''); + if (normalized.length !== 6) { + return color; + } + const red = parseInt(normalized.slice(0, 2), 16); + const green = parseInt(normalized.slice(2, 4), 16); + const blue = parseInt(normalized.slice(4, 6), 16); + const mix = (channel: number) => Math.round(channel + (255 - channel) * ratio); + return `rgb(${mix(red)}, ${mix(green)}, ${mix(blue)})`; +}; + +const truncateText = (text: string, maxLength: number) => { + return text.length > maxLength ? `${text.slice(0, maxLength - 1)}…` : text; +}; + +const getNiceStep = (start: number, stop: number, count: number) => { + const safeCount = Math.max(count, 1); + const rawStep = Math.abs(stop - start) / safeCount; + const power = Math.floor(Math.log10(rawStep || 1)); + const base = Math.pow(10, power); + const error = rawStep / base; + + if (error >= Math.sqrt(50)) { + return base * 10; + } + if (error >= Math.sqrt(10)) { + return base * 5; + } + if (error >= Math.sqrt(2)) { + return base * 2; + } + return base; +}; + +const getNiceDomainAndTicks = (min: number, max: number, count: number) => { + if (min === max) { + return { domain: [min, max] as [number, number], ticks: [min] }; + } + + const step = getNiceStep(min, max, count); + const domainStart = Math.floor(min / step) * step; + const domainEnd = Math.ceil(max / step) * step; + const ticks: number[] = []; + + for (let value = domainStart; value <= domainEnd + step / 2; value += step) { + ticks.push(Number(value.toFixed(12))); + } + + return { + domain: [domainStart, domainEnd] as [number, number], + ticks, + }; +}; + +const getClosestPairDiffAndRange = (values: number[]) => { + if (values.length < 2) { + return undefined; + } + + const sorted = [...values].sort((left, right) => left - right); + let closestPairDiff = Number.POSITIVE_INFINITY; + + for (let index = 1; index < sorted.length; index++) { + closestPairDiff = Math.min(closestPairDiff, sorted[index] - sorted[index - 1]); + } + + if (!Number.isFinite(closestPairDiff) || closestPairDiff <= 0) { + return undefined; + } + + return [closestPairDiff, sorted[sorted.length - 1] - sorted[0]] as const; +}; + +export class HorizontalBarChartWithAxis extends FASTElement { + @attr({ converter: jsonConverter }) + public data!: HorizontalBarChartWithAxisDataPoint[]; + + @attr({ attribute: 'chart-title' }) + public chartTitle?: string; + + @attr + public width?: number | string; + + @attr({ attribute: 'legend-list-label' }) + public legendListLabel?: string; + + @attr({ attribute: 'hide-legends', converter: booleanStringConverter }) + public hideLegends: boolean = false; + + @attr({ attribute: 'hide-tooltip', converter: booleanStringConverter }) + public hideTooltip: boolean = false; + + @attr({ attribute: 'hide-labels', converter: booleanStringConverter }) + public hideLabels: boolean = false; + + @attr({ attribute: 'show-y-axis-labels', converter: booleanStringConverter }) + public showYAxisLabels: boolean = false; + + @attr({ attribute: 'show-y-axis-labels-tooltip', converter: booleanStringConverter }) + public showYAxisLabelsTooltip: boolean = false; + + @attr({ attribute: 'use-single-color', converter: booleanStringConverter }) + public useSingleColor: boolean = false; + + @attr({ attribute: 'enable-gradient', converter: booleanStringConverter }) + public enableGradient: boolean = false; + + @attr({ attribute: 'round-corners', mode: 'boolean' }) + public roundCorners: boolean = false; + + @attr({ attribute: 'allow-multiple-legend-selection', mode: 'boolean' }) + public allowMultipleLegendSelection: boolean = false; + + @attr({ attribute: 'bar-height' }) + public barHeight?: number | string; + + @attr({ attribute: 'height' }) + public height?: number | string; + + @attr({ attribute: 'x-axis-tick-count' }) + public xAxisTickCount?: number | string; + + @attr({ attribute: 'y-axis-tick-count' }) + public yAxisTickCount?: number | string; + + @attr({ attribute: 'y-axis-padding' }) + public yAxisPadding?: number | string; + + @attr({ attribute: 'x-min-value' }) + public xMinValue?: number | string; + + @attr({ attribute: 'x-max-value' }) + public xMaxValue?: number | string; + + @attr({ attribute: 'y-min-value' }) + public yMinValue?: number | string; + + @attr({ attribute: 'y-max-value' }) + public yMaxValue?: number | string; + + @attr({ attribute: 'y-axis-category-order' }) + public yAxisCategoryOrder: AxisCategoryOrder = 'default'; + + @attr + public culture?: string; + + @observable + public uniqueLegends: HorizontalBarChartWithAxisLegend[] = []; + + @observable + public activeLegend: string = ''; + + @observable + public selectedLegends: string[] = []; + + @observable + public tooltipProps: TooltipProps = { + isVisible: false, + legend: '', + xLabel: X_AXIS_LABEL, + xValue: '', + yLabel: Y_AXIS_LABEL, + yValue: '', + color: '', + xPos: 0, + yPos: 0, + }; + + public chartContainer!: HTMLDivElement; + public elementInternals: ElementInternals = this.attachInternals(); + + private _renderedBars: RenderedBar[] = []; + private _resizeObserver?: ResizeObserver; + private _isRTL: boolean = false; + + public connectedCallback() { + this._initializeFromAttributes(); + + super.connectedCallback(); + this.elementInternals.role = 'region'; + + this._isRTL = getRTL(this); + this._resizeObserver = new ResizeObserver(() => this._renderChart()); + this._resizeObserver.observe(this); + if (this.data) { + this._renderChart(); + } + } + + attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null) { + super.attributeChangedCallback(name, oldValue, newValue); + + if (name === 'round-corners' && oldValue !== newValue) { + this.roundCorners = newValue !== null && newValue !== 'false'; + } + } + + public disconnectedCallback() { + this._resizeObserver?.disconnect(); + super.disconnectedCallback(); + } + + public handleLegendMouseoverAndFocus = (legendTitle: string) => { + if (this.selectedLegends.length > 0) { + return; + } + this.activeLegend = legendTitle; + }; + + public handleLegendMouseoutAndBlur = () => { + if (this.selectedLegends.length > 0) { + return; + } + this.activeLegend = ''; + }; + + public handleLegendClick = (legendTitle: string) => { + if (this.allowMultipleLegendSelection) { + const nextSelection = this.selectedLegends.includes(legendTitle) + ? this.selectedLegends.filter(legend => legend !== legendTitle) + : [...this.selectedLegends, legendTitle]; + this.selectedLegends = nextSelection; + if (nextSelection.length === 0) { + this.activeLegend = ''; + } else if (!nextSelection.includes(this.activeLegend)) { + this.activeLegend = nextSelection[nextSelection.length - 1]; + } + return; + } + + if (this.selectedLegends.length === 1 && this.selectedLegends[0] === legendTitle) { + this.selectedLegends = []; + this.activeLegend = ''; + return; + } + + this.selectedLegends = [legendTitle]; + this.activeLegend = legendTitle; + }; + + public isLegendSelected(legendTitle: string) { + return Array.isArray(this.selectedLegends) && this.selectedLegends.includes(legendTitle); + } + + public isLegendDimmed(legendTitle: string) { + const highlighted = this._getHighlightedLegends(); + return highlighted.length > 0 && !highlighted.includes(legendTitle); + } + + protected activeLegendChanged() { + this._applyLegendState(); + } + + protected selectedLegendsChanged() { + this._applyLegendState(); + } + + protected dataChanged() { + this._renderChart(); + } + + protected chartTitleChanged() { + this._renderChart(); + } + + protected widthChanged() { + this._renderChart(); + } + + protected hideLabelsChanged() { + this._renderChart(); + } + + protected useSingleColorChanged() { + this._renderChart(); + } + + protected enableGradientChanged() { + this._renderChart(); + } + + protected roundCornersChanged() { + this._renderChart(); + } + + protected allowMultipleLegendSelectionChanged() { + if (!this.allowMultipleLegendSelection) { + this.selectedLegends = []; + this.activeLegend = ''; + return; + } + + this._applyLegendState(); + } + + protected barHeightChanged() { + this._renderChart(); + } + + protected xAxisTickCountChanged() { + this._renderChart(); + } + + protected yAxisTickCountChanged() { + this._renderChart(); + } + + protected yAxisPaddingChanged() { + this._renderChart(); + } + + protected xMinValueChanged() { + this._renderChart(); + } + + protected xMaxValueChanged() { + this._renderChart(); + } + + protected yMinValueChanged() { + this._renderChart(); + } + + protected yMaxValueChanged() { + this._renderChart(); + } + + protected yAxisCategoryOrderChanged() { + this._renderChart(); + } + + protected showYAxisLabelsChanged() { + this._renderChart(); + } + + protected showYAxisLabelsTooltipChanged() { + this._renderChart(); + } + + public get tooltipInlineTransform() { + return this._isRTL ? 'translateX(50%)' : 'translateX(-50%)'; + } + + private _renderChart() { + if (!this.$fastController.isConnected || !this.chartContainer) { + return; + } + + this._clearChart(); + + if (!Array.isArray(this.data) || this.data.length === 0) { + this.uniqueLegends = []; + this.elementInternals.ariaLabel = this.chartTitle || 'Horizontal bar chart with axis has no data.'; + return; + } + + this._validateData(this.data); + this._isRTL = getRTL(this); + this.elementInternals.ariaLabel = this._getChartAriaLabel(); + this._applyHostDimensions(); + + const width = Math.max(this.getBoundingClientRect().width || 640, 320); + const groups = this._getGroupedSeries(); + const numericYAxis = typeof groups[0]?.rawY === 'number'; + const yValues = groups.map(group => group.rawY).filter((value): value is number => typeof value === 'number'); + const height = this._getChartHeight(groups.length, numericYAxis, yValues); + const yLabelWidth = this._getYAxisLabelWidth(groups, numericYAxis); + const margins = this._isRTL + ? { + top: 20, + right: yLabelWidth, + bottom: 35, + left: 20, + } + : { + top: 20, + right: 20, + bottom: 35, + left: yLabelWidth, + }; + const innerWidth = width - margins.left - margins.right; + const plotLayout = this._getPlotLayout(groups.length, numericYAxis, height, margins, yValues); + const xAxisScale = this._getXScaleInfo(groups); + const yPositionForGroup = this._createYPositioner( + groups, + numericYAxis, + plotLayout.margins, + height, + plotLayout.innerHeight, + yValues, + ); + const svg = createSvgElement('svg'); + + svg.setAttribute('class', 'chart-svg'); + svg.setAttribute('width', `${width}`); + svg.setAttribute('height', `${height}`); + svg.setAttribute('viewBox', `0 0 ${width} ${height}`); + svg.setAttribute('aria-label', this.chartTitle || `Horizontal bar chart with axis with ${this.data.length} bars.`); + + const defs = createSvgElement('defs'); + svg.appendChild(defs); + + const axisLayer = createSvgElement('g'); + const barsLayer = createSvgElement('g'); + svg.appendChild(axisLayer); + svg.appendChild(barsLayer); + + this._renderXAxis(axisLayer, width, height, margins, xAxisScale.domain, xAxisScale.ticks); + this._renderYAxis(axisLayer, groups, numericYAxis, width, height, plotLayout.margins, yPositionForGroup, yValues); + this._renderOriginLine(axisLayer, plotLayout.margins, height, xAxisScale.domain, innerWidth); + + this._renderedBars = []; + const legendColorMap = new Map(); + const scaleX = (value: number) => { + const [min, max] = xAxisScale.domain; + const safeSpan = max - min || 1; + const rangeStart = this._isRTL ? width - margins.right : margins.left; + const rangeEnd = this._isRTL ? margins.left : width - margins.right; + return rangeStart + ((value - min) / safeSpan) * (rangeEnd - rangeStart); + }; + + groups.forEach((group, groupIndex) => { + const yPosition = yPositionForGroup(group, groupIndex); + const resolvedBarHeight = plotLayout.barHeight; + let positiveTotal = 0; + let negativeTotal = 0; + const positivePointCount = group.points.filter(point => point.x >= 0).length; + const negativePointCount = group.points.length - positivePointCount; + let positivePointIndex = 0; + let negativePointIndex = 0; + + group.points.forEach((point, pointIndex) => { + const color = this._getPointColor(point, pointIndex); + const gradientId = this._appendGradient(defs, groupIndex, pointIndex, point, color); + + if (point.legend && !legendColorMap.has(point.legend)) { + legendColorMap.set(point.legend, color); + } + + const startValue = point.x >= 0 ? positiveTotal : negativeTotal; + const endValue = startValue + point.x; + if (point.x >= 0) { + positiveTotal = endValue; + positivePointIndex += 1; + } else { + negativeTotal = endValue; + negativePointIndex += 1; + } + + const xStart = scaleX(startValue); + const xEnd = scaleX(endValue); + const rawWidth = Math.max(Math.abs(xEnd - xStart), 1); + const shouldApplyGap = + rawWidth > STACKED_SEGMENT_GAP && + ((point.x >= 0 && positivePointIndex !== positivePointCount) || + (point.x < 0 && negativePointIndex !== negativePointCount)); + const barWidth = rawWidth - (shouldApplyGap ? STACKED_SEGMENT_GAP : 0); + const rectX = Math.min(xStart, xEnd); + const rect = createSvgElement('rect'); + rect.setAttribute('class', 'bar'); + rect.setAttribute('x', `${rectX}`); + rect.setAttribute('y', `${yPosition - resolvedBarHeight / 2}`); + rect.setAttribute('width', `${barWidth}`); + rect.setAttribute('height', `${resolvedBarHeight}`); + rect.setAttribute('fill', gradientId ? `url(#${gradientId})` : color); + rect.setAttribute('role', 'img'); + rect.setAttribute('tabindex', '0'); + rect.setAttribute('aria-label', this._getAriaLabel(point)); + rect.setAttribute('rx', `${this.roundCorners ? 3 : 0}`); + + rect.addEventListener('mouseover', event => this._showTooltip(point, color, event, rect)); + rect.addEventListener('mouseout', () => this._clearTooltipState()); + rect.addEventListener('focus', event => this._showTooltip(point, color, event, rect)); + rect.addEventListener('blur', () => this._clearTooltipState()); + rect.addEventListener('click', () => point.onClick?.()); + + this._renderedBars.push({ legend: point.legend, element: rect }); + barsLayer.appendChild(rect); + }); + + const totalValue = positiveTotal + negativeTotal; + if (!this.hideLabels && resolvedBarHeight >= 16) { + const label = createSvgElement('text'); + const anchorValue = totalValue >= 0 ? positiveTotal : negativeTotal; + const x = scaleX(anchorValue); + label.setAttribute('class', 'bar-label'); + label.setAttribute('x', `${x}`); + label.setAttribute('y', `${yPosition}`); + label.setAttribute('dominant-baseline', 'central'); + label.setAttribute( + 'text-anchor', + this._isRTL ? (totalValue >= 0 ? 'end' : 'start') : totalValue >= 0 ? 'start' : 'end', + ); + label.setAttribute( + 'transform', + `translate(${totalValue >= 0 ? (this._isRTL ? -4 : 4) : this._isRTL ? 4 : -4}, 0)`, + ); + label.textContent = formatCompactNumber(totalValue, this.culture); + barsLayer.appendChild(label); + } + }); + + this.uniqueLegends = Array.from(legendColorMap.entries()).map(([legend, color]) => ({ legend, color })); + this.chartContainer.appendChild(svg); + this._applyLegendState(); + } + + private _initializeFromAttributes() { + const setString = (name: string, assign: (value: string) => void) => { + const value = this.getAttribute(name); + if (value !== null) { + assign(value); + } + }; + + const setBoolean = (name: string, assign: (value: boolean) => void) => { + const value = this.getAttribute(name); + if (value !== null) { + assign(booleanStringConverter.fromView(value)); + } + }; + + setString('data', value => { + this.data = jsonConverter.fromView(value) as HorizontalBarChartWithAxisDataPoint[]; + }); + setString('chart-title', value => { + this.chartTitle = value; + }); + setString('width', value => { + this.width = value; + }); + setString('legend-list-label', value => { + this.legendListLabel = value; + }); + setString('bar-height', value => { + this.barHeight = value; + }); + setString('height', value => { + this.height = value; + }); + setString('x-axis-tick-count', value => { + this.xAxisTickCount = value; + }); + setString('y-axis-tick-count', value => { + this.yAxisTickCount = value; + }); + setString('y-axis-padding', value => { + this.yAxisPadding = value; + }); + setString('x-min-value', value => { + this.xMinValue = value; + }); + setString('x-max-value', value => { + this.xMaxValue = value; + }); + setString('y-min-value', value => { + this.yMinValue = value; + }); + setString('y-max-value', value => { + this.yMaxValue = value; + }); + setString('y-axis-category-order', value => { + this.yAxisCategoryOrder = value as AxisCategoryOrder; + }); + setString('culture', value => { + this.culture = value; + }); + + setBoolean('hide-legends', value => { + this.hideLegends = value; + }); + setBoolean('hide-tooltip', value => { + this.hideTooltip = value; + }); + setBoolean('hide-labels', value => { + this.hideLabels = value; + }); + setBoolean('show-y-axis-labels', value => { + this.showYAxisLabels = value; + }); + setBoolean('show-y-axis-labels-tooltip', value => { + this.showYAxisLabelsTooltip = value; + }); + setBoolean('use-single-color', value => { + this.useSingleColor = value; + }); + setBoolean('enable-gradient', value => { + this.enableGradient = value; + }); + setBoolean('round-corners', value => { + this.roundCorners = value; + }); + setBoolean('allow-multiple-legend-selection', value => { + this.allowMultipleLegendSelection = value; + }); + } + + private _clearChart() { + if (!this.chartContainer) { + return; + } + this._renderedBars = []; + while (this.chartContainer.firstChild) { + this.chartContainer.removeChild(this.chartContainer.firstChild); + } + } + + private _validateData(data: HorizontalBarChartWithAxisDataPoint[]) { + if (!Array.isArray(data)) { + throw new TypeError('Invalid data: Expected an array.'); + } + + data.forEach((point, index) => { + if (point === null || typeof point !== 'object' || Array.isArray(point)) { + throw new TypeError(`Invalid data[${index}]: Expected an object.`); + } + if (typeof point.x !== 'number') { + throw new TypeError(`Invalid data[${index}].x: Expected a number.`); + } + if (typeof point.y !== 'string' && typeof point.y !== 'number') { + throw new TypeError(`Invalid data[${index}].y: Expected a string or number.`); + } + }); + } + + private _getChartAriaLabel() { + return ( + (this.chartTitle ? `${this.chartTitle}. ` : '') + `Horizontal bar chart with axis with ${this.data.length} bars.` + ); + } + + private _getGroupedSeries(): GroupedSeries[] { + const groups = new Map(); + this.data.forEach(point => { + const key = String(point.y); + const existing = groups.get(key); + if (existing) { + existing.points.push(point); + } else { + groups.set(key, { key, rawY: point.y, points: [point] }); + } + }); + + const groupList = Array.from(groups.values()); + const numericYAxis = typeof groupList[0]?.rawY === 'number'; + if (numericYAxis) { + return groupList.sort((left, right) => Number(right.rawY) - Number(left.rawY)); + } + + return this._sortCategoricalGroups(groupList); + } + + private _sortCategoricalGroups(groups: GroupedSeries[]): GroupedSeries[] { + const order = this.yAxisCategoryOrder || 'default'; + if (order === 'default' || order === 'data') { + const reversed = [...this.data].reverse(); + const orderedKeys = new Set(reversed.map(point => String(point.y))); + return Array.from(orderedKeys) + .map(key => groups.find(group => group.key === key)!) + .filter(Boolean); + } + + const aggregate = (group: GroupedSeries) => { + const values = group.points.map(point => point.x); + switch (order) { + case 'category ascending': + case 'category descending': + return 0; + case 'total ascending': + case 'total descending': + case 'sum ascending': + case 'sum descending': + return values.reduce((sum, value) => sum + value, 0); + case 'min ascending': + case 'min descending': + return Math.min(...values); + case 'max ascending': + case 'max descending': + return Math.max(...values); + case 'mean ascending': + case 'mean descending': + return values.reduce((sum, value) => sum + value, 0) / values.length; + case 'median ascending': + case 'median descending': + return getMedian(values); + default: + return 0; + } + }; + + const sorted = [...groups]; + if (order.startsWith('category')) { + sorted.sort((left, right) => left.key.localeCompare(right.key)); + if (order.endsWith('descending')) { + sorted.reverse(); + } + return sorted; + } + + sorted.sort((left, right) => aggregate(left) - aggregate(right)); + if (order.endsWith('descending')) { + sorted.reverse(); + } + return sorted; + } + + private _getChartHeight(groupCount: number, numericYAxis: boolean, yValues: number[]) { + if (this.height !== undefined) { + return Math.max(toNumber(this.height, DEFAULT_HEIGHT), 160); + } + + if (numericYAxis && yValues.length > 1) { + return DEFAULT_HEIGHT; + } + + return Math.max(DEFAULT_HEIGHT, groupCount * 56 + 56); + } + + private _getPlotLayout( + groupCount: number, + numericYAxis: boolean, + height: number, + baseMargins: { top: number; right: number; bottom: number; left: number }, + yValues: number[], + ): PlotLayout { + const padding = clamp(toNumber(this.yAxisPadding, DEFAULT_Y_AXIS_PADDING), 0, 0.99); + const totalHeight = height - (baseMargins.top + MIN_DOMAIN_MARGIN) - (baseMargins.bottom + MIN_DOMAIN_MARGIN); + let barHeight = this.barHeight !== undefined ? Math.max(toNumber(this.barHeight, DEFAULT_BAR_HEIGHT), 1) : 0; + let domainMargin = MIN_DOMAIN_MARGIN; + + if (numericYAxis) { + if (barHeight === 0) { + barHeight = this._calculateAppropriateNumericBarHeight(yValues, totalHeight, padding); + } + + barHeight = Math.max(barHeight, 1); + domainMargin += barHeight / 2; + } else { + const barGapRate = padding / (1 - padding); + const totalBands = groupCount + Math.max(groupCount - 1, 0) * barGapRate; + if (barHeight === 0) { + barHeight = totalHeight / Math.max(totalBands, 1); + } + + const requiredHeight = totalBands * barHeight; + if (totalHeight >= requiredHeight) { + domainMargin = MIN_DOMAIN_MARGIN + (totalHeight - requiredHeight) / 2; + } + } + + const margins = { + ...baseMargins, + top: baseMargins.top + domainMargin, + bottom: baseMargins.bottom + domainMargin, + }; + + return { + barHeight, + margins, + innerHeight: height - margins.top - margins.bottom, + }; + } + + private _getYAxisLabelWidth(groups: GroupedSeries[], numericYAxis: boolean) { + if (numericYAxis) { + return 40; + } + + const longest = groups.reduce((maxLength, group) => Math.max(maxLength, String(group.rawY).length), 0); + const rawWidth = longest * 7 + 28; + return clamp(rawWidth, 40, this.showYAxisLabels ? 240 : 160); + } + + private _getXScaleInfo(groups: GroupedSeries[]) { + let min = 0; + let max = 0; + + groups.forEach(group => { + const positive = group.points.filter(point => point.x >= 0).reduce((sum, point) => sum + point.x, 0); + const negative = group.points.filter(point => point.x < 0).reduce((sum, point) => sum + point.x, 0); + max = Math.max(max, positive); + min = Math.min(min, negative); + }); + + min = Math.min(min, toOptionalNumber(this.xMinValue) ?? min); + max = Math.max(max, toOptionalNumber(this.xMaxValue) ?? max); + + if (min === max) { + if (max === 0) { + max = 1; + } else { + min = Math.min(0, min); + } + } + + return getNiceDomainAndTicks(min, max, toNumber(this.xAxisTickCount, DEFAULT_X_TICK_COUNT)); + } + + private _getNumericYDomain(yValues: number[]) { + const yMin = Math.min(...yValues); + const yMax = Math.max(...yValues); + const domainMin = Math.min(yMin, toOptionalNumber(this.yMinValue) ?? 0); + const domainMax = Math.max(yMax, toOptionalNumber(this.yMaxValue) ?? 0); + return [domainMin, domainMax] as [number, number]; + } + + private _createYPositioner( + groups: GroupedSeries[], + numericYAxis: boolean, + margins: { top: number; bottom: number }, + height: number, + innerHeight: number, + yValues: number[], + ) { + if (!numericYAxis) { + const padding = clamp(toNumber(this.yAxisPadding, DEFAULT_Y_AXIS_PADDING), 0, 0.95); + const step = innerHeight / Math.max(groups.length + padding, 1); + const barBand = step * (1 - padding); + const startOffset = padding * step; + return (_group: GroupedSeries, index: number) => margins.top + startOffset + index * step + barBand / 2; + } + + const [min, max] = this._getNumericYDomain(yValues); + const safeSpan = max - min || 1; + + return (group: GroupedSeries) => { + const ratio = (Number(group.rawY) - min) / safeSpan; + return height - margins.bottom - ratio * innerHeight; + }; + } + + private _calculateAppropriateNumericBarHeight(yValues: number[], totalHeight: number, innerPadding: number) { + const result = getClosestPairDiffAndRange(yValues); + if (!result || result[1] === 0) { + return 16; + } + + const [closestPairDiff, rawRange] = result; + const yMax = Math.max(...yValues); + const range = Math.max(rawRange, yMax); + return Math.max( + Math.floor((totalHeight * closestPairDiff * (1 - innerPadding)) / (range + closestPairDiff * (1 - innerPadding))), + 1, + ); + } + + private _getPointColor(point: HorizontalBarChartWithAxisDataPoint, index: number) { + if (this.useSingleColor) { + const singleColorPoint = this.data.find( + candidate => typeof candidate.color === 'string' && candidate.color.length > 0, + ); + return singleColorPoint?.color ? getColorFromToken(singleColorPoint.color) : getColorFromToken('qualitative.2'); + } + + if (point.color) { + return getColorFromToken(point.color); + } + + return getNextColor(index, 0); + } + + private _applyHostDimensions() { + if (this.width === undefined || this.width === null || this.width === '') { + this.style.removeProperty('width'); + } else { + this.style.width = this._toCssLength(this.width); + } + + if (this.height === undefined || this.height === null || this.height === '') { + this.style.removeProperty('height'); + } else { + this.style.height = this._toCssLength(this.height); + } + } + + private _toCssLength(value: number | string) { + return typeof value === 'number' || /^\d+(\.\d+)?$/.test(value) ? `${value}px` : value; + } + + private _appendGradient( + defs: SVGDefsElement, + groupIndex: number, + pointIndex: number, + point: HorizontalBarChartWithAxisDataPoint, + color: string, + ) { + if (!this.enableGradient && !point.gradient) { + return undefined; + } + + const gradientId = `hbcwa-gradient-${groupIndex}-${pointIndex}`; + const gradient = createSvgElement('linearGradient'); + gradient.setAttribute('id', gradientId); + gradient.setAttribute('x1', this._isRTL ? '100%' : '0%'); + gradient.setAttribute('x2', this._isRTL ? '0%' : '100%'); + gradient.setAttribute('y1', '0%'); + gradient.setAttribute('y2', '0%'); + + const [from, to] = point.gradient ?? [lightenColor(color, 0.35), color]; + const start = createSvgElement('stop'); + start.setAttribute('offset', '0%'); + start.setAttribute('stop-color', from); + gradient.appendChild(start); + + const end = createSvgElement('stop'); + end.setAttribute('offset', '100%'); + end.setAttribute('stop-color', to); + gradient.appendChild(end); + + defs.appendChild(gradient); + return gradientId; + } + + private _renderXAxis( + axisLayer: SVGGElement, + width: number, + height: number, + margins: { left: number; right: number; bottom: number }, + domain: [number, number], + ticks: number[], + ) { + const axisY = height - margins.bottom; + const min = domain[0]; + const max = domain[1]; + const rangeStart = this._isRTL ? width - margins.right : margins.left; + const rangeEnd = this._isRTL ? margins.left : width - margins.right; + const span = max - min || 1; + const toX = (value: number) => rangeStart + ((value - min) / span) * (rangeEnd - rangeStart); + + ticks.forEach(tick => { + const x = toX(tick); + const tickLine = createSvgElement('line'); + tickLine.setAttribute('class', 'axis-tick-line'); + tickLine.setAttribute('x1', `${x}`); + tickLine.setAttribute('x2', `${x}`); + tickLine.setAttribute('y1', `${axisY}`); + tickLine.setAttribute('y2', `${20}`); + axisLayer.appendChild(tickLine); + + const text = createSvgElement('text'); + text.setAttribute('class', 'axis-text'); + text.setAttribute('x', `${x}`); + text.setAttribute('y', `${axisY + 18}`); + text.setAttribute('text-anchor', 'middle'); + text.textContent = formatAxisNumber(tick, this.culture); + axisLayer.appendChild(text); + }); + } + + private _renderYAxis( + axisLayer: SVGGElement, + groups: GroupedSeries[], + numericYAxis: boolean, + width: number, + height: number, + margins: { top: number; left: number; bottom: number; right: number }, + yPositionForGroup: (group: GroupedSeries, index: number) => number, + yValues: number[], + ) { + const axisX = this._isRTL ? width - margins.right : margins.left; + if (numericYAxis) { + const [min, max] = this._getNumericYDomain(yValues); + const yAxisScale = getNiceDomainAndTicks(min, max, toNumber(this.yAxisTickCount, DEFAULT_Y_TICK_COUNT)); + const safeSpan = yAxisScale.domain[1] - yAxisScale.domain[0] || 1; + yAxisScale.ticks.forEach(tick => { + const ratio = (tick - yAxisScale.domain[0]) / safeSpan; + const y = height - margins.bottom - ratio * (height - margins.top - margins.bottom); + this._appendYAxisTick(axisLayer, axisX, y, formatCompactNumber(tick, this.culture).toLowerCase()); + }); + return; + } + + groups.forEach((group, index) => { + const y = yPositionForGroup(group, index); + const fullLabel = String(group.rawY); + const label = this.showYAxisLabels ? fullLabel : truncateText(fullLabel, 18); + this._appendYAxisTick(axisLayer, axisX, y, label, this.showYAxisLabelsTooltip ? fullLabel : undefined); + }); + } + + private _appendYAxisTick(axisLayer: SVGGElement, axisX: number, y: number, label: string, tooltipText?: string) { + const tickLine = createSvgElement('line'); + tickLine.setAttribute('class', 'axis-tick-line'); + tickLine.setAttribute('x1', `${axisX}`); + tickLine.setAttribute('x2', `${axisX + 6}`); + tickLine.setAttribute('y1', `${y}`); + tickLine.setAttribute('y2', `${y}`); + axisLayer.appendChild(tickLine); + + const text = createSvgElement('text'); + text.setAttribute('class', 'y-axis-text'); + text.setAttribute('x', `${axisX + (this._isRTL ? 12 : -12)}`); + text.setAttribute('y', `${y}`); + text.setAttribute('dominant-baseline', 'central'); + text.setAttribute('text-anchor', 'end'); + text.textContent = label; + + if (tooltipText) { + const title = createSvgElement('title'); + title.textContent = tooltipText; + text.appendChild(title); + } + axisLayer.appendChild(text); + } + + private _renderOriginLine( + axisLayer: SVGGElement, + margins: { top: number; right: number; left: number; bottom: number }, + height: number, + domain: [number, number], + innerWidth: number, + ) { + if (!(domain[0] < 0 && domain[1] > 0)) { + return; + } + + const span = domain[1] - domain[0] || 1; + const rangeStart = this._isRTL + ? this.getBoundingClientRect().width - margins.right || margins.left + innerWidth + : margins.left; + const rangeEnd = this._isRTL + ? margins.left + : this.getBoundingClientRect().width - margins.right || margins.left + innerWidth; + const originX = rangeStart + ((0 - domain[0]) / span) * (rangeEnd - rangeStart); + const line = createSvgElement('line'); + line.setAttribute('class', 'origin-line'); + line.setAttribute('x1', `${originX}`); + line.setAttribute('x2', `${originX}`); + line.setAttribute('y1', `${margins.top}`); + line.setAttribute('y2', `${height - margins.bottom}`); + axisLayer.appendChild(line); + } + + private _showTooltip( + point: HorizontalBarChartWithAxisDataPoint, + color: string, + event: MouseEvent | FocusEvent, + target: SVGRectElement, + ) { + const hostRect = this.getBoundingClientRect(); + const targetRect = target.getBoundingClientRect(); + const xReference = 'clientX' in event ? event.clientX : targetRect.left + targetRect.width / 2; + const xPos = this._isRTL ? hostRect.right - xReference : xReference - hostRect.left; + const yPos = ('clientY' in event ? event.clientY : targetRect.top) - hostRect.top - 44; + this.tooltipProps = { + isVisible: true, + legend: point.legend || '', + xLabel: X_AXIS_LABEL, + xValue: point.xAxisCalloutData || formatAxisNumber(point.x, this.culture), + yLabel: Y_AXIS_LABEL, + yValue: point.yAxisCalloutData || String(point.y), + color, + xPos: Math.max(0, xPos), + yPos: Math.max(0, yPos), + }; + } + + private _clearTooltipState() { + this.tooltipProps = { + isVisible: false, + legend: '', + xLabel: X_AXIS_LABEL, + xValue: '', + yLabel: Y_AXIS_LABEL, + yValue: '', + color: '', + xPos: 0, + yPos: 0, + }; + } + + private _getAriaLabel(point: HorizontalBarChartWithAxisDataPoint) { + const xValue = point.xAxisCalloutData || point.x; + const legend = point.legend; + const yValue = point.yAxisCalloutData || point.y; + return point.callOutAccessibilityData?.ariaLabel || `${yValue}. ${legend ? `${legend}, ` : ''}${xValue}.`; + } + + private _getHighlightedLegends() { + if (Array.isArray(this.selectedLegends) && this.selectedLegends.length > 0) { + return this.selectedLegends; + } + return this.activeLegend ? [this.activeLegend] : []; + } + + private _applyLegendState() { + const highlighted = this._getHighlightedLegends(); + if (!Array.isArray(this._renderedBars)) { + return; + } + + this._renderedBars.forEach(({ legend, element }) => { + const shouldHighlight = highlighted.length === 0 || (legend ? highlighted.includes(legend) : true); + element.classList.toggle('inactive', !shouldHighlight); + element.setAttribute('opacity', shouldHighlight ? '1' : '0.1'); + element.setAttribute('tabindex', shouldHighlight ? '0' : '-1'); + }); + } +} diff --git a/src/Charts.Scripts/src/horizontal-bar-chart-with-axis/index.ts b/src/Charts.Scripts/src/horizontal-bar-chart-with-axis/index.ts new file mode 100644 index 0000000000..7ba55e5665 --- /dev/null +++ b/src/Charts.Scripts/src/horizontal-bar-chart-with-axis/index.ts @@ -0,0 +1,4 @@ +export { definition as HorizontalBarChartWithAxisDefinition } from './horizontal-bar-chart-with-axis.definition.js'; +export { HorizontalBarChartWithAxis } from './horizontal-bar-chart-with-axis.js'; +export { styles as HorizontalBarChartWithAxisStyles } from './horizontal-bar-chart-with-axis.styles.js'; +export { template as HorizontalBarChartWithAxisTemplate } from './horizontal-bar-chart-with-axis.template.js'; diff --git a/src/Charts.Scripts/src/horizontal-bar-chart/define.ts b/src/Charts.Scripts/src/horizontal-bar-chart/define.ts new file mode 100644 index 0000000000..2af6e03097 --- /dev/null +++ b/src/Charts.Scripts/src/horizontal-bar-chart/define.ts @@ -0,0 +1,4 @@ +import { FluentDesignSystem } from '@fluentui/web-components'; +import { definition } from './horizontal-bar-chart.definition.js'; + +definition.define(FluentDesignSystem.registry); diff --git a/src/Charts.Scripts/src/horizontal-bar-chart/horizontal-bar-chart.bench.ts b/src/Charts.Scripts/src/horizontal-bar-chart/horizontal-bar-chart.bench.ts new file mode 100644 index 0000000000..14336cd320 --- /dev/null +++ b/src/Charts.Scripts/src/horizontal-bar-chart/horizontal-bar-chart.bench.ts @@ -0,0 +1,12 @@ +import { FluentDesignSystem } from '@fluentui/web-components'; +import { definition } from './horizontal-bar-chart.definition.js'; + +definition.define(FluentDesignSystem.registry); + +const itemRenderer = () => { + const horizontalbarchart = document.createElement('fluent-horizontal-bar-chart'); + return horizontalbarchart; +}; + +export default itemRenderer; +export { tests } from '../utils/benchmark-wrapper.js'; diff --git a/src/Charts.Scripts/src/horizontal-bar-chart/horizontal-bar-chart.definition.ts b/src/Charts.Scripts/src/horizontal-bar-chart/horizontal-bar-chart.definition.ts new file mode 100644 index 0000000000..580141fbec --- /dev/null +++ b/src/Charts.Scripts/src/horizontal-bar-chart/horizontal-bar-chart.definition.ts @@ -0,0 +1,18 @@ +import { FluentDesignSystem } from '@fluentui/web-components'; +import { HorizontalBarChart } from './horizontal-bar-chart.js'; +import { styles } from './horizontal-bar-chart.styles.js'; +import { template } from './horizontal-bar-chart.template.js'; + +/** + * @public + * @remarks + * HTML Element: `` + */ +export const definition = HorizontalBarChart.compose({ + name: `${FluentDesignSystem.prefix}-horizontal-bar-chart`, + template, + styles, + shadowOptions: { + delegatesFocus: true, + }, +}); diff --git a/src/Charts.Scripts/src/horizontal-bar-chart/horizontal-bar-chart.options.ts b/src/Charts.Scripts/src/horizontal-bar-chart/horizontal-bar-chart.options.ts new file mode 100644 index 0000000000..13b6150cc6 --- /dev/null +++ b/src/Charts.Scripts/src/horizontal-bar-chart/horizontal-bar-chart.options.ts @@ -0,0 +1,50 @@ +export enum Variant { + PartToWhole = 'part-to-whole', + AbsoluteScale = 'absolute-scale', + SingleBar = 'single-bar', +} + +export interface ChartDataPoint { + /** + * Legend text for the datapoint in the chart + */ + legend: string; + + /** + * data the datapoint in the chart + */ + data: number; + + /** + * total length of bar + */ + total?: number; + + /** + * onClick action for each datapoint in the chart + */ + onClick?: VoidFunction; + + /** + * Color for the legend in the chart. If not provided, it will fallback on the default color palette. + */ + color?: string; + + gradient?: [string, string]; +} + +export interface ChartProps { + /** + * title for the data series + */ + chartSeriesTitle?: string; + + /** + * data for the points in the chart + */ + chartData: ChartDataPoint[]; + + benchmarkData?: number; + + chartDataText?: string; +} diff --git a/src/Charts.Scripts/src/horizontal-bar-chart/horizontal-bar-chart.styles.ts b/src/Charts.Scripts/src/horizontal-bar-chart/horizontal-bar-chart.styles.ts new file mode 100644 index 0000000000..32b8d701c9 --- /dev/null +++ b/src/Charts.Scripts/src/horizontal-bar-chart/horizontal-bar-chart.styles.ts @@ -0,0 +1,185 @@ +import type { ElementStyles } from '@microsoft/fast-element'; +import { css } from '@microsoft/fast-element'; +import { + colorNeutralBackground1, + colorNeutralForeground1, + colorNeutralStrokeAccessible, + display, + forcedColorsStylesheetBehavior, + shadow4, + spacingHorizontalL, + spacingHorizontalNone, + spacingHorizontalS, + spacingHorizontalSNudge, + spacingVerticalL, + spacingVerticalM, + spacingVerticalMNudge, + spacingVerticalNone, + spacingVerticalS, + spacingVerticalXS, + strokeWidthThick, + strokeWidthThickest, + strokeWidthThin, + typographyBody1StrongStyles, + typographyBody1Styles, + typographyCaption1Styles, + typographyTitle2Styles, +} from '@fluentui/web-components'; + +/** + * Styles for the HorizontalBarChart component. + * + * @public + */ +export const styles: ElementStyles = css` + ${display('inline-block')} + + :host { + position: relative; + width: 100%; + } + .tooltip { + ${typographyCaption1Styles} + position: absolute; + z-index: 999; + display: grid; + overflow: hidden; + padding: ${spacingVerticalMNudge} ${spacingHorizontalL}; + backgroundcolor: ${colorNeutralBackground1}; + background-blend-mode: normal, luminosity; + text-align: center; + background: ${colorNeutralBackground1}; + box-shadow: ${shadow4}; + border: ${strokeWidthThick}; + pointer-events: none; + } + .tooltip-line { + padding-inline-start: ${spacingHorizontalS}; + height: 50px; + border-inline-start: ${strokeWidthThickest} solid; + } + .tooltip-legend-text { + ${typographyCaption1Styles} + color: ${colorNeutralForeground1}; + text-align: start; + } + .tooltip-data-y { + ${typographyTitle2Styles} + text-align: start; + } + .bar { + opacity: 1; + } + .bar.inactive { + opacity: 0.1; + } + .bar:focus { + outline: none; + stroke-width: ${strokeWidthThick}; + stroke: black; + } + .svg-chart { + display: block; + overflow: visible; + } + .chart-title { + ${typographyBody1StrongStyles} + color: ${colorNeutralForeground1}; + margin-bottom: ${spacingVerticalS}; + } + .bar-title { + ${typographyBody1Styles} + color: ${colorNeutralForeground1}; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + display: block; + } + .legend-container { + display: flex; + flex-direction: row; + flex-wrap: wrap; + padding-top: ${spacingVerticalL}; + width: 100%; + align-items: center; + margin: -${spacingVerticalS} ${spacingHorizontalNone} ${spacingVerticalNone} -${spacingHorizontalS}; + } + .legend { + display: flex; + align-items: center; + cursor: pointer; + border: none; + padding: ${spacingHorizontalS}; + background: none; + text-transform: capitalize; + } + .legend-rect { + width: 12px; + height: 12px; + margin-inline-end: ${spacingHorizontalS}; + border: ${strokeWidthThin} solid; + } + .legend-text { + ${typographyCaption1Styles} + color: ${colorNeutralForeground1}; + } + .legend.inactive .legend-rect { + background-color: transparent !important; + } + .legend.inactive .legend-text { + opacity: 0.67; + } + .bar-label { + ${typographyBody1StrongStyles} + fill: ${colorNeutralForeground1}; + } + .bar-title-div { + width: 100%; + display: flex; + justify-content: space-between; + } + .ratio-numerator { + ${typographyBody1StrongStyles} + color: ${colorNeutralForeground1}; + } + .ratio-denominator { + ${typographyBody1StrongStyles} + color: ${colorNeutralForeground1}; + font-weight: bold; + } + .benchmark-container { + position: relative; + height: 7px; + margin-top: -3px; + } + .triangle { + width: 0; + height: 0; + border-left: ${strokeWidthThickest} solid transparent; + border-right: ${strokeWidthThickest} solid transparent; + border-bottom: 7px solid; + border-bottom-color: ${colorNeutralStrokeAccessible}; + margin-bottom: ${spacingVerticalXS}; + position: absolute; + } + .chart-data-text { + ${typographyBody1StrongStyles} + color: ${colorNeutralForeground1}; + } +`.withBehaviors( + forcedColorsStylesheetBehavior(css` + .legend-rect, + .tooltip-line, + .triangle { + forced-color-adjust: none; + } + .tooltip-legend-text, + .tooltip-content-y { + forced-color-adjust: auto; + color: CanvasText; + } + .bar-label { + fill: CanvasText !important; + } + `), +); diff --git a/src/Charts.Scripts/src/horizontal-bar-chart/horizontal-bar-chart.template.ts b/src/Charts.Scripts/src/horizontal-bar-chart/horizontal-bar-chart.template.ts new file mode 100644 index 0000000000..83040ed4fd --- /dev/null +++ b/src/Charts.Scripts/src/horizontal-bar-chart/horizontal-bar-chart.template.ts @@ -0,0 +1,67 @@ +import { ElementViewTemplate, html, ref, repeat, when } from '@microsoft/fast-element'; +import type { HorizontalBarChart } from './horizontal-bar-chart.js'; +import type { ChartDataPoint } from './horizontal-bar-chart.options.js'; + +/** + * Generates a template for the HorizontalBarChart component. + * + * @public + */ +export function horizontalbarchartTemplate(): ElementViewTemplate { + return html` + + ${when(x => !!x.chartTitle, html`${x => x.chartTitle}`)} + + ${when( + x => !x.hideLegends, + html` + + ${repeat( + x => x.uniqueLegends, + html` c.parent.handleLegendMouseoverAndFocus(x.legend)}" + @mouseout="${(x, c) => c.parent.handleLegendMouseoutAndBlur()}" + @focus="${(x, c) => c.parent.handleLegendMouseoverAndFocus(x.legend)}" + @blur="${(x, c) => c.parent.handleLegendMouseoutAndBlur()}" + @click="${(x, c) => c.parent.handleLegendClick(x.legend)}" + > + + ${x => x.legend} + `, + )} + + `, + )} + ${when( + x => !x.hideTooltip && x.tooltipProps.isVisible, + html` + + + ${x => x.tooltipProps.legend} + + ${x => x.tooltipProps.yValue} + + + + `, + )} + + `; +} + +/** + * @internal + */ +export const template: ElementViewTemplate = horizontalbarchartTemplate(); diff --git a/src/Charts.Scripts/src/horizontal-bar-chart/horizontal-bar-chart.ts b/src/Charts.Scripts/src/horizontal-bar-chart/horizontal-bar-chart.ts new file mode 100644 index 0000000000..c715797bbd --- /dev/null +++ b/src/Charts.Scripts/src/horizontal-bar-chart/horizontal-bar-chart.ts @@ -0,0 +1,638 @@ +import { attr, FASTElement, observable } from '@microsoft/fast-element'; +import { create as d3Create, select as d3Select } from 'd3-selection'; +import { + getRTL, + jsonConverter, + booleanStringConverter, + SVG_NAMESPACE_URI, + validateChartPropsArray, +} from '../utils/chart-helpers.js'; +import type { ChartDataPoint, ChartProps } from './horizontal-bar-chart.options.js'; +import { Variant } from './horizontal-bar-chart.options.js'; + +/** + * A Horizontal Bar Chart HTML Element. + * + * @public + */ +export class HorizontalBarChart extends FASTElement { + @attr + public width?: number | string; + + @attr + public height?: number | string; + + @attr + public variant?: Variant; + + @attr({ converter: jsonConverter }) + public data!: ChartProps[]; + + @attr({ attribute: 'hide-ratio', converter: booleanStringConverter }) + public hideRatio: boolean = false; + + @attr({ attribute: 'hide-labels', converter: booleanStringConverter }) + public hideLabels: boolean = false; + + @attr({ attribute: 'round-corners', mode: 'boolean' }) + public roundCorners: boolean = false; + + @attr({ attribute: 'chart-data-mode' }) + public chartDataMode: 'default' | 'fraction' | 'percentage' = 'default'; + + @attr({ attribute: 'hide-legends', converter: booleanStringConverter }) + public hideLegends: boolean = false; + + @attr({ attribute: 'hide-tooltip', converter: booleanStringConverter }) + public hideTooltip: boolean = false; + + @attr({ attribute: 'legend-list-label' }) + public legendListLabel?: string; + + @attr({ attribute: 'chart-title' }) + public chartTitle?: string; + + @observable + public uniqueLegends: ChartDataPoint[] = []; + + @observable + public activeLegend: string = ''; + protected activeLegendChanged = (oldValue: string, newValue: string) => { + if (newValue === '') { + this._bars?.forEach(bar => bar.classList.remove('inactive')); + } else { + this._bars?.forEach(bar => { + if (bar.getAttribute('barinfo') === newValue) { + bar.classList.remove('inactive'); + } else { + bar.classList.add('inactive'); + } + }); + } + }; + + @observable + public isLegendSelected: boolean = false; + + @observable + public tooltipProps = { + isVisible: false, + legend: '', + yValue: '', + color: '', + xPos: 0, + yPos: 0, + }; + + public chartContainer!: HTMLDivElement; + public elementInternals: ElementInternals = this.attachInternals(); + + private _isRTL: boolean = false; + private _barHeight: number = 12; + private _bars: SVGRectElement[] = []; + + constructor() { + super(); + + this.elementInternals.role = 'region'; + } + + public handleLegendMouseoverAndFocus = (legendTitle: string) => { + if (this.isLegendSelected) { + return; + } + + this.activeLegend = legendTitle; + }; + + public handleLegendMouseoutAndBlur = () => { + if (this.isLegendSelected) { + return; + } + + this.activeLegend = ''; + }; + + public handleLegendClick = (legendTitle: string) => { + if (this.isLegendSelected && this.activeLegend === legendTitle) { + this.activeLegend = ''; + this.isLegendSelected = false; + } else { + this.activeLegend = legendTitle; + this.isLegendSelected = true; + } + }; + + connectedCallback() { + this._initializeFromAttributes(); + + super.connectedCallback(); + + if (!this.data) { + return; + } + + this._initializeAll(); + } + + attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null) { + super.attributeChangedCallback(name, oldValue, newValue); + + if (name === 'round-corners' && oldValue !== newValue) { + this.roundCorners = newValue !== null && newValue !== 'false'; + } + } + + protected dataChanged(_oldValue: ChartProps[], newValue: ChartProps[]) { + if (this.$fastController.isConnected && newValue) { + this._clearChart(); + this._initializeAll(); + } + } + + protected chartTitleChanged() { + if (this.$fastController.isConnected && this.data) { + this._clearChart(); + this._initializeAll(); + } + } + + protected widthChanged() { + if (this.$fastController.isConnected && this.data) { + this._clearChart(); + this._initializeAll(); + } + } + + protected heightChanged() { + if (this.$fastController.isConnected && this.data) { + this._clearChart(); + this._initializeAll(); + } + } + + protected roundCornersChanged() { + if (this.$fastController.isConnected && this.data) { + this._clearChart(); + this._initializeAll(); + } + } + + private _initializeFromAttributes() { + const setString = (name: string, assign: (value: string) => void) => { + const value = this.getAttribute(name); + if (value !== null) { + assign(value); + } + }; + + const setBoolean = (name: string, assign: (value: boolean) => void) => { + const value = this.getAttribute(name); + if (value !== null) { + assign(booleanStringConverter.fromView(value)); + } + }; + + setString('data', value => { + this.data = jsonConverter.fromView(value) as ChartProps[]; + }); + setString('width', value => { + this.width = value; + }); + setString('height', value => { + this.height = value; + }); + setString('variant', value => { + this.variant = value as Variant; + }); + setString('chart-data-mode', value => { + this.chartDataMode = value as 'default' | 'fraction' | 'percentage'; + }); + setString('legend-list-label', value => { + this.legendListLabel = value; + }); + setString('chart-title', value => { + this.chartTitle = value; + }); + + setBoolean('hide-ratio', value => { + this.hideRatio = value; + }); + setBoolean('hide-labels', value => { + this.hideLabels = value; + }); + setBoolean('round-corners', value => { + this.roundCorners = value; + }); + setBoolean('hide-legends', value => { + this.hideLegends = value; + }); + setBoolean('hide-tooltip', value => { + this.hideTooltip = value; + }); + } + + private _clearChart() { + if (this.chartContainer) { + while (this.chartContainer.firstChild) { + this.chartContainer.removeChild(this.chartContainer.firstChild); + } + } + this._bars = []; + } + + private _initializeAll() { + validateChartPropsArray(this.data, 'data'); + + this._isRTL = getRTL(this); + this.elementInternals.ariaLabel = this.chartTitle || `Horizontal bar chart with ${this.data.length} categories.`; + this._applyHostDimensions(); + + this._initializeData(); + this._renderChart(); + } + + private _applyHostDimensions() { + if (this.width === undefined || this.width === null || this.width === '') { + this.style.removeProperty('width'); + } else { + this.style.width = this._toCssLength(this.width); + } + + if (this.height === undefined || this.height === null || this.height === '') { + this.style.removeProperty('height'); + } else { + this.style.height = this._toCssLength(this.height); + } + } + + private _toCssLength(value: number | string) { + return typeof value === 'number' || /^\d+(\.\d+)?$/.test(value) ? `${value}px` : value; + } + + private _initializeData() { + if (this.variant === Variant.SingleBar) { + this._hydrateData(); + } + this._hydrateLegends(); + } + + private _renderChart() { + const chartContainerDiv = d3Select(this.chartContainer); + chartContainerDiv + .selectAll('div') + .data(this.data!) + .enter() + .append('div') + .each((d, i, nodes) => { + this._createSingleChartBars(d, i, nodes); + }); + } + + private _createSingleChartBars(singleChartData: ChartProps, index: number, nodes: any) { + const singleChartBars = this._createBarsAndLegends(singleChartData!, index); + + // create a div element. Loop through chart bars and add to the div as its children + d3Select(nodes[index]) + .attr('key', index) + .attr('id', `_MSBC_bar-${index}`) + .node()! + .appendChild(singleChartBars.node()); + } + + private _hydrateLegends() { + // Create a map to store unique legends + const uniqueLegendsMap = new Map(); + + // Iterate through all chart points and populate the map + for (const dataSeries of this.data) { + for (const point of dataSeries.chartData!) { + if ((point as any).placeholder === true) { + continue; + } + // Check if the legend is already in the map + if (!uniqueLegendsMap.has(point.legend)) { + uniqueLegendsMap.set(point.legend, { + legend: point.legend, + data: point.data, + color: point.gradient ? point.gradient[0] : point.color, + }); + } + } + } + + // Convert the map values back to an array + this.uniqueLegends = Array.from(uniqueLegendsMap.values()); + } + + private _hydrateData() { + this.data!.forEach(({ chartData }) => { + if (chartData!.length === 1) { + const pointData = chartData![0]; + const newEntry = { + legend: '', + data: Math.max(pointData.total! - pointData.data!, 0), + y: pointData.total!, + color: '#edebe9', + placeholder: true, + }; + chartData!.push(newEntry); + } + }); + } + + private _calculateBarSpacing(): number { + const svgWidth = this.getBoundingClientRect().width; + let barSpacing = 0; + const MARGIN_WIDTH_IN_PX = 3; + if (svgWidth) { + const currentBarSpacing = (MARGIN_WIDTH_IN_PX / svgWidth) * 100; + barSpacing = currentBarSpacing; + } + return barSpacing; + } + + private _createBarsAndLegends(data: ChartProps, barNo?: number) { + const _isRTL = this._isRTL; + const _computeLongestBarTotalValue = () => { + let longestBarTotalValue = 0; + this.data!.forEach(({ chartData }) => { + const barTotalValue = chartData!.reduce((acc: number, point: ChartDataPoint) => acc + (point.data ?? 0), 0); + longestBarTotalValue = Math.max(longestBarTotalValue, barTotalValue); + }); + return longestBarTotalValue; + }; + const longestBarTotalValue = _computeLongestBarTotalValue(); + const noOfBars = + data.chartData?.reduce((count: number, point: ChartDataPoint) => (count += (point.data || 0) > 0 ? 1 : 0), 0) || + 1; + const barSpacingInPercent = this._calculateBarSpacing(); + const totalMarginPercent = barSpacingInPercent * (noOfBars - 1); + // calculating starting point of each bar and it's range + const startingPoint: number[] = []; + const barTotalValue = data.chartData!.reduce((acc: number, point: ChartDataPoint) => acc + (point.data ?? 0), 0); + const total = this.variant === Variant.AbsoluteScale ? longestBarTotalValue : barTotalValue; + + let sumOfPercent = 0; + data.chartData!.map((point: ChartDataPoint, index: number) => { + const pointData = point.data ?? 0; + const currValue = (pointData / total) * 100; + let value = currValue ?? 0; + + if (value < 1 && value !== 0) { + value = 1; + } else if (value > 99 && value !== 100) { + value = 99; + } + sumOfPercent += value; + + return sumOfPercent; + }); + + // Include an imaginary placeholder bar with value equal to + // the difference between longestBarTotalValue and barTotalValue + // while calculating sumOfPercent to get correct scalingRatio for absolute-scale variant + if (this.variant === Variant.AbsoluteScale) { + let value = total === 0 ? 0 : ((total - barTotalValue) / total) * 100; + if (value < 1 && value !== 0) { + value = 1; + } else if (value > 99 && value !== 100) { + value = 99; + } + sumOfPercent += value; + } + + /** + * The %age of the space occupied by the margin needs to subtracted + * while computing the scaling ratio, since the margins are not being + * scaled down, only the data is being scaled down from a higher percentage to lower percentage + * Eg: 95% of the space is taken by the bars, 5% by the margins + * Now if the sumOfPercent is 120% -> This needs to be scaled down to 95%, not 100% + * since that's only space available to the bars + */ + + const scalingRatio = sumOfPercent !== 0 ? sumOfPercent / (100 - totalMarginPercent) : 1; + + let prevPosition = 0; + let value = 0; + + const createBars = (g: SVGGElement, point: ChartDataPoint, index: number) => { + const barHeight = 12; + const pointData = point.data ?? 0; + if (index > 0) { + prevPosition += value; + } + value = (pointData / total) * 100 ? (pointData / total) * 100 : 0; + if (value < 1 && value !== 0) { + value = 1 / scalingRatio; + } else if (value > 99 && value !== 100) { + value = 99 / scalingRatio; + } else { + value = value / scalingRatio; + } + + startingPoint.push(prevPosition); + + const gEle = d3Select(g) // 'this' refers to the current 'g' element + .attr('key', index) + .attr('role', 'img') + .attr('aria-label', pointData); + + let gradientId = ''; + if (point.gradient) { + const defs = document.createElementNS(SVG_NAMESPACE_URI, 'defs'); + gEle.node()!.appendChild(defs); + + const linearGradient = document.createElementNS(SVG_NAMESPACE_URI, 'linearGradient'); + defs.appendChild(linearGradient); + gradientId = `gradient-${barNo}-${index}`; + linearGradient.setAttribute('id', gradientId); + + const stop1 = document.createElementNS(SVG_NAMESPACE_URI, 'stop'); + linearGradient.appendChild(stop1); + stop1.setAttribute('offset', '0%'); + stop1.setAttribute('stop-color', point.gradient[0]); + + const stop2 = document.createElementNS(SVG_NAMESPACE_URI, 'stop'); + linearGradient.appendChild(stop2); + stop2.setAttribute('offset', '100%'); + stop2.setAttribute('stop-color', point.gradient[1]); + } + + const rect = gEle + .append('rect') + .attr('key', index) + .attr('id', `${barNo}-${index}`) + .attr('barinfo', `${point.legend}`) + .attr('class', 'bar') + .attr('style', point.gradient ? `fill:url(#${gradientId})` : `fill:${point.color!}`) + .attr('rx', `${this.roundCorners ? 3 : 0}`) + .attr( + 'x', + `${ + _isRTL + ? 100 - startingPoint[index] - value - barSpacingInPercent * index + : startingPoint[index] + barSpacingInPercent * index + }%`, + ) + .attr('y', 0) + .attr('width', value + '%') + .attr('height', barHeight) + .attr('tabindex', 0); + this._bars.push(rect.node()!); + }; + + const containerDiv = d3Create('div').attr( + 'style', + 'position: relative; margin-bottom: var(--spacingVerticalMNudge);', + ); + + const barTitleDiv = containerDiv.append('div').attr('class', 'bar-title-div'); + barTitleDiv + .append('div') + .append('span') + .attr('class', 'bar-title') + .text(data?.chartSeriesTitle ? data?.chartSeriesTitle : ''); + + const showChartDataText = this.variant !== Variant.AbsoluteScale; + + if (!this.hideLabels && showChartDataText) { + const numData = data!.chartData![0].data ?? 0; + // Compute total: prefer explicit total field, fall back to sum of all bar data + const explicitTotal = data!.chartData![0].total; + const sumTotal = data!.chartData!.reduce((acc: number, p: ChartDataPoint) => acc + (p.data ?? 0), 0); + const barTotal = explicitTotal !== undefined ? explicitTotal : sumTotal; + + if (data.chartDataText) { + const chartTitleRight = document.createElement('div'); + barTitleDiv.node()!.appendChild(chartTitleRight); + chartTitleRight.classList.add('chart-data-text'); + chartTitleRight.textContent = data.chartDataText; + } else if (this.chartDataMode === 'fraction') { + const ratioDiv = barTitleDiv.append('div').attr('role', 'text'); + ratioDiv.append('span').attr('class', 'ratio-numerator').text(numData); + ratioDiv.append('span').attr('class', 'ratio-denominator').text(`/${barTotal}`); + } else if (this.chartDataMode === 'percentage') { + const percentage = barTotal > 0 ? Math.round((numData / barTotal) * 100) : 0; + barTitleDiv + .append('div') + .attr('role', 'text') + .append('span') + .attr('class', 'ratio-numerator') + .text(`${percentage}%`); + } else { + // 'default' mode: show ratio when there are exactly 2 data points and hideRatio is false + const showRatio = !this.hideRatio && data!.chartData!.length === 2; + if (showRatio) { + const ratioDiv = barTitleDiv.append('div').attr('role', 'text'); + ratioDiv.append('span').attr('class', 'ratio-numerator').text(numData); + ratioDiv.append('span').attr('class', 'ratio-denominator').text(`/${barTotal}`); + } + } + } + + const svgDiv = containerDiv.append('div').attr('style', 'display: flex;'); + const svgEle = svgDiv + .append('svg') + .attr('height', 12) + .attr('width', 100 + '%') + .attr('class', 'svg-chart') + .attr( + 'aria-label', + data?.chartSeriesTitle ?? + `Series with ${data.chartData.length}${data.chartData.length > 1 ? ' stacked' : ''} bars.`, + ) + .selectAll('g') + .data(data.chartData!) + .enter() + .append('g') + .each(function (this, d, i) { + createBars(this, d, i); + }) + .on('mouseover', (event, d) => { + if (d && d.hasOwnProperty('placeholder') && (d as any).placeholder === true) { + return; + } + + const bounds = this.getBoundingClientRect(); + const centerX = window.innerWidth / 2; + const xPos = Math.max(0, Math.min(centerX, window.innerWidth)); + + this.tooltipProps = { + isVisible: true, + legend: d.legend, + yValue: `${d.data}`, + color: d.gradient ? d.gradient[0] : d.color!, + xPos: this._isRTL ? bounds.right - event.clientX : Math.min(event.clientX - bounds.left, xPos), + yPos: event.clientY - bounds.top - 40, + }; + }) + .on('mouseout', () => { + this.tooltipProps = { isVisible: false, legend: '', yValue: '', color: '', xPos: 0, yPos: 0 }; + }); + + if (this.variant === Variant.AbsoluteScale) { + const showLabel = !this.hideLabels; + const barLabel = barTotalValue; + if (showLabel) { + if (Math.round((startingPoint[startingPoint.length - 1] || 0) + value + totalMarginPercent) === 100) { + svgDiv + .append('text') + .attr('key', 'text') + .attr('style', 'margin-top: -4.5px; margin-left: 2px;') + .attr('class', 'bar-label') + .attr( + 'x', + `${ + this._isRTL + ? 100 - (startingPoint[startingPoint.length - 1] || 0) - value - totalMarginPercent + : (startingPoint[startingPoint.length - 1] || 0) + value + totalMarginPercent + }%`, + ) + .attr('textAnchor', 'start') + .attr('y', this._barHeight / 2 + 6) + .attr('dominantBaseline', 'central') + .attr('transform', `translate(${this._isRTL ? -4 : 4})`) + .attr('aria-label', `Total: ${barLabel}`) + .attr('role', 'img') + .text(barLabel); + } else { + svgEle + .append('text') + .attr('key', 'text') + .attr('class', 'bar-label') + .attr( + 'x', + `${ + this._isRTL + ? 100 - (startingPoint[startingPoint.length - 1] || 0) - value - totalMarginPercent + : (startingPoint[startingPoint.length - 1] || 0) + value + totalMarginPercent + }%`, + ) + .attr('textAnchor', 'start') + .attr('y', this._barHeight / 2 + 6) + .attr('dominantBaseline', 'central') + .attr('transform', `translate(${this._isRTL ? -4 : 4})`) + .attr('aria-label', `Total: ${barLabel}`) + .attr('role', 'img') + .text(barLabel); + } + } + } + + if (data.benchmarkData) { + const benchmarkContainer = document.createElement('div'); + containerDiv.node()!.appendChild(benchmarkContainer); + benchmarkContainer.classList.add('benchmark-container'); + + const triangle = document.createElement('div'); + benchmarkContainer.appendChild(triangle); + triangle.classList.add('triangle'); + + const benchmarkRatio = (data.benchmarkData / total) * 100; + triangle.style['insetInlineStart'] = `calc(${benchmarkRatio}% - 4px)`; + } + + return containerDiv; + } +} diff --git a/src/Charts.Scripts/src/horizontal-bar-chart/index.ts b/src/Charts.Scripts/src/horizontal-bar-chart/index.ts new file mode 100644 index 0000000000..0695516e7d --- /dev/null +++ b/src/Charts.Scripts/src/horizontal-bar-chart/index.ts @@ -0,0 +1,4 @@ +export { definition as HorizontalBarChartDefinition } from './horizontal-bar-chart.definition.js'; +export { HorizontalBarChart } from './horizontal-bar-chart.js'; +export { styles as HorizontalBarChartStyles } from './horizontal-bar-chart.styles.js'; +export { template as HorizontalBarChartTemplate } from './horizontal-bar-chart.template.js'; diff --git a/src/Charts.Scripts/src/index.ts b/src/Charts.Scripts/src/index.ts new file mode 100644 index 0000000000..3b39a65e69 --- /dev/null +++ b/src/Charts.Scripts/src/index.ts @@ -0,0 +1,5 @@ +import { Microsoft as FluentUIChartComponentsFile } from './FluentUIChartComponents'; + +import FluentUIComponents = FluentUIChartComponentsFile.FluentUI.Blazor.FluentUIChartComponents; + +FluentUIComponents.defineComponents(); diff --git a/src/Charts.Scripts/src/utils/chart-helpers.ts b/src/Charts.Scripts/src/utils/chart-helpers.ts new file mode 100644 index 0000000000..7aaeda6270 --- /dev/null +++ b/src/Charts.Scripts/src/utils/chart-helpers.ts @@ -0,0 +1,217 @@ +import type { ValueConverter } from '@microsoft/fast-element'; +import { Direction } from '@microsoft/fast-web-utilities'; +import { getDirection } from '@fluentui/web-components'; + +export const jsonConverter: ValueConverter = { + toView(value: any): string { + return JSON.stringify(value); + }, + fromView(value: unknown): any { + if (value === null || value === undefined) { + return value; + } + + if (typeof value !== 'string') { + return value; + } + + return JSON.parse(value); + }, +}; + +export const booleanStringConverter = { + toView(value: boolean): string { + return value ? 'true' : 'false'; + }, + fromView(value: unknown): boolean { + if (value === null || value === undefined) return false; + if (typeof value === 'boolean') return value; + + const normalized = String(value).trim().toLowerCase(); + if (normalized === 'false') return false; + if (normalized === 'true' || normalized === '') return true; + + return true; + } +}; + +type Dict = { [key: string]: any }; + +export const validateChartPropsArray = (obj: any, objName: string) => { + if (!Array.isArray(obj)) { + throw TypeError(`Invalid ${objName}: Expected an array.`); + } + + obj.forEach((item, idx) => { + validateChartProps(item, `${objName}[${idx}]`); + }); +}; + +export const validateChartProps = (obj: any, objName: string) => { + if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) { + throw TypeError(`Invalid ${objName}: Expected an object.`); + } + + if (!Array.isArray(obj.chartData)) { + throw TypeError(`Invalid ${objName}.chartData: Expected an array.`); + } + + (obj.chartData as any[]).forEach((item, idx) => { + if (item === null || typeof item !== 'object' || Array.isArray(item)) { + throw TypeError(`Invalid ${objName}.chartData[${idx}]: Expected an object.`); + } + + if (typeof item.legend !== 'string') { + throw TypeError(`Invalid ${objName}.chartData[${idx}].legend: Expected a string.`); + } + + if (typeof item.data !== 'number') { + throw TypeError(`Invalid ${objName}.chartData[${idx}].data: Expected a number.`); + } + }); +}; + +export const DataVizPalette = { + color1: 'qualitative.1', + color2: 'qualitative.2', + color3: 'qualitative.3', + color4: 'qualitative.4', + color5: 'qualitative.5', + color6: 'qualitative.6', + color7: 'qualitative.7', + color8: 'qualitative.8', + color9: 'qualitative.9', + color10: 'qualitative.10', + color11: 'qualitative.21', + color12: 'qualitative.22', + color13: 'qualitative.23', + color14: 'qualitative.24', + color15: 'qualitative.25', + color16: 'qualitative.26', + color17: 'qualitative.27', + color18: 'qualitative.28', + color19: 'qualitative.29', + info: 'semantic.info', + disabled: 'semantic.disabled', + highError: 'semantic.highError', + error: 'semantic.error', + warning: 'semantic.warning', + success: 'semantic.success', + highSuccess: 'semantic.highSuccess', +}; + +/** + * Key: Color code. + * Value: + * Index 0 - Default color / Color for light theme, + * Index 1 - Color for dark theme + */ +type Palette = { [key: string]: string[] }; + +const QualitativePalette: Palette = { + '1': ['#637cef'], // [cornflower.tint10], + '2': ['#e3008c'], // [hotPink.primary], + '3': ['#2aa0a4'], // [teal.tint20], + '4': ['#9373c0'], // [orchid.tint10], + '5': ['#13a10e'], // [lightGreen.primary], + '6': ['#3a96dd'], // [lightBlue.primary], + '7': ['#ca5010'], // [pumpkin.primary], + '8': ['#57811b'], // [lime.shade20], + '9': ['#b146c2'], // [lilac.primary], + '10': ['#ae8c00'], // [gold.shade10], + '21': ['#4f6bed'], // [cornflower.primary], + '22': ['#ea38a6'], // [hotPink.tint20], + '23': ['#038387'], // [teal.primary], + '24': ['#8764b8'], // [orchid.primary], + '25': ['#11910d'], // [lightGreen.shade10], + '26': ['#3487c7'], // [lightBlue.shade10], + '27': ['#d06228'], // [pumpkin.tint10], + '28': ['#689920'], // [lime.shade10], + '29': ['#ba58c9'], // [lilac.tint10], +}; + +const SemanticPalette: Palette = { + info: ['#015cda'], + disabled: ['#dbdbdb', '#4d4d4d'], // [grey[86], grey[30]] + highError: ['#6e0811', '#cc2635'], // [cranberry.shade30, cranberry.tint10], + error: ['#c50f1f', '#dc626d'], // [cranberry.primary, cranberry.tint30], + warning: ['#f7630c', '#f87528'], // [orange.primary, orange.tint10], + success: ['#107c10', '#54b054'], // [green.primary, green.tint30], + highSuccess: ['#094509', '#218c21'], // [green.shade30, green.tint10], +}; + +const Colors: { [key: string]: Palette } = { + qualitative: QualitativePalette, + semantic: SemanticPalette, +}; + +const QUALITATIVE_COLORS = Object.values(QualitativePalette); +const TOKENS = Object.values(DataVizPalette); + +const getThemeSpecificColor = (colors: string[], isDarkTheme: boolean): string => { + if (colors.length === 0) { + return ''; + } + const colorIdx = Number(isDarkTheme); + if (colorIdx < colors.length) { + return colors[colorIdx]; + } + return colors[0]; +}; + +export const getNextColor = (index: number, offset: number = 0, isDarkTheme: boolean = false): string => { + const colors = QUALITATIVE_COLORS[(index + offset) % QUALITATIVE_COLORS.length]; + return getThemeSpecificColor(colors, isDarkTheme); +}; + +export const getColorFromToken = (token: string, isDarkTheme: boolean = false): string => { + if (TOKENS.indexOf(token) >= 0) { + const [paletteName, colorCode] = token.split('.'); + const colors = Colors[paletteName][colorCode]; + return getThemeSpecificColor(colors, isDarkTheme); + } + return token; +}; + +export const getRTL = (rootNode: HTMLElement): boolean => { + return getDirection(rootNode) === Direction.rtl; +}; + +export const SVG_NAMESPACE_URI = 'http://www.w3.org/2000/svg'; + +export const wrapText = (text: SVGTextElement, width: number) => { + if (!text.textContent) { + return; + } + + const words = text.textContent.split(/\s+/).reverse(); + let word: string | undefined; + let line: string[] = []; + let lineNumber = 0; + const lineHeight = text.getBoundingClientRect().height; + const y = text.getAttribute('y') || '0'; + + text.textContent = null; + + let tspan = document.createElementNS(SVG_NAMESPACE_URI, 'tspan'); + text.appendChild(tspan); + tspan.setAttribute('x', '0'); + tspan.setAttribute('y', y); + tspan.setAttribute('dy', `${lineNumber++ * lineHeight}`); + + while ((word = words.pop())) { + line.push(word); + tspan.textContent = line.join(' ') + ' '; + if (tspan.getComputedTextLength() > width && line.length > 1) { + line.pop(); + tspan.textContent = line.join(' ') + ' '; + line = [word]; + tspan = document.createElementNS(SVG_NAMESPACE_URI, 'tspan'); + text.appendChild(tspan); + tspan.setAttribute('x', '0'); + tspan.setAttribute('y', y); + tspan.setAttribute('dy', `${lineNumber++ * lineHeight}`); + tspan.textContent = word; + } + } +}; diff --git a/src/Charts.Scripts/tsconfig.json b/src/Charts.Scripts/tsconfig.json new file mode 100644 index 0000000000..30c59c60a2 --- /dev/null +++ b/src/Charts.Scripts/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig to read more about this file */ + + /* Language and Environment */ + "target": "ESNext", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + + /* Modules */ + "module": "ESNext", + "moduleResolution": "bundler", + "experimentalDecorators": true, + "lib": [ "ESNext", "DOM" ], + /* Emit */ + "sourceMap": true, /* Create source map files for emitted JavaScript files. */ + + /* Interop Constraints */ + "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ + "allowSyntheticDefaultImports": true, + // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ + "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ + + /* Type Checking */ + "strict": true, /* Enable all strict type-checking options. */ + "noImplicitAny": true, + + /* Completeness */ + // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ + "skipLibCheck": true, /* Skip type checking all .d.ts files. */ + "baseUrl": ".", + "paths": { + "@core/*": [ "../Core.Scripts/src/*" ] + } + } +} diff --git a/src/Charts/ChartJson.cs b/src/Charts/ChartJson.cs new file mode 100644 index 0000000000..9eb1c882cb --- /dev/null +++ b/src/Charts/ChartJson.cs @@ -0,0 +1,47 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +using System.Text.Json; + +namespace Microsoft.FluentUI.AspNetCore.Components.Charts; + +/// +/// Provides shared source-generated JSON serialization helpers for chart payloads. +/// +public static class ChartJson +{ + /// + /// Serializes donut chart data using the donut chart serializer context. + /// + /// The donut chart data payload. + /// A JSON string suitable for the fluent-donut-chart component. + public static string Serialize(DonutChartData value) => + JsonSerializer.Serialize( + value, + DonutChartDataJsonSerializerContext.Default.DonutChartData); + + /// + /// Serializes horizontal bar chart data using the horizontal bar chart serializer context. + /// + /// The horizontal bar chart series collection. + /// A JSON string suitable for the fluent-horizontal-bar-chart component. + public static string Serialize(IReadOnlyList value) => + JsonSerializer.Serialize( + value, + HorizontalBarChartDataJsonSerializerContext.Default.IReadOnlyListHorizontalBarChartSeries); + + /// + /// Serializes horizontal bar chart data with using the horizontal bar chart + /// serializer context. + /// + /// The horizontal bar chart series collection. + /// + /// A JSON string suitable for the fluent-horizontal-bar-chart + /// component. + /// + public static string Serialize(IReadOnlyList value) => + JsonSerializer.Serialize( + value, + HorizontalBarChartWithAxisDataJsonSerializerContext.Default.IReadOnlyListHorizontalBarChartWithAxisDataPoint); +} diff --git a/src/Charts/Charts/DonutChart/FluentDonutChart.razor b/src/Charts/Charts/DonutChart/FluentDonutChart.razor new file mode 100644 index 0000000000..cff1a20adb --- /dev/null +++ b/src/Charts/Charts/DonutChart/FluentDonutChart.razor @@ -0,0 +1,22 @@ +@namespace Microsoft.FluentUI.AspNetCore.Components.Charts +@inherits FluentComponentBase + + + diff --git a/src/Charts/Charts/DonutChart/FluentDonutChart.razor.cs b/src/Charts/Charts/DonutChart/FluentDonutChart.razor.cs new file mode 100644 index 0000000000..a74c5c1eac --- /dev/null +++ b/src/Charts/Charts/DonutChart/FluentDonutChart.razor.cs @@ -0,0 +1,117 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Components; +using Microsoft.FluentUI.AspNetCore.Components.Utilities; + +namespace Microsoft.FluentUI.AspNetCore.Components.Charts; + +/// +/// A FluentDonutChart is a component that displays data in a donut chart format. +/// +public partial class FluentDonutChart : FluentComponentBase +{ + + /// + public FluentDonutChart(LibraryConfiguration configuration) : base(configuration) + { + Id = Identifier.NewId(); + } + + /// + internal string? ClassValue => DefaultClassBuilder + .AddClass("fluent-donut-chart") + .Build(); + + /// + internal string? StyleValue => DefaultStyleBuilder + .AddStyle("width", Width.HasValue ? $"{Width.Value}px" : null, when: Width.HasValue) + .AddStyle("height", Height.HasValue ? $"{Height.Value}px" : null, when: Height.HasValue) + .Build(); + + /// + /// Gets or sets the title of the donut chart, which is typically displayed above the chart to provide context about the data being represented. + /// + [Parameter] + public string ChartTitle { get; set; } = string.Empty; + + /// + /// Gets or sets the data for the donut chart. + /// + [Parameter, EditorRequired] + public DonutChartData ChartData { get; set; } = new(); + + /// + /// Gets or sets the height of the donut chart. + /// + [Parameter] + public int? Height { get; set; } + + /// + /// Gets or sets the width of the donut chart. + /// + [Parameter] + public int? Width { get; set; } + + /// + /// Gets or sets a value indicating whether labels are hidden in the + /// component output. + /// Default is true. + /// + [Parameter] + public bool HideLabels { get; set; } = true; + + /// + /// Gets or sets a value indicating whether legends are hidden in the component output. + /// + [Parameter] + public bool HideLegends { get; set; } + + /// + /// Gets or sets a value indicating whether the tooltip is hidden. + /// + [Parameter] + public bool HideTooltip { get; set; } + + /// + /// Gets or sets whether bars/arcs in the chart should have rounded corners. + /// + [Parameter] + public bool RoundedCorners { get; set; } + + /// + /// Gets or sets whether label values should be displayed as percentages of the total rather than raw values. + /// + [Parameter] + public bool ShowLabelsInPercent { get; set; } + + /// + /// Gets or sets the inner radius of the component, in pixels. + /// + /// If , a default inner radius is used. The value must be non-negative. + /// This property is typically used to control the thickness of ring-shaped visual elements. + [Parameter] + public int? InnerRadius { get; set; } + + /// + /// Gets or sets the value displayed inside the donut hole. This is typically used to show a summary + /// or total value related to the data represented by the chart. + /// + [Parameter] + public string? ValueInsideDonut { get; set; } + + /// + /// Gets or sets the label text displayed for the legend list. + /// + [Parameter] + public string? LegendListLabel { get; set; } + + /// + /// Gets or sets a value indicating whether multiple legend items can be selected simultaneously. + /// When , clicking a legend item adds it to the active selection rather than replacing the current selection. + /// When (default), only a single legend item can be selected at a time. + /// + [Parameter] + public bool AllowMultipleLegendSelection { get; set; } +} diff --git a/src/Charts/Charts/HorizontalBarChart/FluentHorizontalBarChart.razor b/src/Charts/Charts/HorizontalBarChart/FluentHorizontalBarChart.razor new file mode 100644 index 0000000000..0b5c14c3b1 --- /dev/null +++ b/src/Charts/Charts/HorizontalBarChart/FluentHorizontalBarChart.razor @@ -0,0 +1,16 @@ +@namespace Microsoft.FluentUI.AspNetCore.Components.Charts +@using Microsoft.FluentUI.AspNetCore.Components.Extensions +@inherits FluentComponentBase + + + diff --git a/src/Charts/Charts/HorizontalBarChart/FluentHorizontalBarChart.razor.cs b/src/Charts/Charts/HorizontalBarChart/FluentHorizontalBarChart.razor.cs new file mode 100644 index 0000000000..c841a42220 --- /dev/null +++ b/src/Charts/Charts/HorizontalBarChart/FluentHorizontalBarChart.razor.cs @@ -0,0 +1,83 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Components; +using Microsoft.FluentUI.AspNetCore.Components.Enums; +using Microsoft.FluentUI.AspNetCore.Components.Utilities; + +namespace Microsoft.FluentUI.AspNetCore.Components.Charts; + +/// +/// A FluentHorizontalBarChart is a component that displays data in a horizontal bar chart format. +/// +public partial class FluentHorizontalBarChart : FluentComponentBase +{ + /// + public FluentHorizontalBarChart(LibraryConfiguration configuration) : base(configuration) + { + Id = Identifier.NewId(); + } + + /// + internal string? ClassValue => DefaultClassBuilder + .AddClass("fluent-horizontal-bar-chart") + .Build(); + + /// + internal string? StyleValue => DefaultStyleBuilder + .Build(); + + /// + /// Gets or sets the data for the horizontal bar chart. + /// + [Parameter, EditorRequired] + public IReadOnlyList ChartData { get; set; } = []; + + /// + /// Gets or sets the visual variant to use for rendering + /// the horizontal bar chart. + /// + /// + /// Specify this property to control the appearance or style of the chart. + /// If not set, the default variant is used. + /// + [Parameter] + public HorizontalBarChartVariant? Variant { get; set; } + + /// + /// Gets or sets a value indicating whether the ratio is hidden in the component output. + /// + [Parameter] + public bool HideRatio { get; set; } + + /// + /// Gets or sets a value indicating whether legends are hidden in the component output. + /// + [Parameter] + public bool HideLegends { get; set; } + + /// + /// Gets or sets a value indicating whether the tooltip is hidden. + /// + [Parameter] + public bool HideTooltip { get; set; } + + /// + /// Gets or sets the label displayed for the legend list. + /// + [Parameter] + public string? LegendListLabel { get; set; } + + /// + /// Gets or sets the title text displayed on the chart. + /// + [Parameter] + public string? ChartTitle { get; set; } + + /// + /// Gets a value indicating whether the component has data that can be rendered safely. + /// + protected bool HasRenderableData => + ChartData is { Count: > 0 }; +} diff --git a/src/Charts/Charts/HorizontalBarChartWithAxis/FluentHorizontalBarChartWithAxis.razor b/src/Charts/Charts/HorizontalBarChartWithAxis/FluentHorizontalBarChartWithAxis.razor new file mode 100644 index 0000000000..b84df42236 --- /dev/null +++ b/src/Charts/Charts/HorizontalBarChartWithAxis/FluentHorizontalBarChartWithAxis.razor @@ -0,0 +1,21 @@ +@namespace Microsoft.FluentUI.AspNetCore.Components.Charts +@inherits FluentComponentBase + + + diff --git a/src/Charts/Charts/HorizontalBarChartWithAxis/FluentHorizontalBarChartWithAxis.razor.cs b/src/Charts/Charts/HorizontalBarChartWithAxis/FluentHorizontalBarChartWithAxis.razor.cs new file mode 100644 index 0000000000..782ac38b01 --- /dev/null +++ b/src/Charts/Charts/HorizontalBarChartWithAxis/FluentHorizontalBarChartWithAxis.razor.cs @@ -0,0 +1,77 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Components; +using Microsoft.FluentUI.AspNetCore.Components.Utilities; + +namespace Microsoft.FluentUI.AspNetCore.Components.Charts; + +/// +/// A FluentHorizontalBarChartWithAxis is a component that displays data in a horizontal bar chart format with an axis. +/// +public partial class FluentHorizontalBarChartWithAxis : FluentComponentBase +{ + /// + public FluentHorizontalBarChartWithAxis(LibraryConfiguration configuration) : base(configuration) + { + Id = Identifier.NewId(); + } + + /// + internal string? ClassValue => DefaultClassBuilder + .AddClass("fluent-horizontal-bar-chart-with-axis") + .Build(); + + /// + internal string? StyleValue => DefaultStyleBuilder + .Build(); + + /// + /// Gets or sets the data for the horizontal bar chart. + /// + [Parameter, EditorRequired] + public IReadOnlyList ChartData { get; set; } = []; + + /// + /// Gets or sets the title text displayed on the chart. + /// + [Parameter] + public string? ChartTitle { get; set; } + + /// + /// Gets or sets the height of the horizontal bar chart. + /// + [Parameter] + public int? Height { get; set; } + + /// + /// Gets or sets the width of the horizontal bar chart. + /// + [Parameter] + public int? Width { get; set; } + + /// + /// Gets or sets a value indicating whether legends are hidden in the component output. + /// + [Parameter] + public bool HideLegends { get; set; } + + /// + /// Gets or sets a value indicating whether the tooltip is hidden. + /// + [Parameter] + public bool HideTooltip { get; set; } + + /// + /// Gets or sets a value indicating whether the labels are hidden. + /// + [Parameter] + public bool HideLabels { get; set; } + + /// + /// Gets or sets the label displayed for the legend list. + /// + [Parameter] + public string? LegendListLabel { get; set; } +} diff --git a/src/Charts/Enums/HorizontalBarChartVariant.cs b/src/Charts/Enums/HorizontalBarChartVariant.cs new file mode 100644 index 0000000000..6f1e5cee1e --- /dev/null +++ b/src/Charts/Enums/HorizontalBarChartVariant.cs @@ -0,0 +1,37 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +using System.ComponentModel; + +namespace Microsoft.FluentUI.AspNetCore.Components.Enums; + +/// +/// Specifies which visual variant the HorizontalBarChart uses. +/// +public enum HorizontalBarChartVariant +{ + /// + /// Specifies that the data represents a part-to-whole relationship, where each value is a component of a total. + /// + /// Use this value when visualizing or analyzing data in which individual elements contribute to + /// a collective whole, such as in pie charts or stacked bar charts. + [Description("part-to-whole")] + PartToWhole, + + /// + /// Specifies that bar lengths are based on the raw data values using a + /// shared absolute scale. Choose this variant when comparing magnitudes + /// across bars, rather than showing each bar as part of a total as in + /// , or rendering a standalone value as in + /// . + /// + [Description("absolute-scale")] + AbsoluteScale, + + /// + /// Represents a single bar element, typically used in charting or data visualization scenarios. + /// + [Description("single-bar")] + SingleBar, +} diff --git a/src/Charts/Enums/HorizontalBarChartWithAxisCategoryOrder.cs b/src/Charts/Enums/HorizontalBarChartWithAxisCategoryOrder.cs new file mode 100644 index 0000000000..b2c3a71aae --- /dev/null +++ b/src/Charts/Enums/HorizontalBarChartWithAxisCategoryOrder.cs @@ -0,0 +1,139 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +using System.ComponentModel; + +namespace Microsoft.FluentUI.AspNetCore.Components.Enums; + +/// +/// Specifies which axis catagory order visual variant the +/// HorizontalBarChartWithAxis uses. +/// +public enum HorizontalBarChartWithAxisCategoryOrder +{ + /// + /// Use the default category order, which is typically the order in which + /// categories appear in the data source. + /// + [Description("default")] + Default, + + /// + /// Use the data order, which is the order of categories as they are + /// provided in the data payload. This may differ from the default order if + /// the component applies any internal sorting or grouping logic. + /// + [Description("data")] + Data, + + /// + /// Use the category ascending order, which sorts categories alphabetically + /// from A to Z (or in ascending order for non-alphabetic labels). + /// + [Description("category ascending")] + CategoryAscending, + + /// + /// Use the category descending order, which sorts categories alphabetically + /// from Z to A (or in descending order for non-alphabetic labels). + /// + [Description("category descending")] + CategoryDescending, + + /// + /// Use the total ascending order, which sorts categories based on the total + /// value of their associated data points in ascending order (from smallest + /// to largest total). + /// + [Description("total ascending")] + TotalAscending, + + /// + /// Use the total descending order, which sorts categories based on the + /// total value of their associated data points in descending order (from + /// largest to smallest total). + /// + [Description("total descending")] + TotalDescending, + + /// + /// Use the minimal ascending order, which sorts categories based on the + /// minimum value of their associated data points in ascending order (from + /// smallest to largest minimum). + /// + [Description("min ascending")] + MinAscending, + + /// + /// Use the minimal descending order, which sorts categories based on the + /// minimum value of their associated data points in descending order (from + /// largest to smallest minimum). + /// + [Description("min descending")] + MinDescending, + + /// + /// Use the maximum ascending order, which sorts categories based on the + /// maximum value of their associated data points in ascending order (from + /// smallest to largest maximum). + /// + [Description("max ascending")] + MaxAscending, + + /// + /// Use the maximum descending order, which sorts categories based on the + /// maximum value of their associated data points in descending order (from + /// largest to smallest maximum). + /// + [Description("max descending")] + MaxDescending, + + /// + /// Use the sum ascending order, which sorts categories based on the sum of + /// their associated data points in ascending order (from smallest to + /// largest sum). + /// + [Description("sum ascending")] + SumAscending, + + /// + /// Use the sum descending order, which sorts categories based on the sum of + /// their associated data points in descending order (from largest to + /// smallest sum). + /// + [Description("sum descending")] + SumDescending, + + /// + /// Use the mean ascending order, which sorts categories based on the mean + /// (average) value of their associated data points in ascending order (from + /// smallest to largest mean). + /// + [Description("mean ascending")] + MeanAscending, + + /// + /// Use the mean descending order, which sorts categories based on the mean + /// (average) value of their associated data points in descending order + /// (from largest to smallest mean). + /// + [Description("mean descending")] + MeanDescending, + + /// + /// Use the median ascending order, which sorts categories based on the + /// median value of their associated data points in ascending order (from + /// smallest to largest median). + /// + [Description("median ascending")] + MedianAscending, + + /// + /// Use the median descending order, which sorts categories based on the + /// median value of their associated data points in descending order (from + /// largest to smallest median). + /// + [Description("median descending")] + MedianDescending, +} diff --git a/src/Charts/Microsoft.FluentUI.AspNetCore.Components.Charts.csproj b/src/Charts/Microsoft.FluentUI.AspNetCore.Components.Charts.csproj new file mode 100644 index 0000000000..1cd7d47189 --- /dev/null +++ b/src/Charts/Microsoft.FluentUI.AspNetCore.Components.Charts.csproj @@ -0,0 +1,173 @@ + + + $(TargetNetVersions) + Microsoft.FluentUI.AspNetCore.Components.Charts + + A set of Blazor components wrapping an extended version of the Fluent UI Chart Web Components. + Fluent UI, Blazor, Charts, Web Components + Microsoft Fluent UI Chart Components for Blazor + A set of Blazor components wrapping an extended version of the Fluent UI Chart Web Components. + README.md + icon.png + + False + enable + enable + latest + + true + true + + true + embedded + + $(SolutionDir)artifacts + + + true + $(SolutionDir).generated + + + true + false + + + + true + link + true + + + + + False + 6 + true + + + + + True + true + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + true + + + + + + + + + + + + + + + + + <_EsprojAssetsToFix Include="@(StaticWebAsset)" Condition="'%(StaticWebAsset.OriginalItemSpec)' != '' AND $([System.String]::Copy('%(StaticWebAsset.OriginalItemSpec)').EndsWith('.esproj'))" /> + + + + + %(Identity) + + + + <_EsprojAssetsToFix Remove="@(_EsprojAssetsToFix)" /> + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + true + true + true + CS1591 + + + + + + + diff --git a/src/Charts/Models/DonutChartDataJsonSerializerContext.cs b/src/Charts/Models/DonutChartDataJsonSerializerContext.cs new file mode 100644 index 0000000000..95b01a8f94 --- /dev/null +++ b/src/Charts/Models/DonutChartDataJsonSerializerContext.cs @@ -0,0 +1,17 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +using System.Text.Json.Serialization; + +namespace Microsoft.FluentUI.AspNetCore.Components.Charts; + +/// +/// Provides source-generated JSON serialization metadata for donut chart payloads. +/// +[JsonSerializable(typeof(DonutChartData))] +[JsonSerializable(typeof(DonutChartDataPoint))] +[JsonSerializable(typeof(IReadOnlyList))] +internal sealed partial class DonutChartDataJsonSerializerContext : JsonSerializerContext +{ +} diff --git a/src/Charts/Models/DonutChartModels.cs b/src/Charts/Models/DonutChartModels.cs new file mode 100644 index 0000000000..e902a12b43 --- /dev/null +++ b/src/Charts/Models/DonutChartModels.cs @@ -0,0 +1,66 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +using System.Text.Json.Serialization; + +namespace Microsoft.FluentUI.AspNetCore.Components.Charts; + +#pragma warning disable MA0048 // File name must match type name + +/// +/// Represents a single data point in a donut chart. +/// +public sealed record DonutChartDataPoint +{ + /// + /// Gets the legend text shown for the donut segment. + /// + [JsonPropertyName("legend")] + public string Legend { get; init; } = string.Empty; + + /// + /// Gets the numeric value of the donut segment. + /// + [JsonPropertyName("data")] + public double Data { get; init; } + + /// + /// Gets the color used to render the donut segment and legend. + /// If not provided, the web component falls back to its default palette. + /// + [JsonPropertyName("color")] + public string? Color { get; init; } + + /// + /// Gets optional callout data for the x-axis portion of the tooltip. + /// + [JsonPropertyName("xAxisCalloutData")] + public string? XAxisCalloutData { get; init; } + + /// + /// Gets optional callout data for the y-axis portion of the tooltip. + /// If not provided, the component may fall back to the numeric data value. + /// + [JsonPropertyName("yAxisCalloutData")] + public string? YAxisCalloutData { get; init; } +} + +/// +/// Represents the full data payload consumed by the donut chart web component. +/// +public sealed record DonutChartData +{ + /// + /// Gets the optional title of the chart. + /// + [JsonPropertyName("chartTitle")] + public string? ChartTitle { get; init; } + + /// + /// Gets the collection of donut chart segments. + /// + [JsonPropertyName("chartData")] + public IReadOnlyList ChartData { get; init; } = []; +} +#pragma warning restore MA0048 // File name must match type name diff --git a/src/Charts/Models/HorizontalBarChartDataJsonSerializerContext.cs b/src/Charts/Models/HorizontalBarChartDataJsonSerializerContext.cs new file mode 100644 index 0000000000..faeaa3d5b2 --- /dev/null +++ b/src/Charts/Models/HorizontalBarChartDataJsonSerializerContext.cs @@ -0,0 +1,18 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +using System.Text.Json.Serialization; + +namespace Microsoft.FluentUI.AspNetCore.Components.Charts; + +/// +/// Provides source-generated JSON serialization metadata for horizontal bar chart payloads. +/// +[JsonSerializable(typeof(HorizontalBarChartSeries))] +[JsonSerializable(typeof(HorizontalBarChartDataPoint))] +[JsonSerializable(typeof(IReadOnlyList))] +[JsonSerializable(typeof(IReadOnlyList))] +internal sealed partial class HorizontalBarChartDataJsonSerializerContext : JsonSerializerContext +{ +} diff --git a/src/Charts/Models/HorizontalBarChartModels.cs b/src/Charts/Models/HorizontalBarChartModels.cs new file mode 100644 index 0000000000..98b7e34a28 --- /dev/null +++ b/src/Charts/Models/HorizontalBarChartModels.cs @@ -0,0 +1,78 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +using System.Text.Json.Serialization; + +namespace Microsoft.FluentUI.AspNetCore.Components.Charts; + +#pragma warning disable MA0048 // File name must match type name + +/// +/// Represents a single data point in a horizontal bar chart series. +/// +public sealed record HorizontalBarChartDataPoint +{ + /// + /// Gets the legend text shown for the bar segment. + /// + [JsonPropertyName("legend")] + public string Legend { get; init; } = string.Empty; + + /// + /// Gets the numeric value represented by the bar segment. + /// + [JsonPropertyName("data")] + public double Data { get; init; } + + /// + /// Gets the optional total bar length used for ratio-style rendering. + /// + [JsonPropertyName("total")] + public double? Total { get; init; } + + /// + /// Gets the solid color used to render the bar segment. + /// If not provided, the component may fall back to its default palette behavior. + /// + [JsonPropertyName("color")] + public string? Color { get; init; } + + /// + /// Gets the optional two-color gradient used to render the bar segment. + /// The array should contain exactly two color values: start and end. + /// + [JsonPropertyName("gradient")] + public string[]? Gradient { get; init; } +} + +/// +/// Represents one horizontal bar chart series in the data payload. +/// +public sealed record HorizontalBarChartSeries +{ + /// + /// Gets the optional title shown for the data series. + /// + [JsonPropertyName("chartSeriesTitle")] + public string? ChartSeriesTitle { get; init; } + + /// + /// Gets the collection of data points rendered within the series. + /// + [JsonPropertyName("chartData")] + public IReadOnlyList ChartData { get; init; } = []; + + /// + /// Gets the optional benchmark value used to render the benchmark indicator. + /// + [JsonPropertyName("benchmarkData")] + public double? BenchmarkData { get; init; } + + /// + /// Gets optional text displayed alongside the chart data for the series. + /// + [JsonPropertyName("chartDataText")] + public string? ChartDataText { get; init; } +} +#pragma warning restore MA0048 // File name must match type name diff --git a/src/Charts/Models/HorizontalBarChartWithAxisDataJsonSerializerContext.cs b/src/Charts/Models/HorizontalBarChartWithAxisDataJsonSerializerContext.cs new file mode 100644 index 0000000000..2742755e5c --- /dev/null +++ b/src/Charts/Models/HorizontalBarChartWithAxisDataJsonSerializerContext.cs @@ -0,0 +1,16 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +using System.Text.Json.Serialization; + +namespace Microsoft.FluentUI.AspNetCore.Components.Charts; + +/// +/// Provides source-generated JSON serialization metadata for horizontal bar chart payloads. +/// +[JsonSerializable(typeof(HorizontalBarChartWithAxisDataPoint))] +[JsonSerializable(typeof(IReadOnlyList))] +internal sealed partial class HorizontalBarChartWithAxisDataJsonSerializerContext : JsonSerializerContext +{ +} diff --git a/src/Charts/Models/HorizontalBarChartWithAxisModels.cs b/src/Charts/Models/HorizontalBarChartWithAxisModels.cs new file mode 100644 index 0000000000..596cb1d646 --- /dev/null +++ b/src/Charts/Models/HorizontalBarChartWithAxisModels.cs @@ -0,0 +1,96 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +using System.Text.Json.Serialization; + +namespace Microsoft.FluentUI.AspNetCore.Components.Charts; + +#pragma warning disable MA0048 // File name must match type name + +/// +/// Represents a single data point in a horizontal bar chart series. +/// +public sealed record HorizontalBarChartWithAxisDataPoint +{ + /// + /// Gets the numeric value of the bar segment, which determines its length along the x-axis. + /// + [JsonPropertyName("x")] + public double X { get; init; } + + /// + /// Gets the category or label of the bar segment, which determines its position along the y-axis. + /// + [JsonPropertyName("y")] + public string Y { get; init; } = string.Empty; + + /// + /// Gets the legend text shown for the bar segment. + /// + [JsonPropertyName("legend")] + public string Legend { get; init; } = string.Empty; + + /// + /// Gets the solid color used to render the bar segment. + /// If not provided, the component may fall back to its default palette behavior. + /// + [JsonPropertyName("color")] + public string? Color { get; init; } + + /// + /// Gets the optional two-color gradient used to render the bar segment. + /// The array should contain exactly two color values: start and end. + /// + [JsonPropertyName("gradient")] + public string[]? Gradient { get; init; } + + /// + /// Gets or initializes the accessibility data for the callout. + /// + public string callOutAccessibilityData { get; init; } = string.Empty; + + /// + /// Gets optional callout data for the x-axis portion of the tooltip. + /// + [JsonPropertyName("xAxisCalloutData")] + public string? XAxisCalloutData { get; init; } + + /// + /// Gets optional callout data for the y-axis portion of the tooltip. + /// If not provided, the component may fall back to the numeric data value. + /// + [JsonPropertyName("yAxisCalloutData")] + public string? YAxisCalloutData { get; init; } +} + +/// +/// Represents one horizontal bar chart series in the data payload. +/// +public sealed record HorizontalBarChartSeriesWithAxis +{ + /// + /// Gets the optional title shown for the data series. + /// + [JsonPropertyName("chartSeriesTitle")] + public string? ChartSeriesTitle { get; init; } + + /// + /// Gets the collection of data points rendered within the series. + /// + [JsonPropertyName("chartData")] + public IReadOnlyList ChartData { get; init; } = []; + + /// + /// Gets the optional benchmark value used to render the benchmark indicator. + /// + [JsonPropertyName("benchmarkData")] + public double? BenchmarkData { get; init; } + + /// + /// Gets optional text displayed alongside the chart data for the series. + /// + [JsonPropertyName("chartDataText")] + public string? ChartDataText { get; init; } +} +#pragma warning restore MA0048 // File name must match type name diff --git a/src/Charts/README.md b/src/Charts/README.md new file mode 100644 index 0000000000..c82e98439d --- /dev/null +++ b/src/Charts/README.md @@ -0,0 +1 @@ +# Welcome to the Microsoft Fluent UI Chart Components for Blazor diff --git a/src/Charts/_Imports.razor b/src/Charts/_Imports.razor new file mode 100644 index 0000000000..f9b6d6d8fb --- /dev/null +++ b/src/Charts/_Imports.razor @@ -0,0 +1,9 @@ +@using System +@using System.Diagnostics.CodeAnalysis +@using Microsoft.AspNetCore.Components +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.FluentUI.AspNetCore.Components +@using Microsoft.FluentUI.AspNetCore.Components.Utilities +@using Microsoft.JSInterop diff --git a/src/Core.Scripts/.npmrc b/src/Core.Scripts/.npmrc index db9d16ace4..642288e0b9 100644 --- a/src/Core.Scripts/.npmrc +++ b/src/Core.Scripts/.npmrc @@ -1,2 +1,3 @@ -registry=https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/ +registry=http://localhost:4873/ always-auth=true + diff --git a/src/Core.Scripts/Microsoft.FluentUI.AspNetCore.Components.Scripts.esproj b/src/Core.Scripts/Microsoft.FluentUI.AspNetCore.Components.Scripts.esproj index fe27f6ca51..e8b86aab1a 100644 --- a/src/Core.Scripts/Microsoft.FluentUI.AspNetCore.Components.Scripts.esproj +++ b/src/Core.Scripts/Microsoft.FluentUI.AspNetCore.Components.Scripts.esproj @@ -1,4 +1,4 @@ - + dist\ Microsoft.FluentUI.AspNetCore.Components diff --git a/src/Core.Scripts/package-lock.json b/src/Core.Scripts/package-lock.json index 5fa10aa818..741dbbbf59 100644 --- a/src/Core.Scripts/package-lock.json +++ b/src/Core.Scripts/package-lock.json @@ -15,7 +15,7 @@ "@typescript-eslint/parser": "8.57.1", "esbuild": "0.27.4", "esbuild-plugin-inline-css": "0.0.1", - "eslint": "10.0.3", + "eslint": "10.2.0", "glob": "^13.0.6", "imask": "^7.6.1", "rimraf": "6.1.3", @@ -508,13 +508,13 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.23.3", - "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/@eslint/config-array/-/config-array-0.23.3.tgz", - "integrity": "sha1-P0qT3VRhacCRMMvRDyQVsTogohk=", + "version": "0.23.5", + "resolved": "http://localhost:4873/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^3.0.3", + "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" }, @@ -523,22 +523,22 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.5.3", - "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/@eslint/config-helpers/-/config-helpers-0.5.3.tgz", - "integrity": "sha1-ch/mu7kNdLDIDW/yQo5bvLACvss=", + "version": "0.5.5", + "resolved": "http://localhost:4873/@eslint/config-helpers/-/config-helpers-0.5.5.tgz", + "integrity": "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^1.1.1" + "@eslint/core": "^1.2.1" }, "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/core": { - "version": "1.1.1", - "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/@eslint/core/-/core-1.1.1.tgz", - "integrity": "sha1-RQ89K+LUY8zVERlUQJIla06I3zI=", + "version": "1.2.1", + "resolved": "http://localhost:4873/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -549,9 +549,9 @@ } }, "node_modules/@eslint/object-schema": { - "version": "3.0.3", - "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/@eslint/object-schema/-/object-schema-3.0.3.tgz", - "integrity": "sha1-W/Zx5S44LkrcR6mQbyaZN0Y322s=", + "version": "3.0.5", + "resolved": "http://localhost:4873/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", "dev": true, "license": "Apache-2.0", "engines": { @@ -559,13 +559,13 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.6.1", - "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz", - "integrity": "sha1-655mibVs6LwYVbszCQ5j8/wRXo4=", + "version": "0.7.1", + "resolved": "http://localhost:4873/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz", + "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^1.1.1", + "@eslint/core": "^1.2.1", "levn": "^0.4.1" }, "engines": { @@ -692,8 +692,8 @@ }, "node_modules/@types/json-schema": { "version": "7.0.15", - "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha1-WWoXRyM2lNUPatinhp/Lb1bPWEE=", + "resolved": "http://localhost:4873/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true, "license": "MIT" }, @@ -1132,18 +1132,18 @@ } }, "node_modules/eslint": { - "version": "10.0.3", - "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/eslint/-/eslint-10.0.3.tgz", - "integrity": "sha1-Ngp95/JwbrijLKoXypg/AInv5pQ=", + "version": "10.2.0", + "resolved": "http://localhost:4873/eslint/-/eslint-10.2.0.tgz", + "integrity": "sha512-+L0vBFYGIpSNIt/KWTpFonPrqYvgKw1eUI5Vn7mEogrQcWtWYtNQ7dNqC+px/J0idT3BAkiWrhfS7k+Tum8TUA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", - "@eslint/config-array": "^0.23.3", - "@eslint/config-helpers": "^0.5.2", - "@eslint/core": "^1.1.1", - "@eslint/plugin-kit": "^0.6.1", + "@eslint/config-array": "^0.23.4", + "@eslint/config-helpers": "^0.5.4", + "@eslint/core": "^1.2.0", + "@eslint/plugin-kit": "^0.7.0", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", @@ -1154,7 +1154,7 @@ "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", - "espree": "^11.1.1", + "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", diff --git a/src/Core.Scripts/package.json b/src/Core.Scripts/package.json index 3ec453dcaf..44b1d9022f 100644 --- a/src/Core.Scripts/package.json +++ b/src/Core.Scripts/package.json @@ -19,7 +19,7 @@ "@typescript-eslint/parser": "8.57.1", "esbuild": "0.27.4", "esbuild-plugin-inline-css": "0.0.1", - "eslint": "10.0.3", + "eslint": "10.2.0", "glob": "^13.0.6", "imask": "^7.6.1", "rimraf": "6.1.3", @@ -28,5 +28,5 @@ }, "dependencies": { "@fluentui/web-components": "^3.0.0-rc.13" - } + } } diff --git a/src/Core.Scripts/src/FluentUIWebComponents.ts b/src/Core.Scripts/src/FluentUIWebComponents.ts index ff96f78b36..aa7382aed4 100644 --- a/src/Core.Scripts/src/FluentUIWebComponents.ts +++ b/src/Core.Scripts/src/FluentUIWebComponents.ts @@ -1,4 +1,6 @@ import * as FluentUIComponents from '@fluentui/web-components' +import { defineOnce } from './RegistrationState'; + export namespace Microsoft.FluentUI.Blazor.FluentUIWebComponents { @@ -10,47 +12,131 @@ export namespace Microsoft.FluentUI.Blazor.FluentUIWebComponents { // To generate these definitions, run the `_ExtractWebComponents.ps1` file // and paste the output here. - FluentUIComponents.accordionDefinition.define(registry); - FluentUIComponents.accordionItemDefinition.define(registry); - FluentUIComponents.AnchorButtonDefinition.define(registry); - FluentUIComponents.AvatarDefinition.define(registry); - FluentUIComponents.BadgeDefinition.define(registry); - FluentUIComponents.ButtonDefinition.define(registry); - FluentUIComponents.CheckboxDefinition.define(registry); - FluentUIComponents.CompoundButtonDefinition.define(registry); - FluentUIComponents.CounterBadgeDefinition.define(registry); - FluentUIComponents.DialogBodyDefinition.define(registry); - FluentUIComponents.DialogDefinition.define(registry); - FluentUIComponents.DividerDefinition.define(registry); - FluentUIComponents.DrawerBodyDefinition.define(registry); - FluentUIComponents.DrawerDefinition.define(registry); - FluentUIComponents.DropdownDefinition.define(registry); - FluentUIComponents.DropdownOptionDefinition.define(registry); - FluentUIComponents.FieldDefinition.define(registry); - FluentUIComponents.ImageDefinition.define(registry); - FluentUIComponents.LabelDefinition.define(registry); - FluentUIComponents.LinkDefinition.define(registry); - FluentUIComponents.ListboxDefinition.define(registry); - FluentUIComponents.MenuButtonDefinition.define(registry); - FluentUIComponents.MenuDefinition.define(registry); - FluentUIComponents.MenuItemDefinition.define(registry); - FluentUIComponents.MenuListDefinition.define(registry); - FluentUIComponents.MessageBarDefinition.define(registry); - FluentUIComponents.ProgressBarDefinition.define(registry); - FluentUIComponents.RadioDefinition.define(registry); - FluentUIComponents.RadioGroupDefinition.define(registry); - FluentUIComponents.RatingDisplayDefinition.define(registry); - FluentUIComponents.SliderDefinition.define(registry); - FluentUIComponents.SpinnerDefinition.define(registry); - FluentUIComponents.SwitchDefinition.define(registry); - FluentUIComponents.TabDefinition.define(registry); - FluentUIComponents.TablistDefinition.define(registry); - FluentUIComponents.TextAreaDefinition.define(registry); - FluentUIComponents.TextDefinition.define(registry); - FluentUIComponents.TextInputDefinition.define(registry); - FluentUIComponents.ToggleButtonDefinition.define(registry); - FluentUIComponents.TooltipDefinition.define(registry); - FluentUIComponents.TreeDefinition.define(registry); - FluentUIComponents.TreeItemDefinition.define(registry); + defineOnce('fluentui:web-components:accordion', () => { + FluentUIComponents.accordionDefinition.define(registry); + }); + defineOnce('fluentui:web-components:accordion-item', () => { + FluentUIComponents.accordionItemDefinition.define(registry); + }); + defineOnce('fluentui:web-components:anchor-button', () => { + FluentUIComponents.AnchorButtonDefinition.define(registry); + }); + defineOnce('fluentui:web-components:avatar', () => { + FluentUIComponents.AvatarDefinition.define(registry); + }); + defineOnce('fluentui:web-components:badge', () => { + FluentUIComponents.BadgeDefinition.define(registry); + }); + defineOnce('fluentui:web-components:button', () => { + FluentUIComponents.ButtonDefinition.define(registry); + }); + defineOnce('fluentui:web-components:checkbox', () => { + FluentUIComponents.CheckboxDefinition.define(registry); + }); + defineOnce('fluentui:web-components:compound-button', () => { + FluentUIComponents.CompoundButtonDefinition.define(registry); + }); + defineOnce('fluentui:web-components:counter-badge', () => { + FluentUIComponents.CounterBadgeDefinition.define(registry); + }); + defineOnce('fluentui:web-components:dialog-body', () => { + FluentUIComponents.DialogBodyDefinition.define(registry); + }); + defineOnce('fluentui:web-components:dialog', () => { + FluentUIComponents.DialogDefinition.define(registry); + }); + defineOnce('fluentui:web-components:divider', () => { + FluentUIComponents.DividerDefinition.define(registry); + }); + defineOnce('fluentui:web-components:drawer-body', () => { + FluentUIComponents.DrawerBodyDefinition.define(registry); + }); + defineOnce('fluentui:web-components:drawer', () => { + FluentUIComponents.DrawerDefinition.define(registry); + }); + defineOnce('fluentui:web-components:dropdown', () => { + FluentUIComponents.DropdownDefinition.define(registry); + }); + defineOnce('fluentui:web-components:dropdown-option', () => { + FluentUIComponents.DropdownOptionDefinition.define(registry); + }); + defineOnce('fluentui:web-components:field', () => { + FluentUIComponents.FieldDefinition.define(registry); + }); + defineOnce('fluentui:web-components:image', () => { + FluentUIComponents.ImageDefinition.define(registry); + }); + defineOnce('fluentui:web-components:label', () => { + FluentUIComponents.LabelDefinition.define(registry); + }); + defineOnce('fluentui:web-components:link', () => { + FluentUIComponents.LinkDefinition.define(registry); + }); + defineOnce('fluentui:web-components:listbox', () => { + FluentUIComponents.ListboxDefinition.define(registry); + }); + defineOnce('fluentui:web-components:menu-button', () => { + FluentUIComponents.MenuButtonDefinition.define(registry); + }); + defineOnce('fluentui:web-components:menu', () => { + FluentUIComponents.MenuDefinition.define(registry); + }); + defineOnce('fluentui:web-components:menu-item', () => { + FluentUIComponents.MenuItemDefinition.define(registry); + }); + defineOnce('fluentui:web-components:menu-list', () => { + FluentUIComponents.MenuListDefinition.define(registry); + }); + defineOnce('fluentui:web-components:message-bar', () => { + FluentUIComponents.MessageBarDefinition.define(registry); + }); + defineOnce('fluentui:web-components:progress-bar', () => { + FluentUIComponents.ProgressBarDefinition.define(registry); + }); + defineOnce('fluentui:web-components:radio', () => { + FluentUIComponents.RadioDefinition.define(registry); + }); + defineOnce('fluentui:web-components:radio-group', () => { + FluentUIComponents.RadioGroupDefinition.define(registry); + }); + defineOnce('fluentui:web-components:rating-display', () => { + FluentUIComponents.RatingDisplayDefinition.define(registry); + }); + defineOnce('fluentui:web-components:slider', () => { + FluentUIComponents.SliderDefinition.define(registry); + }); + defineOnce('fluentui:web-components:spinner', () => { + FluentUIComponents.SpinnerDefinition.define(registry); + }); + defineOnce('fluentui:web-components:switch', () => { + FluentUIComponents.SwitchDefinition.define(registry); + }); + defineOnce('fluentui:web-components:tab', () => { + FluentUIComponents.TabDefinition.define(registry); + }); + defineOnce('fluentui:web-components:tablist', () => { + FluentUIComponents.TablistDefinition.define(registry); + }); + defineOnce('fluentui:web-components:text-area', () => { + FluentUIComponents.TextAreaDefinition.define(registry); + }); + defineOnce('fluentui:web-components:text', () => { + FluentUIComponents.TextDefinition.define(registry); + }); + defineOnce('fluentui:web-components:text-input', () => { + FluentUIComponents.TextInputDefinition.define(registry); + }); + defineOnce('fluentui:web-components:toggle-button', () => { + FluentUIComponents.ToggleButtonDefinition.define(registry); + }); + defineOnce('fluentui:web-components:tooltip', () => { + FluentUIComponents.TooltipDefinition.define(registry); + }); + defineOnce('fluentui:web-components:tree', () => { + FluentUIComponents.TreeDefinition.define(registry); + }); + defineOnce('fluentui:web-components:tree-item', () => { + FluentUIComponents.TreeItemDefinition.define(registry); + }); } } diff --git a/src/Core.Scripts/src/RegistrationState.ts b/src/Core.Scripts/src/RegistrationState.ts new file mode 100644 index 0000000000..31f855a905 --- /dev/null +++ b/src/Core.Scripts/src/RegistrationState.ts @@ -0,0 +1,28 @@ +export type RegistrationState = { + definedKeys: Set; +}; + +export function getRegistrationState(): RegistrationState { + const scope = globalThis as typeof globalThis & { + __fluentUIBlazorDefinedKeys__?: RegistrationState; + }; + + if (!scope.__fluentUIBlazorDefinedKeys__) { + scope.__fluentUIBlazorDefinedKeys__ = { + definedKeys: new Set() + }; + } + + return scope.__fluentUIBlazorDefinedKeys__; +} + +export function defineOnce(key: string, callback: () => void) { + const state = getRegistrationState(); + + if (state.definedKeys.has(key)) { + return; + } + + callback(); + state.definedKeys.add(key); +} diff --git a/src/Core/Microsoft.FluentUI.AspNetCore.Components.csproj b/src/Core/Microsoft.FluentUI.AspNetCore.Components.csproj index 81fbacf1af..08dbd6416d 100644 --- a/src/Core/Microsoft.FluentUI.AspNetCore.Components.csproj +++ b/src/Core/Microsoft.FluentUI.AspNetCore.Components.csproj @@ -104,7 +104,7 @@ - + + --> + --> + --> + --> + --> - + - <_EsprojAssetsToFix Include="@(StaticWebAsset)" - Condition="'%(StaticWebAsset.OriginalItemSpec)' != '' AND $([System.String]::Copy('%(StaticWebAsset.OriginalItemSpec)').EndsWith('.esproj'))" /> + <_EsprojAssetsToFix Include="@(StaticWebAsset)" Condition="'%(StaticWebAsset.OriginalItemSpec)' != '' AND $([System.String]::Copy('%(StaticWebAsset.OriginalItemSpec)').EndsWith('.esproj'))" />