Kontakt  |  PRS 170  |  ALK 420  |  PZR 420  |  SIK 420  |  JPR 222  |  SOP 121


Języki programowania - ćwiczenia 14


Temat zajęć: Programowanie w C# na platformie .NET (c.d.).

Literatura:
  • [1] J. Liberty, "Programowanie C#" (wyd. O'Reilly, druk w Polsce: Helion)
  • [2] A. Troelsen, "Pro C# 2005 and the .NET 2.0 Platform, 3rd edition" (wyd. APress)

Materiały dodatkowe:

Dzisiaj zaimplementujemy graficzną reprezentację mapy umysłu dotyczącej danego problemu, rozwijając projekt, który zaczęliśmy na poprzednich zajęciach. Zaczniemy od końca, tzn. najpierw dodamy komponenty do naszego projektu, a dopiero później zastanowimy się, jak ma wyglądać algorytm rysujący diagram.

Zaczynamy od dodania nowego okna do projektu. Proszę kliknąć prawym przyciskiem myszy na projekcie MindMapping w Solution explorer po prawej u góry (na pogrubionym MindMapping, nie na Solution 'MindMapping' (1 project)) i z menu kontekstowego wybrać Add | New Item... Z dostępnego zestawu elementów projektu proszę wybrać Windows Form. Jako nazwę pliku proszę wprowadzić OknoDiagramu.cs (nazewnictwo będzie pasować do istniejącej reszty projektu).
Proszę rozszerzyć nowe okno tak, aby miało rozmiar ok. 600 x 400. Następnie w palecie komponentów (po lewej) proszę rozwinąć kategorię Containers i wybrać Panel, a następnie kliknąć w obrębie OknoDiagramu. Nowo dodany panel proszę rozszerzyć tak, aby zajął całe okno (zostawiając sugerowane marginesy) i proszę ustawić w panelu właściwość Anchor na Top, Bottom, Left, Right oraz (WAŻNE!) właściwość AutoScrollMinSize na 1;1 (było 0;0). Dodany właśnie panel będzie nam służył jako odpowiednik JScrollPane ze Swinga (będzie przewijał zawarty w nim kolejny panel).

Proszę ponownie wybrać Panel z palety komponentów (kategoria Containers) i kliknąć w obrębie wcześniej dodanego panelu. W nowym panelu proszę ustawić następujące właściwości: (Name) na diagram, Location na 0;0. Po wykonaniu opisanych czynności nasze okno diagramu powinno wyglądać mniej więcej tak:

Okno diagramu

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 Symbol Events) 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ę:

Przykładowy diagram

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:

Puste okno diagramu

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:
  1. Zacznij od korzenia. Dodaj go do listy L.
  2. Dopóki lista L nie jest pusta, powtarzaj:
  3. Pobierz wierzchołek v z początku listy.
  4. Przetwarzaj v.
  5. 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:

Diagram dla nauki na kolokwium


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


Szeroka wersja



Valid HTML 4.01!