기술 문서

HotXLS 차트 날짜 축: 달력에 정확한 눈금

2026년 1월 31일에 시작하는 월말 잔고 차트는 4월 눈금을 4월 30일에 찍어야 합니다. 각 눈금을 이전 눈금에서 달력의 달로 전진시키면 라벨이 2026-01-31, 2026-02-28, 2026-03-28, 2026-04-28로 나오며, 그 뒤의 모든 눈금이 28일에 박힙니다. 대신 평평한 30일로 전진하면 2월 눈금은 3월 2일에, 12월 눈금은 12월 27일에 떨어집니다. 두 함정을 모두 살아남는 규칙은 말하기 간단합니다. 모든 눈금을 축 최솟값에서 계산하고, 월말은 실제 달력에 클램프한다

분기 마감 차트에서 첫 번째 실수가 위험한 이유는 그림이 여전히 옳아 보이기 때문입니다. 선은 마땅히 올라가는 곳에서 올라가고, 라벨은 고르게 배치되며, 3월 잔고는 분기 말 3일 전에 앉아 있습니다. 차트를 장부와 비교하는 재무 책임자가 발견하는 사람이고, 그때쯤이면 차트는 이미 이사회 자료에 붙여 넣어진 뒤입니다

월 눈금은 누적하면 왜 어긋날까요?

월 눈금이 어긋나는 이유는 클램핑이 정보를 버리기 때문입니다. 1월 31일이 한번 2월 28일로 클램프되면 계열이 31일을 원했다는 사실은 사라지고, 클램프된 값에서 잡은 스텝은 그 손실을 물려받습니다. HotXLS에서 lxChart 유닛의 TXLSChartDateAxisTransform.BuildTicks는 이전 눈금에서 전진하는 일이 없습니다. 눈금 i는 언제나 AddUnits(MinValue, UnitKind, Step * i, ...)이므로 3월은 1월 31일의 두 달 뒤, 즉 3월 31일로 나옵니다. AddUnits는 일 단위에서는 개수를 serial에 바로 더하고, 월·년 단위에서는 serial을 실제 날짜로 바꿔 IncMonth나 IncYear를 호출한 뒤 되돌립니다

루프는 모든 면에서 경계가 있습니다. 결과 배열은 호출자가 최댓값으로 무엇을 넘기든 4096개를 넘지 않고, Step * i는 곱하기가 일어나기 전에 MaxInt를 검사하며, 눈금이 더 커지지 않거나 축 최댓값을 넘는 즉시 생성이 멈춥니다. 수백만 년 폭의 적대적 워크북은 짧은 배열을 만들고 돌아옵니다

HotXLS 차트 날짜 축이 2026년 1월 31일 앵커 뒤에서 월 눈금을 놓는 두 가지 방식: 이전 클램프 값에서 각 눈금을 전진시키면 28일에 박히지만, BuildTicks는 축 최솟값에서 AddUnits(MinValue, xcduMonths, Step 곱하기 i)를 계산하므로 눈금이 2월 28일, 3월 31일, 4월 30일에 떨어집니다
클램핑은 정보를 버리므로 TXLSChartDateAxisTransform은 이전 눈금에서 전진하지 않고 모든 위치를 축 최솟값에서 다시 계산하며, 월말은 실제 달력에 맞춰 클램프합니다

트랜스폼은 공개되어 있으므로 차트가 존재하기 전에 산술을 직접 돌릴 수 있습니다. 이 콘솔 스니펫은 위 계열의 1년치 월 눈금을 만들고 차트가 쓸 라벨을 출력합니다

uses
  SysUtils, lxChart;

var
  Ticks: TXLSChartDateValues;
  MinSerial, MaxSerial: Double;
  I, Count: Integer;
begin
  // 1900 날짜 체계(Dates1904 = False), 월말 앵커 계열
  MinSerial := TXLSChartDateAxisTransform.DateTimeToSerial(
    EncodeDate(2026, 1, 31), False);
  MaxSerial := TXLSChartDateAxisTransform.DateTimeToSerial(
    EncodeDate(2026, 12, 31), False);

  // 한 달 스텝, 눈금 12개 목표, 최대 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;

