Техническая статья

Ось дат в диаграммах HotXLS: тики по календарю в Delphi

HotXLS рисует оси дат диаграмм Excel календарной арифметикой, а не фиксированным числом дней. Класс TXLSChartDateAxisTransform в юните lxChart держит каждую координату серийным номером книги, вычисляет каждый месячный или годовой тик напрямую от минимума оси через IncMonth и IncYear и прижимает концы месяцев к реальному календарю, так что серия, заякоренная на 31 января, получает тики на 28 февраля, 31 марта и 30 апреля, а не сползает. Тот же трансформ сам выбирает единицы день, месяц или год и разделяется рендерерами диаграмм HTML, SVG и постраничного PDF

Баг, который это предотвращает, знаком каждому, кто хоть раз строил график месячного закрытия. Рендерер, считающий месяц 30 днями, уезжает на пять дней вперёд к концу первого года. Рендерер поумнее, использующий календарные месяцы, но шагающий каждым тиком от предыдущего, отказывается тише: 31 января превращается в 28 февраля, следующий шаг попадает на 28 марта, и все тики после этого навсегда прилипают к 28-му. График выглядит правдоподобно, подписи неверные, и никто не замечает, пока контролёр не спросит, почему мартовский баланс отрисован за три дня до конца квартала

Почему месячные тики уезжают, когда их накапливают?

Месячные тики уезжают, потому что клэмпинг выбрасывает информацию. Как только 31 января прижат к 28 февраля, факт, что серия хотела 31-е, потерян, и любой шаг от прижатого значения наследует потерю. TXLSChartDateAxisTransform.BuildTicks никогда не шагает от предыдущего тика. Тик i всегда вычисляется как AddUnits(MinValue, UnitKind, Step * i, ...), то есть min + unit × index, отмеренные от минимума оси. AddUnits для дневных единиц прибавляет счётчик прямо к серийному номеру, а для месячных и годовых конвертирует номер в настоящую дату, зовёт IncMonth или IncYear и конвертирует обратно. Цикл к тому же ограничен со всех сторон: массив результата зажат 4096 элементами, что бы ни просил вызывающий, Step * i проверяется на MaxInt до умножения, а генерация останавливается, как только тик перестаёт расти или проходит максимум оси

Два способа, которыми ось дат диаграммы HotXLS может расставить месячные тики после якоря 31 января 2026: шаг каждым тиком от предыдущего прижатого значения навсегда прилипает к 28-му, а BuildTicks вычисляет AddUnits(MinValue, xcduMonths, Step times i) от минимума оси, так что тики ложатся на 28 февраля, 31 марта и 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, затем округляет сырой шаг до кратного 1, 2 или 5 степени десятки. Эта прикидка решает только, сколько единиц лежит между тиками. Сами позиции тиков по-прежнему приходят из IncMonth и IncYear, так что приближение может изменить плотность тиков, но никогда не сдвигает тик с календаря

Зачем держать серийные номера книги вместо конвертации в TDateTime?

Ось дат в HotXLS держит серийные номера книги как координатное пространство, потому что у Delphi-TDateTime нет слота для номера 60 — фантомного 29 февраля 1900, которое система дат 1900 унаследовала как компат-квирк. TrySerialToDateTime сдвигает номера ниже 60 на один день и складывает номер 60 на 28 февраля — для подписи одной точки это нормально, но для геометрии смертельно: сконвертируйте сначала каждую точку в TDateTime, и два дня по обе стороны фантомного дня окажутся на одну единицу ближе друг к другу, чем рисует их Excel, так что всё отрисованное до марта 1900 съезжает относительно всего, что после. TXLSChartDateAxisTransform.FormatValue обрабатывает тот же слот отдельным случаем и подписывает его 1900-02-29 ровно как Excel. В книге 1904 нулевой номер — это 1 января 1904, и трансформ применяет фиксированное смещение в 1462 дня в обе стороны; полная история обеих эпох — в нашей статье про серийные номера дат Excel, систему 1904 и числовые форматы

Почему ось дат HotXLS держит серийные номера книги вместо конвертации в TDateTime: номер 60 — фантомное 29 февраля 1900, TrySerialToDateTime сдвигает номера ниже 60 и складывает 60 на 28 февраля, так что дни по обе стороны оказываются на единицу ближе, чем рисует Excel, а FormatValue подписывает номер 60 как 1900-02-29
Складывание нормально для подписи одной точки, но смертельно для геометрии, потому что всё отрисованное до марта 1900 съезжает против всего, что после; книга 1904 вместо этого применяет фиксированное смещение в 1462 дня

