Przygotujemy sobie od razu miejsce na procedurę rysującą diagram. Proszę zaznaczyć panel
diagram, po czym w inspektorze (po prawej u dołu) przejść do kategorii
Events (symbol

) i wcisnąć
Enter w pustym polu na prawo od zdarzenia
Paint. Przejdziemy w ten sposób do edycji kodu źródłowego, a Visual Studio wygeneruje nam szkielet metody
diagram_Paint. Pozostawimy ją na razie pustą - implementacją rysowania diagramu zajmiemy się później.
Zastanówmy
się teraz w jaki sposób będziemy rysować nasz diagram i jakie
narzędzia mogą się przydać. Załóżmy, że drzewo problemów
będziemy rysować metodą "z góry na dół", tzn. korzeń
naszego drzewa (projekt) będzie u góry, problemy na pierwszym
poziomie będą obok siebie (bracia) poniżej korzenia, ich podproblemy
(dzieci) będą jeszcze niżej itd. Zatem, stosując notację z poznanej
tydzień temu reprezentacji tekstowej, nasz diagram będzie posiadał
następującą organizację:

Oczywiście
na wynikowym diagramie pojawią się opisy problemów, a nie ich
numery. Oczywiście lepiej byłoby porozkładać nasze problemy tak, aby
diagram był nieco bardziej estetyczny (proszę spróbować jeśli
ktoś czuje się na siłach), jednak jak się przekonamy, nawet taki
rozkład "do lewej" nastręczy sporo problemów.
Co musimy wiedzieć przed przystąpieniem do implementacji:
- Zdarzenie Paint
jest wysyłane do komponentu za każdym razem, gdy jakaś jego część
wymaga przerysowania. Oznacza to, że rysowanie może odbywać się dość
często, zatem nie powinniśmy w procedurze rysowania umieszczać
obliczania rozkładu poszczególnych elementów: po pierwsze
rysowanie będzie trwało zbyt długo, a po drugie będziemy wykonywać
zbędne obliczenia - rozkład może się zmienić tylko wtedy, gdy zmieni
się model naszej mapy. Wniosek: obliczać rozkład elementów tylko
raz, gdy do okna diagramu zostanie przekazany model i zapamiętać gotowy
rozkład drzewa w sposób wygodny do późniejszego rysowania.
- Biorąc
pod uwagę powyższe rozważania moglibyśmy dojść do wniosku, że dobrym
rozwiązaniem byłoby przygotowanie gotowej bitmapy zawierającej diagram
i kopiowanie jej do naszego panelu kiedy wymagane będzie jego
przerysowanie. Z pewnych względów rozwiązanie to jest atrakcyjne
(zapewnia np. dość płynne przewijanie zawartości), jednak my
zrezygnujemy z niego z dwóch powodów: po pierwsze,
przygotowana bitmapa może mieć bardzo duży rozmiar i zajmować dużo
miejsca w pamięci, a po drugie, chcemy zastosować listy i słowniki
(czyli względy edukacyjne). Zastosujemy zatem inne podejście. Rozłożymy
sobie nasz diagram "w locie", zapisując listę czynności, którą
należy wykonać aby go wyrysować. Lista czynności będzie zawierała wpisy
typu "tutaj narysuj prostokąt", "tutaj wypisz tekst", "tutaj narysuj
odcinek" itp. Obsługa Paint będzie zatem polegała na przetworzeniu
listy czynności (nie będą wymagane żadne obliczenia - wszystkie
współrzędne będą już wyliczone i zapamiętane) i wykonaniu
odpowiednich operacji graficznych.
- Każda czynność opisana wyżej będzie reprezentowana przez słownik (Dictionary), który zawierać będzie klucz "czynnosc" wskazujący na jedną z trzech wartości: "tekst", "prostokat", "odcinek", oraz dodatkowe klucze, w zależności od czynności (dla prostokąta: "x1", "y1", "x2", "y2" - współrzędne lewego-górnego i prawego-dolnego narożnika, dla tekstu: "x", "y", "tresc" - współrzędne i tekst do wyświetlenia, a dla odcinka: "x1", "y1", "x2", "y2" - wspórzędne jego końców).
- Listę czynności przygotujemy raz, gdy do naszego okna zostanie przekazana mapa. Również wtedy ustalimy rozmiar panelu diagram, dostosowując go do liczby elementów w mapie.
Zaczniemy od dodania do okna diagramu atrybutu przechowującego listę czynności. Proszę w kodzie źródłowym dodać pole:
protected List<Dictionary<string, object>> lista;
Pole jest typu
List,
jednak typ ten jest szablonem, parametryzowanym typem danych,
które zawierać będą elementy listy. Nasza lista zawierać będzie
słowniki (
Dictionary), jednak słownik również jest
szablonem, parametryzowanym dwoma typami: typem klucza i typem
wartości. Ponieważ nasze słowniki będą przechowywać wartości
różnych typów, jako typ wartości podaliśmy po prostu
object (będziemy wykonywać odpowiednie rzutowania). Klucze będą łańcuchami tekstowymi.
Proszę jeszcze dodać inicjalizację listy w konstruktorze:
public OknoDiagramu()
{
InitializeComponent();
lista = new List<Dictionary<string,object>>;
}
Najtrudniejszą część - budowę listy - zostawimy na koniec. Teraz przejdźmy do metody
diagram_Paint i zajmijmy się rysowaniem (mimo, że nasza lista na razie jest pusta).
Metoda
diagram_Paint przyjmuje dwa parametry:
sender (źródło zdarzenia
Paint) i
PaintEventArgs. Z tego drugiego parametru wyjmiemy obiekt klasy
Graphics (kontekst graficzny związany z danym komponentem), który posiada metody umożliwiające rysowanie prymitywów.
private void diagram_Paint(object sender, PaintEventArgs e)
{
Pen pisakRamki = new Pen(Brushes.DarkRed);
pisakRamki.Width = 1;
Pen pisakLinii = new Pen(Brushes.Blue);
pisakLinii.Width = 1;
SolidBrush kolorTekstu = new SolidBrush(Color.Black);
e.Graphics.Clear(Color.White);
foreach (Dictionary<string, object> operacja in lista)
{
string op = (string) operacja["czynnosc"];
if (op.Equals("prostokat"))
{
Int32 x1 = (Int32) operacja["x1"];
Int32 x2 = (Int32) operacja["x2"];
Int32 y1 = (Int32) operacja["y1"];
Int32 y2 = (Int32) operacja["y2"];
e.Graphics.DrawRectangle(pisakRamki, x1, y1, (x2 - x1), (y2 - y1));
}
else if (op.Equals("tekst"))
{
Int32 x = (Int32) operacja["x"];
Int32 y = (Int32) operacja["y"];
string tresc = (string) operacja["tresc"];
e.Graphics.DrawString(tresc, diagram.Font, kolorTekstu , x, y);
}
else if (op.Equals("odcinek"))
{
Int32 x1 = (Int32)operacja["x1"];
Int32 x2 = (Int32)operacja["x2"];
Int32 y1 = (Int32)operacja["y1"];
Int32 y2 = (Int32)operacja["y2"];
e.Graphics.DrawLine(pisakLinii, x1, y1, x2, y2);
}
}
}
Na początku tworzymy obiekty
Pen i
Brush
(styl rysowania linii i tekstu), aby nie tworzyć ich osobno dla każdej
operacji. Następnie przechodzimy całą listę operacji (proszę
zwrócić uwagę na instrukcję języka C#
foreach -
umożliwia ona wykonanie operacji dla każdego elementu z kolekcji) i w
zależności od jej rodzaju wyciągamy odpowiednie parametry (zakładamy,
że będziemy je tam wkładać obliczając rozkład diagramu), po czym
korzystamy z odpowiedniej dla danego prymitywu metody z klasy
Graphics. Zakładając, że lista będzie zbudowana poprawnie, ta metoda powinna zapewnić rysowanie jej w panelu
diagram.
Dodajmy do naszego okna diagramu metodę, za pomocą której będziemy przekazywać model danych:
public void setMap(TreeView drzewo)
{
}
Oczywiście jej zadaniem będzie obliczanie rozkładu diagramu, jednak najpierw połączmy nasze nowe okno z resztą projektu.
Zadanie 1
Zanim przejdziemy do najtrudniejszej części - wyliczania rozkładu - proszę dodać do menu głównego w oknie
OknoDrzewa pozycję
Jako diagram i oprogramować ją tak, aby otwierane było modalnie nowe okno klasy
OknoDiagramu i wywoływana była metoda
setMap,
a także ustawiany tytuł okna diagramu (na nazwę projektu). Proszę
wzorować się na obsłudze otwierania okna widoku tekstowego z
poprzednich zajęć.
Otwarcie okna przez kliknięcie
Jako diagram powinno w tej chwili dać w wyniku okno z białym prostokątem:

