Technischer Artikel

HotXLS-Chart-Datumsachse: kalendergenaue Ticks in Delphi

HotXLS rendert Datumsachsen in Excel-Charts mit Kalenderarithmetik, nicht mit fixen Tageszählungen. Die Klasse TXLSChartDateAxisTransform in der Unit lxChart hält jede Koordinate als Workbook-Serial, berechnet jeden Monats- oder Jahrestick direkt vom Achsenminimum aus über IncMonth und IncYear und klemmt Monatsenden an den realen Kalender, sodass eine am 31. Januar verankerte Serie Ticks am 28. Februar, 31. März und 30. April bekommt, statt wegzurutschen. Dasselbe Transform wählt Tages-, Monats- oder Jahreseinheiten automatisch und wird von den HTML-, SVG- und seitenbasierten PDF-Chart-Renderern geteilt

Der Bug, den das verhindert, ist jedem vertraut, der schon einmal ein Monatsende gechartet hat. Ein Renderer, der einen Monat als 30 Tage behandelt, driftet bis Ende des ersten Jahres fünf Tage zu früh. Ein Renderer, der es schlauer macht und Kalendermonate benutzt, aber jeden Tick vom vorherigen weiterschreitet, versagt leiser: Aus dem 31. Januar wird der 28. Februar, der nächste Schritt landet auf dem 28. März, und jeder Tick danach bleibt am 28. festgenagelt. Das Chart wirkt plausibel, die Labels sind falsch, und niemand merkt es, bis ein Controller fragt, warum die März-Bilanz drei Tage vor dem Quartalsende eingezeichnet ist

Warum driften Monatsticks, wenn sie akkumuliert werden?

Monatsticks driften, weil Klemmen Information wegwirft. Sobald der 31. Januar auf den 28. Februar geklemmt wurde, ist die Tatsache verloren, dass die Serie den 31. wollte, und jeder Schritt vom geklemmten Wert erbt den Verlust. TXLSChartDateAxisTransform.BuildTicks schreitet nie von einem vorherigen Tick weiter. Tick i wird immer als AddUnits(MinValue, UnitKind, Step * i, ...) berechnet, also min + Einheit × Index, gemessen vom Achsenminimum. AddUnits addiert die Anzahl bei Tageseinheiten direkt auf den Serial und konvertiert bei Monats- und Jahreseinheiten den Serial in ein echtes Datum, ruft IncMonth oder IncYear auf und rechnet zurück. Die Schleife ist außerdem auf jeder Seite begrenzt: Das Ergebnis-Array ist auf 4096 Einträge gedeckelt, was auch immer der Aufrufer verlangt, Step * i wird vor der Multiplikation gegen MaxInt geprüft, und die Erzeugung stoppt, sobald ein Tick nicht mehr steigt oder das Achsenmaximum passiert

Zwei Arten, wie eine HotXLS-Chart-Datumsachse Monatsticks nach einem Anker am 31. Januar 2026 platzieren kann: Jeder Tick, der vom vorherigen geklemmten Wert weiterschreitet, bleibt am 28. hängen, während BuildTicks AddUnits(MinValue, xcduMonths, Step mal i) vom Achsenminimum berechnet, sodass die Ticks auf dem 28. Februar, 31. März und 30. April landen
Klemmen wirft Information weg, daher schreitet TXLSChartDateAxisTransform nie von einem vorherigen Tick weiter und berechnet jede Position neu vom Achsenminimum, mit Monatsenden am realen Kalender geklemmt
uses
  SysUtils, lxChart;

var
  Ticks: TXLSChartDateValues;
  MinSerial, MaxSerial: Double;
  I, Count: Integer;
begin
  // 1900-Datumssystem (Dates1904 = False), am Monatsende verankerte Serie
  MinSerial := TXLSChartDateAxisTransform.DateTimeToSerial(
    EncodeDate(2026, 1, 31), False);
  MaxSerial := TXLSChartDateAxisTransform.DateTimeToSerial(
    EncodeDate(2026, 12, 31), False);

  // Ein-Monats-Schritt, Ziel 12 Ticks, nie mehr als 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;

Eine ehrliche Näherung steckt in BuildTicks. Ist der angeforderte Schritt null, schätzt die Methode eine Schrittweite, indem sie einen Monat als 30 Tage und ein Jahr als 365 behandelt, und rundet dann den rohen Schritt auf ein 1-, 2- oder 5-Faches einer Zehnerpotenz. Diese Schätzung entscheidet nur, wie viele Einheiten zwischen Ticks liegen. Die Tick-Positionen selbst kommen weiterhin von IncMonth und IncYear, die Näherung kann also die Tick-Dichte ändern, aber nie einen Tick vom Kalender wegrücken

Warum Workbook-Serials behalten, statt zu TDateTime zu konvertieren?

