Technický článek

Vykreslení grafů v PDF pomocí primitiv HotPDF

HotPDF neobsahuje žádný objekt pro grafy. Neexistuje tu žádný TPDFChart, žádné volání typu AddBarSeries, prostě nic, co by přijalo pole čísel a vydalo vyrenderovaný graf. To, co namísto toho dostáváte, je stránka s kreslicím plátnem se stejným nízkoúrovňovým slovníkem, který používá každý kreslicí model v PDF: obdélníky, čáry, kruhy, výplně, tahy a text umísťovaný na naprosto přesné souřadnice. Graf v dokumentu HotPDF je tak něco, co musíte sami sestavit, ne něco, o co si zkrátka zažádáte. Zní to složitěji, než jaké to ve skutečnosti je. Jakmile si jednou napíšete tu správnou souřadnicovou matematiku, sloupcový graf je jen smyčka přes obdélníky, čárový graf je lomená čára (polyline) a koláčový graf je vějíř složený z oblouků, přičemž vy sami kontrolujete u takového výsledku úplně každý pixel

Záleží na tom proto, že alternativa, po které lidé sáhnou jako první (rastrovaný ovládací prvek z obrazovky uložený do bitmapy a vložený coby obrázek na stránku), vám dá graf uzamčený v obrazovkovém rozlišení, který se vytiskne rozmazaně a zbytečně soubor nafoukne. Kreslení grafu pomocí vektorových primitiv HotPDF udržuje výstup ostrý v libovolném přiblížení i v jakémkoli tiskovém DPI, protože sloupce a osy jsou pak ve skutečnosti čisté operátory cesty (path operators) v PDF a nikoli jen pixely. Cenou za to však je, že musíte mít plně ve svých rukou rozvržení stránky (layout). Samotná mechanika k tomu se scvrkává jen na hrstku kroků: jedno překlopení souřadnic, na kterém se každý nachytá, odpracovaný sloupcový graf, trik s lomenou čárou u čárových grafů a nakonec s matematikou pro výpočet kruhových výsečí (plátků koláče)

Jediná těžká část leží u souřadnicového systému

Počítačová grafika klade počátek do levého horního rohu a Y se zvětšuje směrem dolů. PDF dělá pravý opak. Počátek sedí v levém dolním rohu stránky a Y roste vzhůru, přičemž se měří v bodech (1/72 palce). Každé volání pro vykreslení v HotPDF – TextOut, Rectangle, MoveTo, LineTo i Circle – využívá tuto konvenci se spodem vlevo a narůstajícím Y vzhůru. Pokud sem přenesete instinkty z grafiky pro obrazovky, váš první graf se nakreslí vzhůru nohama a vyteče mimo spodní okraj stránky

Tím pádem leží ta skutečná námaha u každého grafu v jednom mapování: přeměnit hodnotu dat na Y souřadnici, která respektuje nárůst Y vzhůru. Stanovte si obdélník pro zobrazení grafu (plot rectangle), neboli čtyři čísla reprezentující plochu, ve které bude graf žít, a poté namapujte nejmenší datovou hodnotu na spodní hranu a tu nejvyšší na horní. Pro sloupec s hodnotou V na škále, která probíhá od 0 po MaxValue, platí, že vrchní hrana sloupce bude PlotBottom + (V / MaxValue) * PlotHeight, a samotný sloupec vyroste od PlotBottom. Zvládněte tento jeden jediný matematický výraz správně a veškerý ten zbytek se stane už jen obyčejnou účtařinou (bookkeeping). Pomocná funkce (helper) níže drží geometrii zobrazení a provádí konverzi tak, aby se kreslicí kód už podruhé nikdy nemusel dotýkat surové aritmetiky:

HotPDF: Obrazovkové souřadnice mají počátek vlevo nahoře s Y rostoucím dolů, PDF souřadnice začínají vlevo dole s Y rostoucím nahoru a ValueToY mapuje datovou hodnotu do tohoto prostoru s Y nahoru
PDF překlápí známý obrazový rámec: počátek sedí vlevo dole a Y roste vzhůru, přičemž jeden výraz ValueToY mapuje každou datovou hodnotu do tohoto prostoru
type
  TPlotArea = record
    Left, Bottom, Width, Height: Single;  // body PDF, počátek vlevo dole
    MaxValue: Single;                     // horní hranice škály hodnot
  end;

// Namapujte datovou hodnotu na její Y souřadnici uvnitř výkresu; Y roste vzhůru.
function ValueToY(const Plot: TPlotArea; V: Single): Single;
begin
  Result := Plot.Bottom + (V / Plot.MaxValue) * Plot.Height;
end;