Как HotXLS автоматически выбирает дни, месяцы или годы?

TXLSChartDateAxisTransform.DetectUnit выбирает самый мелкий календарный юнит, который реально требуют промежутки в данных. Метод сначала выбрасывает NaN и бесконечности, один раз сортирует остальное за O(n log n) и пропускает дубликаты дат. Если хоть один смежный промежуток короче календарного месяца, юнит — дни; иначе, если хоть один промежуток короче календарного года, юнит — месяцы; иначе годы. Проверка — IncMonth(Previous, 1) > Current, а не порог в 30 дней, так что серия концов месяцев 31 января, 28 февраля, 31 марта корректно опознаётся как месячные данные. Когда различных валидных дат меньше двух, промежутка измерять не о чем, и метод откатывается к дням

Затем ResolveUnits сливает опознанный юнит с тем, что объявила диаграмма. Явно заданная базовая единица уважается, а отсутствующая major или minor наследует опознанную, когда та грубее базовой. Последний шаг — тот, который все проглядывают: если фактическая major или minor мельче базовой единицы, база опускается, чтобы совпасть. Базовая единица — то, к чему нормализуются точки данных до появления каких-либо тиков: Normalize прищёлкивает значение к его дню, первому числу месяца или 1 января, так что месячная база под дневной major схлопнула бы месяц точек в один слот ещё до того, как тиковый код их увидел. Плотность тиков тоже ограничена — длиной области построения, измеренной шириной шрифта оси, числовым форматом подписи и проекцией повёрнутых подписей, — и каждый бэкенд рисует подписи, засечки и линии сетки из одного общего ограниченного массива тиков, причём minor-тик, совпадающий с major, рисуется лишь однажды

Как HotXLS автоматически выбирает единицы оси дат: DetectUnit выбрасывает NaN и бесконечности, один раз сортирует и меряет календарные промежутки через IncMonth, а не порогом в 30 дней, так что серия 31 января, 28 февраля, 31 марта опознаётся как месячная, а ResolveUnits уважает явную базу и опускает её, когда major мельче
Normalize прищёлкивает каждое значение к его дню, первому числу месяца или 1 января до появления каких-либо тиков, потому что месячная база под дневной major схлопнула бы месяц точек в один слот

Что хранит элемент dateAx и что значит его отсутствие?

В ChartML каждая календарная настройка оси дат опциональна, и опустить её — не то же самое, что записать её значение по умолчанию. Тип CT_DateAx в ECMA-376 Part 1, §21.2 (DrawingML Charts) позволяет 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 зеркалит major- и minor-тики, линии сетки, точки данных, линии тренда и планки погрешностей вместе — и никогда не только подписи. Когда вы открываете книгу, написанную где-то ещё, флаги говорят, что автор реально указал

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 — просто число без понятий дней, месяцев или лет, так что месячная ось Excel, сохранённая в ODS, не может нести свою единицу, и HotXLS не выдумывает нестандартный атрибут, чтобы сымитировать её. На легаси-стороне BIFF8 запись AxcExt держит девять фиксированных 16-битных слов, и её автоматические флаги лишь маскируют поля, а не убирают их; HotXLS сохраняет замаскированные минимум, максимум, интервал, юнит и точку пересечения при чтении и обратной записи, держит неизвестные коды единиц нетронутыми в Classic-модели и мапит в XLSX только валидные единицы дат 0, 1 и 2 (дни, месяцы, годы)

Оси дат — один слой модели диаграмм, которая ещё должна выживать в файлах, ею не созданных. Когда книга Excel совмещает линию с осью дат и столбцовую серию на вторичной оси, наша статья про сохранённый ChartML и комбинированные диаграммы показывает, как HotXLS переигрывает исходный XML диаграммы байт в байт, когда ничего не изменилось, и вливает типизированные правки, когда изменилось, а наш обзор диаграмм, изображений и drawings покрывает API якорения и серий, использованные выше. Всё это поставляется в компоненте HotXLS Delphi для Delphi и C++Builder, который читает, пишет и рендерит диаграммы XLS и XLSX без Excel automation