Artículo técnico

Eje de fechas en HotXLS: ticks fieles al calendario

Un gráfico de balances de fin de mes que empieza el 31 de enero de 2026 debería poner su tick de abril en el 30 de abril. Avanzad cada tick a partir del anterior con meses de calendario y las etiquetas salen 2026-01-31, 2026-02-28, 2026-03-28, 2026-04-28, con todos los ticks posteriores clavados en el 28. Avanzad con 30 días planos en su lugar y el tick de febrero aterriza el 2 de marzo, mientras que el de diciembre aterriza el 27 de diciembre. La regla que sobrevive a ambas trampas es fácil de enunciar: calculad cada tick desde el mínimo del eje, y ajustad los fines de mes al calendario real

En un gráfico de cierre de trimestre el primer fallo es el peligroso, porque la imagen sigue pareciendo correcta. La línea sube donde debe, las etiquetas van igualmente espaciadas, y el balance de marzo se sienta tres días antes del fin de trimestre. Quien lo encuentra es el controller que compara el gráfico con el libro mayor, y para entonces el gráfico normalmente ya está pegado en un dosier de consejo

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

Los ticks de mes se desvían porque el ajuste tira información a la basura. Una vez que el 31 de enero se ha ajustado al 28 de febrero, el dato de que la serie quería el día 31 ha desaparecido, y cualquier paso dado desde el valor ajustado hereda la pérdida. En HotXLS, TXLSChartDateAxisTransform.BuildTicks en la unidad lxChart jamás avanza desde un tick anterior: el tick i se calcula siempre como AddUnits(MinValue, UnitKind, Step * i, ...), de modo que marzo es dos meses después del 31 de enero y sale como 31 de marzo. AddUnits suma el conteo directamente al serial para unidades de día; para unidades de mes y año convierte el serial a una fecha real, llama a IncMonth o IncYear, y convierte de vuelta

El bucle está acotado por todos lados. El array de resultado nunca supera las 4096 entradas pase lo que pase quien llama como máximo, Step * i se comprueba contra MaxInt antes de que ocurra la multiplicación, y la generación se detiene en cuanto un tick deja de crecer o pasa el máximo del eje. Un workbook hostil con un vano de unos pocos millones de años produce un array corto y vuelve

Dos maneras en que un eje de fechas de gráfico de HotXLS puede colocar los ticks de mes tras un anclaje al 31 de enero de 2026: avanzar cada tick desde el valor ajustado anterior los deja clavados en el 28, mientras que BuildTicks calcula AddUnits(MinValue, xcduMonths, Step por i) desde el mínimo del eje y los ticks caen en 28 de febrero, 31 de marzo y 30 de abril
El ajuste tira información, así que TXLSChartDateAxisTransform jamás avanza desde un tick anterior y recalcula cada posición desde el mínimo del eje, ajustando los fines de mes al calendario real

La transformación es pública, así que podéis correr la aritmética por vuestra cuenta antes de que exista gráfico alguno. Este snippet de consola construye un año de ticks de mes para la serie de arriba e imprime las etiquetas que usará el gráfico

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 exactamente una aproximación dentro de BuildTicks, y está en el sitio donde una aproximación es inofensiva. Cuando el paso pedido es cero, el método estima un 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 siguen viniendo de IncMonth e IncYear. Una longitud de mes tosca puede dejar el eje un poco más denso o más disperso, pero cada tick sigue aterrizando en una fecha que una de esas dos funciones produjo

Los seriales del workbook siguen siendo la coordenada del eje

Un eje de fechas de HotXLS conserva los seriales del workbook como su espacio de coordenadas porque el TDateTime de Delphi no tiene hueco para el serial 60. En el sistema de fechas 1900 ese serial es el 29 de febrero de 1900, un día que nunca existió y que Excel conserva por compatibilidad con programas de hojas de cálculo más viejos. TXLSChartDateAxisTransform.TrySerialToDateTime lo maneja de la única manera que puede un calendario real: los seriales por debajo de 60 se desplazan un día y el serial 60 se pliega sobre el 28 de febrero. Para etiquetar un punto aislado va bien. Para la geometría está mal, porque los seriales 59 y 60 se convierten en el mismo TDateTime, y el 28 de febrero y el 1 de marzo acaban a una unidad donde Excel los dibuja a dos

