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))) + } +}