Using Lets-Plot with GeoTools to Create Maps

The Let's-Plot library allows to easily visualize geospatial features from GeoTools SimpleFeatureCollection.

SimpleFeatureCollection is a collection of SimpleFeature-s. Each SimpleFeature have a "geometry" attribute as well as optional "data" attributes.

The Let's-Plot library understands the following three geometry types:

  • Points / Multi-Points
  • Lines / Multi-Lines
  • Polygons / Multi-Polygons

These shapes can be plotted using various geometry layers, depending on the type of the shape:

  • geomPoint, geom_text with Points / Multi-Points
  • geomPath with Lines / Multi-Lines
  • geomPolygon, geom_map with Polygons / Multi-Polygons
  • geomRect when used with Polygon shapes will display corresponding bounding boxes

Apart from SimpleFeatureCollection the Lets-Plot library can also plot an individual Geometry (org.locationtech.jts.geom) and a ReferencedEnvelope (org.geotools.geometry.jts).

Before passing to a Lets-Plot geometry layer (via map or data parameters) any 'foreign' object must be converted to a Lets-Plot SpatialDataset object. This is done by the toSpatialDataset() extension method provided by Lets-Plot GeoTools extension (see the %use lets-plot-gt 'magic').

Shapfiles used in this tutorial:

  • naturalearth_lowres.shp
  • naturalearth_cities.shp

all are the copies of shapefiles distributed with the GeoPandas Python package.

In [1]:
%use lets-plot
In [2]:
// Initialize Lets-Plot GeoTools extension. 
%use lets-plot-gt(gt="[23,)")
In [3]:
Lets-Plot Kotlin API v.4.1.1. Frontend: Notebook with dynamically loaded JS. Lets-Plot JS v.2.5.1.
In [4]:
In [5]:

val factory = ShapefileDataStoreFactory()

Polygon shapes - Naturalearth low-resolution world dataset.

In [6]:
val worldFeatures : SimpleFeatureCollection = with("naturalearth_lowres") {
    val url = "${this}/${this}.shp"

// Convert Feature Collection to SpatialDataset.
// Use 10 decimals to encode floating point numbers (this is the default).
val world = worldFeatures.toSpatialDataset(10)
In [7]:
[Oceania, Africa, North America, Asia, South America, Europe, Seven seas (open ocean), Antarctica]


In [8]:
val voidTheme = theme(axis="blank", panelGrid="blank")

// Use the parameter `map` in `geomPolygon()` to display Polygons / Multi-Polygons 
letsPlot() + 
    geomPolygon(map = world, fill = "white", color = "gray") + 
    ggsize(700, 400) + 


geom_map() is very similar to geomPolygon() but it automatically applies the Mercator projection and other defaults that are more suitable for displaying blank maps.

In [9]:
letsPlot() + 
    geomMap(map = world) + 
    ggsize(700, 400) + 
In [10]:
// When applying Mercator projection to the world map, Antarctica becomes disproportionally large so 
// in the future let's show only part of it above 85-th parallel south:
val worldLimits = coordMap(ylim = -70 to 85)

Point shapes - Naturalearth world capitals dataset.

In [11]:
val cityFeatures : SimpleFeatureCollection = with("naturalearth_cities") {
    val url = "${this}/${this}.shp"


In [12]:
// Use parameter `map` in `geomPoint()` to display Point shapes
val cities = cityFeatures.toSpatialDataset(10)
letsPlot() + 
    geomMap(map = world) + 
    geomPoint(map = cities, color = "red") +
    ggsize(800, 600) + voidTheme + worldLimits


The situation with geomText() is different because in order to display labels we have to specify mapping for the aesthetic "label".

Aesthetic mapping binds a variable in data (passed via data parameter) with its representation on the screen.

Variables in a SpatialDataset passed via the map parameter can not be used in the aesthetic mapping.

Fortunately, such a SpatialDataset can as well be passed via the data parameter and Lets-Plot will undersand that its geometries should be mapped to the "x" and "y" aesthetic automatically.

In the next example we are going to show names of cities as labels on map.

Let's only show South American capitals because too many labels on the entire world map would quickly become not legible.

In [13]:
import org.geotools.filter.text.cql2.CQL
In [14]:
// Obtain bounding box of South America and use it to set the limits.
val southAm = worldFeatures.subCollection(
    CQL.toFilter("continent = 'South America'")
val southAmBounds = southAm.bounds

// Let's use slightly expanded boundind box.

// Define limits to use later with city markers and labels. 
val southAmLimits = coordMap(
    xlim = southAmBounds.minX to southAmBounds.maxX,
    ylim = southAmBounds.minY to southAmBounds.maxY

letsPlot() + 
    geomMap(map = southAm.toSpatialDataset()) +
    geomRect(map = southAmBounds.toSpatialDataset(), alpha = 0, color = "#EFC623")
In [15]:
// Add `text` layer and use the `data` parameter to pass `cities` SpartialDataset.
// Also configure `tooltip` in the points layer to show the city name.
letsPlot() + 
    geomMap(map = southAm.toSpatialDataset(), fill="#e5f5e0") +
    geomPoint(data = cities, color = "red", size = 3, tooltips = layerTooltips().line("@name")) +
    geomText(data = cities, vjust = 1, position = positionNudge(y = -.5)) { label = "name" } +
    geomRect(map = southAmBounds.toSpatialDataset(), alpha = 0, color="#EFC623", size=16) +
    southAmLimits +
    ggsize(450, 691) +