Рисуем карту сервисов при помощи Qt Quick и GraphViz

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Решил запрототипировать два представления в дополнение к стандартному Jaeger UI. Это

  • построение карты сервисов по трейсу;

  • просмотрщик логов без пиксельхантинга и разворачивания спанов.

Для Qt Widgets есть обертка в виде nbergont/qgv, а хочется сделать на Qt Quick.

Как это выглядит со стороны Qt Quick:

Flickable {
    topMargin: 80
    leftMargin: 80
    bottomMargin: 80
    rightMargin: 80

    contentWidth: svcMap.width
    contentHeight: svcMap.height

    ServiceMap {
        id: svcMap
        visible: true
        
        graph: item.graph
        delegate: Rectangle {
            implicitHeight: content.height + 10
            implicitWidth: content.width + 10

            visible: true
            border.color: "black"
            border.width: 1

            ColumnLayout {
                id: content
                x: 5
                y: 5

                Text {
                    text: node.name
                    Layout.minimumWidth: 100
                    font.bold: true
                }

                Rectangle {
                    visible: node.hasEdges
                    height: 1
                    width: 10
                    Layout.fillWidth: true
                    color: "gray"
                }

                Repeater {
                    model: node.operations

                    Text {
                        text: modelData
                    }
                }
            }
            MouseArea {
                anchors.fill: parent
                onClicked: {
                    nodeItem.setNode(node);
                }
            }
        }
    }
}

ServiceMap помещаем во Flickable на случай если граф не влезет в отображаемые границы. Делегат вычисляет размер исходя из содержимого, которое зависит от переданного свойства node.На стороне С++ следующая последовательность шагов:

  • пробежаться по вершинам и ребрам графа, создать QQuickItem со свойством node, для получения размера;

  • пробежаться по вершинам и ребрам графа, создать Agnode_t, Agedge_t в GraphViz, настроить параметры отображения;

  • выполнить расчет графа в GraphViz;

  • выставить вершинам и ребрам рассчитанные параметры.

Интерфейс ServiceMap, для делегата используется тип QQmlComponent:

struct ServiceMapCtx;

class ServiceMap : public QQuickItem
{
    Q_OBJECT
    Q_PROPERTY(TraceGraph graph READ getGraph WRITE setGraph NOTIFY notifyGraphChanged)
    Q_PROPERTY(QQmlComponent *delegate READ delegate WRITE setDelegate NOTIFY notifyDelegateChanged)
public:
    static constexpr qreal DPI = 72.0; //https://graphviz.org/doc/info/attrs.html

    explicit ServiceMap(QQuickItem *parent = nullptr);
    ~ServiceMap();

    const TraceGraph &getGraph() const;
    void setGraph(const TraceGraph &data);

    QQmlComponent *delegate() const;
    void setDelegate(QQmlComponent *delegate);

signals:

    void notifyGraphChanged();
    void notifyDelegateChanged();

private:
    void makeServiceGraph();
    void makeQuickNodes();
    void computeLayout();
    void resetGraph();

private:
    std::unique_ptr<ServiceMapCtx> m_ctx;
    TraceGraph m_trace;
    QQmlComponent *m_delegate;

    QVector<ServiceMapNode> m_nodes;
    QVector<ServiceMapEdge> m_edges;
};

Немножко хелперов, GraphViz использует строки, для настройки свойств:

namespace {
struct ContextDeleter
{
    void operator()(GVC_t *ctx) const
    {
        gvFinalize(ctx);
        if (gvFreeContext(ctx) != 0) {
            qWarning() << "gvFreeContext != 0";
        }
    }
};

struct GraphDeleter
{
    void operator()(Agraph_t *graph) const
    {
        if (agclose(graph) != 0) {
            qWarning() << "agclose != 0";
        }
    }
};

using GVContextPtr = std::unique_ptr<GVC_t, ContextDeleter>;
using GVGraphPtr = std::unique_ptr<Agraph_t, GraphDeleter>;

template<typename NodeType>
void setAttribute(NodeType *node, const QString &key, const QString &value)
{
    char empty[] = "";

    auto k = key.toLatin1();
    auto v = value.toLatin1();
    agsafeset(node, k.data(), v.data(), empty);
}


} // namespace
//...
struct ServiceMapCtx
{
    ServiceMapCtx()
        : ctx(gvContext())
        , graph(agopen("service_map", Agdirected, NULL))
    {
        setGraphAttribute("label", "service map");

        setGraphAttribute("rankdir", "LR");
        setGraphAttribute("nodesep", "0.5");
        //setGraphAttribute("splines", "ortho");

        setNodeAttribute("shape", "box");
        setEdgeAttribute("minlen", "3");
    }

    ~ServiceMapCtx() { gvFreeLayout(ctx.get(), graph.get()); }

    void setNodeAttribute(const QString &name, const QString &value)
    {
        if (graph) {
            agattr(graph.get(), AGNODE, name.toLocal8Bit().data(), value.toLocal8Bit().data());
        }
    }