Biały prostokąt jest efektem wywołania
e.Graphics.Clear(Color.White);
w metodzie
diagram_Paint. Powoduje ona wyczyszczenie obszaru komponentu (a nasza lista jest w tej chwili pusta, więc pętla
foreach nie wykona ani jednej iteracji).
Nadszedł
wreszcie moment realizacji najtrudniejszej części - wyliczania
rozkładu. Zastanówmy się, jakie informacje będą nam potrzebne:
- musimy wiedzieć jak szeroki będzie najszerszy poziom naszego drzewa (od tego zależy jaki ustalimy rozmiar panelu diagram),
- w tym celu musimy wiedzieć ile pikseli zajmie etykieta danego węzła drzewa,
- musimy wiedzieć jak wysokie będzie nasze drzewo (od tego też zależy rozmiar panelu diagram),
- musimy ustalić odstęp w pionie (np. 50 pikseli) i w poziomie (np. 20 pikseli) między poszczególnymi węzłami drzewa,
- musimy ustalić odstęp między opisem węzła i ramką (np. 5 pikseli),
- musimy
znać współrzędne środka górnej i dolnej krawędzi każdej
ramki aby odpowiednio dorysować odcinek łączący rodzica z dzieckiem.
Ze
swej natury drzewo sugeruje przetwarzanie rekurencyjne. O ile jednak
procedura przeszukiwania wgłąb nadawała się do konstrukcji
reprezentacji tekstowej, o tyle teraz bardziej przydatna będzie metoda
przeszukiwania wszerz, poziom po poziomie.
Algorytm przeszukiwania wszerz (dla naszej mapy) można opisać następująco:
- Zacznij od korzenia. Dodaj go do listy L.
- Dopóki lista L nie jest pusta, powtarzaj:
- Pobierz wierzchołek v z początku listy.
- Przetwarzaj v.
- Dodaj na koniec listy wszystkie dzieci v.
Jest
to uproszczona wersja BFS, ponieważ wiemy, że nasz graf jest drzewem,
zatem nie występują w nim cykle i nie musimy sprawdzać, czy przypadkiem
wierzchołek
v nie został już wcześniej odwiedzony.
Dodatkowo
będziemy pamiętać (globalnie) maksymalną szerokość poziomu i wysokość
naszego drzewa. Dodamy w tym celu prywatne pole do klasy
OknoDiagramu:
private Int32 maxW;
(wysokość będziemy w stanie zmierzyć na bieżąco w metodzie
setMap).
Dodatkowo dodamy do naszej klasy podtyp (klasę wewnętrzną), która będzie wiązać obiekty
TreeNode z ich ramkami (prostokątami, w których będą zawarte):
protected struct wezel
{
public TreeNode v;
public Dictionary<string, object> frame;
}
Na liście
L
z opisanego wcześneij algorytmu BFS będziemy umieszczać takie właśnie
struktury. Dzięki temu będziemy mogli poruszać się po drzewie projektu
(korzystając z pola
v) jednocześnie wiedząc, w którym miejscu diagramu znajduje się dany węzeł (korzystając z pola
frame).
Zabierzmy się zatem do implementacji. Utworzymy sobie pomocniczą metodę
textSize,
która zbada, jaki jest rozmiar zadanego łańcucha znaków
(w pikselach) - chcemy, aby nasze ramki otaczały estetycznie
tekst, zostawiając 5-ciopikselowy odstęp między ramką i tekstem:
protected SizeF textSize(string s)
{
Graphics g = diagram.CreateGraphics();
SizeF ts = g.MeasureString(s, diagram.Font);
g.Dispose();
return ts;
}
Tworzymy kontekst graficzny dla panelu
diagram (chcemy przecież wiedzieć jaki jest rozmiar tekstu w tym panelu - to tam będzie on rysowany) za pomocą
CreateGraphics, a następnie uzyskujemy rozmiar tekstu w danym kontekście korzystając z
MeasureString (podajemy taką czcionkę, jaka jest ustawiona w panelu
diagram). Struktura
SizeF posiada interesujące nas pola
Width (szerokość) i
Height (wysokość).
A teraz gwóźdź programu, czyli metoda
setMap:
public void setMap(TreeView drzewo)
{
TreeNode root = drzewo.Nodes[0];
List<wezel> L = new List<wezel>();
Int32 currX, currY, lineH;
// dodaj korzen
wezel w = new wezel();
w.v = root;
w.frame = new Dictionary<string, object>();
w.frame.Add("czynnosc", "prostokat");
w.frame.Add("x1", 5);
w.frame.Add("y1", 5);
SizeF rozm = textSize(root.Text);
w.frame.Add("x2", (Int32)rozm.Width + 15);
w.frame.Add("y2", (Int32)rozm.Height + 15);
L.Add(w);
maxW = (Int32)w.frame["x2"] + 10;
currX = 5;
currY = 5;
lineH = (Int32)rozm.Height + 10; // wys. ramki
while (L.Count > 0)
{
// pobierz v z listy
wezel v = L[0];
L.RemoveAt(0);
// dodaj do rysowania ramke v
lista.Add(v.frame);
// dodaj do rysowania tekst v
Int32 x0 = (Int32)v.frame["x1"] + 5;
Int32 y0 = (Int32)v.frame["y1"] + 5;
Dictionary<string, object> op = new Dictionary<string, object>();
op.Add("czynnosc", "tekst");
op.Add("x", x0);
op.Add("y", y0);
op.Add("tresc", v.v.Text);
lista.Add(op);
// czy powiekszyc panel?
if (((Int32)v.frame["x2"]) > maxW)
maxW = (Int32)v.frame["x2"];
// czy przeszlismy na nowy poziom?
if (y0 > currY)
{
// jesli tak, rozmieszczamy od lewej
currY = y0;
currX = 5;
}
foreach (TreeNode child in v.v.Nodes)
{
// wylicz pozycje potomka
SizeF rozmt = textSize(child.Text);
Int32 cx1, cy1, cx2, cy2;
cx1 = currX;
cx2 = currX + (Int32)rozmt.Width + 10;
cy1 = currY + lineH + 50;
cy2 = currY + lineH + 50 + lineH;
// przygotuj ramke potomka
Dictionary<string, object> cop = new Dictionary<string, object>();
cop.Add("czynnosc", "prostokat");
cop.Add("x1", cx1);
cop.Add("y1", cy1);
cop.Add("x2", cx2);
cop.Add("y2", cy2);
// przygotuj wezel dla potomka
wezel chld = new wezel();
chld.v = child;
chld.frame = cop;
// dodaj potomka do listy
L.Add(chld);
// przestaw X
currX += (Int32)rozmt.Width + 10 + 20;
// dodaj do rysowania odcinek rodzic-potomek
Dictionary<string, object> kraw = new Dictionary<string, object>();
kraw.Add("czynnosc", "odcinek");
kraw.Add("x1", (Int32)((((Int32)v.frame["x1"]) + ((Int32)v.frame["x2"])) / 2));
kraw.Add("y1", (Int32)v.frame["y2"]);
kraw.Add("x2", (Int32)((cx1 + cx2) / 2));
kraw.Add("y2", (Int32)cy1);
lista.Add(kraw);
}
}
// ustal rozmiar panelu
diagram.Size = new Size(maxW + 10, currY + lineH);
}
Spróbujmy
ją z grubsza prześledzić. Na początku pobieramy korzeń drzewa. Badamy
ile zajmie jego etykieta i dodajemy węzeł z korzeniem i ramką
umiejscowioną w (0,0) (lewy-górny narożnik) do listy
L, a
następnie uruchamiamy pętlę BFS.
W każdym obrocie pętli pobieramy
węzeł
v z początku listy
L i do operacji graficznych dodajemy rysowanie
ramki
v oraz tekstu (etykiety)
v. Następnie sprawdzamy, czy dodana
ramka nie powoduje konieczności powiększenia panelu
diagram (i jeśli
tak, zapamiętujemy zwiększoną szerokość - fizycznie rozmiar ustalimy
dopiero na końcu). Następnie sprawdzamy, czy
v powoduje przejście na
nowy poziom (porównujemy współrzędną Y ramki
v z
zapamiętanym poziomem
currY) i jeśli tak, zerujemy
currX
(rozmieszczenie w poziomie) aby rozmieszczać węzły od lewej.
Następnie
wykonujemy iterację dla każdego dziecka węzła
v (znowu
foreach). W
danej iteracji wyliczamy pozycję (ramkę) dziecka i dodajemy od razu do
listy czynności graficznych linię łączącą rodzica z dzieckiem. Nie
dodajemy w tym kroku ramki dziecka i tekstu - te elementy zostaną
dodane, gdy dziecko zostanie pobrane z kolejki. W końcu dodajemy
dziecko na koniec kolejki i przestawiamy
currX (przesunięcie od lewej
na bieżącym poziomie) aby wskazywał pozycję ew. kolejnych
potomków na aktualnie przetwarzanym poziomie (jeśli poziom się
zmieni - wykryjemy to i wyzerujemy
currX).
Po zakończeniu BFS ustalamy rozmiar panelu
diagram (szerokość -
maxW, wysokość -
currY plus wysokość jednej ramki).
Do
poszczególnych rozmiarów dodajemy jeszcze odstępy - 50 w
pionie między wierszami, 10 w poziomie między węzłami, po 5 wewnątrz
każdej ramki i po 5 marginesu wokół całego diagramu.
Jeśli ktoś zagubił się przy uzupełnianiu kodu,
tutaj jest źródło
OknoDiagramu.cs.
A efekt dla projektu nauki na kolokwium powinien wyglądać tak:

A tak wygląda rozciągnięty ile tylko starczy ekranu (kliknij aby powiększyć):