Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 6 additions & 25 deletions Example/ForeFlightKMLDemo.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@
objects = {

/* Begin PBXBuildFile section */
E9421CEB2E7A0A4200FD39E1 /* ForeFlightKML in Frameworks */ = {isa = PBXBuildFile; productRef = E9421CEA2E7A0A4200FD39E1 /* ForeFlightKML */; };
E94E2EDA2E9E8699006CBDB6 /* ForeFlightKML in Frameworks */ = {isa = PBXBuildFile; productRef = E94E2ED92E9E8699006CBDB6 /* ForeFlightKML */; };
E9D33C1E2EAFD4F70066F05A /* ForeFlightKML in Frameworks */ = {isa = PBXBuildFile; productRef = E9D33C1D2EAFD4F70066F05A /* ForeFlightKML */; };
/* End PBXBuildFile section */

Expand All @@ -29,8 +27,6 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
E9421CEB2E7A0A4200FD39E1 /* ForeFlightKML in Frameworks */,
E94E2EDA2E9E8699006CBDB6 /* ForeFlightKML in Frameworks */,
E9D33C1E2EAFD4F70066F05A /* ForeFlightKML in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
Expand Down Expand Up @@ -74,8 +70,6 @@
);
name = ForeFlightKMLDemo;
packageProductDependencies = (
E9421CEA2E7A0A4200FD39E1 /* ForeFlightKML */,
E94E2ED92E9E8699006CBDB6 /* ForeFlightKML */,
E9D33C1D2EAFD4F70066F05A /* ForeFlightKML */,
);
productName = ForeFlightKMLDemo;
Expand Down Expand Up @@ -107,7 +101,7 @@
mainGroup = E9421CAA2E7A09EA00FD39E1;
minimizedProjectReferenceProxies = 1;
packageReferences = (
E9D33C1C2EAFD4F70066F05A /* XCRemoteSwiftPackageReference "ForeFlightKML" */,
E9D33C1C2EAFD4F70066F05A /* XCLocalSwiftPackageReference ".." */,
);
preferredProjectObjectVersion = 77;
productRefGroup = E9421CB42E7A09EA00FD39E1 /* Products */;
Expand Down Expand Up @@ -360,29 +354,16 @@
};
/* End XCConfigurationList section */

/* Begin XCRemoteSwiftPackageReference section */
E9D33C1C2EAFD4F70066F05A /* XCRemoteSwiftPackageReference "ForeFlightKML" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/Haelix-Code/ForeFlightKML/";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 0.2.1;
};
/* Begin XCLocalSwiftPackageReference section */
E9D33C1C2EAFD4F70066F05A /* XCLocalSwiftPackageReference ".." */ = {
isa = XCLocalSwiftPackageReference;
relativePath = "..";
};
/* End XCRemoteSwiftPackageReference section */
/* End XCLocalSwiftPackageReference section */

/* Begin XCSwiftPackageProductDependency section */
E9421CEA2E7A0A4200FD39E1 /* ForeFlightKML */ = {
isa = XCSwiftPackageProductDependency;
productName = ForeFlightKML;
};
E94E2ED92E9E8699006CBDB6 /* ForeFlightKML */ = {
isa = XCSwiftPackageProductDependency;
productName = ForeFlightKML;
};
E9D33C1D2EAFD4F70066F05A /* ForeFlightKML */ = {
isa = XCSwiftPackageProductDependency;
package = E9D33C1C2EAFD4F70066F05A /* XCRemoteSwiftPackageReference "ForeFlightKML" */;
productName = ForeFlightKML;
};
/* End XCSwiftPackageProductDependency section */
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 11 additions & 2 deletions Example/ForeFlightKMLDemo/KML/KMLGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import CoreLocation
import ForeFlightKML

