Technical Article

Kreslenie grafov do PDF pomocou základných tvarov HotPDF

Knižnica HotPDF neobsahuje objekt grafu. Nenájdete tu žiadny TPDFChart, AddBarSeries ani nič, čo by prijalo pole čísel a vrátilo hotový vykreslený graf. Namiesto toho poskytuje plátno stránky s rovnakými základnými nástrojmi, aké používa každý vykresľovací model PDF: obdĺžniky, čiary, kružnice, výplne, obrysy a text umiestnený na presné súradnice. Graf v PDF dokumente generovanom cez HotPDF is preto niečo, čo musíte sami postaviť, a nie len jednoducho vyžiadať. Znie to síce zložitejšie, než to v skutočnosti je. Akonáhle raz napíšete matematiku na prepočet súradníc, stĺpcový graf je len cyklom vykresľujúcim obdĺžniky, čiarový graf predstavuje lomenú čiaru a koláčový graf je poskladaný z kruhových výsekov – pričom máte plnú kontrolu nad každým bodom výstupu.

Na tomto záleží, pretože prvá alternatíva, po ktorej ľudia zvyčajne siahnu – rasterizácia vizuálneho komponentu grafu z obrazovky do bitmapy a vloženie tohto obrázka do stránky – prinesie graf uzamknutý na rozlíšenie obrazovky. Ten sa potom vytlačí neostro a zbytočne zväčší veľkosť súboru. Vykreslenie grafu pomocou vektorových prvkov HotPDF zachová výstup dokonale ostrý pri akomkoľvek priblížení a pri akomkoľvek rozlíšení tlače (DPI), pretože stĺpce a osi sú skutočnými operátormi ciest PDF a nie pixelmi. Daňou za to je, že musíte sami navrhnúť rozloženie prvkov. Celý mechanizmus spočíva len v niekoľkých krokoch: v správnom otočení súradnicovej sústavy, napísaní stĺpcového grafu, použití lomenej čiary pre čiarové grafy a výpočte oblúkov pre koláčové výseky.

Jednou ťažšou časťou je súradnicový systém

Obrazovková grafika umiestňuje počiatok vľavo hore, pričom súradnica Y rastie smerom nadol. PDF to robí presne naopak. Počiatok leží v ľavom dolnom rohu stránky a Y rastie smerom nahor v bodoch (1/72 palca). Každé volanie kreslenia v HotPDF (TextOut, Rectangle, MoveTo, LineTo, Circle) používa túto konvenciu začínajúcu vľavo dole so smerom Y nahor. Ak si prenesiete návyky z obrazovkovej grafiky, váš prvý graf sa vykreslí hore nohami a mimo spodného okraja stránky.

Skutočná práca na každom grafe spočíva v mapovaní hodnôt: prepočítaní dátovej hodnoty na súradnicu Y smerujúcu nahor. Zvoľte si vykresľovací obdĺžnik – štyri čísla definujúce oblasť, kde bude graf umiestnený. Najmenšiu hodnotu vo vašich dátach namapujte na spodný okraj a najväčšiu na horný okraj tejto oblasti. Pre stĺpec s hodnotou V na stupnici od 0 po MaxValue bude horný okraj stĺpca na pozícii PlotBottom + (V / MaxValue) * PlotHeight, pričom stĺpec rastie nahor od hodnoty PlotBottom. Ak správne napíšete tento jeden výraz, všetko ostatné je už len rutinná evidencia. Nižšie uvedená pomocná štruktúra a funkcia uchovávajú geometriu grafu a vykonávajú prepočet, takže vykresľovací kód nemusí zbytočne opakovať rovnakú matematiku:

type
  TPlotArea = record
    Left, Bottom, Width, Height: Single;  // PDF points, bottom-left origin
    MaxValue: Single;                     // top of the value scale
  end;

// Map a data value to its Y coordinate inside the plot, Y growing upward.
function ValueToY(const Plot: TPlotArea; V: Single): Single;
begin
  Result := Plot.Bottom + (V / Plot.MaxValue) * Plot.Height;
end;

Jedno strategické rozhodnutie sa skrýva v hodnote MaxValue. Ak ju nastavíte presne na najväčší dátový bod, najvyšší stĺpec sa dotkne horného okraja grafu a bude vyzerať useknutý. Zaokrúhlite ju nahor na nejaké pekné celé číslo nad maximom (napríklad na najbližší násobok 10 alebo 100). Graf tak získa optickú rezervu zhora a popisy na mriežke budú zaokrúhlené čísla namiesto náhodných hodnôt, pri ktorých dáta práve kulminovali.

Stĺpcový graf ako cyklus vykresľujúci obdĺžniky

Po vyriešení prepočtu súradníc sa stĺpcový graf píše sám. Rozdeľte šírku oblasti grafu na rovnaké diely pre každú kategóriu, nechajte medzi stĺpcami medzery, aby sa nedotýkali, a vykreslite každý stĺpec ako vyplnený obdĺžnik, ktorého výška pochádza z funkcie ValueToY. Metóda Rectangle v HotPDF prijíma ľavý dolný roh spolu so šírkou a výškou, čo presne zodpovedá stĺpcu rastúcemu nahor od základnej čiary. Najskôr nastavte farbu výplne, zadefinujte obdĺžnik a potom ho vykreslite volaním Fill. Názov kategórie patrí pod základnú čiaru a hodnota nad stĺpec:

