Skip to content

Geospatial Geometry

In this tutorial, we're going to:

  1. Plot geometries on a map using GeoMakie and coordinate reference system (CRS)

  2. Create geospatial geometries with embedded coordinate reference system information

  3. Assign attributes to geospatial geometries

  4. Save geospatial geometries to common geospatial file formats

  5. Introduce Geodesic Paths

Install the packages used in this tutorial:

julia
using Pkg
Pkg.add(["GeoInterface", "GeometryOps", "GeoFormatTypes", 
        "GeoJSON", "GeoParquet", "GeoDataFrames",
        "CoordinateTransformations", "Proj", "DataFrames", 
        "CairoMakie", "GeoMakie", "Shapefile"])
julia
# Geospatial packages
import GeoInterface as GI
import GeometryOps as GO
import GeoFormatTypes as GFT
# Coordinate transformation and projection
import CoordinateTransformations
import Proj
# Plotting
using CairoMakie
using GeoMakie
# Data loading
using GeoDataFrames, DataFrames
import GeoParquet, GeoJSON, Shapefile # activate native IO packages

Plot geometries on a map using GeoMakie and coordinate reference system (CRS)

In geospatial sciences we often have data in one Coordinate Reference System (CRS) (source) and would like to display it in different (destination) CRS. GeoMakie allows us to do this by automatically projecting from source to destination CRS.

Here, our source CRS is common geographic (i.e. coordinates of latitude and longitude), WGS84.

julia
source_crs1 = GFT.EPSG(4326)
EPSG:4326

Now let's pick a destination CRS for displaying our map. Here we'll pick natearth2.

julia
destination_crs = "+proj=natearth2"
"+proj=natearth2"

Let's add land area for context. First, download and open the Natural Earth global land polygons at 110 m resolution. GeoMakie ships with this particular dataset, so we will access it from there. Note that this will be a path on your local machine, so you could easily point it to any other .geojson file you have.

julia
land_geojson_path = GeoMakie.assetpath("ne_110m_land.geojson")
"/home/runner/.julia/packages/GeoMakie/QcBwP/assets/ne_110m_land.geojson"

Note

Natural Earth has lots of other datasets, and there is a Julia package that provides an interface to it called NaturalEarth.jl.

Read this dataset in as a GeoJSON.FeatureCollection.

julia
land_features = GeoDataFrames.read(land_geojson_path)
127×4 DataFrame
Rowmin_zoomgeometryscalerankfeaturecla
Union…Polygon…Int64String
112D Polygon1Land
212D Polygon1Land
302D Polygon1Land
412D Polygon1Land
512D Polygon1Land
612D Polygon1Land
712D Polygon1Land
802D Polygon0Land
902D Polygon0Land
100.52D Polygon0Land
110.52D Polygon0Land
1202D Polygon0Land
1302D Polygon0Land
1402D Polygon0Land
1512D Polygon1Land
160.52D Polygon0Land
171.52D Polygon0Land
181.52D Polygon1Land
191.52D Polygon1Land
2012D Polygon1Land
2102D Polygon0Land
2202D Polygon0Land
231.52D Polygon1Land
241.52D Polygon1Land
2512D Polygon1Land
261.52D Polygon1Land
2712D Polygon1Land
2812D Polygon1Land
2912D Polygon1Land
3012D Polygon1Land
311.52D Polygon1Land
3202D Polygon0Land
331.52D Polygon1Land
3412D Polygon1Land
3512D Polygon1Land
361.52D Polygon1Land
3712D Polygon1Land
381.52D Polygon1Land
3902D Polygon0Land
4002D Polygon0Land
4112D Polygon1Land
4202D Polygon0Land
4302D Polygon0Land
4402D Polygon0Land
4512D Polygon1Land
4612D Polygon0Land
4712D Polygon1Land
4812D Polygon1Land
4912D Polygon1Land
5002D Polygon0Land
511.52D Polygon1Land
5202D Polygon0Land
5312D Polygon1Land
5412D Polygon1Land
5502D Polygon0Land
5612D Polygon1Land
570.52D Polygon0Land
581.52D Polygon0Land
591.52D Polygon0Land
6012D Polygon0Land
611.52D Polygon0Land
6202D Polygon0Land
6312D Polygon0Land
6412D Polygon1Land
651.52D Polygon0Land
661.52D Polygon0Land
671.52D Polygon1Land
6812D Polygon1Land
6912D Polygon1Land
7012D Polygon1Land
7112D Polygon1Land
7202D Polygon0Land
7312D Polygon1Land
7402D Polygon0Land
7512D Polygon0Land
7612D Polygon0Land
7712D Polygon1Land
7802D Polygon0Land
7912D Polygon1Land
8002D Polygon0Land
8102D Polygon0Land
8212D Polygon1Land
8312D Polygon1Land
8402D Polygon0Land
851.52D Polygon0Land
861.52D Polygon0Land
871.52D Polygon0Land
881.52D Polygon1Land
890.52D Polygon1Land
9002D Polygon0Land
9112D Polygon0Land
9202D Polygon0Land
931.52D Polygon1Land
9412D Polygon0Land
9502D Polygon0Land
9602D Polygon0Land
9702D Polygon0Land
981.52D Polygon0Land
9912D Polygon0Land
10002D Polygon0Land
10102D Polygon0Land
10212D Polygon0Land
1030.52D Polygon0Land
10402D Polygon0Land
10512D Polygon0Land
1061.52D Polygon1Land
10702D Polygon0Land
10802D Polygon0Land
10902D Polygon0Land
11002D Polygon0Land
11102D Polygon0Land
1120.52D Polygon1Land
11302D Polygon0Land
1141.52D Polygon1Land
11512D Polygon1Land
11612D Polygon1Land
1171.52D Polygon1Land
11812D Polygon1Land
1190.52D Polygon1Land
1200.52D Polygon0Land
12102D Polygon0Land
12202D Polygon0Land
12312D Polygon1Land
12402D Polygon0Land
12502D Polygon0Land
12602D Polygon0Land
12702D Polygon0Land

