Bài viết kỹ thuật

Vẽ Biểu đồ trong PDF bằng HotPDF Primitives

HotPDF không có đối tượng biểu đồ (chart object). Không có TPDFChart, không có AddBarSeries, không có thứ gì nhận vào một mảng số và trả lại một biểu đồ được kết xuất. Thay vào đó, những gì nó cung cấp cho bạn là một khung vẽ trang (page canvas) với cùng một từ vựng cấp thấp (low-level vocabulary) mà mọi mô hình vẽ PDF đều sử dụng: hình chữ nhật, đường thẳng, hình tròn, màu tô, nét vẽ và văn bản được đặt tại tọa độ chính xác. Do đó, một biểu đồ trong tài liệu HotPDF là thứ bạn xây dựng, không phải thứ bạn yêu cầu. Nghe có vẻ nhiều công việc hơn thực tế. Khi bạn đã viết xong phần tính toán tọa độ một lần, biểu đồ cột là một vòng lặp qua các hình chữ nhật, biểu đồ đường là một đường gấp khúc (polyline), và biểu đồ tròn là một dải các cung tròn, và bạn kiểm soát từng pixel của kết quả

Điều này quan trọng bởi vì phương pháp thay thế mà mọi người thường dùng đầu tiên, đó là raster hóa (rasterizing) một điều khiển biểu đồ trên màn hình thành một ảnh bitmap và dán hình ảnh đó vào trang, sẽ mang đến cho bạn một biểu đồ bị khóa ở độ phân giải màn hình, in mờ và làm phồng kích thước tệp. Việc vẽ biểu đồ bằng các lệnh vẽ vector (vector primitives) của HotPDF giữ cho đầu ra sắc nét ở mọi mức thu phóng và mọi DPI in ấn, bởi vì các cột và các trục là các toán tử đường dẫn (path operators) PDF thực sự, không phải là pixel. Cái giá phải trả là bạn sở hữu phần bố cục (layout). Các cơ chế xoay quanh một vài thao tác: sự đảo ngược tọa độ làm khó mọi người, một biểu đồ cột đã hoàn thiện, mẹo với đường gấp khúc cho biểu đồ đường, và thuật toán cung tròn cho các lát cắt biểu đồ tròn

Phần khó duy nhất là hệ tọa độ

Đồ họa màn hình đặt gốc tọa độ ở trên cùng bên trái với Y tăng dần xuống dưới. PDF làm ngược lại. Gốc tọa độ nằm ở góc dưới cùng bên trái của trang và Y tăng lên trên, đo bằng điểm (point) (1/72 inch). Mỗi lời gọi vẽ trong HotPDF, TextOut, Rectangle, MoveTo, LineTo, Circle, đều sử dụng quy ước dưới cùng bên trái, Y hướng lên này. Nếu bạn mang theo bản năng đồ họa màn hình, biểu đồ đầu tiên của bạn sẽ bị vẽ ngược và chạy ra khỏi phần dưới của trang

Vì vậy, công việc thực sự trong bất kỳ biểu đồ nào là một việc ánh xạ (mapping): chuyển đổi một giá trị dữ liệu thành một tọa độ Y tuân theo Y-hướng-lên. Quyết định một hình chữ nhật vẽ biểu đồ (plot rectangle), tức là bốn con số biểu thị khu vực mà biểu đồ chiếm chỗ, sau đó ánh xạ giá trị nhỏ nhất trong dữ liệu của bạn tới cạnh dưới và giá trị lớn nhất tới cạnh trên. Đối với một cột có giá trị V trên thang đo chạy từ 0 đến MaxValue, cạnh trên của cột là PlotBottom + (V / MaxValue) * PlotHeight, và cột phát triển lên trên từ PlotBottom. Nắm vững biểu thức đó và mọi thứ còn lại chỉ là việc quản lý (bookkeeping). Hàm hỗ trợ bên dưới lưu giữ hình học của đồ thị và thực hiện chuyển đổi, vì vậy mã vẽ đồ họa sẽ không bao giờ chạm vào số học thô hai lần:

type
  TPlotArea = record
    Left, Bottom, Width, Height: Single;  // Điểm PDF, gốc tọa độ góc dưới trái
    MaxValue: Single;                     // Đỉnh của thang giá trị
  end;

// Ánh xạ một giá trị dữ liệu thành tọa độ Y của nó bên trong biểu đồ, Y tăng lên trên.
function ValueToY(const Plot: TPlotArea; V: Single): Single;
begin
  Result := Plot.Bottom + (V / Plot.MaxValue) * Plot.Height;
end;

Một quyết định phán đoán nằm ở MaxValue. Nếu bạn thiết lập nó đúng bằng điểm dữ liệu lớn nhất, cột cao nhất sẽ chạm vào cạnh trên của biểu đồ và trông như bị cắt bớt. Làm tròn nó lên một con số gọn gàng phía trên mức tối đa, ví dụ bội số tiếp theo của 10 hoặc 100, để biểu đồ có không gian trên đầu và các nhãn đường lưới đọc như những con số tròn chẵn thay vì bất cứ con số nào dữ liệu tình cờ đạt đỉnh

