مقاله فنی

رسم نمودارها در یک PDF با عناصر پایه HotPDF

کامپوننت HotPDF هیچ شیء نموداری ندارد. در آن TPDFChart، AddBarSeries، یا هیچ چیزی که یک آرایه از اعداد بگیرد و یک گراف رندر شده را تحویل دهد، وجود ندارد. در عوض، آنچه به شما می‌دهد یک بوم صفحه (page canvas) با همان واژگان سطح پایینی است که هر مدل رسم PDF از آن استفاده می‌کند: مستطیل‌ها، خطوط، دایره‌ها، پر کردن‌ها (fills)، خط‌کشی‌ها (strokes)، و متنی که در مختصات دقیق قرار می‌گیرد. بنابراین یک نمودار در سند HotPDF چیزی است که شما آن را می‌سازید، نه چیزی که آن را درخواست کنید. این ممکن است سخت‌تر از آنچه که هست به نظر برسد. زمانی که یک بار محاسبات ریاضی مختصات را نوشتید، یک نمودار میله‌ای (bar chart) حلقه‌ای از مستطیل‌هاست، یک نمودار خطی (line chart) یک چندخطی (polyline) است، و یک نمودار دایره‌ای (pie chart) مجموعه‌ای از کمان‌هاست، و شما هر پیکسل از نتیجه را کنترل می‌کنید

این موضوع اهمیت دارد زیرا جایگزینی که معمولاً افراد در ابتدا به سراغ آن می‌روند، یعنی تبدیل یک کنترل نمودار صفحه‌نمایش به یک تصویر پیکسلی (bitmap) و چسباندن تصویر در صفحه، به شما نموداری می‌دهد که به وضوح صفحه‌نمایش محدود شده است که به صورت تار چاپ می‌شود و حجم فایل را به شدت افزایش می‌دهد. رسم نمودار با عناصر پایه برداری (vector primitives) HotPDF، خروجی را در هر سطح از بزرگنمایی و هر DPI چاپ، واضح و شفاف نگه می‌دارد، زیرا میله‌ها و محورها عملگرهای واقعی مسیر PDF هستند، نه پیکسل. هزینه‌اش این است که مالکیت چیدمان به عهده شماست. مکانیزم کار به چند حرکت خلاصه می‌شود: تنها یک چرخش مختصات (coordinate flip) که همه را به اشتباه می‌اندازد، یک نمودار میله‌ای کار شده، ترفند چندخطی برای نمودارهای خطی، و ریاضیات کمان برای برش‌های نمودار دایره‌ای

تنها بخش سخت، سیستم مختصات است

گرافیک صفحه‌نمایش مبدأ را در گوشه بالا-چپ قرار می‌دهد که Y در آن به سمت پایین افزایش می‌یابد. PDF برعکس این عمل می‌کند. مبدأ در گوشه پایین-چپ صفحه قرار دارد و Y به سمت بالا افزایش می‌یابد، که بر حسب نقطه (1/72 اینچ) اندازه‌گیری می‌شود. هر فراخوانی رسم در HotPDF، TextOut، Rectangle، MoveTo، LineTo، Circle، از این قرارداد پایین-چپ، Y به سمت بالا استفاده می‌کند. اگر غریزه گرافیک صفحه‌نمایش را با خود بیاورید، اولین نمودار شما به صورت وارونه رسم می‌شود و از پایین صفحه خارج می‌گردد

بنابراین کار اصلی در هر نموداری یک نگاشت (mapping) است: تبدیل یک مقدار داده به یک مختصات Y که با سیستم Y-رو-به-بالا سازگار باشد. در مورد یک مستطیل پلات تصمیم بگیرید، چهار عدد برای ناحیه‌ای که نمودار در آن قرار می‌گیرد، سپس کوچک‌ترین مقدار در داده‌های خود را به لبه پایین و بزرگ‌ترین را به لبه بالا نگاشت کنید. برای یک میله با مقدار 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 پنهان است. اگر آن را دقیقاً روی بزرگ‌ترین نقطه داده تنظیم کنید، بلندترین میله به لبه بالای پلات می‌رسد و به نظر می‌رسد که بریده (clipped) شده است. آن را به یک عدد رُند بالای حداکثر گرد کنید، مثلاً ضریب بعدی از 10 یا 100، تا نمودار فضای خالی بالای سر (headroom) داشته باشد و برچسب‌های خطوط شبکه (gridlines) به جای اینکه هرچه داده‌ها تصادفاً در آن به اوج رسیده‌اند باشند، به عنوان ارقام رُند خوانده شوند

