vendredi 6 janvier 2012

Géolocalisation en QML/C++ avec points d'intérêts personnalisés

Bonjour,
Dans ce nouvel article, je vais vous exposer un certain nombre de fonctionnalités en Qt/QML autour de la géolocalisation sur Symbian^3 / Anna / Belle :
  1. Afficher une carte ovi centrée sur un lieu donné
  2. Ajouter des points d'intérêts
  3. Interagir avec les points d'intérêts
  4. Récupérer les points d'intérêts depuis un flux XML
  5. Gestion du panning utilisateur
Mon environnement utilisé est le SDK Qt 1.1.4 (Qt 4.7.4 / Qt Mobility 1.2.1) sous Windows 7 et l'application développée a été validée sur Nokia E7 upgradé en Anna.

Le but de l'application est d'afficher sur une carte les stations de vélos sur Paris, avec pour chaque station, le nom et le numéro identifiant. Cet article s'inspire directement d'un wiki disponible sur le site de développeurs nokia. Les données sur les stations sont accessibles via un flux XML public disponible ici : comme vous pouvez le constater, il contient une liste de marqueurs :

<marker name="00901 - STATION MOBILE 1" number="901" address="ALLEE DU BELVEDERE PARIS 19 - 0 75000 Paris -" fullAddress="ALLEE DU BELVEDERE PARIS 19 - 0 75000 Paris - 75000 PARIS" lat="48.892745582406675" lng="2.391255159886939" open="1" bonus="0"/>

Dans notre exemple nous nous intéresserons aux champs suivants :
  • name : nom de la station
  • lat : latitude de la station
  • lng : longitude de la station
Je tiens à rappeler que cet article est à usage uniquement pédagogique pour comprendre le fonctionnement des apis Qt/QML de géolocalisation ; l'application peut donc être à tout moment impactée en cas de maintenance de ce service.

Voici une capture représentant le rendu final :



Le picto "Nokia" représente les bureaux de Nokia France et le rectangle gris affiche le nom et le numéro de la station qui a été cliquée par l'utilisateur.

Pour afficher une carte en QML, il est tentant d'utiliser l'élément QML Map : malheureusement cet élément est très limité notamment dans les versions 1.x de Qt Mobility. Or Qt Mobility 1.2 n'étant pas encore déployée sur les terminaux Symbian Anna, je vous propose ici une méthode compatible avec toutes les versions : l'astuce consiste à définir un élément QML Map en C++. Ce nouvel élément personnalisé sera exposé depuis le C++ vers QML via l'appel classique suivant :

qmlRegisterType<MapWidget>("mywidgets", 1, 0, "MyCustomMap");

La classe C++ implémentant l'objet carte personnalisé se nomme MapWidget : nous aurons l'occasion de la détailler largement par la suite. Depuis un fichier QML, il faudra alors importer de manière classique la version 1.0 du module "mywidgets" :

import mywidgets 1.0

Et dans les faits, l'instantiation de notre carte personnalisée se fera classiquement ainsi :

MyCustomMap {
     id: map
     anchors.fill: parent
     centerLatitude: 48.9018112
     centerLongitude: 2.3748493
     zoomLevel:15
          ...
}
 
Rentrons maintenant dans le vif du sujet ...

Partie C++

J'ai défini une classe dédiée à la gestion de la carte dénommée MapWidget qui dérive directement de la classe QGraphicsGeoMap disponible dans Qt Mobility. Et voici le fichier header :

class MapWidget : public QGraphicsGeoMap
{
    Q_OBJECT

    Q_PROPERTY(int zoomLevel WRITE setCustomZoomLevel)
    Q_PROPERTY(double centerLatitude READ centerLatitude WRITE setCenterLatitude)
    Q_PROPERTY(double centerLongitude READ centerLongitude WRITE setCenterLongitude)
    Q_PROPERTY(bool isQtm12 READ isQtm12)


public:
    MapWidget();
    ~MapWidget();

    void setCustomZoomLevel(int aLevel);
    double centerLatitude() const;
    void setCenterLatitude(double lat);
    double centerLongitude() const;
    void setCenterLongitude(double lon);
    bool isQtm12() const;

