Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Gravitate to Centroid #63

Open
wants to merge 1 commit into
base: master
Choose a base branch
from

Conversation

curran
Copy link
Contributor

@curran curran commented Jun 17, 2020

Closes #33

A fresh take on #49

Before (look at Montana, South Dakota, and Kansas):

image

After (with centroidWeight of 0.01):

image

The screenshots are from stamen/pleth#13, if you'd like to experiment or see it in action.

Summary of changes:

  • Add opt-in additional argument centroidWeight.
  • Backwards-compatible (no difference if opt-in arg not specified).
  • Introduced a fitness function that penalizes distance from centroid, according to centroidWeight.

@curran curran changed the title First attempt at gravitating to centroid WIP Gravitate to Centroid Jun 17, 2020
@curran curran changed the title WIP Gravitate to Centroid Gravitate to Centroid Jun 17, 2020
@curran
Copy link
Contributor Author

curran commented Jun 17, 2020

Kindly requesting review @Fil @mourner @SBRK @lazarm. Thanks!

I realize there are no new tests and the README is not updated. This first review I'd like to just get feedback on the overall direction. If the direction is approved I'd be happy to add tests and flesh out documentation.

@@ -65,10 +73,10 @@ function polylabel(polygon, precision, debug) {

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This bit is sort of a mystery to me if (cell.max - bestCell.d <= precision) continue;.

I wonder if the bestCell.d should be replaced here by fitness(bestCell). I don't understand all the logic behind max, so I left this as-is. Seems to work fine like this.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test corresponds to point 4 of the algorithm.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If it helps, my interpretation of that line is that cell.max provides the best possible value for distance from the polygon edge, and if the bestCell.d is at least within precision of cell.max then a better solution does not exist within the selected cell, and we can move on through the priority queue.

this.x = x; // cell center x
this.y = y; // cell center y
this.h = h; // half the cell size
this.d = pointToPolygonDist(x, y, polygon); // distance from cell center to polygon
this.distanceToCentroid = centroidCell ? pointToPointDist(this, centroidCell) : 0;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: The only case where centroidCell is not provided is when generating the centroid cell itself, so distance should be 0.

Copy link

@Fil Fil left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Though of course it works in the simple case of an horizontal rectangle, I think gravitating towards the centroid is the wrong concept. The centroid is global, and what we're looking for is more breadth locally.

Here's a counter-example where gravitating towards the centroid (blue) makes things worse.

  • red is the pole of inaccessibility as a "locus" (in this case a line)
  • red dot is the polylabel given by the standard algorithm
  • green is the polylabel gravitated towards the centroid with a weight of +0.1):

Capture d’écran 2020-06-18 à 14 31 41

https://observablehq.com/d/de13dcd035b7ebbd

What we would want in this case is the middle of the red line.

@curran
Copy link
Contributor Author

curran commented Jun 18, 2020

Thank you so much @Fil for reviewing! Excellent work with the counterexample.

It begs the question, how could we make it resolve to the center of the right side rectangular region?

It's almost as if we should split the polygon into two (or more) separate regions, and gravitate towards the centroid of the region that the point is inside...

But how to split? Maybe looking at previously visited cells could help.

Or... blurring the polygon and applying Marching Squares (as in https://github.com/d3/d3-contour) would probably do that sort of splitting. I bet your counterexample would be split into two separate shapes with the right blur and contour threshold. At that point the shape could be treated as a MultiPolygon, in which case the polygon with the most optimal fitness function would be labeled (at least that's how I solved the MultiPolygon case for map labeling).

Possibly related to this other issue regions of inaccessibility #50.

Quite a difficult problem indeed.

However, I must say that gravitating to the centroid just a little bit really improves the US map labels. Since this change is arguably an overall improvement for the case of geographic regions, and it's strictly opt-in, it may be worth adding this as a stopgap until a more ideal algorithm is discovered (that addresses your counterexample).

I feel like this is a problem space that @veltman would appreciate. Wonder if there's any overlap betweent this and his work on https://github.com/veltman/flubber

@Fil
Copy link

Fil commented Jun 18, 2020

Well first of all it's not for me to decide.

Currently the algorithm is "pure" in the sense that it returns "the pole of inaccessibility" (or more accurately "a" pole of inaccessibility). My preference would be to keep it that way.

But this is not always the best place for a label in the general case. For example, since labels are usually much larger than tall, you might want to stretch your geometry vertically before looking for the pole, as in this block).

As for a better placement algorithm I don't know, but it sure is an interesting question. I have some ideas (maybe we could be happy if, starting from that "pole" position we measured the available breadth, rescaled the geometries accordingly, and replayed the algorithm), but I haven't tried to implement them yet.

@curran
Copy link
Contributor Author

curran commented Jun 18, 2020

