web-dev-qa-db-fra.com

D3js: placement automatique des étiquettes pour éviter les chevauchements? (répulsion forcée)

Comment appliquer la répulsion de force sur les étiquettes de la carte afin qu'elles trouvent automatiquement leurs bons emplacements?


Bostock "Faisons une carte"

Mike Bostock's Créons une carte (capture d'écran ci-dessous). Par défaut, les étiquettes sont placées aux coordonnées du point et les polygones/multipolygones path.centroid(d) + un simple alignement à gauche ou à droite, de sorte qu'ils entrent fréquemment en conflit.

enter image description here

Placements d'étiquettes faits à la main

Une amélioration j'ai rencontré nécessite d'ajouter des correctifs IF créés par l'homme et d'en ajouter autant que nécessaire, tels que:

.attr("dy", function(d){ if(d.properties.name==="Berlin") {return ".9em"} })

L'ensemble devient de plus en plus sale à mesure que le nombre d'étiquettes à réajuster augmente:

//places's labels: point objects
svg.selectAll(".place-label")
    .data(topojson.object(de, de.objects.places).geometries)
  .enter().append("text")
    .attr("class", "place-label")
    .attr("transform", function(d) { return "translate(" + projection(d.coordinates) + ")"; })
    .attr("dy", ".35em")
    .text(function(d) { if (d.properties.name!=="Berlin"&&d.properties.name!=="Bremen"){return d.properties.name;} })
    .attr("x", function(d) { return d.coordinates[0] > -1 ? 6 : -6; })
    .style("text-anchor", function(d) { return d.coordinates[0] > -1 ? "start" : "end"; });

//districts's labels: polygons objects.
svg.selectAll(".subunit-label")
    .data(topojson.object(de, de.objects.subunits).geometries)
  .enter().append("text")
    .attr("class", function(d) { return "subunit-label " + d.properties.name; })
    .attr("transform", function(d) { return "translate(" + path.centroid(d) + ")"; })
    .attr("dy", function(d){
    //handmade IF
        if( d.properties.name==="Sachsen"||d.properties.name==="Thüringen"|| d.properties.name==="Sachsen-Anhalt"||d.properties.name==="Rheinland-Pfalz")
            {return ".9em"}
        else if(d.properties.name==="Brandenburg"||d.properties.name==="Hamburg")
            {return "1.5em"}
        else if(d.properties.name==="Berlin"||d.properties.name==="Bremen")
            {return "-1em"}else{return ".35em"}}
    )
    .text(function(d) { return d.properties.name; });

Besoin d'une meilleure solution

Ce n'est tout simplement pas gérable pour les cartes plus grandes et les ensembles d'étiquettes. Comment ajouter des répulsions de force à ces deux classes: .place-label et .subunit-label?

Ce problème est un véritable brainstorming car je n'ai pas de date limite à ce sujet, mais je suis assez curieux à ce sujet. Je pensais à cette question comme une implémentation de base D3js de Migurski/ Dymo.py . La documentation README.md de Dymo.py a fixé un large éventail d'objectifs à partir desquels sélectionner les besoins et les fonctions de base (20% du travail, 80% du résultat).

  1. Placement initial: Bostock donne un bon départ avec un positionnement gauche/droite par rapport au géopoint.
  2. Répulsion inter-labels: différentes approches sont possibles, Lars & Navarrc en ont proposé une chacune,
  3. Annihilation des étiquettes: Une fonction d'annihilation d'étiquette lorsque la répulsion globale d'une étiquette est trop intense, car coincée entre d'autres étiquettes, la priorité de l'annihilation étant soit aléatoire soit basée sur une valeur de données population, que nous pouvons obtenir via le fichier .shp de NaturalEarth.
  4. [Luxe] Répulsion d'étiquette à points: avec des points fixes et des étiquettes mobiles. Mais c'est plutôt un luxe.

J'ignore si la répulsion des étiquettes fonctionnera à travers les couches et les classes d'étiquettes. Mais obtenir des étiquettes de pays et des étiquettes de villes qui ne se chevauchent pas peut également être un luxe.

42
Hugolpz

À mon avis, la disposition de la force ne convient pas pour placer des étiquettes sur une carte. La raison en est simple: les étiquettes doivent être aussi proches que possible des endroits qu'elles étiquetent, mais la disposition de force n'a rien à imposer. En effet, en ce qui concerne la simulation, il n'y a aucun mal à mélanger les étiquettes, ce qui n'est clairement pas souhaitable pour une carte.

