diff --git a/README.md b/README.md index 9acfb90..1f14150 100644 --- a/README.md +++ b/README.md @@ -184,6 +184,56 @@ end; `SaveToPng` never draws a tooltip, so an export is always clean. +## Painting on your own canvas + +`TChart4D` is not the only way to put a plot on screen. When you already have a canvas, say a +`TPaintBox` in an existing viewer, a `TChartPainter` paints any `TChartPlot` onto it with the +same back buffer, hover highlight and tooltip the control uses. + +```pascal +uses + Chart4D.Plot, + Chart4D.VCL; + +FPlot := TChartPlot.Create; +FPainter := TChartPainter.Create(FPlot); +FPainter.View.OnRepaintRequest := PainterRepaintRequest; + +procedure TFormViewer.PaintBoxPaint(Sender: TObject); +begin + FPainter.Paint(PaintBox.Canvas, PaintBox.Width, PaintBox.Height); +end; + +procedure TFormViewer.PaintBoxMouseMove(Sender: TObject; Shift: TShiftState; X, Y: Integer); +begin + FPainter.MouseMove(X, Y); +end; + +procedure TFormViewer.PaintBoxMouseLeave(Sender: TObject); +begin + FPainter.MouseLeave; +end; + +procedure TFormViewer.PainterRepaintRequest(Sender: TObject); +begin + PaintBox.Invalidate; +end; +``` + +When the chart shares the canvas with other content, paint it into a rectangle instead. It is +laid out for that rectangle's size, and nothing is drawn outside it. `MouseMove` then still +takes canvas coordinates; a position outside the rectangle counts as leaving the chart. + +```pascal +FPainter.Paint(PaintBox.Canvas, TRect.Create(200, 0, PaintBox.Width, PaintBox.Height)); +``` + +The painter does not own the plot: free the painter first, then the plot. It takes over +`Plot.OnChanged` so that every change repaints. `FPainter.View` also carries `ShowTooltips` and +`OnDataPointHover`. In FireMonkey, `Chart4D.FMX` has a `TChartPainter` with the same shape; +call its `Paint` from `OnPaint`, passing the `ARect` it receives, and map `OnRepaintRequest` +to `Repaint`. + ## Export ```pascal diff --git a/SPEC.md b/SPEC.md index 152f21c..27296c9 100644 --- a/SPEC.md +++ b/SPEC.md @@ -40,10 +40,11 @@ Chart4D/ │ ├── Chart4D.Renderer.pas │ ├── Chart4D.Tooltip.pas Hit-testing and the hover tooltip (4.11) │ ├── Chart4D.Hover.pas Hover state shared by both controls (4.11) +│ ├── Chart4D.View.pas TChartView: render validity, hover, overlay (4.25) │ ├── VCL/ -│ │ └── Chart4D.VCL.pas GDI+ canvas, TChart4D control, PNG export +│ │ └── Chart4D.VCL.pas GDI+ canvas, TChartPainter, TChart4D control, PNG export │ └── FMX/ -│ └── Chart4D.FMX.pas FMX canvas, TChart4D control, PNG export +│ └── Chart4D.FMX.pas FMX canvas, TChartPainter, TChart4D control, PNG export ├── packages/RAD Studio 13.0/ │ ├── Chart4D_R.dpk/.dproj requires rtl │ ├── Chart4D_VCL_R.dpk/.dproj requires rtl, vcl, Chart4D_R @@ -211,7 +212,7 @@ const DefaultExportHeight = 450; ``` -The `resourcestring` entries for the capabilities in 4.12 to 4.24: +The `resourcestring` entries for the capabilities in 4.12 to 4.25: ```pascal resourcestring @@ -221,6 +222,7 @@ resourcestring SBandValueCountMismatch = 'Band series "%s" has %d low value(s) but %d high value(s)'; SPieRequiresSingleSeries = 'Pie/Donut charts require exactly one series, got %d'; SPieValuesMustBeNonNegative = 'Pie/Donut values must not be negative, got %g'; + SPaintBoundsNegativeSize = 'Cannot paint a chart into bounds of negative size (%g x %g)'; ``` ### 4.3 Chart4D.Style.pas @@ -668,6 +670,18 @@ type // implements every IChartCanvas method with antialiasing enabled end; + TChartPainter = class + public + constructor Create(const Plot: TChartPlot); // not owned + destructor Destroy; override; + procedure Paint(const TargetCanvas: TCanvas; const Width, Height: Integer); overload; + procedure Paint(const TargetCanvas: TCanvas; const Bounds: TRect); overload; + procedure RenderToBackBuffer(const Width, Height: Integer); + procedure MouseMove(const X, Y: Integer); + procedure MouseLeave; + property View: TChartView read ...; // owned + end; + TChart4D = class(TGraphicControl) public constructor Create(AOwner: TComponent); override; @@ -687,8 +701,17 @@ Implementation notes: `SmoothingModeAntiAlias`, `TextRenderingHintAntiAliasGridF fonts created with `UnitPixel` so style sizes are pixels. `TAlphaColor` maps 1:1 to the GDI+ ARGB color value. `SaveToPng` renders into a `TGPBitmap` and saves with the PNG encoder CLSID; it raises `EChart4DException` when the plot has no series to export, -whereas painting an empty control does not (4.8). Control repaints (`Invalidate`) via `Plot.OnChanged`. Default control -size 640x450. GDI+ startup/shutdown is handled by `Winapi.GDIPOBJ`. +whereas painting an empty control does not (4.8). Default control size 640x450. GDI+ +startup/shutdown is handled by `Winapi.GDIPOBJ`. + +`TChartPainter` (4.25) keeps its back buffer in a `pf32bit` `Vcl.Graphics.TBitmap` and renders +into it through a `TGdiPlusChartCanvas` on the bitmap's `HDC`; the tooltip overlay goes +through a second `TGdiPlusChartCanvas` on the target canvas' `HDC`, created only while +`View.HasOverlay` is true. `TChart4D` owns its plot and a painter on that plot, and passes +`Paint`, `Resize`, `MouseMove` and `CM_MOUSELEAVE` on to the painter. It maps the view's +`OnRepaintRequest` to `Invalidate`, so the control repaints on `Plot.OnChanged`, and re-fires +the view's `OnDataPointHover` with the control as `Sender`. The protected +`RenderChartToBackBuffer` passes the call on to `TChartPainter.RenderToBackBuffer`. ### 4.10 Chart4D.FMX.pas (Source\FMX) @@ -699,6 +722,18 @@ type constructor Create(const Canvas: FMX.Graphics.TCanvas); end; + TChartPainter = class + public + constructor Create(const Plot: TChartPlot); // not owned + destructor Destroy; override; + procedure Paint(const TargetCanvas: FMX.Graphics.TCanvas; const Width, Height: Single); overload; + procedure Paint(const TargetCanvas: FMX.Graphics.TCanvas; const Bounds: TRectF); overload; + procedure RenderToBackBuffer(const Width, Height: Single); + procedure MouseMove(const X, Y: Single); + procedure MouseLeave; + property View: TChartView read ...; // owned + end; + TChart4D = class(TControl) protected procedure Paint; override; @@ -714,13 +749,23 @@ type Text via `TTextLayout` (reliable measuring); `SaveToPng` via an offscreen `FMX.Graphics.TBitmap` (`BeginScene`/`EndScene`, `SaveToFile`), raising the same -no-series `EChart4DException` as the VCL control. Repaint via -`Plot.OnChanged` calling `Repaint`. +no-series `EChart4DException` as the VCL control. + +`TChartPainter` (4.25) keeps its back buffer in an `FMX.Graphics.TBitmap` of +`Round(Width)` x `Round(Height)` pixels and renders into it between `BeginScene` and +`EndScene` through a `TFmxChartCanvas`, raising `EChart4DException` when the scene cannot +start; `Paint` expects `TargetCanvas` to be inside a scene already, as it is during a +control's `Paint`, and stretches the buffer to `Width` x `Height`. `TChart4D` owns its plot +and a painter on that plot, and passes `Paint`, `Resize`, `MouseMove` and `DoMouseLeave` on +to the painter. It maps the view's `OnRepaintRequest` to `Repaint`, so the control repaints +on `Plot.OnChanged`, and re-fires the view's `OnDataPointHover` with the control as +`Sender`. The protected `RenderChartToBackBuffer` passes the call on to +`TChartPainter.RenderToBackBuffer`. ### 4.11 Hover interaction (tooltips) -Hover interaction lives in the core units `Chart4D.Tooltip.pas` and `Chart4D.Hover.pas` -plus the declarations below in existing units. +Hover interaction lives in the core units `Chart4D.Tooltip.pas`, `Chart4D.Hover.pas` and +`Chart4D.View.pas` (4.25) plus the declarations below in existing units. In `Chart4D.Types.pas`: @@ -802,12 +847,16 @@ Both controls (`Chart4D.VCL.pas`, `Chart4D.FMX.pas`): paint pass draws the tooltip last via `TChartTooltip.Draw`. - Mouse leave clears the hover state and repaints. - `SaveToPng` never draws tooltips. -- Both controls hold that state in a `TChartHoverState` (`Chart4D.Hover.pas`), which owns - the hit map, the hovered point, and the rule for whether a pointer move changed it. - `MoveTo` and `Leave` return whether anything changed, which is the only moment a control - fires `OnDataPointHover` and repaints. The rule lives in the RTL-only core so the VCL and - FMX controls cannot drift apart on it; only `Invalidate` versus `Repaint` differs. -- Both core units are part of `Chart4D_R.dpk`/`.dproj`. +- Both controls delegate all of this to a `TChartView` (`Chart4D.View.pas`, 4.25) through + their framework's `TChartPainter`. The view holds a `TChartHoverState` + (`Chart4D.Hover.pas`), which owns the hit map, the hovered point, and the rule for whether + a pointer move changed it. `MoveTo` and `Leave` return whether anything changed, which is + the only moment the view fires `OnDataPointHover` and requests a repaint. The rule and + its use live in the RTL-only core so the VCL and FMX controls cannot drift apart on it; + only the back buffer, the canvas adapter, and `Invalidate` versus `Repaint` differ. +- `ShowTooltips` reads and writes `View.ShowTooltips`; the control's `OnDataPointHover` is + fired with the control, not the view, as `Sender`. +- The three core units are part of `Chart4D_R.dpk`/`.dproj`. ### 4.12 Value labels @@ -1351,6 +1400,75 @@ legend, hit-testing) except: for formatting it (e.g. `'Total: 1,234'`), the same plain-string convention `Source`/`Title`/`Subtitle` already use elsewhere in this library. +### 4.25 Chart view and painters + +Showing a plot on screen, with a cached render, hover tracking and a tooltip, is split into a +framework-neutral view in the core and one painter per framework. `TChart4D` is built on +them, and an application can use a painter directly to paint a plot on a canvas it already +owns (a `TPaintBox`, a custom control) without a `TChart4D`. + +`Chart4D.View.pas`: + +```pascal +type + TChartView = class + public + constructor Create(const Plot: TChartPlot); // not owned; assigns Plot.OnChanged + destructor Destroy; override; + function NeedsRender(const Width, Height: Single): Boolean; + procedure Render(const Canvas: IChartCanvas; const Width, Height: Single); + procedure DrawOverlay(const Canvas: IChartCanvas; const Width, Height: Single); + procedure Invalidate; + procedure MouseMove(const X, Y: Single); + procedure MouseLeave; + property Plot: TChartPlot read ...; + property ShowTooltips: Boolean read ... write ...; // default True + property HasOverlay: Boolean read ...; + property OnDataPointHover: TChartHoverEvent read ... write ...; + property OnRepaintRequest: TNotifyEvent read ... write ...; + end; +``` + +- The view references its plot without owning it; the plot must outlive the view. The + constructor assigns `Plot.OnChanged`; the destructor clears it again only while it still + points at this view, so a handler assigned later survives. +- A render stays valid until the plot changes, `Invalidate` is called, or `Render` is asked + for a different `Width`/`Height` than the last render. `NeedsRender` answers exactly that, + so a painter can skip preparing its surface. `Render` draws through + `TChartRenderer.Render` (4.8) and stores the hit map in the view's `TChartHoverState` + (4.11) only when `NeedsRender` is true, and draws nothing otherwise. +- `DrawOverlay` draws `TChartTooltip.Draw` (4.11) with `Plot.Style`, the hovered point and + `Plot.YAxis.LocaleName` when `HasOverlay` is true (`ShowTooltips` and a hovered point), and + nothing otherwise. It is meant for the screen canvas after the cached render has been + copied there, so the tooltip never ends up in the cache. +- `MouseMove` and `MouseLeave` pass the pointer on to the hover state. When that reports a + change, the view fires `OnDataPointHover` (with the view as `Sender`) and then + `OnRepaintRequest`. A plot change marks the render stale and fires `OnRepaintRequest` + only. `Invalidate` fires nothing, for a caller that repaints anyway. +- The view holds no pixels. The RTL has no bitmap class and `IChartCanvas` has no bitmap + drawing, so the back buffer and the copy to screen stay in the painters. + +`TChartPainter` (declared in `Chart4D.VCL.pas`, 4.9, and in `Chart4D.FMX.pas`, 4.10, with the +same shape) owns a `TChartView` on a caller-supplied plot and the framework's back buffer. +`Paint(TargetCanvas, Bounds)` lays the chart out for the size of `Bounds` (`TRect` in the +VCL, `TRectF` in FMX) and runs four steps in order: resize the back buffer (which +invalidates the view when the size actually changed), render into it through the view when +`View.NeedsRender` is true, copy it to `TargetCanvas` at `Bounds`, and let the view draw its +overlay on `TargetCanvas`, translated to `Bounds.TopLeft` and clipped to `Bounds`. The clip +matters because a tooltip pushed against the chart edge strokes its border across that +edge. `Paint(TargetCanvas, Width, Height)` is `Paint` with bounds `(0, 0, Width, Height)`, +which is what `TChart4D` calls. Both raise `EChart4DException` (`SPaintBoundsNegativeSize`) +for a negative width or height. `RenderToBackBuffer` renders into the back buffer +unconditionally. + +`Paint` remembers `Bounds`, so `MouseMove(X, Y)` takes coordinates on the canvas last painted +on. Inside those bounds it passes the position, relative to `Bounds.TopLeft`, on to the view; +outside them (including before the first `Paint`) it calls `View.MouseLeave` instead, +because a hit target with a radius could otherwise be hit from just outside the chart. +`MouseLeave` passes the call on to the view. The caller +maps `View.OnRepaintRequest` to its framework's repaint and frees the painter before the +plot. + ## 5. Tests (Tests\, DUnitX) Console project `Chart4D.Tests.dpr` + `build.bat` (dcc32; the RAD Studio location is @@ -1386,6 +1504,16 @@ read from the `BDS` environment variable, defaulting to sector target (4.23) is found by `FindTarget` when the point falls between its inner and outer radius and within its angular span, including a case straddling the 0/360 wraparound, and misses when outside either bound. +- `Chart4D.View.Tests.pas`: against a `TRecordingCanvas`, `TChartView.Render` (4.25) draws on + the first call, draws nothing while the last render is still valid, and draws again after + a plot change, a size change or `Invalidate`; a plot change requests a repaint without a + hover event, and `Invalidate` requests nothing; `MouseMove` onto a data point fires + `OnDataPointHover` (with the view as `Sender`) and one repaint request, and fires nothing + when moving within that point, before the first render, or with `ShowTooltips` off; + `MouseLeave` fires a miss and a repaint request only when something was hovered; + `DrawOverlay` draws nothing when nothing is hovered or `ShowTooltips` is off, and the + highlight, box and series name when a point is hovered; destroying the view clears + `Plot.OnChanged`, but leaves a handler assigned after the view alone. - `Chart4D.Axis.Tests.pas` also covers: the `LocaleName` overload of `FormatValue` against at least one non-invariant locale, and that the 2-argument overload's output is unchanged (4.14); `LogBreaks` against a known span (e.g. `LogBreaks(5, 3000, 10) = diff --git a/Source/Chart4D.Consts.pas b/Source/Chart4D.Consts.pas index 3865fbb..bbbff7f 100644 --- a/Source/Chart4D.Consts.pas +++ b/Source/Chart4D.Consts.pas @@ -80,6 +80,8 @@ interface SPngEncoderNotFound = 'No PNG image encoder is available on this system'; /// Raised when writing a chart PNG to disk fails. SFailedToSavePng = 'Failed to save chart PNG to "%s" (GDI+ status %d)'; + /// Raised when a painter is asked to paint into a rectangle with a negative width or height. + SPaintBoundsNegativeSize = 'Cannot paint a chart into bounds of negative size (%g x %g)'; implementation diff --git a/Source/Chart4D.View.pas b/Source/Chart4D.View.pas new file mode 100644 index 0000000..84a5a5d --- /dev/null +++ b/Source/Chart4D.View.pas @@ -0,0 +1,231 @@ +{*******************************************************} +{ } +{ Chart4D Library - Editorial data charts } +{ } +{ Copyright(c) 2026 GDK Software } +{ All rights reserved } +{ } +{ Licensed under MIT License } +{ } +{*******************************************************} +unit Chart4D.View; + +/// +/// The framework-neutral part of showing a plot on screen: when a render is still valid, +/// which data point is hovered, when to fire OnDataPointHover, when to ask for a +/// repaint, and when to draw the tooltip. RTL-only, so the VCL and FMX adapters share one +/// implementation and an application can paint a plot on a canvas it already owns. +/// + +interface + +uses + System.Classes, + Chart4D.Types, + Chart4D.Canvas.Interfaces, + Chart4D.Hover, + Chart4D.Plot; + +type + /// + /// Shows a TChartPlot on a surface the caller owns. The view decides when the + /// last render is stale, keeps the hit map of that render for hover tracking, and draws + /// the hover tooltip as an overlay. It holds no pixels: the caller keeps the rendered + /// image (typically a back buffer) and copies it to the screen between + /// Render and DrawOverlay. + /// + TChartView = class + private + FPlot: TChartPlot; + FHover: TChartHoverState; + FOnDataPointHover: TChartHoverEvent; + FOnRepaintRequest: TNotifyEvent; + FIsRenderValid: Boolean; + FRenderedWidth: Single; + FRenderedHeight: Single; + + procedure PlotChanged(Sender: TObject); + procedure HoverChanged; + procedure RequestRepaint; + function GetShowTooltips: Boolean; + procedure SetShowTooltips(const Value: Boolean); + function GetHasOverlay: Boolean; + + public + /// + /// Creates a view on Plot and assigns Plot.OnChanged, so every plot + /// mutation invalidates the last render and requests a repaint. The view does not + /// own Plot, which must outlive it. Tooltips are shown by default. + /// + constructor Create(const Plot: TChartPlot); + /// + /// Destroys the view and clears Plot.OnChanged when it still points at this + /// view. Leaves the plot itself alone. + /// + destructor Destroy; override; + + /// + /// Returns True when a Render at Width x Height would draw: + /// the plot changed, Invalidate was called, the size differs from the last + /// render, or nothing was rendered yet. Lets a caller skip preparing its surface. + /// + function NeedsRender(const Width, Height: Single): Boolean; + + /// + /// Renders the plot onto Canvas at Width x Height pixels and keeps + /// the hit map for hover tracking, but only when NeedsRender is True; + /// otherwise draws nothing, because the caller's surface still holds the last render. + /// + /// Raised by TChartRenderer for invalid plot input. + procedure Render(const Canvas: IChartCanvas; const Width, Height: Single); + + /// + /// Draws the hover highlight and tooltip onto Canvas when HasOverlay is + /// True, and nothing otherwise. Call it after copying the rendered image to the + /// screen, on the screen canvas, so the tooltip never ends up in the cached render. + /// + procedure DrawOverlay(const Canvas: IChartCanvas; const Width, Height: Single); + + /// + /// Marks the last render as stale without requesting a repaint, for a caller whose + /// surface lost its content. + /// + procedure Invalidate; + + /// + /// Hit-tests the pointer at (X, Y) against the last render. When the hovered + /// data point changed, fires OnDataPointHover and then OnRepaintRequest. + /// + procedure MouseMove(const X, Y: Single); + + /// + /// Clears the hovered data point, for when the pointer leaves the surface. When + /// something was hovered, fires OnDataPointHover with HasHit = False and + /// then OnRepaintRequest. + /// + procedure MouseLeave; + + /// The plot this view shows. Not owned. + property Plot: TChartPlot read FPlot; + /// Whether hovering a data point highlights it and shows a tooltip. Default True. + property ShowTooltips: Boolean read GetShowTooltips write SetShowTooltips; + /// Whether DrawOverlay currently draws anything: tooltips shown and a data point hovered. + property HasOverlay: Boolean read GetHasOverlay; + /// + /// Fired when the hovered data point changes, including when the pointer leaves every + /// target. Sender is the view. + /// + property OnDataPointHover: TChartHoverEvent read FOnDataPointHover write FOnDataPointHover; + /// + /// Fired when the surface should be repainted: after a plot change and after a hover + /// change. Map it to the framework's own repaint, such as Invalidate in the VCL + /// or Repaint in FMX. Sender is the view. + /// + property OnRepaintRequest: TNotifyEvent read FOnRepaintRequest write FOnRepaintRequest; + end; + +implementation + +uses + Chart4D.Renderer, + Chart4D.Tooltip; + +constructor TChartView.Create(const Plot: TChartPlot); +begin + inherited Create; + FPlot := Plot; + FPlot.OnChanged := PlotChanged; + FHover := TChartHoverState.Create; +end; + +destructor TChartView.Destroy; +begin + const PlotStillNotifiesThisView = (TMethod(FPlot.OnChanged).Data = Pointer(Self)); + if PlotStillNotifiesThisView then + FPlot.OnChanged := nil; + + FHover.Free; + inherited Destroy; +end; + +function TChartView.GetShowTooltips: Boolean; +begin + Result := FHover.Enabled; +end; + +procedure TChartView.SetShowTooltips(const Value: Boolean); +begin + FHover.Enabled := Value; +end; + +function TChartView.GetHasOverlay: Boolean; +begin + Result := FHover.IsVisible; +end; + +function TChartView.NeedsRender(const Width, Height: Single): Boolean; +begin + const SizeChanged = (FRenderedWidth <> Width) or (FRenderedHeight <> Height); + Result := (not FIsRenderValid) or SizeChanged; +end; + +procedure TChartView.Render(const Canvas: IChartCanvas; const Width, Height: Single); +begin + if not NeedsRender(Width, Height) then + Exit; + + var HitMap: TArray; + TChartRenderer.Render(FPlot, Canvas, Width, Height, HitMap); + FHover.HitMap := HitMap; + + FRenderedWidth := Width; + FRenderedHeight := Height; + FIsRenderValid := True; +end; + +procedure TChartView.DrawOverlay(const Canvas: IChartCanvas; const Width, Height: Single); +begin + if not FHover.IsVisible then + Exit; + + TChartTooltip.Draw(Canvas, FPlot.Style, FHover.Info, Width, Height, FPlot.YAxis.LocaleName); +end; + +procedure TChartView.Invalidate; +begin + FIsRenderValid := False; +end; + +procedure TChartView.MouseMove(const X, Y: Single); +begin + if FHover.MoveTo(X, Y) then + HoverChanged; +end; + +procedure TChartView.MouseLeave; +begin + if FHover.Leave then + HoverChanged; +end; + +procedure TChartView.PlotChanged(Sender: TObject); +begin + Invalidate; + RequestRepaint; +end; + +procedure TChartView.HoverChanged; +begin + if Assigned(FOnDataPointHover) then + FOnDataPointHover(Self, FHover.Info); + + RequestRepaint; +end; + +procedure TChartView.RequestRepaint; +begin + if Assigned(FOnRepaintRequest) then + FOnRepaintRequest(Self); +end; + +end. diff --git a/Source/FMX/Chart4D.FMX.pas b/Source/FMX/Chart4D.FMX.pas index 3cf0493..798133d 100644 --- a/Source/FMX/Chart4D.FMX.pas +++ b/Source/FMX/Chart4D.FMX.pas @@ -12,7 +12,8 @@ /// /// The FireMonkey adapter: TFmxChartCanvas implements IChartCanvas on top -/// of FMX.Graphics.TCanvas, and TChart4D is the FMX control that owns a +/// of FMX.Graphics.TCanvas, TChartPainter paints a plot through a back +/// buffer onto any FMX canvas, and TChart4D is the FMX control that owns a /// TChartPlot, repaints on OnChanged, and exports PNG files. /// @@ -32,10 +33,9 @@ interface Chart4D.Types, Chart4D.Consts, Chart4D.Canvas.Interfaces, - Chart4D.Hover, Chart4D.Plot, Chart4D.Renderer, - Chart4D.Tooltip; + Chart4D.View; type /// @@ -84,6 +84,71 @@ TFmxChartCanvas = class(TInterfacedObject, IChartCanvas) procedure DrawImage(const FilePath: string; const Bounds: TRectF); end; + /// + /// Paints a TChartPlot onto an FMX canvas the caller owns, such as the canvas of + /// a TPaintBox or of a custom control. Keeps the rendered chart in an offscreen + /// TBitmap so a repaint only re-renders when the plot or the size changed, and + /// draws the hover tooltip on top. The framework-neutral decisions live in the owned + /// TChartView. + /// + TChartPainter = class + private + FView: TChartView; + FBackBuffer: TBitmap; + FPaintedBounds: TRectF; + + procedure ResizeBackBuffer(const Width, Height: Single); + procedure DrawOverlay(const TargetCanvas: FMX.Graphics.TCanvas; const Bounds: TRectF); + + public + /// + /// Creates a painter for Plot. The painter does not own Plot, which must + /// outlive it, and takes over Plot.OnChanged (see TChartView.Create). + /// + constructor Create(const Plot: TChartPlot); + /// Destroys the painter, its view and its back buffer, but not the plot. + destructor Destroy; override; + + /// + /// Paints the plot at Width x Height with its top-left corner at the + /// origin of TargetCanvas. Same as Paint with bounds + /// (0, 0, Width, Height). + /// + /// Raised when Width or Height is + /// negative, or when a scene cannot be started on the back buffer. + procedure Paint(const TargetCanvas: FMX.Graphics.TCanvas; const Width, Height: Single); overload; + /// + /// Paints the plot into Bounds on TargetCanvas, which must be inside a + /// scene (as it is during a control's Paint or a TPaintBox.OnPaint), laid + /// out for the size of Bounds: resizes the back buffer, re-renders it when the view + /// says so, copies it into Bounds, then draws the hover tooltip there. Remembers + /// Bounds, so MouseMove takes TargetCanvas coordinates. + /// + /// Raised when Bounds has a negative width or + /// height, or when a scene cannot be started on the back buffer. + procedure Paint(const TargetCanvas: FMX.Graphics.TCanvas; const Bounds: TRectF); overload; + /// + /// Renders the plot into the back buffer at Width x Height now, even when + /// the last render is still valid, and refreshes the hit map. + /// + /// Raised when a scene cannot be started on the back buffer. + procedure RenderToBackBuffer(const Width, Height: Single); + /// + /// Passes a pointer move on to the view. X and Y are in the coordinates of + /// the canvas last painted on; a position outside the last painted bounds counts as + /// leaving the chart. + /// + procedure MouseMove(const X, Y: Single); + /// Passes the pointer leaving the painted area on to the view. + procedure MouseLeave; + + /// + /// The owned view: ShowTooltips, OnDataPointHover, and the + /// OnRepaintRequest the caller maps to Repaint. + /// + property View: TChartView read FView; + end; + /// /// An FMX control that owns a TChartPlot and renders it with /// TChartRenderer through TFmxChartCanvas. Repaints itself whenever the @@ -93,23 +158,19 @@ TFmxChartCanvas = class(TInterfacedObject, IChartCanvas) TChart4D = class(TControl) private FPlot: TChartPlot; - FHover: TChartHoverState; + FPainter: TChartPainter; FOnDataPointHover: TChartHoverEvent; - FBackBuffer: TBitmap; - FBackBufferValid: Boolean; procedure RenderForExport(const Canvas: FMX.Graphics.TCanvas; const Width, Height: Single); - procedure EnsureBackBuffer; - procedure DrawTooltipOverlay; - procedure PlotChanged(Sender: TObject); - procedure HoverChanged; + procedure ViewDataPointHover(Sender: TObject; const Info: TChartHitInfo); + procedure ViewRepaintRequest(Sender: TObject); function GetShowTooltips: Boolean; procedure SetShowTooltips(const Value: Boolean); protected /// - /// Re-renders the plot into FBackBuffer and refreshes the stored hit map. - /// Called only when the buffer is invalid. + /// Re-renders the plot into the painter's back buffer and refreshes the stored hit + /// map. Paint does this on its own whenever the buffer is invalid. /// procedure RenderChartToBackBuffer; /// Blits the cached back buffer, re-rendering it first only when the plot @@ -325,105 +386,161 @@ function TFmxChartCanvas.TryLoadBitmap(const Bitmap: TBitmap; const FilePath: st end; end; -constructor TChart4D.Create(AOwner: TComponent); +constructor TChartPainter.Create(const Plot: TChartPlot); begin - inherited Create(AOwner); - FPlot := TChartPlot.Create; - FPlot.OnChanged := PlotChanged; - FHover := TChartHoverState.Create; - HitTest := True; + inherited Create; + FView := TChartView.Create(Plot); FBackBuffer := TBitmap.Create; - SetBounds(0, 0, DefaultExportWidth, DefaultExportHeight); end; -destructor TChart4D.Destroy; +destructor TChartPainter.Destroy; begin - FHover.Free; FBackBuffer.Free; - FPlot.Free; + FView.Free; inherited Destroy; end; -function TChart4D.GetShowTooltips: Boolean; +procedure TChartPainter.Paint(const TargetCanvas: FMX.Graphics.TCanvas; const Width, Height: Single); begin - Result := FHover.Enabled; + Paint(TargetCanvas, RectF(0, 0, Width, Height)); end; -procedure TChart4D.SetShowTooltips(const Value: Boolean); +procedure TChartPainter.Paint(const TargetCanvas: FMX.Graphics.TCanvas; const Bounds: TRectF); begin - FHover.Enabled := Value; -end; + const HasNegativeSize = (Bounds.Width < 0) or (Bounds.Height < 0); + if HasNegativeSize then + raise EChart4DException.CreateFmt(SPaintBoundsNegativeSize, [Bounds.Width, Bounds.Height]); -procedure TChart4D.Paint; -begin - EnsureBackBuffer; - Canvas.DrawBitmap(FBackBuffer, RectF(0, 0, FBackBuffer.Width, FBackBuffer.Height), - RectF(0, 0, Width, Height), 1.0); - DrawTooltipOverlay; + FPaintedBounds := Bounds; + ResizeBackBuffer(Bounds.Width, Bounds.Height); + + if FView.NeedsRender(Bounds.Width, Bounds.Height) then + RenderToBackBuffer(Bounds.Width, Bounds.Height); + + TargetCanvas.DrawBitmap(FBackBuffer, RectF(0, 0, FBackBuffer.Width, FBackBuffer.Height), Bounds, 1.0); + DrawOverlay(TargetCanvas, Bounds); end; -procedure TChart4D.Resize; +procedure TChartPainter.RenderToBackBuffer(const Width, Height: Single); begin - inherited Resize; - FBackBufferValid := False; + ResizeBackBuffer(Width, Height); + FView.Invalidate; + + const SceneStarted = FBackBuffer.Canvas.BeginScene; + if not SceneStarted then + raise EChart4DException.Create(SFailedToBeginBackBufferScene); + + try + const ChartCanvas: IChartCanvas = TFmxChartCanvas.Create(FBackBuffer.Canvas); + FView.Render(ChartCanvas, Width, Height); + finally + FBackBuffer.Canvas.EndScene; + end; end; -procedure TChart4D.EnsureBackBuffer; +procedure TChartPainter.MouseMove(const X, Y: Single); begin - const SizeChanged = (FBackBuffer.Width <> Round(Width)) or (FBackBuffer.Height <> Round(Height)); - if SizeChanged then + { Hit targets such as line points have a radius, so a pointer just outside the chart + could still hit one; outside the bounds is outside the chart, whatever it is near. } + const IsInsideChart = FPaintedBounds.Contains(PointF(X, Y)); + if not IsInsideChart then begin - FBackBuffer.SetSize(Round(Width), Round(Height)); - FBackBufferValid := False; + FView.MouseLeave; + Exit; end; - if not FBackBufferValid then - begin - RenderChartToBackBuffer; - FBackBufferValid := True; - end; + FView.MouseMove(X - FPaintedBounds.Left, Y - FPaintedBounds.Top); end; -procedure TChart4D.RenderChartToBackBuffer; +procedure TChartPainter.MouseLeave; begin - const SceneStarted = FBackBuffer.Canvas.BeginScene; - if not SceneStarted then - raise EChart4DException.Create(SFailedToBeginBackBufferScene); + FView.MouseLeave; +end; - try - const ChartCanvas: IChartCanvas = TFmxChartCanvas.Create(FBackBuffer.Canvas); +procedure TChartPainter.ResizeBackBuffer(const Width, Height: Single); +begin + const BufferWidth = Round(Width); + const BufferHeight = Round(Height); + const SizeChanged = (FBackBuffer.Width <> BufferWidth) or (FBackBuffer.Height <> BufferHeight); + if not SizeChanged then + Exit; + + FBackBuffer.SetSize(BufferWidth, BufferHeight); + FView.Invalidate; +end; - var HitMap: TArray; - TChartRenderer.Render(FPlot, ChartCanvas, Width, Height, HitMap); - FHover.HitMap := HitMap; +procedure TChartPainter.DrawOverlay(const TargetCanvas: FMX.Graphics.TCanvas; const Bounds: TRectF); +begin + if not FView.HasOverlay then + Exit; + + const SavedState = TargetCanvas.SaveState; + try + { A tooltip pushed against the chart edge strokes its border across that edge; on a + canvas shared with other content that half pixel must not land outside Bounds. } + TargetCanvas.IntersectClipRect(Bounds); + TargetCanvas.MultiplyMatrix(TMatrix.CreateTranslation(Bounds.Left, Bounds.Top)); + const ChartCanvas: IChartCanvas = TFmxChartCanvas.Create(TargetCanvas); + FView.DrawOverlay(ChartCanvas, Bounds.Width, Bounds.Height); finally - FBackBuffer.Canvas.EndScene; + TargetCanvas.RestoreState(SavedState); end; end; -procedure TChart4D.DrawTooltipOverlay; +constructor TChart4D.Create(AOwner: TComponent); begin - if not FHover.IsVisible then - Exit; + inherited Create(AOwner); + FPlot := TChartPlot.Create; + FPainter := TChartPainter.Create(FPlot); + FPainter.View.OnDataPointHover := ViewDataPointHover; + FPainter.View.OnRepaintRequest := ViewRepaintRequest; + HitTest := True; + SetBounds(0, 0, DefaultExportWidth, DefaultExportHeight); +end; - const ChartCanvas: IChartCanvas = TFmxChartCanvas.Create(Canvas); - TChartTooltip.Draw(ChartCanvas, FPlot.Style, FHover.Info, Width, Height, FPlot.YAxis.LocaleName); +destructor TChart4D.Destroy; +begin + FPainter.Free; + FPlot.Free; + inherited Destroy; +end; + +function TChart4D.GetShowTooltips: Boolean; +begin + Result := FPainter.View.ShowTooltips; +end; + +procedure TChart4D.SetShowTooltips(const Value: Boolean); +begin + FPainter.View.ShowTooltips := Value; +end; + +procedure TChart4D.Paint; +begin + FPainter.Paint(Canvas, Width, Height); +end; + +procedure TChart4D.Resize; +begin + inherited Resize; + FPainter.View.Invalidate; +end; + +procedure TChart4D.RenderChartToBackBuffer; +begin + FPainter.RenderToBackBuffer(Width, Height); end; procedure TChart4D.MouseMove(Shift: TShiftState; X, Y: Single); begin inherited MouseMove(Shift, X, Y); - - if FHover.MoveTo(X, Y) then - HoverChanged; + FPainter.MouseMove(X, Y); end; procedure TChart4D.DoMouseLeave; begin inherited DoMouseLeave; - - if FHover.Leave then - HoverChanged; + FPainter.MouseLeave; end; procedure TChart4D.SaveToPng(const FilePath: string; @@ -452,23 +569,20 @@ procedure TChart4D.SaveToPng(const FilePath: string; end; end; -procedure TChart4D.PlotChanged(Sender: TObject); -begin - FBackBufferValid := False; - Repaint; -end; - procedure TChart4D.RenderForExport(const Canvas: FMX.Graphics.TCanvas; const Width, Height: Single); begin const ChartCanvas: IChartCanvas = TFmxChartCanvas.Create(Canvas); TChartRenderer.Render(FPlot, ChartCanvas, Width, Height); end; -procedure TChart4D.HoverChanged; +procedure TChart4D.ViewDataPointHover(Sender: TObject; const Info: TChartHitInfo); begin if Assigned(FOnDataPointHover) then - FOnDataPointHover(Self, FHover.Info); + FOnDataPointHover(Self, Info); +end; +procedure TChart4D.ViewRepaintRequest(Sender: TObject); +begin Repaint; end; diff --git a/Source/VCL/Chart4D.VCL.pas b/Source/VCL/Chart4D.VCL.pas index 7f5b8d2..ade77ad 100644 --- a/Source/VCL/Chart4D.VCL.pas +++ b/Source/VCL/Chart4D.VCL.pas @@ -11,8 +11,9 @@ unit Chart4D.VCL; /// -/// The VCL adapter: a GDI+ backed IChartCanvas implementation and the -/// TChart4D graphic control that owns a TChartPlot, repaints on +/// The VCL adapter: a GDI+ backed IChartCanvas implementation, the +/// TChartPainter that paints a plot through a back buffer onto any VCL canvas, and +/// the TChart4D graphic control that owns a TChartPlot, repaints on /// OnChanged, and exports PNG snapshots. /// @@ -33,10 +34,9 @@ interface Chart4D.Types, Chart4D.Consts, Chart4D.Canvas.Interfaces, - Chart4D.Hover, Chart4D.Plot, Chart4D.Renderer, - Chart4D.Tooltip; + Chart4D.View; type /// @@ -110,6 +110,67 @@ TChart4DPng = class class procedure Save(const Bitmap: TGPBitmap; const FilePath: string); static; end; + /// + /// Paints a TChartPlot onto a VCL canvas the caller owns, such as the canvas of a + /// TPaintBox or of a custom control. Keeps the rendered chart in a 32-bit back + /// buffer so a repaint only re-renders when the plot or the size changed, and draws the + /// hover tooltip on top. The framework-neutral decisions live in the owned + /// TChartView. + /// + TChartPainter = class + private + FView: TChartView; + FBackBuffer: TBitmap; + FPaintedBounds: TRect; + + procedure ResizeBackBuffer(const Width, Height: Integer); + procedure DrawOverlay(const TargetCanvas: TCanvas; const Bounds: TRect); + + public + /// + /// Creates a painter for Plot. The painter does not own Plot, which must + /// outlive it, and takes over Plot.OnChanged (see TChartView.Create). + /// + constructor Create(const Plot: TChartPlot); + /// Destroys the painter, its view and its back buffer, but not the plot. + destructor Destroy; override; + + /// + /// Paints the plot at Width x Height pixels with its top-left corner at + /// the origin of TargetCanvas. Same as Paint with bounds + /// (0, 0, Width, Height). + /// + /// Raised when Width or Height is negative. + procedure Paint(const TargetCanvas: TCanvas; const Width, Height: Integer); overload; + /// + /// Paints the plot into Bounds on TargetCanvas, laid out for the size of + /// Bounds: resizes the back buffer, re-renders it when the view says so, copies it + /// to the top-left corner of Bounds, then draws the hover tooltip there. Remembers + /// Bounds, so MouseMove takes TargetCanvas coordinates. + /// + /// Raised when Bounds has a negative width or height. + procedure Paint(const TargetCanvas: TCanvas; const Bounds: TRect); overload; + /// + /// Renders the plot into the back buffer at Width x Height pixels now, + /// even when the last render is still valid, and refreshes the hit map. + /// + procedure RenderToBackBuffer(const Width, Height: Integer); + /// + /// Passes a pointer move on to the view. X and Y are in the coordinates of + /// the canvas last painted on; a position outside the last painted bounds counts as + /// leaving the chart. + /// + procedure MouseMove(const X, Y: Integer); + /// Passes the pointer leaving the painted area on to the view. + procedure MouseLeave; + + /// + /// The owned view: ShowTooltips, OnDataPointHover, and the + /// OnRepaintRequest the caller maps to Invalidate. + /// + property View: TChartView read FView; + end; + /// /// A VCL graphic control that owns a TChartPlot, renders it with /// TChartRenderer on top of GDI+, repaints on every plot change, and can @@ -119,16 +180,12 @@ TChart4DPng = class TChart4D = class(TGraphicControl) private FPlot: TChartPlot; - FHover: TChartHoverState; + FPainter: TChartPainter; FOnDataPointHover: TChartHoverEvent; - FBackBuffer: TBitmap; - FBackBufferValid: Boolean; procedure RenderForExport(const Graphics: TGPGraphics; const Width, Height: Single); - procedure EnsureBackBuffer; - procedure DrawTooltipOverlay; - procedure PlotChanged(Sender: TObject); - procedure HoverChanged; + procedure ViewDataPointHover(Sender: TObject; const Info: TChartHitInfo); + procedure ViewRepaintRequest(Sender: TObject); function GetShowTooltips: Boolean; procedure SetShowTooltips(const Value: Boolean); @@ -397,6 +454,109 @@ class procedure TChart4DPng.Save(const Bitmap: TGPBitmap; const FilePath: string raise EChart4DException.CreateFmt(SFailedToSavePng, [FilePath, Ord(SaveStatus)]); end; +{ TChartPainter } + +constructor TChartPainter.Create(const Plot: TChartPlot); +begin + inherited Create; + FView := TChartView.Create(Plot); + FBackBuffer := TBitmap.Create; + FBackBuffer.PixelFormat := pf32bit; +end; + +destructor TChartPainter.Destroy; +begin + FBackBuffer.Free; + FView.Free; + inherited Destroy; +end; + +procedure TChartPainter.Paint(const TargetCanvas: TCanvas; const Width, Height: Integer); +begin + Paint(TargetCanvas, TRect.Create(0, 0, Width, Height)); +end; + +procedure TChartPainter.Paint(const TargetCanvas: TCanvas; const Bounds: TRect); +begin + const HasNegativeSize = (Bounds.Width < 0) or (Bounds.Height < 0); + if HasNegativeSize then + begin + const BoundsWidth: Double = Bounds.Width; + const BoundsHeight: Double = Bounds.Height; + raise EChart4DException.CreateFmt(SPaintBoundsNegativeSize, [BoundsWidth, BoundsHeight]); + end; + + FPaintedBounds := Bounds; + ResizeBackBuffer(Bounds.Width, Bounds.Height); + + if FView.NeedsRender(Bounds.Width, Bounds.Height) then + RenderToBackBuffer(Bounds.Width, Bounds.Height); + + TargetCanvas.Draw(Bounds.Left, Bounds.Top, FBackBuffer); + DrawOverlay(TargetCanvas, Bounds); +end; + +procedure TChartPainter.RenderToBackBuffer(const Width, Height: Integer); +begin + ResizeBackBuffer(Width, Height); + FView.Invalidate; + + const Graphics = TGPGraphics.Create(FBackBuffer.Canvas.Handle); + try + const ChartCanvas: IChartCanvas = TGdiPlusChartCanvas.Create(Graphics); + FView.Render(ChartCanvas, Width, Height); + finally + Graphics.Free; + end; +end; + +procedure TChartPainter.MouseMove(const X, Y: Integer); +begin + { Hit targets such as line points have a radius, so a pointer just outside the chart + could still hit one; outside the bounds is outside the chart, whatever it is near. } + const IsInsideChart = FPaintedBounds.Contains(TPoint.Create(X, Y)); + if not IsInsideChart then + begin + FView.MouseLeave; + Exit; + end; + + FView.MouseMove(X - FPaintedBounds.Left, Y - FPaintedBounds.Top); +end; + +procedure TChartPainter.MouseLeave; +begin + FView.MouseLeave; +end; + +procedure TChartPainter.ResizeBackBuffer(const Width, Height: Integer); +begin + const SizeChanged = (FBackBuffer.Width <> Width) or (FBackBuffer.Height <> Height); + if not SizeChanged then + Exit; + + FBackBuffer.SetSize(Width, Height); + FView.Invalidate; +end; + +procedure TChartPainter.DrawOverlay(const TargetCanvas: TCanvas; const Bounds: TRect); +begin + if not FView.HasOverlay then + Exit; + + const Graphics = TGPGraphics.Create(TargetCanvas.Handle); + try + { A tooltip pushed against the chart edge strokes its border across that edge; on a + canvas shared with other content that half pixel must not land outside Bounds. } + Graphics.SetClip(MakeRect(Bounds.Left, Bounds.Top, Bounds.Width, Bounds.Height)); + Graphics.TranslateTransform(Bounds.Left, Bounds.Top); + const ChartCanvas: IChartCanvas = TGdiPlusChartCanvas.Create(Graphics); + FView.DrawOverlay(ChartCanvas, Bounds.Width, Bounds.Height); + finally + Graphics.Free; + end; +end; + { TChart4D } constructor TChart4D.Create(AOwner: TComponent); @@ -405,30 +565,28 @@ constructor TChart4D.Create(AOwner: TComponent); ControlStyle := ControlStyle + [csOpaque]; FPlot := TChartPlot.Create; - FPlot.OnChanged := PlotChanged; - FHover := TChartHoverState.Create; - FBackBuffer := TBitmap.Create; - FBackBuffer.PixelFormat := pf32bit; + FPainter := TChartPainter.Create(FPlot); + FPainter.View.OnDataPointHover := ViewDataPointHover; + FPainter.View.OnRepaintRequest := ViewRepaintRequest; Width := DefaultExportWidth; Height := DefaultExportHeight; end; destructor TChart4D.Destroy; begin - FHover.Free; - FBackBuffer.Free; + FPainter.Free; FPlot.Free; inherited Destroy; end; function TChart4D.GetShowTooltips: Boolean; begin - Result := FHover.Enabled; + Result := FPainter.View.ShowTooltips; end; procedure TChart4D.SetShowTooltips(const Value: Boolean); begin - FHover.Enabled := Value; + FPainter.View.ShowTooltips := Value; end; procedure TChart4D.SaveToPng(const FilePath: string; @@ -456,59 +614,18 @@ procedure TChart4D.SaveToPng(const FilePath: string; procedure TChart4D.Paint; begin - EnsureBackBuffer; - Canvas.Draw(0, 0, FBackBuffer); - DrawTooltipOverlay; + FPainter.Paint(Canvas, Width, Height); end; procedure TChart4D.Resize; begin - FBackBufferValid := False; + FPainter.View.Invalidate; inherited Resize; end; -procedure TChart4D.EnsureBackBuffer; -begin - const SizeChanged = (FBackBuffer.Width <> Width) or (FBackBuffer.Height <> Height); - if SizeChanged then - begin - FBackBuffer.SetSize(Width, Height); - FBackBufferValid := False; - end; - - if not FBackBufferValid then - begin - RenderChartToBackBuffer; - FBackBufferValid := True; - end; -end; - procedure TChart4D.RenderChartToBackBuffer; begin - const Graphics = TGPGraphics.Create(FBackBuffer.Canvas.Handle); - try - const ChartCanvas: IChartCanvas = TGdiPlusChartCanvas.Create(Graphics); - - var HitMap: TArray; - TChartRenderer.Render(FPlot, ChartCanvas, Width, Height, HitMap); - FHover.HitMap := HitMap; - finally - Graphics.Free; - end; -end; - -procedure TChart4D.DrawTooltipOverlay; -begin - if not FHover.IsVisible then - Exit; - - const Graphics = TGPGraphics.Create(Canvas.Handle); - try - const ChartCanvas: IChartCanvas = TGdiPlusChartCanvas.Create(Graphics); - TChartTooltip.Draw(ChartCanvas, FPlot.Style, FHover.Info, Width, Height, FPlot.YAxis.LocaleName); - finally - Graphics.Free; - end; + FPainter.RenderToBackBuffer(Width, Height); end; procedure TChart4D.RenderForExport(const Graphics: TGPGraphics; const Width, Height: Single); @@ -517,33 +634,26 @@ procedure TChart4D.RenderForExport(const Graphics: TGPGraphics; const Width, Hei TChartRenderer.Render(FPlot, ChartCanvas, Width, Height); end; -procedure TChart4D.PlotChanged(Sender: TObject); -begin - FBackBufferValid := False; - Invalidate; -end; - procedure TChart4D.MouseMove(Shift: TShiftState; X, Y: Integer); begin inherited MouseMove(Shift, X, Y); - - if FHover.MoveTo(X, Y) then - HoverChanged; + FPainter.MouseMove(X, Y); end; procedure TChart4D.CMMouseLeave(var Message: TMessage); begin inherited; - - if FHover.Leave then - HoverChanged; + FPainter.MouseLeave; end; -procedure TChart4D.HoverChanged; +procedure TChart4D.ViewDataPointHover(Sender: TObject; const Info: TChartHitInfo); begin if Assigned(FOnDataPointHover) then - FOnDataPointHover(Self, FHover.Info); + FOnDataPointHover(Self, Info); +end; +procedure TChart4D.ViewRepaintRequest(Sender: TObject); +begin Invalidate; end; diff --git a/Tests/Chart4D.Tests.dpr b/Tests/Chart4D.Tests.dpr index 4d3f0ce..bbb5f07 100644 --- a/Tests/Chart4D.Tests.dpr +++ b/Tests/Chart4D.Tests.dpr @@ -31,6 +31,7 @@ uses Chart4D.Renderer.Tests in 'Chart4D.Renderer.Tests.pas', Chart4D.Invariants.Tests in 'Chart4D.Invariants.Tests.pas', Chart4D.Hover.Tests in 'Chart4D.Hover.Tests.pas', + Chart4D.View.Tests in 'Chart4D.View.Tests.pas', Chart4D.Style.Tests in 'Chart4D.Style.Tests.pas', Chart4D.Tooltip.Tests in 'Chart4D.Tooltip.Tests.pas', Chart4D.ValueLabels.Tests in 'Chart4D.ValueLabels.Tests.pas', diff --git a/Tests/Chart4D.View.Tests.pas b/Tests/Chart4D.View.Tests.pas new file mode 100644 index 0000000..0161321 --- /dev/null +++ b/Tests/Chart4D.View.Tests.pas @@ -0,0 +1,371 @@ +{*******************************************************} +{ } +{ Chart4D Library - Editorial data charts } +{ } +{ Copyright(c) 2026 GDK Software } +{ All rights reserved } +{ } +{ Licensed under MIT License } +{ } +{*******************************************************} +unit Chart4D.View.Tests; + +/// +/// Tests for TChartView: when a render is skipped or repeated, which hover events +/// and repaint requests pointer moves produce, when the tooltip overlay is drawn, and +/// that the view lets go of the plot's OnChanged when it is destroyed. +/// + +interface + +uses + DUnitX.TestFramework, + Chart4D.Tests.Asserts, + Chart4D.Types, + Chart4D.Plot, + Chart4D.View, + Chart4D.Tests.RecordingCanvas; + +type + [TestFixture] + TChartViewTests = class + private const + ViewWidth = 640; + ViewHeight = 450; + + private + FPlot: TChartPlot; + FView: TChartView; + FHoverEventCount: Integer; + FLastHoverInfo: TChartHitInfo; + FLastHoverSender: TObject; + FRepaintRequestCount: Integer; + + procedure HandleDataPointHover(Sender: TObject; const Info: TChartHitInfo); + procedure HandleRepaintRequest(Sender: TObject); + function RenderCallCount(const Width, Height: Single): NativeInt; + function FirstBarTarget: TChartHitTarget; + procedure HoverFirstBar; + + public + [Setup] + procedure Setup; + + [TearDown] + procedure TearDown; + + [Test] + procedure Render_FirstCall_DrawsPlot; + + [Test] + procedure Render_WhileStillValid_DrawsNothing; + + [Test] + procedure Render_AfterPlotChange_DrawsAgain; + + [Test] + procedure Render_AfterSizeChange_DrawsAgain; + + [Test] + procedure Render_AfterInvalidate_DrawsAgain; + + [Test] + procedure NeedsRender_AfterRenderAtSameSize_ReturnsFalse; + + [Test] + procedure PlotChange_RequestsRepaintWithoutHoverEvent; + + [Test] + procedure Invalidate_RequestsNoRepaint; + + [Test] + procedure MouseMove_OntoDataPoint_FiresHoverEventAndRequestsRepaint; + + [Test] + procedure MouseMove_WithinSameDataPoint_FiresNothing; + + [Test] + procedure MouseMove_BeforeFirstRender_FiresNothing; + + [Test] + procedure MouseMove_ShowTooltipsFalse_FiresNothing; + + [Test] + procedure MouseLeave_AfterHover_FiresMissAndRequestsRepaint; + + [Test] + procedure MouseLeave_NothingHovered_FiresNothing; + + [Test] + procedure DrawOverlay_NothingHovered_DrawsNothing; + + [Test] + procedure DrawOverlay_ShowTooltipsFalse_DrawsNothing; + + [Test] + procedure DrawOverlay_DataPointHovered_DrawsTooltip; + + [Test] + procedure Destroy_ReleasesPlotOnChanged; + + [Test] + procedure Destroy_PlotOnChangedReassigned_LeavesItAlone; + end; + +implementation + +uses + System.Classes, + Chart4D.Canvas.Interfaces, + Chart4D.Renderer; + +procedure TChartViewTests.Setup; +begin + FPlot := TChartPlot.Create; + FPlot.Kind := TChartKind.Bar; + FPlot.Title := 'Life expectancy'; + FPlot.Categories := ['Netherlands', 'Belgium', 'France']; + FPlot.AddSeries('2020', [81.4, 80.7, 82.2]); + + FView := TChartView.Create(FPlot); + FView.OnDataPointHover := HandleDataPointHover; + FView.OnRepaintRequest := HandleRepaintRequest; + + FHoverEventCount := 0; + FLastHoverInfo := Default(TChartHitInfo); + FLastHoverSender := nil; + FRepaintRequestCount := 0; +end; + +procedure TChartViewTests.TearDown; +begin + FView.Free; + FView := nil; + FPlot.Free; + FPlot := nil; +end; + +procedure TChartViewTests.HandleDataPointHover(Sender: TObject; const Info: TChartHitInfo); +begin + Inc(FHoverEventCount); + FLastHoverInfo := Info; + FLastHoverSender := Sender; +end; + +procedure TChartViewTests.HandleRepaintRequest(Sender: TObject); +begin + Inc(FRepaintRequestCount); +end; + +function TChartViewTests.RenderCallCount(const Width, Height: Single): NativeInt; +begin + const Canvas = TRecordingCanvas.Create; + const CanvasReference: IChartCanvas = Canvas; + FView.Render(CanvasReference, Width, Height); + Result := Canvas.Calls.Count; +end; + +function TChartViewTests.FirstBarTarget: TChartHitTarget; +begin + { The recording canvas measures text deterministically, so an independent render at the + view's size lays the bars out at exactly the pixels the view hit-tests against. } + const CanvasReference: IChartCanvas = TRecordingCanvas.Create; + var HitMap: TArray; + TChartRenderer.Render(FPlot, CanvasReference, ViewWidth, ViewHeight, HitMap); + Assert.IsTrue(Length(HitMap) > 0, 'The fixture plot must produce hit targets'); + Result := HitMap[0]; +end; + +procedure TChartViewTests.HoverFirstBar; +begin + RenderCallCount(ViewWidth, ViewHeight); + const Target = FirstBarTarget; + FView.MouseMove(Target.Bounds.CenterPoint.X, Target.Bounds.CenterPoint.Y); +end; + +procedure TChartViewTests.Render_FirstCall_DrawsPlot; +begin + Assert.IsTrue(RenderCallCount(ViewWidth, ViewHeight) > 0, 'The first render must draw the plot'); +end; + +procedure TChartViewTests.Render_WhileStillValid_DrawsNothing; +begin + RenderCallCount(ViewWidth, ViewHeight); + + Assert.AreEqual(0, RenderCallCount(ViewWidth, ViewHeight), + 'A render at the same size with no plot change must draw nothing'); +end; + +procedure TChartViewTests.Render_AfterPlotChange_DrawsAgain; +begin + RenderCallCount(ViewWidth, ViewHeight); + FPlot.Subtitle := 'Years at birth'; + + Assert.IsTrue(RenderCallCount(ViewWidth, ViewHeight) > 0, 'A plot change must make the next render draw'); +end; + +procedure TChartViewTests.Render_AfterSizeChange_DrawsAgain; +begin + RenderCallCount(ViewWidth, ViewHeight); + + Assert.IsTrue(RenderCallCount(ViewWidth + 10, ViewHeight) > 0, 'A wider render must draw'); + Assert.IsTrue(RenderCallCount(ViewWidth + 10, ViewHeight + 10) > 0, 'A taller render must draw'); +end; + +procedure TChartViewTests.Render_AfterInvalidate_DrawsAgain; +begin + RenderCallCount(ViewWidth, ViewHeight); + FView.Invalidate; + + Assert.IsTrue(RenderCallCount(ViewWidth, ViewHeight) > 0, 'Invalidate must make the next render draw'); +end; + +procedure TChartViewTests.NeedsRender_AfterRenderAtSameSize_ReturnsFalse; +begin + Assert.IsTrue(FView.NeedsRender(ViewWidth, ViewHeight), 'A view that never rendered needs a render'); + + RenderCallCount(ViewWidth, ViewHeight); + + Assert.IsFalse(FView.NeedsRender(ViewWidth, ViewHeight), 'A fresh render at this size is still valid'); + Assert.IsTrue(FView.NeedsRender(ViewWidth, ViewHeight + 1), 'Another size needs a render'); +end; + +procedure TChartViewTests.PlotChange_RequestsRepaintWithoutHoverEvent; +begin + FPlot.Subtitle := 'Years at birth'; + + Assert.AreEqual(1, FRepaintRequestCount, 'A plot change must request exactly one repaint'); + Assert.AreEqual(0, FHoverEventCount, 'A plot change must not fire OnDataPointHover'); +end; + +procedure TChartViewTests.Invalidate_RequestsNoRepaint; +begin + FView.Invalidate; + + Assert.AreEqual(0, FRepaintRequestCount, 'Invalidate is for a caller that repaints anyway'); +end; + +procedure TChartViewTests.MouseMove_OntoDataPoint_FiresHoverEventAndRequestsRepaint; +begin + HoverFirstBar; + + Assert.AreEqual(1, FHoverEventCount, 'Entering a bar must fire OnDataPointHover once'); + Assert.IsTrue(FLastHoverInfo.HasHit, 'The event must report a hit'); + Assert.AreEqual('Netherlands', FLastHoverInfo.CategoryLabel, 'The event must report the hovered bar'); + Assert.AreSame(FView, FLastHoverSender, 'The view must pass itself as Sender'); + Assert.AreEqual(1, FRepaintRequestCount, 'Entering a bar must request one repaint'); +end; + +procedure TChartViewTests.MouseMove_WithinSameDataPoint_FiresNothing; +begin + HoverFirstBar; + const Target = FirstBarTarget; + + FView.MouseMove(Target.Bounds.CenterPoint.X, Target.Bounds.CenterPoint.Y + 1); + + Assert.AreEqual(1, FHoverEventCount, 'Moving within the hovered bar must not fire again'); + Assert.AreEqual(1, FRepaintRequestCount, 'Moving within the hovered bar must not request a repaint'); +end; + +procedure TChartViewTests.MouseMove_BeforeFirstRender_FiresNothing; +begin + const Target = FirstBarTarget; + + FView.MouseMove(Target.Bounds.CenterPoint.X, Target.Bounds.CenterPoint.Y); + + Assert.AreEqual(0, FHoverEventCount, 'Without a render there is no hit map to hover'); + Assert.AreEqual(0, FRepaintRequestCount, 'Without a hover change there is nothing to repaint'); +end; + +procedure TChartViewTests.MouseMove_ShowTooltipsFalse_FiresNothing; +begin + FView.ShowTooltips := False; + + HoverFirstBar; + + Assert.AreEqual(0, FHoverEventCount, 'With tooltips off, hovering must not fire OnDataPointHover'); + Assert.AreEqual(0, FRepaintRequestCount, 'With tooltips off, hovering must not request a repaint'); +end; + +procedure TChartViewTests.MouseLeave_AfterHover_FiresMissAndRequestsRepaint; +begin + HoverFirstBar; + + FView.MouseLeave; + + Assert.AreEqual(2, FHoverEventCount, 'Leaving must fire OnDataPointHover once more'); + Assert.IsFalse(FLastHoverInfo.HasHit, 'Leaving must report no hit'); + Assert.AreEqual(2, FRepaintRequestCount, 'Leaving must request one more repaint'); + Assert.IsFalse(FView.HasOverlay, 'Nothing is hovered after leaving'); +end; + +procedure TChartViewTests.MouseLeave_NothingHovered_FiresNothing; +begin + RenderCallCount(ViewWidth, ViewHeight); + + FView.MouseLeave; + + Assert.AreEqual(0, FHoverEventCount, 'Leaving without a hovered point must not fire'); + Assert.AreEqual(0, FRepaintRequestCount, 'Leaving without a hovered point must not request a repaint'); +end; + +procedure TChartViewTests.DrawOverlay_NothingHovered_DrawsNothing; +begin + RenderCallCount(ViewWidth, ViewHeight); + const Canvas = TRecordingCanvas.Create; + const CanvasReference: IChartCanvas = Canvas; + + FView.DrawOverlay(CanvasReference, ViewWidth, ViewHeight); + + Assert.IsFalse(FView.HasOverlay, 'Nothing is hovered'); + Assert.AreEqual(0, Canvas.Calls.Count, 'Without a hovered point no overlay may be drawn'); +end; + +procedure TChartViewTests.DrawOverlay_ShowTooltipsFalse_DrawsNothing; +begin + HoverFirstBar; + FView.ShowTooltips := False; + const Canvas = TRecordingCanvas.Create; + const CanvasReference: IChartCanvas = Canvas; + + FView.DrawOverlay(CanvasReference, ViewWidth, ViewHeight); + + Assert.IsFalse(FView.HasOverlay, 'Turning tooltips off hides the overlay'); + Assert.AreEqual(0, Canvas.Calls.Count, 'With tooltips off no overlay may be drawn'); +end; + +procedure TChartViewTests.DrawOverlay_DataPointHovered_DrawsTooltip; +begin + HoverFirstBar; + const Canvas = TRecordingCanvas.Create; + const CanvasReference: IChartCanvas = Canvas; + + FView.DrawOverlay(CanvasReference, ViewWidth, ViewHeight); + + Assert.IsTrue(FView.HasOverlay, 'A hovered bar shows an overlay'); + Assert.AreEqual(1, Canvas.CountOfKind(TCanvasCallKind.FillCircle), 'The overlay must draw the highlight'); + Assert.IsTrue(Canvas.CountOfKind(TCanvasCallKind.FillRect) > 0, 'The overlay must draw the tooltip box'); + Assert.IsTrue(Canvas.HasTextEqualTo('2020'), 'The tooltip must name the hovered series'); + Assert.AreEqual(0, Canvas.CountOfKind(TCanvasCallKind.FillBackground), + 'The overlay must draw on top of the render, never the chart itself'); +end; + +procedure TChartViewTests.Destroy_ReleasesPlotOnChanged; +begin + FView.Free; + FView := nil; + + Assert.IsFalse(Assigned(FPlot.OnChanged), 'A destroyed view must not stay subscribed to the plot'); +end; + +procedure TChartViewTests.Destroy_PlotOnChangedReassigned_LeavesItAlone; +begin + FPlot.OnChanged := HandleRepaintRequest; + + FView.Free; + FView := nil; + + Assert.IsTrue(Assigned(FPlot.OnChanged), 'A handler assigned after the view took over must survive the view'); +end; + +end. diff --git a/Tools/CoreCheck/CoreCheck.dpr b/Tools/CoreCheck/CoreCheck.dpr index ae4451e..39f2637 100644 --- a/Tools/CoreCheck/CoreCheck.dpr +++ b/Tools/CoreCheck/CoreCheck.dpr @@ -29,7 +29,9 @@ uses Chart4D.Canvas.Interfaces in '..\..\Source\Chart4D.Canvas.Interfaces.pas', Chart4D.Plot in '..\..\Source\Chart4D.Plot.pas', Chart4D.Renderer in '..\..\Source\Chart4D.Renderer.pas', - Chart4D.Tooltip in '..\..\Source\Chart4D.Tooltip.pas'; + Chart4D.Tooltip in '..\..\Source\Chart4D.Tooltip.pas', + Chart4D.Hover in '..\..\Source\Chart4D.Hover.pas', + Chart4D.View in '..\..\Source\Chart4D.View.pas'; type /// @@ -280,6 +282,50 @@ begin end; end; +procedure CheckView; +var + Canvas: IChartCanvas; +begin + Canvas := TNullChartCanvas.Create; + + const BarPlot = TChartPlot.Create; + try + BarPlot.Kind := TChartKind.Bar; + BarPlot.Categories := ['A', 'B', 'C']; + BarPlot.AddSeries('Count', [3, 2, 5]); + + var HitMap: TArray; + TChartRenderer.Render(BarPlot, Canvas, 640, 450, HitMap); + + const View = TChartView.Create(BarPlot); + try + View.Render(Canvas, 640, 450); + if View.NeedsRender(640, 450) then + raise EChart4DException.Create('TChartView still needs a render right after rendering'); + + BarPlot.Title := 'Counts'; + if not View.NeedsRender(640, 450) then + raise EChart4DException.Create('TChartView does not need a render after a plot change'); + + View.Render(Canvas, 640, 450); + View.MouseMove(HitMap[1].Bounds.CenterPoint.X, HitMap[1].Bounds.CenterPoint.Y); + if not View.HasOverlay then + raise EChart4DException.Create('TChartView has no overlay while a bar is hovered'); + + View.DrawOverlay(Canvas, 640, 450); + View.MouseLeave; + if View.HasOverlay then + raise EChart4DException.Create('TChartView still has an overlay after the pointer left'); + finally + View.Free; + end; + + Writeln('TChartView (Render, NeedsRender, MouseMove, DrawOverlay, MouseLeave): OK'); + finally + BarPlot.Free; + end; +end; + begin try const Style = TChartStyle.Default; @@ -311,6 +357,7 @@ begin CheckRenderer; CheckTooltip; + CheckView; Writeln('CoreCheck: all checks passed'); ExitCode := 0; diff --git a/Tools/FmxCheck/FmxCheck.dpr b/Tools/FmxCheck/FmxCheck.dpr index a442e43..a4cbe12 100644 --- a/Tools/FmxCheck/FmxCheck.dpr +++ b/Tools/FmxCheck/FmxCheck.dpr @@ -29,6 +29,7 @@ uses System.SysUtils, System.IOUtils, System.Math, + System.Types, System.UITypes, FMX.Graphics, Chart4D.Types in '..\..\Source\Chart4D.Types.pas', @@ -41,6 +42,7 @@ uses Chart4D.Renderer in '..\..\Source\Chart4D.Renderer.pas', Chart4D.Tooltip in '..\..\Source\Chart4D.Tooltip.pas', Chart4D.Hover in '..\..\Source\Chart4D.Hover.pas', + Chart4D.View in '..\..\Source\Chart4D.View.pas', Chart4D.FMX in '..\..\Source\FMX\Chart4D.FMX.pas', Chart4DDemo.Catalog in '..\..\Examples\Common\Chart4DDemo.Catalog.pas'; @@ -615,6 +617,205 @@ begin end; end; +/// +/// Whether two colors are equal up to a rounding step per channel. Under a translated +/// canvas matrix FMX rasterises the same antialiased shape with an occasional +/// one-level difference in a channel; a misplaced shape differs by far more. +/// +function ColorsMatchWithinRounding(const Left, Right: TAlphaColor): Boolean; +const + MaxChannelDifference = 2; +begin + const LeftColor = TAlphaColorRec(Left); + const RightColor = TAlphaColorRec(Right); + Result := (Abs(LeftColor.A - RightColor.A) <= MaxChannelDifference) and + (Abs(LeftColor.R - RightColor.R) <= MaxChannelDifference) and + (Abs(LeftColor.G - RightColor.G) <= MaxChannelDifference) and + (Abs(LeftColor.B - RightColor.B) <= MaxChannelDifference); +end; + +/// +/// Counts the pixels of Target inside Bounds that differ, beyond rounding, +/// from the pixel of Reference at the same offset within Bounds, and the +/// pixels outside Bounds that no longer hold MarkerColor exactly. +/// +procedure CountOffsetPaintDifferences(const Target: TBitmap; const Bounds: TRect; const Reference: TBitmap; + const MarkerColor: TAlphaColor; + out DifferingInside, ChangedOutside: Integer); +begin + DifferingInside := 0; + ChangedOutside := 0; + + var TargetData, ReferenceData: TBitmapData; + if not Target.Map(TMapAccess.Read, TargetData) then + raise EChart4DException.Create('Could not map the offset paint for comparison'); + + try + if not Reference.Map(TMapAccess.Read, ReferenceData) then + raise EChart4DException.Create('Could not map the origin paint for comparison'); + + try + for var Y := 0 to Target.Height - 1 do + begin + for var X := 0 to Target.Width - 1 do + begin + const Pixel = TargetData.GetPixel(X, Y); + const IsInside = Bounds.Contains(TPoint.Create(X, Y)); + if IsInside and not ColorsMatchWithinRounding(Pixel, ReferenceData.GetPixel(X - Bounds.Left, Y - Bounds.Top)) then + Inc(DifferingInside); + if (not IsInside) and (Pixel <> MarkerColor) then + Inc(ChangedOutside); + end; + end; + finally + Reference.Unmap(ReferenceData); + end; + finally + Target.Unmap(TargetData); + end; +end; + +/// Paints Painter into Bounds on Bitmap, inside a scene, after filling the bitmap with FillColor. +procedure PaintInScene(const Painter: TChartPainter; const Bitmap: TBitmap; const Bounds: TRectF; + const FillColor: TAlphaColor); +begin + const SceneStarted = Bitmap.Canvas.BeginScene; + if not SceneStarted then + raise EChart4DException.Create('Failed to begin an FMX scene for the painter bounds check'); + + try + Bitmap.Canvas.Clear(FillColor); + Painter.Paint(Bitmap.Canvas, Bounds); + finally + Bitmap.Canvas.EndScene; + end; +end; + +/// +/// Proves that TChartPainter paints into bounds away from the canvas origin. The same +/// painter paints once at the origin of a bitmap of the chart's size and once into offset +/// bounds on a larger bitmap filled with a marker color. The offset region must match the +/// origin paint pixel for pixel, at rest and with the tooltip showing, which proves the +/// back buffer copy and the overlay translation; nothing outside the bounds may change. +/// Pointer positions are canvas coordinates, so hovering the offset target must find it +/// and moving outside the bounds must count as leaving. +/// +procedure VerifyPainterBounds; +const + OffsetX = 70; + OffsetY = 40; + MarkerColor = TAlphaColors.Fuchsia; +begin + const Plot = TChartPlot.Create; + try + BuildTooltipSamplePlot(Plot); + const Bounds = TRect.Create(OffsetX, OffsetY, OffsetX + DefaultExportWidth, OffsetY + DefaultExportHeight); + const OriginBounds = RectF(0, 0, DefaultExportWidth, DefaultExportHeight); + + var HitMap: TArray; + const HitMapBitmap = TBitmap.Create(DefaultExportWidth, DefaultExportHeight); + try + const SceneStarted = HitMapBitmap.Canvas.BeginScene; + if not SceneStarted then + raise EChart4DException.Create('Failed to begin an FMX scene for the painter bounds check'); + + try + const ChartCanvas: IChartCanvas = TFmxChartCanvas.Create(HitMapBitmap.Canvas); + TChartRenderer.Render(Plot, ChartCanvas, DefaultExportWidth, DefaultExportHeight, HitMap); + finally + HitMapBitmap.Canvas.EndScene; + end; + finally + HitMapBitmap.Free; + end; + + const Painter = TChartPainter.Create(Plot); + const Recorder = THoverRecorder.Create; + const Reference = TBitmap.Create(DefaultExportWidth, DefaultExportHeight); + const Target = TBitmap.Create(Bounds.Right + OffsetX, Bounds.Bottom + OffsetY); + try + Painter.View.OnDataPointHover := Recorder.HandleHover; + + PaintInScene(Painter, Reference, OriginBounds, MarkerColor); + PaintInScene(Painter, Target, TRectF.Create(Bounds), MarkerColor); + + var DifferingPixels, ChangedOutside: Integer; + CountOffsetPaintDifferences(Target, Bounds, Reference, MarkerColor, DifferingPixels, ChangedOutside); + if DifferingPixels <> 0 then + raise EChart4DException.CreateFmt( + 'painting into offset bounds should reproduce the origin paint, but %d pixels differ', [DifferingPixels]); + if ChangedOutside <> 0 then + raise EChart4DException.CreateFmt( + 'painting into offset bounds changed %d pixels outside those bounds', [ChangedOutside]); + + Writeln('FmxCheck: the painter paints into offset bounds and leaves the rest of the canvas alone'); + + const HoveredTarget = HitMap[High(HitMap)]; + var TargetPoint := HoveredTarget.Bounds.CenterPoint; + const IsCircularTarget = (HoveredTarget.Radius > 0); + if IsCircularTarget then + TargetPoint := HoveredTarget.Center; + + const HoverX = OffsetX + TargetPoint.X; + const HoverY = OffsetY + TargetPoint.Y; + Painter.MouseMove(HoverX, HoverY); + if (Recorder.EventCount <> 1) or (not Recorder.LastInfo.HasHit) then + raise EChart4DException.CreateFmt( + 'hovering the offset target should report one hit, but %d events fired', [Recorder.EventCount]); + if Recorder.LastInfo.CategoryLabel <> HoveredTarget.Info.CategoryLabel then + raise EChart4DException.CreateFmt( + 'hovering at the offset reported category "%s" but the target there is "%s"', + [Recorder.LastInfo.CategoryLabel, HoveredTarget.Info.CategoryLabel]); + + const RestingReference = TBitmap.Create; + try + RestingReference.Assign(Reference); + PaintInScene(Painter, Reference, OriginBounds, MarkerColor); + if CountDifferingPixels(RestingReference, Reference) = 0 then + raise EChart4DException.Create('hovering a bar should draw a tooltip, but the origin paint is unchanged'); + finally + RestingReference.Free; + end; + + PaintInScene(Painter, Target, TRectF.Create(Bounds), MarkerColor); + CountOffsetPaintDifferences(Target, Bounds, Reference, MarkerColor, DifferingPixels, ChangedOutside); + if DifferingPixels <> 0 then + raise EChart4DException.CreateFmt( + 'the tooltip in offset bounds should match the one at the origin, but %d pixels differ', [DifferingPixels]); + if ChangedOutside <> 0 then + raise EChart4DException.CreateFmt( + 'the tooltip in offset bounds changed %d pixels outside those bounds', [ChangedOutside]); + + Writeln('FmxCheck: hovering in offset bounds reports the target and draws its tooltip at the offset'); + + Painter.MouseMove(OffsetX - 1, HoverY); + if (Recorder.EventCount <> 2) or Recorder.LastInfo.HasHit then + raise EChart4DException.Create('moving outside the painted bounds should report leaving the chart'); + + Writeln('FmxCheck: moving outside the painted bounds counts as leaving the chart'); + + var RaisedOnNegativeSize := False; + try + PaintInScene(Painter, Target, RectF(OffsetX, OffsetY, OffsetX - 1, OffsetY + 1), MarkerColor); + except + on E: EChart4DException do + RaisedOnNegativeSize := True; + end; + if not RaisedOnNegativeSize then + raise EChart4DException.Create('painting into bounds of negative width should raise'); + + Writeln('FmxCheck: painting into bounds of negative size raises'); + finally + Target.Free; + Reference.Free; + Recorder.Free; + Painter.Free; + end; + finally + Plot.Free; + end; +end; + procedure VerifyExportedFile(const ExportPath: string); begin const FileWasCreated = TFile.Exists(ExportPath); @@ -653,6 +854,7 @@ begin VerifyEveryChartKindDraws(OutputDir); VerifyControlHoverChain(OutputDir); VerifyBackBufferCaching; + VerifyPainterBounds; Writeln('FmxCheck: all checks passed'); ExitCode := 0; diff --git a/Tools/VclCheck/VclCheck.dpr b/Tools/VclCheck/VclCheck.dpr index 686162c..590ba41 100644 --- a/Tools/VclCheck/VclCheck.dpr +++ b/Tools/VclCheck/VclCheck.dpr @@ -41,6 +41,7 @@ uses Chart4D.Renderer in '..\..\Source\Chart4D.Renderer.pas', Chart4D.Tooltip in '..\..\Source\Chart4D.Tooltip.pas', Chart4D.Hover in '..\..\Source\Chart4D.Hover.pas', + Chart4D.View in '..\..\Source\Chart4D.View.pas', Chart4D.VCL in '..\..\Source\VCL\Chart4D.VCL.pas'; procedure ExportSampleChart(const ExportPath: string); @@ -325,6 +326,183 @@ begin end; end; +/// +/// Counts the pixels of Target inside Bounds whose color channels differ from +/// the pixel of Reference at the same offset within Bounds. The alpha byte is +/// ignored for the reason given at BitmapsAreIdentical. +/// +function CountRegionDifferences(const Target: TBitmap; const Bounds: TRect; const Reference: TBitmap): Integer; +const + ColorChannelsMask = $00FFFFFF; +begin + Result := 0; + for var Y := 0 to Bounds.Height - 1 do + begin + var TargetPixel: PCardinal := Target.ScanLine[Bounds.Top + Y]; + Inc(TargetPixel, Bounds.Left); + var ReferencePixel: PCardinal := Reference.ScanLine[Y]; + for var X := 0 to Bounds.Width - 1 do + begin + if (TargetPixel^ and ColorChannelsMask) <> (ReferencePixel^ and ColorChannelsMask) then + Inc(Result); + Inc(TargetPixel); + Inc(ReferencePixel); + end; + end; +end; + +/// Counts the pixels of Target outside Bounds that no longer hold MarkerColor. +function CountChangedPixelsOutside(const Target: TBitmap; const Bounds: TRect; const MarkerColor: TColor): Integer; +const + ColorChannelsMask = $00FFFFFF; +begin + { The marker is a color whose red and blue channels are equal, so its TColor value and + its pf32bit pixel value are the same despite the reversed channel order. } + Result := 0; + for var Y := 0 to Target.Height - 1 do + begin + var Pixel: PCardinal := Target.ScanLine[Y]; + for var X := 0 to Target.Width - 1 do + begin + const IsOutside = not Bounds.Contains(TPoint.Create(X, Y)); + if IsOutside and ((Pixel^ and ColorChannelsMask) <> Cardinal(MarkerColor)) then + Inc(Result); + Inc(Pixel); + end; + end; +end; + +/// +/// Proves that TChartPainter paints into bounds away from the canvas origin. The same +/// painter paints once at the origin of a bitmap of the chart's size and once into offset +/// bounds on a larger bitmap filled with a marker color. The offset region must match the +/// origin paint pixel for pixel, at rest and with the tooltip showing, which proves the +/// back buffer copy and the overlay translation; nothing outside the bounds may change. +/// Pointer positions are canvas coordinates, so hovering the offset target must find it +/// and moving outside the bounds must count as leaving. The hovered point is the last one +/// of a line chart, whose tooltip is pushed against the right edge of the chart, so the +/// check also covers a tooltip border that would otherwise stroke across the bounds. +/// +procedure VerifyPainterBounds; +const + OffsetX = 70; + OffsetY = 40; + MarkerColor = clFuchsia; +begin + const Plot = TChartPlot.Create; + try + Plot.Title := 'Life expectancy'; + Plot.Subtitle := 'Selected countries, 1960-2020'; + Plot.Categories := ['1960', '1980', '2000', '2020']; + Plot.AddSeries('Netherlands', [73.5, 75.8, 78.0, 81.4]); + Plot.AddSeries('Portugal', [61.2, 71.0, 76.4, 80.8]); + const Bounds = TRect.Create(OffsetX, OffsetY, OffsetX + DefaultExportWidth, OffsetY + DefaultExportHeight); + + var HitMap: TArray; + const HitMapBitmap = TGPBitmap.Create(DefaultExportWidth, DefaultExportHeight, PixelFormat32bppARGB); + try + const Graphics = TGPGraphics.Create(HitMapBitmap); + try + const ChartCanvas: IChartCanvas = TGdiPlusChartCanvas.Create(Graphics); + TChartRenderer.Render(Plot, ChartCanvas, DefaultExportWidth, DefaultExportHeight, HitMap); + finally + Graphics.Free; + end; + finally + HitMapBitmap.Free; + end; + + const Painter = TChartPainter.Create(Plot); + const Recorder = THoverRecorder.Create; + const Reference = TBitmap.Create; + const Target = TBitmap.Create; + try + Painter.View.OnDataPointHover := Recorder.HandleHover; + Reference.PixelFormat := pf32bit; + Reference.SetSize(DefaultExportWidth, DefaultExportHeight); + Target.PixelFormat := pf32bit; + Target.SetSize(Bounds.Right + OffsetX, Bounds.Bottom + OffsetY); + + Painter.Paint(Reference.Canvas, DefaultExportWidth, DefaultExportHeight); + Target.Canvas.Brush.Color := MarkerColor; + Target.Canvas.FillRect(TRect.Create(0, 0, Target.Width, Target.Height)); + Painter.Paint(Target.Canvas, Bounds); + + var DifferingPixels := CountRegionDifferences(Target, Bounds, Reference); + if DifferingPixels <> 0 then + raise EChart4DException.CreateFmt( + 'painting into offset bounds should reproduce the origin paint, but %d pixels differ', [DifferingPixels]); + var ChangedOutside := CountChangedPixelsOutside(Target, Bounds, MarkerColor); + if ChangedOutside <> 0 then + raise EChart4DException.CreateFmt( + 'painting into offset bounds changed %d pixels outside those bounds', [ChangedOutside]); + + Writeln('VclCheck: the painter paints into offset bounds and leaves the rest of the canvas alone'); + + const HoveredTarget = HitMap[High(HitMap)]; + const HoverX = OffsetX + Round(HoveredTarget.Center.X); + const HoverY = OffsetY + Round(HoveredTarget.Center.Y); + Painter.MouseMove(HoverX, HoverY); + if (Recorder.EventCount <> 1) or (not Recorder.LastInfo.HasHit) then + raise EChart4DException.CreateFmt( + 'hovering the offset target should report one hit, but %d events fired', [Recorder.EventCount]); + if Recorder.LastInfo.CategoryLabel <> HoveredTarget.Info.CategoryLabel then + raise EChart4DException.CreateFmt( + 'hovering at the offset reported category "%s" but the target there is "%s"', + [Recorder.LastInfo.CategoryLabel, HoveredTarget.Info.CategoryLabel]); + + const RestingReference = TBitmap.Create; + try + RestingReference.Assign(Reference); + Painter.Paint(Reference.Canvas, DefaultExportWidth, DefaultExportHeight); + if BitmapsAreIdentical(RestingReference, Reference) then + raise EChart4DException.Create('hovering a bar should draw a tooltip, but the origin paint is unchanged'); + finally + RestingReference.Free; + end; + + Target.Canvas.FillRect(TRect.Create(0, 0, Target.Width, Target.Height)); + Painter.Paint(Target.Canvas, Bounds); + + DifferingPixels := CountRegionDifferences(Target, Bounds, Reference); + if DifferingPixels <> 0 then + raise EChart4DException.CreateFmt( + 'the tooltip in offset bounds should match the one at the origin, but %d pixels differ', [DifferingPixels]); + ChangedOutside := CountChangedPixelsOutside(Target, Bounds, MarkerColor); + if ChangedOutside <> 0 then + raise EChart4DException.CreateFmt( + 'the tooltip in offset bounds changed %d pixels outside those bounds', [ChangedOutside]); + + Writeln('VclCheck: hovering in offset bounds reports the target and draws its tooltip at the offset'); + + Painter.MouseMove(OffsetX - 1, HoverY); + if (Recorder.EventCount <> 2) or Recorder.LastInfo.HasHit then + raise EChart4DException.Create('moving outside the painted bounds should report leaving the chart'); + + Writeln('VclCheck: moving outside the painted bounds counts as leaving the chart'); + + var RaisedOnNegativeSize := False; + try + Painter.Paint(Target.Canvas, TRect.Create(OffsetX, OffsetY, OffsetX - 1, OffsetY + 1)); + except + on E: EChart4DException do + RaisedOnNegativeSize := True; + end; + if not RaisedOnNegativeSize then + raise EChart4DException.Create('painting into bounds of negative width should raise'); + + Writeln('VclCheck: painting into bounds of negative size raises'); + finally + Target.Free; + Reference.Free; + Recorder.Free; + Painter.Free; + end; + finally + Plot.Free; + end; +end; + function SimulateHover(const HitMap: TArray): TChartHitInfo; begin const HasHitTargets = (Length(HitMap) > 0); @@ -395,6 +573,7 @@ begin VerifyControlHoverChain; VerifyBackBufferCaching; + VerifyPainterBounds; Writeln('VclCheck: all checks passed'); ExitCode := 0; diff --git a/packages/RAD Studio 12.0/Chart4D_R.dpk b/packages/RAD Studio 12.0/Chart4D_R.dpk index cf248c2..00b23fa 100644 --- a/packages/RAD Studio 12.0/Chart4D_R.dpk +++ b/packages/RAD Studio 12.0/Chart4D_R.dpk @@ -52,6 +52,7 @@ contains Chart4D.Plot in '..\..\Source\Chart4D.Plot.pas', Chart4D.Renderer in '..\..\Source\Chart4D.Renderer.pas', Chart4D.Tooltip in '..\..\Source\Chart4D.Tooltip.pas', - Chart4D.Hover in '..\..\Source\Chart4D.Hover.pas'; + Chart4D.Hover in '..\..\Source\Chart4D.Hover.pas', + Chart4D.View in '..\..\Source\Chart4D.View.pas'; end. diff --git a/packages/RAD Studio 12.0/Chart4D_R.dproj b/packages/RAD Studio 12.0/Chart4D_R.dproj index ab28948..2f10645 100644 --- a/packages/RAD Studio 12.0/Chart4D_R.dproj +++ b/packages/RAD Studio 12.0/Chart4D_R.dproj @@ -96,6 +96,7 @@ + Base diff --git a/packages/RAD Studio 13.0/Chart4D_R.dpk b/packages/RAD Studio 13.0/Chart4D_R.dpk index cf248c2..00b23fa 100644 --- a/packages/RAD Studio 13.0/Chart4D_R.dpk +++ b/packages/RAD Studio 13.0/Chart4D_R.dpk @@ -52,6 +52,7 @@ contains Chart4D.Plot in '..\..\Source\Chart4D.Plot.pas', Chart4D.Renderer in '..\..\Source\Chart4D.Renderer.pas', Chart4D.Tooltip in '..\..\Source\Chart4D.Tooltip.pas', - Chart4D.Hover in '..\..\Source\Chart4D.Hover.pas'; + Chart4D.Hover in '..\..\Source\Chart4D.Hover.pas', + Chart4D.View in '..\..\Source\Chart4D.View.pas'; end. diff --git a/packages/RAD Studio 13.0/Chart4D_R.dproj b/packages/RAD Studio 13.0/Chart4D_R.dproj index d6660a5..1ba826f 100644 --- a/packages/RAD Studio 13.0/Chart4D_R.dproj +++ b/packages/RAD Studio 13.0/Chart4D_R.dproj @@ -96,6 +96,7 @@ + Base