Красота и мощь Qt Graphics View Framework на примере

    На мой взгляд Qt Graphics Scene FrameWork — мощный инструмент, незаслуженно обделенный вниманием на Хабре. Я попытаюсь исправить ситуацию, посвятив ему цикл статей. И в этой, пилотной, статье покажу как можно программировать с помощью этого замечательного фреймворка на примере более-менее реальной задачи.

    И в качестве такой задачи я выбрал построение графиков. В ней есть все:
    • Различные виды объектов: как текст, так и различные комбинации примитивов
    • Задача композиции: необходимо расположить заголовок, подписи к осям, ну и саму координатную область с графиками
    • Задачи перевода из одних координат в другую: из системы отсчета, связанных с данными, в отображаемую
    • Взаимодействие между элементами: как минимум при добавлении, удалении и изменении графика обновлять легенду.

    Сразу оговорюсь, что ниже предоставленный код демонстрирует только основные используемые фишки. Полную версию, если кому любопытно может взять здесь.
    Первое удобство фреймворка открывается уже на этапе проектирования. Итак, план работ, который над подсказывает архитектура нашего инструмента:
    1. Создадим сцену, на которой будем рисовать графики: скомпонуем подписи к осям и координатную область.
    2. Создадим координатную сетку (И здесь решим, как будем поступать с графиками).
    3. Создадим Item для графика.
    4. Создадим легенду.

    Первый этап. Создание композиции.

    Скрытый текст
    class GraphicsPlotNocksTube : public QGraphicsItem
    {
    public:
        GraphicsPlotNocksTube(QGraphicsItem *parent): QGraphicsItem(parent){}
        void updateNocks(const QList<QGraphicsSimpleTextItem*>& nocks);
        QRectF boundingRect()const {return m_boundRect;}
        void paint(QPainter *, const QStyleOptionGraphicsItem *, QWidget *){}
        inline const QFont &font(){return m_NocksFont;}
    private:
        QList<QGraphicsSimpleTextItem*> m_nocks;
        QFont m_NocksFont;
        QPen m_nockPen;
        QRectF m_boundRect;
    };
    
    class Graphics2DPlotGrid: public QGraphicsItem
    {
    public:
        Graphics2DPlotGrid(QGraphicsItem * parent);
        QRectF boundingRect() const;
        const QRectF & rect() const;
        void setRange(int axisNumber, double min, double max);
    
        void setMainGrid(int axisNumber, double zero, double step);
        void setSecondaryGrid(int axisNumber, double zero, double step);
        void setMainGridPen(const QPen & pen);
        void setSecondaryGridPen(const QPen &pen);
        inline QPen mainGridPen(){return m_mainPen;}
        inline QPen secondaryGridPen(){return m_secondaryPen;}
    
        void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget);
    public:
        struct AxisGuideLines {
            AxisGuideLines(): showLines(true){}
            QVector<QLineF> lines;
            bool showLines;
        };
        AxisGuideLines abscissMainLines;
        AxisGuideLines abscissSecondaryLines;
        AxisGuideLines ordinateMainLines;
        AxisGuideLines ordinateSecondaryLines;
    private:
    
        void paintAxeGuidLines(const AxisGuideLines& axe, QPainter *painter, const QPen &linePen);
    
        QPen m_mainPen;
        QPen m_secondaryPen;
    
        QRectF m_rect;
    };
    void Graphics2DPlotGrid::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget)
    {
        Q_UNUSED(option)
        Q_UNUSED(widget)
        paintAxeGuidLines(abscissSecondaryLines, painter, m_secondaryPen);
            paintAxeGuidLines(abscissMainLines, painter, m_mainPen);
            paintAxeGuidLines(ordinateSecondaryLines, painter, m_secondaryPen);
            paintAxeGuidLines(ordinateMainLines, painter, m_mainPen);
        painter->setPen(m_mainPen);
        painter->drawRect(m_rect);
    }
    
    class GraphicsPlotItemPrivate
    {
        Q_DECLARE_PUBLIC(GraphicsPlotItem)
        GraphicsPlotItem* q_ptr;
    
        GraphicsPlotItemPrivate(GraphicsPlotItem* parent);
        void compose();
        void calculateAndSetTransForm();
        void autoSetRange();
        void autoSetGrid();
        void calculateOrdinateGrid();
        void calculateAbscissGrid();
        void setAxisRange(int axisNumber, double min, double max);
    
        Graphics2DPlotGrid * gridItem;
        QGraphicsSimpleTextItem * abscissText;
        QGraphicsSimpleTextItem * ordinateText;
        QGraphicsSimpleTextItem *titleText;
        QFont titleFont;
        QFont ordinaateFont;
        QFont abscissFont;
    
        QRectF rect;
        QRectF m_sceneDataRect;
        GraphicsPlotLegend *m_legend;
        GraphicsPlotNocksTube* ordinateMainNocks;
        GraphicsPlotNocksTube* ordinateSecondaryNocks;
        GraphicsPlotNocksTube* abscissSecondaryNocks;
        GraphicsPlotNocksTube* abscissMainNocks;
    
        struct Range{
            double min;
            double max;
        };
        struct AxisGuideLines {
            AxisGuideLines():baseValue(0.0), step(0.0){}
            double baseValue;
            double step;
        };
        AxisGuideLines abscissMainLines;
        AxisGuideLines abscissSecondaryLines;
        AxisGuideLines ordinateMainLines;
        AxisGuideLines ordinateSecondaryLines;
    
        Range abscissRange;
        Range ordinateRange;
        bool isAutoGrid;
        bool isAutoSecondaryGrid;
    
    public:
        void range(int axisNumber, double *min, double *max);
    };
    



    Компонуем:

    void GraphicsPlotItemPrivate::compose()
    {
        titleText->setFont(titleFont);
            abscissText->setFont(abscissFont);
        if(titleText->boundingRect().width() > rect.width()){
            //TODO case when titleText too long
        }
    
        //Composite by height
        qreal dataHeight = rect.height() - 2*titleText->boundingRect().height() - 2*(abscissText->boundingRect().height());
        if(dataHeight < 0.5*rect.height()){
            //TODO decrease font size
        }
    
        titleText->setPos((rect.width()-titleText->boundingRect().width())/2.0, rect.y());
    
        //Compose by width
        qreal dataWidth = rect.width()-2*ordinateText->boundingRect().height();
        if(dataWidth< 0.5*rect.width()){
            //TODO decrease font size
        }
        ordinateMainNocks->setPos(-ordinateMainNocks->boundingRect().width(), -5*ordinateMainNocks->font().pointSizeF()/4.0);
    
        m_sceneDataRect.setRect(rect.width()-dataWidth, 2*titleText->boundingRect().height() , dataWidth, dataHeight);
    
        abscissText->setPos( (dataWidth - abscissText->boundingRect().width())/2.0 + m_sceneDataRect.y(), rect.bottom() - abscissText->boundingRect().height());
            ordinateText->setPos(0, (dataHeight - ordinateText->boundingRect().width())/2.0 + m_sceneDataRect.y());
        calculateAndSetTransForm();
        q_ptr->update()
    }
    


    Создание координатной сетки


    Теперь приступим к рисовании сетки. Надо заметить, что первоначально представлялось, что метки надо рисовать вместе с координатными линиями. Однако такой подход идет вразрез с декларативной идеологией фреймворка: описать как в простейшем случае должен выглядеть item, а затем рассказать сцене как она должна с ним поступать, и на выходе получить идеальную картинку в любых условиях. И в итоге верстка засечек была перенесена в compose.

    А сейчас пока обойдемся без них и нарисуем просто координатную сетку. Наша основная идея: gridItem рисовать в той же шкале, что и данные графиков, а переводом в отображаемые координаты пусть занимается Qt. Если теперь график сделать потомком gridItem, то мы имеем готовое решение:
    • Нам достаточно рисовать линии графика в шкале данных. Они сами отобразятся в нужную область, а если добавить
      gridItem->setFlag(QGraphicsItem::ItemClipsChildrenToShape)
      то решается проблема кадрирования графика
    • Все события сцены (такие как события клавиатуры или события мыши автоматически будут переводится в шкалу данных, что упрощает их обработку.


    Реализация:
    void Graphics2DPlotGrid::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget)
    {
        Q_UNUSED(option)
        Q_UNUSED(widget)
        paintAxeGuidLines(abscissSecondaryLines, painter, m_secondaryPen);
            paintAxeGuidLines(abscissMainLines, painter, m_mainPen);
            paintAxeGuidLines(ordinateSecondaryLines, painter, m_secondaryPen);
            paintAxeGuidLines(ordinateMainLines, painter, m_mainPen);
        painter->setPen(m_mainPen);
        painter->drawRect(m_rect);
    }
    
    void Graphics2DPlotGrid::paintAxeGuidLines(const AxisGuideLines& axe, QPainter *painter, const QPen &linePen)
    {
        if(axe.showLines){
            painter->setPen(linePen);
            painter->drawLines(axe.lines);
        }
    }
    void GraphicsPlotItemPrivate::calculateAndSetTransForm()
    {
        double  scaleX = m_sceneDataRect.width()/gridItem->rect().width();
            double scaleY = m_sceneDataRect.height()/gridItem->rect().height();
        QTransform transform = QTransform::fromTranslate( - gridItem->rect().x()*scaleX + m_sceneDataRect.x(), - gridItem->rect().y()*scaleY +m_sceneDataRect.y());
            transform.scale(scaleX, -scaleY);
        gridItem->setTransform(transform);
        ordinateMainNocks->setTransform(transform);
    //        ordinateSecondaryNocks->setTransform(transform);
        abscissMainNocks->setTransform(transform);
    //    abscissSecondaryNocks->setTransform(transform);
    }
    


    рассчитываем сетку
    void GraphicsPlotItemPrivate::calculateOrdinateGrid()
    {
        const QRectF  r = gridItem->boundingRect();
        if(fabs(r.width()) < std::numeric_limits<float>::min()*5.0 || fabs(r.height()) < std::numeric_limits<float>::min()*5.0)
            return;
        QList<QGraphicsSimpleTextItem*> nocksList;
    
        auto calculteLine = [&] (AxisGuideLines* guides, QVector<QLineF> *lines)
        {
            int k;
            double minValue;
            int count;
    
            nocksList.clear();
            if(fabs(guides->step) > std::numeric_limits<double>::min()*5.0 )
            {
                k = (ordinateRange.min - guides->baseValue)/guides->step;
                minValue = k*guides->step+guides->baseValue;
                count = (ordinateRange.max - minValue)/guides->step;
    
                //TODO додумать что делать, если направляющая всего одна
                if( count >0){
                    lines->resize(count);
                    nocksList.reserve(count);
                    double guidCoordinate;
                    for(int i = 0; i< count; i++){
                        guidCoordinate = minValue+i*guides->step;
                        lines->operator[](i) = QLineF(abscissRange.max, guidCoordinate, abscissRange.min, guidCoordinate);
                        nocksList.append(new QGraphicsSimpleTextItem(QString::number(guidCoordinate)));
                        nocksList.last()->setPos(abscissRange.min, guidCoordinate);
                    }
                }
                else
                    lines->clear();
            }
            else
                lines->clear();
        };
        calculteLine(&ordinateMainLines, &(gridItem->ordinateMainLines.lines));
        ordinateMainNocks->updateNocks(nocksList);
            calculteLine(&ordinateSecondaryLines, &(gridItem->ordinateSecondaryLines.lines));
            ordinateSecondaryNocks->updateNocks(nocksList);
    }
    



    Тут есть один тонкий момент: при увеличении с помощью QTransform нашего gridItem размер кисти тоже растет, чтоб этого не происходило необходимо задать QPen как cosmetic:
        m_secondaryPen.setCosmetic(true);
        m_mainPen.setCosmetic(true);
    


    Item графика

    Объявление класса
    class GraphicsDataItem: public QGraphicsObject
    {
        Q_OBJECT
    public:
        GraphicsDataItem(QGraphicsItem *parent =0);
        ~GraphicsDataItem();
    
        void setPen(const QPen& pen);
        QPen pen();
    
        void setBrush(const QBrush & brush);
        QBrush brush();
    
        void ordinateRange(double *min, double *max);
        void abscissRange(double *min, double *max);
    
        void setTitle(const QString & title);
        QString title();
    
        inline int type() const {return GraphicsPlot::DataType;}
    Q_SIGNALS:
        void dataItemChange();
        void penItemChange();
        void titleChange();
    protected:
        void setOrdinateRange(double min, double max);
        void setAbscissRange(double min, double max);
    private:
        Q_DECLARE_PRIVATE(GraphicsDataItem)
        GraphicsDataItemPrivate *d_ptr;
    };
    
    class Graphics2DGraphItem: public GraphicsDataItem
    {
        Q_OBJECT
    public:
        Graphics2DGraphItem(QGraphicsItem *parent =0);
        Graphics2DGraphItem(double *absciss, double *ordinate, int length, QGraphicsItem *parent =0);
        ~Graphics2DGraphItem();
    
        void setData(double *absciss, double *ordinate, int length);
        void setData(QList<double> absciss, QList<double> ordinate);
        void setData(QVector<double> absciss, QVector<double> ordinate);
    
        QRectF boundingRect() const;
        void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget);
    private:
        Q_DECLARE_PRIVATE(Graphics2DGraphItem)
        Graphics2DGraphItemPrivate *d_ptr;
    };
    


    Реализация графика чрезвычайно простая и большую часть кода занимает выяснение границ boundRect.
    class Graphics2DGraphItemPrivate
    {
        Q_DECLARE_PUBLIC(Graphics2DGraphItem)
        Graphics2DGraphItem *q_ptr;
        Graphics2DGraphItemPrivate(Graphics2DGraphItem *parent):q_ptr(parent){}
        QVector<QLineF> m_lines;
        template<typename T> void setData(T absciss, T ordinate, qint32 length)
        {
            q_ptr->prepareGeometryChange();
            --length;
            m_lines.resize(length);
    
            Range ordinateRange;
            ordinateRange.min = ordinate[0];
                ordinateRange.max = ordinate[0];
            Range abscissRange;
            abscissRange.min = absciss[0];
                abscissRange.max = absciss[0];
            for(int i =0; i < length; ++i)
            {
                if(ordinate[i+1] > ordinateRange.max)
                    ordinateRange.max = ordinate[i+1];
                else if(ordinate[i+1] < ordinateRange.min )
                    ordinateRange.min = ordinate[i+1];
                if(absciss[i+1] > abscissRange.max)
                    abscissRange.max = absciss[i+1];
                else if(absciss[i+1] < abscissRange.min )
                    abscissRange.min = absciss[i+1];
                m_lines[i].setLine(absciss[i], ordinate[i], absciss[i+1], ordinate[i+1]);
            }
            m_boundRect.setRect(abscissRange.min, ordinateRange.min, abscissRange.max - abscissRange.min, ordinateRange.max - abscissRange.min);
            q_ptr->setOrdinateRange(ordinateRange.min, ordinateRange.max);
                q_ptr->setAbscissRange(abscissRange.min, abscissRange.max);
            q_ptr->update();
            QMetaObject::invokeMethod(q_ptr, "dataItemChange");
        }
    
        QRect m_boundRect;
    };
    
    QRectF Graphics2DGraphItem::boundingRect() const
    {
        return d_ptr->m_boundRect;
    }
    
    void Graphics2DGraphItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget)
    {
        Q_UNUSED(option)
        Q_UNUSED(widget)
        painter->setBrush(brush());
        painter->setPen(pen());
        painter->drawLines(d_ptr->m_lines);
    }
    


    Легенда и взаимодействие классов


    На самом деле у нас уже есть решение, оно очевидно, если обратить внимание, что GraphicsDataItem отнаследован от QGraphicsObject и что в теле класса уже объявлены сигналы. Т.е. взаимодействие между объектами сцены происходит привычным образом — через сигналы и слоты.

    Субъективный итог

    А что у нас в субъективном итоге?
    1. Беспроблемность разработки архитектуры, если следовать логике фреймворка.
    2. Все элементы написаны в декларативном стиле и основной код относится к сути и в значительной меньшей мере к пляскам вокруг.
    3. Простата создания item-ов для отображения данных


    Сравнение и калибровка по qwt.



    Чтоб оценить реальное удобство и откалибровать наше субъективное ощущение давайте проведем сравнение объема работ, проделанного нами и тем, что сделали уважаемые разработчики qwt:
    • Первое, что бросается в глаза — огромные листинги в drawItem. Наши на порядок короче. Нам не надо заботиться об оптимизации отрисовки каждого члена. Мы делаем один раз и в одном месте — когда задаем viewport..
    • Куча трудов вложено в QwtLegendData, QwtLegendLabel, QwtPainter, QwtPainterCommand, QwtPlotDirectPainter и т.д. Мы всего это не делали и не ясны причины зачем в нашей ситуации все это реализовывать.
    • Нам не надо писать свои классы трансформации и пересчета координат из одной системы в другую, и нам не надо вручную производить трансформацию координат.
    • Мы добились гораздо большей абстрагированности iem-ов с данными.
    • Наша иерархия классов на порядок проще. И при дальнейшем расширении не видно причин, почему она должна стать сложнее.


    Ссылки


    Документация, развернутая с примерами.
    Видео, если нельзя скачать с офф сайта, то спокойно находятся на youtube
    Проект.

    P.S. Проект демонстрационный, но если будут найдены баги, или кто поможет с улучшением — буду только рад.
    P.P.S На всякий случай: текст опубликован под лицензией CC-BY 3.0

    UPD итог полустараний:
    Метки:
    Поделиться публикацией
    Комментарии 18
    • +13
      Стоять про графики и без графика :(
      • 0
        Когда я думал: «А не добавить ли мне график?». Аргументом против было то, что он ничего из написанного не будет иллюстрировать. Статья о процессе разработки, а не о самих графиках. Да и конечный результат далек от результатов qwt или MathGl, не говоря о gnuplot или Matlab-а, и откровенно сырой, чтоб им было можно хвастаться :)
      • +4
        Ну и где она, красота и мощь-то?

        Ни одного примера нет, проект по ссылке просто так не собирается.
        • 0
          В чем траблы со сборкой?
          Она должна заработать с Qt5 и gcc 4.6
          • +5
            1. Судя по путям, теневая сборка работать не будет.
            2. В Windows символы из динамических библиотек не экспортируются по умолчанию.
            3. Не называйте свои динамические библиотке src.dll, ну серьезно!
            4. У вас 12 warning-ов, один из которых — UB (GraphicsDataItem.cpp, line 194)
            • 0
              Варнинги несущественные для статьи. Поправил. .user файл кинул в gitignore, либу переименовал.

              В винде собираем VisualStudio?
              • 0
                Добавил макрос экспорта, теперь либа должна линковаться в VisualStudioю Если у вас MinGW, то это странно.
                • 0
                  Да, теперь все хорошо.

                  Несущественные ворнинги плохи тем, что когда их становится много, можно пропустить и существенные ворнинги.

                  А что странного с MinGW?
                  • 0
                    Да, в боевых проектах я так и делаю. Да и перед глазами пример Krusader, где из 200 варнингов, которые выкинул компилятор с пяток были серьезны.

                    MinGW как и gcc (что ожидаемо) не требует дополнительных атрибутов при экспорте dll. И если бы у вас mingw не линковал, то это былоб для меня новым открытием, с которым надо былобы разбираться. :)
                    • +1
                      У меня и есть mingw.

                      Будут ли символы экспортироваться, как я понимаю, от платформы зависит, а не от компиллятора. MinGw действует точно так же, как и MSVC.
                      • 0
                        О как. Похоже что-то поменялось. В свое время у меня Mingw 4.4 не требовал и все прекрасно линковал
                        • 0
                          Там вот какая интересная штука:

                          man ld

                          --export-all-symbols
                          If given, all global symbols in the objects used to build a DLL
                          will be exported by the DLL. Note that this is the default if
                          there otherwise wouldn't be any exported symbols.
                          When symbols are
                          explicitly exported via DEF files or implicitly exported via func-
                          tion attributes, the default is to not export anything else unless
                          this option is given. Note that the symbols «DllMain@12», «DllEn-
                          tryPoint@0», «DllMainCRTStartup@12», and «impure_ptr» will not be
                          automatically exported. Also, symbols imported from other DLLs
                          will not be re-exported, nor will symbols specifying the DLL's
                          internal layout such as those beginning with "_head_" or ending
                          with "_iname". In addition, no symbols from «libgcc», «libstd++»,
                          «libmingw32», or «crtX.o» will be exported. Symbols whose names
                          begin with "__rtti_" or "__builtin_" will not be exported, to help
                          with C++ DLLs. Finally, there is an extensive list of cygwin-pri-
                          vate symbols that are not exported (obviously, this applies on when
                          building DLLs for cygwin targets). These cygwin-excludes are:
                          "_cygwin_dll_entry@12", "_cygwin_crt0_common@8", "_cygwin_noncyg-
                          win_dll_entry@12", "_fmode", "_impure_ptr", «cygwin_attach_dll»,
                          «cygwin_premain0», «cygwin_premain1», «cygwin_premain2», «cyg-
                          win_premain3», and «environ». [This option is specific to the i386
                          PE targeted port of the linker]
                          Возможно, Qt что-то экспортирует? Ну или man устарел.

                          Кстати, попробовал собрать студией — тоже не собирается. Сейчас попробую разобраться.
                          • +1
                            У вас нет реализации конструкторов Graphics2DHistogramItem

                            И не пишите QMAKE_CXXFLAGS += -std=c++0x, MSVC ругается на это. Пишите CONFIG+=c++11
                            • +1
                              Блин, в кои-то веки решил полениться и тут такой облом. Сейчас должен заткнуться. (По-хорошему надо взять и реализовать)

                              Спасибо за крассплатформенный C++11.
        • 0
          На счет красоты прийдется поверить на слово? :)
          • 0
            Картинку я добавил, но слово «красота» надо понимать в переносном смысле.
          • 0
            Это мы убили qt-project.org?
            • 0
              Хммм, у меня проблем не наблюдается.

            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.