مقاله فنی

محور تاریخ chart در HotXLS: tickهای دقیق تقویمی در Delphi

HotXLS محورهای تاریخ chart اکسل را با حساب تقویمی رندر می‌کند، نه با شمارش روز ثابت. کلاس TXLSChartDateAxisTransform در یونیت lxChart هر مختصات را به‌صورت سریال ورک‌بوک نگه می‌دارد، هر tick ماهانه یا سالانه را مستقیماً از کمینهٔ محور از طریق IncMonth و IncYear حساب می‌کند، و انتهای ماه را به تقویم واقعی گیره می‌کند، پس یک سری که به 31 ژانویه لنگر شده tickهای 28 فوریه و 31 مارس و 30 آوریل می‌گیرد به‌جای اینکه سر بخورد. همین transform واحد روز و ماه و سال را خودکار انتخاب می‌کند و بین رندرکننده‌های chart مربوط به HTML و SVG و PDF صفحه‌بندی‌شده مشترک است

باگی که این جلویش را می‌گیرد برای هر کسی که chart بستن آخر ماه کشیده آشناست. رندرکننده‌ای که ماه را 30 روز می‌گیرد تا پایان سال اول پنج روز زودتر drift می‌کند. رندرکنندهٔ باهوش‌تری که ماه‌های تقویمی را به کار می‌برد ولی هر tick را از قبلی جلو می‌برد، بی‌سروصداتر شکست می‌خورد: 31 ژانویه می‌شود 28 فوریه، قدم بعدی روی 28 مارس می‌افتد، و هر tick بعدش به 28 چسبیده می‌ماند. chart محتمل به نظر می‌رسد، لیبل‌ها غلط‌اند، و هیچ‌کس نمی‌فهمد تا وقتی کنترلر بپرسد چرا موجودی مارس سه روز قبل از پایان فصل ترسیم شده

چرا tickهای ماهانه وقتی تجمیع می‌شوند drift می‌کنند؟

tickهای ماهانه drift می‌کنند چون گیره کردن اطلاعات را دور می‌ریزد. وقتی 31 ژانویه یک‌بار به 28 فوریه گیره شد، این واقعیت که سری 31ام را می‌خواسته از بین رفته، و هر قدمی که از مقدار گیره‌شده برداشته شود همین ضایعات را به ارث می‌برد. TXLSChartDateAxisTransform.BuildTicks هرگز از tick قبلی قدم برنمی‌دارد. tick i همیشه به‌صورت AddUnits(MinValue, UnitKind, Step * i, ...) حساب می‌شود، یعنی min + واحد × اندیس که از کمینهٔ محور اندازه‌گیری شده. AddUnits برای واحدهای روزی تعداد را مستقیم به سریال اضافه می‌کند و برای واحدهای ماهانه و سالانه سریال را به یک تاریخ واقعی تبدیل می‌کند، IncMonth یا IncYear را صدا می‌زند و برمی‌گرداند. حلقه هم از هر طرف کران‌دار است: آرایهٔ نتیجه فارغ از اینکه فراخوان چه بخواهد سقف 4096 مدخل دارد، Step * i قبل از ضرب با MaxInt چک می‌شود، و تولید همین‌جا که یک tick دیگر افزایش نیابد یا از بیشینهٔ محور رد شود متوقف می‌شود