    Q_INVOKABLE void addNokiaPoi();
    Q_INVOKABLE void addPoi(double lat, double lon, int uuid);
    Q_INVOKABLE void addPoiGroup();


signals:
    void poiClicked(QString uuid, double posX, double posY);
    void poiHidden();


protected:
    void mousePressEvent(QGraphicsSceneMouseEvent* event);
    void mouseReleaseEvent(QGraphicsSceneMouseEvent* event);
    void mouseMoveEvent(QGraphicsSceneMouseEvent* event);


private:
    static QGeoMappingManager* createManager();
    QPointF lastPos;

    QList<QGeoMapPixmapObject*> myObjectsList;
    QGeoMapGroupObject *poiGroup;
    bool m_isQtm12;
};

Cette classe exposera 4 propriétés au monde QML :
  • zoomLevel : le niveau de zoom : c'est une valeur décimale (qreal) dont le range dépend de la plateforme. Sur Symbian il va de 0.0 à 20.0. Il existe des bornes définies sous la forme de constante (minimumZoomLevel et maximumZoomLevel)   
  • centerLatitude : la latitude du centre de la carte
  • centerLongitude : la longitude du centre de la carte
  • isQtm12 : booléen indiquant si Qt Mobility 1.2.x est installée sur le smartphone (nécessaire pour contourner une régression présente depuis la version 1.2)
La classe s'accompagne donc des méthodes de lecture/écriture associées à ces propriétés :

// Indique si la version courante de Qt Mobility est 1.2.x
bool MapWidget::isQtm12() const
{
    return m_isQtm12;
}

// Méthode publique permettant d'ajuster le zoom depuis QML
void MapWidget::setCustomZoomLevel(int aLevel)
{
    setZoomLevel(aLevel);
}

// Méthode publique pour récupérer la latitude du point surlequel est centrée la carte
double MapWidget::centerLatitude() const
{
    return center().latitude();
}

// Méthode publique pour récupérer la longitude du point surlequel est centrée la carte
double MapWidget::centerLongitude() const
{
    return center().longitude();
}

// Méthode publique pour modifier la latitude du point surlequel est centrée la carte
void MapWidget::setCenterLatitude(double lat)
{
    QGeoCoordinate c = center();
    c.setLatitude(lat);
    setCenter(c);
}

// Méthode publique pour modifier la longitude du point surlequel est centrée la carte
void MapWidget::setCenterLongitude(double lon)
{
    QGeoCoordinate c = center();
    c.setLongitude(lon);
    setCenter(c);
}

Le constructeur du widget, outre la détection de version de Qt Mobility, fait appel au constructeur parent QGraphicsGeoMap en lui passant une instance QGeoMappingManager crée par une méthode privée de type singleton "createManager" :

MapWidget::MapWidget() :
    QGraphicsGeoMap(createManager())
{
    m_isQtm12 = detectQTMVersion();

    setCenter(QGeoCoordinate(48.89, 2.37));
    setZoomLevel(7);

    if(m_isQtm12) poiGroup = new QGeoMapGroupObject();
}

La méthode createManager instancie le plugin de géolocalisation "nokia" et renvoie l'instance de QGeoMappingManager associée à l'instance du plugin.

QGeoMappingManager* MapWidget::createManager()
{
    /*QMap<QString, QVariant> parameters;
    parameters["app_id"] = "APPID";
    parameters["token"] = "TOKEN";*/


    // Utilisation du plugin de localisation nokia
    QGeoServiceProvider* serviceProvider = new QGeoServiceProvider("nokia" /*, parameters*/);


    // Récupération du gestionnaire graphique de carte pour gérer les intéractions
    QGeoMappingManager* mappingManager = serviceProvider->mappingManager();


    return mappingManager;
}

Note : depuis peu, il est nécessaire de s'enregistrer pour obtenir un jeton (token) afin d'utiliser ce plugin de géolocalisation

Ensuite elle déclare trois méthodes invocables depuis QML :
  •  addNokiaPoi : pour ajouter sur la carte le point d'intérêt correspondant aux bureaux Nokia France (picto Nokia sur le screenshot)