    void setGraphAttribute(const QString &name, const QString &value)
    {
        if (graph) {
            agattr(graph.get(), AGRAPH, name.toLocal8Bit().data(), value.toLocal8Bit().data());
        }
    }

    void setEdgeAttribute(const QString &name, const QString &value)
    {
        if (graph) {
            agattr(graph.get(), AGEDGE, name.toLocal8Bit().data(), value.toLocal8Bit().data());
        }
    }

    GVContextPtr ctx;
    GVGraphPtr graph;
};

ServiceMap::ServiceMap(QQuickItem *parent)
    : QQuickItem(parent)
    , m_ctx(std::make_unique<ServiceMapCtx>())
    , m_delegate(nullptr)
{
    setFlag(QQuickItem::ItemHasContents);
}

ServiceMap::~ServiceMap() {}

Для визуальных QQuickItem нужно задавать флаг QQuickItem::ItemHasContents в true, означает что item имеет детей, которых нужно отрисовывать. Создаем визуальные элементы вершин и ребер:

void ServiceMap::makeQuickNodes()
{
    for (auto &node : m_nodes) {
        auto creationCtx = m_delegate->creationContext();
        auto ctx = new QQmlContext(creationCtx ? creationCtx : qmlContext(this));

        auto item = m_delegate->beginCreate(ctx);
        if (item) {
            ctx->setContextProperty("node", QVariant::fromValue(node));

            auto quickItem = qobject_cast<QQuickItem *>(item);

            quickItem->setParentItem(this);
            quickItem->setZ(1);
            node.qmlObject = quickItem;
        } else {
            qCritical() << "failed create QQuickItem from delegate" << m_delegate->errors();
        }
        m_delegate->completeCreate();
    }

    for (auto &edge : m_edges) {
        edge.qmlObject = new EdgeItem;
        edge.qmlObject->setZ(2);
        edge.qmlObject->setParentItem(this);
    }
}

Вершина создается в несколько этапов через beginCreate/completeCreate, для установки свойства node. Создаем вершины и узлы в GraphViz, длины задаются в дюймах, при этом зашито 72 DPI:

void applySize(ServiceMapNode &node, qreal DPI)
{
    if (node.qmlObject) {
        auto size = node.qmlObject->size();
        auto widthIn = QString::number(qreal(size.width()) / DPI);
        auto heightIn = QString::number(qreal(size.height()) / DPI);
        setAttribute(node.gvNode, "width", widthIn);
        setAttribute(node.gvNode, "height", heightIn);
        setAttribute(node.gvNode, "fixedsize", "true");
    }
}

//...
auto graph = m_ctx->graph.get();
QHash<graph::Process *, Agnode_t *> nodeMap;
for (auto &node : m_nodes) {
    node.gvNode = agnode(graph, NULL, true);
    nodeMap.insert(node.process, node.gvNode);
    applySize(node, DPI);
}

for (auto &edge : m_edges) {
    auto from = nodeMap[edge.from];
    auto to = nodeMap[edge.to];
    edge.gvEdge = agedge(graph, from, to, NULL, TRUE);
}

Сам расчет графа осуществляется вызовом функции gvLayout из gvc.h(libgvc). В этой библиотеке содержатся функции расчета и рендеринга изображения:

if (gvLayout(m_ctx->ctx.get(), graph, "dot") != 0) {
    qCritical() << "Layout render error" << agerrors() << QString::fromLocal8Bit(aglasterr());
}

Выставляем размер ServiceMap, где UR это координаты верхнего правого угла typedef struct { pointf LL, UR; } boxf:

qreal gvGraphHeight = GD_bb(graph).UR.y;
qreal gvGraphWidth = GD_bb(graph).UR.x;
setImplicitHeight(gvGraphHeight);
setImplicitWidth(gvGraphWidth);

Дальше нужно расставить вершины QQuickItem по координатам, который рассчитал GraphViz. В GraphViz ось Y идет снизу вверх, а в Qt сверху в низ. А координата вершины GraphViz находится в центре фигуры. Поэтому разворачиваем и смещаем координаты:

QPointF centerToOrigin(const QPointF &p, qreal width, qreal height)
{
    return QPointF(p.x() - width / 2, p.y() - height / 2);
}
//...
for (auto &node : m_nodes) {
    auto gvPos = ND_coord(node.gvNode);
    QPoint pt(gvPos.x, gvGraphHeight - gvPos.y);
    auto org = centerToOrigin(pt, node.qmlObject->width(), node.qmlObject->height());
    node.qmlObject->setPosition(org);
}

С ребрами немного сложнее. Вытаскиваем точки, по которым будем рисовать линию:

