Geospatial tools for Dart — version 1.0 published

Navibyte
25 min readOct 29, 2023

Geospatial tools for Dart is a set of code packages written in the Dart language providing data structures and tools for coordinates, geometries, feature objects, metadata, spherical geodesy, projections, tiling schemes, vector data models and formats, and geospatial Web APIs.

Packages help Dart and Flutter developers to model positions, bounding boxes, geometries (point, line string, polygon etc.), feature objects (that is geospatial entities with identifiers, geometries and properties) and geospatial metadata (temporal data like instants and intervals, spatial extents etc.) in their own application specific domain models.

Geometry and feature objects can be read and decoded (and also encoded) from common geospatial vector data formats like GeoJSON, WKT and WKB. A Web API client for geospatial feature services based on the OGC API Features standard is also included.

Basic support for geometry functions is available too. Line lengths, area perimeters and centroids can be computed in cartesian coordinates. For geographic coordinates it’s possible to calculate distances, bearings, destinations points, intermediate points, intersections and areas along the Earth surface based on a spherical earth model.

Geographic coordinates

Web Mercator Quad and Global Geodetic Quad are two WGS 84 based tiling schemes representing the world as tiles and pixels in hierarchical tile matrix sets. Calculations for these schemes as well as the basic support for geospatial projections between geographic and projected coordinate reference systems is also provided.

All code is Open Source and available in the Geospatial tools for Dart repository in GitHub with BSD-3-Clause license.

Using packages in Dart and Flutter apps

Code packages are published at pub.dev as stable 1.0 versions:

  • 🌐 geobase : Geospatial data structures (coordinates, geometries, features, metadata), spherical geodesy, projections and tiling schemes. Vector data format support for GeoJSON, WKT and WKB.
  • 🌎 geodata : Geospatial feature service Web APIs with support for GeoJSON and OGC API Features clients.

Dart 2 and 3 language versions both are supported with Dart SDK 2.17 as a minimum requirement.

You can add dependencies in your pubspec.yamlfile

dependencies:
geobase: ^1.0.0
geodata: ^1.0.0

Now it’s possible to import packages and start using these rich geospatial tools in your Dart or Flutter application:

import `package:geobase/geobase.dart`
import `package:geodata/geodata.dart`

If you don’t need Web API support for OGC API Features and GeoJSON resources, then you can just omit geodata and use only geobase as a dependency.

Wait… what are Dart and Flutter?

This is a good question, and unless you are already familiar with these development toolkits, here is a short introduction.

🎯 Dart is a client-optimized language for fast apps on any platform — and actually it’s very powerful for building server-side apps too. Dart code packages are published at pub.dev.

According to Wikipedia page about Dart it is an object-oriented, class-based, garbage-collected language with C-style syntax. It can compile to machine code, JavaScript, or WebAssembly. It can be used to develop web and mobile apps as well as server and desktop applications.

The latest version Dart 3 is equipped with 100% sound null safety allowing compilers and runtimes to optimize code very efficiently, and also long awaited new language features like records, patterns, and class modifiers. The language is further evolving according to the roadmap with extension types (originally known as inline classes) currently being implemented. Static metaprogramming hopefully follows, but it’s still being specified.

💙 Flutter is a cross-platform UI framework and toolkit allowing us to design, build and deploy beautiful mobile, web, desktop, and embedded apps from a single codebase, based on the Dart language. To learn how to design and develop apps you should check out official Flutter codelabs.

Again Wikipedia page summarises key features very well: Flutter is an open-source UI software development kit created by Google. It is used to develop cross platform applications from a single codebase for any web browser, Fuchsia, Android, iOS, Linux, macOS, and Windows.

See also the Flutter code repository in GitHub, Flutter roadmap and Flutter Forward presentations (January 2023) about current and future features. Support for WebAssembly (Wasm) is still under development as introduced during I/O 2023 (May 2023). The latest version, Flutter 3.13 (August 2023), enhanced Material 3 support, improved the performance of apps on iOS (even if the new Impeller rendering engine has still some issues with blur performance), and provided new widgets for 2D scrolling user interfaces.

🧭 Geospatial tools for Dart, introduced by this blog post, is targeted to be usable in all Dart and Flutter platforms mentioned above. These tools are pure Dart packages (no dependencies on Flutter libraries) and provide utilities for managing geospatial data inside a Dart or Flutter application.

As based on pure Dart code, these tools do not include a map user interface framework or any fancy widgets either. Anyway you might want to check code for a demo application demonstrating using these tools with Google Maps Flutter and Riverpod packages to build a Flutter app with a map user interface too.

Coordinates and positions

The basic building blocks to represent position data in the geobase package are Position, PositionSeries and Box.

A position contains 2 to 4 coordinate values (x and y are required, zand mare optional) representing an exact location in some coordinate reference system. The m (measure) coordinate represents a measurement or a value on a linear referencing system (like time).

// A position as a view on a coordinate array containing x and y.
Position.view([708221.0, 5707225.0]);

// A position as a view on a coordinate array containing x, y and z.
Position.view([708221.0, 5707225.0, 45.0]);

// A position as a view on a coordinate array containing x, y, z and m.
Position.view([708221.0, 5707225.0, 45.0, 123.0]);

// The samples above can be shorted using extension methods on `List<double>`.
[708221.0, 5707225.0].xy;
[708221.0, 5707225.0, 45.0].xyz;
[708221.0, 5707225.0, 45.0, 123.0].xyzm;

