Artículo técnico

Eje de fechas de HotXLS: ticks fieles al calendario

HotXLS renderiza los ejes de fechas de gráficos de Excel con aritmética de calendario, no con conteos fijos de días. La clase TXLSChartDateAxisTransform de la unidad lxChart mantiene cada coordenada como un serial del libro, calcula cada tick de mes o de año directo desde el mínimo del eje mediante IncMonth e IncYear, y ajusta los fines de mes al calendario real, así que una serie anclada al 31 de enero recibe ticks el 28 de febrero, el 31 de marzo y el 30 de abril en lugar de irse deslizando. La misma transformación elige unidades de día, mes o año automáticamente y la comparten los renderizadores de gráficos HTML, SVG y PDF paginado

El defecto que esto previene le resulta familiar a cualquiera que haya graficado un cierre de fin de mes. Un renderizador que trata el mes como 30 días se adelanta cinco días para el primer año. Uno más listo que usa meses de calendario pero avanza cada tick a partir del anterior falla con menos ruido: el 31 de enero se convierte en 28 de febrero, el siguiente paso cae en 28 de marzo, y cada tick posterior queda clavado en el 28. El gráfico se ve plausible, las etiquetas están mal, y nadie lo nota hasta que un controller pregunta por qué el balance de marzo está graficado tres días antes del cierre de trimestre

¿Por qué se desvían los ticks de mes cuando se acumulan?

Los ticks de mes se desvían porque el clamp tira información. Una vez que el 31 de enero fue ajustado a 28 de febrero, el dato de que la serie quería el 31 se perdió, y cualquier paso tomado desde el valor ajustado hereda la pérdida. TXLSChartDateAxisTransform.BuildTicks nunca avanza desde un tick anterior. El tick i siempre se computa como AddUnits(MinValue, UnitKind, Step * i, ...), que es min + unidad × índice medido desde el mínimo del eje. AddUnits suma el conteo directo al serial para unidades de día y, para unidades de mes y año, convierte el serial a una fecha real, llama a IncMonth o IncYear, y convierte de vuelta. El loop además está acotado por todos lados: el arreglo de resultado se limita a 4096 entradas pida lo que pida quien llama, Step * i se verifica contra MaxInt antes de multiplicarse, y la generación se detiene en cuanto un tick deja de crecer o pasa el máximo del eje

Dos formas en que un eje de fechas de gráfico de HotXLS puede colocar ticks de mes tras un anclaje al 31 de enero de 2026: avanzar cada tick desde el valor ajustado anterior queda clavado en el 28, mientras que BuildTicks computa AddUnits(MinValue, xcduMonths, Step por i) desde el mínimo del eje, así que los ticks caen el 28 de febrero, el 31 de marzo y el 30 de abril
El clamp tira información, así que TXLSChartDateAxisTransform nunca avanza desde un tick anterior y recalcula cada posición desde el mínimo del eje, ajustando los fines de mes al calendario real
uses
  SysUtils, lxChart;

var
  Ticks: TXLSChartDateValues;
  MinSerial, MaxSerial: Double;
  I, Count: Integer;
begin
  // Sistema de fechas 1900 (Dates1904 = False), serie anclada a fin de mes
  MinSerial := TXLSChartDateAxisTransform.DateTimeToSerial(
    EncodeDate(2026, 1, 31), False);
  MaxSerial := TXLSChartDateAxisTransform.DateTimeToSerial(
    EncodeDate(2026, 12, 31), False);

  // Paso de un mes, apuntando a 12 ticks, nunca más de 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;

Hay una aproximación honesta dentro de BuildTicks. Cuando el paso pedido es cero, el método estima un tamaño de paso tratando el mes como 30 días y el año como 365, y luego redondea el paso crudo a un múltiplo 1, 2 o 5 de una potencia de diez. Esa estimación solo decide cuántas unidades hay entre ticks. Las posiciones de los ticks siguen viniendo de IncMonth e IncYear, así que la aproximación puede cambiar la densidad de ticks pero jamás mueve un tick fuera del calendario

¿Por qué conservar seriales del libro en lugar de convertir a TDateTime?