void MapWidget::addNokiaPoi()
{
    QGeoCoordinate coord(48.9018112, 2.3748493);
    QPixmap pixmap = QPixmap(":qml/CustomQmlMap/nokia.png");
    QGeoMapPixmapObject *pixMapObject = new QGeoMapPixmapObject(coord, QPoint(-16,-20), pixmap);
    addMapObject(pixMapObject);
}
  • addPoi : pour ajouter une station de vélo sur la carte. Une station sera vue comme une triplette : longitude / latitude / identifiant. A noter que ce nouveau POI sera mémorisé dans une liste (myObjectsList) en plus d'être ajouté à la carte (ou au groupe de POIs en version QTM 1.2)
void MapWidget::addPoi(double lat, double lon, int uuid)
{
    // Création du point d'intérêt
    QGeoCoordinate coord(lat, lon);
    QPixmap pixmap = QPixmap(":qml/CustomQmlMap/poi.png");
    QGeoMapPixmapObject *pixMapObject = new QGeoMapPixmapObject(coord, QPoint(-16,-20), pixmap);
    pixMapObject->setObjectName("poiMarker");

    QVariant v(uuid);
    pixMapObject->setProperty("uuid", v);

    myObjectsList.append(pixMapObject);

    if(m_isQtm12)
    {
        // Utilisation d'un groupe de POIs uniquement pour une version 1.2.x de Qtmobility (bug 1664)
        // https://bugreports.qt.nokia.com//browse/QTMOBILITY-1664
        poiGroup->addChildObject(pixMapObject);
    }
    else
        addMapObject(pixMapObject);
}

Note : les ressources bitmaps utilisées sont référencées dans un fichier de ressource "images.qrc" dont le chemin relatif est "qml/QmlCustomMap"
  • addPoiGroup : pour ajouter un groupement de points d'intérêts sur la carte. Cette méthode est uniquement utilisée pour contourner un bug QTM 1.2
void MapWidget::addPoiGroup()
{
    // Utilisation d'un groupe de POIs uniquement pour une version 1.2.x de Qtmobility (bug 1664)
    // https://bugreports.qt.nokia.com//browse/QTMOBILITY-1664
    if(poiGroup && m_isQtm12) addMapObject(poiGroup);
}

Cette classe dispose par ailleurs de deux signaux récupérables au niveau QML :
  • poiClicked : indiquant que l'utilisateur a cliquer un point d'intérêt particulier. Côté QML cela permettra de dessiner un rectangle gris contenant le nom de la station
  • poiHidden : déclenché lorsque l'utilisateur se déplace dans la carte. Côté QML cela permettra de masquer tout rectangle gris précédemment affiché.
Ensuite j'ai surchargé trois gestionnaires d'événements (mousePressEvent, mouseReleaseEvent et mouseMoveEvent) afin de gérer les interactions utilisateurs avec la carte (principalement le clic sur POI et le pan) :

  • mousePressEvent : appelé lorsque l'utilisateur touche l'écran ; j'y mémorise la position (X, Y) dans une propriété de la classe ; cette propriété sera utilisée  dans le gestionnaire mouseReleaseEvent afin de déterminer quel POI a été cliqué.
 // Mémorisation de la position (cette valeur sera ensuite utilisée dans la méthode mouseReleaseEvent)
void MapWidget::mousePressEvent(QGraphicsSceneMouseEvent* event)
{
    lastPos = event->pos();
}
  • mouseReleaseEvent : appelé lorsque l'utilisateur retire son doigt de l'écran ; le but de cette méthode est de récupérer les informations du POI que l'utilisateur a ciblé afin de les remonter au niveau QML via l'émission du signal poiClicked :