// There are also some other factory methods.
Position.create(x: 708221.0, y: 5707225.0, z: 45.0, m: 123.0);
Position.parse('708221.0,5707225.0,45.0,123.0');
Position.parse('708221.0 5707225.0 45.0 123.0', delimiter: ' ');

A series of positions is backed either by a coordinate value array (with a flat structure) or a list of Position objects. Both structures are fully supported, and PositionSeries provides a common interface for both cases regardless of the internal data structure.

// A position series from a flat coordinate value array.
PositionSeries.view(
[
70800.0, 5707200.0, // (x, y) coordinate values for position 0
70850.0, 5707250.0, // (x, y) coordinate values for position 1
70900.0, 5707300.0, // (x, y) coordinate values for position 2
],
type: Coords.xy,
);

// A position series from an array of position objects.
PositionSeries.from(
[
[70800.0, 5707200.0].xy, // position 0 with (x, y) coordinate values
[70850.0, 5707250.0].xy, // position 1 with (x, y) coordinate values
[70900.0, 5707300.0].xy, // position 2 with (x, y) coordinate values
],
type: Coords.xy,
);

When manipulating positions in code it may be easier to handle lists or iterables of Position objects. However coordinate value arrays in a flat structure is used by default when decoding position data from geospatial data formats.

Coordinate value arrays represented as List<double> also provides more memory-efficient structure than List<Position>. This is further enhanced by using optimized Float64List (a list of double-precision floating-point numbers) or Float32List (a list of single-precision floating-point numbers, takes even less space but sacrifices accuracy a bit) data structures defined by the standard dart:typed_data package.

Building PositionSeries objects from coordinate value arrays can be also shortened. This can be handy when specifying position data in Dart code.

// A position series from a flat coordinate value array (2D positions).
[
70800.0, 5707200.0, // (x, y) coordinate values for position 0
70850.0, 5707250.0, // (x, y) coordinate values for position 1
70900.0, 5707300.0, // (x, y) coordinate values for position 2
].positions(Coords.xy);

// A position series from a flat coordinate value array (3D positions).
[
70800.0, 5707200.0, 40.0, // (x, y, z) coordinate values for position 0
70850.0, 5707250.0, 45.0, // (x, y, z) coordinate values for position 1
70900.0, 5707300.0, 50.0, // (x, y, z) coordinate values for position 2
].positions(Coords.xyz);

As a third basic building block, an axis-aligned bounding box contains 4 to 8 coordinate values (minX, minY, maxX and maxY are required, minZ, minM, maxX and maxM are optional).

// The same bounding box (limits on x and y) created with different factories.
Box.view([70800.0, 5707200.0, 70900.0, 5707300.0]);
Box.create(minX: 70800.0, minY: 5707200.0, maxX: 70900.0, maxY: 5707300.0);
Box.parse('70800.0,5707200.0,70900.0,5707300.0');
Box.parse('70800.0 5707200.0 70900.0 5707300.0', delimiter: ' ');

// The same box using extension methods on `List<double>`.
[70800.0, 5707200.0, 70900.0, 5707300.0].box;

These data structures described above can be used to represent position data in various coordinate reference systems, including geographic, projected and local systems. Data structures are opaque to the reference system used.

When a position contains geographic coordinates, then x represents longitude, y represents latitude, and z represents elevation (or height or altitude) when manipulating data with classes introduced here (external data formats may use different axis orders, but that’s another and very long story).

There are also very specific subtypes of Position and Box classes.

Projected (extending Position) and ProjBox (extending Box) can be used to represent projected or cartesian (xyz) coordinates.

Similarly Geographic and GeoBox can be used to represent geographic coordinates (longitude, latitude) with some geographic logic built-in (like normalizing longitude values, printing degree-minute-second representations and handling antimeridian issues).

See the geobase documentation for more details about these subtypes.

Operators and functions on cartesian positions

Position objects can be manipulated in cartesian coordinate system using operators and functions available.

// a position containing x, y and z
final pos = [708221.0, 5707225.0, 45.0].xyz;

// multiplication operator - prints "708.221,5707.225,0.045" (values in km)
// (the operand is a factor value applied to all coordinate values)
print(pos * 0.001);

// negate operator - prints "-708221.0,-5707225.0,-45.0"
print(-pos);

// following operators expect an operand to be another position object

// add operator - prints "708231.0,5707245.0,50.0"
print(pos + [10.0, 20.0, 5.0].xyz);

// subtraction operator - prints "708211.0,5707205.0,40.0"
print(pos - [10.0, 20.0, 5.0].xyz);

// division operator - prints "708.221,5707.225,45.0" (x and y values in km)
print(pos / [1000.0, 1000.0, 1.0].xyz);

// modulo operator - prints "221.0,225.0,45.0"
print(pos % [1000.0, 1000.0, 1000.0].xyz);

// there is support also for basic calculations in cartesian coordinates

// other point 1000.0 meters to the direction of 45° (north-east)
final other = pos.destinationPoint2D(distance: 1000.0, bearing: 45.0);

// distance between points - prints "1000.0"
print(pos.distanceTo2D(other).toStringAsFixed(1));

// bearing from point to another - prints "45.0"
print(pos.bearingTo2D(other).toStringAsFixed(1));

// midpoint between two points - prints "708574.6,5707578.6"
print(pos.midPointTo(other).toText(decimals: 1));