Eine Datumsachse in HotXLS behält Workbook-Serials als ihren Koordinatenraum, weil Delphis TDateTime keinen Platz für Serial 60 hat – den Phantom-29. Februar 1900, den das 1900er-Datumssystem als Kompatibilitätsquirk geerbt hat. TrySerialToDateTime verschiebt Serials unter 60 um einen Tag und faltet Serial 60 auf den 28. Februar, was für das Labeln eines einzelnen Punkts passt, für Geometrie aber fatal ist: Konvertiert man jeden Punkt zuerst nach TDateTime, liegen die beiden Tage neben dem Phantomtag am Ende eine Einheit näher beieinander, als Excel sie zeichnet, sodass alles, was vor März 1900 geplottet ist, relativ zu allem danach verschoben wird. TXLSChartDateAxisTransform.FormatValue behandelt denselben Slot als Sonderfall und labelt ihn 1900-02-29, genau wie Excel. In einer 1904er-Arbeitsmappe ist Serial 0 der 1. Januar 1904, und das Transform wendet den festen 1462-Tage-Offset in beide Richtungen an; die ganze Geschichte beider Epochen erzählt unser Artikel zu Excel-Datumsserials, dem 1904er-System und Zahlenformaten

Warum eine HotXLS-Datumsachse Workbook-Serials behält, statt zu TDateTime zu konvertieren: Serial 60 ist der Phantom-29. Februar 1900, TrySerialToDateTime verschiebt Serials unter 60 und faltet 60 auf den 28. Februar, sodass die Tage daneben am Ende eine Einheit näher liegen, als Excel sie zeichnet, während FormatValue Serial 60 als 1900-02-29 labelt
Das Falten passt fürs Labeln eines einzelnen Punkts, für Geometrie ist es fatal, denn alles vor März 1900 Geplottete verschiebt sich gegen alles danach; eine 1904er-Arbeitsmappe wendet stattdessen den festen 1462-Tage-Offset an

Wie wählt HotXLS Tage, Monate oder Jahre automatisch?

TXLSChartDateAxisTransform.DetectUnit wählt die feinste Kalendereinheit, die die Lücken in den Daten tatsächlich brauchen. Die Methode wirft zuerst NaN- und unendliche Werte weg, sortiert den Rest einmal in O(n log n) und überspringt doppelte Daten. Ist eine benachbarte Lücke kürzer als ein Kalendermonat, ist die Einheit Tage; andernfalls, ist eine Lücke kürzer als ein Kalenderjahr, ist die Einheit Monate; andernfalls Jahre. Der Test lautet IncMonth(Previous, 1) > Current, nicht eine 30-Tage-Schwelle, sodass eine Monatsserie über den 31. Januar, 28. Februar, 31. März korrekt als monatliche Daten erkannt wird. Mit weniger als zwei verschiedenen gültigen Daten gibt es keine Lücke zu messen, und die Methode fällt auf Tage zurück

ResolveUnits verschmilzt dann die erkannte Einheit mit dem, was das Chart deklariert. Eine explizite Basiseinheit wird respektiert, und eine fehlende Major- oder Minor-Einheit erbt die erkannte Einheit, wenn diese gröber als die Basis ist. Der letzte Schritt ist der, den Leute übersehen: Ist die effektive Major- oder Minor-Einheit feiner als die Basiseinheit, wird die Basis abgesenkt. Die Basiseinheit ist das, worauf Datenpunkte normalisiert werden, bevor es überhaupt Ticks gibt, wobei Normalize einen Wert auf seinen Tag, den Ersten seines Monats oder den 1. Januar schnappt – eine monatliche Basis unter einer täglichen Major-Einheit würde also einen Monat voller Punkte in einen Slot kollabieren, bevor der Tick-Code sie je zu Gesicht bekommt. Die Tick-Dichte ist ebenfalls begrenzt, durch die Plot-Länge, die gemessene Breite der Achsenschrift, das Zahlenformat der Labels und die Projektion rotierter Labels, und jedes Backend zeichnet Labels, Tick-Marks und Gitterlinien aus einem gemeinsamen, begrenzten Tick-Array, wobei ein Minor-Tick, der auf einen Major-Tick fällt, nur einmal gezeichnet wird

Wie HotXLS Datumsachsen-Einheiten automatisch wählt: DetectUnit wirft NaN- und unendliche Werte weg, sortiert einmal und misst Kalenderlücken mit IncMonth statt einer 30-Tage-Schwelle, sodass eine Serie über den 31. Januar, 28. Februar, 31. März als monatlich erkannt wird, und ResolveUnits respektiert eine explizite Basis, senkt sie aber ab, wenn die Major-Einheit feiner ist
Normalize schnappt jeden Wert auf seinen Tag, den Ersten seines Monats oder den 1. Januar, bevor es überhaupt Ticks gibt, denn eine monatliche Basis unter einer täglichen Major-Einheit würde einen Monat voller Punkte in einen Slot kollabieren

Was speichert das dateAx-Element, und was bedeutet Auslassen?

