Revision control
Copy as Markdown
// Copyright (C) 2026 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only
// Qt-Security score:significant reason:default
#include <QtCore/qdatetime.h>
#include <QtCore/qmath.h>
#include <QtGui/qpainterpath.h>
#include <qfontmetrics.h>
#include <QtQuick/private/qquicktext_p.h>
#include <QtGraphs/qabstractaxis.h>
#include <QtGraphs/qvalueaxis.h>
#include <QtGraphs/qdatetimeaxis.h>
#include <QtGraphs/qlogvalueaxis.h>
#include <QtGraphs/qgraphstheme.h>
#include <private/polaraxisrenderer_p.h>
#include <private/qgraphsview_p.h>
#include <private/qpolarview_p.h>
QT_BEGIN_NAMESPACE
static constexpr int kMaxTicks = 100;
static constexpr int kRadialLabelMargin = 16;
PolarAxisRenderer::PolarAxisRenderer(QQuickItem *parent)
: AxisRenderer(parent)
{
m_shape.setParentItem(this);
m_shape.setPreferredRendererType(QQuickShape::CurveRenderer);
m_ringsPath = new QQuickShapePath(&m_shape);
m_ringsPath->setAsynchronous(true);
auto ringsData = m_shape.data();
ringsData.append(&ringsData, m_ringsPath);
m_spokesPath = new QQuickShapePath(&m_shape);
m_spokesPath->setAsynchronous(true);
auto spokesData = m_shape.data();
spokesData.append(&spokesData, m_spokesPath);
}
PolarAxisRenderer::~PolarAxisRenderer() = default;
void PolarAxisRenderer::computeAxisProperties(AxisProperties &ax, QAbstractAxis *axis)
{
ax.axis = axis;
ax.isLogarithmic = false;
if (!axis) {
// m_axes1[0]/m_axes2[0] are reused across polishes rather than
// reconstructed, so the range fields have to be put back to their
// defaults explicitly. Consumers such as
// AreaRenderer::calculateRenderCoordinates() and
// AreaRenderer::polarRadiusAffine() read them without checking axis
// validity, and would otherwise keep mapping values through the range
// of an axis that is no longer set.
ax.maxValue = 20;
ax.minValue = 0;
ax.valueRange = 0;
ax.valueRangeZoomless = 0;
ax.minValueZoomless = 0;
ax.valueStep = 1.0;
ax.minLabel = 0;
ax.tickCount = 0.0;
ax.logBase = 10;
return;
}
// Unlike a Cartesian axis, the visible range is anchored at the axis minimum
// rather than its midpoint: for the radial axis that minimum is the polar
// center, which must stay fixed so zooming scales uniformly from the center
// instead of drifting away from it.
if (auto vaxis = qobject_cast<QValueAxis *>(axis)) {
qreal step = vaxis->tickInterval();
qreal diff = (vaxis->max() - vaxis->min()) / vaxis->zoom();
ax.minValue = vaxis->min() + vaxis->pan();
ax.maxValue = ax.minValue + diff;
ax.valueRange = ax.maxValue - ax.minValue;
ax.valueRangeZoomless = vaxis->max() - vaxis->min();
ax.minValueZoomless = vaxis->min();
if (step <= 0)
step = getValueStepsFromRange(vaxis->max() - vaxis->min());
ax.valueStep = step;
// Used by the radial axis to shift its first visible tick away from
// the center (see radialTickValues()); irrelevant for the angular
// axis, whose spokes are always evenly divided around the full circle.
double minLabel = vaxis->tickAnchor();
while (minLabel < ax.minValue)
minLabel += step;
while (minLabel >= ax.minValue + step)
minLabel -= step;
ax.minLabel = minLabel;
} else if (auto vaxis = qobject_cast<QDateTimeAxis *>(axis)) {
double tickCount = std::clamp<double>(vaxis->tickCount(), 0.0, kMaxTicks);
qreal minMs = vaxis->min().toMSecsSinceEpoch();
qreal diff = (vaxis->max().toMSecsSinceEpoch() - minMs) / vaxis->zoom();
ax.minValue = minMs + vaxis->pan();
ax.maxValue = ax.minValue + diff;
ax.valueRange = std::abs(ax.maxValue - ax.minValue);
ax.valueRangeZoomless = vaxis->max().toMSecsSinceEpoch() - minMs;
ax.minValueZoomless = minMs;
double segment;
if (tickCount <= 0) {
segment = getValueStepsFromRange(ax.valueRange);
tickCount = ax.valueRange / segment;
} else {
segment = ax.valueRange / tickCount;
}
ax.valueStep = segment;
ax.tickCount = tickCount;
ax.minLabel = ax.minValue;
} else if (auto vaxis = qobject_cast<QLogValueAxis *>(axis)) {
ax.isLogarithmic = true;
ax.logBase = vaxis->base();
qreal logBase = log(vaxis->base());
qreal logMin = log(vaxis->min()) / logBase;
qreal logMax = log(vaxis->max()) / logBase;
qreal diff = (logMax - logMin) / vaxis->zoom();
ax.minValue = logMin + vaxis->pan();
ax.maxValue = ax.minValue + diff;
ax.valueRange = ax.maxValue - ax.minValue;
ax.valueRangeZoomless = logMax - logMin;
ax.minValueZoomless = logMin;
ax.valueStep = 1;
ax.minLabel = ax.minValue;
}
}
void PolarAxisRenderer::updateAxisMeasurements()
{
if (m_axes1.empty())
m_axes1.emplace_back();
if (m_axes2.empty())
m_axes2.emplace_back();
computeAxisProperties(m_axes1[0], m_graph->axisX());
computeAxisProperties(m_axes2[0], m_graph->axisY());
// Reserve a band for radial axis labels, read back by
// QPolarView::updateComponentSizes() via radialLabelBand().
m_radialLabelBand = 0;
auto &radial = m_axes2[0];
if (theme() && radial.axis && radial.axis->isVisible() && radial.axis->labelsVisible()) {
const QFontMetricsF fm(theme()->axisYLabelFont());
qreal labelWidth = std::max(fm.horizontalAdvance(QString::number(radial.minValue, 'g', 4)),
fm.horizontalAdvance(QString::number(radial.maxValue, 'g', 4)));
m_radialLabelBand = labelWidth + kRadialLabelMargin;
}
}
void PolarAxisRenderer::handlePolish()
{
if (m_axes1.empty())
m_axes1.emplace_back();
if (m_axes2.empty())
m_axes2.emplace_back();
computeAxisProperties(m_axes1[0], m_graph->axisX());
computeAxisProperties(m_axes2[0], m_graph->axisY());
const QList<qreal> radialTicks = radialTickValues(m_axes2[0]);
updatePolarGrid(radialTicks);
updatePolarLabels(radialTicks);
}
// Rings/labels must sit at their actual value's radius rather than at an even
// i/ringCount fraction: valueStep is derived from the unzoomed axis range, so
// once zoomed it no longer divides valueRange evenly and the two diverge,
// most visibly for the outermost ring. tickAnchor (QValueAxis only, folded
// into minLabel by computeAxisProperties()) shifts where the first tick
// falls, e.g. min: 0, max: 10, tickInterval: 2, tickAnchor: 1 yields
// 1, 3, 5, 7, 9 instead of 2, 4, 6, 8, 10.
QList<qreal> PolarAxisRenderer::radialTickValues(const AxisProperties &radial) const
{
QList<qreal> values;
if (!(radial.valueRange > 0 && radial.valueStep > 0))
return values;
const qreal epsilon = radial.valueStep * 1e-9;
for (qreal value = radial.minLabel; value <= radial.maxValue + epsilon;
value += radial.valueStep) {
// A tick that lands exactly on the center (radius 0) has no ring to
// draw and no sensible place to anchor a label.
if (value > radial.minValue + epsilon)
values.append(value);
// Bail out rather than subdivide the range unboundedly; this also stops the
// loop from stalling when valueStep is too small to advance value at all.
if (values.size() >= kMaxTicks)
break;
}
return values;
}
// Number of evenly spaced spokes/angular labels around the circle. The quotient is
// clamped before being narrowed: qRound() returns int, so casting a quotient beyond
// INT_MAX would be undefined, and an unvalidated tickInterval can produce one.
int PolarAxisRenderer::angularTickCount(const AxisProperties &angular) const
{
if (!(angular.valueRange > 0 && angular.valueStep > 0))
return 0;
const qreal count = angular.valueRange / angular.valueStep;
return qRound(std::clamp<qreal>(count, 1.0, kMaxTicks));
}
// Anchors an axis label so that its edge facing (anchor) stays a constant
// "pad" pixels away from it, however large the label item is, instead of
// centering the item on (anchor) and letting it grow inward over the grid.
// (dx, dy) is the outward unit direction from the polar center through
// (anchor).
static void positionOutwardLabel(QQuickItem *item, const QPointF &anchor, qreal dx, qreal dy,
qreal pad)
{
const qreal halfWidth = item->width() / 2.0;
const qreal halfHeight = item->height() / 2.0;
item->setX(anchor.x() - halfWidth + dx * (halfWidth + pad));
item->setY(anchor.y() - halfHeight + dy * (halfHeight + pad));
}
void PolarAxisRenderer::updatePolarGrid(const QList<qreal> &radialTicks)
{
if (!theme())
return;
auto polarView = qobject_cast<QPolarView *>(m_graph);
if (!polarView)
return;
const QPointF center = polarView->polarCenter();
const qreal radius = polarView->polarRadius();
auto &radial = m_axes2[0];
QPainterPath rings;
if (radial.axis && radial.axis->isVisible() && radial.axis->isGridVisible()) {
for (qreal value : radialTicks) {
qreal r = radius * (value - radial.minValue) / radial.valueRange;
rings.addEllipse(center, r, r);
}
}
m_ringsPath->setStrokeColor(theme()->axisY().mainColor());
m_ringsPath->setStrokeWidth(theme()->axisY().mainWidth());
m_ringsPath->setFillColor(QColorConstants::Transparent);
m_ringsPath->setPath(rings);
auto &angular = m_axes1[0];
QPainterPath spokes;
if (angular.axis && angular.axis->isVisible() && angular.axis->isGridVisible()
&& angular.valueRange > 0 && angular.valueStep > 0) {
int spokeCount = angularTickCount(angular);
for (int i = 0; i < spokeCount; ++i) {
qreal angleDeg = 360.0 * i / spokeCount;
qreal rad = qDegreesToRadians(angleDeg);
QPointF p = center + QPointF(qSin(rad) * radius, -qCos(rad) * radius);
spokes.moveTo(center);
spokes.lineTo(p);
}
}
m_spokesPath->setStrokeColor(theme()->axisX().mainColor());
m_spokesPath->setStrokeWidth(theme()->axisX().mainWidth());
m_spokesPath->setFillColor(QColorConstants::Transparent);
m_spokesPath->setPath(spokes);
m_shape.setSize(QSizeF(width(), height()));
}
static QString polarAxisLabelText(QAbstractAxis *axis, qreal value)
{
if (auto dtAxis = qobject_cast<QDateTimeAxis *>(axis))
return QDateTime::fromMSecsSinceEpoch(qint64(value)).toString(dtAxis->labelFormat());
if (auto logAxis = qobject_cast<QLogValueAxis *>(axis))
return QString::number(value, 'g', std::max(1, logAxis->labelPrecision()));
return QString::number(value, 'g', 4);
}
void PolarAxisRenderer::updatePolarLabels(const QList<qreal> &radialTicks)
{
if (!theme())
return;
auto polarView = qobject_cast<QPolarView *>(m_graph);
if (!polarView)
return;
const QPointF center = polarView->polarCenter();
const qreal radius = polarView->polarRadius();
const qreal pad = polarView->labelPadding();
auto &angular = m_axes1[0];
if (angular.axis && angular.axis->isVisible() && angular.axis->labelsVisible()
&& angular.valueRange > 0 && angular.valueStep > 0) {
int tickCount = angularTickCount(angular);
updateAxisLabelItems(angular.textItems, tickCount, angular.axis->labelDelegate());
for (int i = 0; i < tickCount && i < angular.textItems.size(); ++i) {
qreal value = angular.minValue + i * angular.valueStep;
qreal angleDeg = 360.0 * (value - angular.minValue) / angular.valueRange;
qreal rad = qDegreesToRadians(angleDeg);
qreal dx = qSin(rad);
qreal dy = -qCos(rad);
QPointF p = center + QPointF(dx * radius, dy * radius);
auto item = angular.textItems[i];
item->setVisible(true);
setLabelTextProperties(item, polarAxisLabelText(angular.axis, value), true);
positionOutwardLabel(item, p, dx, dy, pad);
}
}
auto &radial = m_axes2[0];
if (radial.axis && radial.axis->isVisible() && radial.axis->labelsVisible()) {
updateAxisLabelItems(radial.textItems, radialTicks.size(), radial.axis->labelDelegate());
for (qsizetype i = 0; i < radialTicks.size() && i < radial.textItems.size(); ++i) {
qreal value = radialTicks[i];
qreal r = radius * (value - radial.minValue) / radial.valueRange;
auto item = radial.textItems[i];
item->setVisible(true);
setLabelTextProperties(item, polarAxisLabelText(radial.axis, value), false);
// The label's baseline sits right on its ring, on the vertical
// (angle 0) radial axis line, offset sideways by "pad" so it
// doesn't run into the angular axis's own label at its minimum
// value, which is centered right above this same line.
item->setX(center.x() + pad);
item->setY(center.y() - r - item->height());
}
}
}
QT_END_NAMESPACE
#include "moc_polaraxisrenderer_p.cpp"