Slide 1

Slide 1 text

1 GeoJSON × SwiftUI Techniques for Beautiful Map Rendering Ryo Tsuzukihashi / ZOZO, Inc. try! Swift Tokyo 2026

Slide 2

Slide 2 text

2 Self Introduction Ryo Tsuzukihashi ZOZO, Inc. — iOS Engineer Lifework: Indie Development 44 Apps Published 470K+ Total Downloads © ZOZO, Inc.

Slide 3

Slide 3 text

3 Travel Memory Map Clip your memorial photos into the municipality shapes you travelled © ZOZO, Inc.

Slide 4

Slide 4 text

4 The Inspiration My wife showed me one social media post © ZOZO, Inc.

Slide 5

Slide 5 text

5 © ZOZO, Inc.

Slide 6

Slide 6 text

6 I want to create this as an app! © ZOZO, Inc.

Slide 7

Slide 7 text

7 Map Drawing with SwiftUI .> MapKit? © ZOZO, Inc.

Slide 8

Slide 8 text

8 MapKit didn't work © ZOZO, Inc.

Slide 9

Slide 9 text

9 What MapKit Alone Can't Do • Great at displaying maps • Clipping photos to municipality shapes isn't supported • No puzzle-like fill effect © ZOZO, Inc.

Slide 10

Slide 10 text

10 First Approach © ZOZO, Inc.

Slide 11

Slide 11 text

11 Back then, I didn't know GeoJSON © ZOZO, Inc.

Slide 12

Slide 12 text

12 Should I prepare images for all 47 prefectures and use SwiftUI mask to clip photos? © ZOZO, Inc.

Slide 13

Slide 13 text

13 © ZOZO, Inc.

Slide 14

Slide 14 text

14 © ZOZO, Inc.

Slide 15

Slide 15 text

15 © ZOZO, Inc.

Slide 16

Slide 16 text

16 Clipping works! … But how do I arrange them on the map? © ZOZO, Inc.

Slide 17

Slide 17 text

17 © ZOZO, Inc.

Slide 18

Slide 18 text

18 © ZOZO, Inc.

Slide 19

Slide 19 text

19 47 × 3 = 141 magic numbers adjusted manually (Endless fine-tuning while watching the Preview) © ZOZO, Inc.

Slide 20

Slide 20 text

20 I managed them because it was just a one-time thing © ZOZO, Inc.

Slide 21

Slide 21 text

21 One Month Later, a user sent me a request… © ZOZO, Inc.

Slide 22

Slide 22 text

22 "I want a Hokkaido map with all 179 municipalities!" © ZOZO, Inc.

Slide 23

Slide 23 text

23 Challenging 179 municipalities with images + SwiftUI mask © ZOZO, Inc.

Slide 24

Slide 24 text

24 Manual image placement — even AI can't help Fine-tuning coordinates requires human visual judgment © ZOZO, Inc.

Slide 25

Slide 25 text

25 30h+ Time spent placing all 179 municipalities © ZOZO, Inc.

Slide 26

Slide 26 text

26 There must be a better, more technical way to solve this easily! © ZOZO, Inc.

Slide 27

Slide 27 text

27 Turning Point © ZOZO, Inc.

Slide 28

Slide 28 text

28 Turning Point — Internal iOS Newsletter ZOZO's iOS team shares an iOS-related newsletter every Wednesday © ZOZO, Inc.

Slide 29

Slide 29 text

29 Turning Point — Internal iOS Newsletter My colleague laplap "Drawing maps with Swift Charts" — Artem Novichkov https:./artemnovichkov.com/blog/drawing-maps-with-swift-charts shared this article © ZOZO, Inc.

Slide 30

Slide 30 text

30 GeoJSON → Drawing Maps with Swift Charts AreaPlot(featureData.coordinates, x: .value("Longitude", \.longitude), y: .value("Latitude", \.latitude), stacking: .unstacked) .foregroundStyle(by: .value("Population", featureData.population)) Source: https:./artemnovichkov.com/blog/drawing-maps-with-swift-charts © ZOZO, Inc.