Tres seriales muestran las dos caras del problema. FormatValue trata la ranura fantasma como caso especial e imprime exactamente lo que imprime Excel, mientras que un round trip por TDateTime la pierde

var
  S: Integer;
  D: TDateTime;
begin
  for S := 59 to 61 do
  begin
    TXLSChartDateAxisTransform.TrySerialToDateTime(S, False, D);
    Writeln(S, '  axis label: ',
      TXLSChartDateAxisTransform.FormatValue(S, False),
      '  TDateTime: ', FormatDateTime('yyyy-mm-dd', D));
  end;
  // 59  axis label: 1900-02-28  TDateTime: 1900-02-28
  // 60  axis label: 1900-02-29  TDateTime: 1900-02-28
  // 61  axis label: 1900-03-01  TDateTime: 1900-03-01
end;

Casi nadie grafica datos de principios de 1900, así que la lección práctica va de vuestro propio código. Si calculáis límites del eje o posiciones de anotaciones, hacedlo en seriales y convertid con DateTimeToSerial en la frontera, como hace el primer snippet. Mezclad un eje basado en seriales con aritmética TDateTime en quien llama, y una anotación fechada antes del 1 de marzo de 1900 aterriza a un día de su punto. La segunda época importa más a menudo: en un workbook 1904 el serial 0 es el 1 de enero de 1904, y la transformación aplica el offset fijo de 1462 días en ambas direcciones cuando el argumento Dates1904 es true. Ambas épocas, y cómo interactúan con ellas los formatos numéricos de celda, están cubiertas en nuestro artículo sobre los 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 workbook en vez de convertir a TDateTime: el serial 60 es el fantasma 29 de febrero de 1900, TrySerialToDateTime desplaza los seriales por debajo de 60 y pliega el 60 sobre el 28 de febrero, de modo que los días a ambos lados acaban una unidad más juntos de como los dibuja Excel, mientras FormatValue etiqueta el serial 60 como 1900-02-29
El plegado va bien para etiquetar un punto aislado pero es fatal para la geometría, porque todo lo pintado antes de marzo de 1900 se desplaza contra todo lo posterior; un workbook 1904 aplica en cambio el offset 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 necesitan de verdad. Descarta los valores NaN e infinitos, ordena una copia del resto una sola vez y salta las fechas duplicadas. Si algún hueco adyacente es más corto que un mes de calendario, la unidad son días; si no, si algún hueco es más corto que un año de calendario, meses; y si no, años. El test de mes es IncMonth(Previous, 1) > Current, y esa elección importa justo para los datos de este artículo. Un umbral de 30 días llamaría al hueco del 31 de enero al 28 de febrero «menos de un mes» y cambiaría una serie mensual a días. Con menos de dos fechas válidas distintas no hay hueco que medir, y el método devuelve días

ResolveUnits fusiona la unidad detectada con lo que el gráfico declare. Una unidad base explícita manda. Una unidad major o minor ausente hereda la detectada cuando esta es más gruesa que la base, y si no recae en la base. La última regla es la que la gente se pierde: si la unidad major o minor efectiva es más fina que la base, la base se rebaja para igualar. La unidad base decide cómo se agrupan los puntos antes de que exista tick alguno, porque Normalize ajusta un valor a su día, al primero de su mes o al 1 de enero. Una base mensual bajo una unidad major diaria plegaría un mes entero de puntos en una sola ranura antes de que el código de ticks los viera

Cómo elige HotXLS unidades del eje de fechas automáticamente: DetectUnit descarta los valores NaN e infinitos, ordena una sola vez y mide los 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 rebajándola cuando la unidad major 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 ningún tick, porque una base mensual bajo una unidad major diaria colapsaría un mes de puntos en una sola ranura

Cuántos ticks major automáticos aparecen depende de más que el rango de fechas. La longitud del área de trazado, el ancho medido de la fuente del eje, el formato numérico de las etiquetas y el ancho proyectado de las etiquetas rotadas acotan todos el recuento, así que un gráfico estrecho con etiquetas largas mmmm yyyy recibe menos ticks que uno ancho con mm/yy. Etiquetas, marcas de tick y gridlines se dibujan todos desde un único array de ticks, lo que mantiene de acuerdo la salida HTML, SVG y PDF, y un tick minor que cae sobre uno major se dibuja una sola vez