دو راهی که محور تاریخ chart در HotXLS می‌تواند بعد از لنگر 31 ژانویه 2026 tickهای ماهانه بگذارد: جلو بردن هر tick از مقدار گیره‌شدهٔ قبلی همه را به 28 می‌چسباند، در حالی که BuildTicks مقدار AddUnits(MinValue, xcduMonths, Step times i) را از کمینهٔ محور حساب می‌کند تا tickها روی 28 فوریه و 31 مارس و 30 آوریل بنشینند
گیره کردن اطلاعات را دور می‌ریزد، پس TXLSChartDateAxisTransform هرگز از tick قبلی قدم برنمی‌دارد و هر موقعیت را از کمینهٔ محور از نو حساب می‌کند و انتهای ماه را به تقویم واقعی گیره می‌کند
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 tick، هرگز بیشتر از 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 یک تقریب صادقانه هست. وقتی قدم درخواستی صفر است، متد اندازهٔ قدم را با فرض 30 روز برای ماه و 365 روز برای سال تخمین می‌زند، بعد قدم خام را به مضربی از 1 یا 2 یا 5 از یک توان ده گرد می‌کند. این تخمین فقط تصمیم می‌گیرد چند واحد بین tickها فاصله باشد. خودِ موقعیت tickها همچنان از IncMonth و IncYear می‌آیند، پس تقریب می‌تواند تراکم tick را عوض کند ولی هرگز یک tick را از تقویم برنمی‌دارد

چرا سریال‌های ورک‌بوک را نگه می‌داریم به‌جای تبدیل به TDateTime؟

یک محور تاریخ در HotXLS سریال‌های ورک‌بوک را به‌عنوان فضای مختصاتش نگه می‌دارد، چون TDateTime دلفی برای سریال 60 جایی ندارد، یعنی 29 فوریهٔ 1900 خیالی که سیستم تاریخ 1900 به‌عنوان یک ظرافت سازگاری به ارث برده. TrySerialToDateTime سریال‌های زیر 60 را یک روز شیفت می‌دهد و سریال 60 را روی 28 فوریه می‌چسباند، که برای لیبل کردن یک نقطهٔ تکی اشکالی ندارد ولی برای هندسه کشنده است: همهٔ نقاط را اول به TDateTime تبدیل کنید و دو روز دو طرف روز خیالی یک واحد به هم نزدیک‌تر از ترسیم اکسل می‌شوند، پس هر چه قبل از مارس 1900 ترسیم شده نسبت به بعدش جابه‌جا می‌شود. TXLSChartDateAxisTransform.FormatValue همین اسلات را خاص می‌گیرد و آن را 1900-02-29 لیبل می‌زند، دقیقاً مثل اکسل. در یک ورک‌بوک 1904، سریال 0 یعنی 1 ژانویه 1904، و transform آفست ثابت 1462 روزه را در هر دو جهت اعمال می‌کند؛ داستان کامل هر دو epoch را مقالهٔ ما دربارهٔ سریال‌های تاریخ اکسل، سیستم 1904 و فرمت‌های عددی دارد

چرا یک محور تاریخ HotXLS سریال‌های ورک‌بوک را نگه می‌دارد به‌جای تبدیل به TDateTime: سریال 60 همان 29 فوریهٔ 1900 خیالی است، TrySerialToDateTime سریال‌های زیر 60 را شیفت می‌دهد و 60 را روی 28 فوریه می‌چسباند تا دو روز دو طرفش یک واحد نزدیک‌تر از ترسیم اکسل شوند، در حالی که FormatValue سریال 60 را 1900-02-29 لیبل می‌زند
چسباندن برای لیبل کردن یک نقطهٔ تکی اشکالی ندارد ولی برای هندسه کشنده است، چون هر چه قبل از مارس 1900 ترسیم شده نسبت به بعدش جابه‌جا می‌شود؛ ورک‌بوک 1904 به‌جایش آفست ثابت 1462 روزه را اعمال می‌کند

HotXLS چطور روز و ماه و سال را خودکار انتخاب می‌کند؟

