Advanced Label Placement

TileMill is no longer in active development. For our most up-to-date map design tool, check out Mapbox Studio.
If you've found yourself on this page, we're assuming you've

Trying multiple positions

Recent versions of TileMill include two methods to choose for placing labels on points. The choice is made via the text-placement-type CartoCSS property. The default, original method is called none, and the newer, more advanced method is called simple.

The simple method allows the designer to specify multiple potential positions on or around a central point, as well as multiple sizes of text to choose from. If the first attempt at placing a label is blocked by another label that has already been placed, it can look at this list to try the next position.

A full CartoCSS example of the syntax looks like this:

#labels {
  text-name: "[name]";
  text-face-name: "OpenSans Regular";
  text-placement-type: simple;
  text-placements: "N,S,E,W,NE,SE,NW,SW,16,14,12";
  text-dy: 3;
  text-dx: 3;
}

This will first attempt to place a label above a point, then below the point, then to the right, and so on with a text size of 16 until it finds a position that fits. If no labels fit at size 16, the positions will all be retried at a text size of 14, and then 12. If none of these fit the label will be skipped.

The text-dx and text-dy properties specify how far away (in pixels) the label should be placed from the point.

Improved direction distribution: random approach

It’s great to try different placements for a label, but the previous example will always try the same position (North) first. This may not always be the best choice, even if the label happens to fit there. And it may be better for the overall map design to distribute the different placement positions better, rather than letting a single position dominate.

Something as simple as randomly assigning a direction bias can help even out the look of the labels. For example, you could create a PostGIS query that creates a column called dir which is randomly assigned a value of either 0 or 1.

( select *, floor(random()*2) as dir from city_points ) as data

You could then set up your CartoCSS to favor East placement for the 0s and West placement for the 1s.

#labels {
  text-name: "[name]";
  text-face-name: "OpenSans Regular";
  text-placement-type: simple;
  text-placements: "E,NE,SE,W,NW,SW";
  [dir=1] { text-placements: "W,NW,SW,E,NE,SE"; }
}

Improved direction distribution: avoiding nearby neighbors

Using PostGIS its possible to come up with something smarter than random distribution to improve the look of simple label placement. One possibility is to calculate the direction of the nearest object of a certain type, and then try to avoid that. For example you could bias city lable placement away from the next nearest city, or county label placement away from the largest city in the county. These aren’t perfect solutions, but can be a quick way to make your labels more correct in more cases.

For labels on points-of-interest along a city block at high zoom level, the area most likely to have room for the label is away from the street. Placing labels here also keeps the street clear for its own labels and one-way arrows.

So for each label we need to find the nearest city street and its direction relative to the point. Service streets, tracks, footways, and cycleways will be ignored for this logic, but you could adjust it to account for whatever you feel is appropriat. For a basic use case fine if our label sits on top of an alley or park path; the goal is to avoid the main city grid.

Here are some of the spatial functions of PostGIS that will help determine this information:

  • ST_Distance will help us find the closest street to a POI
  • ST_ClosestPoint will tell us the closest point along the closest street, and
  • ST_Azimuth will help us determine the angle between the POI and the closest point.

We can put all these together as a user-defined PostreSQL function:

create or replace function poi_ldir(geometry)
    returns double precision as
$$
    select degrees(st_azimuth(st_closestpoint(way, $1),$1)) as angle
    from planet_osm_line
    where way && st_expand($1, 100)
        and highway in ('motorway', 'trunk', 'primary', 'secondary', 'tertiary',
            'unclassified', 'residential', 'living_street', 'pedestrian')
    order by st_distance(way, $1) asc
    limit 1
$$
language 'sql'
stable;

This particular function assumes you are working with a standard OpenStreetMap rendering database generated by osm2pgsql (you can adjust it to be used with other schemas). The first two lines set up a function with a name, argument, and return value. $$ starts the function. The result of the function, when given a point geometry as an argument, will be a number between 0 and 360 representing the angle between that point and the nearest street of any of the types defined in the where clause. (ST_Azimuth() returns a value in radians, but we convert that to degrees to make it easier to work with in CartoCSS.)

To use the above function to your database, copy its contents to a file (for example, poi_ldir.sql on your Desktop). Then run a command from the terminal to load it into your database:

psql -f ~/Desktop/poi_ldir.sql -d <your_database_name>

You can then use the function in your TileMill select statements. This selection will retrieve all amenity and shop points from the database, their names, and column named ldir that is the result of the poi_ldir function on the geometry for each point.

( select way, name, poi_ldir(way) as ldir
  from planet_osm_point
  where amenity is not null or shop is not null
) as pois

To use the ldir column in a stylesheet, set up a label style with the simple text placement type and nest some filters within that that adjust the text-placements parameter depending on the ldir value. This example will only try each label at one position:

#poi[zoom > 15] {
  text-name: '[name]';
  text-face-name: @sans_medium;
  text-size: 12;
  text-fill: #222;
  text-wrap-width: 60;
  text-wrap-before: true;
  text-halo-radius: 2;
  text-halo-fill: #fff;
  text-min-distance: 2;
  text-placement-type: simple;
  text-dx: 5;
  text-dy: 5;
  text-placements: 'N';
  [ldir >= 45][ldir < 135] { text-placements: 'E'; }
  [ldir >= 135][ldir < 225] { text-placements: 'S'; }
  [ldir >= 225][ldir < 315] { text-placements: 'W'; }
}

After integrating this style into a more complete OSM stylesheet you can see that most of the point labels are now avoiding the roads.