技術記事

Delphiの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 月の残高は四半期末の 3 日手前に座ります。チャートを帳簿と比較するコントローラーが見つける人で、その頃にはチャートはたいてい取締役会資料に貼り込まれています

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

月の目盛りがずれるのは、クランプが情報を捨てるからです。1 月 31 日が 2 月 28 日にクランプされた瞬間、系列が 31 日を望んでいたという事実は消え、クランプ済みの値から取られた一歩は、その損失を引き継ぎます。HotXLS では、lxChart ユニットの TXLSChartDateAxisTransform.BuildTicks は、前の目盛りから決して一歩しません。目盛り i は常に AddUnits(MinValue, UnitKind, Step * i, ...) なので、3 月は 1 月 31 日の 2 か月後であり、3 月 31 日として出てきます。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は前の目盛りからステップせず、すべての位置を軸の最小値から再計算し、月末を実際の暦にクランプします

この変換は公開されているので、チャートが 1 つも存在する前に、自分で算術を走らせられます。このコンソールスニペットは、上の系列の 1 年分の月目盛りを作り、チャートが使うラベルを印字します

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 として扱い、生のステップを 10 の冪の 1、2、5 倍に切り上げてステップを見積もります。その見積もりが決めるのは、目盛りの間に何単位あるかだけです。位置はそれでも IncMonth と IncYear から来ます。粗い月の長さは、軸を少しだけ密にしたり疎にしたりできますが、すべての目盛りは、その 2 つの関数のどちらかが生んだ日付に着地します

ワークブックシリアルが軸の座標であり続ける

HotXLS の日付軸は、ワークブックシリアルを座標空間として保ちます。Delphi の TDateTime には、シリアル 60 の席がないからです。1900 日付システムでそのシリアルは 1900 年 2 月 29 日、存在しなかった日で、Excel は古い表計算プログラムとの互換のためにこれを保持しています。TXLSChartDateAxisTransform.TrySerialToDateTime は、実際の暦に可能な唯一のしかたでこれを扱います。60 未満のシリアルは 1 日ずらし、シリアル 60 は 2 月 28 日へ折りたたみます。1 点のラベル付けならそれで十分です。ジオメトリでは誤りです。シリアル 59 と 60 が同じ TDateTime になり、2 月 28 日と 3 月 1 日が 1 単位の差で終わり、Excel が 2 単位差で描くのと食い違うのです

3 つのシリアルが、問題の両面を見せてくれます。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 日より前の日付の注釈は、ポイントから 1 日外れます。2 つ目のエポックのほうが頻繁に関わります。1904 ワークブックでは、シリアル 0 は 1904 年 1 月 1 日で、変換は Dates1904 引数が真のとき、固定の 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 と無限大の値を落とし、残りのコピーを 1 回ソートし、重複する日付を飛ばします。隣接ギャップが 1 暦月より短いものを 1 つでも持てば単位は日で、なければ、ギャップが 1 暦年より短いものを 1 つでも持てば月、それ以外は年です。月のテストは IncMonth(Previous, 1) > Current で、その選択が効くのは、まさにこの記事のデータです。30 日の閾値では、1 月 31 日から 2 月 28 日へのギャップを「1 か月未満」と呼んで、月次の系列を日へ切り替えてしまいます。異なる有効な日付が 2 個未満なら、測るギャップがなく、メソッドは日を返します

ResolveUnits は、検出された単位を、チャートが宣言したものと合流させます。明示的な基本単位が勝ちます。欠けている主または副単位は、検出単位が基本より粗いときは検出単位を引き継ぎ、そうでなければ基本へフォールバックします。人が見落とすのは最後の規則です。実効的な主や副の単位が基本より細かいとき、基本は合わせて下がります。基本単位は、目盛りが存在する前にポイントのグループ化を決めるからです。Normalize は値をその日、その月の 1 日、または 1 月 1 日へスナップします。日の主単位の下の月の基本単位は、目盛りコードが目にする前に、1 か月分のポイントを 1 つのスロットへ潰します

HotXLSが日付軸の単位を自動選択する仕組み:DetectUnitはNaNと無限大を落として1回ソートし、30日閾値ではなくIncMonthで暦ギャップを測るため、1月31日、2月28日、3月31日の系列は月次と検出され、ResolveUnitsは明示的な基本単位を尊重しつつ、主単位がより細かい場合は基本単位を下げます
Normalizeは目盛りが存在する前に各値をその日、その月の1日、または1月1日にスナップします。日の主単位の下に月の基本単位を置くと、1か月分の点が1つのスロットへ潰れてしまうためです

自動の主目盛りが何本現れるかは、日付範囲だけでは決まりません。プロットの長さ、軸フォントの実測幅、ラベルの表示形式、回転ラベルの予測幅が、すべてカウントに上限を課します。だから長い mmmm yyyy ラベルの狭いチャートは、mm/yy の広いチャートより目盛りが少なくなります。ラベル、目盛りマーク、グリッド線は、すべて 1 つの目盛り配列から描かれ、HTML、SVG、PDF 出力が一致を保ちます。主目盛りに落ちた副目盛りは 1 度だけ描かれます

dateAx要素が保存するものと、省略が意味するもの

ChartML では、日付軸の暦設定はすべてオプションで、1 つを省くことと既定値を書くことは、違うことを言います。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;

ここで基本単位を日にしているのは意図的です。月末の日付は日番号が違う場所に落ち、月の基本単位は、すべてのポイントをその月の 1 日へスナップし、3 月 31 日の残高を 3 月 1 日へ動かします。正確な日に意味があるときは、基本を日に釘付けにし、月次の間隔は主単位にやらせてください

ラベルと向きも、同じ在席の規則に従います。軸の numFmt 要素は sourceLinked を既定で真にするので、TXLSXChartAxis は、形式コード、ソースリンク値、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 レコード、9 個の固定 16 ビットワードに保持します。その自動フラグは、フィールドを取り除かずにマスクします。だから「自動最小値」の印のあるレコードが、最小値をまだ運んでいます。HotXLS は読み取り時に、マスクされた最小値、最大値、間隔、単位、交差値を保持し、変更なしで書き戻し、未知の単位コードを XLS オブジェクトモデルの中で無傷のまま保ち、チャートが XLSX へ変換されるときにだけ、有効な日付単位 0、1、2(日、月、年)を写像します

日付軸は、一人で旅することはめったにありません。Excel のワークブックが、日付軸の折れ線と、副軸上の縦棒系列を組み合わせるとき、保存された ChartML と組み合わせチャートの記事が、何も変わらなかったとき HotXLS が元のチャート XML を再生し、何かが変わったとき型付きの編集をそれへマージするしかたを説明し、チャート、画像、描画の概観が、上で使ったアンカーと系列の呼び出しを扱います。HotXLS Delphi Component のページに、Delphi と C++Builder 向けの完全なチャート機能集合の一覧があります。Excel をインストールしないままの XLS と XLSX チャートの読み取り、書き込み、レンダリングを含めて