Recent

Author Topic: Right-anchored zoom  (Read 359 times)

kapibara

  • Hero Member
  • *****
  • Posts: 656
Right-anchored zoom
« on: January 09, 2026, 06:38:59 pm »
Here is my attempt to extend TZoomMouseWheelTool to support a right-anchored zoom mode, which is important for financial/time-series charts where the user wants to zoom while keeping the latest bar fixed on the right edge. I couldn't find a way to do this with the standard TZoomMouseWheelTool. Hope I didn't reinvent the wheel?

The logic adjusts the X-extent while respecting dataset boundaries and recalculates Y automatically based on visible data.

Code: Pascal  [Select][+][-]
  1. unit uAnchoredZoomTool;
  2.  
  3. {$mode objfpc}{$H+}
  4.  
  5. interface
  6.  
  7. uses
  8.   Classes, SysUtils, Math, TAGraph, TAChartUtils, TATools, TASeries;
  9.  
  10. type
  11.   TZoomAnchorMode = (zamCursor, zamCenter, zamRightPinned);
  12.  
  13.   { -------------------------------------------------------------------
  14.     Patched Zoom Tool
  15.     -------------------------------------------------------------------
  16.          FIX: Override Handled and guard against Toolset=nil so the app
  17.          never crashes on: Toolset.FIsHandled := true;
  18.     ------------------------------------------------------------------- }
  19.  
  20.   TAnchoredZoomMouseWheelTool = class(TZoomMouseWheelTool)
  21.   private
  22.     FAnchorMode : TZoomAnchorMode;
  23.  
  24.     procedure HandleCalculateNewExtent(ATool: TChartTool;
  25.       var ANewExtent: TDoubleRect);
  26.  
  27.     function FirstDataX: Double;
  28.     function LastDataX: Double;
  29.     procedure RecalcYRange(const AXMin, AXMax: Double;
  30.       out AYMin, AYMax: Double);
  31.   public
  32.     constructor Create(AOwner: TComponent); override;
  33.  
  34.     // ********** CRASH PATCH **********
  35.     procedure Handled;
  36.     // **********************************
  37.  
  38.   published
  39.     property AnchorMode : TZoomAnchorMode
  40.       read FAnchorMode write FAnchorMode default zamCursor;
  41.   end;
  42.  
  43. implementation
  44.  
  45. uses
  46.   TACustomSeries;
  47.  
  48. { ===================================================================
  49.   Constructor
  50.   =================================================================== }
  51.  
  52. constructor TAnchoredZoomMouseWheelTool.Create(AOwner: TComponent);
  53. begin
  54.   inherited Create(AOwner);
  55.   FAnchorMode := zamCursor;
  56.   OnCalculateNewExtent := @HandleCalculateNewExtent;
  57. end;
  58.  
  59. { ===================================================================
  60.   HARD CRASH FIX
  61.   =================================================================== }
  62.  
  63. procedure TAnchoredZoomMouseWheelTool.Handled;
  64. begin
  65.   // TAChart calls this even when Toolset=nil → causes NIL dereference
  66.   if Toolset <> nil then
  67.     inherited Handled;
  68. end;
  69.  
  70. { ===================================================================
  71.   Data helpers
  72.   =================================================================== }
  73.  
  74. function TAnchoredZoomMouseWheelTool.FirstDataX: Double;
  75. var
  76.   S: TChartSeries;
  77. begin
  78.   Result := Chart.LogicalExtent.a.x;
  79.   if Chart.SeriesCount = 0 then Exit;
  80.   S := TChartSeries(Chart.Series[0]);
  81.   if S.Count > 0 then
  82.     Result := S.XValue[0];
  83. end;
  84.  
  85. function TAnchoredZoomMouseWheelTool.LastDataX: Double;
  86. var
  87.   S: TChartSeries;
  88. begin
  89.   Result := Chart.LogicalExtent.b.x;
  90.   if Chart.SeriesCount = 0 then Exit;
  91.   S := TChartSeries(Chart.Series[0]);
  92.   if S.Count > 0 then
  93.     Result := S.XValue[S.Count - 1];
  94. end;
  95.  
  96. { ===================================================================
  97.   Recalculate Y-axis range
  98.   =================================================================== }
  99.  
  100. procedure TAnchoredZoomMouseWheelTool.RecalcYRange(const AXMin, AXMax: Double;
  101.   out AYMin, AYMax: Double);
  102. var
  103.   i, j: Integer;
  104.   S: TChartSeries;
  105.   Y: Double;
  106. begin
  107.   AYMin :=  1E308;
  108.   AYMax := -1E308;
  109.  
  110.   for i := 0 to Chart.SeriesCount - 1 do
  111.     if Chart.Series[i] is TChartSeries then
  112.     begin
  113.       S := TChartSeries(Chart.Series[i]);
  114.       for j := 0 to S.Count - 1 do
  115.         if (S.XValue[j] >= AXMin) and (S.XValue[j] <= AXMax) then
  116.         begin
  117.           Y := S.YValue[j];
  118.           AYMin := Min(AYMin, Y);
  119.           AYMax := Max(AYMax, Y);
  120.         end;
  121.     end;
  122.  
  123.   if AYMin < AYMax then
  124.   begin
  125.     Y := (AYMax - AYMin) * 0.05;
  126.     AYMin -= Y;
  127.     AYMax += Y;
  128.   end;
  129. end;
  130.  
  131. { ===================================================================
  132.   MAIN ZOOM LOGIC
  133.   =================================================================== }
  134.  
  135. procedure TAnchoredZoomMouseWheelTool.HandleCalculateNewExtent(
  136.   ATool: TChartTool; var ANewExtent: TDoubleRect);
  137. var
  138.   OldExt   : TDoubleRect;
  139.   OldW, NewW: Double;
  140.   DataMin, DataMax: Double;
  141.   X1, X2: Double;
  142.   YMin, YMax: Double;
  143. begin
  144.   // Default TAChart behavior → skip custom logic
  145.   if AnchorMode = zamCursor then
  146.     Exit;
  147.  
  148.   OldExt := Chart.LogicalExtent;
  149.  
  150.   OldW := OldExt.b.x - OldExt.a.x;
  151.   NewW := ANewExtent.b.x - ANewExtent.a.x;
  152.   if (OldW <= 0) or (NewW <= 0) then
  153.     Exit;
  154.  
  155.   DataMin := FirstDataX;
  156.   DataMax := LastDataX;
  157.  
  158.   case AnchorMode of
  159.  
  160.     { ------------------------------------------------------------
  161.       CENTER-ANCHORED ZOOM
  162.       ------------------------------------------------------------ }
  163.     zamCenter:
  164.       begin
  165.         X1 := OldExt.a.x + (OldW - NewW) * 0.5;
  166.         X2 := X1 + NewW;
  167.  
  168.         if (X2 - X1) > (DataMax - DataMin) then
  169.         begin
  170.           X1 := DataMin;
  171.           X2 := DataMax;
  172.         end
  173.         else
  174.         begin
  175.           if X1 < DataMin then
  176.           begin
  177.             X1 := DataMin;
  178.             X2 := X1 + NewW;
  179.           end;
  180.           if X2 > DataMax then
  181.           begin
  182.             X2 := DataMax;
  183.             X1 := X2 - NewW;
  184.           end;
  185.         end;
  186.  
  187.         ANewExtent.a.x := X1;
  188.         ANewExtent.b.x := X2;
  189.       end;
  190.  
  191.     { ------------------------------------------------------------
  192.       RIGHT-ANCHORED ZOOM
  193.       ------------------------------------------------------------ }
  194.     zamRightPinned:
  195.       begin
  196.         X2 := DataMax;
  197.         X1 := X2 - NewW;
  198.  
  199.         if X1 < DataMin then
  200.         begin
  201.           X1 := DataMin;
  202.           X2 := DataMin + NewW;
  203.           if X2 > DataMax then
  204.             X2 := DataMax;
  205.         end;
  206.  
  207.         ANewExtent.a.x := X1;
  208.         ANewExtent.b.x := X2;
  209.       end;
  210.  
  211.   else
  212.     Exit;
  213.   end;
  214.  
  215.   // Recalculate Y-axis after final X-range is known
  216.   RecalcYRange(ANewExtent.a.x, ANewExtent.b.x, YMin, YMax);
  217.   ANewExtent.a.y := YMin;
  218.   ANewExtent.b.y := YMax;
  219. end;
  220.  
  221. end.

