Wykres skopiowany z ekranowego kontrolera wykresu jako bitmapa 96 DPI wygląda dobrze przy powiększeniu 100% w przeglądarce PDF. Powiększ do 400% albo wyślij stronę do drukarki laserowej 600 DPI, a krawędzie słupków robią się miękkie, etykiety osi rozmywają się w szare smugi, a plik niesie kilkaset kilobajtów pikseli za to, co naprawdę jest tuzinem prostokątów. Narysowany jako wektorowe ścieżki ten sam wykres zostaje ostry na każdym powiększeniu i rozdzielczości druku, bo słupki, osie i etykiety to operatory ścieżek i tekstu PDF, które przeglądarka renderuje ponownie w dowolnej potrzebnej skali
Wyjście wektorowe przesuwa robotę z układem do twojego kodu. Coś musi policzyć, gdzie ląduje każdy słupek, kreska i etykieta, a dla wykresów słupkowych, liniowych i kołowych te obliczenia to jedno mapowanie współrzędnych plus kilka pętli po prostokątach, odcinkach i łukach. Ustaw mapowanie raz dobrze, a pętle są krótkie
DrawChart czy ręcznie składane prymitywy w HotPDF?
HotPDF ma w pakiecie gotowy renderer wykresów i dla standardowego wykresu raportowego to pierwszy wybór. Zbuduj THPDFChart, dodaj serie przez AddSeries (rodzaje hckLine, hckBar, hckArea, hckPie i hckScatter), napełnij je przez AddPoint i zawołaj THPDFPage.DrawChart z prostokątem docelowym. Renderer sam wylicza zakres osi, siatkę, etykiety podziałki, legendę i kolory motywu, a przeciążenie przyjmujące rodzica struktury zapisuje wykres jako tagowaną figurę z tekstem alternatywnym zbudowanym z danych, co się liczy, gdy dokument musi przejść kontrolę dostępności
var
Chart: THPDFChart;
Sales: THPDFChartSeries;
begin
Chart := THPDFChart.Create;
try
Chart.Title := 'Quarterly sales';
Chart.LegendPosition := hclBottom;
Sales := Chart.AddSeries(hckBar, 'Sales');
Sales.AddPoint(1, 420, 'Q1');
Sales.AddPoint(2, 515, 'Q2');
Sales.AddPoint(3, 388, 'Q3');
Sales.AddPoint(4, 610, 'Q4');
// Prostokąt w jednostkach strony: lewo, góra, szerokość, wysokość
HotPDF1.CurrentPage.DrawChart(Chart, 60, 120, 470, 280);
finally
Chart.Free;
end;
end;
Renderer ma jednak zamknięty słownik. Kilka serii słupkowych siedzi obok siebie w każdej kategorii, wykres kołowy musi być jedyną serią w swoim wykresie, a wygląd pochodzi z trzech motywów plus kolorów per seria. Słupki stacked, pas celu zacienione za słupkami, inny kolor firmowy dla każdego słupka w jednej serii, kołowy startujący na dwunastej i biegnący zgodnie z zegarem: gdy projekt zażąda czegokolwiek z tego, narysowanie wykresu samodzielnie kanwą strony to mniej roboty niż wyginanie renderera. Te prymitywy to Rectangle, MoveTo, LineTo, Pie, Fill, Stroke i TextOut, te same wywołania omówione w przewodniku po rysowaniu na kanwie
Dlaczego mój wykres słupkowy wychodzi do góry nogami?
Współrzędne strony HotPDF kładą początek w lewym górnym rogu z Y rosnącym w dół, ta sama ramka co kanwa VCL. Przestrzeń użytkownika PDF sama biegnie w drugą stronę (początek w lewym dolnym, Y w górę, ISO 32000-1 §8.3.2.3), a HotPDF odwraca każdą współrzędną do tej przestrzeni, gdy zapisuje strumień treści. Wartości wykresu z kolei rosną w górę. Podaj przeskalowaną wartość prosto jako Y, a najwyższy słupek zwisa z góry wykresu jak sopl
Poprawka to jedno wyrażenie. Weź prostokąt wykresu, zmapuj zero na jego dolną krawędź i szczyt skali na jego górną krawędź i przepuszczaj każdą wartość przez to mapowanie, zanim dotknie strony. Dla wartości V na skali od 0 do MaxValue strona Y to Plot.Bottom - (V / Plot.MaxValue) * Plot.Height, gdzie Plot.Bottom to podstawa. Kod wykresu słupkowego niżej owija to w małą funkcję pomocniczą ValueToY i każda inna procedura używa jej ponownie
W tym wyrażeniu kryją się dwa wybory. MaxValue powinno być okrągłą liczbą nad największym punktem danych, kolejnym wielokrotnością 10 albo 100, żeby najwyższy słupek nie dotykał górnej krawędzi, a etykiety linii siatki czytały się 0, 200, 400 zamiast 0, 203.5, 407. I skala startuje od zera celowo. Długość słupka to wartość, więc oś wartości startująca od 350 sprawia, że słupek 388 wygląda ledwie o połowę wyższy od słupka 420, gdy realna różnica jest pod 8%. Wbudowany renderer wciąga zero do zakresu dla serii słupkowych i obszarowych dokładnie z tego powodu. Trzymaj tę samą regułę w kodzie rysowanym ręcznie
Jednostki to druga połowa mapowania. Przy domyślnym Resolution równym 72 jedna jednostka strony to jeden punkt PDF (1/72 cala). Zmień rozdzielczość, a twoje współrzędne, szerokości linii i pomiary tekstu przestawiają się na nową jednostkę, co szczegółowo omawia artykuł o Resolution i jednostkach rysowania. DrawChart bierze prostokąt docelowy w jednostkach strony, ale własne marginesy, rozmiary czcionek i szerokości linii układa w punktach przy każdej rozdzielczości
Wykres słupkowy to pętla po prostokątach
Przy gotowym mapowaniu wykres słupkowy to pętla. Podziel szerokość wykresu na jedno miejsce na kategorię, zostaw przerwę, żeby sąsiednie słupki się nie dotykały, i narysuj każdy słupek jako wypełniony prostokąt od jego szczytu z ValueToY w dół do podstawy. Rectangle bierze lewy górny róg, szerokość i wysokość, co pasuje do tego mapowania bezpośrednio:
type
TPlotArea = record
Left, Bottom, Width, Height: Single; // jednostki strony; Bottom to Y podstawy
MaxValue: Single; // szczyt skali wartości
end;
// Twoja własna funkcja pomocnicza: większa wartość siedzi wyżej, co na stronie
// z Y w dół znaczy mniejsze Y
function ValueToY(const Plot: TPlotArea; V: Single): Single;
begin
Result := Plot.Bottom - (V / Plot.MaxValue) * Plot.Height;
end;
procedure DrawBarChart(Page: THPDFPage; const Plot: TPlotArea;
const Values: array of Single; const Labels: array of string;
const BarColors: array of TColor);
var
I, Count: Integer;
SlotW, BarW, BarX, BarTop, Gap, GridV, GridY: Single;
begin
Count := Length(Values);
SlotW := Plot.Width / Count;
Gap := SlotW * 0.25; // ćwierć miejsca, dzielona na lewo i prawo
BarW := SlotW - Gap;
// Kreskowane linie siatki najpierw, żeby słupki zamalowały je od góry
Page.SetLineWidth(0.5);
Page.SetRGBStrokeColor(RGB(200, 200, 200));
Page.SetDash([2, 2], 0);
GridV := Plot.MaxValue / 4;
while GridV <= Plot.MaxValue do
begin
GridY := ValueToY(Plot, GridV);
Page.MoveTo(Plot.Left, GridY);
Page.LineTo(Plot.Left + Plot.Width, GridY);
Page.Stroke;
GridV := GridV + Plot.MaxValue / 4;
end;
Page.NoDash;
Page.SetFont('Arial', [], 9);
for I := 0 to Count - 1 do
begin
BarX := Plot.Left + I * SlotW + Gap / 2;
BarTop := ValueToY(Plot, Values[I]);
Page.SetRGBFillColor(BarColors[I mod Length(BarColors)]);
Page.Rectangle(BarX, BarTop, BarW, Plot.Bottom - BarTop);
Page.Fill;
// TextOut kładzie górę tekstu na Y
Page.SetRGBFillColor(clBlack);
Page.TextOut(BarX, Plot.Bottom + 4, 0, Labels[I]);
Page.TextOut(BarX, BarTop - 12, 0, FormatFloat('0', Values[I]));
end;
// Podstawa na końcu, żeby siedziała na wierzchu dołków słupków
Page.SetLineWidth(1.0);
Page.SetRGBStrokeColor(clBlack);
Page.MoveTo(Plot.Left, Plot.Bottom);
Page.LineTo(Plot.Left + Plot.Width, Plot.Bottom);
Page.Stroke;
end;
Kolejność to cały model warstwowania tutaj. PDF używa modelu malarza, gdzie późniejsze operatory w strumieniu treści malują po wcześniejszych, więc linie siatki idą pierwsze, słupki po nich, podstawa ostatnia. Przenieś blok siatki pod pętlę, a kreski pobiegną prosto przez każdy słupek
Przerwa to ułamek miejsca zamiast stałej liczby punktów. Przy czterech kategoriach na wykresie 470-punktowym każde miejsce ma 117.5 punktu szerokości, a słupki dostają około 29 punktów powietrza między sobą; przy czterdziestu kategoriach ten sam kod zostawia poniżej 3 punktów, co wciąż czyta się jako osobne słupki. Stała przerwa 12 punktów połknęłaby 480 z 470 dostępnych punktów przy czterdziestu kategoriach, a szerokość słupka stałaby się ujemna
Etykiety osi, które się nie gniotą
Etykiety to miejsce, gdzie ręcznie rysowane wykresy psują się pierwsze i prawie zawsze tak samo. Tekst, który mieścił się na ekranie, przekracza swoje miejsce na stronie, bo programista testował na "Sep", a niemiecki raport drukuje "September" albo "Dezember" czcionką 9-punktową. Oś wartości ma cichszy wariant tego samego problemu: etykiety podziałki wyrównane do lewej na stałym offsetsie X wyglądają nierówno od chwili, gdy liczby zmieniają długość, a etykieta "1,200" wjeżdża na linię osi, którą "800" omijała
Mierz zamiast zgadywać. TextWidth zwraca szerokość ciągu bieżącą czcionką, w jednostkach strony, a TextHeight zwraca wzniesienie czcionki przy bieżącym rozmiarze. Wyrównaj każdą etykietę podziałki do prawej względem osi i wycentruj na jej kresce:
procedure DrawValueAxis(Page: THPDFPage; const Plot: TPlotArea;
TickCount: Integer);
var
I: Integer;
TickV, TickY: Single;
S: string;
begin
Page.SetLineWidth(1.0);
Page.MoveTo(Plot.Left, Plot.Bottom);
Page.LineTo(Plot.Left, Plot.Bottom - Plot.Height);
Page.Stroke;
Page.SetFont('Arial', [], 8);
for I := 0 to TickCount do
begin
TickV := (Plot.MaxValue / TickCount) * I;
TickY := ValueToY(Plot, TickV);
Page.MoveTo(Plot.Left - 4, TickY); // krótka kreska na zewnątrz osi
Page.LineTo(Plot.Left, TickY);
Page.Stroke;
S := FormatFloat('#,##0', TickV);
Page.TextOut(Plot.Left - 7 - Page.TextWidth(AnsiString(S)),
TickY - Page.TextHeight(AnsiString(S)) / 2, 0, S);
end;
end;
Dla etykiet kategorii ten sam pomiar mówi ci, kiedy odpuścić tekst poziomy. Porównaj najszerszą etykietę z szerokością miejsca przed narysowaniem czegokolwiek. Jeśli się nie mieści, zejdź czcionką punkt albo dwa; jeśli wciąż się nie mieści, podaj 90 jako argument kąta w TextOut i postaw etykiety na sztorc, a potem zostaw pod podstawą dosyć marginesu na najdłuższą z nich. Testuj na najszerszej etykiecie, jaką raport naprawdę może wyprodukować, a przy nazwach miesięcy w buildsie z lokalizacją rzadko jest to angielska
Wykresy liniowe używają tego samego mapowania ponownie
Wykres liniowy zmienia tylko sposób łączenia punktów. Przejdź dane raz, przelicz każdą wartość przez ValueToY i zszyj punkty w jedną ścieżkę: MoveTo dla pierwszego punktu, LineTo dla każdego następnego, jedno Stroke na końcu. Jeśli masz już punkty w tablicy, Polyline bierze array of THPDFCurrPoint, otwiera świeżą ścieżkę i zostawia kreskowanie tobie; podnosi wyjątek przy mniej niż dwóch punktach, więc seria z pojedynczym odczytem potrzebuje znacznika zamiast linii
Rozstaw to miejsce, gdzie wykres liniowy schodzi z wykresu słupkowego nad tym samym prostokątem. Słupki dzielą szerokość przez liczbę kategorii, bo każdy słupek posiada swoje miejsce. Linia dzieli ją przez liczbę przedziałów, Count - 1, bo pierwszy i ostatni punkt siedzą na krawędziach wykresu. Nałóż jedno na drugie, a punkty linii lądują na granicach miejsc zamiast na środkach słupków. Gdy linia jedzie po słupkach, kładź jej punkty na środku każdego miejsca, Plot.Left + I * SlotW + SlotW / 2, i wyrzuć rozstaw Count - 1 całkiem. Znacznik przy każdym punkcie to małe Circle podążone Fill po skreskowaniu linii
Wycinki kołowe przez Pie, zgodnie z zegarem od dwunastej
Klin kołowy to dwa promienie i łuk. Ręczna droga zbudowania go startuje w środku, stawia krótkie segmenty LineTo co parę stopni wzdłuż obwodu i zamyka z powrotem do środka, to konstrukcja z rysunku niżej. Działa, ale wycinek 270 stopni zamienia się w dobrze ponad sto segmentów ścieżki, a obwód pod dużym powiększeniem wciąż jest wielokątem
Użyj zamiast tego Pie. THPDFPage.Pie trzyma się konwencji GDI: prostokąt otaczający, kąt startowy i końcowy w stopniach, 0 wskazuje wschód, 90 północ, kąty dodatnie idą przeciwnie do wskazówek zegara. Buduje łuk z segmentów Béziera po najwyżej 90 stopni każdy, rysuje linię do środka i zamyka ścieżkę, więc obwód zostaje prawdziwą krzywą, a pełny wycinek kosztuje garść operatorów. Biznesowe kołowe zwykle startują na dwunastej i biegną zgodnie z zegarem, co w terminach GDI znaczy start od 90 i schodzenie kątem w dół:
procedure DrawPieChart(Page: THPDFPage; CX, CY, Radius: Single;
const Values: array of Double; const Colors: array of TColor);
var
I: Integer;
Total, Sweep, Angle: Double;
begin
Total := 0;
for I := 0 to High(Values) do
if Values[I] > 0 then
Total := Total + Values[I];
if Total <= 0 then
Exit;
Angle := 90; // dwunasta
Page.SetRGBStrokeColor(clWhite);
Page.SetLineWidth(0.8);
for I := 0 to High(Values) do
begin
if Values[I] <= 0 then
Continue; // patrz niżej: nigdy nie przekazuj zerowego ani ujemnego wycinka
Sweep := Values[I] / Total * 360;
Page.SetRGBFillColor(Colors[I mod Length(Colors)]);
// Przeciwnie do zegara od Angle - Sweep do Angle to wycinek, który
// siedzi zgodnie z zegarem od bieżącej pozycji
Page.Pie(CX - Radius, CY - Radius, CX + Radius, CY + Radius,
Angle - Sweep, Angle);
Page.FillAndStroke; // biała kreska rozdziela wycinki
Angle := Angle - Sweep;
end;
end;
Strażnik Continue to najważniejsza linijka. Pie traktuje kąt końcowy mniejszy od startowego jako wycinek zawijający za 360, odczyt gdiowski, który pozwala wycinkowi przeciąć linię 0 stopni. Wiersz zwrotu albo korekta w danych źródłowych daje wartość ujemną, wyliczony kąt końcowy ląduje pod startowym, a wycinek zawija się w prawie pełny dysk namalowany po wszystkim, co rysowano przed nim. Wartość zero jest łagodniejsza, ale wciąż widoczna: zamknięta ścieżka zapada się w promień, a biała kreska rozdzielająca rysuje go jako zbłąkaną linię od obwodu do środka. Odfiltruj oba przed pętlą i osobno zdecyduj, jak raport pokazuje kwoty ujemne, bo kołowy nie umie pokazać ich wcale
Gdy projekt nie upiera się przy okręgu, jeden poziomy słupek stacked pokazuje te same udziały niczym prócz wywołań Rectangle układanych koniec przy końcu, a czytelnicy porównują długości celniej niż kąty. Dla figur część-całość z więcej niż pięcioma-sześcioma kategoriami to lepszy wykres, a kod słupkowy wyżej robi już większość roboty
Każdy wykres w tym artykule składa się z wywołań kanwy strony HotPDF Delphi Component, z DrawChart dostępnym dla standardowych układów liniowych, słupkowych, obszarowych, kołowych i rozrzutu, i wszystko to rusza z Delphi albo C++Builder bez biblioteki wykresów zainstalowanej na maszynie budowania