Jeden z odhadů se skrývá v hodnotě MaxValue. Pokud ji nastavíte přesně na největší datový bod, nejvyšší sloupec se dotkne horní hrany grafu a bude vypadat oříznutě. Zaokrouhlete ji nahoru na čisté číslo nad daným maximem, řekněme na další násobek deseti nebo sta, takže graf získá rezervu (headroom) a štítky s hodnotami mřížky budou čitelné v celých (kulatých) číslech, namísto těch, na kterých data zrovna dosáhla svého maxima

Sloupcový graf je smyčka přes obdélníky

Jakmile je mapování vyřešeno, sloupcový graf se napíše v podstatě sám. Rozdělte šířku grafu na jeden slot (okénko) na kategorii, ponechte mezi sloupci určitou mezeru tak, aby se nedotýkaly, a nakreslete každý sloupec jako vyplněný obdélník, jehož výška pochází z metody ValueToY. Metoda Rectangle komponenty HotPDF přijímá levý dolní roh spolu se šířkou a výškou, což se dokonale sladí se sloupcem, který roste směrem vzhůru od své základní linky (baseline). Nastavte barvu výplně, sestavte cestu a následně ji vykreslete zavoláním Fill. Označení kategorie se nachází pod základní linkou a samotná hodnota naopak leží nad sloupcem:

HotPDF: Anatomie sloupcového grafu ukazující sloty odvozené ze šířky vykreslovací plochy s mezerami mezi sloupci, každý sloupec jako vyplněný Rectangle rostoucí nahoru od Plot.Bottom, popisky kategorií pod základnou, popisky hodnot na výšce sloupce a značky osy znovu používající ValueToY
Každý pruh je jedna vyplněná Rectangle rostoucí vzhůru z Plot.Bottom uvnitř svého slotu, mezera čtvrt slotu se škáluje s šířkou a dílky osy znovu používají ValueToY, takže pruhy a mřížka si odpovídají
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;          // čtvrtslotová mezera na každé straně
  BarW := SlotW - Gap;

  // Základní linka (osa X) podél spodní hrany výkresu.
  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;

    // Popisek kategorie pod základní linkou, hodnota nad sloupcem.
    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 z toho si plně zaslouží svou pozornost. Parametr Gap (mezera) tvoří zlomek ze slotu (okénka) a nikoliv fixně daný počet bodů, takže sloupce si zachovávají proporcionální odstupy bez ohledu na to, zda zakreslujete kategorie čtyři, nebo čtyřicet. Dále je popisek s hodnotou umístěn u výšky odvozené ze stejné funkce ValueToY, kterou používá i samotný sloupec, díky čemuž pak bude popisek ukotven těsně nad svým vlastním sloupcem, místo aby jen volně plul s využitím zkusmo odhadnutého offsetu. Pokud si přejete vodorovné linie (mřížku) za sloupci, vykreslete je před samotnou smyčkou: vyberte si tři nebo čtyři kulaté hodnoty, na každou uplatněte ValueToY a danou Y souřadnicí veďte přes graf matnou linku. Kreslením čar jako prvních se tyto ocitnou ve skládání (stackingu), jež v PDF funguje na základě malířského modelu (painter's-model), bezpečně v pozadí (za sloupci)

Osy, značky (ticks) a štítky jsou prostě jen další linky a text

Graf není hotov, dokud čtenář nedokáže jasně poznat, co dané sloupce znamenají, a to je plně v kompetenci práce u os. Svislou osu tvoří jediná čára tažená nahoru podél levého okraje zobrazení, doplněná několika značkami (ticks) a jim odpovídajícími hodnotami. Opět využijte funkce ValueToY, aby značky spadaly do stejného měřítka, které využívají sloupce; v opačném případě pak mezi sebou sloupec a jeho odpovídající mřížka začnou hrubě nesouhlasit, čímž bude graf v tichosti lhát:

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);   // krátká značka vně osy
    Page.LineTo(Plot.Left, TickY);
    Page.Stroke;
    Page.TextOut(Plot.Left - 30, TickY - 3, 0, FormatFloat('0', TickV));
  end;
end;

Zádrhely spojené s popisky jsou to, na čem grafy v produkci selhávají nejčastěji, a princip selhání je pokaždé stejný: text, který se bez problémů vešel na obrazovku, přeteče vymezené místo v PDF dokumentu. Dlouhá jména kategorií začnou narážet do svých sousedů a lokalizované názvy měsíců jako „septembre“ nebo „Dezember“ vyžadují více prostoru do šířky než vámi testované kratší anglické „Sep“. Před touto hrozbou vás tu ovšem nezachrání žádná funkce autosize, proto ponechte pod základní linií dostatečný reálný okraj. U hustých kategorií smrskněte písmo o bod nebo dva dolů, a v případech, kdy jsou názvy opravdu dlouhé, text rovnou zrotujte. Příkaz TextOut přijímá jako třetí argument úhel, takže předání úhlu 90 postaví popisek na konec a získá vám tolik potřebný prostor i bez překryvů (overlap). Před odevzdáním finálního exportu do produkce otestujte rozvržení se svým absolutně nejširším očekávaným popiskem, nikoliv s tím nejkratším