// intermediate point between two point (fraction range: 0.0 to 1.0)
// prints "708397.8,5707401.8"
print(pos.intermediatePointTo(other, fraction: 0.25).toText(decimals: 1));

Other options to apply coordinate transforms on position objects are to use transform (with a custom transform as a parameter) or project (geospatial projections, discussed later).

Also PositionSeries has some helpful operators and functions worth mentioning here.

// a closed linear ring with positions in the counterclockwise (CCW) order
final polygon = [
[1.0, 6.0].xy,
[3.0, 1.0].xy,
[7.0, 2.0].xy,
[4.0, 4.0].xy,
[8.0, 5.0].xy,
[1.0, 6.0].xy,
].series();

// the area of a polygon formed by the linear ring - prints "16.5"
print(polygon.signedArea2D());

// the perimeter of a polygon - prints "24.3"
print(polygon.length2D().toStringAsFixed(1));

// the centroid position of a polygon - prints "3.9,3.7"
print(polygon.centroid2D()!.toText(decimals: 1));

// a closed linear ring with positions in the clockwise (CW) order
final reversed = polygon.reversed();

// a line string omitting the last position of `reversed`
final line = reversed.range(0, reversed.positionCount - 1);

// the length of a line string - prints "18.9"
print(line.length2D().toStringAsFixed(1));

// the line string modified by replacing positions at indexes 1 ja 2
final lineModified = line.rangeReplaced(1, 3, [
[3.5, 1.5].xy,
[7.5, 2.5].xy,
]);

// coordinate values of a line string multiplied by 100.0
final lineModified2 = lineModified * 100.0;

// get position count and a position by index - prints "5" and "350.0,150.0"
print(lineModified2.positionCount);
print(lineModified2[1]);

Also PositionSeries objects can be transformed using transform and project too, just like single positions. Some other methods are available too to add, insert, replace and remove positions on a series, or to filter it. Please consult the package documentation for more information.

Calculations along the Earth surface

Cartesian geometry used in the previous section is not enough when measuring distances between geographic positions located far apart.

The geobase package provides geodesy functions that are based on calculations on a spherical earth model (with errors up to 0.3% compared to an ellipsoidal earth model). Distance, bearing, destination point and midpoint are provided both for great circle paths and rhumb lines. Intermediate points, intersections and areas are available for great circle paths only.

According to Wikipedia, a great circle or orthodrome is the circular intersection of a sphere and a plane passing through the sphere’s center point. A rhumb line or loxodrome is an arc crossing all meridians of longitude at the same angle, that is, a path with constant bearing as measured relative to true north.

The rhumb line path is slightly longer than the path along the great circle. Rhumb lines are sometimes used in marine navigation as it’s easier to follow a constant compass bearing than adjusting bearings when following a great circle path.

Code examples below uses great circle paths (orthodromic).

// sample geographic positions
final greenwich = Geographic.parseDms(lat: '51°28′40″ N', lon: '0°00′05″ W');
final sydney = Geographic.parseDms(lat: '33.8688° S', lon: '151.2093° E');

// decimal degrees (DD) and degrees-minutes (DM) formats
const dd = Dms(decimals: 0);
const dm = Dms.narrowSpace(type: DmsType.degMin, decimals: 1);

// prints: 16988 km
final distanceKm = greenwich.spherical.distanceTo(sydney) / 1000.0;
print('${distanceKm.toStringAsFixed(0)} km');

// prints (bearing varies along the great circle path): 61° -> 139°
final initialBearing = greenwich.spherical.initialBearingTo(sydney);
final finalBearing = greenwich.spherical.finalBearingTo(sydney);
print('${dd.bearing(initialBearing)} -> ${dd.bearing(finalBearing)}');

// prints: 51° 31.3′ N, 0° 07.5′ E
final destPoint =
greenwich.spherical.destinationPoint(distance: 10000, bearing: 61.0);
print(destPoint.latLonDms(format: dm));

// prints: 28° 34.0′ N, 104° 41.6′ E
final midPoint = greenwich.spherical.midPointTo(sydney);
print(midPoint.latLonDms(format: dm));

// prints 10 intermediate points, like fraction 0.6: 16° 14.5′ N, 114° 29.3′ E
for (var fr = 0.0; fr < 1.0; fr += 0.1) {
final ip = greenwich.spherical.intermediatePointTo(sydney, fraction: fr);
print('${fr.toStringAsFixed(1)}: ${ip.latLonDms(format: dm)}');
}

// prints: 0° 00.0′ N, 125° 19.0′ E
final intersection = greenwich.spherical.intersectionWith(
bearing: 61.0,
other: const Geographic(lat: 0.0, lon: 179.0),
otherBearing: 270.0,
);
if (intersection != null) {
print(intersection.latLonDms(format: dm));
}

As described also rhumb line paths are supported (this code block uses same sample positions as one above).

// prints: 17670 km
final distanceKm = greenwich.rhumb.distanceTo(sydney) / 1000.0;
print('${distanceKm.toStringAsFixed(0)} km');

// prints (bearing remains the same along the rhumb line path): 122° -> 122°
final initialBearing = greenwich.rhumb.initialBearingTo(sydney);
final finalBearing = greenwich.rhumb.finalBearingTo(sydney);
print('${dd.bearing(initialBearing)} -> ${dd.bearing(finalBearing)}');

// prints: 51° 25.8′ N, 0° 07.3′ E
final destPoint =
greenwich.spherical.destinationPoint(distance: 10000, bearing: 122.0);
print(destPoint.latLonDms(format: dm));

