Skip to content

Commit f40f11a

Browse files
RD-1307: Add circle layer
1 parent 5668505 commit f40f11a

30 files changed

+1794
-36
lines changed

Examples/Clustering+SwiftUI.swift

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
//
2+
// Clustering+SwiftUI.swift
3+
// MapTilerSDK Examples
4+
//
5+
6+
import SwiftUI
7+
import CoreLocation
8+
import MapTilerSDK
9+
10+
struct ClusteringSwiftUIExample: View {
11+
@State private var mapView = MTMapView(
12+
options: MTMapOptions(center: CLLocationCoordinate2D(latitude: 20, longitude: 0), zoom: 0.3)
13+
)
14+
15+
private let sourceId = "earthquakes"
16+
private let clustersLayerId = "clusters"
17+
private let clusterCountLayerId = "clusterCount"
18+
private let unclusteredLayerId = "unclusteredPoint"
19+
20+
var body: some View {
21+
MTMapViewContainer(map: mapView) {
22+
// Add clustered source via DSL so it exists before layers are added.
23+
MTGeoJSONSource(
24+
identifier: sourceId,
25+
url: URL(string: "https://docs.maptiler.com/sdk-js/assets/earthquakes.geojson")!,
26+
isCluster: true,
27+
clusterMaxZoom: 14,
28+
clusterRadius: 50
29+
)
30+
}
31+
.referenceStyle(.dataviz)
32+
.styleVariant(.dark)
33+
.didInitialize { Task { await setupClustering() } }
34+
}
35+
36+
private func setupClustering() async {
37+
guard let style = mapView.style else { return }
38+
39+
// Cluster bubbles (circles)
40+
let clusters = MTCircleLayer(identifier: clustersLayerId, sourceIdentifier: sourceId)
41+
clusters.strokeColor = .white
42+
clusters.strokeWidth = 2
43+
clusters.pitchAlignment = .viewport
44+
clusters.pitchScale = .map
45+
clusters.filterExpression = MTFilter.clusters()
46+
clusters.color = .expression(MTExpression.step(
47+
input: MTExpression.get(.pointCount),
48+
default: .color(UIColor(hex: "#51bbd6") ?? .systemTeal),
49+
stops: [
50+
(100, .color(UIColor(hex: "#f1f075") ?? .systemYellow)),
51+
(750, .color(UIColor(hex: "#f28cb1") ?? .systemPink))
52+
]
53+
))
54+
clusters.radius = .expression(MTExpression.step(
55+
input: MTExpression.get(.pointCount),
56+
default: .number(20),
57+
stops: [ (100, .number(30)), (750, .number(40)) ]
58+
))
59+
try? await style.addLayer(clusters)
60+
61+
// Cluster counts (symbols)
62+
let count = MTSymbolLayer(identifier: clusterCountLayerId, sourceIdentifier: sourceId)
63+
count.filterExpression = MTFilter.clusters()
64+
count.textField = MTTextToken.pointCountAbbreviated.rawValue
65+
count.textSize = 12
66+
count.textAllowOverlap = true
67+
count.textAnchor = .center
68+
count.textFont = ["DIN Offc Pro Medium", "Arial Unicode MS Bold"]
69+
count.textColor = .white
70+
try? await style.addLayer(count)
71+
72+
// Unclustered points (circles)
73+
let unclustered = MTCircleLayer(identifier: unclusteredLayerId, sourceIdentifier: sourceId)
74+
unclustered.color = .color(UIColor(hex: "#11b4da") ?? .systemBlue)
75+
unclustered.radius = .number(4)
76+
unclustered.strokeColor = .white
77+
unclustered.strokeWidth = 1
78+
unclustered.filterExpression = MTFilter.unclustered()
79+
try? await style.addLayer(unclustered)
80+
}
81+
}

