Technický článek

Date axis grafu v HotXLS: kalendářně přesné ticky v Delphi

HotXLS vykresluje date axis Excel grafů kalendářní aritmetikou, ne pevnými počty dnů. Třída TXLSChartDateAxisTransform v unitu lxChart drží každou souřadnici jako sešitový serial, počítá každý měsíční nebo roční tick přímo z minima osy přes IncMonth a IncYear a upíná konce měsíců na reálný kalendář, takže série ukotvená na 31. ledna dostane ticky na 28. února, 31. března a 30. dubna místo toho, aby klouzala. Tentýž transform si den, měsíc nebo rok jako jednotku vybere automaticky a sdílejí ho renderery grafů do HTML, SVG i stránkovaného PDF

Bug, kterému tohle brání, zná každý, kdo někdy grafem kreslil month-end close. Renderer, který bere měsíc jako 30 dnů, sklouzne do konce prvního roku o pět dní dopředu. Renderer chytřejší, který používá kalendářní měsíce, ale odvozuje každý tick z předchozího, selhává potichuji: 31. ledna se stane 28. únor, další krok dopadne na 28. března a každý další tick už zůstane přilepený na 28. Graf vypadá věrohodně, popisky jsou špatně a nikdo si toho nevšimne, dokud kontrolor nepozeptá, proč je březní zůstatek vykreslený tři dny před koncem kvartálu

Proč měsíční ticky driftují, když se akumulují?

Měsíční ticky driftují, protože upnutí informaci vyhodí. Jakmile se 31. ledna upne na 28. února, fakt, že série chtěla 31., je pryč a každý krok odvozený z upnuté hodnoty ztrátu dědí. TXLSChartDateAxisTransform.BuildTicks nikdy neodstupuje z předchozího ticku. Tick i se vždy počítá jako AddUnits(MinValue, UnitKind, Step * i, ...), tedy min + jednotka × index měřeno od minima osy. AddUnits přičítá počet rovnou k serialu u denních jednotek a u měsíčních a ročních jednotek serial převede na reálné datum, zavolá IncMonth nebo IncYear a převede zpět. Smyčka je taky ohraničená ze všech stran: výsledné pole má strop 4096 položek, ať si volající přeje cokoli, Step * i se zkontroluje proti MaxInt před násobením a generace skončí, jakmile tick přestane růst nebo přeletí maximum osy

Dva způsoby, jak může date axis grafu HotXLS umístit měsíční ticky za ukotvením 31. ledna 2026: odvozování každého ticku z předchozí upnuté hodnoty zůstává přilepené na 28., zatímco BuildTicks počítá AddUnits(MinValue, xcduMonths, Step krát i) z minima osy, takže ticky dopadnou na 28. února, 31. března a 30. dubna
Upnutí informaci vyhodí, takže TXLSChartDateAxisTransform nikdy neodstupuje z předchozího ticku a přepočítává každou pozici z minima osy, konce měsíců přitom upíná na reálný kalendář
uses
  SysUtils, lxChart;

var
  Ticks: TXLSChartDateValues;
  MinSerial, MaxSerial: Double;
  I, Count: Integer;
begin
  // Datový systém 1900 (Dates1904 = False), série ukotvená na konec měsíce
  MinSerial := TXLSChartDateAxisTransform.DateTimeToSerial(
    EncodeDate(2026, 1, 31), False);
  MaxSerial := TXLSChartDateAxisTransform.DateTimeToSerial(
    EncodeDate(2026, 12, 31), False);

  // Krok jednoho měsíce, cíl 12 ticků, nikdy víc než 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;

V BuildTicks je jedna poctivá aproximace. Když je požadovaný krok nula, metoda odhadne velikost kroku s měsícem jako 30 dnů a rokem jako 365 a pak zaokrouhlí syrový krok na násobek 1, 2 nebo 5 mocniny deseti. Tenhle odhad rozhoduje jen o tom, kolik jednotek leží mezi ticky. Pozice ticků samotných dál přicházejí z IncMonth a IncYear, takže aproximace může změnit hustotu ticků, ale nikdy nesune tick z kalendáře

Proč držet sešitové serialy místo konverze na TDateTime?

