Elastic 노드 예시

장면에서 그래픽 항목과 상호 작용하는 방법을 보여줍니다.

Elastic 노드 예제는 기본적인 상호작용을 통해 그래프에서 노드 간의 가장자리를 구현하는 방법을 보여줍니다. 클릭하여 노드를 드래그하고 마우스 휠이나 키보드를 사용하여 확대 및 축소할 수 있습니다. 스페이스바를 누르면 노드가 무작위로 배치됩니다. 또한 이 예제는 해상도와 무관하므로 확대해도 그래픽이 선명하게 유지됩니다.

그래픽 보기는 QGraphicsItem 클래스에서 파생된 수많은 사용자 지정 2D 그래픽 항목을 관리하고 상호 작용할 수 있는 QGraphicsScene 클래스와 확대/축소 및 회전을 지원하여 항목을 시각화할 수 있는 QGraphicsView 위젯을 제공합니다.

이 예제는 Node 클래스, Edge 클래스, GraphWidget 테스트, main 함수로 구성되어 있습니다. Node 클래스는 그리드에서 드래그 가능한 노란색 노드를 나타내고, Edge 클래스는 노드 사이의 선을 나타내며, GraphWidget 클래스는 애플리케이션 창을 나타내고, main() 함수는 이 창을 생성하여 표시하고 이벤트 루프를 실행합니다.

노드 클래스 정의

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() 함수는 그리드에서 노드를 당기고 밀어주는 탄성 효과를 구현합니다. 또한 사용자가 마우스로 노드 하나를 직접 이동할 수도 있습니다. 동일한 노드에서 두 가지 접근 방식이 동시에 작동하는 것을 원하지 않기 때문에 Node 이 현재 마우스 그래버 항목인지 확인하여 calculateForces() 을 시작합니다(예: 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;
    }

노드 사이의 에지는 노드를 서로 끌어당기는 힘을 나타냅니다. 이 노드에 연결된 각 가장자리를 방문하면 위와 유사한 접근 방식을 사용하여 모든 당기는 힘의 방향과 강도를 찾을 수 있습니다. 이러한 힘은 xvelyvel 에서 뺍니다.

    if (qAbs(xvel) < 0.1 && qAbs(yvel) < 0.1)
        xvel = yvel = 0;

이론적으로는 밀고 당기는 힘의 합이 정확히 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() 에서 호출됩니다. 노드의 위치가 변경되면 이 함수는 참을 반환하고, 그렇지 않으면 거짓을 반환합니다.

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)에서 오른쪽으로 (3, 3) 단위 아래, 즉 (-10, -10)에 간단한 짙은 회색 타원 그림자를 그립니다.

그런 다음 방사형 그라데이션 채우기로 타원을 그립니다. 이 채우기는 올라오면 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 에 대한 포인터를 유지해야 하는 유일한 이유입니다. 다른 접근 방식은 신호를 사용하여 이러한 알림을 제공하는 것인데, 이 경우 NodeQGraphicsObject 에서 상속해야 합니다.

void Node::mousePressEvent(QGraphicsSceneMouseEvent *event)
{
    update();
    QGraphicsItem::mousePressEvent(event);
}

void Node::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
{
    update();
    QGraphicsItem::mouseReleaseEvent(event);
}

ItemIsMovable 플래그를 설정했기 때문에 마우스 입력에 따라 노드를 이동하는 로직을 구현할 필요가 없으며, 이는 이미 제공되어 있습니다. 하지만 노드의 시각적 모양(예: 가라앉거나 올라간 상태)을 업데이트하려면 마우스 누르기 및 놓기 핸들러를 다시 구현해야 합니다.

엣지 클래스 정의

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() 에서는 두 개의 포인터를 정의합니다: sourcePointdestPoint, 각각 소스 노드와 목적지 노드의 원점을 가리킵니다. 각 포인트는 로컬 좌표를 사용하여 계산됩니다.

에지 화살표의 끝이 노드의 중심이 아니라 노드의 정확한 윤곽을 가리키기를 원합니다. 이 지점을 찾으려면 먼저 소스 중심에서 대상 노드의 중심을 가리키는 벡터를 X와 Y로 분해한 다음 벡터의 길이로 나누어 성분을 정규화합니다. 이렇게 하면 노드의 반경(10)을 곱하면 에지의 한 지점에 더하고 다른 지점에서 빼야 하는 오프셋이 되는 X 및 Y 단위 델타가 나옵니다.

벡터의 길이가 20보다 작으면(즉, 두 노드가 겹치는 경우) 소스 노드의 중앙에 소스 및 대상 포인터를 고정합니다. 실제로 이 경우 두 노드 사이의 힘이 최대가 되기 때문에 수동으로 재현하기가 매우 어렵습니다.

이 함수에서 prepareGeometryChange()를 호출한다는 점에 주목하는 것이 중요합니다. 그 이유는 sourcePointdestPoint 변수가 페인팅할 때 직접 사용되며 boundingRect() 재구현에서 반환되기 때문입니다. 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인지 확인하고 길이가 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 는 기본 창에 스크롤바를 제공하는 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:
    int timerId = 0;
    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 정적이고 다소 복잡한 배경의 렌더링을 캐시할 수 있도록 합니다. 그래프는 모두 움직이는 작은 아이템의 밀집된 모음을 렌더링하므로 그래픽 뷰가 정확한 업데이트 영역을 찾는 데 시간을 낭비할 필요가 없으므로 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()
{
    if (!timerId)
        timerId = startTimer(1000 / 25);
}

GraphWidgetitemMoved() 함수를 통해 노드 이동에 대한 알림을 받습니다. 이 함수의 역할은 메인 타이머가 아직 실행되고 있지 않은 경우 타이머를 다시 시작하는 것입니다. 타이머는 그래프가 안정화되면 멈추고 다시 불안정해지면 다시 시작하도록 설계되었습니다.

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() 을 호출하여 확대 및 축소하며, 엔터 및 스페이스 키는 노드의 위치를 무작위로 지정합니다. 다른 모든 키 이벤트(예: 페이지 업 및 페이지 다운)는 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) {
        killTimer(timerId);
        timerId = 0;
    }
}

타이머 이벤트 핸들러의 역할은 전체 힘 계산 메커니즘을 부드러운 애니메이션으로 실행하는 것입니다. 타이머가 트리거될 때마다 핸들러는 씬의 모든 노드를 찾아 각 노드에서 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 타이머가 시작됩니다.

예제 프로젝트 @ code.qt.io

© 2025 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.