Articolo tecnico

Asse date dei grafici in HotXLS: tick fedeli al calendario

HotXLS renderizza gli assi date dei grafici Excel con aritmetica di calendario, non con conteggi fissi di giorni. La classe TXLSChartDateAxisTransform nell'unit lxChart tiene ogni coordinata come serial del workbook, calcola ogni tick mensile o annuale direttamente dal minimo dell'asse tramite IncMonth e IncYear, e aggancia le fine mese al calendario reale, così una serie ancorata al 31 gennaio riceve tick su 28 febbraio, 31 marzo e 30 aprile invece di scivolare. La stessa transform sceglie automaticamente le unità giorno, mese o anno ed è condivisa dai renderer dei grafici HTML, SVG e PDF paginato

Il bug che questo previene è familiare a chiunque abbia graficato una chiusura di fine mese. Un renderer che tratta un mese come 30 giorni deriva di cinque giorni in anticipo entro la fine del primo anno. Un renderer più furbo che usa mesi di calendario, ma avanza ogni tick dal precedente, fallisce più silenziosamente: il 31 gennaio diventa 28 febbraio, il passo successivo atterra sul 28 marzo, e ogni tick dopo quello resta inchiodato al 28. Il grafico sembra plausibile, le etichette sono sbagliate, e nessuno se ne accorge finché un controller non chiede perché il saldo di marzo è tracciato tre giorni prima della fine del trimestre

Perché i tick mensili derivano quando vengono accumulati?

I tick mensili derivano perché l'aggancio butta via informazione. Una volta che il 31 gennaio è stato agganciato al 28 febbraio, il fatto che la serie volesse il 31 è perduto, e ogni passo compiuto dal valore agganciato eredita la perdita. TXLSChartDateAxisTransform.BuildTicks non parte mai da un tick precedente. Il tick i è sempre calcolato come AddUnits(MinValue, UnitKind, Step * i, ...), cioè min + unità × indice misurato dal minimo dell'asse. AddUnits aggiunge il conteggio direttamente al serial per le unità giorno e, per le unità mese e anno, converte il serial in una data reale, chiama IncMonth o IncYear, e riconverte. Il loop è anche limitato su ogni fronte: l'array risultato è tappato a 4096 voci qualunque cosa chieda il chiamante, Step * i viene verificato contro MaxInt prima di essere moltiplicato, e la generazione si ferma appena un tick non riesce a crescere o supera il massimo dell'asse

Due modi in cui un asse date dei grafici HotXLS può piazzare tick mensili dopo un ancoraggio al 31 gennaio 2026: avanzare ogni tick dal precedente valore agganciato resta inchiodato al 28, mentre BuildTicks calcola AddUnits(MinValue, xcduMonths, Step times i) dal minimo dell'asse così i tick atterrano su 28 febbraio, 31 marzo e 30 aprile
L'aggancio butta via informazione, così TXLSChartDateAxisTransform non parte mai da un tick precedente e ricalcola ogni posizione dal minimo dell'asse, agganciando le fine mese al calendario reale
uses
  SysUtils, lxChart;

var
  Ticks: TXLSChartDateValues;
  MinSerial, MaxSerial: Double;
  I, Count: Integer;
begin
  // Sistema data 1900 (Dates1904 = False), serie ancorata a fine mese
  MinSerial := TXLSChartDateAxisTransform.DateTimeToSerial(
    EncodeDate(2026, 1, 31), False);
  MaxSerial := TXLSChartDateAxisTransform.DateTimeToSerial(
    EncodeDate(2026, 12, 31), False);

  // Passo di un mese, obiettivo 12 tick, mai più di 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;

C'è una sola approssimazione onesta in BuildTicks. Quando il passo richiesto è zero, il metodo stima una dimensione del passo trattando un mese come 30 giorni e un anno come 365, poi arrotonda il passo grezzo a un multiplo 1, 2 o 5 di una potenza di dieci. Quella stima decide solo quante unità stanno tra i tick. Le posizioni dei tick continuano comunque a venire da IncMonth e IncYear, quindi l'approssimazione può cambiare la densità dei tick ma non sposta mai un tick fuori dal calendario

Perché conservare i serial del workbook invece di convertire a TDateTime?

