Articoli

Creare un grafico a torta con Blazor

da

Che tu stia sviluppando una dashboard per il tuo cliente o presentando dei dati nel tuo blog, sicuramente sarai andato alla ricerca di una soluzione per disegnare dei diagrammi a torta all’interno della tua pagina.

Online possiamo trovare molte librerie già pronte: chart.js, gojs, plotly.js sono alcune delle opzioni pre-costruite che il web ci mette a disposizione. Integrare questi componenti JavaScript pre-esistenti all’interno del nostro applicativo Blazor può talvolta rivelarsi tutt’altro che una passeggiata, e l’aggiunta di ulteriori pacchetti NuGet potrebbe andare ad aggiungere ulteriore overhead e latenza alla nostra applicazione. Ha senso tutto questo solo per disegnare un diagramma a torta?

Le potenzialità di Blazor non si limitano solamente all’HTML, ma possiamo utilizzare il nostro framework preferito anche per creare delle componenti SVG da includere nelle nostre pagine.

In questa breve guida vediamo insieme come sfruttare Blazor ed il tag SVG per creare un componente per disegnare un diagramma a torta.

Le immagini SVG

Un SVG (Scalable Vector Graphics) è un’immagine vettoriale descrivibile attraverso un linguaggio di markup basato su XML. Le immagini SVG sono generalmente salvate con l’estensione .svg e possono essere renderizzate all’interno del browser come qualsiasi altra immagine, sfruttando il tag HTML <img>.

Un’altra possibilità per includere un’immagine vettoriale SVG all’interno della nostra pagina è utilizzando il tag <svg>. Tra il tag di apertura e quello di chiusura possiamo infatti scrivere tutti i contenuti specifici dell’immagine.

Creiamo il nostro componente

Creiamo innanzitutto il nostro progetto. Per questo tutorial ho creato una solution che ho chiamato “BlazorPieChart” con .NET7, sfruttando il template Blazor Server App Empty in Visual Studio Community.

All’interno del solution explorer creiamo una nuova cartella Components ed un file PieChart.razor

A questo punto, possiamo cominciare a creare il nostro componente.

Cancelliamo tutto il contenuto del file `PieChart.razor` e scriviamo quanto segue:

<svg viewBox="0 0 100 100">
    <!-- svg content -->
</svg>

In questo modo creiamo un contenitore svg all’interno del quale possiamo definire la nostra immagine. Al tag di apertura dell’SVG ho aggiunto l’attributo viewBox. La view box definisce la scala delle coordinate dell’immagine vettoriale. Il valore di questo attributo è una sequenza di 4 numeri che indicano rispettivamente min-xmin-ywidthheight dell’immagine. In questo caso abbiamo definito un’immagine che ha come coordinata minima (angolo in alto a sinistra) il punto (0, 0), ha larghezza 100 punti ed altezza 100 punti.

All’interno del file Pages/Index.razor cancelliamo tutto e scriviamo quanto segue:

@page "/"
@using BlazorPieChart.Components;

<div style="width: 300px; border: 1px solid black">
    <PieChart />
</div>

Se lanciamo ora l’applicazione, il risultato è una cornice nera ben poco entusiasmante:

Sì noti che il tag SVG, se non ha gli attributi width o height impostati, occupa tutto lo spazio disponibile. Possiamo quindi inserire il componente all’interno di un tag <div> che ne definisce la larghezza massima. L’SVG scala come conseguenza, mantenendo l’aspect-ratio.

Torniamo ora nel file Components/PieChart.razor e cominciamo a definire il grafico.

Disegniamo una fetta di torta

Per definire una fetta del diagramma a torta, possiamo utilizzare il tag <path>. Questo tag consente la definizione di un percorso attraverso una sequenza di comandi.

Possiamo immaginare un cursore all’interno del SVG che si sposta e disegna sulla base dei comandi che definiamo.

I comandi sono definibili all’interno dell’attributo d del tag path. Ogni comando è definito da un carattere, che ne identifica la tipologia, seguito da una sequenza di argomenti numerici. Ogni comando ha argomenti specifici.

Al termine di ogni comando la posizione del cursore è sempre aggiornata all’ultimo punto definito dal comando stesso.

In questa tabella sono riportati alcuni dei comandi che ci possono essere utili per definire una fetta del nostro grafico.