Čárové grafy: jedna složená lomená čára (polyline) napříč mapovanými body

Čárový graf opakovaně využívá celé mapování hodnot a mění se pouze to, jak se body (points) spojují. Místo abyste vykreslovali obdélník pro každou kategorii, projdete data pouze jednou, zkonvertujete každou hodnotu na její podobu (X, Y) s funkcí ValueToY, a tyto body sešijete dohromady pomocí jednoho MoveTo s následným voláním LineTo, a úplně nakonec to celé vykreslíte jedním tahem (stroked). První bod začne s budováním u cesty a každý pozdější bod u trasy ji nadále prodlužuje (extends):

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)        // otevřete cestu v prvním bodě
    else
      Page.LineTo(X, Y);       // prodlužte ji přes každý další bod
  end;
  Page.Stroke;                 // jeden tah vykreslí celou lomenou čáru
end;

Všimněte si rozdílu v rozestupech. Sloupcový graf dělí šířku počtem sloupců, protože každý sloupec vlastní jeden slot. Čárový graf naopak dělí počtem intervalů, Count - 1, protože první a poslední bod sedí na okrajích zobrazení a čára překlenuje mezery mezi nimi. Popletení těchto dvou variant je nejčastějším důvodem, proč čárový graf o půl slotu uskočí od sloupcového grafu, který má překrývat. Pokud chcete na každém datovém bodě značku, položte po vytažení lomené čáry na každou souřadnici (X, Y) malou Circle a aplikujte Fill

Koláčové grafy: oblouky nebo výseče (wedges), pokud to ponecháte jednoduché

Výseče koláčového grafu jsou jediným tvarem, který vyžaduje trigonometrii, protože klín je ohraničen dvěma poloměry a obloukem. Poctivá varianta opisuje oblouk drobnými úsečkami kráčejícími podél obvodu, což aproximuje křivku dostatečně věrně, aby ji od oka nepoznal žádný čtenář. Úhel oblouku každé výseče je její podíl z celku, (Value / Total) * 2π, a průběžný úhel kumulujete, jak se kolem dokola posouváte:

HotPDF: Konstrukce výseče koláče začínající MoveTo ve středu kruhu, krokováním malých segmentů LineTo asi dva stupně od sebe po oblouku přes rozsah úhlu každé výseče a uzavřením ke středu, takže Fill natře plný klín
Klín začíná MoveTo ve středu, kráčí malými kroky LineTo asi dva stupně od sebe podél svého oblouku po obvodu a zavře se zpět, takže Fill ho natře do plna
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 stupně na segment

    Page.SetRGBFillColor(Colors[I]);
    Page.MoveTo(CX, CY);                     // hrot výseče uprostřed
    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);                      // uzavřete zpět do středu
    Page.Fill;

    Start := Start + Sweep;                   // přejděte k další výseči
  end;
end;

Středová cesta, hrot nejprve, pak oblouk, pak zpět na hrot, dává uzavřenou výseč, kterou Fill (výplň) nakreslí celistvě. Počet segmentů vyměňuje hladkost za velikost cesty: zhruba dva stupně na jeden krok vypadají u jakéhokoli smysluplného poloměru kulatě, aniž by vytvořily obrovský proud obsahu. Pokud nepotřebujete skutečný kruh, můžete trigonometrii zcela vynechat a stejná data vykreslit jako jeden vodorovný skládaný sloupkový graf (horizontal stacked bar), šířka každého segmentu pak odpovídá úměrně jeho podílu. To je často beztak čitelnější než koláčový graf, a jedná se pouze o sloupcový graf (bar code) s obdélníky položenými koncem k sobě. Pro verzi s obloukem sáhněte pouze tehdy, když design vyžaduje skutečný kruh

Nic z toho nezávisí na žádné nainstalované knihovně pro grafy, což je tichá výhoda přímého kreslení primitiv. Stejná volání kreslení na plátno, která umísťují logo nebo box pro podpis, postaví i tyto grafy a stejný TextOut, který popisuje formulářové pole, popíše i osu. Uložte geometrii zobrazení do záznamu, namapujte hodnoty na Y jednou a sloupcový, čárový i koláčový graf je krátká rutina nad Rectangle, LineTo a Circle, kterou můžete vložit do libovolného reportu. Volání Rectangle, MoveTo, LineTo, Circle, Fill, Stroke a TextOut použité v tomto článku jsou součástí HotPDF Delphi Component pro Delphi a C++Builder