{

 *****************************************************************************
  See the file COPYING.modifiedLGPL.txt, included in this distribution,
  for details about the license.
 *****************************************************************************

  Authors: Alexander Klenin

}

unit TAIntervalSources;

{$H+}

interface

uses
  Classes, TAChartUtils, TACustomSource;

type

  { TIntervalChartSource }

  TIntervalChartSource = class(TCustomChartSource)
  strict private
    FParams: TChartAxisIntervalParams;
    procedure SetParams(AValue: TChartAxisIntervalParams);
  strict protected
    procedure CalculateIntervals(
      AParams: TValuesInRangeParams; out ABestStart, ABestStep: Double);
  protected
    function GetCount: Integer; override;
    function GetItem(AIndex: Integer): PChartDataItem; override;
    procedure SetYCount(AValue: Cardinal); override;
  public
    constructor Create(AOwner: TComponent); override;
    destructor Destroy; override;

    procedure ValuesInRange(
      AParams: TValuesInRangeParams; var AValues: TChartValueTextArray); override;
  published
    property Params: TChartAxisIntervalParams read FParams write SetParams;
  end;

  TDateTimeStep = (
    dtsYear, dtsQuarter, dtsMonth, dtsWeek, dtsDay,
    dtsHour, dtsMinute, dtsSecond, dtsMillisecond
  );
  TDateTimeSteps = set of TDateTimeStep;

const
  DATE_TIME_STEPS_ALL = [Low(TDateTimeStep) .. High(TDateTimeStep)];

type

  { TDateTimeIntervalChartSource }

  TDateTimeIntervalChartSource = class(TIntervalChartSource)
  strict private
    FDateTimeFormat: String;
    FSteps: TDateTimeSteps;
    FSuppressPrevUnit: Boolean;
    procedure SetDateTimeFormat(AValue: String);
    procedure SetSteps(AValue: TDateTimeSteps);
    procedure SetSuppressPrevUnit(AValue: Boolean);
  public
    constructor Create(AOwner: TComponent); override;
    procedure ValuesInRange(
      AParams: TValuesInRangeParams; var AValues: TChartValueTextArray); override;
  published
    property DateTimeFormat: String read FDateTimeFormat write SetDateTimeFormat;
    property Steps: TDateTimeSteps
      read FSteps write SetSteps default DATE_TIME_STEPS_ALL;
    property SuppressPrevUnit: Boolean
      read FSuppressPrevUnit write SetSuppressPrevUnit default true;
  end;


procedure Register;

implementation

uses
  DateUtils, Math, StrUtils, SysUtils, TAMath;

const
  YEAR = 365.25;
  DATE_STEP_INTERVALS: array [TDateTimeStep] of Double = (
    YEAR, YEAR / 4, YEAR / 12, 7, 1,
    OneHour, OneMinute, OneSecond, OneMillisecond
  );

type
  TSourceIntervalParams = class(TChartAxisIntervalParams)
  strict protected
    procedure Changed; override;
  end;

  TDateTimeIntervalsHelper = object
    FBestStep: TDateTimeStep;
    FBestStepCoeff: Double;
    FOrigParams: TValuesInRangeParams;
    FStep: TDateTimeStep;
    FStepLen: Double;

    function AxisToGraph(AX: Double): Double;
    procedure CheckStep(AStepCoeff: Double);
    function GraphToAxis(AX: Double): Double;
    function NextValue(AValue: TDateTime): Double;
    function StartValue(AValue: TDateTime): TDateTime;
  end;

procedure Register;
begin
  RegisterComponents(
    CHART_COMPONENT_IDE_PAGE, [
      TIntervalChartSource, TDateTimeIntervalChartSource
    ]);
end;

function SafeRound(AValue: Double): Double; inline;
begin
  Result := Int(AValue * 1e9) / 1e9;
end;

{ TDateTimeIntervalsHelper }

function TDateTimeIntervalsHelper.AxisToGraph(AX: Double): Double;
begin
  Result := FOrigParams.FAxisToGraph(AX) * DATE_STEP_INTERVALS[FStep];
end;

