技術文章

HotXLS 圖表日期座標軸的日曆刻度

一份從 2026 年 1 月 31 日開始的月底結算圖,4 月的刻度應該落在 4 月 30 日。用日曆月從上一個刻度往前推,標籤會變成 2026-01-31、2026-02-28、2026-03-28、2026-04-28,之後每個刻度都卡死在 28 號。改用固定 30 天往前推,2 月的刻度落到 3 月 2 日,12 月的刻度落到 12 月 27 日。能同時躲過兩個陷阱的規則很好說:每個刻度都從軸最小值計算,並把月底夾限到真實日曆

在季結圖上,第一種錯誤是危險的那種,因為圖看起來仍然正常。線在該漲的地方漲,標籤間距均勻,3 月的餘額停在季底前三天。發現它的是拿圖對帳的財務主管,而到那時圖多半已經貼進董事會報告裡了

月刻度為什麼會在累積時漂移?

月刻度漂移,是因為夾限丟掉了資訊。1 月 31 日一旦被夾限成 2 月 28 日,「數列想要的是 31 號」這個事實就沒了,從夾限值出發的任何一步都繼承這個損失。在 HotXLS 裡,lxChart 單元的 TXLSChartDateAxisTransform.BuildTicks 絕不從上一個刻度往下推:刻度 i 永遠是 AddUnits(MinValue, UnitKind, Step * i, ...),所以 3 月是 1 月 31 日之後兩個月,算出來就是 3 月 31 日。AddUnits 對日單位把數量直接加到序號上;對月與年單位,把序號轉成真實日期、呼叫 IncMonth 或 IncYear、再轉回去

迴圈每個方向都有界。不管呼叫端傳什麼最大值,結果陣列都不超過 4096 筆;Step * i 在相乘之前先對 MaxInt 檢查;某個刻度不再遞增或越過軸最大值時,產生立即停止。跨距幾百萬年的惡意活頁簿只會得到一個短陣列然後返回

HotXLS 圖表日期軸在 2026 年 1 月 31 日錨點之後放置月刻度的兩種方式:從上一個夾限值往下推會一路釘死在 28 號,而 BuildTicks 從軸最小值計算 AddUnits(MinValue, xcduMonths, Step times i),刻度落在 2 月 28 日、3 月 31 日與 4 月 30 日
夾限會丟掉資訊,所以 TXLSChartDateAxisTransform 絕不從上一個刻度往下推,而是從軸最小值重算每個位置,把月底夾限到真實日曆

轉換是公開的,所以圖表還不存在時您就能自己跑這套算術。這段主控台程式碼為上面的數列建出一整年的月刻度,並印出圖表將使用的標籤

uses
  SysUtils, lxChart;

var
  Ticks: TXLSChartDateValues;
  MinSerial, MaxSerial: Double;
  I, Count: Integer;
begin
  // 1900 日期系統(Dates1904 = False),月底錨定的數列
  MinSerial := TXLSChartDateAxisTransform.DateTimeToSerial(
    EncodeDate(2026, 1, 31), False);
  MaxSerial := TXLSChartDateAxisTransform.DateTimeToSerial(
    EncodeDate(2026, 12, 31), False);

  // 一步一個月,目標 12 個刻度,絕不超過 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 裡恰好有一個近似,而且它坐在無害的位置。要求的步距為零時,方法以「一個月 30 天、一年 365 天」估算步距,再把原始步距進位到 10 的冪次的 1、2 或 5 倍。這個估算只決定刻度之間隔幾個單位。位置仍然出自 IncMonth 與 IncYear。粗略的月長可以讓軸密一點或疏一點,但每個刻度仍落在那兩個函式產生的日期上

活頁簿序號保持為軸座標

HotXLS 的日期軸以活頁簿序號為座標空間,因為 Delphi 的 TDateTime 沒有序號 60 的位置。在 1900 日期系統裡,那個序號是 1900 年 2 月 29 日——一個從未存在、Excel 為相容更早的試算表程式而保留的日子。TXLSChartDateAxisTransform.TrySerialToDateTime 以真實日曆唯一能做的方式處理它:60 以下的序號位移一天,序號 60 摺疊到 2 月 28 日。標一個點的標籤,這樣就夠了。對幾何卻是錯的:序號 59 與 60 變成同一個 TDateTime,2 月 28 日與 3 月 1 日只差一個單位,而 Excel 畫的是差兩個

