Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 148 additions & 0 deletions Sources/MapConductorCore/overlay/OverlayStacking.swift
Original file line number Diff line number Diff line change
@@ -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
}
126 changes: 126 additions & 0 deletions Tests/MapConductorCoreTests/OverlayStackingTests.swift
Original file line number Diff line number Diff line change
@@ -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<T>(_ 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)))
}
}
Loading