TXLSChartDateAxisTransform.DetectUnit ریزترین واحد تقویمی‌ای را انتخاب می‌کند که شکاف‌های داده واقعاً لازم دارند. متد اول مقادیر NaN و بی‌نهایت را کنار می‌گذارد، بقیه را یک‌بار در O(n log n) مرتب می‌کند، و تاریخ‌های تکراری را رد می‌کند. اگر هر شکاف مجاوری از یک ماه تقویمی کوتاه‌تر باشد واحد روز است؛ وگرنه اگر هر شکافی از یک سال تقویمی کوتاه‌تر باشد واحد ماه است؛ وگرنه سال. آزمون IncMonth(Previous, 1) > Current است، نه یک آستانهٔ 30 روزه، پس سری آخر-ماهی که 31 ژانویه و 28 فوریه و 31 مارس می‌رود درست به‌عنوان دادهٔ ماهانه شناسایی می‌شود. با کمتر از دو تاریخ معتبر متمایز شکافی برای اندازه‌گیری نیست و متد به روز برمی‌گردد

ResolveUnits بعد واحد شناسایی‌شده را با هر چیزی که chart اعلان کرده ترکیب می‌کند. base unit صریح محترم شمرده می‌شود، و major یا minor غایب وقتی درشت‌تر از base است همان واحد شناسایی‌شده را به ارث می‌برد. قدم آخر همان است که مردم از دستش می‌دهند: اگر unit مؤثر major یا minor ریزتر از base باشد، base برای همخوانی پایین کشیده می‌شود. base unit همان چیزی است که نقاط داده قبل از وجود هر tickی به آن نرمال می‌شوند، و Normalize یک مقدار را به روزش یا اول ماهش یا 1 ژانویه می‌چسباند، پس base ماهانه زیر major روزانه یک ماه نقطه را قبل از اینکه کد tick اصلاً ببیندشان در یک اسلات فرو می‌ریزد. تراکم tick هم کران دارد: به طول plot، عرض اندازه‌گیری‌شدهٔ فونت محور، فرمت عددی لیبل و تصویر لیبل‌های چرخیده، و هر backend لیبل‌ها و tick markها و gridlineها را از یک آرایهٔ tick مشترک و کران‌دار ترسیم می‌کند، و minor tickی که بر major منطبق باشد فقط یک‌بار کشیده می‌شود

HotXLS چطور واحدهای محور تاریخ را خودکار انتخاب می‌کند: DetectUnit مقادیر NaN و بی‌نهایت را کنار می‌گذارد، یک‌بار مرتب می‌کند و شکاف‌های تقویمی را با IncMonth می‌سنجد نه با آستانهٔ 30 روزه، پس سری 31 ژانویه و 28 فوریه و 31 مارس ماهانه شناسایی می‌شود، و ResolveUnits base صریح را محترم می‌شمارد و وقتی major ریزتر است base را پایین می‌کشد
Normalize هر مقدار را قبل از وجود هر tickی به روزش یا اول ماهش یا 1 ژانویه می‌چسباند، چون base ماهانه زیر major روزانه یک ماه نقطه را در یک اسلات فرو می‌ریزد

المان dateAx چه چیزی ذخیره می‌کند و حذف شدن یعنی چه؟

در ChartML هر تنظیم تقویمی روی یک محور تاریخ اختیاری است، و حذف کردن یکی با نوشتن مقدار پیش‌فرضش فرق دارد. نوع CT_DateAx در ECMA-376 Part 1، §21.2 (DrawingML Charts) اجازه می‌دهد baseTimeUnit و majorTimeUnit و minorTimeUnit و auto هر کدام حاضر باشند یا نباشند، و baseTimeUnit غایب به اکسل می‌گوید خودش تصمیم بگیرد، که با days صریح فرق دارد. پس TXLSXChartAxis هر مقدار را کنار یک پرچم حضور ذخیره می‌کند: BaseTimeUnit با BaseTimeUnitSet، MajorTimeUnit با MajorTimeUnitSet، MinorTimeUnit با MinorTimeUnitSet، و AutoDateAxis با AutoDateAxisSet. مقدار دادن پرچم را ست می‌کند، پاک کردن پرچم المان را به حالت حذف برمی‌گرداند، و writer مربوط به XLSX المان را فقط وقتی پرچمش ست است بیرون می‌دهد. دقت کنید auto یعنی تشخیص خودکار category در برابر date، نه واحدهای tick خودکار

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;                  // باعث نوشتن 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;             // درجه؛ به‌صورت 1/60000 ذخیره می‌شود
    // AutoDateAxis دست نخورده: المان c:auto نوشته نمی‌شود

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

