Технічна стаття

Вісь дат діаграм HotXLS: поділки, точні за календарем

HotXLS малює осі дат діаграм Excel календарною арифметикою, а не фіксованою кількістю днів. Клас TXLSChartDateAxisTransform в модулі lxChart тримає кожну координату як серійне число книги, обчислює кожен поділок місяця чи року просто з мінімуму осі через IncMonth і IncYear і притискає кінці місяців до справжнього календаря, тож серія з якорем на 31 січня отримує поділки 28 лютого, 31 березня і 30 квітня замість того, щоб сповзати. Той самий transform автоматично обирає одиниці днів, місяців чи років, і ним діляться рендерери діаграм 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 серійне 0 — це 1 січня 1904, і transform застосовує фіксований зсув 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 потім зливає розпізнану одиницю з тим, що оголошує діаграма. Явна базова одиниця шанується, а відсутня основна чи проміжна одиниця успадковує розпізнану, коли та грубіша за базову. Останній крок — той, який люди пропускають: якщо ефективна основна чи проміжна одиниця тонша за базову, базова знижується, щоб зійтися. Базова одиниця — це те, до чого нормалізуються точки даних, поки жодних поділок ще не існує: Normalize притискає значення до його дня, першого числа його місяця чи 1 січня, тож місячна база під денною основною одиницею згорнула б місяць точок в один слот, ще до того, як код поділок їх побачив. Щільність поділок також обмежена — довжиною області побудови, виміряною шириною шрифту осі, числовим форматом підписів і проєкцією повернутих підписів, — і кожен бекенд малює підписи, риски поділок і сітку з одного спільного обмеженого масиву поділок, причому проміжна поділка, що збігається з основною, малюється лише раз

Як HotXLS автоматично обирає одиниці осі дат: DetectUnit відкидає NaN і нескінченні значення, сортує один раз і міряє календарні проміжки через IncMonth замість порогу 30 днів, тож серія 31 січня, 28 лютого, 31 березня розпізнається як місячна, а ResolveUnits шанує явну базу і знижує базу, коли основна одиниця тонша
Normalize притискає кожне значення до його дня, першого числа місяця чи 1 січня, поки поділок ще не існує, бо місячна база під денною основною одиницею згорнула б місяць точок в один слот

Що зберігає елемент 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 дзеркалить основні й проміжні поділки, сітку, точки даних, лінії тренду і планки похибок разом — і ніколи не самі лише підписи. Коли ви відкриваєте книгу, написану десь іще, прапорці кажуть, що автор справді вказав

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

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