Grafici con WPF e LINQ

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.

history_graph

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.

interpolared_graph not_interpolared_graph
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.

RoomDiagram

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.

StepGraph

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.

linq_code

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:

clone_values

Sembra tutto perfetto e sembra che sia giunto il momento di dare in pasto i punti alla funzione di disegno ma riflettiamo un attimo:

linq

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.

extract_previous_value

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.

wpf_coordinate_system graph_coordinate_system
Coordinate 2D WPF Coordinate 2D convenzionali

Vediamo come convertire facilmente le coordinate WPF nel sistema convenzionale tramite poche righe di XAML:

 wpf_conventional_coordinate

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.

wpf_line conventional_line
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.

grid_background

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.

background_brush 

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.

white_grid 

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.

GeometryDrawing

Creiamo ora un UserControl “StepGraph.xaml” e applichiamo uno sfondo nero al controllo appena creato.

background_graph

Inseriamo una griglia (Grid) e incolliamo il codice scritto in precedenza per impostarne lo sfondo “quadrettato”.

grid

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.

non_clipped_graph clipped_graph
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.

polyline

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.

normalize_point

Il metodo chiama altri due metodi dai nomi molto intuitivi, ConvertTimeToX e ConvertValueToY

Convert

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:

drawgraph

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.

usercontrol_resize

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. 

antialiased no_antialiased Aliased e UseLayoutRounding
Grafico con bordi Anti-aliased Grafico con bordi Aliased Grafico con bordi Aliased e UseLayoutRounding=true

 renderoptions

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.

Print | posted on lunedì 10 novembre 2008 14:34

Comments on this post

# re: Grafici con WPF e LINQ

Requesting Gravatar...
Molto bello ed interessante. Complimenti.
Left by Pietro Libro on nov 10, 2008 1:54

# re: Grafici con WPF e LINQ

Requesting Gravatar...
Forte!!! :D
Left by Dario Santarelli on nov 10, 2008 4:43

# re: Grafici con WPF e LINQ

Requesting Gravatar...
Davvero un gran bell'articolo! keep up posting as always ;)
Left by Daevil on giu 06, 2009 3:23

# Grafici con WPF e LINQ

Requesting Gravatar...
Grafici con WPF e LINQ
Left by Il blog di Leonardo on ago 17, 2010 4:26

# re: Grafici con WPF e LINQ

Requesting Gravatar...
sei il numero 1 leo!
Left by pietro on nov 11, 2010 1:35

# re: Grafici con WPF e LINQ

Requesting Gravatar...
Grazie Leonardo, me ne sono accorto dopo aver fatto la domanda =)
Left by Nicola on mag 25, 2011 9:43

# re: Grafici con WPF e LINQ

Requesting Gravatar...
Cool man! Insightful.
Left by Eric Pang on set 01, 2011 7:33

Your comment:

 (will show your gravatar)
 
Please add 8 and 5 and type the answer here: