Skip to content

Repository files navigation






GISTools

GIS tools for Swift, including a GeoJSON implementation and many algorithms ported from https://turfjs.org.

Table of Contents

Features

  • Supports the full GeoJSON standard
  • Load and write GeoJSON objects from and to [String:Any], URL, Data and String
  • Supports Codable and SwiftData (see below)
  • Supports a wide range of projections (see Projections): EPSG:4326 (geodetic), 3857 (web mercator), 4978 (ECEF geocentric), 3395 (World Mercator), 32662 (Plate Carree), 4258 (ETRS89), 4267 (NAD27), 4269 (NAD83), 4277/27700 (OSGB 1936 / British National Grid), 2056/21781 (Swiss CH1903+/LV95 and CH1903/LV03), 29902/29903/2157 (Irish Grid and Irish Transverse Mercator), 25831–25837 (ETRS89/UTM), 26901–26960 (NAD83/UTM), 3035/3034 (EU-wide LAEA and LCC), 5070 (US Conus Albers), 3005 (BC Albers), 3347/3978 (Canadian Lambert), 2154 (French Lambert-93), 28992 (Dutch RD New), 31466–31469 (German DHDN/Gauss-Krüger), 31255–31259 (Austrian MGI/Gauss-Krüger) and all 120 WGS84 UTM zones, plus user-definable custom projections
  • Supports WKT/WKB/TWKB, also with different projections
  • gis-tools-shapefile — reads and writes ESRI Shapefiles (.shp/.dbf/.shx/.prj)
  • gis-tools-geopackage — reads and writes OGC GeoPackage (.gpkg) files
  • gis-tools-gpx — reads and writes GPX 1.1 files (.gpx)
  • gis-tools-fit — reads and writes FIT activity files (.fit)
  • Spatial search with a R-tree
  • Includes many spatial algorithms (ported from turf.js), and more to come
  • Many algorithms accept a gridSize parameter to snap coordinates to a uniform grid before computation, reducing noise from floating-point precision
  • Handles coordinates across the anti-meridian (±180° longitude) — geometries can wrap around the date line
  • Has a helper for working with x/y/z map tiles (center/bounding box/resolution/…)
  • Can encode/decode Polylines
  • Includes a property/spatial query DSL for filtering features (QueryParser)
  • Includes a Graph type for routing and network analysis — Dijkstra, A*, bidirectional search, K-shortest paths, multi-criteria routing, chain contraction, dead-end pruning, bridge/articulation-point detection, betweenness centrality, strongly connected components, minimum spanning tree, Eulerian/Chinese Postman tours, TSP approximation, graph tile merging with spatial deduplication, and export back to GeoJSON
  • Pure Swift without external dependencies

Notes

This package makes some assumptions about what is equal, i.e. coordinates that are inside of 1e-10 degrees are regarded as equal (that's μm precision and is probably overkill). See GISTool.equalityDelta.

Per RFC 7946 §3.1.9, geometries crossing the anti-meridian (±180°) should be cut into parts. The cutAtAntimeridian() functions return a FeatureCollection with one Feature per cut geometry part. This makes iterating the results uniform regardless of the input type. Works natively for EPSG:4326 and EPSG:3857 (splits at ±180° and ±originShift respectively). For EPSG:4978 and noSRID, returns the original geometry unchanged (the antimeridian concept does not apply).

Requirements

This package requires Swift 6.1 or higher (at least Xcode 15), and compiles on iOS (>= iOS 15), macOS (>= macOS 15), tvOS (>= tvOS 15), watchOS (>= watchOS 7) as well as Linux, Android and Wasm.

Installation with Swift Package Manager

dependencies: [
    .package(url: "https://github.com/Outdooractive/gis-tools", from: "2.3.0"),
],
targets: [
    .target(name: "MyTarget", dependencies: [
        .product(name: "GISTools", package: "gis-tools"),
    ]),
]

Package Traits

This package provides the following optional traits:

Unit conversion traits (mutually exclusive):

  • EnableMeasurementConversionExtensions — conversion properties return Measurement<UnitLength> values, enabling unit-aware arithmetic and formatting.
  • EnableMeterConversionExtensions — conversion properties return raw Double meters, providing a lightweight alternative.
// With EnableMeasurementConversionExtensions:
let distance: Measurement<UnitLength> = 1000.0.meters
let total = distance + 500.0.feet  // Measurement arithmetic

// With EnableMeterConversionExtensions:
let distance: Double = 1000.0.meters  // raw meters
let total = distance + 500.0.feet     // Double arithmetic (both in meters)

Usage

Please see also the API documentation (via Swift Package Index).

import GISTools

var feature = Feature(Point(Coordinate3D(latitude: 3.870163, longitude: 11.518585)))
feature.properties = [
    "test": 1,
    "test2": 5.567,
    "test3": [1, 2, 3],
    "test4": [
        "sub1": 1,
        "sub2": 2
    ]
]

// To and from String:
let jsonString = feature.asJsonString(prettyPrinted: true)
let feature = Feature(jsonString: jsonString)

// To and from Data:
let jsonData = feature.asJsonData(prettyPrinted: true)
let feature = Feature(jsonData: jsonData)

// Using Codable:
let jsonData = try JSONEncoder().encode(feature)
let feature = try JSONDecoder().decode(Feature.self, from: jsonData)

// Generic:
let someGeoJson = GeoJsonReader.geoJsonFrom(json: [
    "type": "Point",
    "coordinates": [100.0, 0.0],
])
let someGeoJson = GeoJsonReader.geoJsonFrom(contentsOf: URL(...))
let someGeoJson = GeoJsonReader.geoJsonFrom(jsonData: Data(...))
let someGeoJson = GeoJsonReader.geoJsonFrom(jsonString: "{\"type\":\"Point\",\"coordinates\":[100.0,0.0]}")

switch someGeoJson {
case let point as Point: ...
}
// or
switch someGeoJson.type {
case .point: ...
}

// Wraps *any* GeoJSON into a FeatureCollection
let featureCollection = FeatureCollection(jsonData: someData)
let featureCollection = try JSONDecoder().decode(FeatureCollection.self, from: someData)

...

See the tests for more examples and also the API documentation.

GeoJSON

To quote from the RFC 7946:

GeoJSON is a geospatial data interchange format based on JavaScript Object Notation (JSON).
It defines several types of JSON objects and the manner in which they are combined to represent data about geographic features, their properties, and their spatial extents.
GeoJSON uses a geographic coordinate reference system, World Geodetic System 1984, and units of decimal degrees.

Please read the RFC first to get an overview of what GeoJSON is and is not (in the somewhat unlikely case that you don’t already know all of this… 🙂).

GeoJson protocol

Implementation

The basics for every GeoJSON object:

/// All permitted GeoJSON types.
public enum GeoJsonType: String {
    case point              = "Point"
    case multiPoint         = "MultiPoint"
    case lineString         = "LineString"
    case multiLineString    = "MultiLineString"
    case polygon            = "Polygon"
    case multiPolygon       = "MultiPolygon"
    case geometryCollection = "GeometryCollection"
    case feature            = "Feature"
    case featureCollection  = "FeatureCollection"
}

/// GeoJSON object type.
var type: GeoJsonType { get }

/// The GeoJSON's projection, which should typically be EPSG:4326.
var projection: Projection { get }

/// All of the receiver's coordinates.
var allCoordinates: [Coordinate3D] { get }

/// Any foreign members, i.e. keys in the JSON that are
/// not part of the GeoJSON standard.
var foreignMembers: [String: Any] { get set }

/// Try to initialize a GeoJSON object from any JSON and calculate a bounding box if necessary.
init?(json: Any?, calculateBoundingBox: Bool)

/// Type erased equality check.
func isEqualTo(_ other: GeoJson) -> Bool

BoundingBoxRepresentable protocol

Implementation

All GeoJSON objects may have a bounding box. It is required though if you want to use the R-tree spatial index (see below).

/// The GeoJSON's projection.
var projection: Projection { get }

/// The receiver's bounding box.
var boundingBox: BoundingBox? { get set }

/// Calculates and returns the receiver's bounding box.
func calculateBoundingBox() -> BoundingBox?

/// Calculates the receiver's bounding box and updates the `boundingBox` property.
///
/// - parameter ifNecessary: Only update the bounding box if the receiver doesn't already have one.
@discardableResult
mutating func updateBoundingBox(onlyIfNecessary ifNecessary: Bool) -> BoundingBox?

/// Check if the receiver is inside or crosses  the other bounding box.
///
/// - parameter otherBoundingBox: The bounding box to check.
func intersects(_ otherBoundingBox: BoundingBox) -> Bool

GeoJsonConvertible protocol / GeoJsonCodable

Implementation

GeoJSON objects can be initialized from a variety of sources:

/// Try to initialize a GeoJSON object from any JSON.
init?(json: Any?)

/// Try to initialize a GeoJSON object from a file.
init?(contentsOf url: URL)

/// Try to initialize a GeoJSON object from a data object.
init?(jsonData: Data)

/// Try to initialize a GeoJSON object from a string.
init?(jsonString: String)

/// Try to initialize a GeoJSON object from a Decoder.
init(from decoder: Decoder) throws

They can also be exported in several ways:

/// Return the GeoJson object as Key/Value pairs.
var asJson: [String: Any] { get }

/// Dump the object as JSON data.
func asJsonData(prettyPrinted: Bool = false) -> Data?

/// Dump the object as a JSON string.
func asJsonString(prettyPrinted: Bool = false) -> String?

/// Write the object in it's JSON represenation to a file.
func write(to url: URL, prettyPrinted: Bool = false) throws

/// Write the GeoJSON object to an Encoder.
func encode(to encoder: Encoder) throws

Example:

let point = Point(jsonString: "{\"type\":\"Point\",\"coordinates\":[100.0,0.0]}")!
print(point.allCoordinates)
print(point.asJsonString(prettyPrinted: true)!)

let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let data = try encoder.encode(point)

// This works because `FeatureCollection` will wrap any valid GeoJSON object.
// This is a good way to enforce a common structure for all loaded objects.
let featureCollection = FeatureCollection(jsonData: data)!

Important note: Import and export will always be done in EPSG:4326, with one exception: GeoJSON objects with no SRID will be exported as-is.

GeoJsonReader

Implementation

This is a generic way to create GeoJSON objects from anything that looks like GeoJSON:

/// Try to initialize a GeoJSON object from any JSON.
static func geoJsonFrom(json: Any?) -> GeoJson?

/// Try to initialize a GeoJSON object from a file.
static func geoJsonFrom(contentsOf url: URL) -> GeoJson?

/// Try to initialize a GeoJSON object from a data object.
static func geoJsonFrom(jsonData: Data) -> GeoJson?

/// Try to initialize a GeoJSON object from a string.
static func geoJsonFrom(jsonString: String) -> GeoJson?

The reader can also auto-detect a geometry from a string or data payload, regardless of whether it is GeoJSON, WKT (with or without an SRID=…; prefix), or hex-encoded WKB/EWKB/TWKB:

/// Try to initialize a geometry from a string, auto-detecting the format.
static func geometryFrom(string: String, targetProjection: Projection = .epsg4326) -> GeoJsonGeometry?

/// Try to initialize a geometry from data, auto-detecting the format.
static func geometryFrom(data: Data, targetProjection: Projection = .epsg4326) -> GeoJsonGeometry?

Example:

// A PostGIS EWKB hex string (SRID 3857) is decoded and projected to EPSG:4326.
let ewkb = "0102000020110F00000F000000B1AB426CB24C3141FF9A56141D015741..."
let lineString = GeoJsonReader.geometryFrom(string: ewkb) as! LineString

// Plain WKT and SRID-prefixed WKT are both recognized.
let point = GeoJsonReader.geometryFrom(string: "SRID=4326;POINT (11.5 48.1)") as! Point

The geoJsonFrom methods work on any GeoJSON-shaped input:

let json: [String: Any] = [
    "type": "Point",
    "coordinates": [100.0, 0.0],
    "other": "something",
]
let geoJson = GeoJsonReader.geoJsonFrom(json: json)!
print("Type is \(geoJson.type.rawValue)")
print("Foreign members: \(geoJson.foreignMembers)")

switch geoJson {
case let point as Point:
    print("It's a Point!")
case let multiPoint as MultiPoint:
    print("It's a MultiPoint!")
case let lineString as LineString:
    print("It's a LineString!")
case let multiLineString as MultiLineString:
    print("It's a MultiLineString!")
case let polygon as Polygon:
    print("It's a Polygon!")
case let multiPolygon as MultiPolygon:
    print("It's a MultiPolygon!")
case let geometryCollection as GeometryCollection:
    print("It's a GeometryCollection!")
case let feature as Feature:
    print("It's a Feature!")
case let featureCollection as FeatureCollection:
    print("It's a FeatureCollection!")
default: 
    assertionFailure("Missed an object type?")
}

Important note: Import will always be done in EPSG:4326.

Coordinate3D

Implementation / Coordinate test cases

Coordinates are the most basic building block in this package. Every object and algorithm builds on them:

/// The coordinates projection, either EPSG:4326 or EPSG:3857.
let projection: Projection

/// The coordinate's `latitude`.
var latitude: CLLocationDegrees
/// The coordinate's `longitude`.
var longitude: CLLocationDegrees
/// The coordinate's `altitude`.
var altitude: CLLocationDistance?

/// Linear referencing, timestamp or whatever you want it to use for.
///
/// The GeoJSON specification doesn't specifiy the meaning of this value,
/// and it doesn't guarantee that parsers won't ignore or discard it. See
/// https://datatracker.ietf.org/doc/html/rfc7946#section-3.1.1.
/// - Important: The JSON for a coordinate will contain a `null` altitude value
///              if `altitude` is `nil` so that `m` won't get lost (since it is
///              the 4th value).
///              This might lead to compatibilty issues with other GeoJSON readers.
var m: Double?

/// Alias for longitude
var x: Double { longitude }

/// Alias for latitude
var y: Double { latitude }

/// Create a coordinate with `latitude`, `longitude`, `altitude` and `m`.
/// Projection will be EPSG:4326.
init(latitude: CLLocationDegrees,
     longitude: CLLocationDegrees,
     altitude: CLLocationDistance? = nil,
     m: Double? = nil)

/// Create a coordinate with ``x``, ``y``, ``z`` and ``m``.
/// Default projection will we EPSG:3857 but can be overridden.
init(
    x: Double,
    y: Double,
    z: Double? = nil,
    m: Double? = nil,
    projection: Projection = .epsg3857)

/// Reproject this coordinate.
func projected(to newProjection: Projection) -> Coordinate3D

Example:

let coordinate = Coordinate3D(latitude: 0.0, longitude: 0.0)
print(coordinate.isZero)

Projections

Implementation

Coordinates carry their projection with them and all operations keep them in it. Coordinates can be re-projected:

let coordinate = Coordinate3D(latitude: 41.0, longitude: -71.0)
let utm19 = coordinate.projected(to: .epsg32619)   // UTM zone 19N
let mercator = utm19.projected(to: .epsg3857)
let projectedBack = mercator.projected(to: .epsg4326)
print(coordinate.projection.description, coordinate.projection.srid)

UTM zones can also be selected from a coordinate — including the EPSG Norway ("32V") and Svalbard ("31X/32X/33X/35X/37X") banding exceptions:

let oslo = Coordinate3D(latitude: 59.91149, longitude: 10.75793)
let utm = Projection.utmZone(for: oslo)   // EPSG:32632 (zone 32, the Norwegian exception)
let osloUtm = oslo.projected(to: utm)

Densification, buffer, distance etc. automatically take the projection into account. Beyond the built-in projections listed below, custom projections can be registered - registration is add-only, applied for the whole process, typically at startup. Datums of built-in CRSs can be inspected via Projection.epsg27700.datum (== Datum.osgb1936, Airy 1830):

let custom = CustomProjection(
    srid: 900_001,
    kind: .planar,
    validExtent: ProjectionExtent(minX: -200_000, minY: -200_000, maxX: 200_000, maxY: 200_000),
    forward: { coordinate in
        Coordinate3D(latitude: coordinate.latitude * 1_000.0,
                     longitude: coordinate.longitude * 1_000.0)
    },
    inverse: { coordinate in
        Coordinate3D(latitude: coordinate.latitude / 1_000.0,
                     longitude: coordinate.longitude / 1_000.0)
    })
Projection.register(custom)

let customProjection = try Projection(srid: 900_001)

Implemented projections

EPSG Response Coordinate units Transformation Source
4326 WGS84 geodetic degrees pivot ProjectionDefinition.swift
3857 Web Mercator meters spherical Mercator Epsg3857Definition.swift
4978 WGS84 geocentric (ECEF) meters geodetic <-> geocentric (WGS84) Epsg4978Definition.swift
3395 WGS84 / World Mercator meters ellipsoidal Mercator Epsg3395Definition.swift
32662 WGS84 / Plate Carree degrees identity Epsg32662Definition.swift
4258 ETRS89 geodetic degrees identity (≈ WGS84) Etrs89Definition.swift
4267 NAD27 geodetic degrees Helmert "NAD27 to WGS 84 (4)", ~10 m Nad27Definition.swift
4277 OSGB 1936 geodetic degrees Helmert "OSGB 1936 to WGS 84 (6)", ~2 m Osgb1936Definition.swift
27700 OSGB 1936 / British National Grid meters Helmert + TM on Airy 1830 Osgb1936BngDefinition.swift
32601–32660 UTM zones 1N–60N meters transverse Mercator, Karney series (WGS84) UtmDefinition.swift
32701–32760 UTM zones 1S–60S meters transverse Mercator, Karney series (WGS84) UtmDefinition.swift
2056 CH1903+ / LV95 meters Helmert "CH1903+ to WGS 84 (1)" + Swiss oblique Mercator on Bessel 1841 Ch1903PlusLv95Definition.swift
21781 CH1903 / LV03 meters same projection, false easting/northing shifted Ch1903Lv03Definition.swift
29902 TM65 / Irish Grid meters Helmert "TM65 to WGS 84 (2)" + TM on Modified Airy IrishGridTM65Definition.swift
29903 TM75 / Irish Grid meters same projection, TM75 datum IrishGridTM75Definition.swift
2157 IRENET95 / Irish Transverse Mercator meters TM on GRS80 (≈ WGS84) IrishTransverseMercatorDefinition.swift
25831–25837 ETRS89/UTM zones 31N–37N meters transverse Mercator, Karney series (GRS80, ≈ WGS84) Etrs89UtmDefinition.swift
3035 ETRS89-LAEA Europe meters Lambert azimuthal equal-area (GRS80, ≈ WGS84) Etrs89LaeaDefinition.swift
3034 ETRS89-LCC Europe meters Lambert conformal conic 35°/65° (GRS80, ≈ WGS84) Etrs89LccDefinition.swift
2154 RGF93 / Lambert-93 meters Lambert conformal conic 49°/44° (GRS80, ≈ WGS84) Lambert93Definition.swift
28992 Amersfoort / RD New meters Helmert "Amersfoort to WGS 84 (4)" + oblique stereographic on Bessel 1841 RdNewDefinition.swift
31466–31469 DHDN / Gauss-Krüger zones 2–5 meters Helmert "DHDN to WGS 84 (2)", ~3 m + transverse Mercator, Karney series (Bessel 1841) DhdnGkDefinition.swift
31255–31259 MGI / Austria Gauss-Krüger meters Helmert "MGI to WGS 84 (2)", ~1.5 m + transverse Mercator, Karney series (Bessel 1841) MgiGkDefinition.swift
4269 NAD83 geodetic degrees identity (≈ WGS84) Nad83Definition.swift
26901–26960 NAD83 / UTM zones 1N–60N meters transverse Mercator, Karney series (GRS80, ≈ WGS84) Nad83UtmDefinition.swift
5070 NAD83 / Conus Albers meters Albers equal-area (GRS80, ≈ WGS84) Epsg5070Definition.swift
3005 NAD83 / BC Albers meters Albers equal-area (GRS80, ≈ WGS84) Epsg3005Definition.swift
3347 NAD83 / Statistics Canada Lambert meters Lambert conformal conic (GRS80, ≈ WGS84) Epsg3347Definition.swift
3978 NAD83 / Canada Atlas Lambert meters Lambert conformal conic (GRS80, ≈ WGS84) Epsg3978Definition.swift

Custom projections register through Projection.register(CustomProjection) (see CustomProjection.swift); the model types live in Projection.swift/ProjectionKind.swift/ProjectionExtent.swift/Datum.swift and the WKT matching / registry in ProjectionRegistry.swift resp. ProjectionDefinition.swift in Sources/GISTools/Projections/.

All algorithms dispatch on the projection kind (geographic/planar/geocentric) and honor the definition's capabilities (wraparound extents, valid ranges, world bounding boxes), so custom projections work across the whole library like built-in ones.

Datum accuracy note

The datum-capable built-in projections (NAD27, OSGB 1936, British National Grid, Swiss LV95/LV03, Irish Grid TM65/TM75 and Dutch RD New) use the published EPSG Helmert transformations ("NAD27 to WGS 84 (4)", ~10 m; "OSGB 1936 to WGS 84 (6)" / EPSG:1314, ~2 m; "CH1903+ to WGS 84 (1)" / EPSG:1676, ~1 m; "TM65 to WGS 84 (2)" / EPSG:1641, ~1 m; "Amersfoort to WGS 84 (4)" / EPSG:4833, ~1 m). Sub-meter-centimeter accuracy for NAD27 (NADCON), Great Britain (OSTN15, EPSG:7709), Switzerland (CHENyx06a.gsb, EPSG:15486) or the Netherlands (RD-transformation grids) requires grid shift files, which the library deliberately does not bundle (see #248). Datum transformations are parameterized via HelmertTransformation for use in your own CustomProjection definitions.

Attribution: EPSG parameter values are based on the EPSG dataset (https://epsg.org) used under its terms; OS transform parameters reference the Ordnance Survey Guide to Coordinate Systems in Great Britain. The UTM zones use Karney's transverse Mercator (the Krüger series to 6th order, arXiv:1002.1417), accurate to nanometers anywhere within the zones instead of the Snyder series' ±3–4° degradation; compared to the previous Snyder-based values the results shift at the sub-millimeter level in-zone.

SwiftData

You need to use a transformer for using GeoJson with SwiftData (also have a look at the SwiftData test cases).

First, register the transformer like this:

GeoJsonTransformer.register()

Then create your models like this:

@Attribute(.transformable(by: GeoJsonTransformer.name.rawValue)) var geoJson: GeoJson?
@Attribute(.transformable(by: GeoJsonTransformer.name.rawValue)) var point: Point?
...

This is necessary because SwiftData doesn't work well with the default Codable implementation, so you need to do the serialization for yourself...

WKB/WKT/TWKB

The following geometry types are supported: point, linestring, linearring, polygon, multipoint, multilinestring, multipolygon, geometrycollection and triangle. Please open an issue if you need more.

Every GeoJSON object has convenience methods to encode and decode themselves to and from WKB/WKT, and there are extensions for Data and String to decode from WKB, WKT and TWKB to GeoJSON. In the end, they all forward to WKBCoder, WKTCoder and TWKBCoder which do the heavy lifting.

WKB

Also have a look at the WKB test cases.

Decoding:

// SELECT 'POINT Z (1 2 3)'::geometry;
private let pointZData = Data(hex: "0101000080000000000000F03F00000000000000400000000000000840")!

// Generic
let point = try WKBCoder.decode(wkb: pointData, sourceProjection: .epsg4326) as! Point
let point = pointZData.asGeoJsonGeometry(sourceProjection: .epsg4326) as! Point

// Or create the geometry directly
let point = Point(wkb: pointZData, sourceProjection: .epsg4326)!

// Or create a Feature that contains the geometry
let feature = Feature(wkb: pointZData, sourceProjection: .epsg4326)
let feature = pointZData.asFeature(sourceProjection: .epsg4326)

// Or create a FeatureCollection that contains a feature with the geometry
let featureCollection = FeatureCollection(wkb: pointZData, sourceProjection: .epsg4326)
let featureCollection = pointZData.asFeatureCollection(sourceProjection: .epsg4326)

// Can also reproject on the fly
let point = try WKBCoder.decode(
    wkb: pointData,
    sourceProjection: .epsg4326,
    targetProjection: .epsg3857
) as! Point
print(point.projection)

Encoding:

let point = Point(Coordinate3D(latitude: 0.0, longitude: 100.0))

// Generic
let encodedPoint = WKBCoder.encode(geometry: point, targetProjection: nil)

// Convenience
let encodedPoint = point.asWKB

WKT

This is exactly the same as WKB… Also have a look at the tests to see how it works: WKT test cases

Decoding:

private let pointZString = "POINT Z (1 2 3)"

// Generic
let point = try WKTCoder.decode(wkt: pointZString, sourceProjection: .epsg4326) as! Point
let point = pointZString.asGeoJsonGeometry(sourceProjection: .epsg4326) as! Point

// Or create the geometry directly
let point = Point(wkt: pointZString, sourceProjection: .epsg4326)!

// Or create a Feature that contains the geometry
let feature = Feature(wkt: pointZString, sourceProjection: .epsg4326)
let feature = pointZString.asFeature(sourceProjection: .epsg4326)

// Or create a FeatureCollection that contains a feature with the geometry
let featureCollection = FeatureCollection(wkt: pointZString, sourceProjection: .epsg4326)
let featureCollection = pointZString.asFeatureCollection(sourceProjection: .epsg4326)

// Can also reproject on the fly
let point = try WKTCoder.decode(
    wkt: pointZString,
    sourceProjection: .epsg4326,
    targetProjection: .epsg3857
) as! Point
print(point.projection) // EPSG:3857

Encoding:

let point = Point(Coordinate3D(latitude: 0.0, longitude: 100.0))

// Generic
let encodedPoint = WKTCoder.encode(geometry: point, targetProjection: nil)

// Convenience
let encodedPoint = point.asWKT

TWKB

This is a decode-only coder for Tiny WKB. Also have a look at the TWKB test cases.

Decoding:

// TWKB Point at (0, 0) with precision 6
private let pointData = Data([0x61, 0x00, 0x00, 0x00])

// Generic
let point = try TWKBCoder.decode(twkb: pointData) as! Point
let point = pointData.asGeoJsonGeometryFromTWKB(sourceProjection: .epsg4326) as! Point

// Or with sourceSrid
let point = try TWKBCoder.decode(twkb: pointData, sourceSrid: 4326) as! Point
let point = pointData.asGeoJsonGeometryFromTWKB(sourceSrid: 4326) as! Point

// Or create the geometry directly
let point = Point(twkb: pointData)!

// Or create a Feature that contains the geometry
let feature = Feature(twkb: pointData)
let feature = pointData.asFeatureFromTWKB(sourceProjection: .epsg4326)

// Or create a FeatureCollection that contains a feature with the geometry
let featureCollection = FeatureCollection(twkb: pointData)
let featureCollection = pointData.asFeatureCollectionFromTWKB(sourceProjection: .epsg4326)

// Can also reproject on the fly
let point = try TWKBCoder.decode(
    twkb: pointData,
    sourceProjection: .epsg4326,
    targetProjection: .epsg3857
) as! Point
print(point.projection) // EPSG:3857

Spatial index

This package includes a simple R-tree implementation: RTree test cases

var nodes: [Point] = []
50.times {
    nodes.append(Point(Coordinate3D(
        latitude: Double.random(in: -10.0 ... 10.0),
        longitude: Double.random(in: -10.0 ... 10.0))))
    }

let rTree = RTree(nodes)
let objects = rTree.search(inBoundingBox: boundingBox)
let objectsAround = rTree.search(aroundCoordinate: center, maximumDistance: maximumDistance)

MapTile

This is a helper for working with x/y/z map tiles.

let tile1 = MapTile(x: 138513, y: 91601, z: 18)
let center = tile1.centerCoordinate(projection: .epsg4326) // default
let boundingBox = tile1.boundingBox(projection: .epsg4326) // default

let tile2 = MapTile(coordinate: Coordinate3D(latitude: 47.56, longitude: 10.22), atZoom: 14)
let parent = tile2.parent
let firstChild = tile2.child
let allChildren = tile2.children

let quadkey = tile1.quadkey
let tile3 = MapTile(quadkey: "1202211303220032")

Also, not directly related to map tiles:

let mpp = MapTile.metersPerPixel(at: 15.0, latitude: 45.0)

Polylines

Provides an encoder/decoder for Polylines.

let polyline = [Coordinate3D(latitude: 47.56, longitude: 10.22)].encodePolyline()
let coordinates = polyline.decodePolyline()

Query DSL

This package includes a query DSL parser and evaluator for filtering Feature objects by their properties and spatial location. The parser uses Reverse Polish Notation (RPN) internally but accepts a natural infix syntax.

QueryParser

let parser = QueryParser(string: ".highway == primary and .name =~ '^Main'")
let matches = parser.evaluate(on: someFeature)

Property access

Properties are accessed by prefixing the key with .:

Query Meaning
.name Property name exists and is truthy
.foo.bar Nested property foo → bar
."foo.bar" Property whose key contains a dot
.foo.[0] First element of array property foo
.some.0 Shorthand for array access

Comparisons

Operator Meaning Example
== Equal .value == 1
!= Not equal .value != 2
> Greater than .value > 0
>= Greater or equal .value >= 1
< Less than .value < 2
<= Less or equal .value <= 1
=~ Regex match .name =~ /^Main/i
=* String contains .name =* "ain"
=^ String starts with .name =^ "Mai"
=$ String ends with .name =$ "ain"

Cross-type numeric comparisons work automatically (e.g. Int vs Double).

Set membership

.class in ["primary", "secondary"]
.value in [1, 3, 5]

Grouping with parentheses

Expressions can be grouped with ( and ) to override default left-to-right evaluation:

(.name == "Berlin" OR .name == "Paris") AND .population > 100000
NOT (.bridge exists) OR .oneway == true
(.highway in ["primary", "secondary"] AND .surface == "asphalt") OR .bridge == "yes"

Parentheses may be nested up to any depth.

Boolean logic

Operator Meaning Example
and Logical AND .a == 1 and .b == 2
or Logical OR .a == 1 or .b == 1
not Logical NOT .a not
exists Truthy check .a exists

Spatial predicates

Predicate Syntax Meaning
near near(lat, lon, tolerance) Feature centroid is within tolerance meters
within within(minLon, minLat, maxLon, maxLat) Feature bbox is fully inside the rectangle
intersects intersects(minLon, minLat, maxLon, maxLat) Feature geometry intersects the rectangle

Convenience methods

// Filter a FeatureCollection by query string
let hospitals = featureCollection.query(term: ".class == 'hospital'")

// Filter an array of Features
let matches = features.query(term: ".name =~ /hospital/i and near(48.85, 2.35, 1000)")

// Complex grouped query
let result = featureCollection.query(term: "(.amenity == \"restaurant\" AND .stars >= 3) OR .cuisine == \"italian\"")

GeoPackage (.gpkg)

GeoPackage support has been extracted into its own package: gis-tools-geopackage.

dependencies: [
    .package(url: "https://github.com/Outdooractive/gis-tools-geopackage", from: "1.0.0"),
]

Shapefile (.shp / .dbf / .shx / .prj)

Shapefile support has been extracted into its own package: gis-tools-shapefile.

dependencies: [
    .package(url: "https://github.com/Outdooractive/gis-tools-shapefile", from: "1.0.0"),
]

GPX (.gpx)

GPX support has been extracted into its own package: gis-tools-gpx.

dependencies: [
    .package(url: "https://github.com/Outdooractive/gis-tools-gpx", from: "1.0.0"),
]

FIT (.fit)

FIT support has been extracted into its own package: gis-tools-fit.

dependencies: [
    .package(url: "https://github.com/Outdooractive/gis-tools-fit", from: "1.0.0"),
]

Algorithms

Hint: Most algorithms are optimized for EPSG:4326. Using other projections will have a performance penalty due to added projections.
The union algorithm works in EPSG:3857 (Web Mercator) for uniform Cartesian tolerances. This limits its usable latitude range to approximately ±85°.

Name Example Source/Tests
along let coordinate = lineString.coordinateAlong(distance: 100.0) Source / Tests
antimeridian-cutting let result = lineString.cutAtAntimeridian() Source / Tests
area Polygon(…).area Source
bearing Coordinate3D(…).bearing(to: Coordinate3D(…)) Source / Tests
bezier-spline let spline = lineString.bezierSpline() Source / Tests
boolean-clockwise Polygon(…).outerRing?.isClockwise Source / Tests
boolean-concave anyGeometry.isConcave() Source / Tests
boolean-contains/within polygon.contains(lineString) / point.isWithin(polygon) Source / Tests
boolean-crosses lineString.crosses(otherLineString) Source / Tests
boolean-disjoint let result = polygon.isDisjoint(with: lineString) Source / Tests
boolean-intersects let result = polygon.intersects(with: lineString) Source
boolean-overlap lineString1.isOverlapping(with: lineString2) Source / Tests
boolean-parallel lineString1.isParallel(to: lineString2) Source / Tests
boolean-point-in-polygon polygon.contains(Coordinate3D(…)) Source / Tests
boolean-point-on-line lineString.checkIsOnLine(Coordinate3D(…)) Source / Tests
boolean-touches anyGeometry.touches(other) Source / Tests
boolean-valid anyGeometry.isValid Source / Tests
bbox-clip let clipped = lineString.clipped(to: boundingBox) Source / Tests
boundary let boundary = anyGeometry.boundary Source / Tests
buffer let buffered = lineString.buffered(by: 1000.meters) Source / Tests
center-median let median = featureCollection.centerMedian() Source / Tests
center/centroid/center-mean let center = polygon.center Source / Tests
circle let circle = point.circle(radius: 5000.0) Source / Tests
clean let cleaned = lineString.cleaned() Source / Tests
clusters-dbscan let result = featureCollection.dbscanClusters(maxDistance: 100.0, minPoints: 3) Source / Tests
clusters-kmeans let result = featureCollection.kmeansClusters(numberOfClusters: 5) Source / Tests
collect let result = polygons.collect(from: points, inProperty: "p", outProperty: "vals") Source / Tests
concave-hull anyGeometry.concaveHull(maxEdgeLength: 500.0) Source / Tests
conversions/helpers let distance = GISTool.convert(length: 1.0, from: .miles, to: .meters) Source / Tests
convex-hull let hull = anyGeometry.convexHull() Source / Tests
coverage-is-valid let valid = multiPolygon.coverageIsValid() Source / Tests
coverage-simplify let simplified = multiPolygon.coverageSimplified(tolerance: 5.0) Source / Tests
coverage-union let merged = multiPolygon.coverageUnion() Source / Tests
densify let dense = anyGeometry.densified(maxSegmentLength: 1.0) Source / Tests
destination let destination = coordinate.destination(distance: 1000.0, bearing: 173.0) Source / Tests
difference let diff = polygon.difference(with: other) Source / Tests
distance let distance = coordinate1.distance(from: coordinate2) Source / Tests
distance-along let dist = lineString.distanceAlong(to: coordinate) Source / Tests
ellipse let ellipse = coordinate.ellipse(xSemiAxis: 5000.0, ySemiAxis: 3000.0) Source / Tests
flatten let featureCollection = anyGeometry.flattened Source / Tests
flip let flipped = anyGeometry.flipped() Source / Tests
great-circle let arc = start.greatCircle(to: end) Source / Tests
frechetDistance let distance = lineString.frechetDistance(from: other) Source / Tests
grid-hex bbox.hexGrid(cellSide: 1000.0) Source / Tests
grid-point bbox.pointGrid(cellSide: 1000.0) Source / Tests
grid-rectangle bbox.rectangleGrid(cellWidth: 1000.0, cellHeight: 500.0) Source / Tests
grid-square bbox.squareGrid(cellSide: 1000.0) Source / Tests
grid-triangle bbox.triangleGrid(cellSide: 1000.0) Source / Tests
hausdorffDistance let dist = a.hausdorffDistance(from: b) Source / Tests
intersect let overlap = polygon.intersection(with: other) Source / Tests
isolines let result = grid.isolines(breaks: [0, 100, 200]) Source / Tests
kinks let intersections = anyGeometry.kinks() Source / Tests
make-valid let valid = anyGeometry.madeValid() Source / Tests
length let length = lineString.length Source / Tests
line-arc let lineArc = point.lineArc(radius: 5000.0, bearing1: 20.0, bearing2: 60.0) Source / Tests
line-chunk let chunks = lineString.chunked(segmentLength: 1000.0).lineStrings let dividedLine = lineString.evenlyDivided(segmentLength: 1.0) Source / Tests
line-offset let offset = lineString.offset(by: 50.0) Source / Tests
line-intersect let intersections = feature1.intersections(other: feature2) Source / Tests
line-merge let merged = fc.lineMerged() Source / Tests
line-overlap let overlappingSegments = lineString1.overlappingSegments(with: lineString2) Source / Tests
line-segments let segments = anyGeometry.lineSegments Source / Tests
line-slice let slice = lineString.slice(start: Coordinate3D(…), end: Coordinate3D(…)) Source / Tests
line-slice-along let sliced = lineString.sliceAlong(startDistance: 50.0, stopDistance: 2000.0) Source / Tests
line-split let segments = lineString.lineSplit(with: splitter) Source / Tests
mask let masked = polygon.mask() Source / Tests
midpoint let middle = coordinate1.midpoint(to: coordinate2) Source / Tests
minkowski-difference let eroded = polygon.minkowskiDifference(with: pattern) Source / Tests
minkowski-sum let dilated = polygon.minkowskiSum(with: pattern) Source / Tests
minimum-bounding-circle let circle = anyGeometry.minimumBoundingCircle() Source / Tests
minimum-bounding-radius let r = anyGeometry.minimumBoundingRadius() Source / Tests
maximum-inscribed-circle let circle = polygon.maximumInscribedCircle() / let r = polygon.maximumInscribedRadius() Source / Tests
nearest-point let nearest = anyGeometry.nearestCoordinate(from: Coordinate3D(…)) Source / Tests
nearest-point-on-feature let nearest = anyGeometry. nearestCoordinateOnFeature(from: Coordinate3D(…)) Source / Tests
nearest-point-on-line let nearest = lineString.nearestCoordinateOnLine(from: Coordinate3D(…))?.coordinate Source / Tests
nearest-point-to-line let nearest = lineString. nearestCoordinate(outOf: coordinates) Source / Tests
oriented-envelope let envelope = anyGeometry.orientedEnvelope() Source / Tests
planepoint let z = triangle.planepoint(point) Source / Tests
point-on-feature let coordinate = anyGeometry.coordinateOnFeature Source / Tests
points-within-polygon let within = polygon.coordinatesWithin(coordinates) Source / Tests
point-to-line-distance let distance = lineString.distanceFrom(coordinate: Coordinate3D(…)) Source / Tests
pole-of-inaccessibility let pole = polygon.poleOfInaccessibility() Source / Tests
polygon-smooth let smoothed = polygon.smooth(iterations: 3) Source / Tests
polygon-tangents let tangents = polygon.tangentPoints(to: point) Source / Tests
polygon-to-line var lineStrings = polygon.lineStrings Source
polygonize let polygons = multiLineString.polygonized() Source / Tests
polygon-hull-simplify let hull = polygon.polygonHullSimplified(tolerance: 5.0) Source / Tests
random BoundingBox.randomPoints(count: 10) Source / Tests
reverse let lineStringReversed = lineString.reversed Source / Tests
rhumb-bearing let bearing = start.rhumbBearing(to: end) Source / Tests
rhumb-destination let destination = coordinate.rhumbDestination(distance: 1000.0, bearing: 0.0) Source / Tests
rhumb-distance let distance = coordinate1.rhumbDistance(from: coordinate2) Source / Tests
sample let sampled = featureCollection.sample(size: 10) Source / Tests
sector let sector = coordinate.sector(radius: 5000.0, bearing1: 0.0, bearing2: 90.0) Source / Tests
shared-paths let shared = a.sharedPaths(with: b) Source / Tests
square let squared = boundingBox.squared() Source / Tests
symmetric-difference let xor = polygon.symmetricDifference(with: other) Source / Tests
simplify let simplified = lineString.simplified(tolerance: 5.0, highQuality: false) Source / Tests
topology-preserve-simplify let valid = lineString.topologyPreservedSimplified(tolerance: 5.0) Source / Tests
snap-to-grid anyGeometry.snappedToGrid(tolerance: 0.5) Source / Tests
tile-cover let tileCover = anyGeometry.tileCover(atZoom: 14) Source / Tests
tin anyGeometry.tin() Source / Tests
tesselate let triangles = polygon.tesselated() Source / Tests
tin-to-point-cloud let cloud = tin.tinToPointCloud() Source / Tests
transform-coordinates let transformed = anyGeometry.transformCoordinates({ $0 }) Source / Tests
transform-rotate let transformed = anyGeometry. transformedRotate(angle: 25.0, pivot: Coordinate3D(…)) Source / Tests
transform-scale let transformed = anyGeometry. transformedScale(factor: 2.5, anchor: .center) Source / Tests
transform-translate let transformed = anyGeometry. transformedTranslate(distance: 1000.0, direction: 25.0) Source / Tests
truncate let truncated = lineString.truncated(precision: 2, removeAltitude: true) Source / Tests
union let combined = polygon.union(with: otherPolygon) Source / Tests
unary-union let unioned = multiPolygon.unaryUnion() Source / Tests
unkink-polygon let simplePolygons = polygon.unkinked(gridSize: 0.001) Source / Tests
voronoi let cells = points.voronoiDiagram(boundingBox: bbox) Source / Tests

Graph

The package includes a Graph type for routing and network analysis on GeoJSON LineString / MultiLineString features. Nodes are created at each line-segment endpoint; coordinates within a configurable nodeTolerance (default 1 m) are merged into a single node via a spatial-hash index, giving near O(1) deduplication during construction. The graph supports both undirected and directed (oneway-tagged) edges, optional edge filters for mode-restricted routing (e.g. hiking / cycling), and works across all projections (EPSG:4326, EPSG:3857, EPSG:4978, noSRID), including geometries that cross the antimeridian.

Construction

// Build a graph from a feature collection of LineStrings / MultiLineStrings.
let graph = Graph(featureCollection: featureCollection)

// Directed graph: features with a truthy "oneway" property become one-way edges.
let directed = Graph(featureCollection: featureCollection, isDirected: true)

// Pick two nodes and route between them.
let start = graph.nodes[0]
let end = graph.nodes[graph.nodeCount - 1]
let path = graph.shortestPath(from: start, to: end)
let length = graph.length(ofPath: path)

Nodes can also be added manually with createNode(at:), addUndirectedEdge(from:to:), and addDirectedEdge(from:to:).

A subset of the graph can be extracted as a standalone Graph via subgraph(containing:), and the connected components can each be obtained as a Graph via connectedComponentGraphs:

// Extract each connected component as its own Graph.
let components = graph.connectedComponentGraphs
for component in components {
    print("\(component.nodeCount) nodes, \(component.directedEdgeCount) edges")
}

// Or extract an arbitrary subset of nodes.
let sub = graph.subgraph(containing: [nodeA, nodeB, nodeC])

Merge

Multiple graphs (e.g. from tiled road networks) can be merged into one. Nodes within nodeTolerance are spatially deduplicated, and duplicate edges between the same node pair are removed:

// Merge an array of graphs (primary API).
let merged = Graph.merged([tile1, tile2, tile3])

// Convenience for merging a single graph into another.
let merged = graph.merged(with: anotherGraph)

Edges cut at tile boundaries become degree-2 chain nodes. Call contracted() on the merged result to collapse them into continuous edges.

Export

A graph can be exported back to a FeatureCollection for debugging or for use with other GeoJSON tooling:

let fc = graph.toFeatureCollection()
// Each edge becomes a 2-point LineString feature.
// Original feature properties, id, and edge weight are preserved.
// Directed edges get a "oneway": "yes" property.

Graph algorithms

Name Example Source / Tests
A* search graph.aStarPath(from: a, to: b) Source / Tests
Articulation points graph.articulationPoints() Source / Tests
BFS / DFS graph.breadthFirstSearch(from: node) / graph.depthFirstSearch(from: node) Source
BFS / DFS (callback) graph.breadthFirstSearch(from: node) { _ in true } / graph.depthFirstSearch(from: node) { _ in true } Source
Betweenness centrality graph.betweennessCentrality() Source / Tests
Bidirectional Dijkstra graph.bidirectionalShortestPath(from: a, to: b) Source / Tests
Bridge detection graph.bridges() Source / Tests
Chain contraction graph.contracted() / graph.contracted { $0.feature?.property(for: "type") == $1.feature?.property(for: "type") } Source / Tests
Chinese Postman tour graph.chinesePostmanTour() Source / Tests
Connected components graph.connectedComponents Source
Connected component graphs graph.connectedComponentGraphs Source
Contraction-accelerated routing graph.shortestPathViaContraction(from: a, to: b) Source / Tests
Cycle detection graph.cycles(from: node) Source
Dead-end pruning graph.prunedDeadEnds() Source / Tests
Eulerian path / circuit graph.eulerianPath() Source / Tests
Graph export (to FeatureCollection) graph.toFeatureCollection() Source / Tests
Graph merge Graph.merged([graph1, graph2]) Source / Tests
Graph partitioning (tiling) graph.partition(intoGridRows: 4, columns: 4) Source / Tests
K-shortest paths (Yen) graph.kShortestPaths(from: a, to: b, k: 3) Source / Tests
Minimum spanning tree graph.minimumSpanningTree() Source / Tests
Multi-criteria shortest path graph.shortestPath(from: a, to: b) { $0.weight } Source / Tests
Node-on-edge splitting graph.nodeOnEdge(near: coordinate) Source
Roundabout detection see Graph+Cycles.swift Source
SCC graphs graph.stronglyConnectedComponentGraphs() Source / Tests
Shortest path (Dijkstra) graph.shortestPath(from: a, to: b) Source / Tests
Strongly connected components graph.stronglyConnectedComponents() Source / Tests
Subgraph extraction graph.subgraph(containing: [a, b, c]) Source
TSP approximation graph.travelingSalespersonTour(nodes: [...]) Source / Tests

Related packages

Currently only two:

  • mvt-tools: Vector tiles reader/writer for Swift
  • mvt-postgis: Creates vector tiles from Postgis databases

Contributing

Please create an issue or open a pull request with a fix or enhancement.

License

MIT

Authors

Thomas Rasch, Outdooractive

About

A collection of GIS tools, including a GeoJSON implementation with projection support and WKB/TWKB/WKT coders as well as many algorithms ported from turf.js

Topics

Resources

Stars

35 stars

Watchers

7 watching

Forks

Releases

Used by

Contributors

Languages