We then need to create a figure with a GeoAxis that can handle the projection between source and destination CRS. For GeoMakie, source is the CRS of the input and dest is the CRS you want to visualize in.

julia
fig = Figure(size=(1000, 500));
ga = GeoAxis(
    fig[1, 1];
    source = source_crs1,
    dest = destination_crs,
    xticklabelsvisible = false,
    yticklabelsvisible = false,
);

Plot land for context.

@example
poly!(ga, land_features; color=:black)
fig

Now let's plot a Polygon like before, but this time with a CRS that differs from our source data.

julia
function ring(radius)
    ϴ = 0:0.01:
	points = @. GI.Point(50 + radius * cos(ϴ), 50 + radius * sin(ϴ))
	return GI.LinearRing(points)
end

function spiro(a = 22, b = 2, k = 11)
    ϴ = 0:0.01:
    points = @. GI.Point(
        50 + a*cos(ϴ) - b*cos(k*ϴ),
        50 + a*sin(ϴ) - b*sin(k*ϴ)
    )
    return GI.LinearRing(points)
end


multipolygon = GI.MultiPolygon([
    GI.Polygon([spiro(), ring(8)]),
    GI.Polygon([ring(4)])
])

plot!(multipolygon; color = :green)
fig

But what if we want to plot geometries with a different source CRS on the same figure?

To show how to do this let's create a geometry with coordinates in UTM (Universal Transverse Mercator) zone 10N EPSG:32610.

julia
source_crs2 = GFT.EPSG(32610)
EPSG:32610

