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
4 changes: 1 addition & 3 deletions .github/workflows/swiftlint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- name: GitHub Action for SwiftLint
uses: norio-nomura/action-swiftlint@3.2.1
- name: GitHub Action for SwiftLint with --strict
uses: norio-nomura/action-swiftlint@3.2.1
with:
args: --strict
args: --fix --quiet
4 changes: 2 additions & 2 deletions Example/ForeFlightKMLDemo/KML/KMLGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import CoreLocation
import ForeFlightKML

enum KMLGenerator {
static func generateCircleKML(center: CLLocationCoordinate2D, radiusMeters: Double) -> String {
static func generateCircleKML(center: CLLocationCoordinate2D, radiusMeters: Double) throws -> Data? {
let builder = ForeFlightKMLBuilder(documentName: "Foreflight KML Demo")

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

return builder.build()
return try builder.buildKMZ()
}

static func polygonCoordinatesForMap(center: CLLocationCoordinate2D, radiusMeters: Double) -> [CLLocationCoordinate2D] {
Expand Down
15 changes: 8 additions & 7 deletions Example/ForeFlightKMLDemo/Views/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,19 +48,20 @@ struct ContentView: View {

private func handleMapTap(_ coord: CLLocationCoordinate2D) {
lastTapCoordinate = coord

do {
let kmz = try KMLGenerator.generateCircleKML(center: coord, radiusMeters: defaultRadiusMeters)

let kml = KMLGenerator.generateCircleKML(center: coord, radiusMeters: defaultRadiusMeters)
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyyMMdd'T'HHmmss'Z'"

let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyyMMdd'T'HHmmss'Z'"
let tmpURL = FileManager.default.temporaryDirectory.appendingPathComponent("\(dateFormatter.string(from: Date())).kmz")

let tmpURL = FileManager.default.temporaryDirectory.appendingPathComponent("\(dateFormatter.string(from: Date())).kml")
do {
try kml.data(using: .utf8)?.write(to: tmpURL)
try kmz?.write(to: tmpURL)
kmlToShareURL = tmpURL
showingShare = true
} catch {
print("Failed to write KML: \(error)")
print("Failed to write KMZ: \(error)")
kmlToShareURL = nil
}
}
Expand Down
9 changes: 9 additions & 0 deletions Package.resolved

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

9 changes: 7 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,18 @@ let package = Package(
)
],
dependencies: [
.package(url: "https://github.com/florianreinhart/Geodesy", .upToNextMajor(from: "0.2.2"))
.package(url: "https://github.com/florianreinhart/Geodesy", .upToNextMajor(from: "0.2.2")),
.package(url: "https://github.com/weichsel/ZIPFoundation.git", from: "0.9.0")
],
targets: [
.target(
name: "ForeFlightKML",
dependencies: [
.product(name: "Geodesy", package: "Geodesy")
.product(name: "Geodesy", package: "Geodesy"),
.product(name: "ZIPFoundation", package: "ZIPFoundation")
],
resources: [
.process("Resources")
]
),
.testTarget(
Expand Down
60 changes: 38 additions & 22 deletions README.md

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a pendantic point ref geometry naming. I think the thing we're calling a 'segment' should be called 'sector'.

Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

This package provides a small, focused API for composing KML 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

Expand All @@ -13,15 +12,11 @@ This package provides a small, focused API for composing KML documents suitable
- `ForeFlightKMLBuilder` collects placemarks and styles, emits a complete `kml` document string.
- Lightweight — no UI code.

---

## Install

1. In Xcode: **File › Add Packages...**
2. Enter the repository URL.
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.
Expand All @@ -30,27 +25,23 @@ See `/Tests/ForeFlightKMLTests/UserMapShapesSampleFullTest.swift`

<img width="615" height="770" alt="Image" src="/docs/example-output.png" />

## Minimal Quick Start
## Quick Start

```swift
import ForeFlightKML
import Geodesy (we use this for Coordinate and relative positioniong)
import Geodesy (used for coordinates)

// Example: Airport Traffic Pattern
let builder = ForeFlightKMLBuilder(documentName: "Airport with ATZ")

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

// Traffic Warning Area
builder.addLineCircle(
name: "Airport ATZ",
center: airportCenter,
radiusMeters: 4630, // 2.5 nautical mile ATZ
radiusMeters: 4630,
PolygonStyle(outlineColor: .black, fillColor: .warning.withAlpha(0.3))
)

Expand All @@ -60,34 +51,59 @@ presentShareSheet(with: url)
```

> **Note**: ForeFlight supports importing KML/KMZ files via the iOS share sheet. See ForeFlight's docs for exact import behavior.
---

## API quick reference (important types)

- `ForeFlightKMLBuilder` — builder for the KML document. Methods: `addPlacemark(_:)`, `kmlString()`.

## API Reference

### KMLBuidler
`ForeFlightKMLBuilder` is the builder for the KML/KMZ document.
- Document name can be set on `init` or with `setDocumentName()`
- Elements can be manually added using `addPlacemark(_:)`
- The complete KML string is accessed by `builder.build()`

### 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).
- `addLineSegment` Add an arc segment line geometry.
- `addPolygon` Add a polygon with outer boundary and optional holes.
- `addPolygonCircle` Add a polygon with outer boundary and optional holes.
- `addPolygonSegment` Add a filled segment polygon (pie slice).
- `addPolygonAnnularSegment` Add a filled annular (ring) segment polygon.
- `addLabel` Add a text-only label placemark at a coordinate.

### ForeflightKMLBuilder Export formats
- `kml String` via `builder.build()`
- `kml Data` via `builder.kmlData()`
- `kmz Data` via `builder.buildKMZ()`
- 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`, `LineSegment` (segment of a Circle), `Polygon`, `PolygonCircle` (filled circle), `PolygonSegment` (filled segment) `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; the README examples show common usage patterns.

---
Full public API surface is visible in the package sources.

## 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.
- **Units**: Distances (e.g. `LineCircle.radius`) are in **meters**.
- **Angles/bearings**: bearings (for arc & circle generation) are interpreted in degrees (0..360). The bearing convention is clockwise from north.
- **Altitude**: When you provide altitudes, the `AltitudeMode` is emitted (defaults to `.absolute` in most geometries).
- **Styles**: `Style` generates a stable `id` when provided; otherwise a UUID-based id is generated. `ForeFlightKMLBuilder` will automatically register styles added via `Placemark`.
---

## Demo & tests
To create a label-only point with a colored badge:
```
builder.addLabel("Text", coordinate: .init(...), color: KMLColor?)
```

The repo contains an `Example` app that demonstrates building shapes and the `Tests` folder with unit tests. Before publishing, ensure the example builds and the tests run in CI.
## Demo & tests

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

## Contributing

Expand Down
5 changes: 5 additions & 0 deletions Sources/ForeFlightKML/Errors/KMZExportError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
public enum KMZExportError: Error {
case kmzRequired
case missingLocalResource(String)
case archiveCreationFailed
}
25 changes: 25 additions & 0 deletions Sources/ForeFlightKML/ForeFlightKML+Convenience.swift
Original file line number Diff line number Diff line change
Expand Up @@ -291,4 +291,29 @@ extension ForeFlightKMLBuilder {
let placemark = Placemark(name: name, geometry: segment, style: style)
return addPlacemark(placemark)
}

/// Add a text-only label placemark at a coordinate.
/// This uses a transparent 1×1 icon to enable ForeFlight’s “badge” rendering, with the badge color driven by `IconStyle.color`.
/// - Important: Because this relies on a local icon asset, the output **must be exported as KMZ** (not plain KML) for the label to render correctly.
/// - Parameters:
/// - text: Label text displayed in ForeFlight
/// - coordinate: Geographic coordinate
/// - altitude: Altitude in meters (optional)
/// - color: Badge/background color for the label (default: white)
/// - Returns: Self for method chaining
@discardableResult
public func addLabel(
_ text: String,
coordinate: Coordinate,
altitude: Double? = nil,
color: KMLColor = .white
) -> Self {
addPoint(
name: text,
coordinate: coordinate,
altitude: altitude,
style: .labelBadge(color: color)
)
}

}
11 changes: 10 additions & 1 deletion Sources/ForeFlightKML/ForeFlightKML.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,21 @@ public final class ForeFlightKMLBuilder {
return self
}

/// True if this document must be exported as KMZ to render correctly.
public var requiresKMZ: Bool {
styleManager.requiresKMZ
}

// MARK: - Build Methods

/// Generate the complete KML string for this document.
/// This method can be called multiple times - it doesn't modify the builder state.
/// - Returns: A complete KML document as a UTF-8 string ready for export to ForeFlight
public func build() -> String {
public func build() throws -> String {
guard !requiresKMZ else {
throw KMZExportError.kmzRequired
}

return kmlString()
}

Expand Down
50 changes: 50 additions & 0 deletions Sources/ForeFlightKML/ForeflightKML+KMZ.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import Foundation
import ZIPFoundation

public extension ForeFlightKMLBuilder {

/// Build a KMZ (ZIP) containing doc.kml and any required local assets.
func buildKMZ() throws -> Data? {

let kmlData = kmlData()

let archive = try Archive(accessMode: .create)

try archive.addEntry(
with: "doc.kml",
type: .file,
uncompressedSize: Int64(kmlData.count),
compressionMethod: .deflate,
provider: { position, size in
let start = Int(position)
guard start < kmlData.count else { return Data() }
let end = min(start + size, kmlData.count)
return kmlData.subdata(in: start..<end)
}
)

if requiresKMZ {
try addLocalAssets(to: archive)
}

return archive.data
}
}

private extension ForeFlightKMLBuilder {

func addLocalAssets(to archive: Archive) throws {

let bundle = Bundle.module

guard let iconURL = bundle.url(forResource: "1x1", withExtension: "png") else {
throw KMZExportError.missingLocalResource("1x1.png")
}

try archive.addEntry(
with: "1x1.png",
fileURL: iconURL,
compressionMethod: .deflate
)
}
}
7 changes: 7 additions & 0 deletions Sources/ForeFlightKML/Models/StyleManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,13 @@ internal class StyleManager {
}
}

/// True if any referenced style requires KMZ packaging.
var requiresKMZ: Bool {
referencedStyleIds.contains { id in
styles[id]?.requiresKMZ == true
}
}

// MARK: - Style Management

/// Get a style by its ID.
Expand Down
Binary file added Sources/ForeFlightKML/Resources/1x1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
15 changes: 15 additions & 0 deletions Sources/ForeFlightKML/Styles/Geometry/PointStyle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ public struct PointStyle: KMLStyle {
return styleId
}

public var requiresKMZ: Bool {
icon.requiresKMZ
}

public func kmlString() -> String {
var components: [String] = []
components.append("<Style id=\"\(styleId)\">")
Expand All @@ -36,3 +40,14 @@ public struct PointStyle: KMLStyle {
return components.joined(separator: "\n")
}
}

public extension PointStyle {
/// A point style that renders only the placemark name as a label.
static func labelBadge(color: KMLColor = .white, id: String? = nil) -> PointStyle {
PointStyle(
icon: .transparentLocalPng(tint: color),
label: nil, // ForeFlight ignores LabelStyle for point badges
id: id
)
}
}
15 changes: 15 additions & 0 deletions Sources/ForeFlightKML/Styles/IconStyle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,15 @@ public struct IconStyle: KMLSubStyle {
return iconHrefValue
}

/// True if this icon references a local resource and therefore
/// requires KMZ packaging to render correctly in ForeFlight.
var requiresKMZ: Bool {
// Local icon references (e.g. "1x1.png") must be packaged in a KMZ.
// Remote URLs (http/https) are fine in plain KML.
!iconHrefValue.hasPrefix("http://") &&
!iconHrefValue.hasPrefix("https://")
}

public func kmlString() -> String {
var lines: [String] = []
lines.append("<IconStyle>")
Expand Down Expand Up @@ -127,3 +136,9 @@ public enum DefinedIconColor: String {
case pink = "pink"
case red = "red"
}

public extension IconStyle {
static func transparentLocalPng(tint color: KMLColor = .white, scale: Double? = 1.0) -> IconStyle {
IconStyle(href: "1x1.png", color: color, scale: scale)
}
}
Loading