Examples/Clustering+UIKit.swift

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
//
2+
// Clustering+UIKit.swift
3+
// MapTilerSDK Examples
4+
//
5+
6+
import UIKit
7+
import CoreLocation
8+
import MapTilerSDK
9+
10+
final class ClusteringUIKitExampleViewController: UIViewController {
11+
private var mapView: MTMapView!
12+
13+
private let sourceId = "earthquakes"
14+
private let clustersLayerId = "clusters"
15+
private let clusterCountLayerId = "clusterCount"
16+
private let unclusteredLayerId = "unclusteredPoint"
17+
18+
override func viewDidLoad() {
19+
super.viewDidLoad()
20+
21+
let options = MTMapOptions(center: CLLocationCoordinate2D(latitude: 20, longitude: 0), zoom: 0.3)
22+
mapView = MTMapView(frame: view.bounds, options: options, referenceStyle: .dataviz, styleVariant: .dark)
23+
view.addSubview(mapView)
24+
25+
Task { await setupClustering() }
26+
}
27+
28+
private func setupClustering() async {
29+
guard let style = mapView.style else { return }
30+
31+
// Clustered source
32+
let src = MTGeoJSONSource(
33+
identifier: sourceId,
34+
url: URL(string: "https://docs.maptiler.com/sdk-js/assets/earthquakes.geojson")!,
35+
isCluster: true,
36+
clusterMaxZoom: 14,
37+
clusterRadius: 50
38+
)
39+
try? await style.addSource(src)
40+
41+
// Cluster bubbles (circles)
42+
let clusters = MTCircleLayer(identifier: clustersLayerId, sourceIdentifier: sourceId)
43+
clusters.strokeColor = .white
44+
clusters.strokeWidth = 2
45+
clusters.pitchAlignment = .viewport
46+
clusters.pitchScale = .map
47+
clusters.filterExpression = MTFilter.clusters()
48+
clusters.color = .expression(MTExpression.step(
49+
input: MTExpression.get(.pointCount),
50+
default: .color(UIColor(hex: "#51bbd6") ?? .systemTeal),
51+
stops: [
52+
(100, .color(UIColor(hex: "#f1f075") ?? .systemYellow)),
53+
(750, .color(UIColor(hex: "#f28cb1") ?? .systemPink))
54+
]
55+
))
56+
clusters.radius = .expression(MTExpression.step(
57+
input: MTExpression.get(.pointCount),
58+
default: .number(20),
59+
stops: [ (100, .number(30)), (750, .number(40)) ]
60+
))
61+
try? await style.addLayer(clusters)
62+
63+
// Cluster counts (symbols)
64+
let count = MTSymbolLayer(identifier: clusterCountLayerId, sourceIdentifier: sourceId)
65+
count.filterExpression = MTFilter.clusters()
66+
count.textField = MTTextToken.pointCountAbbreviated.rawValue
67+
count.textSize = 12
68+
count.textAllowOverlap = true
69+
count.textAnchor = .center
70+
count.textFont = ["DIN Offc Pro Medium", "Arial Unicode MS Bold"]
71+
count.textColor = .white
72+
try? await style.addLayer(count)
73+
74+
// Unclustered points (circles)
75+
let unclustered = MTCircleLayer(identifier: unclusteredLayerId, sourceIdentifier: sourceId)
76+
unclustered.color = .color(UIColor(hex: "#11b4da") ?? .systemBlue)
77+
unclustered.radius = .number(4)
78+
unclustered.strokeColor = .white
79+
unclustered.strokeWidth = 1
80+
unclustered.filterExpression = MTFilter.unclustered()
81+
try? await style.addLayer(unclustered)
82+
}
83+
}

Sources/MapTilerSDK/Commands/Style/AddLayer.swift

Lines changed: 63 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ package struct AddLayer: MTCommand {
2121
return handleMTLineLayer(layer)
2222
} else if let layer = layer as? MTRasterLayer {
2323
return handleMTRasterLayer(layer)
24+
} else if let layer = layer as? MTCircleLayer {
25+
return handleMTCircleLayer(layer)
2426
}
2527

2628
return emptyReturnValue
@@ -31,46 +33,90 @@ package struct AddLayer: MTCommand {
3133
return emptyReturnValue
3234
}
3335

34-
return "\(MTBridge.mapObject).addLayer(\(layerString));"
36+
let processed = unquoteExpressions(in: layerString)
37+
var js = "\(MTBridge.mapObject).addLayer(\(processed));"
38+
if let filter = layer.initialFilter {
39+
js.append("\n \(MTBridge.mapObject).setFilter('\(layer.identifier)', \(filter.toJS()));")
40+
}
41+
42+
return js
3543
}
3644