لیبل‌ها و جهت‌گیری هم از همان قواعد حضور-آگاه پیروی می‌کنند. numFmt محور بعد از title و قبل از tick markها می‌نشیند، اتریبیوت sourceLinked آن پیش‌فرض true دارد، و TXLSXChartAxis کد فرمت و مقدار source-linked و NumberFormatSet را جدا نگه می‌دارد، پس یک کد خالی هرگز با المان غایب اشتباه نمی‌شود. چرخش متن به‌صورت 1/60000 درجه ذخیره می‌شود با مقادیر مثبت یعنی ساعتگرد؛ TextStyle.Rotation درجهٔ ساده می‌گیرد، خروجی SVG همان زاویه را به کار می‌برد، و backend صفحه‌بندی‌شده که زوایای مثبتش پادساعتگرد است علامت را در یک مرز مشترک برمی‌گرداند. محور maxMin معکوس tickهای major و minor و gridlineها و نقاط داده و trendlineها و error barها را با هم آینه می‌کند، و هرگز فقط لیبل‌ها را نه. وقتی ورک‌بوکی که جای دیگری نوشته شده را باز می‌کنید، پرچم‌ها به شما می‌گویند نویسنده واقعاً چه چیزهایی را صریح گفته

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;   // دوباره ذخیره کنید و المان‌های حذف‌شده حذف‌شده می‌مانند
  end;
end;

مدل تقویمی کجا تمام می‌شود؟

مدل تقویمی در فرمت‌هایی تمام می‌شود که واحد تقویمی نمی‌فهمند، و HotXLS خودش را به آن‌ها نمی‌فروشد. ODF 1.2 چیزی به نام chart:interval-major دارد که یک عدد ساده است و هیچ تصوری از روز و ماه و سال ندارد، پس یک محور ماهانهٔ اکسل که به ODS ذخیره شود واحدش را نمی‌تواند حمل کند و HotXLS برای فیک کردنش اتریبیوت غیراستانداردی نمی‌سازد. در سمت BIFF8 قدیمی، رکورد AxcExt نه واژهٔ ثابت 16 بیتی حمل می‌کند، و پرچم‌های automatic آن فیلدها را ماسک می‌کنند نه حذف؛ HotXLS مقادیر ماسک‌شدهٔ کمینه و بیشینه و interval و unit و crossing را در خواندن و نوشتن دوباره حفظ می‌کند، کدهای واحد ناشناخته را در مدل Classic دست‌نخورده نگه می‌دارد، و فقط واحدهای تاریخ معتبر 0 و 1 و 2 (روز و ماه و سال) را به XLSX نگاشت می‌کند

محورهای تاریخ یک لایه از مدل chart هستند که باید فایل‌هایی که خودش نساخته را هم سالم عبور دهد. وقتی یک ورک‌بوک اکسل یک خط با محور تاریخ را با یک سری ستونی روی محور ثانویه ترکیب می‌کند، مقالهٔ ما دربارهٔ ChartML حفظ‌شده و combination chartها نشان می‌دهد HotXLS وقتی چیزی عوض نشده XML اصلی chart را بایت‌به‌بایت دوباره پخش می‌کند و وقتی چیزی عوض شده ویرایش‌های تایپ‌دار را داخلش ادغام می‌کند، و مرور ما بر chartها و imageها و drawingها APIهای لنگر و سری استفاده‌شده در بالا را پوشش می‌دهد. همهٔ این‌ها در HotXLS Delphi Component برای Delphi و C++Builder ارسال می‌شود که chartهای XLS و XLSX را بدون Excel automation می‌خواند و می‌نویسد و رندر می‌کند