Il pourrait y avoir quelque chose implémenté au-dessus de la disposition des forces qui a les lieux eux-mêmes comme nœuds fixes et forces attractives entre le lieu et son étiquette, tandis que les forces entre les étiquettes seraient répulsives. Cela nécessiterait probablement une implémentation de la disposition des forces modifiée (ou plusieurs dispositions des forces en même temps), donc je ne vais pas suivre cette voie.

Ma solution repose simplement sur la détection de collision: pour chaque paire d'étiquettes, vérifiez si elles se chevauchent. Si tel est le cas, éloignez-les du chemin, où la direction et l'amplitude du mouvement sont dérivées du chevauchement. De cette façon, seules les étiquettes qui se chevauchent réellement sont déplacées, et les étiquettes ne bougent que légèrement. Ce processus est répété jusqu'à ce qu'aucun mouvement ne se produise.

Le code est quelque peu compliqué, car la vérification du chevauchement est assez compliquée. Je ne posterai pas le code entier ici, il peut être trouvé dans cette démo (notez que j'ai agrandi les étiquettes pour exagérer l'effet). Les bits clés ressemblent à ceci:

function arrangeLabels() {
  var move = 1;
  while(move > 0) {
    move = 0;
    svg.selectAll(".place-label")
       .each(function() {
         var that = this,
             a = this.getBoundingClientRect();
         svg.selectAll(".place-label")
            .each(function() {
              if(this != that) {
                var b = this.getBoundingClientRect();
                if(overlap) {
                  // determine amount of movement, move labels
                }
              }
            });
       });
  }
}

Le tout est loin d'être parfait - notez que certaines étiquettes sont assez loin de l'endroit où elles sont étiquetées, mais la méthode est universelle et devrait au moins éviter le chevauchement des étiquettes.

enter image description here

35
Lars Kotthoff

Une option consiste à utiliser forcer la mise en page avec plusieurs foyers . Chaque foyer doit être situé dans le centre de gravité de l'entité, configurer l'étiquette de manière à être attirée uniquement par les foyers correspondants. De cette façon, chaque étiquette aura tendance à être proche du centre de gravité de l'entité, mais la répulsion avec d'autres étiquettes peut éviter le problème de chevauchement.

En comparaison:

Le code correspondant:

// Place and label location
var foci = [],
    labels = [];

// Store the projected coordinates of the places for the foci and the labels
places.features.forEach(function(d, i) {
    var c = projection(d.geometry.coordinates);
    foci.Push({x: c[0], y: c[1]});
    labels.Push({x: c[0], y: c[1], label: d.properties.name})
});

// Create the force layout with a slightly weak charge
var force = d3.layout.force()
    .nodes(labels)
    .charge(-20)
    .gravity(0)
    .size([width, height]);

// Append the place labels, setting their initial positions to
// the feature's centroid
var placeLabels = svg.selectAll('.place-label')
    .data(labels)
    .enter()
    .append('text')
    .attr('class', 'place-label')
    .attr('x', function(d) { return d.x; })
    .attr('y', function(d) { return d.y; })
    .attr('text-anchor', 'middle')
    .text(function(d) { return d.label; });

force.on("tick", function(e) {
    var k = .1 * e.alpha;
    labels.forEach(function(o, j) {
        // The change in the position is proportional to the distance
        // between the label and the corresponding place (foci)
        o.y += (foci[j].y - o.y) * k;
        o.x += (foci[j].x - o.x) * k;
    });

    // Update the position of the text element
    svg.selectAll("text.place-label")
        .attr("x", function(d) { return d.x; })
        .attr("y", function(d) { return d.y; });
});

force.start();

enter image description here

21
Pablo Navarro

Bien que ShareMap-dymo.js puisse fonctionner, il ne semble pas être très bien documenté. J'ai trouvé une bibliothèque qui fonctionne pour le cas plus général, est bien documentée et utilise également un recuit simulé: D3-Labeler

J'ai rassemblé un exemple d'utilisation avec ceci jsfiddle . La page d'exemple D3-Labeler utilise 1 000 itérations. J'ai trouvé que c'est plutôt inutile et que 50 itérations semblent fonctionner assez bien - c'est très rapide même pour quelques centaines de points de données. Je pense qu'il y a matière à amélioration à la fois dans l'intégration de cette bibliothèque avec D3 et en termes d'efficacité, mais je n'aurais pas pu aller aussi loin par moi-même. Je mettrai à jour ce fil si je trouve le temps de soumettre un PR.

