บทความเทคนิค

แกนวันที่ของกราฟ HotXLS: tick ที่ตรงปฏิทินใน Delphi

HotXLS render แกนวันที่ของกราฟ Excel ด้วยเลขคณิตแบบปฏิทิน ไม่ใช่การนับจำนวนวันตายตัว class TXLSChartDateAxisTransform ใน unit lxChart เก็บพิกัดทุกตัวเป็น workbook serial คำนวณ tick ของเดือนหรือปีแต่ละตัวตรง ๆ จากค่าต่ำสุดของแกนผ่าน IncMonth กับ IncYear แล้ว clamp ปลายเดือนให้เข้ากับปฏิทินจริง ชุดข้อมูลที่ยึดวันที่ 31 มกราคมเอาไว้จึงได้ tick ที่ 28 กุมภาพันธ์, 31 มีนาคม และ 30 เมษายน แทนที่จะเลื่อนหลุดไปเรื่อย ๆ transform ตัวเดียวกันเลือกหน่วยเป็นวัน เดือน หรือปีให้เองอัตโนมัติ และถูกใช้ร่วมกันทั้ง renderer กราฟฝั่ง HTML, SVG และ PDF แบบแบ่งหน้า

bug ที่เรื่องนี้ป้องกันไว้คุ้นเคยกับคนที่เคยวาดกราฟปิดงบปลายเดือน renderer ที่ถือว่าหนึ่งเดือนคือ 30 วันจะเลื่อนมาก่อนกว่าห้าวันภายในปีแรก renderer ที่ฉลาดกว่าใช้เดือนตามปฏิทิน แต่เดิน tick แต่ละตัวต่อจากตัวก่อนหน้า จะพังแบบเงียบกว่านั้น: 31 มกราคมกลายเป็น 28 กุมภาพันธ์ ก้าวถัดไปตกที่ 28 มีนาคม และ tick หลังจากนั้นทุกตัวก็ติดหนึบอยู่ที่วันที่ 28 กราฟดูน่าเชื่อ label ผิด และไม่มีใครสังเกตจนกว่าหัวหน้าบัญชีจะถามว่าทำไมยอดคงเหลือเดือนมีนาคมถึงถูกพล็อตก่อนปิดไตรมาสถึงสามวัน

ทำไม tick ของเดือนถึงเลื่อนเมื่อมันถูกบวกสะสม

tick ของเดือนเลื่อนเพราะการ clamp ทิ้งข้อมูลไป พอ 31 มกราคมถูก clamp เป็น 28 กุมภาพันธ์ ความจริงที่ว่าชุดข้อมูลอยากได้วันที่ 31 ก็หายไป และก้าวใด ๆ ที่เดินต่อจากค่าที่ถูก clamp แล้วก็สืบทอดความสูญเสียนั้นต่อ TXLSChartDateAxisTransform.BuildTicks ไม่มีวันเดินต่อจาก tick ก่อนหน้า tick ตัวที่ i ถูกคำนวณเสมอเป็น AddUnits(MinValue, UnitKind, Step * i, ...) ซึ่งก็คือ min + unit × index ที่วัดจากค่าต่ำสุดของแกน AddUnits บวกจำนวนลง serial ตรง ๆ สำหรับหน่วยวัน และสำหรับหน่วยเดือนกับปี จะแปลง serial เป็นวันที่จริง เรียก IncMonth หรือ IncYear แล้วแปลงกลับ ลูปนี้ยังถูกกั้นขอบครบทุกด้าน: array ผลลัพธ์ถูกจำกัดที่ 4096 entry ไม่ว่าผู้เรียกจะขอเท่าไร Step * i ถูกเทียบกับ MaxInt ก่อนคูณ และการสร้างหยุดทันทีที่ tick ไม่เพิ่มขึ้นหรือข้ามค่าสูงสุดของแกนไป

