탄성 노드 예제
씬 내의 그래픽 요소와 상호작용하는 방법을 보여줍니다.
‘탄성 노드(Elastic Nodes)’ 예제는 그래프 내 노드 간 연결선을 구현하고 기본적인 상호작용을 수행하는 방법을 보여줍니다. 마우스로 노드를 클릭하여 드래그할 수 있으며, 마우스 휠이나 키보드를 사용하여 확대 및 축소할 수 있습니다. 스페이스 바를 누르면 노드 배치가 무작위로 변경됩니다. 또한 이 예제는 해상도에 독립적이어서, 확대해도 그래픽이 선명하게 유지됩니다.

Graphics View는 QGraphicsItem 클래스에서 파생된 대량의 사용자 정의 2D 그래픽 항목을 관리하고 상호 작용하기 위한 QGraphicsScene 클래스와, 확대/축소 및 회전 기능을 지원하며 항목을 시각화하는 QGraphicsView 위젯을 제공합니다.
이 예제는 ` Node ` 클래스, ` Edge ` 클래스, ` GraphWidget ` 테스트, 그리고 ` main ` 함수로 구성됩니다. ` Node ` 클래스는 그리드 내의 드래그 가능한 노란색 노드를 나타내고, ` Edge ` 클래스는 노드 사이의 선을 나타내며, ` GraphWidget ` 클래스는 애플리케이션 창을 나타내고, ` main() ` 함수는 이 창을 생성 및 표시하고 이벤트 루프를 실행합니다.
Node 클래스 정의
Node 클래스는 다음 세 가지 용도로 사용됩니다:
- 두 가지 상태(움푹 들어간 상태와 솟아오른 상태)로 노란색 그라데이션 "공"을 그리는 것.
- 다른 노드와의 연결을 관리합니다.
- 그리드 내 노드를 당기거나 밀어내는 힘을 계산합니다.
먼저 ` Node ` 클래스의 선언을 살펴보겠습니다.
class Node : public QGraphicsItem
{
public:
Node(GraphWidget *graphWidget);
void addEdge(Edge *edge);
QList<Edge *> edges() const;
enum { Type = UserType + 1 };
int type() const override { return Type; }
void calculateForces();
bool advancePosition();
QRectF boundingRect() const override;
QPainterPath shape() const override;
void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override;
protected:
QVariant itemChange(GraphicsItemChange change, const QVariant &value) override;
void mousePressEvent(QGraphicsSceneMouseEvent *event) override;
void mouseReleaseEvent(QGraphicsSceneMouseEvent *event) override;
private:
QList<Edge *> edgeList;
QPointF newPos;
GraphWidget *graph;
};Node 클래스는 ` QGraphicsItem`을 상속받으며, 시각적 외관을 제공하기 위해 두 가지 필수 함수인 ` boundingRect()`와 ` paint()`를 재구현합니다. 또한, 히트 영역이 기본 바운딩 사각형이 아닌 타원형이 되도록 보장하기 위해 ` shape()`을 재구현합니다.
엣지 관리 목적으로, 이 노드는 노드에 엣지를 추가하고 연결된 모든 엣지를 나열하기 위한 간단한 API를 제공합니다.
advance() 메서드의 재구현은 씬의 상태가 한 단계 진행될 때마다 호출됩니다. calculateForces() 함수는 이 노드와 인접 노드들에 가해지는 밀고 당기는 힘을 계산하기 위해 호출됩니다.
Node 클래스는 또한 상태 변경(이 경우 위치 변경)에 반응하기 위해 itemChange()을 재구현하고, 항목의 시각적 외관을 업데이트하기 위해 mousePressEvent() 및 mouseReleaseEvent()을 재구현합니다.
Node 구현을 검토하는 것은 생성자부터 시작하겠습니다:
Node::Node(GraphWidget *graphWidget)
: graph(graphWidget)
{
setFlag(ItemIsMovable);
setFlag(ItemSendsGeometryChanges);
setCacheMode(DeviceCoordinateCache);
setZValue(-1);
}생성자에서, 마우스 드래그에 반응하여 항목이 이동할 수 있도록 ItemIsMovable 플래그를 설정하고, 위치 및 변환 변경에 대한 itemChange() 알림을 활성화하기 위해 ItemSendsGeometryChanges 를 설정합니다. 또한 렌더링 성능을 높이기 위해 DeviceCoordinateCache 를 활성화합니다. 노드가 항상 에지 위에 겹쳐지도록 보장하기 위해, 마지막으로 항목의 Z 값을 -1로 설정합니다.
Node의 생성자는 ` GraphWidget ` 포인터를 받아 이를 멤버 변수로 저장합니다. 이 포인터에 대해서는 나중에 다시 살펴보겠습니다.
void Node::addEdge(Edge *edge)
{
edgeList << edge;
edge->adjust();
}
QList<Edge *> Node::edges() const
{
return edgeList;
}addEdge() 함수는 입력된 에지를 연결된 에지 목록에 추가합니다. 그런 다음 에지의 양 끝점이 소스 및 대상 노드의 위치와 일치하도록 에지를 조정합니다.
edges() 함수는 단순히 연결된 에지 목록을 반환합니다.
void Node::calculateForces()
{
if (!scene() || scene()->mouseGrabberItem() == this) {
newPos = pos();
return;
}노드를 이동하는 방법에는 두 가지가 있습니다. calculateForces() 함수는 그리드 내의 노드를 당기거나 밀어내는 탄성 효과를 구현합니다. 또한 사용자는 마우스를 사용하여 하나의 노드를 직접 이동할 수 있습니다. 두 가지 방식이 동일한 노드에 대해 동시에 작동하는 것을 원하지 않으므로, ` calculateForces() ` 함수는 먼저 이 ` Node `가 현재 마우스 그랩퍼 항목(즉, ` QGraphicsScene::mouseGrabberItem()`)인지 확인하는 것으로 시작합니다. 또한 모든 인접한(반드시 연결되어 있을 필요는 없는) 노드를 찾아야 하므로, 해당 항목이 우선 장면의 일부인지도 확인합니다.
// Sum up all forces pushing this item away
qreal xvel = 0;
qreal yvel = 0;
const QList<QGraphicsItem *> items = scene()->items();
for (QGraphicsItem *item : items) {
Node *node = qgraphicsitem_cast<Node *>(item);
if (!node)
continue;
QPointF vec = mapToItem(node, 0, 0);
qreal dx = vec.x();
qreal dy = vec.y();
double l = 2.0 * (dx * dx + dy * dy);
if (l > 0) {
xvel += (dx * 150.0) / l;
yvel += (dy * 150.0) / l;
}
}“탄성” 효과는 밀고 당기는 힘을 적용하는 알고리즘에서 비롯됩니다. 이 효과는 인상적이며, 구현하기에는 놀라울 정도로 간단합니다.
이 알고리즘은 두 단계로 이루어집니다. 첫 번째는 노드들을 서로 멀리 밀어내는 힘을 계산하는 것이고, 두 번째는 노드들을 서로 끌어당기는 힘을 뺀 값을 구하는 것입니다. 먼저 그래프 내의 모든 노드를 찾아야 합니다. ` QGraphicsScene::items()`를 호출하여 씬 내의 모든 아이템을 찾은 다음, ` qgraphicsitem_cast()`를 사용하여 ` Node ` 인스턴스를 찾습니다.
mapFromItem()를 활용하여 로컬 좌표계에서 해당 노드에서 다른 각 노드로 향하는 임시 벡터를 생성합니다. 이 벡터의 분해된 성분을 사용하여 노드에 가해져야 할 힘의 방향과 강도를 결정합니다. 각 노드에 대한 힘은 누적되며, 거리가 멀어질수록 급격히 감소하도록 조정되어 가장 가까운 노드에 가장 강한 힘이 가해지도록 합니다. 모든 힘의 합은 xvel (X-속도)와 yvel (Y-속도)에 저장됩니다.
// Now subtract all forces pulling items together
double weight = (edgeList.size() + 1) * 10;
for (const Edge *edge : std::as_const(edgeList)) {
QPointF vec;
if (edge->sourceNode() == this)
vec = mapToItem(edge->destNode(), 0, 0);
else
vec = mapToItem(edge->sourceNode(), 0, 0);
xvel -= vec.x() / weight;
yvel -= vec.y() / weight;
}노드 간의 에지는 노드들을 서로 끌어당기는 힘을 나타냅니다. 이 노드에 연결된 각 에지를 순회함으로써, 위와 유사한 접근 방식을 사용하여 모든 당기는 힘의 방향과 크기를 구할 수 있습니다. 이러한 힘들은 xvel 와 yvel 에서 차감됩니다.
이론적으로 밀리는 힘과 당기는 힘의 합은 정확히 0으로 안정화되어야 합니다. 그러나 실제로는 결코 그렇지 않습니다. 수치적 정밀도의 오류를 회피하기 위해, 힘의 합이 0.1 미만일 경우 이를 강제로 0으로 설정합니다.
QRectF sceneRect = scene()->sceneRect();
newPos = pos() + QPointF(xvel, yvel);
newPos.setX(qMin(qMax(newPos.x(), sceneRect.left() + 10), sceneRect.right() - 10));
newPos.setY(qMin(qMax(newPos.y(), sceneRect.top() + 10), sceneRect.bottom() - 10));
}calculateForces() 의 마지막 단계에서는 노드의 새로운 위치를 결정합니다. 노드의 현재 위치에 힘을 더합니다. 또한 새로운 위치가 정의된 경계 내에 머무르도록 합니다. 이 함수에서는 실제로 항목을 이동시키지 않으며, 이는 advance() 에서 별도의 단계로 처리됩니다.
bool Node::advancePosition()
{
if (newPos == pos())
return false;
setPos(newPos);
return true;
}advance() 함수는 아이템의 현재 위치를 업데이트합니다. 이 함수는 GraphWidget::timerEvent() 에서 호출됩니다. 노드의 위치가 변경된 경우 이 함수는 true를 반환하고, 그렇지 않은 경우 false를 반환합니다.
QRectF Node::boundingRect() const
{
qreal adjust = 2;
return QRectF( -10 - adjust, -10 - adjust, 23 + adjust, 23 + adjust);
}Node 의 경계 사각형은 원점 (0, 0)을 중심으로 한 20x20 크기의 사각형으로, 노드의 윤곽선 두께를 보정하기 위해 모든 방향으로 2 단위씩 조정되고, 간단한 드롭 섀도우를 위한 공간을 확보하기 위해 오른쪽 아래로 3 단위씩 조정됩니다.
QPainterPath Node::shape() const
{
QPainterPath path;
path.addEllipse(-10, -10, 20, 20);
return path;
}모양은 단순한 타원입니다. 이를 통해 노드를 드래그하려면 반드시 노드의 타원 모양 안쪽을 클릭해야 합니다. 이 효과를 테스트하려면 예제를 실행하고, 노드가 매우 크게 보이도록 화면을 크게 확대해 보십시오. ` shape()`를 재구현하지 않으면, 항목의 히트 영역은 경계 사각형(즉, 직사각형)과 동일해집니다.
void Node::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *)
{
painter->setPen(Qt::NoPen);
painter->setBrush(Qt::darkGray);
painter->drawEllipse(-7, -7, 20, 20);
QRadialGradient gradient(-3, -3, 10);
if (option->state & QStyle::State_Sunken) {
gradient.setCenter(3, 3);
gradient.setFocalPoint(3, 3);
gradient.setColorAt(1, QColor(Qt::yellow).lighter(120));
gradient.setColorAt(0, QColor(Qt::darkYellow).lighter(120));
} else {
gradient.setColorAt(0, Qt::yellow);
gradient.setColorAt(1, Qt::darkYellow);
}
painter->setBrush(gradient);
painter->setPen(QPen(Qt::black, 0));
painter->drawEllipse(-10, -10, 20, 20);
}이 함수는 노드의 그리기 작업을 구현합니다. 먼저 (-7, -7), 즉 타원의 왼쪽 상단 모서리(-10, -10)에서 오른쪽 아래로 3단, 아래로 3단 떨어진 위치에 단순한 짙은 회색 타원형 드롭 섀도우를 그립니다.
그런 다음 방사형 그라데이션 채우기가 적용된 타원을 그립니다. 이 채우기는 돌출 상태에서는 Qt::yellow 에서 Qt::darkYellow 로, 함몰 상태에서는 그 반대로 변합니다. 함몰 상태에서는 무언가가 아래로 밀려 내려간 듯한 인상을 강조하기 위해 중심과 초점도 (3, 3)만큼 이동시킵니다.
그라디언트로 채워진 타원을 그리는 작업은, 특히 QRadialGradient 와 같은 복잡한 그라디언트를 사용할 때 상당히 느릴 수 있습니다. 이것이 바로 이 예제에서 DeviceCoordinateCache 를 사용하는 이유이며, 이는 불필요한 재그리딩을 방지하는 간단하면서도 효과적인 방법입니다.
QVariant Node::itemChange(GraphicsItemChange change, const QVariant &value)
{
switch (change) {
case ItemPositionHasChanged:
for (Edge *edge : std::as_const(edgeList))
edge->adjust();
graph->itemMoved();
break;
default:
break;
};
return QGraphicsItem::itemChange(change, value);
}우리는 itemChange()를 재구현하여 연결된 모든 에지의 위치를 조정하고, 아이템이 이동했다는 사실(즉, “무언가 발생했다”)을 씬에 알립니다. 이로 인해 새로운 힘 계산이 트리거됩니다.
이 알림이 노드들이 GraphWidget 에 대한 포인터를 유지해야 하는 유일한 이유입니다. 또 다른 접근 방식으로는 신호(signal)를 사용하여 이러한 알림을 제공하는 것이 있을 수 있습니다. 이 경우, Node 는 QGraphicsObject 를 상속받아야 합니다.
void Node::mousePressEvent(QGraphicsSceneMouseEvent *event)
{
update();
QGraphicsItem::mousePressEvent(event);
}
void Node::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
{
update();
QGraphicsItem::mouseReleaseEvent(event);
}ItemIsMovable 플래그를 설정했기 때문에, 마우스 입력에 따라 노드를 이동시키는 로직을 구현할 필요가 없습니다. 이 기능은 이미 제공되고 있습니다. 다만, 노드의 시각적 상태(예: 가라앉음 또는 솟음)를 업데이트하기 위해 마우스 클릭 및 릴리스 핸들러는 여전히 재구현해야 합니다.
Edge 클래스 정의
Edge 클래스는 이 예제에서 노드들 사이의 화살표 선을 나타냅니다. 이 클래스는 매우 간단합니다. 소스 및 대상 노드 포인터를 유지하며, 선이 소스의 위치에서 시작하여 대상의 위치에서 끝날 수 있도록 하는 adjust() 함수를 제공합니다. 에지는 노드에 힘이 작용하여 당기거나 밀릴 때 지속적으로 변화하는 유일한 요소입니다.
클래스 선언을 살펴보겠습니다:
class Edge : public QGraphicsItem
{
public:
Edge(Node *sourceNode, Node *destNode);
Node *sourceNode() const;
Node *destNode() const;
void adjust();
enum { Type = UserType + 2 };
int type() const override { return Type; }
protected:
QRectF boundingRect() const override;
void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override;
private:
Node *source, *dest;
QPointF sourcePoint;
QPointF destPoint;
qreal arrowSize = 10;
};Edge QGraphicsItem 를 상속받는데, 이는 시그널, 슬롯, 속성이 필요 없는 간단한 클래스이기 때문입니다( 와 비교해 보세요). QGraphicsObject
생성자는 두 개의 노드 포인터를 입력으로 받습니다. 이 예제에서는 두 포인터 모두 필수입니다. 또한 각 노드에 대한 get 함수도 제공합니다.
adjust() 함수는 에지의 위치를 재설정하며, 이 아이템은 boundingRect() 및 paint()도 구현합니다.
이제 그 구현 내용을 살펴보겠습니다.
Edge::Edge(Node *sourceNode, Node *destNode)
: source(sourceNode), dest(destNode)
{
setAcceptedMouseButtons(Qt::NoButton);
source->addEdge(this);
dest->addEdge(this);
adjust();
}Edge 생성자는 arrowSize 데이터 멤버를 10 단위로 초기화합니다. 이는 paint()에서 그려지는 화살표의 크기를 결정합니다.
생성자 본문에서 setAcceptedMouseButtons(0)을 호출합니다. 이는 가장자리 항목이 마우스 입력 대상으로 전혀 간주되지 않도록 보장합니다(즉, 가장자리를 클릭할 수 없습니다). 그런 다음 시작점과 끝점 포인터를 업데이트하고, 이 가장자리를 각 노드에 등록한 뒤, adjust() 를 호출하여 이 가장자리의 시작 및 끝 위치를 업데이트합니다.
Node *Edge::sourceNode() const
{
return source;
}
Node *Edge::destNode() const
{
return dest;
}시작점 및 종착점의 get-함수는 단순히 해당 포인터를 반환합니다.
void Edge::adjust()
{
if (!source || !dest)
return;
QLineF line(mapFromItem(source, 0, 0), mapFromItem(dest, 0, 0));
qreal length = line.length();
prepareGeometryChange();
if (length > qreal(20.)) {
QPointF edgeOffset((line.dx() * 10) / length, (line.dy() * 10) / length);
sourcePoint = line.p1() + edgeOffset;
destPoint = line.p2() - edgeOffset;
} else {
sourcePoint = destPoint = line.p1();
}
}adjust() 에서는 소스 노드와 대상 노드의 기점을 각각 가리키는 두 점, 즉 sourcePoint 와 destPoint 를 정의합니다. 각 점은 국소 좌표를 사용하여 계산됩니다.
우리는 에지의 화살표 끝이 노드의 중심이 아닌 정확한 윤곽을 가리키기를 원합니다. 이 점을 찾기 위해, 먼저 소스 노드의 중심에서 대상 노드의 중심을 향하는 벡터를 X와 Y 성분으로 분해한 다음, 벡터의 길이로 나누어 성분을 정규화합니다. 이를 통해 X 및 Y 방향의 단위 델타를 얻게 되며, 이 값에 노드의 반지름(10)을 곱하면 에지의 한쪽 끝점에는 더하고 다른 쪽 끝점에서는 빼야 할 오프셋을 구할 수 있습니다.
벡터의 길이가 20보다 작을 경우(즉, 두 노드가 겹칠 경우), 소스 및 목적지 포인터를 소스 노드의 중심에 고정합니다. 실제로 이 경우는 두 노드 간의 힘이 최대가 되므로 수동으로 재현하기가 매우 어렵습니다.
이 함수에서 ` prepareGeometryChange()`를 호출한다는 점을 유의해야 합니다. 그 이유는 ` sourcePoint `와 ` destPoint ` 변수가 페인팅 시 직접 사용되며, 이 변수들은 ` boundingRect()` 재구현에서 반환되기 때문입니다. Graphics View의 내부 관리를 깔끔하게 유지하기 위해, boundingRect()이 반환하는 값을 변경하기 전과 paint()에서 이러한 변수를 사용할 수 있게 되기 전에는 반드시 prepareGeometryChange()를 호출해야 합니다. 이러한 변수가 수정되기 직전에 이 함수를 한 번 호출하는 것이 가장 안전합니다.
QRectF Edge::boundingRect() const
{
if (!source || !dest)
return QRectF();
qreal penWidth = 1;
qreal extra = (penWidth + arrowSize) / 2.0;
return QRectF(sourcePoint, QSizeF(destPoint.x() - sourcePoint.x(),
destPoint.y() - sourcePoint.y()))
.normalized()
.adjusted(-extra, -extra, extra, extra);
}가장자리의 경계 사각형은 가장자리의 시작점과 끝점을 모두 포함하는 가장 작은 사각형으로 정의됩니다. 각 가장자리에 화살표를 그리는 만큼, 모든 방향에서 화살표 크기의 절반과 펜 너비의 절반만큼 조정하여 보정해야 합니다. 펜은 화살표의 윤곽선을 그리는 데 사용되며, 윤곽선의 절반은 화살표 영역 바깥쪽에, 나머지 절반은 안쪽에 그려진다고 가정할 수 있습니다.
void Edge::paint(QPainter *painter, const QStyleOptionGraphicsItem *, QWidget *)
{
if (!source || !dest)
return;
QLineF line(sourcePoint, destPoint);
if (qFuzzyCompare(line.length(), qreal(0.)))
return;paint()의 재구현을 시작하며 몇 가지 전제 조건을 확인합니다. 첫째, 소스 노드나 대상 노드 중 하나라도 설정되어 있지 않으면 즉시 반환합니다. 그릴 것이 없기 때문입니다.
동시에, 가장자리의 길이가 대략 0인지 확인하고, 그렇다면 역시 반환합니다.
// Draw the line itself
painter->setPen(QPen(Qt::black, 1, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin));
painter->drawLine(line);우리는 둥근 연결부와 끝부분을 가진 펜을 사용하여 선을 그립니다. 예제를 실행하고 확대하여 가장자리를 자세히 살펴보면, 날카롭거나 각진 모서리가 없음을 확인할 수 있습니다.
// Draw the arrows
double angle = std::atan2(-line.dy(), line.dx());
QPointF sourceArrowP1 = sourcePoint + QPointF(sin(angle + M_PI / 3) * arrowSize,
cos(angle + M_PI / 3) * arrowSize);
QPointF sourceArrowP2 = sourcePoint + QPointF(sin(angle + M_PI - M_PI / 3) * arrowSize,
cos(angle + M_PI - M_PI / 3) * arrowSize);
QPointF destArrowP1 = destPoint + QPointF(sin(angle - M_PI / 3) * arrowSize,
cos(angle - M_PI / 3) * arrowSize);
QPointF destArrowP2 = destPoint + QPointF(sin(angle - M_PI + M_PI / 3) * arrowSize,
cos(angle - M_PI + M_PI / 3) * arrowSize);
painter->setBrush(Qt::black);
painter->drawPolygon(QPolygonF() << line.p1() << sourceArrowP1 << sourceArrowP2);
painter->drawPolygon(QPolygonF() << line.p2() << destArrowP1 << destArrowP2);
}이어서 변의 양 끝단에 화살표 하나씩을 그립니다. 각 화살표는 검은색으로 채워진 다각형으로 그려집니다. 화살표의 좌표는 간단한 삼각법을 사용하여 결정됩니다.
GraphWidget 클래스 정의
GraphWidget QGraphicsView 의 서브클래스로, 메인 창에 스크롤바를 제공합니다.
class GraphWidget : public QGraphicsView
{
Q_OBJECT
public:
GraphWidget(QWidget *parent = nullptr);
void itemMoved();
public slots:
void shuffle();
void zoomIn();
void zoomOut();
protected:
void keyPressEvent(QKeyEvent *event) override;
void timerEvent(QTimerEvent *event) override;
#if QT_CONFIG(wheelevent)
void wheelEvent(QWheelEvent *event) override;
#endif
void drawBackground(QPainter *painter, const QRectF &rect) override;
void scaleView(qreal scaleFactor);
private:
QBasicTimer timer;
Node *centerNode;
};이 클래스는 씬을 초기화하는 기본 생성자, 씬의 노드 그래프 변경 사항을 알리는 ` itemMoved() ` 함수, 몇 가지 이벤트 핸들러, ` drawBackground()`의 재구현, 그리고 마우스 휠이나 키보드를 사용하여 뷰의 크기를 조정하는 헬퍼 함수를 제공합니다.
GraphWidget::GraphWidget(QWidget *parent)
: QGraphicsView(parent)
{
QGraphicsScene *scene = new QGraphicsScene(this);
scene->setItemIndexMethod(QGraphicsScene::NoIndex);
scene->setSceneRect(-200, -200, 400, 400);
setScene(scene);
setCacheMode(CacheBackground);
setViewportUpdateMode(BoundingRectViewportUpdate);
setRenderHint(QPainter::Antialiasing);
setTransformationAnchor(AnchorUnderMouse);
scale(qreal(0.8), qreal(0.8));
setMinimumSize(400, 400);
setWindowTitle(tr("Elastic Nodes"));GraphicsWidget의 생성자는 씬을 생성하며, 대부분의 아이템이 거의 항상 이동하기 때문에 QGraphicsScene::NoIndex 을 설정합니다. 그런 다음 씬은 고정된 scene rectangle 을 얻게 되며, GraphWidget 뷰에 할당됩니다.
이 뷰는 QGraphicsView::CacheBackground 를 활성화하여 정적이면서도 다소 복잡한 배경의 렌더링을 캐시합니다. 그래프는 모두 움직이는 작은 항목들이 밀집해 있는 형태를 렌더링하므로, Graphics View가 정확한 업데이트 영역을 찾는 데 시간을 낭비할 필요가 없습니다. 따라서 QGraphicsView::BoundingRectViewportUpdate 뷰포트 업데이트 모드를 설정합니다. 기본값으로도 문제없이 작동하지만, 이 예제에서는 이 모드가 눈에 띄게 더 빠릅니다.
렌더링 품질을 높이기 위해 QPainter::Antialiasing 를 설정했습니다.
변환 앵커는 뷰를 변환할 때, 즉 이 예제의 경우 확대 또는 축소할 때 뷰가 어떻게 스크롤되어야 하는지를 결정합니다. 우리는 QGraphicsView::AnchorUnderMouse 를 선택했는데, 이는 뷰를 마우스 커서 아래 지점을 중심으로 정렬합니다. 이를 통해 마우스를 장면 내의 특정 지점 위로 이동시킨 후 마우스 휠을 굴려 해당 지점을 향해 쉽게 확대할 수 있습니다.
마지막으로, 창의 최소 크기를 씬의 기본 크기와 일치하도록 지정하고, 적절한 창 제목을 설정합니다.
Node *node1 = new Node(this);
Node *node2 = new Node(this);
Node *node3 = new Node(this);
Node *node4 = new Node(this);
centerNode = new Node(this);
Node *node6 = new Node(this);
Node *node7 = new Node(this);
Node *node8 = new Node(this);
Node *node9 = new Node(this);
scene->addItem(node1);
scene->addItem(node2);
scene->addItem(node3);
scene->addItem(node4);
scene->addItem(centerNode);
scene->addItem(node6);
scene->addItem(node7);
scene->addItem(node8);
scene->addItem(node9);
scene->addItem(new Edge(node1, node2));
scene->addItem(new Edge(node2, node3));
scene->addItem(new Edge(node2, centerNode));
scene->addItem(new Edge(node3, node6));
scene->addItem(new Edge(node4, node1));
scene->addItem(new Edge(node4, centerNode));
scene->addItem(new Edge(centerNode, node6));
scene->addItem(new Edge(centerNode, node8));
scene->addItem(new Edge(node6, node9));
scene->addItem(new Edge(node7, node4));
scene->addItem(new Edge(node8, node7));
scene->addItem(new Edge(node9, node8));
node1->setPos(-50, -50);
node2->setPos(0, -50);
node3->setPos(50, -50);
node4->setPos(-50, 0);
centerNode->setPos(0, 0);
node6->setPos(50, 0);
node7->setPos(-50, 50);
node8->setPos(0, 50);
node9->setPos(50, 50);
}생성자의 마지막 부분에서는 노드와 에지로 구성된 그리드를 생성하고, 각 노드에 초기 위치를 지정합니다.
void GraphWidget::itemMoved()
{
using namespace std::chrono_literals;
if (!timer.isActive())
timer.start(1000ms / 25, this);
}GraphWidget itemMoved() 함수를 통해 노드 이동이 알림됩니다. 이 함수의 역할은 메인 타이머가 아직 실행 중이 아닐 경우 이를 다시 시작하는 것뿐입니다. 타이머는 그래프가 안정화되면 중지되고, 다시 불안정해지면 시작되도록 설계되었습니다.
void GraphWidget::keyPressEvent(QKeyEvent *event)
{
switch (event->key()) {
case Qt::Key_Up:
centerNode->moveBy(0, -20);
break;
case Qt::Key_Down:
centerNode->moveBy(0, 20);
break;
case Qt::Key_Left:
centerNode->moveBy(-20, 0);
break;
case Qt::Key_Right:
centerNode->moveBy(20, 0);
break;
case Qt::Key_Plus:
zoomIn();
break;
case Qt::Key_Minus:
zoomOut();
break;
case Qt::Key_Space:
case Qt::Key_Enter:
shuffle();
break;
default:
QGraphicsView::keyPressEvent(event);
}
}이것이 GraphWidget 의 주요 키 이벤트 핸들러입니다. 화살표 키는 중앙 노드를 이동시키고, '+' 및 '-' 키는 scaleView() 를 호출하여 확대/축소하며, Enter 및 스페이스 키는 노드의 위치를 무작위로 변경합니다. 그 외의 모든 키 이벤트(예: Page Up 및 Page Down)는 QGraphicsView 의 기본 구현에 의해 처리됩니다.
void GraphWidget::timerEvent(QTimerEvent *event)
{
Q_UNUSED(event);
QList<Node *> nodes;
const QList<QGraphicsItem *> items = scene()->items();
for (QGraphicsItem *item : items) {
if (Node *node = qgraphicsitem_cast<Node *>(item))
nodes << node;
}
for (Node *node : std::as_const(nodes))
node->calculateForces();
bool itemsMoved = false;
for (Node *node : std::as_const(nodes)) {
if (node->advancePosition())
itemsMoved = true;
}
if (!itemsMoved)
timer.stop();
}타이머 이벤트 핸들러의 역할은 전체 힘 계산 메커니즘을 부드러운 애니메이션으로 실행하는 것입니다. 타이머가 트리거될 때마다 핸들러는 씬 내의 모든 노드를 찾아, 각 노드에 대해 하나씩 차례로 ` Node::calculateForces() `를 호출합니다. 그런 다음 마지막 단계에서 ` Node::advance() `을 호출하여 모든 노드를 새로운 위치로 이동시킵니다. ` advance()`의 반환 값을 확인함으로써 그리드가 안정화되었는지(즉, 이동한 노드가 없는지) 판단할 수 있습니다. 그렇다면 타이머를 중지할 수 있습니다.
void GraphWidget::wheelEvent(QWheelEvent *event)
{
scaleView(pow(2., -event->angleDelta().y() / 240.0));
}휠 이벤트 핸들러에서는 마우스 휠의 이동량을 배율 계수로 변환한 후, 이 계수를 ` scaleView()`에 전달합니다. 이 방식은 휠을 돌리는 속도를 고려합니다. 마우스 휠을 빠르게 돌릴수록 뷰의 확대/축소 속도도 빨라집니다.
void GraphWidget::drawBackground(QPainter *painter, const QRectF &rect)
{
Q_UNUSED(rect);
// Shadow
QRectF sceneRect = this->sceneRect();
QRectF rightShadow(sceneRect.right(), sceneRect.top() + 5, 5, sceneRect.height());
QRectF bottomShadow(sceneRect.left() + 5, sceneRect.bottom(), sceneRect.width(), 5);
if (rightShadow.intersects(rect) || rightShadow.contains(rect))
painter->fillRect(rightShadow, Qt::darkGray);
if (bottomShadow.intersects(rect) || bottomShadow.contains(rect))
painter->fillRect(bottomShadow, Qt::darkGray);
// Fill
QLinearGradient gradient(sceneRect.topLeft(), sceneRect.bottomRight());
gradient.setColorAt(0, Qt::white);
gradient.setColorAt(1, Qt::lightGray);
painter->fillRect(rect.intersected(sceneRect), gradient);
painter->setBrush(Qt::NoBrush);
painter->drawRect(sceneRect);
// Text
QRectF textRect(sceneRect.left() + 4, sceneRect.top() + 4,
sceneRect.width() - 4, sceneRect.height() - 4);
QString message(tr("Click and drag the nodes around, and zoom with the mouse "
"wheel or the '+' and '-' keys"));
QFont font = painter->font();
font.setBold(true);
font.setPointSize(14);
painter->setFont(font);
painter->setPen(Qt::lightGray);
painter->drawText(textRect.translated(2, 2), message);
painter->setPen(Qt::black);
painter->drawText(textRect, message);
}뷰의 배경은 QGraphicsView::drawBackground() 메서드를 재구현하여 렌더링됩니다. 선형 그라데이션으로 채워진 큰 사각형을 그리고, 드롭 섀도우를 추가한 다음, 그 위에 텍스트를 렌더링합니다. 텍스트는 간단한 드롭 섀도우 효과를 위해 두 번 렌더링됩니다.
이 배경 렌더링은 상당히 많은 리소스를 소모하므로, 뷰에서는 QGraphicsView::CacheBackground 를 활성화합니다.
void GraphWidget::scaleView(qreal scaleFactor)
{
qreal factor = transform().scale(scaleFactor, scaleFactor).mapRect(QRectF(0, 0, 1, 1)).width();
if (factor < 0.07 || factor > 100)
return;
scale(scaleFactor, scaleFactor);
}scaleView() 헬퍼 함수는 배율 계수가 특정 한도 내에 머무르는지(즉, 너무 많이 확대하거나 축소할 수 없도록) 확인한 다음, 이 배율을 뷰에 적용합니다.
main() 함수
이 예제의 나머지 부분이 복잡한 것과는 대조적으로, ` main() ` 함수는 매우 간단합니다. ` QApplication ` 인스턴스를 생성한 다음, ` GraphWidget` 인스턴스를 생성하여 표시합니다. 그리드의 모든 노드는 처음에 이동되므로, 제어권이 이벤트 루프로 돌아오자마자 ` GraphWidget ` 타이머가 시작됩니다.
© 2026 The Qt Company Ltd. Documentation contributions included herein are the copyrights of their respective owners. The documentation provided herein is licensed under the terms of the GNU Free Documentation License version 1.3 as published by the Free Software Foundation. Qt and respective logos are trademarks of The Qt Company Ltd. in Finland and/or other countries worldwide. All other trademarks are property of their respective owners.