// prints: 8° 48.3′ N, 80° 44.0′ E
final midPoint = greenwich.rhumb.midPointTo(sydney);
print(midPoint.latLonDms(format: dm));

The code behind geodesy functions and DMS (degrees, minutes and seconds) formatters is ported from JavaScript based spherical geodesy tools by Chris Veness (👉 Movable Type Scripts).

When manipulating geographic coordinates you also have to handle geometries and bounding boxes spanning the antimeridian (longitude +180°). The rules specified by RFC 7946 about GeoJSON are respected. For example bounding boxes may have west longitude coordinate (minX) larger than east longitude coordinate (maxX).

// The bounding box of the Fiji archipelago spans the antimeridian.
GeoBox(west: 177.0, south: -20.0, east: -178.0, north: -16.0);

Special logic is also applied when merging geographic bounding boxes.

// a sample merging two boxes on both sides on the antimeridian
// (the result equal with p3 is then spanning the antimeridian)
const b1 = GeoBox(west: 177.0, south: -20.0, east: 179.0, north: -16.0);
const b2 = GeoBox(west: -179.0, south: -20.0, east: -178.0, north: -16.0);
const b3 = GeoBox(west: 177.0, south: -20.0, east: -178.0, north: -16.0);
b1.mergeGeographically(b2) == b3; // true

// a sample merging two boxes without need for antimeridian logic
const b4 = GeoBox(west: 40.0, south: 10.0, east: 60.0, north: 11.0);
const b5 = GeoBox(west: 55.0, south: 19.0, east: 70.0, north: 20.0);
const b6 = GeoBox(west: 40.0, south: 10.0, east: 70.0, north: 20.0);
b4.mergeGeographically(b5) == b6; // true

Geometry model

The Open Geospatial Consortium (OGC) has published Simple Feature Access — Part 1: Common Architecture that specifies geometry object model including following geometry primitive types that are implemented in the geobase package.

// A point geometry with a single position (with x and y coordinate values).
Point.build([30.0, 10.0]);

// A line string geometry (polyline) with a chain of positions.
LineString.build([30, 10, 10, 30, 40, 40]);

// A polygon geometry with an exterior linear ring.
Polygon.build([
[30, 10, 40, 40, 20, 40, 10, 20, 30, 10],
]);

// A polygon geometry with an exterior linear ring and one interior ring (hole).
Polygon.build([
[35, 10, 45, 45, 15, 40, 10, 20, 35, 10],
[20, 30, 35, 35, 30, 20, 20, 30],
]);

The geometry object model specifies also multipart and collection geometries that are supported too.

// A multi point geometry with four positions.
MultiPoint.build([[10, 40], [40, 30], [20, 20], [30, 10]]);

// A multi line geometry with two chains of positions.
MultiLineString.build([
[10, 10, 20, 20, 10, 40],
[40, 40, 30, 30, 40, 20, 30, 10],
]);

// A multi polygon with two polygons (both with an exterior linear ring).
MultiPolygon.build([
[[30, 20, 45, 40, 10, 40, 30, 20]],
[[15, 5, 40, 10, 10, 20, 5, 10, 15, 5]],
]);


// A multi polygon with two polygons (the second with an exterior and interior
// linear rings).
MultiPolygon.build([
[
[40, 40, 20, 45, 45, 30, 40, 40]
],
[
[20, 35, 10, 30, 10, 10, 30, 5, 45, 20, 20, 35],
[30, 20, 20, 15, 20, 25, 30, 20],
],
]);

// A geometry collection with a point, a line string and a polygon.
GeometryCollection([
Point.build([30.0, 10.0]),
LineString.build([10, 10, 20, 20, 10, 40]),
Polygon.build([[40, 40, 20, 45, 45, 30, 40, 40]]),
]);

See also the Wikipedia page about the WKT data format with illustrative images about geometry objects.

Geometry objects in the geobase package use Position, PositionSeries and Box objects introduced earlier as their internal data storage. For examplePoint has exactly one Position and MultiPoint contains a list of Position objects.

Similarly LineString has exactly one PositionSeries representing a chain of positions, and MultiLineString contains a list of PositionSeries objects.

Polygon is a bit more complex geometry. A valid polygon without any holes has exactly one PositionSeries representing an exterior linear ring. A valid polygon with holes has also 1 to N extra PositionSeries objects representing interior linear rings (holes). MultiPolygon then contains a list of lists of PositionSeries objects.

Geometry objects has multiple different factory methods that all build an internal structure as described above.

Below a line string is constructed with different sets of coordinate values.

// a line string from 2D positions
LineString.build(
[
10.0, 20.0, // (x, y) for position 0
12.5, 22.5, // (x, y) for position 1
15.0, 25.0, // (x, y) for position 2
],
type: Coords.xy,
);

// a line string from 3D positions
LineString.build(
[
10.0, 20.0, 30.0, // (x, y, z) for position 0
12.5, 22.5, 32.5, // (x, y, z) for position 1
15.0, 25.0, 35.0, // (x, y, z) for position 2
],
type: Coords.xyz,
);

// a line string from measured 2D positions
LineString.build(
[
10.0, 20.0, 40.0, // (x, y, m) for position 0
12.5, 22.5, 42.5, // (x, y, m) for position 1
15.0, 25.0, 45.0, // (x, y, m) for position 2
],
type: Coords.xym,
);