สองวิธีที่แกนวันที่ของกราฟ HotXLS วาง tick เดือนหลังจาก anchor 31 มกราคม 2026: การเดิน tick แต่ละตัวต่อจากค่าที่ clamp แล้วทำให้ติดอยู่ที่วันที่ 28 ขณะที่ BuildTicks คำนวณ AddUnits(MinValue, xcduMonths, Step คูณ i) จากค่าต่ำสุดของแกน ทำให้ tick ตกที่ 28 กุมภาพันธ์, 31 มีนาคม และ 30 เมษายน
การ clamp ทิ้งข้อมูล ดังนั้น TXLSChartDateAxisTransform จึงไม่เดินต่อจาก tick ก่อนหน้าและคำนวณทุกตำแหน่งใหม่จากค่าต่ำสุดของแกน โดย clamp ปลายเดือนให้เข้ากับปฏิทินจริง
uses
  SysUtils, lxChart;

var
  Ticks: TXLSChartDateValues;
  MinSerial, MaxSerial: Double;
  I, Count: Integer;
begin
  // date system 1900 (Dates1904 = False), ชุดข้อมูลที่ยึดปลายเดือน
  MinSerial := TXLSChartDateAxisTransform.DateTimeToSerial(
    EncodeDate(2026, 1, 31), False);
  MaxSerial := TXLSChartDateAxisTransform.DateTimeToSerial(
    EncodeDate(2026, 12, 31), False);

  // ก้าวละหนึ่งเดือน ตั้งเป้า 12 tick ไม่เกิน 64 เด็ดขาด
  Count := TXLSChartDateAxisTransform.BuildTicks(MinSerial, MaxSerial,
    1, xcduMonths, False, 12, 64, Ticks);

  for I := 0 to Count - 1 do
    Writeln(TXLSChartDateAxisTransform.FormatValue(Ticks[I], False));
  // 2026-01-31, 2026-02-28, 2026-03-31, 2026-04-30 ... 2026-12-31
end;

ใน BuildTicks มีการประมาณแบบตรงไปตรงมาอยู่หนึ่งจุด เมื่อ step ที่ขอมาเป็นศูนย์ เมธอดจะประมาณขนาด step โดยถือว่าหนึ่งเดือนคือ 30 วัน หนึ่งปีคือ 365 วัน แล้วปัด step ดิบให้เป็นพหุคูณ 1, 2 หรือ 5 ของกำลังสิบ การประมาณนี้ตัดสินแค่ว่าระหว่าง tick มีหน่วยกี่หน่วยเท่านั้น ตำแหน่ง tick เองยังมาจาก IncMonth กับ IncYear เหมือนเดิม มันจึงเปลี่ยนความหนาแน่นของ tick ได้ แต่ไม่มีวันย้าย tick หลุดจากปฏิทิน

ทำไมเก็บ workbook serial ต่อ แทนที่จะแปลงเป็น TDateTime

แกนวันที่ใน HotXLS เก็บ workbook serial ไว้เป็นปริภูมิพิกัด เพราะ TDateTime ของ Delphi ไม่มีที่ว่างสำหรับ serial 60 คือวันที่ 29 กุมภาพันธ์ 1900 ตัวล่องหน ที่ระบบวันที่ 1900 สืบทอดมาเป็นเรื่องความเข้ากันได้เชิงประวัติศาสตร์ TrySerialToDateTime เลื่อน serial ที่ต่ำกว่า 60 ไปหนึ่งวัน แล้วพับ serial 60 ลงบน 28 กุมภาพันธ์ ซึ่งเอาไว้ติดป้ายจุดเดียวก็โอเค แต่เป็นฝันร้ายกับเรขาคณิต: แปลงทุกจุดเป็น TDateTime ก่อน สองวันรอบวันล่องหนจะอยู่ใกล้กันมากขึ้นหนึ่งหน่วยกว่าที่ Excel วาดไว้ ทุกอย่างที่พล็อตก่อนเดือนมีนาคม 1900 จึงขยับเทียบกับทุกอย่างหลังจากนั้น TXLSChartDateAxisTransform.FormatValue จัดการ slot เดิมเป็นกรณีพิเศษและติดป้ายเป็น 1900-02-29 เหมือน Excel เป๊ะ ๆ ในเวิร์กบุ๊กแบบ 1904 serial 0 คือ 1 มกราคม 1904 และ transform ใช้ offset คงที่ 1462 วันไปในสองทิศทาง เรื่องเต็มของสองยุคนี้อยู่ในบทความของเราเรื่อง date serial ของ Excel ระบบ 1904 และ number format

