Technical Article

HotXLS Chart Date Axis: Calendar-Accurate Ticks in Delphi

A month-end balance chart that starts on January 31, 2026 should put its April tick on April 30. Advance each tick from the one before it with calendar months and the labels come out as 2026-01-31, 2026-02-28, 2026-03-28, 2026-04-28, with every later tick stuck on the 28th. Advance by a flat 30 days instead and the February tick lands on March 2, while the December tick lands on December 27. The rule that survives both traps is simple to state: compute every tick from the axis minimum, and clamp month ends to the real calendar

On a quarter-close chart the first mistake is the dangerous one, because the picture still looks right. The line rises where it should, the labels are evenly spaced, and the March balance sits three days before quarter end. A controller comparing the chart with the ledger is the person who finds it, and by then the chart has usually been pasted into a board pack

Why do month ticks drift when they are accumulated?

Month ticks drift because clamping throws information away. Once January 31 has been clamped to February 28, the fact that the series wanted the 31st is gone, and any step taken from the clamped value inherits the loss. In HotXLS, TXLSChartDateAxisTransform.BuildTicks in the lxChart unit never steps from a previous tick: tick i is always AddUnits(MinValue, UnitKind, Step * i, ...), so March is two months after January 31 and comes out as March 31. AddUnits adds the count straight to the serial for day units; for month and year units it converts the serial to a real date, calls IncMonth or IncYear, and converts back

The loop is bounded on every side. The result array never exceeds 4096 entries whatever the caller passes as the maximum, Step * i is checked against MaxInt before the multiplication happens, and generation stops as soon as a tick fails to increase or passes the axis maximum. A hostile workbook with a span of a few million years produces a short array and returns

Two ways a HotXLS chart date axis can place month ticks after a January 31, 2026 anchor: advancing each tick from the previous clamped value stays pinned on the 28th, while BuildTicks computes AddUnits(MinValue, xcduMonths, Step times i) from the axis minimum so the ticks land on February 28, March 31 and April 30
Clamping throws information away, so TXLSChartDateAxisTransform never steps from a previous tick and recomputes every position from the axis minimum, clamping month ends to the real calendar

The transform is public, so you can run the arithmetic yourself before any chart exists. This console snippet builds a year of month ticks for the series above and prints the labels the chart will use

uses
  SysUtils, lxChart;

var
  Ticks: TXLSChartDateValues;
  MinSerial, MaxSerial: Double;
  I, Count: Integer;
begin
  // 1900 date system (Dates1904 = False), month-end anchored series
  MinSerial := TXLSChartDateAxisTransform.DateTimeToSerial(
    EncodeDate(2026, 1, 31), False);
  MaxSerial := TXLSChartDateAxisTransform.DateTimeToSerial(
    EncodeDate(2026, 12, 31), False);

  // One-month step, aim for 12 ticks, never more than 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;

There is exactly one approximation in BuildTicks, and it sits where an approximation is harmless. When the requested step is zero, the method estimates a step by treating a month as 30 days and a year as 365, then rounds the raw step up to a 1, 2 or 5 multiple of a power of ten. That estimate decides only how many units lie between ticks. Positions still come from IncMonth and IncYear. A rough month length can make the axis a little denser or sparser, but every tick still lands on a date that one of those two functions produced

Workbook serials stay the axis coordinate

A HotXLS date axis keeps workbook serials as its coordinate space because Delphi's TDateTime has no slot for serial 60. In the 1900 date system that serial is February 29, 1900, a day that never existed and that Excel keeps for compatibility with older spreadsheet programs. TXLSChartDateAxisTransform.TrySerialToDateTime handles it the only way a real calendar can: serials below 60 shift by one day and serial 60 folds onto February 28. For labelling a single point that is fine. For geometry it is wrong, since serials 59 and 60 become the same TDateTime, and February 28 and March 1 end up one unit apart where Excel draws them two apart

Three serials show both sides of the problem. FormatValue special-cases the phantom slot and prints exactly what Excel prints, while a round trip through TDateTime loses it

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;

Hardly anyone charts data from early 1900, so the practical lesson is about your own code. If you compute axis bounds or annotation positions, do it in serials and convert with DateTimeToSerial at the edge, the way the first snippet does. Mix a serial-based axis with TDateTime arithmetic in the caller, and an annotation dated before March 1, 1900 lands one day away from its point. The second epoch matters more often: in a 1904 workbook serial 0 is January 1, 1904, and the transform applies the fixed 1462-day offset in both directions when the Dates1904 argument is true. Both epochs, and how cell number formats interact with them, are covered in our article on Excel date serials, the 1904 system and number formats

Why a HotXLS date axis keeps workbook serials instead of converting to TDateTime: serial 60 is the phantom February 29, 1900, TrySerialToDateTime shifts serials below 60 and folds 60 onto February 28 so the days either side end up one unit closer than Excel draws them, while FormatValue labels serial 60 as 1900-02-29
The fold is fine for labelling a single point but fatal for geometry, because everything plotted before March 1900 shifts against everything after it; a 1904 workbook applies the fixed 1462-day offset instead

How does HotXLS choose days, months or years automatically?