BuildTicks에는 근사가 정확히 하나 있고, 근사가 무해한 자리에 앉아 있습니다. 요청된 스텝이 0일 때 메서드는 한 달을 30일, 1년을 365로 취급해 스텝을 추정한 다음, 날 스텝을 10의 거듭제곱에 대한 1, 2, 5 배수로 올림합니다. 그 추정은 눈금 사이에 단위가 몇 개 들어갈지만 정합니다. 위치는 여전히 IncMonth와 IncYear에서 나옵니다. 거친 달 길이는 축을 조금 촘촘하게 또는 성기게 만들 수 있지만, 모든 눈금은 여전히 그 두 함수 가운데 하나가 만들어 낸 날짜에 떨어집니다

통합 문서 serial이 축 좌표로 남는다

HotXLS의 날짜 축은 좌표 공간으로 통합 문서 serial을 유지합니다. Delphi의 TDateTime에는 serial 60의 자리가 없기 때문입니다. 1900 날짜 체계에서 그 serial은 1900년 2월 29일, 즉 한 번도 존재한 적 없고 Excel이 더 오래된 스프레드시트 프로그램과의 호환성을 위해 유지하는 날입니다. TXLSChartDateAxisTransform.TrySerialToDateTime은 실제 달력이 가능한 유일한 방식으로 이를 처리합니다. 60 미만의 serial은 하루씩 밀리고 serial 60은 2월 28일로 접힙니다. 점 하나에 라벨을 붙이는 데는 괜찮습니다. 기하에는 틀립니다. serial 59와 60이 같은 TDateTime이 되어 2월 28일과 3월 1일이 한 단위 차이로 끝나는데, Excel은 둘을 두 단위 차이로 그립니다

세 개의 serial이 문제의 양면을 보여 줍니다. FormatValue는 유령 자리를 특별 취급해 Excel이 인쇄하는 것을 정확히 인쇄하는 반면, TDateTime을 통한 왕복은 그것을 잃습니다

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;

1900년 초 데이터를 차트로 그리는 사람은 거의 없으므로 실무 교훈은 여러분 고유의 코드에 관한 것입니다. 축 경계나 주석 위치를 계산한다면 serial로 하고 가장자리에서 DateTimeToSerial로 변환하세요. 첫 스니펫이 그렇게 합니다. serial 기반 축을 호출자의 TDateTime 산술과 섞으면 1900년 3월 1일 이전 날짜의 주석이 자기 점에서 하루 떨어진 곳에 앉습니다. 두 번째 에포크가 더 자주 문제 됩니다. 1904 워크북에서 serial 0은 1904년 1월 1일이고, 트랜스폼은 Dates1904 인자가 참일 때 고정 1462일 오프셋을 양방향으로 적용합니다. 두 에포크와 셀 숫자 서식이 그들과 상호작용하는 방식은 Excel 날짜 serial과 1904 체계, 숫자 서식 글에 있습니다

HotXLS 날짜 축이 TDateTime으로 변환하는 대신 통합 문서 serial을 유지하는 이유: serial 60은 유령 날짜 1900년 2월 29일이고, TrySerialToDateTime은 60 미만을 밀고 60을 2월 28일로 접어 양옆 이틀이 Excel이 그리는 것보다 한 단위 좁아지지만, FormatValue는 serial 60을 1900-02-29로 라벨링합니다
접기는 점 하나에 라벨을 붙이는 데는 괜찮지만 기하에는 치명적입니다. 1900년 3월 이전에 그려진 모든 것이 이후와 상대적으로 밀리고, 1904 통합 문서는 대신 고정 1462일 오프셋을 적용합니다

HotXLS는 일·월·년 단위를 어떻게 자동으로 고를까요?