یک نمودار میله‌ای حلقه‌ای از مستطیل‌هاست

با قطعی شدن نگاشت، یک نمودار میله‌ای خودش را می‌نویسد. عرض پلات را به یک اسلات (slot) برای هر دسته‌بندی تقسیم کنید، فاصله‌ای بین میله‌ها بگذارید تا با هم تماس پیدا نکنند، و هر میله را به عنوان یک مستطیل پر شده رسم کنید که ارتفاع آن از 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;

دو جزئیات در اینجا ارزش خود را ثابت می‌کنند. فاصله (Gap) کسری از اسلات است نه یک تعداد نقطه ثابت، بنابراین میله‌ها چه چهار دسته‌بندی و چه چهل دسته‌بندی را رسم کنید، با تناسب از هم فاصله می‌گیرند. و برچسب مقدار دقیقاً با همان ارتفاع مشتق‌شده از ValueToY که میله استفاده می‌کند، موقعیت‌دهی شده است، بنابراین همیشه درست بالای میله خود قرار می‌گیرد، به جای اینکه در یک افست حدس‌زده‌شده شناور باشد. اگر خطوط شبکه افقی (horizontal gridlines) را در پشت میله‌ها می‌خواهید، آنها را قبل از حلقه رسم کنید: سه یا چهار مقدار رُند انتخاب کنید، ValueToY را روی هر کدام اجرا کنید، و یک خط کمرنگ در سراسر پلات در آن Y رسم (stroke) کنید. رسم آنها در ابتدا، آنها را در مدل چیدمان لایه‌ای نقاش (painter's-model stacking) که PDF از آن استفاده می‌کند، پشت میله‌ها قرار می‌دهد

محورها، علامت‌های تیک (Ticks) و برچسب‌ها فقط خطوط و متون بیشتری هستند

نمودار تا زمانی که خواننده نتواند بفهمد میله‌ها به چه معنا هستند، تمام نشده است، و این کاملاً کار محور (axis work) است. محور عمودی یک خط رسم شده در لبه چپ پلات با تعدادی علامت تیک و مقادیر آنهاست. مجدداً از 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;

برچسب‌ها جایی هستند که نمودارها اغلب در محیط تولید (production) خراب می‌شوند، و دلیل خرابی همیشه یکسان است: متنی که روی صفحه جا می‌گرفت، در PDF از فضای خود خارج می‌شود. نام‌های دسته‌بندی طولانی با همسایگان خود برخورد می‌کنند، و نام ماه‌های بومی‌شده مانند "septembre" یا "Dezember" عریض‌تر از انگلیسی "Sep" هستند که با آن تست کرده‌اید. در اینجا هیچ تغییر اندازه خودکاری (autosize) برای نجات شما وجود ندارد، بنابراین حاشیه واقعی در زیر خط مبنا قرار دهید، فونت را یک یا دو نقطه برای مجموعه‌های دسته‌بندی متراکم کوچک کنید، و اگر نام‌ها واقعاً طولانی هستند، آنها را بچرخانید. TextOut یک زاویه به عنوان آرگومان سوم خود می‌گیرد، بنابراین عبور دادن 90 برچسب را روی یک انتها قرار می‌دهد و فضایی را بدون هم‌پوشانی برای شما می‌خرد. چیدمان را با عریض‌ترین برچسب مورد انتظار خود تست کنید، نه با کوتاه‌ترین برچسب، قبل از اینکه قابلیت صادرات (export) را به مشتری تحویل دهید

نمودارهای خطی: یک چندخطی (polyline) از میان نقاط نگاشت‌شده

یک نمودار خطی (line chart) تمام نگاشت ارزش را مجدداً استفاده می‌کند و فقط نحوه اتصال نقاط را تغییر می‌دهد. به جای یک مستطیل در هر دسته‌بندی، شما یک بار داده‌ها را پیمایش می‌کنید، هر مقدار را با ValueToY به مختصات (X, Y) آن تبدیل می‌کنید، و نقاط را با یک 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;

به تفاوت فاصله‌گذاری توجه کنید. نمودار میله‌ای عرض را بر تعداد میله‌ها تقسیم می‌کند، زیرا هر میله دارای یک اسلات است. نمودار خطی بر تعداد فواصل، یعنی Count - 1 تقسیم می‌کند، زیرا نقاط اول و آخر روی لبه‌های پلات قرار می‌گیرند و خط شکاف‌های بین آنها را در بر می‌گیرد. اشتباه گرفتن این دو دلیل معمولی است که باعث می‌شود یک نمودار خطی نیم اسلات از نمودار میله‌ای که قرار است روی آن قرار گیرد منحرف شود. اگر برای هر نقطه داده یک نشانگر (marker) می‌خواهید، پس از رسم شدن چندخطی، یک Circle کوچک را در هر (X, Y) قرار داده و با Fill رنگ‌آمیزی کنید

نمودارهای دایره‌ای: کمان‌ها، یا قاچ‌ها (wedges) اگر می‌خواهید ساده نگه دارید

برش‌های کیک (نمودار دایره‌ای) تنها شکلی هستند که به مثلثات نیاز دارند، زیرا یک قاچ با دو شعاع و یک کمان محدود شده است. نسخه صادقانه آن است که با برداشتن گام‌های خطی کوچک در امتداد محیط دایره، کمان را رسم کنید، که منحنی را به اندازه‌ای دقیق تقریب می‌زند که هیچ خواننده‌ای نمی‌تواند تفاوت آن را تشخیص دهد. زاویه رسم هر برش سهم آن از کل است، (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 آن را کاملاً رنگ‌آمیزی می‌کند. تعداد قطعات، نرم بودن (smoothness) را با اندازه مسیر مبادله می‌کند: تقریباً دو درجه در هر گام در هر شعاع معقولی گرد به نظر می‌رسد بدون اینکه یک جریان محتوای عظیم تولید کند. اگر به یک دایره واقعی نیاز ندارید، می‌توانید به طور کلی از مثلثات صرف‌نظر کنید و همان داده‌ها را به عنوان یک نمودار میله‌ای انباشته افقی (horizontal stacked bar) واحد رندر کنید، که عرض هر قطعه متناسب با سهم آن است. به هر حال آن نمودار اغلب خواناتر از یک نمودار دایره‌ای است، و در واقع همان نمودار میله‌ای است که مستطیل‌های آن پشت سر هم قرار گرفته‌اند. فقط زمانی به سراغ نسخه کمان بروید که طراحی، یک دایره واقعی را ایجاب می‌کند

هیچ‌کدام از این موارد به نصب بودن هیچ کتابخانه نموداری وابسته نیست، که این مزیت بی‌صدای رسم مستقیم عناصر پایه است. همان فراخوانی‌های رسم بوم که یک لوگو یا یک کادر امضا را قرار می‌دهند، این نمودارها را نیز می‌سازند، و همان TextOut که روی فیلد یک فرم برچسب می‌زند، روی یک محور هم برچسب می‌زند. هندسه پلات را در یک رکورد قرار دهید، مقادیر را یک بار به Y نگاشت کنید، و یک نمودار میله‌ای، خطی، یا دایره‌ای تنها یک روال کوتاه روی Rectangle، LineTo، و Circle است که می‌توانید آن را در هر گزارشی وارد کنید. فراخوانی‌های Rectangle، MoveTo، LineTo، Circle، Fill، Stroke، و TextOut که در اینجا استفاده شده‌اند بخشی از کامپوننت HotPDF برای دلفی و C++Builder هستند