HotPDF ไม่มีวัตถุแผนภูมิ (chart object) ไม่มี TPDFChart ไม่มี AddBarSeries และไม่มีสิ่งใดที่รับอาร์เรย์ของตัวเลขแล้วคืนค่ากลับมาเป็นกราฟที่วาดเสร็จแล้ว สิ่งที่ HotPDF มีให้คือหน้าผืนผ้าใบ (page canvas) ที่ใช้ชุดคำสั่งระดับล่างแบบเดียวกับที่โมเดลการวาด PDF อื่นๆ ใช้ ได้แก่ rectangles, lines, circles, fills, strokes และข้อความที่วางในพิกัดที่แน่นอน แผนภูมิในเอกสาร HotPDF จึงเป็นสิ่งที่คุณต้องสร้างขึ้นเอง ไม่ใช่สิ่งที่คุณร้องขอให้ระบบสร้างให้ ซึ่งอาจฟังดูเหมือนเป็นงานหนักกว่าความเป็นจริง เมื่อคุณเขียนคณิตศาสตร์ของพิกัดเสร็จแล้ว แผนภูมิแท่งก็จะเป็นเพียงการวนลูปสร้างสี่เหลี่ยม แผนภูมิเส้นคือ polyline และแผนภูมิวงกลมคือกลุ่มของเส้นโค้ง (arcs) และคุณสามารถควบคุมทุกพิกเซลของผลลัพธ์ได้
เรื่องนี้สำคัญเพราะทางเลือกแรกที่คนส่วนใหญ่มักนึกถึง คือการแปลงคอนโทรลแผนภูมิบนหน้าจอให้เป็นบิตแมป (bitmap) แล้ววางรูปลงในหน้ากระดาษ ซึ่งจะทำให้คุณได้แผนภูมิที่ถูกล็อกไว้กับความละเอียดของหน้าจอ พิมพ์ออกมาแล้วเบลอ และทำให้ขนาดไฟล์ใหญ่ขึ้น การวาดแผนภูมิด้วยรูปทรงเวกเตอร์พื้นฐาน (vector primitives) ของ HotPDF จะช่วยให้ผลลัพธ์มีความคมชัดในทุกระดับการซูมและทุก DPI ของการพิมพ์ เนื่องจากแท่งแผนภูมิและแกนต่างๆ คือตัวดำเนินการพาธของ PDF จริงๆ ไม่ใช่พิกเซล ข้อแลกเปลี่ยนคือคุณต้องจัดการเค้าโครง (layout) เอง โดยกลไกการทำงานจะประกอบด้วยขั้นตอนไม่กี่อย่าง ได้แก่ การพลิกพิกัดที่ทุกคนมักจะพลาด, แผนภูมิแท่งที่ทำงานได้จริง, เทคนิค polyline สำหรับแผนภูมิเส้น, และคณิตศาสตร์ของเส้นโค้งสำหรับชิ้นส่วนของแผนภูมิวงกลม
ส่วนที่ยากเพียงอย่างเดียวคือระบบพิกัด
กราฟิกบนหน้าจอจะกำหนดจุดกำเนิด (origin) ไว้ที่มุมซ้ายบนและให้แกน Y เพิ่มค่าลงด้านล่าง แต่ PDF ทำตรงกันข้าม จุดกำเนิดจะอยู่ที่มุมซ้ายล่างของหน้าและแกน Y เพิ่มค่าขึ้นด้านบน โดยวัดเป็นพอยต์ (1/72 นิ้ว) ทุกคำสั่งวาดใน HotPDF ไม่ว่าจะเป็น TextOut, Rectangle, MoveTo, LineTo, Circle จะใช้หลักการจุดมุมซ้ายล่างและ Y เพิ่มขึ้นด้านบนนี้ หากคุณใช้สัญชาตญาณของการทำกราฟิกบนหน้าจอ แผนภูมิแรกของคุณจะถูกวาดกลับหัวและทะลุออกไปนอกขอบล่างของหน้า
ดังนั้นงานที่แท้จริงในแผนภูมิใดๆ คือการจับคู่พิกัด (mapping) 1 จุด: แปลงค่าข้อมูลให้เป็นพิกัด Y ที่เป็นไปตามหลักการ Y เพิ่มขึ้นด้านบน กำหนดพื้นที่รูปสี่เหลี่ยมสำหรับพล็อต (plot rectangle) ด้วยตัวเลข 4 ค่าสำหรับพื้นที่ที่แผนภูมิจะอยู่ จากนั้นจับคู่ค่าที่น้อยที่สุดในข้อมูลของคุณเข้ากับขอบล่างและค่าที่มากที่สุดเข้ากับขอบบน สำหรับแท่งแผนภูมิที่มีค่า V บนสเกลที่เริ่มจาก 0 ถึง MaxValue ขอบบนของแท่งจะอยู่ที่ PlotBottom + (V / MaxValue) * PlotHeight และแท่งนั้นจะวาดสูงขึ้นไปจาก PlotBottom เมื่อคุณจัดการนิพจน์นี้ได้อย่างถูกต้อง ทุกอย่างที่เหลือก็แค่การจัดการข้อมูล ฟังก์ชันตัวช่วยด้านล่างจะเก็บค่าเรขาคณิตของพื้นที่พล็อตและทำการแปลงค่า เพื่อให้โค้ดส่วนวาดกราฟิกไม่ต้องไปยุ่งกับการคำนวณทางคณิตศาสตร์ดิบๆ ซ้ำอีก:
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;
การตัดสินใจอย่างหนึ่งแฝงอยู่ใน MaxValue หากคุณตั้งค่าให้เท่ากับจุดข้อมูลที่ใหญ่ที่สุดพอดี แท่งที่สูงที่สุดจะชนขอบบนของพื้นที่พล็อตและดูเหมือนถูกตัดออก ควรปัดเศษขึ้นให้เป็นตัวเลขที่ลงตัวซึ่งมากกว่าค่าสูงสุด เช่น ผลคูณของ 10 หรือ 100 ลำดับถัดไป เพื่อให้แผนภูมิมีพื้นที่ว่างด้านบน (headroom) และป้ายกำกับเส้นตาราง (gridline labels) แสดงเป็นตัวเลขกลมๆ แทนที่จะเป็นค่าที่บังเอิญไปตรงกับจุดสูงสุดของข้อมูล
แผนภูมิแท่งคือการวนลูปสี่เหลี่ยม
เมื่อจัดการการจับคู่พิกัดเสร็จแล้ว แผนภูมิแท่งก็จะสามารถเขียนด้วยตัวมันเอง แบ่งความกว้างของพื้นที่พล็อตให้เป็น 1 ช่องต่อ 1 หมวดหมู่ เว้นช่องว่างระหว่างแท่งเพื่อไม่ให้แท่งติดกัน และวาดแต่ละแท่งเป็นสี่เหลี่ยมแบบเติมสีโดยนำความสูงมาจาก ValueToY คำสั่ง Rectangle ของ HotPDF จะรับค่ามุมซ้ายล่างพร้อมกับความกว้างและความสูง ซึ่งเข้ากันพอดีกับแท่งที่วาดสูงขึ้นไปจากเส้นฐาน (baseline) กำหนดสีพื้นก่อน กำหนดเส้นพาธ แล้วเรียกใช้ Fill เพื่อระบายสี ป้ายกำกับหมวดหมู่จะอยู่ใต้เส้นฐาน และค่าข้อมูลจะอยู่เหนือแท่ง:
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;
มีรายละเอียด 2 อย่างที่สำคัญ ตัวแปร Gap จะกำหนดให้เป็นสัดส่วนของช่องแทนที่จะเป็นจำนวนพอยต์คงที่ เพื่อให้แท่งต่างๆ ยังคงเว้นระยะห่างได้อย่างสมส่วน ไม่ว่าคุณจะพล็อตข้อมูล 4 หมวดหมู่หรือ 40 หมวดหมู่ก็ตาม และป้ายกำกับค่าข้อมูลจะวางตำแหน่งโดยใช้ความสูงที่มาจาก ValueToY ซึ่งเป็นค่าเดียวกับที่แท่งใช้ ทำให้ป้ายกำกับอยู่เหนือแท่งของตัวเองเสมอ แทนที่จะลอยอยู่ด้วยการเดาระยะ หากคุณต้องการเส้นตารางแนวนอนไว้ด้านหลังแท่ง ให้วาดเส้นตารางเหล่านี้ก่อนที่จะวนลูป: เลือกตัวเลขกลมๆ สัก 3 หรือ 4 ค่า นำไปคำนวณผ่าน ValueToY ในแต่ละค่า และวาดเส้นบางๆ ข้ามพื้นที่พล็อตที่พิกัด Y นั้น การวาดเส้นตารางก่อนจะทำให้มันอยู่ด้านหลังแท่ง ตามรูปแบบการทับซ้อนแบบจิตรกร (painter's-model stacking) ที่ PDF ใช้
แกน เครื่องหมายขีด และป้ายกำกับ ก็เป็นเพียงเส้นและข้อความที่เพิ่มขึ้นมา
แผนภูมิจะไม่สมบูรณ์จนกว่าผู้อ่านจะรู้ว่าแท่งเหล่านั้นมีความหมายว่าอย่างไร และนั่นคือหน้าที่ของแกนล้วนๆ แกนแนวตั้งจะเป็นเส้นที่ถูกวาดขึ้นไปตามขอบซ้ายของพื้นที่พล็อต พร้อมด้วยเครื่องหมายขีด (tick marks) เล็กน้อยและค่าของมัน นำ ValueToY กลับมาใช้ซ้ำเพื่อให้เครื่องหมายขีดไปตกอยู่ในสเกลเดียวกันกับที่แท่งใช้ มิฉะนั้นแท่งและเส้นตารางของมันจะไม่ตรงกันและแผนภูมิก็จะแสดงข้อมูลคลาดเคลื่อน:
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;
ป้ายกำกับคือสิ่งที่มักจะทำให้แผนภูมิพังในการใช้งานจริง และปัญหาที่พบก็มักจะเป็นแบบเดิม: ข้อความที่พอดีกับหน้าจอกลับล้นเกินพื้นที่ในเอกสาร PDF ชื่อหมวดหมู่ที่ยาวจะชนกับชื่อที่อยู่ข้างเคียง และชื่อเดือนที่ถูกปรับเป็นภาษาท้องถิ่น เช่น "septembre" หรือ "Dezember" จะกว้างกว่าภาษาอังกฤษ "Sep" ที่คุณใช้ทดสอบ ไม่มีระบบปรับขนาดอัตโนมัติ (autosize) มาช่วยคุณในเรื่องนี้ ดังนั้นควรเว้นระยะขอบ (margin) จริงๆ ไว้ใต้เส้นฐาน ลดขนาดฟอนต์ลงสัก 1 หรือ 2 พอยต์หากมีชุดหมวดหมู่ที่หนาแน่น และหากชื่อยาวจริงๆ ให้หมุนมัน คำสั่ง TextOut รับค่ามุมเป็นอาร์กิวเมนต์ที่ 3 ดังนั้นการส่งค่า 90 จะทำให้ป้ายกำกับตั้งขึ้นและเพิ่มพื้นที่ให้คุณโดยไม่ทับซ้อนกัน ทดสอบเค้าโครงด้วยป้ายกำกับที่คุณคาดว่าจะยาวที่สุด ไม่ใช่แบบที่สั้นที่สุด ก่อนที่จะส่งออกไฟล์
แผนภูมิเส้น: 1 polyline ลากผ่านจุดที่จับคู่ไว้
แผนภูมิเส้นจะนำการจับคู่พิกัดข้อมูลทั้งหมดกลับมาใช้ใหม่ และเปลี่ยนแค่รูปแบบการเชื่อมต่อจุด แทนที่จะใช้ 1 สี่เหลี่ยมต่อ 1 หมวดหมู่ คุณจะวนผ่านข้อมูล 1 รอบ แปลงแต่ละค่าให้เป็น (X, Y) ด้วย ValueToY และเชื่อมจุดต่างๆ เข้าด้วยกันด้วย MoveTo ครั้งเดียวแล้วตามด้วยคำสั่ง LineTo หลายๆ ครั้ง จากนั้นจึงเรียกใช้ stroke ในตอนท้าย จุดแรกจะเป็นตัวเปิดพาธ ส่วนจุดหลังจากนั้นจะเป็นตัวขยายพาธ:
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;
สังเกตความแตกต่างของการเว้นระยะห่าง แผนภูมิแท่งจะหารความกว้างด้วยจำนวนแท่ง เนื่องจากแต่ละแท่งจะมีช่องของตัวเอง ส่วนแผนภูมิเส้นจะหารด้วยจำนวนของช่วงระยะทาง (intervals) คือ Count - 1 เนื่องจากจุดแรกและจุดสุดท้ายจะอยู่ตรงขอบของพื้นที่พล็อต และเส้นจะทอดข้ามช่องว่างระหว่างจุดเหล่านั้น การสับสนระหว่างสองสิ่งนี้มักเป็นสาเหตุที่ทำให้แผนภูมิเส้นคลาดเคลื่อนไปครึ่งช่องเมื่อเทียบกับแผนภูมิแท่งที่มันควรจะวางซ้อนทับกัน หากคุณต้องการสร้างตัวทำเครื่องหมาย (marker) ที่แต่ละจุดข้อมูล ให้เพิ่ม Circle ขนาดเล็กและ Fill ที่ตำแหน่ง (X, Y) ทุกจุด หลังจากที่วาดเส้น polyline เสร็จแล้ว
แผนภูมิวงกลม: เส้นโค้ง หรือรูปทรงลิ่มถ้าต้องการความเรียบง่าย
ชิ้นส่วนของแผนภูมิวงกลมเป็นเพียงรูปทรงเดียวที่ต้องใช้ตรีโกณมิติ เนื่องจากรูปทรงลิ่มจะถูกล้อมรอบด้วยรัศมี 2 เส้นและ 1 เส้นโค้ง (arc) วิธีการวาดเส้นโค้งจริงๆ คือการลากส่วนของเส้นตรงขนาดเล็กไปตามเส้นรอบวง ซึ่งเป็นการประมาณค่าความโค้งได้ใกล้เคียงมากพอจนผู้อ่านไม่สามารถบอกความแตกต่างได้ มุมกวาด (sweep angle) ของแต่ละชิ้นจะคำนวณจากสัดส่วนของยอดรวม คือ (Value / Total) * 2π และคุณจะสะสมค่ามุมต่อเนื่องไปเรื่อยๆ ขณะที่วาดวนรอบวง:
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;
พาธที่เริ่มจากจุดศูนย์กลางออกไปด้านนอก โดยเริ่มที่จุดยอดก่อน จากนั้นวาดเส้นโค้ง แล้วลากกลับมาที่จุดยอด จะสร้างเป็นรูปทรงลิ่มแบบปิดทึบที่ให้คำสั่ง Fill ระบายสีได้เต็มพื้นที่ จำนวนส่วนของเส้นตรงจะเป็นการแลกเปลี่ยนระหว่างความราบเรียบกับขนาดของพาธ: การใช้สเต็ปละประมาณ 2 องศา จะดูมีความกลมมนในทุกขนาดรัศมีที่เหมาะสมโดยไม่สร้างข้อมูลเนื้อหาที่ใหญ่เกินไป หากคุณไม่ต้องการวงกลมที่สมบูรณ์แบบ คุณสามารถข้ามขั้นตอนตรีโกณมิตินี้ไปได้เลย และใช้ข้อมูลชุดเดียวกันในการเรนเดอร์แท่งแผนภูมิแนวนอนแบบซ้อนทับกัน (horizontal stacked bar) 1 แท่ง โดยให้ความกว้างของแต่ละส่วนเป็นไปตามสัดส่วนของยอดรวม ซึ่งมักจะอ่านได้ง่ายกว่าแผนภูมิวงกลมอยู่ดี และใช้แค่โค้ดของแผนภูมิแท่งนำมาวางสี่เหลี่ยมต่อกันจนสุด ใช้เวอร์ชันเส้นโค้งเฉพาะในกรณีที่ดีไซน์ต้องการวงกลมจริงๆ เท่านั้น
ทั้งหมดนี้ไม่จำเป็นต้องพึ่งพาการติดตั้งไลบรารีแผนภูมิใดๆ ซึ่งเป็นข้อดีที่ซ่อนอยู่ของการวาดด้วยรูปทรงพื้นฐานโดยตรง คำสั่งวาดผืนผ้าใบ (canvas drawing calls) แบบเดียวกันกับที่ใช้วางโลโก้หรือกล่องลายเซ็นจะเป็นตัวสร้างแผนภูมิเหล่านี้ และ TextOut ตัวเดียวกันกับที่ใช้ทำป้ายกำกับช่องกรอกแบบฟอร์มก็จะทำหน้าที่เขียนป้ายกำกับแกนด้วย เก็บค่าเรขาคณิตของพล็อตไว้ในตัวแปร record และจับคู่ค่าข้อมูลเข้ากับ Y เพียงรอบเดียว จากนั้นแผนภูมิแท่ง แผนภูมิเส้น หรือแผนภูมิวงกลม ก็จะเป็นแค่รูทีนสั้นๆ ที่เรียกใช้ Rectangle, LineTo และ Circle ที่คุณสามารถนำไปใส่ไว้ในรายงานใดๆ ก็ได้ คำสั่ง Rectangle, MoveTo, LineTo, Circle, Fill, Stroke และ TextOut ที่ใช้ในบทความนี้เป็นส่วนหนึ่งของ HotPDF Component สำหรับ Delphi และ C++Builder