Een grafiek die als 96-DPI-bitmap uit een schermgrafiekcontrol wordt gekopieerd, oogt prima bij 100% zoom in een PDF-viewer. Zoom naar 400% of stuur de pagina naar een 600-DPI-laserprinter, en de staafranden worden zacht, de aslabels vervagen tot grijze vlekken en het bestand sleept een paar honderd kilobyte pixels mee voor wat in werkelijkheid een dozijn rechthoeken is. Als vectorpaden getekend blijft dezelfde grafiek op elke zoomfactor en elke printresolutie scherp, want de staven, assen en labels zijn PDF-pad- en tekstoperatoren die de viewer op elke gewenste schaal opnieuw rendert
Vectoruitvoer schuift het opmaakwerk naar uw code. Iets moet berekenen waar elke staaf, tick en label landt, en voor staaf-, lijn- en taartdiagrammen is die berekening één coördinaatmapping plus een paar lussen over rechthoeken, lijnsegmenten en bogen. Krijgt u de mapping één keer goed, dan zijn de lussen kort
DrawChart of zelf getekende primitieven in HotPDF?
HotPDF levert een kant-en-klare grafiekrenderer mee, en voor een standaardrapportgrafiek is dat het eerste om te proberen. Bouw een THPDFChart, voeg series toe met AddSeries (soorten hckLine, hckBar, hckArea, hckPie en hckScatter), vul ze met AddPoint en roep THPDFPage.DrawChart aan met een doelrechthoek. De renderer bedenkt zelf het asbereik, het raster, de ticklabels, de legenda en de themakleuren, en de overload met een structure-parent schrijft de grafiek als getagd figuur met alternatieve tekst uit de data, wat telt als het document een toegankelijkheidscontrole moet doorstaan
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');
// Rectangle in pagina-units: left, top, width, height
HotPDF1.CurrentPage.DrawChart(Chart, 60, 120, 470, 280);
finally
Chart.Free;
end;
end;
De renderer heeft wel een vast vocabulaire. Verschillende staafseries staan naast elkaar in elke categorie, een taartdiagram moet de enige serie in zijn grafiek zijn, en de look komt uit drie thema's plus per-series kleuren. Gestapelde staven, een doelband achter de staven, per staaf in één serie een andere merkkleur, een taartpunt die om twaalf uur begint en met de klok mee loopt: zodra het ontwerp iets van dit alles vraagt, is de grafiek zelf tekenen met de pagina-canvas minder werk dan de renderer buigen. Die primitieven zijn Rectangle, MoveTo, LineTo, Pie, Fill, Stroke en TextOut, dezelfde aanroepen als behandeld in de canvas-tekenwalkthrough
Waarom staat mijn staafdiagram ondersteboven?
HotPDF-paginacoördinaten zetten de oorsprong in de linkerbovenhoek met Y die naar beneden groeit, hetzelfde kader als een VCL-canvas. PDF user space zelf loopt andersom (oorsprong linksonder, Y omhoog, ISO 32000-1 §8.3.2.3), en HotPDF draait elke coördinaat bij het schrijven van de contentstream in die ruimte om. Grafiekwaarden groeien daarentegen omhoog. Voert u een geschaalde waarde rechtstreeks in als Y, dan hangt de langste staaf als een ijskegel vanaf de bovenkant van de plot
De oplossing is één uitdrukking. Kies een plotrechthoek, map nul op de onderrand en de top van de schaal op de bovenrand, en haal elke waarde door die mapping voordat hij de pagina raakt. Voor een waarde V op een schaal van 0 tot MaxValue is de pagina-Y Plot.Bottom - (V / Plot.MaxValue) * Plot.Height, waarbij Plot.Bottom de basislijn is. De staafdiagramcode hieronder wikkelt dat in een kleine ValueToY-hulpfunctie en elke andere routine hergebruikt hem
In die uitdrukking schuilen twee keuzes. MaxValue hoort een rond getal boven het grootste datapunt te zijn, het volgende veelvoud van 10 of 100, zodat de langste staaf de bovenrand niet raakt en de rasterlijnlabels 0, 200, 400 lezen in plaats van 0, 203,5, 407. En de schaal begint met opzet bij nul. Staaflengte is de waarde, dus een waardeas die bij 350 begint laat een staaf van 388 nauwelijks hoger lijken dan de helft van een staaf van 420, terwijl het echte verschil onder 8% zit. De ingebouwde renderer trekt nul om precies deze reden in het bereik voor staaf- en vlakseries. Houd dezelfde regel aan in zelfgetekende code
Units zijn de andere helft van de mapping. Met de standaard Resolution van 72 is één pagina-unit één PDF-punt (1/72 inch). Wijzigt u de resolutie, dan schakelen uw coördinaten, lijnbreedtes en tekstmaten allemaal naar de nieuwe unit, wat het artikel over Resolution en tekenunits in detail behandelt. DrawChart neemt zijn doelrechthoek in pagina-units maar zet zijn eigen marges, fontgroottes en lijnbreedtes in punten neer, bij elke resolutie
Een staafdiagram is een lus over rechthoeken
Met de mapping op zijn plek is een staafdiagram een lus. Verdeel de plotbreedte in één vak per categorie, laat wat lucht zodat naburige staven elkaar niet raken, en teken elke staaf als gevulde rechthoek van zijn ValueToY-top tot op de basislijn. Rectangle neemt de linkerbovenhoek, een breedte en een hoogte, wat precies op deze mapping past:
type
TPlotArea = record
Left, Bottom, Width, Height: Single; // pagina-units; Bottom is de basislijn-Y
MaxValue: Single; // top van de waardeschaal
end;
// Uw eigen helper: een grotere waarde zit hoger, wat op een Y-omlaag-pagina
// een kleinere Y betekent
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; // een kwart van het vak, links en rechts gesplitst
BarW := SlotW - Gap;
// Eerst de gestreepte rasterlijnen, zodat de staven eroverheen schilderen
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 zet de bovenkant van de tekst op Y
Page.SetRGBFillColor(clBlack);
Page.TextOut(BarX, Plot.Bottom + 4, 0, Labels[I]);
Page.TextOut(BarX, BarTop - 12, 0, FormatFloat('0', Values[I]));
end;
// De basislijn als laatste, zodat hij boven op de staafvoeten ligt
Page.SetLineWidth(1.0);
Page.SetRGBStrokeColor(clBlack);
Page.MoveTo(Plot.Left, Plot.Bottom);
Page.LineTo(Plot.Left + Plot.Width, Plot.Bottom);
Page.Stroke;
end;
Volgorde is hier het hele laagmodel. PDF gebruikt een schildersmodel, waarbij latere operatoren in de contentstream over eerdere schilderen, dus de rasterlijnen eerst, de staven eroverheen, de basislijn als laatste. Wisselt u het rasterlijnblok naar onder de lus, dan lopen de streepjes dwars door elke staaf
De tussenruimte is een fractie van het vak in plaats van een vast aantal punten. Met vier categorieën op een plot van 470 punten is elk vak 117,5 punten breed en krijgen de staven ongeveer 29 punten lucht ertussen; met veertig categorieën laat dezelfde code minder dan 3 punten over, wat nog steeds als aparte staven leest. Een vaste tussenruimte van 12 punten zou bij veertig categorieën 480 van de 470 beschikbare punten opslokken en de staafbreedte zou negatief worden
Aslabels die elkaar niet raken
Labels zijn waar handgetekende grafieken het eerst breken, en bijna altijd op dezelfde manier. Tekst die op het scherm paste loopt op de pagina uit zijn ruimte, omdat de ontwikkelaar met "Sep" testte en het Duitse rapport "September" of "Dezember" print in een font van 9 punten. De waardeas heeft een stillere variant van hetzelfde probleem: links uitgelijnde ticklabels op een vaste X-offset ogen gehavend zodra de getallen van lengte veranderen, en een label "1.200" loopt tegen de aslijn aan die "800" nog wist te vermijden
Meet in plaats van te gokken. TextWidth geeft de breedte van een string in het actieve font terug, in pagina-units, en TextHeight geeft de ascent van het font op de actieve grootte. Lijn elke ticklabel rechts uit tegen de as en centreer hem op zijn tick:
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); // kort streepje buiten de as
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;
Voor categorielabels vertelt dezelfde meting wanneer u horizontale tekst moet opgeven. Vergelijk het breedste label met de vakbreedte voordat u iets tekent. Past het niet, laat het font dan een punt of twee zakken; past het nog niet, geef dan 90 als hoekargument van TextOut en zet de labels op hun punt, en houd dan onder de basislijn genoeg marge voor het langste. Test met het breedste label dat het rapport werkelijk kan opleveren, wat bij maandnamen in een gelokaliseerde build zelden het Engelse is
Lijndiagrammen hergebruiken dezelfde mapping
Een lijndiagram verandert alleen hoe de punten verbonden worden. Loop de data één keer door, zet elke waarde om met ValueToY en rijg de punten aan één pad: MoveTo voor het eerste punt, LineTo voor elk volgend, één Stroke op het eind. Heeft u de punten al in een array, dan neemt Polyline een array of THPDFCurrPoint, opent een vers pad en laat het stroke aan u; onder twee punten gooit hij een exception, dus een serie met één meting heeft een marker nodig in plaats van een lijn
De onderlinge afstand is waar een lijndiagram afwijkt van een staafdiagram over dezelfde plot. Staven delen de breedte door het aantal categorieën omdat elke staaf een vak bezit. Een lijn deelt door het aantal intervallen, Count - 1, want het eerste en laatste punt zitten op de plotranden. Legt u de twee over elkaar, dan landen de lijnpunten op de vakgrenzen in plaats van op de staafcentra. Rijdt een lijn over de staven, plaats zijn punten dan op elk vacentrum, Plot.Left + I * SlotW + SlotW / 2, en laat de Count - 1-afstand helemaal vallen. Voor een marker op elk punt doet een kleine Circle gevolgd door Fill na het strijken van de lijn het werk
Taartpunten met Pie, met de klok mee vanaf twaalf uur
Een taartpunt bestaat uit twee stralen en een boog. De eigenzinnige manier om er een te bouwen begint in het midden, stapt korte LineTo-segmenten van een paar graden uit elkaar langs de rand en sluit terug naar het midden, precies de constructie in de figuur hieronder. Het werkt, maar een taartpunt van 270 graden verandert in ruim honderd padsegmenten en de rand blijft onder sterke zoom een veelhoek
Gebruik liever Pie. THPDFPage.Pie volgt de GDI-conventie: een bounding-rechthoek, een start- en een eindhoek in graden, 0 wijst naar het oosten, 90 naar het noorden, positieve hoeken tegen de klok in. Hij bouwt de boog uit Bézier-segmenten van hoogstens 90 graden elk, tekent de lijn naar het midden en sluit het pad, dus de rand blijft een echte curve en een volledige punt kost een handjevol operatoren. Zakelijke taarten beginnen meestal om twaalf uur en lopen met de klok mee, wat in GDI-termen betekent: beginnen op 90 en de hoek naar beneden laten lopen:
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; // twaalf uur
Page.SetRGBStrokeColor(clWhite);
Page.SetLineWidth(0.8);
for I := 0 to High(Values) do
begin
if Values[I] <= 0 then
Continue; // zie hieronder: geef nooit een nul of negatieve veeg door
Sweep := Values[I] / Total * 360;
Page.SetRGBFillColor(Colors[I mod Length(Colors)]);
// Tegen de klok in van Angle - Sweep tot Angle is de punt die
// met de klok mee van de huidige positie zit
Page.Pie(CX - Radius, CY - Radius, CX + Radius, CY + Radius,
Angle - Sweep, Angle);
Page.FillAndStroke; // de witte omlijning scheidt de punten
Angle := Angle - Sweep;
end;
end;
De Continue-bewaking is de regel die er het meest toe doet. Pie behandelt een eindhoek onder de starthoek als een veeg die voorbij 360 loopt, de GDI-lezing, en dat is juist wat een punt over de 0-gradenlijn laat gaan. Een terugbetaalregel of een correctieregel in de brongegevens geeft een negatieve waarde, de berekende eindhoek belandt onder de start, en de punt wikkelt zich als bijna volledige schijf over alles wat daarvoor is getekend. Een nulwaarde is milder maar wel zichtbaar: het gesloten pad krimpt tot een straal, en de witte scheidingslijn tekent hem als een verdwaalde lijn van rand naar midden. Filter beide weg vóór de lus en besluit apart hoe het rapport negatieve bedragen toont, want een taart kan ze helemaal niet tonen
Als het ontwerp geen cirkel eist, laat één horizontale gestapelde staaf dezelfde verhoudingen zien met niets anders dan Rectangle-aanroepen achter elkaar, en lezers vergelijken lengtes nauwkeuriger dan hoeken. Voor een deel-van-het-geheel-figuur met meer dan vijf of zes categorieën is dat het betere diagram, en de staafdiagramcode hierboven doet al het meeste werk
Elke grafiek in dit artikel is opgebouwd uit de pagina-canvas-aanroepen van het HotPDF Delphi Component, met DrawChart beschikbaar voor de standaardlijn-, staaf-, vlak-, taart- en spreidingslay-outs, en alles draait vanuit Delphi of C++Builder zonder een grafiekbibliotheek op de buildmachine