From f7bcad733db4f07f52fc89fc3e84162af6c8460a Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 7 Nov 2026 23:35:11 -0800 Subject: [PATCH] Add OverlayBand and OverlayStacking for one overlay stacking order Every provider is to stack overlays, bottom to top, as raster layers < ground images < polygons (and polygon tiles) < circles < polylines < markers (and marker tiles), whatever order they were added or re-sent in (after clearOverlays(), after a design change). zIndex orders within a band; equal keys keep the order they were passed in. Providers had drifted: GL providers appended each layer on top, so a circle re-sent after clearOverlays() covered the polyline; Google and HERE compared raw zIndex across kinds (all 0, so add order decided). OverlayStacking gives the providers one place to compute it: - insertionIndex(for:among:ceiling:) - where a layer goes among the existing ones (nil entries are basemap layers); ceiling keeps rasters under the basemap labels without dropping them below lower overlays. - band(ofStyleLayerId:) - the band of the style layer ids MapLibre, Mapbox, MapTiler and Mappls use (they share the prefixes). - rasterKey(stateId:zIndex:) - the band of a layer that rides the raster path: app rasters, marker tiles (marker-tile-...), and PolygonTiler's mc-polygon-tiles (polygon band) once feat/polygon-tiling lands. - globalZIndex(_:) - one Int32 for SDKs that compare a single number across kinds (Google zIndex, HERE drawOrder). Bands are 4096 apart, zIndex clamped to +-2047: with 1e8-wide bands Google drew a circle over a polyline on the device, as if the values were clamped. @_spi(MapConductorDriver): driver-only, not in the app-facing API (api-surface check unchanged). OverlayStackingTests checks all 720 add orders. Swapping in the old MapLibre insertion (raster below the labels, ground image below the first vector overlay, everything else on top) fails four of its tests. --- .../overlay/OverlayStacking.swift | 148 ++++++++++++++++++ .../OverlayStackingTests.swift | 126 +++++++++++++++ 2 files changed, 274 insertions(+) create mode 100644 Sources/MapConductorCore/overlay/OverlayStacking.swift create mode 100644 Tests/MapConductorCoreTests/OverlayStackingTests.swift diff --git a/Sources/MapConductorCore/overlay/OverlayStacking.swift b/Sources/MapConductorCore/overlay/OverlayStacking.swift new file mode 100644 index 0000000..51f86cd --- /dev/null +++ b/Sources/MapConductorCore/overlay/OverlayStacking.swift @@ -0,0 +1,148 @@ +import Foundation + +/// オーバーレイの重なり順の「帯」。下から順に並ぶ。 +/// +/// すべてのプロバイダ・3 プラットフォームで、オーバーレイは下からこの順に重ねる: +/// +/// 1. ラスターレイヤー(``RasterLayerState``、アプリが足すタイル) +/// 2. グラウンドイメージ +/// 3. ポリゴン(ネイティブのポリゴンと、SDK のポリゴンのタイルレイヤーが同じ帯) +/// 4. サークル +/// 5. ポリライン +/// 6. マーカー(ネイティブのマーカーと、SDK のマーカーのタイルレイヤー・strategy の描画が同じ帯) +/// +/// 追加した順・送り直した順(`clearOverlays()` の後など)・デザイン変更の後に左右されない。 +/// 帯の中は zIndex、同じ zIndex なら渡された順(先に置いたものが下)。 +/// +/// クリックの探索順(``OverlayKind``)とは別軸。 +/// +/// android-sdk-core / js-sdk-core にも同じ並びで置く。 +@_spi(MapConductorDriver) +public enum OverlayBand: Int, CaseIterable, Comparable, Sendable { + case rasterLayer = 0 + case groundImage = 1 + case polygon = 2 + case circle = 3 + case polyline = 4 + case marker = 5 + + public static func < (lhs: OverlayBand, rhs: OverlayBand) -> Bool { lhs.rawValue < rhs.rawValue } +} + +/// 重なり順の比較キー。帯 → zIndex の順に比べる。 +@_spi(MapConductorDriver) +public struct OverlayStackKey: Comparable, Hashable, Sendable { + public let band: OverlayBand + public let zIndex: Int + + public init(_ band: OverlayBand, zIndex: Int = 0) { + self.band = band + self.zIndex = zIndex + } + + public static func < (lhs: OverlayStackKey, rhs: OverlayStackKey) -> Bool { + if lhs.band != rhs.band { return lhs.band < rhs.band } + return lhs.zIndex < rhs.zIndex + } +} + +/// 帯の順に並べるための計算。プロバイダの描画担当が、レイヤを差し込む位置や +/// ネイティブの zIndex を決めるのに使う。 +@_spi(MapConductorDriver) +public enum OverlayStacking { + /// 下から並んだ既存のレイヤ(`nil` はベースマップなど MapConductor 以外のレイヤ)に対して、 + /// `key` のレイヤを差し込む位置(その位置にあるレイヤの**下**に入る。`existing.count` なら一番上)。 + /// + /// - 自分より上に来るべき MapConductor のレイヤ(キーが大きいもの)のうち一番下のものの直下に入る。 + /// キーが同じものの上に入るので、同じキーなら渡された順になる。 + /// - 無ければ一番上。 + /// - `ceiling` を渡すと、それより上には入れない(ラスターを「ベースマップのラベルの下」に + /// 置くのに使う)。ただし自分より下に来るべき MapConductor のレイヤよりは下げない。 + public static func insertionIndex( + for key: OverlayStackKey, + among existing: [OverlayStackKey?], + ceiling: Int? = nil + ) -> Int { + var index = existing.firstIndex { other in + guard let other else { return false } + return key < other + } ?? existing.count + if let ceiling, ceiling < index { + let floor = (existing.lastIndex { other in + guard let other else { return false } + return other <= key + }).map { $0 + 1 } ?? 0 + index = max(ceiling, floor) + } + return index + } + + /// GL スタイル(MapLibre / Mapbox / MapTiler / Mappls)で MapConductor が使うレイヤ ID の帯。 + /// MapConductor のレイヤでなければ `nil`。 + /// + /// 4 プロバイダとも同じ接頭辞を使っている。新しい種類のレイヤを足したらここにも足すこと + /// (足し忘れるとそのレイヤは「ベースマップのレイヤ」扱いになり、帯の計算から外れる)。 + public static func band(ofStyleLayerId id: String) -> OverlayBand? { + for (prefix, band) in styleLayerPrefixes where id.hasPrefix(prefix) { + return band + } + return nil + } + + /// ``band(ofStyleLayerId:)`` の表。長い接頭辞を先に書く(`mapconductor-markers-` より + /// `mapconductor-tile-markers-` が先、など前方一致の取り違えを避ける)。 + public static let styleLayerPrefixes: [(String, OverlayBand)] = [ + // ポリゴンのタイル(feat/polygon-tiling の PolygonTiler)はラスターの経路で載るが、帯はポリゴン。 + ("mapconductor-raster-layer-\(polygonTileStateId)", .polygon), + ("mapconductor-raster-layer-\(markerTileStateIdPrefix)", .marker), + ("mapconductor-raster-layer-", .rasterLayer), + ("mc-gimg-lyr-", .groundImage), + ("mapconductor-polygons-fill-", .polygon), + ("mapconductor-polygons-line-", .polygon), + ("mapconductor-circles-layer-", .circle), + ("mapconductor-polylines-layer-", .polyline), + ("mapconductor-tile-markers-layer-", .marker), + ("mapconductor-cluster-layer-", .marker), + ("mapconductor-markers-layer-", .marker), + ] + + /// ラスターの経路(``RasterLayerState``)で載るレイヤの帯のキー。 + /// + /// アプリのラスターはラスターの帯(zIndex つき)。SDK がラスターの経路で載せる + /// ポリゴンのタイル(id ``polygonTileStateId``)はポリゴンの帯、マーカーのタイル + /// (id が ``markerTileStateIdPrefix`` で始まる)はマーカーの帯。 + public static func rasterKey(stateId: String, zIndex: Int) -> OverlayStackKey { + if stateId == polygonTileStateId { return OverlayStackKey(.polygon) } + if stateId.hasPrefix(markerTileStateIdPrefix) { return OverlayStackKey(.marker) } + return OverlayStackKey(.rasterLayer, zIndex: zIndex) + } + + /// PolygonTiler(feat/polygon-tiling)がラスターの経路で載せるレイヤの id。 + public static let polygonTileStateId = "mc-polygon-tiles" + /// マーカーのタイルをラスターの経路で載せるプロバイダ(ArcGIS / OMM)の id の接頭辞。 + public static let markerTileStateIdPrefix = "marker-tile-" + + /// 種類をまたいで 1 本の数で重なり順を比べる SDK(Google Maps の `zIndex`、HERE の + /// `drawOrder` など)へ渡す値。帯ごとに十分離した区間に zIndex を収める。 + /// + /// zIndex は ±``zIndexSpan`` に丸める(区間からはみ出して隣の帯へ入らないように)。 + public static func globalZIndex(_ key: OverlayStackKey) -> Int32 { + globalZIndex(key, bandStride: bandStride) + } + + /// 帯の幅を指定する版。受け付ける zIndex は ±(`bandStride` / 2 - 1) に丸める。 + /// 大きな値を扱えない SDK(HERE の `drawOrder` など)向け。 + public static func globalZIndex(_ key: OverlayStackKey, bandStride: Int) -> Int32 { + let span = max(bandStride / 2 - 1, 0) + let clamped = min(max(key.zIndex, -span), span) + return Int32(key.band.rawValue * bandStride + clamped) + } + + /// ``globalZIndex(_:)`` で 1 つの帯が使う区間の幅。 + /// + /// 小さく取ってある(6 帯で 0〜約 2.2 万)。SDK によっては大きな zIndex を + /// 内部で丸めるおそれがあるため(Int16 に収まる範囲にとどめる)。 + public static let bandStride = 4_096 + /// ``globalZIndex(_:)`` が受け付ける zIndex の絶対値の上限。これを超える zIndex は丸める。 + public static let zIndexSpan = 2_047 +} diff --git a/Tests/MapConductorCoreTests/OverlayStackingTests.swift b/Tests/MapConductorCoreTests/OverlayStackingTests.swift new file mode 100644 index 0000000..67659c8 --- /dev/null +++ b/Tests/MapConductorCoreTests/OverlayStackingTests.swift @@ -0,0 +1,126 @@ +import XCTest + +@_spi(MapConductorDriver) @testable import MapConductorCore + +/// ``OverlayStacking`` が、追加の順に関わらず +/// ラスター < グラウンドイメージ < ポリゴン < サークル < ポリライン < マーカー に並べること。 +final class OverlayStackingTests: XCTestCase { + private struct Layer: Equatable { + let name: String + let key: OverlayStackKey? + let isLabel: Bool + } + + /// GL スタイルの差し込みを真似る: ベースマップ(背景・道路・ラベル)の上に、 + /// `insertionIndex` の位置へ 1 枚ずつ差し込む。ラスターはラベルを天井にする。 + private func stack(_ adds: [(String, OverlayStackKey)]) -> [Layer] { + var layers: [Layer] = [ + Layer(name: "background", key: nil, isLabel: false), + Layer(name: "roads", key: nil, isLabel: false), + Layer(name: "labels", key: nil, isLabel: true), + ] + for (name, key) in adds { + let ceiling = key.band == .rasterLayer ? layers.firstIndex { $0.isLabel } : nil + let index = OverlayStacking.insertionIndex(for: key, among: layers.map(\.key), ceiling: ceiling) + layers.insert(Layer(name: name, key: key, isLabel: false), at: index) + } + return layers + } + + private let kinds: [(String, OverlayStackKey)] = [ + ("raster", OverlayStackKey(.rasterLayer)), + ("ground", OverlayStackKey(.groundImage)), + ("polygon", OverlayStackKey(.polygon)), + ("circle", OverlayStackKey(.circle)), + ("polyline", OverlayStackKey(.polyline)), + ("marker", OverlayStackKey(.marker)), + ] + + private func permutations(_ items: [T]) -> [[T]] { + guard items.count > 1 else { return [items] } + return items.indices.flatMap { i -> [[T]] in + var rest = items + let head = rest.remove(at: i) + return permutations(rest).map { [head] + $0 } + } + } + + func testEveryAddOrderEndsInBandOrder() { + for order in permutations(kinds) { + let names = stack(order).filter { $0.key != nil }.map(\.name) + XCTAssertEqual(names, ["raster", "ground", "polygon", "circle", "polyline", "marker"], + "added as \(order.map(\.0))") + } + } + + func testRasterStaysUnderTheBasemapLabelsAndTheRestAbove() { + for order in permutations(kinds) { + let names = stack(order).map(\.name) + XCTAssertEqual(names, ["background", "roads", "raster", "labels", "ground", "polygon", "circle", "polyline", "marker"], + "added as \(order.map(\.0))") + } + } + + func testZIndexOrdersWithinABandButNeverAcrossBands() { + let names = stack([ + ("marker-low", OverlayStackKey(.marker, zIndex: -1000)), + ("polygon-high", OverlayStackKey(.polygon, zIndex: 1000)), + ("polygon-low", OverlayStackKey(.polygon, zIndex: -5)), + ("raster-2", OverlayStackKey(.rasterLayer, zIndex: 2)), + ("raster-1", OverlayStackKey(.rasterLayer, zIndex: 1)), + ]).filter { $0.key != nil }.map(\.name) + XCTAssertEqual(names, ["raster-1", "raster-2", "polygon-low", "polygon-high", "marker-low"]) + } + + func testEqualKeysKeepThePassedOrder() { + let names = stack([ + ("g1", OverlayStackKey(.groundImage)), + ("polygon", OverlayStackKey(.polygon)), + ("g2", OverlayStackKey(.groundImage)), + ("g3", OverlayStackKey(.groundImage)), + ]).filter { $0.key != nil }.map(\.name) + XCTAssertEqual(names, ["g1", "g2", "g3", "polygon"]) + } + + func testCeilingNeverPushesBelowLowerOverlays() { + // ラベルより上に既にラスター(zIndex 0)がある状態で、zIndex 1 のラスターを足す。 + let existing: [OverlayStackKey?] = [nil, OverlayStackKey(.rasterLayer, zIndex: 0), nil, OverlayStackKey(.polygon)] + XCTAssertEqual(OverlayStacking.insertionIndex(for: OverlayStackKey(.rasterLayer, zIndex: 1), among: existing, ceiling: 0), 2) + } + + func testStyleLayerIdsMapToTheirBands() { + XCTAssertEqual(OverlayStacking.band(ofStyleLayerId: "mapconductor-raster-layer-RL"), .rasterLayer) + XCTAssertEqual(OverlayStacking.band(ofStyleLayerId: "mc-gimg-lyr-GI"), .groundImage) + XCTAssertEqual(OverlayStacking.band(ofStyleLayerId: "mapconductor-polygons-fill-abc"), .polygon) + XCTAssertEqual(OverlayStacking.band(ofStyleLayerId: "mapconductor-polygons-line-abc"), .polygon) + XCTAssertEqual(OverlayStacking.band(ofStyleLayerId: "mapconductor-circles-layer-abc-stroke"), .circle) + XCTAssertEqual(OverlayStacking.band(ofStyleLayerId: "mapconductor-polylines-layer-abc"), .polyline) + XCTAssertEqual(OverlayStacking.band(ofStyleLayerId: "mapconductor-markers-layer-abc"), .marker) + XCTAssertEqual(OverlayStacking.band(ofStyleLayerId: "mapconductor-tile-markers-layer-abc"), .marker) + XCTAssertEqual(OverlayStacking.band(ofStyleLayerId: "mapconductor-cluster-layer-abc"), .marker) + XCTAssertEqual(OverlayStacking.band(ofStyleLayerId: "mapconductor-raster-layer-mc-polygon-tiles"), .polygon) + XCTAssertNil(OverlayStacking.band(ofStyleLayerId: "road-label")) + XCTAssertNil(OverlayStacking.band(ofStyleLayerId: "mapconductor-raster-source-RL")) + } + + func testRasterPathLayersTakeTheirOwnBand() { + XCTAssertEqual(OverlayStacking.rasterKey(stateId: "RL", zIndex: 3), OverlayStackKey(.rasterLayer, zIndex: 3)) + XCTAssertEqual(OverlayStacking.rasterKey(stateId: "mc-polygon-tiles", zIndex: 0), OverlayStackKey(.polygon)) + XCTAssertEqual(OverlayStacking.rasterKey(stateId: "marker-tile-abc", zIndex: 0), OverlayStackKey(.marker)) + } + + func testGlobalZIndexNeverCrossesBands() { + let extremes = [Int.min, -50_000_000, -1, 0, 1, 50_000_000, Int.max] + for (lower, upper) in zip(OverlayBand.allCases, OverlayBand.allCases.dropFirst()) { + let lowerMax = extremes.map { OverlayStacking.globalZIndex(OverlayStackKey(lower, zIndex: $0)) }.max()! + let upperMin = extremes.map { OverlayStacking.globalZIndex(OverlayStackKey(upper, zIndex: $0)) }.min()! + XCTAssertLessThan(lowerMax, upperMin, "\(lower) vs \(upper)") + } + for (lower, upper) in zip(OverlayBand.allCases, OverlayBand.allCases.dropFirst()) { + XCTAssertLessThan(OverlayStacking.globalZIndex(OverlayStackKey(lower, zIndex: .max), bandStride: 100), + OverlayStacking.globalZIndex(OverlayStackKey(upper, zIndex: .min), bandStride: 100)) + } + XCTAssertLessThan(OverlayStacking.globalZIndex(OverlayStackKey(.polygon, zIndex: 1)), + OverlayStacking.globalZIndex(OverlayStackKey(.polygon, zIndex: 2))) + } +}