void MapWidget::mouseReleaseEvent(QGraphicsSceneMouseEvent* event)
{
    QPointF releasePos = event->pos();
    QPointF diff = lastPos - releasePos;

    if (qAbs(diff.x()) > 30 || qAbs(diff.y()) > 30) {
        return;
    }

    int selectedIndex = -1;
    double minDiffX = -1;
    double minDiffY = -1;


    if(myObjectsList.length()>0)
    {
        // Nous parcourrons la liste des POIs
        for(int i=0;i<myObjectsList.length();i++)
        {
            QGeoMapPixmapObject* obj = myObjectsList[i];

            QPointF currentPoiPos = coordinateToScreenPosition(obj->coordinate());
            QPointF invalid(0,0);


            if(currentPoiPos != invalid)
            {
                diff = lastPos - currentPoiPos;

                double currentDiffX = qAbs(diff.x());
                double currentDiffY = qAbs(diff.y());

                // Nous mémorisons l'index du POI le plus proche du doigt de l'utilisateur
                if(selectedIndex == -1)
                {
                    selectedIndex = i;
                    minDiffX = currentDiffX;
                    minDiffY = currentDiffY;
                }
                else
                {
                    if(minDiffX>=currentDiffX && minDiffY>=currentDiffY)
                    {
                        selectedIndex = i;
                        minDiffX = currentDiffX;
                        minDiffY = currentDiffY;
                    }
                }
            }
        }

        if(selectedIndex != -1)
        {
            // Nous récupérons les propriétés du marker correspondant (uuid, position X, position Y) et émettons un signal
            QGeoMapPixmapObject* obj = myObjectsList[selectedIndex];
            if (obj->objectName() == "poiMarker")
            {
                QString uuid = obj->property("uuid").toString();
                qDebug() << "Emit poiClicked signal with id : " << uuid;
                emit poiClicked(uuid, releasePos.x(), releasePos.y());
            }
        }

    }
}

  • mouseMouveEvent : appelé lorsque l'utilisateur glisse son doigt sur l'écran ; cette méthode a deux objectifs : déplacer visuellement la carte via l'appel à QGraphicsGeoMap::pan  et émettre le signal poiHidden afin de masquer le détail du POI courant qui pourrait potentiellement être affiché dans un rectangle gris en overlay côté QML.
void MapWidget::mouseMoveEvent(QGraphicsSceneMouseEvent* event)
{
    QPoint lastPos = event->lastPos().toPoint();
    QPoint pos = event->pos().toPoint();

    int dx = lastPos.x() - pos.x();
    int dy = lastPos.y() - pos.y();

    pan(dx, dy);

    emit poiHidden();
}

Enfin j'ai défini quelques propriétés privées déjà plus au moins décrites plus haut :
  • lastPos : position (X, Y) correspondant à l'endroit où l'utilisateur a toucher l'écran. Cette variable est positionné dans le gestionnaire mousePressEvent
  • myObjectsList : contient la liste des POIs (objet de type QGeoMapPixmapObject) ajoutés sur la carte
  • poiGroup : groupement de POIs (objet de type QGeoMapGroupObject) contenant tous les POIs. Utilisé avec QTM 1.2 seulement. 
Note : pour QTM 1.2 nous ajoutons tous les POIs dans un groupe afin d'ajouter ce groupe à la carte en un seul appel (addPoiGroup) ce qui déclenche un seul "redraw" de la carte. Depuis QTM 1.2, le fait d'ajouter un POI directement dans la carte déclenche un "redraw"  complet de la carte (et non de la zone du POI) : comme il y a 1200 POIs, les performances deviennent catastrophiques d'où l'ajout dans un groupe (ce qui ne déclenche pas de "redraw") qui n'est ajouté à la carte qu'une fois.

Partie QML

Maintenant que vous avez une bonne idée de l'implémentation de notre widget de cartographie personnalisé, voyons comment l'utiliser depuis QML. Ayant crée le projet avec le wizard "Application Qt Quick", nous nous retrouvons avec deux fichiers QML : main.qml et MainPage.qml. N'ayant pas modifier le fichier "main.qml" je ne décrirai que le second fichier MainPage.qml.

Commençons par les règles d'importation :

import QtQuick 1.1
import QtMobility.location 1.2
import com.nokia.symbian 1.1    // Symbian components
import mywidgets 1.0


Pensez à référencer le module mywidgets afin d'utiliser notre élément personnalisé QmlCustomMap.
La page principale contient tout d'abord une partie pour gérer l'indicateur de chargement :