TXLSChartDateAxisTransform.DetectUnit picks the finest calendar unit that the gaps in the data actually need. It drops NaN and infinite values, sorts a copy of the rest once, and skips duplicate dates. If any adjacent gap is shorter than one calendar month, the unit is days; otherwise, if any gap is shorter than a calendar year, months; otherwise years. The month test is IncMonth(Previous, 1) > Current, and that choice matters for exactly the data in this article. A 30-day threshold would call the gap from January 31 to February 28 "less than a month" and switch a monthly series to days. With fewer than two distinct valid dates there is no gap to measure, and the method returns days

ResolveUnits merges the detected unit with whatever the chart declares. An explicit base unit wins. A missing major or minor unit inherits the detected unit when that is coarser than the base, and otherwise falls back to the base. The last rule is the one people miss: if the effective major or minor unit is finer than the base, the base drops to match. The base unit decides how points are grouped before any tick exists, because Normalize snaps a value to its day, the first of its month or January 1. A monthly base under a daily major unit would fold a whole month of points into one slot before the tick code ever saw them

How HotXLS picks date axis units automatically: DetectUnit drops NaN and infinite values, sorts once and measures calendar gaps with IncMonth rather than a 30-day threshold, so a January 31, February 28, March 31 series is detected as monthly, and ResolveUnits honours an explicit base while lowering the base when the major unit is finer
Normalize snaps each value to its day, the first of its month or January 1 before any ticks exist, because a monthly base under a daily major unit would collapse a month of points into one slot

How many automatic major ticks appear depends on more than the date range. The plot length, the measured width of the axis font, the label number format and the projected width of rotated labels all cap the count, so a narrow chart with long mmmm yyyy labels gets fewer ticks than a wide one with mm/yy. Labels, tick marks and gridlines are all drawn from one tick array, which keeps HTML, SVG and PDF output in agreement, and a minor tick that falls on a major tick is drawn once

What does the dateAx element store, and what does omission mean?

In ChartML every calendar setting on a date axis is optional, and leaving one out says something different from writing its default. The CT_DateAx type in ECMA-376 Part 1, §21.2 (DrawingML Charts) lets baseTimeUnit, majorTimeUnit, minorTimeUnit and auto each appear or not. An omitted baseTimeUnit tells Excel to decide when it renders; an explicit days pins it. TXLSXChartAxis keeps each value beside a presence flag: BaseTimeUnit with BaseTimeUnitSet, MajorTimeUnit with MajorTimeUnitSet, MinorTimeUnit with MinorTimeUnitSet, and AutoDateAxis with AutoDateAxisSet. Assigning a value sets its flag, setting the flag to False returns the element to omission, and the XLSX writer emits an element only when its flag is on. auto is easy to misread: it controls automatic category-versus-date detection and has nothing to do with automatic tick units

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;                  // writes c:dateAx
    Axis.BaseTimeUnit := xlsxChartTimeDays;     // also sets BaseTimeUnitSet
    Axis.MajorTimeUnit := xlsxChartTimeMonths;
    Axis.MajorUnit := 1;                        // a value > 0 marks majorUnit as set
    Axis.NumberFormat := 'mmm yyyy';
    Axis.NumberFormatSourceLinked := False;
    Axis.TextStyle.Rotation := -45;             // degrees
    // AutoDateAxis left untouched: no c:auto element is written

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

The base unit here is days on purpose. Month-end dates fall on different day numbers, and a monthly base would snap every point to the first of its month, which moves the March 31 balance to March 1. Pin the base to days whenever the exact day carries meaning, and let the major unit do the monthly spacing

Labels and orientation follow the same presence rules. The axis numFmt element defaults sourceLinked to true, so TXLSXChartAxis keeps the format code, the source-linked value and NumberFormatSet apart, and an empty code is never mistaken for a missing element. ChartML stores text rotation in 1/60000 of a degree with positive values running clockwise; TextStyle.Rotation takes plain degrees, and a label set to -45 leans the same way in SVG and PDF output. A reversed (maxMin) axis mirrors major and minor ticks, gridlines, data points, trendlines and error bars together. Reading works the other way around: open a workbook written elsewhere and the flags tell you what its author actually specified

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[] is 1-based, Charts[] is 0-based
    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;   // save it again and the omitted elements stay omitted
  end;
end;

Calendar units in ODS and legacy XLS files

The calendar model reaches as far as each file format can express it. ODF 1.2 describes axis spacing with chart:interval-major, a plain number with no notion of days, months or years, so an Excel monthly axis saved as ODS cannot carry its unit. HotXLS leaves the unit out of the ODS file rather than invent a non-standard attribute that other ODF applications would ignore anyway. If the monthly spacing matters to the people opening the file, ship XLSX

BIFF8 workbooks keep date axis settings in the AxcExt record, nine fixed 16-bit words. Its automatic flags mask fields without removing them, so a record marked "automatic minimum" still carries a minimum value. HotXLS keeps the masked minimum, maximum, interval, unit and crossing values on read and writes them back unchanged, keeps unknown unit codes intact in the XLS object model, and maps only the valid date units 0, 1 and 2 (days, months, years) when a chart is converted to XLSX

A date axis rarely travels alone. When an Excel workbook pairs a date-axis line with a column series on a secondary axis, our article on preserved ChartML and combination charts explains how HotXLS replays the original chart XML when nothing changed and merges typed edits into it when something did, and our overview of charts, images and drawings covers the anchoring and series calls used above. The HotXLS Delphi Component page lists the full chart feature set for Delphi and C++Builder, including XLS and XLSX chart reading, writing and rendering without Excel installed