Slide 31

Slide 31 text

31 By the way, what is GeoJSON? © ZOZO, Inc.

Slide 32

Slide 32 text

32 What is GeoJSON? A JSON format for geographic data (RFC 7946) { "type": "Feature", "geometry": { "type": "Point", "coordinates": [139.7671, 35.6812] }, "properties": { "name": “Tokyo Station" } } © ZOZO, Inc.

Slide 33

Slide 33 text

GeoJSON Types Overview GeoJSON has 6 main types Structural types FeatureCollection Array of Features (all municipalities) Feature A single geographic entity (geometry + properties) Shape types (Geometry) Point LineString Polygon MultiPolygon 33 © ZOZO, Inc.

Slide 34

Slide 34 text

34 GeoJSON Nesting Structure FeatureCollection The collection — the map itself Feature A single geographic entity Geometry + Properties Shape/Coordinates + Attributes/Name © ZOZO, Inc.

Slide 35

Slide 35 text

What is a FeatureCollection? An array of Features. For municipality data, it's "the list of all municipalities" { "type": "FeatureCollection", "features": [ { ... }, ./ municipality 1 { ... }, ./ municipality 2 { ... }, ./ municipality 3 ... ] } The features array contains a Feature per municipality 35 © ZOZO, Inc.

Slide 36

Slide 36 text

What is a Feature? A single geographic entity with "where it is" and "what it is" { "type": "Feature", "geometry": { ... }, ./ where it is (shape data) "properties": { ... } ./ what it is (attribute data) } geometry "Where is it?" — coordinates and shape type properties "What is it?" — name, code, and other attributes 36 © ZOZO, Inc.

Slide 37

Slide 37 text

37 Geometry and Properties Geometry "Where is it?" Shape and coordinate data Properties "What is it?" Attribute and semantic data A meaningful map element emerges only when both are combined. © ZOZO, Inc.

Slide 38

Slide 38 text

Inside Geometry: type and coordinates Geometry has just two fields. Very simple. "geometry": { "type": "Polygon", "coordinates": [ [[139.7, 35.6], [139.8, 35.7], ...] ] } type Determines the shape type coordinates Actual coordinate data (array) 38 © ZOZO, Inc.

Slide 39

Slide 39 text

Geometry Types at a Glance Array nesting depth determines the dimension Depth 1 = Point, Depth 2 = LineString, Depth 3 = Polygon, Depth 4 = MultiPolygon 39 © ZOZO, Inc.

Slide 40

Slide 40 text

40 Gotcha: Coordinate Order [ 138.730, 35.360 ] Longitude / X-axis Latitude / Y-axis © ZOZO, Inc.

Slide 41

Slide 41 text

41 Polygon Closing Rule { "type": "Polygon", "coordinates": [ [ [100.0, 0.0], ← Start [101.0, 0.0], [101.0, 1.0], [100.0, 1.0], [100.0, 0.0] ← End = Start! ] ] } © ZOZO, Inc.

Slide 42

Slide 42 text

Now we understand GeoJSON structure. Can we draw it directly with Swift Charts? It turns out Swift Charts has GeoJSON support. 42 © ZOZO, Inc.

Slide 43

Slide 43 text

43 The Limitations of Swift Charts © ZOZO, Inc.

Slide 44

Slide 44 text

44 Limitations of Swift Charts Pros • Draws clean boundaries with LinePlot Cons • Can't tap individual municipalities • Can't mask photos to geographic shapes © ZOZO, Inc.

Slide 45

Slide 45 text

45 Instead of Charts, why not draw with Path? © ZOZO, Inc.

Slide 46

Slide 46 text

46 Rendering Pipeline Step 1: Parse MKGeoJSONDecoder → MKPolygon → [[CGPoint]] Step 2: Transform Mercator projection + screen coordinate transform Step 3: Draw SwiftUI Path / .mask / .contentShape © ZOZO, Inc.

Slide 47

Slide 47 text

Load GeoJSON file and create municipality data 47 Step 1: Parse © ZOZO, Inc.

Slide 48

Slide 48 text

Loading a massive JSON with over 120,000 lines 48 Step 1: Parse © ZOZO, Inc.

Slide 49

Slide 49 text

49 Step 1: GeoDataProvider @Observable final class GeoDataProvider { var municipalityPolygons: [MunicipalityPolygon] = [] init() { loadData() } private func loadData() { guard let geoJSONURL = Bundle.main.url(forResource: Municipality.geoJSONResource, withExtension: "json"), let data = try? Data(contentsOf: geoJSONURL), let geoJSONObjects = try? MKGeoJSONDecoder().decode(data) else { return } ……… Decoding GeoJSON with MKGeoJSONDecoder © ZOZO, Inc.

Slide 50

Slide 50 text

MKGeoJSONDecoder MapKit's built-in GeoJSON decoder → no manual parsing // Decoding GeoJSON gives an array of MKGeoJSONFeature let objects = try MKGeoJSONDecoder().decode(data) for case let feature as MKGeoJSONFeature in objects { feature.geometry // [MKShape & MKGeoJSONObject] feature.properties // Data? (JSON) } 50 © ZOZO, Inc.

Slide 51

Slide 51 text

Inside MKGeoJSONFeature When decoded, Features become Swift types MKGeoJSONFeature .properties Data? — Municipality name and other JSON .identifier String? — Feature identifier .geometry [MKShape & MKGeoJSONObject] MKPolygon .pointCount Number of coordinate points .getCoordinates() Get coordinate array .coordinate Center point .boundingMapRect Bounding rectangle MKMultiPolygon .polygons [MKPolygon] array Used when islands exist → Extract each MKPolygons via .polygons or 51 © ZOZO, Inc.

Slide 52

Slide 52 text

Looking at the decoded data... Wait... the same municipality name appears multiple times, right? Nagasaki City has 3 Features, Goto City has 10+...? 52 © ZOZO, Inc.

Slide 53

Slide 53 text

One municipality is split across multiple Features Nagasaki = Mainland + Island A + Island B → need to merge 53 © ZOZO, Inc.

Slide 54

Slide 54 text

Grouping by Municipality Iterating MKGeoJSONFeatures and aggregating into a municipality dictionary var municipalityPolygonsDict: [Municipality: [[CGPoint]]] = [:] for case let feature as MKGeoJSONFeature in geoJSONObjects { ……… let polygons = feature.geometry.flatMap { geometry .> [[CGPoint]] in switch geometry { case let polygon as MKPolygon: return polygon.toAllRings() case let multiPolygon as MKMultiPolygon: return multiPolygon.polygons.flatMap { $0.toAllRings() } default: return [] } } municipalityPolygonsDict[municipality]..append(contentsOf: polygons) } 54 © ZOZO, Inc.

Slide 55

Slide 55 text

Grouping by Municipality Aggregating scattered Features into a dictionary keyed by municipality name Before Feature[0]: Goto (mainland) Feature[1]: Nagasaki (mainland) Feature[2]: Goto (island A)... ↓ After ["Goto": [[pt],[pt],...]] ["Nagasaki": [[pt],[pt],...]] ["Sasebo": [[pt],...] ] Result: dict["Goto"] = [[CGPoint]] × 10+ polygons → All data for one municipality is now in one place 55 © ZOZO, Inc.

Slide 56

Slide 56 text

56 Municipality Data Structure /// Polygon data per municipality struct MunicipalityPolygon: Identifiable { let id: String let municipality: Municipality let mkPolygons: [MKPolygon] let polygons: [[CGPoint]] /// Get center from MKPolygon.coordinate average var center: CGPoint { … } /// Union of all MKPolygon boundingMapRects var boundingMapRect: MKMapRect { … } } The type that holds grouping results © ZOZO, Inc.

Slide 57

Slide 57 text

57 JSON → Swift Enum Mapping let municipality = Nagasaki.from(jsonName: cityName) /** “長崎市" → .nagasakiCity “佐世保市" → .sasebo "五島市" → .goto **/ Converting GeoJSON properties to Swift types enables type-safe access and compile-time error detection © ZOZO, Inc.

Slide 58

Slide 58 text

Feature grouping is done! Still, one more thing to consider 58 © ZOZO, Inc.

Slide 59

Slide 59 text

Solved: Feature grouping (previous topic) Aggregated Features with the same municipality name into one municipality = multiple polygons Next issue: Geometry type inside a single Feature Geometry must be either MKPolygon (one shape) or MKMultiPolygon (multiple shapes) 59 © ZOZO, Inc.

Slide 60

Slide 60 text

How are municipalities with remote islands represented? Polygon vs MultiPolygon 60 © ZOZO, Inc.

Slide 61

Slide 61 text

MultiPolygon (a collection of Polygons) Polygon Mainland-only municipality (one closed shape) "type": "Polygon", "coordinates": [ [ [lon,lat], [lon,lat], ... ] ] → One ring = one polygon MultiPolygon Municipality with islands (multiple closed shapes) "type": "MultiPolygon", "coordinates": [ [ [ [lon,lat], ... ] ], ← Mainland [ [ [lon,lat], ... ] ], ← Island A [ [ [lon,lat], ... ] ] ← Island B ] → Multiple rings = multiple polygons vs Goto = MultiPolygon → must check type and extract all polygons 61 © ZOZO, Inc.

Slide 62

Slide 62 text

Step 1 Complete GeoJSON → Swift type conversion is done •Decoded with MKGeoJSONDecoder •MKGeoJSONFeature → get MKPolygon / MKMultiPolygon •Group Features by municipality •Handle Polygon / MultiPolygon type branching •1 municipality = [[CGPoint]] (multiple polygons) Data is ready! But if we draw as-is... 62 © ZOZO, Inc.

Slide 63

Slide 63 text

63 Step 2: Why Projection Matters Simple Mapping North-south compression looks unnatural Mercator Projection Produces the familiar natural shape Since the Earth is a sphere, projection correction is required © ZOZO, Inc.

Slide 64

Slide 64 text

Converting to Screen Coordinates Mercator projection with MKMapPoint → linear mapping to screen coordinates let mapPoint = MKMapPoint( CLLocationCoordinate2D(latitude: lat, longitude: lon) ) // X: relative position in MKMapRect → map to screen width let x = (mapPoint.x - mapRect.origin.x) / mapRect.size.width * bounds.width // Y: same (MKMapPoint has north as smaller, so keep as-is) let y = (mapPoint.y - mapRect.origin.y) / mapRect.size.height * bounds.height 64 © ZOZO, Inc.

Slide 65

Slide 65 text

Converting to Screen Coordinates 65 © ZOZO, Inc.

Slide 66

Slide 66 text

Step 2 Complete Latitude/longitude are now pixel coordinates on screen ✓ Mercator projection with MKMapPoint (corrects Earth's curvature) ✓ Relative position in mapRect → linear mapping to bounds ✓ Lat/lon values are now screen coordinates (x, y) Time to draw! Step 3: Draw 66 © ZOZO, Inc.

Slide 67

Slide 67 text

67 Step 3: Drawing with SwiftUI Path ./ GeoJSONPolygonParser.createPath var path = Path() for polygon in munPolygon.polygons { let points = polygon.map { point in transformCoordinates( lat: point.x, lon: point.y, bounds: drawRect, mapRect: mapRect) } path.addLines(points) path.closeSubpath() } return path Each polygon becomes a subpath → islands in one Path © ZOZO, Inc.

Slide 68

Slide 68 text

68 Step 3: Drawing with SwiftUI Path Each polygon becomes a subpath → islands in one Path © ZOZO, Inc.

Slide 69

Slide 69 text

Parse, Transform, and Draw in a Single View GeoMapView — Generic for all regions 69 © ZOZO, Inc.

Slide 70

Slide 70 text

70 Drawing an Interactive Map struct GeoMapView() @State private var drawRect: CGRect = .zero var body: some View { ZStack { ForEach(provider.municipalityPolygons) { munPolygon in let path = GeoJSONPolygonParser.createPath(for: munPolygon, drawRect: drawRect, mapRect: provider.mapRect) ZStack { path.fill(Color(.green)) path.stroke(Color(.deepGreen), lineWidth: 0.5) }.contentShape(path).onTapGesture {handleTap(...)} } } } } © ZOZO, Inc.

Slide 71

Slide 71 text

Map is drawn. But is that all? Travel Memory Map has a "clip photos to geographic shapes" feature ① Fill Fill a municipality with base color via path.fill → Minimum for a map ② Photo Mask Clip a user's travel photo with a geographic shape mask → Core feature ③ Border Stroke Overlay borders on photos for map-like appearance → Finishing touch Stack these 3 layers with ZStack to complete the "Memory Map" 71 © ZOZO, Inc.

Slide 72

Slide 72 text

72 municipalityShapeView — 3-Layer Structure ZStack { // Layer 1: Base fill path.fill(Color(.omoideGreen)).opacity(0.95) path.stroke(Color(.omoideDeepGreen),lineWidth: 0.5) // Layer 2: Photo mask (only when photo exists) if let data = targetData(municipality: ...) { RemoteImage(url: data.imageURL).mask { path } } // Layer 3: Overlay border on photo path.stroke(Color(.omoideDeepGreen), lineWidth: 0.5) } .contentShape(path) // Tap region ZStack with 3 layers: base → photo mask → border stroke © ZOZO, Inc.

Slide 73

Slide 73 text

Photo Mask Original Photo Square photo Path Shape .mask { path } Masked Result Photo clipped to geographic shape © ZOZO, Inc.

Slide 74

Slide 74 text

74 Photo Mask — .mask { path } RemoteImage(url: data.imageURL.absoluteString, contentMode: .fill) .frame(width: imageSize, height: imageSize) .position(x: bounds.midX, y: bounds.midY) .mask { path } Position at bounding box center and clip with .mask © ZOZO, Inc.

Slide 75

Slide 75 text

75 Overlaying Borders on Photos // After masking the photo, overlay the stroke again path.stroke(Color(.deepGreen), lineWidth: 0.5) Overlaying borders makes municipality boundaries visually clear → map-like appearance © ZOZO, Inc.

Slide 76

Slide 76 text

76 Tap Region Matches the Path Shape .contentShape(path) .onTapGesture { handleTap(municipality: munPolygon.municipality) } We can use complex geographic shapes directly for hit testing © ZOZO, Inc.

Slide 77

Slide 77 text

77 Before → After Image-based GeoJSON + Path Map data 179 PNG images 1 JSON file Positioning Manual (966 lines, 30h+) Automatic (lat/lon) New regions Days ~ weeks Hours Tap detection Image rectangle Path shape AI assistance Not possible (visual work) Code generation works © ZOZO, Inc.

Slide 78

Slide 78 text

From Raw Numbers to a Beautiful Map GeoJSON Raw coordinate numbers Parse [[CGPoint]] Simple Mapping Distorted shape Mercator Natural shape A sequence of coordinate numbers transforms into a beautiful map through parsing and projection 78 © ZOZO, Inc.

Slide 79

Slide 79 text

79 Raw GeoJSON data transforms through SwiftUI into an experience users can touch © ZOZO, Inc.

Slide 80

Slide 80 text

80 Summary GeoJSON × SwiftUI: "Draw Your Own Maps" 1. Parse GeoJSON → [[CGPoint]] 2. Auto-project with MKMapPoint 3. Draw with SwiftUI Path © ZOZO, Inc.

Slide 81

Slide 81 text

81 Transforming coordinate sequences into beautiful maps SwiftUI's potential as a data visualization tool Please try it in your own projects! © ZOZO, Inc.

Slide 82

Slide 82 text

82 Thank you! Ryo Tsuzukihashi / @tsuzuki817 Travel Memory Map — Available on the App Store