三個序號同時展示問題的兩面。FormatValue 對幽靈槽位做特例、印出與 Excel 完全相同的文字,而經 TDateTime 的來回則把它弄丟

var
  S: Integer;
  D: TDateTime;
begin
  for S := 59 to 61 do
  begin
    TXLSChartDateAxisTransform.TrySerialToDateTime(S, False, D);
    Writeln(S, '  axis label: ',
      TXLSChartDateAxisTransform.FormatValue(S, False),
      '  TDateTime: ', FormatDateTime('yyyy-mm-dd', D));
  end;
  // 59  axis label: 1900-02-28  TDateTime: 1900-02-28
  // 60  axis label: 1900-02-29  TDateTime: 1900-02-28
  // 61  axis label: 1900-03-01  TDateTime: 1900-03-01
end;

幾乎沒有人畫 1900 年初的資料,所以實務教訓是關於您自己的程式碼。要計算軸邊界或註解位置,就用序號算、在邊界上用 DateTimeToSerial 轉換——第一段程式碼就是這麼做的。在呼叫端把序號軸與 TDateTime 算術混用,一條標在 1900 年 3 月 1 日之前的註解,會落在離它的點一天遠的地方。第二個紀元更常出現:1904 活頁簿的序號 0 是 1904 年 1 月 1 日,Dates1904 參數為真時,轉換在兩個方向都套用固定的 1462 天位移。兩個紀元,以及儲存格數字格式與它們的互動,在我們談 Excel 日期序號、1904 系統與數字格式的文章中涵蓋

HotXLS 日期軸為什麼保留活頁簿序號而不轉成 TDateTime:序號 60 是 1900 年 2 月 29 日這個幽靈日,TrySerialToDateTime 位移 60 以下的序號、把 60 摺疊到 2 月 28 日,兩側的日子因此比 Excel 畫的靠近一個單位,而 FormatValue 把序號 60 標成 1900-02-29
摺疊對標示單一點無妨,對幾何卻是致命的,因為 1900 年 3 月之前畫的一切相對之後的一切都位移了;1904 活頁簿則改套固定的 1462 天位移

HotXLS 怎麼自動選擇日、月或年?

TXLSChartDateAxisTransform.DetectUnit 挑的是資料間距實際需要的最細日曆單位。它丟掉 NaN 與無限值,把其餘排序一次,跳過重複日期。任何相鄰間距短於一個日曆月,單位就是日;否則任何間距短於一個日曆年,單位就是月;否則是年。月份測試是 IncMonth(Previous, 1) > Current,而這個選擇對本文的資料正好關鍵:30 天門檻會把 1 月 31 日到 2 月 28 日的間距判成「不足一個月」,把月頻數列切成日。有效日期少於兩個時無間距可量,方法回傳日

ResolveUnits 把偵測單位與圖表宣告的合併。明確的基準單位勝出。缺失的主要或次要單位,在偵測單位比基準粗時繼承偵測值,否則退回基準。最後一條規則最容易被漏掉:有效的主要或次要單位比基準細時,基準調降以對齊。基準單位決定在任何刻度存在之前點怎麼分組,因為 Normalize 把值吸附到當日、當月一號或 1 月 1 日。月基準配日主要單位,會在刻度程式碼看到資料之前就把一整個月的點摺進一個槽位

HotXLS 如何自動挑選日期軸單位:DetectUnit 丟掉 NaN 與無限值、排序一次、用 IncMonth 而非 30 天門檻量測日曆間距,所以 1 月 31 日、2 月 28 日、3 月 31 日的數列被判成月頻,而 ResolveUnits 尊重明確的基準單位、在主要單位較細時調降基準
Normalize 在任何刻度存在之前把每個值吸附到當日、當月一號或 1 月 1 日,因為月基準配上日主要單位會把一整個月的點塌縮進一個槽位