procedure TDateTimeIntervalsHelper.CheckStep(AStepCoeff: Double);
begin
  // Strict inequaltity is importatnt to avoid steps like "ten quarters".
  if (1.0 <= AStepCoeff) and (AStepCoeff < FBestStepCoeff) then begin
    FBestStepCoeff := AStepCoeff;
    FBestStep := FStep;
    FStepLen := DATE_STEP_INTERVALS[FBestStep] * FBestStepCoeff;
  end;
end;

function TDateTimeIntervalsHelper.GraphToAxis(AX: Double): Double;
begin
  Result := FOrigParams.FGraphToAxis(AX / DATE_STEP_INTERVALS[FStep]);
end;

function TDateTimeIntervalsHelper.NextValue(AValue: TDateTime): Double;
begin
  case FBestStep of
    dtsYear:
      if FBestStepCoeff > 10 then
        // DateTime arithmetics fails on large year numbers.
        Result := AValue + FStepLen
      else
        Result := IncYear(AValue, Round(FBestStepCoeff));
    dtsMonth: Result := IncMonth(AValue, Round(FBestStepCoeff));
    otherwise Result := AValue + FStepLen;
  end;
end;

function TDateTimeIntervalsHelper.StartValue(AValue: TDateTime): TDateTime;
begin
  Result := Int(AValue / FStepLen - 1) * FStepLen;
  case FBestStep of
    dtsYear:
      // DateTime arithmetics fails on large year numbers.
      if FBestStepCoeff <= 10 then
        Result := StartOfTheYear(AValue);
    dtsMonth: Result := StartOfTheMonth(AValue);
  end;
end;

{ TSourceIntervalParams }

procedure TSourceIntervalParams.Changed;
begin
  with GetOwner as TCustomChartSource do begin
    BeginUpdate;
    EndUpdate;
  end;
end;

{ TIntervalChartSource }

procedure TIntervalChartSource.CalculateIntervals(
  AParams: TValuesInRangeParams; out ABestStart, ABestStep: Double);

  procedure CalcMinMaxCount(out AMinCount, AMaxCount: Integer);
  var
    imageWidth, len: Integer;
  begin
    // If the axis transformation is non-linear, steps may not be equidistant.
    // However, both minimax and maximin will be achieved on equal steps.
    with AParams do
      imageWidth := Abs(ToImage(FMax) - ToImage(FMin));
    if aipUseMinLength in Params.Options then
      len := AParams.FScale(Max(Params.MinLength, 2))
    else
      len := 2;
    AMaxCount := Max(imageWidth div len, 2);
    if aipUseMaxLength in Params.Options then begin
      len := AParams.FScale(Max(Params.MaxLength, 2));
      AMinCount := Max((imageWidth + 1) div len, 2);
    end
    else
      AMinCount := 2;
  end;

  procedure TryStep(AStep: Double; var ABestCount: Integer);
  var
    m, start: Double;
    mi, prev, cnt: Int64;
  begin
    if AStep <= 0 then exit;
    start := Int(AParams.FMin / AStep) * AStep;
    m := start;
    prev := AParams.ToImage(m);
    cnt := 0;
    while m <= AParams.FMax do begin
      mi := AParams.ToImage(m + AStep);
      if not AParams.IsAcceptableStep(Abs(prev - mi)) then exit;
      m += AStep;
      prev := mi;
      cnt += 1;
    end;
    if
      not (aipUseCount in Params.Options) or (ABestCount <= 0) or
      (Abs(cnt - Params.Count) < Abs(ABestCount - Params.Count))
    then begin
      ABestStart := start - AStep;
      ABestStep := AStep;
      ABestCount := cnt;
    end;
  end;

var
  minCount, maxCount, bestCount: Integer;
  s, sv: Double;
