Bài viết kỹ thuật

Trục ngày của chart HotXLS: tick chuẩn lịch trong Delphi

HotXLS render trục ngày của chart Excel bằng số học lịch, chứ không bằng số ngày cố định. Lớp TXLSChartDateAxisTransform trong unit lxChart giữ mọi tọa độ dưới dạng serial workbook, tính từng tick tháng hay năm trực tiếp từ cực tiểu của trục qua IncMonth và IncYear, và kẹp ngày cuối tháng về lịch thật, nên một series neo vào ngày 31 tháng 1 nhận các tick vào 28 tháng 2, 31 tháng 3 và 30 tháng 4 thay vì trôi dần. Cùng transform đó tự chọn đơn vị ngày, tháng hay năm và được dùng chung bởi các chart renderer HTML, SVG và PDF phân trang

Cái bug tính năng này chặn thì ai từng vẽ chart số dư cuối tháng đều quen thuộc. Một renderer coi tháng là 30 ngày sẽ trôi sớm năm ngày vào cuối năm đầu tiên. Một renderer thông minh hơn và dùng tháng lịch, nhưng lại bước từng tick từ tick trước, thì hỏng lặng lẽ hơn: 31 tháng 1 thành 28 tháng 2, bước kế tiếp đáp vào 28 tháng 3, và mọi tick sau đó cứ dính chặt vào ngày 28. Chart nhìn có vẻ hợp lý, nhãn thì sai, và chẳng ai nhận ra cho tới khi kiểm soát viên hỏi sao số dư tháng 3 lại được vẽ trước ba ngày so với cuối quý

Vì sao tick tháng trôi dần khi bị cộng dồn?

Tick tháng trôi vì việc kẹp giá trị vứt bỏ thông tin. Một khi 31 tháng 1 đã bị kẹp thành 28 tháng 2, việc series muốn ngày 31 đã mất, và bất kỳ bước nào xuất phát từ giá trị đã kẹp đều thừa hưởng mất mát đó. TXLSChartDateAxisTransform.BuildTicks không bao giờ bước từ tick trước. Tick i luôn được tính bằng AddUnits(MinValue, UnitKind, Step * i, ...), tức min + unit × index đo từ cực tiểu của trục. AddUnits cộng thẳng số đếm vào serial với đơn vị ngày, còn với đơn vị tháng và năm thì chuyển serial thành một ngày lịch thật, gọi IncMonth hay IncYear, rồi chuyển ngược lại. Vòng lặp còn bị chặn trên mọi phía: mảng kết quả bị trần 4096 phần tử bất kể bên gọi đòi bao nhiêu, Step * i được đối chiếu MaxInt trước khi nhân, và việc sinh dừng ngay khi một tick không tăng nữa hay vượt cực đại của trục

Hai cách một trục ngày chart HotXLS có thể đặt tick tháng sau neo ngày 31 tháng 1 năm 2026: bước từng tick từ giá trị đã kẹp trước đó thì dính chặt vào ngày 28, còn BuildTicks tính AddUnits(MinValue, xcduMonths, Step nhân i) từ cực tiểu trục nên các tick đáp vào 28 tháng 2, 31 tháng 3 và 30 tháng 4
Việc kẹp giá trị vứt bỏ thông tin, nên TXLSChartDateAxisTransform không bao giờ bước từ tick trước mà tính lại mọi vị trí từ cực tiểu trục, kẹp ngày cuối tháng về lịch thật
uses
  SysUtils, lxChart;

var
  Ticks: TXLSChartDateValues;
  MinSerial, MaxSerial: Double;
  I, Count: Integer;
begin
  // hệ ngày 1900 (Dates1904 = False), series neo cuối tháng
  MinSerial := TXLSChartDateAxisTransform.DateTimeToSerial(
    EncodeDate(2026, 1, 31), False);
  MaxSerial := TXLSChartDateAxisTransform.DateTimeToSerial(
    EncodeDate(2026, 12, 31), False);

  // bước một tháng, nhắm 12 tick, không bao giờ quá 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 có đúng một chỗ xấp xỉ đáng kể ra. Khi bước được yêu cầu bằng 0, method ước lượng kích thước bước bằng cách coi một tháng là 30 ngày và một năm là 365, rồi làm tròn bước thô thành bội 1, 2 hay 5 của một lũy thừa mười. Phép ước lượng đó chỉ quyết định bao nhiêu unit nằm giữa các tick. Bản thân vị trí tick vẫn đến từ IncMonth và IncYear, nên phép xấp xỉ có thể đổi mật độ tick nhưng không bao giờ nhắc một tick rời khỏi lịch

Vì sao giữ serial workbook thay vì chuyển sang TDateTime?