Un eje de fechas en HotXLS conserva los seriales del libro como su espacio de coordenadas porque el TDateTime de Delphi no tiene ranura para el serial 60, el fantasma 29 de febrero de 1900 que el sistema de fechas 1900 heredó como rareza de compatibilidad. TrySerialToDateTime corre los seriales por debajo de 60 un día y pliega el serial 60 sobre el 28 de febrero, lo cual sirve para etiquetar un punto suelto pero es fatal para la geometría: convierta cada punto a TDateTime primero y los dos días a los lados del día fantasma quedan una unidad más cerca de lo que Excel los dibuja, así que todo lo graficado antes de marzo de 1900 se desplaza respecto de todo lo que viene después. TXLSChartDateAxisTransform.FormatValue trata la misma ranura como caso especial y la etiqueta 1900-02-29 exactamente igual que Excel. En un libro 1904, el serial 0 es el 1 de enero de 1904, y la transformación aplica el desfase fijo de 1462 días en ambas direcciones; la historia completa de las dos épocas está en nuestro artículo sobre seriales de fecha de Excel, el sistema 1904 y los formatos numéricos

Por qué un eje de fechas de HotXLS conserva los seriales del libro en lugar de convertir a TDateTime: el serial 60 es el fantasma 29 de febrero de 1900, TrySerialToDateTime corre los seriales por debajo de 60 y pliega el 60 sobre el 28 de febrero de modo que los días a los lados quedan una unidad más cerca de como Excel los dibuja, mientras FormatValue etiqueta el serial 60 como 1900-02-29
El plegado sirve para etiquetar un punto suelto pero es fatal para la geometría, porque todo lo graficado antes de marzo de 1900 se desplaza contra lo que viene después; un libro 1904 aplica en cambio el desfase fijo de 1462 días

¿Cómo elige HotXLS días, meses o años automáticamente?

TXLSChartDateAxisTransform.DetectUnit elige la unidad de calendario más fina que los huecos de los datos realmente necesitan. El método descarta primero los valores NaN e infinitos, ordena el resto una vez en O(n log n), y salta las fechas duplicadas. Si algún hueco adyacente es más corto que un mes de calendario, la unidad es días; si no, si algún hueco es más corto que un año de calendario, la unidad es meses; y si no, años. La prueba es IncMonth(Previous, 1) > Current, no un umbral de 30 días, así que una serie de fin de mes que corre 31 de enero, 28 de febrero, 31 de marzo se detecta correctamente como datos mensuales. Con menos de dos fechas válidas distintas no hay hueco que medir, y el método cae de vuelta a los días

ResolveUnits luego fusiona la unidad detectada con lo que el gráfico declare. Una unidad base explícita se respeta, y una unidad mayor o menor ausente hereda la unidad detectada cuando esta es más gruesa que la base. El último paso es el que la gente pasa por alto: si la unidad mayor o menor efectiva es más fina que la unidad base, la base se baja para emparejar. La unidad base es a lo que se normalizan los puntos de datos antes de que exista cualquier tick, con Normalize ajustando un valor a su día, al primero de su mes o al 1 de enero, de modo que una base mensual bajo una unidad mayor diaria colapsaría un mes de puntos en una sola ranura antes de que el código de ticks los viera. La densidad de ticks también está acotada, por el largo del área de trazado, el ancho medido de la fuente del eje, el formato numérico de las etiquetas y la proyección de etiquetas rotadas, y cada backend dibuja etiquetas, marcas de tick y líneas de cuadrícula desde un único arreglo de ticks compartido y acotado, con un tick menor que coincida con uno mayor dibujado una sola vez

Cómo HotXLS elige unidades del eje de fechas automáticamente: DetectUnit descarta valores NaN e infinitos, ordena una vez y mide huecos de calendario con IncMonth en vez de un umbral de 30 días, así que una serie 31 de enero, 28 de febrero, 31 de marzo se detecta como mensual, y ResolveUnits respeta una base explícita mientras baja la base cuando la unidad mayor es más fina
Normalize ajusta cada valor a su día, al primero de su mes o al 1 de enero antes de que exista cualquier tick, porque una base mensual bajo una unidad mayor diaria colapsaría un mes de puntos en una sola ranura

¿Qué guarda el elemento dateAx y qué significa omitirlo?