begin
  CalcMinMaxCount(minCount, maxCount);
  bestCount := 0;
  if aipUseNiceSteps in Params.Options then begin
    s := AParams.CountToStep(minCount)  * 10;
    while s >= Max(AParams.CountToStep(maxCount), AParams.FMinStep) do begin
      for sv in Params.StepValues do
        TryStep(s * sv, bestCount);
      // We are not required to pick the best count, so any one will do.
      if not (aipUseCount in Params.Options) and (bestCount > 0) then break;
      s *= 0.1;
    end;
  end;
  if bestCount > 0 then exit;
  // Either nice steps were not required, or we failed to find one.
  if aipUseCount in Params.Options then
    bestCount := EnsureRange(Params.Count, minCount, maxCount)
  else
    bestCount := minCount;
  ABestStep := (AParams.FMax - AParams.FMin) / bestCount;
  ABestStart := AParams.FMin - ABestStep;
end;

constructor TIntervalChartSource.Create(AOwner: TComponent);
begin
  inherited Create(AOwner);
  FParams := TChartAxisIntervalParams.Create(Self);
end;

destructor TIntervalChartSource.Destroy;
begin
  FreeAndNil(FParams);
  inherited;
end;

function TIntervalChartSource.GetCount: Integer;
begin
  Result := 0;
end;

function TIntervalChartSource.GetItem(AIndex: Integer): PChartDataItem;
begin
  Unused(AIndex);
  Result := nil;
end;

procedure TIntervalChartSource.SetParams(AValue: TChartAxisIntervalParams);
begin
  if FParams = AValue then exit;
  FParams.Assign(AValue);
  InvalidateCaches;
  Notify;
end;

procedure TIntervalChartSource.SetYCount(AValue: Cardinal);
begin
  Unused(AValue);
  raise EYCountError.Create('Can not set YCount');
end;

procedure TIntervalChartSource.ValuesInRange(
  AParams: TValuesInRangeParams; var AValues: TChartValueTextArray);
const
  // Arbitrary limit to prevent hangup/OOM in case of bug in CalculateIntervals.
  MAX_COUNT = 10000;
var
  start, step, m: Double;
  i: Integer;
begin
  if AParams.FMin >= AParams.FMax then exit;
  AParams.FIntervals := Params;

  if aipGraphCoords in Params.Options then begin
    AParams.FMin := AParams.FAxisToGraph(AParams.FMin);
    AParams.FMax := AParams.FAxisToGraph(AParams.FMax);
  end;
  EnsureOrder(AParams.FMin, AParams.FMax);
  CalculateIntervals(AParams, start, step);
  if step <= 0 then exit;
  m := start;
  SetLength(AValues, Trunc(Min((AParams.FMax - m) / step + 2, MAX_COUNT)));
  for i := 0 to High(AValues) do begin
    if IsZero(m) then
      m := 0;
    AValues[i].FValue := m;
    if m > AParams.FMax then begin
      SetLength(AValues, i + 1);
      break;
    end;
    m += step;
  end;
  if aipGraphCoords in Params.Options then
    for i := 0 to High(AValues) do
      AValues[i].FValue := AParams.FGraphToAxis(AValues[i].FValue);
  for i := 0 to High(AValues) do begin
    AParams.RoundToImage(AValues[i].FValue);
    // Extra format arguments for compatibility with FormatItem.
    AValues[i].FText := Format(
      AParams.FFormat, [AValues[i].FValue, 0.0, '', 0.0, 0.0]);
  end;
end;

{ TDateTimeIntervalChartSource }

constructor TDateTimeIntervalChartSource.Create(AOwner: TComponent);
begin
  inherited Create(AOwner);
  FSteps := DATE_TIME_STEPS_ALL;
  FSuppressPrevUnit := true;
end;

procedure TDateTimeIntervalChartSource.SetDateTimeFormat(AValue: String);
begin
  if FDateTimeFormat = AValue then exit;
  FDateTimeFormat := AValue;
  InvalidateCaches;
  Notify;
end;

procedure TDateTimeIntervalChartSource.SetSteps(AValue: TDateTimeSteps);
begin
  if FSteps = AValue then exit;
  FSteps := AValue;
  InvalidateCaches;
  Notify;
end;

procedure TDateTimeIntervalChartSource.SetSuppressPrevUnit(AValue: Boolean);
begin
  if FSuppressPrevUnit = AValue then exit;
  FSuppressPrevUnit := AValue;
  InvalidateCaches;
  Notify;
end;

