Artykuł techniczny

Oś daty wykresu HotXLS: znaczniki zgodne z kalendarzem

HotXLS renderuje osie dat wykresów Excela arytmetyką kalendarzową, a nie stałą liczbą dni. Klasa TXLSChartDateAxisTransform w jednostce lxChart trzyma każdą współrzędną jako numer seryjny skoroszytu, wylicza każdy znacznik miesiąca albo roku wprost od minimum osi przez IncMonth i IncYear oraz dociąga końce miesięcy do prawdziwego kalendarza, więc seria zakotwiczona 31 stycznia dostaje znaczniki 28 lutego, 31 marca i 30 kwietnia zamiast się zjeżdżać. Ten sam transform sam wybiera jednostki dni, miesięcy albo lat i jest współdzielony przez renderery wykresów HTML, SVG i stronicowanego PDF

Błąd, któremu to zapobiega, zna każdy, kto kiedyś wykreślał zamknięcie miesiąca. Renderer traktujący miesiąc jako 30 dni zjeżdża pięć dni za wcześnie już pod koniec pierwszego roku. Renderer sprytniejszy, używający miesięcy kalendarzowych, ale wyliczający każdy kolejny znacznik z poprzedniego, zawodzi ciszej: 31 stycznia zamienia się w 28 lutego, następny krok ląduje na 28 marca i każdy znacznik po nim zostaje przyklejony do 28. Wykres wygląda wiarygodnie, etykiety są złe, a nikt nie zauważa, dopóki kontroler nie zapyta, dlaczego marcowe saldo jest narysowane trzy dni przed końcem kwartału

Dlaczego znaczniki miesięcy zjeżdżają, gdy się je akumuluje?

Znaczniki miesięcy zjeżdżają, bo dociąganie wyrzuca informację do kosza. Skoro 31 stycznia zostało dociągnięte do 28 lutego, to fakt, że seria chciała 31., przepada, a każdy krok z dociągniętej wartości dziedziczy tę stratę. TXLSChartDateAxisTransform.BuildTicks nigdy nie kroczy od poprzedniego znacznika. Znacznik i jest zawsze liczony jako AddUnits(MinValue, UnitKind, Step * i, ...), czyli min + jednostka × indeks mierzone od minimum osi. AddUnits dla jednostek dniowych dodaje liczbę wprost do numeru seryjnego, a dla jednostek miesięcznych i rocznych konwertuje numer na prawdziwą datę, woła IncMonth albo IncYear i konwertuje z powrotem. Pętla jest też ograniczona z każdej strony: tablica wynikowa ma kap 4096 wpisów, czego by wywołujący nie zażądał, Step * i jest sprawdzane względem MaxInt przed pomnożeniem, a generacja kończy się, gdy tylko znacznik przestaje rosnąć albo przekracza maksimum osi

Dwa sposoby, na jakie oś daty wykresu HotXLS może rozmieścić znaczniki miesięcy po zakotwiczeniu 31 stycznia 2026: przesuwanie każdego znacznika z poprzedniej dociągniętej wartości zostaje przyklejone do 28, podczas gdy BuildTicks liczy AddUnits(MinValue, xcduMonths, Step razy i) od minimum osi, więc znaczniki lądują 28 lutego, 31 marca i 30 kwietnia
Dociąganie wyrzuca informację do kosza, więc TXLSChartDateAxisTransform nigdy nie kroczy od poprzedniego znacznika i przelicza każdą pozycję od minimum osi, dociągając końce miesięcy do prawdziwego kalendarza
uses
  SysUtils, lxChart;

var
  Ticks: TXLSChartDateValues;
  MinSerial, MaxSerial: Double;
  I, Count: Integer;
begin
  // system dat 1900 (Dates1904 = False), seria zakotwiczona na końcu miesiąca
  MinSerial := TXLSChartDateAxisTransform.DateTimeToSerial(
    EncodeDate(2026, 1, 31), False);
  MaxSerial := TXLSChartDateAxisTransform.DateTimeToSerial(
    EncodeDate(2026, 12, 31), False);

  // krok jednego miesiąca, cel: 12 znaczników, nigdy więcej niż 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;

W BuildTicks kryje się jedno uczciwe przybliżenie. Gdy żądany krok to zero, metoda szacuje rozmiar kroku, traktując miesiąc jako 30 dni i rok jako 365, po czym zaokrągla surowy krok do wielokrotności 1, 2 albo 5 potęgi dziesiątki. Ten szacunek decyduje tylko o tym, ile jednostek mieści się między znacznikami. Pozycje znaczników i tak pochodzą z IncMonth i IncYear, więc przybliżenie może zmienić gęstość znaczników, ale nigdy nie zsuwa znacznika z kalendarza

Dlaczego trzymać numery seryjne skoroszytu zamiast konwertować na TDateTime?