Demo app for testing the zoom. Buttons work identically to mousewheel. Note that without the workaround, moving the wheel results in exception because Toolset is nil and I dont know how to set it.

Code: Pascal  [Select][+][-]
  1. unit Unit1;
  2.  
  3. {$mode objfpc}{$H+}
  4.  
  5. interface
  6.  
  7. uses
  8.   Classes, SysUtils, Forms, Controls, Graphics, Dialogs, StdCtrls, ExtCtrls,
  9.   TAGraph, TASeries, TASources, Types, TATools, TAChartUtils;
  10.  
  11. type
  12.   TForm1 = class(TForm)
  13.     btnZoom20Pct: TButton;
  14.     btnZoomOut20Pct: TButton;
  15.     Chart1: TChart;
  16.     Chart1LineSeries1: TLineSeries;
  17.     ListChartSource: TListChartSource;
  18.     procedure FormCreate(Sender: TObject);
  19.     procedure btnZoom20PctClick(Sender: TObject);
  20.     procedure btnZoomOut20PctClick(Sender: TObject);
  21.   private
  22.     FToolset  : TChartToolset;
  23.     FZoomTool : TZoomMouseWheelTool;
  24.     procedure ZoomToolCalcExtent(
  25.       ATool: TChartTool;
  26.       var ANewExtent: TDoubleRect
  27.     );
  28.   end;
  29.  
  30. var
  31.   Form1: TForm1;
  32.  
  33. implementation
  34.  
  35. {$R *.lfm}
  36.  
  37. uses
  38.   Math, DateUtils;
  39.  
  40. type
  41.   TDataRecord = record
  42.     Date  : TDateTime;
  43.     Value : Double;
  44.   end;
  45.  
  46.   TDataArray = array of TDataRecord;
  47.  
  48. var
  49.   DataArray: TDataArray;
  50.  
  51. function AddRecord(var A: TDataArray): Integer;
  52. var
  53.   R: TDataRecord;
  54.   LastValue: Double;
  55.   ChangePercent: Double;
  56. begin
  57.   if Length(A) = 0 then
  58.   begin
  59.     R.Date := Now;
  60.     R.Value := Random(51) + 50;
  61.   end
  62.   else
  63.   begin
  64.     R.Date := A[High(A)].Date + 1;
  65.     LastValue := A[High(A)].Value;
  66.     ChangePercent := (Random(601) - 300) / 10000;
  67.     R.Value := LastValue * (1 + ChangePercent);
  68.     if R.Value < 50 then
  69.       R.Value := 50;
  70.   end;
  71.  
  72.   SetLength(A, Length(A) + 1);
  73.   A[High(A)] := R;
  74.   Result := High(A);
  75. end;
  76.  
  77. procedure TForm1.FormCreate(Sender: TObject);
  78. var
  79.   i, j: Integer;
  80. begin
  81.   Randomize;
  82.  
  83.   for j := 0 to 599 do
  84.   begin
  85.     i := AddRecord(DataArray);
  86.     ListChartSource.Add(
  87.       DataArray[i].Date,
  88.       DataArray[i].Value,
  89.       FormatDateTime('yyyy-mm-dd', DataArray[i].Date)
  90.     );
  91.   end;
  92.  
  93.   // TOOLSET
  94.   FToolset := TChartToolset.Create(Self);
  95.   Chart1.Toolset := FToolset;
  96.  
  97.   // ZOOM TOOL – ÄGS AV TOOLSET (VIKTIGT)
  98.   FZoomTool := TZoomMouseWheelTool.Create(FToolset);
  99.   FZoomTool.ZoomFactor := 0.80;
  100.   FZoomTool.OnCalculateNewExtent := @ZoomToolCalcExtent;
  101.   FZoomTool.Enabled := True;
  102.  
  103.   FToolset.Tools.Add(FZoomTool);
  104. end;
  105.  
  106. procedure TForm1.ZoomToolCalcExtent(
  107.   ATool: TChartTool;
  108.   var ANewExtent: TDoubleRect
  109. );
  110. var
  111.   i, iend: Integer;
  112.   RightX, LeftX: Double;
  113.   YMin, YMax: Double;
  114. begin
  115.   iend := Chart1LineSeries1.Count - 1;
  116.   if iend < 0 then Exit;
  117.  
  118.   RightX := Chart1LineSeries1.XValue[iend];
  119.   LeftX := RightX - (ANewExtent.b.x - ANewExtent.a.x);
  120.  
  121.   if LeftX < Chart1LineSeries1.XValue[0] then
  122.     LeftX := Chart1LineSeries1.XValue[0];
  123.  
  124.   ANewExtent.a.x := LeftX;
  125.   ANewExtent.b.x := RightX;
  126.  
  127.   YMin :=  1E308;
  128.   YMax := -1E308;
  129.  
  130.   for i := 0 to iend do
  131.     if (Chart1LineSeries1.XValue[i] >= ANewExtent.a.x) and
  132.        (Chart1LineSeries1.XValue[i] <= ANewExtent.b.x) then
  133.     begin
  134.       YMin := Min(YMin, Chart1LineSeries1.YValue[i]);
  135.       YMax := Max(YMax, Chart1LineSeries1.YValue[i]);
  136.     end;
  137.  
  138.   ANewExtent.a.y := YMin;
  139.   ANewExtent.b.y := YMax;
  140. end;
  141.  
  142. procedure TForm1.btnZoom20PctClick(Sender: TObject);
  143. var
  144.   i, iend: Integer;
  145.   ext: TDoubleRect;
  146. begin
  147.   iend := Chart1LineSeries1.Count - 1;
  148.   if iend < 0 then Exit;
  149.  
  150.   ext := Chart1.LogicalExtent;
  151.   ext.a.x := ext.a.x + 0.20 * (ext.b.x - ext.a.x);
  152.   ext.b.x := ext.a.x + (ext.b.x - ext.a.x) * 0.80;
  153.  
  154.   ext.a.y :=  1E308;
  155.   ext.b.y := -1E308;
  156.  
  157.   for i := 0 to iend do
  158.     if (Chart1LineSeries1.XValue[i] >= ext.a.x) and
  159.        (Chart1LineSeries1.XValue[i] <= ext.b.x) then
  160.     begin
  161.       ext.a.y := Min(ext.a.y, Chart1LineSeries1.YValue[i]);
  162.       ext.b.y := Max(ext.b.y, Chart1LineSeries1.YValue[i]);
  163.     end;
  164.  
  165.   Chart1.LogicalExtent := ext;
  166. end;
  167.  
  168. procedure TForm1.btnZoomOut20PctClick(Sender: TObject);
  169. var
  170.   i, iend: Integer;
  171.   ext: TDoubleRect;
  172. begin
  173.   iend := Chart1LineSeries1.Count - 1;
  174.   if iend < 0 then Exit;
  175.  
  176.   ext := Chart1.LogicalExtent;
  177.   ext.a.x := ext.a.x - 0.20 * (ext.b.x - ext.a.x);
  178.   ext.b.x := ext.a.x + (ext.b.x - ext.a.x) * 1.20;
  179.  
  180.   if ext.a.x < Chart1LineSeries1.XValue[0] then
  181.     ext.a.x := Chart1LineSeries1.XValue[0];
  182.   if ext.b.x > Chart1LineSeries1.XValue[iend] then
  183.     ext.b.x := Chart1LineSeries1.XValue[iend];
  184.  
  185.   ext.a.y :=  1E308;
  186.   ext.b.y := -1E308;
  187.  
  188.   for i := 0 to iend do
  189.     if (Chart1LineSeries1.XValue[i] >= ext.a.x) and
  190.        (Chart1LineSeries1.XValue[i] <= ext.b.x) then
  191.     begin
  192.       ext.a.y := Min(ext.a.y, Chart1LineSeries1.YValue[i]);
  193.       ext.b.y := Max(ext.b.y, Chart1LineSeries1.YValue[i]);
  194.     end;
  195.  
  196.   Chart1.LogicalExtent := ext;
  197. end;
  198.  
  199. end.
  200.  

