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

Date ос на графиката: календарно точни ticks в HotXLS

HotXLS рендерира date осите на Excel графики с календарна аритметика, не с фиксиран брой дни. Класът TXLSChartDateAxisTransform в unit-а lxChart пази всяка координата като workbook serial, изчислява всеки месечен или годишен tick директно от минимума на оста чрез IncMonth и IncYear и захваща краищата на месеците към истинския календар, така че серия, закачена на 31 януари, получава ticks на 28 февруари, 31 март и 30 април вместо да плъзга. Същият transform избира автоматично единици ден, месец или година и е споделен от HTML, SVG и страничния PDF chart рендерер

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

Защо месечните ticks се отместват, когато се акумулират?

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

Два начина, по които date ос на HotXLS графика може да разположи месечни ticks след котва 31 януари 2026: напредване на всеки tick от предишната захапана стойност остава закачено на 28-ото, докато BuildTicks изчислява AddUnits(MinValue, xcduMonths, Step times i) от минимума на оста, така че ticks кацат на 28 февруари, 31 март и 30 април
Захващането изхвърля информация, затова TXLSChartDateAxisTransform никога не стъпва от предишен tick, а преизчислява всяка позиция от минимума на оста, захвайки краищата на месеците към истинския календар
uses
  SysUtils, lxChart;

var
  Ticks: TXLSChartDateValues;
  MinSerial, MaxSerial: Double;
  I, Count: Integer;
begin
  // 1900 date система (Dates1904 = False), серия закачена на month-end
  MinSerial := TXLSChartDateAxisTransform.DateTimeToSerial(
    EncodeDate(2026, 1, 31), False);
  MaxSerial := TXLSChartDateAxisTransform.DateTimeToSerial(
    EncodeDate(2026, 12, 31), False);

  // Стъпка един месец, цел 12 ticks, никога повече от 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 на степен на десетката. Тази оценка решава само колко единици се побират между ticks. Самите tick позиции продължават да идват от IncMonth и IncYear, така че апроксимацията може да смени плътността на ticks, но никога не измества tick извън календара

Защо да пазите workbook serials вместо да конвертирате към TDateTime?

Date оста в HotXLS пази workbook serials като свое координатно пространство, защото Delphi TDateTime няма слот за serial 60 — фантомните 29 февруари 1900, които 1900 date системата наследи като странност за съвместимост. TrySerialToDateTime измества serials под 60 с един ден и сгъва serial 60 върху 28 февруари, което е наред за етикетиране на една точка, но фатално за геометрията: конвертирайте първо всяка точка в TDateTime и двата дни от двете страни на фантомния ден се оказват с една единица по-близо един до друг, отколкото Excel ги чертае, така че всичко, начертано преди март 1900, се измества спрямо всичко след него. TXLSChartDateAxisTransform.FormatValue третира същия слот като специален случай и го етикетира 1900-02-29 точно както Excel. В 1904 работна книга serial 0 е 1 януари 1904, а transform-ът прилага фиксираното отместване от 1462 дни и в двете посоки; пълната история на двете епохи е в статията ни за Excel date serials, 1904 системата и number форматите

Защо date ос в HotXLS пази workbook serials вместо да конвертира към TDateTime: serial 60 е фантомните 29 февруари 1900, TrySerialToDateTime измества serials под 60 и сгъва 60 върху 28 февруари, така че дните от двете страни се оказват с една единица по-близо, отколкото Excel ги чертае, докато FormatValue етикетира serial 60 като 1900-02-29
Сгъването е наред за етикетиране на една точка, но фатално за геометрията, защото всичко, начертано преди март 1900, се измества спрямо всичко след него; 1904 работна книга вместо това прилага фиксираното отместване от 1462 дни

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

TXLSChartDateAxisTransform.DetectUnit избира най-фината календарна единица, която празнините в данните реално изискват. Методът първо изхвърля NaN и безкрайните стойности, сортира останалите веднъж в O(n log n) и прескача дублирани дати. Ако някой съседен интервал е по-къс от един календарен месец, единицата е дни; иначе, ако някой интервал е по-къс от една календарна година, единицата е месеци; иначе е години. Тестът е IncMonth(Previous, 1) > Current, не праг от 30 дни, така че month-end серията 31 януари, 28 февруари, 31 март се разпознава коректно като месечни данни. При по-малко от две различни валидни дати няма интервал за измерване и методът пада обратно на дни