En ChartML cada ajuste de calendario de un eje de fechas es opcional, y omitir uno no es lo mismo que escribir su valor por defecto. El tipo CT_DateAx en ECMA-376 Part 1, §21.2 (DrawingML Charts) permite que baseTimeUnit, majorTimeUnit, minorTimeUnit y auto aparezcan o no, y un baseTimeUnit omitido le dice a Excel que decida por su cuenta, que es distinto de un days explícito. TXLSXChartAxis guarda por tanto cada valor junto a una bandera de presencia: BaseTimeUnit con BaseTimeUnitSet, MajorTimeUnit con MajorTimeUnitSet, MinorTimeUnit con MinorTimeUnitSet, y AutoDateAxis con AutoDateAxisSet. Asignar un valor activa su bandera, limpiar la bandera devuelve el elemento a la omisión, y el escritor XLSX emite un elemento solo cuando su bandera está activa. Note que auto significa detección automática de categoría contra fecha, no unidades de ticks 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;                  // escribe c:dateAx
    Axis.BaseTimeUnit := xlsxChartTimeDays;     // también activa BaseTimeUnitSet
    Axis.MajorTimeUnit := xlsxChartTimeMonths;
    Axis.MajorUnit := 1;                        // > 0 marca majorUnit como fijado
    Axis.NumberFormat := 'mmm yyyy';
    Axis.NumberFormatSourceLinked := False;
    Axis.TextStyle.Rotation := -45;             // grados; guardado como 1/60000
    // AutoDateAxis se deja intacto: no se escribe ningún elemento c:auto

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

Las etiquetas y la orientación siguen las mismas reglas conscientes de presencia. El numFmt del eje va después del título y antes de las marcas de tick, su atributo sourceLinked viene true por defecto, y TXLSXChartAxis guarda el código de formato, el valor de source-linked y NumberFormatSet por separado, así que un código vacío nunca se confunde con un elemento ausente. La rotación de texto se guarda en 1/60000 de grado con valores positivos significando sentido horario; TextStyle.Rotation toma grados comunes, la salida SVG usa el ángulo tal cual, y el backend paginado, cuyos ángulos positivos corren en sentido antihorario, voltea el signo en una única frontera compartida. Un eje maxMin invertido refleja junto las marcas de tick mayores y menores, las líneas de cuadrícula, los puntos de datos, las líneas de tendencia y las barras de error, y jamás solo las etiquetas. Cuando usted abre un libro escrito por otro, las banderas le dicen qué especificó realmente el autor

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;   // guárdelo de nuevo y los elementos omitidos siguen omitidos
  end;
end;

¿Hasta dónde llega el modelo de calendario?

El modelo de calendario se detiene en los formatos que no pueden expresar unidades de calendario, y HotXLS no finge lo contrario. ODF 1.2 ofrece chart:interval-major, que es un número común sin noción de días, meses o años, así que un eje mensual de Excel guardado como ODS no puede cargar su unidad y HotXLS no inventa un atributo no estándar para simularla. Del lado BIFF8 heredado, el registro AxcExt contiene nueve palabras fijas de 16 bits, y sus banderas automáticas solo enmascaran campos en vez de eliminarlos; HotXLS conserva el mínimo, máximo, intervalo, unidad y valor de cruce enmascarados al leer y al reescribir, mantiene intactos los códigos de unidad desconocidos en el modelo Classic, y mapea solo las unidades de fecha válidas 0, 1 y 2 (días, meses, años) a XLSX

Los ejes de fechas son una capa de un modelo de gráficos que además tiene que sobrevivir a archivos que él no creó. Cuando un libro de Excel combina una línea con eje de fechas y una serie de columnas en un eje secundario, nuestro artículo sobre ChartML preservado y gráficos combinados muestra cómo HotXLS reinterpreta el XML del gráfico original byte a byte cuando nada cambió y fusiona ediciones tipadas en él cuando algo cambió, y nuestra visión general de gráficos, imágenes y drawings cubre las APIs de anclaje y de series usadas acá. Todo esto viene en el HotXLS Delphi Component para Delphi y C++Builder, que lee, escribe y renderiza gráficos XLS y XLSX sin automatización de Excel