自動主要刻度出現幾個,取決的不只是日期範圍。繪圖長度、座標軸字型的實測寬度、標籤數字格式、旋轉標籤的投影寬度都封頂計數,所以帶長 mmmm yyyy 標籤的窄圖,刻度比帶 mm/yy 的寬圖少。標籤、刻度記號與格線全部從同一份刻度陣列繪出,HTML、SVG 與 PDF 輸出因此一致;落在主要刻度上的次要刻度只畫一次

dateAx 元素儲存什麼,省略又代表什麼?

在 ChartML 裡,日期軸上每個日曆設定都是選填的,省略一項不等於寫出它的預設值。ECMA-376 Part 1 §21.2(DrawingML Charts)的 CT_DateAx 型別讓 baseTimeUnit、majorTimeUnit、minorTimeUnit 與 auto 各自可出現可不出現,省略的 baseTimeUnit 告訴 Excel 自己決定,這與明確寫 days 不同。因此 TXLSXChartAxis 把每個值連同一個存在旗標一起儲存:BaseTimeUnit 配 BaseTimeUnitSet、MajorTimeUnit 配 MajorTimeUnitSet、MinorTimeUnit 配 MinorTimeUnitSet,以及 AutoDateAxis 配 AutoDateAxisSet。指派值會設起旗標,把旗標設成 False 則讓元素回到省略狀態,XLSX 寫入器只在旗標設起時才輸出元素。注意 auto 指的是自動判斷類別還是日期,不是自動刻度單位

uses
  SysUtils, DateUtils, lxHandleX;

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));
      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;             // 度
    // AutoDateAxis 保持不動:不寫出任何 c:auto 元素

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

這裡的基準單位刻意設成日。月底日期落在不同的日號上,月基準會把每個點吸附到當月一號,把 3 月 31 日的餘額搬到 3 月 1 日。確切日號有意義時,把基準釘在日,讓主要單位負責按月間隔

標籤與方向遵循同一套存在規則。軸的 numFmt 元素預設 sourceLinked 為 true,所以 TXLSXChartAxis 把格式代碼、source-linked 值與 NumberFormatSet 分開保存,空代碼永遠不會被誤當成缺席元素。ChartML 以 1/60000 度儲存文字旋轉、正值順時針;TextStyle.Rotation 吃普通度數,設成 -45 的標籤在 SVG 與 PDF 輸出裡傾斜方向一致。反轉的(maxMin)軸把主要與次要刻度、格線、資料點、趨勢線與誤差線一起鏡射。讀取則反向運作:打開別處寫的活頁簿,旗標告訴您作者實際指定了什麼

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[1].Charts[0].CategoryAxis;  // Sheets[] 以 1 起算,Charts[] 以 0 起算
    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;   // 再存一次檔,被省略的元素依舊缺席
  end;
end;

ODS 與傳統 XLS 檔案裡的日曆單位

日曆模型延伸到每種檔案格式能表達的極限。ODF 1.2 用 chart:interval-major 描述軸間距——一個純數字,沒有日、月、年的概念,所以存成 ODS 的 Excel 月頻軸帶不走它的單位。HotXLS 不發明反正會被其他 ODF 應用程式忽略的非標準屬性,而是不把單位寫進 ODS 檔。月間距對開檔的人要緊的話,出貨 XLSX

BIFF8 活頁簿把日期軸設定放在 AxcExt 記錄裡,九個固定的 16 位元字組。它的自動旗標遮罩欄位而不移除它們,所以標成「自動最小值」的記錄仍然帶著一個最小值。HotXLS 在讀取時保留被遮罩的最小值、最大值、間距、單位與交叉值並原樣寫回,在 XLS 物件模型裡保持未知的單位代碼,只在圖表轉成 XLSX 時映射有效的日期單位 0、1、2(日、月、年)

日期軸很少單獨旅行。當 Excel 活頁簿把日期軸折線與次要軸上的柱形數列湊成一對,我們談保留 ChartML 與組合圖的文章說明 HotXLS 在什麼都沒變時如何重播原始圖表 XML、有變時如何把型別化編輯合併進去;我們的圖表、圖片與繪圖總覽涵蓋上文用到的錨定與數列呼叫。HotXLS Delphi Component 頁面列出 Delphi 與 C++Builder 的完整圖表功能集,包括不必安裝 Excel 就能讀寫與渲染 XLS 與 XLSX 圖表