Un asse date in HotXLS conserva i serial del workbook come spazio di coordinate perché il TDateTime di Delphi non ha uno slot per il serial 60, il fantomatico 29 febbraio 1900 che il sistema data 1900 ha ereditato come stranezza di compatibilità. TrySerialToDateTime sposta di un giorno i serial sotto 60 e ripiega il serial 60 sul 28 febbraio, il che va bene per etichettare un singolo punto ma è fatale per la geometria: convertite prima ogni punto a TDateTime e i due giorni ai lati del giorno fantasma finiscono un'unità più vicini di quanto Excel li disegni, così tutto ciò che è tracciato prima di marzo 1900 si sposta rispetto a tutto ciò che viene dopo. TXLSChartDateAxisTransform.FormatValue tratta lo stesso slot come caso speciale e lo etichetta 1900-02-29 esattamente come fa Excel. In un workbook 1904, il serial 0 è il 1 gennaio 1904, e la transform applica l'offset fisso di 1462 giorni in entrambe le direzioni; la storia completa delle due epoche è nel nostro articolo sui serial data di Excel, il sistema 1904 e i formati numerici

Perché un asse date HotXLS conserva i serial del workbook invece di convertire a TDateTime: il serial 60 è il fantomatico 29 febbraio 1900, TrySerialToDateTime sposta i serial sotto 60 e ripiega il 60 sul 28 febbraio così i giorni ai lati finiscono un'unità più vicini di come li disegna Excel, mentre FormatValue etichetta il serial 60 come 1900-02-29
Il ripiegamento va bene per etichettare un singolo punto ma è fatale per la geometria, perché tutto ciò che è tracciato prima di marzo 1900 si sposta contro tutto ciò che viene dopo; un workbook 1904 applica invece l'offset fisso di 1462 giorni

Come sceglie HotXLS automaticamente giorni, mesi o anni?

TXLSChartDateAxisTransform.DetectUnit sceglie l'unità di calendario più fina che i gap nei dati richiedono davvero. Il metodo butta prima i valori NaN e infiniti, ordina il resto una volta sola in O(n log n), e salta le date duplicate. Se un gap adiacente è più corto di un mese di calendario, l'unità è giorni; altrimenti, se un gap è più corto di un anno di calendario, l'unità è mesi; altrimenti sono anni. Il test è IncMonth(Previous, 1) > Current, non una soglia di 30 giorni, così una serie di fine mese che corre 31 gennaio, 28 febbraio, 31 marzo viene rilevata correttamente come dati mensili. Con meno di due date valide distinte non c'è gap da misurare, e il metodo ricade sui giorni

ResolveUnits poi fonde l'unità rilevata con quella che il grafico dichiara. Un'unità base esplicita viene rispettata, e un'unità major o minor assente eredita l'unità rilevata quando questa è più grossolana della base. L'ultimo passo è quello che la gente si perde: se l'unità major o minor effettiva è più fina dell'unità base, la base viene abbassata per allinearsi. L'unità base è ciò a cui i punti dati vengono normalizzati prima che esista qualsiasi tick, con Normalize che aggancia un valore al suo giorno, al primo del suo mese o al 1 gennaio, così una base mensile sotto un'unità major giornaliera collasserebbe un mese di punti in uno slot prima ancora che il codice dei tick li vedesse. Anche la densità dei tick è limitata, dalla lunghezza del plot, dalla larghezza misurata del font dell'asse, dal formato numerico delle etichette e dalla proiezione delle etichette ruotate, e ogni backend disegna etichette, segni di tick e griglie da un unico array di tick condiviso e limitato, con un tick minor che coincide con uno major disegnato una volta sola

Come HotXLS sceglie automaticamente le unità dell'asse date: DetectUnit butta i valori NaN e infiniti, ordina una volta sola e misura i gap di calendario con IncMonth invece che con una soglia di 30 giorni, così una serie 31 gennaio, 28 febbraio, 31 marzo viene rilevata come mensile, e ResolveUnits rispetta una base esplicita abbassandola quando l'unità major è più fina
Normalize aggancia ogni valore al suo giorno, al primo del suo mese o al 1 gennaio prima che esista qualsiasi tick, perché una base mensile sotto un'unità major giornaliera collasserebbe un mese di punti in uno slot