procedure TDateTimeIntervalChartSource.ValuesInRange(
  AParams: TValuesInRangeParams; var AValues: TChartValueTextArray);
var
  helper: TDateTimeIntervalsHelper;
  prevSt: TSystemTime;

  function FormatLabel(AValue: TDateTime): String;
  var
    st: TSystemTime;
  begin
    if DateTimeFormat <> '' then
      exit(FormatDateTime(DateTimeFormat, AValue));
    DateTimeToSystemTime(AValue, st);
    case helper.FBestStep of
      dtsYear:
        Result := FormatDateTime('yyyy', AValue);
      dtsQuarter:
        Result :=
          IntToRoman(Floor(AValue / helper.FStepLen) mod 4 + 1) +
          FormatDateTime('/yyyy', AValue);
      dtsMonth:
        Result := FormatDateTime(
          IfThen(FSuppressPrevUnit and (st.Year = prevSt.Year), 'mm', 'mm/yyyy'), AValue);
      dtsWeek:
        Result := FormatDateTime('dd/mm', AValue);
      dtsDay:
        Result := FormatDateTime(
          IfThen(FSuppressPrevUnit and (st.Month = prevSt.Month), 'dd', 'dd/mm'), AValue);
      dtsHour:
        Result := FormatDateTime(
          IfThen(FSuppressPrevUnit and (st.Day = prevSt.Day), 'hh:00', 'dd hh:00'), AValue);
      dtsMinute:
        Result := FormatDateTime(
          IfThen(FSuppressPrevUnit and (st.Hour = prevSt.Hour), 'nn', 'hh:nn'), AValue);
      dtsSecond:
        Result := FormatDateTime(
          IfThen(FSuppressPrevUnit and (st.Minute = prevSt.Minute), 'ss', 'nn:ss'), AValue);
      dtsMillisecond:
        Result :=
          IfThen(FSuppressPrevUnit and (st.Second = prevSt.Second),
            IntToStr(st.Millisecond) + 'ms',
            IntToStr(st.Second*1000 + st.Millisecond) + 'ms');
    end;
    if InRange(AValue, helper.FOrigParams.FMin, helper.FOrigParams.FMax) then
      prevSt := st;
  end;

  procedure AddValue(AIndex: Integer; AValue: Double);
  begin
    with AValues[AIndex] do begin
      FValue := AValue;
      FText := Format(
        AParams.FFormat, [AValue, 0.0, FormatLabel(AValue), 0.0, 0.0]);
    end;
  end;

const
  MAX_COUNT = 1000; // Arbitraty limit to prevent OOM in case of a bug.
var
  i, cnt: Integer;
  x, start, stepLen: Double;
begin
  if
    (AParams.FMin >= AParams.FMax) or (aipGraphCoords in Params.options)
  then
    exit;
  AParams.FIntervals := Params;

  helper.FOrigParams := AParams;
  AParams.FAxisToGraph := @helper.AxisToGraph;
  AParams.FGraphToAxis := @helper.GraphToAxis;
  AParams.FMinStep := 1.0;
  helper.FBestStepCoeff := SafeInfinity;
  for helper.FStep in Steps do begin
    AParams.FMin := helper.FOrigParams.FMin / DATE_STEP_INTERVALS[helper.FStep];
    AParams.FMax := helper.FOrigParams.FMax / DATE_STEP_INTERVALS[helper.FStep];
    CalculateIntervals(AParams, start, stepLen);
    helper.CheckStep(stepLen);
  end;

  if IsInfinite(helper.FBestStepCoeff) then exit;

  start := helper.StartValue(helper.FOrigParams.FMin);
  cnt := 1;
  x := start;
  while (x <= helper.FOrigParams.FMax) and (cnt < MAX_COUNT) do begin
    cnt += 1;
    x := helper.NextValue(x);
  end;
  i := Length(AValues);
  SetLength(AValues, i + cnt);

  FillChar(prevSt, SizeOf(prevSt), $FF);
  x := start;
  while (x <= helper.FOrigParams.FMax) and (i < cnt - 1) do begin
    AddValue(i, x);
    i += 1;
    x := helper.NextValue(x);
  end;
  AddValue(i, x);
end;

end.

