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
23 changes: 23 additions & 0 deletions Sources/MapConductorCore/MapLayerItems.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,29 @@ public struct GroundImage: MapOverlayItemProtocol, Identifiable {
self.id = state.id
}

/// 画像を URL で渡す(``GroundImageState/init(bounds:imageUrl:opacity:tileSize:id:extra:onClick:)``)。
public init(
bounds: GeoRectBounds,
imageUrl: URL,
opacity: Double = 0.5,
tileSize: Int = 512,
id: String? = nil,
extra: Any? = nil,
onClick: OnGroundImageEventHandler? = nil
) {
let state = GroundImageState(
bounds: bounds,
imageUrl: imageUrl,
opacity: opacity,
tileSize: tileSize,
id: id,
extra: extra,
onClick: onClick
)
self.state = state
self.id = state.id
}

public func append(to content: inout MapViewContent) {
content.groundImages.append(self)
}
Expand Down
110 changes: 105 additions & 5 deletions Sources/MapConductorCore/groundimage/GroundImageState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,18 @@ public struct GroundImageEvent {

public typealias OnGroundImageEventHandler = (GroundImageEvent) -> Void

/// 地表画像。
///
/// ## URL で渡す
///
/// `GroundImageState(bounds:imageUrl:)` は画像を自分で読み込む(``ImageIcon`` の `imageUrl` と
/// 同じローダー)。読み終えるまで `image` は 1×1 の透明な画像で、読み終えたら `image` に入る
/// (ふだんの `image` の差し替えと同じく描き直される)。
///
/// - `imageUrl` を別の URL にすると、読み終えるまでは今の画像のまま、読み終えたら差し替わる。
/// 読み終える前にもう一度変えたら、古い方の結果は捨てる。
/// - `image` を自分で入れると `imageUrl` は nil に戻る(URL の読み込みより後の指定が勝つ)。
/// - 失敗したときは画像を変えない(ログに URL ごと 1 度だけ出す)。
public final class GroundImageState: ObservableObject, Identifiable, Equatable, Hashable {
public let id: String

Expand All @@ -32,12 +44,26 @@ public final class GroundImageState: ObservableObject, Identifiable, Equatable,
public let mutations = StateMutationSignal()

@Published public var bounds: GeoRectBounds { didSet { mutations.notifyMutated() } }
@Published public var image: UIImage { didSet { mutations.notifyMutated() } }
@Published public var image: UIImage {
didSet {
if !applyingLoadedImage, imageUrl != nil { imageUrl = nil }
mutations.notifyMutated()
}
}
/// 画像を読み込む URL。nil なら `image` をそのまま使う。
@Published public var imageUrl: URL? {
didSet {
if imageUrl != oldValue { loadImageUrl() }
}
}
@Published public var opacity: Double { didSet { mutations.notifyMutated() } }
@Published public var tileSize: Int { didSet { mutations.notifyMutated() } }
@Published public var extra: Any? { didSet { mutations.notifyMutated() } }
@Published public var onClick: OnGroundImageEventHandler? { didSet { mutations.notifyMutated() } }

private var loader: RemoteImageLoader = .shared
private var applyingLoadedImage = false

public init(
bounds: GeoRectBounds,
image: UIImage,
Expand All @@ -49,7 +75,7 @@ public final class GroundImageState: ObservableObject, Identifiable, Equatable,
) {
let resolvedId = id ?? GroundImageState.makeGroundImageId(
bounds: bounds,
image: image,
imageHash: javaHash(image),
opacity: opacity,
tileSize: tileSize,
extra: extra
Expand All @@ -63,6 +89,75 @@ public final class GroundImageState: ObservableObject, Identifiable, Equatable,
self.onClick = onClick
}

/// 画像を URL で渡す。読み終えるまでは透明。`file:` の URL も使える。
public convenience init(
bounds: GeoRectBounds,
imageUrl: URL,
opacity: Double = 1.0,
tileSize: Int = 512,
id: String? = nil,
extra: Any? = nil,
onClick: OnGroundImageEventHandler? = nil
) {
self.init(
bounds: bounds, imageUrl: imageUrl, opacity: opacity, tileSize: tileSize, id: id, extra: extra,
onClick: onClick, loader: .shared
)
}

/// テスト用。ローダーを差し替える。
convenience init(
bounds: GeoRectBounds,
imageUrl: URL,
opacity: Double = 1.0,
tileSize: Int = 512,
id: String? = nil,
extra: Any? = nil,
onClick: OnGroundImageEventHandler? = nil,
loader: RemoteImageLoader
) {
// 決めた id が読み込みの前後で変わらないよう、画像ではなく URL から作る。
let resolvedId = id ?? GroundImageState.makeGroundImageId(
bounds: bounds,
imageHash: javaHash(imageUrl.absoluteString),
opacity: opacity,
tileSize: tileSize,
extra: extra
)
self.init(
bounds: bounds, image: GroundImageState.placeholder, opacity: opacity, tileSize: tileSize,
id: resolvedId, extra: extra, onClick: onClick
)
self.loader = loader
self.imageUrl = imageUrl
}

/// 読み込み中の画像。
private static let placeholder: UIImage = {
let format = UIGraphicsImageRendererFormat.default()
format.scale = 1
format.opaque = false
return UIGraphicsImageRenderer(size: CGSize(width: 1, height: 1), format: format).image { _ in }
}()

private func loadImageUrl() {
guard let url = imageUrl else { return }
let apply: (UIImage) -> Void = { [weak self] image in
guard let self, self.imageUrl == url else { return }
self.applyingLoadedImage = true
self.image = image
self.applyingLoadedImage = false
}
if case let .ready(image) = loader.status(url) {
apply(image)
return
}
loader.whenSettled(url) { image in
if let image { apply(image) }
}
}

/// `image` を変えなければ、`imageUrl` も引き継ぐ(読み込み中なら写しの方でも読み終えたら入る)。
public func copy(
bounds: GeoRectBounds? = nil,
image: UIImage? = nil,
Expand All @@ -72,7 +167,7 @@ public final class GroundImageState: ObservableObject, Identifiable, Equatable,
extra: Any? = nil,
onClick: OnGroundImageEventHandler? = nil
) -> GroundImageState {
GroundImageState(
let copied = GroundImageState(
bounds: bounds ?? self.bounds,
image: image ?? self.image,
opacity: opacity ?? self.opacity,
Expand All @@ -81,6 +176,11 @@ public final class GroundImageState: ObservableObject, Identifiable, Equatable,
extra: extra ?? self.extra,
onClick: onClick ?? self.onClick
)
if image == nil, let imageUrl {
copied.loader = loader
copied.imageUrl = imageUrl
}
return copied
}

public func fingerPrint() -> GroundImageFingerPrint {
Expand Down Expand Up @@ -141,14 +241,14 @@ public final class GroundImageState: ObservableObject, Identifiable, Equatable,

private static func makeGroundImageId(
bounds: GeoRectBounds,
image: UIImage,
imageHash: Int,
opacity: Double,
tileSize: Int,
extra: Any?
) -> String {
let hashCodes = [
javaHash(bounds),
javaHash(image),
imageHash,
javaHash(opacity),
javaHash(tileSize),
javaHash(extra)
Expand Down
108 changes: 108 additions & 0 deletions Sources/MapConductorCore/image/RemoteImageLoader.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import Foundation
import UIKit

/// URL で渡された画像(``ImageIcon/init(imageUrl:iconSize:scale:anchor:infoAnchor:debug:label:labelTextColor:labelTextSize:labelTypeFace:labelStrokeColor:labelPosition:labelWritingMode:)``、
/// ``GroundImageState/init(bounds:imageUrl:opacity:tileSize:id:extra:onClick:)``)を読み込む。
///
/// - 同じ URL は 1 度しか取りに行かない。読み込み中に何度頼まれても、終わったときに
/// 全員へ知らせる。
/// - 読み終えた画像は `NSCache` に置く。追い出されても、すでに受け取ったアイコンや
/// 地表画像は自分で画像を持っているので困らない。後から作ったものが取り直すだけ。
/// - 失敗も覚えておき、同じ URL で何度も取りに行かない。ログは URL ごとに 1 度だけ。
/// - 知らせ(`whenSettled` に渡したクロージャ)はメインスレッドで、1 度だけ呼ぶ。
///
/// android-sdk / js-sdk-core にも同じ役割のローダーがある。
final class RemoteImageLoader {
enum Status {
case pending
case ready(UIImage)
case failed
}

/// 取りに行く処理。テストで差し替える。結果は任意のスレッドで返してよい。
typealias Fetch = (URL, @escaping (Data?) -> Void) -> Void

static let shared = RemoteImageLoader()

private let fetch: Fetch
private let lock = NSLock()
private let images = NSCache<NSURL, UIImage>()
private var failed = Set<URL>()
private var waiting: [URL: [(UIImage?) -> Void]] = [:]

init(fetch: @escaping Fetch = RemoteImageLoader.urlSessionFetch) {
self.fetch = fetch
images.countLimit = 256
}

/// 今の状態。まだ取りに行っていなければ取りに行く。
func status(_ url: URL) -> Status {
lock.lock()
if let image = images.object(forKey: url as NSURL) {
lock.unlock()
return .ready(image)
}
if failed.contains(url) {
lock.unlock()
return .failed
}
let start = waiting[url] == nil
if start { waiting[url] = [] }
lock.unlock()
if start { begin(url) }
return .pending
}

/// 読み込みが終わったら `callback` を呼ぶ(失敗なら nil)。
///
/// すでに終わっていれば何もせず false を返す(呼び出し側はそのときの `status` を使う)。
/// 状態を確かめてから登録するまでの間に終わることはない(同じ鍵の中で行う)。
@discardableResult
func whenSettled(_ url: URL, _ callback: @escaping (UIImage?) -> Void) -> Bool {
lock.lock()
if images.object(forKey: url as NSURL) != nil || failed.contains(url) {
lock.unlock()
return false
}
let start = waiting[url] == nil
waiting[url, default: []].append(callback)
lock.unlock()
if start { begin(url) }
return true
}

private func begin(_ url: URL) {
fetch(url) { [weak self] data in
let image = data.flatMap { UIImage(data: $0) }
self?.settle(url, image: image)
}
}

private func settle(_ url: URL, image: UIImage?) {
lock.lock()
if let image {
images.setObject(image, forKey: url as NSURL)
} else {
failed.insert(url)
}
let callbacks = waiting.removeValue(forKey: url) ?? []
lock.unlock()
if image == nil {
NSLog("[MapConductor] Failed to load image: %@", url.absoluteString)
}
let deliver = {
for callback in callbacks { callback(image) }
}
if Thread.isMainThread { deliver() } else { DispatchQueue.main.async(execute: deliver) }
}

static func urlSessionFetch(_ url: URL, _ completion: @escaping (Data?) -> Void) {
URLSession.shared.dataTask(with: url) { data, response, _ in
if let http = response as? HTTPURLResponse, !(200 ..< 300).contains(http.statusCode) {
completion(nil)
return
}
completion(data)
}.resume()
}
}
Loading
Loading