V minulém díle tohoto seriálu jsme si ukázali základy vykreslování v rozhraní GDI+ pomocí funkcí ze jmenného prostoru System.Drawing. Dnes v tom budeme pokračovat a zopakujeme některé věci z minulých dílů (načítání souboru). Podle dat v souboru vykreslíme sloupcový graf. Samozřejmě pro vykreslování grafů existuje mnoho jiných způsobů a komponent, ale nás bude zajímat především vykreslování. Máme toho před sebou poměrně dost, takže nezbývá, než začít.
Začínáme
Vytvořte si nový projekt, formuláři nastavte vlastnost FormBorderStyle na hodnotu FixedDialog, vlastnost MaximizeBox na hodnotu False a vlastnost Size na hodnotu 820;650. Tím jsme nastavili velikost formuláře a zakázali jeho roztahování. Do projektu nyní přidáme datový soubor - pravým tlačítkem klikněte na název projektu v podokně Solution Explorer a vyberte z nabídky možnost Add / New Item.... V dialogovém okně pro výběr typu nové položky zvolte typ Text File a pojmenujte jej graf.txt. V okně vlastností mu nastavte hodnotu vlastnosti Copy To Output Directory na CopyAlways. Tím zajistíme, že před spuštěním Visual Studio zkopíruje tento soubor do stejné složky, jako je spustitelný EXE soubor. Bez toho by program soubor nenašel a vyhodil by chybu.
Soubor graf.txt nyní otevřete a vložte do něj tato data:
12
leden
120000
únor
110000
březen
93500
duben
135600
květen
148000
červen
163400
červenec
170000
srpen
172000
září
183200
říjen
193050
listopad
205600
prosinec
235000
První řádek obsahuje celkový počet záznamů v souboru, v našem případě 12. Na dalších řádcích jsou již samotné záznamy. Každý záznam je tvořen názvem období a ziskem v Kč za dané období (každá z informací je na samostatném řádku). V lednu byl tedy zisk 120 000 Kč, v únoru pak 110 000 Kč atd. Údaje jsou samozřejmě vymyšlené.
Odbočka - Struktury
Pokud jste zkoušeli něco programovat sami, možná vás napadlo, že nám obyčejné datové typy (String, Integer atd.) nestačí. Bylo by jistě pěkné a elegantní, kdybychom v každém prvku pole mohli uchovávat více hodnot s různými datovými typy. Mohli bychom pak míti pole Osoby, které by pro každou osobu obsahovalo její jméno(String), příjmení (String) a věk (stačí Byte - hodnoty 0 až 255). Přesně tohle je možné provést pomocí deklarace vlastní struktury.
Strukturu je speciální datový typ složený z více jednodušších datových typů. Aby kompilátor věděl, jaké údaje chceme ve struktuře uchovávat, musíme nejprve strukturu nadeklarovat. Deklarace nesmí být uvnitř procedury ani funkce, vypadá třeba takto (nekopírujte do projektu!):
Structure Osoba
Dim Jmeno, Prijmeni As String
Dim Vek As Byte
End Structure
Její použití pak může vypadat třeba takto (název struktury používáme jako datový typ, k jednotlivým proměnným přistupujeme přes tečku):
Dim os As Osoba
os.Jmeno = "Tomáš"
os.Prijmeni = "Herceg"
os.Vek = 19
Do struktur je možno přidat i deklarace procedur a funkcí, ale to zatím využívat nebudeme.
Načtení datového souboru
Jak asi tušíte, odbočky nejsou v článku pro srandu tvorům s dlouhýma ušima, ale k doplnění teorie, která by se nám mohla hodit. Pokud se podíváme na soubor s daty, uvidíme tam záznamy skládající se ze dvou položek. A právě na ně si vytvoříme strukturu. Tento kód tedy zkopírujte do projektu (hned za řádek Public Class Form1, ne do procedury):
'struktura pro záznam v souboru
Structure Zaznam
Dim Text As String
Dim hodnota As Integer
End Structure
'pole záznamů v grafu
Dim zaznamy() As Zaznam
Vytvořili jsme si tedy pole, v jehož každém prvku uchováváme zisk za období a název tohoto období. Pozastavíme se ještě nad deklarací pole - datový typ je název struktury. Ale nezadali jsme velikost pole, protože ji neznáme. Dali jsme tedy jen (), aby se poznalo, že to je pole. Velikost určíme až v okamžiku, kdy ji budeme znát (až budeme mít načtený soubor).
Dále ještě podotýkám, že pokud proměnnou nadeklarujete mimo proceduru, můžeme tuto proměnnou používat ve všech procedurách v dané třídě (formuláři). Pokud proměnnou nadeklarujete uvnitř procedury, funguje tato proměnná pouze v dané proceduře a po skončení procedury se její hodnota ztratí!
Načítat data ze souboru již umíme, vytvoříme nový objekt StreamReader, předáme mu název souboru a volitelně ještě kódování (pokud soubor vytvoříte ve Visual Studiu a neměnili jste kódování v nastavení nebo při ukládání souboru, mělo by být použito kódování Windows-1250, čili System.Text.Encoding.Default, pokud nefunguje, zkuste System.Text.Encoding.UTF8). Celé načtení souboru tedy provede tento kód v proceduře Form1_Load, který si přidejte do projektu:
Private Sub Form1_Load(ByVal sender As System.Object, ByVal e As System.EventArgs) Handles MyBase.Load
'otevřít soubor pro čtení
Dim sr As New IO.StreamReader("graf.txt", System.Text.Encoding.Default)
'načíst počet záznamů v souboru
Dim pocet As Integer = CInt(sr.ReadLine())
'nadimenzovat pole se záznamy
ReDim zaznamy(pocet - 1)
'projít v cyklu záznamy a načítat hodnoty ze souboru do pole
For i As Integer = 0 To pocet - 1
zaznamy(i).Text = sr.ReadLine() 'načíst popis
zaznamy(i).hodnota = CInt(sr.ReadLine()) 'načíst částku
Next
'zavřít soubor
sr.Close()
End Sub
Nejprve tedy otevřeme soubor pro čtení, pak zjistíme počet záznamů, klíčovým slovem ReDim nastavíme dodatečně velikost pole, protože již ji známe, a pak v cyklu načítáme nejprve popisek a pak částku v daném období. Úplně nakonec datový soubor zavřeme. Máme tedy pole zaznamy s načtenými hodnotami, nezbývá, než vykreslit graf.
Co bude třeba, abychom graf vykreslili?
Protože chceme, aby nám graf optimálně vyplnil plochu formuláře, je dobré znát rozestupy sloupců a nejvyšší sloupec, podle kterého graf roztáhneme na výšku.
Pokud bychom formulář svisle rozřezali na 5 stejných dílů, získáme 4 svislé čáry. A právě na těchto čarách je nejlepší vykreslit sloupce. Rozestup mezi čarami je pak šířka formuláře vydělená počtem dílů, na kolik bychom formulář rozřezali, čili o jednu více, než je sloupců. Pokud je tedy sloupců 12, rozestup je šířka formuláře / 13. To bude vzdálenost mezi středy spodních stran sloupců.
O poznání zajímavější je problém druhý, a to nalezení nejvyššího sloupce. Je to trochu k zamyšlení - jak najít nejvyšší hodnotu v poli hodnot? Provedeme drobný podvod - prohlásíme, že nejvyšší je první sloupec (uložíme si jeho výšku do proměnné max, kde chceme mít výšku toho nejvyššího sloupce). Pak v cyklu projdeme sloupce ostatní a pokud náhodou najdeme nějaký vyšší (vyšší než hodnota max), prohlásíme za nejvyšší tento nový (jeho výšku uložíme do max). Jakmile projdeme všechny sloupce, budeme mít v proměnné max výšku toho nejvyššího sloupce.
A k čemu že nám zjištění hodnoty nejvyššího sloupce bude? Pokud má okno výšku 600 pixelů, řekneme, že nejvyyší sloupec vykreslíme 500 pixelů vysoký. Pokud si pamatujete ještě ze školy trojčlenku, můžeme takto spočítat výšky všech ostatních sloupců:
max (= 100000) ....................... 500 pixelů
67000 ....................... x pixelů
Jelikož se jedná o přímou úměrnost, výšku sloupce s částkou 67 000 Kč v pixelech spočítáme jako x = 67000 / max * 500 pixelů.
Pokud známe rozestup sloupců, jejich pozice bude pak (i + 1) * rozestup, kde i je číslo sloupce (od nuly). Pokud je rozestup třeba 100 pixelů, první sloupec pak bude mít X souřadnici 100, druhý 200 atd. Pokud chceme šířku sloupce třeba 30 pixelů, od spočítané X souřadnice odečteme 15 (protože souřadnice je střed sloupce) a vykreslíme jej (vykreslení totiž nechce střed, ale jeden z rohů).
Private Sub Form1_Paint(ByVal sender As Object, ByVal e As System.Windows.Forms.PaintEventArgs) Handles Me.Paint
e.Graphics.Clear(Color.White)
'zjistit hodnotu nejvyššího sloupce
Dim max As Integer = zaznamy(1).hodnota
For i As Integer = 1 To zaznamy.Length - 1
If zaznamy(i).hodnota > max Then max = zaznamy(i).hodnota
Next
'vykreslit graf
Dim rozestup As Integer = 800 / (zaznamy.Length + 1)
Dim vyska As Integer = 500
For i As Integer = 0 To zaznamy.Length - 1
Dim x As Integer = (i + 1) * rozestup - 15
Dim y As Integer = zaznamy(i).hodnota / max * vyska
e.Graphics.FillRectangle(Brushes.Blue, x, 0, 30, y)
Next
End Sub
Nejprve jsme tedy zjistili částku v nejvyšším sloupci, pak spočítali rozestup (šířka okna děleno počet sloupců zvýšený o 1) a výšku nejvyššího sloupce nastavili na 500. V cyklu pak počítáme proměnnou x (pozici X levého horního rohu sloupce pomocí rozestupů) a proměnnou y (výška sloupce v pixelech). Nakonec sloupce vykreslíme - pozici x známe, pozici y levého horního rohu dáme zatím nula, šířku sloupce 30 a výšku spočítané y.
Tento výsledek není jistě optimální a jistě se s ním nespokojíme, ale základ tam je. Máme již zachovány poměry výšek sloupců, největším problémem je asi to, že graf je otočený vzhůru nohama. V grafech totiž souřadnice Y roste od osy Y nahoru, kdežto na monitoru roste od osy Y dolů.
Určitě by to chtělo graf otočit, řekneme tedy, že spodní strana grafu bude začínat na Y souřadnici 580. Horní levý roh tedy bude o y pixelů výše, kde y je výška sloupce. Změňte tedy příslušný řádek, který vykresluje sloupce, takto:
e.Graphics.FillRectangle(Brushes.Blue, x, 580 - y, 30, y)
Tento graf je již o mnoho lepší, zbývá doplnit popisky měsíců a asi by bylo dobré vypsat nad sloupce jednotlivé částky.
Textové popisky pod sloupci
Ještě jsme si vůbec neukazovali, jak vykreslovat text. Není to nic složitého, ale je tam jeden zádrhel. Objekt Graphics má metodu DrawString, která ale požaduje souřadnice levého horního rohu daného textu, což se nám moc nehodí, protože text potřebujeme vycentrovat na střed sloupce. Existuje ale metoda MeasureString, která nám vrátí rozměry předaného textu. Obě dvě metody vyžadují jako parametr předat font, který se má použít, nejjednodušší je předat Me.Font, čili hodnotu vlastnosti Font aktuálního formuláře.
Díky metodě MeasureString můžeme vytvořit vlastní proceduru DrawText, které předáme souřadnice středu textu, procedura si podle velikosti tohoto textu spočítá jeho rozměry (stačí od souřadnic středu odečíst polovinu šířky resp. výšky textu) a dopočtá souřadnice levého horního rohu. Pak text vykreslí:
Sub DrawText(ByVal g As Graphics, ByVal txt As String, ByVal x As Integer, ByVal y As Integer)
'vykreslit text zadaný středem
Dim velikost As SizeF = g.MeasureString(txt, Me.Font)
Dim s As Integer = velikost.Width / 2
Dim v As Integer = velikost.Height / 2
g.DrawString(txt, Me.Font, Brushes.Black, x - s, y - v)
End Sub
Podotýkám, že v proceduře DrawText používáme proměnnou g, což je objekt Graphics, na který vykreslujeme. Je to jeden z parametrů této procedury, při volání do něj předáváme hodnotu e.Graphics. Ta je totiž dostupná pouze v události Paint, nikde jinde již ne. Proceduře DrawText předáme souřadnice středu vypisovaného textu, metodě DrawString je nutné předat souřadnice levého horního rohu vypisovaného textu.
Pokud tedy přidáme do procedury pro vykreslování volání této funkce pro kreslení textu, můžeme snadno pod sloupce vykreslit názvy měsíců.
Private Sub Form1_Paint(ByVal sender As Object, ByVal e As System.Windows.Forms.PaintEventArgs) Handles Me.Paint
e.Graphics.Clear(Color.White)
'zjistit hodnotu nejvyyššího sloupce
Dim max As Integer = zaznamy(1).hodnota
For i As Integer = 1 To zaznamy.Length - 1
If zaznamy(i).hodnota > max Then max = zaznamy(i).hodnota
Next
'vykreslit graf
Dim rozestup As Integer = 800 / (zaznamy.Length + 1)
Dim vyska As Integer = 500
For i As Integer = 0 To zaznamy.Length - 1
Dim x As Integer = (i + 1) * rozestup
Dim y As Integer = zaznamy(i).hodnota / max * vyska
'vykreslit sloupec
e.Graphics.FillRectangle(Brushes.Blue, x - 15, 580 - y, 30, y)
'vykreslit popisky pod sloupce
DrawText(e.Graphics, zaznamy(i).Text, x, 590)
Next
End Sub
Transformace
Rozhraní GDI+ podporuje tzv. transformace. Před tím, než něco vykreslím, můžu nastavit kombinaci transformací, které nějakým způsobem pozmění vykreslovaný element. V praxi existuje několik jednoduchých transformací - posunutí, otočení a změna měřítka. Jejich použití je poměrně jednoduché.
Náš graf by jistě potřeboval nad každý sloupec umístit danou částku, pokud ji však vykreslíme normálně, budou se částky překrývat (jsou moc široké). Ideální by bylo, kdybychom částky pootočili, nebudou se totiž překrývat.
Potřebujeme tedy rotaci (otočení). Nejjednodušší rotace je rotace kolem počátku, tedy kolem bodu [0, 0]. My bychom potřebovali ale rotovat kolem jiného bodu, což však můžeme krásně vyřešit tak, že text vykreslíme do počátku, pak jej otočíme, a nakonec posuneme tam, kde má správně být. Transformace se totiž dají kombinovat.
Abych to neprotahoval, zde je přetížení procedury DrawText, které podporuje i rotaci. Přidejte jej jako další proceduru do projektu (tu starou nepřepisujte, jedná se o přetížení):
Sub DrawText(ByVal g As Graphics, ByVal txt As String, ByVal x As Integer, ByVal y As Integer, ByVal rotation As Integer)
'vykreslit text zadaný středem a rotací
g.TranslateTransform(x, y)
g.RotateTransform(rotation)
'nakreslit text do počátku
DrawText(g, txt, 0, 0)
'zrušit transformaci
g.ResetTransform()
End Sub
TranslateTransform je posunutí, zadáváme o kolik bodů chceme posunovat ve směru osy X a Y. RotateTransform je rotace, zadáváme úhel ve stupních. Nejprve se vždy nastavují transformace, pak se vykresluje. Nová transformace nezruší tu starou! Po vykreslení je vhodné transformaci zrušit zavoláním ResetTransform, aby se neaplikovala na další vykreslované prvky.
Vykreslení částek nad sloupce a vylepšení celkového dojmu
Pokud tedy dovnitř cyklu přidáme ještě vykreslení otočeného textu o -60 stupňů, vypíší se nad sloupce požadované částky. Ještě můžeme pro sloupce vytvořit štětec s přechodem barev ze světlemodré (nahoře v grafu) na modrou (dole v grafu). Dále jsem ještě založil proměnnou sirka, která podle rozestupu určí polovinu šířky sloupce (aby nebyla šířka sloupce určena pevně, pokud bychom zvětšili počet záznamů, vypadalo by to divně).
Ještě poslední věc - funkce String.Format slouží k dosazování do textu. První parametr je šablona a další parametry jsou dosazované hodnoty (může jich být více). V šabloně se všechny výskyty {0} nahradí první hodnotou, {1} zase druhou hodnotou atd. Takže String.Format("Číslo {0} a {1}.", 15, 32) vrátí Číslo 15 a 32. Pokud za číslo ve složené závorce dáme dvojtečku, můžeme určit formát. Je mnoho parametrů pro určení formátu, nás zajímá hlavně písmeno c, které zformátuje dané číslo jako měnu podle místního nastavení systému. Takže String.Format("{0:c}", 1234) vrátí číslo 1234 jako měnu, čili 1 234,00 Kč. Dosazované parametry nemusí být jen čísla, mohou to být texty, datum atd. Zde je tedy kompletní kód procedury události Paint.
Private Sub Form1_Paint(ByVal sender As Object, ByVal e As System.Windows.Forms.PaintEventArgs) Handles Me.Paint
e.Graphics.Clear(Color.White)
'zjistit hodnotu nejvyyššího sloupce
Dim max As Integer = zaznamy(1).hodnota
For i As Integer = 1 To zaznamy.Length - 1
If zaznamy(i).hodnota > max Then max = zaznamy(i).hodnota
Next
'vykreslit graf
Dim rozestup As Integer = 800 / (zaznamy.Length + 1)
Dim vyska As Integer = 500
Dim sirka As Integer = rozestup * 0.25
Dim pozadi As New Drawing2D.LinearGradientBrush(New Point(0, 60), New Point(0, 600), Color.LightBlue, Color.Blue)
For i As Integer = 0 To zaznamy.Length - 1
Dim x As Integer = (i + 1) * rozestup
Dim y As Integer = zaznamy(i).hodnota / max * vyska
'vykreslit sloupec
e.Graphics.FillRectangle(pozadi, x - sirka, 580 - y, sirka * 2, y)
'vykreslit popisky pod sloupce
DrawText(e.Graphics, zaznamy(i).Text, x, 590)
'vykreslit částky nad sloupce
Dim txt As String = String.Format("{0:c}", zaznamy(i).hodnota)
DrawText(e.Graphics, txt, x, 580 - y - 40, -60)
Next
End Sub
To je pro dnešek vše. Zopakovali jsme načítání ze souboru, naučili se struktury, transformace a vykreslování textu.
Nejste již úplní začátečníci, takže jsem dnes nerozebíral úplně všechny detaily, nepopisoval jsem podrobně, k čemu kde jaký parametr slouží. Pokud něco není jasné, pište do diskuse.
Jako domácí úkol si můžete zkusit napsat program, kterému zadáte text a on spočítá, kolikrát se v tomto textu vyskytují jednotlivá písmena (nerozlišujeme velká a malá). Výsledek program vyexportuje do textového souboru tak, aby jej byl schopen načíst a zobrazit náš program pro zobrazování grafů. Rozlišujte diakritiku, ignorujte znaky, které nejsou písmena. Hodí se funkce Char.IsLetter, která vrátí True, pokud je daný znak písmeno.
Grafům četnosti výskytů jednotlivých znaků se říká histogramy, využívají se například při prolamování primitivnějších šifer. Můžete se také podívat na odlišnosti histogramů v různých jazycích.