技術文章

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

HotXLS 用日曆運算而非固定天數來渲染 Excel 圖表的日期座標軸。lxChart 單元裡的 TXLSChartDateAxisTransform 類別把每個座標都當成活頁簿序號,從軸最小值出發透過 IncMonth 與 IncYear 直接算出每個月刻度與年刻度,並把月底夾限到真實日曆,所以錨定在 1 月 31 日的數列,刻度會落在 2 月 28 日、3 月 31 日與 4 月 30 日,而不是一路滑掉。同一套轉換會自動挑選日、月或年單位,並由 HTML、SVG 與分頁 PDF 圖表渲染器共用

它擋掉的 bug,凡是畫過月底結帳圖的人都熟悉。把一個月當 30 天的渲染器,第一年結束就會提早漂移五天。比較聰明、用日曆月、但從上一個刻度往下推的渲染器,敗得更安靜:1 月 31 日變成 2 月 28 日,下一步落在 3 月 28 日,之後每個刻度都釘死在 28 號。圖看起來像那麼一回事,標籤卻是錯的,而且沒人會發現,直到財務主管問為什麼 3 月的餘額被畫在季底前三天

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

月刻度漂移,是因為夾限丟掉了資訊。一旦 1 月 31 日被夾限成 2 月 28 日,「數列想要的是 31 號」這件事就沒了,而從夾限值出發的任何一步都繼承這個損失。TXLSChartDateAxisTransform.BuildTicks 絕不從上一個刻度往下推。刻度 i 永遠算成 AddUnits(MinValue, UnitKind, Step * i, ...),也就是以軸最小值為基準的 min + 單位 × 索引。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,所以近似可以改變刻度密度,但永遠不會把刻度挪離日曆

為什麼保留活頁簿序號而不轉成 TDateTime?

HotXLS 的日期座標軸以活頁簿序號當座標空間,因為 Delphi 的 TDateTime 沒有序號 60 的位置——1900 年 2 月 29 日這個幽靈日,是 1900 日期系統為了相容而繼承下來的怪癖。TrySerialToDateTime 把 60 以下的序號位移一天,把序號 60 摺疊到 2 月 28 日,拿來標一個點沒問題,對幾何卻是致命的:先把每個點都轉成 TDateTime,幽靈日兩側的兩天就會比 Excel 畫的靠近一個單位,於是 1900 年 3 月之前畫的一切相對之後的一切都位移了。TXLSChartDateAxisTransform.FormatValue 對同一個槽位做特例處理,並照 Excel 的做法標成 1900-02-29。在 1904 活頁簿裡,序號 0 是 1904 年 1 月 1 日,轉換在兩個方向都套用固定的 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 與無限值,把剩下的排一次序(O(n log n)),並跳過重複日期。只要任何相鄰間距短於一個日曆月,單位就是日;否則只要任何間距短於一個日曆年,單位就是月;否則就是年。判斷式是 IncMonth(Previous, 1) > Current,不是 30 天門檻,所以 1 月 31 日、2 月 28 日、3 月 31 日這種月底數列會被正確判成月頻資料。不同的有效日期少於兩個時沒有間距可量,方法就退回日

ResolveUnits 接著把偵測到的單位與圖表宣告的合併。明確宣告的基準單位會被尊重,而缺漏的主要或次要單位在偵測單位比基準粗時繼承偵測值。最後一步是最常被漏掉的:如果有效的主要或次要單位比基準單位細,基準會被調降以對齊。基準單位是資料點在任何刻度存在之前的正規化目標,Normalize 會把值吸附到當日、當月一號或 1 月 1 日,所以月基準配上日主要單位,會在刻度程式碼看到資料之前就把一整個月的點塌縮進一個槽位。刻度密度同樣有界,受繪圖長度、座標軸字型的實測寬度、標籤數字格式與旋轉標籤的投影約束,而每個後端都從同一份共用、有界的刻度陣列畫出標籤、刻度記號與格線,與主要刻度重合的次要刻度只畫一次

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

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。指派值會設起旗標,清掉旗標則讓元素回到省略狀態,XLSX 寫入器只在旗標設起時才輸出元素。注意 auto 指的是自動判斷類別還是日期,不是自動刻度單位

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

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

標籤與方向遵循同一套存在感知規則。座標軸的 numFmt 位在標題之後、刻度記號之前,其 sourceLinked 屬性預設為 true,而 TXLSXChartAxis 把格式代碼、source-linked 值與 NumberFormatSet 分開保存,所以空代碼永遠不會被誤當成缺席的元素。文字旋轉以 1/60000 度儲存,正值代表順時針;TextStyle.Rotation 接受普通度數,SVG 輸出直接使用該角度,而分頁後端的正角是逆時針,於是在一個共用邊界上翻轉正負號。反轉的 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[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;   // 再存一次檔,被省略的元素依舊缺席
  end;
end;

日曆模型在哪裡停手?

日曆模型停在無法表達日曆單位的格式上,HotXLS 也不假裝不然。ODF 1.2 只有 chart:interval-major,一個純數字,完全沒有日、月、年的概念,所以存成 ODS 的 Excel 月頻座標軸帶不走它的單位,HotXLS 也不會發明非標準屬性來假裝有。在傳統 BIFF8 這邊,AxcExt 記錄裝著九個固定的 16 位元字組,其自動旗標只是遮罩欄位而非移除它們;HotXLS 在讀取與寫回時保留被遮罩的最小值、最大值、間距、單位與交叉值,在 Classic 模型裡原樣保存未知的單位代碼,並只把有效的日期單位 0、1、2(日、月、年)映射到 XLSX

日期座標軸是圖表模型的一層,而這個模型還得在不屬於它生的檔案裡存活。當 Excel 活頁簿把日期軸折線與次要座標軸上的柱形數列湊在一起,我們談保留 ChartML 與組合圖的文章展示了 HotXLS 在什麼都沒變時如何逐位元組重播原始圖表 XML、在真的有變時把型別編輯合併進去,我們的圖表、圖片與繪圖總覽則涵蓋上文用到的錨點與數列 API。這一切都隨 HotXLS Delphi Component 出貨,支援 Delphi 與 C++Builder,讀寫與渲染 XLS 和 XLSX 圖表都不靠 Excel 自動化