Диаграмма, скопированная из экранного элемента как битмап 96 DPI, выглядит хорошо при 100% зуме в просмотрщике PDF. Поднимите зум до 400% или отправьте страницу на лазерный принтер 600 DPI — и края полос станут мягкими, подписи осей размоются в серые кляксы, а файл потащит несколько сотен килобайт пикселей ради того, что в действительности является дюжиной прямоугольников. Нарисованная же векторными путями, та же диаграмма остаётся резкой на любом уровне зума и разрешении печати, потому что полосы, оси и подписи — это операторы пути и текста PDF, которые просмотрщик перерендеривает в любом нужном масштабе
Векторный вывод переносит работу по макету в ваш код. Кто-то должен вычислить, куда ложится каждая полоса, метка и подпись, и для столбчатых, линейных и круговых диаграмм это вычисление — одно сопоставление координат плюс несколько циклов по прямоугольникам, отрезкам и дугам. Забейте сопоставление один раз — и циклы короткие
DrawChart или примитивы, нарисованные вручную, в HotPDF?
HotPDF поставляет готовый рендерер диаграмм, и для стандартной отчётной диаграммы это первое, что стоит попробовать. Соберите THPDFChart, добавьте серии через AddSeries (виды hckLine, hckBar, hckArea, hckPie и hckScatter), наполните их AddPoint и вызовите THPDFPage.DrawChart с целевым прямоугольником. Рендерер сам вычисляет диапазон оси, сетку, подписи меток, легенду и цвета темы, а перегрузка, принимающая родителя структуры, пишет диаграмму как тегированную фигуру с альтернативным текстом, построенным из данных, что важно, если документ обязан пройти проверку доступности
var
Chart: THPDFChart;
Sales: THPDFChartSeries;
begin
Chart := THPDFChart.Create;
try
Chart.Title := 'Quarterly sales';
Chart.LegendPosition := hclBottom;
Sales := Chart.AddSeries(hckBar, 'Sales');
Sales.AddPoint(1, 420, 'Q1');
Sales.AddPoint(2, 515, 'Q2');
Sales.AddPoint(3, 388, 'Q3');
Sales.AddPoint(4, 610, 'Q4');
// Прямоугольник в единицах страницы: left, top, width, height
HotPDF1.CurrentPage.DrawChart(Chart, 60, 120, 470, 280);
finally
Chart.Free;
end;
end;
У рендерера, впрочем, фиксированный словарь. Несколько серий полос стоят бок о бок в каждой категории, круговая диаграмма обязана быть единственной серией в своей диаграмме, а внешний вид берётся из трёх тем плюс цвета по сериям. Стековые полосы, целевая полоса-фон за полосами, отдельный брендовый цвет каждой полосе в одной серии, круг, начинающийся с двенадцати часов и идущий по часовой: как только дизайн запросит хоть что-то из этого, нарисовать диаграмму самому канвой страницы — меньше работы, чем гнуть рендерер. Эти примитивы — Rectangle, MoveTo, LineTo, Pie, Fill, Stroke и TextOut, те же вызовы, что разобраны в пошаговом руководстве по рисованию на канве
Столбчатая диаграмма — это цикл по прямоугольникам
Страничные координаты HotPDF кладут начало координат в верхний левый угол с ростом Y вниз — та же рамка, что у канвы VCL. Пользовательское пространство PDF само бежит в другую сторону (начало в левом нижнем углу, Y вверх, ISO 32000-1 §8.3.2.3), и HotPDF переворачивает каждую координату в это пространство, записывая поток содержимого. Значения диаграммы, напротив, растут вверх. Скормите масштабированное значение прямо как Y — и самая высокая полоса повиснет с верха области построения сосулькой
Лекарство — одно выражение. Возьмите прямоугольник построения, отобразите ноль в его нижний край, а верх шкалы — в его верхний край, и прогоняйте каждое значение через это отображение, прежде чем оно коснётся страницы. Для значения V на шкале от 0 до MaxValue страничный Y — это Plot.Bottom - (V / Plot.MaxValue) * Plot.Height, где Plot.Bottom — базовая линия. Код столбчатой диаграммы ниже заворачивает это в маленький помощник ValueToY, и все остальные процедуры переиспользуют его
В этом выражении спрятаны два выбора. MaxValue должен быть круглым числом выше наибольшей точки данных — очередное кратное 10 или 100, чтобы самая высокая полоса не касалась верхнего края, а подписи линий сетки читались 0, 200, 400, а не 0, 203.5, 407. И шкала начинается с нуля намеренно. Длина полосы — это значение, поэтому ось значений, начинающаяся с 350, делает полосу 388 едва ли не вдвое ниже полосы 420, когда реальная разница меньше 8%. Встроенный рендерер затягивает ноль в диапазон для серий bar и area именно по этой причине. Держите то же правило и в коде, нарисованном вручную
Единицы — вторая половина отображения. При Resolution по умолчанию 72 одна единица страницы — один пункт PDF (1/72 дюйма). Поменяйте разрешение — и координаты, ширины линий и текстовые измерения все переключатся на новую единицу; подробно это разбирает статья о Resolution и единицах рисования. DrawChart принимает целевой прямоугольник в единицах страницы, но раскладывает собственные поля, размеры шрифтов и ширины линий в пунктах при любом разрешении
Столбчатая диаграмма — это цикл по прямоугольникам
Когда отображение на месте, столбчатая диаграмма — это цикл. Разделите ширину области построения на слоты по одному на категорию, оставьте зазор, чтобы соседние полосы не слипались, и рисуйте каждую полосу залитым прямоугольником от её верха по ValueToY вниз до базовой линии. Rectangle принимает левый верхний угол, ширину и высоту — это ложится на отображение напрямую:
type
TPlotArea = record
Left, Bottom, Width, Height: Single; // единицы страницы; Bottom — это Y базовой линии
MaxValue: Single; // верх шкалы значений
end;
// Ваш собственный помощник: большее значение сидит выше, что на странице с Y вниз
// означает меньший Y
function ValueToY(const Plot: TPlotArea; V: Single): Single;
begin
Result := Plot.Bottom - (V / Plot.MaxValue) * Plot.Height;
end;
procedure DrawBarChart(Page: THPDFPage; const Plot: TPlotArea;
const Values: array of Single; const Labels: array of string;
const BarColors: array of TColor);
var
I, Count: Integer;
SlotW, BarW, BarX, BarTop, Gap, GridV, GridY: Single;
begin
Count := Length(Values);
SlotW := Plot.Width / Count;
Gap := SlotW * 0.25; // четверть слота, поделённая влево и вправо
BarW := SlotW - Gap;
// Сначала пунктирные линии сетки, чтобы полосы легли поверх них
Page.SetLineWidth(0.5);
Page.SetRGBStrokeColor(RGB(200, 200, 200));
Page.SetDash([2, 2], 0);
GridV := Plot.MaxValue / 4;
while GridV <= Plot.MaxValue do
begin
GridY := ValueToY(Plot, GridV);
Page.MoveTo(Plot.Left, GridY);
Page.LineTo(Plot.Left + Plot.Width, GridY);
Page.Stroke;
GridV := GridV + Plot.MaxValue / 4;
end;
Page.NoDash;
Page.SetFont('Arial', [], 9);
for I := 0 to Count - 1 do
begin
BarX := Plot.Left + I * SlotW + Gap / 2;
BarTop := ValueToY(Plot, Values[I]);
Page.SetRGBFillColor(BarColors[I mod Length(BarColors)]);
Page.Rectangle(BarX, BarTop, BarW, Plot.Bottom - BarTop);
Page.Fill;
// TextOut кладёт верх текста на Y
Page.SetRGBFillColor(clBlack);
Page.TextOut(BarX, Plot.Bottom + 4, 0, Labels[I]);
Page.TextOut(BarX, BarTop - 12, 0, FormatFloat('0', Values[I]));
end;
// Базовая линия последней, чтобы легла поверх низов полос
Page.SetLineWidth(1.0);
Page.SetRGBStrokeColor(clBlack);
Page.MoveTo(Plot.Left, Plot.Bottom);
Page.LineTo(Plot.Left + Plot.Width, Plot.Bottom);
Page.Stroke;
end;
Порядок здесь — вся модель наложения слоёв. PDF использует модель живописца, где операторы, стоящие в потоке содержимого позже, рисуют поверх более ранних, поэтому линии сетки идут первыми, полосы поверх них, базовая линия — последней. Поменяйте блок линий сетки местами с циклом — и пунктир прочертит прямо поперёк каждой полосы
Зазор — это доля слота, а не фиксированное число пунктов. При четырёх категориях на области построения в 470 пунктов каждый слот шириной 117.5 пункта, и полосы получают около 29 пунктов воздуха между собой; при сорока категориях тот же код оставляет меньше 3 пунктов, что всё ещё читается как отдельные полосы. Фиксированный зазор в 12 пунктов при сорока категориях проглотил бы 480 из 470 доступных пунктов, и ширина полосы ушла бы в минус
Подписи осей, которые не сталкиваются
Подписи — то, где самописные диаграммы ломаются первыми, и почти всегда одинаково. Текст, умещавшийся на экране, переполняет своё место на странице, потому что разработчик тестировал с «Sep», а немецкий отчёт печатает «September» или «Dezember» шрифтом 9 пунктов. У оси значений более тихий вариант той же проблемы: подписи меток, выровненные влево на фиксированном смещении X, выглядят лохматыми, как только числа меняют длину, и «1,200» влетает в линию оси, которую «800» обходила
Измеряйте вместо того чтобы гадать. TextWidth возвращает ширину строки текущим шрифтом в единицах страницы, TextHeight — вынос шрифта вверх (ascent) при текущем размере. Выравнивайте каждую подпись метки по правому краю относительно оси и центрируйте на её метке:
procedure DrawValueAxis(Page: THPDFPage; const Plot: TPlotArea;
TickCount: Integer);
var
I: Integer;
TickV, TickY: Single;
S: string;
begin
Page.SetLineWidth(1.0);
Page.MoveTo(Plot.Left, Plot.Bottom);
Page.LineTo(Plot.Left, Plot.Bottom - Plot.Height);
Page.Stroke;
Page.SetFont('Arial', [], 8);
for I := 0 to TickCount do
begin
TickV := (Plot.MaxValue / TickCount) * I;
TickY := ValueToY(Plot, TickV);
Page.MoveTo(Plot.Left - 4, TickY); // короткая метка снаружи оси
Page.LineTo(Plot.Left, TickY);
Page.Stroke;
S := FormatFloat('#,##0', TickV);
Page.TextOut(Plot.Left - 7 - Page.TextWidth(AnsiString(S)),
TickY - Page.TextHeight(AnsiString(S)) / 2, 0, S);
end;
end;
Для подписей категорий то же измерение подсказывает, когда сдаваться с горизонтальным текстом. Сравните самую широкую подпись с шириной слота, прежде чем что-либо рисовать. Не помещается — сбросьте шрифт на пункт-другой; всё ещё не помещается — передайте 90 как аргумент угла в TextOut и поставьте подписи на торец, а затем оставьте под базовой линией достаточно поля для самой длинной. Тестируйтесь с самой широкой подписью, какую отчёт вообще может выдать, — для названий месяцев в локализованной сборке это редко английская
Линейные диаграммы переиспользуют то же отображение
Линейная диаграмма меняет только способ соединения точек. Пройдите данные один раз, переведите каждое значение через ValueToY и сшейте точки в один путь: MoveTo для первой, LineTo для каждой следующей, один Stroke в конце. Если точки уже лежат в массиве, Polyline принимает array of THPDFCurrPoint, открывает свежий путь и оставляет обводку вам; меньше двух точек — исключение, так что серии с единственным замером нужен маркер вместо линии
Именно расстановка расходится у линейной диаграммы со столбчатой, нарисованной над той же областью построения. Полосы делят ширину на число категорий, потому что у каждой полосы свой слот. Линия делит её на число интервалов, Count - 1, потому что первая и последняя точки сидят на краях области. Наложите их — и точки линии лягут на границы слотов вместо центров полос. Когда линия едет поверх полос, ставьте её точки в центр каждого слота, Plot.Left + I * SlotW + SlotW / 2, и выкидывайте расстановку Count - 1 целиком. Для маркера в каждой точке небольшой Circle с последующим Fill после обводки линии делает своё дело
Круговые секторы через Pie, по часовой от двенадцати часов
Сектор круговой диаграммы — это два радиуса и дуга. Самопальный способ построить его начинается в центре, шагает короткими сегментами LineTo в паре градусов друг от друга по ободу и замыкается обратно в центр — конструкция на рисунке ниже. Работает, но сектор в 270 градусов превращается в без малого полторы сотни сегментов пути, и обод под сильным зумом всё равно многоугольник
Используйте вместо этого Pie. THPDFPage.Pie следует конвенции GDI: ограничивающий прямоугольник, начальный и конечный угол в градусах, 0 смотрит на восток, 90 — на север, положительные углы против часовой. Он строит дугу из сегментов Безье не более 90 градусов каждый, ведёт линию в центр и замыкает путь, так что обод остаётся настоящей кривой, а целый сектор стоит горстки операторов. Деловые круговые обычно начинаются с двенадцати часов и идут по часовой, что на языке GDI значит стартовать с 90 и идти углом вниз:
procedure DrawPieChart(Page: THPDFPage; CX, CY, Radius: Single;
const Values: array of Double; const Colors: array of TColor);
var
I: Integer;
Total, Sweep, Angle: Double;
begin
Total := 0;
for I := 0 to High(Values) do
if Values[I] > 0 then
Total := Total + Values[I];
if Total <= 0 then
Exit;
Angle := 90; // двенадцать часов
Page.SetRGBStrokeColor(clWhite);
Page.SetLineWidth(0.8);
for I := 0 to High(Values) do
begin
if Values[I] <= 0 then
Continue; // см. ниже: никогда не передавайте нулевой или отрицательный разворот
Sweep := Values[I] / Total * 360;
Page.SetRGBFillColor(Colors[I mod Length(Colors)]);
// Против часовой от Angle - Sweep до Angle — это сектор, который
// сидит по часовой от текущей позиции
Page.Pie(CX - Radius, CY - Radius, CX + Radius, CY + Radius,
Angle - Sweep, Angle);
Page.FillAndStroke; // белая обводка отделяет секторы
Angle := Angle - Sweep;
end;
end;
Защита через Continue — самая важная строка здесь. Pie трактует конечный угол меньше начального как разворот, закручивающийся за 360, — прочтение в духе GDI, благодаря которому сектор может пересекать нулевую линию. Строка возврата или корректирующая проводка в исходных данных даёт отрицательное значение, вычисленный конечный угол уходит ниже начального, и сектор закручивается в почти полный диск, нарисованный поверх всего, что было до него. Нулевое значение мягче, но всё равно заметно: замкнутый путь схлопывается в радиус, и белая разделительная обводка рисует его как паразитную линию от обода к центру. Отфильтруйте оба случая до цикла и решите отдельно, как отчёт показывает отрицательные суммы, — круговая показать их не может в принципе
Когда дизайн не настаивает на круге, одна горизонтальная стековая полоса показывает те же доли одними лишь вызовами Rectangle встык, а читатели сравнивают длины точнее, чем углы. Для части-от-целого с пятью-шестью и более категориями это лучшая диаграмма, и код столбчатой диаграммы выше уже делает большую часть работы
Каждая диаграмма в этой статье собрана из вызовов канвы страницы HotPDF Delphi Component, с доступным DrawChart для стандартных макетов line, bar, area, pie и scatter, и всё это работает из Delphi или C++Builder без установленной библиотеки диаграмм на машине сборки