enum KMLGenerator {
static func generateCircleKML(center: CLLocationCoordinate2D, radiusMeters: Double) throws -> BuildResult {
/// Builds a KML file and writes it to a temp directory, returning the URL.
static func generateCircleKML(center: CLLocationCoordinate2D, radiusMeters: Double) throws -> URL {
let builder = ForeFlightKMLBuilder(documentName: "Foreflight KML Demo")

let centerCoordinate = Coordinate(latitude: center.latitude, longitude: center.longitude)
Expand All @@ -23,8 +24,16 @@ enum KMLGenerator {
radiusMeters: radiusMeters * 2,
style: PolygonStyle(outlineColor: .black, fillColor: .warning.withAlpha(0.3)))

return try builder.build(as: .kmz)
let result = try builder.build(as: .kml)

let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyyMMdd'T'HHmmss'Z'"

let tmpURL = FileManager.default.temporaryDirectory
.appendingPathComponent("\(dateFormatter.string(from: Date())).\(result.fileExtension)")

try result.data.write(to: tmpURL, options: [.atomic])
return tmpURL
}

static func polygonCoordinatesForMap(center: CLLocationCoordinate2D, radiusMeters: Double) -> [CLLocationCoordinate2D] {
Expand Down
12 changes: 0 additions & 12 deletions Example/ForeFlightKMLDemo/Sharing/ActivityViewController.swift

This file was deleted.

43 changes: 14 additions & 29 deletions Example/ForeFlightKMLDemo/Views/ContentView.swift
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import SwiftUI
import MapKit
import ForeFlightKML

struct ContentView: View {
@State private var lastTapCoordinate: CLLocationCoordinate2D?
@State private var kmlToShareURL: URL?
@State private var showingShare = false

private let defaultRadiusMeters: Double = 500.0

Expand All @@ -26,49 +24,36 @@ struct ContentView: View {
Spacer()
HStack {
Spacer()
Button(action: shareIfAvailable) {
if let url = kmlToShareURL {
ShareLink(item: url) {
Image(systemName: "square.and.arrow.up")
.font(.title2)
.padding()
.background(Color(.systemBackground).opacity(0.9))
.clipShape(Circle())
}
.padding()
} else {
Image(systemName: "square.and.arrow.up")
.font(.title2)
.padding()
.background(Color(.systemBackground).opacity(0.9))
.clipShape(Circle())
.opacity(0.4)
.padding()
}
.disabled(kmlToShareURL == nil)
.padding()
}
}
}
.sheet(isPresented: $showingShare) {
if let url = kmlToShareURL {
ActivityViewController(activityItems: [url])
} else {
Text("No KML available")
}
}
}

private func handleMapTap(_ coord: CLLocationCoordinate2D) {
lastTapCoordinate = coord
do {
let buildResult = try KMLGenerator.generateCircleKML(center: coord, radiusMeters: defaultRadiusMeters)

let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyyMMdd'T'HHmmss'Z'"

let tmpURL = FileManager.default.temporaryDirectory.appendingPathComponent("\(dateFormatter.string(from: Date())).\(buildResult.fileExtension)")

try buildResult.data.write(to: tmpURL)
kmlToShareURL = tmpURL
showingShare = true
kmlToShareURL = try KMLGenerator.generateCircleKML(center: coord, radiusMeters: defaultRadiusMeters)
} catch {
print("Failed to write KMZ: \(error)")
print("Failed to generate KML: \(error)")
kmlToShareURL = nil
}
}

private func shareIfAvailable() {
if kmlToShareURL != nil {
showingShare = true
}
}
}
80 changes: 54 additions & 26 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
# ForeFlightKML

> Swift framework to build KML files in Jeppesen ForeFlight friendly format.
> Swift framework to build KML files in Jeppesen ForeFlight friendly format.

This package provides a small, focused API for composing KML/KMZ documents suitable for importing into ForeFlight as **User Map Shapes (KML)**. It intentionally avoids UI concerns — it gives you `String` KML output (or bytes) which your app can write to disk and share using the standard iOS share sheet.


## Quick highlights

- Compose `Placemark`s with `Point`, `LineString`, `Polygon` and derived geometry helpers (circles, arc sectors, etc.).
- Create reusable styles (`Style`, `LineStyle`, `PolyStyle`, `IconStyle`, `LabelStyle`) and assign them to placemarks.
- `ForeFlightKMLBuilder` collects placemarks and styles, emits a complete `kml` or `kmz` document .
- `ForeFlightKMLBuilder` collects placemarks and styles, emits a complete `kml` or `kmz` document.
- Thread-safe — `ForeFlightKMLBuilder` is `Sendable`, so KML generation can run off the main thread.
- Lightweight — no UI code.

## Install
Expand All @@ -18,8 +19,8 @@ This package provides a small, focused API for composing KML/KMZ documents suita
3. Choose the `ForeFlightKML` package product and add it to your app target.


## Example Output
Using the example given on the [ForeFlight website](https://foreflight.com/support/user-map-shapes/) the below is generated using this Framework.
## Example Output
Using the example given on the [ForeFlight website](https://foreflight.com/support/user-map-shapes/) the below is generated using this Framework.

See `/Tests/UserMapShapeTests/UserMapShapesSampleFullTest.swift`

Expand All @@ -29,27 +30,27 @@ See `/Tests/UserMapShapeTests/UserMapShapesSampleFullTest.swift`

```swift
import ForeFlightKML
import Geodesy (used for coordinates)
import GeodesySpherical

let builder = ForeFlightKMLBuilder(documentName: "Airport with ATZ")

builder.addLine(
name: "Runway 15-33",
coordinates: [Coordinate(latitude:, longitude:),Coordinate(latitude:, longitude:)],
style: LineStyle(color: .black)
name: "Runway 15-33",
coordinates: [Coordinate(latitude:, longitude:), Coordinate(latitude:, longitude:)],
style: PathStyle(color: .black)
)

builder.addLineCircle(
name: "Airport ATZ",
center: Coordinate(latitude:, longitude:),
builder.addPolygonCircle(
name: "Airport ATZ",
center: Coordinate(latitude:, longitude:),
radiusMeters: 4630,
PolygonStyle(outlineColor: .black, fillColor: .warning.withAlpha(0.3))
style: PolygonStyle(outlineColor: .black, fillColor: .warning.withAlpha(0.3))
)

let buildResult = try builder.build(as: .kmz)

let url = FileManager.default.temporaryDirectory.appendingPathComponent("shapes\(buildResult.fileExtension)")
try buildResult.data.write(to: tmpURL)
let url = FileManager.default.temporaryDirectory.appendingPathComponent("shapes.\(buildResult.fileExtension)")
try buildResult.data.write(to: url)
presentShareSheet(with: url)
```

Expand All @@ -58,44 +59,71 @@ presentShareSheet(with: url)

## API Reference

### KMLBuidler
`ForeFlightKMLBuilder` is the builder for the KML/KMZ document.
- Document name can be set on `init` or with `setDocumentName()`
### KMLBuilder
`ForeFlightKMLBuilder` is the builder for the KML/KMZ document.
- Document name can be set on `init` or with `setDocumentName()`
- Coordinate precision can be configured with `setCoordinatePrecision(_:)` (default 8, see below)
- Elements can be manually added using `addPlacemark(_:)`
- The output is accessed by `try builder.build()`

### Coordinate Precision

By default coordinates are written with up to 8 decimal places, with trailing zeros trimmed for cleaner output:

| Value | Output |
|-------|--------|
| `2.0` | `2.0` |
| `51.750188` | `51.750188` |
| `51.12345678` | `51.12345678` |

You can customise the precision (1–15) via the builder:

```swift
let builder = ForeFlightKMLBuilder(documentName: "Low-res demo")
.setCoordinatePrecision(4) // max 4dp, trailing zeros trimmed
```

The `Coordinate.kmlString(precision:)` method also accepts a precision parameter for standalone use.

### KMLBuilder Convenience Elements
- `addPoint` Add a point with style.
- `addLine` Add a line connecting multiple coordinates.
- `addLineCircle` Add a circular line (approximated by line segments).
- `addLineSector` Add an arc sector line geometry.
- `addPolygon` Add a polygon with outer boundary and optional holes.
- `addPolygonCircle` Add a polygon with outer boundary and optional holes.
- `addPolygonCircle` Add a filled circular polygon.
- `addPolygonSector` Add a filled sector polygon (pie slice).
- `addPolygonAnnularSector` Add a filled annular (ring) sector polygon.
- `addLabel` Add a text-only label placemark at a coordinate.

### ForeflightKMLBuilder Export formats
Type `BuildResult` contains:
```
### ForeFlightKMLBuilder Export formats
Type `BuildResult` contains:
```
data: Data
fileExtension: String
mimetype: String
```
Specific data access:
Specific data access:
- `kml Data` via `builder.build(as: .kml)`
- `kmz Data` via `builder.build(as: .kmz)`
- `kml String` via `builder.kmlString()` note: this can be unsafe and should only be used for debugging
- KMZ (zipped KML) is required when using custom icons or using labelBadge (which uses a transparent .png under the hood).
- KMZ (zipped KML) is required when using custom icons or using labelBadge (which uses a transparent .png under the hood).

### Underlying elements
- `Placemark` — a Feature containing a geometry (must implement `KMLElement`). Optionally attach a `KMLStyle`.
- Geometry types: `Point`, `Line`, `LineCircle`, `LineSector` (sector of a Circle), `Polygon`, `PolygonCircle` (filled circle), `PolygonSector` (filled sector) `LinearRing`.
- Geometry types: `Point`, `Line`, `LineCircle`, `LineSector` (sector of a Circle), `Polygon`, `PolygonCircle` (filled circle), `PolygonSector` (filled sector), `PolygonAnnularSector`, `LinearRing`.
- `Style` and substyles: `LineStyle`, `PolyStyle`, `IconStyle`, `LabelStyle`.
- `KMLColor` — helper to create the aabbggrr color values used by KML.

Full public API surface is visible in the package sources.

## Performance

- **Buffer-based generation** — KML is built using a single mutable `String` buffer rather than array concatenation, avoiding intermediate allocations.
- **Sendable** — `ForeFlightKMLBuilder` conforms to `Sendable`, allowing KML/KMZ generation to run on a background thread for a responsive UI.
- **Smart compression** — small documents (< 100 KB) skip DEFLATE compression in KMZ output, reducing overhead.
- **Efficient geometry** — circle point generation uses direct arithmetic rather than `Measurement` conversions.

## Notes, conventions and gotchas

- **Coordinates order**: KML requires `longitude,latitude[,altitude]`. The public API accepts `Coordinate(latitude:..., longitude:...)` (from `Geodesy`) and the framework emits coordinates in the KML `lon,lat[,alt]` order.
Expand All @@ -106,7 +134,7 @@ Full public API surface is visible in the package sources.

## Demo & tests

The repo contains an `Example` app that demonstrates building shapes and the `Tests` folder with unit and end to end example tests.
The repo contains an `Example` app that demonstrates building shapes and the `Tests` folder with unit and end to end example tests.

## Contributing

Expand Down
10 changes: 10 additions & 0 deletions Sources/ForeFlightKML/CoreElements/LineString.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,16 @@ public protocol LineLike: KMLElement, AltitudeSupport {
}

extension LineLike {
public func write(to buffer: inout String) {
write(to: &buffer, precision: kDefaultCoordinatePrecision)
}

public func write(to buffer: inout String, precision: Int) {
let lineString = LineString(
coordinates: coordinates, altitude: altitude, tessellate: tessellate)
lineString.write(to: &buffer, precision: precision)
}

public func kmlString() -> String {
let lineString = LineString(
coordinates: coordinates, altitude: altitude, tessellate: tessellate)
Expand Down
Loading
Loading