ทำไมแกนวันที่ของ HotXLS เก็บ workbook serial ไว้แทนการแปลงเป็น TDateTime: serial 60 คือ 29 กุมภาพันธ์ 1900 ตัวล่องหน TrySerialToDateTime เลื่อน serial ที่ต่ำกว่า 60 ไปหนึ่งวันแล้วพับ 60 ลงบน 28 กุมภาพันธ์ ทำให้สองวันรอบ ๆ อยู่ใกล้กันมากขึ้นหนึ่งหน่วยกว่าที่ Excel วาดไว้ ขณะที่ FormatValue ติดป้าย serial 60 เป็น 1900-02-29
การพับลงนั้นใช้ติดป้ายจุดเดียวได้ แต่เป็นฝันร้ายกับเรขาคณิต เพราะทุกอย่างที่พล็อตก่อนเดือนมีนาคม 1900 ขยับไปเทียบกับทุกอย่างหลังจากนั้น เวิร์กบุ๊กแบบ 1904 ใช้ offset คงที่ 1462 วันแทน

HotXLS เลือกวัน เดือน หรือปีให้เองอย่างไร

TXLSChartDateAxisTransform.DetectUnit เลือกหน่วยปฏิทินที่ละเอียดที่สุดซึ่งช่องว่างในข้อมูลต้องใช้จริง ๆ เมธอดทิ้งค่า NaN กับค่าอนันต์ออกก่อน เรียงที่เหลือครั้งเดียวใน O(n log n) และข้ามวันที่ซ้ำ ถ้าช่องว่างติดกันสักช่วงหนึ่งสั้นกว่าหนึ่งเดือนตามปฏิทิน หน่วยคือวัน มิฉะนั้นถ้าสักช่วงหนึ่งสั้นกว่าหนึ่งปีตามปฏิทิน หน่วยคือเดือน ไม่งั้นคือปี การทดสอบคือ IncMonth(Previous, 1) > Current ไม่ใช่เกณฑ์ 30 วัน ชุดข้อมูลปลายเดือนที่วิ่ง 31 มกราคม, 28 กุมภาพันธ์, 31 มีนาคม จึงถูกจับได้ถูกต้องว่าเป็นข้อมูลรายเดือน ถ้ามีวันที่ valid ที่ไม่ซ้ำน้อยกว่าสองตัว ก็ไม่มีช่องว่างให้วัด เมธอดจะ fallback ไปที่หน่วยวัน

ต่อมา ResolveUnits รวมหน่วยที่ตรวจพบกับสิ่งที่กราฟประกาศไว้ base unit ที่ระบุชัดเจนไว้จะถูกเคารพ และ major หรือ minor unit ที่ไม่ได้ตั้งไว้จะสืบทอดหน่วยที่ตรวจพบเมื่อหน่วยนั้นหยาบกว่า base ขั้นสุดท้ายเป็นขั้นที่คนพลาด: ถ้า major หรือ minor unit ที่มีผลจริงละเอียดกว่า base unit base จะถูกลดลงให้เท่ากัน เพราะ base unit คือสิ่งที่จุดข้อมูลถูก normalize ไปยึดก่อนที่ tick ใดจะมีตัวตน โดย Normalize ดีดค่าไปกลับที่วันของมัน, วันแรกของเดือน หรือวันที่ 1 มกราคม ถ้า base เป็นรายเดือนแต่ major unit เป็นรายวัน จุดของหนึ่งเดือนจะถูกยุบรวมเป็น slot เดียวก่อนที่โค้ด tick จะทันเห็นพวกมันด้วยซ้ำ ความหนาแน่นของ tick ก็มีเพดานด้วย โดยความยาวพื้นที่พล็อต ความกว้างที่วัดได้ของฟอนต์แกน, number format ของ label และการโปรเจกต์ของ label ที่หมุนเอียง และ backend ทุกตัววาด label, tick mark กับ gridline จาก array tick ตัวเดียวที่ใช้ร่วมกันและมีเพดาน โดย minor tick ที่ทับกับ major tick จะถูกวาดแค่ครั้งเดียว