Yes! I had been wondering how to find an "ellipse of inaccessibility", which would be better than using circles, and had not thought of stretching the geometry - amazing idea.

@curran
Copy link
Contributor Author

curran commented Jun 19, 2020

@mourner
Copy link
Member

mourner commented Jun 19, 2020

Very nice implementation — thanks for taking on this! Definitely easier to merge than the previous one if we do decide to do it. I'm not sure yet — @Fil raises a valid counter-point, although it would be nice to find a balance between mathematical correctness and practicality for this library.

I just tried looking into how stretching the input could help with labeling, and it's pretty interesting — it fixes some cases (depending on ratio) but introduces new unexpected ones. Play around here: https://observablehq.com/@mourner/labeling-with-stretched-polylabel

@veltman
Copy link

veltman commented Jun 19, 2020

Interesting idea, agree with @Fil that you can come up with a number of cases where the centroid makes it worse rather than better, especially since it's so dependent on what the the label being placed is.

Not necessarily a dealbreaker since it's opt-in, but I would assume the criteria for inclusion is that you can throw that at a large set of arbitrary shapes and mostly get improvements and rarely make it much worse? Otherwise, if it's something you would only apply on an ad hoc basis (e.g. setting a custom centroidWeight for each country through trial and error) it doesn't seem much easier to me than setting custom x/y nudge values.

@mkotelnikov
Copy link

You can check here a modified version of the Vladimir's notebook with automatic ratio calculations:
https://observablehq.com/@kotelnikov/labeling-with-stretched-polylabel-updated
It seems that it gives acceptable results.

@Fil
Copy link

Fil commented Jun 23, 2020

It looks close to perfect. I've added a customizable projection and a detail (to use geoPath().bounds() rather than do it manually). I'd suggest to clamp the ratio to maybe [1/10, 10] to prevent against the occasional very thin feature, like Fiji on https://observablehq.com/d/7c984c2d23c003fe (try and change the clamping ratio A to 10000).

image

The next step is to add occlusion and zoom, but I've got to go :)

@curran
Copy link
Contributor Author

curran commented Jun 25, 2020

Now I'm looking into solving the case of label overlaps.

Options include:

@@ -65,10 +73,10 @@ function polylabel(polygon, precision, debug) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
}
// do not drill down further if there's no chance of a better solution
if ((cell.max - cell.distanceToCentroid * centroidWeight) - fitness(bestCell) <= precision) continue;

Here's my suggestion to fix a potentially code-breaking issue of this line.
Given that cell.max represents the best possible result from a new cell to be compared, it should be equally adjusted as in the fitness(cell) function (subtracting the weighted cell.distanceToCentroid from the result to obtain the final "fitness".

Not making this change might result in an infinite loop, typically when thecentroidWeightis much larger than the precision. e.g. polylabel(polygon, 0.000001, false, 0.01);

@curran
Copy link
Contributor Author

curran commented Apr 28, 2021

Noting down some thoughts here from renewed interest in this space.

image

The challenge at hand: optimize position of labels on a Streamgraph visualization.

Existing solution in use there: d3-area-label.

Desired solution: optimize angle of the labels as well, such that the size of the text can be maximized.

image

Related work: Experimental Label Placement. This technique suffers from many drawbacks, but has some nice qualities as well.

The reason why I'm posting here is that a solution idea emerged from discussions with @Fil , who suggested the following:

You could pass the shape to polylabel with varying angles, and decide which results in the best rectangle.

This idea is very promising. The polylabel-on-ellipse idea is proved out in Labeling with stretched Polylabel (updated). The aspect ratio of the ellipses for each label can be calculated based on the bounding box of the label text. The remaining work to implement the solution involves:

  • Modifying the polylabel-on-ellipse solution to accept an arbitrary angle. The rotation can be done in the same bit of code that does the stretching, namely this bit here (could rename to polylabelStretchedAndRotated):
polylabelStretched = (rings, ratio) => {
  const polygon = [];
  for (const ring of rings) {
    // stretch the input
    const newRing = [];
    for (const [x, y] of ring.map(projection)) newRing.push([x / ratio, y]);
    polygon.push(newRing);
  }
  const result = polylabel(polygon, 0.5);
  result[0] *= ratio; // stretch the result back
  result.distance *= ratio;
  return result;
}

Related:

@syonfox
Copy link

syonfox commented Jun 27, 2024

Hi just dove into this topic and have created a fork, Not really compatible with a merge as i deleted all the dependencies and enlist stuff but the file can ow be included directly in the browser.

there is a demo at https://polylabel.pages.dev

Let me know what you think feel free to improve it as the code needs another pass of clean up.

Im using leaflet in this project and leaflet-labeler which resolves the collisions.
then I use the stretched polylabel with a setting of 1.5

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Consider also distance from centroid when calculating label position
7 participants