技術記事

DelphiのHotXLSチャート日付軸と暦に正確な目盛り

HotXLSはExcelチャートの日付軸を、固定の日数ではなく暦の演算で描画します。lxChartユニットのTXLSChartDateAxisTransformクラスはすべての座標をワークブックシリアルとして保持し、月や年の目盛りを軸の最小値からIncMonthとIncYearで直接計算し、月末を実際の暦にクランプします。1月31日にアンカーされた系列は、ずれる代わりに2月28日、3月31日、4月30日に目盛りを得ます。同じトランスフォームが日・月・年の単位を自動選択し、HTML、SVG、ページ分割PDFの各チャートレンダラーで共有されます

これが防ぐバグは、月末決算をチャートにしたことがある人なら見覚えがあるものです。月を30日として扱うレンダラーは、1年目の終わりまでに5日も早くずれます。もう少し賢く暦の月を使うレンダラーでも、前の目盛りから順に進める方式なら、もっと静かに破綻します。1月31日が2月28日になり、次のステップは3月28日に着地し、その後の目盛りはずっと28日のまま釘付けです。チャートはそれらしく見え、ラベルは間違っていて、経理担当が「3月の残高が四半期末の3日前にプロットされているのはなぜか」と尋ねるまで誰も気づきません

積み上げていくと月の目盛りはなぜずれるのか

月の目盛りがずれるのは、クランプが情報を捨てるからです。1月31日が2月28日にクランプされた瞬間、系列が31日を望んでいたという事実は失われ、クランプ後の値から取ったステップはすべてその損失を引き継ぎます。TXLSChartDateAxisTransform.BuildTicksは前の目盛りから決してステップしません。目盛りiは常にAddUnits(MinValue, UnitKind, Step * i, ...)として計算されます。つまり軸の最小値から測ったmin + unit × indexです。AddUnitsは日の単位ならカウントをシリアルへ直接加え、月と年の単位ならシリアルを実日付へ変換してIncMonthかIncYearを呼び、また戻します。ループはどの方向にも有界です。結果配列は呼び出し側が何を要求しても4096エントリで打ち止め、Step * iは掛ける前にMaxIntに対してチェックされ、目盛りが増加に失敗するか軸の最大値を超えた時点で生成を止めます

HotXLSチャートの日付軸が2026年1月31日のアンカーの後に月目盛りを置く2つの方法:前のクランプ済み値から順に進めると28日で釘付けになりますが、BuildTicksは軸の最小値からAddUnits(MinValue, xcduMonths, Step × 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);

  // 1か月ステップ、目標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には正直な近似が1つあります。要求されたステップがゼロのとき、メソッドは月を30日、年を365日としてステップサイズを見積もり、生のステップを1、2、5のいずれか×10の累乗の倍数に丸めます。この見積もりが決めるのは目盛り間に何単位あるかだけです。目盛り位置そのものは依然としてIncMonthとIncYearから来るため、近似は目盛りの密度を変えられても、目盛りを暦から外すことはできません

TDateTimeに変換せずワークブックシリアルを保つ理由

HotXLSの日付軸が座標空間としてワークブックシリアルを保つのは、DelphiのTDateTimeにはシリアル60、つまり1900日付システムが互換性の癖として抱え込んだ幻の1900年2月29日の席がないからです。TrySerialToDateTimeは60未満のシリアルを1日ずらし、シリアル60を2月28日に折りたたみます。1点のラベル付けにはこれで十分ですが、ジオメトリにとっては致命的です。すべての点を先にTDateTimeへ変換すると、幻の日の前後2日はExcelが描くより1単位分だけ接近し、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が描くより1単位接近しますが、FormatValueはシリアル60を1900-02-29とラベル付けします
折りたたみは1点のラベル付けなら十分ですが、ジオメトリには致命的です。1900年3月より前のプロットがそれ以降と相対的にずれるためで、1904ワークブックでは代わりに固定の1462日オフセットを適用します

HotXLSは日・月・年をどう自動選択するのか

TXLSChartDateAxisTransform.DetectUnitは、データのギャップが実際に必要とする最も細かい暦単位を選びます。メソッドはまずNaNと無限大の値を落とし、残りをO(n log n)で1回ソートし、重複する日付をスキップします。隣接ギャップが1暦月より短いものが1つでもあれば単位は日、そうでなければ1暦年より短いものが1つでもあれば単位は月、それ以外は年です。判定はIncMonth(Previous, 1) > Currentという式であって30日閾値ではないため、1月31日、2月28日、3月31日と続く月末系列は月次データとして正しく検出されます。異なる有効な日付が2つ未満なら測るギャップが存在せず、メソッドは日にフォールバックします

続いてResolveUnitsが検出された単位をチャートの宣言とマージします。明示的な基本単位は尊重され、欠けている主・副単位は、基本単位より粗い場合に検出単位を引き継ぎます。見落とされがちなのが最後のステップです。実効的な主・副単位が基本単位より細かい場合、基本単位はそれに合わせて下げられます。基本単位は、目盛りが存在する前にデータ点が正規化される先であり、Normalizeは値をその日、その月の1日、または1月1日にスナップします。日の主単位の下に月の基本単位を置くと、目盛りコードがそれを見る前に1か月分の点が1つのスロットへ潰れてしまうわけです。目盛りの密度にも制限があり、プロット長、軸フォントの実測幅、ラベルの数値書式、回転ラベルの射影によって決まります。すべてのバックエンドは共有の有界な目盛り配列からラベル、目盛り線、グリッド線を描き、主目盛りと重なる副目盛りは1回しか描きません

HotXLSが日付軸の単位を自動選択する仕組み:DetectUnitはNaNと無限大を落として1回ソートし、30日閾値ではなくIncMonthで暦ギャップを測るため、1月31日、2月28日、3月31日の系列は月次と検出され、ResolveUnitsは明示的な基本単位を尊重しつつ、主単位がより細かい場合は基本単位を下げます
Normalizeは目盛りが存在する前に各値をその日、その月の1日、または1月1日にスナップします。日の主単位の下に月の基本単位を置くと、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は書式コード、ソースリンク値、NumberFormatSetを別々に保持するため、空のコードが要素の欠如と取り違えられることはありません。テキストの回転は1/60000度で保存され、正の値は時計回りを意味します。TextStyle.Rotationは素の度を受け取り、SVG出力は角度をそのまま使い、正の角度が反時計回りになるページ分割バックエンドは、1つの共有境界で符号を反転させます。反転した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レコードは9個の固定16ビットワードを持ち、その自動フラグはフィールドを削除するのではなくマスクするだけです。HotXLSはマスクされた最小値、最大値、間隔、単位、交差値を読み込みと書き戻しの両方で保持し、未知の単位コードをClassicモデルにそのまま残し、有効な日付単位0、1、2(日、月、年)だけをXLSXへマップします

日付軸は、自分が作っていないファイルも生き延びさせなければならないチャートモデルの1層にすぎません。Excelワークブックが日付軸の折れ線と副軸の縦棒系列を組み合わせている場合、保存済みChartMLと組み合わせチャートの記事が、何も変わっていなければ元のチャートXMLをバイト単位で再生し、変わっていれば型付きの編集をマージするHotXLSの挙動を示し、チャート、画像、描画の概説が上で使ったアンカーと系列のAPIを扱っています。これらすべては、ExcelオートメーションなしでXLSとXLSXのチャートを読み、書き、描画する、DelphiとC++Builder向けのHotXLS Delphi Componentに搭載されています