ComandoParametriEffetto
Mx ySposta il cursore alla posizione (x, y)
Lx yDisegna una linea retta dalla posizione corrente fino a (x, y)
Acx cy a laf sf x yDisegna un arco di circonferenza centrato in (cx, cy) ruotato di a gradi rispetto all’asse x, fino al punto (x, y)laf (large angle flag) è 1 se l’angolo è maggiore o uguale a 180°, 0 altrimenti, sf (sweep flag) è 1 se l’arco è da intendersi in senso orario, 0 se in senso antiorario
ZChiude il percorso

L’elenco esaustivo dei comandi per la definizione di un path SVG è disponibile nella documentazione di Mozilla, al link https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/d

Proviamo quindi ora a definire una fetta di 90° del nostro diagramma a torta. Dobbiamo definire un percorso che segua questi comandi:

  • Mi sposto al centro dell’immagine (che è di 100 punti x 100 punti): M 50 50
  • Disegno una linea fino al margine destro: L 100 50
  • Creo un arco di circonferenza centrato nel centro dell’immagine, fino al margine superiore dell’immagine al punto (50, 0). Posso utilizare il comando Aa è 0, laf è 0 perché l’angolo è minore di 180°, sf è 0 perché voglio disegnare l’angolo in senso antiorario: A 50 50 0 0 0 50 0
  • Torno al centro dell’immagine tracciando una linea: L 50 50
  • Chiudo il percorso: Z

La sequenza completa del percorso sarà quindi M 50 50 L 100 50 A 50 50 0 0 0 50 0 L 50 50 Z.

Aggiorniamo il codice di Components/PieChart.razor con

<svg viewBox="0 0 100 100">
    <path fill="green" d="M 50 50 L 100 50 A 50 50 0 0 0 50 0 L 50 50 Z" />
</svg>

Il risultato dovrebbe ora assomigliare a qualcosa tipo questo:

Fantastico!

Ora possiamo cominciare a parametrizzare questa fetta di torta, andando a definire la percentuale da cui partire con il disegno e la percentuale a cui arrivare.

Per aiutarci in questo, possiamo definire la sezione @code nel file Components/PieChart.razor (se non è già definita), e creare una funzione privata che ci calcoli la coppia di valori (x, y) data una percentuale:

private static (float x, float y) ComputeCoordinatesForValue(float val)
{
    var (sin, cos) = MathF.SinCos(val * MathF.Tau);

    var x = cos * 50 + 50;
    var y = -sin * 50 + 50;

    return (x, y);
}

MathF.Tau corrisponde a 2 * MathF.PI

Creiamo anche una funzione per poterle formattare correttamente dentro alla definizione di un path:

private static string FormatCoordinates((float x, float y) coordinates)
{
    var (x, y) = coordinates;
    var culture = System.Globalization.CultureInfo.InvariantCulture;
    
    return$"{x.ToString(culture)} {y.ToString(culture)}";
}

Aggiorniamo ora la parte SVG per utilizzare le funzioni che abbiamo appena definito.

<svg viewBox="0 0 100 100"><path fill="green" d="
        M 50 50 
        L @FormatCoordinates(ComputeCoordinatesForValue(0f)) 
        A 50 50 0 0 0 @FormatCoordinates(ComputeCoordinatesForValue(0.25f))
        L 50 50 
        Z
    " /></svg>

Il nostro file Components/PieChart.razor a questo punto dovrebbe essere simile a questo

<svg viewBox="0 0 100 100">
    <path fill="green" d="
        M 50 50 
        L @FormatCoordinates(ComputeCoordinatesForValue(0f)) 
        A 50 50 0 0 0 @FormatCoordinates(ComputeCoordinatesForValue(0.25f))
        L 50 50 
        Z
    " />
</svg>

@code {

    private static (float x, float y) ComputeCoordinatesForValue(float val)
    {
        var (sin, cos) = MathF.SinCos(val * MathF.Tau);

        var x = cos * 50 + 50;
        var y = -sin * 50 + 50;

        return (x, y);
    }

    private static string FormatCoordinates((float x, float y) coordinates)
    {
        var (x, y) = coordinates;
        var culture = System.Globalization.CultureInfo.InvariantCulture;
        
        return $"{x.ToString(culture)} {y.ToString(culture)}";
    }

}

Se rilanciamo l’applicativo in questo momento, non dovremmo notare alcuna differenza rispetto alla visualizzazione precedente. Tuttavia, l’approccio ora è completamente diverso: possiamo infatti disegnare fette di qualsiasi percentuale, semplicemente modificando il valore che passiamo come parametro al metodo ComputeCoordinatesForValue.

