Chart4D is a zero-dependency Delphi library that produces publication-ready, editorial-style charts for both VCL and FireMonkey: clean typography, a light horizontal grid, direct labelling, and a publication footer with source text and an optional logo.
This document is the authoritative design contract. Implementations must match the public APIs declared here exactly. Section 8 gives the source conventions they are written to.
Thirteen chart kinds: line (single and multiple series), area, bar, grouped bar,
stacked bar (absolute and proportional), histogram, dot plot, dumbbell, range, arrow,
scatter (with optional bubble sizing), pie and donut. One built-in visual style
(TChartStyle.Default) with user-overridable properties. Annotations (text labels,
segments, arrows, reference lines, shaded range overlays) and uncertainty/range band
series. Value labels with deterministic placement (4.12) and series highlighting (4.13).
Legend control. Axis label formatting, including locale-aware formatting (4.14),
logarithmic value axes (4.15) and date axes (4.16). PNG export
with publication finishing: left-aligned title/subtitle, footer with source text (left),
a full-width separator line above the footer, and an optional logo (right). Default
export size 640x450 pixels. Hover interaction on both controls: nearest data point
highlighting with an in-chart tooltip (see 4.11), on by default.
Out of scope for v1: facets/small multiples, zoom/pan, 3D, design-time component registration (runtime creation only), animation.
Chart4D/
├── Source/ Core, RTL-only (no platform-specific APIs)
│ ├── Chart4D.Types.pas
│ ├── Chart4D.Consts.pas
│ ├── Chart4D.Style.pas
│ ├── Chart4D.Series.pas
│ ├── Chart4D.Axis.pas
│ ├── Chart4D.Canvas.Interfaces.pas
│ ├── Chart4D.Plot.pas
│ ├── 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)
│ ├── Chart4D.Preview.pas TChartPreview: the design-time sample chart (4.26)
│ ├── VCL/
│ │ ├── Chart4D.VCL.pas GDI+ canvas, TChartPainter, TChart4D control, PNG export
│ │ └── Chart4D.VCL.Register.pas Palette registration, design-time package only
│ └── FMX/
│ ├── Chart4D.FMX.pas FMX canvas, TChartPainter, TChart4D control, PNG export
│ └── Chart4D.FMX.Register.pas Palette registration, design-time package only
├── packages/RAD Studio 13.0/
│ ├── Chart4D_R.dpk/.dproj requires rtl
│ ├── Chart4D_VCL_R.dpk/.dproj requires rtl, vcl, Chart4D_R
│ ├── Chart4D_FMX_R.dpk/.dproj requires rtl, fmx, Chart4D_R
│ ├── Chart4D_VCL_D.dpk/.dproj design-only, requires designide, Chart4D_VCL_R
│ └── Chart4D_FMX_D.dpk/.dproj design-only, requires designide, Chart4D_FMX_R
├── packages/
│ ├── Chart4D.Icons.rc/.dcr Palette bitmaps for TChart4D
│ └── icons/ 24 and 32 pixel bitmaps, and make-icons.ps1
├── Tests/ DUnitX console project + build.bat
├── Examples/
│ ├── Common/ Chart4DDemo.Catalog.pas, shared by both demos
│ ├── VCL/ Chart4DDemoVcl.dpr
│ └── FMX/ Chart4DDemoFmx.dpr
├── Tools/
│ ├── CoreCheck/ Console .dpr that uses every core unit (compile check)
│ ├── VclCheck/ Renders and exports through the VCL adapter, plus tooltip,
│ │ the design-time preview and the DFM round trip (4.26)
│ └── FmxCheck/ Same for FMX, plus series-pixel checks on the paint path
├── assets/ Chart4D.ico and the mark, plus the .rc/.res that embeds it
├── docs/images/ Rendered charts used by README.md
├── Build.bat Builds packages, tests, demos
└── SPEC.md, README.md, LICENSE, GDK.Chart4D.dspec
Dependency rules: core units use only System.*. Chart4D.VCL.pas additionally uses
Vcl.*/Winapi.*; Chart4D.FMX.pas additionally uses FMX.*. Nothing in core may
reference an adapter.
All colors are TAlphaColor ($AARRGGBB). Sizes are pixels at the 640x450 reference size,
multiplied by TChartStyle.ScaleFactor when rendering.
| Element | Value |
|---|---|
| Title | bold, 28, $FF222222 |
| Subtitle | regular, 22, $FF222222, 9 px margin above and below |
| Legend text | 18, $FF222222, no legend title, top position, left aligned |
| Axis text | 18, $FF222222; no axis titles, no tick marks, no axis lines |
| X-axis labels | 5 px margin above, 10 px below the labels |
| Gridlines | value-axis only, $FFCBCBCB, width 1; none on the category axis |
| Baseline at value 0 | $FF333333, width 2 (drawn when 0 is inside the value range) |
| Background | $FFFFFFFF |
| Footer (source) | 16, $FF555555, bottom left; separator line $FF222222 width 1 above the footer area |
| Tooltip | white box, border $FFCBCBCB width 1, text 16 $FF222222, 8 px padding; highlight circle radius 5 in the series color |
| Series line width | 3 |
| Font | 'Helvetica'; on Windows 'Arial' ({$IFDEF MSWINDOWS}) |
| Value labels | text AxisFontSize/TextColor, background box in LabelBackgroundColor, white by default (3 px padding), same convention as TextLabel annotations and segment labels (see 4.12, 4.29) |
| Series highlight (muted series) | drawn in ChartLightGrey (see 4.13) |
| Scatter point radius | 4 (uniform, used when a series has no per-point Sizes; see 4.19) |
| Bubble radius range | 4 to 24, area-proportional (Sqrt scaling), domain shared across the whole plot (see 4.19) |
| Donut inner radius | 0.6 × outer radius (see 4.24) |
The last four rows are carried by TChartStyle fields: ScatterPointRadius: Single (4),
MinBubbleRadius: Single (4), MaxBubbleRadius: Single (24), DonutInnerRadiusFactor: Single (0.6). See 4.3.
Palette constants in Chart4D.Style.pas:
const
ChartBlue = TAlphaColor($FF1380A1);
ChartOrange = TAlphaColor($FFFAAB18);
ChartDarkRed = TAlphaColor($FF990000);
ChartGreen = TAlphaColor($FF588300);
ChartTextDark = TAlphaColor($FF222222);
ChartTextMuted = TAlphaColor($FF555555);
ChartBaselineGrey = TAlphaColor($FF333333);
ChartGridGrey = TAlphaColor($FFCBCBCB);
ChartLightGrey = TAlphaColor($FFDDDDDD);
DefaultPalette: array[0..5] of TAlphaColor =
(ChartBlue, ChartOrange, ChartDarkRed, ChartGreen, ChartBaselineGrey, ChartLightGrey);Signatures below are normative. Add /// <summary> docs; private helpers are free.
{$SCOPEDENUMS ON}
type
TChartKind = (Line, Bar, GroupedBar, StackedBar, Dumbbell, Histogram, Area,
Scatter, DotPlot, Range, Arrow, Pie, Donut);
TChartOrientation = (Vertical, Horizontal);
TStackMode = (Values, Proportions);
TLegendPosition = (None, Top, Right, Bottom, Left);
TAnnotationKind = (TextLabel, Segment, Arrow, HorizontalLine, VerticalLine,
HorizontalRangeOverlay, VerticalRangeOverlay);
TTextAlignH = (Left, Center, Right);
TTextAlignV = (Top, Middle, Bottom);
TChartAnnotation = record
Kind: TAnnotationKind;
X, Y, X2, Y2: Double;
Text: string;
Color: TAlphaColor;
FontSize: Single;
LineWidth: Single;
Dashed: Boolean;
AlignH: TTextAlignH;
end;
EChart4DException = class(Exception);
TChartKindTraits = record
/// <summary>
/// Whether the kind draws as wedges of a circle and therefore has no axes, no
/// gridlines and no baseline at all.
/// </summary>
class function IsCircular(const Kind: TChartKind): Boolean; static;
/// <summary>
/// Whether the kind plots against a continuous X axis built from
/// <c>TChartSeries.XValues</c>, rather than against discrete categories.
/// </summary>
class function UsesContinuousX(const Kind: TChartKind): Boolean; static;
/// <summary>
/// Whether the kind reads <c>TChartSeries.EndValues</c> as the second end of every
/// data point, so those values count towards the value range as well.
/// </summary>
class function HasEndValues(const Kind: TChartKind): Boolean; static;
/// <summary>
/// Whether the kind draws marks that grow from a zero baseline, which forces zero
/// into the value range even when no data point is near it.
/// </summary>
class function IncludesZeroBaseline(const Kind: TChartKind): Boolean; static;
/// <summary>
/// Whether the kind's marks encode a position rather than a length from a baseline.
/// Such a scale gets headroom below the data as well as above it, because its lowest
/// mark would otherwise sit on the plot edge.
/// </summary>
class function MarksPositionNotBaseline(const Kind: TChartKind): Boolean; static;
end;Annotation coordinates are in data space. For category charts the category axis
coordinate is the 0-based category index as Double. Pie/Donut charts have no
axes to define a data space, so annotations there use a coordinate space of their own:
X is still the 0-based category index, and Y is a fraction of the plot height, 0 at
the bottom edge and 1 at the top (4.23). FontSize/LineWidth value 0
means "use style default". TextLabel annotations are drawn with a background
rectangle behind the text, in the style's LabelBackgroundColor (opaque white by default,
4.29), so they stay readable on top of series.
TChartKindTraits is the single authority on the per-kind facts that more than one
part of the library has to agree on; every site that needs one of these answers asks
this record rather than keeping its own Kind in [...] set.
Bubble charts are not a separate TChartKind: a Scatter series with a non-empty
Sizes array (4.4) is a bubble series. A dedicated kind would duplicate every scatter
code path (axis computation, hit map, legend) for a difference that is really a
per-series data fact, not a chart-kind fact; see 4.19.
TAnnotationKind's HorizontalRangeOverlay is a filled band between Y and Y2,
spanning the full plot width; VerticalRangeOverlay is a filled band between X and
X2, spanning the full plot height; see 4.18. Both reuse the X2/Y2 fields that
Segment/Arrow also read, so TChartAnnotation needs no extra field for them.
resourcestring error messages (English) and:
const
DefaultExportWidth = 640;
DefaultExportHeight = 450;
AutomaticDecimals = -1; // TAxisOptions.Decimals default; see 4.27The resourcestring entries for the capabilities in 4.12 to 4.25:
resourcestring
SSeriesSizeCountMismatch = 'Series "%s" has %d value(s) but %d size(s)';
SLogAxisRequiresPositiveValues = 'Logarithmic value axis requires every value to be greater than 0, got %g';
SPairedValueCountMismatch = '%s series has %d start value(s) but %d end value(s)';
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)';type
TChartStyle = record
FontName: string;
TitleFontSize: Single; // 28
SubtitleFontSize: Single; // 22
LegendFontSize: Single; // 18
AxisFontSize: Single; // 18
CaptionFontSize: Single; // 16
TitleColor: TAlphaColor; // ChartTextDark
TextColor: TAlphaColor; // ChartTextDark
MutedTextColor: TAlphaColor;// ChartTextMuted
BackgroundColor: TAlphaColor;
GridColor: TAlphaColor;
BaselineColor: TAlphaColor;
ShowGridlines: Boolean; // True: gridlines on the value axis
ShowBaseline: Boolean; // True
GridLineWidth: Single; // 1
BaselineWidth: Single; // 2
SeriesLineWidth: Single; // 3
ScaleFactor: Single; // 1.0
ScatterPointRadius: Single; // 4, see 4.19
MinBubbleRadius: Single; // 4, see 4.19
MaxBubbleRadius: Single; // 24, see 4.19
DonutInnerRadiusFactor: Single; // 0.6, see 4.24
LabelBackgroundColor: TAlphaColor; // ChartLabelBackground, see 4.29
MinimumTextContrast: Double; // 4.5, see 4.29
Palette: TArray<TAlphaColor>; // [] = DefaultPalette, see 4.30
class function Default: TChartStyle; static;
end;
TChartColors = record
class function Blend(const Over, Under: TAlphaColor): TAlphaColor; static;
class function RelativeLuminance(const Color: TAlphaColor): Double; static;
class function ContrastRatio(const First, Second: TAlphaColor): Double; static;
class function ReadableTextColor(const Preferred, Background: TAlphaColor;
const MinimumContrast: Double = MaximumContrastRatio): TAlphaColor; static;
end;MaximumContrastRatio = 21.0 (black against white) is declared alongside.
TChartColors is the color arithmetic behind legible label text (4.29): Blend
composites Over onto Under with the "over" operator, RelativeLuminance and
ContrastRatio are the WCAG 2 definitions (sRGB channels linearized, weights 0.2126,
0.7152, 0.0722; ratio (L1 + 0.05) / (L2 + 0.05) with L1 the lighter), and
ReadableTextColor returns Preferred when its contrast ratio against Background is at
least MinimumContrast, and otherwise whichever of Preferred and white has the higher
ratio, Preferred on a tie. With the default MaximumContrastRatio only black on white
reaches the minimum, and there Preferred is also the better of the two, so the default
always returns the more legible color; a minimum of 1 or less always returns Preferred.
type
TChartSeries = class
public
constructor Create(const Name: string);
property Name: string read ... write ...;
property Color: TAlphaColor read ... write ...; // 0 = auto from palette
property Values: TArray<Double> read ... write ...;
property XValues: TArray<Double> read ... write ...; // Line/Area/Scatter only
property EndValues: TArray<Double> read ... write ...; // Dumbbell/Range/Arrow/range bands
property Sizes: TArray<Double> read ... write ...; // Scatter only; empty = uniform radius
property IsRangeBand: Boolean read ... write ...; // Line/Area only; default False
end;XValues covers Scatter (4.19) as well, which reuses the same continuous-X point
shape as Line. EndValues covers Range (4.21) and Arrow (4.22) as well, which
reuse the same low/high pair shape as Dumbbell; a range-band series (4.17) also reads
it as its high bound. Sizes, when non-empty, must have the
same length as Values; it turns a Scatter series into a bubble series (4.19).
IsRangeBand, when True on a series of a Line/Area plot, marks it as an uncertainty
band: Values is the low bound and EndValues the high bound at each point, drawn as a
shaded region rather than a line (4.17). The defaults (nil/False) leave a plain
series untouched by either capability.
type
TAxisOptions = record
MinValue: Double; // NaN = automatic
MaxValue: Double; // NaN = automatic
Breaks: TArray<Double>; // empty = automatic (NiceBreaks)
BreakLabels: TArray<string>; // empty = automatic (BuildLabels)
UseThousandSeparator: Boolean; // default False
Decimals: Integer; // default AutomaticDecimals; see 4.27
CategoryLabelLayout: TCategoryLabelLayout; // default SingleRow; see 4.28
LabelSuffix: string; // e.g. '%' or ' years'
SuffixOnLastOnly: Boolean; // default True ("90 years" on the last break only)
Visible: Boolean; // default True (axis text)
Scale: TAxisScaleKind; // default Linear; see 4.15
LogBase: Double; // default 10; see 4.15
DateMode: TAxisDateMode; // default None; see 4.16
LocaleName: string; // default ''; '' = invariant; see 4.14
class function Default: TAxisOptions; static;
end;
TAxisScale = class
public
class function NiceBreaks(const MinValue, MaxValue: Double;
const TargetCount: Integer = 5): TArray<Double>; static;
class function FormatValue(const Value: Double;
const UseThousandSeparator: Boolean): string; overload; static;
class function FormatValue(const Value: Double; const UseThousandSeparator: Boolean;
const Decimals: Integer): string; overload; static;
class function FormatValue(const Value: Double; const UseThousandSeparator: Boolean;
const LocaleName: string): string; overload; static;
class function FormatValue(const Value: Double; const UseThousandSeparator: Boolean;
const LocaleName: string; const Decimals: Integer): string; overload; static;
class function FormatPercentage(const Proportion: Double;
const Options: TAxisOptions): string; static;
class function BuildLabels(const Breaks: TArray<Double>;
const Options: TAxisOptions): TArray<string>; static;
class function LogBreaks(const MinValue, MaxValue: Double;
const LogBase: Double = 10): TArray<Double>; static;
class function ResolveDateMode(const MinValue, MaxValue: Double;
const Mode: TAxisDateMode): TAxisDateMode; static;
class function DateBreaks(const MinValue, MaxValue: Double;
const Mode: TAxisDateMode): TArray<Double>; static;
class function FormatDateValue(const Value: Double; const Mode: TAxisDateMode;
const LocaleName: string = ''): string; static;
end;
TLinearMapper = record
DataMin: Double;
DataMax: Double;
PixelMin: Single;
PixelMax: Single;
Inverted: Boolean;
class function Create(const DataMin, DataMax: Double;
const PixelMin, PixelMax: Single;
const Inverted: Boolean): TLinearMapper; static;
function Map(const Value: Double): Single;
end;Chart4D.Axis.pas uses Chart4D.Types for TAxisScaleKind (4.15) and
TAxisDateMode (4.16); both are declared in Chart4D.Types.pas alongside the other
{$SCOPEDENUMS ON} enums, not in this unit. Setting both Scale = Logarithmic and
DateMode <> None on the same axis is not supported; behavior in that combination is
undefined, and callers must treat the two as mutually exclusive per axis.
NiceBreaks algorithm (nice numbers, inclusive-inside breaks):
- Guard: if
MinValue = MaxValue, treat range as[MinValue - 1, MaxValue + 1]. RawStep := (MaxValue - MinValue) / (TargetCount - 1).Magnitude := Power(10, Floor(Log10(RawStep)));Normalized := RawStep / Magnitude.NiceFactor:< 1.5 → 1,< 3 → 2,< 7 → 5, otherwise10.Step := NiceFactor * Magnitude.- Breaks run from
Ceil(MinValue / Step) * Stepup toFloor(MaxValue / Step) * Stepinclusive, stepping byStep(all breaks lie inside[MinValue, MaxValue]).
Examples (used in tests): NiceBreaks(0, 85) = [0, 20, 40, 60, 80];
NiceBreaks(1952, 2007) = [1960, 1970, 1980, 1990, 2000]; NiceBreaks(0, 1, 5) =
[0, 0.2, 0.4, 0.6, 0.8, 1.0].
FormatValue: invariant decimal separator .; thousand separator , (fixed English
convention, not locale dependent, so output is reproducible). Trailing decimal zeros
removed (5.0 → '5'). The overloads taking a Decimals count fix the number of decimals
instead (4.27).
BuildLabels: when Options.DateMode <> TAxisDateMode.None (4.16), every break is
formatted with FormatDateValue(Break, Mode, Options.LocaleName) instead of the rules
below, where Mode is the concrete date mode resolved once from the data range before
the breaks were placed (4.16), never a second resolution from the break span; and
LabelSuffix/UseThousandSeparator/Decimals are ignored. Otherwise: format every break with FormatValue
at Options.Decimals decimals (4.27), through Options.LocaleName when it is non-empty
and the invariant convention otherwise; when LabelSuffix <> '' append it to the last break only when
SuffixOnLastOnly, otherwise to every break. When BreakLabels is non-empty it wins
entirely, before either of the above.
FormatValue(Value, UseThousandSeparator, LocaleName): identical to the 2-argument
overload except it formats with TFormatSettings.Create(LocaleName) instead of
TFormatSettings.Invariant, and trims trailing decimal zeros using that locale's own
DecimalSeparator/ThousandSeparator rather than the fixed ./,. The 2-argument
overload's behavior, and its use as the default everywhere LocaleName is '', is
unchanged, so every existing caller and test keeps its exact invariant output; locale
formatting is strictly opt-in per axis (4.14).
TLinearMapper.Map: linear interpolation of Value from [DataMin, DataMax] to
[PixelMin, PixelMax]; when Inverted, to [PixelMax, PixelMin] (screen Y axis).
TLinearMapper itself is unchanged by the logarithmic axis (4.15): the renderer feeds it
LogN(LogBase, Value) instead of Value, and log-space bounds instead of data-space
bounds, when the axis is logarithmic; the mapper does not know it is operating in log
space.
type
TChartTextStyle = record
FontName: string;
Size: Single;
Bold: Boolean;
Color: TAlphaColor;
class function Create(const FontName: string; const Size: Single;
const Bold: Boolean; const Color: TAlphaColor): TChartTextStyle; static;
end;
IChartCanvas = interface
['{...new GUID...}']
procedure FillBackground(const Width, Height: Single; const Color: TAlphaColor);
procedure DrawLine(const X1, Y1, X2, Y2: Single; const Color: TAlphaColor;
const StrokeWidth: Single; const Dashed: Boolean);
procedure DrawPolyline(const Points: TArray<TPointF>; const Color: TAlphaColor;
const StrokeWidth: Single);
procedure FillPolygon(const Points: TArray<TPointF>; const Color: TAlphaColor);
procedure FillRect(const Bounds: TRectF; const Color: TAlphaColor);
procedure FillCircle(const CenterX, CenterY, Radius: Single; const Color: TAlphaColor);
procedure DrawText(const X, Y: Single; const Text: string;
const TextStyle: TChartTextStyle;
const AlignH: TTextAlignH; const AlignV: TTextAlignV);
function MeasureText(const Text: string;
const TextStyle: TChartTextStyle): TSizeF;
procedure DrawImage(const FilePath: string; const Bounds: TRectF);
end;DrawText anchors at (X, Y) according to the alignment (e.g. AlignH = Left,
AlignV = Top means (X, Y) is the top-left of the text box). DrawImage scales the
image uniformly (aspect fit) inside Bounds, right-aligned within it.
type
TChartPlot = class
public
constructor Create;
destructor Destroy; override;
function AddSeries(const Name: string): TChartSeries; overload;
function AddSeries(const Name: string; const Values: TArray<Double>): TChartSeries; overload;
function AddLineSeries(const Name: string;
const XValues, YValues: TArray<Double>): TChartSeries;
function AddRangeBandSeries(const Name: string;
const XValues, LowValues, HighValues: TArray<Double>): TChartSeries;
function AddDumbbellSeries(const StartValues, EndValues: TArray<Double>): TChartSeries;
function AddRangeSeries(const Name: string;
const LowValues, HighValues: TArray<Double>): TChartSeries;
function AddArrowSeries(const Name: string;
const StartValues, EndValues: TArray<Double>): TChartSeries;
procedure SetHistogramData(const Values: TArray<Double>; const BinWidth: Double);
procedure ClearSeries;
procedure AddTextAnnotation(const X, Y: Double; const Text: string;
const Color: TAlphaColor; const AlignH: TTextAlignH = TTextAlignH.Left);
procedure AddSegment(const X1, Y1, X2, Y2: Double);
procedure AddArrow(const X1, Y1, X2, Y2: Double);
procedure AddHorizontalLine(const Y: Double; const Color: TAlphaColor;
const Dashed: Boolean = False);
procedure AddVerticalLine(const X: Double; const Color: TAlphaColor;
const Dashed: Boolean = False);
procedure AddHorizontalRangeOverlay(const Y1, Y2: Double; const Color: TAlphaColor);
procedure AddVerticalRangeOverlay(const X1, X2: Double; const Color: TAlphaColor);
procedure ClearAnnotations;
function SeriesColor(const Index: Integer): TAlphaColor;
function CategoryColor(const Index: Integer): TAlphaColor;
property Kind: TChartKind ...;
property Title: string ...;
property Subtitle: string ...;
property Source: string ...; // footer text, e.g. 'Source: World Bank'
property LogoFilePath: string ...; // optional footer logo (PNG)
property Categories: TArray<string> ...;
property Series: TObjectList<TChartSeries> read ...; // owned
property Style: TChartStyle ...;
property XAxis: TAxisOptions ...;
property YAxis: TAxisOptions ...;
property Orientation: TChartOrientation ...; // default Vertical
property StackMode: TStackMode ...; // default Values
property LegendPosition: TLegendPosition ...; // default Top
property LegendReversed: Boolean ...;
property Annotations: TArray<TChartAnnotation> read ...;
property ValueLabels: TValueLabelMode ...; // default None; see 4.12
property SegmentLabels: TSegmentLabelMode ...; // default CategoryAndPercentage; see 4.29
property SegmentLabelDecimals: Integer ...; // default AutomaticDecimals; see 4.29
property SegmentLabelPosition: TSegmentLabelPosition ...; // default Inside; see 4.29
property HighlightedSeriesIndex: Integer ...; // default -1; see 4.13
property DonutCenterText: string ...; // default ''; Donut only, see 4.24
property OnChanged: TNotifyEvent ...;
end;Behavior:
SeriesColor(Index): whenHighlightedSeriesIndexis-1(default) or out of range, the series' ownColorwhen non-zero, otherwise palette colorIndex: the plot'sStyle.Palettewhen it is non-empty,DefaultPalettewhen it is empty, wrapping past its length (4.30). WhenHighlightedSeriesIndexis a valid series index,SeriesColor(HighlightedSeriesIndex)still resolves as above, andSeriesColorof every other index returnsChartLightGreyinstead; see 4.13.CategoryColor(Index): palette colorIndex, exactly as for a series without its own color (4.30); categories have no per-category color override. Used byPie/Donut(4.23, 4.24), which color by category rather than by series.AddDumbbellSeriessetsKind := DumbbellandOrientation := Horizontaland fillsValues/EndValuesof a single series.AddRangeSeriesandAddArrowSeriesdo the same forKind := Range(4.21) andKind := Arrow(4.22) respectively, and additionally take aName, since (unlikeDumbbell) a named single series is useful and does not conflict with anything existing.AddRangeBandSeriesappends (does not clear) a series withXValues,Values := LowValues,EndValues := HighValues, andIsRangeBand := True, exactly likeAddLineSeriesotherwise. It does not setKind; the band renders as a shaded region only when the plot'sKindisLineorArea(4.17), matching the existing convention thatAddLineSeriesdoes not setKindeither.AddHorizontalRangeOverlay/AddVerticalRangeOverlayappend aHorizontalRangeOverlay/VerticalRangeOverlayannotation usingY/Y2orX/X2respectively (4.18);Coloris drawn exactly as given, with no alpha adjustment, so a translucent wash requires the caller to pass aTAlphaColorwith a non-$FFalpha channel, the same as every otherColorparameter in this unit.SetHistogramDatasetsKind := Histogram, bins values (binkcovers[k*BinWidth, (k+1)*BinWidth),k = Floor(Value / BinWidth); empty bins between the first and last used bin get count 0), fillsCategorieswith the lower bin bounds formatted withTAxisScale.FormatValue, and one series with the counts.DotPlot(4.20) andPie/Donut(4.23, 4.24) need no newTChartPlotmethod: a dot plot isCategoriesplus one or more ordinaryAddSeriescalls, exactly likeBar/GroupedBar, distinguished only byKind; a pie/donut isCategoriesplus exactly oneAddSeriescall.- Every mutator fires
OnChanged(controls repaint on it). - Guard clauses raise
EChart4DExceptionon invalid input (e.g.BinWidth <= 0, empty histogram data, mismatching array lengths at render time).
type
TChartRenderer = class
public
class procedure Render(const Plot: TChartPlot; const Canvas: IChartCanvas;
const Width, Height: Single);
end;Layout (top to bottom; all sizes scaled by Style.ScaleFactor; outer margin 16 on all
sides):
- Background fill.
- Title (bold, left-aligned at outer margin), when non-empty. Wrapped onto as many lines
as needed when wider than the space between the outer margins:
WrapTextLinesmeasures each candidate line withIChartCanvas.MeasureTextand breaks on word boundaries, adding one word at a time until the next word would not fit, at which point that word starts a new line. A single word wider than the available width is not split and is left to overflow, since breaking a word gives an unreadable result and this is a caller-content problem not a layout defect. Each line advances by the same line height,IChartCanvas.MeasureText('Xg', TextStyle).Height, a fixed per-font-size value rather than each line's own measured height, so a wrapped block has even line spacing regardless of which lines happen to contain descenders (the same fixed-sample precedentChart4D.Tooltip.pasalready uses for its own multi-line text). This is a pure layout change: the plot area top is pushed down by the extra line height whenever a title or subtitle wraps, sincePerformLayoutalready threads the returned bottom of the title/ subtitle block into the content rect it hands to the plot area. - Subtitle with 9 px margin above and below, when non-empty, wrapped exactly like the title
and sharing the same
WrapTextLines/line-height rule, using its own font size. - Legend when
LegendPosition = Topand more than one named series: a horizontal left-aligned row of items (14x14 color swatch, 6 px gap, series name, 24 px between items), wrapping to extra rows when needed.LegendReversedreverses item order. Positions Right/Bottom/Left: vertical or bottom variants (simple implementations acceptable);Nonehides the legend. Legend items come fromBuildSeriesLegendItems, one item per named series; forPie/Donut,BuildCategoryLegendItemsbuilds one item per category (Categories[i]/CategoryColor(i)) instead, since those kinds always have exactly one series (4.23); the "more than one item" condition to show the legend still applies, there counting categories. - Plot area: from below the legend (12 px gap) down to the category/axis labels and
the footer. Left inset = widest value-axis label + 8 px. Right inset = half the
width of the last horizontal-axis label, so that label is never clipped at the
right edge. For
Pie/Donut(4.23, 4.24), which have no axes at all, both insets and the bottom axis label height are always 0:ComputeLeftInset,ComputeRightInset, andComputeBottomAxisLabelHeightreturn 0 whenKind in [Pie, Donut], before their normal axis-visibility checks. - Footer, only when
Source <> ''orLogoFilePath <> '': at the very bottom, height2.2 * CaptionFontSize, a full-width separator line ($FF222222, width 1) at its top, source text left (CaptionFontSize, MutedTextColor, vertically centered), logo right (aspect fit into the footer height).
Value axis: compute data min/max over all series appropriate for the kind. For Bar,
GroupedBar, StackedBar, Histogram and Area the range always includes 0
(TChartKindTraits.IncludesZeroBaseline, 4.1). Line,
Scatter, DotPlot, Range, Arrow and Dumbbell never force 0 into range
(4.19, 4.20, 4.21, 4.22); Range and Arrow compute their range the same
way as Dumbbell, as the min/max across both Values and EndValues of the
single series. Stacked uses per-category sums, with separate positive and negative sums
per category so a mixed-sign stack reserves room on both sides of the baseline;
StackMode.Proportions normalizes each
category to 1.0 with fixed breaks [0, 0.25, 0.5, 0.75, 1] labelled 0%..100% through
TAxisScale.FormatPercentage(Break, YAxis), so YAxis.Decimals controls their decimals
(4.27). Add 4%
headroom above the data maximum; for the kinds that mark a position rather than a length
from a zero baseline, Line, Dumbbell, Range, Arrow, Scatter and DotPlot
(TChartKindTraits.MarksPositionNotBaseline, 4.1), also
add 4% headroom below the data minimum whenever YAxis.MinValue is automatic, regardless
of sign. Bar, GroupedBar, StackedBar, Histogram
and Area keep their zero baseline and stay out of this rule. Manual
MinValue/MaxValue/Breaks in the axis options override all of this,
as does a logarithmic scale, which replaces headroom entirely (4.15). Gridlines are drawn
per break across the plot area on the value axis; the baseline at value 0 on top of them
(never drawn for a logarithmic axis, since 0 cannot be in its range; see 4.15). Pie/
Donut have no value axis and skip this whole computation, and skip DrawAxisLabels,
DrawGrid and DrawValueLabels (4.12) entirely: see 4.23. Category axis: a label is
centered under (or left of, when Orientation = Horizontal) its band, but is drawn only
when it fits beside its neighbours: TChartRenderJob.CategoryLabelPlacements walks
categories left to right (top to bottom when horizontal) and gives each label the first
axis row it fits on, where a label fits an empty row, or one whose last label is at least
the average of the two labels' measured extents away ((Index - LastInRow) * CategoryBand against IChartCanvas. MeasureText on the actual label text and the actual AxisTextStyle; width for a vertical
chart, height for a horizontal one, since a horizontal chart stacks its category labels one
per row down the left edge, where height, not width, is the scarce dimension). A label that
fits no row is dropped. The default axis has one row, which makes this exactly the
walk it has always been: index 0 is always kept, since nothing has been placed when it is
offered the only row, and every later label is kept only when it clears the last one kept.
XAxis.CategoryLabelLayout = Staggered gives the axis of a vertical chart a second row
instead of dropping a crowded label straight away (4.28). This
is deterministic (the same categories, band, rows and font always select the same
placements) and
applies to every kind that draws a discrete category axis, including Histogram; there is
no separate fixed-count thinning rule anywhere, since a measured fit is strictly more
accurate than a fixed count and a second thinning rule alongside it would just be
duplicate logic that could drift out of
sync. For Line, Area and Scatter the X axis uses NiceBreaks over the X range
(TChartKindTraits.UsesContinuousX, 4.1), or, when XAxis.DateMode <> None, a date-aware axis instead (4.16); a continuous X axis draws every break's label
unthinned, since NiceBreaks/DateBreaks already keep to roughly 5-8 breaks by
construction.
Orientation = Horizontal swaps the roles of the axes: categories run top to bottom,
values run left to right, gridlines become vertical.
Series drawing per kind (band = plot width / category count):
Line: one polyline per series,SeriesLineWidth.Area: polygon from the line down to the baseline, series color; polyline on top.Bar: one bar per category, width 0.7 * band, centered.GroupedBar: bars of the series side by side inside 0.7 * band (dodge).StackedBar: segments stacked in series order, positive segments accumulating away from the baseline on one side and negative segments on the other (diverging stacking, with separate positive and negative running totals per category);LegendReversedonly affects the legend, not stacking order.Histogram: likeBarbut bar width = band minus a 1 px gap on each side; its category labels are thinned by the same measured-fit rule as every other discrete category axis, described just above.Dumbbell: per category a connector line (ChartLightGrey, width 3) fromValues[i]toEndValues[i]with a circle (radius 5) at each end: start = ChartOrange, end = ChartBlue.Scatter: see 4.19. One filled circle per point, no connecting line; radius isScatterPointRadiuswhen the series'Sizesis empty, otherwise area-proportional betweenMinBubbleRadiusandMaxBubbleRadius(a bubble series).DotPlot: see 4.20. One filled circle per category per series, all series sharing the same category position (no dodge offset), radiusScatterPointRadius.Range: see 4.21. One filled rectangle per category fromValues[i]toEndValues[i], width0.7 * band, likeBar.Arrow: see 4.22. One line with an arrowhead per category fromValues[i]toEndValues[i], reusing the sharedTChartArrowPainterhelper.Pie,Donut: no category/value axis and no band; see 4.23 and 4.24.
Value labels (4.12) draw next, above the series but below annotations, when
ValueLabels <> None; not drawn for Pie/Donut, which label their segments
unconditionally instead (4.23). Annotations render last, above series and value labels,
mapped through the same mappers, except HorizontalRangeOverlay/VerticalRangeOverlay
(4.18), which render immediately after axis labels and before gridlines, so they sit
behind the grid and the series rather than on top of them; DrawAnnotation's dispatch
excludes these two kinds (already drawn) when it runs the remaining annotations.
HorizontalLine/VerticalLine/HorizontalRangeOverlay/VerticalRangeOverlay span the
full plot area.
The renderer must not mutate the plot. A plot with no series draws just the background
and produces an empty hit map: painting a control before its data arrives is a normal
state and never raises. Once a series is present, the renderer validates the input up
front and raises EChart4DException with a clear message for mismatched lengths
(categories vs values), a series without values, a NaN value, a reversed manual axis
range (MinValue above MaxValue), a LogBase not greater than 1, and a manual
MinValue/MaxValue that is not strictly positive on a logarithmic axis.
type
TGdiPlusChartCanvas = class(TInterfacedObject, IChartCanvas)
public
constructor Create(const Graphics: TGPGraphics);
// 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;
destructor Destroy; override;
procedure SaveToPng(const FilePath: string;
const Width: Integer = DefaultExportWidth;
const Height: Integer = DefaultExportHeight);
property Plot: TChartPlot read ...; // owned
published
// Every property below mirrors the one of the same name on Plot, so the Object
// Inspector reaches the settings that are a single value. Series, categories and
// annotations stay in code: they do not stream to a DFM.
property Kind: TChartKind default TChartKind.Line;
property Title: string;
property Subtitle: string;
property Source: string;
property Orientation: TChartOrientation default TChartOrientation.Vertical;
property StackMode: TStackMode default TStackMode.Values;
property LegendPosition: TLegendPosition default TLegendPosition.Top;
property LegendReversed: Boolean default False;
property ValueLabels: TValueLabelMode default TValueLabelMode.None;
property HighlightedSeriesIndex: Integer default NoHighlightedSeries;
property DonutCenterText: string;
property ShowTooltips: Boolean default True;
property Align;
property Anchors;
property Constraints;
property Enabled;
property Hint;
property ParentShowHint;
property PopupMenu;
property ShowHint;
property Visible;
property OnDataPointHover: TChartHoverEvent;
property OnAfterRender: TChartRenderEvent; // see 4.31
property OnClick;
property OnDblClick;
property OnMouseDown;
property OnMouseMove;
property OnMouseUp;
end;Implementation notes: SmoothingModeAntiAlias, TextRenderingHintAntiAliasGridFit;
fonts created with UnitPixel so style sizes are pixels. TAlphaColor maps 1:1 to the
GDI+ ARGB color value. SaveToPng renders into a TGPBitmap through
View.RenderSnapshot, so OnAfterRender fires for the export too, 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). 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 and OnAfterRender with the control as Sender. The protected
RenderChartToBackBuffer passes the call on to TChartPainter.RenderToBackBuffer.
type
TFmxChartCanvas = class(TInterfacedObject, IChartCanvas)
public
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;
public
constructor Create(AOwner: TComponent); override;
destructor Destroy; override;
procedure SaveToPng(const FilePath: string;
const Width: Integer = DefaultExportWidth;
const Height: Integer = DefaultExportHeight);
property Plot: TChartPlot read ...;
published
// The same mirrored plot settings as the VCL control (4.9), followed by the FMX
// control properties: Align, Anchors, ClipChildren, Cursor, Enabled, Height, HitTest,
// Margins, Opacity, Padding, Position, RotationAngle, RotationCenter, Scale, Size,
// Visible, Width, the mouse events, OnDataPointHover and OnAfterRender (4.31).
end;Text via TTextLayout (reliable measuring); SaveToPng via an offscreen
FMX.Graphics.TBitmap (BeginScene/EndScene, SaveToFile) through
View.RenderSnapshot, so OnAfterRender fires for the export too, raising the same
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 and OnAfterRender with the
control as Sender. The protected RenderChartToBackBuffer passes the call on to
TChartPainter.RenderToBackBuffer.
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:
type
TChartHitInfo = record
HasHit: Boolean;
SeriesIndex: Integer;
PointIndex: Integer;
SeriesName: string;
CategoryLabel: string; // category label, or formatted X value for Line/Area
Value: Double;
AnchorX: Single; // pixel anchor of the data point / bar
AnchorY: Single;
Color: TAlphaColor; // resolved series color
end;
TChartHitTarget = record
Bounds: TRectF; // hit rectangle (bars, stack segments)
Center: TPointF; // hit center (line points, dumbbell dots)
Radius: Single; // > 0: circular target, Bounds ignored
Info: TChartHitInfo;
end;In Chart4D.Renderer.pas (the map-less Render overload delegates to this one with a
discarded map, and this one takes the data points of the layout overload of 4.31):
class procedure Render(const Plot: TChartPlot; const Canvas: IChartCanvas;
const Width, Height: Single;
out HitMap: TArray<TChartHitTarget>); overload;The hit map contains one target per data point: circular targets (radius 12) for
Line/Area points and dumbbell dots, rectangular targets for bars, grouped bars,
stack segments and histogram bins. Info.CategoryLabel holds the category text (or
the formatted X value for Line/Area); Info.Color the resolved series color.
Chart4D.Tooltip.pas:
type
TChartTooltip = class
public
class function FindTarget(const HitMap: TArray<TChartHitTarget>;
const X, Y: Single; out Info: TChartHitInfo): Boolean; static;
class procedure Draw(const Canvas: IChartCanvas; const Style: TChartStyle;
const Info: TChartHitInfo;
const Width, Height: Single;
const LocaleName: string = '';
const Decimals: Integer = AutomaticDecimals;
const UseThousandSeparator: Boolean = False); static;
end;Draw's trailing LocaleName parameter defaults to '' (invariant). When non-empty it
is used to format Info.Value in
BuildLines via the locale TAxisScale.FormatValue overload (4.14) instead of the
invariant one; callers pass the hovered axis' LocaleName (typically Plot.YAxis .LocaleName). Decimals follows it and defaults to AutomaticDecimals; it fixes how
many decimals Info.Value is shown with (4.27). UseThousandSeparator follows that and
defaults to False; it groups that value's digits. The three are the value axis' own
number formatting, and a caller passes all three from that one axis, so a tooltip reads
the way the axis beside it does: TChartView.DrawOverlay passes Plot.YAxis.LocaleName,
Plot.YAxis.Decimals and Plot.YAxis.UseThousandSeparator. TChartHitTarget carries sector fields for Pie/Donut hit-testing, and
TChartTooltip's private TargetContainsPoint has a branch for them; both are
specified in 4.23, the section that introduces sectors, rather than here.
FindTarget returns the target whose center/rect is closest to (X, Y) among those
containing the point (circular: distance <= radius; rect: point inside). Draw
renders a highlight circle (radius 5, Info.Color) at the anchor and a tooltip box
per the section 3 style: text '<SeriesName>' on the first line (omitted when the
series has no name) and '<CategoryLabel>: <formatted Value>' on the second,
positioned 12 px above the anchor, clamped inside the chart bounds, background white,
border $FFCBCBCB. The white is the constant ChartLabelBackground, never the style's
LabelBackgroundColor: the tooltip floats over whatever ink is under the pointer, with no
single color behind it to contrast against, so it keeps an opaque box whatever the labels
do (4.29).
Both controls (Chart4D.VCL.pas, Chart4D.FMX.pas):
- Public property
ShowTooltips: Boolean(default True) and eventOnDataPointHover: TChartHoverEvent(procedure(Sender: TObject; const Info: TChartHitInfo) of object, declared inChart4D.Types.pas; fired when the hovered target changes, also withHasHit = Falsewhen the pointer leaves all targets). - The control stores the hit map produced during
Paintand hit-tests it on mouse move (no re-layout per move). When the hovered target changes it repaints; the paint pass draws the tooltip last viaTChartTooltip.Draw. - Mouse leave clears the hover state and repaints.
SaveToPngnever draws tooltips.- Both controls delegate all of this to a
TChartView(Chart4D.View.pas, 4.25) through their framework'sTChartPainter. The view holds aTChartHoverState(Chart4D.Hover.pas), which owns the hit map, the hovered point, and the rule for whether a pointer move changed it.MoveToandLeavereturn whether anything changed, which is the only moment the view firesOnDataPointHoverand 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, andInvalidateversusRepaintdiffer. ShowTooltipsreads and writesView.ShowTooltips; the control'sOnDataPointHoveris fired with the control, not the view, asSender.- The three core units are part of
Chart4D_R.dpk/.dproj.
In Chart4D.Types.pas:
{$SCOPEDENUMS ON}
type
TValueLabelMode = (None, All, FirstAndLast, Extremes);
{$SCOPEDENUMS OFF}TChartPlot carries property ValueLabels: TValueLabelMode (4.7),
default None. None draws no value labels, so the capability is
purely opt-in per plot.
Value labels are drawn from the same anchor points as the hit map (built during
DrawSeries), as a DrawValueLabels step that runs after DrawSeries and before
DrawAnnotations (4.8), skipped entirely when ValueLabels = None or Kind in [Pie, Donut] (which label their segments unconditionally instead, 4.23).
Grouping. A "group" is the unit FirstAndLast/Extremes select within: one group per
series for Line, Area, Scatter/bubble, GroupedBar, StackedBar, DotPlot; one
group (the single series) for Bar, Histogram; two groups, "Start" and "End", for
Dumbbell/Range (all start-of-connector points across categories, and all
end-of-connector points across categories); one group, "End", for Arrow (only the
arrowhead points are ever candidates, never the start points; see placement below). A
range-band series (4.17) is not a group at all and never receives value labels: a band
draws a shaded region, not marks, so it has no points to label. This
grouping exists only to decide which points are candidates; it does not read or change
TChartHitInfo.SeriesIndex.
Selection, within each group, ordered by the group's own point/category index:
All: every point in the group.FirstAndLast: the first and last point in the group.Extremes: the point with the minimumValueand the point with the maximumValuein the group; when several points tie for a minimum or maximum, the first one in index order is kept and the later ties are dropped, so the result is always exactly zero, one, or two points per group, deterministically.
Placement, at the reference scale (offsets scaled by ScaleFactor), text formatted as
TAxisScale.FormatValue(Value, YAxis.UseThousandSeparator, YAxis.Decimals) + YAxis.LabelSuffix (the suffix always appended, unlike axis break labels, since each value
label stands alone; YAxis.Decimals per 4.27):
Line,Area,Scatter/bubble,DotPlot: centered on the point, offset 8 px away from the plot in the fixed direction for the chart's orientation (up whenVertical, right whenHorizontal); the offset direction does not depend on the point's own value.Bar,GroupedBar,Histogram, and eachStackedBarsegment: centered on the value edge opposite the baseline (the bar/segment top whenVertical, its right edge whenHorizontal), offset 8 px further away from the baseline pixel (FValueMapper.Map(0)) along the value axis;StackedBaruses the segment's ownRawValue(matchingInfo.Valuefor that segment's hit target), not the cumulative total.Dumbbell,Range: one label per end, offset 8 px further away from the other end along the value axis (i.e. the "Start" label moves further from "End", and vice versa).Arrow: one label at the end (arrowhead) point only, offset 8 px further along the arrow's own direction, beyond the arrowhead.
Every label is drawn with the same background rectangle and 3 px padding as a
TextLabel annotation (4.1), in the style's LabelBackgroundColor (opaque white by
default, 4.29), text in AxisFontSize/TextColor. A fully transparent
LabelBackgroundColor draws no rectangle, but the rectangle still counts for the overlap
rule below.
Deterministic overlap avoidance. Candidates across every group are generated in a
single fixed order: outer loop over series index as stored in Plot.Series (or, for
Dumbbell/Range, "Start" group before "End" group), inner loop over category/point
index, exactly the order DrawSeries already iterates in. For each candidate in that
order: compute its background rectangle at the placement above; clamp it into the value
label bounds defined below; if the clamped rectangle intersects
(TRectF.IntersectsWith) the rectangle of any label already drawn earlier in this pass,
skip this candidate entirely (it is not drawn, and not shifted further); otherwise draw it
and add its clamped rectangle to the "already drawn" list for the rest of the pass. This is
a first-come-first-served rule over a fixed order, so it needs no randomization or
iteration and always yields the same result for the same input; it is also exactly the rule
Pie/Donut reuse for segment labels (4.23), over the segments largest first. It only considers other
value labels drawn in the same pass, not pre-existing manual annotations, which are drawn
afterward and are the caller's own responsibility to place.
Value label bounds. The margin-adjusted chart bounds used by
TChartLabelPlacer.ClampShift for
TextLabel annotations, narrowed to the plot area on each edge that carries
axis labels: the left edge when the left-edge axis is visible, the bottom edge when the
bottom-edge axis is visible (which edge that is follows Orientation, exactly as
ComputeLeftInset and ComputeBottomAxisLabelHeight already determine it). A value label
therefore never covers an axis break or category label; it is moved inside the plot area
instead. It is never dropped for overlapping an axis label, because an axis label is
secondary furniture and a value label is the subject of the chart. Pie and Donut have
no axis labels, so no narrowing applies to their segment labels and the plain
margin-adjusted bounds are used.
No dedicated type. TChartPlot carries property HighlightedSeriesIndex: Integer,
default -1 (declared in 4.7), and SeriesColor behaves as specified in 4.7: at -1
(default) or an out-of-range index, the plain palette resolution applies. Setting
HighlightedSeriesIndex fires OnChanged like every other mutator.
Because the legend (BuildSeriesLegendItems, 4.8) and every series-drawing routine
resolve their color through SeriesColor, highlighting needs no other renderer logic:
the muted ChartLightGrey and the still-resolved highlighted color both flow through
automatically, to the series' own ink and to its legend swatch alike.
Has no visible effect on Bar, Histogram, Dumbbell, Range, Arrow (single series
per plot, nothing to contrast against) or Pie/Donut (exactly one series, many
categories; highlighting one wedge is not part of this capability). Meaningful for Line,
Area, GroupedBar, StackedBar, Scatter/bubble, DotPlot.
No dedicated type. TAxisOptions carries LocaleName: string, default '' (declared
in 4.5);
TAxisScale has the FormatValue overload taking a LocaleName, and BuildLabels
dispatches to it automatically per axis (4.5); TChartTooltip.Draw takes a trailing
LocaleName parameter, default '' (4.11).
LocaleName is empty by default, so a chart keeps
the fixed English invariant formatting (. decimal, , thousands) until a caller opts
in; this is deliberate, so test output stays reproducible.
A caller who wants Dutch or German output sets, for example, Plot.YAxis.LocaleName := 'nl-NL', which affects axis break labels (BuildLabels) and, when the hosting control
threads it through to TChartTooltip.Draw, the tooltip's value text too. XAxis.LocaleName
and YAxis.LocaleName are independent; a chart may show an invariant value axis and a
localized date axis (4.16), or vice versa. LocaleName picks the separators, Decimals
(4.27) picks how many decimals follow them; the two are set and applied independently.
In Chart4D.Types.pas:
{$SCOPEDENUMS ON}
type
TAxisScaleKind = (Linear, Logarithmic);
{$SCOPEDENUMS OFF}TAxisOptions carries Scale: TAxisScaleKind (default Linear) and LogBase: Double
(default 10), both declared in 4.5. Linear is the plain axis described in 4.5 and 4.8;
a logarithmic scale is opt-in per axis.
Guard. Before computing the value axis, when YAxis.Scale = Logarithmic, every value
that would feed ComputeRawValueRange for the plot's Kind (4.8) must be strictly greater
than 0; the first value found that is not raises
EChart4DException.CreateFmt(SLogAxisRequiresPositiveValues, [Value]) (4.2). This mirrors
the existing guard-clause convention (e.g. BinWidth <= 0).
Range and breaks. For a logarithmic axis, ApplyHeadroom (4.8) is skipped entirely;
instead the data range [DataMin, DataMax] (after ComputeRawValueRange, before any
headroom) is snapped outward to [LogBase^MinExponent, LogBase^MaxExponent] where
MinExponent := Floor(LogN(LogBase, DataMin)) and MaxExponent := Ceil(LogN(LogBase, DataMax)). TAxisScale.LogBreaks(MinValue, MaxValue, LogBase) (4.5)
returns Power(LogBase, Exponent) for every integer Exponent from MinExponent to
MaxExponent inclusive; it raises the same SLogAxisRequiresPositiveValues guard when
MinValue <= 0. Manual YAxis.MinValue/MaxValue/Breaks still override all of this,
applied after the snap, exactly as manual overrides are applied in the linear case.
Mapping. TLinearMapper itself is unchanged (4.5). The render job builds
FValueMapper with DataMin/DataMax set to the log-space bounds
(LogN(LogBase, LogBase^MinExponent) = MinExponent, and likewise MaxExponent), and,
wherever it would otherwise call FValueMapper.Map(Value) for an actual data value, calls
FValueMapper.Map(LogN(LogBase, Value)) instead. This is the only change needed to every
series-drawing routine in 4.8: none of their pixel-geometry descriptions change, because
the transform happens once, at the point where a value enters the mapper.
Baseline and gridlines. Gridlines are still drawn once per break (now log-spaced,
visually the classic "1, 10, 100, 1000" ladder). The baseline at value 0 (DrawBaseline,
4.8) is never drawn for a logarithmic axis: DrawBaseline's existing IsZeroInRange
check (FValueDataMin <= 0) is already always false once the guard above has passed
(every value, and hence DataMin, is > 0), so no extra branch is needed there.
Scope. Meaningful for any kind with a value axis: Line, Bar, GroupedBar,
StackedBar, Histogram, Area, Dumbbell, Scatter/bubble, DotPlot, Range,
Arrow. Meaningless for Pie/Donut, which have no value axis at all (4.23); YAxis .Scale is ignored for those kinds. Not supported together with DateMode <> None on the
same axis (4.5).
In Chart4D.Types.pas:
{$SCOPEDENUMS ON}
type
TAxisDateMode = (None, Auto, Day, Month, Quarter, Year);
{$SCOPEDENUMS OFF}TAxisOptions carries DateMode: TAxisDateMode (default None), declared in 4.5.
None keeps the plain numeric X axis, so a date axis is opt-in per axis. X values
are TDateTime (= type Double) compatible, so no series-level support is needed: a
Line/Area/Scatter series' XValues can hold dates as they are; setting XAxis .DateMode only changes how the render job picks breaks and labels for them. Meaningful
only for a continuous X axis (Line, Area, Scatter, 4.19); has no effect on a
category X axis or on YAxis.
ResolveDateMode(MinValue, MaxValue, Mode). Returns Mode unchanged unless Mode = Auto, in which case it picks a concrete mode from the span in days,
SpanDays := MaxValue - MinValue, by fixed thresholds: SpanDays <= 60 → Day;
<= 730 (about 2 years) → Month; <= 2920 (about 8 years) → Quarter;
otherwise Year. Fixed thresholds, rather than a dynamic search for a break count, keep
the choice a pure function of the span that two implementations compute identically.
DateBreaks(MinValue, MaxValue, Mode), Mode already resolved (never Auto):
breaks are calendar-aligned, using System.DateUtils, and, like NiceBreaks (4.5), all
lie inside [MinValue, MaxValue].
Day: candidate steps[1, 2, 5, 7, 14, 30]days; pick the smallest candidate>= SpanDays / 5(target ~5 breaks). Breaks start atCeil(MinValue)(the first whole day>= MinValue) and step forward by the chosen number of days while<= MaxValue.Month: candidate steps[1, 2, 3, 6, 12]months; pick the smallest candidate>= MonthsBetween(MaxValue, MinValue) / 5. Breaks start at the first day of the first month whose start is>= MinValue, stepping forward withIncMonthby the chosen number of months while<= MaxValue.Quarter: candidate steps[1, 2, 4, 8]quarters (1 quarter = 3 months); pick the smallest candidate>= (MonthsBetween(MaxValue, MinValue) / 3) / 5. Breaks start at the first day of the first calendar quarter (Month in [1, 4, 7, 10]) whose start is>= MinValue, stepping by3 * chosen-quartersmonths while<= MaxValue.Year: step chosen the same wayNiceStep(4.5) picks a linear step, applied to the year count instead of a raw value (RawStep := YearsBetween(MaxValue, MinValue) / 4, rounded up to the nearest of1, 2, 5, 10, 20, 50, 100using the same< 1.5 → 1,< 3 → 2,< 7 → 5, else→ 10-times-magnitude table). Breaks start atEncodeDate(FirstYear, 1, 1)for the first year>= MinValue, stepping by the chosen number of years while<= MaxValue.
FormatDateValue(Value, Mode, LocaleName = ''), Mode already resolved: Day →
'd MMM'; Month → 'MMM yyyy'; Quarter → 'Q' + IntToStr(Quarter) + ' ' + IntToStr(Year) (no native FormatDateTime quarter token); Year → 'yyyy'; all via
FormatDateTime with TFormatSettings.Invariant when LocaleName = '', otherwise
TFormatSettings.Create(LocaleName) (4.14).
Integration. In ComputeContinuousXAxis (4.8), when XAxis.Breaks is empty and
XAxis.DateMode <> None: resolve the mode once, from the data range, with
ResolveDateMode, then use
DateBreaks/FormatDateValue in place of NiceBreaks/BuildLabels for FXBreaks/
FXBreakLabels. That one resolved mode drives both break placement and label
formatting (4.5); the mode is never re-resolved from the break span, so labels always
match the granularity the breaks were placed at.
UseThousandSeparator/LabelSuffix are ignored. Manual XAxis.Breaks/
BreakLabels still win entirely, exactly as they do for the numeric case.
No dedicated type. TChartSeries carries IsRangeBand: Boolean, default False (4.4);
TChartPlot has AddRangeBandSeries (4.7). False keeps the plain Line/Area
rendering, so a band is opt-in per series.
A band series is meaningful only when the plot's Kind is Line or Area; on any other
kind IsRangeBand has no rendering effect (its rendering path never reads it), the same
scoping convention EndValues already uses for Dumbbell.
Drawing. Within TLineSeriesRenderer/TAreaSeriesRenderer (4.8), series are partitioned and
drawn in two passes, in series-list order within each pass: first every series with
IsRangeBand = True, then every series with IsRangeBand = False.
This partition, not raw list order, guarantees a band always renders behind any ordinary
line/area series, regardless of the order the caller added them in. For a band series: no
polyline and no hit targets are added the way an ordinary series' points are; instead, a
single filled polygon is drawn from the "low" points (SeriesLinePoints using Values)
forward and the "high" points (SeriesLinePoints using EndValues) backward, closing the
loop, filled with SeriesColor(Index) exactly as resolved, with no forced alpha (a
translucent band requires the caller to give the series a Color with a non-$FF alpha
channel, e.g. $30107090, the same convention as 4.18's overlay). No polyline is drawn on
top; a caller wanting a visible central line adds an ordinary Line series over the band.
Hit targets. Two circular targets per point (radius 12, matching Line's own target
radius), at the low and high pixel positions, each reporting its own bound as Value,
and the same SeriesName and CategoryLabel (the formatted X value, or date label per
4.16) for both, so hovering the low edge of the band shows the low bound and hovering the
high edge shows the high bound.
Validation. When a band series' EndValues length does not match its Values
length, EChart4DException.CreateFmt(SBandValueCountMismatch, [Series.Name, Length (Values), Length(EndValues)]) (4.2), checked alongside the existing X/Y length guard for
Line/Area series (ValidateLineSeriesLength, 4.8).
No dedicated type. TAnnotationKind has HorizontalRangeOverlay/VerticalRangeOverlay
(4.1); TChartPlot has AddHorizontalRangeOverlay/AddVerticalRangeOverlay (4.7).
Geometry. HorizontalRangeOverlay uses Y (low bound) and Y2 (high bound), X/
X2 unused; it is a filled rectangle between FValueMapper.Map(Y) and
FValueMapper.Map(Y2), spanning FPlotBounds.Left to .Right when Orientation = Vertical, or .Top to .Bottom when Horizontal (the same axis-swap DrawHorizontal LineAnnotation already applies to a single value). VerticalRangeOverlay uses X/X2
(low/high bound on the category or continuous-X axis, via CategoryAxisPixel,
4.8) the same way DrawVerticalLineAnnotation maps a single X, spanning the full
perpendicular plot dimension. Both fill with Annotation.Color exactly as given (no
forced alpha; see 4.7's behavior note on these two methods).
Draw order. Both kinds are drawn as part of a DrawRangeOverlays step that runs
immediately after DrawAxisLabels and before DrawGrid (4.8), so the overlay sits behind
the gridlines, the baseline, and every series, matching the "zone marking behind the
series" intent of the kind. DrawAnnotation's kind dispatch (4.8) excludes
these two kinds when the main annotation pass runs later, since they are already drawn.
No hit map. Unlike every other drawing path in this document, a range overlay adds nothing to the hit map: it is background decoration, not a data point, so there is nothing meaningful to report on hover; a tooltip on a background wash would be surprising rather than useful. This is a deliberate, explicit exception to the general "every new drawing path needs hit-map targets" rule.
TChartKind includes Scatter (4.1). TChartSeries carries Sizes: TArray<Double>,
default empty (4.4). TChartStyle carries ScatterPointRadius (4), MinBubbleRadius (4),
and MaxBubbleRadius (24) (4.3). No dedicated TChartPlot method: a scatter series is an ordinary
AddLineSeries call (Plot.Kind := TChartKind.Scatter; set by the caller first, matching
the existing convention that AddLineSeries never sets Kind itself), with Sizes
optionally set afterward on the returned series to turn it into a bubble series.
A Scatter series reuses SeriesLinePoints/EffectiveXValues exactly as Line does
(4.8): TChartKindTraits.UsesContinuousX includes Scatter (4.1), and
ComputeRawValueRange gives it the
Line-like ComputeSimpleValueRange(False) branch (no forced 0),
since Scatter is not among the kinds that force 0. It also gets headroom below the
data minimum whenever YAxis.MinValue is automatic, exactly like Line (4.8), since a
scatter point marks a position rather than a length from a baseline the same way a line
point does. Drawing: for each point, one FillCircle at XYPoint(XValues[i], Values[i]);
no DrawPolyline.
Bubble radius. When a series' Sizes is empty, every point of that series draws at
the fixed ScatterPointRadius. When non-empty, radius is area-proportional (Sqrt
scaling, the perceptually correct convention for a size encoding), over a domain shared
across the whole plot for a fair comparison between series:
MinSize/MaxSize are the minimum/maximum of every value in every series' Sizes in the
plot (series without Sizes do not participate in this domain). For a point with size
S: Radius := MinBubbleRadius + (MaxBubbleRadius - MinBubbleRadius) * Sqrt((S - MinSize) / (MaxSize - MinSize)); when MaxSize = MinSize (degenerate, every sized point equal),
Radius := (MinBubbleRadius + MaxBubbleRadius) / 2 for all of them, avoiding division by
zero, the same guard style NiceBreaks already uses for a degenerate value range (4.5).
Hit targets. One circular target per point, matching Line's continuous-X
convention (CategoryLabel = formatted X value, or date label per 4.16) exactly,
except the radius is Max(DrawnRadius, 12 * ScaleFactor), so a small or plain scatter
point still has at least the usual hoverable radius, while a large bubble is hoverable
across its full visible extent.
Validation. When a series' Sizes is non-empty and its length does not match
Values, EChart4DException.CreateFmt(SSeriesSizeCountMismatch, [Series.Name, Length (Values), Length(Sizes)]) (4.2), checked alongside the existing X/Y length guard.
TChartKind includes DotPlot (4.1). No dedicated TChartSeries or TChartPlot member: a dot
plot is Categories plus one or more ordinary AddSeries/AddSeries(Name, Values)
calls, exactly like Bar/GroupedBar, distinguished only by Kind; validated the same
way (ValidateCategorySeries, the same bucket Bar/GroupedBar/StackedBar/Histogram
fall into, 4.8).
Honors whichever Orientation is set (default Vertical), unlike Dumbbell, which
forces Horizontal; a dot plot does not need a fixed orientation to be readable.
Like Scatter (4.19) and Dumbbell, DotPlot never forces 0 into the value range (not
listed among the kinds that do, 4.8), which is the classic advantage of a dot plot over a bar
chart is exactly that its axis need not start at 0. It also gets headroom below the data
minimum whenever YAxis.MinValue is automatic (4.8), so the lowest dot does not land on the
plot boundary and collide with its own category label.
Drawing. For each series j and category i, one FillCircle at
CategoryValuePoint(CategoryCenter(i), Values[j][i]), radius ScatterPointRadius (the
same style field as 4.19, reused rather than duplicated). Multiple series at the same
category draw at the exact same category-center pixel, with no per-series dodge offset
(unlike GroupedBar), so they land "on the same row"; they are
distinguished only by color, and may visually overlap when their values coincide.
Hit targets. One circular target per series/category, matching Bar's
TChartHitMap.BuildInfo fields (SeriesIndex, SeriesName, CategoryLabel, Value, Color)
exactly, radius Max(ScatterPointRadius, 12 * ScaleFactor) (the same minimum-hoverable-
size rule as 4.19).
Legend: BuildSeriesLegendItems (one item per named series) covers a
multi-series dot plot with no dot-plot-specific logic.
TChartKind includes Range (4.1). TChartPlot.AddRangeSeries(Name, LowValues, HighValues)
(4.7): replaces all series with one named series, Values := LowValues, EndValues := HighValues, sets Kind := Range and Orientation := Horizontal, exactly like
AddDumbbellSeries except it also takes a Name and sets a different Kind.
Value range and headroom: computed exactly like Dumbbell (min/max across Values and
EndValues, no forced 0, headroom below the minimum when YAxis.MinValue is automatic;
4.8). Validated exactly like Dumbbell
(Length(Values) = Length(EndValues), both matching Categories length), except the
count mismatch raises EChart4DException.CreateFmt(SPairedValueCountMismatch, ['Range', Length(Values), Length(EndValues)]) (4.2). Every kind that reads EndValues
shares that one message, with its own name as the first format argument, which matches
Dumbbell's wording exactly.
Drawing. One filled rectangle per category, from Values[i] to EndValues[i], width
0.7 * band (the same width factor as Bar), in SeriesColor(0), giving a solid "range
bar" rather than Dumbbell's dots-and-connector.
Hit targets. One rectangular target per category (like Bar's own target, not
Dumbbell's two circles), spanning the same rectangle that was filled; Info.Value := EndValues[i] (the high bound), the same convention StackedBar already uses of reporting
a segment's own raw value rather than a derived one. Info.SeriesName is the series'
Name (may be non-empty, unlike Dumbbell's hardcoded '').
TChartKind includes Arrow (4.1). TChartPlot.AddArrowSeries(Name, StartValues, EndValues) (4.7): replaces all series with one named series, Values := StartValues,
EndValues := EndValues, sets Kind := Arrow and Orientation := Horizontal, the same
shape as AddRangeSeries (4.21).
Value range, headroom, and validation: identical to Range (4.21), including reusing
SPairedValueCountMismatch with 'Arrow' as the first format argument instead of
'Range'.
Drawing. One line with an arrowhead per category, from
CategoryValuePoint(CategoryCenter(i), Values[i]) to
CategoryValuePoint(CategoryCenter(i), EndValues[i]), stroke width SeriesLineWidth,
color SeriesColor(0), arrowhead drawn with the shared TChartArrowPainter helper
(4.8, already used for Arrow-kind annotations) unchanged.
The shaft stops where the two wings meet the line, at ArrowHeadLength * Cos(25°) back
from the tip, and not at the tip itself: a stroke drawn all the way to the apex sticks out
past the point by half its width, so the arrow appears to overshoot its own value. The
same applies to Arrow annotations (4.1), which share the helper. When the whole arrow is
shorter than that distance only the head is drawn.
Hit targets. Two circular targets per category (radius 12, matching Dumbbell's
convention), at the start and end pixel positions, Info.Color := SeriesColor(0) for
both (unlike Dumbbell's two fixed distinct colors, since visually this is one directional
arrow, not two independently colored ends), Info.Value the respective bound,
Info.SeriesName the series' Name for both.
TChartKind includes Pie (4.1). No dedicated TChartSeries/TChartPlot method: a pie chart is
Categories plus exactly one AddSeries/AddSeries(Name, Values) call.
Validation. Plot.Series.Count <> 1 raises EChart4DException.CreateFmt (SPieRequiresSingleSeries, [Plot.Series.Count]) (4.2). Any value < 0 raises
EChart4DException.CreateFmt(SPieValuesMustBeNonNegative, [Value]). When every value is
0 (including an empty series), every wedge has a sweep angle of 0 and nothing is drawn (no
exception), the same degenerate-total handling SegmentValue applies to a
zero-total stacked category (4.8): a data edge case, not a programming error, is not an
exception.
Layout. Execute (4.8) branches on Kind in [Pie, Donut] before its usual sequence:
ValidateInput, then a pie-specific layout (title/subtitle/legend exactly as in
PerformLayout, 4.8, but with insets always 0, per 4.8), then wedges and
segment labels, then DrawAnnotations (still supported: a caller may still add e.g. a
TextLabel), then DrawFooter. ComputeValueAxis, ComputeCategoryAxis,
DrawAxisLabels, DrawGrid, and DrawValueLabels (4.12) are not called at all for these
two kinds: there is no value axis, no category axis, no gridlines, and no baseline to
compute or draw for a shape with no axes. With no axes to define a data space,
ComputeCircularAnnotationSpace gives annotations a coordinate space of their own (4.1):
X is the 0-based category index, exactly as on a category chart, and Y is a fraction of
the plot height, 0 at the bottom edge and 1 at the top, so (1, 0.5) on a three-category
pie is the center of the plot rectangle.
Geometry. Center is the center of the plot rectangle (the content rect after title/
subtitle/legend/footer layout, with 0 insets). Outer radius is
Min(PlotBounds.Width, PlotBounds.Height) / 2. Wedges are drawn in category order,
starting at 12 o'clock and proceeding clockwise (screen-angle convention, 0° at 3
o'clock, increasing clockwise, matching the existing ArcTan2-based convention already
used by TChartArrowPainter, 4.8): category i's sweep angle is 360 * Values[i] / Total
degrees; its start angle is -90 + 360 * (sum of Values[0..i-1]) / Total degrees, where
Total is the sum of every value.
Each wedge is drawn as a FillPolygon (IChartCanvas has no arc/sector primitive, and
none is added; a polygon approximation needs none, the same reasoning that already lets
TChartArrowPainter uses a plain triangle): the point list is [Center] + ArcPoints(OuterRadius, StartAngle, SweepAngle, SegmentCount), where ArcPoints samples the arc at `SegmentCount
- 1
evenly spaced angles fromStartAngletoStartAngle + SweepAngleinclusive, andSegmentCount := Max(2, Ceil(SweepAngle / 6))(one segment per 6 degrees of sweep, minimum 2, so even a very thin wedge is still a closed polygon).FillPolygoncloses the last point back to the first, back toCenter`.
Segment labels, drawn unconditionally (not gated by ValueLabels, 4.12, which has no
effect on Pie/Donut): for each wedge with a non-zero sweep angle, at the wedge's
mid-angle (StartAngle + SweepAngle / 2) and 0.65 * OuterRadius from center, text
Format('%s (%s)', [Categories[i], TAxisScale.FormatPercentage(Values[i] / Total, Options)]),
where Options is YAxis with Decimals replaced by SegmentLabelDecimals, so a whole
percent unless SegmentLabelDecimals asks for decimals (4.27, 4.29), or the share or
the category alone, or no label at all, as SegmentLabels selects (4.29), with the same
background box as a value label (4.12), in LabelBackgroundColor, and text in whichever of
TextColor and white reads better over that box on its own wedge (4.29), and the exact same deterministic
overlap-avoidance rule from 4.12 (skip a candidate whose clamped box intersects an
already-drawn one), with its fixed order being the segments by value, largest first, and
equal values in category order. The largest segments are the ones a reader looks for, so
when two labels collide it is the smaller segment's that goes; a tiny segment early in the
category list no longer pushes out the label of a much larger one after it. The tie-break
makes the order total, so the same data always keeps the same labels. The order decides
only which labels survive; every label still sits on its own wedge's mid-angle, and the
wedges, legend and hit targets stay in category order. Labels are drawn in a second pass, after
every wedge: a label drawn right after its own wedge would be painted over by the next
one, and would still hold its place against the labels after it, so a visible label could
be dropped for colliding with one nobody can see.
With SegmentLabelPosition = Outside (4.29) a label leaves its wedge, and the circle makes
room for it. The text stays exactly as above. What changes is where the label goes, how
labels that would overlap are handled, what joins a label to its wedge, its alignment and
its text color:
- Radius. Before the wedges are drawn, every label is measured.
OuterRadiusbecomesMin(PlotBounds.Width / 2 - RoomX, PlotBounds.Height / 2 - RoomY), whereRoomXisDistance + WidestText + 2 * PaddingandRoomYisDistance + TallestText / 2 + Padding, withDistance30 px andPaddingthe 3 px of the label box (4.12), both at the reference scale. It never drops below half ofMin(PlotBounds.Width, PlotBounds.Height) / 2; labels too wide for that are clamped into the chart like any other label. WithSegmentLabels = Nonethere is nothing to make room for, and the radius is the full one. - Sides. A label belongs to the right half when
Cos(MidAngle) >= 0(12 and 6 o'clock included) and to the left half otherwise. Each half is one column of labels. - Spreading. Each label wants to sit at its natural point,
Distancebeyond the outer edge along the wedge's mid-angle, on what is here called the label circle, of radiusOuterRadius + Distance. Where labels in a column would overlap, they are pushed apart instead of dropped, as ECharts'avoidLabelOverlapand Highcharts do: walking down fromPlotBounds.Top, every box that overlaps the one above moves down until it clears, then walking up fromPlotBounds.Bottom, every box that overlaps the one below moves up until it clears. Only when the boxes of a column together are taller thanPlotBoundsare labels left out, smallest segment first, equal values in category order, so the larger segments keep their labels as they do inside the circle (TOutsideLabelColumn). - Label. At the height the column gave it, on the label circle, or at the distance
of its natural point from the vertical center line when it was moved above or below
that circle. Where its box would touch the circle at that point, it moves further out
until the box is
Paddingclear of it. The label is anchoredPaddingbeyond the end of the connector, vertically centred on it, left-aligned on the right half and right-aligned on the left half, so the box starts where the connector ends. It is drawn through the label placer like every other label, and if the placer drops it, its connector is left out too. - Connector. A polyline, 1.5 px wide at the reference scale, built like Highcharts'
crookedLineconnector. It starts on the outer edge at the wedge's mid-angle and ends at the label. Where the wedge's radial line reaches the label's height beyond the outer edge and at least half a pixel short of the label, the connector bends there and runs level to the label; otherwise it is one straight line. A label at its natural point is therefore joined by one straight radial line, and a label moved too far for a bend by one straight slanted line. A wedge whose mid-angle is level (its sine below 0.001) never bends. The connector is inCategoryColor(i)unless that color'sContrastRatioagainstBackgroundColoris below 1.5, where a thin line all but disappears, as the default palette's light grey does on white; such a connector is drawn inTextColor. - Text color. The box sits on the chart background rather than on a wedge, so the
text is
ReadableTextColor(TextColor, Blend(LabelBackgroundColor, BackgroundColor), MinimumTextContrast).
The hit targets keep their anchor on the wedge, at the point the inside label would use, so the tooltip highlight stays on the segment.
Legend. One item per category (BuildCategoryLegendItems, 4.8):
Categories[i]/CategoryColor(i) (4.7), since wedge color comes from the category
index, not a series index (there is exactly one series).
Hit targets. Circular sectors do not fit the existing Bounds-rectangle or Center+
Radius-circle shapes, so TChartHitTarget (4.11) carries sector fields:
TChartHitTarget = record
Bounds: TRectF;
Center: TPointF;
Radius: Single;
Info: TChartHitInfo;
IsSector: Boolean; // when True, hit-testing uses the four fields below
InnerRadius: Single;
OuterRadius: Single;
StartAngle: Single; // degrees, same clockwise convention as above
SweepAngle: Single; // degrees
end;These fields default to False/0 via the Default(TChartHitTarget) pattern
used everywhere a target is built, so a plain Bounds/Center/Radius
target never carries stray sector data. One sector target per wedge: Center, InnerRadius := 0,
OuterRadius, StartAngle, SweepAngle as computed above; Info.SeriesName := '',
Info.CategoryLabel := Categories[i], Info.Value := Values[i], Info.Color := CategoryColor(i), and Info.AnchorX/AnchorY set to the same mid-angle,
(InnerRadius + OuterRadius) / 2-from-center point used for the segment label, so
TChartTooltip.Draw's existing highlight-circle code (4.11) needs no change.
TChartTooltip's private TargetContainsPoint (4.11) has a sector branch, checked
before the circular/rectangular ones: when Target.IsSector, compute Distance := Point.Distance(Target.Center); the point is inside when Distance is between
InnerRadius and OuterRadius inclusive, and the point's own angle from Center
(RadToDeg(ArcTan2(...)), normalized to [0, 360), same convention as above) falls
within [StartAngle, StartAngle + SweepAngle), also normalized to handle the 0/360
wraparound. Distance is still reported as Point.Distance(Target.Center), the same
convention the existing rectangular branch already uses (distance to the target's center,
not to its nearest edge), so FindTarget's closest-wins tie-break is unaffected.
TChartKind includes Donut (4.1). TChartStyle carries DonutInnerRadiusFactor: Single,
default 0.6 (4.3). TChartPlot carries DonutCenterText: string, default '' (4.7),
meaningful only when Kind = Donut.
Identical to Pie (4.23) in every respect (validation, layout branch, wedge angles,
legend, hit-testing) except:
InnerRadius := OuterRadius * DonutInnerRadiusFactor, both in the wedge'sFillPolygongeometry and in itsTChartHitTarget. The wedge polygon becomes an annular sector: the point list is the forward outer arc followed by the reverse inner arc (ArcPoints(OuterRadius, StartAngle, SweepAngle, SegmentCount) + ReverseArcPoints (InnerRadius, StartAngle, SweepAngle, SegmentCount)), with noCenterpoint, since the hole means the wedge never reaches the center.- Segment labels (4.23) sit at
(InnerRadius + OuterRadius) / 2from center instead of0.65 * OuterRadius, so they stay centered within the visible ring rather than falling inside the hole. - When
DonutCenterText <> '', it is drawn centered (horizontally and vertically) in the hole, inTitleFontSize/TitleColor, not bold (unlike the chartTitle, so it reads as a secondary annotation inside the ring rather than competing with the chart's own title), after the wedges and their labels and beforeDrawAnnotations; the caller is responsible for formatting it (e.g.'Total: 1,234'), the same plain-string conventionSource/Title/Subtitlealready use elsewhere in this library.
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:
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 RenderSnapshot(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 ...;
property OnAfterRender: TChartRenderEvent read ... write ...; // see 4.31
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,
Invalidateis called, orRenderis asked for a differentWidth/Heightthan the last render.NeedsRenderanswers exactly that, so a painter can skip preparing its surface.Renderdraws throughTChartRenderer.Render(4.8), stores the hit map in the view'sTChartHoverState(4.11) and firesOnAfterRender(4.31) only whenNeedsRenderis true, and draws nothing otherwise.RenderSnapshotdraws and firesOnAfterRenderwhatever the state of the last render, and leaves that render and the hit map alone, for an export at any size. DrawOverlaydrawsTChartTooltip.Draw(4.11) withPlot.Style, the hovered point andPlot.YAxis.LocaleNamewhenHasOverlayis true (ShowTooltipsand 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.MouseMoveandMouseLeavepass the pointer on to the hover state. When that reports a change, the view firesOnDataPointHover(with the view asSender) and thenOnRepaintRequest. A plot change marks the render stale and firesOnRepaintRequestonly.Invalidatefires nothing, for a caller that repaints anyway.- The view holds no pixels. The RTL has no bitmap class and
IChartCanvashas 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.
type
TChartPreview = class
public
class procedure FillFrom(const Settings, Preview: TChartPlot); static;
end;A chart control dropped on a form has no series yet, so it would paint nothing and leave
the developer looking at an empty rectangle. TChartPreview.FillFrom clears Preview,
copies every single-value setting from Settings (kind, the three texts, style, both axes,
orientation, stack mode, legend position and direction, value labels, segment labels,
donut centre text)
and fills it with sample data for the kind that was copied: an X value per Y value for
Scatter, both ends of every span for Dumbbell, Range and Arrow, and categories with
one series, or two for GroupedBar and StackedBar, for every other kind.
Both controls keep a second plot and painter for this, created on first use. Paint uses
them when csDesigning is in ComponentState and the developer's own plot has no series,
refilling the preview on every paint so it follows what the Object Inspector changes. The
developer's own plot is never touched.
VclCheck covers both halves of this on the real control: an empty chart renders
differently at design time than at run time, and every published property survives being
written to a stream and read back. That round trip is what catches a default clause that
does not match the value TChartPlot starts out with, which would silently drop the
property from the DFM.
Chart4D.VCL.Register.pas and Chart4D.FMX.Register.pas each hold one Register
procedure that calls RegisterComponents('Chart4D', [TChart4D]) and registers a selection
editor, TChart4DSelectionEditor, whose RequiresUnits adds Chart4D.Types and
Chart4D.Canvas.Interfaces to the uses clause of a form that holds a TChart4D. The form
designer adds only the units that declare the control and its ancestors, while
OnDataPointHover and OnAfterRender take TChartHitInfo, IChartCanvas and
TChartRenderLayout from those two units, so without the editor a handler created from
the Object Inspector would not compile. Each package declares its own editor: both design
packages load into the IDE together, and a unit can be contained in only one of them. They
are compiled into
the design-only packages Chart4D_VCL_D and Chart4D_FMX_D, which require designide
plus their runtime package and link packages\Chart4D.Icons.dcr for the palette bitmaps.
The IDE can run as a 64-bit process, and a 64-bit IDE loads Win64x design-time packages,
so under RAD Studio 13.0 every package targets Win64x next to its existing platforms.
The design packages write their Win64x BPL to $(BDSCOMMONDIR)\Bpl\$(Platform), because
the file name is the same on both. Under RAD Studio 12.0 they target Win32 only: that
release has no Win64x platform.
No dedicated type. Chart4D.Consts declares AutomaticDecimals = -1 (4.2) and
TAxisOptions carries Decimals: Integer, default AutomaticDecimals (4.5).
TAxisScale has a FormatValue overload taking a Decimals count after
UseThousandSeparator, and a second one taking it after LocaleName, so a caller can fix
the decimals with or without locale formatting (4.14). The two are independent per axis.
Decimals selects the FormatFloat pattern FormatValue builds, and nothing else about
it changes:
AutomaticDecimals(any negative count):0.##########or#,##0.##########, the historical pattern. Up to ten decimals, trailing zeros removed (5.0 → '5').0:0or#,##0. The value is rounded to a whole number and no decimal separator is drawn (1234.56 → '1235').n > 0:0.followed bynzeros. Exactlyndecimals, padded where the value has fewer (5.0at 2 →'5.00'), rounded where it has more. Counts above 15 are treated as 15, since aDoublecarries no more significant digits than that and the rest would be noise.
XAxis.Decimals and YAxis.Decimals reach every number the library formats from a value:
- Axis break labels, through
BuildLabels(4.5), per axis. - Value labels, through
YAxis.Decimals(4.12). - Tooltip values, through the
Decimalsparameter ofTChartTooltip.Draw, whichTChartView.DrawOverlayfills fromPlot.YAxis.Decimals(4.11), alongside that axis'LocaleNameandUseThousandSeparator, so the tooltip and the value axis format a value identically. - The continuous-X point label in a tooltip's category line, through
XAxis.Decimals.
Percentages are the one exception, since a percentage carries its own precision rather
than the value's. TAxisScale.FormatPercentage(Proportion, Options) multiplies
Proportion by 100, formats it with Options exactly as above except that
AutomaticDecimals means 0 decimals there, and appends '%'. It is what the
StackMode.Proportions value axis (4.8) and the Pie/Donut segment labels (4.23, 4.24)
use, so both show whole percents until asked for decimals: the axis through
YAxis.Decimals, the segment labels through TChartPlot.SegmentLabelDecimals (4.29),
since a pie has no value axis to set them on. A whole percent is rounded like any other
FormatFloat output, half away from zero, so a share of exactly 12.5% reads '13%'.
Manual BreakLabels still win over all of it, and a date axis ignores Decimals entirely
(4.16), exactly as it ignores UseThousandSeparator.
In Chart4D.Types.pas:
{$SCOPEDENUMS ON}
type
TCategoryLabelLayout = (SingleRow, Staggered);
{$SCOPEDENUMS OFF}TAxisOptions carries CategoryLabelLayout: TCategoryLabelLayout (default SingleRow),
declared in 4.5 and read from XAxis. It applies only to a discrete category axis along
the bottom of a Vertical chart. Everything else ignores it:
- A continuous or date X axis (4.16) spaces its breaks to fit by construction
(
NiceBreaks/DateBreaks). Pie/Donut(4.23) have no category axis at all.- A
Horizontalchart stacks its category labels one per bar down the left edge, where a label only collides with its neighbours once the bars are thinner than a line of text. A second row there would be a second column, costing the plot a whole label width for a zig-zag that reads worse than the single column it replaces; a chart with that many categories is better served by more height. So a horizontal chart keeps the single-row rule, and draws exactly whatSingleRowdraws.
SingleRow is the thinning rule of 4.8 unchanged: one row, and a label that does not fit
beside the last one kept is dropped. Staggered gives the axis
StaggeredCategoryLabelRowCount = 2 rows, and offers each label the rows in order, so a
label crowded out of the first row moves to the second instead of disappearing.
Worked example, the ten countries the demo catalogue charts
(Examples\Common\Chart4DDemo.Catalog.pas) on a 640 px axis. The band there is 55.7 px
while the narrowest pair of neighbours needs 59.4 px, so every adjacent pair collides and
SingleRow can only keep every other name, dropping Belgium, Germany, Poland,
Romania and Sweden. Two bands apart the widest pair needs 91.8 px against 111.4
available, so the ones it dropped all fit a second row and Staggered keeps all ten.
The left end of that axis, drawn to scale at one character per 11 px. SingleRow,
with nothing where the dropped names were:
Netherlands France Italy
and Staggered, which saves them on the row below, each still centred on its own band:
Netherlands France Italy
Belgium Germany
Belgium overlaps both of its neighbours on one row, which is why it is dropped there and
why it is the second row that has to take it.
A label that fits neither row is still dropped, so Staggered never draws two labels on
top of each other; it only raises how many fit. The walk is the one in 4.8, over rows
rather than over a single row, so with one row it selects exactly the indices it always
did and SingleRow output is untouched.
Rows are numbered from the plot area outward, stacking downward below the plot, and step
by TChartRenderJob.CategoryLabelRowPitch: a line of AxisTextStyle text plus
StaggeredCategoryLabelGapAtReferenceScale = 2 px at the reference scale.
The room for the extra row comes out of the plot area: ComputeBottomAxisLabelHeight adds
one CategoryLabelRowPitch. It is reserved whenever Staggered applies,
whether or not any label actually reaches the second row, so the plot area does not
grow and shrink by a line of text as the data or the window size changes; a caller who
wants the line back leaves the axis at SingleRow.
In Chart4D.Types.pas:
{$SCOPEDENUMS ON}
type
TSegmentLabelMode = (CategoryAndPercentage, Percentage, Category, None);
TSegmentLabelPosition = (Inside, Outside);
{$SCOPEDENUMS OFF}TChartPlot carries SegmentLabels: TSegmentLabelMode, default CategoryAndPercentage
(4.7), which picks what each Pie/Donut segment label (4.23) shows:
CategoryAndPercentage:'Fossil (70%)', the only form before this setting existed.Percentage:'70%', for a chart whose legend already names the categories, where the name in every label only repeats it.Category:'Fossil'.None: no segment labels at all. The wedges still get their hit targets, anchored where the label would have been, so hover and tooltips work as before.
The default is listed first so that a zeroed field already means it.
TChartPlot also carries SegmentLabelDecimals: Integer, default AutomaticDecimals, the
number of decimals of the percentage in every mode that shows one, with the meaning of 4.27
(AutomaticDecimals is a whole percent). The percentage takes YAxis.LocaleName and
YAxis.UseThousandSeparator, as the tooltip does, but never YAxis.Decimals: a pie has no
value axis on screen, so its decimals would be set somewhere the reader cannot see.
TChartPlot also carries SegmentLabelPosition: TSegmentLabelPosition, default Inside,
where the labels go: Inside on their own wedges, or
Outside, beside the circle, each joined to its wedge by a connector, with the circle
shrunk to make room (4.23). The default is listed first so that a zeroed field already
means it.
Whatever the mode, a label is placed and dropped by the rule of 4.23: after every wedge, largest segment first, skipping one that collides with a label already drawn, so a shorter label only lets more of them fit. Outside the circle colliding labels are spread apart first, and only a full column drops the smallest segments' labels (4.23). Other chart kinds ignore the setting.
Label background. TChartStyle carries LabelBackgroundColor: TAlphaColor, default
ChartLabelBackground (opaque white), the box behind a label. It is a style property of
its own rather than BackgroundColor, so repainting the chart background never makes
labels transparent by accident. TChartLabelPlacer fills each label box with it; when its
alpha is 0, such as TAlphaColors.Null, no box is drawn, but the box still counts for the
overlap rule, so labels go on avoiding each other with nothing drawn between them. An
alpha between 0 and 255 lets the ink behind show through.
Decision: value labels (4.12), TextLabel annotations (4.1) and segment labels all
follow LabelBackgroundColor; the tooltip does not. The first three are one convention,
with one implementation in TChartLabelPlacer, and giving one of them a background of its
own would split that rule in two. The tooltip is a different kind of thing: an overlay with
a border, drawn over whatever ink is under the pointer, so it has no single color behind it
to contrast with, and a transparent tooltip would be unreadable. It keeps
ChartLabelBackground (4.11).
Text color. A segment label sits on its own wedge, so what is behind its text is known:
TChartColors.Blend(LabelBackgroundColor, CategoryColor(i)). Its text color is
TChartColors.ReadableTextColor(TextColor, Behind, MinimumTextContrast): the style's
TextColor as long as its WCAG contrast ratio against Behind reaches
MinimumTextContrast, and below that whichever of TextColor and white reads better. With
the default opaque white box, Behind is white, so every label keeps TextColor exactly
as before; with no box, the text contrasts with the wedge itself. A label clamped partly off
its wedge is still judged against its own wedge. Value labels and annotations keep
TextColor whatever the background, since 4.12 already places value labels off the ink
and an annotation has its own color.
Minimum text contrast. TChartStyle carries MinimumTextContrast: Double, default
4.5, the WCAG AA minimum for normal text. It lets a chart keep one text color, or switch
only where that color would really be unreadable, rather than wherever white happens to
read better. On the palette with the default dark text (luminance about 0.016) and no box:
- 1 never switches, one text color throughout. A zeroed record's 0 does the same, so a
TChartStylebuilt withoutDefaultnever switches; that is acceptable, but it is why a style should start fromTChartStyle.DefaultorPlot.Style. - 3, the WCAG minimum for large text, keeps the dark text on blue and green (about 3.5 to 1 there, against about 4.5 for white) and turns only dark red and darker wedges white.
- 4.5, the default, turns blue, green and dark red white and keeps orange dark.
The default changes nothing for the default text color. Keeping TextColor at the minimum
can only differ from taking the more legible of the two where TextColor reaches the
minimum while white reads better still, and that needs one of two things. Either both
TextColor and white reach the minimum T on a background lighter than the text, which is
possible only when the text's luminance is below 1.05 / T² - 0.05; or TextColor reaches
T on a background darker than itself, where white always reads better, which is possible
only when its luminance is at least 0.05 * T - 0.05. At 4.5 that makes the default exactly
the "more legible" rule for any TextColor of luminance from about 0.0019 up to, but not
including, 0.175: the greys from #070707 to #747474, which includes the default
#222222. Outside that band the default can keep the text color on a narrow band of
backgrounds where white would have read slightly better: pure black text on greys around
#757575, where black and white both reach about 4.6 to 1, and a light text color such as
#767676 on black. MaximumContrastRatio restores the plain "more legible" rule for any
text color.
TChartStyle carries Palette: TArray<TAlphaColor> (4.3), default empty. Palette color
Index is Palette[Index mod Length(Palette)] when Palette is non-empty and
DefaultPalette[Index mod Length(DefaultPalette)] when it is empty, so a chart keeps the
six editorial colors until a caller supplies more. TChartPlot.SeriesColor uses it for a
series without its own Color, and TChartPlot.CategoryColor for every category (4.7);
HighlightedSeriesIndex still mutes every other series to ChartLightGrey (4.13).
DefaultPalette itself is unchanged: picking more or different colors is the caller's
choice, and the six stay the editorial default.
Everything that takes its color from those two functions follows the palette with them: bars, lines, points and wedges, the legend swatches, the color a hover target reports (4.11), and the wedge color a segment label's text contrast is judged against (4.29).
A caller sets it through the property copy, like every style field:
var Style := Plot.Style;
Style.Palette := [ChartBlue, ChartOrange, ChartDarkRed, ChartGreen, ...];
Plot.Style := Style;The plot keeps its own copy of the array: TChartPlot.Style stores a copy of Palette when
it is written and returns a copy when it is read. A dynamic array in a record is shared by
reference, so without those copies a write to an element of any copy of the style, or to
the array a caller passed in, would recolor the chart in place without OnChanged, and so
without the chart repainting. Assigning the style back is the only way to change the
colors.
Empty is also what a zeroed TChartStyle holds, so a style built without Default still
gets DefaultPalette.
An application can draw its own additions on a chart, in the chart's own pixels, without
changing Chart4D. Other chart libraries offer the same through a hook after drawing plus the
positions of what was drawn: TeeChart's OnAfterDraw with CalcXPos, Highcharts' render
event with its renderer, Chart.js' afterDraw plugin hook with chartArea and the element
positions.
In Chart4D.Types.pas:
type
IChartCoordinates = interface
function XToPixel(const X: Double): Single;
function YToPixel(const Y: Double): Single;
function PointAt(const X, Y: Double): TPointF;
end;
TChartRenderLayout = record
Width: Single; // the size the chart was rendered at
Height: Single;
PlotBounds: TRectF; // where the series are drawn, inside the axes
DataPoints: TArray<TChartHitTarget>; // one per data point, the same as the hit map
Coordinates: IChartCoordinates; // any data coordinate to a pixel
end;In Chart4D.Renderer.pas, a third Render overload returns it; the hit-map overload
delegates to it and returns Layout.DataPoints:
class procedure Render(const Plot: TChartPlot; const Canvas: IChartCanvas;
const Width, Height: Single;
out Layout: TChartRenderLayout); overload;In Chart4D.View.pas:
type
TChartRenderEvent = procedure(Sender: TObject; const Canvas: IChartCanvas;
const Layout: TChartRenderLayout) of object;TChartView.OnAfterRenderfires after every render that draws: fromRenderonce the hit map is stored and the render marked valid, and fromRenderSnapshot. It gets the canvas the chart was drawn on, so what the handler draws lands on top of the chart in the same image. On screen that image is the painter's back buffer, so the additions are drawn once per render, not on every repaint, and the hover tooltip still goes over them.TChart4D(VCL and FMX) publishesOnAfterRenderand fires it with the control asSender.SaveToPngrenders throughView.RenderSnapshot, so an export holds the additions too.- A plot with no series fires the event as well, with an empty
PlotBoundsand no data points, so a handler can draw a message of its own on the empty chart. - The handler must not change the plot. A change marks the render stale and requests a repaint, which renders again and fires the handler again, without end. TeeChart and Highcharts warn about the same loop.
Coordinates. Coordinates maps any data coordinate to a pixel exactly as the render
did, the way TeeChart's CalcXPosValue/CalcYPosValue, Chart.js' getPixelForValue and
Highcharts' toPixels do. Its X and Y are those of annotations (4.1): X is the 0-based
category index on a category axis or the X value on a continuous one, Y the value on the
value axis, through the logarithm on a logarithmic axis; on a Pie or Donut, X is the
category index and Y a fraction of the plot height (4.23). XToPixel returns the pixel
along the X axis, which runs down the screen on a horizontal chart, YToPixel the pixel
along the value axis, and PointAt the screen point. A plot with no series lays nothing
out; its coordinates are not nil, but every call raises EChart4DException
(SCoordinatesNotLaidOut).
There is one rule for both: the renderer's TChartGeometry keeps the orientation, the
value-axis mapping with its logarithm, and the continuous X mapping or category bands in a
TChartCoordinateMapping record and draws through it, and Coordinates is a copy of that
record behind the interface, so a coordinate an application maps lands on the pixel the
chart drew it at.
Console project Chart4D.Tests.dpr + build.bat (dcc32; the RAD Studio location is
read from the BDS environment variable, defaulting to
c:\program files (x86)\embarcadero\studio\37.0 when unset). Fixtures:
-
Chart4D.Axis.Tests.pas: the three NiceBreaks examples from 4.5, plus degenerate range and negative ranges; FormatValue (thousand separators, decimals,5.0 → '5'); BuildLabels (suffix on last only, suffix on all, manual BreakLabels win). -
Chart4D.Mapper.Tests.pas: Map endpoints, midpoint, inverted mapping. -
Chart4D.Plot.Tests.pas: auto palette via SeriesColor (wraps after 6), owned series lifecycle, OnChanged fired, guard exceptions (BinWidth <= 0). -
Chart4D.Histogram.Tests.pas: binning per 4.7 including empty middle bins and category labels. -
Chart4D.Renderer.Tests.pas: aTRecordingCanvas(records every call) verifying: a line chart renders without exception and produces background + title text + one polyline per series + gridlines; a bar chart produces one FillRect per category; a stacked proportions chart maps the top of each stack to the same pixel; legend drawn only when more than one named series; footer separator/source drawn only when Source is set; mismatched category/value lengths raiseEChart4DException; a value axis at a fixed decimal count labels every break with it, andYAxis.Decimalsreaches the proportions axis and the pie segment percentages while the default leaves both at whole percents (4.27); aTextLabelannotation's box takes the style'sLabelBackgroundColor(4.29); a customPalettecolors a pie's wedges, their legend swatches and the colors their hover targets report, and every bar of each series of a grouped bar, with noDefaultPalettecolor left (4.30). -
Chart4D.Style.Tests.pas:TChartStyle.Defaultvalues per section 3,LabelBackgroundColorand an emptyPaletteamong them; andTChartColors(4.3): an opaque color blends to itself and a transparent one to what is under it, half-transparent white over black is mid grey, black against white contrasts 21 to 1 either way round, andReadableTextColorgives white on dark red and keeps the dark text on orange; with a minimum text contrast, 1 never switches on any palette color or black, 3 keeps the dark text on blue and green but not on dark red, and 4.5 turns blue and green white; the default minimumMinimumTextContrastis 4.5, and for the default dark text it matches the plain "more legible" rule on every grey and on an RGB grid in steps of 15; and the two limits of that band are pinned, black text keeping black on#757575and#767676text keeping itself on black, where white reads better. -
Chart4D.Invariants.Tests.pas: boundary and ink invariants forTChartRenderer, reasoning geometrically over aTRecordingCanvasfor every chart kind: no recorded call may draw outside the bitmap, none may land inside the outer margin (other than the footer separator, which spans it by design), and every resolved series color must actually appear in the output. -
Chart4D.Tooltip.Tests.pas: the hit map contains one target per data point for a line chart and one per bar for a bar chart;FindTargetpicks the nearest circular target within its radius and returns False outside every target;Drawproduces a FillRect (box), a FillCircle (highlight) and text calls on a recording canvas; the tooltip box stays inside the chart bounds for anchors near every edge; theLocaleNameoverload ofDrawformatsInfo.Valuewith that locale (4.14), itsDecimalsparameter fixes that value's decimals and itsUseThousandSeparatorparameter groups that value's digits while the default leaves it ungrouped (4.27); the tooltip box staysChartLabelBackgroundeven under a style whoseLabelBackgroundColoris transparent (4.29); a sector target (4.23) is found byFindTargetwhen 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.Preview.Tests.pas:TChartPreview.FillFrom(4.26) carries the text and layout settings over,SegmentLabels,SegmentLabelDecimalsandSegmentLabelPositionamong them (4.29), gives a scatter series its X values and a range series its end values, adds a second series forGroupedBar, and refilling replaces the sample instead of adding to it. -
Chart4D.View.Tests.pas: against aTRecordingCanvas,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 orInvalidate; a plot change requests a repaint without a hover event, andInvalidaterequests nothing;MouseMoveonto a data point firesOnDataPointHover(with the view asSender) and one repaint request, and fires nothing when moving within that point, before the first render, or withShowTooltipsoff;MouseLeavefires a miss and a repaint request only when something was hovered;DrawOverlaydraws nothing when nothing is hovered orShowTooltipsis off, and the highlight, box and series name when a point is hovered, and formats that value with the value axis'DecimalsandUseThousandSeparatorrather than its own defaults (4.27); destroying the view clearsPlot.OnChanged, but leaves a handler assigned after the view alone.OnAfterRender(4.31) fires once per render that draws, with the view asSender, and what its handler draws is the last call on the canvas; a render that draws nothing does not fire it; its layout reports the render's size, one data point per bar, and the first bar where an independent render puts it.RenderSnapshotdraws and fires while the last render is still valid, leaves that render valid, and a snapshot at half the size leaves hover tracking on the bars at their size on screen. -
Chart4D.Axis.Tests.pasalso covers: theLocaleNameoverload ofFormatValueagainst at least one non-invariant locale, and that the 2-argument overload's output is unchanged (4.14);LogBreaksagainst a known span (e.g.LogBreaks(5, 3000, 10) = [1, 10, 100, 1000, 10000]snapped to bounding powers) and its guard exception forMinValue <= 0(4.15);ResolveDateModeat each threshold boundary andDateBreaks/FormatDateValuefor one fixture span per mode (Day,Month,Quarter,Year) (4.16); theDecimalsoverloads ofFormatValue(padding, zero decimals, thousand separator, the 15-decimal cap, the locale overload) and thatAutomaticDecimalsmatches the 2-argument overload,BuildLabelsat a fixed decimal count, andFormatPercentageat its automatic whole percent, at one decimal and through a locale (4.27). -
Chart4D.Plot.Tests.pasalso covers:SeriesColorreturnsChartLightGreyfor every index exceptHighlightedSeriesIndex, and is unchanged at-1(4.13);AddRangeSeries/AddArrowSeriessetKind/Orientation/Values/EndValueslikeAddDumbbellSeries;AddRangeBandSeriesappends (does not clear) and setsIsRangeBand;CategoryColorcycles the palette by category index (4.7);SegmentLabelsdefaults toCategoryAndPercentage,SegmentLabelDecimalstoAutomaticDecimals,SegmentLabelPositiontoInside, and setting any of them firesOnChanged(4.29); and forPalette(4.30), an empty one givesDefaultPaletteover two full cycles, a custom one colors categories in its own order and wraps by its own length, colors a series without its own color while a series' own color still wins and the highlight still mutes the others, and neither a write to an element of a style copy nor to the array the plot was given changes the plot's colors. -
Chart4D.Renderer.Tests.pasalso covers the layout overload ofRender(4.31): a bar chart with a title reports its size, a plot area inside the canvas and below the title, and one data point per bar anchored inside that area; a donut reports the same data points as the hit-map overload; a plot with no series reports its size, an empty plot area and no data points. ItsCoordinatesput every point of a line chart, also on a logarithmic value axis, where the line drew it; map a category index to the middle of its bar and a value to the top of the bar on a bar chart; on a horizontal bar chart map the category index to a vertical pixel whilePointAtreturns the end of the bar; on a pie map(1, 0.5)of three categories to the centre of the plot area; and on a plot with no series are not nil but raise when used. -
Chart4D.Renderer.Tests.pasalso covers, each against aTRecordingCanvas: a logarithmic axis renders breaks at powers of the base and raisesEChart4DExceptionfor a non-positive value (4.15); a date axis withDateMode = Autopicks the expected granularity for a known span (4.16); a range-band series draws its shaded polygon before an ordinary series added earlier, and produces two circular hit targets per point (4.17); aHorizontalRangeOverlay/VerticalRangeOverlayannotation draws before the first gridline and adds nothing to the hit map (4.18); aScatterseries withSizesproduces circles whose radii follow theSqrt-scaling formula betweenMinBubbleRadiusandMaxBubbleRadius, and raisesEChart4DExceptionon aSizes/Valueslength mismatch (4.19); aDotPlotwith two series draws both series' dots at the same category-center pixel (4.20); aRangechart produces one rectangular hit target per category reporting the high bound (4.21); anArrowchart draws a line and reusesTChartArrowPainter's triangle shape, with two circular hit targets per category (4.22); aPiechart's wedge sweep angles sum to 360 degrees and each wedge's polygon points lie within[0, OuterRadius]of the computed center, a wedge is findable byTChartTooltip.FindTargetvia its sector target, and a negative value or a second series raisesEChart4DException(4.23); aDonutchart's wedge points all lie within[InnerRadius, OuterRadius]of the center andDonutCenterTextproduces a text call at the center (4.24); this fixture follows the same "reason geometrically over recorded calls" style asChart4D.Invariants.Tests.pas, needing no rasterisation or baseline image. -
Chart4D.ValueLabels.Tests.pas: for each ofAll/FirstAndLast/Extremes, the expected candidate set per chart kind and group (4.12), including the tie-break rule forExtremes; two labels whose clamped boxes would intersect are drawn in the fixed series-then-index order, with only the first one actually drawn; a label placement per kind (Line,Bar,Dumbbell,Arrow) matches the offsets specified in 4.12; andYAxis.Decimalsreaches the label text (4.27); and the style'sLabelBackgroundColorfills every value label box in place of the white one (4.29). -
Chart4D.CategoryLabels.Tests.pas: the category axis label layout of 4.28, against aTRecordingCanvaswith labels of a known measured width so the expected row of each one follows from the band arithmetic.SingleRowdrops the labels that do not fit and keeps the rest on one row;Staggeredon the same data draws all of them across exactly two rows, one text line apart, and leaves labels that all fit on the first row alone; a band too narrow for either row drops labels again, but fewer thanSingleRowdoes; on the worked ten-country example of 4.28,SingleRowkeeps exactly the five names that section names and drops the other five, whileStaggeredlabels all ten and alternates their rows, each second-row label still centred on its own band, exactly midway between the first-row labels either side of it; the second row stays inside the chart and is paid for by a shorter plot area; a horizontal chart crowded enough to drop labels draws exactly the same calls underStaggeredas underSingleRow; and a continuous X axis ignores the setting. -
Chart4D.SegmentLabels.Tests.pas: thePie/Donutsegment labels of 4.23, against aTRecordingCanvas. In the recorded call order every segment label of a pie and of a donut comes after the last wedge, so no wedge can be drawn over a label. On a pie whose 1% and 8% segments sit side by side ahead of a 91% one, the 8% label is kept and the colliding 1% label is the one dropped; of two equal, colliding segments the earlier category keeps its label. On a pie of three equal segments with no legend, eachSegmentLabelsmode of 4.29 draws exactly its own text:'A (33%)','33%'alone or'A'alone, andNonedraws no text and no label box while the hit map still holds all three segments. On the same pie aSegmentLabelDecimalsof 1 draws'33.3%', and'33,3%'under a DutchYAxis.LocaleName, while aYAxis.Decimalsof 2 leaves the labels at'33%'. By default every label sits on a white box in the dark text color; a custom opaqueLabelBackgroundColorreplaces the white box; a transparent one draws the text with no box at all, turns it white on the blue and dark red wedges and keeps it dark on the orange one, and still drops the colliding 1% label. With no box, aMinimumTextContrastof 3 keeps the dark text on the blue wedge and turns only the dark red one white, and 1 keeps the dark text on every wedge. With a customPaletteof pale yellow, navy and pale cyan the contrast is judged against those wedges, dark, white and dark, the other way round from the default palette (4.30). On a pie of four equal segments withSegmentLabelPosition = Outside, every label is anchored beyond the outer radius, the radius is smaller than withInside, the labels of the two right-hand quarters are left-aligned and those of the two left-hand quarters right-aligned, and each label gets one connector in its segment's color that starts on the edge of the circle. On a pie of 1%, 2% and 97% both small labels are kept, their boxes do not overlap, and every connector ends at the height its label was moved to and bends at most once, on its wedge's radial line, to run level to the label. On the four equal segments, where no label moves, every connector is one straight line along its wedge's mid-angle. On a pie of 18, 27, 0.5, 1, 9, 43.5 and 1 the 0.5% label is moved up beside the circle and every box stays off the circle. On a pie of eighty segments with values 1 to 80, not every label fits, every drawn label has exactly one connector, the largest segment keeps its label and the smallest loses it. On a pie of six equal segments the light grey sixth gets a connector in the dark text color, the other five in their own. WithSegmentLabels = Nonethe radius stays the full one and no connector is drawn. A donut's labels sit outside the ring and its hole shrinks with it, and with no label box the text keeps the dark style color, since it sits on the white chart background rather than on the blue wedge. -
Chart4D.Catalog.Tests.pas: for the shared demo catalogue (Examples\Common\Chart4DDemo.Catalog.pas, 7), the numbers and names parsed back out of an example's displayedCodefragment match the data itsBuildprocedure hands to the plot (Line,GroupedBar,RangePlot,Pie), and every example's heading matches its build data. The code shown beside a chart in the demos therefore cannot drift from the chart it claims to produce.
Tests use no initialization sections: fixtures carry the [TestFixture] attribute and
are discovered through RTTI. Test names are underscore-separated and read as the subject
under test, the condition it is put under, and the expected outcome
(NiceBreaks_DegenerateRange_TreatsRangeAsPlusMinusOne), with the middle part left out
where there is no distinct condition (SetHistogramData_SetsChartKindToHistogram).
Build.bat at the repo root: call rsvars.bat from the RAD Studio installation named
by the BDS environment variable (defaulting to
c:\program files (x86)\embarcadero\studio\37.0 when unset, so another installation is
a matter of setting BDS before the call), then msbuild each
package dproj (Release/Win32), first the three runtime packages and then the two
design-time ones, then all five again for Win64x when the Delphi in use has that platform,
then build and run the tests via Tests\build.bat for Win32 and Win64, then build both
demos with dcc32. Everything must compile with zero hints and warnings.
The palette bitmaps are generated, not hand-drawn: packages\icons\make-icons.ps1 scales
assets\chart4d-mark-256.png down to 24 and 32 pixels on the magenta key colour, and
brcc32 -foChart4D.Icons.dcr Chart4D.Icons.rc in packages\ compiles them into the .dcr
the design packages link. Both outputs are committed, so a normal build needs neither step.
Each demo's main form lives in its form resource, the VCL one in a DFM and the FMX one in
an FMX file, with the TChart4D on it as a design-time component. Each demo shows a sample
switcher (a combo box over the shared catalogue in
Examples\Common\Chart4DDemo.Catalog.pas) that rebuilds the plot for the selected
sample, displays the explanation and the code fragment that produces the chart beside
it, and has an
"Export PNG" button calling SaveToPng with Source set to
demonstrate the publication footer. A sample can also carry a Decorate procedure, which
the form calls from the control's OnAfterRender (4.31), and the code of that handler
after its build code; "Donut, one segment emphasised" uses it to draw a band beside the
largest segment from the layout's sector data. Demo data: published real-world figures, World Bank
World Development Indicators for most samples, the electricity mix from Ember via Our
World in Data, and the temperature series from Met Office Hadley Centre HadCRUT5, each
credited in its chart's Source footer.
Dependencies. Chart4D is zero-dependency: the RTL, plus VCL or FMX for the adapter
units, and nothing else. System.Generics.Collections (TObjectList<T>, TList<T>) and
dynamic arrays (TArray<T>) are the collection types of this project. The framework
layering rules are in section 2.
File header. Every source file starts with the Embarcadero-style header:
{*******************************************************}
{ }
{ Chart4D Library - Editorial data charts }
{ }
{ Copyright(c) 2026 GDK Software }
{ All rights reserved }
{ }
{ Licensed under MIT License }
{ }
{*******************************************************}Documentation comments. Every public member carries an XML documentation comment
(/// <summary>), and every unit carries a unit-level /// <summary> describing what it
is for. Test methods are not public API and are exempt; a test unit still carries the file
header and a unit-level summary describing what it tests.
Comments in implementation code. These should be rare: code is expected to explain itself through naming and structure. What a comment is for is recording a reason the code cannot show on its own, for example why a constant has this value, why steps run in this order, or why a test fixture deliberately avoids a particular value. A comment that restates what the next line does, or that narrates what changed or used to be there, has no place; if you can delete a comment and lose no reason, delete it.
Language. All public API, identifiers, error messages, and documentation are in English.
Enumerations. {$SCOPEDENUMS ON} in every unit that declares an enum; enum values
carry no prefix.
Compiler output. The library, the tests, the demos and the tools all compile with zero
hints and zero warnings, and the test suite stays green. Build.bat builds and runs all of
it in one go.