unit Myc.Fmx.Chart; interface uses System.SysUtils, System.Classes, System.Types, System.Generics.Collections, System.UITypes, System.UIConsts, System.Messaging, System.Math.Vectors, FMX.Types, FMX.Controls, FMX.Graphics, FMX.Forms, Myc.Trade.Types, Myc.Trade.DataPoint, Myc.Signals, Myc.Lazy; type TMycChart = class(TStyledControl) public type TPanel = class; // Abstract base for a data series within the chart. TSeries = class abstract(TObject) protected function GetCount: Int64; virtual; abstract; function GetTotalCount: Int64; virtual; abstract; public property Count: Int64 read GetCount; property TotalCount: Int64 read GetTotalCount; end; // Abstract base for a drawable layer in a panel. TLayer = class abstract(TObject) private FParent: TPanel; function GetOwner: TMycChart; protected function GetSeries: TSeries; virtual; abstract; procedure Update; virtual; abstract; function GetValueRange(First, Last: Int64; out Min, Max: Double): Boolean; virtual; abstract; procedure Paint(const Canvas: TCanvas; First, Last: Int64; const XForm, YForm: TFunc); virtual; abstract; public constructor Create(AOwner: TPanel); procedure Repaint; property Owner: TMycChart read GetOwner; property Series: TSeries read GetSeries; end; // A panel is a rectangular area in the chart with its own Y-axis, which can contain multiple layers. TPanel = class(TObject) private FOwner: TMycChart; FSeriesList: TObjectList; protected procedure Paint(const Canvas: TCanvas; const Viewport: TRectF; MasterTotalCount: Int64; ViewStartIndex, ViewCount: Int64); procedure UpdateAndCheckChanges(MasterTotalCount: Int64; ViewStartIndex: Int64; out SeriesChanged, SeriesRepaint: Boolean); public constructor Create(AOwner: TMycChart); destructor Destroy; override; function AddOhlcSeries( const DataProvider: IMycDataProvider; const AUpColor: TAlphaColor = TAlphaColors.Green; const ADownColor: TAlphaColor = TAlphaColors.Red ): TMycChart.TLayer; function AddDoubleSeries( const DataProvider: IMycDataProvider; const ALineColor: TAlphaColor = TAlphaColors.Cornflowerblue; const ALineWidth: Single = 1.5 ): TMycChart.TLayer; property Owner: TMycChart read FOwner; end; private FPanelList: TObjectList; FXAxisSeries: TMycChart.TLayer; FLookback: TWriteable; FIdleSubscrId: TMessageSubscriptionId; FViewStartIndex: Int64; FViewCount: Int64; FIsDragging: Boolean; FDragStartPoint: TPointF; FJumpButtonRect: TRectF; FJumpButtonHot: Boolean; FJumpButtonPressed: Boolean; FNeedRepaint: TFlag; function GetPanel(Index: Integer): TPanel; function GetPanelCount: Integer; protected procedure Paint; override; procedure DoIdle; procedure MouseWheel(Shift: TShiftState; WheelDelta: Integer; var Handled: Boolean); override; procedure MouseDown(Button: TMouseButton; Shift: TShiftState; X, Y: Single); override; procedure MouseMove(Shift: TShiftState; X, Y: Single); override; procedure MouseUp(Button: TMouseButton; Shift: TShiftState; X, Y: Single); override; public constructor Create(AOwner: TComponent); override; destructor Destroy; override; // Adds a new, empty panel to the bottom of the chart. function AddPanel: TPanel; // Sets the master series that defines the time scale (X-axis). This series is not drawn. function SetXAxisSeries(const DataProvider: IMycDataProvider): TMycChart.TLayer; property Lookback: TWriteable read FLookback write FLookback; property NeedRepaint: TFlag read FNeedRepaint; property PanelCount: Integer read GetPanelCount; property Panels[Index: Integer]: TPanel read GetPanel; default; end; implementation uses System.Math, Myc.FMX.Chart.Series; { TMycChart } constructor TMycChart.Create(AOwner: TComponent); begin inherited Create(AOwner); FPanelList := TObjectList.Create(true); FXAxisSeries := nil; FLookback := TWriteable.CreateWriteable(1000); FViewStartIndex := 0; FViewCount := 100; FIsDragging := false; FIdleSubscrId := TMessageManager .DefaultManager .SubscribeToMessage(TIdleMessage, procedure(const Sender: TObject; const M: TMessage) begin DoIdle; end); // Create the default panel. AddPanel; end; destructor TMycChart.Destroy; begin TMessageManager.DefaultManager.Unsubscribe(TIdleMessage, FIdleSubscrId); FXAxisSeries.Free; FPanelList.Free; inherited; end; function TMycChart.AddPanel: TPanel; begin Result := TPanel.Create(Self); FPanelList.Add(Result); Repaint; end; function TMycChart.GetPanel(Index: Integer): TPanel; begin Result := FPanelList[Index]; end; function TMycChart.GetPanelCount: Integer; begin Result := FPanelList.Count; end; function TMycChart.SetXAxisSeries(const DataProvider: IMycDataProvider): TMycChart.TLayer; begin FXAxisSeries.Free; // Note: TChartCustomLayer needs its owner to be a TPanel. We use the first panel. var counter := TChartSeriesCounter.Create; DataProvider.Link(counter); FXAxisSeries := TChartCustomLayer.Create(Self.Panels[0], counter.Sender); Result := FXAxisSeries; end; procedure TMycChart.DoIdle; var prevTotalCount, newTotalCount: Int64; seriesChanged, seriesRepaint, repaintSignal: Boolean; countDelta: Int64; begin if not Assigned(FXAxisSeries) then exit; prevTotalCount := FXAxisSeries.Series.TotalCount; FXAxisSeries.Update; newTotalCount := FXAxisSeries.Series.TotalCount; seriesChanged := prevTotalCount <> newTotalCount; seriesRepaint := (FViewStartIndex = 0); for var panel in FPanelList do begin var panelChanged, panelRepaint: Boolean; panel.UpdateAndCheckChanges(prevTotalCount, FViewStartIndex, panelChanged, panelRepaint); seriesChanged := seriesChanged or panelChanged; seriesRepaint := seriesRepaint or panelRepaint; end; if (FViewStartIndex > 0) then begin countDelta := newTotalCount - prevTotalCount; if (countDelta > 0) then begin FViewStartIndex := FViewStartIndex + countDelta; if (FViewStartIndex + FViewCount > newTotalCount) then FViewStartIndex := newTotalCount - FViewCount; end; end; repaintSignal := FNeedRepaint.Reset; if repaintSignal or (seriesRepaint and seriesChanged) then Repaint; end; procedure TMycChart.Paint; var rect, panelRect: TRectF; isButtonVisible: Boolean; buttonColor: TAlphaColor; path: TPathData; masterTotalCount: Int64; panelHeight: Single; begin inherited; rect := Self.LocalRect; if (not Assigned(FXAxisSeries)) or (FXAxisSeries.Series.Count <= 1) or (FViewCount <= 1) or (PanelCount = 0) then begin Canvas.Fill.Color := TAlphaColors.Gray; Canvas.FillText(rect, 'No Data', false, 1, [], TTextAlign.Center, TTextAlign.Center); Exit; end; masterTotalCount := FXAxisSeries.Series.TotalCount; panelHeight := rect.Height / PanelCount; for var i := 0 to PanelCount - 1 do begin var panel := Panels[i]; panelRect := TRectF.Create(rect.Left, rect.Top + (i * panelHeight), rect.Right, rect.Top + ((i + 1) * panelHeight)); // Draw panel content panel.Paint(Self.Canvas, panelRect, masterTotalCount, FViewStartIndex, FViewCount); // Draw separator line if (i > 0) then begin Canvas.Stroke.Color := TAlphaColors.Gray; Canvas.Stroke.Thickness := 1; Canvas.DrawLine(PointF(panelRect.Left, panelRect.Top), PointF(panelRect.Right, panelRect.Top), 1); end; end; // --- Draw the "jump to latest" button (code unchanged) --- isButtonVisible := (FViewStartIndex > 0); if isButtonVisible then begin FJumpButtonRect := TRectF.Create(Self.Width - 44, Self.Height - 44, Self.Width - 10, Self.Height - 10); if FJumpButtonPressed then buttonColor := $FF707070 else if FJumpButtonHot then buttonColor := $FF505050 else buttonColor := $FF303030; Canvas.Fill.Color := buttonColor; Canvas.FillRect(FJumpButtonRect, 4, 4, AllCorners, 0.7); Canvas.Stroke.Color := TAlphaColors.White; Canvas.Stroke.Thickness := 1.5; path := TPathData.Create; try var cx := FJumpButtonRect.CenterPoint.X; var cy := FJumpButtonRect.CenterPoint.Y; path.MoveTo(PointF(cx - 5, cy - 6)); path.LineTo(PointF(cx, cy)); path.LineTo(PointF(cx - 5, cy + 6)); path.MoveTo(PointF(cx + 2, cy - 6)); path.LineTo(PointF(cx + 7, cy)); path.LineTo(PointF(cx + 2, cy + 6)); Canvas.DrawPath(path, 1); finally path.Free; end; end else begin FJumpButtonRect := TRectF.Empty; FJumpButtonPressed := false; end; end; procedure TMycChart.MouseDown(Button: TMouseButton; Shift: TShiftState; X, Y: Single); begin // Check for button press first if (Button = TMouseButton.mbLeft) and (FViewStartIndex > 0) and FJumpButtonRect.Contains(PointF(X, Y)) then begin FJumpButtonPressed := true; Repaint; exit; // Prevent chart dragging end; inherited MouseDown(Button, Shift, X, Y); if (Button = TMouseButton.mbLeft) then begin FIsDragging := true; FDragStartPoint := TPointF.Create(X, Y); end; end; procedure TMycChart.MouseUp(Button: TMouseButton; Shift: TShiftState; X, Y: Single); begin // Check if a button press was active if FJumpButtonPressed then begin FJumpButtonPressed := false; // If mouse is released over the button, trigger the action if (FViewStartIndex > 0) and FJumpButtonRect.Contains(PointF(X, Y)) then begin FViewStartIndex := 0; // Jump to the latest candle end; Repaint; exit; end; inherited MouseUp(Button, Shift, X, Y); if (Button = TMouseButton.mbLeft) then begin FIsDragging := false; end; end; procedure TMycChart.MouseMove(Shift: TShiftState; X, Y: Single); var isHot: Boolean; dx: Single; indexDelta: Int64; maxIndex: Int64; begin // Update button hot state if (FViewStartIndex > 0) then begin isHot := FJumpButtonRect.Contains(PointF(X, Y)); if (isHot <> FJumpButtonHot) then begin FJumpButtonHot := isHot; Repaint; end; end else begin // Ensure hot state is off when button is not visible if FJumpButtonHot then begin FJumpButtonHot := false; Repaint; end; end; // Panning logic inherited MouseMove(Shift, X, Y); if FIsDragging then begin dx := X - FDragStartPoint.X; if Abs(dx) < 2 then // Threshold to avoid jitter begin exit; end; // An X-axis series is required for panning if not Assigned(FXAxisSeries) or (FViewCount <= 0) then begin exit; end; indexDelta := Round(dx / (Self.Width / FViewCount)); // To move content left (natural), view must shift to newer data (lower index). // Drag left (dx < 0) -> FViewStartIndex must decrease. // The formula for that is FViewStartIndex := FViewStartIndex + indexDelta; -> StartIndex + (negative) = decrease. FViewStartIndex := FViewStartIndex + indexDelta; // Clamp values maxIndex := FXAxisSeries.Series.Count - FViewCount; if (maxIndex < 0) then maxIndex := 0; if (FViewStartIndex < 0) then FViewStartIndex := 0; if (FViewStartIndex > maxIndex) then FViewStartIndex := maxIndex; FDragStartPoint.X := X; FDragStartPoint.Y := Y; Repaint; end; end; procedure TMycChart.MouseWheel(Shift: TShiftState; WheelDelta: Integer; var Handled: Boolean); var mousePos: TPointF; anchorIndex: Double; zoomFactor: Double; newViewCount: Int64; ratio: Double; begin Handled := true; // An X-axis series is required for zooming if not Assigned(FXAxisSeries) then exit; var xAxisCount := FXAxisSeries.Series.Count; if (xAxisCount = 0) or (Width <= 0) then exit; mousePos := ScreenToLocal(Screen.MousePos); // Calculate the anchor index for a right-to-left axis anchorIndex := FViewStartIndex + ((Self.Width - mousePos.X) / Self.Width) * FViewCount; if (WheelDelta > 0) then zoomFactor := 0.8 // Zoom In else zoomFactor := 1.25; // Zoom Out newViewCount := Round(FViewCount * zoomFactor); // Clamp zoom level if (newViewCount < 10) then newViewCount := 10; if (newViewCount > xAxisCount) then newViewCount := xAxisCount; if (newViewCount = FViewCount) then exit; // Adjust start index to keep anchor point stable if (FViewCount > 0) then ratio := (anchorIndex - FViewStartIndex) / FViewCount else ratio := 0; FViewStartIndex := Round(anchorIndex - (ratio * newViewCount)); FViewCount := newViewCount; // Clamp start index if (FViewStartIndex < 0) then FViewStartIndex := 0; if (FViewStartIndex + FViewCount > xAxisCount) then begin FViewStartIndex := xAxisCount - FViewCount; end; Repaint; end; { TMycChart.TLayer } constructor TMycChart.TLayer.Create(AOwner: TPanel); begin inherited Create; FParent := AOwner; end; function TMycChart.TLayer.GetOwner: TMycChart; begin Result := FParent.Owner; end; procedure TMycChart.TLayer.Repaint; begin FParent.Owner.Repaint; end; { TMycChart.TPanel } constructor TMycChart.TPanel.Create(AOwner: TMycChart); begin inherited Create; FOwner := AOwner; FSeriesList := TObjectList.Create(true); end; destructor TMycChart.TPanel.Destroy; begin FSeriesList.Free; inherited Destroy; end; function TMycChart.TPanel.AddDoubleSeries( const DataProvider: IMycDataProvider; const ALineColor: TAlphaColor; const ALineWidth: Single ): TMycChart.TLayer; begin Result := TChartLineLayer.Create(Self, DataProvider, ALineColor, ALineWidth); FSeriesList.Add(Result); end; function TMycChart.TPanel.AddOhlcSeries( const DataProvider: IMycDataProvider; const AUpColor, ADownColor: TAlphaColor ): TMycChart.TLayer; begin Result := TChartOhlcLayer.Create(Self, DataProvider, TMutable.Constant(AUpColor), TMutable.Constant(ADownColor)); FSeriesList.Add(Result); end; procedure TMycChart.TPanel.Paint(const Canvas: TCanvas; const Viewport: TRectF; MasterTotalCount, ViewStartIndex, ViewCount: Int64); var localMin, localMax, seriesMin, seriesMax, padding: Double; rangeInitialized: Boolean; seriesFirst, seriesLast: Int64; xTransform, yTransform: TFunc; function CalcView(Series: TMycChart.TSeries; out First, Last: Int64): Boolean; var seriesTotalCount, offset, startIdx: Int64; begin seriesTotalCount := series.TotalCount; offset := MasterTotalCount - seriesTotalCount; startIdx := ViewStartIndex - offset; First := Max(0, startIdx); Last := Min(series.Count, startIdx + ViewCount) - 1; Result := First <= Last; end; begin rangeInitialized := false; // 1. Calculate value range (Y-Axis) for this panel only for var series in FSeriesList do begin if CalcView(series.Series, seriesFirst, seriesLast) then begin if series.GetValueRange(seriesFirst, seriesLast, seriesMin, seriesMax) then begin if not rangeInitialized then begin localMin := seriesMin; localMax := seriesMax; rangeInitialized := true; end else begin localMin := Min(localMin, seriesMin); localMax := Max(localMax, seriesMax); end; end; end; end; if not rangeInitialized then Exit; // Nothing to draw in this panel padding := (localMax - localMin) * 0.1; localMin := localMin - padding; localMax := localMax + padding; if (localMax - localMin) = 0 then localMax := localMin + 1; // Avoid division by zero // 2. Create transforms for this panel yTransform := function(value: Double): Single begin Result := Viewport.Top + (1 - (value - localMin) / (localMax - localMin)) * Viewport.Height; end; // 3. Paint all layers in this panel for var series in FSeriesList do begin if CalcView(series.Series, seriesFirst, seriesLast) then begin var offset := MasterTotalCount - series.Series.TotalCount; xTransform := function(index: Double): Single begin Result := Viewport.Right - (((index + offset - ViewStartIndex) / (ViewCount - 1)) * Viewport.Width); end; series.Paint(Canvas, seriesFirst, seriesLast, xTransform, yTransform); end; end; end; procedure TMycChart.TPanel.UpdateAndCheckChanges(MasterTotalCount, ViewStartIndex: Int64; out SeriesChanged, SeriesRepaint: Boolean); var seriesCount: Int64; begin SeriesChanged := false; SeriesRepaint := false; for var series in FSeriesList do begin seriesCount := series.Series.TotalCount; if (ViewStartIndex <= MasterTotalCount - seriesCount) then SeriesRepaint := true; series.Update; if (seriesCount <> series.Series.TotalCount) then SeriesChanged := true; end; end; end.