Biểu đồ cột là một vòng lặp qua các hình chữ nhật

Khi phần ánh xạ đã được giải quyết, một biểu đồ cột tự viết nên chính nó. Chia chiều rộng đồ thị thành một ô (slot) cho mỗi danh mục, để lại khoảng trống giữa các cột để chúng không chạm nhau, và vẽ mỗi cột thành một hình chữ nhật được tô màu mà chiều cao của nó đến từ ValueToY. Rectangle của HotPDF nhận góc dưới trái cộng với chiều rộng và chiều cao, khớp chính xác với một cột mọc lên từ đường cơ sở. Thiết lập màu tô trước, đặt đường dẫn (path), sau đó gọi Fill để sơn nó. Nhãn danh mục nằm dưới đường cơ sở, giá trị nằm phía trên cột:

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;          // khoảng trống phần tư ô (slot) ở mỗi bên
  BarW := SlotW - Gap;

  // Đường cơ sở (trục X) dọc theo cạnh dưới của biểu đồ.
  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, Chiều rộng, Chiều cao
    Page.Fill;

    // Nhãn danh mục dưới đường cơ sở, giá trị phía trên cột.
    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;

Hai chi tiết cho thấy được giá trị của chúng. Gap là một phân số của ô thay vì một số điểm cố định, vì vậy các cột luôn giữ được khoảng cách tỷ lệ bất kể bạn vẽ bốn danh mục hay bốn mươi danh mục. Và nhãn giá trị được đặt vị trí bằng cùng một chiều cao bắt nguồn từ ValueToY mà cột sử dụng, do đó nó luôn nằm ngay trên đầu cột của chính nó thay vì trôi nổi ở một độ lệch ước chừng. Nếu bạn muốn các đường lưới ngang đằng sau các cột, hãy vẽ chúng trước vòng lặp: chọn ba hoặc bốn giá trị chẵn, chạy ValueToY trên từng giá trị, và vẽ một nét mờ (stroke) ngang qua biểu đồ tại điểm Y đó. Việc vẽ chúng trước sẽ đặt chúng phía sau các cột trong mô hình vẽ (painter's-model stacking) mà PDF sử dụng

Trục, vạch chia, và nhãn chỉ là các đường và chữ văn bản bổ sung

Biểu đồ chưa được hoàn thiện cho đến khi người đọc có thể biết các cột có nghĩa là gì, và đó hoàn toàn là công việc về trục. Trục tung (vertical axis) là một đường được vẽ một nét hướng lên cạnh trái của biểu đồ cùng với một vài vạch đánh dấu (tick marks) và giá trị của chúng. Hãy tái sử dụng ValueToY để các vạch chia đáp xuống cùng một tỷ lệ như các thanh biểu đồ (bars), nếu không cột biểu đồ và đường lưới của nó sẽ không ăn khớp và biểu đồ sẽ nói dối một cách thầm lặng:

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);   // vạch chia ngắn nằm ngoài trục
    Page.LineTo(Plot.Left, TickY);
    Page.Stroke;
    Page.TextOut(Plot.Left - 30, TickY - 3, 0, FormatFloat('0', TickV));
  end;
end;

Nhãn (Labels) là nơi các biểu đồ thường bị vỡ nhất trong quá trình phát hành (production), và lỗi luôn luôn giống nhau: văn bản vừa khít trên màn hình tràn ra khỏi không gian của nó trong tệp PDF. Tên danh mục dài va chạm với hàng xóm của chúng, và tên tháng được bản địa hóa như "septembre" hay "Dezember" thì dài hơn là "Sep" trong tiếng Anh mà bạn đã dùng để kiểm thử. Không có tính năng tự động điều chỉnh kích thước (autosize) nào để giải cứu bạn ở đây, do đó hãy chừa lề (margin) đủ thực tế bên dưới đường cơ sở, thu nhỏ kích thước phông chữ (font) xuống một hoặc hai điểm cho các tập hợp danh mục dày đặc, và nếu tên thực sự dài, hãy xoay chúng. TextOut lấy góc (angle) làm đối số thứ ba, do đó việc truyền giá trị 90 sẽ khiến nhãn đứng thẳng ở phần cuối và mang lại không gian cho bạn mà không bị chồng chéo (overlap). Hãy kiểm thử phần bố cục (layout) với nhãn dự kiến có độ rộng lớn nhất của bạn, chứ không phải nhãn ngắn nhất, trước khi ra mắt tính năng kết xuất (export)

Biểu đồ đường: một đường gấp khúc qua các điểm đã ánh xạ

Biểu đồ đường tái sử dụng toàn bộ phép ánh xạ giá trị và chỉ thay đổi cách các điểm được kết nối với nhau. Thay vì sử dụng hình chữ nhật (rectangle) cho từng danh mục, bạn duyệt qua các điểm dữ liệu một lần, chuyển đổi từng giá trị thành (X, Y) bằng hàm ValueToY, và nối các điểm lại với nhau bằng một hàm MoveTo đơn, tiếp theo là các hàm LineTo, và được vẽ ra bằng lệnh Stroke cuối cùng. Điểm đầu tiên sẽ mở ra đường dẫn (path); tất cả các điểm sau đó sẽ kéo dài nó:

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)        // mở đường dẫn ở điểm đầu tiên
    else
      Page.LineTo(X, Y);       // kéo dài nó qua tất cả các điểm sau đó
  end;
  Page.Stroke;                 // một nét Stroke dùng để vẽ toàn bộ đường gấp khúc