¿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 dice algo distinto de escribir su valor por defecto. El tipo CT_DateAx de ECMA-376 Parte 1, §21.2 (DrawingML Charts) deja que baseTimeUnit, majorTimeUnit, minorTimeUnit y auto aparezcan o no. Un baseTimeUnit omitido le dice a Excel que decida en el render; un days explícito lo clava. TXLSXChartAxis guarda cada valor junto a un flag de presencia: BaseTimeUnit con BaseTimeUnitSet, MajorTimeUnit con MajorTimeUnitSet, MinorTimeUnit con MinorTimeUnitSet, y AutoDateAxis con AutoDateAxisSet. Asignar un valor activa su flag, poner el flag a False devuelve el elemento a la omisión, y el escritor XLSX solo emite un elemento cuando su flag está activo. auto es fácil de malinterpretar: controla la detección automática de categoría frente a fecha y no tiene nada que ver con las unidades de tick automáticas

uses
  SysUtils, DateUtils, lxHandleX;

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));
      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;                        // un valor > 0 marca majorUnit como fijado
    Axis.NumberFormat := 'mmm yyyy';
    Axis.NumberFormatSourceLinked := False;
    Axis.TextStyle.Rotation := -45;             // grados
    // AutoDateAxis sin tocar: no se escribe ningún elemento c:auto

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

La unidad base aquí es días a propósito. Las fechas de fin de mes caen en números de día distintos, y una base mensual ajustaría cada punto al primero de su mes, lo que movería el balance del 31 de marzo al 1 de marzo. Clavad la base a días siempre que el día exacto lleve significado, y dejad que la unidad major haga el espaciado mensual

Las etiquetas y la orientación siguen las mismas reglas de presencia. El elemento numFmt del eje da a sourceLinked el valor true por defecto, así que TXLSXChartAxis mantiene aparte el código de formato, el valor source-linked y NumberFormatSet, y un código vacío jamás se confunde con un elemento ausente. ChartML guarda la rotación del texto en 1/60000 de grado con los valores positivos corriendo en sentido horario; TextStyle.Rotation toma grados a secas, y una etiqueta puesta a -45 se inclina igual en la salida SVG y PDF. Un eje invertido (maxMin) refleja juntos los ticks major y minor, las gridlines, los puntos de datos, las líneas de tendencia y las barras de error. La lectura funciona al revés: abrid un workbook escrito por otro y los flags os dicen qué especificó de verdad su 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[1].Charts[0].CategoryAxis;  // Sheets[] es base 1, Charts[] es base 0
    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;   // guardadlo otra vez y los elementos omitidos siguen omitidos
  end;
end;

Unidades de calendario en ficheros ODS y XLS legado

El modelo de calendario llega hasta donde cada formato de fichero puede expresarlo. ODF 1.2 describe el espaciado del eje con chart:interval-major, un número a secas sin noción de días, meses ni años, así que un eje mensual de Excel guardado como ODS no puede llevar su unidad. HotXLS deja la unidad fuera del fichero ODS antes que inventar un atributo no estándar que las demás aplicaciones ODF ignorarían de todos modos. Si el espaciado mensual importa a quien abre el fichero, repartid XLSX

Los workbooks BIFF8 guardan los ajustes del eje de fechas en el record AxcExt, nueve palabras fijas de 16 bits. Sus flags automáticos enmascaran campos sin eliminarlos, así que un record marcado «mínimo automático» sigue llevando un valor de mínimo. HotXLS conserva el mínimo, máximo, intervalo, unidad y cruce enmascarados al leer y los reescribe sin cambios, mantiene intactos los códigos de unidad desconocidos en el modelo de objetos XLS, y mapea solo las unidades de fecha válidas 0, 1 y 2 (días, meses, años) cuando un gráfico se convierte a XLSX

Un eje de fechas rara vez viaja solo. Cuando un workbook de Excel empareja una línea con eje de fechas y una serie de columnas en un eje secundario, nuestro artículo sobre ChartML conservado y gráficos combinados explica cómo HotXLS reproduce el XML original del gráfico cuando nada cambió y fusiona en él las ediciones tipadas cuando algo cambió, y nuestra visión general de gráficos, imágenes y drawings cubre las llamadas de anclaje y de series usadas arriba. La página del HotXLS Delphi Component lista el conjunto completo de características de gráficos para Delphi y C++Builder, incluida la lectura, escritura y renderización de gráficos XLS y XLSX sin Excel instalado