// a line string from measured 3D positions
LineString.build(
[
10.0, 20.0, 30.0, 40.0, // (x, y, z, m) for position 0
12.5, 22.5, 32.5, 42.5, // (x, y, z, m) for position 1
15.0, 25.0, 35.0, 45.0, // (x, y, z, m) for position 2
],
type: Coords.xyzm,
);

Alternative ways to construct geometries are shown for 3D lines strings.

// a line string from a PositionSeries created from coordinate values (3D)
LineString(
[
10.0, 20.0, 30.0, // (x, y, z) for position 0
12.5, 22.5, 32.5, // (x, y, z) for position 1
15.0, 25.0, 35.0, // (x, y, z) for position 2
].positions(Coords.xyz),
// `.positions()` on a double list creates a `PositionSeries` instance.
);

// a line string from 3D position objects
LineString.from([
[10.0, 20.0, 30.0].xyz, // `.xyz` creates a `Position` instance
[12.5, 22.5, 32.5].xyz,
[15.0, 25.0, 35.0].xyz,
]);

// a line string from 3D positions encoded as GeoJSON text
LineString.parse(
format: GeoJSON.geometry,
'''
{
"type": "LineString",
"coordinates": [
[10.0,20.0,30.0],
[12.5,22.5,32.5],
[15.0,25.0,35.0]
]
}
''',
);

// a line string from 3D positions encoded as WKT text
LineString.parse(
format: WKT.geometry,
'LINESTRING Z (10.0 20.0 30.0,12.5 22.5 32.5,15.0 25.0 35.0)',
);

// a line string from 3D positions encoded as a comma delimited string
LineString.parseCoords(
// values for three (x, y, z) positions
'10.0,20.0,30.0,12.5,22.5,32.5,15.0,25.0,35.0',
type: Coords.xyz,
);

Other geometry objects have similar factories too.

Geometry objects also know how to calculate their minimum bounding boxes.

// a line string from 2D positions (without a bounding box stored)
final line = LineString.build(
[
10.0, 20.0, // (x, y) for position 0
12.5, 22.5, // (x, y) for position 1
15.0, 25.0, // (x, y) for position 2
],
type: Coords.xy,
);

// calculate the bounding box when needed
final bounds = line.calculateBounds();

// `bounds` would be equal with this one:
Box.create(minX: 10.0, minY: 20.0, maxX: 15.0, maxY: 25.0);

If bounding boxes are needed frequently it might be more efficient to populate bounding boxes on geometry objects.

// a line string from 2D positions (with bounding box populated and stored)
final line = LineString.build(
[
10.0, 20.0, // (x, y) for position 0
12.5, 22.5, // (x, y) for position 1
15.0, 25.0, // (x, y) for position 2
],
type: Coords.xy,
).populated(onBounds: true);

// as the line string already contains it's bounds, you can just access it
final bounds = line.bounds;

When reading external data, it’s possible that a minimum bounding box is included in data already. The last example uses such external data for bounding box.

// a line string from 2D positions (with a bounding box given explicitly)
final line = LineString.build(
[
10.0, 20.0, // (x, y) for position 0
12.5, 22.5, // (x, y) for position 1
15.0, 25.0, // (x, y) for position 2
],
type: Coords.xy,

// a bounding box (minX, minY, maxX, maxY)
bounds: Box.build(10.0, 20.0, 15.0, 25.0),
)

Features and feature collections

Feature is a geospatial entity with an identifier, a geometry and properties.

// A geospatial feature with id, a point geometry and properties.
Feature(
id: 'ROG',
// a point geometry with a position (lon, lat, elev)
geometry: Point.build([-0.0014, 51.4778, 45.0]),
properties: {
'title': 'Royal Observatory',
'place': 'Greenwich',
'city': 'London',
'isMuseum': true,
'measure': 5.79,
},
);

According to the OGC Glossary a geospatial feature is a digital representation of a real world entity. It has a spatial domain, a temporal domain, or a spatial/temporal domain as one of its attributes. Examples of features include almost anything that can be placed in time and space, including desks, buildings, cities, trees, forest stands, ecosystems, delivery vehicles, snow removal routes, oil wells, oil pipelines, oil spill, and so on.

A FeatureCollection is naturally a list of Featureobjects.

// A geospatial feature collection (with two features):
FeatureCollection([
Feature(
id: 'ROG',
// a point geometry with a position (lon, lat, elev)
geometry: Point.build([-0.0014, 51.4778, 45.0]),
properties: {
'title': 'Royal Observatory',
'place': 'Greenwich',
'city': 'London',
'isMuseum': true,
'measure': 5.79,
},
),
Feature(
id: 'TB',
// a point geometry with a position (lon, lat)
geometry: Point.build([-0.075406, 51.5055]),
properties: {
'title': 'Tower Bridge',
'city': 'London',
'built': 1886,
},
),
]);

Features and feature collections also are Bounded, that is they have similar options to calculate, store, specify explicitly and access minimum bounding boxes as described in the previous section for geometry objects.

And just like geometry objects, features and feature collections can be constructed using various alternative ways, for example using build, and parse factories with similar use cases to examples already shown for line strings. There is also a special static factory method fromData that takes as an input a JSON Object as decoded by json.decode() from external standardized data formats.