Oś daty w HotXLS trzyma numery seryjne skoroszytu jako swoją przestrzeń współrzędnych, bo TDateTime w Delphi nie ma slotu na numer 60 — widmo 29 lutego 1900, które system dat 1900 odziedziczył jako kwiatek dla zgodności wstecz. TrySerialToDateTime przesuwa numery poniżej 60 o jeden dzień i skleja numer 60 z 28 lutego, co wystarcza do etykietowania pojedynczego punktu, ale jest zabójcze dla geometrii: skonwertuj najpierw każdy punkt na TDateTime, a dwa dni po obu stronach widmowego dnia znajdą się o jedną jednostkę bliżej siebie, niż rysuje je Excel, więc wszystko narysowane przed marcem 1900 przesunie się względem wszystkiego, co jest po nim. TXLSChartDateAxisTransform.FormatValue ma specjalny przypadek dla tego samego slotu i etykietuje go 1900-02-29 dokładnie tak jak Excel. W skoroszycie 1904 numer 0 to 1 stycznia 1904, a transform stosuje stały offset 1462 dni w obie strony; pełną historię obu epok opisuje nasz artykuł o numerach seryjnych dat Excela, systemie 1904 i formatach liczb

Dlaczego oś daty HotXLS trzyma numery seryjne skoroszytu zamiast konwertować na TDateTime: numer 60 to widmo 29 lutego 1900, TrySerialToDateTime przesuwa numery poniżej 60 i skleja 60 z 28 lutego, więc dni po obu stronach lądują o jedną jednostkę bliżej, niż rysuje je Excel, a FormatValue etykietuje numer 60 jako 1900-02-29
Sklejenie wystarcza do etykietowania pojedynczego punktu, ale jest zabójcze dla geometrii, bo wszystko narysowane przed marcem 1900 przesuwa się względem wszystkiego po nim; skoroszyt 1904 stosuje zamiast tego stały offset 1462 dni

Jak HotXLS sam wybiera dni, miesiące albo lata?

TXLSChartDateAxisTransform.DetectUnit wybiera najdrobniejszą jednostkę kalendarzową, jakiej przerwy w danych realnie potrzebują. Metoda najpierw wyrzuca wartości NaN i nieskończone, raz sortuje resztę w O(n log n) i pomija zduplikowane daty. Jeśli któraś sąsiednia przerwa jest krótsza niż jeden miesiąc kalendarzowy, jednostką są dni; w przeciwnym razie, jeśli któraś przerwa jest krótsza niż rok kalendarzowy — miesiące; w przeciwnym razie lata. Testem jest IncMonth(Previous, 1) > Current, a nie próg 30 dni, więc seria końców miesiąca biegnąca 31 stycznia, 28 lutego, 31 marca zostanie poprawnie rozpoznana jako dane miesięczne. Przy mniej niż dwóch różnych poprawnych datach nie ma przerwy do zmierzenia i metoda wraca do dni

ResolveUnits scala potem wykrytą jednostkę z tym, co deklaruje wykres. Jawna jednostka bazowa jest respektowana, a brakująca jednostka główna albo pomocnicza dziedziczy wykrytą, gdy ta ma grubsze ziarno niż baza. Ostatni krok to ten, który ludzie pomijają: jeśli efektywna jednostka główna albo pomocnicza jest drobniejsza niż jednostka bazowa, baza zostaje obniżona, by się dopasować. Jednostka bazowa to to, do czego punkty danych są normalizowane, zanim powstaną jakiekolwiek znaczniki — Normalize przyciąga wartość do jej dnia, pierwszego dnia jej miesiąca albo 1 stycznia, więc miesięczna baza pod dzienną jednostką główną zwinęłaby miesiąc punktów w jedno gniazdo, zanim kod znaczników w ogóle by je zobaczył. Gęstość znaczników też jest ograniczona — przez długość wykresu, zmierzoną szerokość czcionki osi, format liczb etykiet i projekcję obróconych etykiet — a każdy backend rysuje etykiety, kreski znaczników i linie siatki z jednej wspólnej, ograniczonej tablicy znaczników, przy czym znacznik pomocniczy pokrywający się z głównym jest rysowany tylko raz

Jak HotXLS sam wybiera jednostki osi dat: DetectUnit wyrzuca wartości NaN i nieskończone, sortuje raz i mierzy przerwy kalendarzowe przez IncMonth zamiast progu 30 dni, więc seria 31 stycznia, 28 lutego, 31 marca zostaje rozpoznana jako miesięczna, a ResolveUnits respektuje jawną bazę, obniżając ją, gdy jednostka główna jest drobniejsza
Normalize przyciąga każdą wartość do jej dnia, pierwszego dnia jej miesiąca albo 1 stycznia, zanim powstaną jakiekolwiek znaczniki, bo miesięczna baza pod dzienną jednostką główną zwinęłaby miesiąc punktów w jedno gniazdo