Một trục ngày trong HotXLS giữ serial workbook làm không gian tọa độ vì TDateTime của Delphi không có chỗ cho serial 60, ngày 29 tháng 2 năm 1900 ma quái mà hệ ngày 1900 thừa hưởng như một quirk tương thích. TrySerialToDateTime dời các serial dưới 60 đi một ngày và gộp serial 60 vào ngày 28 tháng 2, điều đó ổn cho việc dán nhãn một điểm đơn lẻ nhưng chí mạng với hình học: chuyển mọi điểm sang TDateTime trước thì hai ngày hai bên ngày ma kết quả lại gần nhau hơn một unit so với cách Excel vẽ, nên mọi thứ được vẽ trước tháng 3 năm 1900 lệch đi so với mọi thứ sau nó. TXLSChartDateAxisTransform.FormatValue xử lý riêng đúng slot đó và dán nhãn 1900-02-29 y hệt Excel. Trong một workbook 1904, serial 0 là ngày 1 tháng 1 năm 1904, và transform áp offset cố định 1462 ngày theo cả hai chiều; toàn bộ câu chuyện của hai epoch nằm trong bài của chúng tôi về serial ngày của Excel, hệ 1904 và number format

Vì sao một trục ngày HotXLS giữ serial workbook thay vì chuyển sang TDateTime: serial 60 là ngày 29 tháng 2 năm 1900 ma, TrySerialToDateTime dời các serial dưới 60 và gộp 60 vào ngày 28 tháng 2 nên hai ngày hai bên kết quả gần nhau hơn một unit so với cách Excel vẽ, trong khi FormatValue dán nhãn serial 60 là 1900-02-29
Phép gộp ổn cho việc dán nhãn một điểm nhưng chí mạng với hình học, vì mọi thứ vẽ trước tháng 3 năm 1900 lệch đi so với mọi thứ sau nó; một workbook 1904 thay vào đó áp offset cố định 1462 ngày

HotXLS tự chọn ngày, tháng hay năm thế nào?

TXLSChartDateAxisTransform.DetectUnit chọn unit lịch mịn nhất mà các khoảng hở trong dữ liệu thật sự cần. Method vứt các giá trị NaN và vô cực trước, sắp phần còn lại đúng một lần trong O(n log n), và bỏ qua các ngày trùng. Nếu bất kỳ khoảng hở kề nào ngắn hơn một tháng lịch, unit là ngày; nếu không, nếu bất kỳ khoảng hở nào ngắn hơn một năm lịch, unit là tháng; nếu không nữa thì là năm. Phép thử là IncMonth(Previous, 1) > Current, chứ không phải ngưỡng 30 ngày, nên một series cuối tháng chạy 31 tháng 1, 28 tháng 2, 31 tháng 3 được nhận đúng là dữ liệu theo tháng. Với ít hơn hai ngày hợp lệ phân biệt thì không có khoảng hở nào để đo, và method quay về ngày

ResolveUnits sau đó hòa trộn unit được phát hiện với thứ mà chart khai báo. Một base unit tường minh được tôn trọng, và một major hay minor unit bị thiếu thừa hưởng unit phát hiện khi nó thô hơn base. Bước cuối là bước mọi người hay sót: nếu major hay minor unit hiệu lực mịn hơn base unit, base bị hạ xuống để khớp. Base unit là thứ mà các điểm dữ liệu được chuẩn hóa về trước khi bất kỳ tick nào tồn tại, với Normalize snap một giá trị về ngày của nó, mùng một tháng của nó hay 1 tháng 1, nên một base theo tháng dưới một major unit theo ngày sẽ dồn cả tháng điểm vào một slot trước khi code tick kịp nhìn thấy chúng. Mật độ tick cũng bị chặn, bởi chiều dài plot, chiều rộng đo được của font trục, number format của nhãn và phép chiếu của nhãn xoay, và mọi backend vẽ nhãn, vạch tick và gridline từ một mảng tick dùng chung có chặn, với minor tick trùng major tick chỉ được vẽ một lần

HotXLS chọn unit trục ngày tự động thế nào: DetectUnit vứt giá trị NaN và vô cực, sắp một lần rồi đo khoảng hở lịch bằng IncMonth thay vì ngưỡng 30 ngày, nên series 31 tháng 1, 28 tháng 2, 31 tháng 3 được nhận là theo tháng, và ResolveUnits tôn trọng base tường minh trong khi hạ base khi major unit mịn hơn
Normalize snap mỗi giá trị về ngày của nó, mùng một tháng của nó hay 1 tháng 1 trước khi bất kỳ tick nào tồn tại, vì một base theo tháng dưới một major unit theo ngày sẽ dồn cả tháng điểm vào một slot