3745
private func handleMTSymbolLayer(_ layer: MTSymbolLayer) -> JSString {
3846
guard let layerString: JSString = layer.toJSON() else {
3947
return emptyReturnValue
4048
}
4149

42-
var jsString = ""
43-
50+
// If an icon is provided, load it and then add the layer inside onload callback.
4451
if let icon = layer.icon, let encodedImageString = icon.getEncodedString() {
45-
let iconString = """
52+
return """
4653
var icon\(layer.identifier) = new Image();
47-
icon\(layer.identifier).src = 'data:image/png;base64,\(encodedImageString)';
48-
icon\(layer.identifier).onload = function() {
49-
map.addImage('icon\(layer.identifier)', icon\(layer.identifier))
50-
"""
51-
jsString.append(iconString)
52-
jsString.append("\n ")
54+
icon\(layer.identifier).src = 'data:image/png;base64,\(encodedImageString)';
55+
icon\(layer.identifier).onload = function() {
56+
map.addImage('icon\(layer.identifier)', icon\(layer.identifier));
57+
\(MTBridge.mapObject).addLayer(\(unquoteExpressions(in: layerString)));
58+
};
59+
"""
60+
} else {
61+
// No icon: just add the layer.
62+
return "\(MTBridge.mapObject).addLayer(\(unquoteExpressions(in: layerString)));"
5363
}
54-
55-
jsString.append("\(MTBridge.mapObject).addLayer(\(layerString))")
56-
jsString.append("\n };")
57-
58-
return jsString
5964
}
6065

6166
private func handleMTLineLayer(_ layer: MTLineLayer) -> JSString {
6267
guard let layerString: JSString = layer.toJSON() else {
6368
return emptyReturnValue
6469
}
6570

66-
return "\(MTBridge.mapObject).addLayer(\(layerString));"
71+
let processed = unquoteExpressions(in: layerString)
72+
var js = "\(MTBridge.mapObject).addLayer(\(processed));"
73+
if let filter = layer.initialFilter {
74+
js.append("\n \(MTBridge.mapObject).setFilter('\(layer.identifier)', \(filter.toJS()));")
75+
}
76+
return js
6777
}
6878

6979
private func handleMTRasterLayer(_ layer: MTRasterLayer) -> JSString {
7080
guard let layerString: JSString = layer.toJSON() else {
7181
return emptyReturnValue
7282
}
7383

74-
return "\(MTBridge.mapObject).addLayer(\(layerString));"
84+
return "\(MTBridge.mapObject).addLayer(\(unquoteExpressions(in: layerString)));"
85+
}
86+
87+
private func handleMTCircleLayer(_ layer: MTCircleLayer) -> JSString {
88+
guard let layerString: JSString = layer.toJSON() else {
89+
return emptyReturnValue
90+
}
91+
92+
let processed = unquoteExpressions(in: layerString)
93+
var js = "\(MTBridge.mapObject).addLayer(\(processed));"
94+
if let filter = layer.initialFilter {
95+
js.append("\n \(MTBridge.mapObject).setFilter('\(layer.identifier)', \(filter.toJS()));")
96+
}
97+
return js
7598
}
7699
}
100+
/// Replaces string-encoded expressions with raw JSON arrays.
101+
/// Ensures the style parser reads them as expressions (not strings).
102+
fileprivate func unquoteExpressions(in json: String) -> String {
103+
var s = json
104+
s = s.replacingOccurrences(
105+
of: #"(?s)("filter"\s*:\s*)"(\[.*?\])""#,
106+
with: "$1$2",
107+
options: .regularExpression
108+
)
109+
s = s.replacingOccurrences(
110+
of: #"(?s)("circle-color"\s*:\s*)"(\[.*?\])""#,
111+
with: "$1$2",
112+
options: .regularExpression
113+
)
114+
s = s.replacingOccurrences(
115+
of: #"(?s)("circle-radius"\s*:\s*)"(\[.*?\])""#,
116+
with: "$1$2",
117+
options: .regularExpression
118+
)
119+
// Unescape escaped quotes inside expression arrays (e.g., \"step\" -> "step")
120+
s = s.replacingOccurrences(of: "\\\"", with: "\"")
121+
return s
122+
}

Sources/MapTilerSDK/Commands/Style/AddLayers.swift

Lines changed: 58 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ package struct AddLayers: MTCommand {
2626
jsString.append(handleMTLineLayer(layer))
2727
} else if let layer = layer as? MTRasterLayer {
2828
jsString.append(handleMTRasterLayer(layer))
29+
} else if let layer = layer as? MTCircleLayer {
30+
jsString.append(handleMTCircleLayer(layer))
2931
}
3032
}
3133