end;

Chú ý sự khác biệt về khoảng cách. Một biểu đồ cột chia độ rộng cho số cột, bởi vì mỗi cột sở hữu một chỗ trống riêng. Một biểu đồ đường chia độ rộng cho số khoảng cách (intervals), Count - 1, bởi vì các điểm ở đầu và cuối nằm ở trên mép biểu đồ và đường thẳng nối giữa các khe trống ở giữa. Việc trộn lẫn hai loại này chính là lý do khiến một biểu đồ đường thường bị trôi (drift) lệch đi một nửa ô (half-slot) so với biểu đồ cột mà nó đang đè lên (overlay) trên đó. Nếu bạn muốn đánh dấu ở mỗi điểm dữ liệu, hãy thả thêm một Circle nhỏ và hàm Fill ở mọi tọa độ (X, Y) sau khi vẽ xong đường gấp khúc

Biểu đồ tròn: Các đường vòng cung, hoặc các nêm (wedges) nếu bạn giữ nó thật đơn giản

Các miếng cắt (slices) của biểu đồ tròn là hình duy nhất đòi hỏi một chút lượng giác (trigonometry), bởi vì một hình nêm (wedge) được đóng khung bởi hai bán kính và một vòng cung (arc). Phương pháp tiếp cận tốt nhất là lướt vòng cung theo cách chạy các đoạn thẳng nhỏ dọc theo đường tròn chu vi, điều này ước lượng đường cong gần đến mức người đọc không thể phân biệt được. Góc phần cung (sweep angle) của từng phần được chia là tỷ lệ của nó trên tổng số, (Value / Total) * 2π, và bạn có thể tích lũy các góc quay (running angle) khi bạn di chuyển vòng quanh:

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 độ mỗi đoạn cắt

    Page.SetRGBFillColor(Colors[I]);
    Page.MoveTo(CX, CY);                     // Đỉnh của nêm nằm ở tâm
    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);                      // đóng khung bằng cách quay lại tâm
    Page.Fill;

    Start := Start + Sweep;                   // đi tiếp đến lát cắt tiếp theo
  end;
end;

Đường dẫn từ tâm, đỉnh tiếp đến là vòng cung, rồi lại về đỉnh, cung cấp một nêm kín mà hàm Fill tô đậm đặc lại. Số điểm đoạn thẳng (segment count) đánh đổi sự mượt mà với kích thước bản vẽ (path size): mỗi bước tịnh tiến khoảng 2 độ sẽ trông đủ tròn đối với mọi bán kính hợp lý mà không tạo nên dòng dữ liệu nội dung siêu lớn. Nếu bạn không cần một hình tròn thật (true circle), bạn có thể bỏ qua bước tính lượng giác này (trig) hoàn toàn và hiển thị những dữ liệu tương tự thành dạng biểu đồ cột đơn lẻ trải dài ngang xếp lớp (horizontal stacked bar), mỗi độ rộng tương ứng của một đoạn nằm rải tỉ lệ bằng kích cỡ phần (share) chia sẻ của nó. Lựa chọn này hầu như luôn dễ nhìn hơn dạng biểu đồ tròn (pie) đó, hơn nữa nó chỉ dùng cấu trúc code thanh chữ nhật nối theo sau (end to end) của đoạn khác. Hãy chỉ chuyển sang định dạng đường bao vòng (arc) nếu bố cục thiết kế thực sự yêu cầu nó là một hình tròn thực thụ (actual circle)

Chẳng có bước nào phụ thuộc cài đặt vào thư viện chart (chart library) cả, đấy chính là ưu thế im lặng trong việc dùng lệnh đồ họa (primitives) trực tiếp vẽ nên chúng. Những tác vụ vẽ màn nền (canvas) này thường áp định logo hoặc hộp lưu chữ ký để gây dựng các biểu đồ, trong khi chính hàm TextOut thực hiện đặt nhãn các vùng tài liệu cũng sẽ đảm nhiệm đặt nhãn cho đường trục toạ độ. Hãy lưu dữ kiện bố cục thiết kế bằng bộ dữ liệu cấu trúc (record), xác lập những mức điểm ứng hướng toạ độ Y, rồi dùng thủ tục Rectangle, LineToCircle thật ngắn gọn cấu thành thanh chữ nhật (bar), tia thẳng biểu đồ và mảng hình tròn tùy thích ghép nối cho báo cáo tài liệu. Những ứng đoạn tác vụ Rectangle, MoveTo, LineTo, Circle, Fill, Stroke, và TextOut vừa liệt kê luôn tương đương ứng chức năng trực thuộc của Thành phần HotPDF hỗ trợ Delphi và C++Builder