procedure DrawBarChart(Page: THPDFPage; const Plot: TPlotArea;
  const Values: array of Single; const Labels: array of string);
var
  I, Count: Integer;
  SlotW, BarW, BarX, BarH, Gap: Single;
begin
  Count := Length(Values);
  SlotW := Plot.Width / Count;
  Gap := SlotW * 0.25;          // quarter-slot gap on each side
  BarW := SlotW - Gap;

  // Baseline (the X axis) along the bottom of the plot.
  Page.SetLineWidth(1.0);
  Page.MoveTo(Plot.Left, Plot.Bottom);
  Page.LineTo(Plot.Left + Plot.Width, Plot.Bottom);
  Page.Stroke;

  Page.SetFont('Arial', [], 9);
  for I := 0 to Count - 1 do
  begin
    BarX := Plot.Left + I * SlotW + Gap / 2;
    BarH := ValueToY(Plot, Values[I]) - Plot.Bottom;

    Page.SetRGBFillColor(RGB(56, 110, 219));
    Page.Rectangle(BarX, Plot.Bottom, BarW, BarH);  // X, Y, Width, Height
    Page.Fill;

    // Category label below the baseline, value above the bar.
    Page.SetRGBFillColor(clBlack);
    Page.TextOut(BarX, Plot.Bottom - 14, 0, Labels[I]);
    Page.TextOut(BarX, Plot.Bottom + BarH + 4, 0,
      FormatFloat('0', Values[I]));
  end;
end;

Dva detaily sú tu veľmi dôležité. Parameter Gap (medzera) je podielom šírky jedného dielu a nie pevným počtom bodov, takže stĺpce zostávajú proporcionálne rozmiestnené, či uže vykresľujete štyri kategórie alebo štyridsať. Hodnota nad stĺpcom je umiestnená s použitím rovnakej výšky odvodenej z ValueToY, akú používa samotný stĺpec, takže vždy leží tesne nad ním a nevznáša sa v náhodnom odstupe. Ak chcete mať za stĺpcami vodorovné pomocné čiary mriežky, nakreslite ich ešte pred cyklom: vyberte tri alebo štyri zaokrúhlené hodnoty, prežeňte ich cez ValueToY a vykreslite jemnú čiaru naprieč grafom na danej súradnici Y. Ich vykreslenie ako prvých ich umiestni za stĺpce podľa maliarskeho modelu vrstvenia, ktorý PDF používa.

Osi, značky a popisy sú len ďalšie čiary a texty

Graf nie je kompletný, kým čitateľ nevie, čo stĺpce znamenajú, a to je výhradne úloha osí. Vertikálna os je jedna čiara na ľavom okraji grafu s niekoľkými krátkymi značkami (ticks) a ich hodnotami. Znova použite rovnakú funkciu ValueToY, aby značky sedeli s mierkou stĺpcov, inak stĺpec a jeho mriežka nebudú zodpovedať a graf bude potichu zavádzať:

procedure DrawValueAxis(Page: THPDFPage; const Plot: TPlotArea;
  TickCount: Integer);
var
  I: Integer;
  TickV, TickY: Single;
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);   // short tick outside the axis
    Page.LineTo(Plot.Left, TickY);
    Page.Stroke;
    Page.TextOut(Plot.Left - 30, TickY - 3, 0, FormatFloat('0', TickV));
  end;
end;

Popisy sú miestom, kde grafy v produkcii najčastejšie zlyhávajú, pričom problém je vždy rovnaký: text, ktorý sa zmestil na obrazovku, presiahne vyhradený priestor v PDF. Dlhé názvy kategórií sa prekrývajú so susednými a lokalizované názvy mesiacov, ako napríklad "september" alebo "Dezember", sú širšie ako anglická skratka "Sep", s ktorou ste testovali kód. Neexistuje tu žiadne automatické prispôsobenie veľkosti, preto ponechajte dostatočný okraj pod základnou čiarou, zmenšite písmo o bod či dva pri hustejších sadách kategórií, a ak sú názvy naozaj dlhé, otočte ich. Metóda TextOut prijíma uhol ako svoj tretí argument, takže odovzdaním hodnoty 90 postavíte text zvislo a získate priestor bez prekrývania. Pred nasadením otestujte rozloženie s najširším očakávaným popisom a nie s tým najkratším.

Čiarové grafy: jedna lomená čiara cez namapované body

Čiarový graf využíva rovnaké mapovanie hodnôt a mení iba spôsob prepojenia bodov. Namiesto obdĺžnika pre každú kategóriu prechádzate dátami, prepočítate každú hodnotu na súradnice (X, Y) pomocou ValueToY a prepojíte body jedným volaním MoveTo, po ktorom nasledujú volania LineTo, a nakoniec celú cestu vykreslíte pomocou Stroke. Prvý bod otvorí cestu, každý ďalší ju predĺži:

procedure DrawLineChart(Page: THPDFPage; const Plot: TPlotArea;
  const Values: array of Single);
var
  I, Count: Integer;
  StepX, X, Y: Single;
begin
  Count := Length(Values);
  if Count < 2 then Exit;
  StepX := Plot.Width / (Count - 1);

  Page.SetLineWidth(1.5);
  Page.SetRGBStrokeColor(RGB(214, 92, 36));
  for I := 0 to Count - 1 do
  begin
    X := Plot.Left + I * StepX;
    Y := ValueToY(Plot, Values[I]);
    if I = 0 then
      Page.MoveTo(X, Y)        // open the path at the first point
    else
      Page.LineTo(X, Y);       // extend it through every later point
  end;
  Page.Stroke;                 // one stroke paints the whole polyline
end;

Všimnite si rozdiel v rozostupoch. Stĺpcový graf delí šírku počtom stĺpcov, pretože každý stĺpec má svoj vlastný priestor. Čiarový graf delí šírku počtom intervalov, teda Count - 1, pretože prvý a posledný bod ležia presne na okrajoch oblasti grafu a čiara prechádza ponad medzery medzi nimi. Ich zamieňanie je bežným dôvodom, prečo sa čiarový graf posunie o polovicu dielu voči stĺpcovému grafu, ktorý má prekrývať. Ak chcete mať na každom dátovom bode značku, vykreslite malý kruh (Circle) a vyplňte ho (Fill) na každých súradniciach (X, Y) po vykreslení lomenej čiary.

Koláčové grafy: oblúky alebo výseky, ak to chcete urobiť jednoducho

Koláčové výseky sú jediným tvarom, ktorý vyžaduje goniometrické funkcie, pretože výsek je ohraničený dvoma polomermi a oblúkom. Presná verzia vykresľuje oblúk prechádzaním krátkych segmentov čiar pozdĺž obvodu, čo aproximuje zakrivenie tak verne, že to žiadny čitateľ nerozozná. Uhol záberu každého výseku zodpovedá jeho podielu na celku, teda (Value / Total) * 2π, a počas prechádzania uhol postupne sčítavate:

procedure DrawPieChart(Page: THPDFPage; CX, CY, Radius: Single;
  const Values: array of Single; const Colors: array of TColor);
var
  I, Step, Steps: Integer;
  Total, Start, Sweep, A: Single;
begin
  Total := 0;
  for I := 0 to High(Values) do Total := Total + Values[I];
  Start := 0;

  for I := 0 to High(Values) do
  begin
    Sweep := (Values[I] / Total) * 2 * Pi;
    Steps := Round(Sweep / (Pi / 90)) + 1;  // ~2 degrees per segment

    Page.SetRGBFillColor(Colors[I]);
    Page.MoveTo(CX, CY);                     // wedge apex at the center
    for Step := 0 to Steps do
    begin
      A := Start + Sweep * (Step / Steps);
      Page.LineTo(CX + Radius * Cos(A), CY + Radius * Sin(A));
    end;
    Page.LineTo(CX, CY);                      // close back to the center
    Page.Fill;

    Start := Start + Sweep;                   // advance to the next slice
  end;
end;

Cesta smerujúca zo stredu (najskôr vrchol, potom oblúk a návrat do stredu) vytvorí uzavretý výsek, ktorý metóda Fill kompletne vyfarbí. Počet segmentov predstavuje kompromis medzi hladkosťou oblúka a veľkosťou cesty: krok približne dva stupne vyzerá pri akomkoľvek bežnom polomere okrúhlo a negeneruje zbytočne obrovský dátový tok obsahu. Ak nepotrebujete skutočný kruh, môžete goniometriu úplne vynechať a zobraziť rovnaké dáta ako jeden vodorovný skladaný stĺpec, kde šírka každého segmentu zodpovedá jeho podielu. To býva často čitateľnejšie ako samotný koláčový graf a ide o rovnaký princíp ako pri stĺpcoch umiestnených za sebou. Po verzii s oblúkmi siahnite iba vtedy, keď dizajn vyžaduje skutočný kruh.

Nič z tohto nevyžaduje inštaláciu žiadnej knižnice pre grafy, čo je skrytou výhodou priameho kreslenia základných tvarov. Tieto grafy zostavujú rovnaké kresliace volania plátna, ktoré umiestňujú logo alebo podpisové pole, a osi označuje rovnaká metóda TextOut, ktorá popisuje polia formulára. Uložte geometriu grafu do záznamu (record), raz prepočítajte hodnoty na súradnicu Y, a stĺpcový, čiarový alebo koláčový graf sa stane krátkou rutinou využívajúkou metódy Rectangle, LineTo a Circle, ktorú môžete vložiť do akéhokoľvek reportu. Volania Rectangle, MoveTo, LineTo, Circle, Fill, Stroke and TextOut použité v tomto článku sú súčasťou komponentu HotPDF pre Delphi a C++Builder.