Page {
    id: rootWindow
    width: 360
    height: 640

    // Loading screen
    Rectangle {
        id:loadingScreen
        anchors.fill: parent
        opacity: 0.7
        color: "white"
        visible: true

        property alias loadingTxt: bgText.text

        state: "showLoadingScreen"
        states: [
            State {
                name: "showLoadingScreen"
                PropertyChanges {
                    target: loadingScreen
                    visible:true
                    z:1
                }
            },
            State {
                name: "hideLoadingScreen"
                PropertyChanges {
                    target: loadingScreen
                    visible:false
                    z:0
                }
            }
        ]

        Text{
            id: bgText
            anchors.centerIn: parent
            color: "black"
            text: "Chargement de la carte"
        }

        BusyIndicator {
            id: indicator
            width:40
            height:40
            anchors.verticalCenter: parent.verticalCenter
            anchors.right: bgText.left
            anchors.rightMargin: 20
            running: true
        }
    }


Plus important : la création du model de données récupérés initialisée avec un flux XML :
   
XmlListModel {
    id: stations
    source: http://www.velib.paris.fr/service/carto
    query: "/carto/markers/marker"
    XmlRole {name: "number"; query: "@number/string()"}
    XmlRole {name: "name"; query: "@name/string()"}
    XmlRole {name: "lat"; query: "@lat/string()"}
    XmlRole {name: "lng"; query: "@lng/string()"}

    onStatusChanged: {
        if(status == XmlListModel.Ready) {
            console.log("Model Status: ready")
            var i = 0
            for(i=0;i<stations.count;i++)
            {
                map.addPoi(stations.get(i).lat, stations.get(i).lng, parseInt(stations.get(i).number));
            }
            console.log("No more POIs to add")


            // Utilisation d'un groupe de POIs uniquement pour une version 1.2.x de Qtmobility (bug 1664)
            // https://bugreports.qt.nokia.com//browse/QTMOBILITY-1664
            if(map.isQtm12) map.addPoiGroup()


            // Ajout du bureau Nokia à Paris
            map.addNokiaPoi()

                        // On masque la mire de chargement

            loadingScreen.state = "hideLoadingScreen"
            loadingScreen.loadingTxt = ""
       }
       else if(status == XmlListModel.Error) {
            console.log("Model Status: error")
       }
       else if(status == XmlListModel.Loading) {
            console.log("Model Status: loading")
            loadingScreen.loadingTxt = "Chargement des POIs"
       }
    }
}

Le parsing des données XML est un parsing de type XPath (voir documentation pour plus d'info). C'est très simple à utiliser. Dans notre cas je récupère pour chaque "marker" (i.e. POI) les valeurs de balise "number", "name", "lat" et "lng". A réception des données (c'est à dire quand le status passe à l'état "XmlListModel.Ready"), j'ajoute chacun des POIs sur la carte.

Maintenant je vais créer une instance de l'élément personnalisé QmlCustomMap centré sur les bureaux de Nokia France avec un zoom élevé:

MyCustomMap {
    id: map
    anchors.fill: parent
    centerLatitude: 48.9018112
    centerLongitude: 2.3748493
    zoomLevel:15

    onPoiClicked: {
        var i = 0
        for(i=0;i<stations.count;i++)
        {
            if(stations.get(i).number == uuid)
            {
                poiRect.x = posX-85
                poiRect.y = posY-44
                poiRect.poiTxt = stations.get(i).name
                poiRect.visible = true
                break;
            }
        }
    }

    onPoiHidden: {
        poiRect.visible = false
    }

    Rectangle {

        property alias poiTxt: poiTextElement.text

        id: poiRect
        visible:false
        width:170
        height:75
        color: "darkgrey"
        radius:10
        opacity: 0.95

        Text {
            id:poiTextElement
            color:"white"
            font.bold: true
            font.pointSize: 6
            anchors.centerIn: parent
            text:""
            width:parent.width
            wrapMode:Text.WordWrap
        }

        MouseArea {
            anchors.fill: parent
            onClicked: {
                if(poiRect.visible) poiRect.visible = false
            }
        }
   }
}

Le rectangle identifié par "poiRect" est masqué par défaut et affiché uniquement lorsque l'utilisateur clique sur un POI : il consiste en un rectangle gris arrondi contenant l'identifiant et le nom de la station. Vous pouvez tout à fait utiliser une image à la place. Je vais donc jouer sur la propriété "visible" de cet élément : pour cela je mets en place les gestionnaires de signaux onPoiClicked et onPoiHidden.

Et voilà c'est fini : à titre d'exercice je vous invite à afficher plus d'information sur un POI donné en utilisant le second service XML :  http://www.velib.paris.fr/service/stationdetails/ + number

Le code source complet est disponible ici

Aucun commentaire:

Enregistrer un commentaire