TXLSChartDateAxisTransform.DetectUnit은 데이터의 간격이 실제로 요구하는 가장 잘게 쪼개진 달력 단위를 고릅니다. NaN과 무한 값을 버리고, 나머지의 사본을 한 번 정렬한 뒤 중복 날짜를 건너뜁니다. 인접 간격이 하나라도 달력상 한 달보다 짧으면 단위는 일이고, 그렇지 않으면서 간격이 하나라도 달력상 1년보다 짧으면 월이고, 아니면 년입니다. 월 판정은 IncMonth(Previous, 1) > Current이며, 이 선택이 정확히 이 글의 데이터에서 중요합니다. 30일 임계값이라면 1월 31일에서 2월 28까지의 간격을 "한 달 미만"이라 부르고 월 단위 계열을 일 단위로 바꿔 버립니다. 서로 다른 유효 날짜가 둘 미만이면 잴 간격이 없어 메서드는 일을 돌려줍니다

ResolveUnits는 감지된 단위를 차트가 선언한 것과 합칩니다. 명시된 기저 단위가 이깁니다. 빠진 major나 minor 단위는 기저보다 성기면 감지된 단위를 물려받고, 그렇지 않으면 기저로 폴백합니다. 사람들이 놓치는 마지막 규칙이 이것입니다. 유효한 major나 minor 단위가 기저보다 잘게 쪼개져 있으면 기저가 그에 맞춰 낮아집니다. 기저 단위는 눈금이 존재하기 전에 점들이 묶이는 방식을 정하는데, Normalize는 값을 자기 날, 자기 달의 1일, 또는 1월 1일로 스냅하기 때문입니다. 일 단위 major 아래의 월 단위 기저는 눈금 코드가 보기도 전에 한 달치 점을 슬롯 하나로 뭉개 버립니다

HotXLS가 날짜 축 단위를 자동으로 고르는 방식: DetectUnit은 NaN과 무한 값을 버리고 한 번 정렬한 뒤 30일 임계값 대신 IncMonth로 달력 간격을 재므로 1월 31일, 2월 28일, 3월 31일 계열은 월 단위로 감지되고, ResolveUnits는 명시된 base를 존중하면서 major 단위가 더 잘게 쪼개져 있으면 base를 낮춥니다
Normalize는 눈금이 존재하기 전에 각 값을 자기 날, 자기 달의 1일 또는 1월 1일로 스냅합니다. 일 단위 major 아래의 월 단위 base는 한 달치 점을 슬롯 하나로 뭉개버릴 테니까요

자동 major 눈금이 몇 개 나오는지는 날짜 범위만으로 정해지지 않습니다. 플롯 길이, 축 폰트의 측정된 폭, 라벨 숫자 서식, 회전된 라벨의 예상 폭이 모두 개수를 갈아 넣으므로, 긴 mmmm yyyy 라벨을 실은 좁은 차트는 mm/yy를 쓰는 넓은 차트보다 눈금이 적습니다. 라벨, 눈금 표식, 격자선은 모두 하나의 눈금 배열에서 그려지는데, 이 덕분에 HTML, SVG, PDF 출력이 서로 일치하고, major 눈금 위에 떨어지는 minor 눈금은 한 번만 그려집니다

dateAx 요소는 무엇을 저장하고, 생략은 무엇을 뜻할까요?

ChartML에서 날짜 축의 모든 달력 설정은 선택 사항이고, 생략하는 것은 기본값을 쓰는 것과 다른 말을 합니다. ECMA-376 Part 1 §21.2(DrawingML Charts)의 CT_DateAx 타입은 baseTimeUnit, majorTimeUnit, minorTimeUnit, auto가 각각 나오거나 안 나오거나를 허용합니다. 생략된 baseTimeUnit은 Excel이 렌더링할 때 정하라는 뜻이고, 명시된 days는 못 박습니다. TXLSXChartAxis는 각 값을 존재 플래그 곁에 유지합니다. BaseTimeUnit에는 BaseTimeUnitSet, MajorTimeUnit에는 MajorTimeUnitSet, MinorTimeUnit에는 MinorTimeUnitSet, AutoDateAxis에는 AutoDateAxisSet입니다. 값을 대입하면 플래그가 켜지고, 플래그를 False로 두면 요소가 생략 상태로 돌아가며, XLSX 라이터는 플래그가 켜진 요소만 내보냅니다. auto는 오해하기 쉽습니다. 이것은 자동 category 대 날짜 판별을 제어할 뿐 자동 눈금 단위와는 무관합니다

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;                  // c:dateAx를 씁니다
    Axis.BaseTimeUnit := xlsxChartTimeDays;     // BaseTimeUnitSet도 함께 설정됩니다
    Axis.MajorTimeUnit := xlsxChartTimeMonths;
    Axis.MajorUnit := 1;                        // > 0인 값은 majorUnit이 설정됐음을 표시합니다
    Axis.NumberFormat := 'mmm yyyy';
    Axis.NumberFormatSourceLinked := False;
    Axis.TextStyle.Rotation := -45;             // 도 단위
    // AutoDateAxis는 손대지 않음: c:auto 요소를 쓰지 않습니다

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