// a feature with an id and a point geometry (2D coordinates)
Feature.fromData(
format: GeoJSON.feature,

// `Map<String, dynamic>` compatible with objects created by `json.decode()`
// (in this case the object tree follows GeoJSON data structure)
{
'type': 'Feature',
'id': '1',
'geometry': {
'type': 'Point',
'coordinates': [10.0, 20.0]
}
},
);

Using geospatial data formats

The previous sections described how to build geometry and feature objects in multiple different ways in Dart code. And the samples above also hinted how to parse from external data formats or how to create objects fromData already encoded as a JSON Object tree.

The geobase package supports following common geospatial data formats

  • GeoJSON : Specified by the RFC 7946 standard. Supports all geometry and feature object types introduced above. Geometry objects use WGS 84 longitude/latitude geographic coordinates unless using an alternative coordinate reference system is explicitly specified.
  • WKT (Well-known text representation of geometry): A text markup language for representing vector geometry objects (but not applicable for feature objects). Specified by the Simple Feature Access — Part 1: Common Architecture.
  • WKB (Well-known binary): A binary format for representing same vector geometry objects as WKT.

In the package there are text format instances for geometry objects with constants GeoJSON.geometry and WKT.geometry. Use these when parsing geometry objects using parseor writing using toText. Similarly GeoJSON.feature can be used for feature objects.

This sample parses a WKT text representation and writes a geometry (back) to it’s text representation.

// parse a Point geometry from WKT text
final point = Point.parse(
'POINT ZM(10.123 20.25 -30.95 -1.999)',
format: WKT.geometry,
);

// format it (back) as WKT text that is printed:
// POINT ZM(10.123 20.25 -30.95 -1.999)
print(point.toText(format: WKT.geometry));

And this sample parses GeoJSON and decodes a feature object.

// a feature with an id and a point geometry (2D coordinates)
Feature.parse(
format: GeoJSON.feature,
'''
{
"type": "Feature",
"id": "1",
"geometry": {
"type": "Point",
"coordinates": [10.0, 20.0]
}
}
''',
);

The sample above assumes that the point has WGS84 geographic coordinates with longitude before latitude. If data would be in some other coordinate system (or axis order), then you should provide a coordinate reference system object with an optional crs parameter to ensure data is read correctly.

Binary representations are supported only for the WKB.geometry format currently. Geometry objects has the factory decode method to decode such data, and toBytes to encode a geometry object to the binary representation.

This sample encodes a point object to the WKB binary representation, and then decodes it back to a point object that is encoded to the WKT text representation (for demonstrative purposes).

// create a Point object
final point = Point.build([10.123, 20.25, -30.95, -1.999]);

// get encoded bytes (Uint8List)
final wkbBytes = point.toBytes(format: WKB.geometry);

// at this point our WKB bytes could be sent to another system...

// then create a Point object, but now decoding it from WKB bytes
final pointDecoded = Point.decode(wkbBytes, format: WKB.geometry);

// finally print WKT text:
// POINT ZM(10.123 20.25 -30.95 -1.999)
print(pointDecoded.toText(format: WKT.geometry));

Accessing feature services via Web APIs

Now you should already be familiar how to create geometry and feature objects in code (“by hand”) and how to decode/encode with common geospatial data formats using the geobase package.

The next step is to look how to access geospatial features from Web APIs based on the OGC API Features standard or static GeoJSON resources (stored locally or online). The client-side support for these resources in Dart and Flutter applications is provided by the geodata package that extends capabilities of the geobase package.

Decision flowchart to select a client class to access GeoJSON resources.

As an example let’s see shortly how to access OGC API Features services.

// 1. Get a client instance for a Web API endpoint.
final client = OGCAPIFeatures.http(endpoint: Uri.parse('...'));

// 2. Access/check metadata (meta, OpenAPI, conformance, collections) as needed.
final conformance = await client.conformance();
if (!conformance.conformsToFeaturesCore(geoJSON: true)) {
return; // not conforming to core and GeoJSON - so return
}

// 3. Get a feature source for a specific collection.
final source = await client.collection('my_collection');

// 4. Access (and check) metadata for this collection.
final meta = await source.meta();
print('Collection title: ${meta.title}');

// 5. Access feature items.
final items = await source.itemsAll(limit: 100);

// 6. Check response metadata.
print('Timestamp: ${items.timeStamp}');

// 7. Get an iterable of feature objects.
final features = items.collection.features;

// 8. Loop through features (each with id, properties and geometry)
for (final feat in features) {
print('Feature ${feat.id} with geometry: ${feat.geometry}');
}

Now you might be wondering what is OGC API Features as specified by the Open Geospatial Consortium (OGC)…

This is answered by the standard itself with a short summary:

OGC API Features provides API building blocks to create, modify and query features on the Web. OGC API Features is comprised of multiple parts, each of them is a separate standard. The “Core” part specifies the core capabilities and is restricted to fetching features where geometries are represented in the coordinate reference system WGS 84 with axis order longitude/latitude. Additional capabilities that address more advanced needs will be specified in additional parts.

As a background you might also want to check a good introduction about OGC API Features or a video about the OGC API standard family, both provided by OGC.

Coordinate systems and projections

According to Wikipedia a coordinate reference system is a coordinate-based local, regional or global system used to locate geographical entities. They are identified by identifiers specified by registries like The EPSG dataset and OGC CRS registry. Also W3C has a good introduction about coordinate reference systems.

