From 2fbfde3d326e847032991ee8aee7f2c8ac8ef7e0 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Thu, 5 Nov 2026 02:28:01 -0800 Subject: [PATCH] Draw marker labels into tiles as text, and add vertical labels Tiles drew a labelled marker as one baked bitmap per text and position, so relabelling many markers (street trees named by species and side) built a bitmap per combination on the main thread and the map stalled. - Tiles now put only the body (dot, pin) in the atlas and draw the text after all bodies, on both the GPU and CPU paths. Bodies are shared between icons that differ only in their label. A labelled marker wins its declutter cell. - ImageIcon gains the label options ColorDefaultIcon has. Both compute their layout up front and bake the bitmap only when used as a native marker. - labelWritingMode (CSS writing-mode: horizontal-tb, vertical-rl) sets text vertically with CoreText's vertical glyph forms, so labels on markers packed along a horizontal street fit one character wide. - A tile used to search its surroundings by the largest overhang it had seen; right after relabelling, a tile with no markers of its own returned empty and the label reaching in from the next tile was cut at the seam. Labelled icons now record their overhang when created, and tiles search by that. --- .../marker/ColorDefaultIcon.swift | 330 ++++++++++++------ .../MapConductorCore/marker/ImageIcon.swift | 266 +++++++++++--- .../marker/MarkerTileLabel.swift | 218 ++++++++++++ .../marker/MarkerTileRenderer.swift | 147 +++++++- .../marker/MetalMarkerRasterizer.swift | 20 +- api/MapConductorCore.api.swift | 17 +- 6 files changed, 826 insertions(+), 172 deletions(-) create mode 100644 Sources/MapConductorCore/marker/MarkerTileLabel.swift diff --git a/Sources/MapConductorCore/marker/ColorDefaultIcon.swift b/Sources/MapConductorCore/marker/ColorDefaultIcon.swift index 2202d48..2f87f9a 100644 --- a/Sources/MapConductorCore/marker/ColorDefaultIcon.swift +++ b/Sources/MapConductorCore/marker/ColorDefaultIcon.swift @@ -3,7 +3,7 @@ import UIKit /// 単色で塗る既定アイコン(カラーフィル版の DefaultIcon)。 /// `DefaultMarkerIcon` はこれの別名。 -public final class ColorDefaultIcon: MarkerIconProtocol { +public final class ColorDefaultIcon: MarkerIconProtocol, TileLabelledIcon { public static let defaultIconSize: CGFloat = Settings.Default.iconSize public static let defaultStrokeWidth: CGFloat = Settings.Default.iconStroke public static let defaultFillColor: UIColor = .red @@ -15,6 +15,8 @@ public final class ColorDefaultIcon: MarkerIconProtocol { public static let defaultInfoAnchor = CGPoint(x: 0.5, y: 0.0) /// ラベルの既定位置。ピンの頭に重ねる(従来どおり)。書き方は `labelPosition` を参照。 public static let defaultLabelPosition = "center center" + /// ラベルの既定の書字方向。横書き。書き方は `labelWritingMode` を参照。 + public static let defaultLabelWritingMode = LabelTypesetter.horizontal public let scale: CGFloat @@ -37,9 +39,20 @@ public final class ColorDefaultIcon: MarkerIconProtocol { /// - left / right はピンの外側に置き、縦はピンの上端・上端と先端の中間・先端に揃える。 /// - center 列の top / bottom はピンの上・下の外側に置く。 public let labelPosition: String + /// ラベルの書字方向。CSS の `writing-mode` と同じ書き方で、`horizontal-tb`(既定)か + /// `vertical-rl` / `vertical-lr`(縦書き)。縦書きは 1 文字ぶんの幅で縦に並ぶので、 + /// 横に詰まって並ぶ点にも名前を付けやすい。 + public let labelWritingMode: String /// 指定された infoAnchor(ピン本体に対する割合)。`infoAnchor` はビットマップに対する割合へ直した値。 private let requestedInfoAnchor: CGPoint - private let bitmapIcon: BitmapIcon + private let layout: DefaultIconLayout + private let labelFont: UIFont + private let outlineStrokeWidth: CGFloat + private let labelSize: CGSize + /// 焼き込みのビットマップ。ネイティブマーカーとして使われたときに初めて作る。 + private let bitmapIcon: LazyBox + /// ラベルを除いたピン。タイル描画が使う。 + private let body: LazyBox public init( fillColor: UIColor = defaultFillColor, @@ -52,6 +65,7 @@ public final class ColorDefaultIcon: MarkerIconProtocol { labelTypeFace: UIFont = defaultLabelTypeFace, labelStrokeColor: UIColor = defaultLabelStrokeColor, labelPosition: String = defaultLabelPosition, + labelWritingMode: String = defaultLabelWritingMode, infoAnchor: CGPoint = defaultInfoAnchor, iconSize: CGFloat = defaultIconSize, debug: Bool = false @@ -66,32 +80,137 @@ public final class ColorDefaultIcon: MarkerIconProtocol { self.labelTypeFace = labelTypeFace self.labelStrokeColor = labelStrokeColor self.labelPosition = labelPosition + self.labelWritingMode = labelWritingMode self.requestedInfoAnchor = infoAnchor self.iconSize = iconSize self.debug = debug - let bitmapIcon = ColorDefaultIcon.makeIcon( + // 配置は文字の幅を測るだけなので、ここで済ませる。画像は作らない。 + let canvasSize = iconSize * scale + let font = labelTypeFace.withSize(labelTextSize) + let outline = max(1.0 * scale, 2.0) + let vertical = LabelTypesetter.isVertical(labelWritingMode) + let labelSize = label.map { + ColorDefaultIcon.measureLabel($0, font: font, strokeWidth: outline, vertical: vertical) + } ?? .zero + let layout = DefaultIconLayout.make( + canvasSize: canvasSize, + iconScale: scale, + strokeWidth: strokeWidth, + labelSize: labelSize, + outlineStrokeWidth: outline, + labelPosition: labelPosition + ) + self.layout = layout + self.labelFont = font + self.outlineStrokeWidth = outline + self.labelSize = labelSize + // ラベルを横や上に置くとビットマップが片側へ伸びるので、どちらもレイアウトから求めた値を使う。 + self.anchor = layout.anchor + self.infoAnchor = layout.infoAnchor(for: infoAnchor) + if labelSize != .zero { + LabelledIconExtent.note(size: CGSize(width: layout.bitmapWidth, height: layout.bitmapHeight), anchor: layout.anchor) + } + self.bitmapIcon = LazyBox { + ColorDefaultIcon.makeIcon( + iconSize: iconSize, + scale: scale, + fillColor: fillColor, + strokeColor: strokeColor, + strokeWidth: strokeWidth, + label: label, + labelTextColor: labelTextColor, + labelTextSize: labelTextSize, + labelTypeFace: labelTypeFace, + labelStrokeColor: labelStrokeColor, + labelPosition: labelPosition, + labelWritingMode: labelWritingMode, + infoAnchor: infoAnchor, + debug: debug + ) + } + self.body = LazyBox { + ColorDefaultIcon.sharedBody( + iconSize: iconSize, + scale: scale, + fillColor: fillColor, + strokeColor: strokeColor, + strokeWidth: strokeWidth, + infoAnchor: infoAnchor, + debug: debug + ) + } + } + + public func toBitmapIcon() -> BitmapIcon { + bitmapIcon.get() + } + + var tileBody: BitmapIcon { body.get() } + + var tileLabel: MarkerTileLabel? { + guard let label, labelSize != .zero else { return nil } + let anchorX = layout.anchor.x * layout.bitmapWidth + let anchorY = layout.anchor.y * layout.bitmapHeight + return MarkerTileLabel( + text: label, + font: labelFont, + textColor: labelTextColor ?? Self.defaultLabelTextColor, + strokeColor: labelStrokeColor, + outlineStrokeWidth: outlineStrokeWidth, + centerOffset: CGPoint(x: layout.labelCenter.x - anchorX, y: layout.labelCenter.y - anchorY), + size: labelSize, + vertical: LabelTypesetter.isVertical(labelWritingMode) + ) + } + + var layoutSize: CGSize { CGSize(width: layout.bitmapWidth, height: layout.bitmapHeight) } + + /// ラベルを除いたピン。色や大きさが同じなら、ラベルの違うアイコンの間で 1 枚を共有する。 + private static var bodies: [String: BitmapIcon] = [:] + private static let bodiesLock = NSLock() + + private static func sharedBody( + iconSize: CGFloat, + scale: CGFloat, + fillColor: UIColor, + strokeColor: UIColor, + strokeWidth: CGFloat, + infoAnchor: CGPoint, + debug: Bool + ) -> BitmapIcon { + func rgba(_ color: UIColor) -> String { + var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 + color.getRed(&r, green: &g, blue: &b, alpha: &a) + return "\(r),\(g),\(b),\(a)" + } + let key = [rgba(fillColor), rgba(strokeColor), "\(strokeWidth)", "\(scale)", "\(iconSize)", + "\(infoAnchor.x),\(infoAnchor.y)", "\(debug)"].joined(separator: "|") + bodiesLock.lock() + if let cached = bodies[key] { + bodiesLock.unlock() + return cached + } + bodiesLock.unlock() + let made = makeIcon( iconSize: iconSize, scale: scale, fillColor: fillColor, strokeColor: strokeColor, strokeWidth: strokeWidth, - label: label, - labelTextColor: labelTextColor, - labelTextSize: labelTextSize, - labelTypeFace: labelTypeFace, - labelStrokeColor: labelStrokeColor, - labelPosition: labelPosition, + label: nil, + labelTextColor: nil, + labelTextSize: defaultLabelTextSize, + labelTypeFace: defaultLabelTypeFace, + labelStrokeColor: defaultLabelStrokeColor, + labelPosition: defaultLabelPosition, infoAnchor: infoAnchor, debug: debug ) - // ラベルを横や上に置くとビットマップが片側へ伸びるので、どちらもレイアウトから求めた値を使う。 - self.anchor = bitmapIcon.anchor - self.infoAnchor = bitmapIcon.infoAnchor - self.bitmapIcon = bitmapIcon - } - - public func toBitmapIcon() -> BitmapIcon { - bitmapIcon + bodiesLock.lock() + defer { bodiesLock.unlock() } + if let raced = bodies[key] { return raced } + bodies[key] = made + return made } public func hashCode() -> Int { @@ -105,6 +224,7 @@ public final class ColorDefaultIcon: MarkerIconProtocol { result = result &* 31 &+ labelTypeFace.hashValue result = result &* 31 &+ labelStrokeColor.hashValue result = result &* 31 &+ labelPosition.hashValue + result = result &* 31 &+ labelWritingMode.hashValue result = result &* 31 &+ requestedInfoAnchor.x.hashValue result = result &* 31 &+ requestedInfoAnchor.y.hashValue result = result &* 31 &+ iconSize.hashValue @@ -123,6 +243,7 @@ public final class ColorDefaultIcon: MarkerIconProtocol { labelTypeFace: UIFont? = nil, labelStrokeColor: UIColor? = nil, labelPosition: String? = nil, + labelWritingMode: String? = nil, iconSize: CGFloat? = nil, debug: Bool? = nil ) -> ColorDefaultIcon { @@ -137,6 +258,7 @@ public final class ColorDefaultIcon: MarkerIconProtocol { labelTypeFace: labelTypeFace ?? self.labelTypeFace, labelStrokeColor: labelStrokeColor ?? self.labelStrokeColor, labelPosition: labelPosition ?? self.labelPosition, + labelWritingMode: labelWritingMode ?? self.labelWritingMode, infoAnchor: self.requestedInfoAnchor, iconSize: iconSize ?? self.iconSize, debug: debug ?? self.debug @@ -155,6 +277,7 @@ public final class ColorDefaultIcon: MarkerIconProtocol { labelTypeFace: UIFont, labelStrokeColor: UIColor, labelPosition: String, + labelWritingMode: String = defaultLabelWritingMode, infoAnchor: CGPoint, debug: Bool ) -> BitmapIcon { @@ -162,9 +285,10 @@ public final class ColorDefaultIcon: MarkerIconProtocol { let resolvedFont = labelTypeFace.withSize(labelTextSize) let outlineStrokeWidth = max(1.0 * scale, 2.0) + let vertical = LabelTypesetter.isVertical(labelWritingMode) var labelSize: CGSize = .zero if let label { - labelSize = measureLabel(label, font: resolvedFont, strokeWidth: outlineStrokeWidth) + labelSize = measureLabel(label, font: resolvedFont, strokeWidth: outlineStrokeWidth, vertical: vertical) } let layout = DefaultIconLayout.make( @@ -217,7 +341,8 @@ public final class ColorDefaultIcon: MarkerIconProtocol { center: layout.labelCenter, bitmapHeight: bitmapHeight, outlineStrokeWidth: outlineStrokeWidth, - pixelScale: format.scale + pixelScale: format.scale, + vertical: vertical ) } } @@ -231,18 +356,17 @@ public final class ColorDefaultIcon: MarkerIconProtocol { ) } - internal static func measureLabel(_ label: String, font: UIFont, strokeWidth: CGFloat) -> CGSize { - let attributes: [NSAttributedString.Key: Any] = [.font: font] - let line = CTLineCreateWithAttributedString(NSAttributedString(string: label, attributes: attributes)) - var ascent: CGFloat = 0 - var descent: CGFloat = 0 - var leading: CGFloat = 0 - let width = CGFloat(CTLineGetTypographicBounds(line, &ascent, &descent, &leading)) - let height = ascent + descent - let padding = strokeWidth * 2.0 - return CGSize(width: width + padding, height: height + padding) + internal static func measureLabel( + _ label: String, + font: UIFont, + strokeWidth: CGFloat, + vertical: Bool = false + ) -> CGSize { + LabelTypesetter.measure(label, font: font, strokeWidth: strokeWidth, vertical: vertical) } + /// 焼き込みのビットマップ(上が原点)に、`center` を中心にラベルを描く。 + /// 文字の組み方はタイル描画と同じ ``LabelTypesetter``。 internal static func drawLabel( context: CGContext, labelText: String, @@ -250,65 +374,23 @@ public final class ColorDefaultIcon: MarkerIconProtocol { labelTextColor: UIColor, labelStrokeColor: UIColor, center: CGPoint, - bitmapHeight: CGFloat, + bitmapHeight _: CGFloat, outlineStrokeWidth: CGFloat, - pixelScale: CGFloat + pixelScale: CGFloat, + vertical: Bool = false ) { - // DefaultIconLayout が決めたラベルの中心。既定ではマーカーの円形部分の中心。 - let markerCenterX = center.x - let markerCenterY = center.y - - let baseAttributes: [NSAttributedString.Key: Any] = [.font: labelFont] - let baseLine = CTLineCreateWithAttributedString(NSAttributedString(string: labelText, attributes: baseAttributes)) - var ascent: CGFloat = 0 - var descent: CGFloat = 0 - var leading: CGFloat = 0 - let lineWidth = CGFloat(CTLineGetTypographicBounds(baseLine, &ascent, &descent, &leading)) - let baselineY = markerCenterY + (ascent - descent) / 2.0 - - let drawX = alignToPixel(markerCenterX - lineWidth / 2.0, scale: pixelScale) - let drawBaselineY = alignToPixel(baselineY, scale: pixelScale) - let strokePercent = (outlineStrokeWidth / max(labelFont.pointSize, 1.0)) * 100.0 - - context.saveGState() - context.textMatrix = .identity - context.setLineJoin(.round) - context.setLineCap(.round) - context.setShouldAntialias(true) - context.setAllowsFontSmoothing(true) - context.setShouldSmoothFonts(true) - context.setAllowsFontSubpixelPositioning(false) - context.setShouldSubpixelPositionFonts(false) - - context.translateBy(x: 0, y: bitmapHeight) - context.scaleBy(x: 1.0, y: -1.0) - - let flippedBaselineY = bitmapHeight - drawBaselineY - context.textPosition = CGPoint(x: drawX, y: flippedBaselineY) - - let strokeAttributes: [NSAttributedString.Key: Any] = [ - .font: labelFont, - .strokeColor: labelStrokeColor, - .strokeWidth: strokePercent, - .foregroundColor: labelStrokeColor - ] - let fillAttributes: [NSAttributedString.Key: Any] = [ - .font: labelFont, - .foregroundColor: labelTextColor - ] - - let strokeLine = CTLineCreateWithAttributedString( - NSAttributedString(string: labelText, attributes: strokeAttributes) - ) - CTLineDraw(strokeLine, context) - - context.textPosition = CGPoint(x: drawX, y: flippedBaselineY) - let fillLine = CTLineCreateWithAttributedString( - NSAttributedString(string: labelText, attributes: fillAttributes) + LabelTypesetter.draw( + labelText, + in: context, + centerX: center.x, + centerY: center.y, + font: labelFont, + textColor: labelTextColor, + strokeColor: labelStrokeColor, + outlineStrokeWidth: outlineStrokeWidth, + vertical: vertical, + pixelScale: pixelScale ) - CTLineDraw(fillLine, context) - - context.restoreGState() } internal static func alignToPixel(_ value: CGFloat, scale: CGFloat) -> CGFloat { @@ -497,6 +579,49 @@ struct DefaultIconLayout { ) -> DefaultIconLayout { // 円形部分の中心 let headCenterY = canvasSize * 0.35 + // ピンの見えている輪郭(設計座標で x: 4〜20, y: 0〜24)。外接箱より細いので、 + // ラベルを横に置くときはこちらに沿わせる。 + let metrics = DefaultMarkerIcon.markerPathMetrics( + canvasSize: canvasSize, + iconScale: iconScale, + strokeWidth: strokeWidth + ) + let s = metrics.markerScale + let body = CGRect( + x: metrics.offsetX + 4.0 * s, + y: metrics.offsetY, + width: 16.0 * s, + height: 24.0 * s + ) + return make( + canvasSize: canvasSize, + body: body, + centerY: headCenterY, + // 横に置くときの縦の中央は、ピンの上端と先端の中間。頭の中心にすると、 + // ピン全体の中央より上に見える。 + sideCenterY: body.midY, + labelSize: labelSize, + outlineStrokeWidth: outlineStrokeWidth, + labelPosition: labelPosition + ) + } + + /// 本体の形によらない配置。既定のピンと画像アイコンで共通。 + /// + /// - `body`: ラベルを避けて置く、本体の描かれている範囲(本体の左上を原点とする)。 + /// - `centerY`: `center center` のときのラベルの縦位置。 + /// - `sideCenterY`: 左右に置き、縦を指定しないときのラベルの縦位置。 + /// + /// react-sdk の `computeIconLabelLayout` と同じ計算。 + static func make( + canvasSize: CGFloat, + body: CGRect, + centerY: CGFloat, + sideCenterY: CGFloat, + labelSize: CGSize, + outlineStrokeWidth: CGFloat, + labelPosition: String + ) -> DefaultIconLayout { let padding = canvasSize * 0.1 guard labelSize != .zero else { @@ -506,23 +631,11 @@ struct DefaultIconLayout { bitmapHeight: canvasSize, markerOffsetX: 0, markerOffsetY: 0, - labelCenter: CGPoint(x: canvasSize / 2.0, y: headCenterY) + labelCenter: CGPoint(x: canvasSize / 2.0, y: centerY) ) } let (h, v) = parseLabelPosition(labelPosition) - // ピンの見えている輪郭(設計座標で x: 4〜20, y: 0〜24)。外接箱より細いので、 - // ラベルを横に置くときはこちらに沿わせる。 - let metrics = DefaultMarkerIcon.markerPathMetrics( - canvasSize: canvasSize, - iconScale: iconScale, - strokeWidth: strokeWidth - ) - let s = metrics.markerScale - let pinLeft = metrics.offsetX + 4.0 * s - let pinRight = metrics.offsetX + 20.0 * s - let pinTop = metrics.offsetY - let pinBottom = metrics.offsetY + 24.0 * s let gap = max(outlineStrokeWidth, canvasSize * 0.05) let labelWidth = labelSize.width let labelHeight = labelSize.height @@ -530,24 +643,23 @@ struct DefaultIconLayout { // マーカー本体の左上を原点とした座標でラベルの中心を決める。 let cx: CGFloat if h < 0 { - cx = pinLeft - gap - labelWidth / 2.0 + cx = body.minX - gap - labelWidth / 2.0 } else if h > 0 { - cx = pinRight + gap + labelWidth / 2.0 + cx = body.maxX + gap + labelWidth / 2.0 } else { cx = canvasSize / 2.0 } - var cy = headCenterY + var cy = centerY if h != 0 { - // 横に置くときは、縦はピンの上端・上端と先端の中間・先端に揃える。 - // center を頭の中心にすると、ピン全体の中央より上に見える。 - if v < 0 { cy = pinTop + labelHeight / 2.0 } - if v > 0 { cy = pinBottom - labelHeight / 2.0 } - if v == 0 { cy = (pinTop + pinBottom) / 2.0 } + // 横に置くときは、縦は本体の上端・指定の中心・下端に揃える。 + cy = sideCenterY + if v < 0 { cy = body.minY + labelHeight / 2.0 } + if v > 0 { cy = body.maxY - labelHeight / 2.0 } } else { - // 中央列の top / bottom はピンと重ならないよう上下の外側に置く。 - if v < 0 { cy = pinTop - gap - labelHeight / 2.0 } - if v > 0 { cy = pinBottom + gap + labelHeight / 2.0 } + // 中央列の top / bottom は本体と重ならないよう上下の外側に置く。 + if v < 0 { cy = body.minY - gap - labelHeight / 2.0 } + if v > 0 { cy = body.maxY + gap + labelHeight / 2.0 } } let bitmapWidth: CGFloat diff --git a/Sources/MapConductorCore/marker/ImageIcon.swift b/Sources/MapConductorCore/marker/ImageIcon.swift index 472f57f..f14532f 100644 --- a/Sources/MapConductorCore/marker/ImageIcon.swift +++ b/Sources/MapConductorCore/marker/ImageIcon.swift @@ -5,7 +5,24 @@ import UIKit /// /// This icon uses the provided image as-is (scaled to the requested size) without applying the /// default marker/pin shape. -public final class ImageIcon: MarkerIconProtocol { +/// +/// ## ラベル +/// +/// `label` を渡すと画像に文字を添える。書き方と既定値は `ColorDefaultIcon` と同じで、 +/// react-sdk の `ImageIcon` とも揃えてある。 +/// +/// - `left` / `right` は画像の外側に置き、縦は画像の上端・中間・下端に揃える。 +/// - `center` 列の `top` / `bottom` は画像の上・下の外側に置く。 +/// - `"center center"`(既定)は画像の中央に重ねる。 +/// +/// `labelWritingMode` に `"vertical-rl"` を渡すと縦書きになる(``ColorDefaultIcon/labelWritingMode``)。 +/// +/// `anchor` / `infoAnchor` の指定は、ラベルの有無によらず**画像に対する割合**。 +/// 公開している `anchor` / `infoAnchor` は、ラベルで広がった全体に対する割合へ直した値。 +/// +/// 画像は使われたときに初めて作る。タイル描画は本体の画像と文字を別々に使うので、 +/// ラベル付きのアイコンをいくつ作っても、本体の画像は元の画像ごとに 1 枚で済む。 +public final class ImageIcon: MarkerIconProtocol, TileLabelledIcon { public static let defaultIconSize: CGFloat = DefaultMarkerIcon.defaultIconSize public static let defaultAnchor: CGPoint = CGPoint(x: 0.5, y: 0.5) public static let defaultInfoAnchor: CGPoint = CGPoint(x: 0.5, y: 0.5) @@ -16,8 +33,25 @@ public final class ImageIcon: MarkerIconProtocol { public let infoAnchor: CGPoint public let debug: Bool + public let label: String? + public let labelTextColor: UIColor? + public let labelTextSize: CGFloat + public let labelTypeFace: UIFont + public let labelStrokeColor: UIColor + public let labelPosition: String + public let labelWritingMode: String + private let image: UIImage - private let bitmapIcon: BitmapIcon + private let requestedAnchor: CGPoint + private let requestedInfoAnchor: CGPoint + private let layout: DefaultIconLayout + private let pixelScale: CGFloat + private let labelFont: UIFont + private let outlineStrokeWidth: CGFloat + private let labelSize: CGSize + private let vertical: Bool + private let body: LazyBox + private let full: LazyBox public init( image: UIImage, @@ -25,35 +59,156 @@ public final class ImageIcon: MarkerIconProtocol { scale: CGFloat = 1.0, anchor: CGPoint = defaultAnchor, infoAnchor: CGPoint = defaultInfoAnchor, - debug: Bool = false + debug: Bool = false, + label: String? = nil, + labelTextColor: UIColor? = ColorDefaultIcon.defaultLabelTextColor, + labelTextSize: CGFloat = ColorDefaultIcon.defaultLabelTextSize, + labelTypeFace: UIFont = ColorDefaultIcon.defaultLabelTypeFace, + labelStrokeColor: UIColor = ColorDefaultIcon.defaultLabelStrokeColor, + labelPosition: String = ColorDefaultIcon.defaultLabelPosition, + labelWritingMode: String = ColorDefaultIcon.defaultLabelWritingMode ) { self.image = image self.iconSize = iconSize self.scale = scale - self.anchor = anchor - self.infoAnchor = infoAnchor self.debug = debug - self.bitmapIcon = Self.makeIcon( - image: image, - iconSize: iconSize, - scale: scale, - anchor: anchor, - infoAnchor: infoAnchor, - debug: debug + self.label = label + self.labelTextColor = labelTextColor + self.labelTextSize = labelTextSize + self.labelTypeFace = labelTypeFace + self.labelStrokeColor = labelStrokeColor + self.labelPosition = labelPosition + self.labelWritingMode = labelWritingMode + self.requestedAnchor = anchor + self.requestedInfoAnchor = infoAnchor + self.pixelScale = UIScreen.main.scale + + // 配置は文字の幅を測るだけなので、ここで済ませる。画像は作らない。 + let canvasSize = iconSize * scale + let font = labelTypeFace.withSize(labelTextSize) + let outline = max(1.0 * scale, 2.0) + let vertical = LabelTypesetter.isVertical(labelWritingMode) + let labelSize = label.map { + ColorDefaultIcon.measureLabel($0, font: font, strokeWidth: outline, vertical: vertical) + } ?? .zero + let layout = DefaultIconLayout.make( + canvasSize: canvasSize, + body: CGRect(x: 0, y: 0, width: canvasSize, height: canvasSize), + centerY: canvasSize / 2.0, + sideCenterY: canvasSize / 2.0, + labelSize: labelSize, + outlineStrokeWidth: outline, + labelPosition: labelPosition + ) + self.layout = layout + self.labelFont = font + self.outlineStrokeWidth = outline + self.labelSize = labelSize + self.vertical = vertical + self.anchor = CGPoint( + x: (layout.markerOffsetX + anchor.x * canvasSize) / layout.bitmapWidth, + y: (layout.markerOffsetY + anchor.y * canvasSize) / layout.bitmapHeight + ) + self.infoAnchor = CGPoint( + x: (layout.markerOffsetX + infoAnchor.x * canvasSize) / layout.bitmapWidth, + y: (layout.markerOffsetY + infoAnchor.y * canvasSize) / layout.bitmapHeight + ) + + if labelSize != .zero { + LabelledIconExtent.note(size: CGSize(width: layout.bitmapWidth, height: layout.bitmapHeight), anchor: self.anchor) + } + + let pixelScale = self.pixelScale + let body = LazyBox { + BitmapIcon( + bitmap: ImageIcon.bodyImage(image, canvasSize: canvasSize, pixelScale: pixelScale, debug: debug), + anchor: anchor, + size: CGSize(width: canvasSize, height: canvasSize), + scale: scale, + iconSize: iconSize, + infoAnchor: infoAnchor, + debug: debug + ) + } + self.body = body + let publicAnchor = self.anchor + let publicInfoAnchor = self.infoAnchor + let textColor = labelTextColor ?? ColorDefaultIcon.defaultLabelTextColor + self.full = LazyBox { + let bodyIcon = body.get() + guard let label, labelSize != .zero else { return bodyIcon } + let size = CGSize(width: layout.bitmapWidth, height: layout.bitmapHeight) + let format = UIGraphicsImageRendererFormat.default() + format.scale = pixelScale + let rendered = UIGraphicsImageRenderer(size: size, format: format).image { context in + bodyIcon.bitmap.draw(in: CGRect( + x: layout.markerOffsetX, y: layout.markerOffsetY, + width: canvasSize, height: canvasSize + )) + ColorDefaultIcon.drawLabel( + context: context.cgContext, + labelText: label, + labelFont: font, + labelTextColor: textColor, + labelStrokeColor: labelStrokeColor, + center: layout.labelCenter, + bitmapHeight: size.height, + outlineStrokeWidth: outline, + pixelScale: pixelScale, + vertical: vertical + ) + } + return BitmapIcon( + bitmap: rendered, + anchor: publicAnchor, + size: size, + scale: scale, + iconSize: iconSize, + infoAnchor: publicInfoAnchor, + debug: debug + ) + } + } + + public func toBitmapIcon() -> BitmapIcon { full.get() } + + var tileBody: BitmapIcon { body.get() } + + var tileLabel: MarkerTileLabel? { + guard let label, labelSize != .zero else { return nil } + let canvasSize = iconSize * scale + let anchorX = layout.markerOffsetX + requestedAnchor.x * canvasSize + let anchorY = layout.markerOffsetY + requestedAnchor.y * canvasSize + return MarkerTileLabel( + text: label, + font: labelFont, + textColor: labelTextColor ?? ColorDefaultIcon.defaultLabelTextColor, + strokeColor: labelStrokeColor, + outlineStrokeWidth: outlineStrokeWidth, + centerOffset: CGPoint(x: layout.labelCenter.x - anchorX, y: layout.labelCenter.y - anchorY), + size: labelSize, + vertical: vertical ) } - public func toBitmapIcon() -> BitmapIcon { bitmapIcon } + var layoutSize: CGSize { CGSize(width: layout.bitmapWidth, height: layout.bitmapHeight) } public func hashCode() -> Int { var result = image.hashValue result = result &* 31 &+ scale.hashValue - result = result &* 31 &+ anchor.x.hashValue - result = result &* 31 &+ anchor.y.hashValue + result = result &* 31 &+ requestedAnchor.x.hashValue + result = result &* 31 &+ requestedAnchor.y.hashValue result = result &* 31 &+ iconSize.hashValue - result = result &* 31 &+ infoAnchor.x.hashValue - result = result &* 31 &+ infoAnchor.y.hashValue + result = result &* 31 &+ requestedInfoAnchor.x.hashValue + result = result &* 31 &+ requestedInfoAnchor.y.hashValue result = result &* 31 &+ debug.hashValue + result = result &* 31 &+ (label?.hashValue ?? 0) + result = result &* 31 &+ (labelTextColor?.hashValue ?? 0) + result = result &* 31 &+ labelTextSize.hashValue + result = result &* 31 &+ labelTypeFace.hashValue + result = result &* 31 &+ labelStrokeColor.hashValue + result = result &* 31 &+ labelPosition.hashValue + result = result &* 31 &+ labelWritingMode.hashValue return result } @@ -63,33 +218,57 @@ public final class ImageIcon: MarkerIconProtocol { scale: CGFloat? = nil, anchor: CGPoint? = nil, infoAnchor: CGPoint? = nil, - debug: Bool? = nil + debug: Bool? = nil, + label: String? = nil, + labelTextColor: UIColor? = nil, + labelTextSize: CGFloat? = nil, + labelTypeFace: UIFont? = nil, + labelStrokeColor: UIColor? = nil, + labelPosition: String? = nil, + labelWritingMode: String? = nil ) -> ImageIcon { ImageIcon( image: image ?? self.image, iconSize: iconSize ?? self.iconSize, scale: scale ?? self.scale, - anchor: anchor ?? self.anchor, - infoAnchor: infoAnchor ?? self.infoAnchor, - debug: debug ?? self.debug + anchor: anchor ?? self.requestedAnchor, + infoAnchor: infoAnchor ?? self.requestedInfoAnchor, + debug: debug ?? self.debug, + label: label ?? self.label, + labelTextColor: labelTextColor ?? self.labelTextColor, + labelTextSize: labelTextSize ?? self.labelTextSize, + labelTypeFace: labelTypeFace ?? self.labelTypeFace, + labelStrokeColor: labelStrokeColor ?? self.labelStrokeColor, + labelPosition: labelPosition ?? self.labelPosition, + labelWritingMode: labelWritingMode ?? self.labelWritingMode ) } - private static func makeIcon( - image: UIImage, - iconSize: CGFloat, - scale: CGFloat, - anchor: CGPoint, - infoAnchor: CGPoint, - debug: Bool - ) -> BitmapIcon { - let canvasSize = iconSize * scale - let size = CGSize(width: canvasSize, height: canvasSize) + // MARK: - 本体の画像 + /// 元の画像 -> 大きさごとの本体の画像。 + /// + /// 元の画像は弱参照で、ポインタの同一性で引く。元の画像が解放されれば行ごと消えるので、 + /// 解放後に同じアドレスへ来た別の画像が古い本体を拾うことはない。 + private static let bodyCache = NSMapTable( + keyOptions: [.weakMemory, .objectPointerPersonality], + valueOptions: .strongMemory + ) + private static let bodyCacheLock = NSLock() + + private static func bodyImage(_ image: UIImage, canvasSize: CGFloat, pixelScale: CGFloat, debug: Bool) -> UIImage { + let key = "\(canvasSize)/\(pixelScale)/\(debug)" as NSString + bodyCacheLock.lock() + if let cached = bodyCache.object(forKey: image)?[key] as? UIImage { + bodyCacheLock.unlock() + return cached + } + bodyCacheLock.unlock() + + let size = CGSize(width: canvasSize, height: canvasSize) let format = UIGraphicsImageRendererFormat.default() - format.scale = UIScreen.main.scale - let renderer = UIGraphicsImageRenderer(size: size, format: format) - let rendered = renderer.image { context in + format.scale = pixelScale + let rendered = UIGraphicsImageRenderer(size: size, format: format).image { context in if debug { context.cgContext.setStrokeColor(UIColor.black.cgColor) context.cgContext.setLineWidth(1.0) @@ -98,14 +277,15 @@ public final class ImageIcon: MarkerIconProtocol { image.draw(in: CGRect(origin: .zero, size: size)) } - return BitmapIcon( - bitmap: rendered, - anchor: anchor, - size: size, - scale: scale, - iconSize: iconSize, - infoAnchor: infoAnchor, - debug: debug - ) + bodyCacheLock.lock() + defer { bodyCacheLock.unlock() } + let sizes = bodyCache.object(forKey: image) ?? { + let fresh = NSMutableDictionary() + bodyCache.setObject(fresh, forKey: image) + return fresh + }() + if let raced = sizes[key] as? UIImage { return raced } + sizes[key] = rendered + return rendered } } diff --git a/Sources/MapConductorCore/marker/MarkerTileLabel.swift b/Sources/MapConductorCore/marker/MarkerTileLabel.swift new file mode 100644 index 0000000..cc8bcc3 --- /dev/null +++ b/Sources/MapConductorCore/marker/MarkerTileLabel.swift @@ -0,0 +1,218 @@ +import CoreText +import UIKit + +/// タイルに描くとき、アイコン本体とは別に描くラベル。 +/// +/// ## なぜ分けるか +/// +/// ラベルを焼き込んだビットマップは、文字列と置き場所の組の数だけ要る。 +/// 街路樹のように「樹種 × 向き」でラベルを付け替えると、そのたびに画像を作り、 +/// タイルのアトラスにも別の絵として積むことになる。本体(点やピン)だけを +/// アトラスに入れ、文字はタイルを描くときに直接描けば、画像は本体の種類だけで済む。 +/// +/// ネイティブマーカーとして描くときは、従来どおり焼き込んだビットマップ +/// (`toBitmapIcon()`)を使う。どちらも配置は `DefaultIconLayout` の同じ計算から出す。 +struct MarkerTileLabel { + let text: String + /// 大きさはアイコンの `scale` 込み。タイルではさらに描画倍率を掛ける。 + let font: UIFont + let textColor: UIColor + let strokeColor: UIColor + let outlineStrokeWidth: CGFloat + /// ラベルの中心の、アンカー点からのずれ(pt)。 + let centerOffset: CGPoint + /// 縁取り込みの大きさ(pt)。 + let size: CGSize + /// 縦書きか。``LabelTypesetter`` を参照。 + let vertical: Bool + + /// 上が原点の座標系(タイル描画の context)で、中心 (`centerX`, `centerY`) に描く。 + /// + /// 書き出し位置は整数の画素に揃える。タイルの原点も整数の画素なので、 + /// 境界をまたぐラベルは両側のタイルが同じ画素から描き、継ぎ目で字が割れない。 + func draw(in context: CGContext, centerX: CGFloat, centerY: CGFloat, scale: CGFloat) { + LabelTypesetter.draw( + text, + in: context, + centerX: centerX, + centerY: centerY, + font: font.withSize(font.pointSize * scale), + textColor: textColor, + strokeColor: strokeColor, + outlineStrokeWidth: outlineStrokeWidth * scale, + vertical: vertical, + pixelScale: 1 + ) + } +} + +/// 本体とラベルを分けて渡せるアイコン。 +/// +/// タイル描画は `tileBody` をアトラスに入れ、`tileLabel` をタイルに直接描く。 +/// どちらも焼き込みのビットマップを作らずに得られるので、ラベル付きのアイコンを +/// 大量に作っても、ネイティブマーカーとして描かれない限り画像は増えない。 +protocol TileLabelledIcon: MarkerIconProtocol { + /// ラベルを除いた本体。アンカーは本体に対する割合。 + var tileBody: BitmapIcon { get } + var tileLabel: MarkerTileLabel? { get } + /// ラベル込みの全体の大きさ(pt)。`anchor` はこれに対する割合。 + var layoutSize: CGSize { get } +} + +/// 一度だけ作って覚える値。タイルは複数のスレッドから同時に描かれるので鍵を掛ける。 +final class LazyBox { + private let lock = NSLock() + private var value: Value? + private let make: () -> Value + + init(_ make: @escaping () -> Value) { + self.make = make + } + + func get() -> Value { + lock.lock() + defer { lock.unlock() } + if let value { return value } + let made = make() + value = made + return made + } +} + +/// ラベルの文字の測り方と描き方。焼き込み(``ColorDefaultIcon`` / ``ImageIcon``)とタイル +/// (``MarkerTileLabel``)の両方がここを通るので、どちらで描いても同じ字面になる。 +/// +/// ## 縦書き +/// +/// `labelWritingMode` は CSS の `writing-mode` と同じ書き方で、`vertical-rl` / `vertical-lr` を +/// 縦書きとして扱う(1 行しかないので左右の違いは出ない)。それ以外は横書き。 +/// +/// 縦書きは CoreText の縦組み用の字形(`verticalGlyphForm`)で 1 行を組み、文字の進む向きが +/// 下になるように回して描く。長音の「ー」は縦棒に、小書きの「ョ」は縦書きの位置になる。 +/// 横の通りに沿って並ぶ街路樹のように、横書きでは幅を取りすぎて並ばないラベルに使う。 +enum LabelTypesetter { + static let horizontal = "horizontal-tb" + + static func isVertical(_ writingMode: String) -> Bool { + let mode = writingMode.trimmingCharacters(in: .whitespaces).lowercased() + return mode == "vertical-rl" || mode == "vertical-lr" + } + + /// 縁取り込みの大きさ(pt)。縦書きなら幅は 1 文字ぶん、高さが文字列の長さになる。 + static func measure(_ text: String, font: UIFont, strokeWidth: CGFloat, vertical: Bool) -> CGSize { + let padding = strokeWidth * 2.0 + let line = makeLine(text, attributes: [.font: font], vertical: vertical) + var ascent: CGFloat = 0 + var descent: CGFloat = 0 + var leading: CGFloat = 0 + let length = CGFloat(CTLineGetTypographicBounds(line, &ascent, &descent, &leading)) + guard vertical else { + return CGSize(width: length + padding, height: ascent + descent + padding) + } + // 縦組みの字形は行の中心線に沿って並ぶので、列の幅は字面の外接矩形から取る。 + let glyphs = CTLineGetBoundsWithOptions(line, .useGlyphPathBounds) + return CGSize(width: ceil(glyphs.height) + padding, height: length + padding) + } + + /// 上が原点の context に、中心 (`centerX`, `centerY`) で描く。`font` と `outlineStrokeWidth` は + /// context の単位で渡す。書き出し位置は `pixelScale` の画素に揃える。 + static func draw( + _ text: String, + in context: CGContext, + centerX: CGFloat, + centerY: CGFloat, + font: UIFont, + textColor: UIColor, + strokeColor: UIColor, + outlineStrokeWidth: CGFloat, + vertical: Bool, + pixelScale: CGFloat + ) { + func align(_ value: CGFloat) -> CGFloat { + guard pixelScale > 0 else { return value } + return (value * pixelScale).rounded() / pixelScale + } + let strokePercent = (outlineStrokeWidth / max(font.pointSize, 1.0)) * 100.0 + let stroke = makeLine(text, attributes: [ + .font: font, + .strokeColor: strokeColor, + .strokeWidth: strokePercent, + .foregroundColor: strokeColor, + ], vertical: vertical) + let fill = makeLine(text, attributes: [.font: font, .foregroundColor: textColor], vertical: vertical) + + context.saveGState() + context.textMatrix = .identity + context.setLineJoin(.round) + context.setLineCap(.round) + context.setShouldAntialias(true) + context.setAllowsFontSubpixelPositioning(false) + context.setShouldSubpixelPositionFonts(false) + + let origin: CGPoint + if vertical { + // 中心へ寄せてから、CoreText の向き(上が正)に戻し、文字の進む向きが下になるよう回す。 + // 回したあとの行の座標で、字面の外接矩形の中心が原点に来るように置く。 + context.translateBy(x: align(centerX), y: align(centerY)) + context.scaleBy(x: 1, y: -1) + context.rotate(by: -.pi / 2) + let glyphs = CTLineGetBoundsWithOptions(fill, .useGlyphPathBounds) + origin = CGPoint(x: -glyphs.midX, y: -glyphs.midY) + } else { + var ascent: CGFloat = 0 + var descent: CGFloat = 0 + var leading: CGFloat = 0 + let width = CGFloat(CTLineGetTypographicBounds(fill, &ascent, &descent, &leading)) + // context は上が原点。CoreText は下が原点で字を積むので、ここだけ戻す。 + context.translateBy(x: align(centerX - width / 2.0), y: align(centerY + (ascent - descent) / 2.0)) + context.scaleBy(x: 1, y: -1) + origin = .zero + } + + context.textPosition = origin + CTLineDraw(stroke, context) + context.textPosition = origin + CTLineDraw(fill, context) + context.restoreGState() + } + + private static func makeLine( + _ text: String, + attributes: [NSAttributedString.Key: Any], + vertical: Bool + ) -> CTLine { + var attributes = attributes + if vertical { attributes[.verticalGlyphForm] = true } + return CTLineCreateWithAttributedString(NSAttributedString(string: text, attributes: attributes)) + } +} + +/// ラベル付きのアイコンのはみ出し量(アンカーからいちばん遠い辺まで、pt)の、プロセス全体での最大。 +/// +/// ``ColorDefaultIcon`` と ``ImageIcon`` は作った時点で配置を計算するので、ここへ記録する。 +/// タイル描画は、隣のタイルからはみ出してくるラベルを取りこぼさないよう、この値で探す範囲を +/// 広げる(``MarkerTileRenderer`` の `queryHalfExtentPx`)。マーカーに付ける前にアイコンは +/// 作られるので、タイルが描き直される時点ではもう記録されている。 +/// +/// 小さくはならない。大きなラベルを一度作ると、以後は少し広めに探す。 +enum LabelledIconExtent { + private static let lock = NSLock() + private static var current: Double = 0 + + static var maxPt: Double { + lock.lock() + defer { lock.unlock() } + return current + } + + static func note(size: CGSize, anchor: CGPoint) { + let w = Double(size.width) + let h = Double(size.height) + let ax = Double(anchor.x) + let ay = Double(anchor.y) + let extent = max(max(w * ax, w * (1 - ax)), max(h * ay, h * (1 - ay))) + lock.lock() + if extent > current { current = extent } + lock.unlock() + } +} diff --git a/Sources/MapConductorCore/marker/MarkerTileRenderer.swift b/Sources/MapConductorCore/marker/MarkerTileRenderer.swift index f4b41c3..0a3a196 100644 --- a/Sources/MapConductorCore/marker/MarkerTileRenderer.swift +++ b/Sources/MapConductorCore/marker/MarkerTileRenderer.swift @@ -45,6 +45,15 @@ public final class MarkerTileRenderer: TileProvider { /// costs one extra pass on one tile, which is the very thing it avoids. private var observedHalfExtentPx: Double + /// ズームごとの、アイコンに掛かった倍率の最大(`iconScaleCallback` × `extraIconScale`)。 + /// ``LabelledIconExtent`` の pt を px へ直すのに使う。 + private let scaleLock = NSLock() + private var maxScaleByZoom: [Int: Double] = [:] + /// 倍率をまだ見ていないズームで `iconScaleCallback` に渡す見本。どこかのズームで描いた + /// マーカーを 1 つ覚えておく。一斉に描き直すとき、そのズームの最初のタイルたちは + /// 倍率を誰も記録していないので、これが無いと育っていない値で探すことになる。 + private var sampleState: MarkerState? + private let defaultIcon: BitmapIcon public init( @@ -123,16 +132,26 @@ public final class MarkerTileRenderer: TileProvider { guard markerIds.contains(entity.state.id), entity.state.clickable, let markerPoint = project(entity.state.position) else { continue } - let icon = entity.state.icon?.toBitmapIcon() ?? defaultIcon + // ラベル込みの全体の矩形。分けて渡せるアイコンは、焼き込みを作らずに大きさが分かる。 + let size: CGSize + let anchor: CGPoint + if let labelled = entity.state.icon as? TileLabelledIcon { + size = labelled.layoutSize + anchor = labelled.anchor + } else { + let icon = entity.state.icon?.toBitmapIcon() ?? defaultIcon + size = icon.size + anchor = icon.anchor + } let callbackScale = max(iconScaleCallback?(entity.state, zoom) ?? 1, 0) let scale = CGFloat(callbackScale * extraIconScale) / max(renderScaleToScreenScale, 1) states.append(entity.state) rects.append( MarkerHitTest.ScreenRect( anchorScreen: markerPoint, - width: max(icon.size.width * scale, 1), - height: max(icon.size.height * scale, 1), - anchor: icon.anchor + width: max(size.width * scale, 1), + height: max(size.height * scale, 1), + anchor: anchor ) ) } @@ -181,7 +200,7 @@ public final class MarkerTileRenderer: TileProvider { southWest: GeoPoint(latitude: se.latitude, longitude: nw.longitude), northEast: GeoPoint(latitude: nw.latitude, longitude: se.longitude) ) - return queryByHalfExtentPx(observedHalfExtentPx, bounds: bounds, tilePx: Double(tileSize)) + return queryByHalfExtentPx(queryHalfExtentPx(zoom: z), bounds: bounds, tilePx: Double(tileSize)) .isEmpty } @@ -221,7 +240,7 @@ public final class MarkerTileRenderer: TileProvider { // Left fixed, the "conservative first pass" never pays off — real icons // are larger than the guess — and the query and prepare passes simply // run twice for every tile. - let assumedHalfExtentPx: Double = observedHalfExtentPx + let assumedHalfExtentPx: Double = queryHalfExtentPx(zoom: z) let phaseStart = Self.tracePhases ? Self.now() : 0 var entities = queryByHalfExtentPx(assumedHalfExtentPx, bounds: bounds, tilePx: tilePx) var queryMs = Self.tracePhases ? Self.since(phaseStart) : 0 @@ -424,6 +443,15 @@ public final class MarkerTileRenderer: TileProvider { for (index, group) in groups.enumerated() { guard let group else { continue } if let current = lastAt[group] { + // ラベル付きを先に残す。ラベルは利用者が「この木の名前を見せたい」と + // 選んだもので、同じマス目の点のどれかと入れ替えてよいものではない。 + // マーカーの属性だけで決まるので、隣のタイルとも答えは食い違わない。 + let labelledA = prepared.markers[index].label != nil + let labelledB = prepared.markers[current].label != nil + if labelledA != labelledB { + if labelledA { lastAt[group] = index } + continue + } let a = worldKeys[index] let b = worldKeys[current] let iconA = UInt(bitPattern: placements[index]!.icon.hashValue) @@ -444,17 +472,53 @@ public final class MarkerTileRenderer: TileProvider { // 残すものだけ先に集める。GPU 経路はこれをそのままインスタンスにする。 var kept: [(placement: MarkerPlacement, bitmap: CGImage)] = [] kept.reserveCapacity(lastAt.count) + var keptLabels: [KeptLabel] = [] for (index, placement) in placements.enumerated() { guard let placement, let group = groups[index], let bitmap = images[index] else { continue } guard lastAt[group] == index else { continue } kept.append((placement, bitmap)) + let m = prepared.markers[index] + if let label = m.label { + keptLabels.append(KeptLabel( + label: label, + anchorX: m.centerNormX * tilePx + Double(paddingPx), + anchorY: m.centerNormY * tilePx + Double(paddingPx), + scale: m.drawScale + )) + } + } + let tileSide = self.tileSize + // 文字は本体をすべて描いたあと。ほかの点の下に字が隠れない。 + let drawLabels: ((CGContext) -> Void)? = keptLabels.isEmpty ? nil : { context in + for kept in keptLabels { + kept.label.draw( + in: context, + centerX: CGFloat(kept.anchorX + Double(kept.label.centerOffset.x) * kept.scale), + centerY: CGFloat(kept.anchorY + Double(kept.label.centerOffset.y) * kept.scale), + scale: CGFloat(kept.scale) + ) + } } // GPU で描けたらそれを使う。描けなければ下の CPU 経路へ落ちる -- // Metal が無い端末、アトラスに収まらない絵、パイプラインを拒む環境。 if !debugTileOverlay, let gpu = gpuRasterizer(), - let png = Self.renderOnGpu(gpu, kept: kept, paddingPx: paddingPx) { + let png = Self.renderOnGpu(gpu, kept: kept, paddingPx: paddingPx, overlay: drawLabels.map { draw in + { pixels in + // CPU 経路と同じ向きの context を読み戻した画素に被せて、文字を足す。 + guard let context = CGContext( + data: pixels, width: tileSide, height: tileSide, + bitsPerComponent: 8, bytesPerRow: tileSide * 4, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { return } + context.translateBy(x: 0, y: CGFloat(tileSide)) + context.scaleBy(x: 1, y: -1) + context.translateBy(x: CGFloat(-paddingPx), y: CGFloat(-paddingPx)) + draw(context) + } + }) { if Self.tracePhases { print(String(format: "MARKERTILE_PHASES z=%d markers=%d passes=%d query=%.1fms prepare=%.1fms gpuDraw=%.1fms", z, prepared.markers.count, passes, queryMs, prepareMs, Self.since(drawStart))) @@ -480,6 +544,7 @@ public final class MarkerTileRenderer: TileProvider { height: CGFloat(placement.height))) context.restoreGState() } + drawLabels?(context) let drawMs = Self.tracePhases ? Self.since(drawStart) : 0 let encodeStart = Self.tracePhases ? Self.now() : 0 @@ -524,6 +589,18 @@ public final class MarkerTileRenderer: TileProvider { let drawW: CGFloat let drawH: CGFloat let anchor: CGPoint + /// 本体とは別にタイルへ直接描くラベル。`TileLabelledIcon` のときだけ。 + let label: MarkerTileLabel? + /// 本体の描画倍率。ラベルにも同じ倍率を掛ける。 + let drawScale: Double + } + + /// 残ったマーカーのラベル。本体をすべて描いたあとで描く。 + private struct KeptLabel { + let label: MarkerTileLabel + let anchorX: Double + let anchorY: Double + let scale: Double } private struct PreparedResult { @@ -565,7 +642,8 @@ public final class MarkerTileRenderer: TileProvider { private static func renderOnGpu( _ gpu: MetalMarkerRasterizer, kept: [(placement: MarkerPlacement, bitmap: CGImage)], - paddingPx: Int + paddingPx: Int, + overlay: ((UnsafeMutableRawPointer) -> Void)? ) -> Data? { guard !kept.isEmpty else { return nil } let atlasStart = tracePhases ? now() : 0 @@ -607,7 +685,7 @@ public final class MarkerTileRenderer: TileProvider { // 削るかで打ち手が違う。 let atlasMs = tracePhases ? since(atlasStart) : 0 let drawStart = tracePhases ? now() : 0 - let png = gpu.renderPng(instances: instances, atlas: atlas.texture, paddingPx: paddingPx) + let png = gpu.renderPng(instances: instances, atlas: atlas.texture, paddingPx: paddingPx, overlay: overlay) if tracePhases { print(String(format: "MARKERTILE_GPU kept=%d icons=%d atlas=%.1fms drawEncode=%.1fms", kept.count, uniqueImages.count, atlasMs, since(drawStart))) @@ -627,7 +705,11 @@ public final class MarkerTileRenderer: TileProvider { markers.reserveCapacity(entities.count) for entity in entities { - let icon = (entity.state.icon?.toBitmapIcon() ?? defaultIcon) + // 本体とラベルを分けて渡せるアイコンは、ラベルを焼き込んだ画像を作らせない。 + // 本体だけをアトラスに入れ、文字はタイルに直接描く(`MarkerTileLabel`)。 + let labelled = entity.state.icon as? TileLabelledIcon + let icon = labelled?.tileBody ?? (entity.state.icon?.toBitmapIcon() ?? defaultIcon) + let label = labelled?.tileLabel let pos = entity.state.position let tilePoint = geoToTilePoint(longitude: pos.longitude, latitude: pos.latitude, zoom: zoom) let centerNormX = tilePoint.x - tileX @@ -637,13 +719,21 @@ public final class MarkerTileRenderer: TileProvider { // icon.size already includes MarkerIconProtocol.scale (baked into the // bitmap by toBitmapIcon), so it must not be applied again here. let scale = max(callbackScale, 0.0) * self.extraIconScale + noteScale(scale, zoom: zoom, state: entity.state) let drawW = max(Double(icon.size.width) * scale, 1.0) let drawH = max(Double(icon.size.height) * scale, 1.0) let anchorX = Double(icon.anchor.x) let anchorY = Double(icon.anchor.y) - let halfX = max(abs(drawW * anchorX), abs(drawW * (1.0 - anchorX))) - let halfY = max(abs(drawH * anchorY), abs(drawH * (1.0 - anchorY))) + var halfX = max(abs(drawW * anchorX), abs(drawW * (1.0 - anchorX))) + var halfY = max(abs(drawH * anchorY), abs(drawH * (1.0 - anchorY))) + if let label { + // ラベルもはみ出しに数える。境界をまたぐ文字を隣のタイルも描くため。 + let dx = abs(Double(label.centerOffset.x)) + Double(label.size.width) / 2 + let dy = abs(Double(label.centerOffset.y)) + Double(label.size.height) / 2 + halfX = max(halfX, dx * scale) + halfY = max(halfY, dy * scale) + } maxHalfExtentPx = max(maxHalfExtentPx, max(halfX, halfY)) markers.append(PreparedMarker( @@ -652,13 +742,44 @@ public final class MarkerTileRenderer: TileProvider { centerNormY: centerNormY, drawW: CGFloat(drawW), drawH: CGFloat(drawH), - anchor: icon.anchor + anchor: icon.anchor, + label: label, + drawScale: scale )) } return PreparedResult(markers: markers, maxHalfExtentPx: maxHalfExtentPx) } + /// 探す範囲に使う、はみ出し量(px)。 + /// + /// `observedHalfExtentPx` は「これまでに描いたタイルで見た最大」なので、ラベルを付けた + /// 直後に全タイルが一斉に描き直されると、まだ育っていない値で探すタイルが出る。 + /// そのタイルに自分のマーカーが無ければ空として返し、2 回目の探索もしないので、 + /// 隣から**はみ出してくるラベルが境目で切れる**(縦書きで縦に長くなって目立った)。 + /// + /// ラベル付きのアイコンは作った時点ではみ出し量を ``LabelledIconExtent`` に記録して + /// いるので、それも使う。全マーカーを数え直す方法は、`@Published` の読み出しだけで + /// 14.4 万本に 0.6 秒以上かかり、ラベルの表示が目に見えて遅れたのでやめた。 + private func queryHalfExtentPx(zoom: Int) -> Double { + scaleLock.lock() + var scale = maxScaleByZoom[zoom] + let sample = sampleState + scaleLock.unlock() + if scale == nil, let sample { + scale = max(iconScaleCallback?(sample, zoom) ?? 1.0, 0.0) * extraIconScale + } + guard let scale else { return observedHalfExtentPx } + return max(observedHalfExtentPx, LabelledIconExtent.maxPt * scale) + } + + private func noteScale(_ scale: Double, zoom: Int, state: MarkerState) { + scaleLock.lock() + if scale > (maxScaleByZoom[zoom] ?? 0) { maxScaleByZoom[zoom] = scale } + if sampleState == nil { sampleState = state } + scaleLock.unlock() + } + private func queryByHalfExtentPx( _ halfExtentPx: Double, bounds: GeoRectBounds, diff --git a/Sources/MapConductorCore/marker/MetalMarkerRasterizer.swift b/Sources/MapConductorCore/marker/MetalMarkerRasterizer.swift index 1262383..b87b3cd 100644 --- a/Sources/MapConductorCore/marker/MetalMarkerRasterizer.swift +++ b/Sources/MapConductorCore/marker/MetalMarkerRasterizer.swift @@ -203,12 +203,25 @@ final class MetalMarkerRasterizer { } /// 描いて PNG にする。描けなければ nil で、呼び出し側は CPU へ落ちる。 - func renderPng(instances: [Instance], atlas: MTLTexture, paddingPx: Int) -> Data? { + /// + /// `overlay` は読み戻した画素(乗算済み RGBA、上の行から)を PNG にする前に受け取る。 + /// GPU では描けないもの -- タイルに直接描くラベルの文字 -- をそこで足す。 + func renderPng( + instances: [Instance], + atlas: MTLTexture, + paddingPx: Int, + overlay: ((UnsafeMutableRawPointer) -> Void)? = nil + ) -> Data? { guard !instances.isEmpty else { return nil } - return queue.sync { draw(instances: instances, atlas: atlas, paddingPx: paddingPx) } + return queue.sync { draw(instances: instances, atlas: atlas, paddingPx: paddingPx, overlay: overlay) } } - private func draw(instances: [Instance], atlas: MTLTexture, paddingPx: Int) -> Data? { + private func draw( + instances: [Instance], + atlas: MTLTexture, + paddingPx: Int, + overlay: ((UnsafeMutableRawPointer) -> Void)? + ) -> Data? { let target: MTLTexture if let existing = self.target { target = existing @@ -259,6 +272,7 @@ final class MetalMarkerRasterizer { } return readback.withUnsafeMutableBytes { raw -> Data? in guard let base = raw.baseAddress else { return nil } + overlay?(base) // ブレンドは乗算済みアルファのまま積むので、CPU 経路と同じ扱い。 return TilePngEncoder.encode(rgba: base, width: tileSize, height: tileSize, premultiplied: true) diff --git a/api/MapConductorCore.api.swift b/api/MapConductorCore.api.swift index 9f73b05..a0a5e66 100644 --- a/api/MapConductorCore.api.swift +++ b/api/MapConductorCore.api.swift @@ -1623,16 +1623,18 @@ final public class ColorDefaultIcon : MapConductorCore::MarkerIconProtocol { public static let defaultLabelTypeFace: UIKit::UIFont public static let defaultInfoAnchor: CoreFoundation::CGPoint public static let defaultLabelPosition: Swift::String + public static let defaultLabelWritingMode: Swift::String final public let scale: CoreFoundation::CGFloat final public let anchor: CoreFoundation::CGPoint final public let iconSize: CoreFoundation::CGFloat final public let infoAnchor: CoreFoundation::CGPoint final public let debug: Swift::Bool final public let labelPosition: Swift::String - public init(fillColor: UIKit::UIColor = defaultFillColor, strokeColor: UIKit::UIColor = defaultStrokeColor, strokeWidth: CoreFoundation::CGFloat = defaultStrokeWidth, scale: CoreFoundation::CGFloat = 1.0, label: Swift::String? = nil, labelTextColor: UIKit::UIColor? = defaultLabelTextColor, labelTextSize: CoreFoundation::CGFloat = defaultLabelTextSize, labelTypeFace: UIKit::UIFont = defaultLabelTypeFace, labelStrokeColor: UIKit::UIColor = defaultLabelStrokeColor, labelPosition: Swift::String = defaultLabelPosition, infoAnchor: CoreFoundation::CGPoint = defaultInfoAnchor, iconSize: CoreFoundation::CGFloat = defaultIconSize, debug: Swift::Bool = false) + final public let labelWritingMode: Swift::String + public init(fillColor: UIKit::UIColor = defaultFillColor, strokeColor: UIKit::UIColor = defaultStrokeColor, strokeWidth: CoreFoundation::CGFloat = defaultStrokeWidth, scale: CoreFoundation::CGFloat = 1.0, label: Swift::String? = nil, labelTextColor: UIKit::UIColor? = defaultLabelTextColor, labelTextSize: CoreFoundation::CGFloat = defaultLabelTextSize, labelTypeFace: UIKit::UIFont = defaultLabelTypeFace, labelStrokeColor: UIKit::UIColor = defaultLabelStrokeColor, labelPosition: Swift::String = defaultLabelPosition, labelWritingMode: Swift::String = defaultLabelWritingMode, infoAnchor: CoreFoundation::CGPoint = defaultInfoAnchor, iconSize: CoreFoundation::CGFloat = defaultIconSize, debug: Swift::Bool = false) final public func toBitmapIcon() -> MapConductorCore::BitmapIcon final public func hashCode() -> Swift::Int - final public func copy(fillColor: UIKit::UIColor? = nil, strokeColor: UIKit::UIColor? = nil, strokeWidth: CoreFoundation::CGFloat? = nil, scale: CoreFoundation::CGFloat? = nil, label: Swift::String? = nil, labelTextColor: UIKit::UIColor? = nil, labelTextSize: CoreFoundation::CGFloat? = nil, labelTypeFace: UIKit::UIFont? = nil, labelStrokeColor: UIKit::UIColor? = nil, labelPosition: Swift::String? = nil, iconSize: CoreFoundation::CGFloat? = nil, debug: Swift::Bool? = nil) -> MapConductorCore::ColorDefaultIcon + final public func copy(fillColor: UIKit::UIColor? = nil, strokeColor: UIKit::UIColor? = nil, strokeWidth: CoreFoundation::CGFloat? = nil, scale: CoreFoundation::CGFloat? = nil, label: Swift::String? = nil, labelTextColor: UIKit::UIColor? = nil, labelTextSize: CoreFoundation::CGFloat? = nil, labelTypeFace: UIKit::UIFont? = nil, labelStrokeColor: UIKit::UIColor? = nil, labelPosition: Swift::String? = nil, labelWritingMode: Swift::String? = nil, iconSize: CoreFoundation::CGFloat? = nil, debug: Swift::Bool? = nil) -> MapConductorCore::ColorDefaultIcon @objc deinit } public typealias DefaultMarkerIcon = MapConductorCore::ColorDefaultIcon @@ -1708,10 +1710,17 @@ final public class ImageIcon : MapConductorCore::MarkerIconProtocol { final public let anchor: CoreFoundation::CGPoint final public let infoAnchor: CoreFoundation::CGPoint final public let debug: Swift::Bool - public init(image: UIKit::UIImage, iconSize: CoreFoundation::CGFloat = defaultIconSize, scale: CoreFoundation::CGFloat = 1.0, anchor: CoreFoundation::CGPoint = defaultAnchor, infoAnchor: CoreFoundation::CGPoint = defaultInfoAnchor, debug: Swift::Bool = false) + final public let label: Swift::String? + final public let labelTextColor: UIKit::UIColor? + final public let labelTextSize: CoreFoundation::CGFloat + final public let labelTypeFace: UIKit::UIFont + final public let labelStrokeColor: UIKit::UIColor + final public let labelPosition: Swift::String + final public let labelWritingMode: Swift::String + public init(image: UIKit::UIImage, iconSize: CoreFoundation::CGFloat = defaultIconSize, scale: CoreFoundation::CGFloat = 1.0, anchor: CoreFoundation::CGPoint = defaultAnchor, infoAnchor: CoreFoundation::CGPoint = defaultInfoAnchor, debug: Swift::Bool = false, label: Swift::String? = nil, labelTextColor: UIKit::UIColor? = ColorDefaultIcon.defaultLabelTextColor, labelTextSize: CoreFoundation::CGFloat = ColorDefaultIcon.defaultLabelTextSize, labelTypeFace: UIKit::UIFont = ColorDefaultIcon.defaultLabelTypeFace, labelStrokeColor: UIKit::UIColor = ColorDefaultIcon.defaultLabelStrokeColor, labelPosition: Swift::String = ColorDefaultIcon.defaultLabelPosition, labelWritingMode: Swift::String = ColorDefaultIcon.defaultLabelWritingMode) final public func toBitmapIcon() -> MapConductorCore::BitmapIcon final public func hashCode() -> Swift::Int - final public func copy(image: UIKit::UIImage? = nil, iconSize: CoreFoundation::CGFloat? = nil, scale: CoreFoundation::CGFloat? = nil, anchor: CoreFoundation::CGPoint? = nil, infoAnchor: CoreFoundation::CGPoint? = nil, debug: Swift::Bool? = nil) -> MapConductorCore::ImageIcon + final public func copy(image: UIKit::UIImage? = nil, iconSize: CoreFoundation::CGFloat? = nil, scale: CoreFoundation::CGFloat? = nil, anchor: CoreFoundation::CGPoint? = nil, infoAnchor: CoreFoundation::CGPoint? = nil, debug: Swift::Bool? = nil, label: Swift::String? = nil, labelTextColor: UIKit::UIColor? = nil, labelTextSize: CoreFoundation::CGFloat? = nil, labelTypeFace: UIKit::UIFont? = nil, labelStrokeColor: UIKit::UIColor? = nil, labelPosition: Swift::String? = nil, labelWritingMode: Swift::String? = nil) -> MapConductorCore::ImageIcon @objc deinit } public struct MarkerAnimationOverlayEntry {