Technical Article

رسم المخططات في PDF باستخدام بدائيات HotPDF

لا يوفّر HotPDF كائنًا خاصًا بالمخططات. لا يوجد TPDFChart، ولا AddBarSeries، ولا أي شيء يأخذ مصفوفة أرقام ويعيد رسمًا جاهزًا. ما يقدمه بدلًا من ذلك هو لوحة صفحة تتكلم القاموس نفسه الذي تستخدمه كل نماذج الرسم في PDF: مستطيلات، وخطوط، ودوائر، وتعبئة، وحدود، ونص يوضع عند إحداثيات دقيقة. لذلك فالمخطط في مستند HotPDF شيء تبنيه بنفسك، لا شيء تطلبه من المكتبة. ويبدو ذلك أعقد مما هو عليه. فبمجرد أن تكتب حساب الإحداثيات مرة واحدة، يصبح مخطط الأعمدة حلقة على مستطيلات، ومخطط الخط سلسلة مقاطع متصلة، والمخطط الدائري مروحة من الأقواس، وتتحكم بكل بكسل في النتيجة.

وهذا مهم لأن البديل الذي يتجه إليه الناس أولًا، أي تحويل عنصر مخطط من الشاشة إلى bitmap ثم لصقه داخل الصفحة، يعطيك مخططًا مقيدًا بدقة الشاشة، يطبع ضبابيًا ويضخم حجم الملف. أما رسم المخطط بدائيات HotPDF المتجهة فيحافظ على حدة المخرجات عند أي تكبير وأي DPI للطباعة، لأن الأعمدة والمحاور معاملات PDF حقيقية لا بكسلات. الثمن هو أنك تتولى التخطيط بنفسك. وتتلخص الآلية في بضع خطوات: قلب الإحداثيات الذي يربك الجميع، ومثال عملي لمخطط أعمدة، وخدعة polyline لمخططات الخط، وحساب الأقواس للمخططات الدائرية.

الجزء الأصعب الوحيد هو نظام الإحداثيات

رسومات الشاشة تضع نقطة الأصل في أعلى اليسار مع ازدياد Y إلى الأسفل. أما PDF فيفعل العكس. تقع نقطة الأصل في الزاوية السفلية اليسرى من الصفحة، ويزداد Y إلى أعلى، وتقاس القيم بالنقاط، أي 1/72 بوصة. كل استدعاء رسم في HotPDF، مثل TextOut وRectangle وMoveTo وLineTo وCircle، يستخدم هذا الاصطلاح الذي يبدأ من أسفل اليسار ويرفع 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 ملاحظة مهمة. إذا ضبطته على أكبر نقطة بيانات بالضبط، فسيلامس أعلى عمود الحافة العليا لمنطقة الرسم ويبدو مقصوصًا. ارفعه إلى رقم نظيف أعلى من الحد الأقصى، كأقرب مضاعف لـ 10 أو 100، حتى يحصل المخطط على مساحة تنفّس وتبدو تسميات الشبكة أرقامًا مستديرة بدلًا من ذروة البيانات كما صادفتها.

مخطط الأعمدة مجرد حلقة على مستطيلات

بعد تثبيت التحويل، يكتب مخطط الأعمدة نفسه تقريبًا. اقسم عرض منطقة الرسم إلى خانة لكل فئة، واترك فراغًا بين الأعمدة حتى لا تتلامس، ثم ارسم كل عمود كمستطيل مملوء يأتي ارتفاعه من ValueToY. يأخذ Rectangle في HotPDF الزاوية السفلية اليسرى مع العرض والارتفاع، وهذا يطابق تمامًا عمودًا ينمو إلى أعلى من خط الأساس. اضبط لون التعبئة أولًا، وضع المسار، ثم استدعِ 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 الذي يستخدمه العمود، لذلك تبقى دائمًا فوق عمودها لا طافية عند إزاحة تقديرية. وإذا أردت خطوط شبكة أفقية خلف الأعمدة، فارسمها قبل الحلقة: اختر ثلاث قيم أو أربعًا مستديرة، ومرّر كل منها عبر ValueToY، ثم ارسم خطًا خفيفًا عبر منطقة الرسم عند ذلك الـ Y. رسمها أولًا يضعها خلف الأعمدة في ترتيب الطبقات الذي يستخدمه PDF.

