Artigo Técnico

Eixo de datas no HotXLS: ticks fiéis ao calendário no Delphi

O HotXLS renderiza eixos de datas de gráficos do Excel com aritmética de calendário, não com contagens fixas de dias. A classe TXLSChartDateAxisTransform na unit lxChart mantém toda coordenada como um serial da pasta de trabalho, calcula cada tick de mês ou de ano direto do mínimo do eixo por meio de IncMonth e IncYear, e faz clamp dos fins de mês no calendário real, então uma série ancorada em 31 de janeiro recebe ticks em 28 de fevereiro, 31 de março e 30 de abril em vez de escorregar. A mesma transform escolhe unidades de dia, mês ou ano automaticamente e é compartilhada pelos renderers de gráfico HTML, SVG e PDF paginado

O bug que isso previne é familiar para quem já graficou um fechamento de fim de mês. Um renderer que trata um mês como 30 dias adianta cinco dias até o fim do primeiro ano. Um renderer mais esperto que usa meses de calendário, mas avança cada tick a partir do anterior, falha mais silenciosamente: 31 de janeiro vira 28 de fevereiro, o próximo passo cai em 28 de março, e todo tick depois disso fica preso no dia 28. O gráfico parece plausível, os rótulos estão errados, e ninguém nota até o controller perguntar por que o saldo de março está plotado três dias antes do fim do trimestre

Por que os ticks de mês escorregam quando são acumulados?

Ticks de mês escorregam porque o clamp joga informação fora. Depois que 31 de janeiro foi clampeado para 28 de fevereiro, o fato de a série querer o dia 31 se perdeu, e qualquer passo dado a partir do valor clampeado herda a perda. O TXLSChartDateAxisTransform.BuildTicks nunca dá passo a partir de um tick anterior. O tick i é sempre calculado como AddUnits(MinValue, UnitKind, Step * i, ...), que é min + unidade × índice medido do mínimo do eixo. O AddUnits soma a contagem direto no serial para unidades de dia e, para unidades de mês e ano, converte o serial numa data real, chama IncMonth ou IncYear, e converte de volta. O loop também é limitado em toda direção: o array de resultado tem teto de 4096 entradas não importa o que o chamador peça, Step * i é conferido contra MaxInt antes de ser multiplicado, e a geração para assim que um tick deixa de aumentar ou passa o máximo do eixo

Duas maneiras de um eixo de datas de gráfico do HotXLS posicionar ticks de mês depois de uma âncora em 31 de janeiro de 2026: avançar cada tick a partir do valor clampeado anterior fica preso no dia 28, enquanto o BuildTicks calcula AddUnits(MinValue, xcduMonths, Step times i) do mínimo do eixo, então os ticks caem em 28 de fevereiro, 31 de março e 30 de abril
O clamp joga informação fora, então o TXLSChartDateAxisTransform nunca dá passo a partir de um tick anterior e recalcula toda posição do mínimo do eixo, clampeando fins de mês no calendário real
uses
  SysUtils, lxChart;

var
  Ticks: TXLSChartDateValues;
  MinSerial, MaxSerial: Double;
  I, Count: Integer;
begin
  // Sistema de datas 1900 (Dates1904 = False), série ancorada no fim de mês
  MinSerial := TXLSChartDateAxisTransform.DateTimeToSerial(
    EncodeDate(2026, 1, 31), False);
  MaxSerial := TXLSChartDateAxisTransform.DateTimeToSerial(
    EncodeDate(2026, 12, 31), False);

  // Passo de um mês, mirar 12 ticks, nunca mais que 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;

Há uma aproximação honesta no BuildTicks. Quando o passo pedido é zero, o método estima um tamanho de passo tratando um mês como 30 dias e um ano como 365, e então arredonda o passo cru para um múltiplo 1, 2 ou 5 de uma potência de dez. Essa estimativa só decide quantas unidades há entre ticks. As posições dos ticks em si ainda vêm de IncMonth e IncYear, então a aproximação pode mudar a densidade de ticks, mas nunca move um tick para fora do calendário

Por que manter seriais da pasta em vez de converter para TDateTime?