Create a polygon (we're working in meters now, not latitude and longitude)

@example
rs = 1000000;
ϴs = 0:0.01:2pi;
xs = rs .* cos.(ϴs).^3 .+ 500000;
ys = rs .* sin.(ϴs) .^ 3 .+ 5000000;
DisplayAs.setcontext(y, :compact => true, :displaysize => (10, 40),) # hide

Now create a LinearRing from Points

julia
ring3 = GI.LinearRing(GI.Point.(xs, ys))
GeoInterface.Wrappers.LinearRing{false, false}([Point((1.5e6, 5.0e6)), … (627) … , Point((1.499984780817334e6, 4.999999967681458e6))])

Now create a Polygon from the LineRing

julia
polygon3 = GI.Polygon([ring3])
GeoInterface.Wrappers.Polygon{false, false}([GeoInterface.Wrappers.LinearRing([Point((1.5e6, 5.0e6)), … (627) … , Point((1.499984780817334e6, 4.999999967681458e6))])])

Now plot on the existing GeoAxis.

Note

The keyword argument source is used to specify the source CRS of that particular plot, when plotting on an existing GeoAxis.

julia
plot!(ga,polygon3; color=:red, source = source_crs2)
fig

Create geospatial geometries with embedded coordinate reference system information

Great, we can make geometries and plot them on a map... now let's export the data to common geospatial data formats. To do this we now need to create geometries with embedded CRS information, making it a geospatial geometry. All that's needed is to include ; crs = crs as a keyword argument when constructing the geometry.

Let's do this for a new Polygon

julia
rs = 3;
ks = 7;
ϴs = 0:0.01:2pi;
xs = rs .* (ks + 1) .* cos.(ϴs) .- rs .* cos.((ks + 1) .* ϴs);
ys = rs .* (ks + 1) .* sin.(ϴs) .- rs .* sin.((ks + 1) .* ϴs);
ring4 = GI.LinearRing(tuple.(xs, ys))
GeoInterface.Wrappers.LinearRing{false, false}([(21.0, 0.0), … (627) … , (21.00085222666982, -8.14404531208901e-6)])

But this time when we create the Polygon we need to specify the CRS at the time of creation, associating that metadata with the geometry.

julia
geopoly1 = GI.Polygon([ring4], crs = source_crs1)
GeoInterface.Wrappers.Polygon{false, false}([GeoInterface.Wrappers.LinearRing([(21.0, 0.0), … (627) … , (21.00085222666982, -8.14404531208901e-6)])], crs = "EPSG:4326")

Note

It is good practice to only include CRS information with the highest-level geometry. Not doing so can sometimes bloat the memory footprint of the geometry. CRS information can be included at the individual Point level but is discouraged.

And let's create a second Polygon by shifting the first using CoordinateTransformations

julia
xoffset = 20.
yoffset = -25.
f = CoordinateTransformations.Translation(xoffset, yoffset)
geopoly2 = GO.transform(f, geopoly1)
GeoInterface.Wrappers.Polygon{false, false}([GeoInterface.Wrappers.LinearRing([[41.0, -25.0], … (627) … , [41.00085222666982, -25.000008144045314]], crs = "EPSG:4326")], crs = "EPSG:4326")

Creating a table with attributes and geometry

Typically, you'll also want to include attributes with your geometries. Attributes are simply data that are attributed to each geometry. The easiest way to do this is to create a table with a :geometry column. Let's do this using DataFrames.

julia
df = DataFrame(geometry = [geopoly1, geopoly2])
2×1 DataFrame
Rowgeometry
Polygon…
1GeoInterface.Wrappers.Polygon([GeoInterface.Wrappers.LinearRing([(21.0,0.0),…(627)…,(21.00085222666982,-8.14404531208901e-6)])])
2GeoInterface.Wrappers.Polygon([GeoInterface.Wrappers.LinearRing([[41.0,-25.0],…(627)…,[41.00085222666982,-25.000008144045314]])])

Now let's add a couple of attributes to the geometries by adding new columns to our existing data frame.

julia
df.id = ["a", "b"]
df.name = ["polygon 1", "polygon 2"]
df
2×3 DataFrame
Rowgeometryidname
Polygon…StringString
1GeoInterface.Wrappers.Polygon([GeoInterface.Wrappers.LinearRing([(21.0,0.0),…(627)…,(21.00085222666982,-8.14404531208901e-6)])])apolygon 1
2GeoInterface.Wrappers.Polygon([GeoInterface.Wrappers.LinearRing([[41.0,-25.0],…(627)…,[41.00085222666982,-25.000008144045314]])])bpolygon 2

Saving your geospatial data

There are Julia packages for most commonly used geographic data formats. Below, we show how to export that data to each of these.

In general, GeoDataFrames is recomended as the default way to write data to your desired format. This package uses the GDAL library under the hood which supports writing to nearly all geospatial formats.

Writing to gpkg:

julia
GeoDataFrames.write("shapes.gpkg", df)
"shapes.gpkg"

Writing to GeoJSON:

julia
GeoDataFrames.write("file.geojson", df)
"file.geojson"

View the GeoDataFrames documentation for all recognized file extensions.

Geodesic paths

Geodesic paths are paths computed on an ellipsoid, as opposed to a plane. The geodesic is the shortest path between two points measured along the Earth's curved surface. Because the surface is curved, that shortest path appears as a curve (not a straight line) when drawn on a flat map.

Here, we use the segmentize function to add vertices along the geodesic between two points, so the line follows Earth's curved surface instead of a straight line.

julia
# Two points in (longitude, latitude) order: Houston (IAH) and Amsterdam (AMS).
# Geodesic methods assume lon/lat input.
IAH = (-95.358421, 29.749907)
AMS = (4.897070, 52.377956)

# Draw coastlines for geographic context
fig, ga, _cp = lines(GeoMakie.coastlines(); axis = (; type = GeoAxis))

# Create our line along the Earth, accounting for curvature
lines!(ga, GO.segmentize(GO.Geodesic(), GI.LineString([IAH, AMS]); max_distance = 100_000))
fig

And there we go, you can now create mapped geometries from scratch, manipulate them, plot them on a map, and save them in multiple geospatial data formats, as well as create geodesic paths.