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 до умножения, а генерация останавливается, как только тик перестаёт расти или проходит максимум оси
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 автоматически выбирает дни, месяцы или годы?
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, рисуется лишь однажды
Что хранит элемент 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