Um eixo de datas no HotXLS mantém seriais da pasta de trabalho como espaço de coordenadas porque o TDateTime do Delphi não tem slot para o serial 60, o fantasma 29 de fevereiro de 1900 que o sistema de datas 1900 herdou como uma peculiaridade de compatibilidade. O TrySerialToDateTime desloca seriais abaixo de 60 em um dia e dobra o serial 60 em 28 de fevereiro, o que serve para rotular um ponto isolado, mas é fatal para a geometria: converta todo ponto para TDateTime primeiro e os dois dias em volta do dia fantasma acabam uma unidade mais próximos do que o Excel os desenha, então tudo plotado antes de março de 1900 se desloca em relação a tudo depois. O TXLSChartDateAxisTransform.FormatValue trata o mesmo slot como caso especial e o rotula 1900-02-29 exatamente como o Excel faz. Numa pasta 1904, o serial 0 é 1º de janeiro de 1904, e a transform aplica o offset fixo de 1462 dias nas duas direções; a história completa das duas épocas está no nosso artigo sobre seriais de data do Excel, o sistema 1904 e formatos de número

Por que um eixo de datas do HotXLS mantém seriais da pasta em vez de converter para TDateTime: o serial 60 é o fantasma 29 de fevereiro de 1900, o TrySerialToDateTime desloca seriais abaixo de 60 e dobra o 60 em 28 de fevereiro, então os dias em volta acabam uma unidade mais próximos do que o Excel desenha, enquanto o FormatValue rotula o serial 60 como 1900-02-29
A dobra serve para rotular um ponto isolado, mas é fatal para a geometria, porque tudo plotado antes de março de 1900 se desloca contra tudo depois; uma pasta 1904 aplica o offset fixo de 1462 dias em vez disso

Como o HotXLS escolhe dias, meses ou anos automaticamente?

O TXLSChartDateAxisTransform.DetectUnit escolhe a menor unidade de calendário que os vãos nos dados realmente pedem. O método descarta valores NaN e infinitos primeiro, ordena o resto uma vez em O(n log n) e pula datas duplicadas. Se algum vão adjacente é mais curto que um mês de calendário, a unidade é dias; senão, se algum vão é mais curto que um ano de calendário, a unidade é meses; senão é anos. O teste é IncMonth(Previous, 1) > Current, não um limiar de 30 dias, então uma série de fim de mês rodando 31 de janeiro, 28 de fevereiro, 31 de março é corretamente detectada como dados mensais. Com menos de duas datas válidas distintas não há vão para medir, e o método cai para dias

O ResolveUnits então mescla a unidade detectada com o que o gráfico declara. Uma unidade base explícita é respeitada, e uma unidade major ou minor ausente herda a unidade detectada quando essa é mais grossa que a base. O último passo é o que as pessoas deixam passar: se a unidade major ou minor efetiva é mais fina que a unidade base, a base é abaixada para casar. A unidade base é para onde os pontos de dados são normalizados antes de qualquer tick existir, com o Normalize ajeitando um valor para o seu dia, o primeiro do seu mês ou 1º de janeiro, então uma base mensal sob uma unidade major diária colapsaria um mês de pontos num único slot antes de o código de ticks sequer vê-los. A densidade de ticks também tem limite, pela extensão do plot, pela largura medida da fonte do eixo, pelo formato de número dos rótulos e pela projeção de rótulos rotacionados, e todo backend desenha rótulos, marcas de tick e gridlines de um único array de ticks compartilhado e limitado, com um tick minor que coincide com um major desenhado só uma vez

Como o HotXLS escolhe unidades de eixo de datas automaticamente: o DetectUnit descarta valores NaN e infinitos, ordena uma vez e mede vãos de calendário com IncMonth em vez de um limiar de 30 dias, então uma série 31 de janeiro, 28 de fevereiro, 31 de março é detectada como mensal, e o ResolveUnits respeita uma base explícita enquanto abaixa a base quando a unidade major é mais fina
O Normalize ajeita cada valor para o seu dia, o primeiro do seu mês ou 1º de janeiro antes de qualquer tick existir, porque uma base mensal sob uma unidade major diária colapsaria um mês de pontos num único slot