Co przechowuje element dateAx i co znaczy jego pominięcie?

W ChartML każde ustawienie kalendarzowe osi dat jest opcjonalne, a pominięcie czegoś nie równa się zapisaniu jego wartości domyślnej. Typ CT_DateAx w ECMA-376 Part 1, §21.2 (DrawingML Charts) pozwala, by baseTimeUnit, majorTimeUnit, minorTimeUnit i auto każdy wystąpił albo nie, a pominięty baseTimeUnit każe Excelowi zdecydować samemu, co jest czymś innym niż jawne days. TXLSXChartAxis przechowuje więc każdą wartość obok flagi obecności: BaseTimeUnit z BaseTimeUnitSet, MajorTimeUnit z MajorTimeUnitSet, MinorTimeUnit z MinorTimeUnitSet i AutoDateAxis z AutoDateAxisSet. Przypisanie wartości ustawia jej flagę, skasowanie flagi przywraca element do stanu pominięcia, a zapisujący XLSX emituje element tylko wtedy, gdy jego flaga jest ustawiona. Zauważ, że auto znaczy automatyczne rozstrzyganie kategoria kontra data, a nie automatyczne jednostki znaczników

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;                  // pisze c:dateAx
    Axis.BaseTimeUnit := xlsxChartTimeDays;     // przy okazji ustawia BaseTimeUnitSet
    Axis.MajorTimeUnit := xlsxChartTimeMonths;
    Axis.MajorUnit := 1;                        // > 0 oznacza majorUnit jako ustawiony
    Axis.NumberFormat := 'mmm yyyy';
    Axis.NumberFormatSourceLinked := False;
    Axis.TextStyle.Rotation := -45;             // stopnie; zapisywane jako 1/60000
    // AutoDateAxis nietknięte: element c:auto nie jest zapisywany

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

Etykiety i orientacja podlegają tym samym regułom świadomym obecności. numFmt osi siedzi za tytułem, a przed kreskami znaczników, jego atrybut sourceLinked ma domyślnie true, a TXLSXChartAxis trzyma kod formatu, wartość source-linked i NumberFormatSet osobno, więc pustego kodu nigdy nie pomylisz z nieobecnym elementem. Obrót tekstu jest zapisywany w 1/60000 stopnia, przy czym wartości dodatnie znaczą obrót zgodnie z ruchem wskazówek zegara; TextStyle.Rotation przyjmuje zwykłe stopnie, wyjście SVG używa kąta takim, jaki jest, a backend stronicowany, którego dodatnie kąty biegną przeciwnie do wskazówek zegara, odwraca znak na jednej wspólnej granicy. Odwrócona oś maxMin odbija lustrzanie znaczniki główne i pomocnicze, linie siatki, punkty danych, linie trendu i paski błędów razem — i nigdy samych etykiet. Gdy otwierasz skoroszyt napisany gdzie indziej, flagi mówią ci, co autor faktycznie określił

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;   // zapisz jeszcze raz, a pominięte elementy pozostaną pominięte
  end;
end;

Gdzie kończy się model kalendarzowy?

Model kalendarzowy kończy się na formatach, które nie umieją wyrazić jednostek kalendarzowych, i HotXLS nie udaje, że jest inaczej. ODF 1.2 oferuje chart:interval-major, czyli zwykłą liczbę bez pojęcia dni, miesięcy czy lat, więc miesięczna oś Excela zapisana jako ODS nie może nieść swojej jednostki, a HotXLS nie wymyśla niestandardowego atrybutu, żeby ją udawać. Po stronie legacy BIFF8 rekord AxcExt trzyma dziewięć stałych słów 16-bitowych, a jego flagi automatic tylko maskują pola, zamiast je usuwać; HotXLS zachowuje zamaskowane wartości minimum, maksimum, interwału, jednostki i przecięcia przy odczycie i zapisie zwrotnym, trzyma nieznane kody jednostek nietknięte w modelu Classic i mapuje na XLSX wyłącznie poprawne jednostki dat 0, 1 i 2 (dni, miesiące, lata)

Osie dat to jedna warstwa modelu wykresów, który musi też przeżywać pliki, których sam nie stworzył. Gdy skoroszyt Excela łączy linię na osi dat z serią kolumnową na osi pomocniczej, nasz artykuł o zachowanym ChartML i wykresach kombinowanych pokazuje, jak HotXLS odtwarza oryginalny XML wykresu bajt w bajt, gdy nic się nie zmieniło, i wciska w niego typowane edycje, gdy coś się zmieniło, a nasze omówienie wykresów, obrazów i rysunków obejmuje API zakotwiczania i serii użyte wyżej. Całość jest w HotXLS Delphi Component dla Delphi i C++Buildera, który czyta, zapisuje i renderuje wykresy XLS i XLSX bez automatyzacji Excela