The geobase package also contains CoordRefSys class with following static constants:

  • CRS84: WGS 84 geographic coordinates (order: longitude, latitude). See the definition by OGC.
  • CRS84h:WGS 84 geographic coordinates (order: longitude, latitude) with ellipsoidal height (elevation).
  • EPSG:4326:WGS 84 geographic coordinates (order: latitude, longitude).
  • EPSG:4258:ETRS89 geographic coordinates (order: latitude, longitude).
  • EPSG:3857:WGS 84 projected (Web Mercator) metric coordinates based on "spherical development of ellipsoidal coordinates".
  • EPSG:3395:WGS 84 projected (World Mercator) metric coordinates based on "ellipsoidal coordinates".

These constants can be used to check id, axisOrder and whether to swapXY when dealing with external data sources like GeoJSON data. However currently geodetic datum and projection parameters are not available.

Other identifiers can be added by creating a custom class implementing CoordRefSysResolver and by registering it's global instance using CoordRefSysResolver.register() on startup routines of your app.

Coordinate projections between geographic and projected (map) coordinate reference systems are available with a dependency to the external proj4dart package. When using projections you need at least following imports:

// import the default geobase library
import 'package:geobase/geobase.dart';

// need also an additional import with dependency to `proj4dart`
import 'package:geobase/projections_proj4d.dart';

A sample below applies a forward projection from WGS84 geographic coordinates to a projected coordinate system (EPSG:23700) with projection parameters defined.

// The projection adapter between WGS84 (CRS84) and EPSG:23700 (definition)
// (based on the sample at https://pub.dev/packages/proj4dart).
final adapter = Proj4d.init(
CoordRefSys.CRS84,
CoordRefSys.normalized('EPSG:23700'),
targetDef: '+proj=somerc +lat_0=47.14439372222222 +lon_0=19.04857177777778 '
'+k_0=0.99993 +x_0=650000 +y_0=200000 +ellps=GRS67 '
'+towgs84=52.17,-71.82,-14.9,0,0,0,0 +units=m +no_defs',
);

// The forward projection from WGS84 (CRS84) to EPSG:23700.
final forward = adapter.forward;

// A source geographic position.
const geographic = Geographic(lat: 46.8922, lon: 17.8880);

// Apply the forward projection returning a projected position in EPSG:23700.
final projected = geographic.project(forward);

// Prints: "561647.27300,172651.56518"
print(projected.toText(decimals: 5));

Please see the documentation of the proj4dart package about it’s capabilities, and accuracy of forward and inverse projections.

Geometry and feature objects have also project method that allow applying projections directly on them.

It’s also possible to implement your own custom projections by extending ProjectionAdapter mixin in your projection class. See Dart code of projections between WGS 84 (geographic) and Web Mercator (projected with metric coordinates) as an example. Custom projections can be applied to geometry and feature objects just like projections provided by packages discussed above.

Tiling schemes

Tiling schemes or tile matrix sets are used in tiled map services to index and reference fixed size map tiles in different zoom levels (map scales).

For example WebMercatorQuad is a "Google Maps Compatible" tile matrix set with tiles defined in the WGS 84 / Web Mercator projection (EPSG:3857).

Using WebMercatorQuad involves following coordinates:

  • position: geographic coordinates (longitude, latitude)
  • world: a position projected to the pixel space of the map at level 0
  • pixel: pixel coordinates (x, y) in the pixel space of the map at zoom
  • tile: tile coordinates (x, y) in the tile matrix at zoom

OGC Two Dimensional Tile Matrix Set describes this well:

Level 0 allows representing most of the world (limited to latitudes between approximately ±85 degrees) in a single tile of 256x256 pixels (Mercator projection cannot cover the whole world because mathematically the poles are at infinity). The next level represents most of the world in 2x2 tiles of 256x256 pixels and so on in powers of 2. Mercator projection distorts the pixel size closer to the poles. The pixel sizes provided here are only valid next to the equator.

See below how to calculate between geographic positions, world coordinates, pixel coordinates and tile coordinates.

// "WebMercatorQuad" tile matrix set with 256 x 256 pixel tiles and with
// "top-left" origin for the tile matrix and map pixel space
const quad = WebMercatorQuad.epsg3857();

// source position as geographic coordinates
const position = Geographic(lon: -0.0014, lat: 51.4778);

// get world, tile and pixel coordinates for a geographic position
print(quad.positionToWorld(position)); // ~ x=127.999004 y=85.160341
print(quad.positionToTile(position, zoom: 2)); // zoom=2 x=1 y=1
print(quad.positionToPixel(position, zoom: 2)); // zoom=2 x=511 y=340
print(quad.positionToPixel(position, zoom: 4)); // zoom=4 x=2047 y=1362

// world coordinates can be instantiated as projected coordinates
// x range: (0.0, 256.0) / y range: (0.0, 256.0)
const world = Projected(x: 127.99900444444444, y: 85.16034098329446);

// from world coordinates to tile and pixel coordinates
print(quad.worldToTile(world, zoom: 2)); // zoom=2 x=1 y=1
print(quad.worldToPixel(world, zoom: 2)); // zoom=2 x=511 y=340
print(quad.worldToPixel(world, zoom: 4)); // zoom=4 x=2047 y=1362

// tile and pixel coordinates with integer values can be defined too
const tile = Scalable2i(zoom: 2, x: 1, y: 1);
const pixel = Scalable2i(zoom: 2, x: 511, y: 340);

// tile and pixel coordinates can be zoomed (scaled to other level of details)
print(pixel.zoomIn()); // zoom=3 x=1022 y=680
print(pixel.zoomOut()); // zoom=1 x=255 y=170

