TAChart Tutorial: ColorMapSeries, Zooming
Introduction
The "Mandelbrot set", named after Benoit Mandelbrot, is a so-called "fractal" -- a two-dimensional shape that is famous for its self-similarity at various scales. Magnifying a fractal reveals small-scale details similar to the large-scale characteristics.
In this tutorial, we will use TAChart to draw the Mandelbrot set by means of a colormap series and practice a variety of zooming techniques, like using the zoom drag tool, or setting up an extent history.
As usual, we require a basic knowledge of working with TAChart which you can aquire by going through the "Getting Started" tutorial. And, of course, you must be familiar with the Lazarus IDE and the Object Pascal language.
Using TColormapSeries
What is a TColorMapSeries
?
The series that you typically work with are for two-dimensional data -- there is one axis for the x coordinates, and another one for the y values which are assigned to the x values. TColorMapSeries
is one exception: it displays three-dimensional data: we have a point in the xy plane and assign to it a third value as its height above the xy plane. Often, 3D plots, are considered to be like a mountain landscape above a base plane. Unfortunately, TAChart cannot draw 3d projections. But there is an alternative: the height above the base plane can be can be mapped to a color, this means the chart fills the xy plane with colors which correspnd to the value of the third coordinate.
TColorMapSeries
is a functional series, this means that it cannot plot arbitrary data, but only data that can be calculated from a function. For this purpose, the series exposes an event OnCalculate
which is called for every (x,y) point to pass the function value. In this sense, the TColorMapSeries
is similar to the TFuncSeries
which we meet in another tutorial.
The second basic ingredient is a ColorSource
which maps the function values to a color. It is convenient to use a TListChartSource
for this purpose. In general, it stores x and y values along with a color value and a descriptive label in TChartDataItem
s, but for the mapping purpose we need only the x and color entries.
Ready to start?
Create a new project, and add a client-aligned TChart component. Don't make the window too large since the project in this tutorial will require a LOT of computation, and too many pixels will kill performance...
Double-click on the chart to open the series editor and add a "Color map series" to the chart. Next, drop a TListChartSource
to the form. This will be the ColorSource
for the ColorMapSeries. So, name it "ColorSource" and assign it to the ColorSource
property of the series.
Setting up the color map
To get some familiarity in this new terrain, let us begin with a simple example: we use the ColorMapSeries to draw a gradient along the x axis.
At first, we write an event handler for the ColorMapSeries' OnCalculate
. This is easy since we want only a simple linear gradient:
procedure TForm1.Chart1ColorMapSeries1Calculate(const AX, AY: Double;
out AZ: Double);
var
ext: TDoubleRect;
begin
ext := Chart1.GetFullExtent;
AZ := (AX - ext.a.x) /(ext.b.x - ext.a.x);
end;
To be independent of the size of the chart we normalize the x coordinate by the range of x data (in graph units). The x range can be calculated from the FullExtent of the chart -- this is a rectangle of double-precision values defined by the corner points of the chart's plotting rectangle before any zooming or panning is applied. The normalization as shown above makes sure that AZ is 0 at the left and 1 at the right end of the x axis.
Finally we have to populate the ColorSource to setup the gradient. This is very easy since the ColorMapSeries does all the interpolations if its property Interpolate
is set to true
.
To define the gradient we begin with the value 0.0 and assign it to the color blue. The end of the gradient corresponds to the value 1.0 and it should appear in yellow. We make these assignments in a procedure PopulateColorSource
that we call from the OnCreate
event handler of the form; we use a separate procedure since we'll do some modifications throughout this tutorial and want to keep things separate.
procedure TForm1.FormCreate(Sender: TObject);
begin
PopulateColorSource;
end;
procedure TForm1.PopulateColorSource;
const
DUMMY = 0.0;
begin
with ColorSource do begin
Add(0.0, DUMMY, '', clBlue); // 0.0 --> blue
Add(1.0, DUMMY, '', clYellow); // 1.0 --> yellow
end;
end;
Due to the syntax of TListChartSource.Add
we also have to enter values for the y coordinate and the marks text, we use the constant DUMMY
and an empty string since we don't need them.
Let's run the program. If you don't see a gradient you may have forgotten to activate the Interpolate
setting of the ColorMapSource. If you look carefully you will see some narrow banding of the color shades particularly in the center transition region. This is due to the StepX
and StepY
properties of the ColorMapSource which are still at their default value of 4. This means that the xy plane is not scanned pixel by pixel, but in blocks of 4x4 pixels, to speed up drawing. Set these values to 1 to get rid of the banding. The improved resolution is also advantageous for the Mandelbrot set later. However, it will slow down program execution which may be quite dramatic if you have a slow computer...
Let's go a step further and add another pivot to our gradient. What about red in the middle corresponding to, say, 0.3? Just put another call to Add
into the PopulateColorSource
procedure. Since the interpolation requires a sorted list you should add the new color between the other ones, or you should call Sort
after adding color-value pairs. The result of this tree-color gradient is shown above, we will used it now when we draw the Mandelbrot set.
procedure TForm1.PopulateColorSource;
const
DUMMY = 0.0;
begin
with ColorSource do begin
Add(0.0, DUMMY, '', clBlue); // 0.0 --> blue
Add(0.3, DUMMY, '', clRed); // 0.3 --> red
Add(1.0, DUMMY, '', clYellow); // 1.0 --> yellow
end;
end;
The Mandelbrot set
So what is the Mandelbrot set? Roughly speaking, it is a set of points in the xy plane that follow a certain rule, like a circle which has the rule that all points on the circle (i.e. in the set) must have the same distance from the origin.
For the Mandelbrot set the rule is a bit more complex. The world "complex" is to be understood here in a two-fold sense: complex in the sense of "complicated", but also complex in the mathematical sense that the points in the xy plane are treated as complex numbers. If you are not familiar with complex numbers, don't give up -- we will avoid calculations with these "strange" numbers completely.
Calculation
The best way to understand the Mandelbrot set is do its construction. We apply the following recipe for each 2d point in the xy plane; these starting points will be called c = (cx, cy).
- Begin by assigning c to another point z = (zx, zy) = (cx, cy).
- Calculate the "square" of z according to the following formula: z2 = (zx2 - zy2, 2 * zx * zy) -- this formula may appear a bit strange, but if you are familiar with complex numbers then you will notice that it is the way how to calculate the square of a complex number.
- Then add the coordinates of c to those of
z2
. The result of this step is z2 + c = (zx2 - zy2 + cx, 2 * zx * zy + cy) - Now take that result and put it into the calculation in step 2 again as a new value of z.
- Repeat this procedure again and again. As a result the point z will follow some kind of trajectory in the xy plane. In the figure we show some examples of that trace:
- The red and fuchsia curves eventually move away from the origin, these paths are "unbounded". It can be shown that once the path has crossed a critical distance of 2 from the origin it will never return back and will escape into infinity. Usually the calculation counts the iterations until the distance from the origin exceeds 2. The number of iterations is mapped to a color which is used to draw the pixel at the starting point c.
- The blue path, on the other hand, converges towards the origin. The green curve does not converge, but remains within the escape radius. Both cases are called "bounded". The iterative calculation would go on forever. Therefore it is stopped after a maximum count of iterations. The starting points c for these trajectories are said to belong to the Mandelbrot set and are drawn in black color.
Although this description may sound a bit complicated only a few lines of Pascal code are required to do this calculation. The following function determines whether a point c is in the Mandelbrot set or not. It returns the number of iterations needed and the coordinates of the point after the last iteration performed.
const
MANDELBROT_NUM_ITERATIONS = 100;
MANDELBROT_ESCAPE_RADIUS = 2.0;
MANDELBROT_LIMIT = sqr(MANDELBROT_ESCAPE_RADIUS);
function InMandelbrotSet(c:TDoublePoint; out Iterations:Integer; out z: TDoublePoint): Boolean;
var
j: Integer;
begin
Iterations := 0;
z := DoublePoint(0.0, 0.0);
for j:=0 to MANDELBROT_NUM_ITERATIONS-1 do begin
z := DoublePoint(
sqr(z.X) - sqr(z.Y) + c.X,
2 * z.X * z.Y + c.Y
);
if sqr(z.X) + sqr(z.Y) > MANDELBROT_LIMIT then begin
Result := false;
// point did escape --> c is not in Mandelbrot set
exit;
end;
inc(Iterations);
end;
Result := true;
end;
Drawing
Now we want to draw the Mandelbrot set. You know what to do? Yes - we have to write a corresponding handler for OnCalculate
. In this event handler we call our InMandelbrotSet
procedure. If the point passed to the event handler is in the Mandelbrot set we set its color to black, if not we divide the number of iterations by the maximum number of iterations to get a value between 0 and 1 which has a corresponding entry in our ColorSource. Oh - but how can we give the points in the Mandelbrot set a black color? Just add another number-color pair to the ColorSource which links clBlack
to, e.g., -1.
procedure TForm1.PopulateColorSource;
const
DUMMY = 0.0;
begin
with ColorSource do begin
Clear;
Add(-1.0, DUMMY, '', clBlack);
Add( 0.0, DUMMY, '', clBlue);
Add( 0.3, DUMMY, '', clRed);
Add( 1.0, DUMMY, '', clYellow);
end;
end;
procedure TForm1.Chart1ColorMapSeries1Calculate(const AX, AY: Double;
out AZ: Double);
var
iterations: Integer;
z: TDoublePoint;
begin
if InMandelBrotSet(DoublePoint(AX, AY), iterations, z) then
AZ := -1
else
AZ := iterations / MANDELBROT_NUM_ITERATIONS;
end;
And that's what we get - see the left image:
Very nice. But as usual the first result is not yet the best one. These are two points that we can improve:
- The picture is clipped. This is because we did not define the calculation range that the ColorMapSeries uses for x and y. The overview of the Mandelbrot set is seen best when x and y range between -2.2 and 0.8, and between -1.5 and 1.5, respectively. Enter these numbers in the chart's
Extent
fieldsXMin, XMax, YMin
andYMax
, and set the fieldsUseXMin, UseXMax, UseYMin
andUseYMin
totrue
to activate these values as axis limits. You might as well use theExtent
property of the series, but then you would have to set the chart'sMargins
to 0 in order to remove the non-filled background near the axes.
- The picture looks somehow distorted if you compare it with the images in [1]. This happens because x and y axes have different unit lengths. To avoid the distortion the distance between 0 and 1 must be equal for both axes. Of course, we could initialize the width and height of the form correctly, but the user could resize the form, and this would distort our image again. However,
TChart
has a practical property to enforce a "square" coordinate system: setProportional
totrue
. With this setting the extent of the x and y axes is adjusted automatically to always get equal units.
The result after these modifications is displayed above in the right image. You maybe notice that the x axis range is wider than we had requested. This is a consequence of the Proportional
setting since the aspect ratio of our window is not compatible with the x and y ranges set in the chart's Extent
.
Before we continue with the tutorial, here's a piece of "homework": Modify the program such that the Mandelbrot set is not black, but shows colors according to the distance of the trajectory from the origin when the iteration is aborted.
Zooming and panning
It was mentioned in the introduction that the Mandelbrot set is self-similar at magnified scales. This calls for zooming -- and this will be the subject of the next part of the tutorial.
TChart
has built-in zooming capabilities. You just have to drag a rectangle with the mouse from the top-left to the bottom-right corner of the region that you want to magnify and hold the left mouse button down while dragging. In the current trunk version of Lazarus you can pan by dragging the viewport with the right mouse button.
Of course, you can use the new magnified image to zoom again and again. However, at some zoom level, the axis labels will get longer and longer and may eventually overlap. This does not look nice. Do we need the axis at all? No -- let's turn it off by going to the left and the bottom axis and setting their property Visible
to false
. But maybe we should have some indication on the size of the current viewport. We could display the current magnification in the statusline.
Drop a statusline component to the form -- it is on page Common Controls of the component palette. Since we will add more items later, go to property Panels
and add two status panels, set their width to 200.
How do we get the current magnification? The viewport is described in TAChart by several "extents"; these are rectangles like TRect
, but the corner points are given by floating point numbers (in graph coordinates). This data type is called TDoubleRect
and is declared in unit TAChartUtils
. We already had discussed the function GetFullExtent
of TChart
which returns the unzoomed/unpanned viewport. After zooming or panning the visible viewport can be accessed by CurrentExtent
and LogicalExtent
. Both rectangles are very similar, the former one is slightly larger than the latter one since it also takes care of the space needed for the axis tick marks and the inner chart margins. Because this extra space is not considered in the full extent, we have to compare the widths of the full extent to that of the logical extent to calculate the magnification.
It should be noted that the extents are always given in graph units, i.e. the underlying coordinate system of the chart. In our case this is the same as the coordinate system displayed at the axes (axes coordinates), but if our chart would take advantage of TChartTransformations
we would have to consider this important difference.
When do we update the status bar? Whenever the extent of the chart changes. TChart
provides two events for this occasion: OnExtentChanged
and OnExtentChanging
. Both events are fired when the extent already has changed, OnExtentChanged is called from within the drawing routine, OnExtentChanging
is called before any drawing. The difference is important if your event handler would initiate a re-draw of the chart again -- this might lock your application... In our application the difference is not important, so we'll vote against OnExtentChanging
which is not yet available in the official Lazarus version.
procedure TForm1.Chart1ExtentChanged(ASender: TChart);
var
cex, fex: TDoubleRect;
factor: double;
begin
cex := Chart1.CurrentExtent;
fex := Chart1.GetFullExtent;
if cex.b.x = cex.a.x then exit;
factor := (fex.b.x - fex.a.x) / (cex.b.x - cex.a.x);
if factor > 1e6 then
Statusbar1.Panels[0].Text := Format('Magnification: %.0e', [factor])
else
Statusbar1.Panels[0].Text := Format('Magnification: %0.n', [factor]);
end;
This is a possible version of the event handler. Since the magnifications will be difficult to read in case of high magnifications we switch between standard and exponential numeric notations.
Play with the magnifications -- this image is magnified almost 1 billion times (1E9). And when you are lucky you'll find locations where you can zoom to magnifications of 1E14 before the discrete nature of double precision number becomes visible. Hold on for a moment and think about this number: if the original, non-zoomed window is 10 cm across -- then it would measure 1E13 meters at this magnification. This is the size of the solar system! Unfortunately, TAChart uses double-precision numbers in its internal calculations, so we cannot zoom further. But there are specialized programs out there in the internet which can overcome this limitation.
Related tutorials
- TAChart Tutorial: Getting started: explains how the chart used here is created.
- TAChart Tutorial: Chart Tools: introduction to chart tools
- TAChart Tutorial: Function Series: introduces
TFunctionSeries
which follows a similar concept asTColorMapSeries
<---