Un problema molto comune durante lo sviluppo di applicazioni è la visualizzazione dei dati. Windows Presentation Foundation non fornisce grafici già pronti e sebbene esistano diversi controlli gratuiti di terze parti vedremo come costruirne uno “from scratch” (da zero).
L’obiettivo che ci prefiggiamo è creare un grafico personalizzabile, efficiente e riutilizzabile facilmente nelle nostre applicazioni.
Il grafico servirà a visualizzare la temperatura di una stanza in un certo intervallo di tempo, non volendo visualizzare valori linearmente interpolati visualizzeremo le temperature con un grafico a gradini (o Step Graph in inglese) invece che a linee.
Un grafico a gradini è un grafico che assomiglia a dei gradini perché i valori sono uguali per un intervallo, poi cambiano all’intervallo successivo. Ogni valore può essere visto come una linea orizzontale connessa ai valori adiacenti tramite una linea verticale.
|
|
Grafico Lineare |
Grafico a gradini |
Progettiamo le nostre entità
Il nostro programma dovrà visualizzare la temperatura di una stanza in diversi istanti quindi creeremo innanzitutto una classe Room (per convenzione nel codice sorgente la lingua ufficiale sarà inglese).
La nostra stanza dovrà avere: una descrizione per poterla individuare (creeremo la proprietà Description di tipo string), l’ultima temperatura rilevata (per il nostro scopo il tipo double andrà più che bene, volendo maggiore controllo potremmo creare un tipo Temperature contenente funzioni di conversione tra le varie scale) e una cronologia delle temperature rilevate in precedenza.
Per tenere una cronologia di valori creeremo una classe riutilizzabile che conterrà una collezione di oggetti aventi un valore e una data.
Creiamo una generica struttura HistoryRecord che rappresenterà il valore di un oggetto generico in un determinato istante, ideale per il nostro scopo.
Per tenere il più semplice possibile quest’articolo è stato scelto il tipo double per la proprietà RecordValue. Utilizzando i Generics (o il tipo object) è possibile creare del codice adattabile a diversi scenari, ad es. è possibile utilizzare la classe per memorizzare il valore di stringhe di testo al posto di numeri.
Creiamo ora una collezione di HistoryRecord chiamata HistoryCollection utilizzando come classe base ObservableCollection per aggiungere automaticamente il supporto alle notifiche quando gli oggetti della collezione saranno aggiunti, rimossi o quando la lista sarà aggiornata.
La nostra “infrastruttura di base” è pronta, passiamo ora alla progettazione del grafico.
Progettiamo il grafico
Il grafico non sarà “onnifunzione”, per lo scopo che ci siamo prefissati dovrà:
- Visualizzare la temperatura della stanza in un certo intervallo di tempo e di valori tramite un grafico a gradini
- Essere ridimensionabile
- Aggiornarsi ad un intervallo definito e poter “rimanere fisso”
Per facilitare la comprensione del codice non sarà creata una classe base generica per diversi tipi di grafico ma il controllo verrà implementato direttamente ereditando dalla classe UserControl.
Per raggiungere i nostri obiettivi il nostro grafico avrà bisogno di una serie di dati che gli forniremo attraverso delle proprietà:
Proprietà |
Scopo |
Tipo |
History |
Visualizzare le temperature |
HistoryCollection |
FromTime e ToTime |
Mostrare un intervallo di tempo |
DateTime |
Minimum e Maximum |
Definire un intervallo di valori |
double |
LiveUpdate |
“Congelare” il grafico |
bool |
UpdateInterval |
Definire un intervallo di aggiornamento |
TimeSpan |
I dati grezzi, provenienti nel nostro caso dalla proprietà TemperatureHistory della nostra stanza tramite Databinding, saranno passati alla proprietà History del grafico che provvederà allo scatenarsi dell’intervallo di aggiornamento a estrarre i dati utili per la visualizzazione dell’intervallo definito.
Guardiamo con più dettaglio come effettuare la selezione dei dati con LINQ.
Estrazione dei dati da una collezione con LINQ
Prima di tutto estraiamo i valori da visualizzare (assumendo che siano ordinati cronologicamente), ovvero quelli compresi tra FromTime e ToTime, tramite LINQ (una sitassi simile a SQL) in un Buffer temporaneo dove “attingeremo” successivamente per ridisegnare il grafico anche in caso di ridimensionamento.
A prima vista potremmo aver finito ma non scordiamo che i punti provengono da una cronologia “lineare” mentre noi vogliamo visualizzare ogni valore come una linea orizzontale connessa ai valori adiacenti tramite una linea verticale.
Occorre dunque un’azione correttiva:
Sembra tutto perfetto e sembra che sia giunto il momento di dare in pasto i punti alla funzione di disegno ma riflettiamo un attimo:
Se il rettangolo rosso rappresentasse l’intervallo di tempo da visualizzare, come farebbe il nostro programma a visualizzare il segmento cerchiato in giallo e dare la rappresentazione desiderata ai nostri dati se come primo punto conosce il punto cerchiato in blu? (il punto nel cerchio giallo è ottenuto per clonazione del valore precedente solamente durante l’operazione di disegno)
Per risolvere il problema dobbiamo estrarre il valore precedente al primo rientrante nell’intervallo di tempo visualizzato e aggiungerlo nei valori da disegnare.
Nota: se la proprietà ToTime viene impostata ad un valore superiore al TimeStamp dell'ultimo elemento dell'History si otterrà un effetto "autoaggiornante" dell'ultimo tratto del grafico non potendo prevedere il valore che assumerà l'elemento successivo.
Scendiamo adesso sotto il cofano del nostro controllo, nelle viscere delle routine di disegno.
Sistema di Coordinate 2D di WPF
In WPF l’origine (0,0) è situata nell’angolo in alto a sinistra dell’area di rendering, l’asse delle X punta a destra e l’asse delle Y punta verso il basso.
Nei grafici è però utilizzato un sistema di coordinate convenzionale in cui l’asse delle X punta sempre a destra ma l’asse delle Y punta versa l’alto.
|
|
Coordinate 2D WPF |
Coordinate 2D convenzionali |
Vediamo come convertire facilmente le coordinate WPF nel sistema convenzionale tramite poche righe di XAML:
La griglia e il suo contenuto, in questo caso la linea che va dall’origine (0,0) al punto di coordinate (25,25), viene capovolta verticalmente tramite la proprietà LayoutTransform e quindi normalizzata nelle coordinate convenzionali.
|
|
Linea prima della normalizzazione |
Linea dopo la normalizzazione |
L’origine per gli elementi della griglia è ora in basso a sinistra come desideravamo.
Disegniamo una griglia per lo sfondo del nostro grafico
Passiamo ora alla costruzione della griglia che costituirà lo sfondo del grafico.
La griglia dovrà visualizzare delle linee equidistanti nello sfondo (che formano tanti piccoli quadrati), disegniamole tramite degli oggetti GeometryDrawing (oggetti che definiscono forme geometriche) “applicati” ad un DrawingBrush.
Innanzitutto applichiamo alla griglia (che non ha ne righe ne colonne impostate) uno sfondo di tipo DrawingBrush, un’area che può visualizzare forme, testo, video e immagini in cui disegniamo due forme geometriche ripetutamente (impostando la proprietà TileMode=”Tile”) in modo tale da riempire tutta l’area.
PERFORMANCE: Impostando TileMode="Tile" perderemo però gran parte dei benefici di accelerazione Hardware, in scenari ad alte prestazioni è più conveniente disegnare tramite codice la griglia di sfondo
Aprendo il progetto con Expression Blend e selezionando la griglia possiamo vedere un’anteprima dell’effetto delle varie combinazioni di TileMode.
Le proprietà Viewport e ViewportUnits servono sostanzialmente per indicare le dimensioni di un “quadrato” della nostra griglia e a specificare se esse sono relative all’area di output.
Soffermiamoci per un attimo sugli oggetti GeometryDrawing disegnati, definiscono una forma tramite la proprietà Geometry che si avvale di una sintassi particolare chiamata StreamGeometry, un vero e proprio minilinguaggio che può portare alla memoria la vecchia tartarughina del Logo che a molti di noi ha insegnato a programmare.
Vediamo i comandi principali di cui avremo bisogno:
Comando |
Descrizione |
Sintassi |
Esempio |
Move |
Muove una matita virtuale nel punto specificato |
M punto |
M0,0 posiziona la matita al punto 0,0 |
Line |
Disegna una linea dal punto corrente al punto specificato |
L punto |
L5,5 disegna una linea dal punto corrente al punto 5,5 |
Close |
Termina la figura, collega il punto iniziale al punto finale |
Z |
|
Quando si inseriscono più comandi uguali è possibile evitare di ripetere la sintassi il comando, ad es. L10,10 20,30 è uguale a L10,20 20,30. Per una descrizione completa dei comandi disponibili rimando a Sintassi di markup del percorso.
Le forme che abbiamo disegnato avranno quindi l’aspetto di due rettangoli (nella figura il primo oggetto GeometryDrawing è disegnato in verde mentre il secondo è disegnato in rosso per una più facile distinzione) che ripetuti formeranno la nostra griglia.
Creiamo ora un UserControl “StepGraph.xaml” e applichiamo uno sfondo nero al controllo appena creato.
Inseriamo una griglia (Grid) e incolliamo il codice scritto in precedenza per impostarne lo sfondo “quadrettato”.
Adesso siamo pronti per entrare nel “cuore” del grafico.
Disegniamo le linee del nostro grafico
Per disegnare il nostro grafico useremo il controllo Polyline che permette di disegnare una serie di line connesse.
PERFORMANCE: Il controllo Polyline supporta Binding, Stili, Animazioni etc.. e può risultare poco performante in situazioni dove queste caratteristiche non sono richieste. Un'alternativa più performante consiste nell'utilizzare il controllo Path e disegnare il grafico tramite PathGeometry se dinamico o StreamGeometry se statico
Aggiungiamo alla griglia del nostro UserControl l’oggetto Polyline e ottimizziamo lievemente le performance spostando la trasformazione della scala dalla griglia all’oggetto Polyline, sarà evitata la trasformazione alla griglia che in effetti non ci interessa.
Notiamo però che se l’intervallo di visualizzazione dei valori viene abbassato sotto al valore minimo (o alzato sopra al valore massimo) l’oggetto Polyline “esce” dal grafico.
|
|
Polyline con ClipToBounds=false |
Polyline con ClipToBounds=true |
Impostiamo la proprietà ClipToBounds a true per correggere lo “straripamento” del controllo e ritagliare le parti uscenti dall’area di visualizzazione. Per poter funzionare correttamente ClipToBounds necessita che l'altezza del controllo sia impostata, colleghiamola quindi all'altezza effettiva dell'UserControl.
Come prima cosa ora dovremo normalizzare le coordinate dei punti per adattarle all’altezza, lunghezza e all’intervallo verticale visualizzato dal grafico.
Centralizzeremo tutte queste operazioni in un metodo chiamato NormalizePoint.
Il metodo chiama altri due metodi dai nomi molto intuitivi, ConvertTimeToX e ConvertValueToY
La matematica dietro a questi metodi è molto semplice e si limita a effettuare una serie proporzioni (viene sottratto 1 all’altezza e alla larghezza per visualizzare i valori massimi correttamente).
Per migliorare le performance dell’espressione di conversione da un valore ad una coordinata Y del grafico è stata effettuata una semplificazione matematica, trovate comunque l’espressione originale commentata.
Passiamo alla routine di disegno, che verrà chiamata ad ogni intervallo:
La routine è molto semplice, accetta in ingresso una collezione di punti (il BackBufferHistory) e gli aggiunge ad una collezione di punti (di tipo PointCollection) che verrà poi assegnata alla Polyline.
Rifiniamo il controllo aggiornando immediatamente il grafico al ridimensionamento, evitando così di attendere fino al prossimo ridisegno.
UPDATE .NET 4.0 17/08/2010:
UPDATE PERFORMANCE TIPS 20/09/2010:
Per concludere miglioriamo la resa a video disabilitando l’Anti-Aliasing dei bordi degli oggetti per aumentare la nitidezza della linea del grafico del nostro controllo e abilitiamo UseLayoutRounding per eliminare la sfuocatura dalla griglia di sfondo.
|
|
|
Grafico con bordi Anti-aliased |
Grafico con bordi Aliased |
Grafico con bordi Aliased e UseLayoutRounding=true |
Grazie al nuovo rendering del testo di WPF 4.0 possiamo rendere i testi della finestra principale più leggibili impostando TextOptions.TextFormattingMode="Display".
|
|
Testo con impostazioni di default |
Testo con TextOptions.TextFormattingMode="Display" |
Il controllo è adesso completato. Obiettivo raggiunto.
Scarica il codice sorgente dell’articolo (aggiornato al 17/08/2010, formato Visual Studio 2010)
NB: Per ragioni di spazio lascio ai lettori l’analisi dei dettagli non trattati nell’articolo. Nei sorgenti allegati troverete un’applicazione completa che genera valori casuali per il grafico (a intervalli anch’essi casuali) e permette di modificare tramite interfaccia grafica tutti i parametri del controllo.