O que o elemento dateAx guarda, e o que a omissão significa?

No ChartML toda configuração de calendário num eixo de datas é opcional, e omitir uma não é o mesmo que escrever o valor padrão dela. O tipo CT_DateAx no ECMA-376 Part 1, §21.2 (DrawingML Charts) deixa baseTimeUnit, majorTimeUnit, minorTimeUnit e auto aparecerem ou não, e um baseTimeUnit omitido diz ao Excel para decidir sozinho, o que é diferente de um days explícito. O TXLSXChartAxis portanto guarda cada valor ao lado de uma flag de presença: BaseTimeUnit com BaseTimeUnitSet, MajorTimeUnit com MajorTimeUnitSet, MinorTimeUnit com MinorTimeUnitSet, e AutoDateAxis com AutoDateAxisSet. Atribuir um valor liga a flag dele, limpar a flag devolve o elemento à omissão, e o writer de XLSX emite um elemento só quando a flag dele está ligada. Note que auto significa detecção automática de categoria-versus-data, não unidades de tick automáticas

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;                  // grava c:dateAx
    Axis.BaseTimeUnit := xlsxChartTimeDays;     // também seta BaseTimeUnitSet
    Axis.MajorTimeUnit := xlsxChartTimeMonths;
    Axis.MajorUnit := 1;                        // > 0 marca majorUnit como setado
    Axis.NumberFormat := 'mmm yyyy';
    Axis.NumberFormatSourceLinked := False;
    Axis.TextStyle.Rotation := -45;             // graus; armazenado como 1/60000
    // AutoDateAxis intocado: nenhum elemento c:auto é gravado

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

Rótulos e orientação seguem as mesmas regras de presença. O numFmt do eixo fica depois do título e antes das marcas de tick, o atributo sourceLinked dele tem true como padrão, e o TXLSXChartAxis guarda o código de formato, o valor source-linked e o NumberFormatSet separadamente, então um código vazio nunca é confundido com um elemento ausente. A rotação de texto é armazenada em 1/60000 de grau, com valores positivos significando sentido horário; o TextStyle.Rotation recebe graus comuns, a saída SVG usa o ângulo como está, e o backend paginado, cujos ângulos positivos correm no sentido anti-horário, inverte o sinal numa única fronteira compartilhada. Um eixo maxMin invertido espelha ticks major e minor, gridlines, pontos de dados, linhas de tendência e barras de erro juntos, e nunca só os rótulos. Quando você abre uma pasta de trabalho escrita por outro programa, as flags dizem o que o autor realmente especificou

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;   // salve de novo e os elementos omitidos continuam omitidos
  end;
end;

Onde o modelo de calendário para?

O modelo de calendário para nos formatos que não conseguem expressar unidades de calendário, e o HotXLS não finge o contrário. O ODF 1.2 oferece chart:interval-major, que é um número simples sem noção de dias, meses ou anos, então um eixo mensal do Excel salvo como ODS não pode carregar a unidade dele, e o HotXLS não inventa um atributo não padrão para fingir uma. Do lado legado do BIFF8, o registro AxcExt guarda nove words fixos de 16 bits, e as flags automáticas dele apenas mascaram campos em vez de removê-los; o HotXLS preserva os valores mascarados de mínimo, máximo, intervalo, unidade e cruzamento na leitura e na regravação, mantém códigos de unidade desconhecidos intactos no modelo Classic, e mapeia só as unidades de data válidas 0, 1 e 2 (dias, meses, anos) para XLSX

Eixos de datas são uma camada de um modelo de gráfico que também precisa sobreviver a arquivos que ele não criou. Quando uma pasta de trabalho do Excel combina uma linha de eixo de datas com uma série de colunas num eixo secundário, o nosso artigo sobre ChartML preservado e gráficos de combinação mostra como o HotXLS repassa o XML original do gráfico byte a byte quando nada mudou e mescla edições tipadas nele quando algo mudou, e a nossa visão geral de gráficos, imagens e drawings cobre as APIs de ancoragem e séries usadas acima. Tudo isso vem no HotXLS Delphi Component para Delphi e C++Builder, que lê, grava e renderiza gráficos XLS e XLSX sem automação do Excel