Comment appliquer la répulsion de force sur les étiquettes de la carte afin qu'elles trouvent automatiquement leurs bons emplacements?
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.
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; });
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).
population
, que nous pouvons obtenir via le fichier .shp de NaturalEarth.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.
À 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.
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();
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é.
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:
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é!
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 .
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
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>