// get tile bounds and pixel position (accucy lost) as geographic coordinates
print(quad.tileToBounds(tile)); // west: -90 south: 0 east: 0 north: 66.51326
print(quad.pixelToPosition(pixel)); // longitude: -0.17578 latitude: 51.50874

// world coordinates returns geographic positions still accurately
print(quad.worldToPosition(world)); // longitude: -0.00140 latitude: 51.47780

// aligned points (world, pixel and position coordinates) inside tile or edges
print(quad.tileToWorld(tile, align: Aligned.northWest));
print(quad.tileToPixel(tile, align: Aligned.center));
print(quad.tileToPosition(tile, align: Aligned.center));
print(quad.tileToPosition(tile, align: Aligned.southEast));

// get zoomed tile at the center of a source tile
final centerOfTile2 = quad.tileToWorld(tile, align: Aligned.center);
final tile7 = quad.worldToTile(centerOfTile2, zoom: 7);
print('tile at zoom 2: $tile => center of tile: $centerOfTile2 '
'=> tile at zoom 7: $tile7');

// a quad key is a string identifier for tiles
print(quad.tileToQuadKey(tile)); // "03"
print(quad.quadKeyToTile('03')); // zoom=2 x=1 y=1
print(quad.quadKeyToTile('0321')); // zoom=4 x=5 y=6

// tile size and map bounds can be checked dynamically
print(quad.tileSize); // 256
print(quad.mapBounds()); // ~ west: -180 south: -85.05 east: 180 north: 85.05

// matrix width and height tells number of tiles in a given zoom level
print('${quad.matrixWidth(2)} x ${quad.matrixHeight(2)}'); // 4 x 4
print('${quad.matrixWidth(10)} x ${quad.matrixHeight(10)}'); // 1024 x 1024

// map width and height tells number of pixels in a given zoom level
print('${quad.mapWidth(2)} x ${quad.mapHeight(2)}'); // 1024 x 1024
print('${quad.mapWidth(10)} x ${quad.mapHeight(10)}'); // 262144 x 262144

// ground resolutions and scale denominator for zoom level 10 at the Equator
print(quad.tileGroundResolution(10)); // ~ 39135.76 (meters)
print(quad.pixelGroundResolution(10)); // ~ 152.87 (meters)
print(quad.scaleDenominator(10)); // ~ 545978.77

// inverse: zoom from ground resolution and scale denominator
print(quad.zoomFromPixelGroundResolution(152.87)); // ~ 10.0 (double value)
print(quad.zoomFromScaleDenominator(545978.77)); // ~ 10.0 (double value)

// ground resolutions and scale denominator for zoom level 10 at lat 51.4778
print(quad.pixelGroundResolutionAt(latitude: 51.4778, zoom: 10)); // ~ 95.21
print(quad.scaleDenominatorAt(latitude: 51.4778, zoom: 10)); // ~ 340045.31

// inverse: zoom from ground resolution and scale denominator at lat 51.4778
print(
quad.zoomFromPixelGroundResolutionAt(
latitude: 51.4778,
resolution: 95.21,
),
); // ~ 10.0 (double value)
print(
quad.zoomFromScaleDenominatorAt(
latitude: 51.4778,
denominator: 340045.31,
),
); // ~ 10.0 (double value)

Also GlobalGeodeticQuad (or "World CRS84 Quad" for WGS 84) is supported. It’s a tile matrix set with tiles defined in the Equirectangular Plate Carrée projection. See the geobase package documentation for more information.

Conclusions

The two Dart code packages, introduced by this blog post and now published as stable 1.0 version, provide quite an extensive set of features on manipulating geospatial data with coordinate values, positions, geometries and feature objects for Dart and Flutter developers.

However the geospatial application domain and capabilities of geographic information systems (GIS) is much larger topic. The packages do not provide map user interface components, there are even more data formats outside the supported GeoJSON, WKT and WKB formats, and in real world applications the variety of different coordinate reference systems and tiling schemes is huge. Also 3D geometry types, coverage data structures, spatial indexes, topology, geocoding, spatial analysis functions, geometry simplification, cartographic elements, and modern Cloud-Native for Geospatial formats are not supported.

Anyway as the summary capabilities of code packages are shown below.

Key features of the geobase package:

  • 🌐 geographic (longitude-latitude) and projected positions and bounding boxes
  • 📐 spherical geodesy functions for great circle and rhumb line paths
  • 🧩 simple geometries (point, line string, polygon, multi point, multi line string, multi polygon, geometry collection)
  • 🔷 features (with id, properties and geometry) and feature collections
  • 📅 temporal data structures (instant, interval) and spatial extents
  • 📃 vector data formats supported (GeoJSON, WKT, WKB )
  • 🗺️ coordinate projections (web mercator + based on the external proj4dart library)
  • 🔢 tiling schemes and tile matrix sets (web mercator, global geodetic)

Key features of the geodata package:

  • 🪄 Client-side data source abstraction for geospatial feature service Web APIs
  • 🌐 The GeoJSON client to read features from static web resources and local files
  • 🌎 The OGC API Features client to access metadata and feature items from a compliant geospatial Web API providing GeoJSON data

As mentioned on the introduction all code is Open Source and available in the Geospatial tools for Dart repository in GitHub.

Check out also open issues in GitHub. Any suggestions, feedback and code contributions are welcome.

--

--