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