for (auto &edge : m_edges) {
    auto spline = ED_spl(edge.gvEdge);
    QVector<QPointF> points;

    if (spline->size != 0) {
        bezier bez = spline->list[0];
        points.reserve(bez.size);

        for (int i = 0; i < bez.size; ++i) {
            auto &p = bez.list[i];
            points << QPointF(p.x, gvGraphHeight - p.y);
        }
        points << QPointF(spline->list->ep.x, gvGraphHeight - spline->list->ep.y);
    }

    edge.qmlObject->setPoints(points);

Осталось нарисовать объект с необычной геометрией. В документации Qt Quick Examples and Tutorials есть интересующие нас вещи. От туда потребуется примеры работы с графом сцены(Scene Graph), из которого возьмем два примера Graph и Custom Geometry. Т.к. это прототип, то для ребер написал код на выброс:

class EdgeItem : public QQuickItem
{
    Q_OBJECT
    QML_ELEMENT
public:
    explicit EdgeItem(QQuickItem *parent = nullptr);
    QSGNode *updatePaintNode(QSGNode *, UpdatePaintNodeData *) override;

    void setPoints(const QVector<QPointF> &points);

private:
    QVector<QPointF> m_points;
    QSGGeometryNode *m_arrowNode;
};
///...
EdgeItem::EdgeItem(QQuickItem *parent)
    : QQuickItem(parent)
    , m_arrowNode(nullptr)
{
    setFlag(ItemHasContents);
}

QSGNode *EdgeItem::updatePaintNode(QSGNode *oldNode, UpdatePaintNodeData *)
{
    if (!m_arrowNode) {
        m_arrowNode = new QSGGeometryNode;
        auto geometry = new QSGGeometry(QSGGeometry::defaultAttributes_Point2D(), 3);
        geometry->setLineWidth(1);
        geometry->setDrawingMode(QSGGeometry::DrawTriangles);
        m_arrowNode->setGeometry(geometry);
        m_arrowNode->setFlag(QSGNode::OwnsGeometry);
        auto *material = new QSGFlatColorMaterial;
        material->setColor(QColor("black"));
        m_arrowNode->setMaterial(material);
        m_arrowNode->setFlag(QSGNode::OwnsMaterial);
        geometry->allocate(3);
    }

    QSGGeometryNode *node = nullptr;
    QSGGeometry *geometry = nullptr;

    if (!oldNode) {
        node = new QSGGeometryNode;
        geometry = new QSGGeometry(QSGGeometry::defaultAttributes_Point2D(),
                                   std::max(m_points.size() - 1, qsizetype(0)));
        geometry->setLineWidth(1);
        geometry->setDrawingMode(QSGGeometry::DrawLineStrip);
        node->setGeometry(geometry);
        node->setFlag(QSGNode::OwnsGeometry);
        auto *material = new QSGFlatColorMaterial;
        material->setColor(QColor("black"));
        node->setMaterial(material);
        node->setFlag(QSGNode::OwnsMaterial);

        node->appendChildNode(m_arrowNode);
    } else {
        node = static_cast<QSGGeometryNode *>(oldNode);
        geometry = node->geometry();
        geometry->allocate(m_points.size() - 1);
    }

    QSGGeometry::Point2D *vertices = geometry->vertexDataAsPoint2D();

    for (int i = 0; i < m_points.size() - 1; ++i) {
        vertices[i].set(m_points[i].x(), m_points[i].y());
    }

    if (!m_points.isEmpty()) {
        QLineF line(m_points[m_points.size() - 2], m_points[m_points.size() - 1]);
        QLineF n = line.normalVector();
        QPointF o(n.dx() / 3.0, n.dy() / 3.0);

        auto arrVertices = m_arrowNode->geometry()->vertexDataAsPoint2D();
        arrVertices[0].set(line.p1().x() + o.x(), line.p1().y() + o.y());
        arrVertices[1].set(line.p2().x(), line.p2().y());
        arrVertices[2].set(line.p1().x() - o.x(), line.p1().y() - o.y());
    }

    node->markDirty(QSGNode::DirtyGeometry);
    return node;
}

void EdgeItem::setPoints(const QVector<QPointF> &points)
{
    m_points = points;
    update();
}

Этого достаточно. Если задать свойство ребер setGraphAttribute("splines", "ortho"), то получим результат:

Код доступен на RPG-18/jgv и может отличаться от кода в статье.

Источник: https://habr.com/ru/post/689496/


Интересные статьи

Интересные статьи

Перед началом чтения, хочу предупредить что все описанные ниже определения и проделанные мною исследования несут в себе ознакомительный характер, и являются неполными или...
О том, что такое сервисы облачного гейминга, вряд ли стоит рассказывать — на Хабре о них писали много раз, включая меня. С каждым месяцем эти сервисы становятся все популярнее — части...
Если вы используете велосипед для передвижения по городу, то, скорее всего, у вас есть какие-то вопросы к велоинфраструктуре и ее качеству. Чтобы понять, что велодорожки вашего гор...
Идея Примерно из ниоткуда возникает идея сделать прекрасную складную карту Петербурга, показывающую возраст домов, их архитектурный стиль и на которой будут выделены здания — яркие...
Один из самых острых вопросов при разработке на Битрикс - это миграции базы данных. Какие же способы облегчить эту задачу есть на данный момент?