@@ -36,40 +38,44 @@ package struct AddLayers: MTCommand {
3638
guard let layerString: JSString = layer.toJSON() else {
3739
return emptyReturnValue
3840
}
41+
let processed = unquoteExpressions(in: layerString)
42+
var js = "\(MTBridge.mapObject).addLayer(\(processed));"
43+
if let filter = layer.initialFilter {
44+
js.append("\n \(MTBridge.mapObject).setFilter('\(layer.identifier)', \(filter.toJS()));")
45+
}
3946

40-
return "\(MTBridge.mapObject).addLayer(\(layerString));"
47+
return js
4148
}
4249

4350
private func handleMTSymbolLayer(_ layer: MTSymbolLayer) -> JSString {
4451
guard let layerString: JSString = layer.toJSON() else {
4552
return emptyReturnValue
4653
}
4754

48-
var jsString = ""
49-
5055
if let icon = layer.icon, let encodedImageString = icon.getEncodedString() {
51-
let iconString = """
56+
return """
5257
var icon\(layer.identifier) = new Image();
53-
icon\(layer.identifier).src = 'data:image/png;base64,\(encodedImageString)';
54-
icon\(layer.identifier).onload = function() {
55-
map.addImage('icon\(layer.identifier)', icon\(layer.identifier))
56-
"""
57-
jsString.append(iconString)
58-
jsString.append("\n ")
58+
icon\(layer.identifier).src = 'data:image/png;base64,\(encodedImageString)';
59+
icon\(layer.identifier).onload = function() {
60+
map.addImage('icon\(layer.identifier)', icon\(layer.identifier));
61+
\(MTBridge.mapObject).addLayer(\(layerString));
62+
};
63+
"""
64+
} else {
65+
return "\(MTBridge.mapObject).addLayer(\(layerString));"
5966
}
60-
61-
jsString.append("\(MTBridge.mapObject).addLayer(\(layerString))")
62-
jsString.append("\n };")
63-
64-
return jsString
6567
}
6668

6769
private func handleMTLineLayer(_ layer: MTLineLayer) -> JSString {
6870
guard let layerString: JSString = layer.toJSON() else {
6971
return emptyReturnValue
7072
}
71-
72-
return "\(MTBridge.mapObject).addLayer(\(layerString));"
73+
let processed = unquoteExpressions(in: layerString)
74+
var js = "\(MTBridge.mapObject).addLayer(\(processed));"
75+
if let filter = layer.initialFilter {
76+
js.append("\n \(MTBridge.mapObject).setFilter('\(layer.identifier)', \(filter.toJS()));")
77+
}
78+
return js
7379
}
7480

7581
private func handleMTRasterLayer(_ layer: MTRasterLayer) -> JSString {
@@ -79,4 +85,39 @@ package struct AddLayers: MTCommand {
7985

8086
return "\(MTBridge.mapObject).addLayer(\(layerString));"
8187
}
88+
89+
private func handleMTCircleLayer(_ layer: MTCircleLayer) -> JSString {
90+
guard let layerString: JSString = layer.toJSON() else {
91+
return emptyReturnValue
92+
}
93+
let processed = unquoteExpressions(in: layerString)
94+
var js = "\(MTBridge.mapObject).addLayer(\(processed));"
95+
if let filter = layer.initialFilter {
96+
js.append("\n \(MTBridge.mapObject).setFilter('\(layer.identifier)', \(filter.toJS()));")
97+
}
98+
return js
99+
}
100+
}
101+
102+
/// Replaces string-encoded expressions with raw JSON arrays.
103+
/// Ensures the style parser reads them as expressions (not strings).
104+
fileprivate func unquoteExpressions(in json: String) -> String {
105+
var s = json
106+
s = s.replacingOccurrences(
107+
of: #"(?s)("filter"\s*:\s*)"(\[.*?\])""#,
108+
with: "$1$2",
109+
options: .regularExpression
110+
)
111+
s = s.replacingOccurrences(
112+
of: #"(?s)("circle-color"\s*:\s*)"(\[.*?\])""#,
113+
with: "$1$2",
114+
options: .regularExpression
115+
)
116+
s = s.replacingOccurrences(
117+
of: #"(?s)("circle-radius"\s*:\s*)"(\[.*?\])""#,
118+
with: "$1$2",
119+
options: .regularExpression
120+
)
121+
s = s.replacingOccurrences(of: "\\\"", with: "\"")
122+
return s
82123
}

0 commit comments

Comments
 (0)