In ChartML ist jede Kalendereinstellung einer Datumsachse optional, und ein Auslassen ist nicht dasselbe wie das Schreiben ihres Default-Werts. Der Typ CT_DateAx in ECMA-376 Part 1, §21.2 (DrawingML Charts) lässt baseTimeUnit, majorTimeUnit, minorTimeUnit und auto jeweils erscheinen oder nicht, und ein ausgelassenes baseTimeUnit sagt Excel, dass es selbst entscheiden soll – was von einem expliziten days verschieden ist. TXLSXChartAxis legt daher jeden Wert neben einem Presence-Flag ab: BaseTimeUnit mit BaseTimeUnitSet, MajorTimeUnit mit MajorTimeUnitSet, MinorTimeUnit mit MinorTimeUnitSet und AutoDateAxis mit AutoDateAxisSet. Eine Wertzuweisung setzt das Flag, das Löschen des Flags bringt das Element zurück ins Auslassen, und der XLSX-Writer gibt ein Element nur aus, wenn sein Flag gesetzt ist. Beachten Sie, dass auto die automatische Kategorie-oder-Datum-Erkennung meint, nicht automatische Tick-Einheiten

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;                  // schreibt c:dateAx
    Axis.BaseTimeUnit := xlsxChartTimeDays;     // setzt auch BaseTimeUnitSet
    Axis.MajorTimeUnit := xlsxChartTimeMonths;
    Axis.MajorUnit := 1;                        // > 0 markiert majorUnit als gesetzt
    Axis.NumberFormat := 'mmm yyyy';
    Axis.NumberFormatSourceLinked := False;
    Axis.TextStyle.Rotation := -45;             // Grad; gespeichert als 1/60000
    // AutoDateAxis unangetastet gelassen: kein c:auto-Element wird geschrieben

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

Labels und Orientierung folgen denselben presence-bewussten Regeln. Das Achsen-numFmt sitzt nach dem Titel und vor den Tick-Marks, sein sourceLinked-Attribut ist per Default true, und TXLSXChartAxis hält den Formatcode, den Source-Linked-Wert und NumberFormatSet getrennt, sodass ein leerer Code nie für ein fehlendes Element gehalten wird. Die Textrotation wird in 1/60000 Grad gespeichert, positive Werte bedeuten im Uhrzeigersinn; TextStyle.Rotation nimmt schlichte Grad, die SVG-Ausgabe benutzt den Winkel, wie er ist, und das seitenbasierte Backend, dessen positive Winkel gegen den Uhrzeigersinn laufen, dreht das Vorzeichen an einer gemeinsamen Grenzstelle um. Eine umgekehrte maxMin-Achse spiegelt Major- und Minor-Ticks, Gitterlinien, Datenpunkte, Trendlinien und Fehlerbalken zusammen – und niemals nur die Labels. Wenn Sie eine anderswo geschriebene Arbeitsmappe öffnen, sagen Ihnen die Flags, was der Autor tatsächlich angegeben hat

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;   // nochmals speichern, und die ausgelassenen Elemente bleiben ausgelassen
  end;
end;

Wo endet das Kalendermodell?

Das Kalendermodell endet bei Formaten, die keine Kalendereinheiten ausdrücken können, und HotXLS tut nicht so, als wäre es anders. ODF 1.2 bietet chart:interval-major, eine schlichte Zahl ohne Begriff von Tagen, Monaten oder Jahren, sodass eine als ODS gespeicherte Excel-Monatsachse ihre Einheit nicht tragen kann und HotXLS kein nicht standardisiertes Attribut erfindet, um eine vorzutäuschen. Auf der Legacy-BIFF8-Seite hält der AxcExt-Record neun feste 16-Bit-Wörter, und seine automatischen Flags maskieren Felder nur, statt sie zu entfernen; HotXLS bewahrt das maskierte Minimum, Maximum, Intervall, die Einheit und die Schnittpunktwerte beim Lesen und Zurückschreiben, hält unbekannte Unit-Codes im Classic-Modell unangetastet und mappt nur die gültigen Datumseinheiten 0, 1 und 2 (Tage, Monate, Jahre) nach XLSX

Datumsachsen sind eine Schicht eines Chart-Modells, das auch Dateien überleben muss, die es nicht selbst erzeugt hat. Kombiniert eine Excel-Arbeitsmappe eine Datumsachsen-Linie mit einer Column-Serie auf einer Sekundärachse, zeigt unser Artikel zu bewahrtem ChartML und Kombinations-Charts, wie HotXLS das originale Chart-XML Byte für Byte zurückspielt, wenn nichts geändert wurde, und typisierte Edits hineinmergt, wenn doch, und unser Überblick über Charts, Bilder und Drawings deckt die oben benutzten Anker- und Serien-APIs ab. Das alles liefert die HotXLS Delphi Component für Delphi und C++Builder mit, die XLS- und XLSX-Charts ohne Excel-Automatisierung liest, schreibt und rendert