HotXLS เลือกหน่วยของแกนวันที่ให้เองอย่างไร: DetectUnit ทิ้งค่า NaN กับค่าอนันต์ เรียงครั้งเดียว แล้ววัดช่องว่างตามปฏิทินด้วย IncMonth แทนเกณฑ์ 30 วัน ชุดข้อมูล 31 มกราคม, 28 กุมภาพันธ์, 31 มีนาคม จึงถูกจับได้ว่าเป็นรายเดือน และ ResolveUnits เคารพ base ที่ระบุชัดเจน พร้อมลด base ลงเมื่อ major unit ละเอียดกว่า
Normalize ดีดแต่ละค่าไปกลับที่วันของมัน, วันแรกของเดือน หรือวันที่ 1 มกราคม ก่อนที่ tick ใดจะมีตัวตน เพราะ base รายเดือนใต้ major unit รายวันจะยุบจุดของหนึ่งเดือนให้เหลือ slot เดียว

element dateAx เก็บอะไร และการเว้นไว้หมายความว่าอะไร

ใน ChartML การตั้งค่าปฏิทินบนแกนวันที่ทุกตัวเป็น optional และการเว้นไว้ไม่เท่ากับการเขียนค่า default ลงไป type CT_DateAx ใน ECMA-376 Part 1, §21.2 (DrawingML Charts) ให้ baseTimeUnit, majorTimeUnit, minorTimeUnit กับ auto แต่ละตัวจะโผล่หรือไม่โผล่ก็ได้ และ baseTimeUnit ที่ถูกเว้นไว้บอกให้ Excel ตัดสินใจเอง ซึ่งต่างจาก days ที่ระบุชัด ๆ TXLSXChartAxis จึงเก็บแต่ละค่าไว้คู่กับ presence flag: BaseTimeUnit คู่ BaseTimeUnitSet, MajorTimeUnit คู่ MajorTimeUnitSet, MinorTimeUnit คู่ MinorTimeUnitSet และ AutoDateAxis คู่ AutoDateAxisSet assign ค่าก็ตั้ง flag, ล้าง flag ก็ให้ element กลับไปเป็นการเว้นไว้ และ writer ฝั่ง XLSX emit element ออกไปเฉพาะเมื่อ flag ถูกตั้ง ข้อสังเกต: auto หมายถึงการตรวจจับอัตโนมัติว่าเป็น category หรือ date ไม่ใช่หน่วย tick อัตโนมัติ

var
  Book: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
  Chart: TXLSXChart;
  Axis: TXLSXChartAxis;
  M: Integer;
begin
  Book := TXLSXWorkbook.Create;
  try
    Sheet := Book.Sheets.Add('Close');
    Sheet.Cells[1, 1].Value := 'Month end';
    Sheet.Cells[1, 2].Value := 'Balance';
    for M := 1 to 12 do
    begin
      Sheet.Cells[M + 1, 1].Value :=
        EncodeDate(2026, M, DaysInAMonth(2026, M));   // DateUtils
      Sheet.Cells[M + 1, 2].Value := 1000 + M * 75;
    end;

    Chart := Sheet.AddLineChart('Month-end balance',
      'Close!$A$2:$A$13', 'Close!$B$2:$B$13', 15, 1, 32, 9);
    Axis := Chart.CategoryAxis;
    Axis.Kind := xlsxAxisDate;                  // เขียน c:dateAx
    Axis.BaseTimeUnit := xlsxChartTimeDays;     // ตั้ง BaseTimeUnitSet ให้ด้วย
    Axis.MajorTimeUnit := xlsxChartTimeMonths;
    Axis.MajorUnit := 1;                        // > 0 หมายถึง majorUnit ถูกตั้งแล้ว
    Axis.NumberFormat := 'mmm yyyy';
    Axis.NumberFormatSourceLinked := False;
    Axis.TextStyle.Rotation := -45;             // องศา เก็บเป็นหน่วย 1/60000
    // AutoDateAxis ถูกปล่อยไว้: ไม่มี element c:auto ถูกเขียน

    Book.SaveAs('month-end-balance.xlsx');
  finally
    Book.Free;
  end;
end;