Voici le code pertinent (voir le lien D3-Labeler pour plus de documentation):

var label_array = [];
var anchor_array = [];

//Create circles
svg.selectAll("circle")
.data(dataset)
.enter()
.append("circle")
.attr("id", function(d){
    var text = getRandomStr();
    var id = "point-" + text;
    var point = { x: xScale(d[0]), y: yScale(d[1]) }
    var onFocus = function(){
        d3.select("#" + id)
            .attr("stroke", "blue")
            .attr("stroke-width", "2");
    };
    var onFocusLost = function(){
        d3.select("#" + id)
            .attr("stroke", "none")
            .attr("stroke-width", "0");
    };
    label_array.Push({x: point.x, y: point.y, name: text, width: 0.0, height: 0.0, onFocus: onFocus, onFocusLost: onFocusLost});
    anchor_array.Push({x: point.x, y: point.y, r: rScale(d[1])});
    return id;                                   
})
.attr("fill", "green")
.attr("cx", function(d) {
    return xScale(d[0]);
})
.attr("cy", function(d) {
    return yScale(d[1]);
})
.attr("r", function(d) {
    return rScale(d[1]);
});

//Create labels
var labels = svg.selectAll("text")
.data(label_array)
.enter()
.append("text")
.attr("class", "label")
.text(function(d) {
    return d.name;
})
.attr("x", function(d) {
    return d.x;
})
.attr("y", function(d) {
    return d.y;
})
.attr("font-family", "sans-serif")
.attr("font-size", "11px")
.attr("fill", "black")
.on("mouseover", function(d){
    d3.select(this).attr("fill","blue");
    d.onFocus();
})
.on("mouseout", function(d){
    d3.select(this).attr("fill","black");
    d.onFocusLost();
});

var links = svg.selectAll(".link")
.data(label_array)
.enter()
.append("line")
.attr("class", "link")
.attr("x1", function(d) { return (d.x); })
.attr("y1", function(d) { return (d.y); })
.attr("x2", function(d) { return (d.x); })
.attr("y2", function(d) { return (d.y); })
.attr("stroke-width", 0.6)
.attr("stroke", "gray");

var index = 0;
labels.each(function() {
    label_array[index].width = this.getBBox().width;
    label_array[index].height = this.getBBox().height;
    index += 1;
});

d3.labeler()
    .label(label_array)
    .anchor(anchor_array)
    .width(w)
    .height(h)
    .start(50);

labels
    .transition()
    .duration(800)
    .attr("x", function(d) { return (d.x); })
    .attr("y", function(d) { return (d.y); });

links
    .transition()
    .duration(800)
    .attr("x2",function(d) { return (d.x); })
    .attr("y2",function(d) { return (d.y); });

Pour une analyse plus approfondie du fonctionnement de D3-Labeler, voir "Un plug-in D3 pour le placement automatique d'étiquettes à l'aide d'un recuit simulé"

Jeff Heaton "Artificial Intelligence for Humans, Volume 1" fait également un excellent travail pour expliquer le processus de recuit simulé.

14
Jordan

Vous pourriez être intéressé par le composant d3fc-label-layout (pour D3v5) spécialement conçu à cet effet. Le composant fournit un mécanisme pour organiser les composants enfants en fonction de leurs boîtes de délimitation rectangulaires. Vous pouvez appliquer une stratégie de recuit gourmande ou simulée afin de minimiser les chevauchements.

Voici un extrait de code qui montre comment appliquer ce composant de disposition à l'exemple de carte de Mike Bostock:

const labelPadding = 2;

// the component used to render each label
const textLabel = layoutTextLabel()
  .padding(labelPadding)
  .value(d => d.properties.name);

// a strategy that combines simulated annealing with removal
// of overlapping labels
const strategy = layoutRemoveOverlaps(layoutGreedy());

// create the layout that positions the labels
const labels = layoutLabel(strategy)
    .size((d, i, g) => {
        // measure the label and add the required padding
        const textSize = g[i].getElementsByTagName('text')[0].getBBox();
        return [textSize.width + labelPadding * 2, textSize.height + labelPadding * 2];
    })
    .position(d => projection(d.geometry.coordinates))
    .component(textLabel);

// render!
svg.datum(places.features)
     .call(labels);

Et voici une petite capture d'écran du résultat:

enter image description here

Vous pouvez voir un exemple complet ici:

http://bl.ocks.org/ColinEberhardt/389c76c6a544af9f0cab

Divulgation: Comme discuté dans le commentaire ci-dessous, je suis un contributeur de base de ce projet, donc clairement je suis quelque peu biaisé. Plein crédit aux autres réponses à cette question qui nous ont inspiré!

11
ColinE

Une option consiste à utiliser une disposition Voronoi pour calculer où il y a de l'espace entre les points. Il y a un bon exemple de Mike Bostock ici .

3
RobinL

Pour le cas 2D, voici quelques exemples qui font quelque chose de très similaire:

un http://bl.ocks.org/16914
deux http://bl.ocks.org/1377729

merci Alexander Skaburskis qui a soulevé cette question ici


Pour le cas 1D Pour ceux qui recherchent une solution à un problème similaire en 1-D, je peux partager mon sandbox JSfiddle où j'essaie de le résoudre. C'est loin d'être parfait mais ça fait le genre de chose.

Gauche: le modèle de bac à sable, droite: un exemple d'utilisation enter image description here

Voici l'extrait de code que vous pouvez exécuter en appuyant sur le bouton à la fin de l'article, ainsi que le code lui-même. Lors de l'exécution, cliquez sur le champ pour positionner les nœuds fixes.

var width = 700,
    height = 500;

var mouse = [0,0];

var force = d3.layout.force()
    .size([width*2, height])
    .gravity(0.05)
    .chargeDistance(30)
    .friction(0.2)
    .charge(function(d){return d.fixed?0:-1000})
    .linkDistance(5)
    .on("tick", tick);

var drag = force.drag()
    .on("dragstart", dragstart);

var svg = d3.select("body").append("svg")
    .attr("width", width)
    .attr("height", height)
    .on("click", function(){
        mouse = d3.mouse(d3.select(this).node()).map(function(d) {
            return parseInt(d);
        });
        graph.links.forEach(function(d,i){
            var rn = Math.random()*200 - 100;
            d.source.fixed = true; 
            d.source.px = mouse[0];
            d.source.py = mouse[1] + rn;
            d.target.y = mouse[1] + rn;
        })
        force.resume();
        
        d3.selectAll("circle").classed("fixed", function(d){ return d.fixed});
    });

var link = svg.selectAll(".link"),
    node = svg.selectAll(".node");
 
var graph = {
  "nodes": [
    {"x": 469, "y": 410},
    {"x": 493, "y": 364},
    {"x": 442, "y": 365},
    {"x": 467, "y": 314},
    {"x": 477, "y": 248},
    {"x": 425, "y": 207},
    {"x": 402, "y": 155},
    {"x": 369, "y": 196},
    {"x": 350, "y": 148},
    {"x": 539, "y": 222},
    {"x": 594, "y": 235},
    {"x": 582, "y": 185}
  ],
  "links": [
    {"source":  0, "target":  1},
    {"source":  2, "target":  3},
    {"source":  4, "target":  5},
    {"source":  6, "target":  7},
    {"source":  8, "target":  9},
    {"source":  10, "target":  11}
  ]
}

function tick() {
  graph.nodes.forEach(function (d) {
     if(d.fixed) return;
     if(d.x<mouse[0]) d.x = mouse[0]
     if(d.x>mouse[0]+50) d.x--
    })
    
    
  link.attr("x1", function(d) { return d.source.x; })
      .attr("y1", function(d) { return d.source.y; })
      .attr("x2", function(d) { return d.target.x; })
      .attr("y2", function(d) { return d.target.y; });

  node.attr("cx", function(d) { return d.x; })
      .attr("cy", function(d) { return d.y; });
}

function dblclick(d) {
  d3.select(this).classed("fixed", d.fixed = false);
}

function dragstart(d) {
  d3.select(this).classed("fixed", d.fixed = true);
}



  force
      .nodes(graph.nodes)
      .links(graph.links)
      .start();

  link = link.data(graph.links)
    .enter().append("line")
      .attr("class", "link");

  node = node.data(graph.nodes)
    .enter().append("circle")
      .attr("class", "node")
      .attr("r", 10)
      .on("dblclick", dblclick)
      .call(drag);
.link {
  stroke: #ccc;
  stroke-width: 1.5px;
}

.node {
  cursor: move;
  fill: #ccc;
  stroke: #000;
  stroke-width: 1.5px;
  opacity: 0.5;
}

.node.fixed {
  fill: #f00;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.4.11/d3.min.js"></script>
<body></body>
2
Angie