Date axis v HotXLS drží jako souřadnicový prostor sešitové serialy, protože TDateTime v Delphi nemá slot pro serial 60, fiktivní 29. únor 1900, který datový systém 1900 zdědil jako kompatibilní kuriozitu. TrySerialToDateTime posune serialy pod 60 o jeden den a slepí serial 60 na 28. února, což na popisek jediného bodu stačí, ale pro geometrii je fatální: převeďte nejdřív všechny body na TDateTime a dva dny kolem fiktivního dne skončí o jednotku blíž u sebe, než je Excel kreslí, takže všechno vykreslené před březnem 1900 se posune vůči tomu po něm. TXLSChartDateAxisTransform.FormatValue má pro tentýž slot speciální případ a nalepí mu 1900-02-29 přesně jako Excel. V sešitu 1904 je serial 0 roven 1. lednu 1904 a transform aplikuje pevný offset 1462 dní oběma směry; celý příběh obou epoch rozebírá náš článek o Excel date serialech, systému 1904 a number formátech

Proč date axis HotXLS drží sešitové serialy místo konverze na TDateTime: serial 60 je fiktivní 29. únor 1900, TrySerialToDateTime posune serialy pod 60 a slepí 60 na 28. února, takže dny kolem skončí o jednotku blíž u sebe, než je kreslí Excel, zatímco FormatValue nalepí serialu 60 popisek 1900-02-29
Slepení na popisek jediného bodu stačí, ale geometrii zabíjí, protože všechno vykreslené před březnem 1900 se posune vůči tomu po něm; sešit 1904 místo toho aplikuje pevný offset 1462 dní

Jak si HotXLS automaticky vybere dny, měsíce nebo roky?

TXLSChartDateAxisTransform.DetectUnit vybere nejjemnější kalendářní jednotku, kterou mezery v datech doopravdy potřebují. Metoda nejdřív zahodí NaN a nekonečné hodnoty, jednou setřídí zbytek v O(n log n) a přeskočí duplicitní data. Pokud je jakákoli sousední mezera kratší než jeden kalendářní měsíc, jednotkou jsou dny; jinak, pokud je jakákoli mezera kratší než jeden kalendářní rok, jednotkou jsou měsíce; jinak roky. Test je IncMonth(Previous, 1) > Current, ne práh 30 dnů, takže month-end série 31. ledna, 28. února, 31. března se správně detekuje jako měsíční data. S méně než dvěma různými platnými daty není co měřit a metoda spadne na dny

ResolveUnits pak sloučí detekovanou jednotku s tím, co deklaruje graf. Explicitní base jednotka se respektuje a chybějící major nebo minor jednotka dědí detekovanou, když je ta hrubší než base. Poslední krok je ten, který lidé přehlížejí: pokud je efektivní major nebo minor jednotka jemnější než base jednotka, base se sníží, aby seděla. Base jednotka je to, na co se normalizují datové body, než vůbec existují ticky, s Normalize přichytávajícím hodnotu na její den, první den jejího měsíce nebo 1. ledna, takže měsíční base pod denní major jednotkou by slepila měsíc bodů do jednoho slotu, dřív než je tickový kód vůbec uvidí. Hustota ticků má taky strop — řídí ji délka plotu, změřená šířka písma osy, number formát popisků a projekce otočených popisků — a každý backend kreslí popisky, tick značky a mřížku z jednoho sdíleného, ohraničeného pole ticků, přičemž minor tick splývající s major tickem se kreslí jen jednou

Jak HotXLS vybírá jednotky date axis automaticky: DetectUnit zahodí NaN a nekonečné hodnoty, jednou setřídí a měří kalendářní mezery přes IncMonth místo prahu 30 dnů, takže série 31. ledna, 28. února, 31. března se detekuje jako měsíční, a ResolveUnits respektuje explicitní base, přičemž base sníží, když je major jednotka jemnější
Normalize přichytí každou hodnotu na její den, první den jejího měsíce nebo 1. ledna, než vůbec existují ticky, protože měsíční base pod denní major jednotkou by slepila měsíc bodů do jednoho slotu

Co element dateAx ukládá a co znamená jeho vynechání?