Phần tử dateAx lưu gì, và việc bỏ qua nghĩa là gì?

Trong ChartML mọi thiết lập lịch trên một trục ngày đều tùy chọn, và bỏ qua một cái không giống với ghi giá trị mặc định của nó. Kiểu CT_DateAx trong ECMA-376 Part 1, §21.2 (DrawingML Charts) cho baseTimeUnit, majorTimeUnit, minorTimeUnit và auto mỗi cái tùy ý xuất hiện hay không, và một baseTimeUnit bị bỏ qua bảo Excel tự quyết, khác với một days tường minh. Vì thế TXLSXChartAxis lưu mỗi giá trị cạnh một cờ hiện diện: BaseTimeUnit với BaseTimeUnitSet, MajorTimeUnit với MajorTimeUnitSet, MinorTimeUnit với MinorTimeUnitSet, và AutoDateAxis với AutoDateAxisSet. Gán một giá trị thì bật cờ của nó, xóa cờ thì trả phần tử về trạng thái bị bỏ qua, và writer XLSX chỉ phát ra một phần tử khi cờ của nó bật. Hãy để ý auto nghĩa là tự động phát hiện category-hay-ngày, không phải unit tick tự động

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;                  // ghi c:dateAx
    Axis.BaseTimeUnit := xlsxChartTimeDays;     // cũng đặt BaseTimeUnitSet
    Axis.MajorTimeUnit := xlsxChartTimeMonths;
    Axis.MajorUnit := 1;                        // > 0 đánh dấu majorUnit đã được đặt
    Axis.NumberFormat := 'mmm yyyy';
    Axis.NumberFormatSourceLinked := False;
    Axis.TextStyle.Rotation := -45;             // độ; lưu dưới dạng 1/60000
    // AutoDateAxis giữ nguyên: không phần tử c:auto nào được ghi

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

Nhãn và hướng đi theo cùng luật nhận thức hiện diện. numFmt của trục nằm sau title và trước các vạch tick, thuộc tính sourceLinked của nó mặc định là true, và TXLSXChartAxis giữ mã format, giá trị source-linked và NumberFormatSet tách rời, nên một mã rỗng không bao giờ bị lầm là phần tử vắng mặt. Góc xoay text được lưu theo 1/60000 độ với giá trị dương nghĩa là theo chiều kim đồng hồ; TextStyle.Rotation nhận độ trơn, output SVG dùng góc nguyên trạng, còn backend phân trang, nơi góc dương chạy ngược chiều kim, đảo dấu tại một biên dùng chung. Một trục maxMin đảo ngược phản chiếu major và minor tick, gridline, điểm dữ liệu, trendline và error bar cùng lúc, và không bao giờ chỉ phản chiếu nhãn. Khi bạn mở một workbook được viết bởi nơi khác, các cờ cho bạn biết tác giả thật sự chỉ định gì

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;   // lưu lại nữa và các phần tử bị bỏ qua vẫn bị bỏ qua
  end;
end;

Model lịch dừng ở đâu?

Model lịch dừng lại ở các định dạng không diễn đạt được unit lịch, và HotXLS không giả vờ khác đi. ODF 1.2 chỉ có chart:interval-major, một số trơn không có khái niệm ngày, tháng hay năm, nên một trục theo tháng của Excel được lưu thành ODS không thể mang theo unit của nó, và HotXLS không bịa một thuộc tính phi chuẩn để giả vờ có. Về phía BIFF8 cũ, record AxcExt giữ chín word 16-bit cố định, và các cờ automatic của nó chỉ che các trường chứ không xóa chúng; HotXLS bảo toàn các giá trị cực tiểu, cực đại, interval, unit và crossing đã bị che khi đọc và ghi ngược, giữ nguyên các mã unit lạ trong model Classic, và chỉ ánh xạ các unit ngày hợp lệ 0, 1 và 2 (ngày, tháng, năm) sang XLSX

Trục ngày là một lớp của model chart mà còn phải sống sót qua những file nó không phải người tạo ra. Khi một workbook Excel kết hợp một line theo trục ngày với một column series trên trục phụ, bài của chúng tôi về ChartML được bảo toàn và combination chart cho thấy HotXLS phát lại XML chart gốc từng byte khi chẳng có gì đổi và hòa các chỉnh sửa có kiểu vào đó khi có gì đó đổi, còn bài tổng quan về chart, ảnh và drawing của chúng tôi trình bày các API anchoring và series được dùng ở trên. Tất cả nằm trong HotXLS Delphi Component cho Delphi và C++Builder, vốn đọc, ghi và render chart XLS và XLSX mà không cần Excel automation