Che cosa memorizza l'elemento dateAx, e cosa significa l'omissione?

In ChartML ogni impostazione di calendario su un asse date è opzionale, e ometterne una non è la stessa cosa che scriverne il valore predefinito. Il tipo CT_DateAx in ECMA-376 Part 1, §21.2 (DrawingML Charts) lascia che baseTimeUnit, majorTimeUnit, minorTimeUnit e auto compaiano o no ciascuno, e un baseTimeUnit omesso dice a Excel di decidere da sé, il che è diverso da un days esplicito. TXLSXChartAxis quindi memorizza ogni valore accanto a un flag di presenza: BaseTimeUnit con BaseTimeUnitSet, MajorTimeUnit con MajorTimeUnitSet, MinorTimeUnit con MinorTimeUnitSet, e AutoDateAxis con AutoDateAxisSet. Assegnare un valore imposta il suo flag, azzerare il flag riporta l'elemento all'omissione, e il writer XLSX emette un elemento solo quando il suo flag è impostato. Notate che auto significa rilevamento automatico categoria-contro-data, non unità dei tick automatiche

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;                  // scrive c:dateAx
    Axis.BaseTimeUnit := xlsxChartTimeDays;     // imposta anche BaseTimeUnitSet
    Axis.MajorTimeUnit := xlsxChartTimeMonths;
    Axis.MajorUnit := 1;                        // > 0 segna majorUnit come impostato
    Axis.NumberFormat := 'mmm yyyy';
    Axis.NumberFormatSourceLinked := False;
    Axis.TextStyle.Rotation := -45;             // gradi; memorizzato come 1/60000
    // AutoDateAxis lasciato intatto: nessun elemento c:auto viene scritto

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

Etichette e orientamento seguono le stesse regole consapevoli della presenza. Il numFmt dell'asse sta dopo il titolo e prima dei segni di tick, il suo attributo sourceLinked vale true per default, e TXLSXChartAxis tiene separati il codice di formato, il valore source-linked e NumberFormatSet, così un codice vuoto non viene mai scambiato per un elemento assente. La rotazione del testo è memorizzata in 1/60000 di grado con i valori positivi che significano senso orario; TextStyle.Rotation prende gradi semplici, l'output SVG usa l'angolo così com'è, e il backend paginato, i cui angoli positivi girano in senso antiorario, inverte il segno a un unico confine condiviso. Un asse maxMin rovesciato specchia insieme i tick major e minor, le griglie, i punti dati, le linee di tendenza e le barre di errore, e mai solo le etichette. Quando aprite un workbook scritto altrove, i flag vi dicono che cosa l'autore ha davvero specificato

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;   // salvate di nuovo e gli elementi omessi restano omessi
  end;
end;

Dove si ferma il modello di calendario?

Il modello di calendario si ferma ai formati che non possono esprimere unità di calendario, e HotXLS non finge il contrario. ODF 1.2 offre chart:interval-major, che è un numero nudo e crudo senza nozione di giorni, mesi o anni, quindi un asse mensile di Excel salvato come ODS non può trasportare la sua unità e HotXLS non inventa un attributo non standard per fingerne uno. Sul fronte legacy BIFF8, il record AxcExt contiene nove word fisse a 16 bit, e i suoi flag automatici mascherano i campi invece di rimuoverli; HotXLS preserva minimo, massimo, intervallo, unità e valori di attraversamento mascherati in lettura e in riscrittura, tiene intatti i codici unità sconosciuti nel modello Classic, e mappa su XLSX solo le unità data valide 0, 1 e 2 (giorni, mesi, anni)

Gli assi date sono uno strato di un modello di grafici che deve anche sopravvivere a file che non ha creato lui. Quando un workbook Excel combina una linea su asse date con una serie a colonne su un asse secondario, il nostro articolo su ChartML preservato e grafici combinati mostra come HotXLS riproduce l'XML originale del grafico byte per byte quando nulla è cambiato e vi fonde le modifiche tipizzate quando qualcosa è cambiato, e la nostra panoramica su grafici, immagini e drawing copre le API di ancoraggio e serie usate sopra. Il tutto viaggia nel componente HotXLS per Delphi per Delphi e C++Builder, che legge, scrive e renderizza grafici XLS e XLSX senza automazione di Excel