diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..6ae862a
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,97 @@
+name: Test
+
+# ios-sdk-core のユニットテスト。
+#
+# これまで CI は api-surface.yml(公開 API の凍結)と deploy 系だけで、
+# **テストを走らせるワークフローが無かった。** 凍結ゲートが見るのは型の形だけ
+# なので、シグネチャが同じまま中身が壊れる変更は素通りする。
+#
+# ## なぜ `swift test` ではないのか
+#
+# 通らないため。ios-sdk-core は TilePng.xcframework(Rust の PNG エンコーダ)に
+# 依存していて、この xcframework は ios-arm64 と ios-arm64-simulator しか持た
+# ない。macOS 向けにビルドしようとすると:
+#
+# error: TilePng.xcframework While building for macOS,
+# no library for this platform was found
+#
+# したがって iOS シミュレータ宛に xcodebuild で回す。`swift test` を復活させたい
+# なら TilePng.xcframework に macOS スライスを足すことになる
+# (mapconductor-vectortile の scripts/build-ios-core-png.sh)。ローカルでの反復
+# は速くなるが、CI に必要なのはこちらではない。
+#
+# スキーム名 `mapconductor-core` は Package.swift の `name:` に由来する。
+# product 名(MapConductorCore)ではない。
+
+on:
+ pull_request:
+ types: [opened, reopened, synchronize, ready_for_review]
+ branches:
+ - main
+ push:
+ branches:
+ - main
+ workflow_dispatch:
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ unit-test:
+ if: github.event.pull_request.draft == false
+ runs-on: macos-15
+ name: ios-sdk-core tests
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+ with:
+ submodules: recursive
+ token: ${{ secrets.GITHUB_TOKEN }}
+
+ # ベースラインは Swift コンパイラの出力をそのまま記録しているので、
+ # 違う Xcode を引くと API を変えていなくても数千行の差分が出る。
+ # 3,000 行の diff ではなく、ここで「ツールチェインが違う」と言って落とす。
+ - name: Pin Xcode
+ run: |
+ set -euo pipefail
+ source ios-sdk-core/api/TOOLCHAIN
+ app="/Applications/Xcode_${XCODE_VERSION}.app"
+ if [ ! -d "$app" ]; then
+ echo "Xcode ${XCODE_VERSION} がこのランナーにありません。利用可能:" >&2
+ ls -d /Applications/Xcode*.app >&2
+ echo "ランナーのイメージを合わせるか、その Xcode で dump し直して" >&2
+ echo "ios-sdk-core/api/TOOLCHAIN を更新してください。" >&2
+ exit 1
+ fi
+ sudo xcode-select -s "$app"
+ xcodebuild -version
+
+ # 名前を決め打ちにしない。ランナーのイメージが変わるたびに
+ # 「iPhone 16 が無い」で落ちるのは、コードの問題ではない。
+ - name: Pick a simulator
+ id: sim
+ run: |
+ set -euo pipefail
+ name=$(xcrun simctl list devices available --json \
+ | python3 -c "import json,sys;d=json.load(sys.stdin)['devices'];print(next(x['name'] for k,v in d.items() if 'iOS' in k for x in v))")
+ echo "Using simulator: $name"
+ echo "name=$name" >> "$GITHUB_OUTPUT"
+
+ - name: Test
+ working-directory: ios-sdk-core
+ run: |
+ set -euo pipefail
+ xcodebuild test \
+ -scheme mapconductor-core \
+ -destination "platform=iOS Simulator,name=${{ steps.sim.outputs.name }}" \
+ -resultBundlePath TestResults.xcresult
+
+ - name: Upload result bundle
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: xcresult
+ path: ios-sdk-core/TestResults.xcresult
+ retention-days: 7
diff --git a/.gitignore b/.gitignore
index c06bc4b..050d312 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,3 +24,7 @@ maps-core/
app1786472922370i267371265/
samples/MapConductorSampleApp/Resources/mappls.i.conf
samples/MapConductorSampleApp/Resources/mappls.i.olf
+
+# Xcode per-user state: window layout, open tabs, breakpoints. Tracked by
+# accident; it changes on every launch and belongs to one machine.
+xcuserdata/
diff --git a/ios-for-arcgis b/ios-for-arcgis
index d364ad0..47fc976 160000
--- a/ios-for-arcgis
+++ b/ios-for-arcgis
@@ -1 +1 @@
-Subproject commit d364ad000a6b06a8ccb26f92367ab83f962c1450
+Subproject commit 47fc976d24ab4734ff3791e2b388669480fe7293
diff --git a/ios-for-googlemaps b/ios-for-googlemaps
index 16848fb..695eeda 160000
--- a/ios-for-googlemaps
+++ b/ios-for-googlemaps
@@ -1 +1 @@
-Subproject commit 16848fbd886bd89e7898351e077b29c1451d0ed3
+Subproject commit 695eeda908399415fc588fc961a08387358a8554
diff --git a/ios-for-here b/ios-for-here
index 8301787..1a06977 160000
--- a/ios-for-here
+++ b/ios-for-here
@@ -1 +1 @@
-Subproject commit 83017871151baa6ed4df69521cd43ea701789ff4
+Subproject commit 1a0697799ebdc7b034abd997c8809d151d7fd26f
diff --git a/ios-for-longdo b/ios-for-longdo
index a50cb6b..1c09c89 160000
--- a/ios-for-longdo
+++ b/ios-for-longdo
@@ -1 +1 @@
-Subproject commit a50cb6b579cfadf11a18d6a4dc5967f2026fd349
+Subproject commit 1c09c89f5d3c9559240f07108ca511bff1109ad6
diff --git a/ios-for-mapbox b/ios-for-mapbox
index f3c607a..f47c7f8 160000
--- a/ios-for-mapbox
+++ b/ios-for-mapbox
@@ -1 +1 @@
-Subproject commit f3c607a523412a1ca7097d88cf3000046fa04f7a
+Subproject commit f47c7f8d2ceff978d775d2feacfd3550849906d6
diff --git a/ios-for-mapkit b/ios-for-mapkit
index 24ee69c..d241d25 160000
--- a/ios-for-mapkit
+++ b/ios-for-mapkit
@@ -1 +1 @@
-Subproject commit 24ee69ce214ad620ca0b7d20de31ac90a17f09a6
+Subproject commit d241d2540fab624995d3caea67531e34a5e6390f
diff --git a/ios-for-maplibre b/ios-for-maplibre
index d8c5cb2..a334b75 160000
--- a/ios-for-maplibre
+++ b/ios-for-maplibre
@@ -1 +1 @@
-Subproject commit d8c5cb2d5c61f1b00bbc9af631d5d423c8b1ba15
+Subproject commit a334b750ef862f82c60fff9454bdf01832e288cb
diff --git a/ios-for-mappls b/ios-for-mappls
index 29484a6..c2a19e8 160000
--- a/ios-for-mappls
+++ b/ios-for-mappls
@@ -1 +1 @@
-Subproject commit 29484a60b33451997b4911c4e175ea6caa75e835
+Subproject commit c2a19e860613265aeefcdd2885ca1ea9d49e48e9
diff --git a/ios-for-maptiler b/ios-for-maptiler
index 2de2305..17a24c1 160000
--- a/ios-for-maptiler
+++ b/ios-for-maptiler
@@ -1 +1 @@
-Subproject commit 2de23059f00463e356e83eef69a75481653b044c
+Subproject commit 17a24c179b9c1dd2b32697c6c2f1d0c72479aa14
diff --git a/ios-for-openmobilemaps b/ios-for-openmobilemaps
index e0ab1ee..ed66261 160000
--- a/ios-for-openmobilemaps
+++ b/ios-for-openmobilemaps
@@ -1 +1 @@
-Subproject commit e0ab1eeb462e304caa3b422c8df7ba7f4710f4e1
+Subproject commit ed66261e1a17783b2f5bf7f17c363125dff80a3b
diff --git a/ios-for-template b/ios-for-template
index de5d235..8bba7e1 160000
--- a/ios-for-template
+++ b/ios-for-template
@@ -1 +1 @@
-Subproject commit de5d2352ce3e64ad2e677d510dfb92016c04eeb2
+Subproject commit 8bba7e12dc6fc53cd5609abf821d1646b191570c
diff --git a/ios-for-tomtom b/ios-for-tomtom
index d385129..8d5177d 160000
--- a/ios-for-tomtom
+++ b/ios-for-tomtom
@@ -1 +1 @@
-Subproject commit d3851294415da53ce77b38fc4bcbbdfc339de505
+Subproject commit 8d5177df51f7876f0326d1b544da6bdad3bdfd0b
diff --git a/ios-icons b/ios-icons
index 1be49f0..5a4843e 160000
--- a/ios-icons
+++ b/ios-icons
@@ -1 +1 @@
-Subproject commit 1be49f0c77a1de39e9ae4709e694b55d14ab2cf1
+Subproject commit 5a4843e43cd70620e24c0cf60b2cbfeea3be2ddd
diff --git a/ios-sdk-core b/ios-sdk-core
index 08b4d72..a899663 160000
--- a/ios-sdk-core
+++ b/ios-sdk-core
@@ -1 +1 @@
-Subproject commit 08b4d72e74d1657125908df0a8888d3b5c1e5516
+Subproject commit a899663f7662b64b5333271c84e71d9b5f390a31
diff --git a/ios-vectorstyle/MvtStyle.xcframework/Info.plist b/ios-vectorstyle/MvtStyle.xcframework/Info.plist
new file mode 100644
index 0000000..bddbbfb
--- /dev/null
+++ b/ios-vectorstyle/MvtStyle.xcframework/Info.plist
@@ -0,0 +1,43 @@
+
+
+
+
+ AvailableLibraries
+
+
+ BinaryPath
+ libmvt_style_ffi.a
+ LibraryIdentifier
+ ios-arm64-simulator
+ LibraryPath
+ libmvt_style_ffi.a
+ SupportedArchitectures
+
+ arm64
+
+ SupportedPlatform
+ ios
+ SupportedPlatformVariant
+ simulator
+
+
+ BinaryPath
+ libmvt_style_ffi.a
+ LibraryIdentifier
+ ios-arm64
+ LibraryPath
+ libmvt_style_ffi.a
+ SupportedArchitectures
+
+ arm64
+
+ SupportedPlatform
+ ios
+
+
+ CFBundlePackageType
+ XFWK
+ XCFrameworkFormatVersion
+ 1.0
+
+
diff --git a/ios-vectorstyle/MvtStyle.xcframework/ios-arm64-simulator/libmvt_style_ffi.a b/ios-vectorstyle/MvtStyle.xcframework/ios-arm64-simulator/libmvt_style_ffi.a
new file mode 100644
index 0000000..65c7845
Binary files /dev/null and b/ios-vectorstyle/MvtStyle.xcframework/ios-arm64-simulator/libmvt_style_ffi.a differ
diff --git a/ios-vectorstyle/MvtStyle.xcframework/ios-arm64/libmvt_style_ffi.a b/ios-vectorstyle/MvtStyle.xcframework/ios-arm64/libmvt_style_ffi.a
new file mode 100644
index 0000000..206e308
Binary files /dev/null and b/ios-vectorstyle/MvtStyle.xcframework/ios-arm64/libmvt_style_ffi.a differ
diff --git a/ios-vectorstyle/Package.swift b/ios-vectorstyle/Package.swift
new file mode 100644
index 0000000..ec433cc
--- /dev/null
+++ b/ios-vectorstyle/Package.swift
@@ -0,0 +1,56 @@
+// swift-tools-version: 5.9
+import Foundation
+import PackageDescription
+
+let frameworkLibraryType: Product.Library.LibraryType? =
+ ProcessInfo.processInfo.environment["MAPCONDUCTOR_BUILD_XCFRAMEWORK"] == "1" ? .dynamic : nil
+let usingLocalCore = FileManager.default.fileExists(atPath: "../ios-sdk-core/Package.swift")
+let coreDependency: Package.Dependency = usingLocalCore
+ ? .package(path: "../ios-sdk-core")
+ : .package(url: "https://github.com/MapConductor/ios-sdk-core", from: "1.3.2")
+
+let package = Package(
+ name: "mapconductor-vectorstyle",
+ platforms: [
+ // See ios-sdk-core/Package.swift's comment: "15.0" must not be used here.
+ .iOS("15.1"),
+ ],
+ products: [
+ .library(
+ name: "MapConductorVectorStyle",
+ type: frameworkLibraryType,
+ targets: ["MapConductorVectorStyle"]
+ ),
+ ],
+ dependencies: [
+ coreDependency,
+ ],
+ targets: [
+ // The Rust style compiler. Built and packaged by the
+ // mapconductor-vectortile repo (scripts/build-ios-style.sh) and
+ // copied here by its scripts/sync-sdk.sh.
+ //
+ // Its own binary rather than a corner of MvtRender: an app using
+ // MapLibre, Mapbox or MapTiler adjusts a style without ever
+ // rasterising one, and must not have to link the rasteriser to do it.
+ .binaryTarget(name: "MvtStyle", path: "MvtStyle.xcframework"),
+ // The header travels as a C target rather than inside the
+ // XCFramework: Xcode merges every linked framework's Headers into
+ // one include directory, and two module.modulemaps collide there --
+ // which anything depending on both this and MvtRender would hit.
+ .target(name: "CMvtStyle"),
+ .target(
+ name: "MapConductorVectorStyle",
+ dependencies: [
+ .product(name: "MapConductorCore", package: "ios-sdk-core"),
+ "CMvtStyle",
+ "MvtStyle",
+ ],
+ ),
+ .testTarget(
+ name: "MapConductorVectorStyleTests",
+ dependencies: ["MapConductorVectorStyle"],
+ resources: [.process("Resources")],
+ ),
+ ]
+)
diff --git a/ios-vectorstyle/Sources/CMvtStyle/include/mvt_style.h b/ios-vectorstyle/Sources/CMvtStyle/include/mvt_style.h
new file mode 100644
index 0000000..ba51a8e
--- /dev/null
+++ b/ios-vectorstyle/Sources/CMvtStyle/include/mvt_style.h
@@ -0,0 +1,38 @@
+// The vector style compiler for the iOS SDK. Mirrors crates/mvt-style-ffi.
+//
+// Travels as a source header in ios-vectorstyle rather than inside the
+// XCFramework: Xcode merges every linked framework's Headers into one include
+// directory, and two frameworks shipping a module.modulemap collide there --
+// which anything depending on both this and MvtRender would hit.
+#ifndef MVT_STYLE_H
+#define MVT_STYLE_H
+
+#include
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+// Applies a rules document to a style document.
+//
+// Returns JSON: the adjusted style, the mutations, what they affect and the
+// diagnostics -- or {"error": "..."} when either document could not be read.
+// NULL only when an argument was not valid UTF-8 or the compiler panicked.
+// Release the result with mvt_style_string_free.
+char *mvt_style_compile(const char *style_json, const char *rules_json);
+
+// The style's layers and the role each would be given, as a JSON array, or
+// {"error": "..."}. Release with mvt_style_string_free.
+char *mvt_style_describe(const char *style_json);
+
+// The rules document version this library understands.
+uint32_t mvt_style_schema_version(void);
+
+// Releases a string this library returned. NULL is accepted.
+void mvt_style_string_free(char *text);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif // MVT_STYLE_H
diff --git a/ios-vectorstyle/Sources/CMvtStyle/shim.c b/ios-vectorstyle/Sources/CMvtStyle/shim.c
new file mode 100644
index 0000000..9bd871a
--- /dev/null
+++ b/ios-vectorstyle/Sources/CMvtStyle/shim.c
@@ -0,0 +1,3 @@
+// SwiftPM needs a target to have at least one source file. This one exists so
+// CMvtStyle can be a target whose only real content is its public header; the
+// implementation is linked from the MvtStyle XCFramework.
diff --git a/ios-vectorstyle/Sources/MapConductorVectorStyle/StyleRules.swift b/ios-vectorstyle/Sources/MapConductorVectorStyle/StyleRules.swift
new file mode 100644
index 0000000..617e4e2
--- /dev/null
+++ b/ios-vectorstyle/Sources/MapConductorVectorStyle/StyleRules.swift
@@ -0,0 +1,374 @@
+import Foundation
+
+#if canImport(UIKit)
+ import UIKit
+#endif
+
+/**
+ What to change about a style, and where.
+
+ ```swift
+ let rules = StyleRules.build {
+ $0.all { $0.color = .black }
+ $0.role(LayerRole.label) { $0.visible = false }
+ $0.role(LayerRole.roadCasing) { $0.color = UIColor(white: 0.19, alpha: 1) }
+ $0.role(LayerRole.road) { $0.color = .white; $0.widthScale = 1.3 }
+ }
+ ```
+
+ Rules apply in order and override each other property by property, the way
+ CSS does: the last rule to name a colour wins, and a rule that says nothing
+ about width leaves the width alone. Reordering the two road rules above
+ gives a different map, which is the point -- only the app knows whether the
+ casings should keep their own colour.
+
+ ## It is JSON underneath, on purpose
+
+ ``json`` is the real definition, not a serialisation of the builder. The
+ same text is what the compiler reads on Android, iOS and the web, so a rule
+ set can be written by hand, stored, shipped from a server, or diffed between
+ two versions of an app -- and ``parse(_:)`` takes it straight back. The
+ builder is sugar over that, there so the common case is checked by the
+ compiler rather than by a typo.
+
+ Nothing is validated here. A rule set is checked when it is compiled against
+ a style, which is the only place that can tell whether `role("road")` means
+ anything.
+ */
+public struct StyleRules: Equatable, Sendable {
+ /// The canonical form.
+ public let json: String
+
+ private init(json: String) {
+ self.json = json
+ }
+
+ /// No adjustments: the style as its author wrote it.
+ public static let none = StyleRules(json: #"{"schemaVersion":1,"rules":[]}"#)
+
+ /**
+ Takes a rules document as it stands.
+
+ Not checked here -- a document is checked when it is compiled against a
+ style, and ``VectorStyleRules/compile(styleJSON:rulesJSON:)`` throws then.
+ */
+ public static func parse(_ json: String) -> StyleRules { StyleRules(json: json) }
+
+ /// Writes a rules document with the compiler checking the shape.
+ public static func build(_ block: (StyleRulesBuilder) -> Void) -> StyleRules {
+ let builder = StyleRulesBuilder()
+ block(builder)
+ return StyleRules(json: builder.toJSON())
+ }
+}
+
+/**
+ The roles the built-in schema profiles assign.
+
+ Strings rather than an enum: a style cut to a schema nobody here has heard
+ of can name its own roles through ``StyleRulesBuilder/customSchema(_:)``,
+ and adding a role to the built-in tables must not be a breaking change on
+ three platforms.
+ */
+public enum LayerRole {
+ public static let background = "background"
+ public static let water = "water"
+ public static let waterway = "waterway"
+ public static let land = "land"
+ public static let landuse = "landuse"
+ public static let park = "park"
+ public static let building = "building"
+ public static let road = "road"
+ /**
+ The wide dark line drawn under a road to give it an edge.
+
+ A style draws every road twice, and "make roads white" applied to both
+ halves gives a white slab rather than a road. Nothing in the tile
+ distinguishes them -- only the layer's name does -- so this is the one
+ role decided by a naming convention, and the compiler reports how many
+ layers it found.
+ */
+ public static let roadCasing = "road-casing"
+ public static let rail = "rail"
+ public static let transit = "transit"
+ public static let boundary = "boundary"
+ public static let aeroway = "aeroway"
+ public static let label = "label"
+ public static let poi = "poi"
+}
+
+/// A style layer's `type`, for ``StyleSelector/kind(_:)``.
+public enum StyleLayerKind: String, Sendable {
+ case background
+ case fill
+ case line
+ case circle
+ case symbol
+ /// Everything else: raster, hillshade, heatmap, fill-extrusion, sky.
+ case other
+}
+
+/// Which layers a rule reaches.
+public indirect enum StyleSelector: Sendable {
+ /// Every layer in the style, including the background.
+ case all
+ /// What the layer *is*, across schemas that name their sources differently.
+ case role(String)
+ /// The layer's `id`, as a glob: `*` for any run, `?` for one character.
+ case layerId(String)
+ case sourceLayer(String)
+ case kind(StyleLayerKind)
+ case anyOf([StyleSelector])
+ case allOf([StyleSelector])
+ case not(StyleSelector)
+
+ var json: String {
+ switch self {
+ case .all: return "\"all\""
+ case let .role(name): return #"{"role":\#(quoteJSON(name))}"#
+ case let .layerId(glob): return #"{"layerId":\#(quoteJSON(glob))}"#
+ case let .sourceLayer(name): return #"{"sourceLayer":\#(quoteJSON(name))}"#
+ case let .kind(kind): return #"{"kind":\#(quoteJSON(kind.rawValue))}"#
+ case let .anyOf(of):
+ return #"{"anyOf":[\#(of.map(\.json).joined(separator: ","))]}"#
+ case let .allOf(of):
+ return #"{"allOf":[\#(of.map(\.json).joined(separator: ","))]}"#
+ case let .not(of): return #"{"not":\#(of.json)}"#
+ }
+ }
+}
+
+/// Builds the `rules` array.
+public final class StyleRulesBuilder {
+ /**
+ Which tile schema the style's `source-layer` names come from.
+
+ Left alone it is worked out from the style, which is right for
+ OpenMapTiles, Shortbread and Mapbox Streets. Name one to be sure, or
+ supply ``customSchema(_:)`` for a schema the compiler has never seen.
+ */
+ public var schema: String?
+
+ private var custom: [(String, [String])]?
+ private var rules: [String] = []
+
+ fileprivate init() {}
+
+ /**
+ Teaches the compiler a schema of your own: role name to the
+ `source-layer` names that carry it.
+ */
+ public func customSchema(_ roles: [String: [String]]) {
+ custom = roles.sorted { $0.key < $1.key }.map { ($0.key, $0.value) }
+ }
+
+ public func all(_ patch: (StylePatchBuilder) -> Void) { self.where(.all, patch) }
+
+ public func role(_ role: String, _ patch: (StylePatchBuilder) -> Void) {
+ self.where(.role(role), patch)
+ }
+
+ public func layerId(_ glob: String, _ patch: (StylePatchBuilder) -> Void) {
+ self.where(.layerId(glob), patch)
+ }
+
+ public func sourceLayer(_ name: String, _ patch: (StylePatchBuilder) -> Void) {
+ self.where(.sourceLayer(name), patch)
+ }
+
+ public func kind(_ kind: StyleLayerKind, _ patch: (StylePatchBuilder) -> Void) {
+ self.where(.kind(kind), patch)
+ }
+
+ /// For a selector built by hand: ``StyleSelector/allOf(_:)``, ``StyleSelector/not(_:)`` and friends.
+ public func `where`(_ selector: StyleSelector, _ patch: (StylePatchBuilder) -> Void) {
+ let builder = StylePatchBuilder()
+ patch(builder)
+ rules.append(#"{"selector":\#(selector.json),"patch":\#(builder.toJSON())}"#)
+ }
+
+ func toJSON() -> String {
+ var out = #"{"schemaVersion":1"#
+ if let custom {
+ let roles = custom.map { role, layers in
+ #"\#(quoteJSON(role)):[\#(layers.map(quoteJSON).joined(separator: ","))]"#
+ }
+ out += #","schema":{"roles":{\#(roles.joined(separator: ","))}}"#
+ } else if let schema {
+ out += #","schema":\#(quoteJSON(schema))"#
+ }
+ out += #","rules":[\#(rules.joined(separator: ","))]}"#
+ return out
+ }
+}
+
+/// Builds one rule's `patch`.
+public final class StylePatchBuilder {
+ private var fields: [String] = []
+ private var properties: [String] = []
+
+ fileprivate init() {}
+
+ /// `layout.visibility`.
+ public var visible: Bool? {
+ didSet { visible.map { fields.append(#""visible":\#($0)"#) } }
+ }
+
+ #if canImport(UIKit)
+ /**
+ The layer's colour, whichever property that is for its type:
+ `fill-color` on a fill, `line-color` on a line, `text-color` and
+ `icon-color` on a symbol, `background-color` on the background.
+ */
+ public var color: UIColor? {
+ didSet { color.map { fields.append(#""color":\#(quoteJSON($0.styleText))"#) } }
+ }
+ #endif
+
+ /// `*-opacity`, 0 to 1.
+ public var opacity: Double? {
+ didSet { opacity.map { fields.append(#""opacity":\#(trimmed($0))"#) } }
+ }
+
+ /**
+ Multiplies how fat the layer is drawn: `line-width` on a line,
+ `circle-radius` on a circle.
+
+ A multiplier rather than a value because the width is almost always an
+ expression over zoom, and replacing it with a number would throw that
+ curve away.
+ */
+ public var widthScale: Double? {
+ didSet { widthScale.map { fields.append(#""widthScale":\#(trimmed($0))"#) } }
+ }
+
+ public var minZoom: Double? {
+ didSet { minZoom.map { fields.append(#""minZoom":\#(trimmed($0))"#) } }
+ }
+
+ public var maxZoom: Double? {
+ didSet { maxZoom.map { fields.append(#""maxZoom":\#(trimmed($0))"#) } }
+ }
+
+ /// Drains the colour the style chose, 0 to 1.
+ public func desaturate(_ amount: Double) {
+ fields.append(#""colorFilter":{"desaturate":\#(trimmed(amount))}"#)
+ }
+
+ public func darken(_ amount: Double) {
+ fields.append(#""colorFilter":{"darken":\#(trimmed(amount))}"#)
+ }
+
+ public func lighten(_ amount: Double) {
+ fields.append(#""colorFilter":{"lighten":\#(trimmed(amount))}"#)
+ }
+
+ /**
+ Flips how light each colour is and keeps its hue: the cheapest way to
+ get a dark basemap out of a light one without naming a single colour.
+ */
+ public func invertLightness() {
+ fields.append(#""colorFilter":{"invertLightness":true}"#)
+ }
+
+ #if canImport(UIKit)
+ /// Blends what is there towards `target`.
+ public func mix(_ target: UIColor, amount: Double = 0.5) {
+ fields.append(
+ #""colorFilter":{"mix":{"color":\#(quoteJSON(target.styleText)),"amount":\#(trimmed(amount))}}"#
+ )
+ }
+ #endif
+
+ /// Replaces the layer's filter, as a MapLibre filter expression in JSON.
+ public func filter(_ json: String) {
+ fields.append(#""filter":\#(json)"#)
+ }
+
+ /**
+ Any style-spec property by name, as JSON. The last word: applied after
+ everything above, including over a `color` in the same rule.
+
+ The escape hatch for what the fields above do not name -- `text-field`,
+ `fill-pattern`, `line-dasharray`, a `fill-extrusion` height. Note that a
+ property the SDK's own rasteriser cannot draw will show only on a map
+ that renders the style itself; the compiler says so in its diagnostics.
+ */
+ public func property(_ key: String, _ valueJSON: String) {
+ properties.append(#"\#(quoteJSON(key)):\#(valueJSON)"#)
+ }
+
+ /// ``property(_:_:)`` for a plain text value, quoted for you.
+ public func propertyText(_ key: String, _ value: String) {
+ property(key, quoteJSON(value))
+ }
+
+ func toJSON() -> String {
+ var all = fields
+ if !properties.isEmpty {
+ all.append(#""properties":{\#(properties.joined(separator: ","))}"#)
+ }
+ return "{\(all.joined(separator: ","))}"
+ }
+}
+
+/// `1.3` rather than `1.3000000000000003`, and `2` rather than `2.0`.
+private func trimmed(_ value: Double) -> String {
+ if value == value.rounded() && abs(value) < 1e15 {
+ return String(Int64(value))
+ }
+ var text = String(format: "%.3f", value)
+ while text.hasSuffix("0") { text.removeLast() }
+ if text.hasSuffix(".") { text.removeLast() }
+ return text
+}
+
+#if canImport(UIKit)
+ extension UIColor {
+ /// `#rrggbb`, or `rgba(...)` when it is not fully opaque.
+ var styleText: String {
+ var r: CGFloat = 0
+ var g: CGFloat = 0
+ var b: CGFloat = 0
+ var a: CGFloat = 0
+ guard getRed(&r, green: &g, blue: &b, alpha: &a) else { return "#000000" }
+ let to255 = { (v: CGFloat) in Int((v * 255).rounded()).clamped(to: 0...255) }
+ if a >= 1 {
+ return String(format: "#%02x%02x%02x", to255(r), to255(g), to255(b))
+ }
+ return "rgba(\(to255(r)),\(to255(g)),\(to255(b)),\(trimmed(Double(a))))"
+ }
+ }
+
+ extension Int {
+ fileprivate func clamped(to range: ClosedRange) -> Int {
+ Swift.min(Swift.max(self, range.lowerBound), range.upperBound)
+ }
+ }
+#endif
+
+/**
+ A JSON string literal.
+
+ Written here rather than through `JSONSerialization`, which refuses a bare
+ string as a top-level value and would need a wrapper object unpicked again.
+ */
+func quoteJSON(_ text: String) -> String {
+ var out = "\""
+ for character in text.unicodeScalars {
+ switch character {
+ case "\"": out += "\\\""
+ case "\\": out += "\\\\"
+ case "\n": out += "\\n"
+ case "\r": out += "\\r"
+ case "\t": out += "\\t"
+ default:
+ if character.value < 0x20 {
+ out += String(format: "\\u%04x", character.value)
+ } else {
+ out.unicodeScalars.append(character)
+ }
+ }
+ }
+ return out + "\""
+}
diff --git a/ios-vectorstyle/Sources/MapConductorVectorStyle/VectorStyle.swift b/ios-vectorstyle/Sources/MapConductorVectorStyle/VectorStyle.swift
new file mode 100644
index 0000000..3b1cc49
--- /dev/null
+++ b/ios-vectorstyle/Sources/MapConductorVectorStyle/VectorStyle.swift
@@ -0,0 +1,433 @@
+import Foundation
+import MapConductorCore
+
+/**
+ A vector style, with adjustments, as the map's appearance.
+
+ ```swift
+ MapLibreMapView(
+ state: state,
+ style: VectorStyle(rules: StyleRules.build {
+ $0.all { $0.color = .black }
+ $0.role(LayerRole.label) { $0.visible = false }
+ $0.role(LayerRole.road) { $0.color = .white }
+ })
+ )
+ ```
+
+ What happens underneath depends on the backend, and the app does not have
+ to know:
+
+ - **A map that reads vector styles** (MapLibre, Mapbox, MapTiler) is given
+ the document and told the per-layer differences. Changing the rules later
+ sends new differences and **reloads nothing** -- which is the whole reason
+ the adjustments are compiled into deltas as well as a document.
+ - **A map that cannot** (Google Maps, MapKit, HERE, ArcGIS...) is handed
+ raster tiles drawn from the adjusted document, if the app passed a
+ `rasteriser`. Changing the rules re-rasterises and refetches nothing.
+
+ Either way the same rules produce the same map, because both paths start
+ from one compilation of one style.
+
+ ## What it reports
+
+ Quietly doing nothing is the failure this design is built against, so
+ `onDiagnostics` always hears how many layers each rule matched, which
+ matched none, what this backend would not take, and what the rasteriser
+ will not draw. A rule written for the wrong tile schema produces a
+ perfectly good map with nothing changed on it; nothing else says so.
+ */
+public struct VectorStyle: MapViewStyle {
+ /// Which style to adjust. Defaults to whatever the map is already drawing.
+ public let document: VectorStyleSource
+ public let rules: StyleRules
+ /**
+ Draws the style for a backend that cannot read one.
+
+ `MapConductorVectorTile` supplies one. Left out, a map without vector
+ style support is told so in the diagnostics rather than left blank --
+ there is nothing sensible this can do on its own.
+ */
+ /**
+ Draws the style for a backend that cannot read one.
+
+ `MapConductorVectorTile` supplies one. Left out, a map without vector
+ style support is told so in the diagnostics rather than left blank.
+
+ **Hold it, do not rebuild it.** Two styles count as the same one only
+ if they name the same rasteriser *instance*, so a `let` on a `View`
+ makes every evaluation a different style — the map tears the old one
+ down and installs a new one each time, and the result is tiles fetched
+ forever with nothing drawn. `@State private var rasteriser = …`.
+ */
+ public let rasteriser: VectorStyleRasteriser?
+ public let onUnsupported: UnsupportedPolicy
+ public let onDiagnostics: (([String]) -> Void)?
+
+ public init(
+ document: VectorStyleSource = .currentDesign,
+ rules: StyleRules = .none,
+ rasteriser: VectorStyleRasteriser? = nil,
+ onUnsupported: UnsupportedPolicy = .report,
+ onDiagnostics: (([String]) -> Void)? = nil
+ ) {
+ self.document = document
+ self.rules = rules
+ self.rasteriser = rasteriser
+ self.onUnsupported = onUnsupported
+ self.onDiagnostics = onDiagnostics
+ }
+
+ public var key: String { "\(document.key)|\(rules.json.hashValue)" }
+
+ /// The message an error carries, rather than Foundation's stand-in for one.
+ static func describe(_ error: Error) -> String {
+ if let known = error as? VectorStyleError { return known.message }
+ return (error as NSError).localizedDescription
+ }
+
+ /// Whether `next` differs from this only in its adjustments.
+ func sameStyle(as next: VectorStyle) -> Bool {
+ next.document == document
+ && next.rasteriser === rasteriser
+ && next.onUnsupported == onUnsupported
+ }
+
+ public func install(host: MapStyleHost) -> MapStyleInstallation {
+ let installation = Installation(style: self, host: host)
+ installation.start()
+ return MapStyleInstallation(
+ dispose: { installation.dispose() },
+ update: { next in
+ guard let next = next as? VectorStyle else { return false }
+ return installation.update(next)
+ }
+ )
+ }
+}
+
+extension UnsupportedPolicy: Equatable {}
+
+/**
+ One style's life on one map.
+
+ Resolving the document can mean a network round trip, so the work starts on
+ a queue and ``start()`` returns at once; everything after that checks
+ `disposed` before touching the map, because a view can be gone before its
+ style arrives.
+ */
+final class Installation: @unchecked Sendable {
+ private let lock = NSLock()
+ private var style: VectorStyle
+ private let host: MapStyleHost
+ private var disposed = false
+ private let documentId = "vectorstyle-\(UUID().uuidString)"
+ // Accessed on the main thread along with the native style handoff.
+ private var documentRevision = 0
+
+ /// The style as its author wrote it, kept so later rules compile against it.
+ private var base: String?
+ private var rasterisation: VectorStyleRasterisation?
+ private var servedDocument = false
+ private var styleLoaded: MapStyleInstallation?
+
+ init(style: VectorStyle, host: MapStyleHost) {
+ self.style = style
+ self.host = host
+ }
+
+ func start() {
+ Installation.workers.async { [self] in
+ let resolved: String
+ do {
+ resolved = try resolve(style.document)
+ } catch {
+ // `localizedDescription` on a plain Swift error is
+ // "The operation couldn't be completed", which tells the app
+ // nothing. The message this module wrote is the point.
+ report(["the style could not be read: \(VectorStyle.describe(error))"])
+ return
+ }
+ guard !isDisposed else { return }
+ lock.lock()
+ base = resolved
+ lock.unlock()
+ apply(style, firstTime: true)
+ }
+ }
+
+ func update(_ next: VectorStyle) -> Bool {
+ // A different document is a different style, whatever else matches:
+ // it has to be fetched and the map has to be told.
+ guard style.sameStyle(as: next) else { return false }
+ lock.lock()
+ let haveBase = base != nil
+ if haveBase { style = next }
+ lock.unlock()
+ guard haveBase else { return false }
+ Installation.workers.async { [self] in
+ guard !isDisposed else { return }
+ apply(next, firstTime: false)
+ }
+ return true
+ }
+
+ func dispose() {
+ lock.lock()
+ if disposed {
+ lock.unlock()
+ return
+ }
+ disposed = true
+ let hadDocument = servedDocument
+ let raster = rasterisation
+ let loaded = styleLoaded
+ rasterisation = nil
+ styleLoaded = nil
+ lock.unlock()
+
+ loaded?.dispose()
+ // Order matters: the mutations have to come off while the document
+ // they were applied to is still the one loaded.
+ host.serviceRegistry.get(VectorStyleMutationSupportKey.self)?.clear()
+ if hadDocument {
+ host.serviceRegistry.get(VectorStyleSupportKey.self)?.clearStyle()
+ host.tileServer.unregisterDocument(id: documentId)
+ }
+ raster?.dispose()
+ }
+
+ private var isDisposed: Bool {
+ lock.lock()
+ defer { lock.unlock() }
+ return disposed
+ }
+
+ /// Compiles on a worker, then routes on the main thread.
+ private func apply(_ style: VectorStyle, firstTime: Bool) {
+ lock.lock()
+ let base = self.base
+ lock.unlock()
+ guard let base else { return }
+
+ let compiled: CompiledStyle
+ do {
+ compiled = try VectorStyleRules.compile(styleJSON: base, rulesJSON: style.rules.json)
+ } catch {
+ report(["the rules could not be applied: \(VectorStyle.describe(error))"])
+ return
+ }
+ guard !isDisposed else { return }
+ report(compiled.diagnostics)
+ onMain { [weak self] in self?.route(style, compiled, firstTime: firstTime, base: base) }
+ }
+
+ /**
+ Hands the compiled result to the map. **Main thread only.**
+
+ Every branch below touches the renderer, and a renderer is a UI object.
+ MapLibre on Android says so out loud -- `CalledFromWorkerThreadException:
+ Method invoked from wrong thread is getLayer` -- and because
+ `applyStyleMutations` catches what a mutation throws, all of them came
+ back as adjustments the map "cannot" make. Here it does not throw, which
+ only means the same mistake is quieter.
+ */
+ private func route(
+ _ style: VectorStyle, _ compiled: CompiledStyle, firstTime: Bool, base: String
+ ) {
+ guard !isDisposed else { return }
+ let registry = host.serviceRegistry
+ let vector = registry.get(VectorStyleSupportKey.self)
+ let mutation = registry.get(VectorStyleMutationSupportKey.self)
+
+ if let vector, let mutation {
+ // The map draws styles itself and can be told differences: give
+ // it the author's document and the deltas on top. The compiled
+ // document is deliberately *not* what it is given -- the next
+ // rule change produces deltas against the original, and a map
+ // holding an already-adjusted document would take them to mean
+ // something else.
+ if firstTime { serve(document: base, to: vector) }
+ let unapplied = mutation.apply(compiled.mutations)
+ if !unapplied.isEmpty {
+ handle(unapplied: unapplied, vector: vector, compiled: compiled, style: style)
+ }
+ } else if let vector {
+ // It draws styles but cannot be patched: the adjusted document
+ // is the only way, and every rule change reloads.
+ serve(document: compiled.styleJSON, to: vector)
+ } else if let rasteriser = style.rasteriser {
+ lock.lock()
+ let current = rasterisation
+ lock.unlock()
+ if let current {
+ current.restyle(styleJSON: compiled.styleJSON, affects: compiled.affects)
+ } else {
+ let started = rasteriser.install(
+ host: host, styleJSON: compiled.styleJSON, affects: compiled.affects)
+ lock.lock()
+ rasterisation = started
+ let gone = disposed
+ lock.unlock()
+ if gone { started.dispose() }
+ }
+ } else {
+ report([
+ "this map cannot draw a vector style, and no rasteriser was given; "
+ + "pass `rasteriser:` from MapConductorVectorTile"
+ ])
+ }
+
+ // Whatever was just applied is lost the moment the map loads a style
+ // again -- an app switching basemap, a provider rebuilding. The
+ // mutation store puts its own set back.
+ lock.lock()
+ let needsHook = firstTime && styleLoaded == nil
+ lock.unlock()
+ if needsHook {
+ let hook = host.onStyleLoaded {}
+ lock.lock()
+ styleLoaded = hook
+ lock.unlock()
+ }
+ }
+
+ private func serve(document json: String, to vector: VectorStyleSupport) {
+ host.tileServer.registerDocument(
+ id: documentId, contentType: "application/json", body: Data(json.utf8))
+ lock.lock()
+ servedDocument = true
+ lock.unlock()
+ // Providers identify designs by URL, and native engines cache styles
+ // by that URL. Replacing only the response body leaves the old style
+ // on screen on backends without in-place mutation support (MapTiler).
+ documentRevision += 1
+ vector.showStyle(
+ url: host.tileServer.documentUrl(id: documentId) + "?revision=\(documentRevision)",
+ attributionRules: Installation.attributions(of: json).map {
+ AttributionRule(attribution: $0)
+ }
+ )
+ }
+
+ private func handle(
+ unapplied: [StyleMutation], vector: VectorStyleSupport, compiled: CompiledStyle,
+ style: VectorStyle
+ ) {
+ // Named by property, not by layer. A backend with no property for a
+ // spec key fails that key on every layer that uses it, so the layer
+ // list was hundreds of names for one cause -- and never said which
+ // property, which is the only part an app can act on.
+ let properties = Array(Set(unapplied.map(\.propertyName))).sorted()
+ let shown = properties.prefix(5)
+ let more = properties.count > shown.count ? ", ..." : ""
+ let layers = Set(unapplied.map(\.layerId)).count
+ let message =
+ "\(unapplied.count) adjustments were not applied: this map cannot set these "
+ + "properties by name (\(shown.joined(separator: ", "))\(more)) "
+ + "across \(layers) layers"
+ switch style.onUnsupported {
+ case .report:
+ report([message])
+ case .reload:
+ report(["\(message); handing the map the adjusted document instead"])
+ host.serviceRegistry.get(VectorStyleMutationSupportKey.self)?.clear()
+ serve(document: compiled.styleJSON, to: vector)
+ }
+ }
+
+ private func resolve(_ source: VectorStyleSource) throws -> String {
+ switch source {
+ case let .text(json):
+ return json
+ case let .url(url, headers):
+ return try Installation.fetch(url, headers: headers)
+ case .currentDesign:
+ guard let url = host.vectorStyleUrl else {
+ throw VectorStyleError.unreadable(
+ "this map is not drawing a vector style, so there is nothing to adjust; "
+ + "give `document` a style of your own")
+ }
+ return try Installation.fetch(url, headers: [:])
+ }
+ }
+
+ private func report(_ diagnostics: [String]) {
+ guard !diagnostics.isEmpty else { return }
+ host.report(diagnostics)
+ style.onDiagnostics?(diagnostics)
+ }
+
+ /**
+ Where a style is fetched and compiled.
+
+ Serial: a map has one style, the work is a round trip plus a parse, and
+ two rule changes in flight at once would race to be the last applied.
+ */
+ private static let workers = DispatchQueue(
+ label: "com.mapconductor.vectorstyle", qos: .userInitiated)
+
+ /// Runs now when already on the main thread, and hops otherwise.
+ private func onMain(_ block: @escaping () -> Void) {
+ if Thread.isMainThread { block() } else { DispatchQueue.main.async(execute: block) }
+ }
+
+ private static func fetch(_ url: String, headers: [String: String]) throws -> String {
+ guard let target = URL(string: url) else {
+ throw VectorStyleError.unreadable("not a URL: \(url)")
+ }
+ var request = URLRequest(url: target, timeoutInterval: 15)
+ for (name, value) in headers { request.setValue(value, forHTTPHeaderField: name) }
+
+ // Synchronous on purpose: this already runs on a worker, and the
+ // caller is a sequence of steps that have to happen in order.
+ var answer: Result = .failure(
+ VectorStyleError.unreadable("the style could not be fetched"))
+ let done = DispatchSemaphore(value: 0)
+ URLSession.shared.dataTask(with: request) { data, response, error in
+ defer { done.signal() }
+ if let error {
+ answer = .failure(error)
+ return
+ }
+ let status = (response as? HTTPURLResponse)?.statusCode ?? 0
+ guard (200..<300).contains(status) else {
+ answer = .failure(VectorStyleError.unreadable("HTTP \(status) for \(url)"))
+ return
+ }
+ guard let data, let text = String(data: data, encoding: .utf8) else {
+ answer = .failure(VectorStyleError.unreadable("the style was not text"))
+ return
+ }
+ answer = .success(text)
+ }.resume()
+ done.wait()
+ return try answer.get()
+ }
+
+ /**
+ The credits the style's sources ask for.
+
+ Carried onto the design so the map's attribution overlay shows them for
+ as long as the style is up. Nothing else about this can be wrong while
+ still looking right: the map draws perfectly whether or not anyone is
+ credited for the data.
+ */
+ private static func attributions(of json: String) -> [String] {
+ guard
+ let parsed = try? JSONSerialization.jsonObject(with: Data(json.utf8))
+ as? [String: Any],
+ let sources = parsed["sources"] as? [String: Any]
+ else { return [] }
+ var seen: [String] = []
+ for key in sources.keys.sorted() {
+ guard
+ let source = sources[key] as? [String: Any],
+ let credit = source["attribution"] as? String,
+ !credit.isEmpty, !seen.contains(credit)
+ else { continue }
+ seen.append(credit)
+ }
+ return seen
+ }
+}
diff --git a/ios-vectorstyle/Sources/MapConductorVectorStyle/VectorStyleRules.swift b/ios-vectorstyle/Sources/MapConductorVectorStyle/VectorStyleRules.swift
new file mode 100644
index 0000000..676562e
--- /dev/null
+++ b/ios-vectorstyle/Sources/MapConductorVectorStyle/VectorStyleRules.swift
@@ -0,0 +1,201 @@
+import CMvtStyle
+import Foundation
+import MapConductorCore
+
+/**
+ Declarative adjustments to a MapLibre style.
+
+ A map in this SDK is drawn one of two ways: by a renderer that takes a
+ vector style directly (MapLibre, Mapbox, MapTiler), or by rasterising the
+ style to PNG tiles on the device and handing those to a backend that cannot
+ read a style at all (Google Maps, MapKit, HERE, ArcGIS...). Both start from
+ the same `style.json`, so adjusting the style belongs *before* that fork
+ rather than on either side of it.
+
+ ``compile(styleJSON:rulesJSON:)`` is that step. From one pass over one style
+ it produces both the adjusted document -- for a renderer to read, or for the
+ rasteriser to draw -- and the per-layer deltas a live renderer can be told
+ about without reloading anything. The two cannot disagree, because they come
+ from the same evaluation.
+
+ ```swift
+ let compiled = try VectorStyleRules.compile(styleJSON: style, rulesJSON: rules)
+ compiled.diagnostics.forEach { print($0) } // what matched, and what did not
+ ```
+
+ The rules themselves are JSON. That is the definition rather than a
+ serialisation of something else: a rule set can be written by hand, stored,
+ shipped from a server or diffed, and it is exactly what the compiler reads
+ on all three platforms.
+ */
+public enum VectorStyleRules {
+ /**
+ The rules document version this build understands.
+
+ A document claiming another version is refused rather than partly
+ applied -- a rule that silently does nothing is the failure this whole
+ design exists to avoid.
+ */
+ public static var schemaVersion: Int {
+ Int(mvt_style_schema_version())
+ }
+
+ /**
+ Applies `rulesJSON` to `styleJSON`.
+
+ - Throws: ``VectorStyleError`` when either document cannot be read: a
+ style that is not JSON, a rules document from a newer build, a
+ selector this version does not know.
+ */
+ public static func compile(styleJSON: String, rulesJSON: String) throws -> CompiledStyle {
+ let answer = try call { style in
+ try rulesJSON.withCString { rules in
+ try take(mvt_style_compile(style, rules))
+ }
+ }(styleJSON)
+
+ guard let root = try JSONSerialization.jsonObject(with: Data(answer.utf8)) as? [String: Any]
+ else { throw VectorStyleError.unreadable("the compiler answered with nothing") }
+ if let message = root["error"] as? String { throw VectorStyleError.unreadable(message) }
+ guard
+ let style = root["style"],
+ let styleData = try? JSONSerialization.data(withJSONObject: style),
+ let styleText = String(data: styleData, encoding: .utf8)
+ else { throw VectorStyleError.unreadable("the adjusted style could not be written") }
+
+ let mutations = root["mutations"] ?? []
+ let mutationsText =
+ (try? JSONSerialization.data(withJSONObject: mutations))
+ .flatMap { String(data: $0, encoding: .utf8) } ?? "[]"
+
+ return CompiledStyle(
+ styleJSON: styleText,
+ mutations: StyleMutation.parseList(mutationsText),
+ affects: StyleAffects(name: root["affects"] as? String),
+ patchable: root["patchable"] as? Bool ?? true,
+ diagnostics: root["diagnostics"] as? [String] ?? []
+ )
+ }
+
+ /**
+ Every layer in the style, with the role this would give it.
+
+ Rules are written against someone else's style, so being able to ask
+ what is in it -- and on what evidence a layer counts as a road -- is not
+ a debugging aid, it is how the rules get written in the first place.
+ */
+ public static func describe(styleJSON: String) throws -> [StyleLayerInfo] {
+ let answer = try styleJSON.withCString { style in
+ try take(mvt_style_describe(style))
+ }
+ let parsed = try JSONSerialization.jsonObject(with: Data(answer.utf8))
+ if let failure = parsed as? [String: Any] {
+ throw VectorStyleError.unreadable(
+ failure["error"] as? String ?? "the style could not be read")
+ }
+ guard let listed = parsed as? [[String: Any]] else {
+ throw VectorStyleError.unreadable("the compiler answered with nothing")
+ }
+ return listed.map { entry in
+ StyleLayerInfo(
+ id: entry["id"] as? String ?? "",
+ kind: entry["kind"] as? String ?? "",
+ sourceLayer: entry["sourceLayer"] as? String,
+ roles: entry["roles"] as? [String] ?? [],
+ evidence: entry["evidence"] as? [String] ?? []
+ )
+ }
+ }
+
+ /// Reads a string the compiler returned and releases it.
+ private static func take(_ raw: UnsafeMutablePointer?) throws -> String {
+ guard let raw else {
+ // Null means an argument was not valid UTF-8 or the compiler
+ // panicked. Everything else arrives inside the JSON.
+ throw VectorStyleError.unreadable("the style compiler could not be called")
+ }
+ defer { mvt_style_string_free(raw) }
+ return String(cString: raw)
+ }
+
+ /// `withCString` on the outer argument, so both strings stay alive for the call.
+ private static func call(
+ _ body: @escaping (UnsafePointer) throws -> String
+ ) -> (String) throws -> String {
+ { text in try text.withCString(body) }
+ }
+}
+
+/// What came out of ``VectorStyleRules/compile(styleJSON:rulesJSON:)``.
+public struct CompiledStyle: Sendable {
+ /// The adjusted document, to hand to a renderer or to rasterise.
+ public let styleJSON: String
+ /**
+ The per-layer deltas, for a map that can change a loaded style in place
+ rather than reading the document again.
+ */
+ public let mutations: [StyleMutation]
+ /**
+ Which half of a split raster layer has to be redrawn. A rule that only
+ recolours text need not invalidate every tile on screen.
+ */
+ public let affects: StyleAffects
+ /**
+ False when the mutations alone cannot reproduce the document -- a rule
+ added a layer -- so a live map has to be given the document instead of
+ being patched.
+ */
+ public let patchable: Bool
+ /**
+ What the app should know: how many layers each rule matched, a rule that
+ matched none, a change the rasteriser will not draw, an expression it
+ cannot evaluate.
+
+ Worth surfacing rather than keeping internal. A rule written against the
+ wrong tile schema produces a perfectly good map with nothing changed on
+ it, and nothing else says so.
+ */
+ public let diagnostics: [String]
+}
+
+/// Which half of a split raster layer a change reaches.
+public enum StyleAffects: String, Sendable {
+ case none
+ case ground
+ case labels
+ case both
+
+ init(name: String?) {
+ self = name.flatMap(StyleAffects.init(rawValue:)) ?? .none
+ }
+}
+
+/// One layer of a style, and the role the compiler would give it.
+public struct StyleLayerInfo: Sendable, Equatable {
+ public let id: String
+ /// The layer's `type`: `background`, `fill`, `line`, `circle`, `symbol`, or another.
+ public let kind: String
+ public let sourceLayer: String?
+ /// Most specific last: a road casing is both `road` and `road-casing`.
+ public let roles: [String]
+ /**
+ How each role was decided, in the same order as `roles`:
+ `shortbread:streets` from the schema's table, `id~*-casing` from the
+ layer's name, `kind:symbol` from its type.
+
+ The id heuristics are the part most likely to be wrong on a style nobody
+ has tried yet, so what they decided is said out loud.
+ */
+ public let evidence: [String]
+}
+
+/// A style or a rules document that could not be read.
+public enum VectorStyleError: Error, Equatable {
+ case unreadable(String)
+
+ public var message: String {
+ switch self {
+ case let .unreadable(message): return message
+ }
+ }
+}
diff --git a/ios-vectorstyle/Sources/MapConductorVectorStyle/VectorStyleSource.swift b/ios-vectorstyle/Sources/MapConductorVectorStyle/VectorStyleSource.swift
new file mode 100644
index 0000000..5ff3a69
--- /dev/null
+++ b/ios-vectorstyle/Sources/MapConductorVectorStyle/VectorStyleSource.swift
@@ -0,0 +1,98 @@
+import Foundation
+import MapConductorCore
+
+/**
+ Which style the rules are applied to.
+
+ A rule cannot be matched against anything until the style document is in
+ hand: "roads" means whichever layers *this* style draws roads with, and only
+ the document says. So every path here ends in text, and the one that has to
+ go and get it says so.
+ */
+public enum VectorStyleSource: Equatable, Sendable {
+ /// The document itself. The app already has it.
+ case text(String)
+
+ /**
+ A `style.json` to fetch.
+
+ Fetched by this module rather than by the map, because the rules have to
+ be compiled against it before the map is given anything.
+ */
+ case url(String, headers: [String: String] = [:])
+
+ /**
+ Whatever the map is already drawing.
+
+ For adjusting a basemap the app did not supply: a MapLibre, Mapbox or
+ MapTiler design. The module reads the design's style URL from
+ ``MapStyleHost/vectorStyleUrl`` and fetches it; a backend that has no
+ such thing cannot be adjusted this way and says so.
+ */
+ case currentDesign
+
+ /// What identifies this source, for deciding whether a style changed.
+ public var key: String {
+ switch self {
+ case let .text(json): return "text:\(json.hashValue)"
+ case let .url(url, _): return "url:\(url)"
+ case .currentDesign: return "current"
+ }
+ }
+}
+
+/**
+ What to do about an adjustment the map would not take.
+
+ Only MapLibre here reaches this today: it exposes paint properties as typed
+ `NSExpression`s rather than by name, so a raw style-spec key it has no
+ mapping for cannot be set.
+ */
+public enum UnsupportedPolicy: Sendable {
+ /**
+ Say so and leave the rest applied. The default, because the alternative
+ makes the map reload -- and not reloading is the point.
+ */
+ case report
+
+ /**
+ Hand the map the adjusted document instead, so everything applies.
+
+ Correct, and expensive: the renderer drops its tiles, re-reads the
+ document and the app's overlays are rebuilt.
+ */
+ case reload
+}
+
+/**
+ Draws a style the map cannot read itself.
+
+ The seam between this module and the rasteriser. Declared here and
+ implemented in `MapConductorVectorTile`, which is what keeps an app using
+ only MapLibre from linking a megabyte of renderer it will never call. An app
+ that also targets Google Maps, MapKit, HERE or ArcGIS passes one in; one
+ that does not, does not, and is told so rather than shown a blank map.
+ */
+public protocol VectorStyleRasteriser: AnyObject {
+ /**
+ Starts drawing `styleJSON` as raster tiles on `host`.
+
+ - Parameter affects: which half of the split tile layers the adjustments
+ reached, so a change to labels alone need not redraw the ground.
+ */
+ func install(host: MapStyleHost, styleJSON: String, affects: StyleAffects)
+ -> VectorStyleRasterisation
+}
+
+/// A rasteriser's work in progress.
+public protocol VectorStyleRasterisation: AnyObject {
+ /**
+ Draws a different style with the same tiles.
+
+ The geometry has not changed, only the paint over it, so this costs a
+ re-rasterise and no network.
+ */
+ func restyle(styleJSON: String, affects: StyleAffects)
+
+ func dispose()
+}
diff --git a/ios-vectorstyle/Tests/MapConductorVectorStyleTests/Resources/versatiles-colorful.json b/ios-vectorstyle/Tests/MapConductorVectorStyleTests/Resources/versatiles-colorful.json
new file mode 100644
index 0000000..c7bd8d7
--- /dev/null
+++ b/ios-vectorstyle/Tests/MapConductorVectorStyleTests/Resources/versatiles-colorful.json
@@ -0,0 +1 @@
+{"version": 8, "name": "versatiles-colorful", "metadata": {"license": "https://creativecommons.org/publicdomain/zero/1.0/"}, "glyphs": "https://tiles.versatiles.org/assets/glyphs/{fontstack}/{range}.pbf", "sprite": [{"id": "base", "url": "https://tiles.versatiles.org/assets/sprites/base"}], "sources": {"versatiles-shortbread": {"type": "vector", "tiles": ["https://vector.openstreetmap.org/shortbread_v1/{z}/{x}/{y}.mvt"], "minzoom": 0, "maxzoom": 14}}, "layers": [{"id": "background", "type": "background", "paint": {"background-color": "rgb(248,244,240)"}}, {"id": "slot-below-fills", "type": "background", "paint": {"background-opacity": 0}}, {"id": "water-ocean", "type": "fill", "source-layer": "ocean", "paint": {"fill-color": "rgb(191,217,242)"}, "source": "versatiles-shortbread"}, {"id": "land-glacier", "type": "fill", "source-layer": "water_polygons", "filter": ["==", ["get", "kind"], "glacier"], "paint": {"fill-color": "rgb(255,255,255)", "fill-opacity": ["interpolate", ["linear"], ["zoom"], 4, 0, 5, 1]}, "minzoom": 4, "source": "versatiles-shortbread"}, {"id": "land-commercial", "type": "fill", "source-layer": "land", "filter": ["in", ["get", "kind"], ["literal", ["commercial", "retail"]]], "paint": {"fill-color": "rgb(248,238,239)", "fill-opacity": ["interpolate", ["linear"], ["zoom"], 10, 0, 11, 1]}, "minzoom": 10, "source": "versatiles-shortbread"}, {"id": "land-industrial", "type": "fill", "source-layer": "land", "filter": ["in", ["get", "kind"], ["literal", ["industrial", "quarry", "railway"]]], "paint": {"fill-color": "rgb(250,244,225)", "fill-opacity": ["interpolate", ["linear"], ["zoom"], 10, 0, 11, 1]}, "minzoom": 10, "source": "versatiles-shortbread"}, {"id": "land-residential", "type": "fill", "source-layer": "land", "filter": ["in", ["get", "kind"], ["literal", ["garages", "residential"]]], "paint": {"fill-color": "rgb(245,241,237)", "fill-opacity": ["interpolate", ["linear"], ["zoom"], 10, 0, 11, 1]}, "minzoom": 10, "source": "versatiles-shortbread"}, {"id": "land-agriculture", "type": "fill", "source-layer": "land", "filter": ["in", ["get", "kind"], ["literal", ["farmland", "farmyard", "greenhouse_horticulture", "orchard", "plant_nursery", "vineyard"]]], "paint": {"fill-color": "rgb(233,242,206)", "fill-opacity": ["interpolate", ["linear"], ["zoom"], 10, 0, 11, 1]}, "minzoom": 10, "source": "versatiles-shortbread"}, {"id": "land-waste", "type": "fill", "source-layer": "land", "filter": ["in", ["get", "kind"], ["literal", ["landfill"]]], "paint": {"fill-color": "rgb(219,214,189)", "fill-opacity": ["interpolate", ["linear"], ["zoom"], 10, 0, 11, 1]}, "minzoom": 10, "source": "versatiles-shortbread"}, {"id": "land-park", "type": "fill", "source-layer": "land", "filter": ["in", ["get", "kind"], ["literal", ["park", "village_green", "recreation_ground"]]], "paint": {"fill-color": "rgba(205,217,169,0.5)", "fill-opacity": ["interpolate", ["linear"], ["zoom"], 11, 0, 12, 1]}, "minzoom": 11, "source": "versatiles-shortbread"}, {"id": "land-garden", "type": "fill", "source-layer": "land", "filter": ["in", ["get", "kind"], ["literal", ["allotments", "garden"]]], "paint": {"fill-color": "rgb(205,217,169)", "fill-opacity": ["interpolate", ["linear"], ["zoom"], 11, 0, 12, 1]}, "minzoom": 11, "source": "versatiles-shortbread"}, {"id": "land-burial", "type": "fill", "source-layer": "land", "filter": ["in", ["get", "kind"], ["literal", ["cemetery", "grave_yard"]]], "paint": {"fill-color": "rgb(224,228,221)", "fill-opacity": ["interpolate", ["linear"], ["zoom"], 13, 0, 14, 1]}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "land-leisure", "type": "fill", "source-layer": "land", "filter": ["in", ["get", "kind"], ["literal", ["miniature_golf", "playground", "golf_course"]]], "paint": {"fill-color": "rgb(231,237,222)", "fill-opacity": ["interpolate", ["linear"], ["zoom"], 11, 0, 12, 1]}, "minzoom": 11, "source": "versatiles-shortbread"}, {"id": "land-rock", "type": "fill", "source-layer": "land", "filter": ["in", ["get", "kind"], ["literal", ["bare_rock", "scree", "shingle"]]], "paint": {"fill-color": "rgb(242,242,230)", "fill-opacity": ["interpolate", ["linear"], ["zoom"], 11, 0, 12, 1]}, "minzoom": 11, "source": "versatiles-shortbread"}, {"id": "land-forest", "type": "fill", "source-layer": "land", "filter": ["in", ["get", "kind"], ["literal", ["forest"]]], "paint": {"fill-color": "rgb(203,229,184)", "fill-opacity": ["interpolate", ["linear"], ["zoom"], 7, 0, 8, 1]}, "minzoom": 7, "source": "versatiles-shortbread"}, {"id": "land-grass", "type": "fill", "source-layer": "land", "filter": ["in", ["get", "kind"], ["literal", ["grass", "grassland", "meadow", "wet_meadow"]]], "paint": {"fill-color": "rgb(209,229,195)", "fill-opacity": ["interpolate", ["linear"], ["zoom"], 11, 0, 12, 1]}, "minzoom": 11, "source": "versatiles-shortbread"}, {"id": "land-vegetation", "type": "fill", "source-layer": "land", "filter": ["in", ["get", "kind"], ["literal", ["heath", "scrub"]]], "paint": {"fill-color": "rgb(230,238,199)", "fill-opacity": ["interpolate", ["linear"], ["zoom"], 11, 0, 12, 1]}, "minzoom": 11, "source": "versatiles-shortbread"}, {"id": "land-sand", "type": "fill", "source-layer": "land", "filter": ["in", ["get", "kind"], ["literal", ["beach", "sand"]]], "paint": {"fill-color": "rgb(242,242,206)", "fill-opacity": ["interpolate", ["linear"], ["zoom"], 10, 0, 11, 1]}, "minzoom": 10, "source": "versatiles-shortbread"}, {"id": "land-wetland", "type": "fill", "source-layer": "land", "filter": ["in", ["get", "kind"], ["literal", ["bog", "marsh", "string_bog", "swamp"]]], "paint": {"fill-color": "rgb(211,230,219)", "fill-opacity": ["interpolate", ["linear"], ["zoom"], 11, 0, 12, 1]}, "minzoom": 11, "source": "versatiles-shortbread"}, {"id": "site-dangerarea", "type": "fill", "source-layer": "sites", "filter": ["==", ["get", "kind"], "danger_area"], "paint": {"fill-color": "rgb(255,0,0)", "fill-outline-color": "rgb(255,0,0)", "fill-opacity": ["interpolate", ["linear"], ["zoom"], 14, 0, 15, 0.302], "fill-pattern": "base:pattern-hatched"}, "minzoom": 14, "source": "versatiles-shortbread"}, {"id": "site-sportscentre", "type": "fill", "source-layer": "sites", "filter": ["==", ["get", "kind"], "sports_centre"], "paint": {"fill-color": "rgba(246,243,238,0.149)", "fill-opacity": ["interpolate", ["linear"], ["zoom"], 14, 0, 15, 1]}, "minzoom": 14, "source": "versatiles-shortbread"}, {"id": "site-education", "type": "fill", "source-layer": "sites", "filter": ["any", ["==", ["get", "kind"], "university"], ["==", ["get", "kind"], "college"], ["==", ["get", "kind"], "school"]], "paint": {"fill-color": "rgba(255,255,128,0.125)", "fill-opacity": ["interpolate", ["linear"], ["zoom"], 14, 0, 15, 1]}, "minzoom": 14, "source": "versatiles-shortbread"}, {"id": "site-hospital", "type": "fill", "source-layer": "sites", "filter": ["==", ["get", "kind"], "hospital"], "paint": {"fill-color": "rgba(255,102,102,0.102)", "fill-opacity": ["interpolate", ["linear"], ["zoom"], 14, 0, 15, 1]}, "minzoom": 14, "source": "versatiles-shortbread"}, {"id": "site-prison", "type": "fill", "source-layer": "sites", "filter": ["==", ["get", "kind"], "prison"], "paint": {"fill-color": "rgb(253,242,252)", "fill-pattern": "base:pattern-striped", "fill-opacity": ["interpolate", ["linear"], ["zoom"], 14, 0, 15, 0.102]}, "minzoom": 14, "source": "versatiles-shortbread"}, {"id": "site-parking", "type": "fill", "source-layer": "sites", "filter": ["any", ["==", ["get", "kind"], "parking"], ["==", ["get", "kind"], "bicycle_parking"]], "paint": {"fill-color": "rgb(235,232,230)", "fill-opacity": ["interpolate", ["linear"], ["zoom"], 14, 0, 15, 1]}, "minzoom": 14, "source": "versatiles-shortbread"}, {"id": "site-construction", "type": "fill", "source-layer": "sites", "filter": ["==", ["get", "kind"], "construction"], "paint": {"fill-color": "rgb(169,169,169)", "fill-pattern": "base:pattern-hatched_thin", "fill-opacity": ["interpolate", ["linear"], ["zoom"], 14, 0, 15, 0.102]}, "minzoom": 14, "source": "versatiles-shortbread"}, {"id": "water-river", "type": "line", "source-layer": "water_lines", "filter": ["all", ["==", ["get", "kind"], "river"], ["!=", ["get", "tunnel"], true], ["!=", ["get", "bridge"], true]], "paint": {"line-color": "rgb(166,202,237)", "line-width": ["interpolate", ["exponential", 1.2], ["zoom"], 10, 0.8, 20, 6]}, "layout": {"line-cap": "round", "line-join": "round"}, "minzoom": 9, "source": "versatiles-shortbread"}, {"id": "water-canal", "type": "line", "source-layer": "water_lines", "filter": ["all", ["==", ["get", "kind"], "canal"], ["!=", ["get", "tunnel"], true], ["!=", ["get", "bridge"], true]], "paint": {"line-color": "rgb(166,202,237)", "line-width": ["interpolate", ["exponential", 1.3], ["zoom"], 13, 0.5, 20, 6]}, "layout": {"line-cap": "round", "line-join": "round"}, "minzoom": 9, "source": "versatiles-shortbread"}, {"id": "water-stream", "type": "line", "source-layer": "water_lines", "filter": ["all", ["==", ["get", "kind"], "stream"], ["!=", ["get", "tunnel"], true], ["!=", ["get", "bridge"], true]], "paint": {"line-color": "rgb(166,202,237)", "line-width": ["interpolate", ["exponential", 1.3], ["zoom"], 13, 0.5, 20, 6]}, "layout": {"line-cap": "round", "line-join": "round"}, "minzoom": 14, "source": "versatiles-shortbread"}, {"id": "water-ditch", "type": "line", "source-layer": "water_lines", "filter": ["all", ["==", ["get", "kind"], "ditch"], ["!=", ["get", "tunnel"], true], ["!=", ["get", "bridge"], true]], "paint": {"line-color": "rgb(166,202,237)", "line-width": ["interpolate", ["exponential", 1.3], ["zoom"], 13, 0.5, 20, 2]}, "layout": {"line-cap": "round", "line-join": "round"}, "minzoom": 14, "source": "versatiles-shortbread"}, {"id": "water-area", "type": "fill", "source-layer": "water_polygons", "filter": ["==", ["get", "kind"], "water"], "paint": {"fill-color": "rgb(191,217,242)", "fill-opacity": ["interpolate", ["linear"], ["zoom"], 4, 0, 5, 1]}, "minzoom": 4, "source": "versatiles-shortbread"}, {"id": "water-area-river", "type": "fill", "source-layer": "water_polygons", "filter": ["==", ["get", "kind"], "river"], "paint": {"fill-color": "rgb(191,217,242)", "fill-opacity": ["interpolate", ["linear"], ["zoom"], 4, 0, 5, 1]}, "minzoom": 4, "source": "versatiles-shortbread"}, {"id": "water-area-small", "type": "fill", "source-layer": "water_polygons", "filter": ["in", ["get", "kind"], ["literal", ["reservoir", "basin", "dock"]]], "paint": {"fill-color": "rgb(191,217,242)", "fill-opacity": ["interpolate", ["linear"], ["zoom"], 4, 0, 5, 1]}, "minzoom": 4, "source": "versatiles-shortbread"}, {"id": "water-dam-area", "type": "fill", "source-layer": "dam_polygons", "filter": ["==", ["get", "kind"], "dam"], "paint": {"fill-color": "rgb(248,244,240)", "fill-opacity": ["interpolate", ["linear"], ["zoom"], 12, 0, 13, 1]}, "minzoom": 12, "source": "versatiles-shortbread"}, {"id": "water-dam", "type": "line", "source-layer": "dam_lines", "filter": ["==", ["get", "kind"], "dam"], "paint": {"line-color": "rgb(191,217,242)", "line-width": ["interpolate", ["linear"], ["zoom"], 12, 0, 13, 1, 16, 4, 18, 18, 20, 90]}, "layout": {"line-cap": "round", "line-join": "round"}, "minzoom": 12, "source": "versatiles-shortbread"}, {"id": "water-pier-area", "type": "fill", "source-layer": "pier_polygons", "filter": ["in", ["get", "kind"], ["literal", ["pier", "breakwater", "groyne"]]], "paint": {"fill-color": "rgb(248,244,240)", "fill-opacity": ["interpolate", ["linear"], ["zoom"], 12, 0, 13, 1]}, "minzoom": 12, "source": "versatiles-shortbread"}, {"id": "water-pier", "type": "line", "source-layer": "pier_lines", "filter": ["in", ["get", "kind"], ["literal", ["pier", "breakwater", "groyne"]]], "paint": {"line-color": "rgb(248,244,240)", "line-width": ["interpolate", ["linear"], ["zoom"], 12, 0, 13, 1, 16, 4, 18, 18, 20, 90]}, "layout": {"line-cap": "round", "line-join": "round"}, "minzoom": 12, "source": "versatiles-shortbread"}, {"id": "airport-area", "type": "fill", "source-layer": "street_polygons", "filter": ["in", ["get", "kind"], ["literal", ["runway", "taxiway"]]], "paint": {"fill-color": "rgb(255,255,255)", "fill-opacity": ["interpolate", ["linear"], ["zoom"], 13, 0, 14, 1]}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "airport-taxiway:outline", "type": "line", "source-layer": "streets", "filter": ["==", ["get", "kind"], "taxiway"], "paint": {"line-color": "rgb(207,205,202)", "line-width": ["interpolate", ["linear"], ["zoom"], 13, 0, 14, 2, 15, 10, 16, 14, 18, 20, 20, 40]}, "layout": {"line-cap": "butt", "line-join": "round"}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "airport-runway:outline", "type": "line", "source-layer": "streets", "filter": ["==", ["get", "kind"], "runway"], "paint": {"line-color": "rgb(207,205,202)", "line-width": ["interpolate", ["linear"], ["zoom"], 11, 0, 12, 6, 13, 9, 14, 16, 15, 24, 16, 40, 17, 100, 18, 160, 20, 300]}, "layout": {"line-cap": "butt", "line-join": "round"}, "minzoom": 11, "source": "versatiles-shortbread"}, {"id": "airport-taxiway", "type": "line", "source-layer": "streets", "filter": ["==", ["get", "kind"], "taxiway"], "paint": {"line-color": "rgb(255,255,255)", "line-width": ["interpolate", ["linear"], ["zoom"], 13, 0, 14, 1, 15, 8, 16, 12, 18, 18, 20, 36]}, "layout": {"line-cap": "butt", "line-join": "round"}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "airport-runway", "type": "line", "source-layer": "streets", "filter": ["==", ["get", "kind"], "runway"], "paint": {"line-color": "rgb(255,255,255)", "line-width": ["interpolate", ["linear"], ["zoom"], 11, 0, 12, 5, 13, 8, 14, 14, 15, 22, 16, 38, 17, 98, 18, 158, 20, 298], "line-opacity": ["interpolate", ["linear"], ["zoom"], 11, 0, 12, 1]}, "layout": {"line-cap": "butt", "line-join": "round"}, "minzoom": 11, "source": "versatiles-shortbread"}, {"id": "building:outline", "type": "fill", "source-layer": "buildings", "paint": {"fill-color": "rgb(223,219,215)", "fill-opacity": ["interpolate", ["linear"], ["zoom"], 14, 0, 15, 1]}, "minzoom": 14, "source": "versatiles-shortbread"}, {"id": "building", "type": "fill", "source-layer": "buildings", "paint": {"fill-color": "rgb(242,234,226)", "fill-translate": [-2, -2], "fill-opacity": ["interpolate", ["linear"], ["zoom"], 14, 0, 15, 1]}, "minzoom": 14, "source": "versatiles-shortbread"}, {"id": "slot-below-streets", "type": "background", "paint": {"background-opacity": 0}}, {"id": "tunnel-street-pedestrian-zone", "type": "fill", "source-layer": "street_polygons", "filter": ["all", ["==", ["get", "tunnel"], true], ["==", ["get", "kind"], "pedestrian"]], "paint": {"fill-color": "rgb(255,255,255)", "fill-opacity": ["interpolate", ["linear"], ["zoom"], 12, 0, 13, 1]}, "minzoom": 12, "source": "versatiles-shortbread"}, {"id": "tunnel-way-footway:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "tunnel"], true], ["==", ["get", "kind"], "footway"]], "paint": {"line-color": "rgb(239,231,241)", "line-width": ["interpolate", ["linear"], ["zoom"], 13, 0, 16, 5, 18, 7, 19, 12, 20, 22]}, "layout": {"line-join": "round", "line-cap": "round"}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "tunnel-way-steps:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "tunnel"], true], ["==", ["get", "kind"], "steps"]], "paint": {"line-color": "rgb(239,231,241)", "line-width": ["interpolate", ["linear"], ["zoom"], 13, 0, 16, 5, 18, 7, 19, 12, 20, 22]}, "layout": {"line-join": "round", "line-cap": "round"}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "tunnel-way-path:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "tunnel"], true], ["==", ["get", "kind"], "path"]], "paint": {"line-color": "rgb(239,231,241)", "line-width": ["interpolate", ["linear"], ["zoom"], 13, 0, 16, 5, 18, 7, 19, 12, 20, 22]}, "layout": {"line-join": "round", "line-cap": "round"}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "tunnel-way-cycleway:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "tunnel"], true], ["==", ["get", "kind"], "cycleway"]], "paint": {"line-color": "rgb(233,238,241)", "line-width": ["interpolate", ["linear"], ["zoom"], 13, 0, 16, 5, 18, 7, 19, 12, 20, 22]}, "layout": {"line-join": "round", "line-cap": "round"}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "tunnel-street-track:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "kind"], "track"], ["==", ["get", "tunnel"], true]], "layout": {"line-join": "round", "line-cap": "round"}, "paint": {"line-color": "rgb(229,228,226)", "line-width": ["interpolate", ["linear"], ["zoom"], 14, 2, 16, 4, 18, 18, 19, 48, 20, 96], "line-opacity": ["interpolate", ["linear"], ["zoom"], 13, 0, 14, 1]}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "tunnel-street-pedestrian:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "kind"], "pedestrian"], ["==", ["get", "tunnel"], true]], "layout": {"line-join": "round", "line-cap": "round"}, "paint": {"line-color": "rgb(229,228,226)", "line-width": ["interpolate", ["linear"], ["zoom"], 12, 2, 14, 3, 16, 6, 18, 26, 19, 64, 20, 128], "line-opacity": ["interpolate", ["linear"], ["zoom"], 13, 0, 14, 1]}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "tunnel-street-service:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "kind"], "service"], ["==", ["get", "tunnel"], true]], "layout": {"line-join": "round", "line-cap": "round"}, "paint": {"line-color": "rgb(237,236,235)", "line-width": ["interpolate", ["linear"], ["zoom"], 14, 1, 16, 3, 18, 12, 19, 32, 20, 48], "line-opacity": ["interpolate", ["linear"], ["zoom"], 13, 0, 14, 1]}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "tunnel-street-livingstreet:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "kind"], "living_street"], ["==", ["get", "tunnel"], true]], "layout": {"line-join": "round", "line-cap": "round"}, "paint": {"line-color": "rgb(229,228,226)", "line-width": ["interpolate", ["linear"], ["zoom"], 12, 2, 14, 3, 16, 6, 18, 26, 19, 64, 20, 128], "line-opacity": ["interpolate", ["linear"], ["zoom"], 13, 0, 14, 1]}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "tunnel-street-minor:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["in", ["get", "kind"], ["literal", ["residential", "unclassified"]]], ["==", ["get", "tunnel"], true]], "layout": {"line-join": "round", "line-cap": "round"}, "paint": {"line-color": "rgb(229,228,226)", "line-width": ["interpolate", ["linear"], ["zoom"], 12, 2, 14, 3, 16, 6, 18, 26, 19, 64, 20, 128], "line-opacity": ["interpolate", ["linear"], ["zoom"], 12, 0, 13, 1]}, "minzoom": 12, "source": "versatiles-shortbread"}, {"id": "tunnel-street-bus:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["in", ["get", "kind"], ["literal", ["busway", "bus_guideway"]]], ["==", ["get", "tunnel"], true]], "layout": {"line-join": "round", "line-cap": "round"}, "paint": {"line-color": "rgb(237,236,235)", "line-width": ["interpolate", ["linear"], ["zoom"], 14, 1, 16, 3, 18, 12, 19, 32, 20, 48], "line-opacity": ["interpolate", ["linear"], ["zoom"], 12, 0, 13, 1]}, "minzoom": 12, "source": "versatiles-shortbread"}, {"id": "tunnel-street-tertiary-link:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "tunnel"], true], ["==", ["get", "kind"], "tertiary"], ["==", ["get", "link"], true]], "layout": {"line-join": "round", "line-cap": "round"}, "paint": {"line-color": "rgb(229,228,226)", "line-width": ["interpolate", ["linear"], ["zoom"], 12, 2, 14, 3, 16, 6, 18, 26, 19, 64, 20, 128], "line-opacity": ["interpolate", ["linear"], ["zoom"], 13, 0, 14, 1]}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "tunnel-street-arterial-link:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "tunnel"], true], ["in", ["get", "kind"], ["literal", ["secondary", "primary"]]], ["==", ["get", "link"], true]], "layout": {"line-join": "round", "line-cap": "round"}, "paint": {"line-color": "rgb(243,209,180)", "line-width": ["interpolate", ["linear"], ["zoom"], 12, 2, 14, 3, 16, 7, 18, 14, 20, 40], "line-opacity": ["interpolate", ["linear"], ["zoom"], 13, 0, 14, 1]}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "tunnel-street-trunk-link:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "tunnel"], true], ["==", ["get", "kind"], "trunk"], ["==", ["get", "link"], true]], "layout": {"line-join": "round", "line-cap": "round"}, "paint": {"line-color": "rgb(243,209,180)", "line-width": ["interpolate", ["linear"], ["zoom"], 12, 2, 14, 3, 16, 7, 18, 14, 20, 40], "line-opacity": ["interpolate", ["linear"], ["zoom"], 13, 0, 14, 1]}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "tunnel-street-motorway-link:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "tunnel"], true], ["==", ["get", "kind"], "motorway"], ["==", ["get", "link"], true]], "layout": {"line-join": "round", "line-cap": "round"}, "paint": {"line-color": "rgb(243,209,180)", "line-width": ["interpolate", ["linear"], ["zoom"], 12, 2, 14, 3, 16, 7, 18, 14, 20, 40], "line-opacity": ["interpolate", ["linear"], ["zoom"], 12, 0, 13, 1]}, "minzoom": 12, "source": "versatiles-shortbread"}, {"id": "tunnel-street-tertiary:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "tunnel"], true], ["==", ["get", "kind"], "tertiary"], ["!=", ["get", "link"], true]], "layout": {"line-join": "round", "line-cap": "round"}, "paint": {"line-color": "rgb(229,228,226)", "line-width": ["interpolate", ["linear"], ["zoom"], 12, 2, 14, 3, 16, 6, 18, 26, 19, 64, 20, 128], "line-opacity": ["interpolate", ["linear"], ["zoom"], 10, 0, 11, 1]}, "minzoom": 10, "source": "versatiles-shortbread"}, {"id": "tunnel-street-secondary:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "tunnel"], true], ["==", ["get", "kind"], "secondary"], ["!=", ["get", "link"], true]], "layout": {"line-join": "round", "line-cap": "round"}, "paint": {"line-color": "rgb(243,209,180)", "line-width": ["interpolate", ["linear"], ["zoom"], 11, 2, 14, 5, 16, 8, 18, 30, 19, 68, 20, 138], "line-opacity": ["interpolate", ["linear"], ["zoom"], 9, 0, 10, 1]}, "minzoom": 9, "source": "versatiles-shortbread"}, {"id": "tunnel-street-primary:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "tunnel"], true], ["==", ["get", "kind"], "primary"], ["!=", ["get", "link"], true]], "layout": {"line-join": "round", "line-cap": "round"}, "paint": {"line-color": "rgb(243,209,180)", "line-width": ["interpolate", ["linear"], ["zoom"], 8, 0, 9, 2, 10, 4, 14, 6, 16, 12, 18, 36, 19, 74, 20, 144], "line-opacity": ["interpolate", ["linear"], ["zoom"], 8, 0, 9, 1]}, "minzoom": 8, "source": "versatiles-shortbread"}, {"id": "tunnel-street-trunk:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "tunnel"], true], ["==", ["get", "kind"], "trunk"], ["!=", ["get", "link"], true]], "layout": {"line-join": "round", "line-cap": "round"}, "paint": {"line-color": "rgb(243,209,180)", "line-width": ["interpolate", ["linear"], ["zoom"], 6, 0, 7, 2, 10, 4, 14, 6, 16, 12, 18, 36, 19, 74, 20, 144], "line-opacity": ["interpolate", ["linear"], ["zoom"], 6, 0, 7, 1]}, "minzoom": 6, "source": "versatiles-shortbread"}, {"id": "tunnel-street-motorway:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "tunnel"], true], ["==", ["get", "kind"], "motorway"], ["!=", ["get", "link"], true]], "layout": {"line-join": "round", "line-cap": "round"}, "paint": {"line-color": "rgb(243,209,180)", "line-width": ["interpolate", ["linear"], ["zoom"], 5, 0, 6, 2, 10, 5, 14, 5, 16, 14, 18, 38, 19, 84, 20, 168], "line-opacity": ["interpolate", ["linear"], ["zoom"], 5, 0, 6, 1]}, "minzoom": 5, "source": "versatiles-shortbread"}, {"id": "tunnel-way-footway", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "tunnel"], true], ["==", ["get", "kind"], "footway"]], "paint": {"line-color": "rgb(253,244,255)", "line-width": ["interpolate", ["linear"], ["zoom"], 13, 0, 16, 4, 18, 6, 19, 10, 20, 20]}, "layout": {"line-join": "round", "line-cap": "round"}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "tunnel-way-steps", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "tunnel"], true], ["==", ["get", "kind"], "steps"]], "paint": {"line-color": "rgb(253,244,255)", "line-width": ["interpolate", ["linear"], ["zoom"], 13, 0, 16, 4, 18, 6, 19, 10, 20, 20]}, "layout": {"line-join": "round", "line-cap": "round"}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "tunnel-way-path", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "tunnel"], true], ["==", ["get", "kind"], "path"]], "paint": {"line-color": "rgb(253,244,255)", "line-width": ["interpolate", ["linear"], ["zoom"], 13, 0, 16, 4, 18, 6, 19, 10, 20, 20]}, "layout": {"line-join": "round", "line-cap": "round"}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "tunnel-way-cycleway", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "tunnel"], true], ["==", ["get", "kind"], "cycleway"]], "paint": {"line-color": "rgb(246,252,255)", "line-width": ["interpolate", ["linear"], ["zoom"], 13, 0, 16, 4, 18, 6, 19, 10, 20, 20]}, "layout": {"line-join": "round", "line-cap": "round"}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "tunnel-street-track", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "kind"], "track"], ["==", ["get", "tunnel"], true]], "layout": {"line-join": "round", "line-cap": "round"}, "paint": {"line-color": "rgb(255,255,255)", "line-width": ["interpolate", ["linear"], ["zoom"], 14, 1, 16, 3, 18, 16, 19, 44, 20, 88], "line-opacity": ["interpolate", ["linear"], ["zoom"], 13, 0, 14, 1]}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "tunnel-street-pedestrian", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "kind"], "pedestrian"], ["==", ["get", "tunnel"], true]], "layout": {"line-join": "round", "line-cap": "round"}, "paint": {"line-color": "rgb(253,244,255)", "line-width": ["interpolate", ["linear"], ["zoom"], 12, 1, 14, 2, 16, 5, 18, 24, 19, 60, 20, 120], "line-opacity": ["interpolate", ["linear"], ["zoom"], 13, 0, 14, 1]}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "tunnel-street-service", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "kind"], "service"], ["==", ["get", "tunnel"], true]], "layout": {"line-join": "round", "line-cap": "round"}, "paint": {"line-color": "rgb(251,251,251)", "line-width": ["interpolate", ["linear"], ["zoom"], 14, 1, 16, 2, 18, 10, 19, 28, 20, 40], "line-opacity": ["interpolate", ["linear"], ["zoom"], 13, 0, 14, 1]}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "tunnel-street-livingstreet", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "kind"], "living_street"], ["==", ["get", "tunnel"], true]], "layout": {"line-join": "round", "line-cap": "round"}, "paint": {"line-color": "rgb(255,255,255)", "line-width": ["interpolate", ["linear"], ["zoom"], 12, 1, 14, 2, 16, 5, 18, 24, 19, 60, 20, 120], "line-opacity": ["interpolate", ["linear"], ["zoom"], 13, 0, 14, 1]}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "tunnel-street-minor", "type": "line", "source-layer": "streets", "filter": ["all", ["in", ["get", "kind"], ["literal", ["residential", "unclassified"]]], ["==", ["get", "tunnel"], true]], "layout": {"line-join": "round", "line-cap": "round"}, "paint": {"line-color": "rgb(255,255,255)", "line-width": ["interpolate", ["linear"], ["zoom"], 12, 1, 14, 2, 16, 5, 18, 24, 19, 60, 20, 120], "line-opacity": ["interpolate", ["linear"], ["zoom"], 12, 0, 13, 1]}, "minzoom": 12, "source": "versatiles-shortbread"}, {"id": "tunnel-street-bus", "type": "line", "source-layer": "streets", "filter": ["all", ["in", ["get", "kind"], ["literal", ["busway", "bus_guideway"]]], ["==", ["get", "tunnel"], true]], "layout": {"line-join": "round", "line-cap": "round"}, "paint": {"line-color": "rgb(251,251,251)", "line-width": ["interpolate", ["linear"], ["zoom"], 14, 1, 16, 2, 18, 10, 19, 28, 20, 40], "line-opacity": ["interpolate", ["linear"], ["zoom"], 12, 0, 13, 1]}, "minzoom": 12, "source": "versatiles-shortbread"}, {"id": "tunnel-street-pedestrian-bicycle", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "kind"], "pedestrian"], ["==", ["get", "bicycle"], "designated"], ["==", ["get", "tunnel"], true]], "layout": {"line-join": "round", "line-cap": "round"}, "paint": {"line-color": "rgb(246,252,255)", "line-width": ["interpolate", ["linear"], ["zoom"], 12, 1, 14, 2, 16, 5, 18, 24, 19, 60, 20, 120], "line-opacity": ["interpolate", ["linear"], ["zoom"], 12, 0, 13, 1]}, "minzoom": 12, "source": "versatiles-shortbread"}, {"id": "tunnel-street-minor-bicycle", "type": "line", "source-layer": "streets", "filter": ["all", ["in", ["get", "kind"], ["literal", ["living_street", "residential", "unclassified"]]], ["==", ["get", "bicycle"], "designated"], ["==", ["get", "tunnel"], true]], "layout": {"line-join": "round", "line-cap": "round"}, "paint": {"line-color": "rgb(246,252,255)", "line-width": ["interpolate", ["linear"], ["zoom"], 12, 1, 14, 2, 16, 5, 18, 24, 19, 60, 20, 120], "line-opacity": ["interpolate", ["linear"], ["zoom"], 12, 0, 13, 1]}, "minzoom": 12, "source": "versatiles-shortbread"}, {"id": "tunnel-street-tertiary-link", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "tunnel"], true], ["==", ["get", "kind"], "tertiary"], ["==", ["get", "link"], true]], "layout": {"line-join": "round", "line-cap": "round"}, "paint": {"line-color": "rgb(255,255,255)", "line-width": ["interpolate", ["linear"], ["zoom"], 12, 1, 14, 2, 16, 5, 18, 24, 19, 60, 20, 120], "line-opacity": ["interpolate", ["linear"], ["zoom"], 13, 0, 14, 1]}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "tunnel-street-arterial-link", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "tunnel"], true], ["in", ["get", "kind"], ["literal", ["secondary", "primary"]]], ["==", ["get", "link"], true]], "layout": {"line-join": "round", "line-cap": "round"}, "paint": {"line-color": "rgb(255,246,208)", "line-width": ["interpolate", ["linear"], ["zoom"], 12, 1, 14, 2, 16, 5, 18, 12, 20, 38], "line-opacity": ["interpolate", ["linear"], ["zoom"], 13, 0, 14, 1]}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "tunnel-street-trunk-link", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "tunnel"], true], ["==", ["get", "kind"], "trunk"], ["==", ["get", "link"], true]], "layout": {"line-join": "round", "line-cap": "round"}, "paint": {"line-color": "rgb(255,246,208)", "line-width": ["interpolate", ["linear"], ["zoom"], 12, 1, 14, 2, 16, 5, 18, 12, 20, 38], "line-opacity": ["interpolate", ["linear"], ["zoom"], 13, 0, 14, 1]}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "tunnel-street-motorway-link", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "tunnel"], true], ["==", ["get", "kind"], "motorway"], ["==", ["get", "link"], true]], "layout": {"line-join": "round", "line-cap": "round"}, "paint": {"line-color": "rgb(255,227,190)", "line-width": ["interpolate", ["linear"], ["zoom"], 12, 1, 14, 2, 16, 5, 18, 12, 20, 38], "line-opacity": ["interpolate", ["linear"], ["zoom"], 12, 0, 13, 1]}, "minzoom": 12, "source": "versatiles-shortbread"}, {"id": "tunnel-street-tertiary", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "tunnel"], true], ["==", ["get", "kind"], "tertiary"], ["!=", ["get", "link"], true]], "layout": {"line-join": "round", "line-cap": "round"}, "paint": {"line-color": "rgb(255,255,255)", "line-width": ["interpolate", ["linear"], ["zoom"], 12, 1, 14, 2, 16, 5, 18, 24, 19, 60, 20, 120], "line-opacity": ["interpolate", ["linear"], ["zoom"], 10, 0, 11, 1]}, "minzoom": 10, "source": "versatiles-shortbread"}, {"id": "tunnel-street-secondary", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "tunnel"], true], ["==", ["get", "kind"], "secondary"], ["!=", ["get", "link"], true]], "layout": {"line-join": "round", "line-cap": "round"}, "paint": {"line-color": "rgb(255,246,208)", "line-width": ["interpolate", ["linear"], ["zoom"], 11, 1, 14, 4, 16, 6, 18, 28, 19, 64, 20, 130], "line-opacity": ["interpolate", ["linear"], ["zoom"], 9, 0, 10, 1]}, "minzoom": 9, "source": "versatiles-shortbread"}, {"id": "tunnel-street-primary", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "tunnel"], true], ["==", ["get", "kind"], "primary"], ["!=", ["get", "link"], true]], "layout": {"line-join": "round", "line-cap": "round"}, "paint": {"line-color": "rgb(255,246,208)", "line-width": ["interpolate", ["linear"], ["zoom"], 8, 0, 9, 1, 10, 3, 14, 5, 16, 10, 18, 34, 19, 70, 20, 140], "line-opacity": ["interpolate", ["linear"], ["zoom"], 8, 0, 9, 1]}, "minzoom": 8, "source": "versatiles-shortbread"}, {"id": "tunnel-street-trunk", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "tunnel"], true], ["==", ["get", "kind"], "trunk"], ["!=", ["get", "link"], true]], "layout": {"line-join": "round", "line-cap": "round"}, "paint": {"line-color": "rgb(255,246,208)", "line-width": ["interpolate", ["linear"], ["zoom"], 6, 0, 7, 1, 10, 3, 14, 5, 16, 10, 18, 34, 19, 70, 20, 140], "line-opacity": ["interpolate", ["linear"], ["zoom"], 6, 0, 7, 1]}, "minzoom": 6, "source": "versatiles-shortbread"}, {"id": "tunnel-street-motorway", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "tunnel"], true], ["==", ["get", "kind"], "motorway"], ["!=", ["get", "link"], true]], "layout": {"line-join": "round", "line-cap": "round"}, "paint": {"line-color": "rgb(255,227,190)", "line-width": ["interpolate", ["linear"], ["zoom"], 5, 0, 6, 1, 10, 4, 14, 4, 16, 12, 18, 36, 19, 80, 20, 160], "line-opacity": ["interpolate", ["linear"], ["zoom"], 5, 0, 6, 1]}, "minzoom": 5, "source": "versatiles-shortbread"}, {"id": "tunnel-transport-rail:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "kind"], "rail"], ["!", ["has", "service"]], ["==", ["get", "tunnel"], true]], "paint": {"line-color": "rgb(212,218,223)", "line-width": ["interpolate", ["linear"], ["zoom"], 8, 1, 13, 1, 15, 1, 20, 14], "line-opacity": ["interpolate", ["linear"], ["zoom"], 11, 0, 12, 0.5]}, "minzoom": 11, "source": "versatiles-shortbread"}, {"id": "tunnel-transport-rail-service:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "kind"], "rail"], ["has", "service"], ["==", ["get", "tunnel"], true]], "paint": {"line-color": "rgb(212,218,223)", "line-width": ["interpolate", ["linear"], ["zoom"], 14, 0, 15, 1, 16, 1, 20, 14], "line-opacity": 0.5}, "minzoom": 14, "source": "versatiles-shortbread"}, {"id": "tunnel-transport-lightrail:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "kind"], "light_rail"], ["!", ["has", "service"]], ["==", ["get", "tunnel"], true]], "paint": {"line-color": "rgb(212,218,223)", "line-width": ["interpolate", ["linear"], ["zoom"], 8, 1, 13, 1, 15, 1, 20, 14], "line-opacity": ["interpolate", ["linear"], ["zoom"], 11, 0, 12, 0.5]}, "minzoom": 11, "source": "versatiles-shortbread"}, {"id": "tunnel-transport-lightrail-service:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "kind"], "light_rail"], ["has", "service"], ["==", ["get", "tunnel"], true]], "paint": {"line-color": "rgb(212,218,223)", "line-width": ["interpolate", ["linear"], ["zoom"], 14, 0, 15, 1, 16, 1, 20, 14], "line-opacity": 0.5}, "minzoom": 14, "source": "versatiles-shortbread"}, {"id": "tunnel-transport-subway:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "kind"], "subway"], ["!", ["has", "service"]], ["==", ["get", "tunnel"], true]], "paint": {"line-color": "rgb(206,216,224)", "line-width": ["interpolate", ["linear"], ["zoom"], 11, 0, 12, 1, 15, 3, 16, 3, 18, 6, 19, 8, 20, 10], "line-opacity": ["interpolate", ["linear"], ["zoom"], 11, 0, 12, 0.5]}, "minzoom": 11, "source": "versatiles-shortbread"}, {"id": "tunnel-transport-minorrail:outline", "type": "line", "source-layer": "streets", "filter": ["any", ["all", ["==", ["get", "kind"], "narrow_gauge"], ["!", ["has", "service"]], ["==", ["get", "tunnel"], true]], ["all", ["==", ["get", "kind"], "tram"], ["!", ["has", "service"]], ["==", ["get", "tunnel"], true]], ["all", ["==", ["get", "kind"], "funicular"], ["==", ["get", "tunnel"], true]], ["all", ["==", ["get", "kind"], "monorail"], ["==", ["get", "tunnel"], true]]], "paint": {"line-color": "rgb(212,218,223)", "line-width": ["interpolate", ["linear"], ["zoom"], 15, 0, 16, 5, 18, 7, 20, 20], "line-dasharray": [0.1, 0.5], "line-opacity": 0.5}, "minzoom": 14, "source": "versatiles-shortbread"}, {"id": "tunnel-transport-rail", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "kind"], "rail"], ["!", ["has", "service"]], ["==", ["get", "tunnel"], true]], "paint": {"line-color": "rgb(225,229,232)", "line-dasharray": [2, 2], "line-width": ["interpolate", ["linear"], ["zoom"], 14, 0, 15, 1, 20, 10], "line-opacity": ["interpolate", ["linear"], ["zoom"], 14, 0, 15, 0.5]}, "minzoom": 14, "source": "versatiles-shortbread"}, {"id": "tunnel-transport-rail-service", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "kind"], "rail"], ["has", "service"], ["==", ["get", "tunnel"], true]], "paint": {"line-color": "rgb(225,229,232)", "line-dasharray": [2, 2], "line-width": ["interpolate", ["linear"], ["zoom"], 15, 0, 16, 1, 20, 10], "line-opacity": 0.5}, "minzoom": 14, "source": "versatiles-shortbread"}, {"id": "tunnel-transport-lightrail", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "kind"], "light_rail"], ["!", ["has", "service"]], ["==", ["get", "tunnel"], true]], "paint": {"line-color": "rgb(225,229,232)", "line-dasharray": [2, 2], "line-width": ["interpolate", ["linear"], ["zoom"], 14, 0, 15, 1, 20, 10], "line-opacity": ["interpolate", ["linear"], ["zoom"], 14, 0, 15, 0.5]}, "minzoom": 14, "source": "versatiles-shortbread"}, {"id": "tunnel-transport-lightrail-service", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "kind"], "light_rail"], ["has", "service"], ["==", ["get", "tunnel"], true]], "paint": {"line-color": "rgb(225,229,232)", "line-dasharray": [2, 2], "line-width": ["interpolate", ["linear"], ["zoom"], 15, 0, 16, 1, 20, 10], "line-opacity": 0.5}, "minzoom": 14, "source": "versatiles-shortbread"}, {"id": "tunnel-transport-subway", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "kind"], "subway"], ["!", ["has", "service"]], ["==", ["get", "tunnel"], true]], "paint": {"line-color": "rgb(221,228,233)", "line-dasharray": [2, 2], "line-width": ["interpolate", ["linear"], ["zoom"], 11, 0, 12, 1, 15, 2, 16, 2, 18, 5, 19, 6, 20, 8], "line-opacity": ["interpolate", ["linear"], ["zoom"], 14, 0, 15, 0.5]}, "minzoom": 14, "source": "versatiles-shortbread"}, {"id": "tunnel-transport-minorrail", "type": "line", "source-layer": "streets", "filter": ["any", ["all", ["==", ["get", "kind"], "narrow_gauge"], ["!", ["has", "service"]], ["==", ["get", "tunnel"], true]], ["all", ["==", ["get", "kind"], "tram"], ["!", ["has", "service"]], ["==", ["get", "tunnel"], true]], ["all", ["==", ["get", "kind"], "funicular"], ["==", ["get", "tunnel"], true]], ["all", ["==", ["get", "kind"], "monorail"], ["==", ["get", "tunnel"], true]]], "paint": {"line-width": ["interpolate", ["linear"], ["zoom"], 13, 0, 16, 1, 17, 2, 18, 3, 20, 5], "line-color": "rgb(212,218,223)", "line-opacity": 0.5}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "bridge", "type": "fill", "source-layer": "bridges", "paint": {"fill-color": "rgb(243,239,235)", "fill-antialias": true, "fill-opacity": 0.8}, "minzoom": 12, "source": "versatiles-shortbread"}, {"id": "street-pedestrian-zone", "type": "fill", "source-layer": "street_polygons", "filter": ["all", ["!=", ["get", "bridge"], true], ["!=", ["get", "tunnel"], true], ["==", ["get", "kind"], "pedestrian"]], "paint": {"fill-color": "rgb(251,235,255)", "fill-opacity": ["interpolate", ["linear"], ["zoom"], 14, 0, 15, 0.25]}, "minzoom": 14, "source": "versatiles-shortbread"}, {"id": "way-footway:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["!=", ["get", "bridge"], true], ["!=", ["get", "tunnel"], true], ["==", ["get", "kind"], "footway"]], "paint": {"line-color": "rgb(226,212,230)", "line-width": ["interpolate", ["linear"], ["zoom"], 13, 0, 16, 5, 18, 7, 19, 12, 20, 22]}, "layout": {"line-join": "round", "line-cap": "round"}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "way-steps:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["!=", ["get", "bridge"], true], ["!=", ["get", "tunnel"], true], ["==", ["get", "kind"], "steps"]], "paint": {"line-color": "rgb(226,212,230)", "line-width": ["interpolate", ["linear"], ["zoom"], 13, 0, 16, 5, 18, 7, 19, 12, 20, 22]}, "layout": {"line-join": "round", "line-cap": "round"}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "way-path:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["!=", ["get", "bridge"], true], ["!=", ["get", "tunnel"], true], ["==", ["get", "kind"], "path"]], "paint": {"line-color": "rgb(226,212,230)", "line-width": ["interpolate", ["linear"], ["zoom"], 13, 0, 16, 5, 18, 7, 19, 12, 20, 22]}, "layout": {"line-join": "round", "line-cap": "round"}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "way-cycleway:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["!=", ["get", "bridge"], true], ["!=", ["get", "tunnel"], true], ["==", ["get", "kind"], "cycleway"]], "paint": {"line-color": "rgb(215,224,230)", "line-width": ["interpolate", ["linear"], ["zoom"], 13, 0, 16, 5, 18, 7, 19, 12, 20, 22]}, "layout": {"line-join": "round", "line-cap": "round"}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "street-track:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "kind"], "track"], ["!=", ["get", "bridge"], true], ["!=", ["get", "tunnel"], true]], "layout": {"line-join": "round", "line-cap": "round"}, "paint": {"line-color": "rgb(207,205,202)", "line-width": ["interpolate", ["linear"], ["zoom"], 14, 2, 16, 4, 18, 18, 19, 48, 20, 96], "line-opacity": ["interpolate", ["linear"], ["zoom"], 13, 0, 14, 1]}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "street-pedestrian:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "kind"], "pedestrian"], ["!=", ["get", "bridge"], true], ["!=", ["get", "tunnel"], true]], "layout": {"line-join": "round", "line-cap": "round"}, "paint": {"line-color": "rgb(207,205,202)", "line-width": ["interpolate", ["linear"], ["zoom"], 12, 2, 14, 3, 16, 6, 18, 26, 19, 64, 20, 128], "line-opacity": ["interpolate", ["linear"], ["zoom"], 13, 0, 14, 1]}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "street-service:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "kind"], "service"], ["!=", ["get", "bridge"], true], ["!=", ["get", "tunnel"], true]], "layout": {"line-join": "round", "line-cap": "round"}, "paint": {"line-color": "rgb(221,220,218)", "line-width": ["interpolate", ["linear"], ["zoom"], 14, 1, 16, 3, 18, 12, 19, 32, 20, 48], "line-opacity": ["interpolate", ["linear"], ["zoom"], 13, 0, 14, 1]}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "street-livingstreet:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "kind"], "living_street"], ["!=", ["get", "bridge"], true], ["!=", ["get", "tunnel"], true]], "layout": {"line-join": "round", "line-cap": "round"}, "paint": {"line-color": "rgb(207,205,202)", "line-width": ["interpolate", ["linear"], ["zoom"], 12, 2, 14, 3, 16, 6, 18, 26, 19, 64, 20, 128], "line-opacity": ["interpolate", ["linear"], ["zoom"], 13, 0, 14, 1]}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "street-minor:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["in", ["get", "kind"], ["literal", ["residential", "unclassified"]]], ["!=", ["get", "bridge"], true], ["!=", ["get", "tunnel"], true]], "layout": {"line-join": "round", "line-cap": "round"}, "paint": {"line-color": "rgb(207,205,202)", "line-width": ["interpolate", ["linear"], ["zoom"], 12, 2, 14, 3, 16, 6, 18, 26, 19, 64, 20, 128], "line-opacity": ["interpolate", ["linear"], ["zoom"], 12, 0, 13, 1]}, "minzoom": 12, "source": "versatiles-shortbread"}, {"id": "street-bus:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["in", ["get", "kind"], ["literal", ["busway", "bus_guideway"]]], ["!=", ["get", "bridge"], true], ["!=", ["get", "tunnel"], true]], "layout": {"line-join": "round", "line-cap": "round"}, "paint": {"line-color": "rgb(221,220,218)", "line-width": ["interpolate", ["linear"], ["zoom"], 14, 1, 16, 3, 18, 12, 19, 32, 20, 48], "line-opacity": ["interpolate", ["linear"], ["zoom"], 12, 0, 13, 1]}, "minzoom": 12, "source": "versatiles-shortbread"}, {"id": "street-tertiary-link:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["!=", ["get", "bridge"], true], ["!=", ["get", "tunnel"], true], ["==", ["get", "kind"], "tertiary"], ["==", ["get", "link"], true]], "layout": {"line-join": "round", "line-cap": "round"}, "paint": {"line-color": "rgb(207,205,202)", "line-width": ["interpolate", ["linear"], ["zoom"], 12, 2, 14, 3, 16, 6, 18, 26, 19, 64, 20, 128], "line-opacity": ["interpolate", ["linear"], ["zoom"], 13, 0, 14, 1]}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "street-arterial-link:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["!=", ["get", "bridge"], true], ["!=", ["get", "tunnel"], true], ["in", ["get", "kind"], ["literal", ["secondary", "primary"]]], ["==", ["get", "link"], true]], "layout": {"line-join": "round", "line-cap": "round"}, "paint": {"line-color": "rgb(233,172,119)", "line-width": ["interpolate", ["linear"], ["zoom"], 12, 2, 14, 3, 16, 7, 18, 14, 20, 40], "line-opacity": ["interpolate", ["linear"], ["zoom"], 13, 0, 14, 1]}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "street-trunk-link:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["!=", ["get", "bridge"], true], ["!=", ["get", "tunnel"], true], ["==", ["get", "kind"], "trunk"], ["==", ["get", "link"], true]], "layout": {"line-join": "round", "line-cap": "round"}, "paint": {"line-color": "rgb(233,172,119)", "line-width": ["interpolate", ["linear"], ["zoom"], 12, 2, 14, 3, 16, 7, 18, 14, 20, 40], "line-opacity": ["interpolate", ["linear"], ["zoom"], 13, 0, 14, 1]}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "street-motorway-link:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["!=", ["get", "bridge"], true], ["!=", ["get", "tunnel"], true], ["==", ["get", "kind"], "motorway"], ["==", ["get", "link"], true]], "layout": {"line-join": "round", "line-cap": "round"}, "paint": {"line-color": "rgb(233,172,119)", "line-width": ["interpolate", ["linear"], ["zoom"], 12, 2, 14, 3, 16, 7, 18, 14, 20, 40], "line-opacity": ["interpolate", ["linear"], ["zoom"], 12, 0, 13, 1]}, "minzoom": 12, "source": "versatiles-shortbread"}, {"id": "street-tertiary:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["!=", ["get", "bridge"], true], ["!=", ["get", "tunnel"], true], ["==", ["get", "kind"], "tertiary"], ["!=", ["get", "link"], true]], "layout": {"line-join": "round", "line-cap": "round"}, "paint": {"line-color": "rgb(207,205,202)", "line-width": ["interpolate", ["linear"], ["zoom"], 12, 2, 14, 3, 16, 6, 18, 26, 19, 64, 20, 128], "line-opacity": ["interpolate", ["linear"], ["zoom"], 10, 0, 11, 1]}, "minzoom": 10, "source": "versatiles-shortbread"}, {"id": "street-secondary:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["!=", ["get", "bridge"], true], ["!=", ["get", "tunnel"], true], ["==", ["get", "kind"], "secondary"], ["!=", ["get", "link"], true]], "layout": {"line-join": "round", "line-cap": "round"}, "paint": {"line-color": "rgb(233,172,119)", "line-width": ["interpolate", ["linear"], ["zoom"], 11, 2, 14, 5, 16, 8, 18, 30, 19, 68, 20, 138], "line-opacity": ["interpolate", ["linear"], ["zoom"], 9, 0, 10, 1]}, "minzoom": 9, "source": "versatiles-shortbread"}, {"id": "street-primary:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["!=", ["get", "bridge"], true], ["!=", ["get", "tunnel"], true], ["==", ["get", "kind"], "primary"], ["!=", ["get", "link"], true]], "layout": {"line-join": "round", "line-cap": "round"}, "paint": {"line-color": "rgb(233,172,119)", "line-width": ["interpolate", ["linear"], ["zoom"], 8, 0, 9, 2, 10, 4, 14, 6, 16, 12, 18, 36, 19, 74, 20, 144], "line-opacity": ["interpolate", ["linear"], ["zoom"], 8, 0, 9, 1]}, "minzoom": 8, "source": "versatiles-shortbread"}, {"id": "street-trunk:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["!=", ["get", "bridge"], true], ["!=", ["get", "tunnel"], true], ["==", ["get", "kind"], "trunk"], ["!=", ["get", "link"], true]], "layout": {"line-join": "round", "line-cap": "round"}, "paint": {"line-color": "rgb(233,172,119)", "line-width": ["interpolate", ["linear"], ["zoom"], 6, 0, 7, 2, 10, 4, 14, 6, 16, 12, 18, 36, 19, 74, 20, 144], "line-opacity": ["interpolate", ["linear"], ["zoom"], 6, 0, 7, 1]}, "minzoom": 6, "source": "versatiles-shortbread"}, {"id": "street-motorway:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["!=", ["get", "bridge"], true], ["!=", ["get", "tunnel"], true], ["==", ["get", "kind"], "motorway"], ["!=", ["get", "link"], true]], "layout": {"line-join": "round", "line-cap": "round"}, "paint": {"line-color": "rgb(233,172,119)", "line-width": ["interpolate", ["linear"], ["zoom"], 5, 0, 6, 2, 10, 5, 14, 5, 16, 14, 18, 38, 19, 84, 20, 168], "line-opacity": ["interpolate", ["linear"], ["zoom"], 5, 0, 6, 1]}, "minzoom": 5, "source": "versatiles-shortbread"}, {"id": "way-footway", "type": "line", "source-layer": "streets", "filter": ["all", ["!=", ["get", "bridge"], true], ["!=", ["get", "tunnel"], true], ["==", ["get", "kind"], "footway"]], "paint": {"line-color": "rgb(251,235,255)", "line-width": ["interpolate", ["linear"], ["zoom"], 13, 0, 16, 4, 18, 6, 19, 10, 20, 20]}, "layout": {"line-join": "round", "line-cap": "round"}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "way-steps", "type": "line", "source-layer": "streets", "filter": ["all", ["!=", ["get", "bridge"], true], ["!=", ["get", "tunnel"], true], ["==", ["get", "kind"], "steps"]], "paint": {"line-color": "rgb(251,235,255)", "line-width": ["interpolate", ["linear"], ["zoom"], 13, 0, 16, 4, 18, 6, 19, 10, 20, 20]}, "layout": {"line-join": "round", "line-cap": "round"}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "way-path", "type": "line", "source-layer": "streets", "filter": ["all", ["!=", ["get", "bridge"], true], ["!=", ["get", "tunnel"], true], ["==", ["get", "kind"], "path"]], "paint": {"line-color": "rgb(251,235,255)", "line-width": ["interpolate", ["linear"], ["zoom"], 13, 0, 16, 4, 18, 6, 19, 10, 20, 20]}, "layout": {"line-join": "round", "line-cap": "round"}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "way-cycleway", "type": "line", "source-layer": "streets", "filter": ["all", ["!=", ["get", "bridge"], true], ["!=", ["get", "tunnel"], true], ["==", ["get", "kind"], "cycleway"]], "paint": {"line-color": "rgb(239,249,255)", "line-width": ["interpolate", ["linear"], ["zoom"], 13, 0, 16, 4, 18, 6, 19, 10, 20, 20]}, "layout": {"line-join": "round", "line-cap": "round"}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "street-track", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "kind"], "track"], ["!=", ["get", "bridge"], true], ["!=", ["get", "tunnel"], true]], "layout": {"line-join": "round", "line-cap": "round"}, "paint": {"line-color": "rgb(255,255,255)", "line-width": ["interpolate", ["linear"], ["zoom"], 14, 1, 16, 3, 18, 16, 19, 44, 20, 88], "line-opacity": ["interpolate", ["linear"], ["zoom"], 13, 0, 14, 1]}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "street-pedestrian", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "kind"], "pedestrian"], ["!=", ["get", "bridge"], true], ["!=", ["get", "tunnel"], true]], "layout": {"line-join": "round", "line-cap": "round"}, "paint": {"line-color": "rgb(251,235,255)", "line-width": ["interpolate", ["linear"], ["zoom"], 12, 1, 14, 2, 16, 5, 18, 24, 19, 60, 20, 120], "line-opacity": ["interpolate", ["linear"], ["zoom"], 13, 0, 14, 1]}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "street-service", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "kind"], "service"], ["!=", ["get", "bridge"], true], ["!=", ["get", "tunnel"], true]], "layout": {"line-join": "round", "line-cap": "round"}, "paint": {"line-color": "rgb(247,247,247)", "line-width": ["interpolate", ["linear"], ["zoom"], 14, 1, 16, 2, 18, 10, 19, 28, 20, 40], "line-opacity": ["interpolate", ["linear"], ["zoom"], 13, 0, 14, 1]}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "street-livingstreet", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "kind"], "living_street"], ["!=", ["get", "bridge"], true], ["!=", ["get", "tunnel"], true]], "layout": {"line-join": "round", "line-cap": "round"}, "paint": {"line-color": "rgb(255,255,255)", "line-width": ["interpolate", ["linear"], ["zoom"], 12, 1, 14, 2, 16, 5, 18, 24, 19, 60, 20, 120], "line-opacity": ["interpolate", ["linear"], ["zoom"], 13, 0, 14, 1]}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "street-minor", "type": "line", "source-layer": "streets", "filter": ["all", ["in", ["get", "kind"], ["literal", ["residential", "unclassified"]]], ["!=", ["get", "bridge"], true], ["!=", ["get", "tunnel"], true]], "layout": {"line-join": "round", "line-cap": "round"}, "paint": {"line-color": "rgb(255,255,255)", "line-width": ["interpolate", ["linear"], ["zoom"], 12, 1, 14, 2, 16, 5, 18, 24, 19, 60, 20, 120], "line-opacity": ["interpolate", ["linear"], ["zoom"], 12, 0, 13, 1]}, "minzoom": 12, "source": "versatiles-shortbread"}, {"id": "street-bus", "type": "line", "source-layer": "streets", "filter": ["all", ["in", ["get", "kind"], ["literal", ["busway", "bus_guideway"]]], ["!=", ["get", "bridge"], true], ["!=", ["get", "tunnel"], true]], "layout": {"line-join": "round", "line-cap": "round"}, "paint": {"line-color": "rgb(247,247,247)", "line-width": ["interpolate", ["linear"], ["zoom"], 14, 1, 16, 2, 18, 10, 19, 28, 20, 40], "line-opacity": ["interpolate", ["linear"], ["zoom"], 12, 0, 13, 1]}, "minzoom": 12, "source": "versatiles-shortbread"}, {"id": "street-pedestrian-bicycle", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "kind"], "pedestrian"], ["==", ["get", "bicycle"], "designated"], ["!=", ["get", "bridge"], true], ["!=", ["get", "tunnel"], true]], "layout": {"line-join": "round", "line-cap": "round"}, "paint": {"line-color": "rgb(239,249,255)", "line-width": ["interpolate", ["linear"], ["zoom"], 12, 1, 14, 2, 16, 5, 18, 24, 19, 60, 20, 120], "line-opacity": ["interpolate", ["linear"], ["zoom"], 12, 0, 13, 1]}, "minzoom": 12, "source": "versatiles-shortbread"}, {"id": "street-minor-bicycle", "type": "line", "source-layer": "streets", "filter": ["all", ["in", ["get", "kind"], ["literal", ["living_street", "residential", "unclassified"]]], ["==", ["get", "bicycle"], "designated"], ["!=", ["get", "bridge"], true], ["!=", ["get", "tunnel"], true]], "layout": {"line-join": "round", "line-cap": "round"}, "paint": {"line-color": "rgb(239,249,255)", "line-width": ["interpolate", ["linear"], ["zoom"], 12, 1, 14, 2, 16, 5, 18, 24, 19, 60, 20, 120], "line-opacity": ["interpolate", ["linear"], ["zoom"], 12, 0, 13, 1]}, "minzoom": 12, "source": "versatiles-shortbread"}, {"id": "street-tertiary-link", "type": "line", "source-layer": "streets", "filter": ["all", ["!=", ["get", "bridge"], true], ["!=", ["get", "tunnel"], true], ["==", ["get", "kind"], "tertiary"], ["==", ["get", "link"], true]], "layout": {"line-join": "round", "line-cap": "round"}, "paint": {"line-color": "rgb(255,255,255)", "line-width": ["interpolate", ["linear"], ["zoom"], 12, 1, 14, 2, 16, 5, 18, 24, 19, 60, 20, 120], "line-opacity": ["interpolate", ["linear"], ["zoom"], 13, 0, 14, 1]}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "street-arterial-link", "type": "line", "source-layer": "streets", "filter": ["all", ["!=", ["get", "bridge"], true], ["!=", ["get", "tunnel"], true], ["in", ["get", "kind"], ["literal", ["secondary", "primary"]]], ["==", ["get", "link"], true]], "layout": {"line-join": "round", "line-cap": "round"}, "paint": {"line-color": "rgb(255,238,170)", "line-width": ["interpolate", ["linear"], ["zoom"], 12, 1, 14, 2, 16, 5, 18, 12, 20, 38], "line-opacity": ["interpolate", ["linear"], ["zoom"], 13, 0, 14, 1]}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "street-trunk-link", "type": "line", "source-layer": "streets", "filter": ["all", ["!=", ["get", "bridge"], true], ["!=", ["get", "tunnel"], true], ["==", ["get", "kind"], "trunk"], ["==", ["get", "link"], true]], "layout": {"line-join": "round", "line-cap": "round"}, "paint": {"line-color": "rgb(255,238,170)", "line-width": ["interpolate", ["linear"], ["zoom"], 12, 1, 14, 2, 16, 5, 18, 12, 20, 38], "line-opacity": ["interpolate", ["linear"], ["zoom"], 13, 0, 14, 1]}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "street-motorway-link", "type": "line", "source-layer": "streets", "filter": ["all", ["!=", ["get", "bridge"], true], ["!=", ["get", "tunnel"], true], ["==", ["get", "kind"], "motorway"], ["==", ["get", "link"], true]], "layout": {"line-join": "round", "line-cap": "round"}, "paint": {"line-color": "rgb(255,204,136)", "line-width": ["interpolate", ["linear"], ["zoom"], 12, 1, 14, 2, 16, 5, 18, 12, 20, 38], "line-opacity": ["interpolate", ["linear"], ["zoom"], 12, 0, 13, 1]}, "minzoom": 12, "source": "versatiles-shortbread"}, {"id": "street-tertiary", "type": "line", "source-layer": "streets", "filter": ["all", ["!=", ["get", "bridge"], true], ["!=", ["get", "tunnel"], true], ["==", ["get", "kind"], "tertiary"], ["!=", ["get", "link"], true]], "layout": {"line-join": "round", "line-cap": "round"}, "paint": {"line-color": "rgb(255,255,255)", "line-width": ["interpolate", ["linear"], ["zoom"], 12, 1, 14, 2, 16, 5, 18, 24, 19, 60, 20, 120], "line-opacity": ["interpolate", ["linear"], ["zoom"], 10, 0, 11, 1]}, "minzoom": 10, "source": "versatiles-shortbread"}, {"id": "street-secondary", "type": "line", "source-layer": "streets", "filter": ["all", ["!=", ["get", "bridge"], true], ["!=", ["get", "tunnel"], true], ["==", ["get", "kind"], "secondary"], ["!=", ["get", "link"], true]], "layout": {"line-join": "round", "line-cap": "round"}, "paint": {"line-color": "rgb(255,238,170)", "line-width": ["interpolate", ["linear"], ["zoom"], 11, 1, 14, 4, 16, 6, 18, 28, 19, 64, 20, 130], "line-opacity": ["interpolate", ["linear"], ["zoom"], 9, 0, 10, 1]}, "minzoom": 9, "source": "versatiles-shortbread"}, {"id": "street-primary", "type": "line", "source-layer": "streets", "filter": ["all", ["!=", ["get", "bridge"], true], ["!=", ["get", "tunnel"], true], ["==", ["get", "kind"], "primary"], ["!=", ["get", "link"], true]], "layout": {"line-join": "round", "line-cap": "round"}, "paint": {"line-color": "rgb(255,238,170)", "line-width": ["interpolate", ["linear"], ["zoom"], 8, 0, 9, 1, 10, 3, 14, 5, 16, 10, 18, 34, 19, 70, 20, 140], "line-opacity": ["interpolate", ["linear"], ["zoom"], 8, 0, 9, 1]}, "minzoom": 8, "source": "versatiles-shortbread"}, {"id": "street-trunk", "type": "line", "source-layer": "streets", "filter": ["all", ["!=", ["get", "bridge"], true], ["!=", ["get", "tunnel"], true], ["==", ["get", "kind"], "trunk"], ["!=", ["get", "link"], true]], "layout": {"line-join": "round", "line-cap": "round"}, "paint": {"line-color": "rgb(255,238,170)", "line-width": ["interpolate", ["linear"], ["zoom"], 6, 0, 7, 1, 10, 3, 14, 5, 16, 10, 18, 34, 19, 70, 20, 140], "line-opacity": ["interpolate", ["linear"], ["zoom"], 6, 0, 7, 1]}, "minzoom": 6, "source": "versatiles-shortbread"}, {"id": "street-motorway", "type": "line", "source-layer": "streets", "filter": ["all", ["!=", ["get", "bridge"], true], ["!=", ["get", "tunnel"], true], ["==", ["get", "kind"], "motorway"], ["!=", ["get", "link"], true]], "layout": {"line-join": "round", "line-cap": "round"}, "paint": {"line-color": "rgb(255,204,136)", "line-width": ["interpolate", ["linear"], ["zoom"], 5, 0, 6, 1, 10, 4, 14, 4, 16, 12, 18, 36, 19, 80, 20, 160], "line-opacity": ["interpolate", ["linear"], ["zoom"], 5, 0, 6, 1]}, "minzoom": 5, "source": "versatiles-shortbread"}, {"id": "transport-rail:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "kind"], "rail"], ["!", ["has", "service"]], ["!=", ["get", "bridge"], true], ["!=", ["get", "tunnel"], true]], "paint": {"line-color": "rgb(177,187,196)", "line-width": ["interpolate", ["linear"], ["zoom"], 8, 1, 13, 1, 15, 1, 20, 14], "line-opacity": ["interpolate", ["linear"], ["zoom"], 11, 0, 12, 1]}, "minzoom": 11, "source": "versatiles-shortbread"}, {"id": "transport-rail-service:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "kind"], "rail"], ["has", "service"], ["!=", ["get", "bridge"], true], ["!=", ["get", "tunnel"], true]], "paint": {"line-color": "rgb(177,187,196)", "line-width": ["interpolate", ["linear"], ["zoom"], 14, 0, 15, 1, 16, 1, 20, 14]}, "minzoom": 14, "source": "versatiles-shortbread"}, {"id": "transport-lightrail:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "kind"], "light_rail"], ["!", ["has", "service"]], ["!=", ["get", "bridge"], true], ["!=", ["get", "tunnel"], true]], "paint": {"line-color": "rgb(177,187,196)", "line-width": ["interpolate", ["linear"], ["zoom"], 8, 1, 13, 1, 15, 1, 20, 14], "line-opacity": ["interpolate", ["linear"], ["zoom"], 11, 0, 12, 1]}, "minzoom": 11, "source": "versatiles-shortbread"}, {"id": "transport-lightrail-service:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "kind"], "light_rail"], ["has", "service"], ["!=", ["get", "bridge"], true], ["!=", ["get", "tunnel"], true]], "paint": {"line-color": "rgb(177,187,196)", "line-width": ["interpolate", ["linear"], ["zoom"], 14, 0, 15, 1, 16, 1, 20, 14]}, "minzoom": 14, "source": "versatiles-shortbread"}, {"id": "transport-subway:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "kind"], "subway"], ["!", ["has", "service"]], ["!=", ["get", "bridge"], true], ["!=", ["get", "tunnel"], true]], "paint": {"line-color": "rgb(166,184,199)", "line-width": ["interpolate", ["linear"], ["zoom"], 11, 0, 12, 1, 15, 3, 16, 3, 18, 6, 19, 8, 20, 10], "line-opacity": ["interpolate", ["linear"], ["zoom"], 11, 0, 12, 1]}, "minzoom": 11, "source": "versatiles-shortbread"}, {"id": "transport-minorrail:outline", "type": "line", "source-layer": "streets", "filter": ["any", ["all", ["==", ["get", "kind"], "narrow_gauge"], ["!", ["has", "service"]], ["!=", ["get", "bridge"], true], ["!=", ["get", "tunnel"], true]], ["all", ["==", ["get", "kind"], "tram"], ["!", ["has", "service"]], ["!=", ["get", "bridge"], true], ["!=", ["get", "tunnel"], true]], ["all", ["==", ["get", "kind"], "funicular"], ["!=", ["get", "bridge"], true], ["!=", ["get", "tunnel"], true]], ["all", ["==", ["get", "kind"], "monorail"], ["!=", ["get", "bridge"], true], ["!=", ["get", "tunnel"], true]]], "paint": {"line-color": "rgb(177,187,196)", "line-width": ["interpolate", ["linear"], ["zoom"], 15, 0, 16, 5, 18, 7, 20, 20], "line-dasharray": [0.1, 0.5]}, "minzoom": 14, "source": "versatiles-shortbread"}, {"id": "aerialway:outline", "type": "line", "source-layer": "aerialways", "filter": ["any", ["==", ["get", "kind"], "cable_car"], ["==", ["get", "kind"], "gondola"], ["==", ["get", "kind"], "goods"], ["==", ["get", "kind"], "chair_lift"], ["==", ["get", "kind"], "drag_lift"], ["==", ["get", "kind"], "t-bar"], ["==", ["get", "kind"], "j-bar"], ["==", ["get", "kind"], "platter"], ["==", ["get", "kind"], "rope-tow"]], "paint": {"line-color": "rgb(177,187,196)", "line-width": ["interpolate", ["exponential", 1], ["zoom"], 11, 1, 19, 2.5]}, "minzoom": 13, "layout": {"line-cap": "round"}, "source": "versatiles-shortbread"}, {"id": "transport-rail", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "kind"], "rail"], ["!", ["has", "service"]], ["!=", ["get", "bridge"], true], ["!=", ["get", "tunnel"], true]], "paint": {"line-color": "rgb(200,207,214)", "line-dasharray": [2, 2], "line-width": ["interpolate", ["linear"], ["zoom"], 14, 0, 15, 1, 20, 10], "line-opacity": ["interpolate", ["linear"], ["zoom"], 14, 0, 15, 1]}, "minzoom": 14, "source": "versatiles-shortbread"}, {"id": "transport-rail-service", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "kind"], "rail"], ["has", "service"], ["!=", ["get", "bridge"], true], ["!=", ["get", "tunnel"], true]], "paint": {"line-color": "rgb(200,207,214)", "line-dasharray": [2, 2], "line-width": ["interpolate", ["linear"], ["zoom"], 15, 0, 16, 1, 20, 10]}, "minzoom": 14, "source": "versatiles-shortbread"}, {"id": "transport-lightrail", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "kind"], "light_rail"], ["!", ["has", "service"]], ["!=", ["get", "bridge"], true], ["!=", ["get", "tunnel"], true]], "paint": {"line-color": "rgb(200,207,214)", "line-dasharray": [2, 2], "line-width": ["interpolate", ["linear"], ["zoom"], 14, 0, 15, 1, 20, 10], "line-opacity": ["interpolate", ["linear"], ["zoom"], 14, 0, 15, 1]}, "minzoom": 14, "source": "versatiles-shortbread"}, {"id": "transport-lightrail-service", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "kind"], "light_rail"], ["has", "service"], ["!=", ["get", "bridge"], true], ["!=", ["get", "tunnel"], true]], "paint": {"line-color": "rgb(200,207,214)", "line-dasharray": [2, 2], "line-width": ["interpolate", ["linear"], ["zoom"], 15, 0, 16, 1, 20, 10]}, "minzoom": 14, "source": "versatiles-shortbread"}, {"id": "transport-subway", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "kind"], "subway"], ["!", ["has", "service"]], ["!=", ["get", "bridge"], true], ["!=", ["get", "tunnel"], true]], "paint": {"line-color": "rgb(193,205,216)", "line-dasharray": [2, 2], "line-width": ["interpolate", ["linear"], ["zoom"], 11, 0, 12, 1, 15, 2, 16, 2, 18, 5, 19, 6, 20, 8], "line-opacity": ["interpolate", ["linear"], ["zoom"], 14, 0, 15, 1]}, "minzoom": 14, "source": "versatiles-shortbread"}, {"id": "transport-minorrail", "type": "line", "source-layer": "streets", "filter": ["any", ["all", ["==", ["get", "kind"], "narrow_gauge"], ["!", ["has", "service"]], ["!=", ["get", "bridge"], true], ["!=", ["get", "tunnel"], true]], ["all", ["==", ["get", "kind"], "tram"], ["!", ["has", "service"]], ["!=", ["get", "bridge"], true], ["!=", ["get", "tunnel"], true]], ["all", ["==", ["get", "kind"], "funicular"], ["!=", ["get", "bridge"], true], ["!=", ["get", "tunnel"], true]], ["all", ["==", ["get", "kind"], "monorail"], ["!=", ["get", "bridge"], true], ["!=", ["get", "tunnel"], true]]], "paint": {"line-width": ["interpolate", ["linear"], ["zoom"], 13, 0, 16, 1, 17, 2, 18, 3, 20, 5], "line-color": "rgb(177,187,196)"}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "aerialway", "type": "line", "source-layer": "aerialways", "filter": ["any", ["==", ["get", "kind"], "cable_car"], ["==", ["get", "kind"], "gondola"], ["==", ["get", "kind"], "goods"], ["==", ["get", "kind"], "chair_lift"], ["==", ["get", "kind"], "drag_lift"], ["==", ["get", "kind"], "t-bar"], ["==", ["get", "kind"], "j-bar"], ["==", ["get", "kind"], "platter"], ["==", ["get", "kind"], "rope-tow"]], "paint": {"line-color": "rgb(177,187,196)", "line-width": ["interpolate", ["exponential", 1], ["zoom"], 11, 3, 19, 5.5], "line-dasharray": [2, 3]}, "minzoom": 13, "layout": {"line-cap": "round"}, "source": "versatiles-shortbread"}, {"id": "transport-ferry", "type": "line", "source-layer": "ferries", "minzoom": 10, "paint": {"line-color": "rgb(125,152,179)", "line-width": 1.1, "line-opacity": ["interpolate", ["linear"], ["zoom"], 10, 0, 11, 1], "line-dasharray": [2, 2]}, "layout": {"line-join": "round"}, "source": "versatiles-shortbread"}, {"id": "bridge-way-footway:bridge", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "bridge"], true], ["==", ["get", "kind"], "footway"]], "layout": {"line-cap": "butt", "line-join": "round"}, "paint": {"line-color": "rgb(243,239,235)", "line-opacity": 0.5, "line-width": ["interpolate", ["linear"], ["zoom"], 15, 0, 16, 7, 18, 10, 19, 17, 20, 31]}, "minzoom": 14, "source": "versatiles-shortbread"}, {"id": "bridge-way-steps:bridge", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "bridge"], true], ["==", ["get", "kind"], "steps"]], "layout": {"line-cap": "butt", "line-join": "round"}, "paint": {"line-color": "rgb(243,239,235)", "line-opacity": 0.5, "line-width": ["interpolate", ["linear"], ["zoom"], 15, 0, 16, 7, 18, 10, 19, 17, 20, 31]}, "minzoom": 14, "source": "versatiles-shortbread"}, {"id": "bridge-way-paths:bridge", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "bridge"], true], ["in", ["get", "kind"], ["literal", ["path", "cycleway"]]]], "layout": {"line-cap": "butt", "line-join": "round"}, "paint": {"line-color": "rgb(243,239,235)", "line-opacity": 0.5, "line-width": ["interpolate", ["linear"], ["zoom"], 15, 0, 16, 7, 18, 10, 19, 17, 20, 31]}, "minzoom": 14, "source": "versatiles-shortbread"}, {"id": "bridge-street-track:bridge", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "kind"], "track"], ["==", ["get", "bridge"], true]], "layout": {"line-cap": "butt", "line-join": "round"}, "paint": {"line-color": "rgb(243,239,235)", "line-opacity": ["interpolate", ["linear"], ["zoom"], 14, 0, 15, 1], "line-width": ["interpolate", ["linear"], ["zoom"], 14, 3, 16, 6, 18, 25, 19, 67, 20, 134]}, "minzoom": 14, "source": "versatiles-shortbread"}, {"id": "bridge-street-pedestrian:bridge", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "kind"], "pedestrian"], ["==", ["get", "bridge"], true]], "layout": {"line-cap": "butt", "line-join": "round"}, "paint": {"line-color": "rgb(243,239,235)", "line-opacity": ["interpolate", ["linear"], ["zoom"], 12, 0, 13, 1], "line-width": ["interpolate", ["linear"], ["zoom"], 12, 3, 14, 4, 16, 8, 18, 36, 19, 90, 20, 179]}, "minzoom": 12, "source": "versatiles-shortbread"}, {"id": "bridge-street-service:bridge", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "kind"], "service"], ["==", ["get", "bridge"], true]], "layout": {"line-cap": "butt", "line-join": "round"}, "paint": {"line-color": "rgb(243,239,235)", "line-opacity": ["interpolate", ["linear"], ["zoom"], 14, 0, 15, 1], "line-width": ["interpolate", ["linear"], ["zoom"], 14, 3, 16, 6, 18, 25, 19, 67, 20, 134]}, "minzoom": 14, "source": "versatiles-shortbread"}, {"id": "bridge-street-minor:bridge", "type": "line", "source-layer": "streets", "filter": ["all", ["in", ["get", "kind"], ["literal", ["living_street", "residential", "unclassified"]]], ["==", ["get", "bridge"], true]], "layout": {"line-cap": "butt", "line-join": "round"}, "paint": {"line-color": "rgb(243,239,235)", "line-opacity": ["interpolate", ["linear"], ["zoom"], 12, 0, 13, 1], "line-width": ["interpolate", ["linear"], ["zoom"], 12, 3, 14, 4, 16, 8, 18, 36, 19, 90, 20, 179]}, "minzoom": 12, "source": "versatiles-shortbread"}, {"id": "bridge-street-tertiary-link:bridge", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "bridge"], true], ["==", ["get", "kind"], "tertiary"], ["==", ["get", "link"], true]], "layout": {"line-cap": "butt", "line-join": "round"}, "paint": {"line-color": "rgb(243,239,235)", "line-opacity": ["interpolate", ["linear"], ["zoom"], 12, 0, 13, 1], "line-width": ["interpolate", ["linear"], ["zoom"], 12, 3, 14, 4, 16, 8, 18, 36, 19, 90, 20, 179]}, "minzoom": 12, "source": "versatiles-shortbread"}, {"id": "bridge-street-arterial-link:bridge", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "bridge"], true], ["in", ["get", "kind"], ["literal", ["secondary", "primary"]]], ["==", ["get", "link"], true]], "layout": {"line-cap": "butt", "line-join": "round"}, "paint": {"line-color": "rgb(243,239,235)", "line-opacity": 0.5, "line-width": ["interpolate", ["linear"], ["zoom"], 12, 3, 14, 4, 16, 10, 18, 20, 20, 56]}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "bridge-street-trunk-link:bridge", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "bridge"], true], ["==", ["get", "kind"], "trunk"], ["==", ["get", "link"], true]], "layout": {"line-cap": "butt", "line-join": "round"}, "paint": {"line-color": "rgb(243,239,235)", "line-opacity": 0.5, "line-width": ["interpolate", ["linear"], ["zoom"], 12, 3, 14, 4, 16, 10, 18, 20, 20, 56]}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "bridge-street-motorway-link:bridge", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "bridge"], true], ["==", ["get", "kind"], "motorway"], ["==", ["get", "link"], true]], "layout": {"line-cap": "butt", "line-join": "round"}, "paint": {"line-color": "rgb(243,239,235)", "line-opacity": 0.5, "line-width": ["interpolate", ["linear"], ["zoom"], 12, 3, 14, 4, 16, 10, 18, 20, 20, 56]}, "minzoom": 12, "source": "versatiles-shortbread"}, {"id": "bridge-street-tertiary:bridge", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "bridge"], true], ["==", ["get", "kind"], "tertiary"], ["!=", ["get", "link"], true]], "layout": {"line-cap": "butt", "line-join": "round"}, "paint": {"line-color": "rgb(243,239,235)", "line-opacity": ["interpolate", ["linear"], ["zoom"], 12, 0, 13, 1], "line-width": ["interpolate", ["linear"], ["zoom"], 12, 3, 14, 4, 16, 8, 18, 36, 19, 90, 20, 179]}, "minzoom": 12, "source": "versatiles-shortbread"}, {"id": "bridge-street-secondary:bridge", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "bridge"], true], ["==", ["get", "kind"], "secondary"], ["!=", ["get", "link"], true]], "layout": {"line-cap": "butt", "line-join": "round"}, "paint": {"line-color": "rgb(243,239,235)", "line-opacity": ["interpolate", ["linear"], ["zoom"], 11, 0, 12, 1], "line-width": ["interpolate", ["linear"], ["zoom"], 11, 3, 14, 7, 16, 11, 18, 42, 19, 95, 20, 193]}, "minzoom": 11, "source": "versatiles-shortbread"}, {"id": "bridge-street-primary:bridge", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "bridge"], true], ["==", ["get", "kind"], "primary"], ["!=", ["get", "link"], true]], "layout": {"line-cap": "butt", "line-join": "round"}, "paint": {"line-color": "rgb(243,239,235)", "line-opacity": 0.5, "line-width": ["interpolate", ["linear"], ["zoom"], 8, 0, 9, 1, 10, 6, 14, 8, 16, 17, 18, 50, 19, 104, 20, 202]}, "minzoom": 8, "source": "versatiles-shortbread"}, {"id": "bridge-street-trunk:bridge", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "bridge"], true], ["==", ["get", "kind"], "trunk"], ["!=", ["get", "link"], true]], "layout": {"line-cap": "butt", "line-join": "round"}, "paint": {"line-color": "rgb(243,239,235)", "line-opacity": 0.5, "line-width": ["interpolate", ["linear"], ["zoom"], 7, 0, 8, 3, 10, 6, 14, 8, 16, 17, 18, 50, 19, 104, 20, 202]}, "minzoom": 7, "source": "versatiles-shortbread"}, {"id": "bridge-street-motorway:bridge", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "bridge"], true], ["==", ["get", "kind"], "motorway"], ["!=", ["get", "link"], true]], "layout": {"line-cap": "butt", "line-join": "round"}, "paint": {"line-color": "rgb(243,239,235)", "line-opacity": 0.5, "line-width": ["interpolate", ["linear"], ["zoom"], 5, 0, 6, 3, 10, 7, 14, 7, 16, 20, 18, 53, 19, 118, 20, 235]}, "minzoom": 5, "source": "versatiles-shortbread"}, {"id": "bridge-street-pedestrian-zone", "type": "fill", "source-layer": "street_polygons", "filter": ["all", ["==", ["get", "bridge"], true], ["==", ["get", "kind"], "pedestrian"]], "paint": {"fill-color": "rgb(255,255,255)", "fill-opacity": ["interpolate", ["linear"], ["zoom"], 12, 0, 13, 1]}, "minzoom": 12, "source": "versatiles-shortbread"}, {"id": "bridge-way-footway:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "bridge"], true], ["==", ["get", "kind"], "footway"]], "paint": {"line-color": "rgb(226,212,230)", "line-width": ["interpolate", ["linear"], ["zoom"], 13, 0, 16, 5, 18, 7, 19, 12, 20, 22]}, "layout": {"line-join": "round", "line-cap": "butt"}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "bridge-way-steps:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "bridge"], true], ["==", ["get", "kind"], "steps"]], "paint": {"line-color": "rgb(226,212,230)", "line-width": ["interpolate", ["linear"], ["zoom"], 13, 0, 16, 5, 18, 7, 19, 12, 20, 22]}, "layout": {"line-join": "round", "line-cap": "butt"}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "bridge-way-path:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "bridge"], true], ["==", ["get", "kind"], "path"]], "paint": {"line-color": "rgb(226,212,230)", "line-width": ["interpolate", ["linear"], ["zoom"], 13, 0, 16, 5, 18, 7, 19, 12, 20, 22]}, "layout": {"line-join": "round", "line-cap": "butt"}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "bridge-way-cycleway:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "bridge"], true], ["==", ["get", "kind"], "cycleway"]], "paint": {"line-color": "rgb(215,224,230)", "line-width": ["interpolate", ["linear"], ["zoom"], 13, 0, 16, 5, 18, 7, 19, 12, 20, 22]}, "layout": {"line-join": "round", "line-cap": "butt"}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "bridge-street-track:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "kind"], "track"], ["==", ["get", "bridge"], true]], "layout": {"line-join": "round", "line-cap": "butt"}, "paint": {"line-color": "rgb(217,217,217)", "line-width": ["interpolate", ["linear"], ["zoom"], 14, 2, 16, 4, 18, 18, 19, 48, 20, 96], "line-opacity": ["interpolate", ["linear"], ["zoom"], 13, 0, 14, 1]}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "bridge-street-pedestrian:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "kind"], "pedestrian"], ["==", ["get", "bridge"], true]], "layout": {"line-join": "round", "line-cap": "butt"}, "paint": {"line-color": "rgb(217,217,217)", "line-width": ["interpolate", ["linear"], ["zoom"], 12, 2, 14, 3, 16, 6, 18, 26, 19, 64, 20, 128], "line-opacity": ["interpolate", ["linear"], ["zoom"], 13, 0, 14, 1]}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "bridge-street-service:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "kind"], "service"], ["==", ["get", "bridge"], true]], "layout": {"line-join": "round", "line-cap": "butt"}, "paint": {"line-color": "rgb(221,220,218)", "line-width": ["interpolate", ["linear"], ["zoom"], 14, 1, 16, 3, 18, 12, 19, 32, 20, 48], "line-opacity": ["interpolate", ["linear"], ["zoom"], 13, 0, 14, 1]}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "bridge-street-livingstreet:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "kind"], "living_street"], ["==", ["get", "bridge"], true]], "layout": {"line-join": "round", "line-cap": "butt"}, "paint": {"line-color": "rgb(217,217,217)", "line-width": ["interpolate", ["linear"], ["zoom"], 12, 2, 14, 3, 16, 6, 18, 26, 19, 64, 20, 128], "line-opacity": ["interpolate", ["linear"], ["zoom"], 13, 0, 14, 1]}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "bridge-street-minor:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["in", ["get", "kind"], ["literal", ["residential", "unclassified"]]], ["==", ["get", "bridge"], true]], "layout": {"line-join": "round", "line-cap": "butt"}, "paint": {"line-color": "rgb(217,217,217)", "line-width": ["interpolate", ["linear"], ["zoom"], 12, 2, 14, 3, 16, 6, 18, 26, 19, 64, 20, 128], "line-opacity": ["interpolate", ["linear"], ["zoom"], 12, 0, 13, 1]}, "minzoom": 12, "source": "versatiles-shortbread"}, {"id": "bridge-street-bus:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["in", ["get", "kind"], ["literal", ["busway", "bus_guideway"]]], ["==", ["get", "bridge"], true]], "layout": {"line-join": "round", "line-cap": "butt"}, "paint": {"line-color": "rgb(221,220,218)", "line-width": ["interpolate", ["linear"], ["zoom"], 14, 1, 16, 3, 18, 12, 19, 32, 20, 48], "line-opacity": ["interpolate", ["linear"], ["zoom"], 12, 0, 13, 1]}, "minzoom": 12, "source": "versatiles-shortbread"}, {"id": "bridge-street-tertiary-link:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "bridge"], true], ["==", ["get", "kind"], "tertiary"], ["==", ["get", "link"], true]], "layout": {"line-join": "round", "line-cap": "butt"}, "paint": {"line-color": "rgb(217,217,217)", "line-width": ["interpolate", ["linear"], ["zoom"], 12, 2, 14, 3, 16, 6, 18, 26, 19, 64, 20, 128], "line-opacity": ["interpolate", ["linear"], ["zoom"], 13, 0, 14, 1]}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "bridge-street-arterial-link:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "bridge"], true], ["in", ["get", "kind"], ["literal", ["secondary", "primary"]]], ["==", ["get", "link"], true]], "layout": {"line-join": "round", "line-cap": "butt"}, "paint": {"line-color": "rgb(233,172,119)", "line-width": ["interpolate", ["linear"], ["zoom"], 12, 2, 14, 3, 16, 7, 18, 14, 20, 40], "line-opacity": ["interpolate", ["linear"], ["zoom"], 13, 0, 14, 1]}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "bridge-street-trunk-link:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "bridge"], true], ["==", ["get", "kind"], "trunk"], ["==", ["get", "link"], true]], "layout": {"line-join": "round", "line-cap": "butt"}, "paint": {"line-color": "rgb(233,172,119)", "line-width": ["interpolate", ["linear"], ["zoom"], 12, 2, 14, 3, 16, 7, 18, 14, 20, 40], "line-opacity": ["interpolate", ["linear"], ["zoom"], 13, 0, 14, 1]}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "bridge-street-motorway-link:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "bridge"], true], ["==", ["get", "kind"], "motorway"], ["==", ["get", "link"], true]], "layout": {"line-join": "round", "line-cap": "butt"}, "paint": {"line-color": "rgb(233,172,119)", "line-width": ["interpolate", ["linear"], ["zoom"], 12, 2, 14, 3, 16, 7, 18, 14, 20, 40], "line-opacity": ["interpolate", ["linear"], ["zoom"], 12, 0, 13, 1]}, "minzoom": 12, "source": "versatiles-shortbread"}, {"id": "bridge-street-tertiary:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "bridge"], true], ["==", ["get", "kind"], "tertiary"], ["!=", ["get", "link"], true]], "layout": {"line-join": "round", "line-cap": "butt"}, "paint": {"line-color": "rgb(217,217,217)", "line-width": ["interpolate", ["linear"], ["zoom"], 12, 2, 14, 3, 16, 6, 18, 26, 19, 64, 20, 128], "line-opacity": ["interpolate", ["linear"], ["zoom"], 10, 0, 11, 1]}, "minzoom": 10, "source": "versatiles-shortbread"}, {"id": "bridge-street-secondary:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "bridge"], true], ["==", ["get", "kind"], "secondary"], ["!=", ["get", "link"], true]], "layout": {"line-join": "round", "line-cap": "butt"}, "paint": {"line-color": "rgb(233,172,119)", "line-width": ["interpolate", ["linear"], ["zoom"], 11, 2, 14, 5, 16, 8, 18, 30, 19, 68, 20, 138], "line-opacity": ["interpolate", ["linear"], ["zoom"], 9, 0, 10, 1]}, "minzoom": 9, "source": "versatiles-shortbread"}, {"id": "bridge-street-primary:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "bridge"], true], ["==", ["get", "kind"], "primary"], ["!=", ["get", "link"], true]], "layout": {"line-join": "round", "line-cap": "butt"}, "paint": {"line-color": "rgb(233,172,119)", "line-width": ["interpolate", ["linear"], ["zoom"], 8, 0, 9, 2, 10, 4, 14, 6, 16, 12, 18, 36, 19, 74, 20, 144], "line-opacity": ["interpolate", ["linear"], ["zoom"], 8, 0, 9, 1]}, "minzoom": 8, "source": "versatiles-shortbread"}, {"id": "bridge-street-trunk:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "bridge"], true], ["==", ["get", "kind"], "trunk"], ["!=", ["get", "link"], true]], "layout": {"line-join": "round", "line-cap": "butt"}, "paint": {"line-color": "rgb(233,172,119)", "line-width": ["interpolate", ["linear"], ["zoom"], 6, 0, 7, 2, 10, 4, 14, 6, 16, 12, 18, 36, 19, 74, 20, 144], "line-opacity": ["interpolate", ["linear"], ["zoom"], 6, 0, 7, 1]}, "minzoom": 6, "source": "versatiles-shortbread"}, {"id": "bridge-street-motorway:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "bridge"], true], ["==", ["get", "kind"], "motorway"], ["!=", ["get", "link"], true]], "layout": {"line-join": "round", "line-cap": "butt"}, "paint": {"line-color": "rgb(233,172,119)", "line-width": ["interpolate", ["linear"], ["zoom"], 5, 0, 6, 2, 10, 5, 14, 5, 16, 14, 18, 38, 19, 84, 20, 168], "line-opacity": ["interpolate", ["linear"], ["zoom"], 5, 0, 6, 1]}, "minzoom": 5, "source": "versatiles-shortbread"}, {"id": "bridge-way-footway", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "bridge"], true], ["==", ["get", "kind"], "footway"]], "paint": {"line-color": "rgb(251,235,255)", "line-width": ["interpolate", ["linear"], ["zoom"], 13, 0, 16, 4, 18, 6, 19, 10, 20, 20]}, "layout": {"line-join": "round", "line-cap": "butt"}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "bridge-way-steps", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "bridge"], true], ["==", ["get", "kind"], "steps"]], "paint": {"line-color": "rgb(251,235,255)", "line-width": ["interpolate", ["linear"], ["zoom"], 13, 0, 16, 4, 18, 6, 19, 10, 20, 20]}, "layout": {"line-join": "round", "line-cap": "butt"}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "bridge-way-path", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "bridge"], true], ["==", ["get", "kind"], "path"]], "paint": {"line-color": "rgb(251,235,255)", "line-width": ["interpolate", ["linear"], ["zoom"], 13, 0, 16, 4, 18, 6, 19, 10, 20, 20]}, "layout": {"line-join": "round", "line-cap": "butt"}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "bridge-way-cycleway", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "bridge"], true], ["==", ["get", "kind"], "cycleway"]], "paint": {"line-color": "rgb(239,249,255)", "line-width": ["interpolate", ["linear"], ["zoom"], 13, 0, 16, 4, 18, 6, 19, 10, 20, 20]}, "layout": {"line-join": "round", "line-cap": "butt"}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "bridge-street-track", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "kind"], "track"], ["==", ["get", "bridge"], true]], "layout": {"line-join": "round", "line-cap": "butt"}, "paint": {"line-color": "rgb(255,255,255)", "line-width": ["interpolate", ["linear"], ["zoom"], 14, 1, 16, 3, 18, 16, 19, 44, 20, 88], "line-opacity": ["interpolate", ["linear"], ["zoom"], 13, 0, 14, 1]}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "bridge-street-pedestrian", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "kind"], "pedestrian"], ["==", ["get", "bridge"], true]], "layout": {"line-join": "round", "line-cap": "butt"}, "paint": {"line-color": "rgb(255,255,255)", "line-width": ["interpolate", ["linear"], ["zoom"], 12, 1, 14, 2, 16, 5, 18, 24, 19, 60, 20, 120], "line-opacity": ["interpolate", ["linear"], ["zoom"], 13, 0, 14, 1]}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "bridge-street-service", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "kind"], "service"], ["==", ["get", "bridge"], true]], "layout": {"line-join": "round", "line-cap": "butt"}, "paint": {"line-color": "rgb(247,247,247)", "line-width": ["interpolate", ["linear"], ["zoom"], 14, 1, 16, 2, 18, 10, 19, 28, 20, 40], "line-opacity": ["interpolate", ["linear"], ["zoom"], 13, 0, 14, 1]}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "bridge-street-livingstreet", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "kind"], "living_street"], ["==", ["get", "bridge"], true]], "layout": {"line-join": "round", "line-cap": "butt"}, "paint": {"line-color": "rgb(255,255,255)", "line-width": ["interpolate", ["linear"], ["zoom"], 12, 1, 14, 2, 16, 5, 18, 24, 19, 60, 20, 120], "line-opacity": ["interpolate", ["linear"], ["zoom"], 13, 0, 14, 1]}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "bridge-street-minor", "type": "line", "source-layer": "streets", "filter": ["all", ["in", ["get", "kind"], ["literal", ["residential", "unclassified"]]], ["==", ["get", "bridge"], true]], "layout": {"line-join": "round", "line-cap": "butt"}, "paint": {"line-color": "rgb(255,255,255)", "line-width": ["interpolate", ["linear"], ["zoom"], 12, 1, 14, 2, 16, 5, 18, 24, 19, 60, 20, 120], "line-opacity": ["interpolate", ["linear"], ["zoom"], 12, 0, 13, 1]}, "minzoom": 12, "source": "versatiles-shortbread"}, {"id": "bridge-street-bus", "type": "line", "source-layer": "streets", "filter": ["all", ["in", ["get", "kind"], ["literal", ["busway", "bus_guideway"]]], ["==", ["get", "bridge"], true]], "layout": {"line-join": "round", "line-cap": "butt"}, "paint": {"line-color": "rgb(247,247,247)", "line-width": ["interpolate", ["linear"], ["zoom"], 14, 1, 16, 2, 18, 10, 19, 28, 20, 40], "line-opacity": ["interpolate", ["linear"], ["zoom"], 12, 0, 13, 1]}, "minzoom": 12, "source": "versatiles-shortbread"}, {"id": "bridge-street-pedestrian-bicycle", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "kind"], "pedestrian"], ["==", ["get", "bicycle"], "designated"], ["==", ["get", "bridge"], true]], "layout": {"line-join": "round", "line-cap": "butt"}, "paint": {"line-color": "rgb(239,249,255)", "line-width": ["interpolate", ["linear"], ["zoom"], 12, 1, 14, 2, 16, 5, 18, 24, 19, 60, 20, 120], "line-opacity": ["interpolate", ["linear"], ["zoom"], 12, 0, 13, 1]}, "minzoom": 12, "source": "versatiles-shortbread"}, {"id": "bridge-street-minor-bicycle", "type": "line", "source-layer": "streets", "filter": ["all", ["in", ["get", "kind"], ["literal", ["living_street", "residential", "unclassified"]]], ["==", ["get", "bicycle"], "designated"], ["==", ["get", "bridge"], true]], "layout": {"line-join": "round", "line-cap": "butt"}, "paint": {"line-color": "rgb(239,249,255)", "line-width": ["interpolate", ["linear"], ["zoom"], 12, 1, 14, 2, 16, 5, 18, 24, 19, 60, 20, 120], "line-opacity": ["interpolate", ["linear"], ["zoom"], 12, 0, 13, 1]}, "minzoom": 12, "source": "versatiles-shortbread"}, {"id": "bridge-street-tertiary-link", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "bridge"], true], ["==", ["get", "kind"], "tertiary"], ["==", ["get", "link"], true]], "layout": {"line-join": "round", "line-cap": "butt"}, "paint": {"line-color": "rgb(255,255,255)", "line-width": ["interpolate", ["linear"], ["zoom"], 12, 1, 14, 2, 16, 5, 18, 24, 19, 60, 20, 120], "line-opacity": ["interpolate", ["linear"], ["zoom"], 13, 0, 14, 1]}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "bridge-street-arterial-link", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "bridge"], true], ["in", ["get", "kind"], ["literal", ["secondary", "primary"]]], ["==", ["get", "link"], true]], "layout": {"line-join": "round", "line-cap": "butt"}, "paint": {"line-color": "rgb(255,238,170)", "line-width": ["interpolate", ["linear"], ["zoom"], 12, 1, 14, 2, 16, 5, 18, 12, 20, 38], "line-opacity": ["interpolate", ["linear"], ["zoom"], 13, 0, 14, 1]}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "bridge-street-trunk-link", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "bridge"], true], ["==", ["get", "kind"], "trunk"], ["==", ["get", "link"], true]], "layout": {"line-join": "round", "line-cap": "butt"}, "paint": {"line-color": "rgb(255,238,170)", "line-width": ["interpolate", ["linear"], ["zoom"], 12, 1, 14, 2, 16, 5, 18, 12, 20, 38], "line-opacity": ["interpolate", ["linear"], ["zoom"], 13, 0, 14, 1]}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "bridge-street-motorway-link", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "bridge"], true], ["==", ["get", "kind"], "motorway"], ["==", ["get", "link"], true]], "layout": {"line-join": "round", "line-cap": "butt"}, "paint": {"line-color": "rgb(255,204,136)", "line-width": ["interpolate", ["linear"], ["zoom"], 12, 1, 14, 2, 16, 5, 18, 12, 20, 38], "line-opacity": ["interpolate", ["linear"], ["zoom"], 12, 0, 13, 1]}, "minzoom": 12, "source": "versatiles-shortbread"}, {"id": "bridge-street-tertiary", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "bridge"], true], ["==", ["get", "kind"], "tertiary"], ["!=", ["get", "link"], true]], "layout": {"line-join": "round", "line-cap": "butt"}, "paint": {"line-color": "rgb(255,255,255)", "line-width": ["interpolate", ["linear"], ["zoom"], 12, 1, 14, 2, 16, 5, 18, 24, 19, 60, 20, 120], "line-opacity": ["interpolate", ["linear"], ["zoom"], 10, 0, 11, 1]}, "minzoom": 10, "source": "versatiles-shortbread"}, {"id": "bridge-street-secondary", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "bridge"], true], ["==", ["get", "kind"], "secondary"], ["!=", ["get", "link"], true]], "layout": {"line-join": "round", "line-cap": "butt"}, "paint": {"line-color": "rgb(255,238,170)", "line-width": ["interpolate", ["linear"], ["zoom"], 11, 1, 14, 4, 16, 6, 18, 28, 19, 64, 20, 130], "line-opacity": ["interpolate", ["linear"], ["zoom"], 9, 0, 10, 1]}, "minzoom": 9, "source": "versatiles-shortbread"}, {"id": "bridge-street-primary", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "bridge"], true], ["==", ["get", "kind"], "primary"], ["!=", ["get", "link"], true]], "layout": {"line-join": "round", "line-cap": "butt"}, "paint": {"line-color": "rgb(255,238,170)", "line-width": ["interpolate", ["linear"], ["zoom"], 8, 0, 9, 1, 10, 3, 14, 5, 16, 10, 18, 34, 19, 70, 20, 140], "line-opacity": ["interpolate", ["linear"], ["zoom"], 8, 0, 9, 1]}, "minzoom": 8, "source": "versatiles-shortbread"}, {"id": "bridge-street-trunk", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "bridge"], true], ["==", ["get", "kind"], "trunk"], ["!=", ["get", "link"], true]], "layout": {"line-join": "round", "line-cap": "butt"}, "paint": {"line-color": "rgb(255,238,170)", "line-width": ["interpolate", ["linear"], ["zoom"], 6, 0, 7, 1, 10, 3, 14, 5, 16, 10, 18, 34, 19, 70, 20, 140], "line-opacity": ["interpolate", ["linear"], ["zoom"], 6, 0, 7, 1]}, "minzoom": 6, "source": "versatiles-shortbread"}, {"id": "bridge-street-motorway", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "bridge"], true], ["==", ["get", "kind"], "motorway"], ["!=", ["get", "link"], true]], "layout": {"line-join": "round", "line-cap": "butt"}, "paint": {"line-color": "rgb(255,204,136)", "line-width": ["interpolate", ["linear"], ["zoom"], 5, 0, 6, 1, 10, 4, 14, 4, 16, 12, 18, 36, 19, 80, 20, 160], "line-opacity": ["interpolate", ["linear"], ["zoom"], 5, 0, 6, 1]}, "minzoom": 5, "source": "versatiles-shortbread"}, {"id": "bridge-transport-rail:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "kind"], "rail"], ["!", ["has", "service"]], ["==", ["get", "bridge"], true]], "paint": {"line-color": "rgb(177,187,196)", "line-width": ["interpolate", ["linear"], ["zoom"], 8, 1, 13, 1, 15, 1, 20, 14], "line-opacity": ["interpolate", ["linear"], ["zoom"], 11, 0, 12, 1]}, "layout": {"line-cap": "butt"}, "minzoom": 11, "source": "versatiles-shortbread"}, {"id": "bridge-transport-rail-service:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "kind"], "rail"], ["has", "service"], ["==", ["get", "bridge"], true]], "paint": {"line-color": "rgb(177,187,196)", "line-width": ["interpolate", ["linear"], ["zoom"], 14, 0, 15, 1, 16, 1, 20, 14]}, "layout": {"line-cap": "butt"}, "minzoom": 14, "source": "versatiles-shortbread"}, {"id": "bridge-transport-lightrail:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "kind"], "light_rail"], ["!", ["has", "service"]], ["==", ["get", "bridge"], true]], "paint": {"line-color": "rgb(177,187,196)", "line-width": ["interpolate", ["linear"], ["zoom"], 8, 1, 13, 1, 15, 1, 20, 14], "line-opacity": ["interpolate", ["linear"], ["zoom"], 11, 0, 12, 1]}, "layout": {"line-cap": "butt"}, "minzoom": 11, "source": "versatiles-shortbread"}, {"id": "bridge-transport-lightrail-service:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "kind"], "light_rail"], ["has", "service"], ["==", ["get", "bridge"], true]], "paint": {"line-color": "rgb(177,187,196)", "line-width": ["interpolate", ["linear"], ["zoom"], 14, 0, 15, 1, 16, 1, 20, 14]}, "layout": {"line-cap": "butt"}, "minzoom": 14, "source": "versatiles-shortbread"}, {"id": "bridge-transport-subway:outline", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "kind"], "subway"], ["!", ["has", "service"]], ["==", ["get", "bridge"], true]], "paint": {"line-color": "rgb(166,184,199)", "line-width": ["interpolate", ["linear"], ["zoom"], 11, 0, 12, 1, 15, 3, 16, 3, 18, 6, 19, 8, 20, 10], "line-opacity": ["interpolate", ["linear"], ["zoom"], 11, 0, 12, 1]}, "layout": {"line-cap": "butt"}, "minzoom": 11, "source": "versatiles-shortbread"}, {"id": "bridge-transport-minorrail:outline", "type": "line", "source-layer": "streets", "filter": ["any", ["all", ["==", ["get", "kind"], "narrow_gauge"], ["!", ["has", "service"]], ["==", ["get", "bridge"], true]], ["all", ["==", ["get", "kind"], "tram"], ["!", ["has", "service"]], ["==", ["get", "bridge"], true]], ["all", ["==", ["get", "kind"], "funicular"], ["==", ["get", "bridge"], true]], ["all", ["==", ["get", "kind"], "monorail"], ["==", ["get", "bridge"], true]]], "paint": {"line-color": "rgb(177,187,196)", "line-width": ["interpolate", ["linear"], ["zoom"], 15, 0, 16, 5, 18, 7, 20, 20], "line-dasharray": [0.1, 0.5]}, "layout": {"line-cap": "butt"}, "minzoom": 14, "source": "versatiles-shortbread"}, {"id": "bridge-transport-rail", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "kind"], "rail"], ["!", ["has", "service"]], ["==", ["get", "bridge"], true]], "paint": {"line-color": "rgb(200,207,214)", "line-dasharray": [2, 2], "line-width": ["interpolate", ["linear"], ["zoom"], 14, 0, 15, 1, 20, 10], "line-opacity": ["interpolate", ["linear"], ["zoom"], 14, 0, 15, 1]}, "layout": {"line-cap": "butt"}, "minzoom": 14, "source": "versatiles-shortbread"}, {"id": "bridge-transport-rail-service", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "kind"], "rail"], ["has", "service"], ["==", ["get", "bridge"], true]], "paint": {"line-color": "rgb(200,207,214)", "line-dasharray": [2, 2], "line-width": ["interpolate", ["linear"], ["zoom"], 15, 0, 16, 1, 20, 10]}, "layout": {"line-cap": "butt"}, "minzoom": 14, "source": "versatiles-shortbread"}, {"id": "bridge-transport-lightrail", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "kind"], "light_rail"], ["!", ["has", "service"]], ["==", ["get", "bridge"], true]], "paint": {"line-color": "rgb(200,207,214)", "line-dasharray": [2, 2], "line-width": ["interpolate", ["linear"], ["zoom"], 14, 0, 15, 1, 20, 10], "line-opacity": ["interpolate", ["linear"], ["zoom"], 14, 0, 15, 1]}, "layout": {"line-cap": "butt"}, "minzoom": 14, "source": "versatiles-shortbread"}, {"id": "bridge-transport-lightrail-service", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "kind"], "light_rail"], ["has", "service"], ["==", ["get", "bridge"], true]], "paint": {"line-color": "rgb(200,207,214)", "line-dasharray": [2, 2], "line-width": ["interpolate", ["linear"], ["zoom"], 15, 0, 16, 1, 20, 10]}, "layout": {"line-cap": "butt"}, "minzoom": 14, "source": "versatiles-shortbread"}, {"id": "bridge-transport-subway", "type": "line", "source-layer": "streets", "filter": ["all", ["==", ["get", "kind"], "subway"], ["!", ["has", "service"]], ["==", ["get", "bridge"], true]], "paint": {"line-color": "rgb(193,205,216)", "line-dasharray": [2, 2], "line-width": ["interpolate", ["linear"], ["zoom"], 11, 0, 12, 1, 15, 2, 16, 2, 18, 5, 19, 6, 20, 8], "line-opacity": ["interpolate", ["linear"], ["zoom"], 14, 0, 15, 1]}, "layout": {"line-cap": "butt"}, "minzoom": 14, "source": "versatiles-shortbread"}, {"id": "bridge-transport-minorrail", "type": "line", "source-layer": "streets", "filter": ["any", ["all", ["==", ["get", "kind"], "narrow_gauge"], ["!", ["has", "service"]], ["==", ["get", "bridge"], true]], ["all", ["==", ["get", "kind"], "tram"], ["!", ["has", "service"]], ["==", ["get", "bridge"], true]], ["all", ["==", ["get", "kind"], "funicular"], ["==", ["get", "bridge"], true]], ["all", ["==", ["get", "kind"], "monorail"], ["==", ["get", "bridge"], true]]], "paint": {"line-width": ["interpolate", ["linear"], ["zoom"], 13, 0, 16, 1, 17, 2, 18, 3, 20, 5], "line-color": "rgb(177,187,196)"}, "layout": {"line-cap": "butt"}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "slot-below-symbols", "type": "background", "paint": {"background-opacity": 0}}, {"id": "boundary-country:outline", "type": "line", "source-layer": "boundaries", "filter": ["any", ["all", ["==", ["get", "admin_level"], 2], ["!=", ["get", "disputed"], true], ["!=", ["get", "maritime"], true]], ["all", ["==", ["get", "admin_level"], 2], ["==", ["get", "disputed"], true], ["!=", ["get", "maritime"], true]]], "paint": {"line-color": "rgb(248,244,240)", "line-opacity": 0.75, "line-width": ["interpolate", ["linear"], ["zoom"], 0, 0, 3, 2, 10, 8]}, "layout": {"line-cap": "round", "line-join": "round"}, "minzoom": 0, "source": "versatiles-shortbread"}, {"id": "boundary-state:outline", "type": "line", "source-layer": "boundaries", "filter": ["all", ["==", ["get", "admin_level"], 4], ["!=", ["get", "disputed"], true], ["!=", ["get", "maritime"], true]], "paint": {"line-color": "rgb(248,244,240)", "line-opacity": 0.75, "line-width": ["interpolate", ["linear"], ["zoom"], 7, 0, 8, 2, 10, 4]}, "layout": {"line-cap": "round", "line-join": "round"}, "minzoom": 7, "source": "versatiles-shortbread"}, {"id": "boundary-country", "type": "line", "source-layer": "boundaries", "filter": ["all", ["==", ["get", "admin_level"], 2], ["!=", ["get", "disputed"], true], ["!=", ["get", "maritime"], true]], "paint": {"line-color": "rgb(166,166,200)", "line-width": ["interpolate", ["linear"], ["zoom"], 0, 0, 3, 1, 10, 4]}, "layout": {"line-cap": "round", "line-join": "round"}, "minzoom": 0, "source": "versatiles-shortbread"}, {"id": "boundary-country-disputed", "type": "line", "source-layer": "boundaries", "filter": ["all", ["==", ["get", "admin_level"], 2], ["==", ["get", "disputed"], true], ["!=", ["get", "maritime"], true]], "paint": {"line-color": "rgb(190,188,207)", "line-width": ["interpolate", ["linear"], ["zoom"], 0, 0, 3, 1, 10, 4], "line-dasharray": [2, 1]}, "layout": {"line-cap": "round", "line-join": "round"}, "minzoom": 0, "source": "versatiles-shortbread"}, {"id": "boundary-state", "type": "line", "source-layer": "boundaries", "filter": ["all", ["==", ["get", "admin_level"], 4], ["!=", ["get", "disputed"], true], ["!=", ["get", "maritime"], true]], "paint": {"line-color": "rgb(166,166,200)", "line-width": ["interpolate", ["linear"], ["zoom"], 7, 0, 8, 1, 10, 2], "line-opacity": ["interpolate", ["linear"], ["zoom"], 7, 0, 8, 1]}, "layout": {"line-cap": "round", "line-join": "round"}, "minzoom": 7, "source": "versatiles-shortbread"}, {"id": "boundary-country-maritime", "type": "line", "source-layer": "boundaries", "filter": ["all", ["==", ["get", "admin_level"], 2], ["==", ["get", "maritime"], true], ["!=", ["get", "disputed"], true]], "paint": {"line-color": "rgb(185,210,235)", "line-width": ["interpolate", ["linear"], ["zoom"], 0, 0, 3, 1, 10, 4], "line-opacity": ["interpolate", ["linear"], ["zoom"], 4, 0, 5, 1]}, "layout": {"line-cap": "round", "line-join": "round"}, "minzoom": 4, "source": "versatiles-shortbread"}, {"id": "label-address-housenumber", "type": "symbol", "source-layer": "addresses", "filter": ["has", "housenumber"], "layout": {"text-field": ["case", ["has", "unit"], ["concat", ["get", "housenumber"], "/", ["get", "unit"]], ["get", "housenumber"]], "symbol-placement": "point", "text-anchor": "center", "text-size": ["interpolate", ["linear"], ["zoom"], 17, 8, 19, 10], "text-font": ["noto_sans_regular"]}, "paint": {"icon-color": "rgba(15,11,7,0.302)", "text-color": "rgba(15,11,7,0.302)"}, "minzoom": 17, "source": "versatiles-shortbread"}, {"id": "marking-oneway", "type": "symbol", "source-layer": "streets", "filter": ["all", ["==", ["get", "oneway"], true], ["in", ["get", "kind"], ["literal", ["trunk", "primary", "secondary", "tertiary", "unclassified", "residential", "living_street"]]]], "layout": {"symbol-placement": "line", "symbol-spacing": 175, "icon-rotate": 90, "icon-rotation-alignment": "map", "icon-padding": 5, "symbol-avoid-edges": true, "icon-image": "base:marking-oneway"}, "minzoom": 14, "paint": {"icon-color": "rgb(0,0,0)", "text-color": "rgb(0,0,0)", "icon-opacity": ["interpolate", ["linear"], ["zoom"], 16, 0, 17, 0.4, 20, 0.4], "text-opacity": ["interpolate", ["linear"], ["zoom"], 16, 0, 17, 0.4, 20, 0.4]}, "source": "versatiles-shortbread"}, {"id": "marking-oneway-reverse", "type": "symbol", "source-layer": "streets", "filter": ["all", ["==", ["get", "oneway_reverse"], true], ["in", ["get", "kind"], ["literal", ["trunk", "primary", "secondary", "tertiary", "unclassified", "residential", "living_street"]]]], "layout": {"symbol-placement": "line", "symbol-spacing": 75, "icon-rotate": -90, "icon-rotation-alignment": "map", "icon-padding": 5, "symbol-avoid-edges": true, "icon-image": "base:marking-oneway"}, "minzoom": 14, "paint": {"icon-color": "rgb(0,0,0)", "text-color": "rgb(0,0,0)", "icon-opacity": ["interpolate", ["linear"], ["zoom"], 16, 0, 17, 0.4, 20, 0.4], "text-opacity": ["interpolate", ["linear"], ["zoom"], 16, 0, 17, 0.4, 20, 0.4]}, "source": "versatiles-shortbread"}, {"id": "poi-amenity", "type": "symbol", "source-layer": "pois", "filter": ["to-boolean", ["get", "amenity"]], "minzoom": 14, "layout": {"icon-size": ["interpolate", ["exponential", 2], ["zoom"], 16, 0.4, 20, 1.2], "text-field": ["get", "name"], "text-size": ["interpolate", ["linear"], ["zoom"], 17, 0, 18, 11, 22, 13], "text-anchor": "top", "text-offset": [0, 1.2], "text-optional": true, "symbol-placement": "point", "icon-optional": true, "icon-image": ["match", ["get", "amenity"], "arts_centre", "base:icon-art_gallery", "clinic", "base:icon-doctor", "courthouse", "base:icon-justice", "food_court", "base:icon-restaurant", "public_building", "base:icon-town_hall", "university", "base:icon-college", "atm", "base:icon-atm", "bank", "base:icon-bank", "bar", "base:icon-bar", "bench", "base:icon-bench", "bicycle_rental", "base:icon-bicycle_share", "biergarten", "base:icon-beer_mug", "cafe", "base:icon-cafe", "car_rental", "base:icon-car_rental", "car_sharing", "base:icon-car_rental", "car_wash", "base:icon-car_wash", "cinema", "base:icon-cinema", "college", "base:icon-college", "community_centre", "base:icon-community", "dentist", "base:icon-dentist", "doctors", "base:icon-doctor", "drinking_water", "base:icon-drinking_water", "embassy", "base:icon-embassy", "fast_food", "base:icon-fast_food", "fire_station", "base:icon-fire_station", "fountain", "base:icon-fountain", "fuel", "base:icon-fuel_pump", "grave_yard", "base:icon-cemetery", "hospital", "base:icon-hospital", "hunting_stand", "base:icon-hunting_stand", "library", "base:icon-library", "marketplace", "base:icon-marketplace", "nightclub", "base:icon-nightclub", "nursing_home", "base:icon-nursing_home", "pharmacy", "base:icon-pill", "place_of_worship", ["match", ["get", "religion"], "christian", "base:icon-latin_cross", "muslim", "base:icon-star_and_crescent", "jewish", "base:icon-star_of_david", "buddhist", "base:icon-dharma_wheel", "hindu", "base:icon-om", "sikh", "base:icon-khanda", "taoist", "base:icon-yin_yang", "base:icon-person_kneeling_and_praying"], "police", "base:icon-police_officer", "post_box", "base:icon-postbox", "post_office", "base:icon-post", "prison", "base:icon-prison", "pub", "base:icon-pint_glass", "recycling", "base:icon-recycling", "restaurant", "base:icon-restaurant", "school", "base:icon-school", "shelter", "base:icon-shelter", "telephone", "base:icon-telephone", "theatre", "base:icon-theater", "toilets", "base:icon-restrooms", "townhall", "base:icon-town_hall", "vending_machine", "base:icon-vending_machine", "veterinary", "base:icon-veterinary", "waste_basket", "base:icon-waste_basket", "base:transport-information"], "text-font": ["noto_sans_bold"]}, "paint": {"icon-opacity": ["interpolate", ["linear"], ["zoom"], 16, 0, 17, 0.4], "text-opacity": ["interpolate", ["linear"], ["zoom"], 18, 0, 18.5, 0.4], "icon-color": "rgb(102,102,102)", "text-color": "rgb(102,102,102)", "text-halo-color": "rgb(255,255,255)", "text-halo-width": 0.5, "text-halo-blur": 0.5}, "source": "versatiles-shortbread"}, {"id": "poi-leisure", "type": "symbol", "source-layer": "pois", "filter": ["to-boolean", ["get", "leisure"]], "minzoom": 14, "layout": {"icon-size": ["interpolate", ["exponential", 2], ["zoom"], 16, 0.4, 20, 1.2], "text-field": ["get", "name"], "text-size": ["interpolate", ["linear"], ["zoom"], 17, 0, 18, 11, 22, 13], "text-anchor": "top", "text-offset": [0, 1.2], "text-optional": true, "symbol-placement": "point", "icon-optional": true, "icon-image": ["match", ["get", "leisure"], "dog_park", "base:icon-dog", "golf_course", "base:icon-golf", "ice_rink", "base:icon-ice_rink", "pitch", "base:icon-pitch", "playground", "base:icon-seesaw", "stadium", "base:icon-stadium", "swimming_pool", "base:icon-swimming", "water_park", "base:icon-water_park", "base:icon-sports"], "text-font": ["noto_sans_bold"]}, "paint": {"icon-opacity": ["interpolate", ["linear"], ["zoom"], 16, 0, 17, 0.4], "text-opacity": ["interpolate", ["linear"], ["zoom"], 18, 0, 18.5, 0.4], "icon-color": "rgb(102,102,102)", "text-color": "rgb(102,102,102)", "text-halo-color": "rgb(255,255,255)", "text-halo-width": 0.5, "text-halo-blur": 0.5}, "source": "versatiles-shortbread"}, {"id": "poi-tourism", "type": "symbol", "source-layer": "pois", "filter": ["to-boolean", ["get", "tourism"]], "minzoom": 14, "layout": {"icon-size": ["interpolate", ["exponential", 2], ["zoom"], 16, 0.4, 20, 1.2], "text-field": ["get", "name"], "text-size": ["interpolate", ["linear"], ["zoom"], 17, 0, 18, 11, 22, 13], "text-anchor": "top", "text-offset": [0, 1.2], "text-optional": true, "symbol-placement": "point", "icon-optional": true, "icon-image": ["match", ["get", "tourism"], "artwork", "base:icon-artwork", "alpine_hut", "base:icon-chalet", "bed_and_breakfast", "base:icon-lodging", "camp_site", "base:icon-campsite", "caravan_site", "base:icon-caravan", "guest_house", "base:icon-lodging", "hostel", "base:icon-lodging", "hotel", "base:icon-lodging", "motel", "base:icon-lodging", "theme_park", "base:icon-amusement_park", "chalet", "base:icon-chalet", "information", "base:transport-information", "picnic_site", "base:icon-picnic_site", "viewpoint", "base:icon-viewpoint", "zoo", "base:icon-zoo", "base:transport-information"], "text-font": ["noto_sans_bold"]}, "paint": {"icon-opacity": ["interpolate", ["linear"], ["zoom"], 16, 0, 17, 0.4], "text-opacity": ["interpolate", ["linear"], ["zoom"], 18, 0, 18.5, 0.4], "icon-color": "rgb(102,102,102)", "text-color": "rgb(102,102,102)", "text-halo-color": "rgb(255,255,255)", "text-halo-width": 0.5, "text-halo-blur": 0.5}, "source": "versatiles-shortbread"}, {"id": "poi-shop", "type": "symbol", "source-layer": "pois", "filter": ["to-boolean", ["get", "shop"]], "minzoom": 14, "layout": {"icon-size": ["interpolate", ["exponential", 2], ["zoom"], 16, 0.4, 20, 1.2], "text-field": ["get", "name"], "text-size": ["interpolate", ["linear"], ["zoom"], 17, 0, 18, 11, 22, 13], "text-anchor": "top", "text-offset": [0, 1.2], "text-optional": true, "symbol-placement": "point", "icon-optional": true, "icon-image": ["match", ["get", "shop"], "alcohol", "base:icon-alcohol_shop", "bakery", "base:icon-bakery", "beauty", "base:icon-beauty", "beverages", "base:icon-beverages", "books", "base:icon-books", "butcher", "base:icon-butcher", "chemist", "base:icon-tube_and_toothbrush", "clothes", "base:icon-clothes", "doityourself", "base:icon-do_it_yourself", "dry_cleaning", "base:icon-dry_cleaning", "florist", "base:icon-florist", "furniture", "base:icon-furniture", "garden_centre", "base:icon-garden_center", "general", "base:icon-shop", "gift", "base:icon-gift", "greengrocer", "base:icon-greengrocer", "hairdresser", "base:icon-scissors_and_comb", "hardware", "base:icon-hardware", "jewelry", "base:icon-ring", "kiosk", "base:icon-newspaper", "laundry", "base:icon-laundry", "newsagent", "base:icon-newsagent", "optician", "base:icon-optician", "outdoor", "base:icon-outdoor", "shoes", "base:icon-shoes", "sports", "base:icon-sports", "stationery", "base:icon-stationery", "toys", "base:icon-rocking_horse", "travel_agency", "base:icon-travel_agent", "video", "base:icon-video", "base:icon-shop"], "text-font": ["noto_sans_bold"]}, "paint": {"icon-opacity": ["interpolate", ["linear"], ["zoom"], 16, 0, 17, 0.4], "text-opacity": ["interpolate", ["linear"], ["zoom"], 18, 0, 18.5, 0.4], "icon-color": "rgb(102,102,102)", "text-color": "rgb(102,102,102)", "text-halo-color": "rgb(255,255,255)", "text-halo-width": 0.5, "text-halo-blur": 0.5}, "source": "versatiles-shortbread"}, {"id": "poi-man_made", "type": "symbol", "source-layer": "pois", "filter": ["to-boolean", ["get", "man_made"]], "minzoom": 14, "layout": {"icon-size": ["interpolate", ["exponential", 2], ["zoom"], 16, 0.4, 20, 1.2], "text-field": ["get", "name"], "text-size": ["interpolate", ["linear"], ["zoom"], 17, 0, 18, 11, 22, 13], "text-anchor": "top", "text-offset": [0, 1.2], "text-optional": true, "symbol-placement": "point", "icon-optional": true, "icon-image": ["match", ["get", "man_made"], "lighthouse", "base:icon-lighthouse", "wastewater_plant", "base:icon-wastewater", "water_well", "base:icon-drinking_water", "water_works", "base:icon-waterworks", "surveillance", "base:icon-surveillance", "tower", "base:icon-observation_tower", "watermill", "base:icon-watermill", "windmill", "base:icon-windmill", "base:transport-information"], "text-font": ["noto_sans_bold"]}, "paint": {"icon-opacity": ["interpolate", ["linear"], ["zoom"], 16, 0, 17, 0.4], "text-opacity": ["interpolate", ["linear"], ["zoom"], 18, 0, 18.5, 0.4], "icon-color": "rgb(102,102,102)", "text-color": "rgb(102,102,102)", "text-halo-color": "rgb(255,255,255)", "text-halo-width": 0.5, "text-halo-blur": 0.5}, "source": "versatiles-shortbread"}, {"id": "poi-historic", "type": "symbol", "source-layer": "pois", "filter": ["to-boolean", ["get", "historic"]], "minzoom": 14, "layout": {"icon-size": ["interpolate", ["exponential", 2], ["zoom"], 16, 0.4, 20, 1.2], "text-field": ["get", "name"], "text-size": ["interpolate", ["linear"], ["zoom"], 17, 0, 18, 11, 22, 13], "text-anchor": "top", "text-offset": [0, 1.2], "text-optional": true, "symbol-placement": "point", "icon-optional": true, "icon-image": ["match", ["get", "historic"], "castle", "base:icon-castle", "monument", "base:icon-monument", "wayside_shrine", "base:icon-shrine", "base:icon-historic"], "text-font": ["noto_sans_bold"]}, "paint": {"icon-opacity": ["interpolate", ["linear"], ["zoom"], 16, 0, 17, 0.4], "text-opacity": ["interpolate", ["linear"], ["zoom"], 18, 0, 18.5, 0.4], "icon-color": "rgb(102,102,102)", "text-color": "rgb(102,102,102)", "text-halo-color": "rgb(255,255,255)", "text-halo-width": 0.5, "text-halo-blur": 0.5}, "source": "versatiles-shortbread"}, {"id": "poi-emergency", "type": "symbol", "source-layer": "pois", "filter": ["to-boolean", ["get", "emergency"]], "minzoom": 14, "layout": {"icon-size": ["interpolate", ["exponential", 2], ["zoom"], 16, 0.4, 20, 1.2], "text-field": ["get", "name"], "text-size": ["interpolate", ["linear"], ["zoom"], 17, 0, 18, 11, 22, 13], "text-anchor": "top", "text-offset": [0, 1.2], "text-optional": true, "symbol-placement": "point", "icon-optional": true, "icon-image": ["match", ["get", "emergency"], "defibrillator", "base:icon-defibrillator", "fire_hydrant", "base:icon-hydrant", "phone", "base:icon-emergency_phone", ""], "text-font": ["noto_sans_bold"]}, "paint": {"icon-opacity": ["interpolate", ["linear"], ["zoom"], 16, 0, 17, 0.4], "text-opacity": ["interpolate", ["linear"], ["zoom"], 18, 0, 18.5, 0.4], "icon-color": "rgb(102,102,102)", "text-color": "rgb(102,102,102)", "text-halo-color": "rgb(255,255,255)", "text-halo-width": 0.5, "text-halo-blur": 0.5}, "source": "versatiles-shortbread"}, {"id": "poi-highway", "type": "symbol", "source-layer": "pois", "filter": ["to-boolean", ["get", "highway"]], "minzoom": 14, "layout": {"icon-size": ["interpolate", ["exponential", 2], ["zoom"], 16, 0.4, 20, 1.2], "text-field": ["get", "name"], "text-size": ["interpolate", ["linear"], ["zoom"], 17, 0, 18, 11, 22, 13], "text-anchor": "top", "text-offset": [0, 1.2], "text-optional": true, "symbol-placement": "point", "icon-optional": true, "icon-image": ["match", ["get", "highway"], "emergency_access_point", "base:icon-emergency_access", ""], "text-font": ["noto_sans_bold"]}, "paint": {"icon-opacity": ["interpolate", ["linear"], ["zoom"], 16, 0, 17, 0.4], "text-opacity": ["interpolate", ["linear"], ["zoom"], 18, 0, 18.5, 0.4], "icon-color": "rgb(102,102,102)", "text-color": "rgb(102,102,102)", "text-halo-color": "rgb(255,255,255)", "text-halo-width": 0.5, "text-halo-blur": 0.5}, "source": "versatiles-shortbread"}, {"id": "poi-office", "type": "symbol", "source-layer": "pois", "filter": ["to-boolean", ["get", "office"]], "minzoom": 14, "layout": {"icon-size": ["interpolate", ["exponential", 2], ["zoom"], 16, 0.4, 20, 1.2], "text-field": ["get", "name"], "text-size": ["interpolate", ["linear"], ["zoom"], 17, 0, 18, 11, 22, 13], "text-anchor": "top", "text-offset": [0, 1.2], "text-optional": true, "symbol-placement": "point", "icon-optional": true, "icon-image": ["match", ["get", "office"], "diplomatic", "base:icon-embassy", ""], "text-font": ["noto_sans_bold"]}, "paint": {"icon-opacity": ["interpolate", ["linear"], ["zoom"], 16, 0, 17, 0.4], "text-opacity": ["interpolate", ["linear"], ["zoom"], 18, 0, 18.5, 0.4], "icon-color": "rgb(102,102,102)", "text-color": "rgb(102,102,102)", "text-halo-color": "rgb(255,255,255)", "text-halo-width": 0.5, "text-halo-blur": 0.5}, "source": "versatiles-shortbread"}, {"id": "slot-below-labels", "type": "background", "paint": {"background-opacity": 0}}, {"id": "label-motorway-exit", "type": "symbol", "source-layer": "street_labels_points", "filter": ["==", ["get", "kind"], "motorway_junction"], "layout": {"text-field": "{ref}", "symbol-placement": "point", "text-anchor": "center", "text-size": ["interpolate", ["linear"], ["zoom"], 14, 9, 18, 11], "text-font": ["noto_sans_regular"]}, "paint": {"icon-color": "rgb(51,51,68)", "text-color": "rgb(51,51,68)", "text-halo-color": "rgba(255,255,255,0.8)", "text-halo-width": 1, "text-halo-blur": 1}, "minzoom": 14, "source": "versatiles-shortbread"}, {"id": "label-motorway-shield", "type": "symbol", "source-layer": "street_labels", "filter": ["==", ["get", "kind"], "motorway"], "layout": {"text-field": "{ref}", "symbol-placement": "line", "text-anchor": "center", "text-size": ["interpolate", ["linear"], ["zoom"], 14, 10, 18, 12, 20, 16], "text-font": ["noto_sans_bold"]}, "paint": {"icon-color": "rgb(255,255,255)", "text-color": "rgb(255,255,255)", "text-halo-color": "rgb(255,204,136)", "text-halo-width": 0.1, "text-halo-blur": 1}, "minzoom": 14, "source": "versatiles-shortbread"}, {"id": "label-street-track", "type": "symbol", "source-layer": "street_labels", "filter": ["==", ["get", "kind"], "track"], "layout": {"text-field": ["get", "name"], "symbol-placement": "line", "text-anchor": "center", "text-size": ["interpolate", ["linear"], ["zoom"], 12, 10, 15, 13], "text-font": ["noto_sans_regular"]}, "paint": {"icon-color": "rgb(51,51,68)", "text-color": "rgb(51,51,68)", "text-halo-color": "rgba(255,255,255,0.8)", "text-halo-width": 2, "text-halo-blur": 1}, "minzoom": 12, "source": "versatiles-shortbread"}, {"id": "label-street-pedestrian", "type": "symbol", "source-layer": "street_labels", "filter": ["==", ["get", "kind"], "pedestrian"], "layout": {"text-field": ["get", "name"], "symbol-placement": "line", "text-anchor": "center", "text-size": ["interpolate", ["linear"], ["zoom"], 12, 10, 15, 13], "text-font": ["noto_sans_regular"]}, "paint": {"icon-color": "rgb(51,51,68)", "text-color": "rgb(51,51,68)", "text-halo-color": "rgba(255,255,255,0.8)", "text-halo-width": 2, "text-halo-blur": 1}, "minzoom": 12, "source": "versatiles-shortbread"}, {"id": "label-street-livingstreet", "type": "symbol", "source-layer": "street_labels", "filter": ["==", ["get", "kind"], "living_street"], "layout": {"text-field": ["get", "name"], "symbol-placement": "line", "text-anchor": "center", "text-size": ["interpolate", ["linear"], ["zoom"], 12, 10, 15, 13], "text-font": ["noto_sans_regular"]}, "paint": {"icon-color": "rgb(51,51,68)", "text-color": "rgb(51,51,68)", "text-halo-color": "rgba(255,255,255,0.8)", "text-halo-width": 2, "text-halo-blur": 1}, "minzoom": 12, "source": "versatiles-shortbread"}, {"id": "label-street-residential", "type": "symbol", "source-layer": "street_labels", "filter": ["==", ["get", "kind"], "residential"], "layout": {"text-field": ["get", "name"], "symbol-placement": "line", "text-anchor": "center", "text-size": ["interpolate", ["linear"], ["zoom"], 12, 10, 15, 13], "text-font": ["noto_sans_regular"]}, "paint": {"icon-color": "rgb(51,51,68)", "text-color": "rgb(51,51,68)", "text-halo-color": "rgba(255,255,255,0.8)", "text-halo-width": 2, "text-halo-blur": 1}, "minzoom": 12, "source": "versatiles-shortbread"}, {"id": "label-street-unclassified", "type": "symbol", "source-layer": "street_labels", "filter": ["==", ["get", "kind"], "unclassified"], "layout": {"text-field": ["get", "name"], "symbol-placement": "line", "text-anchor": "center", "text-size": ["interpolate", ["linear"], ["zoom"], 12, 10, 15, 13], "text-font": ["noto_sans_regular"]}, "paint": {"icon-color": "rgb(51,51,68)", "text-color": "rgb(51,51,68)", "text-halo-color": "rgba(255,255,255,0.8)", "text-halo-width": 2, "text-halo-blur": 1}, "minzoom": 12, "source": "versatiles-shortbread"}, {"id": "label-street-tertiary", "type": "symbol", "source-layer": "street_labels", "filter": ["==", ["get", "kind"], "tertiary"], "layout": {"text-field": ["get", "name"], "symbol-placement": "line", "text-anchor": "center", "text-size": ["interpolate", ["linear"], ["zoom"], 12, 10, 15, 13], "text-font": ["noto_sans_regular"]}, "paint": {"icon-color": "rgb(51,51,68)", "text-color": "rgb(51,51,68)", "text-halo-color": "rgba(255,255,255,0.8)", "text-halo-width": 2, "text-halo-blur": 1}, "minzoom": 12, "source": "versatiles-shortbread"}, {"id": "label-street-secondary", "type": "symbol", "source-layer": "street_labels", "filter": ["==", ["get", "kind"], "secondary"], "layout": {"text-field": ["get", "name"], "symbol-placement": "line", "text-anchor": "center", "text-size": ["interpolate", ["linear"], ["zoom"], 12, 10, 15, 13], "text-font": ["noto_sans_regular"]}, "paint": {"icon-color": "rgb(51,51,68)", "text-color": "rgb(51,51,68)", "text-halo-color": "rgba(255,255,255,0.8)", "text-halo-width": 2, "text-halo-blur": 1}, "minzoom": 12, "source": "versatiles-shortbread"}, {"id": "label-street-primary", "type": "symbol", "source-layer": "street_labels", "filter": ["==", ["get", "kind"], "primary"], "layout": {"text-field": ["get", "name"], "symbol-placement": "line", "text-anchor": "center", "text-size": ["interpolate", ["linear"], ["zoom"], 12, 10, 15, 13], "text-font": ["noto_sans_regular"]}, "paint": {"icon-color": "rgb(51,51,68)", "text-color": "rgb(51,51,68)", "text-halo-color": "rgba(255,255,255,0.8)", "text-halo-width": 2, "text-halo-blur": 1}, "minzoom": 12, "source": "versatiles-shortbread"}, {"id": "label-street-trunk", "type": "symbol", "source-layer": "street_labels", "filter": ["==", ["get", "kind"], "trunk"], "layout": {"text-field": ["get", "name"], "symbol-placement": "line", "text-anchor": "center", "text-size": ["interpolate", ["linear"], ["zoom"], 12, 10, 15, 13], "text-font": ["noto_sans_regular"]}, "paint": {"icon-color": "rgb(51,51,68)", "text-color": "rgb(51,51,68)", "text-halo-color": "rgba(255,255,255,0.8)", "text-halo-width": 2, "text-halo-blur": 1}, "minzoom": 12, "source": "versatiles-shortbread"}, {"id": "label-street-pedestrian-zone", "type": "symbol", "source-layer": "streets_polygons_labels", "filter": ["==", ["get", "kind"], "pedestrian"], "layout": {"text-field": ["get", "name"], "symbol-placement": "point", "text-anchor": "center", "text-size": ["interpolate", ["linear"], ["zoom"], 14, 10, 17, 12], "text-font": ["noto_sans_regular"]}, "paint": {"icon-color": "rgb(51,51,68)", "text-color": "rgb(51,51,68)", "text-halo-color": "rgba(255,255,255,0.8)", "text-halo-width": 2, "text-halo-blur": 1}, "minzoom": 14, "source": "versatiles-shortbread"}, {"id": "label-water-area-major", "type": "symbol", "source-layer": "water_polygons_labels", "filter": ["all", ["!=", ["get", "kind"], "glacier"], [">", ["get", "way_area"], 1000000000]], "layout": {"text-field": ["get", "name"], "text-anchor": "center", "symbol-placement": "point", "text-size": ["interpolate", ["linear"], ["zoom"], 4, 11, 10, 14], "text-font": ["noto_sans_regular"]}, "paint": {"icon-color": "rgb(78,91,104)", "text-color": "rgb(78,91,104)", "text-halo-color": "rgba(255,255,255,0.8)", "icon-opacity": ["interpolate", ["linear"], ["zoom"], 4, 0, 5, 1], "text-opacity": ["interpolate", ["linear"], ["zoom"], 4, 0, 5, 1], "text-halo-width": 2, "text-halo-blur": 1}, "minzoom": 4, "source": "versatiles-shortbread"}, {"id": "label-water-area-large", "type": "symbol", "source-layer": "water_polygons_labels", "filter": ["all", ["!=", ["get", "kind"], "glacier"], [">", ["get", "way_area"], 10000000], ["<=", ["get", "way_area"], 1000000000]], "layout": {"text-field": ["get", "name"], "text-anchor": "center", "symbol-placement": "point", "text-size": ["interpolate", ["linear"], ["zoom"], 8, 10, 12, 13], "text-font": ["noto_sans_regular"]}, "paint": {"icon-color": "rgb(78,91,104)", "text-color": "rgb(78,91,104)", "text-halo-color": "rgba(255,255,255,0.8)", "icon-opacity": ["interpolate", ["linear"], ["zoom"], 8, 0, 9, 1], "text-opacity": ["interpolate", ["linear"], ["zoom"], 8, 0, 9, 1], "text-halo-width": 2, "text-halo-blur": 1}, "minzoom": 8, "source": "versatiles-shortbread"}, {"id": "label-water-area-medium", "type": "symbol", "source-layer": "water_polygons_labels", "filter": ["all", ["!=", ["get", "kind"], "glacier"], [">", ["get", "way_area"], 100000], ["<=", ["get", "way_area"], 10000000]], "layout": {"text-field": ["get", "name"], "text-anchor": "center", "symbol-placement": "point", "text-size": ["interpolate", ["linear"], ["zoom"], 11, 10, 14, 12], "text-font": ["noto_sans_regular"]}, "paint": {"icon-color": "rgb(78,91,104)", "text-color": "rgb(78,91,104)", "text-halo-color": "rgba(255,255,255,0.8)", "icon-opacity": ["interpolate", ["linear"], ["zoom"], 11, 0, 12, 1], "text-opacity": ["interpolate", ["linear"], ["zoom"], 11, 0, 12, 1], "text-halo-width": 2, "text-halo-blur": 1}, "minzoom": 11, "source": "versatiles-shortbread"}, {"id": "label-water-area-small", "type": "symbol", "source-layer": "water_polygons_labels", "filter": ["all", ["!=", ["get", "kind"], "glacier"], [">", ["get", "way_area"], 0], ["<=", ["get", "way_area"], 100000]], "layout": {"text-field": ["get", "name"], "text-anchor": "center", "symbol-placement": "point", "text-size": ["interpolate", ["linear"], ["zoom"], 14, 10, 17, 12], "text-font": ["noto_sans_regular"]}, "paint": {"icon-color": "rgb(78,91,104)", "text-color": "rgb(78,91,104)", "text-halo-color": "rgba(255,255,255,0.8)", "icon-opacity": ["interpolate", ["linear"], ["zoom"], 15, 0, 16, 1], "text-opacity": ["interpolate", ["linear"], ["zoom"], 15, 0, 16, 1], "text-halo-width": 2, "text-halo-blur": 1}, "minzoom": 14, "source": "versatiles-shortbread"}, {"id": "label-water-river", "type": "symbol", "source-layer": "water_lines_labels", "filter": ["in", ["get", "kind"], ["literal", ["river", "canal"]]], "layout": {"text-field": ["get", "name"], "text-anchor": "center", "symbol-placement": "line", "text-size": ["interpolate", ["linear"], ["zoom"], 12, 10, 15, 12], "text-font": ["noto_sans_regular"]}, "paint": {"icon-color": "rgb(78,91,104)", "text-color": "rgb(78,91,104)", "text-halo-color": "rgba(255,255,255,0.8)", "text-halo-width": 2, "text-halo-blur": 1}, "minzoom": 12, "source": "versatiles-shortbread"}, {"id": "label-water-stream", "type": "symbol", "source-layer": "water_lines_labels", "filter": ["in", ["get", "kind"], ["literal", ["stream", "ditch"]]], "layout": {"text-field": ["get", "name"], "text-anchor": "center", "symbol-placement": "line", "text-size": ["interpolate", ["linear"], ["zoom"], 14, 9, 17, 11], "text-font": ["noto_sans_regular"]}, "paint": {"icon-color": "rgb(78,91,104)", "text-color": "rgb(78,91,104)", "text-halo-color": "rgba(255,255,255,0.8)", "text-halo-width": 2, "text-halo-blur": 1}, "minzoom": 14, "source": "versatiles-shortbread"}, {"id": "symbol-transit-bus", "type": "symbol", "source-layer": "public_transport", "filter": ["==", ["get", "kind"], "bus_stop"], "layout": {"text-field": ["get", "name"], "symbol-placement": "point", "icon-keep-upright": true, "text-size": 10, "icon-anchor": "bottom", "text-anchor": "top", "icon-image": "base:icon-bus", "icon-size": ["interpolate", ["linear"], ["zoom"], 16, 0.5, 18, 1], "text-font": ["noto_sans_regular"]}, "paint": {"icon-opacity": 0.7, "icon-color": "rgb(102,98,106)", "text-color": "rgb(102,98,106)", "text-halo-color": "rgba(255,255,255,0.8)", "text-halo-width": 2, "text-halo-blur": 1}, "minzoom": 16, "source": "versatiles-shortbread"}, {"id": "symbol-transit-tram", "type": "symbol", "source-layer": "public_transport", "filter": ["==", ["get", "kind"], "tram_stop"], "layout": {"text-field": ["get", "name"], "symbol-placement": "point", "icon-keep-upright": true, "text-size": 10, "icon-anchor": "bottom", "text-anchor": "top", "icon-image": "base:transport-tram", "icon-size": ["interpolate", ["linear"], ["zoom"], 15, 0.5, 17, 1], "text-font": ["noto_sans_regular"]}, "paint": {"icon-opacity": 0.7, "icon-color": "rgb(102,98,106)", "text-color": "rgb(102,98,106)", "text-halo-color": "rgba(255,255,255,0.8)", "text-halo-width": 2, "text-halo-blur": 1}, "minzoom": 15, "source": "versatiles-shortbread"}, {"id": "symbol-transit-station", "type": "symbol", "source-layer": "public_transport", "filter": ["in", ["get", "kind"], ["literal", ["station", "halt"]]], "layout": {"text-field": ["get", "name"], "symbol-placement": "point", "icon-keep-upright": true, "text-size": 10, "icon-anchor": "bottom", "text-anchor": "top", "icon-image": "base:icon-rail", "icon-size": ["interpolate", ["linear"], ["zoom"], 13, 0.5, 15, 1], "text-font": ["noto_sans_regular"]}, "paint": {"icon-opacity": 0.7, "icon-color": "rgb(102,98,106)", "text-color": "rgb(102,98,106)", "text-halo-color": "rgba(255,255,255,0.8)", "text-halo-width": 2, "text-halo-blur": 1}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "symbol-transit-airfield", "type": "symbol", "source-layer": "public_transport", "filter": ["all", ["==", ["get", "kind"], "aerodrome"], ["!", ["has", "iata"]]], "layout": {"text-field": ["get", "name"], "symbol-placement": "point", "icon-keep-upright": true, "text-size": 10, "icon-anchor": "bottom", "text-anchor": "top", "icon-image": "base:icon-airfield", "icon-size": ["interpolate", ["linear"], ["zoom"], 13, 0.5, 15, 1], "text-font": ["noto_sans_regular"]}, "paint": {"icon-opacity": 0.7, "icon-color": "rgb(102,98,106)", "text-color": "rgb(102,98,106)", "text-halo-color": "rgba(255,255,255,0.8)", "text-halo-width": 2, "text-halo-blur": 1}, "minzoom": 13, "source": "versatiles-shortbread"}, {"id": "symbol-transit-airport", "type": "symbol", "source-layer": "public_transport", "filter": ["all", ["==", ["get", "kind"], "aerodrome"], ["has", "iata"]], "layout": {"text-field": ["get", "name"], "symbol-placement": "point", "icon-keep-upright": true, "text-size": 10, "icon-anchor": "bottom", "text-anchor": "top", "icon-image": "base:icon-airport", "icon-size": ["interpolate", ["linear"], ["zoom"], 12, 0.5, 14, 1], "text-font": ["noto_sans_regular"]}, "paint": {"icon-opacity": 0.7, "icon-color": "rgb(102,98,106)", "text-color": "rgb(102,98,106)", "text-halo-color": "rgba(255,255,255,0.8)", "text-halo-width": 2, "text-halo-blur": 1}, "minzoom": 12, "source": "versatiles-shortbread"}, {"id": "label-place-neighbourhood", "type": "symbol", "source-layer": "place_labels", "filter": ["==", ["get", "kind"], "neighbourhood"], "layout": {"text-field": ["get", "name"], "symbol-sort-key": ["-", ["to-number", ["get", "population"], 0]], "text-size": 12, "text-font": ["noto_sans_regular"], "text-transform": "uppercase"}, "paint": {"icon-color": "rgb(51,51,68)", "text-color": "rgb(51,51,68)", "text-halo-color": "rgba(255,255,255,0.8)", "icon-opacity": ["interpolate", ["linear"], ["zoom"], 17, 1, 18, 0], "text-opacity": ["interpolate", ["linear"], ["zoom"], 17, 1, 18, 0], "text-halo-width": 2, "text-halo-blur": 1}, "minzoom": 14, "maxzoom": 18, "source": "versatiles-shortbread"}, {"id": "label-place-quarter", "type": "symbol", "source-layer": "place_labels", "filter": ["==", ["get", "kind"], "quarter"], "layout": {"text-field": ["get", "name"], "symbol-sort-key": ["-", ["to-number", ["get", "population"], 0]], "text-size": 13, "text-font": ["noto_sans_regular"], "text-transform": "uppercase"}, "paint": {"icon-color": "rgb(51,51,68)", "text-color": "rgb(51,51,68)", "text-halo-color": "rgba(255,255,255,0.8)", "icon-opacity": ["interpolate", ["linear"], ["zoom"], 15, 1, 16, 0], "text-opacity": ["interpolate", ["linear"], ["zoom"], 15, 1, 16, 0], "text-halo-width": 2, "text-halo-blur": 1}, "minzoom": 13, "maxzoom": 16, "source": "versatiles-shortbread"}, {"id": "label-place-suburb", "type": "symbol", "source-layer": "place_labels", "filter": ["==", ["get", "kind"], "suburb"], "layout": {"text-field": ["get", "name"], "symbol-sort-key": ["-", ["to-number", ["get", "population"], 0]], "text-size": ["interpolate", ["linear"], ["zoom"], 11, 11, 13, 14], "text-font": ["noto_sans_regular"], "text-transform": "uppercase"}, "paint": {"icon-color": "rgb(51,51,68)", "text-color": "rgb(51,51,68)", "text-halo-color": "rgba(255,255,255,0.8)", "icon-opacity": ["interpolate", ["linear"], ["zoom"], 14, 1, 15, 0], "text-opacity": ["interpolate", ["linear"], ["zoom"], 14, 1, 15, 0], "text-halo-width": 2, "text-halo-blur": 1}, "minzoom": 10, "maxzoom": 15, "source": "versatiles-shortbread"}, {"id": "label-place-hamlet", "type": "symbol", "source-layer": "place_labels", "filter": ["==", ["get", "kind"], "hamlet"], "layout": {"text-field": ["get", "name"], "symbol-sort-key": ["-", ["to-number", ["get", "population"], 0]], "text-size": ["interpolate", ["linear"], ["zoom"], 10, 11, 12, 14], "text-font": ["noto_sans_regular"], "text-transform": "uppercase"}, "paint": {"icon-color": "rgb(51,51,68)", "text-color": "rgb(51,51,68)", "text-halo-color": "rgba(255,255,255,0.8)", "icon-opacity": ["interpolate", ["linear"], ["zoom"], 17, 1, 18, 0], "text-opacity": ["interpolate", ["linear"], ["zoom"], 17, 1, 18, 0], "text-halo-width": 2, "text-halo-blur": 1}, "minzoom": 13, "maxzoom": 18, "source": "versatiles-shortbread"}, {"id": "label-place-village", "type": "symbol", "source-layer": "place_labels", "filter": ["==", ["get", "kind"], "village"], "layout": {"text-field": ["get", "name"], "symbol-sort-key": ["-", ["to-number", ["get", "population"], 0]], "text-size": ["interpolate", ["linear"], ["zoom"], 9, 11, 12, 14], "text-font": ["noto_sans_regular"]}, "paint": {"icon-color": "rgb(51,51,68)", "text-color": "rgb(51,51,68)", "text-halo-color": "rgba(255,255,255,0.8)", "icon-opacity": ["interpolate", ["linear"], ["zoom"], 15, 1, 16, 0], "text-opacity": ["interpolate", ["linear"], ["zoom"], 15, 1, 16, 0], "text-halo-width": 2, "text-halo-blur": 1}, "minzoom": 10, "maxzoom": 16, "source": "versatiles-shortbread"}, {"id": "label-place-town", "type": "symbol", "source-layer": "place_labels", "filter": ["==", ["get", "kind"], "town"], "layout": {"text-field": ["get", "name"], "symbol-sort-key": ["-", ["to-number", ["get", "population"], 0]], "text-size": ["interpolate", ["linear"], ["zoom"], 8, 11, 12, 14], "text-font": ["noto_sans_regular"]}, "paint": {"icon-color": "rgb(51,51,68)", "text-color": "rgb(51,51,68)", "text-halo-color": "rgba(255,255,255,0.8)", "icon-opacity": ["interpolate", ["linear"], ["zoom"], 14, 1, 15, 0], "text-opacity": ["interpolate", ["linear"], ["zoom"], 14, 1, 15, 0], "text-halo-width": 2, "text-halo-blur": 1}, "minzoom": 7, "maxzoom": 15, "source": "versatiles-shortbread"}, {"id": "label-boundary-state", "type": "symbol", "source-layer": "boundary_labels", "filter": ["in", ["get", "admin_level"], ["literal", [4, "4"]]], "layout": {"text-field": ["get", "name"], "text-anchor": "top", "text-offset": [0, 0.2], "text-padding": 0, "text-optional": true, "text-size": ["interpolate", ["linear"], ["zoom"], 5, 8, 8, 12], "text-font": ["noto_sans_regular"], "text-transform": "uppercase"}, "paint": {"icon-color": "rgb(61,61,77)", "text-color": "rgb(61,61,77)", "text-halo-color": "rgba(255,255,255,0.8)", "text-halo-width": 2, "text-halo-blur": 1}, "minzoom": 3, "maxzoom": 10, "source": "versatiles-shortbread"}, {"id": "label-place-city", "type": "symbol", "source-layer": "place_labels", "filter": ["==", ["get", "kind"], "city"], "layout": {"text-field": ["get", "name"], "symbol-sort-key": ["-", ["to-number", ["get", "population"], 0]], "text-size": ["interpolate", ["linear"], ["zoom"], 7, 11, 10, 14], "text-font": ["noto_sans_regular"]}, "paint": {"icon-color": "rgb(51,51,68)", "text-color": "rgb(51,51,68)", "text-halo-color": "rgba(255,255,255,0.8)", "icon-opacity": ["interpolate", ["linear"], ["zoom"], 13, 1, 14, 0], "text-opacity": ["interpolate", ["linear"], ["zoom"], 13, 1, 14, 0], "text-halo-width": 2, "text-halo-blur": 1}, "minzoom": 6, "maxzoom": 14, "source": "versatiles-shortbread"}, {"id": "label-place-statecapital", "type": "symbol", "source-layer": "place_labels", "filter": ["==", ["get", "kind"], "state_capital"], "layout": {"text-field": ["get", "name"], "symbol-sort-key": ["-", ["to-number", ["get", "population"], 0]], "text-size": ["interpolate", ["linear"], ["zoom"], 6, 11, 10, 15], "text-font": ["noto_sans_regular"]}, "paint": {"icon-color": "rgb(51,51,68)", "text-color": "rgb(51,51,68)", "text-halo-color": "rgba(255,255,255,0.8)", "icon-opacity": ["interpolate", ["linear"], ["zoom"], 13, 1, 14, 0], "text-opacity": ["interpolate", ["linear"], ["zoom"], 13, 1, 14, 0], "text-halo-width": 2, "text-halo-blur": 1}, "minzoom": 4, "maxzoom": 14, "source": "versatiles-shortbread"}, {"id": "label-place-capital", "type": "symbol", "source-layer": "place_labels", "filter": ["==", ["get", "kind"], "capital"], "layout": {"text-field": ["get", "name"], "symbol-sort-key": ["-", ["to-number", ["get", "population"], 0]], "text-size": ["interpolate", ["linear"], ["zoom"], 5, 12, 10, 16], "text-font": ["noto_sans_regular"]}, "paint": {"icon-color": "rgb(51,51,68)", "text-color": "rgb(51,51,68)", "text-halo-color": "rgba(255,255,255,0.8)", "icon-opacity": ["interpolate", ["linear"], ["zoom"], 11, 1, 12, 0], "text-opacity": ["interpolate", ["linear"], ["zoom"], 11, 1, 12, 0], "text-halo-width": 2, "text-halo-blur": 1}, "minzoom": 4, "maxzoom": 12, "source": "versatiles-shortbread"}, {"id": "label-boundary-country-small", "type": "symbol", "source-layer": "boundary_labels", "filter": ["all", ["in", ["get", "admin_level"], ["literal", [2, "2"]]], ["<=", ["get", "way_area"], 10000000]], "layout": {"text-field": ["get", "name"], "text-anchor": "top", "text-offset": [0, 0.2], "text-padding": 0, "text-optional": true, "text-size": ["interpolate", ["linear"], ["zoom"], 4, 8, 5, 11], "text-font": ["noto_sans_regular"], "text-transform": "uppercase"}, "paint": {"icon-color": "rgb(51,51,68)", "text-color": "rgb(51,51,68)", "text-halo-color": "rgba(255,255,255,0.8)", "text-halo-width": 2, "text-halo-blur": 1}, "minzoom": 4, "maxzoom": 10, "source": "versatiles-shortbread"}, {"id": "label-boundary-country-medium", "type": "symbol", "source-layer": "boundary_labels", "filter": ["all", ["in", ["get", "admin_level"], ["literal", [2, "2"]]], ["<", ["get", "way_area"], 90000000], [">", ["get", "way_area"], 10000000]], "layout": {"text-field": ["get", "name"], "text-anchor": "top", "text-offset": [0, 0.2], "text-padding": 0, "text-optional": true, "text-size": ["interpolate", ["linear"], ["zoom"], 3, 8, 5, 12], "text-font": ["noto_sans_regular"], "text-transform": "uppercase"}, "paint": {"icon-color": "rgb(51,51,68)", "text-color": "rgb(51,51,68)", "text-halo-color": "rgba(255,255,255,0.8)", "text-halo-width": 2, "text-halo-blur": 1}, "minzoom": 2, "maxzoom": 10, "source": "versatiles-shortbread"}, {"id": "label-boundary-country-large", "type": "symbol", "source-layer": "boundary_labels", "filter": ["all", ["in", ["get", "admin_level"], ["literal", [2, "2"]]], [">=", ["get", "way_area"], 90000000]], "layout": {"text-field": ["get", "name"], "text-anchor": "top", "text-offset": [0, 0.2], "text-padding": 0, "text-optional": true, "text-size": ["interpolate", ["linear"], ["zoom"], 2, 8, 5, 13], "text-font": ["noto_sans_regular"], "text-transform": "uppercase"}, "paint": {"icon-color": "rgb(51,51,68)", "text-color": "rgb(51,51,68)", "text-halo-color": "rgba(255,255,255,0.8)", "text-halo-width": 2, "text-halo-blur": 1}, "minzoom": 2, "maxzoom": 9, "source": "versatiles-shortbread"}], "sky": {"sky-color": "rgb(191,217,242)", "horizon-color": "rgb(255,255,255)", "fog-color": "rgb(255,255,255)", "sky-horizon-blend": 0.8, "horizon-fog-blend": 0.8, "fog-ground-blend": 0.5, "atmosphere-blend": 0}, "projection": {"type": "globe"}}
\ No newline at end of file
diff --git a/ios-vectorstyle/Tests/MapConductorVectorStyleTests/VectorStyleRulesTests.swift b/ios-vectorstyle/Tests/MapConductorVectorStyleTests/VectorStyleRulesTests.swift
new file mode 100644
index 0000000..4df3a71
--- /dev/null
+++ b/ios-vectorstyle/Tests/MapConductorVectorStyleTests/VectorStyleRulesTests.swift
@@ -0,0 +1,141 @@
+import MapConductorCore
+import XCTest
+
+@testable import MapConductorVectorStyle
+
+/**
+ The whole chain: Rust compiler, C ABI, Swift.
+
+ What is being checked is not the compiler -- that has its own tests and
+ golden files in Rust -- but that this binding carries the answer across
+ unchanged. The numbers asserted here are the ones
+ `crates/mvt-style/tests/golden/versatiles-road-emphasis.json` records, so a
+ binding that quietly loses or mangles something fails rather than producing
+ a slightly different map.
+
+ The same assertions exist in `VectorStyleRulesTest` on Android and in
+ `bindings/js-style/test/compile.test.mjs`.
+ */
+final class VectorStyleRulesTests: XCTestCase {
+ private lazy var style: String = {
+ guard
+ let url = Bundle.module.url(forResource: "versatiles-colorful", withExtension: "json"),
+ let text = try? String(contentsOf: url, encoding: .utf8)
+ else {
+ XCTFail("the fixture is missing")
+ return ""
+ }
+ return text
+ }()
+
+ private let roadEmphasis = """
+ {
+ "schemaVersion": 1,
+ "rules": [
+ { "selector": "all", "patch": { "color": "#000000" } },
+ { "selector": { "role": "label" }, "patch": { "visible": false } },
+ { "selector": { "role": "road-casing" }, "patch": { "color": "#303030" } },
+ { "selector": { "role": "road" }, "patch": { "color": "#ffffff", "widthScale": 1.3 } }
+ ]
+ }
+ """
+
+ func testAdjustsARealStyleAndSaysWhatItDid() throws {
+ let compiled = try VectorStyleRules.compile(styleJSON: style, rulesJSON: roadEmphasis)
+
+ XCTAssertEqual(compiled.affects, .both)
+ XCTAssertTrue(compiled.patchable, "this style has a background already")
+ // The same counts the Rust golden records, reached through the C ABI.
+ XCTAssertTrue(
+ compiled.diagnostics.contains { $0.contains("schema: shortbread") },
+ compiled.diagnostics.joined(separator: "\n")
+ )
+ XCTAssertTrue(
+ compiled.diagnostics.contains { $0.contains("rule 3 matched 77 layers") },
+ compiled.diagnostics.joined(separator: "\n")
+ )
+
+ XCTAssertGreaterThan(compiled.mutations.count, 400)
+ // Every mutation carries what was there before, or nothing could be
+ // taken back when the rules change.
+ for mutation in compiled.mutations.prefix(50) {
+ XCTAssertNotNil(mutation.reversed())
+ }
+
+ let parsed = try JSONSerialization.jsonObject(with: Data(compiled.styleJSON.utf8))
+ let layers = (parsed as? [String: Any])?["layers"] as? [[String: Any]]
+ XCTAssertEqual(layers?.count, 280, "no layer was added or lost")
+ }
+
+ func testLeavesAloneWhatNoRuleNamed() throws {
+ let compiled = try VectorStyleRules.compile(
+ styleJSON: style,
+ rulesJSON: ##"{"schemaVersion":1,"rules":[{"selector":{"role":"water"},"patch":{"color":"#000"}}]}"##
+ )
+ let before = try JSONSerialization.jsonObject(with: Data(style.utf8)) as? [String: Any]
+ let after =
+ try JSONSerialization.jsonObject(with: Data(compiled.styleJSON.utf8)) as? [String: Any]
+ XCTAssertEqual(
+ (before?["sources"] as? NSDictionary), (after?["sources"] as? NSDictionary)
+ )
+ XCTAssertEqual(before?["name"] as? String, after?["name"] as? String)
+ }
+
+ func testDescribesWhatIsInAStyle() throws {
+ let layers = try VectorStyleRules.describe(styleJSON: style)
+ XCTAssertEqual(layers.count, 280)
+ let casing = try XCTUnwrap(
+ layers.first { $0.id.hasSuffix(":outline") && $0.roles.contains("road") })
+ XCTAssertEqual(casing.roles, ["road", "road-casing"])
+ XCTAssertEqual(casing.evidence.first, "shortbread:streets")
+ }
+
+ /// A rule set written for a newer build must be refused loudly, not
+ /// applied in part.
+ func testRefusesARulesDocumentItDoesNotUnderstand() {
+ XCTAssertThrowsError(
+ try VectorStyleRules.compile(
+ styleJSON: style, rulesJSON: #"{"schemaVersion":99,"rules":[]}"#)
+ ) { error in
+ XCTAssertTrue(
+ (error as? VectorStyleError)?.message.contains("schemaVersion") == true,
+ "\(error)"
+ )
+ }
+ }
+
+ func testRefusesAStyleItCannotRead() {
+ XCTAssertThrowsError(
+ try VectorStyleRules.compile(
+ styleJSON: "{ not json", rulesJSON: #"{"schemaVersion":1,"rules":[]}"#)
+ )
+ XCTAssertThrowsError(try VectorStyleRules.describe(styleJSON: "{ not json"))
+ }
+
+ func testReportsTheRulesVersionItWasBuiltWith() {
+ XCTAssertEqual(VectorStyleRules.schemaVersion, 1)
+ }
+
+ /// The mutations have to survive the trip through core's parser with
+ /// their values intact -- a colour that lost its quotes is a colour no
+ /// renderer can read back.
+ func testMutationsArriveAsCoreTypesWithTheirValues() throws {
+ let compiled = try VectorStyleRules.compile(
+ styleJSON: style,
+ rulesJSON:
+ ##"{"schemaVersion":1,"rules":[{"selector":{"layerId":"background"},"patch":{"color":"#010203"}}]}"##
+ )
+ let single = try XCTUnwrap(compiled.mutations.first)
+ guard case let .setPaint(layerId, key, value, previous) = single else {
+ return XCTFail("expected a paint mutation, got \(single)")
+ }
+ XCTAssertEqual(compiled.mutations.count, 1)
+ XCTAssertEqual(layerId, "background")
+ XCTAssertEqual(key, "background-color")
+ XCTAssertEqual(value, "\"#010203\"")
+ // The document says `rgb(248,244,240)`; what comes back is the same
+ // colour written the one way every renderer can read -- which this
+ // provider's MapLibre adapter is the reason for.
+ XCTAssertEqual(previous, "\"#f8f4f0\"")
+ }
+}
diff --git a/ios-vectorstyle/Tests/MapConductorVectorStyleTests/VectorStyleTests.swift b/ios-vectorstyle/Tests/MapConductorVectorStyleTests/VectorStyleTests.swift
new file mode 100644
index 0000000..a48f100
--- /dev/null
+++ b/ios-vectorstyle/Tests/MapConductorVectorStyleTests/VectorStyleTests.swift
@@ -0,0 +1,372 @@
+import MapConductorCore
+import UIKit
+import XCTest
+
+@testable import MapConductorVectorStyle
+
+/**
+ What ``VectorStyle`` does to a map, against a map that is only a recorder.
+
+ No provider is involved on purpose. What is being checked is the contract
+ every provider will be held to -- which route a backend's capabilities put
+ it on, that changing the rules does not reload anything, and that disposing
+ leaves nothing behind -- and a fake host is the only way to check all of
+ those without three real maps.
+
+ Kept in step with `VectorStyleTest` on Android.
+ */
+final class VectorStyleTests: XCTestCase {
+ private let style = """
+ {"version":8,"name":"test",
+ "sources":{"s":{"type":"vector","tiles":["https://x/{z}/{x}/{y}"],"attribution":"© Someone"}},
+ "layers":[
+ {"id":"bg","type":"background","paint":{"background-color":"#f8f4f0"}},
+ {"id":"water","type":"fill","source":"s","source-layer":"water","paint":{"fill-color":"#a0c8f0"}},
+ {"id":"roads","type":"line","source":"s","source-layer":"transportation",
+ "paint":{"line-color":"#888","line-width":2}},
+ {"id":"labels","type":"symbol","source":"s","source-layer":"place","paint":{"text-color":"#333"}}
+ ]}
+ """
+
+ private func rules(_ color: String) -> StyleRules {
+ StyleRules.parse(
+ #"{"schemaVersion":1,"rules":[{"selector":{"role":"road"},"patch":{"color":"\#(color)"}}]}"#
+ )
+ }
+
+ // MARK: - the fake map
+
+ private final class Host: MapStyleHost, @unchecked Sendable {
+ let registry = MutableMapServiceRegistry()
+ private let lock = NSLock()
+ private var _shown: [String?] = []
+ private var _applied: [[StyleMutation]] = []
+ private var _diagnostics: [String] = []
+ private var _credited: [String] = []
+ var rasters: [String] = []
+ var styleLoadedAttached = false
+ let vectorStyleUrl: String?
+
+ /// Set to fail the mutations whose key matches, as iOS MapLibre does.
+ var refuse: @Sendable (StyleMutation) -> Bool = { _ in false }
+
+ var shown: [String?] { lock.lock(); defer { lock.unlock() }; return _shown }
+ var applied: [[StyleMutation]] { lock.lock(); defer { lock.unlock() }; return _applied }
+ var diagnostics: [String] { lock.lock(); defer { lock.unlock() }; return _diagnostics }
+ var credited: [String] { lock.lock(); defer { lock.unlock() }; return _credited }
+
+ private final class Vector: VectorStyleSupport {
+ let host: Host
+ init(_ host: Host) { self.host = host }
+ func showStyle(url: String, attributionRules: [AttributionRule]) {
+ host.lock.lock()
+ host._shown.append(url)
+ host._credited.append(contentsOf: attributionRules.map(\.attribution))
+ host.lock.unlock()
+ }
+
+ func clearStyle() {
+ host.lock.lock()
+ host._shown.append(nil)
+ host.lock.unlock()
+ }
+ }
+
+ lazy var store = StyleMutationStore { [weak self] list in
+ guard let self else { return [] }
+ lock.lock()
+ _applied.append(list)
+ lock.unlock()
+ return list.filter(refuse)
+ }
+
+ init(vector: Bool, mutations: Bool, vectorStyleUrl: String? = nil) {
+ self.vectorStyleUrl = vectorStyleUrl
+ if vector { registry.put(VectorStyleSupportKey.self, Vector(self)) }
+ if mutations { registry.put(VectorStyleMutationSupportKey.self, store) }
+ }
+
+ var serviceRegistry: MapServiceRegistry { registry }
+ var tileServer: LocalTileServer { TileServerRegistry.get() }
+
+ func onStyleLoaded(_: @escaping () -> Void) -> MapStyleInstallation {
+ styleLoadedAttached = true
+ return MapStyleInstallation(dispose: { [weak self] in self?.styleLoadedAttached = false })
+ }
+
+ func upsertRaster(_ state: RasterLayerState) { rasters.append(state.id) }
+ func removeRaster(id: String) { rasters.removeAll { $0 == id } }
+
+ func report(_ diagnostics: [String]) {
+ lock.lock()
+ _diagnostics.append(contentsOf: diagnostics)
+ lock.unlock()
+ }
+ }
+
+ private final class Rasteriser: VectorStyleRasteriser, @unchecked Sendable {
+ var installed: [String] = []
+ var restyled: [String] = []
+ var disposals = 0
+
+ private final class Work: VectorStyleRasterisation {
+ let owner: Rasteriser
+ init(_ owner: Rasteriser) { self.owner = owner }
+ func restyle(styleJSON: String, affects _: StyleAffects) {
+ owner.restyled.append(styleJSON)
+ }
+
+ func dispose() { owner.disposals += 1 }
+ }
+
+ func install(host _: MapStyleHost, styleJSON: String, affects _: StyleAffects)
+ -> VectorStyleRasterisation
+ {
+ installed.append(styleJSON)
+ return Work(self)
+ }
+ }
+
+ /// The work happens on a queue; wait for it to land rather than guess.
+ private func settle(_ host: Host, until: () -> Bool) {
+ let deadline = Date().addingTimeInterval(10)
+ while !until(), Date() < deadline {
+ RunLoop.current.run(until: Date().addingTimeInterval(0.01))
+ }
+ XCTAssertTrue(until(), "nothing happened: \(host.diagnostics)")
+ }
+
+ // MARK: - the routes
+
+ /**
+ A map that reads styles and takes patches gets the author's document and
+ the differences on top -- not the adjusted document. The next rule
+ change produces differences against the original, and a map holding an
+ already-adjusted document would read them as something else.
+ */
+ func testAMapThatReadsStylesIsGivenTheOriginalAndTheDifferences() {
+ let host = Host(vector: true, mutations: true)
+ let installation = VectorStyle(document: .text(style), rules: rules("#ffffff"))
+ .install(host: host)
+ defer { installation.dispose() }
+
+ settle(host) { !host.applied.isEmpty }
+
+ XCTAssertEqual(host.shown.count, 1)
+ XCTAssertTrue(host.shown[0]?.contains("/docs/") == true, "\(host.shown)")
+ let paints = host.applied.flatMap { $0 }
+ XCTAssertEqual(paints.count, 1)
+ guard case let .setPaint(layerId, _, value, previous) = paints[0] else {
+ return XCTFail("expected a paint mutation")
+ }
+ XCTAssertEqual(layerId, "roads")
+ XCTAssertEqual(value, "\"#ffffff\"")
+ // The document says `#888`; what comes back is the same colour in
+ // the one form every renderer can read -- which this platform's
+ // MapLibre adapter is the reason for.
+ XCTAssertEqual(previous, "\"#888888\"")
+ XCTAssertTrue(host.credited.contains("© Someone"), "the style's credit was not carried")
+ }
+
+ /**
+ The point of the whole design: moving a slider sends new differences and
+ the map is never handed a document again.
+ */
+ func testChangingTheRulesDoesNotReloadAnything() {
+ let host = Host(vector: true, mutations: true)
+ let installation = VectorStyle(document: .text(style), rules: rules("#ffffff"))
+ .install(host: host)
+ defer { installation.dispose() }
+ settle(host) { !host.applied.isEmpty }
+ let servedOnce = host.shown.count
+
+ let again = VectorStyle(document: .text(style), rules: rules("#ff0000"))
+ XCTAssertTrue(installation.update(again), "an adjustment-only change was refused")
+ settle(host) { host.applied.count > 1 }
+
+ XCTAssertEqual(host.shown.count, servedOnce, "the map was handed a document again")
+ guard case let .setPaint(_, _, value, previous) = host.applied.last!.first! else {
+ return XCTFail("expected a paint mutation")
+ }
+ XCTAssertEqual(value, "\"#ff0000\"")
+ XCTAssertEqual(previous, "\"#888888\"", "still relative to the author's style")
+ }
+
+ /// A different document is a different style, so the view has to redo it.
+ func testADifferentDocumentIsNotAnUpdate() {
+ let host = Host(vector: true, mutations: true)
+ let installation = VectorStyle(document: .text(style), rules: rules("#ffffff"))
+ .install(host: host)
+ defer { installation.dispose() }
+ settle(host) { !host.applied.isEmpty }
+ XCTAssertFalse(installation.update(VectorStyle(document: .url("https://example.test/s.json"))))
+ }
+
+ /**
+ A map that reads styles but cannot be patched has only one way to show
+ an adjustment: the adjusted document. Every rule change reloads, which
+ is why the capability is worth implementing.
+ */
+ func testAMapThatCannotBePatchedIsGivenTheAdjustedDocument() throws {
+ let host = Host(vector: true, mutations: false)
+ let installation = VectorStyle(document: .text(style), rules: rules("#ffffff"))
+ .install(host: host)
+ defer { installation.dispose() }
+ settle(host) { !host.shown.isEmpty }
+
+ XCTAssertTrue(host.applied.isEmpty)
+ let body = try XCTUnwrap(fetchServed(XCTUnwrap(host.shown[0])))
+ XCTAssertTrue(body.contains("#ffffff"), "the original was served instead")
+
+ let firstURL = try XCTUnwrap(host.shown[0])
+ XCTAssertTrue(installation.update(VectorStyle(document: .text(style), rules: rules("#ff0000"))))
+ settle(host) { host.shown.count == 2 }
+ let updatedURL = try XCTUnwrap(host.shown[1])
+ XCTAssertNotEqual(firstURL, updatedURL, "a URL-keyed provider would keep the previous style")
+ let updatedBody = try XCTUnwrap(fetchServed(updatedURL))
+ XCTAssertTrue(updatedBody.contains("#ff0000"), "the updated URL must serve the new rules")
+ }
+
+ func testAMapThatCannotReadStylesGoesThroughTheRasteriser() {
+ let host = Host(vector: false, mutations: false)
+ let rasteriser = Rasteriser()
+ let installation = VectorStyle(
+ document: .text(style), rules: rules("#ffffff"), rasteriser: rasteriser
+ ).install(host: host)
+
+ settle(host) { !rasteriser.installed.isEmpty }
+ XCTAssertTrue(rasteriser.installed[0].contains("#ffffff"))
+ XCTAssertTrue(host.shown.isEmpty, "the map was handed a style it cannot read")
+
+ // A rule change re-rasterises; it does not install again.
+ XCTAssertTrue(
+ installation.update(
+ VectorStyle(document: .text(style), rules: rules("#ff0000"), rasteriser: rasteriser)))
+ settle(host) { !rasteriser.restyled.isEmpty }
+ XCTAssertEqual(rasteriser.installed.count, 1)
+ XCTAssertTrue(rasteriser.restyled[0].contains("#ff0000"))
+
+ installation.dispose()
+ XCTAssertEqual(rasteriser.disposals, 1)
+ }
+
+ /**
+ A backend that can do neither is told so. Left to itself it would show
+ an ordinary map and the adjustments would simply not be there, which is
+ the failure this whole design is built against.
+ */
+ func testAMapWithNeitherIsToldSoRatherThanLeftBlank() {
+ let host = Host(vector: false, mutations: false)
+ let installation = VectorStyle(document: .text(style)).install(host: host)
+ defer { installation.dispose() }
+ settle(host) { host.diagnostics.contains { $0.contains("no rasteriser") } }
+ }
+
+ /// Adjusting "whatever is showing" needs something to be showing.
+ func testAdjustingTheCurrentDesignNeedsAStyleToAdjust() {
+ let host = Host(vector: true, mutations: true, vectorStyleUrl: nil)
+ let installation = VectorStyle(rules: rules("#ffffff")).install(host: host)
+ defer { installation.dispose() }
+ settle(host) { host.diagnostics.contains { $0.contains("not drawing a vector style") } }
+ XCTAssertTrue(host.applied.isEmpty)
+ }
+
+ // MARK: - what the map would not take
+
+ func testWhatTheMapRefusesIsReportedAndTheRestStays() {
+ let host = Host(vector: true, mutations: true)
+ host.refuse = { _ in true }
+ let installation = VectorStyle(document: .text(style), rules: rules("#ffffff"))
+ .install(host: host)
+ defer { installation.dispose() }
+ settle(host) { host.diagnostics.contains { $0.contains("were not applied") } }
+ // .report is the default, so the document was not re-served.
+ XCTAssertEqual(host.shown.count, 1)
+ }
+
+ func testReloadPolicyHandsOverTheAdjustedDocumentInstead() throws {
+ let host = Host(vector: true, mutations: true)
+ host.refuse = { _ in true }
+ let installation = VectorStyle(
+ document: .text(style), rules: rules("#ffffff"), onUnsupported: .reload
+ ).install(host: host)
+ defer { installation.dispose() }
+ settle(host) { host.shown.count >= 2 }
+ let body = try XCTUnwrap(fetchServed(XCTUnwrap(host.shown.last!)))
+ XCTAssertTrue(body.contains("#ffffff"), "the adjusted document was not served")
+ }
+
+ // MARK: - taking it off again
+
+ func testDisposingLeavesTheMapAsItWasFound() throws {
+ let host = Host(vector: true, mutations: true)
+ let installation = VectorStyle(document: .text(style), rules: rules("#ffffff"))
+ .install(host: host)
+ settle(host) { !host.applied.isEmpty }
+ let served = try XCTUnwrap(host.shown[0])
+
+ installation.dispose()
+
+ XCTAssertTrue(host.shown.contains(nil), "the style was not cleared")
+ XCTAssertTrue(host.store.applied.isEmpty, "the adjustments were not taken off")
+ XCTAssertNil(fetchServed(served), "the document is still being served")
+ XCTAssertFalse(host.styleLoadedAttached, "the style-loaded hook is still attached")
+ }
+
+ /// Disposing twice is what a view does on a fast remount.
+ func testDisposingTwiceIsHarmless() {
+ let host = Host(vector: true, mutations: true)
+ let installation = VectorStyle(document: .text(style), rules: rules("#ffffff"))
+ .install(host: host)
+ settle(host) { !host.applied.isEmpty }
+ installation.dispose()
+ installation.dispose()
+ XCTAssertEqual(host.shown.filter { $0 == nil }.count, 1)
+ }
+
+ func testTheDiagnosticsAlwaysSayWhatTheRulesMatched() {
+ let host = Host(vector: true, mutations: true)
+ let heard = Heard()
+ let installation = VectorStyle(
+ document: .text(style), rules: rules("#ffffff"),
+ onDiagnostics: { heard.add($0) }
+ ).install(host: host)
+ defer { installation.dispose() }
+ settle(host) { heard.contains("matched 1 layers") }
+ }
+
+ private final class Heard: @unchecked Sendable {
+ private let lock = NSLock()
+ private var lines: [String] = []
+ func add(_ more: [String]) {
+ lock.lock()
+ lines.append(contentsOf: more)
+ lock.unlock()
+ }
+
+ func contains(_ text: String) -> Bool {
+ lock.lock()
+ defer { lock.unlock() }
+ return lines.contains { $0.contains(text) }
+ }
+ }
+
+ // MARK: - helpers
+
+ private func fetchServed(_ url: String) -> String? {
+ guard let target = URL(string: url) else { return nil }
+ var answer: String?
+ let done = DispatchSemaphore(value: 0)
+ URLSession.shared.dataTask(with: URLRequest(url: target, timeoutInterval: 5)) {
+ data, response, _ in
+ defer { done.signal() }
+ guard
+ let status = (response as? HTTPURLResponse)?.statusCode, (200..<300).contains(status),
+ let data
+ else { return }
+ answer = String(data: data, encoding: .utf8)
+ }.resume()
+ _ = done.wait(timeout: .now() + 10)
+ return answer
+ }
+}
diff --git a/ios-vectorstyle/api/MapConductorVectorStyle.api.swift b/ios-vectorstyle/api/MapConductorVectorStyle.api.swift
new file mode 100644
index 0000000..fbeb6ba
--- /dev/null
+++ b/ios-vectorstyle/api/MapConductorVectorStyle.api.swift
@@ -0,0 +1,187 @@
+import CMvtStyle
+import Foundation
+import MapConductorCore
+import Swift
+import UIKit
+import _Concurrency
+import _StringProcessing
+import _SwiftConcurrencyShims
+public struct StyleRules : Swift::Equatable, Swift::Sendable {
+ public let json: Swift::String
+ public static let none: MapConductorVectorStyle::StyleRules
+ public static func parse(_ json: Swift::String) -> MapConductorVectorStyle::StyleRules
+ public static func build(_ block: (MapConductorVectorStyle::StyleRulesBuilder) -> Swift::Void) -> MapConductorVectorStyle::StyleRules
+ public static func == (a: MapConductorVectorStyle::StyleRules, b: MapConductorVectorStyle::StyleRules) -> Swift::Bool
+}
+public enum LayerRole {
+ public static let background: Swift::String
+ public static let water: Swift::String
+ public static let waterway: Swift::String
+ public static let land: Swift::String
+ public static let landuse: Swift::String
+ public static let park: Swift::String
+ public static let building: Swift::String
+ public static let road: Swift::String
+ public static let roadCasing: Swift::String
+ public static let rail: Swift::String
+ public static let transit: Swift::String
+ public static let boundary: Swift::String
+ public static let aeroway: Swift::String
+ public static let label: Swift::String
+ public static let poi: Swift::String
+}
+public enum StyleLayerKind : Swift::String, Swift::Sendable {
+ case background
+ case fill
+ case line
+ case circle
+ case symbol
+ case other
+ public init?(rawValue: Swift::String)
+ public typealias RawValue = Swift::String
+ public var rawValue: Swift::String {
+ get
+ }
+}
+indirect public enum StyleSelector : Swift::Sendable {
+ case all
+ case role(Swift::String)
+ case layerId(Swift::String)
+ case sourceLayer(Swift::String)
+ case kind(MapConductorVectorStyle::StyleLayerKind)
+ case anyOf([MapConductorVectorStyle::StyleSelector])
+ case allOf([MapConductorVectorStyle::StyleSelector])
+ case not(MapConductorVectorStyle::StyleSelector)
+}
+@_hasMissingDesignatedInitializers final public class StyleRulesBuilder {
+ final public var schema: Swift::String?
+ final public func customSchema(_ roles: [Swift::String : [Swift::String]])
+ final public func all(_ patch: (MapConductorVectorStyle::StylePatchBuilder) -> Swift::Void)
+ final public func role(_ role: Swift::String, _ patch: (MapConductorVectorStyle::StylePatchBuilder) -> Swift::Void)
+ final public func layerId(_ glob: Swift::String, _ patch: (MapConductorVectorStyle::StylePatchBuilder) -> Swift::Void)
+ final public func sourceLayer(_ name: Swift::String, _ patch: (MapConductorVectorStyle::StylePatchBuilder) -> Swift::Void)
+ final public func kind(_ kind: MapConductorVectorStyle::StyleLayerKind, _ patch: (MapConductorVectorStyle::StylePatchBuilder) -> Swift::Void)
+ final public func `where`(_ selector: MapConductorVectorStyle::StyleSelector, _ patch: (MapConductorVectorStyle::StylePatchBuilder) -> Swift::Void)
+ @objc deinit
+}
+@_hasMissingDesignatedInitializers final public class StylePatchBuilder {
+ final public var visible: Swift::Bool? {
+ get
+ set
+ }
+ final public var color: UIKit::UIColor? {
+ get
+ set
+ }
+ final public var opacity: Swift::Double? {
+ get
+ set
+ }
+ final public var widthScale: Swift::Double? {
+ get
+ set
+ }
+ final public var minZoom: Swift::Double? {
+ get
+ set
+ }
+ final public var maxZoom: Swift::Double? {
+ get
+ set
+ }
+ final public func desaturate(_ amount: Swift::Double)
+ final public func darken(_ amount: Swift::Double)
+ final public func lighten(_ amount: Swift::Double)
+ final public func invertLightness()
+ final public func mix(_ target: UIKit::UIColor, amount: Swift::Double = 0.5)
+ final public func filter(_ json: Swift::String)
+ final public func property(_ key: Swift::String, _ valueJSON: Swift::String)
+ final public func propertyText(_ key: Swift::String, _ value: Swift::String)
+ @objc deinit
+}
+public struct VectorStyle : MapConductorCore::MapViewStyle {
+ public let document: MapConductorVectorStyle::VectorStyleSource
+ public let rules: MapConductorVectorStyle::StyleRules
+ public let rasteriser: (any MapConductorVectorStyle::VectorStyleRasteriser)?
+ public let onUnsupported: MapConductorVectorStyle::UnsupportedPolicy
+ public let onDiagnostics: (([Swift::String]) -> Swift::Void)?
+ public init(document: MapConductorVectorStyle::VectorStyleSource = .currentDesign, rules: MapConductorVectorStyle::StyleRules = .none, rasteriser: (any MapConductorVectorStyle::VectorStyleRasteriser)? = nil, onUnsupported: MapConductorVectorStyle::UnsupportedPolicy = .report, onDiagnostics: (([Swift::String]) -> Swift::Void)? = nil)
+ public var key: Swift::String {
+ get
+ }
+ public func install(host: any MapConductorCore::MapStyleHost) -> MapConductorCore::MapStyleInstallation
+}
+extension MapConductorVectorStyle::UnsupportedPolicy : Swift::Equatable {
+ public static func == (a: MapConductorVectorStyle::UnsupportedPolicy, b: MapConductorVectorStyle::UnsupportedPolicy) -> Swift::Bool
+}
+public enum VectorStyleRules {
+ public static var schemaVersion: Swift::Int {
+ get
+ }
+ public static func compile(styleJSON: Swift::String, rulesJSON: Swift::String) throws -> MapConductorVectorStyle::CompiledStyle
+ public static func describe(styleJSON: Swift::String) throws -> [MapConductorVectorStyle::StyleLayerInfo]
+}
+public struct CompiledStyle : Swift::Sendable {
+ public let styleJSON: Swift::String
+ public let mutations: [MapConductorCore::StyleMutation]
+ public let affects: MapConductorVectorStyle::StyleAffects
+ public let patchable: Swift::Bool
+ public let diagnostics: [Swift::String]
+}
+public enum StyleAffects : Swift::String, Swift::Sendable {
+ case none
+ case ground
+ case labels
+ case both
+ public init?(rawValue: Swift::String)
+ public typealias RawValue = Swift::String
+ public var rawValue: Swift::String {
+ get
+ }
+}
+public struct StyleLayerInfo : Swift::Sendable, Swift::Equatable {
+ public let id: Swift::String
+ public let kind: Swift::String
+ public let sourceLayer: Swift::String?
+ public let roles: [Swift::String]
+ public let evidence: [Swift::String]
+ public static func == (a: MapConductorVectorStyle::StyleLayerInfo, b: MapConductorVectorStyle::StyleLayerInfo) -> Swift::Bool
+}
+public enum VectorStyleError : Swift::Error, Swift::Equatable {
+ case unreadable(Swift::String)
+ public var message: Swift::String {
+ get
+ }
+ public static func == (a: MapConductorVectorStyle::VectorStyleError, b: MapConductorVectorStyle::VectorStyleError) -> Swift::Bool
+}
+public enum VectorStyleSource : Swift::Equatable, Swift::Sendable {
+ case text(Swift::String)
+ case url(Swift::String, headers: [Swift::String : Swift::String] = [:])
+ case currentDesign
+ public var key: Swift::String {
+ get
+ }
+ public static func == (a: MapConductorVectorStyle::VectorStyleSource, b: MapConductorVectorStyle::VectorStyleSource) -> Swift::Bool
+}
+public enum UnsupportedPolicy : Swift::Sendable {
+ case report
+ case reload
+ public func hash(into hasher: inout Swift::Hasher)
+ public var hashValue: Swift::Int {
+ get
+ }
+}
+public protocol VectorStyleRasteriser : AnyObject {
+ func install(host: any MapConductorCore::MapStyleHost, styleJSON: Swift::String, affects: MapConductorVectorStyle::StyleAffects) -> any MapConductorVectorStyle::VectorStyleRasterisation
+}
+public protocol VectorStyleRasterisation : AnyObject {
+ func restyle(styleJSON: Swift::String, affects: MapConductorVectorStyle::StyleAffects)
+ func dispose()
+}
+extension MapConductorVectorStyle::StyleLayerKind : Swift::Equatable {}
+extension MapConductorVectorStyle::StyleLayerKind : Swift::Hashable {}
+extension MapConductorVectorStyle::StyleLayerKind : Swift::RawRepresentable {}
+extension MapConductorVectorStyle::UnsupportedPolicy : Swift::Hashable {}
+extension MapConductorVectorStyle::StyleAffects : Swift::Equatable {}
+extension MapConductorVectorStyle::StyleAffects : Swift::Hashable {}
+extension MapConductorVectorStyle::StyleAffects : Swift::RawRepresentable {}
diff --git a/ios-vectortile/LICENSE b/ios-vectortile/LICENSE
new file mode 100644
index 0000000..4438287
--- /dev/null
+++ b/ios-vectortile/LICENSE
@@ -0,0 +1,201 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright 2026 Masashi Katsumata
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/ios-vectortile/MvtRender.xcframework/Info.plist b/ios-vectortile/MvtRender.xcframework/Info.plist
new file mode 100644
index 0000000..03666e4
--- /dev/null
+++ b/ios-vectortile/MvtRender.xcframework/Info.plist
@@ -0,0 +1,47 @@
+
+
+
+
+ AvailableLibraries
+
+
+ BinaryPath
+ libmvt_render_ffi.a
+ HeadersPath
+ Headers
+ LibraryIdentifier
+ ios-arm64
+ LibraryPath
+ libmvt_render_ffi.a
+ SupportedArchitectures
+
+ arm64
+
+ SupportedPlatform
+ ios
+
+
+ BinaryPath
+ libmvt_render_ffi.a
+ HeadersPath
+ Headers
+ LibraryIdentifier
+ ios-arm64-simulator
+ LibraryPath
+ libmvt_render_ffi.a
+ SupportedArchitectures
+
+ arm64
+
+ SupportedPlatform
+ ios
+ SupportedPlatformVariant
+ simulator
+
+
+ CFBundlePackageType
+ XFWK
+ XCFrameworkFormatVersion
+ 1.0
+
+
diff --git a/ios-vectortile/MvtRender.xcframework/ios-arm64-simulator/Headers/module.modulemap b/ios-vectortile/MvtRender.xcframework/ios-arm64-simulator/Headers/module.modulemap
new file mode 100644
index 0000000..185ca11
--- /dev/null
+++ b/ios-vectortile/MvtRender.xcframework/ios-arm64-simulator/Headers/module.modulemap
@@ -0,0 +1,4 @@
+module CMvtRender {
+ header "mvt_render.h"
+ export *
+}
diff --git a/ios-vectortile/MvtRender.xcframework/ios-arm64-simulator/Headers/mvt_render.h b/ios-vectortile/MvtRender.xcframework/ios-arm64-simulator/Headers/mvt_render.h
new file mode 100644
index 0000000..dfa1e20
--- /dev/null
+++ b/ios-vectortile/MvtRender.xcframework/ios-arm64-simulator/Headers/mvt_render.h
@@ -0,0 +1,244 @@
+/*
+ * C ABI for the MapConductor vector tile renderer.
+ *
+ * Rendering is split in two so no network I/O happens inside this library:
+ * `mvt_renderer_plan` says which source tiles are needed, the caller fetches
+ * them with its own networking stack, and `mvt_renderer_render` rasterises.
+ *
+ * Ownership: anything returned by pointer is Rust-allocated and must be
+ * released with the matching free function. Calling free() on it is undefined.
+ */
+#ifndef MVT_RENDER_H
+#define MVT_RENDER_H
+
+#include
+#include
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+typedef struct MvtRenderer MvtRenderer;
+
+#define MVT_OK 0
+// Swift's C importer drops these: it keeps MVT_OK, which is a bare 0, but not
+// macros whose body starts with a minus. MvtStatus in VectorTileRenderer.swift
+// restates them for the Swift side; keep the two in step.
+#define MVT_ERR_NULL_HANDLE (-1)
+#define MVT_ERR_BAD_ARGUMENT (-2)
+#define MVT_ERR_RENDER_FAILED (-3)
+#define MVT_ERR_PANIC (-4)
+
+// Scope to one native call on the current thread; restore the returned value.
+uint8_t mvt_set_thread_source_zoom_offset(uint8_t offset);
+
+/* Returns NULL on failure; *error_out then holds an owned message. */
+/// `display_tile_size` is how many points one tile covers where the host shows
+/// it -- not the pixel count a render is asked for (a label pass draws at twice
+/// the pixels, and a 256pt tile is still 256pt however many pixels it carries).
+/// It sets the size the style draws at and the zoom its expressions are read
+/// at. Pass 0 for the usual 512.
+MvtRenderer *mvt_renderer_new(const char *style_json,
+ uint32_t display_tile_size,
+ char **error_out);
+
+void mvt_renderer_free(MvtRenderer *handle);
+
+/* Replaces the style. Fetched vector tiles stay valid: only paint changes. */
+int mvt_renderer_set_style(MvtRenderer *handle,
+ const char *style_json,
+ char **error_out);
+
+/* JSON array of { sourceId, url, z, x, y, scale, offsetX, offsetY }. */
+char *mvt_renderer_plan(MvtRenderer *handle, uint8_t z, uint32_t x, uint32_t y);
+
+/* JSON array of layer `type` values that will not be drawn. */
+char *mvt_renderer_unsupported_layer_types(MvtRenderer *handle);
+
+/* JSON array of reasons the style may not render as intended. */
+char *mvt_renderer_diagnostics(MvtRenderer *handle);
+
+/*
+ * Rasterises one tile to PNG.
+ *
+ * `data` is every fetched source tile concatenated in plan order; `lengths`
+ * gives each tile's byte length, 0 marking one that failed to fetch. On success
+ * *out_ptr / *out_len receive a buffer to release with mvt_buffer_free.
+ */
+int mvt_renderer_render(MvtRenderer *handle,
+ uint8_t z,
+ uint32_t x,
+ uint32_t y,
+ uint32_t tile_size,
+ const uint8_t *data,
+ size_t data_len,
+ const uint32_t *lengths,
+ size_t lengths_len,
+ uint8_t **out_ptr,
+ size_t *out_len);
+
+/* JSON array of the credits the style's sources ask to be shown.
+ *
+ * The host must display these: a style is data under someone's licence. The
+ * text may contain HTML, because a credit is normally a link to the licence. */
+char *mvt_renderer_attributions(MvtRenderer *handle);
+
+/* The style's `glyphs` URL template, or NULL when it names none. */
+char *mvt_renderer_glyphs_url_template(MvtRenderer *handle);
+
+/* JSON array of glyph-range URLs this tile's labels need and the store has not
+ * got. Empty when the style names no template, or all of them are loaded.
+ * Tiles are passed exactly as for mvt_renderer_render. */
+char *mvt_renderer_needed_glyphs(MvtRenderer *handle,
+ uint8_t z,
+ uint32_t x,
+ uint32_t y,
+ const uint8_t *data,
+ size_t data_len,
+ const uint32_t *lengths,
+ size_t lengths_len);
+
+/* Features in the tile as a JSON array of objects {sourceId, layer, id,
+ * geometry, lon, lat, bounds:[w,s,e,n], properties:{...}}. Only the tile's
+ * own requests are read; a magnified ancestor reports its quadrant. Restrict
+ * with a source-layer name, a MapLibre filter expression (JSON) and/or a
+ * case-insensitive substring some string property must contain; each may be
+ * NULL. limit 0 = no limit. NULL when the filter is not JSON. Tiles are
+ * passed exactly as for mvt_renderer_render. Free with mvt_string_free. */
+char *mvt_renderer_query_features(MvtRenderer *handle,
+ uint8_t z,
+ uint32_t x,
+ uint32_t y,
+ const uint8_t *data,
+ size_t data_len,
+ const uint32_t *lengths,
+ size_t lengths_len,
+ const char *source_layer,
+ const char *filter_json,
+ const char *text,
+ uint32_t limit);
+
+/* Adds one fetched glyph range PBF. Returns the glyph count, or < 0. */
+int mvt_renderer_add_glyphs(MvtRenderer *handle, const uint8_t *pbf, size_t pbf_len);
+
+/* 1 when any glyph is loaded, 0 when none, < 0 on failure. */
+int mvt_renderer_has_glyphs(MvtRenderer *handle);
+
+/* JSON array of the sprite sheet's [json, png] URLs at this pixel ratio, or []
+ * when the style names no sprite. */
+char *mvt_renderer_sprite_urls(MvtRenderer *handle, uint32_t pixel_ratio);
+
+/* Adds the fetched sprite sheet. Returns the icon count, or < 0. */
+int mvt_renderer_add_sprite(MvtRenderer *handle,
+ const char *json,
+ const uint8_t *png,
+ size_t png_len);
+
+/* 1 when the style names a sprite the renderer has not been given yet. */
+int mvt_renderer_needs_sprite(MvtRenderer *handle);
+
+/* 1 when this tile must be drawn on the CPU because the style paints something
+ * the GPU path cannot -- today, a patterned fill. */
+int mvt_renderer_needs_cpu(MvtRenderer *handle,
+ uint8_t z,
+ const uint8_t *data,
+ size_t data_len,
+ const uint32_t *lengths,
+ size_t lengths_len);
+
+/*
+ * Rasterises the ground alone -- fills, lines and circles, no labels or icons.
+ *
+ * The half of a split layer that a GPU draws and that fonts arriving never
+ * invalidate. Output and ownership match mvt_renderer_render.
+ */
+int mvt_renderer_render_geometry(MvtRenderer *handle,
+ uint8_t z,
+ uint32_t x,
+ uint32_t y,
+ uint32_t tile_size,
+ const uint8_t *data,
+ size_t data_len,
+ const uint32_t *lengths,
+ size_t lengths_len,
+ uint8_t **out_ptr,
+ size_t *out_len);
+
+/*
+ * Draws labels and icons alone on a transparent ground, into a new
+ * PREMULTIPLIED RGBA buffer of tile_size * tile_size * 4 bytes.
+ *
+ * The other half of a split layer: redrawn on its own when a font arrives,
+ * leaving the ground untouched. *out_placed receives the label count; when it
+ * is 0 nothing is allocated, *out_ptr stays NULL, and the caller should serve
+ * one shared transparent tile. Encoding these pixels as straight alpha turns
+ * every halo grey. Release with mvt_buffer_free.
+ */
+int mvt_renderer_render_labels(MvtRenderer *handle,
+ uint8_t z,
+ uint32_t x,
+ uint32_t y,
+ uint32_t tile_size,
+ const uint8_t *data,
+ size_t data_len,
+ const uint32_t *lengths,
+ size_t lengths_len,
+ uint8_t **out_ptr,
+ size_t *out_len,
+ size_t *out_placed);
+
+/*
+ * Draws labels and icons over pixels the caller already has, in place.
+ *
+ * `rgba` must hold exactly tile_size * tile_size * 4 bytes and is read as
+ * STRAIGHT alpha, which is what a GPU readback is. Returns the label count,
+ * or < 0.
+ */
+int mvt_renderer_draw_labels(MvtRenderer *handle,
+ uint8_t z,
+ uint32_t x,
+ uint32_t y,
+ uint32_t tile_size,
+ uint8_t *rgba,
+ size_t rgba_len,
+ const uint8_t *data,
+ size_t data_len,
+ const uint32_t *lengths,
+ size_t lengths_len);
+
+/// Tessellates a tile into a packed float buffer for GPU drawing.
+///
+/// Layout, in floats:
+/// [0] extent in tile units
+/// [1] 1.0 if the style has a background colour
+/// [2..6] background rgba
+/// [6] batch count B
+/// [7..13] timings: decode, tessellate, filter compile, fill, line, spare
+/// [13..13+B*2] per batch: first vertex, vertex count
+/// [13+B*2..] vertices: x, y, r, g, b, a
+///
+/// out_len counts floats, not bytes. Release with mvt_floats_free.
+int32_t mvt_renderer_tessellate(struct MvtRenderer *handle,
+ uint8_t z,
+ uint32_t x,
+ uint32_t y,
+ uint32_t tile_size,
+ const uint8_t *data,
+ size_t data_len,
+ const uint32_t *lengths,
+ size_t lengths_len,
+ float **out_ptr,
+ size_t *out_len);
+
+
+/// Releases a float buffer returned by mvt_renderer_tessellate.
+void mvt_floats_free(float *ptr, size_t len);
+
+void mvt_buffer_free(uint8_t *ptr, size_t len);
+void mvt_string_free(char *text);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* MVT_RENDER_H */
diff --git a/ios-vectortile/MvtRender.xcframework/ios-arm64-simulator/libmvt_render_ffi.a b/ios-vectortile/MvtRender.xcframework/ios-arm64-simulator/libmvt_render_ffi.a
new file mode 100644
index 0000000..988e728
Binary files /dev/null and b/ios-vectortile/MvtRender.xcframework/ios-arm64-simulator/libmvt_render_ffi.a differ
diff --git a/ios-vectortile/MvtRender.xcframework/ios-arm64/Headers/module.modulemap b/ios-vectortile/MvtRender.xcframework/ios-arm64/Headers/module.modulemap
new file mode 100644
index 0000000..185ca11
--- /dev/null
+++ b/ios-vectortile/MvtRender.xcframework/ios-arm64/Headers/module.modulemap
@@ -0,0 +1,4 @@
+module CMvtRender {
+ header "mvt_render.h"
+ export *
+}
diff --git a/ios-vectortile/MvtRender.xcframework/ios-arm64/Headers/mvt_render.h b/ios-vectortile/MvtRender.xcframework/ios-arm64/Headers/mvt_render.h
new file mode 100644
index 0000000..dfa1e20
--- /dev/null
+++ b/ios-vectortile/MvtRender.xcframework/ios-arm64/Headers/mvt_render.h
@@ -0,0 +1,244 @@
+/*
+ * C ABI for the MapConductor vector tile renderer.
+ *
+ * Rendering is split in two so no network I/O happens inside this library:
+ * `mvt_renderer_plan` says which source tiles are needed, the caller fetches
+ * them with its own networking stack, and `mvt_renderer_render` rasterises.
+ *
+ * Ownership: anything returned by pointer is Rust-allocated and must be
+ * released with the matching free function. Calling free() on it is undefined.
+ */
+#ifndef MVT_RENDER_H
+#define MVT_RENDER_H
+
+#include
+#include
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+typedef struct MvtRenderer MvtRenderer;
+
+#define MVT_OK 0
+// Swift's C importer drops these: it keeps MVT_OK, which is a bare 0, but not
+// macros whose body starts with a minus. MvtStatus in VectorTileRenderer.swift
+// restates them for the Swift side; keep the two in step.
+#define MVT_ERR_NULL_HANDLE (-1)
+#define MVT_ERR_BAD_ARGUMENT (-2)
+#define MVT_ERR_RENDER_FAILED (-3)
+#define MVT_ERR_PANIC (-4)
+
+// Scope to one native call on the current thread; restore the returned value.
+uint8_t mvt_set_thread_source_zoom_offset(uint8_t offset);
+
+/* Returns NULL on failure; *error_out then holds an owned message. */
+/// `display_tile_size` is how many points one tile covers where the host shows
+/// it -- not the pixel count a render is asked for (a label pass draws at twice
+/// the pixels, and a 256pt tile is still 256pt however many pixels it carries).
+/// It sets the size the style draws at and the zoom its expressions are read
+/// at. Pass 0 for the usual 512.
+MvtRenderer *mvt_renderer_new(const char *style_json,
+ uint32_t display_tile_size,
+ char **error_out);
+
+void mvt_renderer_free(MvtRenderer *handle);
+
+/* Replaces the style. Fetched vector tiles stay valid: only paint changes. */
+int mvt_renderer_set_style(MvtRenderer *handle,
+ const char *style_json,
+ char **error_out);
+
+/* JSON array of { sourceId, url, z, x, y, scale, offsetX, offsetY }. */
+char *mvt_renderer_plan(MvtRenderer *handle, uint8_t z, uint32_t x, uint32_t y);
+
+/* JSON array of layer `type` values that will not be drawn. */
+char *mvt_renderer_unsupported_layer_types(MvtRenderer *handle);
+
+/* JSON array of reasons the style may not render as intended. */
+char *mvt_renderer_diagnostics(MvtRenderer *handle);
+
+/*
+ * Rasterises one tile to PNG.
+ *
+ * `data` is every fetched source tile concatenated in plan order; `lengths`
+ * gives each tile's byte length, 0 marking one that failed to fetch. On success
+ * *out_ptr / *out_len receive a buffer to release with mvt_buffer_free.
+ */
+int mvt_renderer_render(MvtRenderer *handle,
+ uint8_t z,
+ uint32_t x,
+ uint32_t y,
+ uint32_t tile_size,
+ const uint8_t *data,
+ size_t data_len,
+ const uint32_t *lengths,
+ size_t lengths_len,
+ uint8_t **out_ptr,
+ size_t *out_len);
+
+/* JSON array of the credits the style's sources ask to be shown.
+ *
+ * The host must display these: a style is data under someone's licence. The
+ * text may contain HTML, because a credit is normally a link to the licence. */
+char *mvt_renderer_attributions(MvtRenderer *handle);
+
+/* The style's `glyphs` URL template, or NULL when it names none. */
+char *mvt_renderer_glyphs_url_template(MvtRenderer *handle);
+
+/* JSON array of glyph-range URLs this tile's labels need and the store has not
+ * got. Empty when the style names no template, or all of them are loaded.
+ * Tiles are passed exactly as for mvt_renderer_render. */
+char *mvt_renderer_needed_glyphs(MvtRenderer *handle,
+ uint8_t z,
+ uint32_t x,
+ uint32_t y,
+ const uint8_t *data,
+ size_t data_len,
+ const uint32_t *lengths,
+ size_t lengths_len);
+
+/* Features in the tile as a JSON array of objects {sourceId, layer, id,
+ * geometry, lon, lat, bounds:[w,s,e,n], properties:{...}}. Only the tile's
+ * own requests are read; a magnified ancestor reports its quadrant. Restrict
+ * with a source-layer name, a MapLibre filter expression (JSON) and/or a
+ * case-insensitive substring some string property must contain; each may be
+ * NULL. limit 0 = no limit. NULL when the filter is not JSON. Tiles are
+ * passed exactly as for mvt_renderer_render. Free with mvt_string_free. */
+char *mvt_renderer_query_features(MvtRenderer *handle,
+ uint8_t z,
+ uint32_t x,
+ uint32_t y,
+ const uint8_t *data,
+ size_t data_len,
+ const uint32_t *lengths,
+ size_t lengths_len,
+ const char *source_layer,
+ const char *filter_json,
+ const char *text,
+ uint32_t limit);
+
+/* Adds one fetched glyph range PBF. Returns the glyph count, or < 0. */
+int mvt_renderer_add_glyphs(MvtRenderer *handle, const uint8_t *pbf, size_t pbf_len);
+
+/* 1 when any glyph is loaded, 0 when none, < 0 on failure. */
+int mvt_renderer_has_glyphs(MvtRenderer *handle);
+
+/* JSON array of the sprite sheet's [json, png] URLs at this pixel ratio, or []
+ * when the style names no sprite. */
+char *mvt_renderer_sprite_urls(MvtRenderer *handle, uint32_t pixel_ratio);
+
+/* Adds the fetched sprite sheet. Returns the icon count, or < 0. */
+int mvt_renderer_add_sprite(MvtRenderer *handle,
+ const char *json,
+ const uint8_t *png,
+ size_t png_len);
+
+/* 1 when the style names a sprite the renderer has not been given yet. */
+int mvt_renderer_needs_sprite(MvtRenderer *handle);
+
+/* 1 when this tile must be drawn on the CPU because the style paints something
+ * the GPU path cannot -- today, a patterned fill. */
+int mvt_renderer_needs_cpu(MvtRenderer *handle,
+ uint8_t z,
+ const uint8_t *data,
+ size_t data_len,
+ const uint32_t *lengths,
+ size_t lengths_len);
+
+/*
+ * Rasterises the ground alone -- fills, lines and circles, no labels or icons.
+ *
+ * The half of a split layer that a GPU draws and that fonts arriving never
+ * invalidate. Output and ownership match mvt_renderer_render.
+ */
+int mvt_renderer_render_geometry(MvtRenderer *handle,
+ uint8_t z,
+ uint32_t x,
+ uint32_t y,
+ uint32_t tile_size,
+ const uint8_t *data,
+ size_t data_len,
+ const uint32_t *lengths,
+ size_t lengths_len,
+ uint8_t **out_ptr,
+ size_t *out_len);
+
+/*
+ * Draws labels and icons alone on a transparent ground, into a new
+ * PREMULTIPLIED RGBA buffer of tile_size * tile_size * 4 bytes.
+ *
+ * The other half of a split layer: redrawn on its own when a font arrives,
+ * leaving the ground untouched. *out_placed receives the label count; when it
+ * is 0 nothing is allocated, *out_ptr stays NULL, and the caller should serve
+ * one shared transparent tile. Encoding these pixels as straight alpha turns
+ * every halo grey. Release with mvt_buffer_free.
+ */
+int mvt_renderer_render_labels(MvtRenderer *handle,
+ uint8_t z,
+ uint32_t x,
+ uint32_t y,
+ uint32_t tile_size,
+ const uint8_t *data,
+ size_t data_len,
+ const uint32_t *lengths,
+ size_t lengths_len,
+ uint8_t **out_ptr,
+ size_t *out_len,
+ size_t *out_placed);
+
+/*
+ * Draws labels and icons over pixels the caller already has, in place.
+ *
+ * `rgba` must hold exactly tile_size * tile_size * 4 bytes and is read as
+ * STRAIGHT alpha, which is what a GPU readback is. Returns the label count,
+ * or < 0.
+ */
+int mvt_renderer_draw_labels(MvtRenderer *handle,
+ uint8_t z,
+ uint32_t x,
+ uint32_t y,
+ uint32_t tile_size,
+ uint8_t *rgba,
+ size_t rgba_len,
+ const uint8_t *data,
+ size_t data_len,
+ const uint32_t *lengths,
+ size_t lengths_len);
+
+/// Tessellates a tile into a packed float buffer for GPU drawing.
+///
+/// Layout, in floats:
+/// [0] extent in tile units
+/// [1] 1.0 if the style has a background colour
+/// [2..6] background rgba
+/// [6] batch count B
+/// [7..13] timings: decode, tessellate, filter compile, fill, line, spare
+/// [13..13+B*2] per batch: first vertex, vertex count
+/// [13+B*2..] vertices: x, y, r, g, b, a
+///
+/// out_len counts floats, not bytes. Release with mvt_floats_free.
+int32_t mvt_renderer_tessellate(struct MvtRenderer *handle,
+ uint8_t z,
+ uint32_t x,
+ uint32_t y,
+ uint32_t tile_size,
+ const uint8_t *data,
+ size_t data_len,
+ const uint32_t *lengths,
+ size_t lengths_len,
+ float **out_ptr,
+ size_t *out_len);
+
+
+/// Releases a float buffer returned by mvt_renderer_tessellate.
+void mvt_floats_free(float *ptr, size_t len);
+
+void mvt_buffer_free(uint8_t *ptr, size_t len);
+void mvt_string_free(char *text);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* MVT_RENDER_H */
diff --git a/ios-vectortile/MvtRender.xcframework/ios-arm64/libmvt_render_ffi.a b/ios-vectortile/MvtRender.xcframework/ios-arm64/libmvt_render_ffi.a
new file mode 100644
index 0000000..d3ac952
Binary files /dev/null and b/ios-vectortile/MvtRender.xcframework/ios-arm64/libmvt_render_ffi.a differ
diff --git a/ios-vectortile/Package.swift b/ios-vectortile/Package.swift
new file mode 100644
index 0000000..b07712c
--- /dev/null
+++ b/ios-vectortile/Package.swift
@@ -0,0 +1,61 @@
+// swift-tools-version: 5.9
+import Foundation
+import PackageDescription
+
+let frameworkLibraryType: Product.Library.LibraryType? =
+ ProcessInfo.processInfo.environment["MAPCONDUCTOR_BUILD_XCFRAMEWORK"] == "1" ? .dynamic : nil
+let usingLocalCore = FileManager.default.fileExists(atPath: "../ios-sdk-core/Package.swift")
+let coreDependency: Package.Dependency = usingLocalCore
+ ? .package(path: "../ios-sdk-core")
+ : .package(url: "https://github.com/MapConductor/ios-sdk-core", from: "1.3.2")
+// `VectorStyleRasteriser` はあちらが宣言し、こちらが実装する
+// (`VectorTileRasteriser`)。依存の向きはこの一方向だけ。
+// `atPath:` の相対パスは**マニフェストの場所ではなくプロセスの CWD** から解決される。
+// サンプルアプリのように外から path 参照されるとそこが別の場所になり、兄弟ディレクトリ
+// を見つけられずリモートを取りに行って失敗する。`#filePath` から絶対パスで引く。
+let vectorTileRoot = URL(fileURLWithPath: #filePath).deletingLastPathComponent()
+let localStyleRoot = vectorTileRoot.deletingLastPathComponent().appendingPathComponent("ios-vectorstyle")
+let usingLocalStyle = FileManager.default.fileExists(
+ atPath: localStyleRoot.appendingPathComponent("Package.swift").path
+)
+let styleDependency: Package.Dependency = usingLocalStyle
+ ? .package(path: localStyleRoot.path)
+ : .package(url: "https://github.com/MapConductor/ios-vectorstyle", from: "1.0.0")
+
+let package = Package(
+ name: "mapconductor-vectortile",
+ platforms: [
+ // See ios-sdk-core/Package.swift's comment: "15.0" must not be used here.
+ .iOS("15.1"),
+ ],
+ products: [
+ .library(
+ name: "MapConductorVectorTile",
+ type: frameworkLibraryType,
+ targets: ["MapConductorVectorTile"]
+ ),
+ ],
+ dependencies: [
+ coreDependency,
+ styleDependency,
+ ],
+ targets: [
+ // The Rust renderer. Built and packaged by the mapconductor-vectortile
+ // repo (scripts/build-ios.sh, then xcodebuild -create-xcframework) and
+ // copied here by its scripts/sync-sdk.sh.
+ .binaryTarget(name: "MvtRender", path: "MvtRender.xcframework"),
+ .target(
+ name: "MapConductorVectorTile",
+ dependencies: [
+ .product(name: "MapConductorCore", package: "ios-sdk-core"),
+ .product(name: "MapConductorVectorStyle", package: "ios-vectorstyle"),
+ "MvtRender",
+ ],
+ ),
+ .testTarget(
+ name: "MapConductorVectorTileTests",
+ dependencies: ["MapConductorVectorTile"],
+ resources: [.process("Resources")],
+ ),
+ ]
+)
diff --git a/ios-vectortile/README.md b/ios-vectortile/README.md
new file mode 100644
index 0000000..e76e495
--- /dev/null
+++ b/ios-vectortile/README.md
@@ -0,0 +1,146 @@
+# MapConductor Vector Tile
+
+## Description
+
+MapConductor Vector Tile draws a MapLibre vector style on **any** map implementation, by rasterising the style to tiles on the device and serving them through the SDK's local tile server.
+
+The backend only ever sees an ordinary raster layer. That is what makes it work on MapKit, Google Maps, HERE, ArcGIS, TomTom and the rest — none of which can render a vector style themselves.
+
+The renderer is a Rust core shared with the Android and web modules, so the same style and the same source tile produce a byte-identical PNG on all three platforms.
+
+## Setup
+
+https://docs-ios.mapconductor.com/setup/
+
+------------------------------------------------------------------------
+
+## Usage
+
+Unlike the Android module there is no SwiftUI wrapper: you hold a `VectorTileProvider`, register its two tile routes, and point two `RasterLayerState`s at them.
+
+### Creating the provider
+
+```swift
+import MapConductorCore
+import MapConductorVectorTile
+
+let cacheDirectory = FileManager.default
+ .urls(for: .cachesDirectory, in: .userDomainMask)
+ .first?
+ .appendingPathComponent("vectortile")
+
+let provider = try VectorTileProvider(
+ styleJSON: styleText,
+ tileSize: 512,
+ assetCacheDirectory: cacheDirectory
+)
+```
+
+`styleJSON` is the text of a `style.json`. Fetching it is the caller's job — the provider does not decide how your app does network I/O.
+
+### Registering the two routes
+
+```swift
+let server = TileServerRegistry.get()
+server.register(routeId: groundRoute, provider: provider.groundTiles)
+server.register(routeId: labelRoute, provider: provider.labelTiles)
+
+ground = RasterLayerState(
+ source: .urlTemplate(
+ template: server.urlTemplate(
+ routeId: groundRoute, tileSize: 512, cacheKey: "static"
+ ),
+ tileSize: 512,
+ maxZoom: 22,
+ attributionRules: provider.attributions().map { AttributionRule(attribution: $0) }
+ ),
+ zIndex: 0
+)
+```
+
+### Replacing the labels when glyphs arrive
+
+Glyph ranges arrive after the tiles that need them — a range is a round trip and a low-zoom tile wants dozens — so tiles are drawn with whatever is loaded and the overlay is replaced once more lands.
+
+```swift
+provider.onGlyphsLoaded = { [weak self] in
+ Task { @MainActor in self?.handOverLabels() }
+}
+```
+
+Give the replacement overlay a new `cacheKey` so the map refetches it, and a `zIndex` above the ground. Only the overlay changes, so the worst a handover can cost is a moment of the labels being redrawn, never a bare map.
+
+### Tearing down
+
+```swift
+server.unregister(routeId: groundRoute)
+server.unregister(routeId: labelRoute)
+provider.close()
+```
+
+`ios-sdk/samples/MapConductorSampleApp` has the whole flow in `VectorTilePageViewModel`.
+
+------------------------------------------------------------------------
+
+## How it draws
+
+The style is served as **two** stacked raster layers, not one:
+
+- **`groundTiles`** — fills, lines and circles, the half no font arriving can change
+- **`labelTiles`** — labels and icons on a transparent ground, replaced on its own when glyph ranges land
+
+To the map and the user they read as one layer. The halves render in parallel, and a glyph range landing redraws only the transparent one.
+
+### Tile size
+
+Only 512 and 1024 are usable. MapKit's tile grid is built from 256, and a size that is not a power-of-two multiple of it draws a blank map — 768 was tried and drew nothing at all.
+
+Raising `tileSize` is not a lever for label size. Apparent size works out to the style's own value times `tileSize / 512`, but the number of tiles across the screen scales the same way, so doubling it is exactly a one-step zoom: the map gets bigger and the text keeps its size *relative to the map*. Text too small relative to the map is the style's `text-size`, and nothing here can change that.
+
+------------------------------------------------------------------------
+
+## API Reference
+
+### VectorTileProvider.init
+
+| Parameter | Type | Default | Description |
+|---|---|---|---|
+| `styleJSON` | `String` | — | The text of a `style.json` |
+| `tileSize` | `Int` | `512` | Output tile size in pixels |
+| `headers` | `[String: String]` | empty | Sent with every source tile request — auth tokens, API keys |
+| `cacheBytes` | `Int` | 16 MiB | In-memory tile cache budget |
+| `renderMode` | `RenderMode` | `.auto` | Which rasteriser to use |
+| `assetCacheDirectory` | `URL?` | `nil` | Where to keep glyphs, sprites and rendered tiles between launches; nil disables all three |
+| `renderedCacheBytes` | `Int` | 48 MiB | On-disk budget for rendered tiles |
+| `renderScale` | `Int?` | display scale | Pixels drawn per point of screen |
+| `fetchTile` | `((URL) -> Data?)?` | `nil` | Override how source tiles are fetched |
+
+Throws if the style cannot be parsed.
+
+### VectorTileProvider
+
+| Member | Description |
+|---|---|
+| `groundTiles` | Tile provider for fills, lines and circles |
+| `labelTiles` | Tile provider for labels and icons, on a transparent ground |
+| `onGlyphsLoaded` | Called when a glyph range lands and the label overlay is worth replacing |
+| `attributions()` | Credits the style's sources ask to be shown |
+| `diagnostics()` | Reasons the style may not render as intended |
+| `setStyle(_:)` | Replaces the paint without refetching: the geometry is unchanged, only the paint applied to it, so recolouring costs a re-rasterise and no network traffic. The caller still has to make the map drop its *raster* tiles — pass a new `cacheKey` to `urlTemplate` |
+| `renderMode` | Which rasteriser was actually obtained |
+| `gpuRenders` / `gpuFallbacks` | Counters for how the GPU path is faring |
+| `close()` | Releases the renderer and its caches |
+
+### RenderMode
+
+| Value | Description |
+|---|---|
+| `.cpu` | `tiny-skia`. Always available, and the only path that draws every layer type the renderer supports. |
+| `.gpu` | Metal — rather than OpenGL ES, which has been deprecated on iOS since 12. Roughly twice as fast per tile and, the reason it exists, it spends the map's time on a processor the rest of the app is not competing for. |
+| `.auto` | `.gpu` where it initialises, `.cpu` otherwise. |
+
+------------------------------------------------------------------------
+
+## License
+
+Apache License 2.0. See [LICENSE](./LICENSE).
diff --git a/ios-vectortile/Sources/MapConductorVectorTile/MetalTileRasterizer.swift b/ios-vectortile/Sources/MapConductorVectorTile/MetalTileRasterizer.swift
new file mode 100644
index 0000000..093f105
--- /dev/null
+++ b/ios-vectortile/Sources/MapConductorVectorTile/MetalTileRasterizer.swift
@@ -0,0 +1,273 @@
+import Foundation
+import MapConductorCore
+import Metal
+
+/// Draws a tessellated tile on the GPU and returns PNG bytes.
+///
+/// The counterpart of the Android GL shell, and the same shape: the renderer
+/// hands over triangles in tile coordinates, this draws them offscreen, and the
+/// native encoder turns the pixels into PNG. Metal rather than OpenGL ES
+/// because ES has been deprecated on iOS since 12.
+///
+/// Everything runs on one serial queue. Metal objects are safe to use from
+/// several threads, but a tile is a short burst of work against one command
+/// queue and one pipeline, and serialising it keeps the GPU from being handed
+/// interleaved tiles that only contend for the same resources.
+public final class MetalTileRasterizer {
+
+ private let device: MTLDevice
+ private let commandQueue: MTLCommandQueue
+ private let pipeline: MTLRenderPipelineState
+ private let queue = DispatchQueue(label: "com.mapconductor.vectortile.metal")
+ private let renderGate = DispatchSemaphore(value: 1)
+
+ /// Multisample count for the offscreen target. Anti-aliasing comes from
+ /// MSAA rather than per-vertex coverage, which keeps the shader trivial —
+ /// the same trade the GL path makes.
+ private static let sampleCount = 4
+
+ private let tileSize: Int
+ private var colorTexture: MTLTexture?
+ private var resolveTexture: MTLTexture?
+
+ /// Vertex and fragment stages.
+ ///
+ /// Positions arrive in tile units (0..extent) and are mapped to clip space
+ /// here, so the geometry never has to be rescaled on the CPU.
+ ///
+ /// Unlike the GL path there is no deliberate flip: tile y grows downward,
+ /// Metal clip space has y up, and `getBytes` reads row 0 as the top — so
+ /// mapping y=0 to clip y=+1 already lands the tile the right way up.
+ private static let shaderSource = """
+ #include
+ using namespace metal;
+
+ struct VertexIn {
+ float2 position [[attribute(0)]];
+ float4 color [[attribute(1)]];
+ };
+
+ struct VertexOut {
+ float4 position [[position]];
+ float4 color;
+ };
+
+ vertex VertexOut tile_vertex(VertexIn in [[stage_in]],
+ constant float &extent [[buffer(1)]]) {
+ float2 unit = in.position / extent;
+ VertexOut out;
+ out.position = float4(unit.x * 2.0 - 1.0, 1.0 - unit.y * 2.0, 0.0, 1.0);
+ out.color = in.color;
+ return out;
+ }
+
+ fragment float4 tile_fragment(VertexOut in [[stage_in]]) {
+ return in.color;
+ }
+ """
+
+ /// Builds a rasterizer, or returns nil where Metal is unavailable — the
+ /// simulator on an Intel host, or a device that refuses the pipeline. The
+ /// caller is expected to fall back to CPU rendering rather than fail.
+ public static func createOrNull(tileSize: Int) -> MetalTileRasterizer? {
+ try? MetalTileRasterizer(tileSize: tileSize)
+ }
+
+ public init(tileSize: Int) throws {
+ guard let device = MTLCreateSystemDefaultDevice() else {
+ throw VectorTileError.renderFailed(MvtStatus.renderFailed)
+ }
+ guard let commandQueue = device.makeCommandQueue() else {
+ throw VectorTileError.renderFailed(MvtStatus.renderFailed)
+ }
+ self.device = device
+ self.commandQueue = commandQueue
+ self.tileSize = tileSize
+
+ let library = try device.makeLibrary(source: Self.shaderSource, options: nil)
+
+ let vertexDescriptor = MTLVertexDescriptor()
+ vertexDescriptor.attributes[0].format = .float2
+ vertexDescriptor.attributes[0].offset = 0
+ vertexDescriptor.attributes[0].bufferIndex = 0
+ vertexDescriptor.attributes[1].format = .float4
+ vertexDescriptor.attributes[1].offset = MemoryLayout.size * 2
+ vertexDescriptor.attributes[1].bufferIndex = 0
+ vertexDescriptor.layouts[0].stride =
+ MemoryLayout.size * TessellatedTile.vertexStride
+
+ let descriptor = MTLRenderPipelineDescriptor()
+ descriptor.vertexFunction = library.makeFunction(name: "tile_vertex")
+ descriptor.fragmentFunction = library.makeFunction(name: "tile_fragment")
+ descriptor.vertexDescriptor = vertexDescriptor
+ descriptor.rasterSampleCount = Self.sampleCount
+
+ let attachment = descriptor.colorAttachments[0]!
+ attachment.pixelFormat = .rgba8Unorm
+ attachment.isBlendingEnabled = true
+ attachment.rgbBlendOperation = .add
+ attachment.alphaBlendOperation = .add
+ attachment.sourceRGBBlendFactor = .sourceAlpha
+ attachment.destinationRGBBlendFactor = .oneMinusSourceAlpha
+ // Alpha blends separately from colour on purpose. Using source alpha as
+ // the alpha factor too would pull the stored alpha *down* every time a
+ // translucent layer is drawn — a 0.10-alpha layer leaves 0.91 behind —
+ // and anything that then treats the pixels as premultiplied divides the
+ // colour back out and blows highlights to white. With `one` the
+ // destination alpha saturates and the readback is exact.
+ attachment.sourceAlphaBlendFactor = .one
+ attachment.destinationAlphaBlendFactor = .oneMinusSourceAlpha
+
+ self.pipeline = try device.makeRenderPipelineState(descriptor: descriptor)
+ }
+
+ /**
+ Draws `tile` and encodes the result as PNG, or returns nil if the GPU work
+ fails. Blocks until the tile is done.
+
+ - Parameter decorate: given the readback in straight-alpha RGBA, in place,
+ before it is encoded. Used to draw labels, which the tessellator does not
+ produce: they come from a distance field per glyph and are painted over
+ the finished ground. A raw buffer rather than an array so the label pass
+ writes into the readback instead of a copy of it.
+ */
+ public func renderPng(
+ _ tile: TessellatedTile,
+ decorate: ((UnsafeMutableRawBufferPointer) -> Void)? = nil
+ ) -> Data? {
+ renderPng(tile, isCancelled: { false }, decorate: decorate)
+ }
+
+ /// Waiting tiles must give their render slot back when the camera moves.
+ /// Queueing a synchronous block alone cannot cancel that wait.
+ func renderPng(
+ _ tile: TessellatedTile,
+ isCancelled: () -> Bool,
+ decorate: ((UnsafeMutableRawBufferPointer) -> Void)? = nil
+ ) -> Data? {
+ while renderGate.wait(timeout: .now() + .milliseconds(10)) != .success {
+ if isCancelled() { return nil }
+ }
+ defer { renderGate.signal() }
+ guard !isCancelled() else { return nil }
+ return queue.sync {
+ guard !isCancelled() else { return nil }
+ return drawAndEncode(tile, decorate: decorate)
+ }
+ }
+
+ private func targets() throws -> (color: MTLTexture, resolve: MTLTexture) {
+ if let colorTexture, let resolveTexture {
+ return (colorTexture, resolveTexture)
+ }
+ let resolveDescriptor = MTLTextureDescriptor.texture2DDescriptor(
+ pixelFormat: .rgba8Unorm,
+ width: tileSize, height: tileSize,
+ mipmapped: false
+ )
+ resolveDescriptor.usage = [.renderTarget, .shaderRead]
+ resolveDescriptor.storageMode = .shared
+
+ let multisampleDescriptor = MTLTextureDescriptor.texture2DDescriptor(
+ pixelFormat: .rgba8Unorm,
+ width: tileSize, height: tileSize,
+ mipmapped: false
+ )
+ multisampleDescriptor.textureType = .type2DMultisample
+ multisampleDescriptor.sampleCount = Self.sampleCount
+ multisampleDescriptor.usage = [.renderTarget]
+ multisampleDescriptor.storageMode = .private
+
+ guard let color = device.makeTexture(descriptor: multisampleDescriptor),
+ let resolve = device.makeTexture(descriptor: resolveDescriptor) else {
+ throw VectorTileError.renderFailed(MvtStatus.renderFailed)
+ }
+ colorTexture = color
+ resolveTexture = resolve
+ return (color, resolve)
+ }
+
+ private func drawAndEncode(
+ _ tile: TessellatedTile,
+ decorate: ((UnsafeMutableRawBufferPointer) -> Void)? = nil
+ ) -> Data? {
+ guard let (color, resolve) = try? targets() else { return nil }
+
+ let pass = MTLRenderPassDescriptor()
+ pass.colorAttachments[0].texture = color
+ pass.colorAttachments[0].resolveTexture = resolve
+ pass.colorAttachments[0].loadAction = .clear
+ pass.colorAttachments[0].storeAction = .multisampleResolve
+ let background = tile.background
+ pass.colorAttachments[0].clearColor = MTLClearColor(
+ red: Double(background?.r ?? 0),
+ green: Double(background?.g ?? 0),
+ blue: Double(background?.b ?? 0),
+ alpha: Double(background?.a ?? 0)
+ )
+
+ guard let buffer = commandQueue.makeCommandBuffer(),
+ let encoder = buffer.makeRenderCommandEncoder(descriptor: pass) else {
+ return nil
+ }
+
+ let vertexFloats = tile.vertexFloatCount
+ if vertexFloats > 0, tile.extent > 0 {
+ let uploaded: MTLBuffer? = tile.withVertices { vertices in
+ guard let base = vertices.baseAddress else { return nil }
+ return device.makeBuffer(
+ bytes: base,
+ length: vertexFloats * MemoryLayout.size,
+ options: .storageModeShared
+ )
+ }
+ guard let uploaded else {
+ encoder.endEncoding()
+ return nil
+ }
+
+ encoder.setRenderPipelineState(pipeline)
+ encoder.setVertexBuffer(uploaded, offset: 0, index: 0)
+ var extent = tile.extent
+ encoder.setVertexBytes(&extent, length: MemoryLayout.size, index: 1)
+
+ // One draw call per batch, to preserve painter's order.
+ for index in 0.. 0 else { continue }
+ encoder.drawPrimitives(
+ type: .triangle,
+ vertexStart: batch.firstVertex,
+ vertexCount: batch.vertexCount
+ )
+ }
+ }
+
+ encoder.endEncoding()
+ buffer.commit()
+ buffer.waitUntilCompleted()
+ guard buffer.error == nil else { return nil }
+
+ let bytesPerRow = tileSize * 4
+ var pixels = [UInt8](repeating: 0, count: bytesPerRow * tileSize)
+ pixels.withUnsafeMutableBytes { raw in
+ resolve.getBytes(
+ raw.baseAddress!,
+ bytesPerRow: bytesPerRow,
+ from: MTLRegionMake2D(0, 0, tileSize, tileSize),
+ mipmapLevel: 0
+ )
+ }
+
+ // The core's encoder, not this module's: one copy of it per app. The
+ // blend keeps destination alpha saturated, so the readback is straight
+ // alpha and there is nothing to un-premultiply.
+ return pixels.withUnsafeMutableBytes { raw in
+ guard let base = raw.baseAddress else { return nil }
+ decorate?(raw)
+ return TilePngEncoder.encode(
+ rgba: base, width: tileSize, height: tileSize, premultiplied: false
+ )
+ }
+ }
+}
diff --git a/ios-vectortile/Sources/MapConductorVectorTile/OfflinePackage.swift b/ios-vectortile/Sources/MapConductorVectorTile/OfflinePackage.swift
new file mode 100644
index 0000000..48d060e
--- /dev/null
+++ b/ios-vectortile/Sources/MapConductorVectorTile/OfflinePackage.swift
@@ -0,0 +1,253 @@
+import Foundation
+import MapConductorCore
+
+/**
+ A style and everything it needs to draw one area, on disk.
+
+ Downloaded by ``OfflinePackageDownloader`` and read here. The layout is
+ plain files the style's own URL templates map onto, so a package can be
+ served to a map that draws vector styles itself (MapLibre, Mapbox,
+ MapTiler) as well as read by the rasteriser for every other map:
+
+ ```
+ manifest.json what is in here and where it came from
+ style.json the style, as downloaded
+ index.json source URL -> relative path
+ tiles////.mvt
+ glyphs//.pbf
+ sprite.json sprite.png sprite@2x.json sprite@2x.png
+ ```
+
+ android-vectortile reads and writes the same layout, so a package made on
+ one platform serves the other.
+ */
+public final class OfflinePackage: @unchecked Sendable {
+ /// South-west to north-east, degrees.
+ public struct Bounds: Codable, Equatable, Sendable {
+ public var south: Double
+ public var west: Double
+ public var north: Double
+ public var east: Double
+
+ public init(south: Double, west: Double, north: Double, east: Double) {
+ self.south = south
+ self.west = west
+ self.north = north
+ self.east = east
+ }
+ }
+
+ public struct Manifest: Codable, Sendable {
+ public var version: Int = OfflinePackage.formatVersion
+ public var bounds: Bounds
+ public var minZoom: Int
+ public var maxZoom: Int
+ public var styleDigest: String
+ public var createdAt: Int64
+ public var tiles: Int
+ public var glyphs: Int
+ public var sprites: Int
+ public var bytes: Int64
+ /// Source id -> the tile URL template the tiles were fetched from.
+ public var sources: [String: String]
+ public var glyphsTemplate: String?
+ public var spriteBase: String?
+ }
+
+ public let directory: URL
+ public let manifest: Manifest
+ private let index: [String: String]
+
+ private init(directory: URL, manifest: Manifest, index: [String: String]) {
+ self.directory = directory
+ self.manifest = manifest
+ self.index = index
+ }
+
+ public static let formatVersion = 1
+ static let manifestFile = "manifest.json"
+ static let styleFile = "style.json"
+ static let indexFile = "index.json"
+ static let tilesDir = "tiles"
+ static let glyphsDir = "glyphs"
+ static let spriteFile = "sprite"
+
+ /// The package in `directory`, or nil when there is none or it will not parse.
+ public static func open(_ directory: URL) -> OfflinePackage? {
+ guard
+ let manifestData = try? Data(contentsOf: directory.appendingPathComponent(manifestFile)),
+ let manifest = try? JSONDecoder().decode(Manifest.self, from: manifestData),
+ let indexData = try? Data(contentsOf: directory.appendingPathComponent(indexFile)),
+ let index = try? JSONDecoder().decode([String: String].self, from: indexData)
+ else { return nil }
+ return OfflinePackage(directory: directory, manifest: manifest, index: index)
+ }
+
+ /// Removes a package, whole.
+ public static func delete(_ directory: URL) {
+ try? FileManager.default.removeItem(at: directory)
+ }
+
+ static func create(directory: URL, manifest: Manifest, index: [String: String]) throws -> OfflinePackage {
+ let encoder = JSONEncoder()
+ try encoder.encode(manifest).write(to: directory.appendingPathComponent(manifestFile), options: .atomic)
+ try encoder.encode(index).write(to: directory.appendingPathComponent(indexFile), options: .atomic)
+ return OfflinePackage(directory: directory, manifest: manifest, index: index)
+ }
+
+ /// The style as it was downloaded, pointing at its original servers.
+ public var styleJSON: String {
+ (try? String(contentsOf: directory.appendingPathComponent(Self.styleFile), encoding: .utf8)) ?? ""
+ }
+
+ /// The bytes the package holds for a source URL, or nil when it has none.
+ public func bytes(for url: String) -> Data? {
+ guard let relative = index[url] else { return nil }
+ return try? Data(contentsOf: directory.appendingPathComponent(relative))
+ }
+
+ /// Whether the package answers for this URL.
+ public func contains(_ url: String) -> Bool {
+ guard let relative = index[url] else { return false }
+ return FileManager.default.fileExists(atPath: directory.appendingPathComponent(relative).path)
+ }
+
+ /**
+ Where a file the package does not have would come from.
+
+ The relative path is the one the served style asks for, so it maps back
+ through the same templates the package was fetched with. Nil for a path
+ that is not in the package's vocabulary.
+ */
+ public func upstreamURL(forRelativePath relativePath: String) -> String? {
+ let segments = relativePath.split(separator: "/", omittingEmptySubsequences: false).map(String.init)
+ if segments.count == 5, segments[0] == Self.tilesDir, segments[4].hasSuffix(".mvt") {
+ guard let template = manifest.sources[segments[1]] else { return nil }
+ return template
+ .replacingOccurrences(of: "{z}", with: segments[2])
+ .replacingOccurrences(of: "{x}", with: segments[3])
+ .replacingOccurrences(of: "{y}", with: String(segments[4].dropLast(4)))
+ }
+ if segments.count == 3, segments[0] == Self.glyphsDir, segments[2].hasSuffix(".pbf") {
+ guard let template = manifest.glyphsTemplate else { return nil }
+ let fontstack = segments[1].addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? segments[1]
+ return template
+ .replacingOccurrences(of: "{fontstack}", with: fontstack)
+ .replacingOccurrences(of: "{range}", with: String(segments[2].dropLast(4)))
+ }
+ if segments.count == 1, segments[0].hasPrefix(Self.spriteFile) {
+ guard let base = manifest.spriteBase else { return nil }
+ return base + segments[0].dropFirst(Self.spriteFile.count)
+ }
+ return nil
+ }
+
+ /**
+ The style rewritten to read from `baseUrl`, for a map that draws the
+ style itself. Every source becomes a tile template under
+ `tiles//`, the glyphs and the sprite move under the same root,
+ and a TileJSON `url` is dropped in favour of the tiles it named.
+ */
+ public func styleServed(by baseUrl: String) -> String {
+ guard
+ var style = try? JSONSerialization.jsonObject(with: Data(styleJSON.utf8)) as? [String: Any]
+ else { return styleJSON }
+ var sources = style["sources"] as? [String: Any] ?? [:]
+ for (id, value) in sources {
+ guard var source = value as? [String: Any], manifest.sources[id] != nil else { continue }
+ source.removeValue(forKey: "url")
+ source["tiles"] = ["\(baseUrl)/\(Self.tilesDir)/\(id)/{z}/{x}/{y}.mvt"]
+ sources[id] = source
+ }
+ style["sources"] = sources
+ if manifest.glyphsTemplate != nil { style["glyphs"] = "\(baseUrl)/\(Self.glyphsDir)/{fontstack}/{range}.pbf" }
+ if manifest.spriteBase != nil { style["sprite"] = "\(baseUrl)/\(Self.spriteFile)" }
+ guard let data = try? JSONSerialization.data(withJSONObject: style) else { return styleJSON }
+ return String(decoding: data, as: UTF8.self)
+ }
+
+ /**
+ How the fetches of one provider have gone so far: what the package
+ answered, what went to the network, and what was refused offline.
+ */
+ public struct Stats: Equatable, Sendable, CustomStringConvertible {
+ public var packageHits = 0
+ public var networkFetches = 0
+ public var blocked = 0
+
+ public var description: String { "package=\(packageHits) network=\(networkFetches) blocked=\(blocked)" }
+ }
+
+ /**
+ A `fetchTile` for ``VectorTileProvider``: the package first, then --
+ while ``online`` -- the network, and otherwise nothing.
+
+ "Nothing" is thrown rather than returned: a nil from a fetch means "there
+ is no such tile" and is remembered as such, whereas a tile the network
+ was not allowed to fetch is there to be had the moment it is.
+ */
+ public final class Fetcher: @unchecked Sendable {
+ private let package: OfflinePackage
+ private let upstream: (URL) -> Data?
+ private let onStats: ((Stats) -> Void)?
+ private let lock = NSLock()
+ private var counters = Stats()
+ private var _online: Bool
+
+ public var online: Bool {
+ get { lock.lock(); defer { lock.unlock() }; return _online }
+ set { lock.lock(); _online = newValue; lock.unlock() }
+ }
+
+ public var stats: Stats {
+ lock.lock()
+ defer { lock.unlock() }
+ return counters
+ }
+
+ /// - Parameter upstream: how the network is reached; nil for plain
+ /// HTTP with `headers`.
+ public init(
+ package: OfflinePackage,
+ online: Bool = true,
+ headers: [String: String] = [:],
+ upstream: ((URL) -> Data?)? = nil,
+ onStats: ((Stats) -> Void)? = nil
+ ) {
+ self.package = package
+ self._online = online
+ self.upstream = upstream ?? { url in VectorTileProvider.get(url, headers: headers, cancellation: nil) }
+ self.onStats = onStats
+ }
+
+ public func fetch(_ url: URL) throws -> Data? {
+ if let packaged = package.bytes(for: url.absoluteString) {
+ bump { $0.packageHits += 1 }
+ return packaged
+ }
+ if !online {
+ bump { $0.blocked += 1 }
+ throw OfflineUnavailableError(url: url.absoluteString)
+ }
+ bump { $0.networkFetches += 1 }
+ return upstream(url)
+ }
+
+ private func bump(_ change: (inout Stats) -> Void) {
+ lock.lock()
+ change(&counters)
+ let snapshot = counters
+ lock.unlock()
+ onStats?(snapshot)
+ }
+ }
+}
+
+/// A fetch refused because the network is off and the package has no answer.
+public struct OfflineUnavailableError: Error, CustomStringConvertible {
+ public let url: String
+
+ public init(url: String) { self.url = url }
+
+ public var description: String { "offline: \(url)" }
+}
diff --git a/ios-vectortile/Sources/MapConductorVectorTile/OfflinePackageDownloader.swift b/ios-vectortile/Sources/MapConductorVectorTile/OfflinePackageDownloader.swift
new file mode 100644
index 0000000..202123d
--- /dev/null
+++ b/ios-vectortile/Sources/MapConductorVectorTile/OfflinePackageDownloader.swift
@@ -0,0 +1,418 @@
+import Foundation
+
+/**
+ Fetches everything a style needs to draw one area and writes an
+ ``OfflinePackage``.
+
+ What "everything" is comes from the renderer, not from guessing at the
+ style: `plan` says which source tiles each display tile reads (including
+ the shallower tile a magnified level reads from, so a source that stops at
+ z14 is not asked for z15), and `neededGlyphs` says which font ranges the
+ labels in those tiles use -- fetching every range a CJK font has would be
+ eighty files per font, nearly all unused. The sprite comes in both
+ resolutions, since the two platforms' rasterisers ask for different ones.
+ */
+public enum OfflinePackageDownloader {
+ public enum Phase: String, Sendable { case planning, tiles, glyphs, sprite, done }
+
+ public struct Progress: Equatable, Sendable, CustomStringConvertible {
+ public let phase: Phase
+ public let done: Int
+ public let total: Int
+
+ public var description: String { "\(phase.rawValue) \(done)/\(total)" }
+ }
+
+ /// A bounds and zoom range that would need more tiles than this is refused.
+ public static let maxTiles = 20_000
+
+ public enum DownloadError: Error, CustomStringConvertible {
+ case zoomRange(Int, Int)
+ case tooManyTiles(Int)
+ case glyphTemplate(String)
+ case fetchFailed(URL, Int?)
+ case tileJSON(URL, String)
+
+ public var description: String {
+ switch self {
+ case .zoomRange(let a, let b): return "zoom range \(a)..\(b)"
+ case .tooManyTiles(let max): return "more than \(max) tiles; shrink the area or the zoom range"
+ case .glyphTemplate(let t): return "glyph template must name {fontstack} then {range}: \(t)"
+ case .fetchFailed(let url, let status):
+ if let status { return "offline package fetch failed: HTTP \(status) \(url.absoluteString)" }
+ return "offline package fetch failed: \(url.absoluteString)"
+ case .tileJSON(let url, let message):
+ return "offline package TileJSON failed: \(message) \(url.absoluteString)"
+ }
+ }
+ }
+
+ /**
+ - Parameters:
+ - styleJSON: the style, with absolute source, glyph and sprite URLs
+ - bounds: the area, in degrees
+ - minZoom: the shallowest display zoom to cover; 0 costs one tile
+ - maxZoom: the deepest display zoom; sources deeper than their own
+ `maxzoom` are magnified from it, so this can exceed the data's zoom
+ - directory: where the package goes; anything there is replaced
+ - fetch: how bytes are got; nil for plain HTTP with `headers`
+ */
+ public static func download(
+ styleJSON: String,
+ bounds: OfflinePackage.Bounds,
+ minZoom: Int = 0,
+ maxZoom: Int = 14,
+ directory: URL,
+ headers: [String: String] = [:],
+ parallelism: Int = 8,
+ fetch: ((URL) -> Data?)? = nil,
+ onProgress: @escaping @Sendable (Progress) -> Void = { _ in }
+ ) async throws -> OfflinePackage {
+ guard minZoom >= 0, minZoom <= maxZoom, maxZoom <= 22 else { throw DownloadError.zoomRange(minZoom, maxZoom) }
+ let get: (URL) -> FetchResult = fetch.map { custom in
+ { url in
+ guard let data = custom(url), !data.isEmpty else { return .notFound }
+ return .success(data)
+ }
+ } ?? { url in httpFetch(url, headers: headers) }
+ let normalizedStyleJSON = try normalizeStyle(styleJSON, get: get)
+ let renderer = try VectorTileRenderer(styleJSON: normalizedStyleJSON, displayTileSize: VectorTileProvider.defaultTileSize)
+ defer { renderer.close() }
+
+ let files = FileManager.default
+ try? files.removeItem(at: directory)
+ try files.createDirectory(at: directory, withIntermediateDirectories: true)
+ try Data(normalizedStyleJSON.utf8).write(
+ to: directory.appendingPathComponent(OfflinePackage.styleFile), options: .atomic
+ )
+
+ // --- plan: which source tiles, for which display tiles ---
+ onProgress(Progress(phase: .planning, done: 0, total: 0))
+ struct DisplayTile { let z: UInt8; let x: UInt32; let y: UInt32; let urls: [String?] }
+ var displayTiles: [DisplayTile] = []
+ var wanted: [(url: String, relative: String)] = []
+ var wantedSet = Set()
+ for z in minZoom...maxZoom {
+ let n = 1 << z
+ let x0 = min(max(tileX(bounds.west, z), 0), n - 1)
+ let x1 = min(max(tileX(bounds.east, z), 0), n - 1)
+ let y0 = min(max(tileY(bounds.north, z), 0), n - 1)
+ let y1 = min(max(tileY(bounds.south, z), 0), n - 1)
+ let minX = min(x0, x1)
+ let maxX = max(x0, x1)
+ let minY = min(y0, y1)
+ let maxY = max(y0, y1)
+ for x in minX...maxX {
+ for y in minY...maxY {
+ try Task.checkCancellation()
+ let json = try renderer.plan(z: UInt8(z), x: UInt32(x), y: UInt32(y))
+ let plan = (try? JSONSerialization.jsonObject(with: Data(json.utf8)) as? [[String: Any]]) ?? []
+ var urls: [String?] = []
+ let boundary = x == minX || x == maxX || y == minY || y == maxY
+ for entry in plan {
+ // Interior neighbours are only for label placement and
+ // stay out of the package. At the requested edge they
+ // are the one-tile buffer that keeps boundary labels
+ // intact.
+ if entry["labelsOnly"] as? Bool ?? false, !boundary {
+ urls.append(nil)
+ continue
+ }
+ guard let url = entry["url"] as? String else {
+ urls.append(nil)
+ continue
+ }
+ urls.append(url)
+ if wantedSet.insert(url).inserted {
+ let relative = "\(OfflinePackage.tilesDir)/\(entry["sourceId"] as? String ?? "source")/"
+ + "\(entry["z"] ?? 0)/\(entry["x"] ?? 0)/\(entry["y"] ?? 0).mvt"
+ wanted.append((url, relative))
+ if wanted.count > maxTiles { throw DownloadError.tooManyTiles(maxTiles) }
+ }
+ }
+ displayTiles.append(DisplayTile(z: UInt8(z), x: UInt32(x), y: UInt32(y), urls: urls))
+ }
+ }
+ }
+
+ // --- tiles ---
+ let total = wanted.count
+ onProgress(Progress(phase: .tiles, done: 0, total: total))
+ let index = Index()
+ let width = max(1, parallelism)
+ try await withThrowingTaskGroup(of: Void.self) { group in
+ var next = 0
+ var running = 0
+ func launch(_ item: (url: String, relative: String)) {
+ group.addTask {
+ try Task.checkCancellation()
+ if let target = VectorTileProvider.parse(item.url) {
+ switch get(target) {
+ case .success(let data):
+ try write(directory: directory, relative: item.relative, data: data)
+ index.record(url: item.url, relative: item.relative, bytes: data.count)
+ case .notFound:
+ break
+ case .temporaryFailure(let url, let status):
+ throw DownloadError.fetchFailed(url, status)
+ }
+ }
+ onProgress(Progress(phase: .tiles, done: index.done(), total: total))
+ }
+ }
+ while next < wanted.count, running < width {
+ launch(wanted[next]); next += 1; running += 1
+ }
+ while running > 0 {
+ try await group.next()
+ running -= 1
+ if next < wanted.count { launch(wanted[next]); next += 1; running += 1 }
+ }
+ }
+
+ // --- glyphs: what the fetched tiles' labels need ---
+ let glyphTemplate = try renderer.glyphsURLTemplate()
+ let glyphMatcher = try glyphTemplate.map(templateRegex)
+ var glyphCount = 0
+ onProgress(Progress(phase: .glyphs, done: 0, total: displayTiles.count))
+ if let glyphMatcher {
+ for (i, tile) in displayTiles.enumerated() {
+ try Task.checkCancellation()
+ let data: [Data?] = tile.urls.map { url in
+ guard let url, let relative = index.relative(for: url) else { return nil }
+ return try? Data(contentsOf: directory.appendingPathComponent(relative))
+ }
+ let needed = (try? renderer.neededGlyphs(z: tile.z, x: tile.x, y: tile.y, tiles: data)) ?? []
+ for url in needed {
+ if index.relative(for: url) != nil { continue }
+ guard
+ let match = glyphMatcher.firstMatch(in: url, range: NSRange(url.startIndex..., in: url)),
+ let fontstackRange = Range(match.range(at: 1), in: url),
+ let rangeRange = Range(match.range(at: 2), in: url)
+ else { continue }
+ let fontstack = String(url[fontstackRange]).removingPercentEncoding ?? String(url[fontstackRange])
+ let range = String(url[rangeRange])
+ guard let target = VectorTileProvider.parse(url) else { continue }
+ let pbf: Data
+ switch get(target) {
+ case .success(let data):
+ pbf = data
+ case .notFound:
+ continue
+ case .temporaryFailure(let url, let status):
+ throw DownloadError.fetchFailed(url, status)
+ }
+ let relative = "\(OfflinePackage.glyphsDir)/\(fontstack)/\(range).pbf"
+ try write(directory: directory, relative: relative, data: pbf)
+ index.record(url: url, relative: relative, bytes: pbf.count)
+ glyphCount += 1
+ // Fed back so the next tile asks only for what is still missing.
+ _ = try? renderer.addGlyphs(pbf)
+ }
+ onProgress(Progress(phase: .glyphs, done: i + 1, total: displayTiles.count))
+ }
+ }
+
+ // --- sprite, both resolutions ---
+ onProgress(Progress(phase: .sprite, done: 0, total: 2))
+ var spriteCount = 0
+ for ratio in 1...2 {
+ guard let urls = try? renderer.spriteURLs(pixelRatio: UInt32(ratio)) else { continue }
+ let suffix = ratio > 1 ? "@2x" : ""
+ for (url, ext) in [(urls.json, "json"), (urls.png, "png")] {
+ guard let target = VectorTileProvider.parse(url) else { continue }
+ let data: Data
+ switch get(target) {
+ case .success(let bytes):
+ data = bytes
+ case .notFound:
+ continue
+ case .temporaryFailure(let url, let status):
+ throw DownloadError.fetchFailed(url, status)
+ }
+ let relative = "\(OfflinePackage.spriteFile)\(suffix).\(ext)"
+ try write(directory: directory, relative: relative, data: data)
+ index.record(url: url, relative: relative, bytes: data.count)
+ spriteCount += 1
+ }
+ onProgress(Progress(phase: .sprite, done: ratio, total: 2))
+ }
+
+ let style = (try? JSONSerialization.jsonObject(with: Data(normalizedStyleJSON.utf8)) as? [String: Any]) ?? [:]
+ var templates: [String: String] = [:]
+ for (id, value) in style["sources"] as? [String: Any] ?? [:] {
+ if let source = value as? [String: Any], let tiles = source["tiles"] as? [String], let first = tiles.first {
+ templates[id] = first
+ }
+ }
+ let snapshot = index.snapshot()
+ let manifest = OfflinePackage.Manifest(
+ bounds: bounds,
+ minZoom: minZoom,
+ maxZoom: maxZoom,
+ styleDigest: Digest.hex(normalizedStyleJSON),
+ createdAt: Int64(Date().timeIntervalSince1970 * 1000),
+ tiles: snapshot.entries.values.filter { $0.hasPrefix("\(OfflinePackage.tilesDir)/") }.count,
+ glyphs: glyphCount,
+ sprites: spriteCount,
+ bytes: snapshot.bytes,
+ sources: templates,
+ glyphsTemplate: glyphTemplate,
+ spriteBase: style["sprite"] as? String
+ )
+ onProgress(Progress(phase: .done, done: total, total: total))
+ return try OfflinePackage.create(directory: directory, manifest: manifest, index: snapshot.entries)
+ }
+
+ private static let tileJSONSourceFields = ["minzoom", "maxzoom", "bounds", "attribution", "scheme"]
+
+ private static func normalizeStyle(
+ _ styleJSON: String,
+ get: (URL) -> FetchResult
+ ) throws -> String {
+ var style = try JSONSerialization.jsonObject(with: Data(styleJSON.utf8)) as? [String: Any] ?? [:]
+ var sources = style["sources"] as? [String: Any] ?? [:]
+ for (id, value) in sources {
+ guard
+ var source = value as? [String: Any],
+ let tileJSONString = source["url"] as? String,
+ let tileJSONURL = VectorTileProvider.parse(tileJSONString)
+ else { continue }
+
+ let data: Data
+ switch get(tileJSONURL) {
+ case .success(let bytes):
+ data = bytes
+ case .notFound:
+ throw DownloadError.tileJSON(tileJSONURL, "not found")
+ case .temporaryFailure(let url, let status):
+ throw DownloadError.fetchFailed(url, status)
+ }
+
+ guard let tileJSON = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
+ throw DownloadError.tileJSON(tileJSONURL, "invalid JSON")
+ }
+ guard let tiles = tileJSON["tiles"] as? [String], !tiles.isEmpty else {
+ throw DownloadError.tileJSON(tileJSONURL, "missing tiles")
+ }
+ source["tiles"] = tiles.map { resolveURL(base: tileJSONURL, value: $0) }
+ for field in tileJSONSourceFields where tileJSON[field] != nil {
+ source[field] = tileJSON[field]
+ }
+ source.removeValue(forKey: "url")
+ sources[id] = source
+ }
+ style["sources"] = sources
+ let data = try JSONSerialization.data(withJSONObject: style, options: [])
+ return String(data: data, encoding: .utf8) ?? styleJSON
+ }
+
+ private static func resolveURL(base: URL, value: String) -> String {
+ URL(string: value, relativeTo: base)?.absoluteURL.absoluteString ?? value
+ }
+
+ private enum FetchResult {
+ case success(Data)
+ case notFound
+ case temporaryFailure(URL, Int?)
+ }
+
+ private static func httpFetch(_ url: URL, headers: [String: String]) -> FetchResult {
+ var request = URLRequest(url: url, timeoutInterval: 15)
+ for (name, value) in headers { request.setValue(value, forHTTPHeaderField: name) }
+
+ var result: FetchResult = .temporaryFailure(url, nil)
+ let done = DispatchSemaphore(value: 0)
+ let task = URLSession.shared.dataTask(with: request) { data, response, error in
+ defer { done.signal() }
+ if error != nil {
+ result = .temporaryFailure(url, nil)
+ return
+ }
+ guard let http = response as? HTTPURLResponse else {
+ result = .temporaryFailure(url, nil)
+ return
+ }
+ switch http.statusCode {
+ case 200..<300:
+ if let data, !data.isEmpty {
+ result = .success(data)
+ } else {
+ result = .notFound
+ }
+ case 404, 204:
+ result = .notFound
+ default:
+ result = .temporaryFailure(url, http.statusCode)
+ }
+ }
+ task.resume()
+ done.wait()
+ return result
+ }
+
+ /// The URL -> path map being built, from several tasks at once.
+ private final class Index: @unchecked Sendable {
+ private let lock = NSLock()
+ private var entries: [String: String] = [:]
+ private var bytes: Int64 = 0
+ private var completed = 0
+
+ func record(url: String, relative: String, bytes: Int) {
+ lock.lock()
+ entries[url] = relative
+ self.bytes += Int64(bytes)
+ lock.unlock()
+ }
+
+ func done() -> Int {
+ lock.lock()
+ defer { lock.unlock() }
+ completed += 1
+ return completed
+ }
+
+ func relative(for url: String) -> String? {
+ lock.lock()
+ defer { lock.unlock() }
+ return entries[url]
+ }
+
+ func snapshot() -> (entries: [String: String], bytes: Int64) {
+ lock.lock()
+ defer { lock.unlock() }
+ return (entries, bytes)
+ }
+ }
+
+ private static func write(directory: URL, relative: String, data: Data) throws {
+ let file = directory.appendingPathComponent(relative)
+ try FileManager.default.createDirectory(
+ at: file.deletingLastPathComponent(), withIntermediateDirectories: true
+ )
+ try data.write(to: file, options: .atomic)
+ }
+
+ /// `{fontstack}` and `{range}` as capture groups; everything else literal.
+ private static func templateRegex(_ template: String) throws -> NSRegularExpression {
+ guard
+ let fontstack = template.range(of: "{fontstack}"),
+ let range = template.range(of: "{range}"),
+ fontstack.upperBound <= range.lowerBound
+ else { throw DownloadError.glyphTemplate(template) }
+ let head = NSRegularExpression.escapedPattern(for: String(template[.. Int {
+ Int(floor((lon + 180.0) / 360.0 * Double(1 << z)))
+ }
+
+ private static func tileY(_ lat: Double, _ z: Int) -> Int {
+ let clamped = min(max(lat, -85.05112878), 85.05112878)
+ let rad = clamped * .pi / 180
+ return Int(floor((1.0 - log(tan(rad) + 1.0 / cos(rad)) / .pi) / 2.0 * Double(1 << z)))
+ }
+}
diff --git a/ios-vectortile/Sources/MapConductorVectorTile/RasterStyleSnapshot.swift b/ios-vectortile/Sources/MapConductorVectorTile/RasterStyleSnapshot.swift
new file mode 100644
index 0000000..fb1cae8
--- /dev/null
+++ b/ios-vectortile/Sources/MapConductorVectorTile/RasterStyleSnapshot.swift
@@ -0,0 +1,37 @@
+import Foundation
+import MapConductorVectorStyle
+
+/// Compare the documents actually displayed, rather than adjustments against
+/// the author's base style. Recolouring roads must not invalidate unchanged
+/// labels, and removing every adjustment still needs to restore both halves.
+struct RasterStyleSnapshot {
+ private let ground: Data
+ private let labels: Data
+ let hasVisibleSymbols: Bool
+
+ init(styleJSON: String) throws {
+ guard var root = try JSONSerialization.jsonObject(with: Data(styleJSON.utf8))
+ as? [String: Any]
+ else { throw VectorTileError.styleRejected("expected a style object") }
+ let layers = root.removeValue(forKey: "layers") as? [[String: Any]] ?? []
+ hasVisibleSymbols = layers.contains {
+ ($0["type"] as? String) == "symbol"
+ && (($0["layout"] as? [String: Any])?["visibility"] as? String) != "none"
+ }
+ var groundDocument = root
+ var labelDocument = root
+ groundDocument["layers"] = layers.filter { ($0["type"] as? String) != "symbol" }
+ labelDocument["layers"] = layers.filter { ($0["type"] as? String) == "symbol" }
+ ground = try JSONSerialization.data(withJSONObject: groundDocument, options: .sortedKeys)
+ labels = try JSONSerialization.data(withJSONObject: labelDocument, options: .sortedKeys)
+ }
+
+ func changes(from previous: RasterStyleSnapshot) -> StyleAffects {
+ switch (ground != previous.ground, labels != previous.labels) {
+ case (false, false): return .none
+ case (true, false): return .ground
+ case (false, true): return .labels
+ case (true, true): return .both
+ }
+ }
+}
diff --git a/ios-vectortile/Sources/MapConductorVectorTile/SourceTileDiskCache.swift b/ios-vectortile/Sources/MapConductorVectorTile/SourceTileDiskCache.swift
new file mode 100644
index 0000000..0a0886c
--- /dev/null
+++ b/ios-vectortile/Sources/MapConductorVectorTile/SourceTileDiskCache.swift
@@ -0,0 +1,111 @@
+import Foundation
+
+/// Persistent cache for fetched source MVT/PBF bytes.
+final class SourceTileDiskCache {
+ private let directory: URL
+ private let budgetBytes: Int
+ private let queue = DispatchQueue(label: "mapconductor.vectortile.sources", attributes: .concurrent)
+ private var writesSinceSweep = 0
+ private static let sweepEvery = 32
+
+ init?(directory: URL, budgetBytes: Int) {
+ guard (try? FileManager.default.createDirectory(
+ at: directory, withIntermediateDirectories: true
+ )) != nil else { return nil }
+ self.directory = directory
+ self.budgetBytes = budgetBytes
+ }
+
+ private func file(_ key: String) -> URL {
+ directory.appendingPathComponent("\(Digest.hex(key)).mvt")
+ }
+
+ func get(_ key: String) -> Data? {
+ queue.sync {
+ let url = file(key)
+ guard let bytes = try? Data(contentsOf: url), !bytes.isEmpty else { return nil }
+ try? FileManager.default.setAttributes(
+ [.modificationDate: Date()], ofItemAtPath: url.path
+ )
+ return bytes
+ }
+ }
+
+ func put(_ key: String, _ bytes: Data) {
+ queue.async(flags: .barrier) {
+ guard (try? bytes.write(to: self.file(key), options: .atomic)) != nil else { return }
+ self.writesSinceSweep += 1
+ if self.writesSinceSweep % SourceTileDiskCache.sweepEvery == 0 { self.sweep() }
+ }
+ }
+
+ private func sweep() {
+ let keys: [URLResourceKey] = [.contentModificationDateKey, .fileSizeKey]
+ guard let entries = try? FileManager.default.contentsOfDirectory(
+ at: directory, includingPropertiesForKeys: keys
+ ) else { return }
+
+ var described = entries.compactMap { url -> (URL, Date, Int)? in
+ guard let values = try? url.resourceValues(forKeys: Set(keys)),
+ let modified = values.contentModificationDate,
+ let size = values.fileSize
+ else { return nil }
+ return (url, modified, size)
+ }
+ var total = described.reduce(0) { $0 + $1.2 }
+ guard total > budgetBytes else { return }
+
+ described.sort { $0.1 < $1.1 }
+ for (url, _, size) in described {
+ if total <= budgetBytes { break }
+ if (try? FileManager.default.removeItem(at: url)) != nil { total -= size }
+ }
+ }
+}
+
+final class SourceFetchStats: @unchecked Sendable {
+ private let lock = NSLock()
+ private var values = [0, 0, 0, 0, 0, 0, 0]
+
+ var memoryHits: Int {
+ get { get(0) }
+ set { set(0, newValue) }
+ }
+ var diskHits: Int {
+ get { get(1) }
+ set { set(1, newValue) }
+ }
+ var networkFetches: Int {
+ get { get(2) }
+ set { set(2, newValue) }
+ }
+ var sharedWaits: Int {
+ get { get(3) }
+ set { set(3, newValue) }
+ }
+ var cancelled: Int {
+ get { get(4) }
+ set { set(4, newValue) }
+ }
+ var queueWaitMs: Int {
+ get { get(5) }
+ set { set(5, newValue) }
+ }
+ /// Fetches refused because the network was off and the package had no answer.
+ var blocked: Int {
+ get { get(6) }
+ set { set(6, newValue) }
+ }
+
+ private func get(_ index: Int) -> Int {
+ lock.lock()
+ defer { lock.unlock() }
+ return values[index]
+ }
+
+ private func set(_ index: Int, _ value: Int) {
+ lock.lock()
+ values[index] = value
+ lock.unlock()
+ }
+}
diff --git a/ios-vectortile/Sources/MapConductorVectorTile/StyleAssets.swift b/ios-vectortile/Sources/MapConductorVectorTile/StyleAssets.swift
new file mode 100644
index 0000000..f1c2d90
--- /dev/null
+++ b/ios-vectortile/Sources/MapConductorVectorTile/StyleAssets.swift
@@ -0,0 +1,71 @@
+import Foundation
+
+/**
+ Glyph ranges and sprite sheets kept on disk between launches.
+
+ These are the slowest part of a cold start and the least likely to change: a
+ glyph range is a 0.4-1.3 second round trip, a low zoom wants dozens of them,
+ and the bytes for a given range never change. Keeping them means the second
+ launch draws labelled tiles immediately instead of drawing them bare and
+ handing them over a moment later.
+
+ Deliberately the caller's choice of directory rather than something this
+ module digs out for itself: apps have their own opinions about cache location
+ and lifetime, and a library helping itself to disk behind their back is not a
+ favour.
+ */
+public final class StyleAssetCache {
+ private let directory: URL
+ private let queue = DispatchQueue(label: "mapconductor.vectortile.assets", attributes: .concurrent)
+
+ public init?(directory: URL) {
+ do {
+ try FileManager.default.createDirectory(
+ at: directory, withIntermediateDirectories: true
+ )
+ } catch {
+ return nil
+ }
+ self.directory = directory
+ }
+
+ /// A file name that cannot collide and cannot escape the directory.
+ ///
+ /// URLs carry slashes, query strings and characters a file system will not
+ /// take, so the key is hashed rather than sanitised — sanitising is how two
+ /// different URLs end up sharing one entry.
+ private func path(for key: String) -> URL {
+ directory.appendingPathComponent(Digest.hex(key))
+ }
+
+ public func get(_ key: String) -> Data? {
+ queue.sync { try? Data(contentsOf: path(for: key)) }
+ }
+
+ public func put(_ key: String, _ value: Data) {
+ queue.async(flags: .barrier) {
+ // A partial write must not be read back as a whole asset.
+ try? value.write(to: self.path(for: key), options: .atomic)
+ }
+ }
+}
+
+/// A stable short hash, used for cache keys.
+///
+/// FNV-1a rather than a cryptographic digest: these are file names, not
+/// signatures, and the whole point is to be cheap.
+enum Digest {
+ static func hex(_ values: String...) -> String {
+ var hash: UInt64 = 0xcbf2_9ce4_8422_2325
+ for value in values {
+ for byte in value.utf8 {
+ hash ^= UInt64(byte)
+ hash = hash &* 0x1_0000_01b3
+ }
+ // Separator, so ("ab", "c") and ("a", "bc") differ.
+ hash ^= 0xff
+ hash = hash &* 0x1_0000_01b3
+ }
+ return String(hash, radix: 16)
+ }
+}
diff --git a/ios-vectortile/Sources/MapConductorVectorTile/TileDiskCache.swift b/ios-vectortile/Sources/MapConductorVectorTile/TileDiskCache.swift
new file mode 100644
index 0000000..39fc4b4
--- /dev/null
+++ b/ios-vectortile/Sources/MapConductorVectorTile/TileDiskCache.swift
@@ -0,0 +1,105 @@
+import Foundation
+
+/**
+ Stores rendered PNG tiles on disk, keyed by style and coordinates.
+
+ This does not make the first view of an area faster — nothing can, the tiles
+ have to be fetched and rasterised once. What it removes is paying that cost
+ *again* on the next launch. Within a single session the map SDK already caches
+ raster tiles itself, so this is specifically about surviving process death.
+
+ Entries are content-addressed, so a restyle simply misses rather than needing
+ explicit invalidation.
+ */
+final class TileDiskCache {
+ private let directory: URL
+ private let budgetBytes: Int
+ private let queue = DispatchQueue(label: "mapconductor.vectortile.tiles", attributes: .concurrent)
+
+ /// Writes since the last sweep. Eviction is amortised rather than paid on
+ /// every write: listing a directory costs more than the write did.
+ private var writesSinceSweep = 0
+ private static let sweepEvery = 32
+
+ init?(directory: URL, budgetBytes: Int) {
+ guard (try? FileManager.default.createDirectory(
+ at: directory, withIntermediateDirectories: true
+ )) != nil else { return nil }
+ self.directory = directory
+ self.budgetBytes = budgetBytes
+ }
+
+ private func file(_ key: String) -> URL {
+ directory.appendingPathComponent("\(key).png")
+ }
+
+ func get(_ key: String) -> Data? {
+ queue.sync {
+ let path = file(key)
+ guard let bytes = try? Data(contentsOf: path), !bytes.isEmpty else { return nil }
+ // Touch, so the sweep evicts genuinely cold entries rather than
+ // whichever happened to be written first.
+ try? FileManager.default.setAttributes(
+ [.modificationDate: Date()], ofItemAtPath: path.path
+ )
+ return bytes
+ }
+ }
+
+ func put(_ key: String, _ bytes: Data) {
+ queue.async(flags: .barrier) {
+ // Atomic write: two threads rendering the same tile must not leave
+ // a half-written file behind for a third to read.
+ guard (try? bytes.write(to: self.file(key), options: .atomic)) != nil else { return }
+ self.writesSinceSweep += 1
+ if self.writesSinceSweep % TileDiskCache.sweepEvery == 0 { self.sweep() }
+ }
+ }
+
+ /// Drops the least recently used files until the budget is met.
+ ///
+ /// Called on the barrier queue, so it already has exclusive access.
+ private func sweep() {
+ let keys: [URLResourceKey] = [.contentModificationDateKey, .fileSizeKey]
+ guard let entries = try? FileManager.default.contentsOfDirectory(
+ at: directory, includingPropertiesForKeys: keys
+ ) else { return }
+
+ var described = entries.compactMap { url -> (URL, Date, Int)? in
+ guard let values = try? url.resourceValues(forKeys: Set(keys)),
+ let modified = values.contentModificationDate,
+ let size = values.fileSize
+ else { return nil }
+ return (url, modified, size)
+ }
+ var total = described.reduce(0) { $0 + $1.2 }
+ guard total > budgetBytes else { return }
+
+ described.sort { $0.1 < $1.1 }
+ for (url, _, size) in described {
+ if total <= budgetBytes { break }
+ if (try? FileManager.default.removeItem(at: url)) != nil { total -= size }
+ }
+ }
+
+ /**
+ Waits for writes already queued to reach disk.
+
+ Writes are queued rather than paid for on the render thread, which means a
+ provider closed straight after drawing — or an app put into the background
+ — can drop the tile it just made. Not a hypothetical: the test that stands
+ in for a second launch started missing as soon as the tiles got bigger.
+ */
+ func flush() {
+ queue.sync(flags: .barrier) {}
+ }
+
+ func clear() {
+ queue.async(flags: .barrier) {
+ guard let entries = try? FileManager.default.contentsOfDirectory(
+ at: self.directory, includingPropertiesForKeys: nil
+ ) else { return }
+ for url in entries { try? FileManager.default.removeItem(at: url) }
+ }
+ }
+}
diff --git a/ios-vectortile/Sources/MapConductorVectorTile/TileMemoryCache.swift b/ios-vectortile/Sources/MapConductorVectorTile/TileMemoryCache.swift
new file mode 100644
index 0000000..f6e7ad7
--- /dev/null
+++ b/ios-vectortile/Sources/MapConductorVectorTile/TileMemoryCache.swift
@@ -0,0 +1,101 @@
+import Foundation
+
+/// Strict byte/count limits. Hits promote entries; idle entries expire.
+/// Unlike NSCache, insertion returns with the retained cost within budget.
+final class TileMemoryCache {
+ private final class Entry {
+ let key: String
+ let value: Value
+ let cost: Int
+ var accessed: TimeInterval
+ weak var previous: Entry?
+ var next: Entry?
+
+ init(key: String, value: Value, cost: Int, accessed: TimeInterval) {
+ self.key = key; self.value = value; self.cost = cost; self.accessed = accessed
+ }
+ }
+
+ private let lock = NSLock()
+ private let byteLimit: Int
+ private let countLimit: Int
+ private let ttl: TimeInterval
+ private let clock: () -> TimeInterval
+ private var entries: [String: Entry] = [:]
+ private var newest: Entry?
+ private var oldest: Entry?
+ private var bytes = 0
+
+ init(byteLimit: Int, countLimit: Int = 512, ttl: TimeInterval = 120,
+ clock: @escaping () -> TimeInterval = { ProcessInfo.processInfo.systemUptime }) {
+ self.byteLimit = max(0, byteLimit)
+ self.countLimit = max(0, countLimit)
+ self.ttl = ttl
+ self.clock = clock
+ }
+
+ func value(for key: String) -> Value? {
+ lock.lock(); defer { lock.unlock() }
+ let now = clock()
+ expire(now)
+ guard let entry = entries[key] else { return nil }
+ unlink(entry)
+ entry.accessed = now
+ prepend(entry)
+ return entry.value
+ }
+
+ func insert(_ value: Value, for key: String, cost: Int) {
+ lock.lock(); defer { lock.unlock() }
+ let now = clock()
+ expire(now)
+ if let old = entries[key] { remove(old) }
+ let cost = max(1, cost)
+ guard cost <= byteLimit, countLimit > 0 else { return }
+ while entries.count >= countLimit || bytes > byteLimit - cost {
+ guard let entry = oldest else { break }
+ remove(entry)
+ }
+ let entry = Entry(key: key, value: value, cost: cost, accessed: now)
+ entries[key] = entry
+ bytes += cost
+ prepend(entry)
+ }
+
+ private func expire(_ now: TimeInterval) {
+ while let entry = oldest, now - entry.accessed >= ttl { remove(entry) }
+ }
+
+ private func prepend(_ entry: Entry) {
+ entry.previous = nil
+ entry.next = newest
+ newest?.previous = entry
+ newest = entry
+ if oldest == nil { oldest = entry }
+ }
+
+ private func unlink(_ entry: Entry) {
+ if let previous = entry.previous { previous.next = entry.next } else { newest = entry.next }
+ if let next = entry.next { next.previous = entry.previous } else { oldest = entry.previous }
+ entry.previous = nil
+ entry.next = nil
+ }
+
+ private func remove(_ entry: Entry) {
+ unlink(entry)
+ entries.removeValue(forKey: entry.key)
+ bytes -= entry.cost
+ }
+
+ func removeAll() {
+ lock.lock(); defer { lock.unlock() }
+ // Break the chain iteratively rather than recursing through deinit.
+ while let entry = oldest { remove(entry) }
+ }
+
+ var retainedCost: Int {
+ lock.lock(); defer { lock.unlock() }
+ expire(clock())
+ return bytes
+ }
+}
diff --git a/ios-vectortile/Sources/MapConductorVectorTile/VectorStyleHandoff.swift b/ios-vectortile/Sources/MapConductorVectorTile/VectorStyleHandoff.swift
new file mode 100644
index 0000000..5d250ad
--- /dev/null
+++ b/ios-vectortile/Sources/MapConductorVectorTile/VectorStyleHandoff.swift
@@ -0,0 +1,157 @@
+import Foundation
+import MapConductorCore
+import MapConductorVectorStyle
+
+/**
+ Hands a style to a map that draws vector styles itself.
+
+ The style document is served by the SDK's local tile server and the map is
+ told, through its ``VectorStyleSupport``, to make it its basemap. Nothing is
+ rasterised: on MapLibre, Mapbox and MapTiler this is the whole of what
+ "vector tile layer as basemap" has to do, and it is the path an offline
+ package will take. A map without the capability cannot use this; it gets
+ ``VectorTileProvider`` and raster layers instead.
+
+ The document id is the content and nothing else. The map only re-reads a
+ style whose URL changed, so a changed style has to change the URL; but a
+ host that rebuilds its overlay content on a design change hands the style
+ over again from the rebuilt content, and if that minted a fresh URL it would
+ be a fresh design, another rebuild, and so on. Same content, same URL, and
+ the second handoff is a no-op.
+ */
+public final class VectorStyleHandoff {
+ public let documentId: String
+ public let url: String
+ /// The credits the style's sources ask for, in style order, without duplicates.
+ public let attributions: [String]
+ public let support: VectorStyleSupport
+
+ /**
+ What the caller should know about the rules it passed: a rule that
+ matched nothing, a rules document that could not be read, or an offline
+ package, whose style is served from the package and so is handed over
+ as it was packaged.
+
+ Empty whenever there were no rules. Nothing here stops the map being
+ drawn -- a bad rule costs the adjustment, never the style.
+ */
+ public let diagnostics: [String]
+
+ private let server: LocalTileServer
+ private var disposed = false
+
+ /// The files route serving the package, when there is one.
+ public let filesRoute: String?
+
+ /**
+ - Parameters:
+ - offlinePackage: a package the map reads its tiles, glyphs and sprite
+ from through the local server, which answers from the package and --
+ while `online` -- fetches upstream for the rest. The route carries the
+ mode: a map remembers a tile it was told does not exist, so going back
+ online has to change the URLs to be noticed.
+ - headers: sent with upstream fetches made for the package
+ */
+ public init(
+ styleJSON: String,
+ /// Adjustments applied before the map is given the document. The
+ /// same ``StyleRules`` ``VectorTileProvider`` takes, so the style
+ /// looks the same whether the map draws it or this rasterises it.
+ /// A rules document the compiler rejects is reported through
+ /// ``diagnostics`` and the author's style is handed over unchanged --
+ /// a bad rule must not cost the map.
+ rules: StyleRules = .none,
+ support: VectorStyleSupport,
+ server: LocalTileServer = TileServerRegistry.get(),
+ offlinePackage: OfflinePackage? = nil,
+ online: Bool = true,
+ headers: [String: String] = [:]
+ ) {
+ self.support = support
+ self.server = server
+ var notes: [String] = []
+ // Applied to the document, as in `VectorTileProvider` -- a map that
+ // draws the style itself is given the adjusted one, not the author's
+ // with a list of corrections.
+ var adjusted = styleJSON
+ if rules != .none {
+ do {
+ adjusted = try VectorStyleRules.compile(styleJSON: styleJSON, rulesJSON: rules.json).styleJSON
+ } catch {
+ notes.append("the rules could not be applied: \(error)")
+ }
+ }
+ let served: String
+ if let offlinePackage {
+ let route = "vectortile-package-\(offlinePackage.manifest.styleDigest.prefix(12))-\(online ? "online" : "offline")"
+ filesRoute = route
+ var fallback: (@Sendable (String) -> Data?)?
+ if online {
+ fallback = { relative in
+ guard
+ let upstream = offlinePackage.upstreamURL(forRelativePath: relative),
+ let target = VectorTileProvider.parse(upstream)
+ else { return nil }
+ return VectorTileProvider.get(target, headers: headers, cancellation: nil)
+ }
+ }
+ server.registerFiles(routeId: route, directory: offlinePackage.directory, fallback: fallback)
+ // The package serves its own style, rewritten to point at the
+ // files beside it. Rules cannot reach it from here, and saying so
+ // beats a silently unadjusted map.
+ if rules != .none { notes.append("rules do not apply to a style served from an offline package") }
+ served = offlinePackage.styleServed(by: server.filesUrl(routeId: route))
+ } else {
+ filesRoute = nil
+ served = adjusted
+ }
+ documentId = "vectortile-style-\(Self.digest(served))"
+ url = server.documentUrl(id: documentId)
+ attributions = Self.attributions(ofStyle: served)
+ diagnostics = notes
+ server.registerDocument(id: documentId, contentType: "application/json", body: Data(served.utf8))
+ support.showStyle(url: url, attributionRules: attributions.map { AttributionRule(attribution: $0) })
+ }
+
+ /// Restores the map's previous design and stops serving the document.
+ public func dispose() {
+ guard !disposed else { return }
+ disposed = true
+ support.clearStyle()
+ server.unregisterDocument(id: documentId)
+ if let filesRoute { server.unregisterFiles(routeId: filesRoute) }
+ }
+
+ /**
+ The `attribution` of every source in a style. A style that will not
+ parse credits nothing here; the map will refuse it anyway, more visibly.
+ */
+ public static func attributions(ofStyle json: String) -> [String] {
+ guard
+ let object = try? JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any],
+ let sources = object["sources"] as? [String: Any]
+ else { return [] }
+ var seen = Set()
+ var result: [String] = []
+ for key in sources.keys.sorted() {
+ guard
+ let source = sources[key] as? [String: Any],
+ let attribution = source["attribution"] as? String,
+ !attribution.trimmingCharacters(in: .whitespaces).isEmpty,
+ seen.insert(attribution).inserted
+ else { continue }
+ result.append(attribution)
+ }
+ return result
+ }
+
+ /// FNV-1a over the UTF-8 bytes: stable across launches, which `hashValue` is not.
+ private static func digest(_ text: String) -> String {
+ var hash: UInt64 = 0xcbf2_9ce4_8422_2325
+ for byte in text.utf8 {
+ hash ^= UInt64(byte)
+ hash = hash &* 0x0000_0100_0000_01b3
+ }
+ return String(hash, radix: 16)
+ }
+}
diff --git a/ios-vectortile/Sources/MapConductorVectorTile/VectorTileProvider.swift b/ios-vectortile/Sources/MapConductorVectorTile/VectorTileProvider.swift
new file mode 100644
index 0000000..d4c299a
--- /dev/null
+++ b/ios-vectortile/Sources/MapConductorVectorTile/VectorTileProvider.swift
@@ -0,0 +1,1293 @@
+import Foundation
+import MapConductorCore
+import MapConductorVectorStyle
+import UIKit
+
+/**
+ Renders a MapLibre vector style to raster tiles, for map backends that cannot
+ display a vector style themselves.
+
+ Register it with `TileServerRegistry.get()` and point a raster layer at the
+ resulting route; the backend only ever sees ordinary raster tiles, which is
+ what makes this work on MapKit, Google Maps, HERE, ArcGIS and the rest.
+
+ Network I/O happens here rather than in the native library, so the app's own
+ URLSession behaviour keeps applying: `headers` covers auth tokens, and
+ `fetchTile` hands over completely.
+
+ # Two layers, not one
+
+ A style is served as **two** stacked raster layers rather than one. `groundTiles`
+ draws fills, lines and circles — the half a GPU can draw, and the half no font
+ arriving can change. `labelTiles` draws the labels and icons on a transparent
+ ground. The map shows one map; the halves are rendered in parallel, and a glyph
+ range landing redraws only the transparent half, so a label appearing never
+ blanks the map beneath it.
+
+ `renderTile(request:)` still draws both into one tile, for callers that want a
+ single layer.
+ */
+public final class VectorTileProvider: TileProvider {
+ public static let defaultTileSize = 512
+
+ /**
+ The least grain the label layer is drawn at, as a multiple of the tile size.
+
+ Labels are the one thing on a map read as *shapes*, so they show a tile's
+ resolution the way nothing else does. On a 1x screen this is what keeps
+ them from looking like a fax; on a Retina screen `renderScale` already
+ asks for those pixels and this adds nothing.
+ */
+ public static let labelResolution = 2
+
+ /**
+ How many pixels to draw per point of screen.
+
+ Neither MapKit nor MapLibre asks for `@2x` tiles — both request at
+ `pixelRatio` 1 — so a tile drawn at its nominal size is stretched across
+ twice as many pixels on a Retina screen and every edge in it goes soft.
+ Small text is where that shows first, because a label is read as a shape
+ and a blurred shape reads as a smaller one.
+
+ This changes sharpness only. **How large the map looks is set by the tile
+ size the raster layer declares**, not by this: apparent size works out to
+ the style's own value: a tile is drawn for the size it is shown at. It
+ was once `tileSize / 512` -- the renderer assumed every tile covered
+ 512pt -- which drew everything at half size wherever that was untrue.
+ */
+ private let renderScale: Int
+
+ /// What a request should draw.
+ public enum Content {
+ /// Ground and labels in one tile: the single-layer behaviour.
+ case full
+ /// Fills, lines and circles. No labels, no icons, no glyph dependency.
+ case ground
+ /// Labels and icons on a transparent ground.
+ case labels
+ }
+
+ private let renderer: VectorTileRenderer
+ private let tileSize: Int
+ private let fetchTile: (URL, FetchCancellation?) throws -> Data?
+ private let sourceDiskCache: SourceTileDiskCache?
+
+ /// Source tiles keyed by URL.
+ ///
+ /// Not an optimisation detail: neighbouring target tiles routinely need the
+ /// same source tile — always, once overzoom kicks in, where one magnified
+ /// ancestor serves 16 targets — and a map asks for a whole viewport at once.
+ /// Budgeted by a strict byte limit and idle TTL. Counting
+ /// entries is the easy mistake: a basemap tile is 150-300 KB, so a few
+ /// hundred of them is tens of megabytes.
+ private var cache = TileMemoryCache(byteLimit: 16 << 20)
+ /// Present only when GPU rendering is in use.
+ private let gpu: MetalTileRasterizer?
+ private let gpuRenderCount = Counter()
+ private let gpuFallbackCount = Counter()
+ /// URLs known to hold nothing, so a missing tile is not re-requested.
+ private let empties = TileMemoryCache(byteLimit: 256 << 10, countLimit: 1024, ttl: 30)
+ private var memoryWarningObserver: NSObjectProtocol?
+ private let lock = NSLock()
+ private var closed = false
+
+ // MARK: Style assets
+
+ private let glyphCache: StyleAssetCache?
+ private let spriteCache: StyleAssetCache?
+
+ /// Rendered PNGs, so a second launch does not redraw what the first drew.
+ private let diskCache: TileDiskCache?
+ /// The ground of every tile the GPU drew, straight RGBA, keyed like the
+ /// disk cache. A glyph generation arriving redraws the tile, and with this
+ /// the redraw is the labels and a PNG encode, not the geometry again.
+ /// Strict LRU budget for raw RGBA; compressed source bytes have their own budget.
+ private let groundRasters = TileMemoryCache(byteLimit: 32 << 20, countLimit: 64)
+
+ /// Identifies the style these tiles were drawn from. A restyle changes it,
+ /// so the old tiles simply stop being found rather than needing to be
+ /// hunted down and deleted.
+ private var styleKey: String
+
+ /// Glyph ranges somebody has already claimed, so two tiles wanting the
+ /// same range do not both fetch it.
+ private var requestedGlyphs = Set()
+ private let glyphsInFlight = Counter()
+
+ /// Bumped when glyphs arrive, so tiles drawn before them stop being served.
+ private let glyphGeneration = Counter()
+ private var notifyPending = false
+
+ /**
+ Whether any tile has been drawn short of glyphs since the last handover.
+
+ Most glyph arrivals change nothing: the ranges landed before anything
+ needed them. Handing over anyway makes the map refetch and redraw the whole
+ viewport to arrive at the same pixels.
+ */
+ private var provisionalSinceHandover = false
+
+ /// True when the style names no `glyphs` template, so labels can never
+ /// need one and none of the fetching below has anything to do.
+ private let glyphsUnavailable: Bool
+
+ /// Fetches source tiles several at a time.
+ ///
+ /// Sized for the whole viewport rather than one tile: a plan is nine
+ /// fetches and a screen is several tiles, and fetching them one after
+ /// another spends a second waiting for what takes a fraction of it at once.
+ /// The threads spend their lives blocked on sockets, so they are cheap.
+ private let fetchSlots = DispatchSemaphore(value: 16)
+ private let fetchQueue = DispatchQueue(
+ label: "mapconductor.vectortile.fetch", attributes: .concurrent
+ )
+
+ /// Narrower than the tile pool on purpose: glyph ranges compete with the
+ /// source tiles that are needed to draw anything at all.
+ private let glyphQueue: OperationQueue = {
+ let queue = OperationQueue()
+ queue.name = "mapconductor.vectortile.glyphs"
+ queue.qualityOfService = .utility
+ queue.maxConcurrentOperationCount = 6
+ return queue
+ }()
+
+ /// One fetch per URL. Two tiles asking for the same source at the same
+ /// moment would otherwise both pay for it.
+ private var inFlight: [String: SourceFetchFlight] = [:]
+
+ /// Told when glyphs arrive and tiles drawn before them are now stale.
+ ///
+ /// The host must make the map refetch: the tiles already on screen were
+ /// drawn without those labels, and nothing else will replace them.
+ public var onGlyphsLoaded: (() -> Void)?
+
+ /// How long to let a burst of ranges settle before telling the host.
+ ///
+ /// A viewport's worth arrives within a second or two of each other, and
+ /// notifying per range redraws every visible tile dozens of times to reach
+ /// the same picture.
+ private let notifyQuiet: DispatchTimeInterval = .milliseconds(400)
+
+ /// How long to keep holding the window open while ranges are in flight.
+ ///
+ /// The quiet window alone is not enough: ranges do not arrive in one burst,
+ /// so the window closes between them again and again and every close is a
+ /// handover. The cap is there because a range that never answers must not
+ /// hold the labels back forever.
+ private let notifyMaxWait: TimeInterval = 10
+
+ /// How tiles are turned into pixels.
+ public enum RenderMode {
+ /// tiny-skia on the CPU. Always available, and the only path that
+ /// draws every layer type the renderer supports.
+ case cpu
+ /// Metal. Roughly twice as fast per tile, and — the reason it exists —
+ /// it spends the map's time on a processor the rest of the app is not
+ /// competing for.
+ case gpu
+ /// GPU where it initialises, CPU otherwise.
+ case auto
+ }
+
+ /// Which path this provider actually took, after `auto` resolved.
+ public var renderMode: RenderMode { gpu != nil ? .gpu : .cpu }
+
+ /// Tiles drawn on the GPU, and tiles that asked for the GPU and fell back.
+ ///
+ /// Worth reading in tests: a silent fallback looks exactly like success
+ /// from the outside, and a suite once passed with the GPU path failing on
+ /// every single tile.
+ public var gpuRenders: Int { gpuRenderCount.value }
+ public var gpuFallbacks: Int { gpuFallbackCount.value }
+
+ /// - Throws: `VectorTileError.styleRejected` if the style cannot be parsed,
+ /// or `renderFailed` if `renderMode` is `.gpu` and Metal is unavailable.
+ public init(
+ styleJSON: String,
+ /// Adjustments applied to the style before anything is drawn -- the
+ /// same rules ``VectorStyle`` takes, so "roads are white" or "no
+ /// buildings" reads the same wherever it is written. A rule that
+ /// hides a role is the reason to reach for this rather than editing
+ /// the document: those layers are switched off in what the renderer
+ /// is given, so they cost nothing to not draw.
+ rules: StyleRules = .none,
+ tileSize: Int = VectorTileProvider.defaultTileSize,
+ headers: [String: String] = [:],
+ cacheBytes: Int = 16 * 1024 * 1024,
+ renderMode: RenderMode = .auto,
+ /// Where to keep glyph ranges, the sprite sheet, and rendered tiles
+ /// between launches. Nil disables all three, at the cost of refetching
+ /// and redrawing them on every cold start.
+ assetCacheDirectory: URL? = nil,
+ /// Budget for rendered tiles on disk. Rendered PNGs are 2-3x larger
+ /// than a network tile would be — the encoder trades ratio for speed —
+ /// so this is generous by design.
+ renderedCacheBytes: Int = 48 * 1024 * 1024,
+ /// Budget for fetched source MVT/PBF tiles kept across launches.
+ sourceCacheBytes: Int = 128 * 1024 * 1024,
+ /// Pixels drawn per point of screen. Defaults to the display's own
+ /// scale, which is what stops a Retina screen stretching every tile.
+ renderScale: Int? = nil,
+ /// Replaces the network for source tiles, glyph ranges and the
+ /// sprite. Nil means "no such resource" and is remembered; a thrown
+ /// ``OfflineUnavailableError`` means "not now" and is not.
+ fetchTile: ((URL) throws -> Data?)? = nil
+ ) throws {
+ // The renderer needs the points a tile covers on screen, not the
+ // pixels a render asks for (the label pass draws at twice the
+ // pixels). It sets the size the style draws at and the zoom its
+ // expressions are read at.
+ let drawn = try drawnStyleJSON(styleJSON, rules)
+ self.renderer = try VectorTileRenderer(styleJSON: drawn, displayTileSize: tileSize)
+ self.tileSize = tileSize
+ if let fetchTile {
+ // Public test/custom transports keep their existing synchronous
+ // contract. The built-in URLSession path below is cancellable.
+ self.fetchTile = { url, _ in try fetchTile(url) }
+ } else {
+ self.fetchTile = { url, cancellation in
+ try VectorTileProvider.fetch(url, headers: headers, cancellation: cancellation)
+ }
+ }
+ cache = TileMemoryCache(byteLimit: cacheBytes)
+ self.glyphsUnavailable = (try? renderer.glyphsURLTemplate()) == nil
+ // The *drawn* document, not the author's: rendered tiles are keyed by
+ // it, and a style with its buildings hidden must not be served the
+ // tiles drawn with them -- nor leave its own behind for the style
+ // that comes back.
+ self.styleKey = Digest.hex(drawn)
+ self.renderScale = max(1, renderScale ?? Int(UIScreen.main.scale.rounded()))
+
+ // Separate directories: a glyph range and a sprite sheet have nothing
+ // to do with each other, and clearing one should not take the other.
+ self.glyphCache = assetCacheDirectory.flatMap {
+ StyleAssetCache(directory: $0.appendingPathComponent("glyphs"))
+ }
+ self.spriteCache = assetCacheDirectory.flatMap {
+ StyleAssetCache(directory: $0.appendingPathComponent("sprites"))
+ }
+ self.diskCache = assetCacheDirectory.flatMap {
+ TileDiskCache(
+ directory: $0.appendingPathComponent("tiles"),
+ budgetBytes: renderedCacheBytes
+ )
+ }
+ self.sourceDiskCache = assetCacheDirectory.flatMap {
+ SourceTileDiskCache(
+ directory: $0.appendingPathComponent("sources"),
+ budgetBytes: sourceCacheBytes
+ )
+ }
+
+ // Built at the size tiles are actually drawn at: the rasteriser has one
+ // fixed target, so a scale it does not know about would be a readback
+ // of the wrong size.
+ let drawnSize = tileSize * self.renderScale
+ switch renderMode {
+ case .cpu:
+ self.gpu = nil
+ case .auto:
+ self.gpu = MetalTileRasterizer.createOrNull(tileSize: drawnSize)
+ case .gpu:
+ // Explicitly asking for the GPU and quietly getting the CPU is the
+ // failure that hides worst, so this one throws.
+ guard let rasterizer = MetalTileRasterizer.createOrNull(tileSize: drawnSize) else {
+ throw VectorTileError.renderFailed(MvtStatus.renderFailed)
+ }
+ self.gpu = rasterizer
+ }
+
+ memoryWarningObserver = NotificationCenter.default.addObserver(
+ forName: UIApplication.didReceiveMemoryWarningNotification, object: nil, queue: nil
+ ) { [weak self] _ in
+ self?.cache.removeAll()
+ self?.groundRasters.removeAll()
+ self?.empties.removeAll()
+ }
+ warmStyleAssets()
+ }
+
+ deinit {
+ if let memoryWarningObserver { NotificationCenter.default.removeObserver(memoryWarningObserver) }
+ close()
+ }
+
+ private func beginClose() -> Bool {
+ lock.lock()
+ defer { lock.unlock() }
+ guard !closed else { return false }
+ closed = true
+ onGlyphsLoaded = nil
+ empties.removeAll()
+ return true
+ }
+
+ private func releaseResources() {
+ renderer.close()
+ // A successor may share these source bytes. The bounded cache releases them
+ // when the last provider goes away; closing one must not evict them.
+ groundRasters.removeAll()
+ diskCache?.flush()
+ }
+
+ /// Called only before the successor is registered with the tile server.
+ /// Styles in one rasterisation use the same transport and headers, so
+ /// successful source bytes remain valid across paint changes.
+ func reuseSourceTiles(from previous: VectorTileProvider) {
+ cache = previous.cache
+ }
+
+ /// Stops accepting requests immediately; waits for active native calls off the UI thread.
+ public func closeAsync() {
+ guard beginClose() else { return }
+ DispatchQueue.global(qos: .utility).async { self.releaseResources() }
+ }
+
+ /// Releases resources synchronously. UI owners should use `closeAsync()`.
+ public func close() {
+ guard beginClose() else { return }
+ releaseResources()
+ }
+
+ private var isClosed: Bool {
+ lock.lock()
+ defer { lock.unlock() }
+ return closed
+ }
+
+ // MARK: - What the host must show
+
+ /**
+ The credits this style's sources ask to be shown.
+
+ These are not optional. A style is data under someone's licence, and a
+ basemap drawing OpenStreetMap requires the credit; reading it out of the
+ style and then not showing it is the one way this provider can be used
+ wrongly without anything looking wrong. May contain HTML — the credit is
+ normally a link to the licence.
+ */
+ public func attributions() -> [String] {
+ (try? renderer.attributions()) ?? []
+ }
+
+ /// Reasons the current style may not render as intended.
+ public func diagnostics() -> [String] {
+ (try? renderer.diagnostics()) ?? []
+ }
+
+ /// Replaces the style. Fetched vector tiles stay valid — the geometry is
+ /// unchanged, only the paint applied to it — so recolouring costs a
+ /// re-rasterise and no network traffic.
+ ///
+ /// The caller still has to make the map drop its *raster* tiles; pass a new
+ /// `cacheKey` to `LocalTileServer.urlTemplate`.
+ public func setStyle(_ styleJSON: String, rules: StyleRules = .none) throws {
+ let drawn = try drawnStyleJSON(styleJSON, rules)
+ try renderer.setStyle(drawn)
+ // Rendered tiles are keyed by style, so the old ones simply stop being
+ // found. Nothing has to be deleted.
+ styleKey = Digest.hex(drawn)
+ warmStyleAssets()
+ }
+
+ /// The generation to put in a tile URL, so tiles drawn short of their
+ /// glyphs stop being served once the glyphs land.
+ public var glyphGenerationValue: Int { glyphGeneration.value }
+
+ // MARK: - The two halves
+
+ /// The ground alone: fills, lines and circles, drawn on the GPU where there
+ /// is one. Never invalidated by a font arriving.
+ public private(set) lazy var groundTiles: TileProvider = Facade(self, .ground)
+
+ /// The labels and icons alone, on a transparent ground.
+ public private(set) lazy var labelTiles: TileProvider = Facade(self, .labels)
+
+ /// One half of a split layer, as something a tile server can register.
+ private final class Facade: TileProvider {
+ private let content: Content
+ private weak var owner: VectorTileProvider?
+
+ init(_ owner: VectorTileProvider, _ content: Content) {
+ self.owner = owner
+ self.content = content
+ }
+
+ func renderTile(request: TileRequest) -> Data? {
+ owner?.renderTile(request: request, content: content) { false }
+ }
+
+ func renderTile(request: TileRequest, isCancelled: () -> Bool) -> Data? {
+ owner?.renderTile(request: request, content: content, isCancelled: isCancelled)
+ }
+ }
+
+ public func renderTile(request: TileRequest) -> Data? {
+ renderTile(request: request, content: .full) { false }
+ }
+
+ public func renderTile(request: TileRequest, isCancelled: () -> Bool) -> Data? {
+ renderTile(request: request, content: .full, isCancelled: isCancelled)
+ }
+
+ /**
+ Draws one tile.
+
+ - Parameter isCancelled: asked repeatedly while the tile is being drawn.
+ A map that has moved on is not owed this tile, and the render slots are
+ shared, so a doomed tile is drawn *instead of* one still on screen.
+ */
+ public func renderTile(
+ request: TileRequest,
+ content: Content = .full,
+ isCancelled: () -> Bool = { false }
+ ) -> Data? {
+ // Dispatch workers otherwise retain temporary Foundation/Metal objects
+ // until their outer pool drains, potentially across many panned tiles.
+ autoreleasepool {
+ renderTileInPool(request: request, content: content, isCancelled: isCancelled)
+ }
+ }
+
+ private func renderTileInPool(
+ request: TileRequest, content: Content, isCancelled: () -> Bool
+ ) -> Data? {
+ if isClosed || isCancelled() { return nil }
+
+ let sourceZoomOffset = request.sourceZoomOffset
+ let z = UInt8(clamping: request.z)
+ let x = UInt32(clamping: request.x)
+ let y = UInt32(clamping: request.y)
+
+ /*
+ Two keys, because a rendered tile goes stale for one reason only: it
+ was drawn while some of its glyphs were still on the way. A tile that
+ had them all is finished, and no later arrival can change it, so it is
+ stored under a key with no generation in it and survives every
+ handover. Only the ones drawn short are tied to the generation they
+ were drawn at, and only they are redrawn.
+ */
+ func key(_ generation: String) -> String {
+ Digest.hex(
+ styleKey,
+ "\(tileSize)",
+ // A build that draws more than the one that filled this cache
+ // must not serve its tiles.
+ "v\(VectorTileRenderer.outputVersion)",
+ generation,
+ "scale\(renderScale)",
+ "sourceOffset\(sourceZoomOffset)",
+ "\(request.z)/\(request.x)/\(request.y)"
+ )
+ }
+ let completeKey: String? = diskCache.map { _ in
+ switch content {
+ // The ground has no glyphs to be short of; one key, forever.
+ case .ground: return key("ground")
+ case .labels: return key("labels-complete")
+ case .full: return key("complete")
+ }
+ }
+ let provisionalKey: String? = diskCache.flatMap { _ in
+ switch content {
+ case .ground: return nil
+ case .labels: return key("labels-g\(glyphGeneration.value)")
+ case .full: return key("g\(glyphGeneration.value)")
+ }
+ }
+ for candidate in [completeKey, provisionalKey].compactMap({ $0 }) {
+ if let hit = diskCache?.get(candidate) { return hit }
+ }
+
+ guard let plan = plan(z: z, x: x, y: y, sourceZoomOffset: sourceZoomOffset) else { return nil }
+
+ // The ground never draws a label, so the ring of neighbours the label
+ // pass asks for is nine fetches it has no use for. The entries stay in
+ // place — the native side reads the plan positionally — but hold
+ // nothing.
+ let wanted = plan.map { entry in
+ content == .ground && (entry["labelsOnly"] as? Bool ?? false)
+ ? nil
+ : (entry["url"] as? String).flatMap(VectorTileProvider.parse)
+ }
+ let priorities = plan.enumerated().map { index, entry in
+ ((entry["labelsOnly"] as? Bool ?? false) ? 1_000 : 0) + index
+ }
+ let fetchStarted = DispatchTime.now()
+ let fetchStats = SourceFetchStats()
+ guard let tiles = fetchAll(wanted, priorities: priorities, stats: fetchStats, isCancelled: isCancelled) else {
+ VectorTileProvider.debug(
+ "tile \(request.z)/\(request.x)/\(request.y) content=\(content) cancelled "
+ + "mvt(mem=\(fetchStats.memoryHits) disk=\(fetchStats.diskHits) "
+ + "net=\(fetchStats.networkFetches) shared=\(fetchStats.sharedWaits) "
+ + "blocked=\(fetchStats.blocked) cancel=\(fetchStats.cancelled) "
+ + "queue=\(fetchStats.queueWaitMs)ms)"
+ )
+ return nil
+ }
+ let fetchMs = VectorTileProvider.millis(since: fetchStarted)
+ if isClosed || isCancelled() { return nil }
+
+ // Cached ranges belong in the store before the tile is judged to be
+ // missing them; otherwise the first tile of a launch is drawn bare and
+ // handed over a moment later for no reason. The ground has no glyphs,
+ // so it never waits.
+ let short = content != .ground && requestGlyphs(z: z, x: x, y: y, tiles: tiles, sourceZoomOffset: sourceZoomOffset)
+ if short {
+ lock.lock()
+ provisionalSinceHandover = true
+ lock.unlock()
+ }
+ if isClosed || isCancelled() { return nil }
+
+ let renderStarted = DispatchTime.now()
+ let png: Data?
+ let groundKey = key("ground")
+ // A tile drawn while the network was off and a source was not in the
+ // package is missing ink, like one drawn short of glyphs -- but
+ // nothing will arrive to redraw it, so it must not be kept at all.
+ let drawnShortOfSources = fetchStats.blocked > 0
+ switch content {
+ case .labels:
+ png = renderLabelTile(z: z, x: x, y: y, tiles: tiles, sourceZoomOffset: sourceZoomOffset)
+ case .full:
+ if let ground = groundRasters.value(for: groundKey) {
+ png = compositeLabels(onto: ground as Data, z: z, x: x, y: y, tiles: tiles, sourceZoomOffset: sourceZoomOffset)
+ } else {
+ png = renderGroundTile(
+ z: z, x: x, y: y, tiles: tiles, content: content,
+ cacheGroundAs: drawnShortOfSources ? nil : groundKey, isCancelled: isCancelled,
+ sourceZoomOffset: sourceZoomOffset
+ )
+ }
+ case .ground:
+ png = renderGroundTile(z: z, x: x, y: y, tiles: tiles, content: content, cacheGroundAs: nil, isCancelled: isCancelled, sourceZoomOffset: sourceZoomOffset)
+ }
+ let renderMs = VectorTileProvider.millis(since: renderStarted)
+
+ // Timed separately, and logged: when a tile is slow the answer is
+ // almost always one or the other, and guessing which wastes an
+ // afternoon. android-sdk's provider prints the same line; iOS had no
+ // measurement at all, which is why "it feels slow" had nowhere to go.
+ VectorTileProvider.debug(
+ "tile \(request.z)/\(request.x)/\(request.y) content=\(content) "
+ + "sources=\(wanted.compactMap { $0 }.count) planned=\(plan.count) "
+ + "fetch=\(fetchMs)ms render=\(renderMs)ms bytes=\(png?.count ?? 0) "
+ + "mvt(mem=\(fetchStats.memoryHits) disk=\(fetchStats.diskHits) "
+ + "net=\(fetchStats.networkFetches) shared=\(fetchStats.sharedWaits) "
+ + "blocked=\(fetchStats.blocked) cancel=\(fetchStats.cancelled) "
+ + "queue=\(fetchStats.queueWaitMs)ms)"
+ )
+
+ if let png, !drawnShortOfSources, let store = short ? provisionalKey : completeKey {
+ diskCache?.put(store, png)
+ }
+ return png
+ }
+
+ private static func millis(since start: DispatchTime) -> Int {
+ Int((DispatchTime.now().uptimeNanoseconds &- start.uptimeNanoseconds) / 1_000_000)
+ }
+
+ /// On only when the app asks for it, so a release build prints nothing.
+ private static let debugLogging =
+ ProcessInfo.processInfo.environment["MAPCONDUCTOR_DEBUG_LOG"] == "1"
+
+ private static func debug(_ message: @autoclosure () -> String) {
+ guard debugLogging else { return }
+ NSLog("[MapConductor][VectorTile] %@", message())
+ }
+
+ private func plan(z: UInt8, x: UInt32, y: UInt32, sourceZoomOffset: Int) -> [[String: Any]]? {
+ guard let json = try? renderer.plan(z: z, x: x, y: y, sourceZoomOffset: sourceZoomOffset),
+ let data = json.data(using: .utf8),
+ let plan = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]]
+ else { return nil }
+ return plan
+ }
+
+ /// The ground, on the GPU when there is one and the style paints nothing
+ /// it cannot draw. `.full` composites labels over the readback.
+ private func renderGroundTile(
+ z: UInt8, x: UInt32, y: UInt32, tiles: [Data?], content: Content, cacheGroundAs: String?,
+ isCancelled: () -> Bool,
+ sourceZoomOffset: Int
+ ) -> Data? {
+ // Patterns need the CPU; very complex polygons also use its scanline
+ // renderer to avoid expensive GPU triangulation.
+ let onGpu = gpu != nil && !((try? renderer.needsCPU(z: z, tiles: tiles, sourceZoomOffset: sourceZoomOffset)) ?? false)
+ if onGpu, let png = renderOnGpu(
+ z: z, x: x, y: y, tiles: tiles, withLabels: content == .full, cacheGroundAs: cacheGroundAs,
+ isCancelled: isCancelled,
+ sourceZoomOffset: sourceZoomOffset
+ ) {
+ gpuRenderCount.increment()
+ return png
+ }
+ if isClosed || isCancelled() { return nil }
+ if onGpu { gpuFallbackCount.increment() }
+
+ // A GPU failure must not lose the tile; the CPU can always draw it.
+ Self.debug("cpu tile \(z)/\(x)/\(y)")
+ let resolution = UInt32(tileSize * renderScale)
+ if content == .ground {
+ return try? renderer.renderGeometry(
+ z: z, x: x, y: y, tileSize: resolution, tiles: tiles,
+ sourceZoomOffset: sourceZoomOffset
+ )
+ }
+ return try? renderer.render(z: z, x: x, y: y, tileSize: resolution, tiles: tiles, sourceZoomOffset: sourceZoomOffset)
+ }
+
+ /**
+ The labels and icons on a transparent ground.
+
+ Pure CPU: no GPU pass, no readback — which is the point of serving them as
+ their own layer. Several of these run while the GPU draws the ground.
+ */
+ private func renderLabelTile(z: UInt8, x: UInt32, y: UInt32, tiles: [Data?], sourceZoomOffset: Int) -> Data? {
+ // The larger of the two, not the product: `labelResolution` exists to
+ // reach device resolution on a 1x screen, and `renderScale` already
+ // does that on a Retina one. Multiplying them would draw sixteen times
+ // the pixels for no visible gain.
+ let resolution = tileSize * max(renderScale, VectorTileProvider.labelResolution)
+ guard let drawn = try? renderer.renderLabels(
+ z: z, x: x, y: y, tileSize: UInt32(resolution), tiles: tiles,
+ sourceZoomOffset: sourceZoomOffset
+ ) else { return nil }
+
+ // A tile with nothing on it is common — water, fields — and one shared
+ // transparent PNG serves them all.
+ guard drawn.placed > 0, !drawn.pixels.isEmpty else { return emptyLabelTile }
+
+ var pixels = drawn.pixels
+ return pixels.withUnsafeMutableBytes { buffer -> Data? in
+ guard let base = buffer.baseAddress else { return nil }
+ // Premultiplied: the label pass writes colour already scaled by
+ // alpha, and encoding that as straight alpha turns every halo grey.
+ return TilePngEncoder.encode(
+ rgba: base, width: resolution, height: resolution, premultiplied: true
+ )
+ }
+ }
+
+ /// One transparent tile, encoded once and served for every empty one.
+ private lazy var emptyLabelTile: Data? = {
+ var pixels = [UInt8](repeating: 0, count: 4)
+ return pixels.withUnsafeMutableBytes { buffer -> Data? in
+ guard let base = buffer.baseAddress else { return nil }
+ return TilePngEncoder.encode(rgba: base, width: 1, height: 1, premultiplied: true)
+ }
+ }()
+
+ private func renderOnGpu(
+ z: UInt8, x: UInt32, y: UInt32, tiles: [Data?], withLabels: Bool, cacheGroundAs: String?,
+ isCancelled: () -> Bool,
+ sourceZoomOffset: Int
+ ) -> Data? {
+ guard !isClosed, !isCancelled(), let gpu else { return nil }
+ do {
+ let started = DispatchTime.now()
+ let tessellated = try renderer.tessellate(
+ z: z, x: x, y: y, tileSize: UInt32(tileSize * renderScale), tiles: tiles,
+ sourceZoomOffset: sourceZoomOffset
+ )
+ let tessellateMs = Self.millis(since: started)
+ guard !isClosed, !isCancelled() else { return nil }
+ let drawStarted = DispatchTime.now()
+ // The tessellator draws fills and lines; labels come from a
+ // distance field per glyph and are painted over the readback.
+ // Without this the GPU path loses every label the style asked for.
+ let png = gpu.renderPng(tessellated, isCancelled: isCancelled) { readback in
+ if let cacheGroundAs, let base = readback.baseAddress {
+ self.groundRasters.insert(
+ Data(bytes: base, count: readback.count),
+ for: cacheGroundAs, cost: readback.count + cacheGroundAs.utf8.count + 128
+ )
+ }
+ guard withLabels else { return }
+ _ = try? self.renderer.drawLabels(
+ z: z, x: x, y: y, tileSize: UInt32(self.tileSize * self.renderScale),
+ rgba: readback, tiles: tiles,
+ sourceZoomOffset: sourceZoomOffset
+ )
+ }
+ Self.debug("gpu tile \(z)/\(x)/\(y) tessellate=\(tessellateMs)ms draw=\(Self.millis(since: drawStarted))ms")
+ return png
+ } catch {
+ return nil
+ }
+ }
+
+ /// Labels over a ground the GPU drew earlier: the whole cost of a glyph
+ /// generation arriving, once the ground is in ``groundRasters``.
+ private func compositeLabels(
+ onto ground: Data, z: UInt8, x: UInt32, y: UInt32, tiles: [Data?],
+ sourceZoomOffset: Int
+ ) -> Data? {
+ let resolution = tileSize * renderScale
+ guard ground.count == resolution * resolution * 4 else { return nil }
+ var rgba = ground
+ guard (try? renderer.drawLabels(
+ z: z, x: x, y: y, tileSize: UInt32(resolution), rgba: &rgba, tiles: tiles,
+ sourceZoomOffset: sourceZoomOffset
+ )) != nil else { return nil }
+ return rgba.withUnsafeMutableBytes { buffer -> Data? in
+ guard let base = buffer.baseAddress else { return nil }
+ return TilePngEncoder.encode(
+ rgba: base, width: resolution, height: resolution, premultiplied: false
+ )
+ }
+ }
+
+ // MARK: - Fetching
+
+ /// Fetches a whole plan at once.
+ ///
+ /// A plan is nine tiles once the label pass asks for its neighbours, and
+ /// nine round trips one after another is a second of waiting for what takes
+ /// a fraction of it in parallel.
+ private func fetchAll(
+ _ urls: [URL?],
+ priorities: [Int],
+ stats: SourceFetchStats,
+ isCancelled: () -> Bool
+ ) -> [Data?]? {
+ let results = FetchResults(count: urls.count)
+ let group = DispatchGroup()
+ let cancellation = FetchCancellation()
+ defer { cancellation.cancel() }
+
+ // Sorted on this thread, before anything is dispatched: what is
+ // already here, what somebody else is fetching, and what is ours to
+ // fetch. Only the last kind goes to the queue and takes a slot.
+ //
+ // It used to take a slot for every entry, on this thread, before it
+ // knew which kind it was -- and a tile whose source somebody else was
+ // already fetching then sat in the queue holding a slot just to wait
+ // for them. On a zoom out every source is new and sixteen children
+ // want each one, so the sixteen slots filled with waiters and the
+ // fetches that would have released them could not start. That is the
+ // 1.3 s per request the simulator showed while nothing moved.
+ var waitingOn: [(index: Int, key: String, latch: SourceFetchFlight)] = []
+
+ let ordered = urls.indices.sorted { left, right in
+ (priorities.indices.contains(left) ? priorities[left] : left)
+ < (priorities.indices.contains(right) ? priorities[right] : right)
+ }
+
+ for index in ordered {
+ guard let url = urls[index] else { continue }
+ let key = url.absoluteString
+ if let cached = cache.value(for: key) {
+ stats.memoryHits += 1
+ results.set(cached as Data, at: index)
+ continue
+ }
+ lock.lock()
+ if empties.value(for: key) == true {
+ lock.unlock()
+ continue
+ }
+ if let latch = inFlight[key] {
+ lock.unlock()
+ stats.sharedWaits += 1
+ waitingOn.append((index, key, latch))
+ continue
+ }
+ let done = SourceFetchFlight()
+ inFlight[key] = done
+ lock.unlock()
+
+ group.enter()
+ fetchQueue.async {
+ defer { group.leave() }
+ results.set(
+ self.transfer(url, key: key, done: done, cancellation: cancellation, stats: stats),
+ at: index
+ )
+ }
+ }
+
+ while group.wait(timeout: .now() + .milliseconds(10)) != .success {
+ if isClosed || isCancelled() {
+ cancellation.cancel()
+ stats.cancelled += 1
+ return nil
+ }
+ }
+
+ // The ones somebody else was fetching ran alongside ours; waiting for
+ // them now costs the slowest of them, not the sum, and holds nothing
+ // but this thread.
+ for (index, key, originalLatch) in waitingOn {
+ var latch = originalLatch
+ while true {
+ while latch.wait(timeout: .now() + .milliseconds(10)) != .success {
+ if isClosed || isCancelled() { return nil }
+ }
+ latch.signal()
+ // An oversized or evicted tile still belongs to all requests
+ // already sharing this flight. Keep it only until they finish.
+ let response = latch.response
+ if response.received {
+ results.set(response.bytes, at: index)
+ break
+ }
+ if let bytes = cache.value(for: key) {
+ stats.memoryHits += 1
+ results.set(bytes as Data, at: index)
+ break
+ }
+ if let bytes = sourceDiskCache?.get(key) {
+ stats.diskHits += 1
+ cache.insert(bytes, for: key, cost: bytes.count + key.utf8.count + 128)
+ results.set(bytes, at: index)
+ break
+ }
+ if latch.isUnavailable {
+ stats.blocked += 1
+ break
+ }
+ // They were cancelled before the bytes landed, and a tile left
+ // without its source is drawn blank and cached that way. Fetch it
+ // ourselves rather than inherit their bad luck.
+ lock.lock()
+ if empties.value(for: key) == true {
+ lock.unlock()
+ break
+ }
+ let done = inFlight[key] ?? SourceFetchFlight()
+ let ours = inFlight[key] == nil
+ if ours { inFlight[key] = done }
+ lock.unlock()
+ if ours, let url = urls[index] {
+ // A cancelled owner may leave several waiters. The retry
+ // must stay cancellable too, rather than blocking this render
+ // thread in a synchronous 15-second transfer.
+ let retry = DispatchGroup()
+ retry.enter()
+ fetchQueue.async {
+ defer { retry.leave() }
+ results.set(self.transfer(
+ url, key: key, done: done, cancellation: cancellation, stats: stats
+ ), at: index)
+ }
+ while retry.wait(timeout: .now() + .milliseconds(10)) != .success {
+ if isClosed || isCancelled() { return nil }
+ }
+ break
+ }
+ // Another waiter claimed the retry. Wait for its actual result;
+ // returning now would draw and cache a tile without that source.
+ latch = done
+ }
+ }
+ return results.snapshot()
+ }
+
+ /// The transfer itself, and nothing else under a fetch slot.
+ ///
+ /// Caller has already claimed `key` in `inFlight` with `done`; this
+ /// releases both however it ends, so waiters always wake.
+ private func transfer(
+ _ url: URL,
+ key: String,
+ done: SourceFetchFlight,
+ cancellation: FetchCancellation,
+ stats: SourceFetchStats
+ ) -> Data? {
+ defer {
+ lock.lock()
+ inFlight.removeValue(forKey: key)
+ lock.unlock()
+ done.signal()
+ }
+ let queued = DispatchTime.now()
+ while fetchSlots.wait(timeout: .now() + .milliseconds(10)) != .success {
+ if cancellation.isCancelled || isClosed { return nil }
+ }
+ stats.queueWaitMs += VectorTileProvider.millis(since: queued)
+ defer { fetchSlots.signal() }
+ if cancellation.isCancelled || isClosed { return nil }
+
+ // Claim the URL before disk I/O so neighbouring tiles share cold reads.
+ if let cached = cache.value(for: key) {
+ stats.memoryHits += 1
+ done.publish(cached)
+ return cached as Data
+ }
+ if let cached = sourceDiskCache?.get(key) {
+ stats.diskHits += 1
+ cache.insert(cached, for: key, cost: cached.count + key.utf8.count + 128)
+ done.publish(cached)
+ return cached
+ }
+ stats.networkFetches += 1
+ let fetched: Data?
+ do {
+ fetched = try fetchTile(url, cancellation)
+ } catch {
+ // Not an answer: the network is off, or failed. Either way the
+ // tile is drawn without this source and not kept, so it is
+ // drawn again once the network is back -- and the URL is not
+ // remembered as empty.
+ stats.blocked += 1
+ done.markUnavailable()
+ return nil
+ }
+ guard let bytes = fetched, !bytes.isEmpty else {
+ if cancellation.isCancelled { return nil }
+ // Only a genuine "no tile" answer is remembered. A transient
+ // failure blacklisted here would leave a hole for the session.
+ lock.lock()
+ empties.insert(true, for: key, cost: key.utf8.count + 128)
+ lock.unlock()
+ done.publish(nil)
+ return nil
+ }
+ cache.insert(bytes, for: key, cost: bytes.count + key.utf8.count + 128)
+ sourceDiskCache?.put(key, bytes)
+ done.publish(bytes)
+ return bytes
+ }
+
+ // MARK: - Glyphs and sprites
+
+ /// Loads whatever is already on disk, and fetches the sprite if it is not.
+ private func warmStyleAssets() {
+ guard !isClosed else { return }
+ fetchQueue.async { [weak self] in
+ guard let self, !self.isClosed else { return }
+ if let cache = self.glyphCache, (try? self.renderer.glyphsURLTemplate()) != nil {
+ // Nothing to enumerate a disk cache by — the ranges a style
+ // wants are only known per tile — so warming happens as tiles
+ // ask, through `requestGlyphs`, which checks the cache first.
+ _ = cache
+ }
+ self.loadSprite()
+ }
+ }
+
+ /// Fetches the sprite sheet, preferring what is already on disk.
+ ///
+ /// Icons are drawn only where the sheet has them, so until this lands a
+ /// style's shields and pins are simply absent.
+ private func loadSprite() {
+ guard (try? renderer.needsSprite()) == true,
+ let urls = try? renderer.spriteURLs(pixelRatio: 2)
+ else { return }
+
+ let index = spriteCache?.get(urls.json) ?? fetched(urls.json, into: spriteCache)
+ let sheet = spriteCache?.get(urls.png) ?? fetched(urls.png, into: spriteCache)
+ guard let index, let sheet,
+ let text = String(data: index, encoding: .utf8), !isClosed
+ else { return }
+ _ = try? renderer.addSprite(json: text, png: sheet)
+ }
+
+ private func fetched(_ url: String, into store: StyleAssetCache?) -> Data? {
+ guard let target = VectorTileProvider.parse(url) else { return nil }
+ do {
+ guard let bytes = try fetchTile(target, nil), !bytes.isEmpty else { return nil }
+ store?.put(url, bytes)
+ return bytes
+ } catch is OfflineUnavailableError {
+ // Unclaimed: the range is there to be had once the network is
+ // back. Harmless for the sprite, which is not claimed.
+ lock.lock()
+ requestedGlyphs.remove(url)
+ lock.unlock()
+ return nil
+ } catch {
+ return nil
+ }
+ }
+
+ /**
+ Builds a URL from a string a style produced.
+
+ `URL(string:)` returns nil for a space, and glyph URLs are full of them:
+ the template's `{fontstack}` is a font *name*, so a MapLibre style asks for
+ `.../font/Open Sans Semibold/0-255.pbf`. Rejecting those silently meant no
+ glyph was ever fetched and no label was ever drawn, with nothing logged --
+ the tiles came out bare and looked like a renderer that could not draw
+ text.
+ */
+ static func parse(_ url: String) -> URL? {
+ if let direct = URL(string: url) { return direct }
+ return url
+ .addingPercentEncoding(withAllowedCharacters: .urlFragmentAllowed)
+ .flatMap(URL.init(string:))
+ }
+
+ /**
+ Starts fetching the glyph ranges this tile's labels need, and returns.
+
+ Waiting is the obvious thing and the wrong one: a range is a 0.4-1.3 second
+ round trip, a tile at low zoom wants dozens, and waiting means the tile
+ draws nothing until the last one lands. The tile is drawn with whatever is
+ already in, exactly as MapLibre does it, and the ranges that arrive later
+ bring their labels with them through `onGlyphsLoaded`.
+
+ - Returns: whether this tile is about to be drawn without glyphs it wants,
+ which is what decides whether a handover is worth making.
+ */
+ @discardableResult
+ private func requestGlyphs(z: UInt8, x: UInt32, y: UInt32, tiles: [Data?], sourceZoomOffset: Int) -> Bool {
+ if glyphsUnavailable || isClosed { return false }
+ let needed = (try? renderer.neededGlyphs(z: z, x: x, y: y, tiles: tiles, sourceZoomOffset: sourceZoomOffset)) ?? []
+ if needed.isEmpty { return false }
+
+ lock.lock()
+ let mine = needed.filter { requestedGlyphs.insert($0).inserted }
+ lock.unlock()
+ if mine.isEmpty { return true }
+
+ for url in mine {
+ glyphsInFlight.increment()
+ // Admission belongs on the asset queue. Acquiring a seventh
+ // permit on the render thread holds a server slot for an entire
+ // network round trip (or timeout) before any pixels are returned.
+ glyphQueue.addOperation {
+ defer { self.glyphsInFlight.decrement() }
+ guard !self.isClosed else { return }
+ // Ranges never change, so a hit here is kept without expiry.
+ let bytes = self.glyphCache?.get(url) ?? self.fetched(url, into: self.glyphCache)
+ guard let bytes, !bytes.isEmpty else { return }
+ // Left in the claimed set on failure: a range the server does
+ // not have will not appear on a retry.
+ if let added = try? self.renderer.addGlyphs(bytes), added > 0 {
+ self.glyphsArrived()
+ }
+ }
+ }
+ return true
+ }
+
+ /**
+ Tells the host that tiles drawn before now are missing labels.
+
+ Coalesced: a viewport's worth of ranges lands in a burst, and asking the
+ map to refetch on each one would redraw everything dozens of times for the
+ same result. The generation is what makes the already-rendered tiles stale
+ — without it the map's own cache keeps serving the unlabelled ones.
+ */
+ private func glyphsArrived() {
+ glyphGeneration.increment()
+
+ lock.lock()
+ if notifyPending {
+ lock.unlock()
+ return
+ }
+ notifyPending = true
+ lock.unlock()
+
+ let deadline = Date().addingTimeInterval(notifyMaxWait)
+ scheduleNotify(deadline: deadline)
+ }
+
+ private func scheduleNotify(deadline: Date) {
+ fetchQueue.asyncAfter(deadline: .now() + notifyQuiet) { [weak self] in
+ guard let self, !self.isClosed else { return }
+ // Hold the window open while ranges are still coming, or the whole
+ // viewport hands over once per range instead of once.
+ if self.glyphsInFlight.value > 0, Date() < deadline {
+ self.scheduleNotify(deadline: deadline)
+ return
+ }
+
+ self.lock.lock()
+ self.notifyPending = false
+ // Nothing to hand over to: every tile drawn since the last one had
+ // all the glyphs it wanted, so what is on screen is already the
+ // finished picture.
+ let worthIt = self.provisionalSinceHandover
+ self.provisionalSinceHandover = false
+ self.lock.unlock()
+
+ if worthIt { self.onGlyphsLoaded?() }
+ }
+ }
+
+ /// Synchronous by design: `renderTile` is already called off the main
+ /// thread by the tile server, and the plan/render contract is positional.
+ /// Like ``get(_:headers:cancellation:)``, but a transport failure -- no
+ /// network, a refused connection, a 5xx -- is thrown rather than turned
+ /// into "no such tile", which the caller would remember for the session.
+ static func fetch(
+ _ url: URL,
+ headers: [String: String],
+ cancellation: FetchCancellation?
+ ) throws -> Data? {
+ var request = URLRequest(url: url, timeoutInterval: 15)
+ for (name, value) in headers { request.setValue(value, forHTTPHeaderField: name) }
+ let done = DispatchSemaphore(value: 0)
+ var payload: Data?
+ var failure: Error?
+ var status = 0
+ let task = URLSession.shared.dataTask(with: request) { data, response, error in
+ defer { done.signal() }
+ failure = error
+ guard let http = response as? HTTPURLResponse else { return }
+ status = http.statusCode
+ if (200..<300).contains(http.statusCode) { payload = data }
+ }
+ task.resume()
+ while done.wait(timeout: .now() + .milliseconds(10)) != .success {
+ if cancellation?.isCancelled == true {
+ task.cancel()
+ done.wait()
+ return nil
+ }
+ }
+ if let failure { throw failure }
+ // A missing tile is normal at the edge of a source's coverage; a
+ // server that is down is not.
+ if status >= 500 { throw URLError(.badServerResponse) }
+ return payload
+ }
+
+ static func get(
+ _ url: URL,
+ headers: [String: String],
+ cancellation: FetchCancellation?
+ ) -> Data? {
+ var request = URLRequest(url: url, timeoutInterval: 15)
+ for (name, value) in headers { request.setValue(value, forHTTPHeaderField: name) }
+
+ var payload: Data?
+ let done = DispatchSemaphore(value: 0)
+ let task = URLSession.shared.dataTask(with: request) { data, response, _ in
+ defer { done.signal() }
+ guard let http = response as? HTTPURLResponse else { return }
+ // A missing tile is normal at the edge of a source's coverage.
+ guard (200..<300).contains(http.statusCode) else { return }
+ payload = data
+ }
+ task.resume()
+ while done.wait(timeout: .now() + .milliseconds(10)) != .success {
+ if cancellation?.isCancelled == true {
+ task.cancel()
+ // The completion handler signals `done`; waiting for it keeps
+ // the captured payload alive until URLSession is finished.
+ done.wait()
+ return nil
+ }
+ }
+ return payload
+ }
+}
+
+/// Waiters share transport failures as well as bytes. Retrying a failed
+/// owner once per waiter would multiply a network timeout across the viewport.
+private final class SourceFetchFlight: @unchecked Sendable {
+ private let done = DispatchSemaphore(value: 0)
+ private let lock = NSLock()
+ private var unavailable = false
+ private var received = false
+ private var bytes: Data?
+
+ func publish(_ bytes: Data?) {
+ lock.lock(); defer { lock.unlock() }
+ self.bytes = bytes
+ received = true
+ }
+
+ var response: (received: Bool, bytes: Data?) {
+ lock.lock(); defer { lock.unlock() }
+ return (received, bytes)
+ }
+
+ func wait(timeout: DispatchTime) -> DispatchTimeoutResult { done.wait(timeout: timeout) }
+ func signal() { done.signal() }
+
+ func markUnavailable() {
+ lock.lock(); unavailable = true; lock.unlock()
+ }
+
+ var isUnavailable: Bool {
+ lock.lock(); defer { lock.unlock() }
+ return unavailable
+ }
+}
+
+final class FetchCancellation: @unchecked Sendable {
+ private let lock = NSLock()
+ private var cancelled = false
+
+ var isCancelled: Bool {
+ lock.lock()
+ defer { lock.unlock() }
+ return cancelled
+ }
+
+ func cancel() {
+ lock.lock()
+ cancelled = true
+ lock.unlock()
+ }
+}
+
+private final class FetchResults: @unchecked Sendable {
+ private let lock = NSLock()
+ private var values: [Data?]
+
+ init(count: Int) {
+ values = [Data?](repeating: nil, count: count)
+ }
+
+ func set(_ value: Data?, at index: Int) {
+ lock.lock()
+ values[index] = value
+ lock.unlock()
+ }
+
+ func snapshot() -> [Data?] {
+ lock.lock()
+ defer { lock.unlock() }
+ return values
+ }
+}
+
+/// A counter that is safe to read from whichever thread the tile server used.
+private final class Counter {
+ private var count = 0
+ private let lock = NSLock()
+
+ var value: Int {
+ lock.lock()
+ defer { lock.unlock() }
+ return count
+ }
+
+ func increment() {
+ lock.lock()
+ count += 1
+ lock.unlock()
+ }
+
+ func decrement() {
+ lock.lock()
+ count -= 1
+ lock.unlock()
+ }
+}
+
+/**
+ The document as it will be drawn: `styleJSON` with `rules` applied.
+
+ Applied to the document rather than sent as per-layer deltas: both ways a
+ style reaches a map from here -- rasterised tiles and the handover to a map
+ that draws styles itself -- take a document, so applying it once means both
+ see the same one. A rule that hides a role switches those layers off in the
+ document, and a layer that is not there is not drawn.
+
+ Without rules the author's own text comes back and the compiler never runs.
+ */
+func drawnStyleJSON(_ styleJSON: String, _ rules: StyleRules) throws -> String {
+ guard rules != .none else { return styleJSON }
+ return try VectorStyleRules.compile(styleJSON: styleJSON, rulesJSON: rules.json).styleJSON
+}
diff --git a/ios-vectortile/Sources/MapConductorVectorTile/VectorTileRasteriser.swift b/ios-vectortile/Sources/MapConductorVectorTile/VectorTileRasteriser.swift
new file mode 100644
index 0000000..3dfe007
--- /dev/null
+++ b/ios-vectortile/Sources/MapConductorVectorTile/VectorTileRasteriser.swift
@@ -0,0 +1,307 @@
+import Foundation
+import UIKit
+import MapConductorCore
+import MapConductorVectorStyle
+
+/**
+ Draws a vector style for a map that cannot read one.
+
+ ```swift
+ GoogleMapView(
+ state: state,
+ style: VectorStyle(
+ document: .url("https://…/style.json"),
+ rules: rules,
+ rasteriser: VectorTileRasteriser()
+ )
+ )
+ ```
+
+ Google Maps, MapKit, HERE, ArcGIS, Longdo, TomTom and the rest have no
+ vector renderer to hand a style to. This renders the style to PNG tiles on
+ the device and mounts them as an ordinary raster layer, so the same rules
+ produce the same map on a backend that has never heard of MapLibre.
+
+ ## Why two layers
+
+ The ground (geometry, drawn by Metal) and the labels (glyphs, drawn on the
+ CPU) are separate raster layers stacked on each other: they render in
+ parallel, and glyphs arriving late refresh only the transparent overlay
+ rather than redrawing the map.
+
+ android-vectortile's `VectorTileRasteriser` is the same class.
+ */
+public final class VectorTileRasteriser: VectorStyleRasteriser {
+ private let tileSize: Int?
+ private let headers: [String: String]
+ private let assetCacheDirectory: URL?
+ private let renderMode: VectorTileProvider.RenderMode
+ private let renderScale: Int?
+
+ /// - Parameter tileSize: nil takes the map's own preference, which is
+ /// almost always right -- a provider that wants 256 says so through
+ /// ``RasterTilePreferenceKey``.
+ public init(
+ tileSize: Int? = nil,
+ headers: [String: String] = [:],
+ assetCacheDirectory: URL? = nil,
+ renderMode: VectorTileProvider.RenderMode = .auto,
+ renderScale: Int? = nil
+ ) {
+ self.tileSize = tileSize
+ self.headers = headers
+ self.assetCacheDirectory = assetCacheDirectory
+ self.renderMode = renderMode
+ self.renderScale = renderScale
+ }
+
+ public func install(host: MapStyleHost, styleJSON: String, affects _: StyleAffects)
+ -> VectorStyleRasterisation
+ {
+ let rasterisation = Rasterisation(host: host, options: self)
+ rasterisation.start(styleJSON: styleJSON)
+ return rasterisation
+ }
+
+ fileprivate final class Rasterisation: VectorStyleRasterisation {
+ private let host: MapStyleHost
+ private let options: VectorTileRasteriser
+ private let groupId = "vectorstyle-raster-\(UUID().uuidString)"
+ private let lock = NSLock()
+
+ /// Daemon-ish and serial: a map has one style, and the work is a
+ /// parse plus a re-rasterise.
+ private let work = DispatchQueue(label: "mapconductor.vectorstyle.raster", qos: .userInitiated)
+
+ private var disposed = false
+ private var revision = 0
+ private var pendingStyleJSON: String?
+ private var redrawScheduled = false
+ private var provider: VectorTileProvider?
+ // Accessed on `work`; revisions and provider ownership use `lock`.
+ private var displayedStyle: RasterStyleSnapshot?
+ private var credits: [AttributionRule] = []
+ private var resolvedTileSize = VectorTileProvider.defaultTileSize
+ private var declaredTileSize = VectorTileProvider.defaultTileSize
+
+ /**
+ Bumped whenever the pixels change for the same geometry.
+
+ A map only refetches a raster source whose URL changed, so a restyle
+ has to reach the template. The ground and the labels are counted
+ apart: recolouring roads need not redraw the labels, and `affects`
+ is what says which moved.
+ */
+ private var groundGeneration = 0
+ private var labelGeneration = 0
+
+ init(host: MapStyleHost, options: VectorTileRasteriser) {
+ self.host = host
+ self.options = options
+ }
+
+ func start(styleJSON: String) {
+ work.async { [weak self] in
+ guard let self else { return }
+ let preference = host.serviceRegistry.get(RasterTilePreferenceKey.self)
+ resolvedTileSize =
+ options.tileSize
+ ?? preference?.preferredTileSize
+ ?? VectorTileProvider.defaultTileSize
+ // What the map is told. The point size for a map that lays
+ // tiles out in points, the pixel size for one that lays them
+ // out in physical pixels -- which is the provider's to say,
+ // through `RasterTilePreference.layoutInDevicePixels`. The
+ // tile URL always carries `resolvedTileSize`: that is what
+ // the provider renders at.
+ declaredTileSize =
+ preference?.layoutInDevicePixels == true
+ ? Int((Double(resolvedTileSize) * UIScreen.main.scale).rounded())
+ : resolvedTileSize
+ do {
+ try apply(styleJSON: styleJSON, revision: nil)
+ } catch {
+ host.report(["the style could not be rasterised: \(describe(error))"])
+ }
+ }
+ }
+
+ func restyle(styleJSON: String, affects _: StyleAffects) {
+ lock.lock()
+ guard !disposed else { lock.unlock(); return }
+ revision += 1
+ pendingStyleJSON = styleJSON
+ guard !redrawScheduled else { lock.unlock(); return }
+ redrawScheduled = true
+ lock.unlock()
+ // A slider emits every frame. Coalesce a short interval into one
+ // document, retaining periodic previews during a continuous drag.
+ work.asyncAfter(deadline: .now() + .milliseconds(150)) { [weak self] in
+ guard let self else { return }
+ lock.lock()
+ let latest = pendingStyleJSON
+ let requestedRevision = revision
+ pendingStyleJSON = nil
+ redrawScheduled = false
+ lock.unlock()
+ guard let latest, isCurrent(requestedRevision) else { return }
+ do {
+ try apply(styleJSON: latest, revision: requestedRevision)
+ } catch {
+ host.report(["the style could not be redrawn: \(describe(error))"])
+ }
+ }
+ }
+
+ private func isCurrent(_ requestedRevision: Int) -> Bool {
+ lock.lock()
+ defer { lock.unlock() }
+ return !disposed && revision == requestedRevision
+ }
+
+ func dispose() {
+ lock.lock()
+ if disposed {
+ lock.unlock()
+ return
+ }
+ disposed = true
+ pendingStyleJSON = nil
+ let current = provider
+ provider = nil
+ lock.unlock()
+
+ host.tileServer.unregister(routeId: "\(groupId)-geom")
+ host.tileServer.unregister(routeId: "\(groupId)-labels")
+ current?.closeAsync()
+ DispatchQueue.main.async { [host, groupId] in
+ host.removeRaster(id: "\(groupId)-geom")
+ host.removeRaster(id: "\(groupId)-labels")
+ }
+ }
+
+ // MARK: - Private
+
+ private func build(styleJSON: String) throws -> VectorTileProvider {
+ try VectorTileProvider(
+ styleJSON: styleJSON,
+ tileSize: resolvedTileSize,
+ headers: options.headers,
+ renderMode: options.renderMode,
+ assetCacheDirectory: options.assetCacheDirectory,
+ renderScale: options.renderScale
+ )
+ }
+
+ private func apply(styleJSON: String, revision requestedRevision: Int?) throws {
+ let snapshot = try RasterStyleSnapshot(styleJSON: styleJSON)
+ let affects = displayedStyle.map { snapshot.changes(from: $0) } ?? .both
+ guard affects != .none else { return }
+ let created = try build(styleJSON: styleJSON)
+ watchGlyphs(of: created)
+
+ lock.lock()
+ guard !disposed, requestedRevision == nil || requestedRevision == revision else {
+ lock.unlock()
+ created.closeAsync()
+ return
+ }
+ let previous = provider
+ if let previous { created.reuseSourceTiles(from: previous) }
+ provider = created
+ displayedStyle = snapshot
+ // Commit routes under the ownership lock so dispose cannot
+ // unregister them just before an in-flight build re-registers.
+ host.tileServer.register(routeId: "\(groupId)-geom", provider: created.groundTiles)
+ host.tileServer.register(routeId: "\(groupId)-labels", provider: created.labelTiles)
+ credits = created.attributions().map { AttributionRule(attribution: $0) }
+ if affects == .ground || affects == .both { groundGeneration += 1 }
+ if affects == .labels || affects == .both { labelGeneration += 1 }
+ let ground = affects == .ground || affects == .both ? groundState() : nil
+ let labels = affects == .labels || affects == .both ? labelState() : nil
+ lock.unlock()
+ previous?.closeAsync()
+ DispatchQueue.main.async { [weak self] in
+ guard let self, !isDisposed() else { return }
+ host.report(created.diagnostics())
+ if let ground { host.upsertRaster(ground) }
+ if let labels { host.upsertRaster(labels) }
+ }
+ }
+
+ /// Glyphs arrive after the tiles that need them -- a range is a round
+ /// trip and a low zoom wants dozens -- so tiles are drawn with what
+ /// is loaded and the labels refetched once more is.
+ private func watchGlyphs(of created: VectorTileProvider) {
+ created.onGlyphsLoaded = { [weak self, weak created] in
+ guard let self else { return }
+ work.async { [weak self, weak created] in
+ guard let self, let created else { return }
+ lock.lock()
+ guard !disposed, provider === created else { lock.unlock(); return }
+ labelGeneration += 1
+ let labels = labelState()
+ lock.unlock()
+ DispatchQueue.main.async { [weak self] in
+ guard let self, !isDisposed() else { return }
+ host.upsertRaster(labels)
+ }
+ }
+ }
+ }
+
+ private func isDisposed() -> Bool {
+ lock.lock()
+ defer { lock.unlock() }
+ return disposed
+ }
+
+ /// Geometry only, drawn by Metal, never invalidated by glyphs.
+ private func groundState() -> RasterLayerState {
+ RasterLayerState(
+ source: .urlTemplate(
+ template: host.tileServer.urlTemplate(
+ routeId: "\(groupId)-geom",
+ tileSize: resolvedTileSize,
+ cacheKey: "g\(groundGeneration)"
+ ),
+ tileSize: declaredTileSize,
+ maxZoom: Self.maxZoom,
+ attributionRules: credits
+ ),
+ // Under everything the app declared, and under the
+ // labels. A style *is* the basemap here.
+ zIndex: -2,
+ id: "\(groupId)-geom"
+ )
+ }
+
+ /// The transparent overlay the glyphs land on.
+ private func labelState() -> RasterLayerState {
+ RasterLayerState(
+ source: .urlTemplate(
+ template: host.tileServer.urlTemplate(
+ routeId: "\(groupId)-labels",
+ tileSize: resolvedTileSize,
+ cacheKey: "g\(labelGeneration)"
+ ),
+ tileSize: declaredTileSize,
+ maxZoom: Self.maxZoom,
+ // Both halves carry the credit, so hiding either one
+ // cannot silence it; the overlay ends in `distinct`.
+ attributionRules: credits
+ ),
+ visible: displayedStyle?.hasVisibleSymbols ?? true,
+ zIndex: -1,
+ id: "\(groupId)-labels"
+ )
+ }
+
+ private func describe(_ error: Error) -> String {
+ (error as? VectorTileError).map(String.init(describing:))
+ ?? (error as NSError).localizedDescription
+ }
+
+ private static let maxZoom = 22
+ }
+}
diff --git a/ios-vectortile/Sources/MapConductorVectorTile/VectorTileRenderer.swift b/ios-vectortile/Sources/MapConductorVectorTile/VectorTileRenderer.swift
new file mode 100644
index 0000000..a20095e
--- /dev/null
+++ b/ios-vectortile/Sources/MapConductorVectorTile/VectorTileRenderer.swift
@@ -0,0 +1,565 @@
+import CMvtRender
+import Foundation
+
+public enum VectorTileError: Error, CustomStringConvertible {
+ case styleRejected(String)
+ case renderFailed(Int32)
+ case closed
+
+ public var description: String {
+ switch self {
+ case .styleRejected(let message): return "style rejected: \(message)"
+ case .renderFailed(let code): return "render failed with code \(code)"
+ case .closed: return "renderer has been closed"
+ }
+ }
+}
+
+/// Presents `tiles` the way every native entry point takes them: one
+/// concatenated buffer plus a length table, `0` marking a tile that failed to
+/// fetch.
+///
+/// An array-of-arrays would need a marshaller on every platform; this needs
+/// none, which is why the JNI, C and wasm surfaces all agree on it.
+private func withTiles(
+ _ tiles: [Data?],
+ _ body: (UnsafePointer?, Int, UnsafePointer?, Int) -> R
+) -> R {
+ var lengths = [UInt32]()
+ var payload = Data()
+ lengths.reserveCapacity(tiles.count)
+ for tile in tiles {
+ lengths.append(UInt32(tile?.count ?? 0))
+ if let tile { payload.append(tile) }
+ }
+ return payload.withUnsafeBytes { payloadBuffer in
+ lengths.withUnsafeBufferPointer { lengthBuffer in
+ body(
+ payloadBuffer.bindMemory(to: UInt8.self).baseAddress,
+ payload.count,
+ lengthBuffer.baseAddress,
+ lengthBuffer.count
+ )
+ }
+ }
+}
+
+private func takeStrings(_ json: UnsafeMutablePointer?) -> [String] {
+ guard let json else { return [] }
+ defer { mvt_string_free(json) }
+ let text = String(cString: json)
+ return (try? JSONDecoder().decode([String].self, from: Data(text.utf8))) ?? []
+}
+
+/// Labels and icons drawn on a transparent ground.
+public struct LabelTile {
+ /// **Premultiplied** RGBA, `tileSize * tileSize * 4` bytes. Empty when
+ /// nothing was placed.
+ ///
+ /// Premultiplied, not straight: encoding these as straight alpha turns
+ /// every halo grey.
+ public let pixels: Data
+
+ /// How many labels were placed. Zero means the tile is empty, and the
+ /// caller should serve one shared transparent image rather than encode it.
+ public let placed: Int
+}
+
+/// Renders MapLibre vector styles to raster PNG tiles.
+///
+/// Rendering is split in two so that **no network I/O happens inside the native
+/// library**: ``plan(z:x:y:)`` says which source tiles are needed, the caller
+/// fetches them with URLSession — keeping its own auth headers, pinning and
+/// cache — and passes the bytes to ``render(z:x:y:tileSize:tiles:)``.
+public final class VectorTileRenderer {
+ public static let defaultTileSize: UInt32 = 512
+
+ /**
+ What this renderer draws, as a number that goes up whenever it draws more.
+
+ Rendered tiles are cached on disk across launches, and a cache keyed only
+ by style and coordinates is keyed by *what the style says*, not by *what
+ the renderer did with it*. The build that started drawing labels kept
+ serving the unlabelled PNGs the previous one had cached — correct for the
+ renderer that made them, wrong for the one asking.
+
+ Kept in step with the Android binding's `OUTPUT_VERSION`: they cache the
+ output of the same native renderer.
+
+ 18: glyph pixels align across tile boundaries.
+ 19: label bounds include glyph bearings and SDF raster extents.
+ 20: that extent decides which tile a label draws in; collisions are
+ judged on the layout box again, so labels are as dense as before.
+ 24: Google's finer target grids retain the parent style zoom and paint sizes.
+ */
+ public static let outputVersion = 24
+
+ private var handle: OpaquePointer?
+
+ /**
+ - Parameter displayTileSize: how many points of screen one tile covers
+ where the host shows it. **Not** the pixel count a render is asked for:
+ the label pass draws at twice the pixels, and a 256pt tile is still
+ 256pt however many pixels it carries. It fixes the size the style draws
+ at and the zoom its expressions are read at, so passing the wrong one
+ draws every label and every road at the wrong size. 512 is what a
+ raster source declared at 512 gets.
+ - Throws: ``VectorTileError/styleRejected(_:)`` if the style cannot be parsed.
+ */
+ public init(styleJSON: String, displayTileSize: Int = 512) throws {
+ var errorPointer: UnsafeMutablePointer?
+ let created = styleJSON.withCString {
+ mvt_renderer_new($0, UInt32(max(1, displayTileSize)), &errorPointer)
+ }
+ guard let created else {
+ let message = errorPointer.map { pointer -> String in
+ defer { mvt_string_free(pointer) }
+ return String(cString: pointer)
+ } ?? "unknown error"
+ throw VectorTileError.styleRejected(message)
+ }
+ // `MvtRenderer` is an incomplete C struct, so Swift models it as an
+ // OpaquePointer rather than a typed pointer.
+ handle = created
+ pthread_rwlock_init(&liveCalls, nil)
+ }
+
+ /**
+ Holds the renderer alive for the length of a native call.
+
+ `close()` frees the native renderer, and a call already inside it on
+ another thread then reads freed memory. That is not hypothetical: the
+ provider is rebuilt when the map under it changes its tile size, and
+ ArcGIS's tiles are drawn on a queue of their own, so a render was still
+ running when the old provider closed — and the app went to the home
+ screen a second after switching to ArcGIS. android-sdk's binding hit the
+ same fault (SIGSEGV in `nativeRender`, fault address made of style bytes)
+ and holds the same lock.
+
+ Calls take the read side, close takes the write side and so waits for
+ every call in flight. A call that arrives after the close finds the
+ handle cleared and throws instead of touching freed memory.
+ */
+ private var liveCalls = pthread_rwlock_t()
+
+ deinit {
+ close()
+ pthread_rwlock_destroy(&liveCalls)
+ }
+
+ /// Releases the native renderer. Safe to call more than once. Waits for
+ /// any call in flight; never frees under one.
+ public func close() {
+ pthread_rwlock_wrlock(&liveCalls)
+ defer { pthread_rwlock_unlock(&liveCalls) }
+ guard let live = handle else { return }
+ // Clearing first keeps a double close from freeing the same pointer twice.
+ handle = nil
+ mvt_renderer_free(live)
+ }
+
+ /// Runs one native call with the renderer held alive for its duration.
+ private func withRenderer(sourceZoomOffset: Int = 0, _ body: (OpaquePointer) throws -> T) throws -> T {
+ pthread_rwlock_rdlock(&liveCalls)
+ defer { pthread_rwlock_unlock(&liveCalls) }
+ guard let live = handle else { throw VectorTileError.closed }
+ let previous = mvt_set_thread_source_zoom_offset(UInt8(clamping: sourceZoomOffset))
+ defer { _ = mvt_set_thread_source_zoom_offset(previous) }
+ return try body(live)
+ }
+
+ /// Source tiles needed to draw `z/x/y`, as JSON. Fetch them in order and
+ /// pass the bytes to ``render(z:x:y:tileSize:tiles:)`` positionally.
+ public func plan(z: UInt8, x: UInt32, y: UInt32, sourceZoomOffset: Int = 0) throws -> String {
+ return try withRenderer(sourceZoomOffset: sourceZoomOffset) { live in
+ guard let json = mvt_renderer_plan(live, z, x, y) else { return "[]" }
+ defer { mvt_string_free(json) }
+ return String(cString: json)
+ }
+ }
+
+ /// Replaces the style. Fetched vector tiles stay valid — the geometry is
+ /// unchanged, only the paint applied to it — so recolouring needs no refetch.
+ public func setStyle(_ styleJSON: String) throws {
+ return try withRenderer { live in
+ var errorPointer: UnsafeMutablePointer?
+ let code = styleJSON.withCString { mvt_renderer_set_style(live, $0, &errorPointer) }
+ guard code == MVT_OK else {
+ let message = errorPointer.map { pointer -> String in
+ defer { mvt_string_free(pointer) }
+ return String(cString: pointer)
+ } ?? "unknown error"
+ throw VectorTileError.styleRejected(message)
+ }
+ }
+ }
+
+ /// Layer `type` values in the current style that will not be drawn.
+ public func unsupportedLayerTypes() throws -> [String] {
+ return try withRenderer { live in
+ guard let json = mvt_renderer_unsupported_layer_types(live) else { return [] }
+ defer { mvt_string_free(json) }
+ let text = String(cString: json)
+ return (try? JSONDecoder().decode([String].self, from: Data(text.utf8))) ?? []
+ }
+ }
+
+ /// Reasons the current style may not render as intended: unsupported layer
+ /// types, sources that cannot be fetched, layers pointing at undefined
+ /// sources, Mapbox `imports`.
+ ///
+ /// Worth surfacing — the failure mode that matters is a blank tile, and a
+ /// style this renderer cannot use should say so.
+ public func diagnostics() throws -> [String] {
+ return try withRenderer { live in
+ guard let json = mvt_renderer_diagnostics(live) else { return [] }
+ defer { mvt_string_free(json) }
+ let text = String(cString: json)
+ return (try? JSONDecoder().decode([String].self, from: Data(text.utf8))) ?? []
+ }
+ }
+
+ /// Rasterises `z/x/y` to PNG bytes.
+ ///
+ /// - Parameter tiles: one entry per ``plan(z:x:y:)`` request, in the same
+ /// order; `nil` where the fetch produced nothing.
+ public func render(
+ z: UInt8,
+ x: UInt32,
+ y: UInt32,
+ tileSize: UInt32 = defaultTileSize,
+ tiles: [Data?],
+ sourceZoomOffset: Int = 0
+ ) throws -> Data {
+ return try withRenderer(sourceZoomOffset: sourceZoomOffset) { live in
+
+ // The native side takes one concatenated buffer plus a length table,
+ // which avoids marshalling an array-of-arrays across the ABI.
+ var outPointer: UnsafeMutablePointer?
+ var outLength = 0
+
+ let code = withTiles(tiles) { data, dataLength, lengths, lengthsCount in
+ mvt_renderer_render(
+ live, z, x, y, tileSize,
+ data, dataLength, lengths, lengthsCount,
+ &outPointer, &outLength
+ )
+ }
+
+ guard code == MVT_OK, let outPointer else {
+ throw VectorTileError.renderFailed(code)
+ }
+ defer { mvt_buffer_free(outPointer, outLength) }
+ return Data(bytes: outPointer, count: outLength)
+ }
+ }
+ /// Tessellates `z/x/y` into triangles for GPU drawing.
+ ///
+ /// - Parameter tiles: as in ``render(z:x:y:tileSize:tiles:)``.
+ public func tessellate(
+ z: UInt8,
+ x: UInt32,
+ y: UInt32,
+ tileSize: UInt32 = defaultTileSize,
+ tiles: [Data?],
+ sourceZoomOffset: Int = 0
+ ) throws -> TessellatedTile {
+ return try withRenderer(sourceZoomOffset: sourceZoomOffset) { live in
+
+ var outPointer: UnsafeMutablePointer?
+ var outLength = 0
+
+ let code = withTiles(tiles) { data, dataLength, lengths, lengthsCount in
+ mvt_renderer_tessellate(
+ live, z, x, y, tileSize,
+ data, dataLength, lengths, lengthsCount,
+ &outPointer, &outLength
+ )
+ }
+
+ guard code == MVT_OK, let outPointer else {
+ throw VectorTileError.renderFailed(code)
+ }
+ defer { mvt_floats_free(outPointer, outLength) }
+ return TessellatedTile(
+ packed: Array(UnsafeBufferPointer(start: outPointer, count: outLength))
+ )
+ }
+ }
+
+ // MARK: - Labels and icons
+
+ /// The credits this style's sources ask to be shown.
+ ///
+ /// The host must display these: a style is data under someone's licence,
+ /// and a basemap drawing OpenStreetMap requires the credit. May contain
+ /// HTML — the text is normally a link to the licence.
+ public func attributions() throws -> [String] {
+ takeStrings(try withRenderer { mvt_renderer_attributions($0) })
+ }
+
+ /// The style's `glyphs` URL template, or nil when it names none.
+ public func glyphsURLTemplate() throws -> String? {
+ return try withRenderer { live in
+ guard let json = mvt_renderer_glyphs_url_template(live) else { return nil }
+ defer { mvt_string_free(json) }
+ return String(cString: json)
+ }
+ }
+
+ /// URLs of the glyph ranges this tile's labels need and the store has not
+ /// got. Fetch them, then feed each back through ``addGlyphs(_:)``.
+ ///
+ /// Empty when the style names no template, or everything needed is loaded.
+ public func neededGlyphs(z: UInt8, x: UInt32, y: UInt32, tiles: [Data?], sourceZoomOffset: Int = 0) throws -> [String] {
+ return try withRenderer(sourceZoomOffset: sourceZoomOffset) { live in
+ let json = withTiles(tiles) { data, dataLength, lengths, lengthsCount in
+ mvt_renderer_needed_glyphs(live, z, x, y, data, dataLength, lengths, lengthsCount)
+ }
+ return takeStrings(json)
+ }
+ }
+
+ /// Adds one fetched glyph range. Returns how many glyphs it carried.
+ ///
+ /// Labels are drawn with whatever is loaded when the tile is drawn, so a
+ /// range arriving later means redrawing the tiles that wanted it.
+ @discardableResult
+ public func addGlyphs(_ pbf: Data) throws -> Int {
+ return try withRenderer { live in
+ let count = pbf.withUnsafeBytes { buffer in
+ mvt_renderer_add_glyphs(
+ live, buffer.bindMemory(to: UInt8.self).baseAddress, pbf.count
+ )
+ }
+ guard count >= 0 else { throw VectorTileError.renderFailed(count) }
+ return Int(count)
+ }
+ }
+
+ /// Whether any glyph has been loaded. Labels need at least one.
+ public func hasGlyphs() throws -> Bool {
+ try withRenderer { mvt_renderer_has_glyphs($0) == 1 }
+ }
+
+ /// The sprite sheet's `.json` and `.png` URLs, or nil when the style names
+ /// no sprite. Fetch both and pass them to ``addSprite(json:png:)``.
+ public func spriteURLs(pixelRatio: UInt32 = 2) throws -> (json: String, png: String)? {
+ let urls = takeStrings(try withRenderer { mvt_renderer_sprite_urls($0, pixelRatio) })
+ guard urls.count == 2 else { return nil }
+ return (json: urls[0], png: urls[1])
+ }
+
+ /// Adds the fetched sprite sheet. Returns how many icons it carried.
+ @discardableResult
+ public func addSprite(json: String, png: Data) throws -> Int {
+ return try withRenderer { live in
+ let count = json.withCString { index in
+ png.withUnsafeBytes { buffer in
+ mvt_renderer_add_sprite(
+ live, index, buffer.bindMemory(to: UInt8.self).baseAddress, png.count
+ )
+ }
+ }
+ guard count >= 0 else { throw VectorTileError.renderFailed(count) }
+ return Int(count)
+ }
+ }
+
+ /// Whether the style names a sprite the renderer has not been given yet.
+ public func needsSprite() throws -> Bool {
+ try withRenderer { mvt_renderer_needs_sprite($0) == 1 }
+ }
+
+ /// Whether this tile must be drawn on the CPU because the style paints
+ /// a pattern the GPU cannot draw or a polygon that is too costly to triangulate.
+ public func needsCPU(z: UInt8, tiles: [Data?], sourceZoomOffset: Int = 0) throws -> Bool {
+ return try withRenderer(sourceZoomOffset: sourceZoomOffset) { live in
+ return withTiles(tiles) { data, dataLength, lengths, lengthsCount in
+ mvt_renderer_needs_cpu(live, z, data, dataLength, lengths, lengthsCount)
+ } == 1
+ }
+ }
+
+ /// Rasterises the ground alone — fills, lines and circles, no labels or
+ /// icons — to PNG bytes.
+ ///
+ /// The half of a split layer that a GPU draws and that fonts arriving never
+ /// invalidate. Serve this and ``renderLabels(z:x:y:tileSize:tiles:)`` as two
+ /// stacked raster layers and the map shows one; the halves are drawn in
+ /// parallel, and a font landing redraws only the transparent one.
+ public func renderGeometry(
+ z: UInt8,
+ x: UInt32,
+ y: UInt32,
+ tileSize: UInt32 = defaultTileSize,
+ tiles: [Data?],
+ sourceZoomOffset: Int = 0
+ ) throws -> Data {
+ return try withRenderer(sourceZoomOffset: sourceZoomOffset) { live in
+ var outPointer: UnsafeMutablePointer?
+ var outLength = 0
+
+ let code = withTiles(tiles) { data, dataLength, lengths, lengthsCount in
+ mvt_renderer_render_geometry(
+ live, z, x, y, tileSize,
+ data, dataLength, lengths, lengthsCount,
+ &outPointer, &outLength
+ )
+ }
+ guard code == MVT_OK, let outPointer else {
+ throw VectorTileError.renderFailed(code)
+ }
+ defer { mvt_buffer_free(outPointer, outLength) }
+ return Data(bytes: outPointer, count: outLength)
+ }
+ }
+
+ /// Draws the labels and icons alone, on a transparent ground.
+ ///
+ /// The other half of a split layer. A ``LabelTile`` whose `placed` is zero
+ /// carries no pixels — water and fields are common, and one shared
+ /// transparent image serves them all.
+ public func renderLabels(
+ z: UInt8,
+ x: UInt32,
+ y: UInt32,
+ tileSize: UInt32 = defaultTileSize,
+ tiles: [Data?],
+ sourceZoomOffset: Int = 0
+ ) throws -> LabelTile {
+ return try withRenderer(sourceZoomOffset: sourceZoomOffset) { live in
+ var outPointer: UnsafeMutablePointer?
+ var outLength = 0
+ var placed = 0
+
+ let code = withTiles(tiles) { data, dataLength, lengths, lengthsCount in
+ mvt_renderer_render_labels(
+ live, z, x, y, tileSize,
+ data, dataLength, lengths, lengthsCount,
+ &outPointer, &outLength, &placed
+ )
+ }
+ guard code == MVT_OK else { throw VectorTileError.renderFailed(code) }
+ guard let outPointer else { return LabelTile(pixels: Data(), placed: placed) }
+ defer { mvt_buffer_free(outPointer, outLength) }
+ return LabelTile(pixels: Data(bytes: outPointer, count: outLength), placed: placed)
+ }
+ }
+
+ /// Draws labels and icons over pixels the caller already has — a GPU
+ /// readback, typically — in place. Returns how many labels were placed.
+ ///
+ /// `rgba` must hold exactly `tileSize * tileSize * 4` bytes and is read as
+ /// **straight** alpha, which is what a Metal readback is.
+ @discardableResult
+ public func drawLabels(
+ z: UInt8,
+ x: UInt32,
+ y: UInt32,
+ tileSize: UInt32 = defaultTileSize,
+ rgba: inout Data,
+ tiles: [Data?],
+ sourceZoomOffset: Int = 0
+ ) throws -> Int {
+ try rgba.withUnsafeMutableBytes { pixels in
+ try drawLabels(z: z, x: x, y: y, tileSize: tileSize, rgba: pixels, tiles: tiles, sourceZoomOffset: sourceZoomOffset)
+ }
+ }
+
+ /// The same, painting straight into a buffer the caller already holds — a
+ /// GPU readback, which is the whole reason this overload exists. Copying a
+ /// tile in and out to add labels costs more than drawing them.
+ @discardableResult
+ public func drawLabels(
+ z: UInt8,
+ x: UInt32,
+ y: UInt32,
+ tileSize: UInt32 = defaultTileSize,
+ rgba: UnsafeMutableRawBufferPointer,
+ tiles: [Data?],
+ sourceZoomOffset: Int = 0
+ ) throws -> Int {
+ return try withRenderer(sourceZoomOffset: sourceZoomOffset) { live in
+ let placed = withTiles(tiles) { data, dataLength, lengths, lengthsCount in
+ mvt_renderer_draw_labels(
+ live, z, x, y, tileSize,
+ rgba.bindMemory(to: UInt8.self).baseAddress, rgba.count,
+ data, dataLength, lengths, lengthsCount
+ )
+ }
+ guard placed >= 0 else { throw VectorTileError.renderFailed(placed) }
+ return Int(placed)
+ }
+ }
+
+
+}
+
+/// A tile reduced to triangles, as the native side packs it.
+///
+/// One flat `[Float]` rather than a decoded object graph: it crosses the C ABI
+/// as a single allocation, and the parts the GPU consumes — vertices, batch
+/// ranges — are read straight out of it without copying.
+public struct TessellatedTile {
+ /// Where the fixed-size prologue ends and the batch table begins.
+ private static let headerFloats = 13
+
+ public let packed: [Float]
+
+ public init(packed: [Float]) {
+ self.packed = packed
+ }
+
+ /// Tile-unit coordinate space the vertices live in.
+ public var extent: Float { packed.isEmpty ? 0 : packed[0] }
+
+ /// The style's background colour, if it declares one.
+ public var background: (r: Float, g: Float, b: Float, a: Float)? {
+ guard packed.count >= 6, packed[1] != 0 else { return nil }
+ return (packed[2], packed[3], packed[4], packed[5])
+ }
+
+ public var batchCount: Int { packed.count > 6 ? Int(packed[6]) : 0 }
+
+ /// Native timings in milliseconds: decode, tessellate, filter compile,
+ /// fill, line.
+ public var timings: (decode: Float, tessellate: Float, filterCompile: Float, fill: Float, line: Float) {
+ guard packed.count >= Self.headerFloats else { return (0, 0, 0, 0, 0) }
+ return (packed[7], packed[8], packed[9], packed[10], packed[11])
+ }
+
+ /// Vertex range of one draw call. Batches stay separate to preserve
+ /// painter's order.
+ public func batch(_ index: Int) -> (firstVertex: Int, vertexCount: Int) {
+ let base = Self.headerFloats + index * 2
+ return (Int(packed[base]), Int(packed[base + 1]))
+ }
+
+ /// Offset into `packed` where the interleaved vertices start.
+ public var vertexOffset: Int { Self.headerFloats + batchCount * 2 }
+
+ /// Floats per vertex: x, y, r, g, b, a.
+ public static let vertexStride = 6
+
+ public var vertexFloatCount: Int { max(0, packed.count - vertexOffset) }
+
+ /// Runs `body` with the vertex data only, without copying it out.
+ public func withVertices(_ body: (UnsafeBufferPointer) -> R) -> R {
+ packed.withUnsafeBufferPointer { buffer in
+ body(UnsafeBufferPointer(rebasing: buffer[vertexOffset...]))
+ }
+ }
+}
+
+/// Status codes from the native ABI.
+///
+/// Restated here because Swift's C importer only brings across `MVT_OK`; the
+/// error macros start with a minus and are dropped. Mirrors `mvt_render.h`.
+public enum MvtStatus {
+ public static let ok: Int32 = 0
+ public static let nullHandle: Int32 = -1
+ public static let badArgument: Int32 = -2
+ public static let renderFailed: Int32 = -3
+ public static let panic: Int32 = -4
+}
diff --git a/ios-vectortile/Tests/MapConductorVectorTileTests/BlitAlignmentTests.swift b/ios-vectortile/Tests/MapConductorVectorTileTests/BlitAlignmentTests.swift
new file mode 100644
index 0000000..75f30bc
--- /dev/null
+++ b/ios-vectortile/Tests/MapConductorVectorTileTests/BlitAlignmentTests.swift
@@ -0,0 +1,62 @@
+import UIKit
+import XCTest
+
+/**
+ Measures what a non-integer destination costs CoreGraphics.
+
+ The marker tile renderer used to hand `draw(in:)` a rectangle straight out of
+ the projection, so it almost never landed on a whole pixel. On Android that
+ cost 20x and in Chromium 7x; this checks whether CoreGraphics behaves the same
+ way rather than assuming it does.
+ */
+final class BlitAlignmentTests: XCTestCase {
+
+ func testWholePixelDestinationsAreCheaper() throws {
+ let iconPx: CGFloat = 84
+ let count = 20_000
+ let canvasPx: CGFloat = 1344
+
+ let format = UIGraphicsImageRendererFormat.default()
+ format.scale = 1
+ format.opaque = false
+
+ let icon = UIGraphicsImageRenderer(
+ size: CGSize(width: iconPx, height: iconPx), format: format
+ ).image { context in
+ UIColor(red: 0.16, green: 0.35, blue: 0.78, alpha: 0.86).setFill()
+ context.fill(CGRect(x: 0, y: 0, width: iconPx, height: iconPx))
+ }
+
+ func cost(fractional: Bool) -> Double {
+ var samples: [Double] = []
+ for _ in 0..<3 {
+ let started = CFAbsoluteTimeGetCurrent()
+ _ = UIGraphicsImageRenderer(
+ size: CGSize(width: canvasPx, height: canvasPx), format: format
+ ).image { _ in
+ for index in 0.. GeoPoint {
+ let worldTiles = Double(1 << z)
+ let longitude = (Double(tileX) + fx) / worldTiles * 360.0 - 180.0
+ let n = Double.pi * (1.0 - 2.0 * (Double(tileY) + fy) / worldTiles)
+ return GeoPoint(latitude: atan(sinh(n)) * 180 / .pi, longitude: longitude)
+ }
+
+ func testMarkersLandWhereTheirCoordinatesSay() throws {
+ let positions = [atFraction(0.25, 0.25), atFraction(0.50, 0.60), atFraction(0.75, 0.40)]
+
+ let manager = MarkerManager.defaultManager()
+ for position in positions {
+ manager.registerEntity(MarkerEntity(
+ marker: nil, state: MarkerState(position: position),
+ visible: true, isRendered: true, tiling: true
+ ))
+ }
+
+ let renderer = MarkerTileRenderer(markerManager: manager, tileSize: tileSize)
+ let png = try XCTUnwrap(
+ renderer.renderTile(request: TileRequest(x: tileX, y: tileY, z: z)),
+ "the tile was empty"
+ )
+ let image = try XCTUnwrap(UIImage(data: png)?.cgImage)
+ let width = image.width
+ var buffer = [UInt8](repeating: 0, count: width * width * 4)
+ let context = try XCTUnwrap(CGContext(
+ data: &buffer, width: width, height: width,
+ bitsPerComponent: 8, bytesPerRow: width * 4,
+ space: CGColorSpaceCreateDeviceRGB(),
+ bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
+ ))
+ context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: width))
+
+ func painted(_ x: Int, _ y: Int, radius: Int) -> Bool {
+ for dy in -radius...radius {
+ for dx in -radius...radius {
+ let px = x + dx, py = y + dy
+ guard px >= 0, px < width, py >= 0, py < width else { continue }
+ if buffer[(py * width + px) * 4 + 3] != 0 { return true }
+ }
+ }
+ return false
+ }
+
+ for (index, position) in positions.enumerated() {
+ let worldTiles = Double(1 << z)
+ let worldX = (position.longitude + 180) / 360 * worldTiles
+ let latRad = position.latitude * .pi / 180
+ let worldY = (1 - log(tan(latRad) + 1 / cos(latRad)) / .pi) / 2 * worldTiles
+ let x = Int(((worldX - Double(tileX)) * Double(width)).rounded())
+ let y = Int(((worldY - Double(tileY)) * Double(width)).rounded())
+ // The default pin is anchored near its tip, so the icon body sits
+ // above the coordinate; a small box around it is the honest test.
+ XCTAssertTrue(painted(x, y, radius: 6), "marker \(index) missing at \(x),\(y)")
+ }
+
+ // Somewhere no marker is, so the tile is not simply filled in.
+ XCTAssertFalse(painted(width / 2, 8, radius: 2), "unexpected paint near the top edge")
+
+ // The icons are the right way up. The rewrite carries two vertical
+ // flips that have to cancel, and a position check alone would pass with
+ // both of them wrong: the default pin is anchored near its tip, so its
+ // body sits above the coordinate. Counting which side is painted is
+ // what actually distinguishes that from upside down.
+ func paintedRows(around position: GeoPoint, above: Bool) -> Int {
+ let worldTiles = Double(1 << z)
+ let worldX = (position.longitude + 180) / 360 * worldTiles
+ let latRad = position.latitude * .pi / 180
+ let worldY = (1 - log(tan(latRad) + 1 / cos(latRad)) / .pi) / 2 * worldTiles
+ let x = Int(((worldX - Double(tileX)) * Double(width)).rounded())
+ let y = Int(((worldY - Double(tileY)) * Double(width)).rounded())
+ var count = 0
+ for offset in 1...30 {
+ let row = above ? y - offset : y + offset
+ guard row >= 0, row < width else { continue }
+ for dx in -20...20 where x + dx >= 0 && x + dx < width {
+ if buffer[(row * width + x + dx) * 4 + 3] != 0 { count += 1 }
+ }
+ }
+ return count
+ }
+
+ let bodyAbove = paintedRows(around: positions[0], above: true)
+ let bodyBelow = paintedRows(around: positions[0], above: false)
+ XCTAssertGreaterThan(
+ bodyAbove, bodyBelow,
+ "the pin looks upside down: \(bodyAbove) painted above the anchor, \(bodyBelow) below"
+ )
+ }
+ /// Stacked duplicates render the same as one marker.
+ ///
+ /// The renderer drops markers that agree exactly on rectangle and icon,
+ /// on the grounds that all but the last are invisible. This is that claim,
+ /// stated as a test rather than as a comment.
+ func testStackedDuplicatesLookLikeOne() throws {
+ func tile(copies: Int) throws -> [UInt8] {
+ let manager = MarkerManager.defaultManager()
+ for _ in 0..(
+ marker: nil,
+ // A fresh state each time: the same position, but not the
+ // same object, so the manager keeps all of them.
+ state: MarkerState(position: atFraction(0.5, 0.5)),
+ visible: true, isRendered: true, tiling: true
+ ))
+ }
+ let renderer = MarkerTileRenderer(markerManager: manager, tileSize: tileSize)
+ let png = try XCTUnwrap(
+ renderer.renderTile(request: TileRequest(x: tileX, y: tileY, z: z))
+ )
+ let image = try XCTUnwrap(UIImage(data: png)?.cgImage)
+ let width = image.width
+ var buffer = [UInt8](repeating: 0, count: width * width * 4)
+ let context = try XCTUnwrap(CGContext(
+ data: &buffer, width: width, height: width,
+ bitsPerComponent: 8, bytesPerRow: width * 4,
+ space: CGColorSpaceCreateDeviceRGB(),
+ bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
+ ))
+ context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: width))
+ return buffer
+ }
+
+ let one = try tile(copies: 1)
+ let many = try tile(copies: 50)
+ XCTAssertEqual(one.count, many.count)
+
+ var worst = 0
+ for index in 0.. VectorTileProvider {
+ try VectorTileProvider(
+ styleJSON: styleJSON, tileSize: tileSize, renderMode: mode
+ ) { [tileData] _ in tileData }
+ }
+
+ func testGpuModeProducesAPng() throws {
+ let provider = try provider(.gpu)
+ defer { provider.close() }
+
+ let png = try XCTUnwrap(provider.renderTile(request: TileRequest(x: 0, y: 0, z: 0)))
+ XCTAssertEqual(Array(png.prefix(4)), [0x89, 0x50, 0x4E, 0x47], "not a PNG")
+
+ // Pixels, not points: tiles are drawn at the display's scale so a
+ // Retina screen is not handed an image to stretch. The layer still
+ // declares `tileSize`, which is what sets how large the map looks.
+ let image = try XCTUnwrap(UIImage(data: png))
+ let expected = tileSize * Int(UIScreen.main.scale.rounded())
+ XCTAssertEqual(Int(image.size.width), expected)
+ XCTAssertEqual(Int(image.size.height), expected)
+ }
+
+ func testGpuModeActuallyDrawsOnTheGpu() throws {
+ let provider = try provider(.gpu)
+ defer { provider.close() }
+
+ for x in 0..<2 {
+ XCTAssertNotNil(provider.renderTile(request: TileRequest(x: x, y: 0, z: 1)))
+ }
+ XCTAssertEqual(provider.gpuRenders, 2)
+ // The claim this test exists to defend. Without it the whole suite
+ // passes while every tile silently falls back to tiny-skia.
+ XCTAssertEqual(provider.gpuFallbacks, 0, "GPU tiles fell back to the CPU")
+ }
+
+ func testCpuModeNeverTouchesTheGpu() throws {
+ let provider = try provider(.cpu)
+ defer { provider.close() }
+
+ XCTAssertNotNil(provider.renderTile(request: TileRequest(x: 0, y: 0, z: 0)))
+ XCTAssertEqual(provider.renderMode, .cpu)
+ XCTAssertEqual(provider.gpuRenders, 0)
+ }
+
+ func testGpuAndCpuAgreeOnWhatTheTileLooksLike() throws {
+ let gpuProvider = try provider(.gpu)
+ let cpuProvider = try provider(.cpu)
+ defer { gpuProvider.close(); cpuProvider.close() }
+
+ let request = TileRequest(x: 0, y: 0, z: 0)
+ let gpu = try pixels(XCTUnwrap(gpuProvider.renderTile(request: request)))
+ let cpu = try pixels(XCTUnwrap(cpuProvider.renderTile(request: request)))
+
+ // Compared as 8x8 block averages, not pixel by pixel. The two
+ // rasterisers genuinely differ on edges — MSAA against analytic
+ // coverage — and this test tile is a whole world at z0, which is almost
+ // entirely coastline: about 3% of its pixels sit on an edge, while a
+ // normal street tile measures well under one. Averaging asks the
+ // question the claim actually makes, that no *region* disagrees.
+ let block = 8
+ let blocks = tileSize / block
+ var differingBlocks = 0
+ var worstBlock = 0
+
+ for blockY in 0.. 24 { differingBlocks += 1 }
+ worstBlock = max(worstBlock, worstChannel)
+ }
+ }
+
+ let percent = Double(differingBlocks) * 100 / Double(blocks * blocks)
+ print(String(format: "GPU_VS_CPU_BLOCKS=%.2f%% worst=%d", percent, worstBlock))
+ XCTAssertLessThan(
+ percent, 1.0,
+ String(format: "%.2f%% of 8x8 blocks disagree (worst channel delta %d)", percent, worstBlock)
+ )
+ }
+
+ func testGpuIsNotSlowerThanTheCpu() throws {
+ let gpuProvider = try provider(.gpu)
+ let cpuProvider = try provider(.cpu)
+ defer { gpuProvider.close(); cpuProvider.close() }
+
+ func cost(_ provider: VectorTileProvider) -> Double {
+ // One warm-up: the first tile pays for pipeline compilation and
+ // texture allocation, which is not what this measures.
+ _ = provider.renderTile(request: TileRequest(x: 0, y: 0, z: 0))
+ let started = CFAbsoluteTimeGetCurrent()
+ for _ in 0..<3 {
+ _ = provider.renderTile(request: TileRequest(x: 0, y: 0, z: 0))
+ }
+ return (CFAbsoluteTimeGetCurrent() - started) * 1000 / 3
+ }
+
+ let gpu = cost(gpuProvider)
+ let cpu = cost(cpuProvider)
+ print(String(format: "MODE_COST gpu=%.1fms cpu=%.1fms", gpu, cpu))
+ // Deliberately loose. The point is to catch a regression that makes the
+ // GPU path pointless, not to pin a number that varies with thermal
+ // state — device timings swing by 3x when the chip is hot.
+ XCTAssertLessThan(gpu, cpu * 1.5)
+ }
+
+ /// Decodes PNG bytes to straight-alpha RGBA.
+ private func pixels(_ png: Data) throws -> [UInt8] {
+ let image = try XCTUnwrap(UIImage(data: png)?.cgImage)
+ var buffer = [UInt8](repeating: 0, count: tileSize * tileSize * 4)
+ let context = try XCTUnwrap(
+ CGContext(
+ data: &buffer,
+ width: tileSize, height: tileSize,
+ bitsPerComponent: 8, bytesPerRow: tileSize * 4,
+ space: CGColorSpaceCreateDeviceRGB(),
+ bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
+ )
+ )
+ // Draw onto opaque white so premultiplication cannot change the
+ // comparison: both renders land on the same ground.
+ context.setFillColor(red: 1, green: 1, blue: 1, alpha: 1)
+ context.fill(CGRect(x: 0, y: 0, width: tileSize, height: tileSize))
+ context.draw(image, in: CGRect(x: 0, y: 0, width: tileSize, height: tileSize))
+ return buffer
+ }
+}
diff --git a/ios-vectortile/Tests/MapConductorVectorTileTests/MetalTileRasterizerCancellationTests.swift b/ios-vectortile/Tests/MapConductorVectorTileTests/MetalTileRasterizerCancellationTests.swift
new file mode 100644
index 0000000..ba3b9b3
--- /dev/null
+++ b/ios-vectortile/Tests/MapConductorVectorTileTests/MetalTileRasterizerCancellationTests.swift
@@ -0,0 +1,50 @@
+import XCTest
+@testable import MapConductorVectorTile
+
+final class MetalTileRasterizerCancellationTests: XCTestCase {
+ func testCancellationReleasesAWaiterWhileTheGPUIsStillBusy() throws {
+ guard let gpu = MetalTileRasterizer.createOrNull(tileSize: 32) else {
+ throw XCTSkip("Metal unavailable")
+ }
+ let renderer = try VectorTileRenderer(styleJSON:
+ "{\"version\":8,\"sources\":{},\"layers\":[{\"id\":\"bg\",\"type\":\"background\",\"paint\":{\"background-color\":\"#000000\"}}]}")
+ let tile = try renderer.tessellate(z: 0, x: 0, y: 0, tileSize: 32, tiles: [])
+ let busy = expectation(description: "first render owns GPU resources")
+ let waiting = expectation(description: "second render waiting")
+ let cancelled = expectation(description: "waiter cancelled before GPU becomes free")
+ let finished = expectation(description: "first and current renders finish")
+ finished.expectedFulfillmentCount = 2
+ let release = DispatchSemaphore(value: 0)
+ defer { release.signal() }
+ let flag = Cancellation()
+ DispatchQueue.global().async {
+ XCTAssertNotNil(gpu.renderPng(tile) { _ in
+ busy.fulfill()
+ _ = release.wait(timeout: .now() + 5)
+ })
+ finished.fulfill()
+ }
+ wait(for: [busy], timeout: 2)
+ DispatchQueue.global().async {
+ waiting.fulfill()
+ XCTAssertNil(gpu.renderPng(tile, isCancelled: { flag.isCancelled }))
+ cancelled.fulfill()
+ }
+ wait(for: [waiting], timeout: 1)
+ flag.cancel()
+ wait(for: [cancelled], timeout: 1)
+ DispatchQueue.global().async {
+ XCTAssertNotNil(gpu.renderPng(tile, isCancelled: { false }))
+ finished.fulfill()
+ }
+ release.signal()
+ wait(for: [finished], timeout: 2)
+ }
+
+ private final class Cancellation {
+ private let lock = NSLock()
+ private var value = false
+ func cancel() { lock.lock(); value = true; lock.unlock() }
+ var isCancelled: Bool { lock.lock(); defer { lock.unlock() }; return value }
+ }
+}
diff --git a/ios-vectortile/Tests/MapConductorVectorTileTests/PngEncoderTests.swift b/ios-vectortile/Tests/MapConductorVectorTileTests/PngEncoderTests.swift
new file mode 100644
index 0000000..9af365c
--- /dev/null
+++ b/ios-vectortile/Tests/MapConductorVectorTileTests/PngEncoderTests.swift
@@ -0,0 +1,187 @@
+import UIKit
+import XCTest
+
+import CTilePng
+import MapConductorCore
+
+/**
+ The Rust encoder against `UIImage.pngData()`, on marker-tile-shaped content.
+
+ Reports rather than asserts a ratio: it measures the platform, and pinning a
+ number would only fail the suite on different hardware.
+ */
+final class PngEncoderTests: XCTestCase {
+
+ /// A tile the way the marker renderer leaves it: mostly transparent, with a
+ /// few hundred anti-aliased icons on it. Circles, not rectangles — hard
+ /// edges compress unlike anything real, which already sent one filter
+ /// decision the wrong way in this project.
+ private func markerTile(size: CGFloat, icons: Int, iconPx: CGFloat) -> UIImage {
+ let format = UIGraphicsImageRendererFormat.default()
+ format.scale = 1
+ format.opaque = false
+ var seed: UInt64 = 12345
+ func next(_ bound: UInt64) -> CGFloat {
+ seed = seed &* 6364136223846793005 &+ 1442695040888963407
+ return CGFloat((seed >> 33) % bound)
+ }
+ return UIGraphicsImageRenderer(size: CGSize(width: size, height: size), format: format)
+ .image { context in
+ for _ in 0.. Double { samples.sorted()[samples.count / 2] }
+
+ var platformBytes = 0
+ let platform = median((0..<5).map { _ in
+ let started = CFAbsoluteTimeGetCurrent()
+ let data = tile.pngData()
+ let elapsed = (CFAbsoluteTimeGetCurrent() - started) * 1000
+ platformBytes = data?.count ?? 0
+ return elapsed
+ })
+
+ var rustBytes = 0
+ let rust = median((0..<5).map { _ in
+ let started = CFAbsoluteTimeGetCurrent()
+ let data = TilePngEncoder.encode(tile)
+ let elapsed = (CFAbsoluteTimeGetCurrent() - started) * 1000
+ rustBytes = data?.count ?? 0
+ return elapsed
+ })
+
+ print(String(
+ format: "IOS_PNG platform=%.1fms/%dB rust=%.1fms/%dB speed=%.1fx size=%.1fx",
+ platform, platformBytes, rust, rustBytes,
+ platform / rust, Double(rustBytes) / Double(platformBytes)
+ ))
+ XCTAssertGreaterThan(rustBytes, 0, "the native encoder produced nothing")
+ }
+
+ /// The encoder must not change what the tile looks like.
+ func testRustOutputMatchesThePlatformVisually() throws {
+ let tile = markerTile(size: 256, icons: 40, iconPx: 40)
+ let rust = try XCTUnwrap(TilePngEncoder.encode(tile))
+ let platform = try XCTUnwrap(tile.pngData())
+
+ func pixels(_ data: Data) throws -> [UInt8] {
+ let image = try XCTUnwrap(UIImage(data: data)?.cgImage)
+ var buffer = [UInt8](repeating: 0, count: 256 * 256 * 4)
+ let context = try XCTUnwrap(CGContext(
+ data: &buffer, width: 256, height: 256,
+ bitsPerComponent: 8, bytesPerRow: 256 * 4,
+ space: CGColorSpaceCreateDeviceRGB(),
+ bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
+ ))
+ context.draw(image, in: CGRect(x: 0, y: 0, width: 256, height: 256))
+ return buffer
+ }
+
+ let a = try pixels(rust)
+ let b = try pixels(platform)
+ var worst = 0
+ for index in 0.. Double { samples.sorted()[samples.count / 2] }
+
+ let extract = median((0..<5).map { _ in
+ let started = CFAbsoluteTimeGetCurrent()
+ pixels.withUnsafeMutableBytes { raw in
+ let context = CGContext(
+ data: raw.baseAddress, width: size, height: size,
+ bitsPerComponent: 8, bytesPerRow: size * 4,
+ space: CGColorSpaceCreateDeviceRGB(),
+ bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
+ )
+ context?.draw(cgImage, in: CGRect(x: 0, y: 0, width: size, height: size))
+ }
+ return (CFAbsoluteTimeGetCurrent() - started) * 1000
+ })
+
+ // Encode only, on pixels already in hand.
+ let encodeOnly = median((0..<5).map { _ in
+ var scratch = pixels
+ var outPointer: UnsafeMutablePointer?
+ var outLength = 0
+ let started = CFAbsoluteTimeGetCurrent()
+ _ = scratch.withUnsafeMutableBufferPointer { buffer in
+ tile_png_encode(buffer.baseAddress, UInt32(size), UInt32(size), 1,
+ &outPointer, &outLength)
+ }
+ let elapsed = (CFAbsoluteTimeGetCurrent() - started) * 1000
+ if let outPointer { tile_png_buffer_free(outPointer, outLength) }
+ return elapsed
+ })
+
+ print(String(format: "IOS_PNG_SPLIT extract=%.1fms encodeOnly=%.1fms", extract, encodeOnly))
+ XCTAssertGreaterThan(encodeOnly, 0)
+ }
+
+ /// What share of a real marker tile the encoder actually is.
+ ///
+ /// The encoder being 1.7x faster only matters in proportion to how much of
+ /// a tile it accounts for, and nothing had measured that on iOS.
+ @MainActor
+ func testEncoderShareOfARealMarkerTile() throws {
+ for markerCount in [2_000, 20_000] {
+ let manager = MarkerManager.defaultManager()
+ var seed: UInt64 = 12345
+ func next() -> Double {
+ seed = seed &* 6364136223846793005 &+ 1442695040888963407
+ return Double(seed >> 11) / Double(1 << 53)
+ }
+ for _ in 0..(
+ marker: nil, state: MarkerState(position: position),
+ visible: true, isRendered: true, tiling: true
+ ))
+ }
+
+ for (z, x, y) in [(12, 3638, 1612), (6, 56, 25)] {
+ func median(_ samples: [Double]) -> Double { samples.sorted()[samples.count / 2] }
+ // A fresh renderer per round: its cache would answer every call
+ // after the first, which is not what this measures.
+ let samples = (0..<3).map { _ -> Double in
+ let renderer = MarkerTileRenderer(
+ markerManager: manager, tileSize: 512
+ )
+ let started = CFAbsoluteTimeGetCurrent()
+ _ = renderer.renderTile(request: TileRequest(x: x, y: y, z: z))
+ return (CFAbsoluteTimeGetCurrent() - started) * 1000
+ }
+ print(String(format: "IOS_MARKERTILE n=%d z=%d render=%.1fms",
+ markerCount, z, median(samples)))
+ }
+ }
+ }
+
+}
diff --git a/ios-vectortile/Tests/MapConductorVectorTileTests/RasterisationTests.swift b/ios-vectortile/Tests/MapConductorVectorTileTests/RasterisationTests.swift
new file mode 100644
index 0000000..3611512
--- /dev/null
+++ b/ios-vectortile/Tests/MapConductorVectorTileTests/RasterisationTests.swift
@@ -0,0 +1,115 @@
+import MapConductorCore
+import MapConductorVectorStyle
+import UIKit
+import XCTest
+@testable import MapConductorVectorTile
+
+final class RasterisationTests: XCTestCase {
+ private final class Host: MapStyleHost {
+ private let registry = MutableMapServiceRegistry()
+ var serviceRegistry: MapServiceRegistry { registry }
+ var tileServer: LocalTileServer { TileServerRegistry.get() }
+ var mounted: [RasterLayerState] = []
+ var removed: [String] = []
+ func onStyleLoaded(_ block: @escaping () -> Void) -> MapStyleInstallation { .none }
+ func upsertRaster(_ state: RasterLayerState) { mounted.append(state) }
+ func removeRaster(id: String) { removed.append(id) }
+ func report(_ diagnostics: [String]) {}
+ }
+
+ private func document(_ color: String, visible: Bool = false) -> String {
+ """
+ {"version":8,"sources":{},"layers":[
+ {"id":"background","type":"background","paint":{"background-color":"\(color)"}},
+ {"id":"labels","type":"symbol","layout":{"visibility":"\(visible ? "visible" : "none")"},"paint":{"text-color":"#ffffff"}}
+ ]}
+ """
+ }
+
+ private func settle(until condition: () -> Bool) {
+ let deadline = Date().addingTimeInterval(5)
+ while !condition(), Date() < deadline {
+ RunLoop.current.run(until: Date().addingTimeInterval(0.01))
+ }
+ XCTAssertTrue(condition())
+ }
+
+ private func drainPendingUpdates() {
+ RunLoop.current.run(until: Date().addingTimeInterval(0.3))
+ }
+
+ private func pixel(in state: RasterLayerState) throws -> [UInt8] {
+ guard case let .urlTemplate(template, _, _, _, _, _) = state.source else {
+ throw VectorTileError.styleRejected("expected raster URL")
+ }
+ let url = try XCTUnwrap(URL(string: template
+ .replacingOccurrences(of: "{z}", with: "0")
+ .replacingOccurrences(of: "{x}", with: "0")
+ .replacingOccurrences(of: "{y}", with: "0")))
+ let received = expectation(description: "rendered raster")
+ var png: Data?
+ URLSession.shared.dataTask(with: url) { data, _, _ in
+ png = data
+ received.fulfill()
+ }.resume()
+ wait(for: [received], timeout: 5)
+ let image = try XCTUnwrap(UIImage(data: try XCTUnwrap(png))?.cgImage)
+ var pixel = [UInt8](repeating: 0, count: 4)
+ try pixel.withUnsafeMutableBytes { buffer in
+ let context = try XCTUnwrap(CGContext(
+ data: buffer.baseAddress, width: 1, height: 1,
+ bitsPerComponent: 8, bytesPerRow: 4, space: CGColorSpaceCreateDeviceRGB(),
+ bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue))
+ context.draw(image, in: CGRect(x: 0, y: 0, width: 1, height: 1))
+ }
+ return pixel
+ }
+
+ func testBurstPublishesOnlyLatestStyleAndKeepsUnchangedLabelLayer() throws {
+ let host = Host()
+ let work = VectorTileRasteriser(tileSize: 32, renderMode: .cpu, renderScale: 1)
+ .install(host: host, styleJSON: document("#000000"), affects: .both)
+ defer { work.dispose() }
+ settle { host.mounted.count == 2 }
+ XCTAssertFalse(host.mounted[1].visible, "hidden symbols should not request empty tiles")
+ let labels = host.mounted[1].source
+ for step in 1...40 {
+ work.restyle(styleJSON: document(String(format: "#%06x", step)), affects: .both)
+ }
+ settle { host.mounted.count > 2 }
+ drainPendingUpdates()
+ XCTAssertEqual(host.mounted.count, 3, "a burst should publish only one ground update")
+ XCTAssertEqual(try pixel(in: host.mounted[2]), [0, 0, 40, 255], "the final requested colour must reach the map")
+ XCTAssertEqual(host.mounted.filter { $0.id.hasSuffix("-labels") }.map(\.source), [labels])
+
+ // The compiler reports .none for returning to the authored style.
+ // It is still a change relative to the pixels currently on screen.
+ work.restyle(styleJSON: document("#000000"), affects: .none)
+ settle { host.mounted.count == 4 }
+ XCTAssertNotEqual(host.mounted[2].source, host.mounted[3].source)
+ XCTAssertEqual(try pixel(in: host.mounted[3]), [0, 0, 0, 255])
+ work.restyle(styleJSON: document("#000000", visible: true), affects: .labels)
+ settle { host.mounted.count == 5 }
+ XCTAssertTrue(host.mounted[4].id.hasSuffix("-labels"))
+ XCTAssertTrue(host.mounted[4].visible)
+ }
+
+ func testDisposalPreventsPendingRestyleFromRemountingLayers() {
+ let host = Host()
+ let work = VectorTileRasteriser(tileSize: 32, renderMode: .cpu, renderScale: 1)
+ .install(host: host, styleJSON: document("#000000"), affects: .both)
+ settle { host.mounted.count == 2 }
+ work.restyle(styleJSON: document("#ffffff"), affects: .ground)
+ work.dispose()
+ settle { host.removed.count == 2 }
+ drainPendingUpdates()
+ XCTAssertEqual(host.mounted.count, 2)
+ }
+
+ func testSourceChangesInvalidateBothHalves() throws {
+ let before = try RasterStyleSnapshot(styleJSON: document("#000000"))
+ let changed = document("#000000").replacingOccurrences(
+ of: "\"sources\":{}", with: "\"sources\":{\"s\":{\"type\":\"vector\",\"tiles\":[\"https://example.com/{z}/{x}/{y}.pbf\"]}}")
+ XCTAssertEqual(try RasterStyleSnapshot(styleJSON: changed).changes(from: before), .both)
+ }
+}
diff --git a/ios-vectortile/Tests/MapConductorVectorTileTests/RendererCloseRaceTests.swift b/ios-vectortile/Tests/MapConductorVectorTileTests/RendererCloseRaceTests.swift
new file mode 100644
index 0000000..48633f4
--- /dev/null
+++ b/ios-vectortile/Tests/MapConductorVectorTileTests/RendererCloseRaceTests.swift
@@ -0,0 +1,69 @@
+import XCTest
+@testable import MapConductorVectorTile
+
+/// Closing the renderer while a render is in flight.
+///
+/// The provider is rebuilt when the map under it changes its tile size, and
+/// ArcGIS's tiles are drawn on a queue of their own, so a render was still
+/// running when the old provider closed. Freeing the native renderer under
+/// that call read freed memory, and the app went to the home screen a second
+/// after switching to ArcGIS. android-sdk's binding hit the same fault.
+///
+/// A use-after-free does not fail reliably -- it corrupts, or crashes later,
+/// or does nothing -- so this runs the race many times and treats surviving it
+/// as the assertion, plus the one thing that *is* deterministic: a call that
+/// starts after the close must throw rather than touch anything.
+final class RendererCloseRaceTests: XCTestCase {
+ private var styleJSON: String!
+ private var tileData: Data!
+
+ override func setUpWithError() throws {
+ let bundle = Bundle.module
+ styleJSON = try String(
+ contentsOf: try XCTUnwrap(bundle.url(forResource: "demo-style", withExtension: "json")),
+ encoding: .utf8
+ )
+ tileData = try Data(
+ contentsOf: try XCTUnwrap(bundle.url(forResource: "tile-0-0-0", withExtension: "pbf"))
+ )
+ }
+
+ func testCloseWaitsForARenderInFlight() throws {
+ for _ in 0..<40 {
+ let renderer = try VectorTileRenderer(styleJSON: styleJSON, displayTileSize: 512)
+ let plan = try renderer.plan(z: 0, x: 0, y: 0)
+ XCTAssertFalse(plan.isEmpty)
+ let tiles: [Data?] = [tileData]
+
+ let started = DispatchSemaphore(value: 0)
+ let finished = DispatchSemaphore(value: 0)
+ var renderThrew = false
+ let worker = Thread {
+ started.signal()
+ // Either it drew (close waited for it) or the close got in
+ // first and it threw. Both are correct; a crash is not.
+ for _ in 0..<3 {
+ do {
+ _ = try renderer.render(z: 0, x: 0, y: 0, tileSize: 512, tiles: tiles)
+ } catch {
+ renderThrew = true
+ break
+ }
+ }
+ finished.signal()
+ }
+ worker.start()
+ XCTAssertEqual(started.wait(timeout: .now() + 5), .success)
+ renderer.close()
+ XCTAssertEqual(finished.wait(timeout: .now() + 10), .success, "render never finished")
+
+ // Deterministic half: after close, every call throws `.closed`.
+ XCTAssertThrowsError(try renderer.plan(z: 0, x: 0, y: 0)) { error in
+ guard case VectorTileError.closed = error else {
+ return XCTFail("expected .closed, got \(error)")
+ }
+ }
+ _ = renderThrew
+ }
+ }
+}
diff --git a/ios-vectortile/Tests/MapConductorVectorTileTests/Resources/demo-style.json b/ios-vectortile/Tests/MapConductorVectorTileTests/Resources/demo-style.json
new file mode 100644
index 0000000..2cad3c2
--- /dev/null
+++ b/ios-vectortile/Tests/MapConductorVectorTileTests/Resources/demo-style.json
@@ -0,0 +1 @@
+{"id": "43f36e14-e3f5-43c1-84c0-50a9c80dc5c7", "name": "MapLibre", "zoom": 0.8619833357855968, "pitch": 0, "center": [17.65431710431244, 32.954120326746775], "glyphs": "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf", "layers": [{"id": "background", "type": "background", "paint": {"background-color": "#D8F2FF"}, "filter": ["all"], "layout": {"visibility": "visible"}, "maxzoom": 24}, {"id": "coastline", "type": "line", "paint": {"line-blur": 0.5, "line-color": "#198EC8", "line-width": {"stops": [[0, 2], [6, 6], [14, 9], [22, 18]]}}, "filter": ["all"], "layout": {"line-cap": "round", "line-join": "round", "visibility": "visible"}, "source": "maplibre", "maxzoom": 24, "minzoom": 0, "source-layer": "countries"}, {"id": "countries-fill", "type": "fill", "paint": {"fill-color": ["match", ["get", "ADM0_A3"], ["ARM", "ATG", "AUS", "BTN", "CAN", "COG", "CZE", "GHA", "GIN", "HTI", "ISL", "JOR", "KHM", "KOR", "LVA", "MLT", "MNE", "MOZ", "PER", "SAH", "SGP", "SLV", "SOM", "TJK", "TUV", "UKR", "WSM"], "#D6C7FF", ["AZE", "BGD", "CHL", "CMR", "CSI", "DEU", "DJI", "GUY", "HUN", "IOA", "JAM", "LBN", "LBY", "LSO", "MDG", "MKD", "MNG", "MRT", "NIU", "NZL", "PCN", "PYF", "SAU", "SHN", "STP", "TTO", "UGA", "UZB", "ZMB"], "#EBCA8A", ["AGO", "ASM", "ATF", "BDI", "BFA", "BGR", "BLZ", "BRA", "CHN", "CRI", "ESP", "HKG", "HRV", "IDN", "IRN", "ISR", "KNA", "LBR", "LCA", "MAC", "MUS", "NOR", "PLW", "POL", "PRI", "SDN", "TUN", "UMI", "USA", "USG", "VIR", "VUT"], "#C1E599", ["ARE", "ARG", "BHS", "CIV", "CLP", "DMA", "ETH", "GAB", "GRD", "HMD", "IND", "IOT", "IRL", "IRQ", "ITA", "KOS", "LUX", "MEX", "NAM", "NER", "PHL", "PRT", "RUS", "SEN", "SUR", "TZA", "VAT"], "#E7E58F", ["AUT", "BEL", "BHR", "BMU", "BRB", "CYN", "DZA", "EST", "FLK", "GMB", "GUM", "HND", "JEY", "KGZ", "LIE", "MAF", "MDA", "NGA", "NRU", "SLB", "SOL", "SRB", "SWZ", "THA", "TUR", "VEN", "VGB"], "#98DDA1", ["AIA", "BIH", "BLM", "BRN", "CAF", "CHE", "COM", "CPV", "CUB", "ECU", "ESB", "FSM", "GAZ", "GBR", "GEO", "KEN", "LTU", "MAR", "MCO", "MDV", "NFK", "NPL", "PNG", "PRY", "QAT", "SLE", "SPM", "SYC", "TCA", "TKM", "TLS", "VNM", "WEB", "WSB", "YEM", "ZWE"], "#83D5F4", ["ABW", "ALB", "AND", "ATC", "BOL", "COD", "CUW", "CYM", "CYP", "EGY", "FJI", "GGY", "IMN", "KAB", "KAZ", "KWT", "LAO", "MLI", "MNP", "MSR", "MYS", "NIC", "NLD", "PAK", "PAN", "PRK", "ROU", "SGS", "SVN", "SWE", "TGO", "TWN", "VCT", "ZAF"], "#B1BBF9", ["ATA", "GRL"], "#FFFFFF", "#EAB38F"]}, "filter": ["all"], "layout": {"visibility": "visible"}, "source": "maplibre", "maxzoom": 24, "source-layer": "countries"}, {"id": "countries-boundary", "type": "line", "paint": {"line-color": "rgba(255, 255, 255, 1)", "line-width": {"stops": [[1, 1], [6, 2], [14, 6], [22, 12]]}, "line-opacity": {"stops": [[3, 0.5], [6, 1]]}}, "layout": {"line-cap": "round", "line-join": "round", "visibility": "visible"}, "source": "maplibre", "maxzoom": 24, "source-layer": "countries"}, {"id": "geolines", "type": "line", "paint": {"line-color": "#1077B0", "line-opacity": 1, "line-dasharray": [3, 3]}, "filter": ["all", ["!=", "name", "International Date Line"]], "layout": {"visibility": "visible"}, "source": "maplibre", "maxzoom": 24, "source-layer": "geolines"}, {"id": "geolines-label", "type": "symbol", "paint": {"text-color": "#1077B0", "text-halo-blur": 1, "text-halo-color": "rgba(255, 255, 255, 1)", "text-halo-width": 1}, "filter": ["all", ["!=", "name", "International Date Line"]], "layout": {"text-font": ["Open Sans Semibold"], "text-size": {"stops": [[2, 12], [6, 16]]}, "text-field": "{name}", "visibility": "visible", "symbol-placement": "line"}, "source": "maplibre", "maxzoom": 24, "minzoom": 1, "source-layer": "geolines"}, {"id": "countries-label", "type": "symbol", "paint": {"text-color": "rgba(8, 37, 77, 1)", "text-halo-blur": {"stops": [[2, 0.2], [6, 0]]}, "text-halo-color": "rgba(255, 255, 255, 1)", "text-halo-width": {"stops": [[2, 1], [6, 1.6]]}}, "filter": ["all"], "layout": {"text-font": ["Open Sans Semibold"], "text-size": {"stops": [[2, 10], [4, 12], [6, 16]]}, "text-field": {"stops": [[2, "{ABBREV}"], [4, "{NAME}"]]}, "visibility": "visible", "text-max-width": 10, "text-transform": {"stops": [[0, "uppercase"], [2, "none"]]}}, "source": "maplibre", "maxzoom": 24, "minzoom": 2, "source-layer": "centroids"}, {"id": "crimea-fill", "type": "fill", "source": "crimea", "paint": {"fill-color": "#D6C7FF"}}], "bearing": 0, "sources": {"maplibre": {"type": "vector", "tiles": ["https://demotiles.maplibre.org/tiles/{z}/{x}/{y}.pbf"], "minzoom": 0, "maxzoom": 6}, "crimea": {"type": "geojson", "data": {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[34.00905273547181, 46.55925987559425], [33.64325260204026, 46.34533545368038], [33.628682598560204, 46.12569762665683], [33.64292861730951, 46.10476396128129], [33.648473474905984, 46.09033047763651], [33.63876482059936, 46.077976784785335], [33.62782672238245, 46.06747935719011], [33.62911357645072, 46.05708111413949], [33.642686868727424, 46.02192963417187], [33.6429723910654, 46.01521185644708], [33.636224138774026, 46.006705833212465], [33.63052626465907, 45.99692992186792], [33.63193836679693, 45.988472992911284], [33.64276684834442, 45.984575360297384], [33.646928693041986, 45.97981936210982], [33.638745893564305, 45.96829769147004], [33.61958133326394, 45.951176418494185], [33.63181380398527, 45.9445404758078], [33.638921676216, 45.94737012930554], [33.64561542516918, 45.95403251372139], [33.65666403976448, 45.95687114427736], [33.6825817382811, 45.95878100879199], [33.738791807037614, 45.94836945227263], [33.758180142697, 45.94072970008301], [33.77735917288169, 45.92923970233858], [33.75927796793485, 45.92241179584471], [33.72529865009221, 45.91587363154565], [33.70875012326826, 45.91008760988058], [33.69378857293381, 45.91480850795665], [33.69092650243843, 45.89657370898402], [33.693592356906805, 45.87271465766318], [33.69226765972388, 45.86041392418218], [33.6704813511748, 45.8584273836251], [33.65936345808916, 45.85944682601249], [33.653870582376726, 45.86425922279372], [33.65107345584843, 45.87089907254003], [33.63067378180233, 45.88040685247182], [33.61945300059696, 45.88147266102649], [33.60987421595539, 45.88048951126686], [33.59906957603934, 45.877610457390375], [33.57828877687868, 45.86810261756233], [33.55357394560386, 45.84700625141778], [33.530220674480375, 45.84221983655459], [33.5192297395441, 45.84121682367507], [33.50832088442496, 45.84313067048083], [33.48901101848409, 45.85268298292175], [33.482152996405716, 45.854578171799005], [33.46719955896293, 45.849912739405056], [33.42447496599681, 45.83075886348303], [33.40940172404095, 45.82691953557702], [33.37918350072067, 45.802867525073566], [33.37362145339398, 45.79619281922518], [33.33805543634864, 45.78577808972071], [33.26498872665803, 45.75410774187094], [33.22887541283427, 45.75131270772724], [33.19548267281132, 45.7644887297206], [33.1789202379222, 45.78010311364778], [33.1688456078636, 45.78470227904205], [33.161012432811674, 45.77921593899549], [33.15951585299757, 45.76864464913777], [33.165962301438725, 45.762685940125465], [33.1750888126426, 45.759218220695715], [33.181464829753, 45.75490447884948], [33.17613930782352, 45.7437961960276], [33.16369168844906, 45.735912015025065], [32.93692665480876, 45.662114646778264], [32.86839112407645, 45.63044340698664], [32.83803944575723, 45.60834075026611], [32.82702772424804, 45.59576101516498], [32.82433467080986, 45.58705137380335], [32.82563941622885, 45.579605763895614], [32.82993674258438, 45.56978311819469], [32.82851940940563, 45.56227808675749], [32.813310142795274, 45.55930933158257], [32.80213583657516, 45.560145780074464], [32.78258622159436, 45.565158335073846], [32.77333922465823, 45.56689313356526], [32.758306734735356, 45.565030173463356], [32.750177256846115, 45.55943726334968], [32.74340732630185, 45.55261895849793], [32.73524549539499, 45.54598788110354], [32.72031700779701, 45.53735927760957], [32.70536040418847, 45.53169142131733], [32.68589438933773, 45.52663379187257], [32.66370583186284, 45.52563107058867], [32.64312077736798, 45.52188979044979], [32.525284074162556, 45.45838108691365], [32.49490411219156, 45.43524910229854], [32.48107654411925, 45.408986638827514], [32.48514589713025, 45.39458067125969], [32.51256939517424, 45.34060655033625], [32.535915460470335, 45.33777248012882], [32.57027153843481, 45.32510892683359], [32.590830644991826, 45.32038723212662], [32.66380378113439, 45.320421746458976], [32.67760722618917, 45.32609231279554], [32.71316246802607, 45.353283572618125], [32.72817188836078, 45.36074681043402], [32.750518060251466, 45.36371725645313], [32.89973931692998, 45.35412322462227], [32.941197846443885, 45.34245505845169], [32.97701667405008, 45.32596743563991], [33.04296090827762, 45.2853982930032], [33.05274355585479, 45.28154273654923], [33.06850284417635, 45.27703461892352], [33.07825272648239, 45.272210805127315], [33.089426322403455, 45.25656353201492], [33.09897492343546, 45.247820101667884], [33.12384611720435, 45.238235755071685], [33.15767197859745, 45.20755227709648], [33.172959979330074, 45.19681657531794], [33.21837666514142, 45.187878368659824], [33.24017433636709, 45.180191106261134], [33.248571989896675, 45.16588271012458], [33.259649216030766, 45.155918961282026], [33.28309785485047, 45.16064860772312], [33.31767999550894, 45.17535522412791], [33.35458473323109, 45.18598673360148], [33.39725661527919, 45.18973663076909], [33.41344561756824, 45.18490731877088], [33.468468576977216, 45.149132412229676], [33.537128652906205, 45.11719769268973], [33.56161328289443, 45.094099022711475], [33.57837628774928, 45.053145935448015], [33.58247744978442, 45.027377243150454], [33.5851414316958, 45.01816461606674], [33.6031021265521, 44.993103583251695], [33.605922209331794, 44.986905272229734], [33.60843524291815, 44.97039962759274], [33.61943161357851, 44.93184946652454], [33.619484500808824, 44.90819321920554], [33.61549738593425, 44.88894092276257], [33.608561183117274, 44.871288478948514], [33.59889474705494, 44.859790298912856], [33.55904244709464, 44.850057575124595], [33.54667558363471, 44.83724531175508], [33.53701832136994, 44.81871953508235], [33.5303157846202, 44.798338017069625], [33.5249116915937, 44.78918633101301], [33.51669091675143, 44.784809980590666], [33.524785531609865, 44.77183212449111], [33.5302902535075, 44.75724515985675], [33.53710734694323, 44.73034290771247], [33.54650992495621, 44.70989226909535], [33.5481286806762, 44.699106546699085], [33.543995566510915, 44.68230506537358], [33.53580273994743, 44.6726082589706], [33.52337411931097, 44.661863083605255], [33.515320778874354, 44.6491266698327], [33.516377841582795, 44.63464990118433], [33.52466971637648, 44.62863961572572], [33.557474298027785, 44.62473000923737], [33.5710648827386, 44.620853511273225], [33.55105839203679, 44.61506440493406], [33.499905706797676, 44.61452599304897], [33.48451102966331, 44.60992438254493], [33.47658499621011, 44.60714391514574], [33.46705078205747, 44.60616254193252], [33.44476599234898, 44.607062134677875], [33.4353466482458, 44.60509936890821], [33.413591053005575, 44.593500212748125], [33.40543527945235, 44.59055535193136], [33.37510958624222, 44.58564691897425], [33.37074452434078, 44.58851022190515], [33.372237834990756, 44.576810695127364], [33.37913003799301, 44.56412673079859], [33.48759131590526, 44.51024086451031], [33.50011215135888, 44.50041002882833], [33.517917009115365, 44.49074142372788], [33.53836387802215, 44.49164280212756], [33.56041892763031, 44.4966411022441], [33.57822378538677, 44.497542389459795], [33.59062975079095, 44.48975808594983], [33.619577003408466, 44.46229988129974], [33.62635433636015, 44.45336293328907], [33.63175322871038, 44.434828756313124], [33.645537634715026, 44.42498521035591], [33.721007257593925, 44.39946630464436], [33.74168386660085, 44.39560878121904], [33.80727466517129, 44.39454176175843], [33.81841706002561, 44.39552670349164], [33.83909366903248, 44.40143600575672], [33.85149963444792, 44.40143600575945], [33.91467816197718, 44.38387049706651], [33.938111652185, 44.38083293528811], [33.957065210440874, 44.38272116790142], [34.06614966692763, 44.42019923628979], [34.088893936836286, 44.42200415824283], [34.10279321289039, 44.42487551014821], [34.135933345669, 44.44163597968952], [34.14696087047267, 44.44959070749778], [34.16058918507403, 44.466287285335795], [34.170123399227776, 44.48186111741296], [34.182759104731986, 44.49267838558103], [34.22923417224524, 44.49949719774551], [34.24301857824986, 44.50744404277697], [34.263903954150294, 44.53186886058606], [34.26631622520165, 44.53555362837611], [34.26631622520165, 44.54153064468656], [34.27033667695244, 44.545378535987936], [34.2757355693048, 44.54644280144541], [34.285384653508004, 44.54562413743594], [34.299973149863405, 44.54554227040174], [34.32260254971496, 44.543577427039224], [34.3308731933177, 44.54546040325087], [34.340292537420794, 44.55798473830754], [34.38042135640006, 44.631830317636684], [34.41495238900856, 44.673669777529994], [34.424193090575585, 44.68239452736094], [34.42959198292681, 44.68884644523774], [34.469399167794535, 44.730194532749294], [34.47376422969597, 44.73011292571252], [34.47376422969597, 44.72635887754967], [34.475142670296464, 44.723502373339585], [34.499724861011515, 44.74292382044041], [34.532800295801195, 44.752620844929055], [34.61217550038418, 44.76534519537847], [34.65065696715081, 44.777088262503725], [34.72084256772871, 44.811080759265764], [34.756796893391225, 44.82094054159748], [34.82646979041766, 44.81208604604609], [34.84289620758207, 44.816893835303176], [34.856910353686715, 44.82373813182468], [34.889648317948144, 44.817871641692506], [34.90733830566026, 44.820886440346584], [34.922960632465504, 44.83050015059965], [34.92950822531711, 44.83652826953224], [34.94179932067178, 44.84019370922482], [34.95282684547897, 44.841415470643284], [34.98567967978991, 44.840275160795755], [35.0053224583441, 44.83538786296728], [35.017958163849414, 44.82219008824552], [35.02703289780189, 44.80890779582285], [35.037933245998005, 44.79869792240089], [35.08073333784134, 44.793525442788905], [35.1080207326404, 44.824553365795765], [35.130368105574235, 44.86879838545747], [35.15485200090768, 44.90071251697748], [35.17111229780758, 44.90746386008772], [35.21522068940149, 44.91421441031795], [35.233163085981715, 44.925728224907715], [35.25636688416236, 44.95896657181197], [35.27300098099195, 44.96690119386028], [35.29748487632534, 44.95605693543271], [35.30496087491386, 44.96121482614441], [35.315240372954605, 44.965711070514175], [35.31935217217088, 44.96941359539801], [35.36757236298112, 44.94362319076086], [35.36103086422793, 44.97364475976596], [35.362152264014156, 44.98593980935419], [35.374674561627444, 44.997835734117416], [35.389439658813274, 45.00180049366759], [35.42270785247763, 45.00087540764923], [35.43504325012745, 45.00470780964241], [35.43504325012745, 45.011446929213974], [35.40631957913584, 45.02015821022701], [35.40089948016896, 45.025046135473445], [35.39790908073891, 45.03482073400548], [35.40052568024015, 45.042216617888045], [35.40631957913584, 45.051328088783805], [35.40744097892215, 45.06294640963205], [35.41734667704213, 45.0708666385693], [35.469304867139925, 45.10068964922732], [35.5070260597534, 45.113341616151644], [35.54758335202416, 45.12019982412133], [35.59019654390909, 45.11993606213795], [35.63411803553862, 45.11439677872579], [35.70669729572677, 45.09480210570922], [35.771782422456766, 45.06572995732262], [35.78430472007, 45.057941041321754], [35.81250040352472, 45.031852200991295], [35.81941570220667, 45.021152336906454], [35.82763930064016, 44.99895365027004], [35.848198296721705, 44.99208088455586], [35.916977483614176, 45.00172895661731], [35.99360646900681, 44.997896355361604], [36.00893226608571, 45.00926125333629], [36.02539976723364, 45.03288661039673], [36.047827762958946, 45.048074065419456], [36.078666257082034, 45.03883000769565], [36.079137312377895, 45.046610970582435], [36.135020401727616, 45.02125162210126], [36.2241716847341, 45.00751061631556], [36.24398308095806, 45.011474706353084], [36.24828178013877, 45.01649549321965], [36.25332807917695, 45.03247980324494], [36.25743987839326, 45.03842324279259], [36.267158676549116, 45.043573724415154], [36.2783726744118, 45.04555455542638], [36.36740852558336, 45.04833265291825], [36.44029951169139, 45.06787222615526], [36.45375630913995, 45.07631970334319], [36.455251508854985, 45.09202341204062], [36.44142091149291, 45.10709638287736], [36.41432041665814, 45.12872568311289], [36.40852651776157, 45.149160473330085], [36.409997342308856, 45.171615955386955], [36.418312796420764, 45.23001671705953], [36.42672329481775, 45.25186253492981], [36.43756477765089, 45.27227491599612], [36.4497132753354, 45.28542626329343], [36.45905827355429, 45.28753019598713], [36.4814862692796, 45.28845064200263], [36.4909554290368, 45.29213135137758], [36.49637552800283, 45.300940007322055], [36.49394582846682, 45.305015191082816], [36.48871262946426, 45.30935296803605], [36.48460083024801, 45.315924724862185], [36.489647129296515, 45.336413860372005], [36.502169426909745, 45.34731734941451], [36.52104632331191, 45.35033842661815], [36.544281237819945, 45.34731734942025], [36.57455903204905, 45.33601971904315], [36.585399229982954, 45.333917585593355], [36.59810088537549, 45.334837278577254], [36.630808379142394, 45.34048649352954], [36.637536777859964, 45.3511265071989], [36.63099527910589, 45.3741073632589], [36.61359545390113, 45.40895280985421], [36.59845655678569, 45.421547717459106], [36.58331765967199, 45.42731944465129], [36.566309762912795, 45.42548305000767], [36.54836736633254, 45.41210180010589], [36.53285466928139, 45.4090840212946], [36.51565987255873, 45.41957994832251], [36.49117597722616, 45.44279525429408], [36.47043008117939, 45.4458112314303], [36.411182792482634, 45.43610707766504], [36.391371396258705, 45.43991025572652], [36.35959840231365, 45.45407156049933], [36.33960010612526, 45.45695583486963], [36.33025510790637, 45.454464879327446], [36.32053630976225, 45.44856480887407], [36.31156511147125, 45.4438443081136], [36.29885591389362, 45.442795254299995], [36.3072664122906, 45.46115087970253], [36.30016421364425, 45.47320989503609], [36.283717016779036, 45.476355300848866], [36.267082919949445, 45.46704963343626], [36.25213092279836, 45.46115087970253], [36.13681364478941, 45.46219959214511], [36.11700224855986, 45.45721803432335], [36.097003952371466, 45.441483909606006], [36.06952965760803, 45.43046741078453], [36.0655449627526, 45.42553028973455], [36.05134056545904, 45.39535242162091], [36.022557970944945, 45.368441166003805], [35.986486277818386, 45.362926059418186], [35.94723728529826, 45.372380198658874], [35.87220216002379, 45.404075760536614], [35.85388596351393, 45.413916621802144], [35.84715756479628, 45.426379251448395], [35.8524047739447, 45.44386497541683], [35.85950697259193, 45.45933624762881], [35.857824872912545, 45.469953901705], [35.83278027768503, 45.47087138287168], [35.8167068807486, 45.46392436820739], [35.80362388324218, 45.44963442058864], [35.79469305616038, 45.42980210462429], [35.791889556694684, 45.41209230278156], [35.772265060435046, 45.39214572935421], [35.767405661361295, 45.38873311015669], [35.75189296431793, 45.386632934388984], [35.7481549650407, 45.379938103368545], [35.746846665290036, 45.369960021421576], [35.74423006578874, 45.36076812520648], [35.71619507113218, 45.34040932557082], [35.69451467527287, 45.32989869277279], [35.51720627467216, 45.29506847418358], [35.48038698168983, 45.2979608697527], [35.33194061536096, 45.371562726652314], [35.04491375777232, 45.669545248704424], [35.00230056589345, 45.7290693869553], [34.70631294999043, 46.024929846739866], [34.35868883309806, 46.106725558140795], [34.00905273547181, 46.55925987559425]]]}}}}, "version": 8, "metadata": {"maptiler:copyright": "This style was generated on MapTiler Cloud. Usage is governed by the license terms in https://github.com/maplibre/demotiles/blob/gh-pages/LICENSE", "openmaptiles:version": "3.x"}}
\ No newline at end of file
diff --git a/ios-vectortile/Tests/MapConductorVectorTileTests/Resources/glyphs-0-255.pbf b/ios-vectortile/Tests/MapConductorVectorTileTests/Resources/glyphs-0-255.pbf
new file mode 100644
index 0000000..45b2cc3
Binary files /dev/null and b/ios-vectortile/Tests/MapConductorVectorTileTests/Resources/glyphs-0-255.pbf differ
diff --git a/ios-vectortile/Tests/MapConductorVectorTileTests/Resources/tile-0-0-0.pbf b/ios-vectortile/Tests/MapConductorVectorTileTests/Resources/tile-0-0-0.pbf
new file mode 100644
index 0000000..b843640
Binary files /dev/null and b/ios-vectortile/Tests/MapConductorVectorTileTests/Resources/tile-0-0-0.pbf differ
diff --git a/ios-vectortile/Tests/MapConductorVectorTileTests/SplitLayerTests.swift b/ios-vectortile/Tests/MapConductorVectorTileTests/SplitLayerTests.swift
new file mode 100644
index 0000000..1d36025
--- /dev/null
+++ b/ios-vectortile/Tests/MapConductorVectorTileTests/SplitLayerTests.swift
@@ -0,0 +1,354 @@
+import MapConductorCore
+import XCTest
+
+import MapConductorVectorTile
+
+/**
+ Covers what the provider gained when iOS caught up with Android: the split
+ ground/label layers, glyph warming, and the credits a style asks for.
+
+ Source tiles and the glyph range come from bundled assets, so nothing here
+ touches the network. Every fetch is counted, because half of what this layer
+ does is decide what *not* to ask for.
+ */
+final class SplitLayerTests: XCTestCase {
+ private var styleJSON: String!
+ private var tileData: Data!
+ private var glyphData: Data!
+
+ /**
+ Not z0: the demo style gives `geolines-label` a minzoom of 1 and
+ `countries-label` a minzoom of 2, so at z0 no symbol layer applies and
+ there is correctly nothing to label. z2 has both, plus the ring of
+ neighbours the label pass asks for.
+ */
+ private let target = TileRequest(x: 1, y: 1, z: 2)
+
+ override func setUpWithError() throws {
+ let bundle = Bundle.module
+ styleJSON = try String(
+ contentsOf: try XCTUnwrap(bundle.url(forResource: "demo-style", withExtension: "json")),
+ encoding: .utf8
+ )
+ tileData = try Data(
+ contentsOf: try XCTUnwrap(bundle.url(forResource: "tile-0-0-0", withExtension: "pbf"))
+ )
+ glyphData = try Data(
+ contentsOf: try XCTUnwrap(bundle.url(forResource: "glyphs-0-255", withExtension: "pbf"))
+ )
+ }
+
+ /// Answers every source tile from the bundle and every glyph range from the
+ /// fixture, recording what was asked for.
+ private func provider(
+ onFetch: ((URL) -> Void)? = nil
+ ) throws -> VectorTileProvider {
+ let tile = tileData!
+ let glyphs = glyphData!
+ return try VectorTileProvider(
+ styleJSON: styleJSON,
+ tileSize: 256,
+ renderMode: .cpu
+ ) { url in
+ onFetch?(url)
+ // Source tiles and glyph ranges are both .pbf on the same host;
+ // only the path separates them.
+ return url.path.contains("/font/") ? glyphs : tile
+ }
+ }
+
+ private func isPng(_ data: Data?) -> Bool {
+ guard let data, data.count > 4 else { return false }
+ return Array(data.prefix(4)) == [0x89, 0x50, 0x4E, 0x47]
+ }
+
+ /// Waits for a condition the provider reaches on its own threads.
+ private func eventually(
+ _ description: String,
+ timeout: TimeInterval = 10,
+ _ condition: () -> Bool
+ ) {
+ let deadline = Date().addingTimeInterval(timeout)
+ while !condition(), Date() < deadline {
+ RunLoop.current.run(until: Date().addingTimeInterval(0.05))
+ }
+ XCTAssertTrue(condition(), description)
+ }
+
+ // MARK: - The two halves
+
+ func testBothHalvesDrawAPng() throws {
+ let subject = try provider()
+ defer { subject.close() }
+ XCTAssertTrue(isPng(subject.groundTiles.renderTile(request: target)), "ground")
+ XCTAssertTrue(isPng(subject.labelTiles.renderTile(request: target)), "labels")
+ XCTAssertTrue(isPng(subject.renderTile(request: target)), "both in one")
+ }
+
+ /**
+ The point of the split: the ground is drawn once and a font landing must
+ not change it. If it did, every glyph range would redraw the whole map
+ rather than the transparent half.
+ */
+ func testGlyphsArrivingDoNotChangeTheGround() throws {
+ let subject = try provider()
+ defer { subject.close() }
+
+ let before = subject.groundTiles.renderTile(request: target)
+ XCTAssertTrue(isPng(before))
+
+ // Draw labels, which warms the glyph ranges as a side effect.
+ _ = subject.labelTiles.renderTile(request: target)
+ eventually("glyphs never arrived") { subject.glyphGenerationValue > 0 }
+
+ XCTAssertEqual(before, subject.groundTiles.renderTile(request: target))
+ }
+
+ /**
+ Labels appear only once their glyphs are in.
+
+ Compared against a provider whose glyph fetches all fail rather than
+ against an earlier render of the same one: the fixture answers instantly,
+ so the range lands before the first tile is drawn and there is no "before"
+ to catch. What is being asserted is the end state either way -- ink on the
+ tile when the font is available, none when it is not.
+ */
+ func testLabelsNeedTheirGlyphs() throws {
+ let withFont = try provider()
+ defer { withFont.close() }
+ _ = withFont.labelTiles.renderTile(request: target)
+ eventually("glyphs never arrived") { withFont.glyphGenerationValue > 0 }
+ let labelled = withFont.labelTiles.renderTile(request: target)
+
+ let tile = tileData!
+ let starved = try VectorTileProvider(
+ styleJSON: styleJSON, tileSize: 256, renderMode: .cpu
+ ) { url in url.path.contains("/font/") ? nil : tile }
+ defer { starved.close() }
+ let bare = starved.labelTiles.renderTile(request: target)
+
+ XCTAssertTrue(isPng(labelled))
+ XCTAssertTrue(isPng(bare))
+ XCTAssertGreaterThan(
+ labelled!.count, bare!.count,
+ "the labelled tile carries ink the starved one does not"
+ )
+ }
+
+ /**
+ The ground never draws a label, so the ring of neighbours the label pass
+ asks for is nine fetches it has no use for.
+ */
+ func testTheGroundSkipsTheLabelOnlyNeighbours() throws {
+ // The provider fetches a plan in parallel, so the recorder is shared
+ // across threads and has to be guarded.
+ let lock = NSLock()
+ var groundUrls = Set()
+ let ground = try provider { url in
+ lock.lock()
+ groundUrls.insert(url.absoluteString)
+ lock.unlock()
+ }
+ _ = ground.groundTiles.renderTile(request: target)
+ ground.close()
+
+ var labelUrls = Set()
+ let labels = try provider { url in
+ lock.lock()
+ labelUrls.insert(url.absoluteString)
+ lock.unlock()
+ }
+ _ = labels.labelTiles.renderTile(request: target)
+ labels.close()
+
+ lock.lock()
+ defer { lock.unlock() }
+
+ let groundTiles = groundUrls.filter { !$0.contains("/font/") }
+ let labelTiles = labelUrls.filter { !$0.contains("/font/") }
+ XCTAssertLessThan(
+ groundTiles.count, labelTiles.count,
+ "the ground fetched the label pass's neighbours"
+ )
+ }
+
+ // MARK: - Fetching
+
+ func testSlowGlyphsDoNotHoldTheRenderThreadWhenMoreThanSixRangesAreNeeded() throws {
+ let document: [String: Any] = [
+ "version": 8,
+ "glyphs": "https://example.test/font/{fontstack}/{range}.pbf",
+ "sources": ["points": ["type": "vector", "tiles": ["https://example.test/{z}/{x}/{y}.pbf"]]],
+ "layers": (0..<7).map { index -> [String: Any] in
+ ["id": "font-\(index)", "type": "symbol", "source": "points", "source-layer": "place",
+ "layout": ["text-field": "{name}", "text-font": ["Slow Font \(index)"], "text-size": 16]]
+ }
+ ]
+ let style = try XCTUnwrap(String(
+ data: JSONSerialization.data(withJSONObject: document), encoding: .utf8))
+ let entered = DispatchSemaphore(value: 0)
+ let release = DispatchSemaphore(value: 0)
+ // A single point at the centre of an MVT tile, with name="Label".
+ let tile = try XCTUnwrap(Data(base64Encoded:
+ "GioKBXBsYWNlEg0SAgAAGAEiBQmAIIAgGgRuYW1lIgcKBUxhYmVsKIAgeAI="))
+ let subject = try VectorTileProvider(
+ styleJSON: style, tileSize: 256, renderMode: .cpu, renderScale: 1
+ ) { url in
+ if url.path.contains("/font/") {
+ entered.signal()
+ _ = release.wait(timeout: .now() + 5)
+ return nil
+ }
+ return tile
+ }
+ defer {
+ for _ in 0..<7 { release.signal() }
+ subject.close()
+ }
+ let drawn = expectation(description: "labels drawn while fonts are still loading")
+ let request = target
+ DispatchQueue.global().async {
+ XCTAssertNotNil(subject.labelTiles.renderTile(request: request))
+ drawn.fulfill()
+ }
+ for _ in 0..<6 {
+ XCTAssertEqual(entered.wait(timeout: .now() + 2), .success)
+ }
+ // All six network slots are occupied. Enqueuing the seventh range
+ // must not make a tile wait for those transfers to finish.
+ wait(for: [drawn], timeout: 1)
+ }
+
+ /// Two halves of the same tile want the same sources. Fetching them twice
+ /// is the difference between one viewport of traffic and two.
+ func testFetchesEachSourceOnce() throws {
+ var counts: [String: Int] = [:]
+ let lock = NSLock()
+ let subject = try provider { url in
+ lock.lock()
+ counts[url.absoluteString, default: 0] += 1
+ lock.unlock()
+ }
+ defer { subject.close() }
+
+ _ = subject.groundTiles.renderTile(request: target)
+ _ = subject.labelTiles.renderTile(request: target)
+
+ let repeated = counts.filter { !$0.key.contains("/font/") && $0.value > 1 }
+ XCTAssertTrue(repeated.isEmpty, "fetched more than once: \(repeated)")
+ }
+
+ /// A map that has moved on is not owed the tile it stopped waiting for.
+ func testGivesUpOnACancelledTile() throws {
+ let subject = try provider()
+ defer { subject.close() }
+ XCTAssertNil(subject.renderTile(request: target, content: .full) { true })
+ }
+
+ // MARK: - Giving up and remembering
+
+ /**
+ A tile the map stopped waiting for must not be drawn: renderers are a
+ fixed resource, so a doomed tile is drawn *instead of* one still on
+ screen.
+ */
+ func testGivesUpOnACancelledTileThroughTheProtocol() throws {
+ let subject = try provider()
+ defer { subject.close() }
+ // The protocol method, which is what the tile server calls.
+ let asProvider: TileProvider = subject
+ XCTAssertNil(try asProvider.renderTile(request: target, isCancelled: { true }))
+ XCTAssertNotNil(try asProvider.renderTile(request: target, isCancelled: { false }))
+ }
+
+ /// Each half has to honour it too, or half the map keeps drawing.
+ func testBothHalvesGiveUp() throws {
+ let subject = try provider()
+ defer { subject.close() }
+ XCTAssertNil(try subject.groundTiles.renderTile(request: target, isCancelled: { true }))
+ XCTAssertNil(try subject.labelTiles.renderTile(request: target, isCancelled: { true }))
+ }
+
+ /**
+ A second launch must not redraw what the first drew.
+
+ Two providers over one directory stand in for two launches: the second one
+ is given no network at all, so anything it serves came off disk.
+ */
+ func testRemembersRenderedTilesAcrossLaunches() throws {
+ let directory = FileManager.default.temporaryDirectory
+ .appendingPathComponent("vectortile-test-\(UUID().uuidString)")
+ defer { try? FileManager.default.removeItem(at: directory) }
+
+ let tile = tileData!
+ let first = try VectorTileProvider(
+ styleJSON: styleJSON, tileSize: 256, renderMode: .cpu,
+ assetCacheDirectory: directory
+ ) { url in url.path.contains("/font/") ? nil : tile }
+ let drawn = first.groundTiles.renderTile(request: target)
+ first.close()
+ XCTAssertTrue(isPng(drawn))
+
+ var fetches = 0
+ let second = try VectorTileProvider(
+ styleJSON: styleJSON, tileSize: 256, renderMode: .cpu,
+ assetCacheDirectory: directory
+ ) { _ in
+ fetches += 1
+ return nil
+ }
+ defer { second.close() }
+ let served = second.groundTiles.renderTile(request: target)
+
+ XCTAssertEqual(served, drawn, "the second launch redrew the tile")
+ XCTAssertEqual(fetches, 0, "the second launch went to the network")
+ }
+
+ /// A restyle must not serve the old style's pixels. The key carries the
+ /// style, so the old tiles simply stop being found.
+ func testARestyleStopsFindingTheOldTiles() throws {
+ let directory = FileManager.default.temporaryDirectory
+ .appendingPathComponent("vectortile-test-\(UUID().uuidString)")
+ defer { try? FileManager.default.removeItem(at: directory) }
+
+ let tile = tileData!
+ let subject = try VectorTileProvider(
+ styleJSON: styleJSON, tileSize: 256, renderMode: .cpu,
+ assetCacheDirectory: directory
+ ) { url in url.path.contains("/font/") ? nil : tile }
+ defer { subject.close() }
+
+ let before = subject.groundTiles.renderTile(request: target)
+ // Same layers, different background colour.
+ let restyled = styleJSON.replacingOccurrences(of: "#D8F2FF", with: "#FF0000")
+ XCTAssertNotEqual(restyled, styleJSON, "the fixture no longer has that colour")
+ try subject.setStyle(restyled)
+ let after = subject.groundTiles.renderTile(request: target)
+
+ XCTAssertTrue(isPng(before) && isPng(after))
+ XCTAssertNotEqual(before, after, "the restyle served the cached tile")
+ }
+
+ // MARK: - Credits
+
+ func testCarriesTheCreditOutOfTheStyle() throws {
+ let credited = try VectorTileProvider(
+ styleJSON: """
+ {"version": 8,
+ "sources": {"osm": {"type": "vector",
+ "tiles": ["https://example.com/{z}/{x}/{y}.pbf"],
+ "attribution": "© OpenStreetMap"}},
+ "layers": [{"id": "w", "type": "fill", "source": "osm", "source-layer": "water"}]}
+ """,
+ renderMode: .cpu
+ ) { _ in nil }
+ defer { credited.close() }
+ XCTAssertEqual(credited.attributions(), ["© OpenStreetMap"])
+ }
+
+ func testInventsNoCreditWhenTheStyleAsksForNone() throws {
+ let subject = try provider()
+ defer { subject.close() }
+ XCTAssertTrue(subject.attributions().isEmpty)
+ }
+}
diff --git a/ios-vectortile/Tests/MapConductorVectorTileTests/TileLevelTests.swift b/ios-vectortile/Tests/MapConductorVectorTileTests/TileLevelTests.swift
new file mode 100644
index 0000000..99dd0a2
--- /dev/null
+++ b/ios-vectortile/Tests/MapConductorVectorTileTests/TileLevelTests.swift
@@ -0,0 +1,66 @@
+import XCTest
+import MapConductorCore
+@testable import MapConductorVectorTile
+
+final class TileLevelTests: XCTestCase {
+ func testGoogleSizedTilesUseTheSameSourceLevelAs512PointTiles() throws {
+ let style = """
+ {"version":8,"sources":{"v":{"type":"vector","tiles":["https://tiles.test/{z}/{x}/{y}.pbf"]}},
+ "layers":[{"id":"roads","type":"line","source":"v","source-layer":"roads"}]}
+ """
+ let google = try VectorTileRenderer(styleJSON: style, displayTileSize: 256)
+ let other = try VectorTileRenderer(styleJSON: style, displayTileSize: 512)
+ defer { google.close(); other.close() }
+ let first = try google.plan(z: 8, x: 226, y: 100)
+ let second = try other.plan(z: 7, x: 113, y: 50)
+ let plans = try [first, second].map { json in
+ try XCTUnwrap(JSONSerialization.jsonObject(with: Data(json.utf8)) as? [[String: Any]])
+ }
+ XCTAssertEqual(plans[0].first?["z"] as? Int, 7)
+ XCTAssertEqual(plans[0].first?["url"] as? String, plans[1].first?["url"] as? String)
+ }
+ func testSourceOffsetChangesDataWithoutLeakingToTheNextCall() throws {
+ let style = """
+ {"version":8,"sources":{"v":{"type":"vector","tiles":["https://tiles.test/{z}/{x}/{y}.pbf"]}},
+ "layers":[{"id":"roads","type":"line","source":"v","source-layer":"roads"}]}
+ """
+ let renderer = try VectorTileRenderer(styleJSON: style, displayTileSize: 256)
+ defer { renderer.close() }
+ func sourceLevel(_ offset: Int) throws -> Int? {
+ let plan = try renderer.plan(z: 8, x: 226, y: 100, sourceZoomOffset: offset)
+ let entries = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(plan.utf8)) as? [[String: Any]])
+ return entries.first?["z"] as? Int
+ }
+ XCTAssertEqual(try sourceLevel(0), 7)
+ XCTAssertEqual(try sourceLevel(1), 6)
+ XCTAssertEqual(try sourceLevel(0), 7)
+ }
+
+ func testProviderDoesNotReuseAnImageFromAnotherSourceLevel() throws {
+ let style = """
+ {"version":8,"sources":{"v":{"type":"vector","tiles":["https://tiles.test/{z}/{x}/{y}.pbf"]}},
+ "layers":[{"id":"roads","type":"line","source":"v","source-layer":"countries"}]}
+ """
+ let fixture = try XCTUnwrap(Bundle.module.url(forResource: "tile-0-0-0", withExtension: "pbf"))
+ let tile = try Data(contentsOf: fixture)
+ let lock = NSLock()
+ var paths: [String] = []
+ let provider = try VectorTileProvider(styleJSON: style, tileSize: 256, renderMode: .cpu) { url in
+ lock.lock()
+ paths.append(url.path)
+ lock.unlock()
+ return tile
+ }
+ defer { provider.close() }
+ let normal = TileRequest(x: 226, y: 100, z: 8)
+ let coarse = TileRequest(x: 226, y: 100, z: 8, sourceZoomOffset: 1)
+ XCTAssertNotNil(provider.groundTiles.renderTile(request: normal))
+ XCTAssertNotNil(provider.groundTiles.renderTile(request: coarse))
+ XCTAssertTrue(paths.contains("/7/113/50.pbf"))
+ XCTAssertTrue(paths.contains("/6/56/25.pbf"))
+ let count = paths.count
+ XCTAssertNotNil(provider.groundTiles.renderTile(request: normal))
+ XCTAssertEqual(paths.count, count)
+ }
+
+}
diff --git a/ios-vectortile/Tests/MapConductorVectorTileTests/TileMemoryCacheTests.swift b/ios-vectortile/Tests/MapConductorVectorTileTests/TileMemoryCacheTests.swift
new file mode 100644
index 0000000..3361b05
--- /dev/null
+++ b/ios-vectortile/Tests/MapConductorVectorTileTests/TileMemoryCacheTests.swift
@@ -0,0 +1,48 @@
+import XCTest
+@testable import MapConductorVectorTile
+
+final class TileMemoryCacheTests: XCTestCase {
+ func testStrictBytesAndRecentHitsSurviveEviction() {
+ let cache = TileMemoryCache(byteLimit: 10)
+ cache.insert("A", for: "a", cost: 4)
+ cache.insert("B", for: "b", cost: 4)
+ XCTAssertEqual(cache.value(for: "a"), "A")
+ cache.insert("C", for: "c", cost: 4)
+ XCTAssertNil(cache.value(for: "b"))
+ XCTAssertEqual(cache.value(for: "a"), "A")
+ XCTAssertEqual(cache.retainedCost, 8)
+ cache.insert("large", for: "large", cost: 11)
+ XCTAssertNil(cache.value(for: "large"))
+ XCTAssertEqual(cache.retainedCost, 8)
+ }
+
+ func testReplacementCountAndIdleExpiry() {
+ var now: TimeInterval = 0
+ let cache = TileMemoryCache(byteLimit: 100, countLimit: 2, ttl: 30, clock: { now })
+ cache.insert(true, for: "a", cost: 10)
+ cache.insert(true, for: "a", cost: 20)
+ XCTAssertEqual(cache.retainedCost, 20)
+ cache.insert(true, for: "b", cost: 10)
+ cache.insert(true, for: "c", cost: 10)
+ XCTAssertNil(cache.value(for: "a"))
+ now = 20
+ XCTAssertEqual(cache.value(for: "b"), true)
+ now = 31
+ XCTAssertNil(cache.value(for: "c"))
+ XCTAssertEqual(cache.value(for: "b"), true)
+ now = 62
+ XCTAssertEqual(cache.retainedCost, 0)
+ }
+
+ func testConcurrentPanningStaysWithinBudgetAndCanBePurged() {
+ let cache = TileMemoryCache(byteLimit: 4096, countLimit: 16)
+ DispatchQueue.concurrentPerform(iterations: 1000) { index in
+ let key = "tile-\(index)"
+ cache.insert(Data(repeating: 1, count: 512), for: key, cost: 512)
+ _ = cache.value(for: key)
+ XCTAssertLessThanOrEqual(cache.retainedCost, 4096)
+ }
+ cache.removeAll()
+ XCTAssertEqual(cache.retainedCost, 0)
+ }
+}
diff --git a/ios-vectortile/Tests/MapConductorVectorTileTests/VectorTileProviderTests.swift b/ios-vectortile/Tests/MapConductorVectorTileTests/VectorTileProviderTests.swift
new file mode 100644
index 0000000..55a1ca7
--- /dev/null
+++ b/ios-vectortile/Tests/MapConductorVectorTileTests/VectorTileProviderTests.swift
@@ -0,0 +1,371 @@
+import UIKit
+import MapConductorCore
+import XCTest
+
+@testable import MapConductorVectorTile
+
+/**
+ Exercises the path a map backend actually takes: provider ->
+ `LocalTileServer` route -> URL template -> HTTP -> PNG.
+
+ Source tiles come from a bundled asset rather than the network, so the test
+ measures the SDK plumbing and not connectivity.
+ */
+final class VectorTileProviderTests: XCTestCase {
+
+ private var styleJSON: String!
+ private var tileData: Data!
+ private var provider: VectorTileProvider!
+ private let routeId = "vectortile-test"
+
+ override func setUpWithError() throws {
+ let bundle = Bundle.module
+ styleJSON = try String(
+ contentsOf: try XCTUnwrap(bundle.url(forResource: "demo-style", withExtension: "json")),
+ encoding: .utf8
+ )
+ tileData = try Data(
+ contentsOf: try XCTUnwrap(bundle.url(forResource: "tile-0-0-0", withExtension: "pbf"))
+ )
+ // Serve the bundled tile for whatever the plan asks for.
+ provider = try VectorTileProvider(styleJSON: styleJSON, tileSize: 512) { [tileData] _ in
+ tileData
+ }
+ }
+
+ override func tearDown() {
+ TileServerRegistry.get().unregister(routeId: routeId)
+ provider?.close()
+ provider = nil
+ }
+
+ func testServesAPngThroughTheLocalTileServer() throws {
+ let server = TileServerRegistry.get()
+ server.register(routeId: routeId, provider: provider)
+
+ let template = server.urlTemplate(routeId: routeId, tileSize: 512)
+ XCTAssertTrue(template.contains(routeId), "unexpected template: \(template)")
+
+ let url = try XCTUnwrap(
+ URL(
+ string: template
+ .replacingOccurrences(of: "{z}", with: "0")
+ .replacingOccurrences(of: "{x}", with: "0")
+ .replacingOccurrences(of: "{y}", with: "0")
+ )
+ )
+
+ let received = expectation(description: "tile fetched")
+ var payload: Data?
+ var status = 0
+ URLSession.shared.dataTask(with: url) { data, response, _ in
+ status = (response as? HTTPURLResponse)?.statusCode ?? 0
+ payload = data
+ received.fulfill()
+ }.resume()
+ wait(for: [received], timeout: 20)
+
+ XCTAssertEqual(status, 200)
+ let png = try XCTUnwrap(payload)
+ XCTAssertGreaterThan(png.count, 10_000)
+
+ // Pixels, not points: tiles are drawn at the display's scale so a
+ // Retina screen is not handed an image to stretch. The raster layer
+ // still declares 512, which is what sets how large the map looks.
+ let image = try XCTUnwrap(UIImage(data: png)?.cgImage)
+ XCTAssertEqual(image.width, 512 * Int(UIScreen.main.scale.rounded()))
+ }
+
+ func testRendersDirectlyThroughTheProviderInterface() throws {
+ let png = try XCTUnwrap(provider.renderTile(request: TileRequest(x: 0, y: 0, z: 0)))
+ XCTAssertGreaterThan(png.count, 10_000)
+ }
+
+ /// `symbol` used to appear here as a type nothing could draw. It is drawn
+ /// now, so this style has nothing left to complain about.
+ func testReportsNothingWrongWithAStyleItCanDraw() {
+ XCTAssertEqual(provider.diagnostics(), [])
+ }
+
+ /// A style it genuinely cannot draw still has to say so: the failure mode
+ /// that matters is a tile that is quietly missing something.
+ func testReportsStyleDiagnostics() throws {
+ let hillshaded = try VectorTileProvider(
+ styleJSON: """
+ {"version": 8, "sources": {}, "layers": [{"id": "h", "type": "hillshade"}]}
+ """,
+ renderMode: .cpu
+ ) { _ in nil }
+ defer { hillshaded.close() }
+ let messages = hillshaded.diagnostics()
+ XCTAssertTrue(messages.contains { $0.contains("hillshade") }, "\(messages)")
+ }
+
+ func testRestylingChangesOutputWithoutRefetching() throws {
+ var fetches = 0
+ let subject = try VectorTileProvider(styleJSON: styleJSON, tileSize: 256) { [tileData] _ in
+ fetches += 1
+ return tileData
+ }
+ defer { subject.close() }
+
+ let before = try XCTUnwrap(subject.renderTile(request: TileRequest(x: 0, y: 0, z: 0)))
+ let afterFirstRender = fetches
+
+ try subject.setStyle(
+ """
+ {"version":8,"sources":{},"layers":[
+ {"id":"bg","type":"background","paint":{"background-color":"#ff0000"}}
+ ]}
+ """
+ )
+ let after = try XCTUnwrap(subject.renderTile(request: TileRequest(x: 0, y: 0, z: 0)))
+
+ XCTAssertNotEqual(before, after, "restyle should change the pixels")
+ // Geometry is unchanged, so the cache serves the second render.
+ XCTAssertEqual(fetches, afterFirstRender)
+ }
+
+ func testReplacementReusesSourceTilesAfterPreviousProviderCloses() throws {
+ let original = try VectorTileProvider(
+ styleJSON: styleJSON, tileSize: 32, renderMode: .cpu, renderScale: 1
+ ) { [tileData] _ in tileData }
+ defer { original.close() }
+ let request = TileRequest(x: 0, y: 0, z: 0)
+ let before = try XCTUnwrap(original.renderTile(request: request, content: .ground))
+
+ var document = try XCTUnwrap(
+ JSONSerialization.jsonObject(with: Data(styleJSON.utf8)) as? [String: Any])
+ var layers = try XCTUnwrap(document["layers"] as? [[String: Any]])
+ for index in layers.indices {
+ if layers[index]["type"] as? String == "fill" {
+ layers[index]["paint"] = ["fill-color": "#ff0000"]
+ }
+ }
+ document["layers"] = layers
+ let changed = try XCTUnwrap(String(data: JSONSerialization.data(withJSONObject: document), encoding: .utf8))
+ let replacement = try VectorTileProvider(
+ styleJSON: changed, tileSize: 32, renderMode: .cpu, renderScale: 1
+ ) { _ in
+ XCTFail("a paint change must reuse the previous provider's source bytes")
+ return nil
+ }
+ defer { replacement.close() }
+ replacement.reuseSourceTiles(from: original)
+ original.close()
+ let after = try XCTUnwrap(replacement.renderTile(request: request, content: .ground))
+ XCTAssertNotEqual(before, after, "cached sources must still be painted with the new style")
+ }
+
+ func testAsyncCloseDuringSourceFetchStopsServingWithoutWaiting() throws {
+ let entered = DispatchSemaphore(value: 0)
+ let release = DispatchSemaphore(value: 0)
+ let finished = expectation(description: "cancelled render finished")
+ let subject = try VectorTileProvider(styleJSON: styleJSON) { [tileData] _ in
+ entered.signal()
+ _ = release.wait(timeout: .now() + 5)
+ return tileData
+ }
+ DispatchQueue.global().async {
+ XCTAssertNil(subject.renderTile(request: TileRequest(x: 0, y: 0, z: 0)))
+ finished.fulfill()
+ }
+ XCTAssertEqual(entered.wait(timeout: .now() + 5), .success)
+ let started = Date()
+ subject.closeAsync()
+ XCTAssertLessThan(Date().timeIntervalSince(started), 0.5)
+ XCTAssertNil(subject.renderTile(request: TileRequest(x: 0, y: 0, z: 0)))
+ subject.closeAsync()
+ release.signal()
+ wait(for: [finished], timeout: 5)
+ }
+
+ func testWaitersShareTheRetryAfterTheirSourceOwnerIsCancelled() throws {
+ let firstEntered = DispatchSemaphore(value: 0)
+ let firstRelease = DispatchSemaphore(value: 0)
+ let retryEntered = DispatchSemaphore(value: 0)
+ let retryRelease = DispatchSemaphore(value: 0)
+ let waitersReady = DispatchSemaphore(value: 0)
+ let lock = NSLock()
+ var attempts = 0
+ var polls = [0, 0]
+ var pixels: [Data?] = [nil, nil]
+ var retryReleased = false
+ var finishedEarly = false
+ let subject = try VectorTileProvider(
+ styleJSON: styleJSON, tileSize: 32, renderMode: .cpu, renderScale: 1
+ ) { [tileData] _ in
+ lock.lock(); attempts += 1; let attempt = attempts; lock.unlock()
+ if attempt == 1 {
+ firstEntered.signal()
+ _ = firstRelease.wait(timeout: .now() + 5)
+ return nil
+ }
+ retryEntered.signal()
+ _ = retryRelease.wait(timeout: .now() + 5)
+ return tileData
+ }
+ defer {
+ firstRelease.signal()
+ retryRelease.signal()
+ subject.close()
+ }
+ let request = TileRequest(x: 0, y: 0, z: 0)
+ let cancellation = FetchCancellation()
+ let ownerFinished = expectation(description: "cancelled owner finished")
+ DispatchQueue.global().async {
+ XCTAssertNil(subject.renderTile(request: request, content: .ground) { cancellation.isCancelled })
+ ownerFinished.fulfill()
+ }
+ XCTAssertEqual(firstEntered.wait(timeout: .now() + 2), .success)
+ let waitersFinished = expectation(description: "both waiters received the source")
+ waitersFinished.expectedFulfillmentCount = 2
+ for index in 0..<2 {
+ DispatchQueue.global().async {
+ let png = subject.renderTile(request: request, content: .ground) {
+ lock.lock(); polls[index] += 1; let poll = polls[index]; lock.unlock()
+ if poll == 2 { waitersReady.signal() }
+ return false
+ }
+ lock.lock()
+ finishedEarly = finishedEarly || !retryReleased
+ pixels[index] = png
+ lock.unlock()
+ waitersFinished.fulfill()
+ }
+ }
+ for _ in 0..<2 { XCTAssertEqual(waitersReady.wait(timeout: .now() + 2), .success) }
+ cancellation.cancel()
+ wait(for: [ownerFinished], timeout: 1)
+ firstRelease.signal()
+ XCTAssertEqual(retryEntered.wait(timeout: .now() + 2), .success)
+ let held = expectation(description: "retry deliberately remains in flight")
+ DispatchQueue.global().asyncAfter(deadline: .now() + 0.2) { held.fulfill() }
+ wait(for: [held], timeout: 1)
+ lock.lock(); retryReleased = true; lock.unlock()
+ retryRelease.signal()
+ wait(for: [waitersFinished], timeout: 2)
+ XCTAssertFalse(finishedEarly, "a waiter rendered without the still-loading source")
+ XCTAssertEqual(attempts, 2, "waiters must share one retry")
+ let expected = try XCTUnwrap(subject.renderTile(request: request, content: .ground))
+ XCTAssertEqual(pixels[0], expected)
+ XCTAssertEqual(pixels[1], expected)
+ }
+
+ func testConcurrentWaiterReceivesSourceEvenWhenCacheBudgetIsZero() throws {
+ let entered = DispatchSemaphore(value: 0)
+ let release = DispatchSemaphore(value: 0)
+ let ready = DispatchSemaphore(value: 0)
+ let lock = NSLock()
+ var fetches = 0
+ var polls = 0
+ let subject = try VectorTileProvider(
+ styleJSON: styleJSON, tileSize: 32, cacheBytes: 0, renderMode: .cpu, renderScale: 1
+ ) { [tileData] _ in
+ lock.lock(); fetches += 1; lock.unlock()
+ entered.signal()
+ _ = release.wait(timeout: .now() + 5)
+ return tileData
+ }
+ defer { release.signal(); subject.close() }
+ let done = expectation(description: "both requests received uncached source")
+ done.expectedFulfillmentCount = 2
+ let request = TileRequest(x: 0, y: 0, z: 0)
+ DispatchQueue.global().async {
+ XCTAssertNotNil(subject.renderTile(request: request, content: .ground))
+ done.fulfill()
+ }
+ XCTAssertEqual(entered.wait(timeout: .now() + 2), .success)
+ DispatchQueue.global().async {
+ let png = subject.renderTile(request: request, content: .ground) {
+ lock.lock(); polls += 1; let count = polls; lock.unlock()
+ if count == 2 { ready.signal() }
+ return false
+ }
+ XCTAssertNotNil(png)
+ done.fulfill()
+ }
+ XCTAssertEqual(ready.wait(timeout: .now() + 2), .success)
+ release.signal()
+ wait(for: [done], timeout: 5)
+ XCTAssertEqual(fetches, 1, "eviction must not duplicate an in-flight transfer")
+ }
+
+ func testWaitersShareTransportFailureAndDoNotCacheIncompletePixels() throws {
+ let entered = DispatchSemaphore(value: 0)
+ let release = DispatchSemaphore(value: 0)
+ let waiterReady = DispatchSemaphore(value: 0)
+ let lock = NSLock()
+ var attempts = 0
+ var polls = 0
+ var pixels: [Data?] = [nil, nil]
+ let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
+ let subject = try VectorTileProvider(
+ styleJSON: styleJSON, tileSize: 32, renderMode: .cpu,
+ assetCacheDirectory: directory, renderScale: 1
+ ) { [tileData] _ in
+ lock.lock(); attempts += 1; let attempt = attempts; lock.unlock()
+ if attempt == 1 {
+ entered.signal()
+ _ = release.wait(timeout: .now() + 5)
+ throw URLError(.timedOut)
+ }
+ return tileData
+ }
+ defer {
+ release.signal()
+ subject.close()
+ try? FileManager.default.removeItem(at: directory)
+ }
+ let request = TileRequest(x: 0, y: 0, z: 0)
+ let finished = expectation(description: "both renders recover from the shared failure")
+ finished.expectedFulfillmentCount = 2
+ DispatchQueue.global().async {
+ let png = subject.renderTile(request: request, content: .ground)
+ lock.lock(); pixels[0] = png; lock.unlock()
+ finished.fulfill()
+ }
+ XCTAssertEqual(entered.wait(timeout: .now() + 2), .success)
+ DispatchQueue.global().async {
+ let png = subject.renderTile(request: request, content: .ground) {
+ lock.lock(); polls += 1; let poll = polls; lock.unlock()
+ if poll == 2 { waiterReady.signal() }
+ return false
+ }
+ lock.lock(); pixels[1] = png; lock.unlock()
+ finished.fulfill()
+ }
+ XCTAssertEqual(waiterReady.wait(timeout: .now() + 2), .success)
+ release.signal()
+ wait(for: [finished], timeout: 2)
+ XCTAssertEqual(attempts, 1, "one timeout must not be retried once per waiter")
+ let recovered = try XCTUnwrap(subject.renderTile(request: request, content: .ground))
+ XCTAssertEqual(attempts, 2)
+ XCTAssertNotEqual(pixels[0], recovered)
+ XCTAssertNotEqual(pixels[1], recovered, "incomplete waiter output must not enter the PNG cache")
+ }
+
+ func testRetainedLayerStopsServingAfterProviderIsReleased() throws {
+ var subject: VectorTileProvider? = try VectorTileProvider(
+ styleJSON: "{\"version\":8,\"sources\":{},\"layers\":[]}"
+ ) { _ in nil }
+ let layer = subject!.groundTiles
+ weak var released = subject
+ subject?.close()
+ subject = nil
+ // Asset warming may already have borrowed the owner on its queue.
+ let deadline = Date().addingTimeInterval(5)
+ while released != nil, Date() < deadline {
+ RunLoop.current.run(until: Date().addingTimeInterval(0.01))
+ }
+ XCTAssertNil(released)
+ XCTAssertNil(layer.renderTile(request: TileRequest(x: 0, y: 0, z: 0)))
+ }
+
+ func testClosedProviderStopsServing() throws {
+ let subject = try VectorTileProvider(styleJSON: styleJSON) { [tileData] _ in tileData }
+ subject.close()
+ subject.close()
+ XCTAssertNil(subject.renderTile(request: TileRequest(x: 0, y: 0, z: 0)))
+ }
+}
diff --git a/ios-vectortile/api/MapConductorVectorTile.api.swift b/ios-vectortile/api/MapConductorVectorTile.api.swift
new file mode 100644
index 0000000..ac9ddbc
--- /dev/null
+++ b/ios-vectortile/api/MapConductorVectorTile.api.swift
@@ -0,0 +1,274 @@
+import CMvtRender
+import Foundation
+import MapConductorCore
+import MapConductorVectorStyle
+import Metal
+import Swift
+import UIKit
+import _Concurrency
+import _StringProcessing
+import _SwiftConcurrencyShims
+final public class MetalTileRasterizer {
+ public static func createOrNull(tileSize: Swift::Int) -> MapConductorVectorTile::MetalTileRasterizer?
+ public init(tileSize: Swift::Int) throws
+ final public func renderPng(_ tile: MapConductorVectorTile::TessellatedTile, decorate: ((Swift::UnsafeMutableRawBufferPointer) -> Swift::Void)? = nil) -> Foundation::Data?
+ @objc deinit
+}
+@_hasMissingDesignatedInitializers final public class OfflinePackage : @unchecked Swift::Sendable {
+ public struct Bounds : Swift::Codable, Swift::Equatable, Swift::Sendable {
+ public var south: Swift::Double
+ public var west: Swift::Double
+ public var north: Swift::Double
+ public var east: Swift::Double
+ public init(south: Swift::Double, west: Swift::Double, north: Swift::Double, east: Swift::Double)
+ public static func == (a: MapConductorVectorTile::OfflinePackage.MapConductorVectorTile::Bounds, b: MapConductorVectorTile::OfflinePackage.MapConductorVectorTile::Bounds) -> Swift::Bool
+ public func encode(to encoder: any Swift::Encoder) throws
+ public init(from decoder: any Swift::Decoder) throws
+ }
+ public struct Manifest : Swift::Codable, Swift::Sendable {
+ public var version: Swift::Int
+ public var bounds: MapConductorVectorTile::OfflinePackage.MapConductorVectorTile::Bounds
+ public var minZoom: Swift::Int
+ public var maxZoom: Swift::Int
+ public var styleDigest: Swift::String
+ public var createdAt: Swift::Int64
+ public var tiles: Swift::Int
+ public var glyphs: Swift::Int
+ public var sprites: Swift::Int
+ public var bytes: Swift::Int64
+ public var sources: [Swift::String : Swift::String]
+ public var glyphsTemplate: Swift::String?
+ public var spriteBase: Swift::String?
+ public func encode(to encoder: any Swift::Encoder) throws
+ public init(from decoder: any Swift::Decoder) throws
+ }
+ final public let directory: Foundation::URL
+ final public let manifest: MapConductorVectorTile::OfflinePackage.MapConductorVectorTile::Manifest
+ public static let formatVersion: Swift::Int
+ public static func open(_ directory: Foundation::URL) -> MapConductorVectorTile::OfflinePackage?
+ public static func delete(_ directory: Foundation::URL)
+ final public var styleJSON: Swift::String {
+ get
+ }
+ final public func bytes(for url: Swift::String) -> Foundation::Data?
+ final public func contains(_ url: Swift::String) -> Swift::Bool
+ final public func upstreamURL(forRelativePath relativePath: Swift::String) -> Swift::String?
+ final public func styleServed(by baseUrl: Swift::String) -> Swift::String
+ public struct Stats : Swift::Equatable, Swift::Sendable, Swift::CustomStringConvertible {
+ public var packageHits: Swift::Int
+ public var networkFetches: Swift::Int
+ public var blocked: Swift::Int
+ public var description: Swift::String {
+ get
+ }
+ public static func == (a: MapConductorVectorTile::OfflinePackage.MapConductorVectorTile::Stats, b: MapConductorVectorTile::OfflinePackage.MapConductorVectorTile::Stats) -> Swift::Bool
+ }
+ final public class Fetcher : @unchecked Swift::Sendable {
+ final public var online: Swift::Bool {
+ get
+ set
+ }
+ final public var stats: MapConductorVectorTile::OfflinePackage.MapConductorVectorTile::Stats {
+ get
+ }
+ public init(package: MapConductorVectorTile::OfflinePackage, online: Swift::Bool = true, headers: [Swift::String : Swift::String] = [:], upstream: ((Foundation::URL) -> Foundation::Data?)? = nil, onStats: ((MapConductorVectorTile::OfflinePackage.MapConductorVectorTile::Stats) -> Swift::Void)? = nil)
+ final public func fetch(_ url: Foundation::URL) throws -> Foundation::Data?
+ @objc deinit
+ }
+ @objc deinit
+}
+public struct OfflineUnavailableError : Swift::Error, Swift::CustomStringConvertible {
+ public let url: Swift::String
+ public init(url: Swift::String)
+ public var description: Swift::String {
+ get
+ }
+}
+public enum OfflinePackageDownloader {
+ public enum Phase : Swift::String, Swift::Sendable {
+ case planning, tiles, glyphs, sprite, done
+ public init?(rawValue: Swift::String)
+ public typealias RawValue = Swift::String
+ public var rawValue: Swift::String {
+ get
+ }
+ }
+ public struct Progress : Swift::Equatable, Swift::Sendable, Swift::CustomStringConvertible {
+ public let phase: MapConductorVectorTile::OfflinePackageDownloader.MapConductorVectorTile::Phase
+ public let done: Swift::Int
+ public let total: Swift::Int
+ public var description: Swift::String {
+ get
+ }
+ public static func == (a: MapConductorVectorTile::OfflinePackageDownloader.MapConductorVectorTile::Progress, b: MapConductorVectorTile::OfflinePackageDownloader.MapConductorVectorTile::Progress) -> Swift::Bool
+ }
+ public static let maxTiles: Swift::Int
+ public enum DownloadError : Swift::Error, Swift::CustomStringConvertible {
+ case zoomRange(Swift::Int, Swift::Int)
+ case tooManyTiles(Swift::Int)
+ case glyphTemplate(Swift::String)
+ case fetchFailed(Foundation::URL, Swift::Int?)
+ case tileJSON(Foundation::URL, Swift::String)
+ public var description: Swift::String {
+ get
+ }
+ }
+ public static func download(styleJSON: Swift::String, bounds: MapConductorVectorTile::OfflinePackage.MapConductorVectorTile::Bounds, minZoom: Swift::Int = 0, maxZoom: Swift::Int = 14, directory: Foundation::URL, headers: [Swift::String : Swift::String] = [:], parallelism: Swift::Int = 8, fetch: ((Foundation::URL) -> Foundation::Data?)? = nil, onProgress: @escaping @Sendable (MapConductorVectorTile::OfflinePackageDownloader.MapConductorVectorTile::Progress) -> Swift::Void = { _ in }) async throws -> MapConductorVectorTile::OfflinePackage
+}
+final public class StyleAssetCache {
+ public init?(directory: Foundation::URL)
+ final public func get(_ key: Swift::String) -> Foundation::Data?
+ final public func put(_ key: Swift::String, _ value: Foundation::Data)
+ @objc deinit
+}
+final public class VectorStyleHandoff {
+ final public let documentId: Swift::String
+ final public let url: Swift::String
+ final public let attributions: [Swift::String]
+ final public let support: any MapConductorCore::VectorStyleSupport
+ final public let diagnostics: [Swift::String]
+ final public let filesRoute: Swift::String?
+ public init(styleJSON: Swift::String, rules: MapConductorVectorStyle::StyleRules = .none, support: any MapConductorCore::VectorStyleSupport, server: MapConductorCore::LocalTileServer = TileServerRegistry.get(), offlinePackage: MapConductorVectorTile::OfflinePackage? = nil, online: Swift::Bool = true, headers: [Swift::String : Swift::String] = [:])
+ final public func dispose()
+ public static func attributions(ofStyle json: Swift::String) -> [Swift::String]
+ @objc deinit
+}
+final public class VectorTileProvider : MapConductorCore::TileProvider {
+ public static let defaultTileSize: Swift::Int
+ public static let labelResolution: Swift::Int
+ public enum Content {
+ case full
+ case ground
+ case labels
+ public static func == (a: MapConductorVectorTile::VectorTileProvider.MapConductorVectorTile::Content, b: MapConductorVectorTile::VectorTileProvider.MapConductorVectorTile::Content) -> Swift::Bool
+ public func hash(into hasher: inout Swift::Hasher)
+ public var hashValue: Swift::Int {
+ get
+ }
+ }
+ final public var onGlyphsLoaded: (() -> Swift::Void)?
+ public enum RenderMode {
+ case cpu
+ case gpu
+ case auto
+ public static func == (a: MapConductorVectorTile::VectorTileProvider.MapConductorVectorTile::RenderMode, b: MapConductorVectorTile::VectorTileProvider.MapConductorVectorTile::RenderMode) -> Swift::Bool
+ public func hash(into hasher: inout Swift::Hasher)
+ public var hashValue: Swift::Int {
+ get
+ }
+ }
+ final public var renderMode: MapConductorVectorTile::VectorTileProvider.MapConductorVectorTile::RenderMode {
+ get
+ }
+ final public var gpuRenders: Swift::Int {
+ get
+ }
+ final public var gpuFallbacks: Swift::Int {
+ get
+ }
+ public init(styleJSON: Swift::String, rules: MapConductorVectorStyle::StyleRules = .none, tileSize: Swift::Int = VectorTileProvider.defaultTileSize, headers: [Swift::String : Swift::String] = [:], cacheBytes: Swift::Int = 16 * 1024 * 1024, renderMode: MapConductorVectorTile::VectorTileProvider.MapConductorVectorTile::RenderMode = .auto, assetCacheDirectory: Foundation::URL? = nil, renderedCacheBytes: Swift::Int = 48 * 1024 * 1024, sourceCacheBytes: Swift::Int = 128 * 1024 * 1024, renderScale: Swift::Int? = nil, fetchTile: ((Foundation::URL) throws -> Foundation::Data?)? = nil) throws
+ @objc deinit
+ final public func closeAsync()
+ final public func close()
+ final public func attributions() -> [Swift::String]
+ final public func diagnostics() -> [Swift::String]
+ final public func setStyle(_ styleJSON: Swift::String, rules: MapConductorVectorStyle::StyleRules = .none) throws
+ final public var glyphGenerationValue: Swift::Int {
+ get
+ }
+ final public var groundTiles: any MapConductorCore::TileProvider {
+ get
+ }
+ final public var labelTiles: any MapConductorCore::TileProvider {
+ get
+ }
+ final public func renderTile(request: MapConductorCore::TileRequest) -> Foundation::Data?
+ final public func renderTile(request: MapConductorCore::TileRequest, isCancelled: () -> Swift::Bool) -> Foundation::Data?
+ final public func renderTile(request: MapConductorCore::TileRequest, content: MapConductorVectorTile::VectorTileProvider.MapConductorVectorTile::Content = .full, isCancelled: () -> Swift::Bool = { false }) -> Foundation::Data?
+}
+final public class VectorTileRasteriser : MapConductorVectorStyle::VectorStyleRasteriser {
+ public init(tileSize: Swift::Int? = nil, headers: [Swift::String : Swift::String] = [:], assetCacheDirectory: Foundation::URL? = nil, renderMode: MapConductorVectorTile::VectorTileProvider.MapConductorVectorTile::RenderMode = .auto, renderScale: Swift::Int? = nil)
+ final public func install(host: any MapConductorCore::MapStyleHost, styleJSON: Swift::String, affects _: MapConductorVectorStyle::StyleAffects) -> any MapConductorVectorStyle::VectorStyleRasterisation
+ @objc deinit
+}
+public enum VectorTileError : Swift::Error, Swift::CustomStringConvertible {
+ case styleRejected(Swift::String)
+ case renderFailed(Swift::Int32)
+ case closed
+ public var description: Swift::String {
+ get
+ }
+}
+public struct LabelTile {
+ public let pixels: Foundation::Data
+ public let placed: Swift::Int
+}
+final public class VectorTileRenderer {
+ public static let defaultTileSize: Swift::UInt32
+ public static let outputVersion: Swift::Int
+ public init(styleJSON: Swift::String, displayTileSize: Swift::Int = 512) throws
+ @objc deinit
+ final public func close()
+ final public func plan(z: Swift::UInt8, x: Swift::UInt32, y: Swift::UInt32, sourceZoomOffset: Swift::Int = 0) throws -> Swift::String
+ final public func setStyle(_ styleJSON: Swift::String) throws
+ final public func unsupportedLayerTypes() throws -> [Swift::String]
+ final public func diagnostics() throws -> [Swift::String]
+ final public func render(z: Swift::UInt8, x: Swift::UInt32, y: Swift::UInt32, tileSize: Swift::UInt32 = defaultTileSize, tiles: [Foundation::Data?], sourceZoomOffset: Swift::Int = 0) throws -> Foundation::Data
+ final public func tessellate(z: Swift::UInt8, x: Swift::UInt32, y: Swift::UInt32, tileSize: Swift::UInt32 = defaultTileSize, tiles: [Foundation::Data?], sourceZoomOffset: Swift::Int = 0) throws -> MapConductorVectorTile::TessellatedTile
+ final public func attributions() throws -> [Swift::String]
+ final public func glyphsURLTemplate() throws -> Swift::String?
+ final public func neededGlyphs(z: Swift::UInt8, x: Swift::UInt32, y: Swift::UInt32, tiles: [Foundation::Data?], sourceZoomOffset: Swift::Int = 0) throws -> [Swift::String]
+ @discardableResult
+ final public func addGlyphs(_ pbf: Foundation::Data) throws -> Swift::Int
+ final public func hasGlyphs() throws -> Swift::Bool
+ final public func spriteURLs(pixelRatio: Swift::UInt32 = 2) throws -> (json: Swift::String, png: Swift::String)?
+ @discardableResult
+ final public func addSprite(json: Swift::String, png: Foundation::Data) throws -> Swift::Int
+ final public func needsSprite() throws -> Swift::Bool
+ final public func needsCPU(z: Swift::UInt8, tiles: [Foundation::Data?], sourceZoomOffset: Swift::Int = 0) throws -> Swift::Bool
+ final public func renderGeometry(z: Swift::UInt8, x: Swift::UInt32, y: Swift::UInt32, tileSize: Swift::UInt32 = defaultTileSize, tiles: [Foundation::Data?], sourceZoomOffset: Swift::Int = 0) throws -> Foundation::Data
+ final public func renderLabels(z: Swift::UInt8, x: Swift::UInt32, y: Swift::UInt32, tileSize: Swift::UInt32 = defaultTileSize, tiles: [Foundation::Data?], sourceZoomOffset: Swift::Int = 0) throws -> MapConductorVectorTile::LabelTile
+ @discardableResult
+ final public func drawLabels(z: Swift::UInt8, x: Swift::UInt32, y: Swift::UInt32, tileSize: Swift::UInt32 = defaultTileSize, rgba: inout Foundation::Data, tiles: [Foundation::Data?], sourceZoomOffset: Swift::Int = 0) throws -> Swift::Int
+ @discardableResult
+ final public func drawLabels(z: Swift::UInt8, x: Swift::UInt32, y: Swift::UInt32, tileSize: Swift::UInt32 = defaultTileSize, rgba: Swift::UnsafeMutableRawBufferPointer, tiles: [Foundation::Data?], sourceZoomOffset: Swift::Int = 0) throws -> Swift::Int
+}
+public struct TessellatedTile {
+ public let packed: [Swift::Float]
+ public init(packed: [Swift::Float])
+ public var extent: Swift::Float {
+ get
+ }
+ public var background: (r: Swift::Float, g: Swift::Float, b: Swift::Float, a: Swift::Float)? {
+ get
+ }
+ public var batchCount: Swift::Int {
+ get
+ }
+ public var timings: (decode: Swift::Float, tessellate: Swift::Float, filterCompile: Swift::Float, fill: Swift::Float, line: Swift::Float) {
+ get
+ }
+ public func batch(_ index: Swift::Int) -> (firstVertex: Swift::Int, vertexCount: Swift::Int)
+ public var vertexOffset: Swift::Int {
+ get
+ }
+ public static let vertexStride: Swift::Int
+ public var vertexFloatCount: Swift::Int {
+ get
+ }
+ public func withVertices(_ body: (Swift::UnsafeBufferPointer) -> R) -> R
+}
+public enum MvtStatus {
+ public static let ok: Swift::Int32
+ public static let nullHandle: Swift::Int32
+ public static let badArgument: Swift::Int32
+ public static let renderFailed: Swift::Int32
+ public static let panic: Swift::Int32
+}
+extension MapConductorVectorTile::OfflinePackageDownloader.MapConductorVectorTile::Phase : Swift::Equatable {}
+extension MapConductorVectorTile::OfflinePackageDownloader.MapConductorVectorTile::Phase : Swift::Hashable {}
+extension MapConductorVectorTile::OfflinePackageDownloader.MapConductorVectorTile::Phase : Swift::RawRepresentable {}
+extension MapConductorVectorTile::VectorTileProvider.MapConductorVectorTile::Content : Swift::Equatable {}
+extension MapConductorVectorTile::VectorTileProvider.MapConductorVectorTile::Content : Swift::Hashable {}
+extension MapConductorVectorTile::VectorTileProvider.MapConductorVectorTile::RenderMode : Swift::Equatable {}
+extension MapConductorVectorTile::VectorTileProvider.MapConductorVectorTile::RenderMode : Swift::Hashable {}
diff --git a/samples/MapConductorSampleApp/Benchmarks/DeviceSeamReproBench.swift b/samples/MapConductorSampleApp/Benchmarks/DeviceSeamReproBench.swift
new file mode 100644
index 0000000..e469a05
--- /dev/null
+++ b/samples/MapConductorSampleApp/Benchmarks/DeviceSeamReproBench.swift
@@ -0,0 +1,202 @@
+import UIKit
+import XCTest
+
+import MapConductorCore
+
+/// 実機で出た「境界で左右の色が違う割れた丸」の再現。
+///
+/// 実機の街路樹と同じ規模・同じ設定(144,183 本、アイコン 101 色 20px、
+/// scale 2.8 相当、declutter 14、タイル 512px、z17)で隣接ペアを描き、
+/// 境界の左右 ±3px の帯を突き合わせる。どちらか片方だけに不透明画素があり、
+/// かつ左右の色相が食い違う行が続けば「割れ」。
+final class DeviceSeamReproBench: XCTestCase {
+
+ private struct Lcg {
+ private var seed: UInt64 = 12345
+ mutating func next() -> Double {
+ seed = seed &* 6_364_136_223_846_793_005 &+ 1_442_695_040_888_963_407
+ return Double(seed >> 11) / Double(UInt64(1) << 53)
+ }
+ }
+
+ private func palette(_ count: Int) -> [ImageIcon] {
+ (0...defaultManager(minMarkerCount: 1)
+ let icons = palette(101)
+ var lcg = Lcg()
+ for at in 0..<144_183 {
+ let lat = 35.5 + lcg.next() * 0.4
+ let lng = 139.5 + lcg.next() * 0.5
+ manager.registerEntity(
+ MarkerEntity(
+ marker: nil,
+ state: MarkerState(
+ position: GeoPoint(latitude: lat, longitude: lng),
+ id: String(at),
+ icon: icons[at % icons.count]
+ ),
+ visible: true, isRendered: true, tiling: true
+ )
+ )
+ }
+ // 前半は既定(GPU)で回し、後半の CPU 強制と比べる。
+ unsetenv("MAPCONDUCTOR_MARKER_TILE_CPU")
+ let renderer = MarkerTileRenderer(
+ markerManager: manager,
+ tileSize: 512,
+ cacheSizeBytes: 64 * 1024 * 1024,
+ iconScaleCallback: { _, z in (z > 15 ? 1.4 : 1.0) * 2.0 },
+ declutterPx: 14
+ )
+
+ func rgba(_ png: Data) throws -> (pixels: [UInt8], width: Int, height: Int) {
+ let image = try XCTUnwrap(UIImage(data: png)?.cgImage)
+ var pixels = [UInt8](repeating: 0, count: image.width * image.height * 4)
+ let context = CGContext(
+ data: &pixels, width: image.width, height: image.height,
+ bitsPerComponent: 8, bytesPerRow: image.width * 4,
+ space: CGColorSpaceCreateDeviceRGB(),
+ bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
+ )
+ context?.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height))
+ return (pixels, image.width, image.height)
+ }
+
+ let z = 17
+ let n = Double(1 << z)
+ let latRad = 35.69 * Double.pi / 180
+ let cx = Int((139.694 + 180.0) / 360.0 * n)
+ let cy = Int((1.0 - log(tan(latRad) + 1.0 / cos(latRad)) / .pi) / 2.0 * n)
+
+ var firstBad: TileRequest?
+ var seams = 0
+ var badSeams = 0
+ for dx in -3...3 {
+ for dy in -3...3 {
+ let left = renderer.renderTile(request: TileRequest(x: cx + dx, y: cy + dy, z: z))
+ let right = renderer.renderTile(request: TileRequest(x: cx + dx + 1, y: cy + dy, z: z))
+ guard let left, let right else { continue }
+ let l = try rgba(left)
+ let r = try rgba(right)
+ seams += 1
+ // 境界の両側 3px: 左タイルの最終 3 列 vs 右タイルの先頭 3 列は
+ // **同じ world 列ではない**が、境界をまたぐ円は両方に描かれる。
+ // 割れの判定は「左の最終列に不透明があるのに、右の先頭列の同じ行が
+ // 透明、または色相が大きく違う」が 6 行以上連続。
+ var run = 0
+ var worst = 0
+ for row in 0.. 200, ra > 200 {
+ let dr = abs(Int(l.pixels[li]) - Int(r.pixels[ri]))
+ let dg = abs(Int(l.pixels[li + 1]) - Int(r.pixels[ri + 1]))
+ let db = abs(Int(l.pixels[li + 2]) - Int(r.pixels[ri + 2]))
+ broken = (dr + dg + db) > 180
+ }
+ run = broken ? run + 1 : 0
+ worst = max(worst, run)
+ }
+ if worst >= 4 {
+ badSeams += 1
+ if firstBad == nil { firstBad = TileRequest(x: cx + dx, y: cy + dy, z: z) }
+ print("SEAMREPRO z=\(z) between x=\(cx + dx),\(cx + dx + 1) y=\(cy + dy) worstRun=\(worst)")
+ for (name, data) in [("L", left), ("R", right)] {
+ let attachment = XCTAttachment(data: data, uniformTypeIdentifier: "public.png")
+ attachment.name = "bad-\(cx + dx)-\(cy + dy)-\(name)"
+ attachment.lifetime = .keepAlways
+ add(attachment)
+ }
+ }
+ }
+ }
+ print("SEAMREPRO seams=\(seams) bad=\(badSeams) path=GPU")
+
+ // 同じ検査を CPU 強制でもう一周。GPU だけ割れるなら GPU が犯人。
+ setenv("MAPCONDUCTOR_MARKER_TILE_CPU", "1", 1)
+ defer { unsetenv("MAPCONDUCTOR_MARKER_TILE_CPU") }
+ let cpuRenderer = MarkerTileRenderer(
+ markerManager: manager,
+ tileSize: 512,
+ cacheSizeBytes: 64 * 1024 * 1024,
+ iconScaleCallback: { _, z in (z > 15 ? 1.4 : 1.0) * 2.0 },
+ declutterPx: 14
+ )
+ var cpuBad = 0
+ for dx in -3...3 {
+ for dy in -3...3 {
+ let left = cpuRenderer.renderTile(request: TileRequest(x: cx + dx, y: cy + dy, z: z))
+ let right = cpuRenderer.renderTile(request: TileRequest(x: cx + dx + 1, y: cy + dy, z: z))
+ guard let left, let right else { continue }
+ let l = try rgba(left)
+ let r = try rgba(right)
+ var run = 0
+ var worst = 0
+ for row in 0.. 200, r.pixels[ri + 3] > 200 {
+ let dr = abs(Int(l.pixels[li]) - Int(r.pixels[ri]))
+ let dg = abs(Int(l.pixels[li + 1]) - Int(r.pixels[ri + 1]))
+ let db = abs(Int(l.pixels[li + 2]) - Int(r.pixels[ri + 2]))
+ broken = (dr + dg + db) > 180
+ }
+ run = broken ? run + 1 : 0
+ worst = max(worst, run)
+ }
+ if worst >= 4 {
+ cpuBad += 1
+ print("SEAMREPRO-CPU z=\(z) between x=\(cx + dx),\(cx + dx + 1) y=\(cy + dy) worstRun=\(worst)")
+ }
+ }
+ }
+ print("SEAMREPRO seams=\(seams) badGPU=\(badSeams) badCPU=\(cpuBad)")
+
+ // 最初に割れたタイルの GPU/CPU 両版を添付し、差分画素の分布を出す。
+ if let first = firstBad {
+ let gpuPng = try XCTUnwrap(renderer.renderTile(request: first))
+ let cpuPng = try XCTUnwrap(cpuRenderer.renderTile(request: first))
+ for (name, data) in [("gpu", gpuPng), ("cpu", cpuPng)] {
+ let attachment = XCTAttachment(data: data, uniformTypeIdentifier: "public.png")
+ attachment.name = "difftile-\(name)"
+ attachment.lifetime = .keepAlways
+ add(attachment)
+ }
+ let g = try rgba(gpuPng)
+ let c = try rgba(cpuPng)
+ var minX = Int.max, maxX = -1, minY = Int.max, maxY = -1, count = 0
+ for y in 0.. 120 {
+ count += 1
+ minX = min(minX, x); maxX = max(maxX, x)
+ minY = min(minY, y); maxY = max(maxY, y)
+ }
+ }
+ }
+ print("DIFFTILE z=\(first.z) x=\(first.x) y=\(first.y) diffPx=\(count) box=(\(minX),\(minY))-(\(maxX),\(maxY))")
+ }
+ }
+}
diff --git a/samples/MapConductorSampleApp/Benchmarks/GeocellKeyProbe.swift b/samples/MapConductorSampleApp/Benchmarks/GeocellKeyProbe.swift
new file mode 100644
index 0000000..3d27a6a
--- /dev/null
+++ b/samples/MapConductorSampleApp/Benchmarks/GeocellKeyProbe.swift
@@ -0,0 +1,277 @@
+import XCTest
+
+/// 現行の平坦キーと、geomodel 方式の階層キーを同じデータで比べる。
+///
+/// cordova-plugin-googlemaps の `geomodel.js` は、各段で 4x4 に割った位置を
+/// 16 進 1 文字にする:
+///
+/// _subdiv_char(x, y) = (y & 2) << 2 | (x & 2) << 1 | (y & 1) << 1 | (x & 1)
+///
+/// これは緯度経度のビットを交互に並べたもの(Morton 順)を 4 ビットずつ切った
+/// ものと同じで、**粗いセルが細かいセルの前置になる**。
+///
+/// 現行の `MarkerGridIndex` は `(lat << 20) | lon` の行優先なので前置にならず、
+/// 粗さを変えるには段ごとに配列を作り直すしかない。実機で 1 段あたり 340ms
+/// かかっていた。そのコストが階層キーで本当に消えるのかを測る。
+final class GeocellKeyProbe: XCTestCase {
+
+ private static let count = 144_183
+ private static let depth: Int64 = 19 // 軸あたり 19 ビット。+ 位置 24 ビットで 62。
+
+ private struct Lcg {
+ private var seed: UInt64 = 12345
+ mutating func next() -> Double {
+ seed = seed &* 6_364_136_223_846_793_005 &+ 1_442_695_040_888_963_407
+ return Double(seed >> 11) / Double(UInt64(1) << 53)
+ }
+ }
+
+ private func positions() -> [(Double, Double)] {
+ var lcg = Lcg()
+ return (0.. [Int64] {
+ var keys = [Int64](); keys.reserveCapacity(points.count)
+ for (at, p) in points.enumerated() {
+ let lat = Int64((p.0 / cellDegrees).rounded(.down))
+ let lon = Int64((p.1 / cellDegrees).rounded(.down))
+ keys.append(((((lat + 262_144) << 20) | (lon + 524_288)) << 24) | Int64(at))
+ }
+ keys.sort()
+ return keys
+ }
+
+ /// geomodel 方式: 最も細かい段で 1 本だけ作る。粗い段は上位ビットの前置。
+ private func mortonKeysUnsorted(_ points: [(Double, Double)]) -> [Int64] {
+ var keys = [Int64](); keys.reserveCapacity(points.count)
+ let side = Double(Int64(1) << Self.depth)
+ for (at, p) in points.enumerated() {
+ let latIdx = Int64(((p.0 + 90.0) / 180.0 * side).rounded(.down))
+ let lonIdx = Int64(((p.1 + 180.0) / 360.0 * side).rounded(.down))
+ var morton: Int64 = 0
+ for b in stride(from: Self.depth - 1, through: 0, by: -1) {
+ morton = (morton << 2) | (((latIdx >> b) & 1) << 1) | ((lonIdx >> b) & 1)
+ }
+ keys.append((morton << 24) | Int64(at))
+ }
+ return keys
+ }
+
+ private func mortonKeys(_ points: [(Double, Double)]) -> [Int64] {
+ var keys = [Int64](); keys.reserveCapacity(points.count)
+ let side = Double(Int64(1) << Self.depth)
+ for (at, p) in points.enumerated() {
+ let latIdx = Int64(((p.0 + 90.0) / 180.0 * side).rounded(.down))
+ let lonIdx = Int64(((p.1 + 180.0) / 360.0 * side).rounded(.down))
+ var morton: Int64 = 0
+ for b in stride(from: Self.depth - 1, through: 0, by: -1) {
+ morton = (morton << 2) | (((latIdx >> b) & 1) << 1) | ((lonIdx >> b) & 1)
+ }
+ keys.append((morton << 24) | Int64(at))
+ }
+ keys.sort()
+ return keys
+ }
+
+ private func lowerBound(_ a: [Int64], _ target: Int64) -> Int {
+ var low = 0, high = a.count
+ while low < high { let mid = (low + high) >> 1; if a[mid] < target { low = mid + 1 } else { high = mid } }
+ return low
+ }
+
+ private func ms(_ body: () -> Void) -> Double {
+ let t = DispatchTime.now().uptimeNanoseconds
+ body()
+ return Double(DispatchTime.now().uptimeNanoseconds - t) / 1_000_000
+ }
+
+ func testBuildCostAcrossLevels() {
+ let points = positions()
+
+ // ズーム 11/12/13 が要求する分離距離に対応する段。
+ let wanted: [(zoom: Int, separation: Double)] = [(11, 0.0032), (12, 0.0016), (13, 0.0008)]
+
+ print("GEOCELL points=\(points.count)")
+
+ // 現行方式: 段ごとに 1 本ずつ作る。
+ var flatTotal = 0.0
+ for w in wanted {
+ let t = ms { _ = self.flatKeys(points, cellDegrees: w.separation) }
+ flatTotal += t
+ print(String(format: "GEOCELL flat z=%d cell=%.5f build=%.0fms", w.zoom, w.separation, t))
+ }
+ print(String(format: "GEOCELL flat 合計 build=%.0fms(段ごとに払う)", flatTotal))
+
+ // geomodel 方式: 1 本だけ。
+ // 鍵の計算と並べ替えを分けて測る。GPU に出せるのは前者だけ -- 後者は
+ // 要素間に依存があり、素直な並列化ができない。
+ var raw: [Int64] = []
+ let keyCost = ms { raw = self.mortonKeysUnsorted(points) }
+ var morton: [Int64] = []
+ let sortCost = ms { morton = raw.sorted() }
+ print(String(format: "GEOCELL morton 鍵計算=%.0fms 並べ替え=%.0fms 合計=%.0fms",
+ keyCost, sortCost, keyCost + sortCost))
+ print(String(format: "GEOCELL morton 鍵計算の占める割合=%.0f%%",
+ 100 * keyCost / (keyCost + sortCost)))
+
+ // その 1 本から各段の粗さで問い合わせられることを確認する。
+ for w in wanted {
+ // 段 k のセル幅(経度)= 360 / 2^k。要求された分離以下になる最大の k。
+ var level = Self.depth
+ while level > 1 && 360.0 / Double(Int64(1) << level) < w.separation { level -= 1 }
+ let shift = (Self.depth - level) * 2
+ // 東京中心の 1 タイル相当の箱に入るセルを、前置の範囲として数える。
+ let t = ms {
+ var cells = 0
+ var at = 0
+ var previous: Int64 = -1
+ while at < morton.count {
+ let coarse = (morton[at] >> 24) >> shift
+ if coarse != previous { cells += 1; previous = coarse }
+ // 同じ粗いセルの終端まで一気に飛ぶ(前置なので連続している)
+ let limit = ((coarse + 1) << shift) << 24
+ at = self.lowerBound(morton, limit)
+ }
+ print(String(format: "GEOCELL morton z=%d level=%d cellDeg=%.5f cells=%d",
+ w.zoom, level, 360.0 / Double(Int64(1) << level), cells))
+ }
+ print(String(format: "GEOCELL morton z=%d 走査=%.1fms(build 追加なし)", w.zoom, t))
+ }
+ XCTAssertGreaterThan(morton.count, 0)
+ }
+
+ /// 比較ソート・基数ソート・ハッシュ表を同じ鍵で比べる。
+ ///
+ /// 「ソートを外してハッシュにすれば 194ms が消えるのでは」に対する実測。
+ /// 平坦な格子ならハッシュで足りるが、geomodel の前置範囲は順序に依存する。
+ /// そして順序が要るとしても、鍵は上限のある整数なので比較ソートである必要は
+ /// ない -- 基数ソートは O(n) で、接頭辞和は GPU に出せる。
+ func testSortAlternatives() {
+ let points = positions()
+ var raw: [Int64] = []
+ let keyCost = ms { raw = self.mortonKeysUnsorted(points) }
+
+ let comparison = ms { _ = raw.sorted() }
+
+ var radixResult: [Int64] = []
+ let radix = ms { radixResult = Self.radixSorted(raw) }
+
+ // ハッシュ表: セル -> そのセルのマーカー位置
+ var table: [Int64: [Int32]] = [:]
+ let hashing = ms {
+ table = [:]
+ table.reserveCapacity(raw.count / 4)
+ for key in raw {
+ table[key >> 24, default: []].append(Int32(key & 0xFFFFFF))
+ }
+ }
+
+ print(String(format: "SORTALT 鍵計算=%.0fms", keyCost))
+ print(String(format: "SORTALT 比較ソート=%.0fms 基数ソート=%.0fms ハッシュ表=%.0fms",
+ comparison, radix, hashing))
+ print(String(format: "SORTALT 基数/比較=%.2fx セル数=%d", radix / comparison, table.count))
+ XCTAssertEqual(radixResult, raw.sorted(), "基数ソートの結果が比較ソートと違う")
+ }
+
+ /// 下位 8 ビットずつ、8 周。鍵は 62 ビットなので符号の扱いは要らない。
+ private static func radixSorted(_ input: [Int64]) -> [Int64] {
+ var source = input
+ var destination = [Int64](repeating: 0, count: input.count)
+ for pass in 0..<8 {
+ let shift = Int64(pass * 8)
+ var counts = [Int](repeating: 0, count: 256)
+ for value in source { counts[Int((value >> shift) & 0xFF)] += 1 }
+ // 接頭辞和。GPU ではここが scan になる。
+ var total = 0
+ for bucket in 0..<256 {
+ let count = counts[bucket]
+ counts[bucket] = total
+ total += count
+ }
+ for value in source {
+ let bucket = Int((value >> shift) & 0xFF)
+ destination[counts[bucket]] = value
+ counts[bucket] += 1
+ }
+ swap(&source, &destination)
+ }
+ return source
+ }
+
+ /// geomodel 本来の文字列キーと、同じ階層を整数で表したものを比べる。
+ ///
+ /// `GEOCELL_ALPHABET = '0123456789abcdef'` は 4 ビットを 1 文字に写している
+ /// だけなので、文字列の前置と整数の上位ビットは同じものを指す。違うのは
+ /// 1 件あたりの費用で、このリポジトリは以前 String id を 1 件ごとに持って
+ /// 144,183 本で OOM を起こしている。
+ func testStringKeyVersusIntegerKey() {
+ let points = positions()
+ let alphabet = Array("0123456789abcdef")
+ let resolution = 9 // 4 ビット x 9 = 36 ビット。整数版の 38 ビットとほぼ同じ粒度。
+
+ // --- geomodel 本来の形: 文字列を組み立てる ---
+ var cells: [String] = []
+ let stringBuild = ms {
+ cells = points.map { point in
+ var south = -90.0, north = 90.0, west = -180.0, east = 180.0
+ var cell = ""
+ cell.reserveCapacity(resolution)
+ for _ in 0..> 24, default: []].append(Int32(key & 0xFFFFFF)) }
+ }
+ var intCoarse: [Int64: [Int32]] = [:]
+ let intCoarseCost = ms {
+ intCoarse = [:]
+ for (key, list) in intTable { intCoarse[key >> 12, default: []].append(contentsOf: list) }
+ }
+
+ print(String(format: "STRKEY 文字列: 組み立て=%.0fms ハッシュ=%.0fms 粗い段=%.0fms 合計=%.0fms cells=%d",
+ stringBuild, stringHash, stringCoarse, stringBuild + stringHash + stringCoarse, stringTable.count))
+ print(String(format: "STRKEY 整数 : 組み立て=%.0fms ハッシュ=%.0fms 粗い段=%.0fms 合計=%.0fms cells=%d",
+ intBuild, intHash, intCoarseCost, intBuild + intHash + intCoarseCost, intTable.count))
+ print(String(format: "STRKEY 粗い段のセル数 文字列=%d 整数=%d", coarse.count, intCoarse.count))
+ XCTAssertGreaterThan(stringTable.count, 0)
+ }
+}
diff --git a/samples/MapConductorSampleApp/Benchmarks/GpuCpuTileDiff.swift b/samples/MapConductorSampleApp/Benchmarks/GpuCpuTileDiff.swift
new file mode 100644
index 0000000..b8a8c00
--- /dev/null
+++ b/samples/MapConductorSampleApp/Benchmarks/GpuCpuTileDiff.swift
@@ -0,0 +1,231 @@
+import MapConductorCore
+import UIKit
+import XCTest
+
+/// 同じタイルを GPU と CPU で描いて、画素で突き合わせる。
+///
+/// 「格子(=CPU 描画)だと切れない、消す(=GPU 描画)と切れる」という観測の
+/// 直接検証。理屈で経路を追うのではなく、両方の絵を並べて差分を見る。
+/// アイコンは実物と同じ 101 種・10pt にして、GPU 側のアトラスも実物と同じ
+/// 構成(≈101 セル)で組ませる。
+///
+/// 差が出たタイルは PNG を添付で残す。実機でしか意味がない(シミュレータの
+/// Metal は別物)。
+final class GpuCpuTileDiff: XCTestCase {
+
+ private struct Lcg {
+ private var seed: UInt64 = 12345
+ mutating func next() -> Double {
+ seed = seed &* 6_364_136_223_846_793_005 &+ 1_442_695_040_888_963_407
+ return Double(seed >> 11) / Double(UInt64(1) << 53)
+ }
+ }
+
+ private func icons(_ count: Int) -> [ImageIcon] {
+ (0.. MarkerManager {
+ let manager = MarkerManager.defaultManager(minMarkerCount: 1)
+ let palette = icons(101)
+ var lcg = Lcg()
+ for at in 0.. [UInt8]? {
+ guard let image = UIImage(data: png)?.cgImage else { return nil }
+ var pixels = [UInt8](repeating: 0, count: image.width * image.height * 4)
+ guard let context = CGContext(
+ data: &pixels, width: image.width, height: image.height,
+ bitsPerComponent: 8, bytesPerRow: image.width * 4,
+ space: CGColorSpaceCreateDeviceRGB(),
+ bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
+ ) else { return nil }
+ context.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height))
+ return pixels
+ }
+
+ func testGpuMatchesCpu() throws {
+ let live = manager(144_183)
+ let scale = Double(UIScreen.main.scale)
+ let callback: (MarkerState, Int) -> Double = { _, z in
+ (z > 15 ? 1.4 : (z > 13 ? 1.0 : (z > 11 ? 0.7 : 0.5))) * scale
+ }
+ func makeRenderer() -> MarkerTileRenderer {
+ MarkerTileRenderer(
+ markerManager: live,
+ tileSize: 512,
+ cacheSizeBytes: 32 * 1024 * 1024,
+ iconScaleCallback: callback,
+ declutterPx: 14
+ )
+ }
+ // GPU 側を先に作る(env なし)。CPU 側は env を立ててから作る --
+ // どちらを使うかは renderer ごとに最初の 1 回で決まる。
+ let gpuSide = makeRenderer()
+ setenv("MAPCONDUCTOR_MARKER_TILE_CPU", "1", 1)
+ let cpuSide = makeRenderer()
+ unsetenv("MAPCONDUCTOR_MARKER_TILE_CPU")
+
+ var tiles = 0
+ var mismatched = 0
+ for z in [13, 14, 15, 16] {
+ let n = Double(1 << z)
+ let latRad = 35.68 * Double.pi / 180
+ let cx = Int((139.75 + 180.0) / 360.0 * n)
+ let cy = Int((1.0 - log(tan(latRad) + 1.0 / cos(latRad)) / .pi) / 2.0 * n)
+ for dx in -2...2 {
+ for dy in -2...2 {
+ let request = TileRequest(x: cx + dx, y: cy + dy, z: z)
+ let gpu = gpuSide.renderTile(request: request)
+ let cpu = cpuSide.renderTile(request: request)
+ tiles += 1
+ if gpu == nil, cpu == nil { continue }
+ guard let gpu, let cpu,
+ let gpuPixels = rgba(gpu), let cpuPixels = rgba(cpu),
+ gpuPixels.count == cpuPixels.count
+ else {
+ mismatched += 1
+ print("GPUDIFF z=\(z) x=\(cx + dx) y=\(cy + dy) one side nil or size mismatch")
+ continue
+ }
+ // ニアレストサンプリングの丸めで 1 段ずれる画素は許す。
+ // 数十 px 以上まとまって違うなら絵として別物。
+ var bad = 0
+ for at in stride(from: 0, to: gpuPixels.count, by: 4) {
+ let da = abs(Int(gpuPixels[at]) - Int(cpuPixels[at]))
+ let db = abs(Int(gpuPixels[at + 2]) - Int(cpuPixels[at + 2]))
+ let dalpha = abs(Int(gpuPixels[at + 3]) - Int(cpuPixels[at + 3]))
+ if da > 40 || db > 40 || dalpha > 40 { bad += 1 }
+ }
+ let badShare = Double(bad) / Double(gpuPixels.count / 4)
+ if bad > 500 {
+ mismatched += 1
+ print(String(format: "GPUDIFF z=%d x=%d y=%d badPx=%d (%.2f%%)",
+ z, cx + dx, cy + dy, bad, badShare * 100))
+ for (name, data) in [("gpu", gpu), ("cpu", cpu)] {
+ let attachment = XCTAttachment(data: data, uniformTypeIdentifier: "public.png")
+ attachment.name = "diff-z\(z)-\(cx + dx)-\(cy + dy)-\(name)"
+ attachment.lifetime = .keepAlways
+ add(attachment)
+ }
+ }
+ }
+ }
+ }
+ print("GPUDIFF total=\(tiles) mismatched=\(mismatched)")
+ XCTAssertEqual(mismatched, 0, "GPU と CPU の絵が \(mismatched)/\(tiles) 枚で食い違う")
+ }
+
+ /// 同じ比較を**6 並列**で。実アプリはタイルサーバの 6 レーンから同時に
+ /// 描かせるので、逐次で一致しても並列で壊れるなら(共有バッファの
+ /// 上書き・アトラスの取り違え・padding の競合)ここで出る。
+ /// 正解は逐次 CPU の絵。
+ func testGpuMatchesCpuUnderConcurrency() throws {
+ let live = manager(144_183)
+ let scale = Double(UIScreen.main.scale)
+ let callback: (MarkerState, Int) -> Double = { _, z in
+ (z > 15 ? 1.4 : (z > 13 ? 1.0 : (z > 11 ? 0.7 : 0.5))) * scale
+ }
+ func makeRenderer() -> MarkerTileRenderer {
+ MarkerTileRenderer(
+ markerManager: live,
+ tileSize: 512,
+ cacheSizeBytes: 64 * 1024 * 1024,
+ iconScaleCallback: callback,
+ declutterPx: 14
+ )
+ }
+ let gpuSide = makeRenderer()
+ setenv("MAPCONDUCTOR_MARKER_TILE_CPU", "1", 1)
+ let cpuSide = makeRenderer()
+ unsetenv("MAPCONDUCTOR_MARKER_TILE_CPU")
+
+ var requests: [TileRequest] = []
+ for z in [13, 14, 15, 16] {
+ let n = Double(1 << z)
+ let latRad = 35.68 * Double.pi / 180
+ let cx = Int((139.75 + 180.0) / 360.0 * n)
+ let cy = Int((1.0 - log(tan(latRad) + 1.0 / cos(latRad)) / .pi) / 2.0 * n)
+ for dx in -2...2 {
+ for dy in -2...2 {
+ requests.append(TileRequest(x: cx + dx, y: cy + dy, z: z))
+ }
+ }
+ }
+ // 正解(逐次 CPU)。
+ var reference: [Int: Data?] = [:]
+ for (at, request) in requests.enumerated() {
+ reference[at] = cpuSide.renderTile(request: request)
+ }
+ // 3 周回す。周回ごとにキャッシュを捨て、毎回ナマの並列描画にする。
+ var mismatched = 0
+ for round in 0..<3 {
+ gpuSide.clear()
+ var results = [Data?](repeating: nil, count: requests.count)
+ let lock = NSLock()
+ DispatchQueue.concurrentPerform(iterations: requests.count) { at in
+ let png = gpuSide.renderTile(request: requests[at])
+ lock.lock(); results[at] = png; lock.unlock()
+ }
+ for (at, request) in requests.enumerated() {
+ let expected = reference[at] ?? nil
+ let actual = results[at]
+ if expected == nil, actual == nil { continue }
+ guard let expected, let actual,
+ let want = rgba(expected), let got = rgba(actual), want.count == got.count
+ else {
+ mismatched += 1
+ print("GPUDIFF-C round=\(round) z=\(request.z) x=\(request.x) y=\(request.y) nil/size mismatch")
+ continue
+ }
+ var bad = 0
+ for index in stride(from: 0, to: want.count, by: 4) {
+ if abs(Int(want[index]) - Int(got[index])) > 40
+ || abs(Int(want[index + 2]) - Int(got[index + 2])) > 40
+ || abs(Int(want[index + 3]) - Int(got[index + 3])) > 40 { bad += 1 }
+ }
+ if bad > 500 {
+ mismatched += 1
+ print("GPUDIFF-C round=\(round) z=\(request.z) x=\(request.x) y=\(request.y) badPx=\(bad)")
+ for (name, data) in [("gpu", actual), ("cpu", expected)] {
+ let attachment = XCTAttachment(data: data, uniformTypeIdentifier: "public.png")
+ attachment.name = "cdiff-r\(round)-z\(request.z)-\(request.x)-\(request.y)-\(name)"
+ attachment.lifetime = .keepAlways
+ add(attachment)
+ }
+ }
+ }
+ }
+ print("GPUDIFF-C total=\(requests.count * 3) mismatched=\(mismatched)")
+ XCTAssertEqual(mismatched, 0, "並列 GPU の絵が \(mismatched) 枚で逐次 CPU と食い違う")
+ }
+}
diff --git a/samples/MapConductorSampleApp/Benchmarks/IconSizeProbe.swift b/samples/MapConductorSampleApp/Benchmarks/IconSizeProbe.swift
new file mode 100644
index 0000000..098c166
--- /dev/null
+++ b/samples/MapConductorSampleApp/Benchmarks/IconSizeProbe.swift
@@ -0,0 +1,80 @@
+import MapConductorCore
+import UIKit
+import XCTest
+
+@testable import MapConductorSampleApp
+
+/// 街路樹アイコンが実際に何ピクセルで描かれるかを、出来上がったタイルから測る。
+///
+/// 計算で追うと前提を一つ間違えるだけで答えが変わるので、描いた結果を数える。
+/// マーカーを 1 本だけ置いたタイルを描き、色の付いた画素の外接矩形を取る。
+final class IconSizeProbe: XCTestCase {
+
+ func testIconPixelSizeOnDevice() {
+ let screenScale = UIScreen.main.scale
+ // プロバイダと同じ組み立て(GoogleMapMarkerController.setupTileRenderer)。
+ let tileSize = 256 * max(1, Int(screenScale))
+ let contentScale = Double(screenScale)
+
+ let icon = StreetTreeIcons.palette(count: 1).first!
+ let bitmap = icon.toBitmapIcon()
+
+ let zoom = 16
+ let n = Double(1 << zoom)
+ let lat = 35.68, lon = 139.75
+ let latRad = lat * .pi / 180
+ let tx = (lon + 180.0) / 360.0 * n
+ let ty = (1.0 - log(tan(latRad) + 1.0 / cos(latRad)) / .pi) / 2.0 * n
+
+ let manager = MarkerManager.defaultManager(minMarkerCount: 1)
+ manager.registerEntity(
+ MarkerEntity(
+ marker: nil,
+ state: MarkerState(position: GeoPoint(latitude: lat, longitude: lon), icon: icon),
+ visible: true, isRendered: true, tiling: true
+ )
+ )
+
+ // サンプルと同じ iconScaleCallback。zoom 16 は 1.4 の帯。
+ // サンプルと同じ帯。@MainActor のビューモデルをここから呼べないので写す。
+ let base: (MarkerState, Int) -> Double = { _, z in
+ z > 15 ? 1.4 : (z > 13 ? 1.0 : (z > 11 ? 0.7 : 0.5))
+ }
+ let renderer = MarkerTileRenderer(
+ markerManager: manager,
+ tileSize: tileSize,
+ cacheSizeBytes: 1 << 20,
+ iconScaleCallback: { state, z in base(state, z) * contentScale }
+ )
+
+ guard let data = renderer.renderTile(request: TileRequest(x: Int(tx), y: Int(ty), z: zoom)),
+ let image = UIImage(data: data), let cg = image.cgImage else {
+ return XCTFail("タイルが描けていない")
+ }
+
+ // 不透明な画素の外接矩形。
+ let w = cg.width, h = cg.height
+ var pixels = [UInt8](repeating: 0, count: w * h * 4)
+ let context = CGContext(
+ data: &pixels, width: w, height: h, bitsPerComponent: 8, bytesPerRow: w * 4,
+ space: CGColorSpaceCreateDeviceRGB(),
+ bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
+ )!
+ context.draw(cg, in: CGRect(x: 0, y: 0, width: w, height: h))
+
+ var minX = w, maxX = -1, minY = h, maxY = -1
+ for y in 0.. 8 {
+ minX = min(minX, x); maxX = max(maxX, x)
+ minY = min(minY, y); maxY = max(maxY, y)
+ }
+ }
+
+ let drawnW = maxX - minX + 1, drawnH = maxY - minY + 1
+ // 画面上の見かけの大きさ。タイルは 256pt として表示される。
+ let apparentPt = Double(drawnW) / Double(w) * 256.0
+ print(String(
+ format: "ICONPROBE screenScale=%.0f tileSize=%d canvas=%dx%d bitmapIconSize=%.1f drawnPx=%dx%d apparentPt=%.2f",
+ screenScale, tileSize, w, h, bitmap.size.width, drawnW, drawnH, apparentPt))
+ }
+}
diff --git a/samples/MapConductorSampleApp/Benchmarks/MarkerGpuDrawProbe.swift b/samples/MapConductorSampleApp/Benchmarks/MarkerGpuDrawProbe.swift
new file mode 100644
index 0000000..1f8a5ac
--- /dev/null
+++ b/samples/MapConductorSampleApp/Benchmarks/MarkerGpuDrawProbe.swift
@@ -0,0 +1,185 @@
+import Metal
+import UIKit
+import XCTest
+
+/// マーカータイルの描画を GPU に載せたら何 ms になるかを、本実装の前に測る。
+///
+/// 実機の内訳(`TilePhaseProbe`)では、ズーム 11 のタイル 1 枚 143ms のうち
+/// 115ms が問い合わせ以外 -- 準備と描画だった。そこが本題なので、同じ枚数の
+/// アイコンを CPU(CoreGraphics)と GPU(Metal)で貼って比べる。
+///
+/// `MetalTileRasterizer`(ios-vectortile)と同じ骨格を使う: オフスクリーンへ
+/// 描いて読み戻し、PNG 化は core の Rust エンコーダに渡す。違うのは中身で、
+/// あちらは単色の三角形、こちらはテクスチャ付きのクアッドをインスタンスで
+/// 並べる -- 同じ絵を位置だけ変えて 1 万〜2 万回、GPU がいちばん得意な形。
+final class MarkerGpuDrawProbe: XCTestCase {
+
+ private static let shader = """
+ #include
+ using namespace metal;
+
+ struct Instance { float2 centre; float radius; float pad; };
+
+ struct VertexOut {
+ float4 position [[position]];
+ float2 uv;
+ };
+
+ vertex VertexOut icon_vertex(uint vid [[vertex_id]],
+ uint iid [[instance_id]],
+ constant Instance *instances [[buffer(0)]],
+ constant float &extent [[buffer(1)]]) {
+ // 三角形 2 枚ぶんの 6 頂点を id から組み立てる。頂点バッファは要らない。
+ const float2 corners[6] = { float2(-1,-1), float2(1,-1), float2(-1,1),
+ float2(1,-1), float2(1,1), float2(-1,1) };
+ float2 corner = corners[vid];
+ Instance inst = instances[iid];
+ float2 px = inst.centre + corner * inst.radius;
+ float2 unit = px / extent;
+
+ VertexOut out;
+ out.position = float4(unit.x * 2.0 - 1.0, 1.0 - unit.y * 2.0, 0.0, 1.0);
+ out.uv = corner * 0.5 + 0.5;
+ return out;
+ }
+
+ fragment float4 icon_fragment(VertexOut in [[stage_in]],
+ texture2d atlas [[texture(0)]],
+ sampler smp [[sampler(0)]]) {
+ return atlas.sample(smp, in.uv);
+ }
+ """
+
+ private func ms(_ body: () -> Void) -> Double {
+ let t = DispatchTime.now().uptimeNanoseconds
+ body()
+ return Double(DispatchTime.now().uptimeNanoseconds - t) / 1_000_000
+ }
+
+ /// 14pt の円を 1 枚。実際の街路樹アイコンと同じ大きさ。
+ private func iconImage(sizePx: Int) -> UIImage {
+ let format = UIGraphicsImageRendererFormat.default()
+ format.scale = 1
+ return UIGraphicsImageRenderer(size: CGSize(width: sizePx, height: sizePx), format: format).image { ctx in
+ UIColor.systemRed.setFill()
+ ctx.cgContext.fillEllipse(in: CGRect(x: 0, y: 0, width: sizePx, height: sizePx))
+ }
+ }
+
+ func testGpuVersusCoreGraphics() throws {
+ let tilePx = 768 // プロバイダが使う 256 * scale(3)
+ let count = 17_937 // ズーム 11 のタイルに残っていた実数
+ let iconPx = 21 // 10dp 相当を密度 2 で
+ let half = Float(iconPx) / 2
+
+ var lcg: UInt64 = 99
+ func next() -> Float {
+ lcg = lcg &* 6_364_136_223_846_793_005 &+ 1_442_695_040_888_963_407
+ return Float(lcg >> 11) / Float(UInt64(1) << 53)
+ }
+ let centres: [(Float, Float)] = (0..($0.0, $0.1, half, 0) }
+ let instanceBuffer = device.makeBuffer(bytes: &instances,
+ length: MemoryLayout>.stride * count,
+ options: .storageModeShared)!
+ let targetDescriptor = MTLTextureDescriptor.texture2DDescriptor(
+ pixelFormat: .rgba8Unorm, width: tilePx, height: tilePx, mipmapped: false)
+ targetDescriptor.usage = [.renderTarget, .shaderRead]
+ let target = device.makeTexture(descriptor: targetDescriptor)!
+
+ var extent = Float(tilePx)
+ var readback = [UInt8](repeating: 0, count: tilePx * tilePx * 4)
+
+ // 1 回目はシェーダの準備が乗るので、2 回目を採る。
+ var gpuMs = 0.0
+ for pass in 0..<2 {
+ gpuMs = ms {
+ let renderPass = MTLRenderPassDescriptor()
+ renderPass.colorAttachments[0].texture = target
+ renderPass.colorAttachments[0].loadAction = .clear
+ renderPass.colorAttachments[0].storeAction = .store
+ renderPass.colorAttachments[0].clearColor = MTLClearColorMake(0, 0, 0, 0)
+
+ let buffer = queue.makeCommandBuffer()!
+ let encoder = buffer.makeRenderCommandEncoder(descriptor: renderPass)!
+ encoder.setRenderPipelineState(pipeline)
+ encoder.setVertexBuffer(instanceBuffer, offset: 0, index: 0)
+ encoder.setVertexBytes(&extent, length: MemoryLayout.size, index: 1)
+ encoder.setFragmentTexture(texture, index: 0)
+ encoder.setFragmentSamplerState(sampler, index: 0)
+ encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 6,
+ instanceCount: count)
+ encoder.endEncoding()
+ buffer.commit()
+ buffer.waitUntilCompleted()
+
+ readback.withUnsafeMutableBytes { raw in
+ target.getBytes(raw.baseAddress!, bytesPerRow: tilePx * 4,
+ from: MTLRegionMake2D(0, 0, tilePx, tilePx), mipmapLevel: 0)
+ }
+ }
+ _ = pass
+ }
+
+ print(String(format: "GPUDRAW icons=%d tile=%d cpu=%.1fms gpu=%.1fms speedup=%.1fx",
+ count, tilePx, cpuMs, gpuMs, cpuMs / max(gpuMs, 0.001)))
+ XCTAssertGreaterThan(cpuMs, 0)
+ }
+}
diff --git a/samples/MapConductorSampleApp/Benchmarks/MarkerTileBenchmark.swift b/samples/MapConductorSampleApp/Benchmarks/MarkerTileBenchmark.swift
new file mode 100644
index 0000000..9ba3eed
--- /dev/null
+++ b/samples/MapConductorSampleApp/Benchmarks/MarkerTileBenchmark.swift
@@ -0,0 +1,142 @@
+import MapConductorCore
+import UIKit
+import XCTest
+
+/// マーカータイル 1 枚の実測コストを**実機で**測る。
+///
+/// ios-sdk-core にも同じ内容の `MarkerTileCostTests` があるが、あちらは SPM の
+/// テストターゲットなので実機では走らない:
+///
+/// Cannot test target "MapConductorCoreTests" on "iPad":
+/// Tool-hosted testing is unavailable on device destinations.
+///
+/// ホストアプリが要る。それがこのターゲットの存在理由で、ここはサンプルアプリに
+/// ホストされている。シミュレータは M シリーズの Mac で動くので、実機より速い
+/// 方向に嘘をつく -- 「Android より遅い」を追うのにシミュレータの数字は使えない。
+///
+/// 条件は android-vectortile の `MarkerTileCostTest` と揃えてある。同じ LCG
+/// シード、同じ緯度経度の分布、同じマーカー数、同じタイルサイズ、同じズーム。
+final class MarkerTileBenchmark: XCTestCase {
+
+ private let tileSize = 512
+
+ /// android 側と同じ線形合同法。定数まで同じなので同じ座標が同じ順で出る。
+ private struct Lcg {
+ private var seed: UInt64 = 12345
+ mutating func next() -> Double {
+ seed = seed &* 6_364_136_223_846_793_005 &+ 1_442_695_040_888_963_407
+ return Double(seed >> 11) / Double(UInt64(1) << 53)
+ }
+ }
+
+ private func manager(_ count: Int) -> MarkerManager {
+ let manager = MarkerManager.defaultManager(minMarkerCount: 1)
+ var lcg = Lcg()
+ for _ in 0.. Double { values.sorted()[values.count / 2] }
+
+ private func tileXY(zoom: Int) -> (Int, Int) {
+ let n = Double(1 << zoom)
+ let lat = 35.68 * .pi / 180
+ return (
+ Int((139.75 + 180.0) / 360.0 * n),
+ Int((1.0 - log(tan(lat) + 1.0 / cos(lat)) / .pi) / 2.0 * n)
+ )
+ }
+
+ /// 街路樹サンプルと同じ 144,183 本。android の `StreetTreeCostTest` と同じズーム。
+ func testStreetTreeScaleOnDevice() {
+ let live = manager(144_183)
+ for declutter in [0, 14] {
+ for zoom in [9, 11, 12, 14] {
+ let (x, y) = tileXY(zoom: zoom)
+ let request = TileRequest(x: x, y: y, z: zoom)
+ let render = MarkerTileRenderer(
+ markerManager: live,
+ tileSize: tileSize,
+ cacheSizeBytes: 8 * 1024 * 1024,
+ declutterPx: declutter
+ )
+ var samples: [Double] = []
+ var bytes = 0
+ for _ in 0..<3 {
+ render.clear()
+ let started = DispatchTime.now().uptimeNanoseconds
+ let data = render.renderTile(request: request)
+ samples.append(Double(DispatchTime.now().uptimeNanoseconds - started) / 1_000_000)
+ bytes = data?.count ?? 0
+ }
+ print(
+ String(
+ format: "DEVICE TREES declutter=%d z=%d render=%.0fms png=%dKB",
+ declutter, zoom, median(samples), bytes / 1024
+ )
+ )
+ }
+ }
+ }
+
+ /// ズームを 1 段変えたときの実コスト。
+ ///
+ /// ズームが変わるとタイルの z が変わるので、画面ぶんのタイルが全部描き直しに
+ /// なる。1 枚の時間だけ見ていると「速い」に見えて、実際の操作が重い理由が
+ /// 分からない。iPad Pro 11 は 834x1194pt = 256pt タイルで 4x5 枚ほど。
+ func testViewportCostPerZoom() {
+ let live = manager(144_183)
+ let across = 4, down = 5
+
+ for zoom in [9, 10, 11, 12, 13, 14] {
+ let (cx, cy) = tileXY(zoom: zoom)
+ let render = MarkerTileRenderer(
+ markerManager: live,
+ tileSize: tileSize,
+ cacheSizeBytes: 8 * 1024 * 1024,
+ // サンプルと同じ帯。zoom > 15 は 1.4、> 13 は 1.0、> 11 は 0.7、以下 0.5。
+ iconScaleCallback: { _, z in
+ let base: Double = z > 15 ? 1.4 : (z > 13 ? 1.0 : (z > 11 ? 0.7 : 0.5))
+ return base * Double(UIScreen.main.scale)
+ },
+ declutterPx: 14
+ )
+ // 2 周する。1 周目はその分離距離に対応する格子の段をまだ持って
+ // いないので、並べ替えのコストを含む。2 周目は格子が温まっていて、
+ // 問い合わせと描画だけが残る。どちらが効いているのか、合計だけ見て
+ // いると分からない。
+ var timings: [Double] = []
+ var drawn = 0
+ for pass in 0..<2 {
+ render.clear()
+ let started = DispatchTime.now().uptimeNanoseconds
+ var count = 0
+ for dx in 0.. Double {
+ seed = seed &* 6_364_136_223_846_793_005 &+ 1_442_695_040_888_963_407
+ return Double(seed >> 11) / Double(UInt64(1) << 53)
+ }
+ }
+
+ private func manager(_ count: Int) -> MarkerManager {
+ let manager = MarkerManager.defaultManager(minMarkerCount: 1)
+ var lcg = Lcg()
+ for _ in 0.. (Int, Int) {
+ let n = Double(1 << zoom)
+ let lat = 35.68 * .pi / 180
+ return (
+ Int((139.75 + 180.0) / 360.0 * n),
+ Int((1.0 - log(tan(lat) + 1.0 / cos(lat)) / .pi) / 2.0 * n)
+ )
+ }
+
+ func testSeamShot() throws {
+ let live = manager(144_183)
+
+ // declutter を切った対照も回す。
+ //
+ // 間引きが無ければタイルごとの判断は入らず、継ぎ目の食い違いは原理的に
+ // 起きない。それでも `cutRuns` が数えるなら、それは物差しの偽陽性 --
+ // アイコンの右端がたまたま境界に落ちた場合で、ピンは横より縦に長いので
+ // 縦の継ぎ目にだけ出る。残った数をどう読むかの基準になる。
+ for (zoom, declutter) in [(11, 14), (12, 14), (13, 14), (11, 0), (12, 0)] {
+ let render = MarkerTileRenderer(
+ markerManager: live,
+ tileSize: tileSize,
+ cacheSizeBytes: 8 * 1024 * 1024,
+ // わざと小さくする。
+ //
+ // サンプルと同じ大きさ(0.7 x 画面倍率)で焼くと、declutter を
+ // 入れてもピンが重なって**タイルが赤一色**になり、1 個が半分に
+ // 切れていても画素に出ない。実際にそれで 1 度見えなかった。
+ // 間引きが効く条件は密度のほうなので、そこは 144,183 本のまま、
+ // 絵だけ疎にする。
+ iconScaleCallback: { _, _ in 0.2 },
+ declutterPx: declutter
+ )
+ let (cx, cy) = tileXY(zoom: zoom)
+
+ let side = tileSize * 2
+ UIGraphicsBeginImageContextWithOptions(CGSize(width: side, height: side), true, 1.0)
+ UIColor.white.setFill()
+ UIRectFill(CGRect(x: 0, y: 0, width: side, height: side))
+ var drawn = 0
+ for dx in 0..<2 {
+ for dy in 0..<2 {
+ guard let data = render.renderTile(
+ request: TileRequest(x: cx + dx, y: cy + dy, z: zoom)
+ ), let tile = UIImage(data: data) else { continue }
+ tile.draw(in: CGRect(
+ x: dx * tileSize, y: dy * tileSize,
+ width: tileSize, height: tileSize
+ ))
+ drawn += 1
+ }
+ }
+ let stitched = UIGraphicsGetImageFromCurrentImageContext()
+ UIGraphicsEndImageContext()
+ XCTAssertEqual(drawn, 4, "z=\(zoom) d=\(declutter) で 4 枚そろわなかった")
+ guard let stitched, let cg = stitched.cgImage else { return XCTFail("貼り合わせに失敗") }
+
+ // 継ぎ目で断ち切られているアイコンを数える。
+ //
+ // またいでいるマーカーが両方のタイルに描かれていれば、境界の左右の
+ // 列はどちらも絵になる。片方だけに描かれていると、片側が絵でもう
+ // 片側が余白になり、**まっすぐな縦の縁**がそこにできる。アイコンの
+ // 右端がたまたま境界に落ちた場合もそうなるが、そちらはピンの丸みに
+ // 沿うので 1 行か 2 行で終わる。4 行以上続いたものだけ数える。
+ let cutRuns = Self.straightEdgeRuns(cg, tileSize: tileSize)
+ print("SEAMSHOT z=\(zoom) declutter=\(declutter) cutRuns=\(cutRuns.vertical)v \(cutRuns.horizontal)h")
+
+ let whole = XCTAttachment(image: stitched)
+ whole.name = "seam-z\(zoom)-d\(declutter)-2x2"
+ whole.lifetime = .keepAlways
+ add(whole)
+
+ // 継ぎ目に沿った帯を丸ごと拡大する。
+ //
+ // 交点まわりだけを切り出すと、継ぎ目の長さの 1 割しか見えない。
+ // z=11 で食い違うのはタイル 1 枚あたり数十個なので、そこに 1 個も
+ // 入らずに「切れていない」と読めてしまう。継ぎ目の全長を細長く
+ // 取って拡大すれば、またいでいるマーカーが一度に並ぶ。
+ let band = 96
+ let scale = 4
+ for (name, crop) in [
+ ("vertical", CGRect(x: tileSize - band / 2, y: tileSize / 2,
+ width: band, height: tileSize)),
+ ("horizontal", CGRect(x: tileSize / 2, y: tileSize - band / 2,
+ width: tileSize, height: band)),
+ ] {
+ guard let piece = cg.cropping(to: crop) else { continue }
+ let size = CGSize(width: crop.width * CGFloat(scale),
+ height: crop.height * CGFloat(scale))
+ UIGraphicsBeginImageContextWithOptions(size, true, 1.0)
+ let context = UIGraphicsGetCurrentContext()
+ context?.interpolationQuality = .none
+ UIImage(cgImage: piece).draw(in: CGRect(origin: .zero, size: size))
+ // 継ぎ目そのものに線を引く。どこが境界かを画の中で示す。
+ context?.setStrokeColor(UIColor.blue.withAlphaComponent(0.5).cgColor)
+ context?.setLineWidth(1.0)
+ if name == "vertical" {
+ context?.move(to: CGPoint(x: size.width / 2, y: 0))
+ context?.addLine(to: CGPoint(x: size.width / 2, y: size.height))
+ } else {
+ context?.move(to: CGPoint(x: 0, y: size.height / 2))
+ context?.addLine(to: CGPoint(x: size.width, y: size.height / 2))
+ }
+ context?.strokePath()
+ let magnified = UIGraphicsGetImageFromCurrentImageContext()
+ UIGraphicsEndImageContext()
+ if let magnified {
+ let shot = XCTAttachment(image: magnified)
+ shot.name = "seam-z\(zoom)-d\(declutter)-\(name)-x\(scale)"
+ shot.lifetime = .keepAlways
+ add(shot)
+ }
+ }
+ }
+ }
+
+ /// 継ぎ目の左右(上下)で絵と余白が分かれている、4 以上の連続。
+ private static func straightEdgeRuns(
+ _ image: CGImage,
+ tileSize: Int
+ ) -> (vertical: Int, horizontal: Int) {
+ let width = image.width
+ let height = image.height
+ var pixels = [UInt8](repeating: 0, count: width * height * 4)
+ guard let context = CGContext(
+ data: &pixels, width: width, height: height,
+ bitsPerComponent: 8, bytesPerRow: width * 4,
+ space: CGColorSpaceCreateDeviceRGB(),
+ bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
+ ) else { return (0, 0) }
+ context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height))
+ // 背景は白で塗ってあるので、絵かどうかは「白でない」で見る。
+ func inked(_ x: Int, _ y: Int) -> Bool {
+ let at = (y * width + x) * 4
+ return pixels[at] > 250 && pixels[at + 1] < 200 || pixels[at + 1] < 200
+ }
+ func runs(_ pairs: [(Bool, Bool)]) -> Int {
+ var total = 0
+ var run = 0
+ for (near, far) in pairs {
+ if near != far {
+ run += 1
+ } else {
+ if run >= 4 { total += 1 }
+ run = 0
+ }
+ }
+ if run >= 4 { total += 1 }
+ return total
+ }
+ let vertical = runs((0.. Double {
+ seed = seed &* 6_364_136_223_846_793_005 &+ 1_442_695_040_888_963_407
+ return Double(seed >> 11) / Double(UInt64(1) << 53)
+ }
+ }
+
+ private func manager(_ count: Int) -> MarkerManager {
+ let manager = MarkerManager.defaultManager(minMarkerCount: 1)
+ var lcg = Lcg()
+ for _ in 0.. GeoRectBounds {
+ func lon(_ tx: Double) -> Double { tx / pow(2.0, Double(z)) * 360.0 - 180.0 }
+ func lat(_ ty: Double) -> Double {
+ let n = Double.pi - 2.0 * Double.pi * ty / pow(2.0, Double(z))
+ return 180.0 / .pi * atan(0.5 * (exp(n) - exp(-n)))
+ }
+ return GeoRectBounds(
+ southWest: GeoPoint(latitude: lat(Double(y) + 1), longitude: lon(Double(x))),
+ northEast: GeoPoint(latitude: lat(Double(y)), longitude: lon(Double(x) + 1))
+ )
+ }
+
+ private func ms(_ body: () -> Void) -> Double {
+ let t = DispatchTime.now().uptimeNanoseconds
+ body()
+ return Double(DispatchTime.now().uptimeNanoseconds - t) / 1_000_000
+ }
+
+ func testPhaseBreakdown() {
+ MarkerTilePhaseTrace.enabled = true
+ defer { MarkerTilePhaseTrace.enabled = false }
+
+ let live = manager(144_183)
+ let scale = Double(UIScreen.main.scale)
+ let tileSize = 256 * max(1, Int(scale))
+
+ for zoom in [9, 11, 12, 13] {
+ let n = Double(1 << zoom)
+ let latRad = 35.68 * Double.pi / 180
+ let x = Int((139.75 + 180.0) / 360.0 * n)
+ let y = Int((1.0 - log(tan(latRad) + 1.0 / cos(latRad)) / .pi) / 2.0 * n)
+
+ let render = MarkerTileRenderer(
+ markerManager: live,
+ tileSize: tileSize,
+ cacheSizeBytes: 8 * 1024 * 1024,
+ iconScaleCallback: { _, z in
+ (z > 15 ? 1.4 : (z > 13 ? 1.0 : (z > 11 ? 0.7 : 0.5))) * scale
+ },
+ declutterPx: 14
+ )
+ // 格子を温めてから測る。構築コストは別の話。
+ render.clear()
+ _ = render.renderTile(request: TileRequest(x: x, y: y, z: zoom))
+
+ let bounds = tileBounds(x: x, y: y, z: zoom)
+ let span = max(bounds.toSpan()?.latitude ?? 0, bounds.toSpan()?.longitude ?? 0)
+ let separation = span * 14.0 / Double(tileSize)
+
+ var queried = 0
+ let queryMs = ms {
+ queried = live.findMarkersInBounds(bounds, minSeparationDegrees: separation).count
+ }
+ render.clear()
+ let totalMs = ms { _ = render.renderTile(request: TileRequest(x: x, y: y, z: zoom)) }
+
+ print(String(
+ format: "PHASES z=%d markers=%d query=%.1fms total=%.1fms rest=%.1fms queryShare=%.0f%%",
+ zoom, queried, queryMs, totalMs, totalMs - queryMs, 100 * queryMs / max(totalMs, 0.001)))
+ }
+ }
+}
diff --git a/samples/MapConductorSampleApp/MapConductorSampleApp.xcodeproj/project.pbxproj b/samples/MapConductorSampleApp/MapConductorSampleApp.xcodeproj/project.pbxproj
index ca744a8..53a01f4 100644
--- a/samples/MapConductorSampleApp/MapConductorSampleApp.xcodeproj/project.pbxproj
+++ b/samples/MapConductorSampleApp/MapConductorSampleApp.xcodeproj/project.pbxproj
@@ -13,22 +13,27 @@
06EEC3A4595051957A53DE13 /* RasterLayerPageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C878252AEEAFC99FEC69A95 /* RasterLayerPageViewModel.swift */; };
081CEE5396A315A9EF3E7A2B /* RasterLayerMapPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01A79993FD9DEFEE0B183916 /* RasterLayerMapPage.swift */; };
0AE7FB160024B8B485EB15EB /* LongdoInfoBubbleUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E37BD0E38E109FB9A88C7FEF /* LongdoInfoBubbleUITests.swift */; };
+ 0BF80A0BAE88ABEDCC8B4C31 /* OfflineMapPageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E5C52D60E6433C2D4D6AE4E /* OfflineMapPageViewModel.swift */; };
0DB0C1C45CD99B44262FF919 /* FitBoundsMapComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7C4718260907A3770D50458 /* FitBoundsMapComponent.swift */; };
0E0AB02ADFBFA812B33DBDEC /* VisibleRegionPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EAF083CC1444EF2343A56F7 /* VisibleRegionPage.swift */; };
0FAC625D001BF5EC93954F1E /* PolylineMapPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BE5BB5D5DB84A8B5E698BB4 /* PolylineMapPage.swift */; };
- 105AB28EF7DC4395C8F056A3 /* MapConductorForMappls in Frameworks */ = {isa = PBXBuildFile; productRef = 54A8FE0DCEC6B7B6686369C5 /* MapConductorForMappls */; };
+ 105AB28EF7DC4395C8F056A3 /* MapConductorVectorTile in Frameworks */ = {isa = PBXBuildFile; productRef = 768288D668B17CED8B34CFA4 /* MapConductorVectorTile */; };
1091E30A026F66E8E1F46EE4 /* MapplsPerfUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04D303901FC0014E31C66ECD /* MapplsPerfUITests.swift */; };
+ 10A64B4BB89CD39C0F820245 /* MarkerTileBenchmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AEC15FFD58F168AA19B3B25 /* MarkerTileBenchmark.swift */; };
10E5AF39816AAE3BD1C04F18 /* GroundImageMapComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 235E67A1859B3CA8F12D37E8 /* GroundImageMapComponent.swift */; };
129EBE1EB46F88C4E4715E1A /* PostOfficeMapComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C36C321C43614D74792FE49 /* PostOfficeMapComponent.swift */; };
15BFFF5AEE90E30A1648BF9B /* MapConductorForTomTom in Frameworks */ = {isa = PBXBuildFile; productRef = 7D730C3AFB56BCACB70D7B43 /* MapConductorForTomTom */; };
17309AC5A47C71C08F82311D /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 313B53E1BB15ED36A24D884A /* ContentView.swift */; };
17B5F2B2856E7710BC987153 /* UISettingsUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D764AEA50006E42D46EF8E42 /* UISettingsUITests.swift */; };
18183758DF7D84609BBC70E5 /* StoreMapComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = B129F8127A4F68D7014EF037 /* StoreMapComponent.swift */; };
- 1AAF7237622937ACB3D6450A /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = CC49320174E28DBBB2DA3E3B /* ZIPFoundation */; };
+ 1AAF7237622937ACB3D6450A /* GoogleMaps in Frameworks */ = {isa = PBXBuildFile; productRef = F67792B83660D03AE335EDCD /* GoogleMaps */; };
1AB418776A864E3F7EA42470 /* GroundImageResources.swift in Sources */ = {isa = PBXBuildFile; fileRef = 029F54738EC2590081CA44A8 /* GroundImageResources.swift */; };
1ECF732832616FF083AC089D /* FlyToPageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 496B5E1E6513458E647DF810 /* FlyToPageViewModel.swift */; };
+ 1FB69D5581C9158D2BDBC553 /* VectorTileMapPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28D584010FF66C5D83365615 /* VectorTileMapPage.swift */; };
+ 21D31B765327548FE097F067 /* VectorStyleMapPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC1FF81A849A6B5ECA35825A /* VectorStyleMapPage.swift */; };
21DEE995EF7CDCC705716720 /* MapConductorForLongdo in Frameworks */ = {isa = PBXBuildFile; productRef = 035F4F1283704F98E8E7C663 /* MapConductorForLongdo */; };
221F14F85019FD4F1742B53A /* PolylineGeodesicPageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D663A887D0AFCDCAF6E4EB7 /* PolylineGeodesicPageViewModel.swift */; };
+ 22DC626CC90E1D75F23AB881 /* SampleBasemap.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAFA15DD2DD5B193E20AABF0 /* SampleBasemap.swift */; };
239206A33F5255AFF2ED073D /* MapDesignMapPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 536A6FD93067C70FAF6FC8CE /* MapDesignMapPage.swift */; };
284B63D3105246B496337FDC /* SampleConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 673785C2388A204D53E08EF9 /* SampleConfig.swift */; };
2B286E8D0C115213181B1916 /* GeoJSONShotUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34F95A4E1917D6BC3F5C0B82 /* GeoJSONShotUITests.swift */; };
@@ -36,14 +41,17 @@
2C31107D1F3D76A5987BC55E /* FlyToMapComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E7322320F385235CECACA81 /* FlyToMapComponent.swift */; };
32CC3376ECDC8BE23CBE1B46 /* ExampleGeoJSONLayerLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCDDCC99B087D3B1329444A /* ExampleGeoJSONLayerLoader.swift */; };
346966A6732A7E80DF7BD137 /* PolygonGeodesicPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A543622B3A557165F7C8E10 /* PolygonGeodesicPage.swift */; };
+ 346F4B10462A8BB74AA696FF /* VectorTileStyleLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5292E6BFF99F6DD91FEBFA8 /* VectorTileStyleLoader.swift */; };
3494CEED6AE66BCBD33AC145 /* HeatmapPageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B454F900084472843036F8CF /* HeatmapPageViewModel.swift */; };
- 34FB7D29521BEE34749D62EF /* GoogleMaps in Frameworks */ = {isa = PBXBuildFile; productRef = F67792B83660D03AE335EDCD /* GoogleMaps */; };
+ 34FB7D29521BEE34749D62EF /* MapConductorMarkerClustering in Frameworks */ = {isa = PBXBuildFile; productRef = 22BC8FB0708860F782AD73DF /* MapConductorMarkerClustering */; };
385A04B7F95CE23B5F6D9043 /* MapConductorForMapLibre in Frameworks */ = {isa = PBXBuildFile; productRef = DE9E4D481C91003D86901307 /* MapConductorForMapLibre */; };
393D3040E362D5FAEC3A6DA9 /* HeatmapMapPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2601B9681BC7350C9B999E5 /* HeatmapMapPage.swift */; };
39BD7B6F805E581E3ECD665D /* ProviderSwitchUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7C711748AE5022E33B5DCC9 /* ProviderSwitchUITests.swift */; };
+ 39FBCAFACC2F25A40EF5847E /* StreetTreeMapComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB56A5FF135F211A3D30C349 /* StreetTreeMapComponent.swift */; };
3DE14E8C2ED828F123AFEC78 /* HeatmapMapComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4169D6811740458CDDA8ED /* HeatmapMapComponent.swift */; };
3FE6EE1DAC0F59D7B38B2957 /* GeoJSONLayerClickUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B733B38F2CA606DE5F3C8A2 /* GeoJSONLayerClickUITests.swift */; };
439ED392E79B767B159AB7A3 /* TiltMapPageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 619427C6B7849A8B98677C4A /* TiltMapPageViewModel.swift */; };
+ 44F778DF5446996858D63345 /* DeviceSeamReproBench.swift in Sources */ = {isa = PBXBuildFile; fileRef = 856AAE95668E25F6AC2E9B27 /* DeviceSeamReproBench.swift */; };
455F6D8DBBAF522E01199F4A /* California.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05DCD88C1178628A7AD754C3 /* California.swift */; };
46F5E8DF912419D9D2787CA9 /* PolylineMapComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = E60581AEC1C00F85895B924E /* PolylineMapComponent.swift */; };
4A74FE88789A45D86D8C60F6 /* PolygonMapComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF5A5CFFEF92694444627765 /* PolygonMapComponent.swift */; };
@@ -54,30 +62,38 @@
4FAA3CD1B1C81E78D5EFD48D /* CircleMapComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3413C5C263D93E79F5C6F79 /* CircleMapComponent.swift */; };
4FC1432266AE069EF90AB666 /* InfoBubbleCompareUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C904FA9CEC6089ECA225050F /* InfoBubbleCompareUITests.swift */; };
52D68B68A3B56440B12DF14E /* StartUpPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21BB4D278EEAEE695E62351E /* StartUpPage.swift */; };
+ 530DC66F44E610CE773BF6EB /* StreetTreeInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 868AFBA60C169F03236D803F /* StreetTreeInfoView.swift */; };
54B5880433557F1FC0C478ED /* PolygonClickPageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8EF3B3111BFBB17B5B1C14F2 /* PolygonClickPageViewModel.swift */; };
55FC9049CBE1797978FE5401 /* VisibleRegionPanelUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CEAB1031FDC359DC73B4A8A /* VisibleRegionPanelUITests.swift */; };
56AAABDA4A3F2F7B7D1BBC8B /* VisibleRegionMapComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47498C58CDEE0E9CF7342A52 /* VisibleRegionMapComponent.swift */; };
57170F6394EBFE012560B267 /* DemoMapPageScaffold.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1D4F852BF6BEE5EB887B62B /* DemoMapPageScaffold.swift */; };
58EDB1BF6B43FC714812ED94 /* SpiderfyUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47EF2B7EE37AC5A382BFA399 /* SpiderfyUITests.swift */; };
5A53C030EE4F908F7FC106C8 /* CameraRestrictionPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55FF87F1B16C72C5140C208F /* CameraRestrictionPage.swift */; };
+ 5CD70B173904DF4D66124FFD /* StreetTreePage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01356819105AD9E20DE6BD30 /* StreetTreePage.swift */; };
5D389D96A80C37356179AEE2 /* DemoData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B3C8F4443CED133798AA355 /* DemoData.swift */; };
5E917641E9ED4BE2C183CB8F /* MarkerBasicMapComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 866556D6922D74C2445D5771 /* MarkerBasicMapComponent.swift */; };
5FB3C13BA06F1482B4661242 /* GeoJSONLayerMapPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6BD26E6526A17508DFDE48 /* GeoJSONLayerMapPage.swift */; };
+ 60674BE8B8679E1D04C5B6F9 /* BearingDirectionUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85CB265EA2D3D8D32BD7ECD9 /* BearingDirectionUITests.swift */; };
6099B1F9204EDECA514773D4 /* InfoBubbleProjectionUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 624CDE3FBAAB41C41BFC73D4 /* InfoBubbleProjectionUITests.swift */; };
+ 6187A15C744C040198327F88 /* OfflineMapUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC72A88197A90EBBF06E6EB0 /* OfflineMapUITests.swift */; };
62B38FB816A90530C5E94533 /* Sidebar.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB437D06DC85F2E05CC7F4B3 /* Sidebar.swift */; };
+ 62ED37D4FB27CF09588D2784 /* StreetTree.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F1A1BF9F75075FE566D9213 /* StreetTree.swift */; };
6318C0A7B64F8D69DE1EEE24 /* FitBoundsMapPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C0BDC9303996CAFA67CCB72 /* FitBoundsMapPage.swift */; };
631CDE728E8CCC6DEAAC005C /* CirclePageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0FD3CF585E712FF3B248B0F /* CirclePageViewModel.swift */; };
634EA220584E00AAFD8022F3 /* MapConductorForGoogleMaps in Frameworks */ = {isa = PBXBuildFile; productRef = 06C1DBF8CB687729DF1C807A /* MapConductorForGoogleMaps */; };
6BD87DE57C766D0BB384A240 /* CameraRestrictionUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4C6709BFAFDBBE0C3D2504B /* CameraRestrictionUITests.swift */; };
+ 6CD7324BE2F239E67C6FD681 /* StyleUnappliedDiagnosticUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0E61E22DDA8100A97C98CD5 /* StyleUnappliedDiagnosticUITests.swift */; };
6D2317AFDDDAEB77C961EC26 /* MapDesignMapComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85C0AB60B489874EBCF704F5 /* MapDesignMapComponent.swift */; };
- 6F54D000AA0F05FEF5CE7E1A /* MapConductorKML in Frameworks */ = {isa = PBXBuildFile; productRef = D1071D3ED1AFC20892E4FF58 /* MapConductorKML */; };
+ 6F54D000AA0F05FEF5CE7E1A /* MapConductorGeoJSON in Frameworks */ = {isa = PBXBuildFile; productRef = A2893889C0B45F35A8DB0C4B /* MapConductorGeoJSON */; };
6F6897312AA41B79421BA95C /* CameraSyncArcGISUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A243AD91D8C5452AD848138B /* CameraSyncArcGISUITests.swift */; };
70FA687400503741ACC26B5E /* RichContentBubblePage.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF2D8AE048398EAB46BC1455 /* RichContentBubblePage.swift */; };
723CFFDF4FA4EABD8736B916 /* ClickCascadeUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26C59C5A1489A42CCB3E50B2 /* ClickCascadeUITests.swift */; };
729F6A4535B02CE619998235 /* StoreMapPageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DE52221880CD793F1BF0560 /* StoreMapPageViewModel.swift */; };
72BDA654C664E0E9D9567BB1 /* GroundImagePageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6E16C76A9F0BB857BA08951 /* GroundImagePageViewModel.swift */; };
+ 72F682C645C12439E30DD3EC /* VectorTileColourProbeUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F33B9080A8E615D5B6C59F3 /* VectorTileColourProbeUITests.swift */; };
754B2C5F6F912D23720FD7A9 /* MapConductorForMapTiler in Frameworks */ = {isa = PBXBuildFile; productRef = DD849FDD6B93F3EDA511B6F1 /* MapConductorForMapTiler */; };
75B989F4A6C7BC3F59FE580B /* OpenMobileMapsProjectionUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FE393B5A6AB91D05DD2DF77 /* OpenMobileMapsProjectionUITests.swift */; };
+ 75E5E203DE78500F4702558A /* FrameGapProbe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46BBC2F86CA38C49BDBE5331 /* FrameGapProbe.swift */; };
768036852114CA499289CB4D /* MapDesignPageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39DC859AB185EB39E357FBB2 /* MapDesignPageViewModel.swift */; };
77D622AF5E9B3C7036591F63 /* SelectedProviderCarryOverUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B536F77432B5C2C631B09DD1 /* SelectedProviderCarryOverUITests.swift */; };
78099986D8558396904424B8 /* PostOfficeClusterMapPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9636CBE9C7019A27A04C147 /* PostOfficeClusterMapPage.swift */; };
@@ -85,31 +101,45 @@
79AB161A992A0CC73CB58A3B /* ClusterZoomUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB97E9FB4272D0D0BFCFC5AD /* ClusterZoomUITests.swift */; };
7AE25EB485CF3E07C6855302 /* HereHitTestPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32B1FF3E2CC528F12B3F4EAD /* HereHitTestPage.swift */; };
7B21D76122110C43BD9D414F /* RasterHeaderUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D788830F4469D4610C71A869 /* RasterHeaderUITests.swift */; };
+ 7B4A96DC9AA09B83027BF6D2 /* StreetTreeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 547FD0EEDF06C0B7D0B3D8FD /* StreetTreeViewModel.swift */; };
7F051E3CC34D18CFF6850A62 /* InfoBubbleProbeUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B318C64E7510720E289F4EF2 /* InfoBubbleProbeUITests.swift */; };
+ 80B07A0F69F2D0F3F51F550A /* VectorTileCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BDAB81BBFAF629E76FDA3DB /* VectorTileCategory.swift */; };
82F246CF9D7DABFECC0BAB49 /* MultipleBubblesPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D48D5B0D7E2125723C3A9DE /* MultipleBubblesPage.swift */; };
8703219D862CFC787CC2B082 /* PolylinePageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4C15792FC2A80B01CD49E08 /* PolylinePageViewModel.swift */; };
876A63315A19B8E0CBBD8FB9 /* StoreInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 596B67D7F433BEA427C799BF /* StoreInfoView.swift */; };
879866CE6BA654FDBDFE1C3D /* CameraSyncTestPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB0403006AA6C5EB150EABD2 /* CameraSyncTestPage.swift */; };
886D5CA070732881773AC4BD /* MapConductorCore in Frameworks */ = {isa = PBXBuildFile; productRef = 5F868FD6810605D51D8DE0E0 /* MapConductorCore */; };
- 88D5641422025010188F524B /* MapConductorHeatmap in Frameworks */ = {isa = PBXBuildFile; productRef = DF824C7DFDFC731E797795D6 /* MapConductorHeatmap */; };
+ 88D5641422025010188F524B /* MapConductorForMappls in Frameworks */ = {isa = PBXBuildFile; productRef = 54A8FE0DCEC6B7B6686369C5 /* MapConductorForMappls */; };
8B51C0F020F2CFCAAF071D1C /* AnimationMapPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A58F15C0FEEEB504570717B7 /* AnimationMapPage.swift */; };
8C557E443C67FAEFAE20FF01 /* StyleReloadPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FAF468FB00E39057C7B9C96 /* StyleReloadPage.swift */; };
8DD9330782039E90CD911444 /* UISettingsPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F0A048B1B580AC5BE76075 /* UISettingsPage.swift */; };
8F349E5133D9A348DB0F6E85 /* CircleMapPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C5796C83B450E126E53BD5F /* CircleMapPage.swift */; };
8F6DF2FC54B36BC96B6C8BE4 /* MapConductorForOpenMobileMaps in Frameworks */ = {isa = PBXBuildFile; productRef = 296C0CEC881327C4C3BE65BE /* MapConductorForOpenMobileMaps */; };
8FB9E5BE76F30E432DCC8174 /* InfoBubbleCustomUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DE6402C500A3CC965F4FEF9 /* InfoBubbleCustomUITests.swift */; };
+ 92673AD475A21F806C32F989 /* MarkerTileSeamShot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9052AE94917335BDD3624EA3 /* MarkerTileSeamShot.swift */; };
+ 95BD2DCFD63A2F3BE74E16A4 /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = CC49320174E28DBBB2DA3E3B /* ZIPFoundation */; };
+ 96064F3E412831638D577BC9 /* VectorTileLayerUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60EBBC8E9991A85DF7B849B5 /* VectorTileLayerUITests.swift */; };
973E88576456DA1C7FF2B8AF /* FitBoundsPageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E09E3098A7041DD92F4CDA5D /* FitBoundsPageViewModel.swift */; };
+ 99A896846C57C842F1B2EE81 /* StreetTreePinchProbeUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AA1ADBD0DE76D9935760B6 /* StreetTreePinchProbeUITests.swift */; };
+ 9BB7B71550E3E8C9B4F46904 /* TilePhaseProbe.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBBF7706A47ED3E330583956 /* TilePhaseProbe.swift */; };
9BE1985582B254F7E7F9E77F /* MarkerDragUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06F98351A07D308D4D7F2A6E /* MarkerDragUITests.swift */; };
+ 9E4A19C59036F35E137096A3 /* StreetTreeSeamUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0386FAC2B21FCD3DCE2C39E8 /* StreetTreeSeamUITests.swift */; };
A06A7AA7386A13FD0F087D54 /* AnimationMapComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 247ED7633D70326148E9523A /* AnimationMapComponent.swift */; };
+ A2FB8E0145CDFCB61E7FD865 /* StreetTreeShotUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 077703031B4855917A300EF9 /* StreetTreeShotUITests.swift */; };
A902BC97B29AFA4BBBD8310A /* SidebarItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = F23BE35F0F841264AED454DE /* SidebarItem.swift */; };
A92A4D5BB420A460B5E6FED4 /* SampleMapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8281BE72BC47056A7E6108BB /* SampleMapView.swift */; };
A955322BEA38C109B9581248 /* StyleReloadUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F98B052B5A45377554CC9C2D /* StyleReloadUITests.swift */; };
A9C5ACDF37AF9C73F8F32118 /* StyledBubbleUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77220E1AEF5EA1875A979D02 /* StyledBubbleUITests.swift */; };
+ A9C8A1EB33F4415715662530 /* MarkerGpuDrawProbe.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAA484B46FE88ED8E4C6AD9D /* MarkerGpuDrawProbe.swift */; };
AA4893EDEE9ED83024F91791 /* PostOfficeClusterPageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA2AB39A80E3A5C65B944467 /* PostOfficeClusterPageViewModel.swift */; };
- AB3A278E630050C0B69D29C9 /* MapConductorGeoJSON in Frameworks */ = {isa = PBXBuildFile; productRef = A2893889C0B45F35A8DB0C4B /* MapConductorGeoJSON */; };
+ AB3A278E630050C0B69D29C9 /* MapConductorHeatmap in Frameworks */ = {isa = PBXBuildFile; productRef = DF824C7DFDFC731E797795D6 /* MapConductorHeatmap */; };
+ ADE363FE4E267A419E092090 /* OfflineMapPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49DFB98F45A3801887797002 /* OfflineMapPage.swift */; };
B010C23C9F28D88F616EACBC /* ExampleGeoJSONStyler.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5FE164C5622D5ABA920992F /* ExampleGeoJSONStyler.swift */; };
B0A1D3E58CCB8244F30FBBF4 /* A2DBubbleProbeUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73E26429303A139633A520F2 /* A2DBubbleProbeUITests.swift */; };
+ B21DFC8C569F6351C8B02A4C /* VectorTilePageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3408FF1ADAA037809ED455D /* VectorTilePageViewModel.swift */; };
B2B112AA7F88521C3FD10FDD /* HolePolygonUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08101FE49CA383BD346A656C /* HolePolygonUITests.swift */; };
+ B8AB40A99933C155065EE08D /* GeocellKeyProbe.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7481221CBAA00A967FCA103 /* GeocellKeyProbe.swift */; };
+ B958289F668363F06B70FACC /* VectorTileHideProbeUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F4D0572030A80E0F01E7033 /* VectorTileHideProbeUITests.swift */; };
BD2731DF6E02759221CA5C94 /* CameraSyncTestViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C251EA117D0D739F43944D94 /* CameraSyncTestViewModel.swift */; };
BD4622474FEDF89F306D5989 /* MarkerLabelPositionPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E080576F5AF1A485FA1118 /* MarkerLabelPositionPage.swift */; };
BE5D7705F2D71DD9DE900C5D /* PostOfficeDataLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F129A8A5CDCF49717F09F1 /* PostOfficeDataLoader.swift */; };
@@ -119,15 +149,20 @@
CB49E4C305852E171446DA2E /* HeatmapCameraUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA8730F46EA8AA766554BD56 /* HeatmapCameraUITests.swift */; };
CBAF018856AD8D7727B0904C /* MapConductorForMapbox in Frameworks */ = {isa = PBXBuildFile; productRef = 07F84CB727B4D7BFA9E6BBA9 /* MapConductorForMapbox */; };
CC0E3528375293C416C891DB /* PostOfficePage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFA7A17FE65561B4737D7AC2 /* PostOfficePage.swift */; };
+ CD699E97BD31217503210460 /* IconSizeProbe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B0F811D6A05F088725BE6C5 /* IconSizeProbe.swift */; };
CFDB054F9C8EA4B05168DF2F /* TokyoPostOffices.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCE316F0873F733D073DCFB /* TokyoPostOffices.swift */; };
D136C9B97EE353605F49E3B3 /* OpenMobileMapsSmokeUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D85F0AA9AC34D158C685AB38 /* OpenMobileMapsSmokeUITests.swift */; };
D1683E97E10E09AD57B0C7BE /* TiltMapPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB13F476A2A9A93D09A42D9E /* TiltMapPage.swift */; };
D39229103CD9AC99AB00B6EA /* HolePolygonMapPageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3753C91B977BC9754F803F69 /* HolePolygonMapPageViewModel.swift */; };
D5165A402B239CCB97FD2FE1 /* PolylineClickMapComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 372828CAD4DECE4B66C4521D /* PolylineClickMapComponent.swift */; };
D538D79AC73CE6F51FC3C09B /* PostOfficeClusterMapComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71603F4704CD2F7AD581A0D3 /* PostOfficeClusterMapComponent.swift */; };
- D65B9FDDBF8983CFCF3ED2F3 /* MapConductorMarkerClustering in Frameworks */ = {isa = PBXBuildFile; productRef = 22BC8FB0708860F782AD73DF /* MapConductorMarkerClustering */; };
+ D65B9FDDBF8983CFCF3ED2F3 /* MapConductorForHERE in Frameworks */ = {isa = PBXBuildFile; productRef = C8D5A9C0CCED6456B3D81D24 /* MapConductorForHERE */; };
+ D7D3B17C80BFDE04466989E8 /* GpuCpuTileDiff.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5614DFD6D201A9D434F9D86 /* GpuCpuTileDiff.swift */; };
+ D853BBDD0BCDB13F241801E8 /* BasemapBlankUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39A2B3A1524214517D0217C5 /* BasemapBlankUITests.swift */; };
D9EE443DC85D3AE0F4EA3759 /* PostOfficeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AF48ED67015660EDC4DC9ED /* PostOfficeViewModel.swift */; };
+ DBAE340184248F639996F57A /* VectorTileMapComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = CADB9CDA9748B15E03CDD807 /* VectorTileMapComponent.swift */; };
DC8F656F22A74CAE5057E238 /* BasicGeoJSONMapPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E9ACEF15DA4EA6478E15919 /* BasicGeoJSONMapPage.swift */; };
+ DF1A91EF730830E89D7D11F5 /* VectorStyleFreezeUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D99F5C8A1E6ADAA21E653D3 /* VectorStyleFreezeUITests.swift */; };
DF8E0172DD76E2A6300EBD6B /* MapConductorForArcGIS in Frameworks */ = {isa = PBXBuildFile; productRef = A9471523A3BF989FE54B2446 /* MapConductorForArcGIS */; };
E08EBDEDB1603FF34CF6F427 /* RasterHeaderPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7A9E514E0D709DBB2647B86 /* RasterHeaderPage.swift */; };
E1E2A6D2D99982653D935CDC /* PolylineGeodesicPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C777804F8BD63B74CE6ED7A1 /* PolylineGeodesicPage.swift */; };
@@ -145,6 +180,7 @@
F1984AA149912DDD52D4B8B9 /* RasterLayerMapComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 803C0755788E22BF686AB8DB /* RasterLayerMapComponent.swift */; };
F520A5493A78D7D156153507 /* MapConductorForMapKit in Frameworks */ = {isa = PBXBuildFile; productRef = BCE99CF68028363B1329EC64 /* MapConductorForMapKit */; };
F563F6AF3F992969EC8A5B17 /* OverlayClickUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D8F401557D0F3F5D9806A6E /* OverlayClickUITests.swift */; };
+ F6DD3D80E952F991B649CDE0 /* MainThreadHangSampler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E4CCA29CC543135C31B95C3 /* MainThreadHangSampler.swift */; };
F8BD7D7E4AD94A01F07C838D /* DemoAppScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0224D133C8AE0A8F51D465BD /* DemoAppScreen.swift */; };
F8D38831B9AFBC8150987736 /* GroundImageMapPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33936F78F0FA083376362231 /* GroundImageMapPage.swift */; };
F9CDC7F7EDE828E44973B26E /* PolygonGeodesicPageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892A43B49256809B228489B8 /* PolygonGeodesicPageViewModel.swift */; };
@@ -152,10 +188,17 @@
FACC823631EE4D95873171EE /* LongdoClickDiagnosticUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D1037C9DFD86ADDCF3863DC /* LongdoClickDiagnosticUITests.swift */; };
FC61029EEAAF0FDBD4A5649F /* PolygonMapPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8FDE7D6165092E3147E09F58 /* PolygonMapPage.swift */; };
FD16826298AAADD228089FA1 /* VisibleRegionUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC34113CB0D007B466026CEF /* VisibleRegionUITests.swift */; };
- FE7DB5E571C7BD47A5729A21 /* MapConductorForHERE in Frameworks */ = {isa = PBXBuildFile; productRef = C8D5A9C0CCED6456B3D81D24 /* MapConductorForHERE */; };
+ FE7DB5E571C7BD47A5729A21 /* MapConductorKML in Frameworks */ = {isa = PBXBuildFile; productRef = D1071D3ED1AFC20892E4FF58 /* MapConductorKML */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
+ D80799CC0DEB0301CEE8524D /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 027B56AE645A6E8D32E682FE /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = 59602D30B1131DB7A9B05787;
+ remoteInfo = MapConductorSampleApp;
+ };
DB8AC8FE011D53CD1E93DE96 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 027B56AE645A6E8D32E682FE /* Project object */;
@@ -166,59 +209,76 @@
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
+ 01356819105AD9E20DE6BD30 /* StreetTreePage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreetTreePage.swift; sourceTree = ""; };
01A79993FD9DEFEE0B183916 /* RasterLayerMapPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RasterLayerMapPage.swift; sourceTree = ""; };
0224D133C8AE0A8F51D465BD /* DemoAppScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoAppScreen.swift; sourceTree = ""; };
029F54738EC2590081CA44A8 /* GroundImageResources.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroundImageResources.swift; sourceTree = ""; };
+ 0386FAC2B21FCD3DCE2C39E8 /* StreetTreeSeamUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreetTreeSeamUITests.swift; sourceTree = ""; };
04D303901FC0014E31C66ECD /* MapplsPerfUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapplsPerfUITests.swift; sourceTree = ""; };
05DCD88C1178628A7AD754C3 /* California.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = California.swift; sourceTree = ""; };
06F98351A07D308D4D7F2A6E /* MarkerDragUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkerDragUITests.swift; sourceTree = ""; };
+ 077703031B4855917A300EF9 /* StreetTreeShotUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreetTreeShotUITests.swift; sourceTree = ""; };
07E56B15B236BE92BD93461D /* Secrets.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Secrets.xcconfig; sourceTree = ""; };
08101FE49CA383BD346A656C /* HolePolygonUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HolePolygonUITests.swift; sourceTree = ""; };
0B2B7B69B3FA403C6F675D8F /* ios-for-maplibre */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "ios-for-maplibre"; path = "../../ios-for-maplibre"; sourceTree = SOURCE_ROOT; };
0B93BB101525AB3833FF25C0 /* HeaderRecordingTileServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeaderRecordingTileServer.swift; sourceTree = ""; };
+ 0E4CCA29CC543135C31B95C3 /* MainThreadHangSampler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainThreadHangSampler.swift; sourceTree = ""; };
+ 0F1A1BF9F75075FE566D9213 /* StreetTree.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreetTree.swift; sourceTree = ""; };
115EEADF4253AE3F62E349B7 /* ios-heatmap */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "ios-heatmap"; path = "../../ios-heatmap"; sourceTree = SOURCE_ROOT; };
16FCD4A32E5D0E85AB2E1024 /* SimpleTextBubblePage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleTextBubblePage.swift; sourceTree = ""; };
+ 1AEC15FFD58F168AA19B3B25 /* MarkerTileBenchmark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkerTileBenchmark.swift; sourceTree = ""; };
1B02E9F2ADE08AB6F6A9F0F3 /* NavigationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationViewModel.swift; sourceTree = ""; };
1BE5BB5D5DB84A8B5E698BB4 /* PolylineMapPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PolylineMapPage.swift; sourceTree = ""; };
1E7322320F385235CECACA81 /* FlyToMapComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlyToMapComponent.swift; sourceTree = ""; };
+ 1F4D0572030A80E0F01E7033 /* VectorTileHideProbeUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VectorTileHideProbeUITests.swift; sourceTree = ""; };
21BB4D278EEAEE695E62351E /* StartUpPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartUpPage.swift; sourceTree = ""; };
235E67A1859B3CA8F12D37E8 /* GroundImageMapComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroundImageMapComponent.swift; sourceTree = ""; };
247ED7633D70326148E9523A /* AnimationMapComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimationMapComponent.swift; sourceTree = ""; };
26C59C5A1489A42CCB3E50B2 /* ClickCascadeUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClickCascadeUITests.swift; sourceTree = ""; };
+ 28D584010FF66C5D83365615 /* VectorTileMapPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VectorTileMapPage.swift; sourceTree = ""; };
2A543622B3A557165F7C8E10 /* PolygonGeodesicPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PolygonGeodesicPage.swift; sourceTree = ""; };
2B9266EDD049DA45972FDCE9 /* ios-for-tomtom */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "ios-for-tomtom"; path = "../../ios-for-tomtom"; sourceTree = SOURCE_ROOT; };
2C36C321C43614D74792FE49 /* PostOfficeMapComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostOfficeMapComponent.swift; sourceTree = ""; };
2D663A887D0AFCDCAF6E4EB7 /* PolylineGeodesicPageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PolylineGeodesicPageViewModel.swift; sourceTree = ""; };
+ 2D99F5C8A1E6ADAA21E653D3 /* VectorStyleFreezeUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VectorStyleFreezeUITests.swift; sourceTree = ""; };
2E9ACEF15DA4EA6478E15919 /* BasicGeoJSONMapPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasicGeoJSONMapPage.swift; sourceTree = ""; };
313B53E1BB15ED36A24D884A /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; };
+ 319B64311356F459A8C791D4 /* MapConductorSampleAppBenchmarks.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = MapConductorSampleAppBenchmarks.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
32B1FF3E2CC528F12B3F4EAD /* HereHitTestPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HereHitTestPage.swift; sourceTree = ""; };
33936F78F0FA083376362231 /* GroundImageMapPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroundImageMapPage.swift; sourceTree = ""; };
34F95A4E1917D6BC3F5C0B82 /* GeoJSONShotUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeoJSONShotUITests.swift; sourceTree = ""; };
370DD0AE8450C20221B1E3E6 /* NegativeTiltUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NegativeTiltUITests.swift; sourceTree = ""; };
372828CAD4DECE4B66C4521D /* PolylineClickMapComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PolylineClickMapComponent.swift; sourceTree = ""; };
3753C91B977BC9754F803F69 /* HolePolygonMapPageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HolePolygonMapPageViewModel.swift; sourceTree = ""; };
+ 39A2B3A1524214517D0217C5 /* BasemapBlankUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasemapBlankUITests.swift; sourceTree = ""; };
39DC859AB185EB39E357FBB2 /* MapDesignPageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapDesignPageViewModel.swift; sourceTree = ""; };
3B733B38F2CA606DE5F3C8A2 /* GeoJSONLayerClickUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeoJSONLayerClickUITests.swift; sourceTree = ""; };
3D8F401557D0F3F5D9806A6E /* OverlayClickUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverlayClickUITests.swift; sourceTree = ""; };
3DE52221880CD793F1BF0560 /* StoreMapPageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreMapPageViewModel.swift; sourceTree = ""; };
3FAF468FB00E39057C7B9C96 /* StyleReloadPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StyleReloadPage.swift; sourceTree = ""; };
400B3C529916452091CFBFCE /* KMLLayerMapPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KMLLayerMapPage.swift; sourceTree = ""; };
+ 46BBC2F86CA38C49BDBE5331 /* FrameGapProbe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameGapProbe.swift; sourceTree = ""; };
47498C58CDEE0E9CF7342A52 /* VisibleRegionMapComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisibleRegionMapComponent.swift; sourceTree = ""; };
47EF2B7EE37AC5A382BFA399 /* SpiderfyUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpiderfyUITests.swift; sourceTree = ""; };
496B5E1E6513458E647DF810 /* FlyToPageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlyToPageViewModel.swift; sourceTree = ""; };
+ 49DFB98F45A3801887797002 /* OfflineMapPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfflineMapPage.swift; sourceTree = ""; };
+ 4B0F811D6A05F088725BE6C5 /* IconSizeProbe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconSizeProbe.swift; sourceTree = ""; };
4B5FA1B6F9FF315058B30672 /* ios-kml */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "ios-kml"; path = "../../ios-kml"; sourceTree = SOURCE_ROOT; };
4B8345A4E4E2060505B6AFAF /* FlyToMapPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlyToMapPage.swift; sourceTree = ""; };
4C4169D6811740458CDDA8ED /* HeatmapMapComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeatmapMapComponent.swift; sourceTree = ""; };
4DE6402C500A3CC965F4FEF9 /* InfoBubbleCustomUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoBubbleCustomUITests.swift; sourceTree = ""; };
+ 4F33B9080A8E615D5B6C59F3 /* VectorTileColourProbeUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VectorTileColourProbeUITests.swift; sourceTree = ""; };
4FE393B5A6AB91D05DD2DF77 /* OpenMobileMapsProjectionUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenMobileMapsProjectionUITests.swift; sourceTree = ""; };
51C699EF9F4BAE6967A14536 /* ios-for-mapkit */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "ios-for-mapkit"; path = "../../ios-for-mapkit"; sourceTree = SOURCE_ROOT; };
536A6FD93067C70FAF6FC8CE /* MapDesignMapPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapDesignMapPage.swift; sourceTree = ""; };
539010E48B6C8FEF66E0F280 /* LargeMarkerSwitchUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeMarkerSwitchUITests.swift; sourceTree = ""; };
+ 547FD0EEDF06C0B7D0B3D8FD /* StreetTreeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreetTreeViewModel.swift; sourceTree = ""; };
55FF87F1B16C72C5140C208F /* CameraRestrictionPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraRestrictionPage.swift; sourceTree = ""; };
5675E69177526509D92883B2 /* ios-for-arcgis */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "ios-for-arcgis"; path = "../../ios-for-arcgis"; sourceTree = SOURCE_ROOT; };
5828B2F881475BCBDAA22B88 /* StyledInfoBubblePage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StyledInfoBubblePage.swift; sourceTree = ""; };
596B67D7F433BEA427C799BF /* StoreInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreInfoView.swift; sourceTree = ""; };
59A41E8801182242AA851AD7 /* ios-for-openmobilemaps */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "ios-for-openmobilemaps"; path = "../../ios-for-openmobilemaps"; sourceTree = SOURCE_ROOT; };
+ 60EBBC8E9991A85DF7B849B5 /* VectorTileLayerUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VectorTileLayerUITests.swift; sourceTree = ""; };
619427C6B7849A8B98677C4A /* TiltMapPageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TiltMapPageViewModel.swift; sourceTree = ""; };
624CDE3FBAAB41C41BFC73D4 /* InfoBubbleProjectionUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoBubbleProjectionUITests.swift; sourceTree = ""; };
635B5B791B73E89988420538 /* PolylineClickPageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PolylineClickPageViewModel.swift; sourceTree = ""; };
@@ -235,33 +295,43 @@
7EE360DCB2F3547A51FBB17A /* ios-geojson */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "ios-geojson"; path = "../../ios-geojson"; sourceTree = SOURCE_ROOT; };
803C0755788E22BF686AB8DB /* RasterLayerMapComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RasterLayerMapComponent.swift; sourceTree = ""; };
8281BE72BC47056A7E6108BB /* SampleMapView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SampleMapView.swift; sourceTree = ""; };
+ 856AAE95668E25F6AC2E9B27 /* DeviceSeamReproBench.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceSeamReproBench.swift; sourceTree = ""; };
85C0AB60B489874EBCF704F5 /* MapDesignMapComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapDesignMapComponent.swift; sourceTree = ""; };
+ 85CB265EA2D3D8D32BD7ECD9 /* BearingDirectionUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BearingDirectionUITests.swift; sourceTree = ""; };
866556D6922D74C2445D5771 /* MarkerBasicMapComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkerBasicMapComponent.swift; sourceTree = ""; };
+ 868AFBA60C169F03236D803F /* StreetTreeInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreetTreeInfoView.swift; sourceTree = ""; };
892A43B49256809B228489B8 /* PolygonGeodesicPageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PolygonGeodesicPageViewModel.swift; sourceTree = ""; };
8AF48ED67015660EDC4DC9ED /* PostOfficeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostOfficeViewModel.swift; sourceTree = ""; };
8B3C8F4443CED133798AA355 /* DemoData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoData.swift; sourceTree = ""; };
8BC61F8A5C2B9C5876A0F036 /* ios-for-here */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "ios-for-here"; path = "../../ios-for-here"; sourceTree = SOURCE_ROOT; };
+ 8BDAB81BBFAF629E76FDA3DB /* VectorTileCategory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VectorTileCategory.swift; sourceTree = ""; };
8C5796C83B450E126E53BD5F /* CircleMapPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CircleMapPage.swift; sourceTree = ""; };
8C93487E79A349AC12446EDA /* MapLibreTiltZoomUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapLibreTiltZoomUITests.swift; sourceTree = ""; };
8D1037C9DFD86ADDCF3863DC /* LongdoClickDiagnosticUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LongdoClickDiagnosticUITests.swift; sourceTree = ""; };
8D48D5B0D7E2125723C3A9DE /* MultipleBubblesPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultipleBubblesPage.swift; sourceTree = ""; };
+ 8E5C52D60E6433C2D4D6AE4E /* OfflineMapPageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfflineMapPageViewModel.swift; sourceTree = ""; };
8EF3B3111BFBB17B5B1C14F2 /* PolygonClickPageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PolygonClickPageViewModel.swift; sourceTree = ""; };
8FDE7D6165092E3147E09F58 /* PolygonMapPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PolygonMapPage.swift; sourceTree = ""; };
+ 9052AE94917335BDD3624EA3 /* MarkerTileSeamShot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkerTileSeamShot.swift; sourceTree = ""; };
91F0A048B1B580AC5BE76075 /* UISettingsPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UISettingsPage.swift; sourceTree = ""; };
9C0BDC9303996CAFA67CCB72 /* FitBoundsMapPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FitBoundsMapPage.swift; sourceTree = ""; };
A243AD91D8C5452AD848138B /* CameraSyncArcGISUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraSyncArcGISUITests.swift; sourceTree = ""; };
A58F15C0FEEEB504570717B7 /* AnimationMapPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimationMapPage.swift; sourceTree = ""; };
+ A7481221CBAA00A967FCA103 /* GeocellKeyProbe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeocellKeyProbe.swift; sourceTree = ""; };
A7C78BFE289DDEEABFFDB4D9 /* AnimationPageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimationPageViewModel.swift; sourceTree = ""; };
AA8730F46EA8AA766554BD56 /* HeatmapCameraUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeatmapCameraUITests.swift; sourceTree = ""; };
AC34113CB0D007B466026CEF /* VisibleRegionUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisibleRegionUITests.swift; sourceTree = ""; };
+ AC72A88197A90EBBF06E6EB0 /* OfflineMapUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfflineMapUITests.swift; sourceTree = ""; };
AD9E7DD79ABF1B72A4B13050 /* ios-sdk-core */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "ios-sdk-core"; path = "../../ios-sdk-core"; sourceTree = SOURCE_ROOT; };
AF2D8AE048398EAB46BC1455 /* RichContentBubblePage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RichContentBubblePage.swift; sourceTree = ""; };
B129F8127A4F68D7014EF037 /* StoreMapComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreMapComponent.swift; sourceTree = ""; };
B2FE7D0C3C813CCED5F3853E /* MapConductorSampleApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapConductorSampleApp.swift; sourceTree = ""; };
B318C64E7510720E289F4EF2 /* InfoBubbleProbeUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoBubbleProbeUITests.swift; sourceTree = ""; };
+ B3408FF1ADAA037809ED455D /* VectorTilePageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VectorTilePageViewModel.swift; sourceTree = ""; };
B3413C5C263D93E79F5C6F79 /* CircleMapComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CircleMapComponent.swift; sourceTree = ""; };
B454F900084472843036F8CF /* HeatmapPageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeatmapPageViewModel.swift; sourceTree = ""; };
B536F77432B5C2C631B09DD1 /* SelectedProviderCarryOverUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectedProviderCarryOverUITests.swift; sourceTree = ""; };
+ B5614DFD6D201A9D434F9D86 /* GpuCpuTileDiff.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GpuCpuTileDiff.swift; sourceTree = ""; };
B5FE164C5622D5ABA920992F /* ExampleGeoJSONStyler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExampleGeoJSONStyler.swift; sourceTree = ""; };
BAB49FEC2EA6F72C7A90061E /* StoreMapPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreMapPage.swift; sourceTree = ""; };
BB437D06DC85F2E05CC7F4B3 /* Sidebar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar.swift; sourceTree = ""; };
@@ -274,10 +344,12 @@
C4C6709BFAFDBBE0C3D2504B /* CameraRestrictionUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraRestrictionUITests.swift; sourceTree = ""; };
C5F51605FCA8E08A095FC699 /* ios-for-mapbox */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "ios-for-mapbox"; path = "../../ios-for-mapbox"; sourceTree = SOURCE_ROOT; };
C777804F8BD63B74CE6ED7A1 /* PolylineGeodesicPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PolylineGeodesicPage.swift; sourceTree = ""; };
+ C7D7FF46DB2C3C35373E61A3 /* ios-vectortile */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "ios-vectortile"; path = "../../ios-vectortile"; sourceTree = SOURCE_ROOT; };
C892B05755C2A7666D2F8C00 /* MapConductorSampleApp.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = MapConductorSampleApp.app; sourceTree = BUILT_PRODUCTS_DIR; };
C904FA9CEC6089ECA225050F /* InfoBubbleCompareUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoBubbleCompareUITests.swift; sourceTree = ""; };
CA2AB39A80E3A5C65B944467 /* PostOfficeClusterPageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostOfficeClusterPageViewModel.swift; sourceTree = ""; };
CA87900CF58E0DB99C6A3D44 /* MapConductorSampleAppUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = MapConductorSampleAppUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
+ CADB9CDA9748B15E03CDD807 /* VectorTileMapComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VectorTileMapComponent.swift; sourceTree = ""; };
CB0403006AA6C5EB150EABD2 /* CameraSyncTestPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraSyncTestPage.swift; sourceTree = ""; };
CBB49746B0EAA41AF2083493 /* HolePolygonMapPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HolePolygonMapPage.swift; sourceTree = ""; };
CCB28D67275FF6999E2D5BA3 /* ios-for-googlemaps */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "ios-for-googlemaps"; path = "../../ios-for-googlemaps"; sourceTree = SOURCE_ROOT; };
@@ -293,6 +365,8 @@
DDCE316F0873F733D073DCFB /* TokyoPostOffices.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokyoPostOffices.swift; sourceTree = ""; };
DFA7A17FE65561B4737D7AC2 /* PostOfficePage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostOfficePage.swift; sourceTree = ""; };
E09E3098A7041DD92F4CDA5D /* FitBoundsPageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FitBoundsPageViewModel.swift; sourceTree = ""; };
+ E0E61E22DDA8100A97C98CD5 /* StyleUnappliedDiagnosticUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StyleUnappliedDiagnosticUITests.swift; sourceTree = ""; };
+ E1AA1ADBD0DE76D9935760B6 /* StreetTreePinchProbeUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreetTreePinchProbeUITests.swift; sourceTree = ""; };
E1E080576F5AF1A485FA1118 /* MarkerLabelPositionPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkerLabelPositionPage.swift; sourceTree = ""; };
E2601B9681BC7350C9B999E5 /* HeatmapMapPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeatmapMapPage.swift; sourceTree = ""; };
E2F129A8A5CDCF49717F09F1 /* PostOfficeDataLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostOfficeDataLoader.swift; sourceTree = ""; };
@@ -300,10 +374,14 @@
E47647DA995A6B25B95F3C3A /* PolylineClickMapPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PolylineClickMapPage.swift; sourceTree = ""; };
E49B77569013FC71CE767C76 /* OpenMobileMapsTilingUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenMobileMapsTilingUITests.swift; sourceTree = ""; };
E4C15792FC2A80B01CD49E08 /* PolylinePageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PolylinePageViewModel.swift; sourceTree = ""; };
+ E5292E6BFF99F6DD91FEBFA8 /* VectorTileStyleLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VectorTileStyleLoader.swift; sourceTree = ""; };
E60581AEC1C00F85895B924E /* PolylineMapComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PolylineMapComponent.swift; sourceTree = ""; };
E7C4718260907A3770D50458 /* FitBoundsMapComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FitBoundsMapComponent.swift; sourceTree = ""; };
E905A2A41F668FBF19BC9519 /* PolygonMapPageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PolygonMapPageViewModel.swift; sourceTree = ""; };
+ EAA484B46FE88ED8E4C6AD9D /* MarkerGpuDrawProbe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkerGpuDrawProbe.swift; sourceTree = ""; };
EB13F476A2A9A93D09A42D9E /* TiltMapPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TiltMapPage.swift; sourceTree = ""; };
+ EB56A5FF135F211A3D30C349 /* StreetTreeMapComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreetTreeMapComponent.swift; sourceTree = ""; };
+ EBBF7706A47ED3E330583956 /* TilePhaseProbe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TilePhaseProbe.swift; sourceTree = ""; };
ED5E4FF8CA6CC53779C785BE /* PolygonClickMapPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PolygonClickMapPage.swift; sourceTree = ""; };
F22665DDCF354F5B3FB68973 /* ios-for-mappls */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "ios-for-mappls"; path = "../../ios-for-mappls"; sourceTree = SOURCE_ROOT; };
F23BE35F0F841264AED454DE /* SidebarItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarItem.swift; sourceTree = ""; };
@@ -311,7 +389,9 @@
F7A9E514E0D709DBB2647B86 /* RasterHeaderPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RasterHeaderPage.swift; sourceTree = ""; };
F9636CBE9C7019A27A04C147 /* PostOfficeClusterMapPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostOfficeClusterMapPage.swift; sourceTree = ""; };
F98B052B5A45377554CC9C2D /* StyleReloadUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StyleReloadUITests.swift; sourceTree = ""; };
+ FAFA15DD2DD5B193E20AABF0 /* SampleBasemap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SampleBasemap.swift; sourceTree = ""; };
FB97E9FB4272D0D0BFCFC5AD /* ClusterZoomUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClusterZoomUITests.swift; sourceTree = ""; };
+ FC1FF81A849A6B5ECA35825A /* VectorStyleMapPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VectorStyleMapPage.swift; sourceTree = ""; };
FD6BD26E6526A17508DFDE48 /* GeoJSONLayerMapPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeoJSONLayerMapPage.swift; sourceTree = ""; };
/* End PBXFileReference section */
@@ -330,14 +410,15 @@
754B2C5F6F912D23720FD7A9 /* MapConductorForMapTiler in Frameworks */,
21DEE995EF7CDCC705716720 /* MapConductorForLongdo in Frameworks */,
8F6DF2FC54B36BC96B6C8BE4 /* MapConductorForOpenMobileMaps in Frameworks */,
- 105AB28EF7DC4395C8F056A3 /* MapConductorForMappls in Frameworks */,
- 88D5641422025010188F524B /* MapConductorHeatmap in Frameworks */,
- AB3A278E630050C0B69D29C9 /* MapConductorGeoJSON in Frameworks */,
- 6F54D000AA0F05FEF5CE7E1A /* MapConductorKML in Frameworks */,
- FE7DB5E571C7BD47A5729A21 /* MapConductorForHERE in Frameworks */,
- D65B9FDDBF8983CFCF3ED2F3 /* MapConductorMarkerClustering in Frameworks */,
- 34FB7D29521BEE34749D62EF /* GoogleMaps in Frameworks */,
- 1AAF7237622937ACB3D6450A /* ZIPFoundation in Frameworks */,
+ 105AB28EF7DC4395C8F056A3 /* MapConductorVectorTile in Frameworks */,
+ 88D5641422025010188F524B /* MapConductorForMappls in Frameworks */,
+ AB3A278E630050C0B69D29C9 /* MapConductorHeatmap in Frameworks */,
+ 6F54D000AA0F05FEF5CE7E1A /* MapConductorGeoJSON in Frameworks */,
+ FE7DB5E571C7BD47A5729A21 /* MapConductorKML in Frameworks */,
+ D65B9FDDBF8983CFCF3ED2F3 /* MapConductorForHERE in Frameworks */,
+ 34FB7D29521BEE34749D62EF /* MapConductorMarkerClustering in Frameworks */,
+ 1AAF7237622937ACB3D6450A /* GoogleMaps in Frameworks */,
+ 95BD2DCFD63A2F3BE74E16A4 /* ZIPFoundation in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -382,6 +463,21 @@
path = labelposition;
sourceTree = "";
};
+ 235AB049C99B9DBDDCB6F29D /* Benchmarks */ = {
+ isa = PBXGroup;
+ children = (
+ 856AAE95668E25F6AC2E9B27 /* DeviceSeamReproBench.swift */,
+ A7481221CBAA00A967FCA103 /* GeocellKeyProbe.swift */,
+ B5614DFD6D201A9D434F9D86 /* GpuCpuTileDiff.swift */,
+ 4B0F811D6A05F088725BE6C5 /* IconSizeProbe.swift */,
+ EAA484B46FE88ED8E4C6AD9D /* MarkerGpuDrawProbe.swift */,
+ 1AEC15FFD58F168AA19B3B25 /* MarkerTileBenchmark.swift */,
+ 9052AE94917335BDD3624EA3 /* MarkerTileSeamShot.swift */,
+ EBBF7706A47ED3E330583956 /* TilePhaseProbe.swift */,
+ );
+ path = Benchmarks;
+ sourceTree = "";
+ };
248DAB3F99A5A003037AC88C /* flyto */ = {
isa = PBXGroup;
children = (
@@ -501,9 +597,19 @@
path = fitbounds;
sourceTree = "";
};
+ 66F057D7C8D553DE9D8C0141 /* offlinemap */ = {
+ isa = PBXGroup;
+ children = (
+ 49DFB98F45A3801887797002 /* OfflineMapPage.swift */,
+ 8E5C52D60E6433C2D4D6AE4E /* OfflineMapPageViewModel.swift */,
+ );
+ path = offlinemap;
+ sourceTree = "";
+ };
6C190CD24E1B555B5E63A7D8 = {
isa = PBXGroup;
children = (
+ 235AB049C99B9DBDDCB6F29D /* Benchmarks */,
03FE199E9B83358035F7AE46 /* Config */,
BF6BE4052C3F7CC23A00E56C /* Packages */,
358A74F0CE83FABD33E281FD /* Sources */,
@@ -554,6 +660,7 @@
isa = PBXGroup;
children = (
C892B05755C2A7666D2F8C00 /* MapConductorSampleApp.app */,
+ 319B64311356F459A8C791D4 /* MapConductorSampleAppBenchmarks.xctest */,
CA87900CF58E0DB99C6A3D44 /* MapConductorSampleAppUITests.xctest */,
);
name = Products;
@@ -588,6 +695,14 @@
path = geojson;
sourceTree = "";
};
+ 9568943E0B75FB4BD68274C4 /* vectorstyle */ = {
+ isa = PBXGroup;
+ children = (
+ FC1FF81A849A6B5ECA35825A /* VectorStyleMapPage.swift */,
+ );
+ path = vectorstyle;
+ sourceTree = "";
+ };
9DECACA325A80E44CB913DE4 /* heatmap */ = {
isa = PBXGroup;
children = (
@@ -656,6 +771,7 @@
4B5FA1B6F9FF315058B30672 /* ios-kml */,
7105347887569C04EFF104DE /* ios-marker-clustering */,
AD9E7DD79ABF1B72A4B13050 /* ios-sdk-core */,
+ C7D7FF46DB2C3C35373E61A3 /* ios-vectortile */,
);
name = Packages;
sourceTree = "";
@@ -668,6 +784,7 @@
0AE7BB5036D1C5457AE088BF /* labelposition */,
4372DBEE065AA4AEEA109523 /* postoffice */,
80CD2CD0E2CB85EFB411F92D /* postofficecluster */,
+ EF4DE0DB570900C807D64F05 /* streettree */,
);
path = marker;
sourceTree = "";
@@ -685,14 +802,30 @@
E4D53605FED1779B723AC06D /* kml */,
D58105C4BF3B711107B7106B /* map */,
C157E16E11B5346736CC8A4B /* marker */,
+ 66F057D7C8D553DE9D8C0141 /* offlinemap */,
3D1DA908927B688810F6A817 /* polygon */,
D908CC94D2BBE202CE428CC1 /* polyline */,
ED4CE6C39BE8B20AD43B8184 /* raster */,
DCBAD95D58A11D3E9A0343AC /* startup */,
+ 9568943E0B75FB4BD68274C4 /* vectorstyle */,
+ CD34723A41F15CC1F6DFA271 /* vectortile */,
);
path = pages;
sourceTree = "";
};
+ CD34723A41F15CC1F6DFA271 /* vectortile */ = {
+ isa = PBXGroup;
+ children = (
+ FAFA15DD2DD5B193E20AABF0 /* SampleBasemap.swift */,
+ 8BDAB81BBFAF629E76FDA3DB /* VectorTileCategory.swift */,
+ CADB9CDA9748B15E03CDD807 /* VectorTileMapComponent.swift */,
+ 28D584010FF66C5D83365615 /* VectorTileMapPage.swift */,
+ B3408FF1ADAA037809ED455D /* VectorTilePageViewModel.swift */,
+ E5292E6BFF99F6DD91FEBFA8 /* VectorTileStyleLoader.swift */,
+ );
+ path = vectortile;
+ sourceTree = "";
+ };
D58105C4BF3B711107B7106B /* map */ = {
isa = PBXGroup;
children = (
@@ -761,6 +894,8 @@
isa = PBXGroup;
children = (
73E26429303A139633A520F2 /* A2DBubbleProbeUITests.swift */,
+ 39A2B3A1524214517D0217C5 /* BasemapBlankUITests.swift */,
+ 85CB265EA2D3D8D32BD7ECD9 /* BearingDirectionUITests.swift */,
C4C6709BFAFDBBE0C3D2504B /* CameraRestrictionUITests.swift */,
A243AD91D8C5452AD848138B /* CameraSyncArcGISUITests.swift */,
26C59C5A1489A42CCB3E50B2 /* ClickCascadeUITests.swift */,
@@ -781,6 +916,7 @@
04D303901FC0014E31C66ECD /* MapplsPerfUITests.swift */,
06F98351A07D308D4D7F2A6E /* MarkerDragUITests.swift */,
370DD0AE8450C20221B1E3E6 /* NegativeTiltUITests.swift */,
+ AC72A88197A90EBBF06E6EB0 /* OfflineMapUITests.swift */,
78FB7B1699E13C53108BF653 /* OpenMobileMapsDeviceSweepUITests.swift */,
4FE393B5A6AB91D05DD2DF77 /* OpenMobileMapsProjectionUITests.swift */,
D85F0AA9AC34D158C685AB38 /* OpenMobileMapsSmokeUITests.swift */,
@@ -790,9 +926,17 @@
D788830F4469D4610C71A869 /* RasterHeaderUITests.swift */,
B536F77432B5C2C631B09DD1 /* SelectedProviderCarryOverUITests.swift */,
47EF2B7EE37AC5A382BFA399 /* SpiderfyUITests.swift */,
+ E1AA1ADBD0DE76D9935760B6 /* StreetTreePinchProbeUITests.swift */,
+ 0386FAC2B21FCD3DCE2C39E8 /* StreetTreeSeamUITests.swift */,
+ 077703031B4855917A300EF9 /* StreetTreeShotUITests.swift */,
77220E1AEF5EA1875A979D02 /* StyledBubbleUITests.swift */,
F98B052B5A45377554CC9C2D /* StyleReloadUITests.swift */,
+ E0E61E22DDA8100A97C98CD5 /* StyleUnappliedDiagnosticUITests.swift */,
D764AEA50006E42D46EF8E42 /* UISettingsUITests.swift */,
+ 2D99F5C8A1E6ADAA21E653D3 /* VectorStyleFreezeUITests.swift */,
+ 4F33B9080A8E615D5B6C59F3 /* VectorTileColourProbeUITests.swift */,
+ 1F4D0572030A80E0F01E7033 /* VectorTileHideProbeUITests.swift */,
+ 60EBBC8E9991A85DF7B849B5 /* VectorTileLayerUITests.swift */,
6CEAB1031FDC359DC73B4A8A /* VisibleRegionPanelUITests.swift */,
AC34113CB0D007B466026CEF /* VisibleRegionUITests.swift */,
);
@@ -835,6 +979,18 @@
path = raster;
sourceTree = "";
};
+ EF4DE0DB570900C807D64F05 /* streettree */ = {
+ isa = PBXGroup;
+ children = (
+ 0F1A1BF9F75075FE566D9213 /* StreetTree.swift */,
+ 868AFBA60C169F03236D803F /* StreetTreeInfoView.swift */,
+ EB56A5FF135F211A3D30C349 /* StreetTreeMapComponent.swift */,
+ 01356819105AD9E20DE6BD30 /* StreetTreePage.swift */,
+ 547FD0EEDF06C0B7D0B3D8FD /* StreetTreeViewModel.swift */,
+ );
+ path = streettree;
+ sourceTree = "";
+ };
F081C5FE9538A04A5EE86446 /* geodesic */ = {
isa = PBXGroup;
children = (
@@ -866,7 +1022,9 @@
F4A9A3764B49F69E582A7BDA /* support */ = {
isa = PBXGroup;
children = (
+ 46BBC2F86CA38C49BDBE5331 /* FrameGapProbe.swift */,
0B93BB101525AB3833FF25C0 /* HeaderRecordingTileServer.swift */,
+ 0E4CCA29CC543135C31B95C3 /* MainThreadHangSampler.swift */,
);
path = support;
sourceTree = "