المحاور والشرطات والعناوين ليست سوى مزيد من الخطوط والنص

لا يكتمل المخطط حتى يفهم القارئ معنى الأعمدة، وهذا كله عمل المحاور. المحور الرأسي هو خط واحد مرسوم إلى أعلى على الحافة اليسرى لمنطقة الرسم مع بضع علامات تدريج وقيمها. أعد استخدام 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 لإنقاذك هنا، لذلك اترك هامشًا حقيقيًا تحت خط الأساس، وصغّر الخط نقطة أو نقطتين عند الفئات الكثيفة، وإذا كانت الأسماء طويلة حقًا فقم بتدويرها. يقبل TextOut زاوية في الوسيط الثالث، لذا فإن تمرير 90 يضع العنوان عموديًا ويمنحك مساحة من دون تداخل. اختبر التخطيط بأعرض تسمية تتوقعها، لا بأقصرها، قبل أن يغادر التصدير.

مخططات الخط: polyline واحدة عبر نقاط محوّلة

يعيد مخطط الخط استخدام تحويل القيم نفسه ويغيّر فقط طريقة وصل النقاط. بدلًا من مستطيل لكل فئة، تمر على البيانات مرة واحدة، وتحول كل قيمة إلى (X, Y) باستخدام ValueToY، ثم تخيط النقاط معًا بـ MoveTo واحد يتبعه استدعاءات LineTo، ثم ترسمه في النهاية. النقطة الأولى تفتح المسار، وكل نقطة لاحقة تمده:

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، لأن أول نقطة وآخر نقطة تقعان على حافتي منطقة الرسم ويمتد الخط عبر الفراغات بينهما. الخلط بين الأمرين هو السبب المعتاد لانزياح مخطط الخط نصف خانة عن مخطط الأعمدة الذي يفترض أن يعلوه. وإذا أردت علامة عند كل نقطة بيانات، فضع Circle صغيرًا وFill عند كل (X, Y) بعد رسم polyline.

المخططات الدائرية: أقواس، أو قطاعات إذا أردتها أبسط

شرائح المخطط الدائري هي الشكل الوحيد الذي يحتاج مثلثات، لأن القطاع تحده نصفا قطر وقوس. الصيغة الصادقة تمسح القوس عبر خطوات صغيرة من المقاطع المستقيمة على المحيط، وهو تقريب يكفي لأن لا يلحظه أي قارئ. زاوية المسح لكل شريحة هي حصتها من الإجمالي، أي (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 بشكل صلب. عدد المقاطع يقايض السلاسة بحجم المسار: فحوالي درجتين لكل خطوة تبدو مستديرة عند أي نصف قطر معقول من دون إنتاج مجرى محتوى ضخم. وإذا لم تكن بحاجة إلى دائرة حقيقية، فيمكنك تجاوز المثلثات تمامًا وعرض البيانات نفسها كشرائط أفقية مكدسة، يكون عرض كل مقطع فيها متناسبًا مع حصته. وغالبًا ما يكون ذلك أوضح من المخطط الدائري أصلًا، وهو في النهاية مجرد كود الأعمدة مع وضع المستطيلات طرفًا إلى طرف. استخدم النسخة المقوسة فقط عندما يتطلب التصميم دائرة فعلية.

لا يعتمد أي شيء من هذا على تثبيت مكتبة مخططات، وهذه هي الميزة الهادئة لرسم البدائيات مباشرة. فـ استدعاءات الرسم على Canvas نفسها التي تضع شعارًا أو صندوق توقيع هي التي تبني هذه المخططات، وTextOut نفسه الذي يضع تسمية لحقل نموذج يضع تسمية للمحور. ضع هندسة الرسم في record، واربط القيم إلى Y مرة واحدة، وسيصبح مخطط الأعمدة أو الخط أو الدائرة دالة قصيرة على Rectangle وLineTo وCircle يمكنك إسقاطها في أي تقرير. واستدعاءات Rectangle وMoveTo وLineTo وCircle وFill وStroke وTextOut المستخدمة هنا جزء من مكوّن HotPDF الخاص بـ Delphi وC++Builder.