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

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

Диаграмма баланса на конец месяца, стартующая 31 января 2026, должна поставить апрельский тик на 30 апреля. Шагайте каждым тиком от предыдущего календарными месяцами — и метки выйдут 2026-01-31, 2026-02-28, 2026-03-28, 2026-04-28, с каждым поздним тиком, прилипшим к 28-му. Шагайте вместо этого плоскими 30 днями — и февральский тик упадёт на 2 марта, а декабрьский на 27 декабря. Правило, выживающее в обеих ловушках, формулируется просто: вычисляйте каждый тик от минимума оси и прижимайте концы месяцев к реальному календарю

На диаграмме закрытия квартала первая ошибка — опасная, потому что картинка всё ещё выглядит правильно. Линия растёт там, где должна, метки расставлены ровно, и мартовский баланс сидит за три дня до конца квартала. Найдёт её контролёр, сравнивающий диаграмму с главной книгой, а к тому моменту диаграмма обычно уже вклеена в пакет для правления

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

Месячные тики уезжают, потому что прижимание выбрасывает информацию. Стоит 31 января прижаться к 28 февраля — и факт, что серия хотела 31-е, потерян, а любой шаг от прижатого значения наследует потерю. В HotXLS TXLSChartDateAxisTransform.BuildTicks в модуле lxChart никогда не шагает от предыдущего тика: тик i — всегда AddUnits(MinValue, UnitKind, Step * i, ...), поэтому март — два месяца после 31 января и выходит 31 марта. 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. Грубая длина месяца может сделать ось чуть плотнее или реже, но каждый тик всё равно падает на дату, которую породила одна из этих двух функций

Серийные номера книги остаются координатой оси

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

Три номера показывают обе стороны проблемы. 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  метка оси: 1900-02-28  TDateTime: 1900-02-28
  // 60  метка оси: 1900-02-29  TDateTime: 1900-02-28
  // 61  метка оси: 1900-03-01  TDateTime: 1900-03-01
end;

Мало кто строит диаграммы по данным начала 1900 года, поэтому практический урок — о вашем собственном коде. Если вы вычисляете границы оси или позиции аннотаций, делайте это в номерах и конвертируйте через DateTimeToSerial на краю, как делает первый фрагмент. Смешайте ось на номерах с арифметикой TDateTime в вызывающем — и аннотация, датированная до 1 марта 1900, приземлится в одном дне от своей точки. Вторая эпоха всплывает чаще: в книге 1904 номер 0 — это 1 января 1904, и преобразование применяет фиксированное смещение в 1462 дня в обе стороны, когда аргумент Dates1904 истинен. Обе эпохи и то, как с ними взаимодействуют числовые форматы ячеек, разобраны в нашей статье о серийных номерах дат Excel, системе 1904 и числовых форматах

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

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

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

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

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

Сколько автоматических major-тиков появится, зависит от большего, чем диапазон дат. Длина поля построения, измеренная ширина шрифта оси, числовой формат меток и проецируемая ширина повёрнутых меток — всё ограничивает счёт, поэтому узкая диаграмма с длинными метками mmmm yyyy получит меньше тиков, чем широкая с mm/yy. Метки, деления и линии сетки рисуются из одного массива тиков, что держит вывод HTML, SVG и PDF в согласии, а minor-тик, попавший на 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. Присваивание значения ставит его флаг, установка флага в 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;

Базовая единица здесь — дни намеренно. Даты концов месяцев падают на разные числа, и месячная база прищёлкнула бы каждую точку к первому числу её месяца, что передвинуло бы баланс 31 марта на 1 марта. Закрепляйте базу на днях всегда, когда точный день несёт смысл, а месячный шаг доверяйте major-единице

Метки и ориентация следуют тем же правилам присутствия. Элемент numFmt оси по умолчанию держит sourceLinked истинным, поэтому TXLSXChartAxis хранит код формата, значение source-linked и NumberFormatSet порознь, и пустой код никогда не спутается с отсутствующим элементом. ChartML хранит поворот текста в 1/60000 градуса, положительные значения идут по часовой; TextStyle.Rotation берёт обычные градусы, и метка, поставленная в -45, наклоняется одинаково в выводе SVG и PDF. Развёрнутая (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[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 и legacy XLS

Календарная модель дотягивается ровно до того, что каждый файловый формат способен выразить. ODF 1.2 описывает шаг оси через chart:interval-major — простое число без понятия дней, месяцев или лет, поэтому месячная ось Excel, сохранённая как ODS, не может нести свою единицу. HotXLS оставляет единицу за пределами файла ODS вместо изобретения нестандартного атрибута, который другие ODF-приложения всё равно бы игнорировали. Если месячный шаг важен тем, кто открывает файл, поставляйте XLSX

Книги BIFF8 хранят настройки оси дат в записи AxcExt — девять фиксированных 16-битных слов. Её автоматические флаги маскируют поля, не удаляя их, поэтому запись с пометкой «автоматический минимум» всё равно несёт значение минимума. HotXLS при чтении сохраняет замаскированные минимум, максимум, интервал, единицу и точку пересечения и пишет их обратно без изменений, держит неизвестные коды единиц нетронутыми в объектной модели XLS и отображает лишь валидные единицы дат 0, 1 и 2 (дни, месяцы, годы), когда диаграмма конвертируется в XLSX

Ось дат редко путешествует одна. Когда книга Excel ставит пару «линия на оси дат + колонка на вторичной оси», наша статья о сохранённом ChartML и комбинированных диаграммах объясняет, как HotXLS переигрывает исходный XML диаграммы, когда ничего не изменилось, и вливает типизированные правки в него, когда что-то изменилось, а наш обзор диаграмм, изображений и рисунков покрывает вызовы якорения и серий, использованные выше. Страница HotXLS Delphi Component перечисляет полный набор возможностей диаграмм для Delphi и C++Builder, включая чтение, запись и отрисовку диаграмм XLS и XLSX без установленного Excel