From 472a8b273b29565092006ea1e06affb9d4dec84a Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 7 Nov 2026 18:45:50 -0800 Subject: [PATCH 1/2] Clear overlays in the order remove, empty the manager, then post-process GL-style renderers ignore onRemove and redraw everything the manager holds in onPostProcess. Polyline, GroundImage and RasterLayer called onPostProcess before emptying the manager, so clear() redrew every overlay it was meant to remove; Polygon and Circle did not call it at all. All five now run onRemove, manager.clear(), onPostProcess, matching android-sdk-core. OverlayClearOrderTests pins this with a GL-style fake renderer for each kind. --- .../circle/CircleController.swift | 3 + .../groundimage/GroundImageController.swift | 5 +- .../polygon/PolygonController.swift | 3 + .../polyline/PolylineController.swift | 5 +- .../raster/RasterLayerController.swift | 4 +- .../OverlayClearOrderTests.swift | 154 ++++++++++++++++++ 6 files changed, 169 insertions(+), 5 deletions(-) create mode 100644 Tests/MapConductorCoreTests/OverlayClearOrderTests.swift diff --git a/Sources/MapConductorCore/circle/CircleController.swift b/Sources/MapConductorCore/circle/CircleController.swift index 81636cd..c9c7ef6 100644 --- a/Sources/MapConductorCore/circle/CircleController.swift +++ b/Sources/MapConductorCore/circle/CircleController.swift @@ -119,7 +119,10 @@ where Renderer.ActualCircle == ActualCircle { await semaphore.withPermit { let entities = circleManager.allEntities() await renderer.onRemove(data: entities) + // manager を空にしてから onPostProcess を呼ぶ。GL 系の描画担当は remove では何もせず、 + // onPostProcess で manager の全部を描き直すので、逆の順だと clear() 後も地図に残る。 circleManager.clear() + await renderer.onPostProcess() } } diff --git a/Sources/MapConductorCore/groundimage/GroundImageController.swift b/Sources/MapConductorCore/groundimage/GroundImageController.swift index 1a9fcff..d1dcf7b 100644 --- a/Sources/MapConductorCore/groundimage/GroundImageController.swift +++ b/Sources/MapConductorCore/groundimage/GroundImageController.swift @@ -118,9 +118,10 @@ where Renderer.ActualGroundImage == ActualGroundImage { await semaphore.withPermit { let entities = groundImageManager.allEntities() await renderer.onRemove(data: entities) - // android-sdk / react-sdk と同じく clear() でも onPostProcess を呼ぶ。 - await renderer.onPostProcess() + // manager を空にしてから onPostProcess を呼ぶ。GL 系の描画担当は remove では何もせず、 + // onPostProcess で manager の全部を描き直すので、逆の順だと clear() 後も地図に残る。 groundImageManager.clear() + await renderer.onPostProcess() } } diff --git a/Sources/MapConductorCore/polygon/PolygonController.swift b/Sources/MapConductorCore/polygon/PolygonController.swift index 4a795a1..b7fb270 100644 --- a/Sources/MapConductorCore/polygon/PolygonController.swift +++ b/Sources/MapConductorCore/polygon/PolygonController.swift @@ -122,7 +122,10 @@ where Renderer.ActualPolygon == ActualPolygon { await semaphore.withPermit { let entities = polygonManager.allEntities() await renderer.onRemove(data: entities) + // manager を空にしてから onPostProcess を呼ぶ。GL 系の描画担当は remove では何もせず、 + // onPostProcess で manager の全部を描き直すので、逆の順だと clear() 後も地図に残る。 polygonManager.clear() + await renderer.onPostProcess() } } diff --git a/Sources/MapConductorCore/polyline/PolylineController.swift b/Sources/MapConductorCore/polyline/PolylineController.swift index 00e21f6..8ee5bb5 100644 --- a/Sources/MapConductorCore/polyline/PolylineController.swift +++ b/Sources/MapConductorCore/polyline/PolylineController.swift @@ -127,9 +127,10 @@ where Renderer.ActualPolyline == ActualPolyline { let entities = polylineManager.allEntities() await renderer.onRemove(data: entities) if polylineManager.isDestroyed { return } - await renderer.onPostProcess() - if polylineManager.isDestroyed { return } + // manager を空にしてから onPostProcess を呼ぶ。GL 系の描画担当は remove では何もせず、 + // onPostProcess で manager の全部を描き直すので、逆の順だと clear() 後も地図に残る。 polylineManager.clear() + await renderer.onPostProcess() } } diff --git a/Sources/MapConductorCore/raster/RasterLayerController.swift b/Sources/MapConductorCore/raster/RasterLayerController.swift index c5adda8..33e3e12 100644 --- a/Sources/MapConductorCore/raster/RasterLayerController.swift +++ b/Sources/MapConductorCore/raster/RasterLayerController.swift @@ -163,8 +163,10 @@ where Renderer.ActualLayer == ActualLayer { upsertedIds.removeAll() let entities = rasterLayerManager.allEntities() await renderer.onRemove(data: entities) - await renderer.onPostProcess() + // manager を空にしてから onPostProcess を呼ぶ。GL 系の描画担当は remove では何もせず、 + // onPostProcess で manager の全部を描き直すので、逆の順だと clear() 後も地図に残る。 rasterLayerManager.clear() + await renderer.onPostProcess() } } diff --git a/Tests/MapConductorCoreTests/OverlayClearOrderTests.swift b/Tests/MapConductorCoreTests/OverlayClearOrderTests.swift new file mode 100644 index 0000000..a15da6b --- /dev/null +++ b/Tests/MapConductorCoreTests/OverlayClearOrderTests.swift @@ -0,0 +1,154 @@ +import UIKit +import XCTest + +@testable import MapConductorCore + +/// clear() の後、地図に何も残らないこと。 +/// +/// MapLibre / Mapbox / Mappls / MapTiler などの GL 系の描画担当は、remove では何もせず、 +/// onPostProcess で manager の全部を描き直す。ここの偽レンダラも同じ作りにしてあり、 +/// `drawn` は最後の onPostProcess で描いたもの(=地図に見えているもの)を表す。 +/// +/// clear() が manager を空にする前に onPostProcess を呼ぶと、全部を描き直して残る。 +/// 呼ばなければ、前に描いたものが残る。android-sdk の OverlayClearOrderTest と同じ。 +final class OverlayClearOrderTests: XCTestCase { + private let a = GeoPoint(latitude: 35.0, longitude: 139.0) + private let b = GeoPoint(latitude: 35.1, longitude: 139.1) + private let c = GeoPoint(latitude: 35.0, longitude: 139.1) + + // MARK: - Polygon + + private final class PolygonGL: PolygonOverlayRendererProtocol { + let manager: PolygonManager + var drawn: [String] = [] + init(_ manager: PolygonManager) { self.manager = manager } + func onAdd(data: [PolygonOverlayAddParams]) async -> [String?] { data.map { $0.state.id } } + func onChange(data: [PolygonOverlayChangeParams]) async -> [String?] { data.map { $0.current.state.id } } + func onRemove(data: [PolygonEntity]) async {} + func onPostProcess() async { drawn = manager.allEntities().map { $0.state.id }.sorted() } + } + + func testPolygonClearLeavesNothingDrawn() async { + let manager = PolygonManager() + let renderer = PolygonGL(manager) + let controller = PolygonController(polygonManager: manager, renderer: renderer) + await controller.add(data: [ + PolygonState(points: [a, b, c], id: "p1"), + PolygonState(points: [a, b, c], id: "p2"), + ]) + XCTAssertEqual(renderer.drawn, ["p1", "p2"]) + + await controller.clear() + + XCTAssertEqual(renderer.drawn, []) + } + + // MARK: - Polyline + + private final class PolylineGL: PolylineOverlayRendererProtocol { + let manager: PolylineManager + var drawn: [String] = [] + init(_ manager: PolylineManager) { self.manager = manager } + func onAdd(data: [PolylineOverlayAddParams]) async -> [String?] { data.map { $0.state.id } } + func onChange(data: [PolylineOverlayChangeParams]) async -> [String?] { data.map { $0.current.state.id } } + func onRemove(data: [PolylineEntity]) async {} + func onPostProcess() async { drawn = manager.allEntities().map { $0.state.id }.sorted() } + } + + func testPolylineClearLeavesNothingDrawn() async { + let manager = PolylineManager() + let renderer = PolylineGL(manager) + let controller = PolylineController(polylineManager: manager, renderer: renderer) + await controller.add(data: [ + PolylineState(points: [a, b], id: "l1"), + PolylineState(points: [b, c], id: "l2"), + ]) + XCTAssertEqual(renderer.drawn, ["l1", "l2"]) + + await controller.clear() + + XCTAssertEqual(renderer.drawn, []) + } + + // MARK: - Circle + + private final class CircleGL: CircleOverlayRendererProtocol { + let manager: CircleManager + var drawn: [String] = [] + init(_ manager: CircleManager) { self.manager = manager } + func onAdd(data: [CircleOverlayAddParams]) async -> [String?] { data.map { $0.state.id } } + func onChange(data: [CircleOverlayChangeParams]) async -> [String?] { data.map { $0.current.state.id } } + func onRemove(data: [CircleEntity]) async {} + func onPostProcess() async { drawn = manager.allEntities().map { $0.state.id }.sorted() } + } + + func testCircleClearLeavesNothingDrawn() async { + let manager = CircleManager() + let renderer = CircleGL(manager) + let controller = CircleController(circleManager: manager, renderer: renderer) + await controller.add(data: [ + CircleState(center: a, radiusMeters: 100, id: "c1"), + CircleState(center: b, radiusMeters: 50, id: "c2"), + ]) + XCTAssertEqual(renderer.drawn, ["c1", "c2"]) + + await controller.clear() + + XCTAssertEqual(renderer.drawn, []) + } + + // MARK: - GroundImage + + private final class GroundImageGL: GroundImageOverlayRendererProtocol { + let manager: GroundImageManager + var drawn: [String] = [] + init(_ manager: GroundImageManager) { self.manager = manager } + func onAdd(data: [GroundImageOverlayAddParams]) async -> [String?] { data.map { $0.state.id } } + func onChange(data: [GroundImageOverlayChangeParams]) async -> [String?] { data.map { $0.current.state.id } } + func onRemove(data: [GroundImageEntity]) async {} + func onPostProcess() async { drawn = manager.allEntities().map { $0.state.id }.sorted() } + } + + func testGroundImageClearLeavesNothingDrawn() async { + let manager = GroundImageManager() + let renderer = GroundImageGL(manager) + let controller = GroundImageController(groundImageManager: manager, renderer: renderer) + let bounds = GeoRectBounds(southWest: a, northEast: b) + await controller.add(data: [ + GroundImageState(bounds: bounds, image: UIImage(), id: "g1"), + GroundImageState(bounds: bounds, image: UIImage(), id: "g2"), + ]) + XCTAssertEqual(renderer.drawn, ["g1", "g2"]) + + await controller.clear() + + XCTAssertEqual(renderer.drawn, []) + } + + // MARK: - RasterLayer + + private final class RasterGL: RasterLayerOverlayRendererProtocol { + let manager: RasterLayerManager + var drawn: [String] = [] + init(_ manager: RasterLayerManager) { self.manager = manager } + func onAdd(data: [RasterLayerOverlayAddParams]) async -> [String?] { data.map { $0.state.id } } + func onChange(data: [RasterLayerOverlayChangeParams]) async -> [String?] { data.map { $0.current.state.id } } + func onRemove(data: [RasterLayerEntity]) async {} + func onPostProcess() async { drawn = manager.allEntities().map { $0.state.id }.sorted() } + } + + func testRasterLayerClearLeavesNothingDrawn() async { + let manager = RasterLayerManager() + let renderer = RasterGL(manager) + let controller = RasterLayerController(rasterLayerManager: manager, renderer: renderer) + await controller.add(data: [ + RasterLayerState(source: .urlTemplate(template: "https://example.com/a/{z}/{x}/{y}.png"), id: "r1"), + RasterLayerState(source: .urlTemplate(template: "https://example.com/b/{z}/{x}/{y}.png"), id: "r2"), + ]) + XCTAssertEqual(renderer.drawn, ["r1", "r2"]) + + await controller.clear() + + XCTAssertEqual(renderer.drawn, []) + } +} From 0acf5e37fd3b221461bb283b01556a8dfa5b5400 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 7 Nov 2026 20:06:35 -0800 Subject: [PATCH 2/2] Post-process after clearing markers too AbstractMarkerController.clear() emptied the manager without calling onPostProcess. The MapLibre-family marker renderers only drop their per-marker bookkeeping in onRemove and redraw the source from the manager in onPostProcess, so the markers stayed on the map. Clear now ends the same way as the other five overlay kinds, matching android-sdk-core and js-sdk-core. OverlayClearOrderTests gains the marker case. --- .../marker/AbstractMarkerController.swift | 3 ++ .../OverlayClearOrderTests.swift | 40 +++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/Sources/MapConductorCore/marker/AbstractMarkerController.swift b/Sources/MapConductorCore/marker/AbstractMarkerController.swift index f1714b2..285ad15 100644 --- a/Sources/MapConductorCore/marker/AbstractMarkerController.swift +++ b/Sources/MapConductorCore/marker/AbstractMarkerController.swift @@ -349,6 +349,9 @@ open class AbstractMarkerController< await renderer.onRemove(data: entities) markerManager.clear() draggingMarkerIds.removeAll() + // 他の 5 種類と同じく、manager を空にしてから onPostProcess を呼ぶ。GL 系の描画担当は + // onRemove では地図に触らず、onPostProcess で manager の中身から描き直す。 + await renderer.onPostProcess() } } diff --git a/Tests/MapConductorCoreTests/OverlayClearOrderTests.swift b/Tests/MapConductorCoreTests/OverlayClearOrderTests.swift index a15da6b..172339d 100644 --- a/Tests/MapConductorCoreTests/OverlayClearOrderTests.swift +++ b/Tests/MapConductorCoreTests/OverlayClearOrderTests.swift @@ -151,4 +151,44 @@ final class OverlayClearOrderTests: XCTestCase { XCTAssertEqual(renderer.drawn, []) } + + // MARK: - Marker + + /// MapLibre / Mapbox / Mappls / MapTiler のマーカーの描画担当と同じ作り: onRemove は + /// アニメーションやアイコンの記録を捨てるだけで、地図のソースは onPostProcess が描き直す。 + @MainActor + private final class MarkerGL: MarkerOverlayRendererProtocol { + typealias ActualMarker = String + let manager: MarkerManager + var drawn: [String] = [] + var animateStartListener: OnMarkerEventHandler? + var animateEndListener: OnMarkerEventHandler? + init(_ manager: MarkerManager) { self.manager = manager } + func onAdd(data: [MarkerOverlayAddParams]) async -> [String?] { data.map { $0.state.id } } + func onChange(data: [MarkerOverlayChangeParams]) async -> [String?] { data.map { $0.current.state.id } } + func onRemove(data: [MarkerEntity]) async {} + func onAnimate(entity: MarkerEntity) async {} + func onPostProcess() async { drawn = manager.allEntities().map { $0.state.id }.sorted() } + } + + /// `find(position:)` が `fatalError` なので具象サブクラスを用意する。 + private final class MarkerControllerGL: AbstractMarkerController { + override func find(position: GeoPointProtocol) -> MarkerEntity? { nil } + } + + @MainActor + func testMarkerClearLeavesNothingDrawn() async { + let manager = MarkerManager() + let renderer = MarkerGL(manager) + let controller = MarkerControllerGL(markerManager: manager, renderer: renderer) + await controller.add(data: [ + MarkerState(position: a, id: "m1"), + MarkerState(position: b, id: "m2"), + ]) + XCTAssertEqual(renderer.drawn, ["m1", "m2"]) + + await controller.clear() + + XCTAssertEqual(renderer.drawn, []) + } }