unit ChartDisplayMain; // ------------------------------------------------------------------------------ // Unit Name: ChartDisplayMain // Author: Delphi Algo Trading Assistant // Date: May 22, 2025 // Purpose: Main form for chart display. Uses TChartDataController for data // management and OHLC aggregation, including IGenericCandleBuilder. // Timeframes are selected via unique captions (keys), while user-friendly // descriptions are shown in the UI. // ------------------------------------------------------------------------------ interface uses Winapi.Windows, Winapi.Messages, System.SysUtils, System.Variants, System.Classes, Vcl.Graphics, Vcl.Controls, Vcl.Forms, Vcl.Dialogs, Vcl.StdCtrls, Vcl.ExtCtrls, System.Generics.Collections, System.DateUtils, System.Math, System.IOUtils, System.UITypes, System.Types, Myc.TickLoader, // For TTickData Myc.OHLCCache, // For TOHLC, IGenericCandleBuilder, TF_ constants Myc.ChartDataController, // Uses the updated DataController Vcl.Buttons; type // TTimeframeSetting is defined in Myc.ChartDataController.pas // IGenericCandleBuilder is defined in Myc.OHLCCache.pas TChartForm = class( TForm ) FileNameEdit: TEdit; LoadButton: TButton; Memo: TMemo; RefreshButton: TButton; TimeframeComboBox: TComboBox; GoToCurrentButton: TButton; FileSelectButton: TSpeedButton; MouseHoverDataLabel: TLabel; ChartScrollBox: TScrollBox; PaintBox: TPaintBox; procedure LoadButtonClick( Sender: TObject ); procedure PaintBoxPaint( Sender: TObject ); procedure RefreshButtonClick( Sender: TObject ); procedure FormCreate( Sender: TObject ); procedure FormDestroy( Sender: TObject ); procedure FormMouseWheel( Sender: TObject; Shift: TShiftState; WheelDelta: Integer; MousePos: TPoint; var Handled: Boolean ); procedure PaintBoxMouseDown( Sender: TObject; Button: TMouseButton; Shift: TShiftState; X, Y: Integer ); procedure PaintBoxMouseMove( Sender: TObject; Shift: TShiftState; X, Y: Integer ); procedure PaintBoxMouseUp( Sender: TObject; Button: TMouseButton; Shift: TShiftState; X, Y: Integer ); procedure TimeframeComboBoxChange( Sender: TObject ); procedure GoToCurrentButtonClick( Sender: TObject ); procedure FileSelectButtonClick( Sender: TObject ); procedure PaintBoxMouseLeave( Sender: TObject ); private FActiveOHLCCache: TOHLC; FCandleWidthInPixels: Integer; FCandleSpacing: Integer; FDisplayStartIndex: Integer; FIsDragging: Boolean; FDragStartX: Integer; FDragStartDisplayIndex: Integer; FSelectedTimeframeSeconds: Int64; // Stores the .Seconds value of the currently selected timeframe setting FTimeframeSettings: TArray; // Local copy of all available timeframe settings FCurrentPriceRect: TRect; FCurrentVolumeHistRect: TRect; FMouseLastChartPos: TPoint; FMouseCurrentlyInChartArea: Boolean; FBufferBitmap: TBitmap; FDataController: TChartDataController; procedure PopulateTimeframeComboBox; procedure EnsureCacheForCurrentTimeframe( ARebuildIfPresent: Boolean = False ); procedure ClearAllCachesInForm; procedure DrawChart( ACanvas: TCanvas; ARect: TRect ); public end; var ChartForm: TChartForm; implementation {$R *.dfm} const DefaultChartMargin = 20; DojiPenWidth = 2; InitialCandleWidth = 5; InitialCandleSpacing = 2; DefaultEveryNTicksCount = 20; DefaultVolumeAccumulationThreshold = 10; // --- Generic Candle Builder Implementations --- type TEveryNTicksBuilder = class( TInterfacedObject, IGenericCandleBuilder ) private FTickCountForNewBar: Integer; FDescriptionText: string; // User-friendly description public constructor Create( ATickCount: Integer ); function Init( const ADataSet: TArray ): Boolean; function IsNewBar( AIndex: Integer; const ATick: TTickData ): Boolean; function GetCaption: string; // Returns the Description for UI property Caption: string read GetCaption; // Interface property uses GetCaption end; TVolumeAccumulationBuilder = class( TInterfacedObject, IGenericCandleBuilder ) private FTargetTickVolumePerBar: Integer; FCurrentTickInBarCount: Integer; FDescriptionText: string; // User-friendly description public constructor Create( ATargetTickVolume: Integer ); function Init( const ADataSet: TArray ): Boolean; function IsNewBar( AIndex: Integer; const ATick: TTickData ): Boolean; function GetCaption: string; // Returns the Description for UI property Caption: string read GetCaption; // Interface property uses GetCaption end; { TEveryNTicksBuilder } constructor TEveryNTicksBuilder.Create( ATickCount: Integer ); begin inherited Create; FTickCountForNewBar := ATickCount; if FTickCountForNewBar <= 0 then FTickCountForNewBar := 1; // The Caption property of IGenericCandleBuilder is used as the Description when registering FDescriptionText := Format( 'Every %d Ticks', [FTickCountForNewBar] ); end; function TEveryNTicksBuilder.Init( const ADataSet: TArray ): Boolean; begin Result := True; end; function TEveryNTicksBuilder.IsNewBar( AIndex: Integer; const ATick: TTickData ): Boolean; begin Result := ( AIndex mod FTickCountForNewBar = 0 ); end; function TEveryNTicksBuilder.GetCaption: string; begin Result := FDescriptionText; // This is what TChartDataController gets if it calls ABuilder.Caption end; { TVolumeAccumulationBuilder } constructor TVolumeAccumulationBuilder.Create( ATargetTickVolume: Integer ); begin inherited Create; FTargetTickVolumePerBar := ATargetTickVolume; if FTargetTickVolumePerBar <= 0 then FTargetTickVolumePerBar := 1; FDescriptionText := Format( 'Ticks/Bar: %d', [FTargetTickVolumePerBar] ); FCurrentTickInBarCount := 0; end; function TVolumeAccumulationBuilder.Init( const ADataSet: TArray ): Boolean; begin FCurrentTickInBarCount := 0; Result := True; end; function TVolumeAccumulationBuilder.IsNewBar( AIndex: Integer; const ATick: TTickData ): Boolean; begin Inc( FCurrentTickInBarCount ); if FCurrentTickInBarCount >= FTargetTickVolumePerBar then begin FCurrentTickInBarCount := 0; Result := True; end else Result := False; end; function TVolumeAccumulationBuilder.GetCaption: string; begin Result := FDescriptionText; // This is what TChartDataController gets if it calls ABuilder.Caption end; { TChartForm } procedure TChartForm.PopulateTimeframeComboBox; var i: Integer; DefaultItemIndex: Integer; PreferredDefaultCaptionKey: string; // The short KEY caption for the preferred default begin TimeframeComboBox.Items.Clear; if not Assigned( FDataController ) then begin Memo.Lines.Add( 'Error: DataController not initialized for PopulateTimeframeComboBox.' ); Exit; end; // Get all available timeframes, including base and registered generic ones FTimeframeSettings := FDataController.GetAvailableTimeframes( ); DefaultItemIndex := -1; PreferredDefaultCaptionKey := 'Auto'; // Short key for "Auto (Screen Fit)" for i := Low( FTimeframeSettings ) to High( FTimeframeSettings ) do begin // Add the user-friendly Description to the ComboBox TimeframeComboBox.Items.Add( FTimeframeSettings[i].Description ); // Check against the short Caption (key) for default selection if CompareText( FTimeframeSettings[i].Caption, PreferredDefaultCaptionKey ) = 0 then DefaultItemIndex := i; end; if TimeframeComboBox.Items.Count > 0 then begin if ( DefaultItemIndex = -1 ) then DefaultItemIndex := 0; // Fallback to the first item TimeframeComboBox.ItemIndex := DefaultItemIndex; if ( TimeframeComboBox.ItemIndex >= 0 ) and ( TimeframeComboBox.ItemIndex < Length( FTimeframeSettings ) ) then FSelectedTimeframeSeconds := FTimeframeSettings[TimeframeComboBox.ItemIndex].Seconds else FSelectedTimeframeSeconds := TF_AUTO_AGGREGATION; // Should ideally not happen end else begin FSelectedTimeframeSeconds := TF_AUTO_AGGREGATION; // Default if no timeframes Memo.Lines.Add( 'Warning: No timeframes available to populate ComboBox.' ); end; if TimeframeComboBox.Items.Count > 0 then // Log current selection Memo.Lines.Add( 'Timeframes populated. Default UI: "' + TimeframeComboBox.Text + Format( '", Key: "%s", AggSecs: %d', [FTimeframeSettings[TimeframeComboBox.ItemIndex].Caption, FSelectedTimeframeSeconds] ) ) else Memo.Lines.Add( 'Timeframes populated, but ComboBox is empty. No default set.' ); end; procedure TChartForm.FormCreate( Sender: TObject ); var BuilderN: IGenericCandleBuilder; BuilderV: IGenericCandleBuilder; BuilderNCaptionKey, BuilderVCaptionKey: string; // These will be the unique keys BuilderNDescription, BuilderVDescription: string; begin FCandleWidthInPixels := InitialCandleWidth; FCandleSpacing := InitialCandleSpacing; FDisplayStartIndex := 0; FIsDragging := False; FDragStartX := 0; FDragStartDisplayIndex := 0; FDataController := TChartDataController.Create( Memo.Lines ); // Create and register generic builders BuilderN := TEveryNTicksBuilder.Create( DefaultEveryNTicksCount ); BuilderNCaptionKey := Format( 'NTICKS_%d', [DefaultEveryNTicksCount] ); // e.g., "NTICKS_20" - this is the unique key BuilderNDescription := BuilderN.Caption; // BuilderN.Caption returns "Every 20 Ticks" FDataController.RegisterGenericBuilder( BuilderNCaptionKey, BuilderNDescription, BuilderN ); BuilderV := TVolumeAccumulationBuilder.Create( DefaultVolumeAccumulationThreshold ); BuilderVCaptionKey := Format( 'VOLACC_%d', [DefaultVolumeAccumulationThreshold] ); // e.g., "VOLACC_10" - unique key BuilderVDescription := BuilderV.Caption; // BuilderV.Caption returns "Ticks/Bar: 10" FDataController.RegisterGenericBuilder( BuilderVCaptionKey, BuilderVDescription, BuilderV ); PopulateTimeframeComboBox; // Populates from FDataController, now includes generic builders if TimeframeComboBox.ItemIndex <> -1 then TimeframeComboBoxChange( TimeframeComboBox ) // Trigger initial cache build else if Length( FTimeframeSettings ) > 0 then begin // If ComboBox empty but settings exist (e.g. Populate didn't find default) TimeframeComboBox.ItemIndex := 0; TimeframeComboBoxChange( TimeframeComboBox ); end else begin Memo.Lines.Add( 'FormCreate: No timeframes to select after population. Ensuring cache for a default (likely empty).' ); EnsureCacheForCurrentTimeframe( True ); // Attempt to build something end; FActiveOHLCCache := nil; FCurrentPriceRect := Rect( 0, 0, 0, 0 ); FCurrentVolumeHistRect := Rect( 0, 0, 0, 0 ); MouseHoverDataLabel.Caption := ''; FMouseLastChartPos := Point( -1, -1 ); FMouseCurrentlyInChartArea := False; PaintBox.OnMouseLeave := PaintBoxMouseLeave; FBufferBitmap := TBitmap.Create; end; procedure TChartForm.FormDestroy( Sender: TObject ); begin FreeAndNil( FDataController ); FreeAndNil( FBufferBitmap ); FActiveOHLCCache := nil; end; procedure TChartForm.ClearAllCachesInForm; begin Memo.Lines.Add( 'TChartForm: Clearing all caches via DataController...' ); if Assigned( FDataController ) then FDataController.ClearAllDataAndCaches( ); FActiveOHLCCache := nil; end; procedure TChartForm.EnsureCacheForCurrentTimeframe( ARebuildIfPresent: Boolean = False ); var Index: Integer; EffectivePaintBoxClientWidthForAuto: Integer; SelectedTFKeyCaption: string; // The short key caption (e.g., "M1", "NTICKS_20") SelectedTFDescription: string; // The UI description (e.g., "1 Minute (M1)") begin Index := TimeframeComboBox.ItemIndex; if ( Index < Low( FTimeframeSettings ) ) or ( Index > High( FTimeframeSettings ) ) then begin Memo.Lines.Add( 'EnsureCache: Invalid TF ComboBox Index: ' + IntToStr( Index ) ); FActiveOHLCCache := nil; PaintBox.Invalidate; Exit; end; if not Assigned( FDataController ) then begin Memo.Lines.Add( 'EnsureCache: FDataController not assigned.' ); FActiveOHLCCache := nil; PaintBox.Invalidate; Exit; end; SelectedTFKeyCaption := FTimeframeSettings[Index].Caption; // This is the short key SelectedTFDescription := FTimeframeSettings[Index].Description; // This is for UI/logging FSelectedTimeframeSeconds := FTimeframeSettings[Index].Seconds; // Still useful for some logic EffectivePaintBoxClientWidthForAuto := PaintBox.ClientWidth - ( 2 * DefaultChartMargin ); if EffectivePaintBoxClientWidthForAuto < 0 then EffectivePaintBoxClientWidthForAuto := 0; // Call GetOrBuildCache using the selected timeframe's short KEY CAPTION. // The ASelectedGenericBuilderCaption parameter was removed from GetOrBuildCache. FActiveOHLCCache := FDataController.GetOrBuildCache( SelectedTFKeyCaption, FCandleWidthInPixels, FCandleSpacing, EffectivePaintBoxClientWidthForAuto, ARebuildIfPresent ); if Assigned( FActiveOHLCCache ) and FActiveOHLCCache.IsValid then Memo.Lines.Add( Format( 'Active Cache UI: "%s" (Key: %s). Candles: %d. AggSecs: %d. ApproxTime/Candle: %s', [SelectedTFDescription, SelectedTFKeyCaption, FActiveOHLCCache.Count, FActiveOHLCCache.AggregationTimeframeSeconds, FActiveOHLCCache.ApproxTimePerCandleText] ) ) else Memo.Lines.Add( Format( 'Active Cache for UI: "%s" (Key: %s) not valid or empty after EnsureCache.', [SelectedTFDescription, SelectedTFKeyCaption] ) ); end; procedure TChartForm.TimeframeComboBoxChange( Sender: TObject ); var CurrentTFKeyCaption: string; begin if TimeframeComboBox.ItemIndex < 0 then begin Memo.Lines.Add( 'TimeframeComboBoxChange: Invalid ItemIndex (-1).' ); Exit; end; if ( TimeframeComboBox.ItemIndex >= Low( FTimeframeSettings ) ) and ( TimeframeComboBox.ItemIndex <= High( FTimeframeSettings ) ) then begin FSelectedTimeframeSeconds := FTimeframeSettings[TimeframeComboBox.ItemIndex].Seconds; CurrentTFKeyCaption := FTimeframeSettings[TimeframeComboBox.ItemIndex].Caption; Memo.Lines.Add( FormatDateTime( 'yyyy-mm-dd hh:nn:ss', Now ) + Format( ' - TF Change Event: UI shows "%s" (Key: %s, Resolved AggSecs: %d)', [TimeframeComboBox.Text, // This is FTimeframeSettings[Index].Description CurrentTFKeyCaption, FSelectedTimeframeSeconds] ) ); end else begin Memo.Lines.Add( Format( 'Error: Invalid ItemIndex %d in TimeframeComboBoxChange. FTimeframeSettings valid range %d to %d.', [TimeframeComboBox.ItemIndex, Low( FTimeframeSettings ), High( FTimeframeSettings )] ) ); if Length( FTimeframeSettings ) > 0 then FSelectedTimeframeSeconds := FTimeframeSettings[0].Seconds else FSelectedTimeframeSeconds := TF_AUTO_AGGREGATION; end; FDisplayStartIndex := 0; EnsureCacheForCurrentTimeframe( True ); PaintBox.Invalidate; end; procedure TChartForm.LoadButtonClick( Sender: TObject ); begin Memo.Clear; Memo.Lines.Add( FormatDateTime( 'yyyy-mm-dd hh:nn:ss', Now ) + ' - Starting data load...' ); ClearAllCachesInForm( ); FDisplayStartIndex := 0; if Trim( FileNameEdit.Text ) = '' then begin Memo.Lines.Add( 'Error: File name cannot be empty.' ); MessageDlg( 'Please enter a file name.', mtWarning, [mbOK], 0 ); PaintBox.Invalidate; Exit; end; Memo.Lines.Add( 'Loading data from file: ' + FileNameEdit.Text ); if Assigned( FDataController ) then begin FDataController.LoadTickDataSeries( FileNameEdit.Text ); if FDataController.HasData then begin Memo.Lines.Add( FormatDateTime( 'yyyy-mm-dd hh:nn:ss', Now ) + Format( ' - Successfully loaded %d ticks by controller.', [FDataController.GetTickDataCount] ) ); EnsureCacheForCurrentTimeframe( True ); end else begin Memo.Lines.Add( FormatDateTime( 'yyyy-mm-dd hh:nn:ss', Now ) + ' - No tick data was loaded by controller, or an error occurred.' ); FActiveOHLCCache := nil; end; end else Memo.Lines.Add( 'Error: DataController is not initialized. Cannot load data.' ); PaintBox.Invalidate; end; procedure TChartForm.RefreshButtonClick( Sender: TObject ); var TargetStartTickIndex: Integer; VisibleStartIndexInOldCache: Integer; ActualNumVisibleCandlesOld: Integer; ChartDrawWidth, OldDisplaySlotWidth, NumCandlesDrawableOld: Integer; NewOptimalStartIndex, i: Integer; begin Memo.Lines.Add( FormatDateTime( 'yyyy-mm-dd hh:nn:ss', Now ) + ' - Refresh Button clicked...' ); TargetStartTickIndex := -1; if Assigned( FActiveOHLCCache ) and FActiveOHLCCache.IsValid and ( FActiveOHLCCache.Count > 0 ) and Assigned( FDataController ) and FDataController.HasData then begin VisibleStartIndexInOldCache := FDisplayStartIndex; if FCurrentPriceRect.Width > 0 then ChartDrawWidth := FCurrentPriceRect.Width else ChartDrawWidth := PaintBox.ClientWidth - ( 2 * DefaultChartMargin ); OldDisplaySlotWidth := FCandleWidthInPixels + FCandleSpacing; NumCandlesDrawableOld := 0; if OldDisplaySlotWidth > 0 then NumCandlesDrawableOld := ChartDrawWidth div OldDisplaySlotWidth; ActualNumVisibleCandlesOld := 0; if ( VisibleStartIndexInOldCache >= 0 ) and ( VisibleStartIndexInOldCache < FActiveOHLCCache.Count ) then ActualNumVisibleCandlesOld := Min( NumCandlesDrawableOld, FActiveOHLCCache.Count - VisibleStartIndexInOldCache ); if ActualNumVisibleCandlesOld > 0 then begin TargetStartTickIndex := FActiveOHLCCache.Candles[VisibleStartIndexInOldCache].OriginalDataIndexStart; Memo.Lines.Add( Format( 'Refresh S1: Old Cache Index %d maps to Original Tick Index: %d', [VisibleStartIndexInOldCache, TargetStartTickIndex] ) ); end else begin Memo.Lines.Add( 'Refresh S1: No candles visible in old cache or invalid start index. Defaulting to Tick Index 0.' ); TargetStartTickIndex := 0; end; end else begin Memo.Lines.Add( 'Refresh S1: No active cache or no raw data. Defaulting Target Tick Index to 0.' ); TargetStartTickIndex := 0; end; if Assigned( FDataController ) and FDataController.HasData then begin TargetStartTickIndex := Max( 0, TargetStartTickIndex ); if FDataController.GetTickDataLength > 0 then TargetStartTickIndex := Min( TargetStartTickIndex, FDataController.GetTickDataLength - 1 ) else TargetStartTickIndex := 0; end else TargetStartTickIndex := -1; Memo.Lines.Add( Format( 'Refresh S1a: Final Target Original Tick Index: %d', [TargetStartTickIndex] ) ); FCandleWidthInPixels := InitialCandleWidth; FCandleSpacing := InitialCandleSpacing; Memo.Lines.Add( Format( 'Refresh S2: Visuals (Candle Width/Spacing) reset to W:%dpx, S:%dpx.', [FCandleWidthInPixels, FCandleSpacing] ) ); EnsureCacheForCurrentTimeframe( True ); NewOptimalStartIndex := 0; if Assigned( FActiveOHLCCache ) and FActiveOHLCCache.IsValid and ( FActiveOHLCCache.Count > 0 ) and ( TargetStartTickIndex >= 0 ) and Assigned( FDataController ) and FDataController.HasData then begin for i := 0 to FActiveOHLCCache.Count - 1 do begin if FActiveOHLCCache.Candles[i].OriginalDataIndexStart >= TargetStartTickIndex then begin NewOptimalStartIndex := i; Break; end else if FActiveOHLCCache.Candles[i].OriginalDataIndexEnd >= TargetStartTickIndex then begin NewOptimalStartIndex := i; Break; end; if i = FActiveOHLCCache.Count - 1 then NewOptimalStartIndex := i; end; Memo.Lines.Add( Format( 'Refresh S4a: New Optimal Start Index in new cache = %d (for Original Tick Index %d).', [NewOptimalStartIndex, TargetStartTickIndex] ) ); FDisplayStartIndex := NewOptimalStartIndex; if FActiveOHLCCache.Count > 0 then begin FDisplayStartIndex := Max( 0, FDisplayStartIndex ); FDisplayStartIndex := Min( FDisplayStartIndex, FActiveOHLCCache.Count - 1 ); end else FDisplayStartIndex := 0; Memo.Lines.Add( Format( 'Refresh S4b: Final FDisplayStartIndex: %d (New Cache Candle Count: %d).', [FDisplayStartIndex, FActiveOHLCCache.Count] ) ); end else begin FDisplayStartIndex := 0; Memo.Lines.Add( 'Refresh S4c: New cache invalid/empty, or no target tick. FDisplayStartIndex reset to 0.' ); end; PaintBox.Invalidate; end; procedure TChartForm.FileSelectButtonClick( Sender: TObject ); var OpenDialog: TOpenDialog; InitialDirectory: string; SelectedPath, SelectedSymbol: string; SelectedYear: Integer; SelectedMonth: Integer; IsValidSyntax: Boolean; begin OpenDialog := TOpenDialog.Create( Self ); try OpenDialog.Title := 'Open Tick Data File'; OpenDialog.Filter := 'Tick Data Files (*.tab)|*.tab;*.tab_zip;*.tab_live|All Files (*.*)|*.*'; OpenDialog.DefaultExt := 'tab'; OpenDialog.Options := [ofFileMustExist, ofPathMustExist, ofHideReadOnly, ofEnableSizing]; if Trim( FileNameEdit.Text ) <> '' then begin InitialDirectory := System.IOUtils.TPath.GetDirectoryName( FileNameEdit.Text ); if ( InitialDirectory <> '' ) and System.IOUtils.TDirectory.Exists( InitialDirectory ) then begin OpenDialog.InitialDir := InitialDirectory; Memo.Lines.Add( 'FSButton: OpenDialog InitialDir set to: ' + InitialDirectory ); end else if InitialDirectory <> '' then Memo.Lines.Add( 'FSButton: Directory from FileNameEdit not found: ' + InitialDirectory ) else Memo.Lines.Add( 'FSButton: No directory information in FileNameEdit.' ); end else Memo.Lines.Add( 'FSButton: FileNameEdit is empty, using default InitialDir.' ); if OpenDialog.Execute then begin FileNameEdit.Text := OpenDialog.FileName; Memo.Lines.Add( FormatDateTime( 'yyyy-mm-dd hh:nn:ss', Now ) + ' - File selected via dialog: ' + OpenDialog.FileName ); IsValidSyntax := Myc.TickLoader.TryParseFileName( OpenDialog.FileName, SelectedPath, SelectedSymbol, SelectedYear, SelectedMonth ); if not IsValidSyntax then begin MessageDlg( Format( 'Warning: The selected filename ''%s'' does not match the expected format (Prefix_Symbol_Year_Month.tab).', [System.IOUtils.TPath.GetFileName( OpenDialog.FileName )] ), mtWarning, [mbOK], 0 ); Memo.Lines.Add( 'Warning: Incorrect filename syntax chosen by user.' ); end; LoadButtonClick( Self ); end else Memo.Lines.Add( FormatDateTime( 'yyyy-mm-dd hh:nn:ss', Now ) + ' - File selection dialog cancelled by user.' ); finally OpenDialog.Free; end; end; procedure TChartForm.PaintBoxMouseLeave( Sender: TObject ); begin if FMouseCurrentlyInChartArea then begin FMouseCurrentlyInChartArea := False; MouseHoverDataLabel.Caption := ''; PaintBox.Invalidate; end; FMouseLastChartPos := Point( -1, -1 ); end; procedure TChartForm.PaintBoxMouseDown( Sender: TObject; Button: TMouseButton; Shift: TShiftState; X, Y: Integer ); var IsOverPrice, IsOverVolume: Boolean; begin if Button = mbLeft then begin FMouseLastChartPos := Point( X, Y ); IsOverPrice := ( FCurrentPriceRect.Width > 0 ) and PtInRect( FCurrentPriceRect, FMouseLastChartPos ); IsOverVolume := ( FCurrentVolumeHistRect.Width > 0 ) and ( FCurrentVolumeHistRect.Height > 0 ) and PtInRect( FCurrentVolumeHistRect, FMouseLastChartPos ); FMouseCurrentlyInChartArea := IsOverPrice or IsOverVolume; if FMouseCurrentlyInChartArea and Assigned( FActiveOHLCCache ) and FActiveOHLCCache.IsValid and ( FActiveOHLCCache.Count > 0 ) then begin FIsDragging := True; FDragStartX := X; FDragStartDisplayIndex := FDisplayStartIndex; PaintBox.Cursor := crSizeWE; Memo.Lines.Add( FormatDateTime( 'yyyy-mm-dd hh:nn:ss', Now ) + Format( ' - Drag Start: MouseX=%d, StartDisplayIdx=%d', [X, FDisplayStartIndex] ) ); end else if Assigned( FActiveOHLCCache ) and FActiveOHLCCache.IsValid and ( FActiveOHLCCache.Count > 0 ) then begin Memo.Lines.Add( FormatDateTime( 'yyyy-mm-dd hh:nn:ss', Now ) + Format( ' - MouseDown at X=%d, Y=%d (outside chart drawing areas). No drag initiated.', [X, Y] ) ); end; if FMouseCurrentlyInChartArea then PaintBox.Invalidate; end; end; procedure TChartForm.PaintBoxMouseMove( Sender: TObject; Shift: TShiftState; X, Y: Integer ); var DeltaX, NumCandlesToScroll, PrevDisplayStartIndex, CurrentFixedSlotWidth, MaxPossibleStartIndex, ChartDrawWidthForDrag, DrawableCandlesInViewPort, RelativeX, CandleIndexInViewPort, ActualCacheIndex, CurrentDrawingSlotWidthForHover: Integer; HoveredCandle: TCachedCandle; UTCTimeStart, UTCTimeEnd, LocalTimeStart, LocalTimeEnd: TDateTime; StartTimeStr, EndTimeStr, TimeZoneStr, HoverInfo: string; IsOverPrice, IsOverVolume, NeedsInvalidate, OldMouseCurrentlyInChartArea: Boolean; begin OldMouseCurrentlyInChartArea := FMouseCurrentlyInChartArea; FMouseLastChartPos := Point( X, Y ); if FIsDragging then begin DeltaX := X - FDragStartX; CurrentFixedSlotWidth := FCandleWidthInPixels + FCandleSpacing; if CurrentFixedSlotWidth > 0 then begin NumCandlesToScroll := Round( -DeltaX / CurrentFixedSlotWidth ); PrevDisplayStartIndex := FDisplayStartIndex; FDisplayStartIndex := FDragStartDisplayIndex + NumCandlesToScroll; if FCurrentPriceRect.Width > 0 then ChartDrawWidthForDrag := FCurrentPriceRect.Width else ChartDrawWidthForDrag := PaintBox.ClientWidth - ( 2 * DefaultChartMargin ); DrawableCandlesInViewPort := Max( 1, ChartDrawWidthForDrag div CurrentFixedSlotWidth ); if Assigned( FActiveOHLCCache ) and ( FActiveOHLCCache.Count > 0 ) then begin MaxPossibleStartIndex := FActiveOHLCCache.Count - DrawableCandlesInViewPort; if MaxPossibleStartIndex < 0 then MaxPossibleStartIndex := 0; FDisplayStartIndex := Max( 0, FDisplayStartIndex ); FDisplayStartIndex := Min( FDisplayStartIndex, MaxPossibleStartIndex ); end else FDisplayStartIndex := 0; if FDisplayStartIndex <> PrevDisplayStartIndex then begin Memo.Lines.Add( FormatDateTime( 'yyyy-mm-dd hh:nn:ss', Now ) + Format( ' - Dragging: DeltaX=%d, NumScroll=%d, NewDisplayIdx=%d', [DeltaX, NumCandlesToScroll, FDisplayStartIndex] ) ); PaintBox.Invalidate; end; end; IsOverPrice := ( FCurrentPriceRect.Width > 0 ) and PtInRect( FCurrentPriceRect, FMouseLastChartPos ); IsOverVolume := ( FCurrentVolumeHistRect.Width > 0 ) and PtInRect( FCurrentVolumeHistRect, FMouseLastChartPos ); FMouseCurrentlyInChartArea := IsOverPrice or IsOverVolume; if FMouseCurrentlyInChartArea <> OldMouseCurrentlyInChartArea then PaintBox.Invalidate; MouseHoverDataLabel.Caption := ''; Exit; end; NeedsInvalidate := False; IsOverPrice := ( FCurrentPriceRect.Width > 0 ) and PtInRect( FCurrentPriceRect, FMouseLastChartPos ); IsOverVolume := ( FCurrentVolumeHistRect.Width > 0 ) and PtInRect( FCurrentVolumeHistRect, FMouseLastChartPos ); FMouseCurrentlyInChartArea := IsOverPrice or IsOverVolume; if FMouseCurrentlyInChartArea <> OldMouseCurrentlyInChartArea then NeedsInvalidate := True; if FMouseCurrentlyInChartArea then NeedsInvalidate := True; if FMouseCurrentlyInChartArea and Assigned( FActiveOHLCCache ) and FActiveOHLCCache.IsValid and ( FActiveOHLCCache.Count > 0 ) and Assigned( FDataController ) and FDataController.HasData then begin CurrentDrawingSlotWidthForHover := FCandleWidthInPixels + FCandleSpacing; if CurrentDrawingSlotWidthForHover > 0 then begin RelativeX := FMouseLastChartPos.X - FCurrentPriceRect.Left; CandleIndexInViewPort := RelativeX div CurrentDrawingSlotWidthForHover; ActualCacheIndex := FDisplayStartIndex + CandleIndexInViewPort; if ( ActualCacheIndex >= 0 ) and ( ActualCacheIndex < FActiveOHLCCache.Count ) then begin HoveredCandle := FActiveOHLCCache.Candles[ActualCacheIndex]; if ( HoveredCandle.OriginalDataIndexStart >= 0 ) and ( HoveredCandle.OriginalDataIndexStart < FDataController.GetTickDataLength ) and ( HoveredCandle.OriginalDataIndexEnd >= 0 ) and ( HoveredCandle.OriginalDataIndexEnd < FDataController.GetTickDataLength ) then begin UTCTimeStart := FDataController.GetTickDataItem( HoveredCandle.OriginalDataIndexStart ).OADateTime; UTCTimeEnd := FDataController.GetTickDataItem( HoveredCandle.OriginalDataIndexEnd ).OADateTime; LocalTimeStart := TTimeZone.Local.ToLocalTime( UTCTimeStart ); LocalTimeEnd := TTimeZone.Local.ToLocalTime( UTCTimeEnd ); TimeZoneStr := TTimeZone.Local.GetAbbreviation( LocalTimeStart ); StartTimeStr := FormatDateTime( 'yy-mm-dd hh:nn:ss', LocalTimeStart ); if System.DateUtils.DateOf( LocalTimeStart ) <> System.DateUtils.DateOf( LocalTimeEnd ) then EndTimeStr := FormatDateTime( 'yy-mm-dd hh:nn:ss', LocalTimeEnd ) else EndTimeStr := FormatDateTime( 'hh:nn:ss', LocalTimeEnd ); HoverInfo := Format( 'S: %s E: %s (%s) O:%.5f H:%.5f L:%.5f C:%.5f Vol:%d', [StartTimeStr, EndTimeStr, TimeZoneStr, HoveredCandle.OpenPrice, HoveredCandle.HighPrice, HoveredCandle.LowPrice, HoveredCandle.ClosePrice, HoveredCandle.TickVolume] ); MouseHoverDataLabel.Caption := HoverInfo; end else MouseHoverDataLabel.Caption := 'Candle (Original Tick Index Error)'; end else MouseHoverDataLabel.Caption := ''; end else MouseHoverDataLabel.Caption := ''; end else MouseHoverDataLabel.Caption := ''; if NeedsInvalidate then PaintBox.Invalidate; end; procedure TChartForm.PaintBoxMouseUp( Sender: TObject; Button: TMouseButton; Shift: TShiftState; X, Y: Integer ); begin if Button = mbLeft then begin if FIsDragging then begin FIsDragging := False; PaintBox.Cursor := crDefault; Memo.Lines.Add( FormatDateTime( 'yyyy-mm-dd hh:nn:ss', Now ) + ' - Drag End. Final DisplayIdx=' + IntToStr( FDisplayStartIndex ) ); FMouseLastChartPos := Point( X, Y ); PaintBoxMouseMove( Sender, Shift, X, Y ); end; end; end; procedure TChartForm.PaintBoxPaint( Sender: TObject ); begin if not Assigned( FBufferBitmap ) then begin Memo.Lines.Add( 'Error: FBufferBitmap not assigned in PaintBoxPaint.' ); Exit; end; if ( PaintBox.ClientWidth <= 0 ) or ( PaintBox.ClientHeight <= 0 ) then begin if Assigned( PaintBox.Canvas ) then PaintBox.Canvas.FillRect( PaintBox.ClientRect ); Exit; end; if ( FBufferBitmap.Width <> PaintBox.ClientWidth ) or ( FBufferBitmap.Height <> PaintBox.ClientHeight ) then begin FBufferBitmap.SetSize( PaintBox.ClientWidth, PaintBox.ClientHeight ); end; DrawChart( FBufferBitmap.Canvas, Rect( 0, 0, FBufferBitmap.Width, FBufferBitmap.Height ) ); PaintBox.Canvas.Draw( 0, 0, FBufferBitmap ); end; procedure TChartForm.DrawChart( ACanvas: TCanvas; ARect: TRect ); const DefaultVolumeAreaHeightRatio = 0.20; MinVolumeAreaPixelHeight = 30; MaxVolumeAreaPixelHeight = 80; PriceToVolumeSeparatorPixels = 5; CrosshairPriceTimeMargin = 3; var LoopIdx, CacheIdx: Integer; ScaleY: Double; Y_open, Y_high, Y_low, Y_close: Integer; NumCachedCandlesInActiveCache: Integer; CurrentDrawingSlotWidth: Integer; ActualNumCandlesToDraw: Integer; CurrentCandleXStart: Integer; X_wick_center: Integer; BodyRect: TRect; CachedCandleRec: TCachedCandle; NumCandlesDrawableInViewPort: Integer; LocalMinYValue, LocalMaxYValue: Single; VisibleCandleForScale: TCachedCandle; k_scale: Integer; CurrentMinYToUse, CurrentMaxYToUse: Single; ExpansionValue: Single; DrawableContentRect, PriceRect_Local, VolumeHistRect_Local: TRect; ActualVolumeHistAreaHeight: Integer; MaxVisibleTickVolume: Integer; VolumeScaleY: Double; VolumeBarHeight: Integer; VolBarRect: TRect; RelativeX, CandleIndexInViewPortForCrosshair, VerticalLineX: Integer; ShowVolumeBars: Boolean; PriceAtMouseY: Double; PriceStr, TimeStr: string; TextX, TextY: Integer; CrosshairCandle: TCachedCandle; UTCTimeStart, LocalTimeStart: TDateTime; OriginalPenStyle: TPenStyle; OriginalPenWidth: Integer; OriginalFontColor: TColor; OriginalBrushStyle: TBrushStyle; OriginalFontSize: Integer; // For saving and restoring font size begin ACanvas.Brush.Color := clWhite; ACanvas.FillRect( ARect ); FCurrentPriceRect := Rect( 0, 0, 0, 0 ); FCurrentVolumeHistRect := Rect( 0, 0, 0, 0 ); if not Assigned( FActiveOHLCCache ) or not FActiveOHLCCache.IsValid or ( FActiveOHLCCache.Count = 0 ) then begin ACanvas.Font.Color := clGray; ACanvas.TextOut( ARect.Left + 10, ARect.Top + 10, 'No data in active cache or cache is invalid.' ); Exit; end; DrawableContentRect := Rect( ARect.Left + DefaultChartMargin, ARect.Top + DefaultChartMargin, ARect.Right - DefaultChartMargin, ARect.Bottom - DefaultChartMargin ); if ( DrawableContentRect.Width <= 0 ) or ( DrawableContentRect.Height <= 0 ) then begin ACanvas.Font.Color := clRed; ACanvas.TextOut( ARect.Left + 10, ARect.Top + 10, 'Drawable area too small.' ); Exit; end; ShowVolumeBars := ( FActiveOHLCCache.AggregationTimeframeSeconds <> TF_AUTO_AGGREGATION ) and ( FActiveOHLCCache.AggregationTimeframeSeconds <> TF_TICK_BY_TICK ); ActualVolumeHistAreaHeight := 0; if ShowVolumeBars then begin ActualVolumeHistAreaHeight := Round( DrawableContentRect.Height * DefaultVolumeAreaHeightRatio ); ActualVolumeHistAreaHeight := Max( MinVolumeAreaPixelHeight, Min( ActualVolumeHistAreaHeight, MaxVolumeAreaPixelHeight ) ); if DrawableContentRect.Height < ( ActualVolumeHistAreaHeight + PriceToVolumeSeparatorPixels + 50 ) then begin ActualVolumeHistAreaHeight := Max( 0, DrawableContentRect.Height - PriceToVolumeSeparatorPixels - 50 ); if ActualVolumeHistAreaHeight < MinVolumeAreaPixelHeight then ActualVolumeHistAreaHeight := 0; end; end; if ActualVolumeHistAreaHeight <= 0 then ShowVolumeBars := False; if ShowVolumeBars then begin VolumeHistRect_Local := Rect( DrawableContentRect.Left, DrawableContentRect.Bottom - ActualVolumeHistAreaHeight, DrawableContentRect.Right, DrawableContentRect.Bottom ); PriceRect_Local := Rect( DrawableContentRect.Left, DrawableContentRect.Top, DrawableContentRect.Right, VolumeHistRect_Local.Top - PriceToVolumeSeparatorPixels ); end else begin // Corrected block for when ShowVolumeBars is false PriceRect_Local := DrawableContentRect; VolumeHistRect_Local := Rect( 0, 0, 0, 0 ); end; if ( PriceRect_Local.Height <= 0 ) then begin ACanvas.Font.Color := clRed; ACanvas.TextOut( ARect.Left + 10, ARect.Top + 10, 'Price chart area too small to draw.' ); Exit; end; FCurrentPriceRect := PriceRect_Local; FCurrentVolumeHistRect := VolumeHistRect_Local; NumCachedCandlesInActiveCache := FActiveOHLCCache.Count; CurrentDrawingSlotWidth := FCandleWidthInPixels + FCandleSpacing; if CurrentDrawingSlotWidth <= 0 then Exit; NumCandlesDrawableInViewPort := PriceRect_Local.Width div CurrentDrawingSlotWidth; ActualNumCandlesToDraw := 0; if ( FDisplayStartIndex >= 0 ) and ( FDisplayStartIndex < NumCachedCandlesInActiveCache ) then ActualNumCandlesToDraw := Min( NumCandlesDrawableInViewPort, NumCachedCandlesInActiveCache - FDisplayStartIndex ); MaxVisibleTickVolume := 0; if ActualNumCandlesToDraw > 0 then begin VisibleCandleForScale := FActiveOHLCCache.Candles[FDisplayStartIndex]; LocalMinYValue := VisibleCandleForScale.LowPrice; LocalMaxYValue := VisibleCandleForScale.HighPrice; if ShowVolumeBars then MaxVisibleTickVolume := VisibleCandleForScale.TickVolume; for k_scale := 1 to ActualNumCandlesToDraw - 1 do begin CacheIdx := FDisplayStartIndex + k_scale; VisibleCandleForScale := FActiveOHLCCache.Candles[CacheIdx]; if VisibleCandleForScale.LowPrice < LocalMinYValue then LocalMinYValue := VisibleCandleForScale.LowPrice; if VisibleCandleForScale.HighPrice > LocalMaxYValue then LocalMaxYValue := VisibleCandleForScale.HighPrice; if ShowVolumeBars and ( VisibleCandleForScale.TickVolume > MaxVisibleTickVolume ) then MaxVisibleTickVolume := VisibleCandleForScale.TickVolume; end; if Abs( LocalMaxYValue - LocalMinYValue ) < 1E-6 then begin ExpansionValue := Max( Abs( LocalMinYValue ) * 0.001, 0.0001 ); LocalMaxYValue := LocalMinYValue + ExpansionValue; LocalMinYValue := LocalMinYValue - ExpansionValue; end; CurrentMinYToUse := LocalMinYValue; CurrentMaxYToUse := LocalMaxYValue; if ShowVolumeBars and ( MaxVisibleTickVolume = 0 ) then MaxVisibleTickVolume := 1; end else begin CurrentMinYToUse := FActiveOHLCCache.GlobalMinY; CurrentMaxYToUse := FActiveOHLCCache.GlobalMaxY; if Abs( CurrentMaxYToUse - CurrentMinYToUse ) < 1E-6 then begin CurrentMaxYToUse := CurrentMinYToUse + 0.0002; CurrentMinYToUse := CurrentMinYToUse - 0.0002; end; end; OriginalFontSize := ACanvas.Font.Size; // Save original font size ACanvas.Font.Size := 8; ACanvas.Font.Color := clGray; ACanvas.TextOut( PriceRect_Local.Left + 3, PriceRect_Local.Top + 3, Format( '%.5f', [CurrentMaxYToUse] ) ); ACanvas.TextOut( PriceRect_Local.Left + 3, PriceRect_Local.Bottom - ACanvas.TextHeight( '0' ) - 3, Format( '%.5f', [CurrentMinYToUse] ) ); if ShowVolumeBars and ( VolumeHistRect_Local.Height > 0 ) then ACanvas.TextOut( VolumeHistRect_Local.Left + 3, VolumeHistRect_Local.Top + 3, Format( '%d', [MaxVisibleTickVolume] ) ); ACanvas.Font.Size := OriginalFontSize; // Restore original font size if Abs( CurrentMaxYToUse - CurrentMinYToUse ) < 1E-7 then ScaleY := 1.0 else ScaleY := PriceRect_Local.Height / ( CurrentMaxYToUse - CurrentMinYToUse ); if ScaleY <= 0 then ScaleY := 1.0; VolumeScaleY := 0; if ShowVolumeBars and ( VolumeHistRect_Local.Height > 0 ) and ( MaxVisibleTickVolume > 0 ) then VolumeScaleY := VolumeHistRect_Local.Height / MaxVisibleTickVolume; CurrentCandleXStart := PriceRect_Local.Left; if ActualNumCandlesToDraw > 0 then begin for LoopIdx := 0 to ActualNumCandlesToDraw - 1 do begin CacheIdx := FDisplayStartIndex + LoopIdx; CachedCandleRec := FActiveOHLCCache.Candles[CacheIdx]; Y_high := PriceRect_Local.Bottom - Round( ( CachedCandleRec.HighPrice - CurrentMinYToUse ) * ScaleY ); Y_low := PriceRect_Local.Bottom - Round( ( CachedCandleRec.LowPrice - CurrentMinYToUse ) * ScaleY ); Y_open := PriceRect_Local.Bottom - Round( ( CachedCandleRec.OpenPrice - CurrentMinYToUse ) * ScaleY ); Y_close := PriceRect_Local.Bottom - Round( ( CachedCandleRec.ClosePrice - CurrentMinYToUse ) * ScaleY ); Y_high := Max( PriceRect_Local.Top, Min( PriceRect_Local.Bottom, Y_high ) ); Y_low := Max( PriceRect_Local.Top, Min( PriceRect_Local.Bottom, Y_low ) ); Y_open := Max( PriceRect_Local.Top, Min( PriceRect_Local.Bottom, Y_open ) ); Y_close := Max( PriceRect_Local.Top, Min( PriceRect_Local.Bottom, Y_close ) ); X_wick_center := CurrentCandleXStart + ( FCandleWidthInPixels div 2 ); ACanvas.Pen.Color := clBlack; ACanvas.Pen.Width := 1; ACanvas.MoveTo( X_wick_center, Y_high ); ACanvas.LineTo( X_wick_center, Y_low ); BodyRect.Left := CurrentCandleXStart; BodyRect.Right := CurrentCandleXStart + FCandleWidthInPixels; BodyRect.Top := Min( Y_open, Y_close ); BodyRect.Bottom := Max( Y_open, Y_close ); if Abs( Y_open - Y_close ) < 1 then begin ACanvas.Pen.Width := DojiPenWidth; ACanvas.MoveTo( BodyRect.Left, Y_open ); ACanvas.LineTo( BodyRect.Right, Y_open ); ACanvas.Pen.Width := 1; end else begin if BodyRect.Bottom <= BodyRect.Top then BodyRect.Bottom := BodyRect.Top + 1; if CachedCandleRec.ClosePrice > CachedCandleRec.OpenPrice then ACanvas.Brush.Color := clWhite else ACanvas.Brush.Color := clBlack; ACanvas.Pen.Color := clBlack; ACanvas.Rectangle( BodyRect ); end; if ShowVolumeBars and ( VolumeScaleY > 0 ) then begin VolumeBarHeight := Round( CachedCandleRec.TickVolume * VolumeScaleY ); VolumeBarHeight := Max( 0, Min( VolumeBarHeight, VolumeHistRect_Local.Height ) ); VolBarRect := Rect( CurrentCandleXStart, VolumeHistRect_Local.Bottom - VolumeBarHeight, CurrentCandleXStart + FCandleWidthInPixels, VolumeHistRect_Local.Bottom ); if CachedCandleRec.ClosePrice > CachedCandleRec.OpenPrice then ACanvas.Brush.Color := TColor( $00B0F0B0 ) else if CachedCandleRec.ClosePrice < CachedCandleRec.OpenPrice then ACanvas.Brush.Color := TColor( $00B0B0F0 ) else ACanvas.Brush.Color := clSilver; ACanvas.Pen.Color := clGray; ACanvas.Rectangle( VolBarRect ); end; CurrentCandleXStart := CurrentCandleXStart + CurrentDrawingSlotWidth; end; end; ACanvas.Pen.Style := psSolid; ACanvas.Pen.Width := 1; ACanvas.Pen.Color := clBlack; ACanvas.Brush.Style := bsClear; ACanvas.Rectangle( PriceRect_Local ); if ShowVolumeBars and ( VolumeHistRect_Local.Width > 0 ) and ( VolumeHistRect_Local.Height > 0 ) then ACanvas.Rectangle( VolumeHistRect_Local ); if FMouseCurrentlyInChartArea and Assigned( FActiveOHLCCache ) and FActiveOHLCCache.IsValid and ( FActiveOHLCCache.Count > 0 ) and ( FMouseLastChartPos.X >= 0 ) and Assigned( FDataController ) and FDataController.HasData then begin OriginalPenStyle := ACanvas.Pen.Style; OriginalPenWidth := ACanvas.Pen.Width; OriginalFontColor := ACanvas.Font.Color; OriginalBrushStyle := ACanvas.Brush.Style; OriginalFontSize := ACanvas.Font.Size; ACanvas.Pen.Color := clGray; ACanvas.Pen.Style := psDot; ACanvas.Pen.Width := 1; ACanvas.Font.Color := clWindowText; ACanvas.Brush.Style := bsClear; ACanvas.Font.Size := 8; var HorzYClamped := Max( DrawableContentRect.Top, Min( FMouseLastChartPos.Y, DrawableContentRect.Bottom ) ); ACanvas.MoveTo( DrawableContentRect.Left, HorzYClamped ); ACanvas.LineTo( DrawableContentRect.Right, HorzYClamped ); if ( ScaleY > 1E-6 ) and ( PriceRect_Local.Height > 0 ) then begin PriceAtMouseY := CurrentMinYToUse + ( ( PriceRect_Local.Bottom - HorzYClamped ) / ScaleY ); PriceStr := Format( '%.5f', [PriceAtMouseY] ); TextX := DrawableContentRect.Right + CrosshairPriceTimeMargin; TextY := HorzYClamped - ( ACanvas.TextHeight( PriceStr ) div 2 ); ACanvas.TextOut( TextX, TextY, PriceStr ); end; if CurrentDrawingSlotWidth > 0 then begin RelativeX := FMouseLastChartPos.X - PriceRect_Local.Left; if RelativeX >= 0 then begin CandleIndexInViewPortForCrosshair := RelativeX div CurrentDrawingSlotWidth; VerticalLineX := PriceRect_Local.Left + ( CandleIndexInViewPortForCrosshair * CurrentDrawingSlotWidth ) + ( FCandleWidthInPixels div 2 ); if ( VerticalLineX >= DrawableContentRect.Left ) and ( VerticalLineX <= DrawableContentRect.Right ) then begin ACanvas.MoveTo( VerticalLineX, DrawableContentRect.Top ); ACanvas.LineTo( VerticalLineX, DrawableContentRect.Bottom ); var ActualCacheIdxForCrosshair := FDisplayStartIndex + CandleIndexInViewPortForCrosshair; if ( ActualCacheIdxForCrosshair >= 0 ) and ( ActualCacheIdxForCrosshair < FActiveOHLCCache.Count ) then begin CrosshairCandle := FActiveOHLCCache.Candles[ActualCacheIdxForCrosshair]; if ( CrosshairCandle.OriginalDataIndexStart >= 0 ) and ( CrosshairCandle.OriginalDataIndexStart < FDataController.GetTickDataLength ) then begin UTCTimeStart := FDataController.GetTickDataItem( CrosshairCandle.OriginalDataIndexStart ).OADateTime; LocalTimeStart := TTimeZone.Local.ToLocalTime( UTCTimeStart ); TimeStr := FormatDateTime( 'hh:nn:ss', LocalTimeStart ); TextX := VerticalLineX - ( ACanvas.TextWidth( TimeStr ) div 2 ); TextY := DrawableContentRect.Top - ACanvas.TextHeight( TimeStr ) - CrosshairPriceTimeMargin; if TextY < ARect.Top then TextY := ARect.Top; ACanvas.TextOut( TextX, TextY, TimeStr ); end; end; end; end; end; ACanvas.Pen.Style := OriginalPenStyle; ACanvas.Pen.Width := OriginalPenWidth; ACanvas.Font.Color := OriginalFontColor; ACanvas.Brush.Style := OriginalBrushStyle; ACanvas.Font.Size := OriginalFontSize; end; end; procedure TChartForm.FormMouseWheel( Sender: TObject; Shift: TShiftState; WheelDelta: Integer; MousePos: TPoint; var Handled: Boolean ); const MinDisplayCandleWidth = 1; MaxDisplayCandleWidth = 100; ZoomStep = 1; var LMousePosInPaintBox: TPoint; OldCandleWidthInPixels, OriginalFDisplayStartIndex: Integer; MouseX_InChartArea_Relative: Integer; OldSlotWidth, NewSlotWidth: Integer; GlobalCandleIndexUnderMouse_Float: Double; ChartDrawWidth, DrawableCandlesInViewPort, MaxPossibleStartIndex: Integer; ZoomToCursorActive: Boolean; begin Handled := False; LMousePosInPaintBox := PaintBox.ScreenToClient( MousePos ); if not PtInRect( PaintBox.ClientRect, LMousePosInPaintBox ) then Exit; ZoomToCursorActive := Assigned( FActiveOHLCCache ) and FActiveOHLCCache.IsValid and ( FActiveOHLCCache.Count > 0 ) and ( FCurrentPriceRect.Width > 0 ) and PtInRect( FCurrentPriceRect, LMousePosInPaintBox ); OriginalFDisplayStartIndex := FDisplayStartIndex; OldCandleWidthInPixels := FCandleWidthInPixels; if WheelDelta > 0 then FCandleWidthInPixels := Min( MaxDisplayCandleWidth, FCandleWidthInPixels + ZoomStep ) else if WheelDelta < 0 then FCandleWidthInPixels := Max( MinDisplayCandleWidth, FCandleWidthInPixels - ZoomStep ) else Exit; if FCandleWidthInPixels = OldCandleWidthInPixels then Exit; NewSlotWidth := FCandleWidthInPixels + FCandleSpacing; if NewSlotWidth <= 0 then begin FCandleWidthInPixels := OldCandleWidthInPixels; Exit; end; if ZoomToCursorActive then begin OldSlotWidth := OldCandleWidthInPixels + FCandleSpacing; if OldSlotWidth <= 0 then ZoomToCursorActive := False else begin MouseX_InChartArea_Relative := LMousePosInPaintBox.X - FCurrentPriceRect.Left; GlobalCandleIndexUnderMouse_Float := OriginalFDisplayStartIndex + ( MouseX_InChartArea_Relative / OldSlotWidth ); FDisplayStartIndex := Round( GlobalCandleIndexUnderMouse_Float - ( MouseX_InChartArea_Relative / NewSlotWidth ) ); end; end; if FCurrentPriceRect.Width > 0 then ChartDrawWidth := FCurrentPriceRect.Width else ChartDrawWidth := PaintBox.ClientWidth - ( 2 * DefaultChartMargin ); DrawableCandlesInViewPort := Max( 1, ChartDrawWidth div NewSlotWidth ); MaxPossibleStartIndex := 0; if Assigned( FActiveOHLCCache ) and ( FActiveOHLCCache.Count > 0 ) then MaxPossibleStartIndex := FActiveOHLCCache.Count - DrawableCandlesInViewPort; if MaxPossibleStartIndex < 0 then MaxPossibleStartIndex := 0; FDisplayStartIndex := Max( 0, FDisplayStartIndex ); FDisplayStartIndex := Min( FDisplayStartIndex, MaxPossibleStartIndex ); if Assigned( FActiveOHLCCache ) and ( FActiveOHLCCache.Count = 0 ) then FDisplayStartIndex := 0; Memo.Lines.Add( Format( 'MouseWheel: CW: %d->%d. NewStartIdx: %d. ZoomToCursor: %s', [OldCandleWidthInPixels, FCandleWidthInPixels, FDisplayStartIndex, BoolToStr( ZoomToCursorActive, True )] ) ); PaintBox.Invalidate; Handled := True; PaintBoxMouseMove( Sender, Shift, LMousePosInPaintBox.X, LMousePosInPaintBox.Y ); end; procedure TChartForm.GoToCurrentButtonClick( Sender: TObject ); var ChartDrawWidth, CurrentDrawingSlotWidth, NumCandlesDrawableInViewPort, NewDisplayStartIndex: Integer; begin Memo.Lines.Add( FormatDateTime( 'yyyy-mm-dd hh:nn:ss', Now ) + ' - GoToCurrentButton clicked.' ); if not Assigned( FActiveOHLCCache ) or not FActiveOHLCCache.IsValid or ( FActiveOHLCCache.Count = 0 ) then begin Memo.Lines.Add( 'GoToCurrent: No data or cache invalid.' ); Exit; end; if FCurrentPriceRect.Width > 0 then ChartDrawWidth := FCurrentPriceRect.Width else ChartDrawWidth := PaintBox.ClientWidth - ( 2 * DefaultChartMargin ); CurrentDrawingSlotWidth := FCandleWidthInPixels + FCandleSpacing; if CurrentDrawingSlotWidth <= 0 then begin Memo.Lines.Add( 'GoToCurrent: Invalid candle slot width.' ); Exit; end; NumCandlesDrawableInViewPort := Max( 1, ChartDrawWidth div CurrentDrawingSlotWidth ); NewDisplayStartIndex := Max( 0, FActiveOHLCCache.Count - NumCandlesDrawableInViewPort ); if FActiveOHLCCache.Count > 0 then NewDisplayStartIndex := Min( NewDisplayStartIndex, FActiveOHLCCache.Count - 1 ); if FDisplayStartIndex <> NewDisplayStartIndex then begin FDisplayStartIndex := NewDisplayStartIndex; Memo.Lines.Add( Format( 'GoToCurrent: Scrolled to display last candles. New StartIdx: %d', [FDisplayStartIndex] ) ); PaintBox.Invalidate; end else Memo.Lines.Add( 'GoToCurrent: Already showing the latest candles.' ); end; end.