V ChartML je každé kalendářní nastavení na date axis volitelné a vynechání není totéž jako zápis výchozí hodnoty. Typ CT_DateAx v ECMA-376 Part 1, §21.2 (DrawingML Charts) nechá baseTimeUnit, majorTimeUnit, minorTimeUnit i auto každý objevit se nebo ne, a vynechaný baseTimeUnit říká Excelu, ať se rozhodne sám, což je něco jiného než explicitní days. TXLSXChartAxis proto ukládá každou hodnotu vedle presence flagu: BaseTimeUnit s BaseTimeUnitSet, MajorTimeUnit s MajorTimeUnitSet, MinorTimeUnit s MinorTimeUnitSet a AutoDateAxis s AutoDateAxisSet. Přiřazení hodnoty nastaví její flag, smazání flagu vrátí element do stavu vynechání a XLSX writer emituje element jen tehdy, když je jeho flag nastavený. Pozor, auto znamená automatickou detekci category versus date, ne automatické jednotky 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;                  // zapíše c:dateAx
    Axis.BaseTimeUnit := xlsxChartTimeDays;     // zároveň nastaví BaseTimeUnitSet
    Axis.MajorTimeUnit := xlsxChartTimeMonths;
    Axis.MajorUnit := 1;                        // > 0 označí majorUnit jako nastavený
    Axis.NumberFormat := 'mmm yyyy';
    Axis.NumberFormatSourceLinked := False;
    Axis.TextStyle.Rotation := -45;             // stupně; ukládá se jako 1/60000
    // AutoDateAxis zůstává nedotčené: element c:auto se nezapisuje

    Book.SaveAs('month-end-balance.xlsx');
  finally
    Book.Free;
  end;
end;

Popisky a orientace následují stejná presence-aware pravidla. numFmt osy sedí za titulkem a před tick značkami, jeho atribut sourceLinked defaultně stojí na true a TXLSXChartAxis drží formátový kód, hodnotu source-linked a NumberFormatSet odděleně, takže prázdný kód se nikdy nezamění za chybějící element. Otočení textu se ukládá v 1/60000 stupně, přičemž kladné hodnoty znamenají po směru hodinových ručiček; TextStyle.Rotation bere obyčejné stupně, SVG výstup použije úhel tak, jak je, a stránkovaný backend, jehož kladné úhly běží proti směru hodinových ručiček, překlápí znaménko na jedné sdílené hranici. Obrácená osa maxMin zrcadlí major i minor ticky, mřížku, datové body, trend lines a error bary dohromady, a nikdy jen popisky. Když otevřete sešit napsaný jinde, flagy vám poví, co autor doopravdy zadal

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;   // uložte znovu a vynechané elementy zůstanou vynechané
  end;
end;

Kde model kalendáře končí?

Model kalendáře končí u formátů, které kalendářní jednotky vyjádřit neumí, a HotXLS to nepředstírá jinak. ODF 1.2 nabízí chart:interval-major, což je holé číslo bez pojmu dnů, měsíců nebo roků, takže měsíční osa Excelu uložená jako ODS nemůže svou jednotku nést a HotXLS si nevynalézá nestandardní atribut, aby jednotku předstíral. Na legacy straně BIFF8 drží záznam AxcExt devět pevných 16bitových slov a jeho automatické flagy pole jen maskují, ne odstraňují; HotXLS zachovává maskované minimum, maximum, interval, jednotku a průsečíky při čtení i zpětném zápisu, v Classic modelu nechává neznámé kódy jednotek nedotčené a do XLSX mapuje jen platné date jednotky 0, 1 a 2 (dny, měsíce, roky)

Date axis jsou jedna vrstva modelu grafu, který musí přežít i soubory, které sám nevytvořil. Když Excel sešit zkombinuje linku s date axis se sloupcovou sérií na sekundární ose, článek o zachovávaném ChartML a combination grafech ukazuje, jak HotXLS přehraje původní chart XML bajt po bajtu, když se nic nezměnilo, a smísí do něj typované editace, když se změnilo něco, a přehled grafů, obrázků a kreseb pokrývá API pro ukotvení a série použité výše. Všechno dohromady dodává HotXLS Delphi Component pro Delphi a C++Builder, která čte, zapisuje a vykresluje XLS a XLSX grafy bez Excel automation