Procediamo ora a creare una funzione per creare la sequenza dei comandi per il path svg:

static string CreatePieSlicePathDefinition(float fromValue, float toValue)
{
    var amplitude = toValue - fromValue;
    var isLargeAngle = amplitude > .5f;

    return$@"
        M 50 50
        L {FormatCoordinates(ComputeCoordinatesForValue(fromValue))}
        A 50 50 0 {(isLargeAngle ? '1' : '0')} 0 {FormatCoordinates(ComputeCoordinatesForValue(toValue))}
        L 50 50
        Z
    ";
}

e semplifichiamo il codice HTML come segue:

<svg viewBox="0 0 100 100">
    <path fill="green" d="@CreatePieSlicePathDefinition(0f, 0.25f)" />
</svg>

Disegnamo un diagramma a torta

Possiamo ora creare tutte le fette del nostro diagramma a torta sfruttando la generazione della path definition grazie al metodo che abbiamo appena implementato:

<svg viewBox="0 0 100 100">
    <path fill="green" d="@CreatePieSlicePathDefinition(0f, 0.25f)" />
    <path fill="yellow" d="@CreatePieSlicePathDefinition(0.25f, 0.7f)"/>
    <path fill="red" d="@CreatePieSlicePathDefinition(0.7f, 0.85f)" />
    <path fill="blue" d="@CreatePieSlicePathDefinition(0.85f, 1f)" />
</svg>

Parametrizziamo il componente

Il componente accetterà come parametro una collezione IEnumerable di oggetti tipizzati.

Definiamo la classe ChartItem dentro ad una nuova cartella Models, in un nuovo file ChartItem.cs

publicclassChartItem
{
    public string Label { get; set; }
    public string HtmlColor { get; set; }
    public float Value { get; set; }
}

Questa classe prevede 3 proprietà:

  • Label – L’etichetta per la fetta del diagramma a torta. La mostreremo all’interno della legenda (che vedremo poi)
  • HtmlColor – Il colore della fetta
  • Value – Il valore che quella fetta rappresenta. Il componente calcolerà la dimensione della fetta del diagramma a torta internamente.

Torniamo al file Components/PieChart.razor ed aggiungiamo il parametro.

[Parameter]
public IEnumerable<Models.ChartItem> ChartItems { get; set; }

Creiamo quindi il metodo che trasforma il parametro in una sequenza di path definitions rappresentabili

private IEnumerable<(string color, string pathDefinition)> GetPieSlicesPathDefinitions()
{
    if (ChartItems isnull || !ChartItems.Any())
        yieldbreak;

    var elements = ChartItems.ToArray();
    var sumOfValues = ChartItems.Sum(ci => ci.Value);

    float fromValue = 0;
    float toValue = 0;

    foreach(var element in elements)
    {
        fromValue = toValue;
        toValue = toValue + element.Value / sumOfValues;

        yield return (element.HtmlColor, CreatePieSlicePathDefinition(fromValue, toValue));
    }
}

Modifichiamo infine il codice HTML in

<svg viewBox="0 0 100 100">
    @foreach(var (color, pathDefinition) in GetPieSlicesPathDefinitions())
    {
        <path fill="@color" d="@pathDefinition" />
    }
</svg>

Qui di seguito il codice completo del file Components/PieChart.razor

<svg viewBox="0 0 100 100">
    @foreach(var (color, pathDefinition) in GetPieSlicesPathDefinitions())
    {
        <path fill="@color" d="@pathDefinition" />
    }
</svg>