여기서 기저 단위가 일인 것은 의도입니다. 월말 날짜는 서로 다른 일 번호에 떨어지고, 월 단위 기저는 모든 점을 자기 달의 1일로 스냅해서 3월 31일 잔고를 3월 1일로 옮깁니다. 정확한 날이 의미를 지닐 때는 기저를 일에 못 박고 월 간격은 major 단위에 맡기세요

라벨과 방향도 같은 존재 규칙을 따릅니다. 축의 numFmt 요소는 sourceLinked의 기본값을 참으로 두므로, TXLSXChartAxis는 서식 코드와 source-linked 값, NumberFormatSet을 따로 유지해서 빈 코드가 없는 요소로 오인되지 않습니다. ChartML은 텍스트 회전을 1/60000도 단위로 저장하고 양수를 시계 방향으로 둡니다. TextStyle.Rotation은 평범한 도를 받으며, -45로 둔 라벨은 SVG와 PDF 출력에서 같은 쪽으로 기웁니다. 뒤집힌(maxMin) 축은 major와 minor 눈금, 격자선, 데이터 점, 추세선, 오차 막대를 함께 미러링합니다. 읽기는 반대 방향으로 동작합니다. 다른 곳에서 쓰인 워크북을 열면 플래그가 저자가 실제로 지정한 것을 알려 줍니다

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[]는 1 기반, Charts[]는 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;   // 다시 저장해도 생략된 요소는 생략된 채로 남습니다
  end;
end;

ODS와 레거시 XLS 파일의 달력 단위

달력 모델은 각 파일 형식이 표현할 수 있는 만큼만 갑니다. ODF 1.2는 축 간격을 chart:interval-major로 기술하는데, 일·월·년 개념이 없는 평범한 숫자라서 ODS로 저장된 Excel 월 단위 축은 자기 단위를 실을 수 없습니다. HotXLS는 다른 ODF 애플리케이션이 어차피 무시할 비표준 속성을 발명하는 대신 단위를 ODS 파일에서 뺍니다. 월 간격이 파일을 여는 사람들에게 중요하다면 XLSX로 배송하세요

BIFF8 워크북은 날짜 축 설정을 AxcExt 레코드, 고정 16비트 워드 아홉 개에 보관합니다. 자동 플래그는 필드를 제거하지 않고 마스킹할 뿐이라 "자동 최솟값"으로 표시된 레코드도 최솟값을 실어 다닙니다. HotXLS는 마스킹된 최솟값, 최댓값, 간격, 단위, 교차 값을 읽을 때 유지하고 되쓸 때 그대로 돌려주며, 모르는 단위 코드는 XLS 객체 모델에 온전히 보존하고, 차트를 XLSX로 변환할 때만 유효한 날짜 단위 0, 1, 2(일, 월, 년)를 매핑합니다

날짜 축은 홀로 다니는 일이 드뭅니다. Excel 워크북이 날짜 축 꺾은선을 보조 축의 세로 막대 계열과 결합할 때, 보존된 ChartML과 조합 차트 글은 아무것도 바뀌지 않았을 때 HotXLS가 원본 차트 XML을 재생하고 뭔가 바뀌었을 때 타입 편집을 거기에 병합하는 방식을 설명하고, 차트, 이미지, 드로잉 개요 글은 위에서 쓴 앵커링과 계열 호출을 다룹니다. HotXLS Delphi Component 페이지에 Delphi와 C++Builder용 전체 차트 기능 집합, Excel이 설치되지 않은 상태에서의 XLS와 XLSX 차트 읽기, 쓰기, 렌더링이 정리되어 있습니다