ResolveUnits после слива разпознатата единица с това, което графиката декларира. Изрично зададена base единица се уважава, а липсваща major или minor единица наследява разпознатата, когато тя е по-груба от base. Последната стъпка е тази, която хората прескачат: ако ефективната major или minor единица е по-фина от base единицата, base се снишава, за да се съобрази. Base единицата е това, към което точките от данните се нормализират, преди да съществува и един tick, като Normalize прищраква стойност към нейния ден, първото число на нейния месец или 1 януари, така че месечна base под дневна major единица би сринала месец точки в един слот, преди tick кодът изобщо да ги е видял. Плътността на ticks също е ограничена — от дължината на чертежа, измерената ширина на шрифта на оста, number формата на етикетите и проекцията на завъртяните етикети — и всеки backend чертае етикети, tick марки и мрежови линии от един споделен, ограничен tick масив, като minor tick, съвпадащ с major tick, се чертае само веднъж

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

Какво съхранява елементът dateAx и какво значи пропускането?

В ChartML всяка календарна настройка на date ос е опционална и пропускането ѝ не е същото като записването на нейната стойност по подразбиране. Типът CT_DateAx в ECMA-376 Part 1, §21.2 (DrawingML Charts) позволява baseTimeUnit, majorTimeUnit, minorTimeUnit и auto всяко да се появи или не, а пропуснат baseTimeUnit казва на Excel да реши сам, което е различно от изричен days. TXLSXChartAxis затова пази всяка стойност до presence флаг: BaseTimeUnit с BaseTimeUnitSet, MajorTimeUnit с MajorTimeUnitSet, MinorTimeUnit с MinorTimeUnitSet и AutoDateAxis с AutoDateAxisSet. Присвояването на стойност вдига флага ѝ, изчистването на флага връща елемента към пропускане, а XLSX writer-ът излъчва елемент само когато флагът му е вдигнат. Обърнете внимание, че auto значи автоматично разпознаване category срещу date, не автоматични tick единици

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;

Етикетите и ориентацията следват същите presence-aware правила. numFmt на оста седи след заглавието и преди tick марките, атрибутът му sourceLinked по подразбиране е true, а TXLSXChartAxis пази форматния код, source-linked стойността и NumberFormatSet поотделно, така че празен код никога не се бърка за отсъстващ елемент. Завъртането на текста се съхранява в 1/60000 от градус с положителните стойности значещи по посоката на часовниковата стрелка; TextStyle.Rotation приема обикновени градуси, SVG изходът ползва ъгъла такъв, какъвто е, а страничният backend, чийто положителни ъгли вървят обратно на часовниковата стрелка, обръща знака на една споделена граница. Обърната maxMin ос огледално отразява major и minor ticks, мрежовите линии, точките данни, trendline-ите и error bar-овете заедно, и никога само етикетите. Когато отворите работна книга, писана другаде, флаговете ви казват какво авторът действително е задал

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 запазва маскираните минимум, максимум, интервал, единица и crossing стойности при четене и записване назад, пази непознатите unit кодове непокътнати в Classic модела и картира само валидните date единици 0, 1 и 2 (дни, месеци, години) към XLSX

Date осите са един слой от chart модел, който трябва да оцелее и срещу файлове, които не е създал сам. Когато Excel работна книга съчетава date-axis линия с колонна серия върху второстепенна ос, статията ни за запазен ChartML и комбинираните графики показва как HotXLS възпроизвежда оригиналния chart XML байт по байт, когато нищо не се е променило, и слива типизирани редакции в него, когато нещо се е променило, а прегледът ни на графиките, изображенията и drawings покрива anchoring и series API-ите, ползвани по-горе. Всичко това се доставя в HotXLS Delphi Component за Delphi и C++Builder, който чете, записва и рендерира XLS и XLSX графики без Excel automation