label กับทิศทางวิ่งตามกฎ presence-aware ชุดเดียวกัน numFmt ของแกนวางอยู่หลัง title ก่อน tick mark, attribute sourceLinked ของมัน default เป็น true และ TXLSXChartAxis เก็บ format code, ค่า source-linked กับ NumberFormatSet แยกจากกัน โค้ดที่ว่างเปล่าจึงไม่มีทางถูกเข้าใจผิดเป็น element ที่ไม่มีอยู่ การหมุนข้อความถูกเก็บในหน่วย 1/60000 องศา โดยค่าบวกหมายถึงตามเข็มนาฬิกา TextStyle.Rotation รับเป็นองศาธรรมดา ฝั่ง SVG ใช้มุมนั้นตรง ๆ ส่วน backend แบบแบ่งหน้าซึ่งมุมบวกวิ่งทวนเข็มจะสลับเครื่องหมายที่ขอบเขตใช้ร่วมตัวเดียว แกนที่กลับด้านเป็น maxMin จะพลิกแบบมิเรอร์ major กับ minor tick, gridline, จุดข้อมูล, trendline และ error bar ไปพร้อมกัน ไม่ใช่แค่ label ลอย ๆ เมื่อคุณเปิดเวิร์กบุ๊กที่คนอื่นเขียนไว้ flags จะบอกคุณว่าผู้เขียนระบุอะไรไว้จริง ๆ

var
  Book: TXLSXWorkbook;
  Axis: TXLSXChartAxis;
begin
  Book := TXLSXWorkbook.Create;
  try
    if Book.Open('forecast.xlsx') <> 1 then
      raise Exception.Create('Cannot open workbook');
    Axis := Book.Sheets[0].Charts[0].CategoryAxis;
    if Axis.Kind = xlsxAxisDate then
    begin
      if Axis.BaseTimeUnitSet then
        Writeln('baseTimeUnit = ', Ord(Axis.BaseTimeUnit))
      else
        Writeln('baseTimeUnit omitted: Excel chooses at render time');
      if Axis.AutoDateAxisSet then
        Writeln('auto = ', Axis.AutoDateAxis);
    end;
  finally
    Book.Free;   // save ซ้ำอีกครั้ง และ element ที่ถูกเว้นไว้ก็ยังถูกเว้น
  end;
end;

โมเดลปฏิทินหยุดอยู่ตรงไหน

โมเดลปฏิทินหยุดตรงฟอร์แมตที่พูดถึงหน่วยปฏิทินไม่ได้ และ HotXLS ก็ไม่แกล้งทำเหมือนไม่จริง ODF 1.2 มี chart:interval-major ซึ่งเป็นแค่ตัวเลขธรรมดา ไม่มีความหมายของวัน เดือน หรือปี แกนรายเดือนของ Excel ที่ save เป็น ODS จึงพกหน่วยของมันไปไม่ได้ และ HotXLS ก็ไม่ประดิษฐ์ attribute นอกมาตรฐานขึ้นมาแกล้ง ฝั่ง BIFF8 รุ่นเก่า record AxcExt ถือ word 16-bit คงที่เก้าตัว และ flag อัตโนมัติของมันแค่ mask field ไว้ ไม่ได้เอาออก HotXLS รักษาค่า minimum, maximum, interval, unit และ crossing ที่ถูก mask ไว้ทั้งตอนอ่านและตอนเขียนกลับ เก็บ unit code ที่ไม่รู้จักไว้ครบถ้วนในโมเดล Classic และ map เฉพาะหน่วยวันที่ที่ valid คือ 0, 1 และ 2 (วัน เดือน ปี) ไปยัง XLSX

แกนวันที่เป็นแค่ชั้นหนึ่งของโมเดลกราฟที่ต้องรอดจากไฟล์ที่มันไม่ได้สร้างเองด้วย เมื่อเวิร์กบุ๊ก Excel รวมเส้นแบบ date axis เข้ากับชุดคอลัมน์บนแกนรอง บทความของเราเรื่อง ChartML ที่ถูกเก็บรักษากับกราฟผสม แสดงให้เห็นว่า HotXLS เล่น XML กราฟเดิมซ้ำแบบ byte ต่อ byte เมื่อไม่มีอะไรเปลี่ยน และรวมการแก้แบบ typed เข้าไปเมื่อมีอะไรเปลี่ยน ส่วน บทความภาพรวมเรื่องกราฟ รูปภาพ และ drawings ครอบคลุม API anchoring กับ series ที่ใช้ในบทความนี้ ทั้งหมดนี้มาใน HotXLS Delphi Component สำหรับ Delphi และ C++Builder ซึ่งอ่าน เขียน และ render กราฟ XLS กับ XLSX โดยไม่ใช้ Excel automation