Since I don't fully understand how to set up Toolset and Tools at runtime, a workaround to guard against Toolset=nil in TChartTool.Handled was added. I assume there is a cleaner, intended usage pattern? Without the workaround there will be an exception when moving the wheel for zooming. Hopefully just a silly mistake from my side. Temporarily, for testing the zoom:

Code: Pascal  [Select][+][-]
  1. procedure TChartTool.Handled;
  2. begin
  3.   if (Toolset <> nil) then
  4.     Toolset.FIsHandled := true;
  5. end;
  6.  

Likewise and probably for same reason exception at app close:

Code: Pascal  [Select][+][-]
  1. destructor TChartToolset.Destroy;
  2. begin
  3.   while Tools.Count > 0 do
  4.     Item[Tools.Count - 1].Free;
  5.   FreeAndNil(FTools);
  6.   inherited;
  7. end;  
  8.  
Lazarus trunk / fpc 3.2.2 / Kubuntu 24.04 - 64 bit

wp

  • Hero Member
  • *****
  • Posts: 13350
Re: Right-anchored zoom
« Reply #1 on: January 11, 2026, 10:48:06 pm »
Note that without the workaround, moving the wheel results in exception because Toolset is nil and I dont know how to set it
...
Since I don't fully understand how to set up Toolset and Tools at runtime, a workaround to guard against Toolset=nil in TChartTool.Handled was added. I assume there is a cleaner, intended usage pattern? Without the workaround there will be an exception when moving the wheel for zooming.
Some of the collection-like classes in TAChart have the strange behaviour that adding items in code should not be done by the Add method but by assigning the owning component to the item (e.g., https://wiki.lazarus.freepascal.org/TAChart_Runtime_FAQ#How_to_add_an_axis_transformation?):
Code: Pascal  [Select][+][-]
  1. //  FToolset.Tools.Add(FZoomTool);   // <--- WRONG
  2.   FZoomTool.Toolset := FToolset;     // <--- CORRECT[/code}
  3.  
« Last Edit: January 11, 2026, 11:16:17 pm by wp »

kapibara

  • Hero Member
  • *****
  • Posts: 656
Re: Right-anchored zoom
« Reply #2 on: January 11, 2026, 11:54:30 pm »
Yep, that was the solution, thanks.

Added to the Runtime FAQ:

https://wiki.freepascal.org/TAChart_Runtime_FAQ#Toolsets_and_ChartTools
Lazarus trunk / fpc 3.2.2 / Kubuntu 24.04 - 64 bit

 

TinyPortal © 2005-2018