@code {

    [Parameter]
    public IEnumerable<Models.ChartItem> ChartItems { get; set; }

    private IEnumerable<(string color, string pathDefinition)> GetPieSlicesPathDefinitions()
    {
        if (ChartItems is null || !ChartItems.Any())
            yield break;

        var elements = ChartItems.ToArray();
        var sumOfValues = ChartItems.Sum(ci => ci.Value);

        float fromValue = 0;
        float toValue = 0;

        foreach(var element in elements)
        {
            fromValue = toValue;
            toValue = toValue + element.Value / sumOfValues;

            yield return (element.HtmlColor, CreatePieSlicePathDefinition(fromValue, toValue));
        }
    }

    static string CreatePieSlicePathDefinition(float fromValue, float toValue)
    {
        var amplitude = toValue - fromValue;
        var isLargeAngle = amplitude > .5f;

        return $@"
            M 50 50
            L {FormatCoordinates(ComputeCoordinatesForValue(fromValue))}
            A 50 50 0 {(isLargeAngle ? '1' : '0')} 0 {FormatCoordinates(ComputeCoordinatesForValue(toValue))}
            L 50 50
            Z
        ";
    }

    private static (float x, float y) ComputeCoordinatesForValue(float val)
    {
        var (sin, cos) = MathF.SinCos(val * MathF.Tau);

        var x = cos * 50 + 50;
        var y = -sin * 50 + 50;

        return (x, y);
    }

    private static string FormatCoordinates((float x, float y) coordinates)
    {
        var (x, y) = coordinates;
        var culture = System.Globalization.CultureInfo.InvariantCulture;
        
        return $"{x.ToString(culture)} {y.ToString(culture)}";
    }
}

Modifichiamo ora il file Pages/Index.razor per passare anche i parametri del nostro componente. Per il test utilizzerò dei dati cablati a codice, direttamente in Index.razor

@page "/"
@using BlazorPieChart.Components;
@using BlazorPieChart.Models;

<div style="width: 300px; border: 1px solid black">
    <PieChart ChartItems="items" />
</div>

@code {
    private static readonly ChartItem[] items = new ChartItem[]
    {
        new Models.ChartItem
        {
            Label = "Elemento 1",
            Value = 42,
            HtmlColor = "hsl(240, 100%, 30%)"
        },
        new Models.ChartItem
        {
            Label = "Elemento 2",
            Value = 83,
            HtmlColor = "hsl(240, 100%, 40%)"
        },
        new Models.ChartItem
        {
            Label = "Elemento 3",
            Value = 23,
            HtmlColor = "hsl(240, 100%, 50%)"
        },
    };
}

Come ogni grafico che si rispetti, aggiungiamo infine la legenda per i nostri dati. Creiamo quindi un componente ChartLegend.razor all’interno della cartella Components con il seguente codice:

<table>
    <thead>
        <tr>
            <th colspan="3">Legenda</th>
        </tr>
    </thead>
    <tbody>
        @foreach (var item in ChartItems ?? Enumerable.Empty<Models.ChartItem>())
        {
            <tr>
                <td>
                    <div style="width: 24px; height: 24px; background-color: @item.HtmlColor"></div>
                </td>
                <td>@item.Label:</td>
                <td>@item.Value</td>
            </tr>
        }
    </tbody>
</table>

@code {
    [Parameter]
    public IEnumerable<Models.ChartItem> ChartItems { get; set; }
}

Aggiungiamo quindi anche quest’ultimo componente alla nostra pagina Index.razor:

<div style="width: 300px; border: 1px solid black">
    <PieChart ChartItems="items" />
    <ChartLegend ChartItems="items" />
</div>

Et voilla!, ecco qui un diagramma a torta scritto completamente in Blazor, senza bisogno di aggiungere alcuna libreria esterna.

Le potenzialità di questo approccio non si limitano qui. E’ possibile impostare anche gli attributi idclass, … pure per gli elementi delle SVG. Possiamo impostare gli event handler, per reagire alle interazioni con l’utente, e possiamo applicare degli stili utilizzando CSS.

Considerazioni

Per la natura del comando A dei path SVG, un grafico a torta con un solo elemento da rappresentare non è visualizzabile. Si può raggirare il problema forzando il disegno di una circonferenza in SVG nel caso in cui sia passata come parametro una collezione con un solo elemento.

<svg viewBox="0 0 100 100">
    @if(ChartItems is not null && ChartItems.Count() == 1)
    {
        <circle fill="@ChartItems.First().HtmlColor" cx="50" cy="50" r="50" />
    }
    else 
    {
        @foreach(var (color, pathDefinition) in GetPieSlicesPathDefinitions())
        {
            <path fill="@color" d="@pathDefinition" />
        }
    }
</svg>

Conclusione

In questo breve tutorial ho voluto mostrare una possibilità di utilizzo di Blazor un po’ diversa dal solito. In relativamente poche righe siamo riusciti a creare un diagramma a torta totalmente personalizzabile utilizzando solamente codice C#.

Il codice completo presentato in questo articolo è anche disponibile su GitHub: https://github.com/nicolaparo/blazor-pie-chart.

Scritto da: