From 786372972349340aea0a7dff1252d785d4e8aa49 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Thu, 1 Oct 2026 16:54:00 -0700 Subject: [PATCH 1/3] Cache TomTom base style for offline startup --- .../tomtom/raster/TomTomStyleComposer.kt | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/mapconductor/tomtom/raster/TomTomStyleComposer.kt b/src/main/java/com/mapconductor/tomtom/raster/TomTomStyleComposer.kt index 8e30de1..b750985 100644 --- a/src/main/java/com/mapconductor/tomtom/raster/TomTomStyleComposer.kt +++ b/src/main/java/com/mapconductor/tomtom/raster/TomTomStyleComposer.kt @@ -47,6 +47,13 @@ object TomTomStyleComposer { /** 可視ベースに使う TomTom ラスタ地図タイル(basic/main)テンプレート(%s = API キー)。 */ private const val BASE_RASTER_TILES = "https://api.tomtom.com/map/1/tile/basic/main/{z}/{x}/{y}.png?key=%s" + /** + * The browsing style's skeleton, kept so a map with no network still has + * the SDK layers it insists on. Private cache only: the JSON carries the + * API key. + */ + private const val BASE_STYLE_CACHE_FILE = "tomtom_base_style.json" + private const val TILE_SIZE = 256 /** 合成スタイルに載せる自前ラスタレイヤー1枚分の指定。 */ @@ -79,11 +86,22 @@ object TomTomStyleComposer { withoutBasemap: Boolean = false, ): Uri? = withContext(Dispatchers.IO) { + // The SDK will not take a style that has none of its own layers + // (it reverts to its default), so every composed style is built + // on the browsing style's skeleton -- which lives on + // api.tomtom.com. Keeping the last copy on disk is what lets the + // map come up at all with no network: the skeleton is reused and + // only the raster layers on top, which are served from the + // device, are drawn. + val cachedBase = File(cacheDir, BASE_STYLE_CACHE_FILE) val baseJson = - fetch(String.format(BROWSING_STYLE_URL, apiKey)) ?: run { - Log.e(TAG, "Failed to fetch base style JSON") - return@withContext null - } + fetch(String.format(BROWSING_STYLE_URL, apiKey)) + ?.also { runCatching { cachedBase.writeText(it) } } + ?: cachedBase.takeIf { it.isFile }?.let { runCatching { it.readText() }.getOrNull() } + ?: run { + Log.e(TAG, "Failed to fetch base style JSON and no cached copy") + return@withContext null + } val root = try { From 7651d21ebe6d183b0b924d0eb217b7502b2ab71e Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 10 Oct 2026 14:18:53 -0700 Subject: [PATCH 2/3] feat: integrate vector style support and pending map fixes --- api/android-for-tomtom.api | 4 +- gradle/libs.versions.toml | 2 +- .../mapconductor/tomtom/TomTomMapDesign.kt | 136 ++--- .../com/mapconductor/tomtom/TomTomMapView.kt | 12 + .../tomtom/TomTomMapViewController.kt | 17 + .../tomtom/raster/TomTomStyleComposer.kt | 529 +++++++++--------- 6 files changed, 369 insertions(+), 331 deletions(-) diff --git a/api/android-for-tomtom.api b/api/android-for-tomtom.api index 1983248..940e2ea 100644 --- a/api/android-for-tomtom.api +++ b/api/android-for-tomtom.api @@ -86,6 +86,8 @@ public final class com.mapconductor.tomtom.TomTomMapViewController extends com.m public void setMapDesignTypeChangeListener(kotlin.jvm.functions.Function1, kotlin.Unit>); public java.lang.Object compositionRasterLayers(java.util.List, kotlin.coroutines.Continuation); public java.lang.Object updateRasterLayer(com.mapconductor.core.raster.RasterLayerState, kotlin.coroutines.Continuation); + public void mountRasterLayer(com.mapconductor.core.raster.RasterLayerState); + public void unmountRasterLayer(java.lang.String); public boolean hasRasterLayer(com.mapconductor.core.raster.RasterLayerState); public java.lang.Object upsertRasterLayer(java.lang.String, java.lang.String, double, int, java.lang.Integer, java.lang.Integer, kotlin.coroutines.Continuation); public java.lang.Object removeRasterLayer(java.lang.String, kotlin.coroutines.Continuation); @@ -150,7 +152,7 @@ public final class com.mapconductor.tomtom.TomTomMapViewHolder implements com.ma public java.lang.Object getMap(); } public final class com.mapconductor.tomtom.TomTomMapViewKt { - public static final void TomTomMapView(com.mapconductor.tomtom.TomTomMapViewState, androidx.compose.ui.Modifier, com.mapconductor.core.marker.MarkerTilingOptions, com.mapconductor.core.map.CameraRestriction, kotlin.jvm.functions.Function2, ? extends java.lang.Object>, kotlin.jvm.functions.Function1, kotlin.Unit>, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function3, androidx.compose.runtime.Composer, int, int, int); + public static final void TomTomMapView(com.mapconductor.tomtom.TomTomMapViewState, androidx.compose.ui.Modifier, com.mapconductor.core.marker.MarkerTilingOptions, com.mapconductor.core.map.CameraRestriction, kotlin.jvm.functions.Function2, ? extends java.lang.Object>, kotlin.jvm.functions.Function1, kotlin.Unit>, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, com.mapconductor.core.map.MapViewStyle, kotlin.jvm.functions.Function1, kotlin.Unit>, kotlin.jvm.functions.Function3, androidx.compose.runtime.Composer, int, int, int); public static final com.mapconductor.tomtom.TomTomMapViewController createTomTomMapViewController(com.mapconductor.tomtom.TomTomMapViewHolder, com.mapconductor.core.marker.MarkerTilingOptions, com.mapconductor.core.map.MutableMapServiceRegistry); } public final class com.mapconductor.tomtom.TomTomMapViewSaver extends com.mapconductor.compose.map.BaseMapViewSaver { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 27e4342..200ceed 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,7 +11,7 @@ composeBom = "2025.02.00" lifecycleRuntimeKtx = "2.8.7" # TomTom Orbis Maps Display SDK # 参照: https://developer.tomtom.com/android/maps/documentation -tomtomMaps = "2.4.1" +tomtomMaps = "2.6.2" activityCompose = "1.9.3" ktLint = "14.2.0" diff --git a/src/main/java/com/mapconductor/tomtom/TomTomMapDesign.kt b/src/main/java/com/mapconductor/tomtom/TomTomMapDesign.kt index b0022ef..ac30fbd 100644 --- a/src/main/java/com/mapconductor/tomtom/TomTomMapDesign.kt +++ b/src/main/java/com/mapconductor/tomtom/TomTomMapDesign.kt @@ -1,68 +1,68 @@ -package com.mapconductor.tomtom - -import com.mapconductor.core.map.AttributionRule -import com.mapconductor.core.map.MapDesignTypeInterface -import com.tomtom.sdk.map.display.style.StandardStyles -import android.net.Uri -import com.mapconductor.core.map.BlankMapStyle -import com.tomtom.sdk.map.display.style.StyleDescriptor - -typealias TomTomMapDesignType = MapDesignTypeInterface - -/** - * TomTom のマップデザイン(スタイル)。 - * - * ## ★ Orbis のスタイル記述子は使わない - * - * `StandardStyles.TomTomOrbisMaps.*`(`tomtom://styles/standard/orbismap/browsing` 等)を - * 渡すと `loadStyle` は **success を返すのに何も描画されない**(地色だけの地図になる)。 - * 実機(Pixel 5a / map-display 2.4.x)で確認したうえ、API 側も - * `api.tomtom.com/maps/orbis/map-display/...` が 400 を返し、classic の - * `api.tomtom.com/map/1/tile/basic/main/...` は 200 を返す。Orbis のベクタ地図は - * 別建ての権限が要り、キーがそれを持たないと**エラーではなく無音の白紙**になる。 - * - * `MapOptions.mapStyle` の既定は `null`(SDK 内部の既定スタイル)で、これは描画される。 - * その挙動に合わせて classic の `StandardStyles.TomTomMaps.*` を使う。Orbis が要る - * 場合は、Orbis 権限付きのキーを用意したうえでここを差し替えること。 - * - * `id` / `getValue()` は安定キー(保存・復元に使用)で、実際に TomTom へ読み込ませる - * [StyleDescriptor] は [styleDescriptor] が保持する(ライト/ダークは各 [StandardStyles] - * の descriptor に内包され `StyleMode` で切り替わる)。 - */ -sealed class TomTomMapDesign( - override val id: String, - val styleDescriptor: StyleDescriptor, - override val attributionRules: List = emptyList(), -) : TomTomMapDesignType { - /** 既定(ブラウジング)スタイル。 */ - /** ベースマップ無し。背景色だけのスタイル(core の同梱アセット)。 */ - object None : TomTomMapDesign("none", StyleDescriptor(Uri.parse(BlankMapStyle.ASSET_URI))) - - object Standard : TomTomMapDesign("standard", StandardStyles.TomTomMaps.BROWSING) - - /** ナビゲーション向けスタイル。 */ - object Driving : TomTomMapDesign("driving", StandardStyles.TomTomMaps.DRIVING) - - /** 衛星写真スタイル。 */ - object Satellite : TomTomMapDesign("satellite", StandardStyles.TomTomMaps.SATELLITE) - - /** 任意の [StyleDescriptor](独自スタイル URI 等)を使うカスタムデザイン。 */ - class Custom( - id: String, - styleDescriptor: StyleDescriptor, - attributionRules: List = emptyList(), - ) : TomTomMapDesign(id, styleDescriptor, attributionRules) - - override fun getValue(): String = id - - companion object { - fun create(id: String): TomTomMapDesign = - when (id) { - None.id -> None - Standard.id -> Standard - Driving.id -> Driving - Satellite.id -> Satellite - else -> Standard - } - } -} +package com.mapconductor.tomtom + +import com.mapconductor.core.map.AttributionRule +import com.mapconductor.core.map.BlankMapStyle +import com.mapconductor.core.map.MapDesignTypeInterface +import com.tomtom.sdk.map.display.style.StandardStyles +import com.tomtom.sdk.map.display.style.StyleDescriptor +import android.net.Uri + +typealias TomTomMapDesignType = MapDesignTypeInterface + +/** + * TomTom のマップデザイン(スタイル)。 + * + * ## ★ Orbis のスタイル記述子は使わない + * + * `StandardStyles.TomTomOrbisMaps.*`(`tomtom://styles/standard/orbismap/browsing` 等)を + * 渡すと `loadStyle` は **success を返すのに何も描画されない**(地色だけの地図になる)。 + * 実機(Pixel 5a / map-display 2.4.x)で確認したうえ、API 側も + * `api.tomtom.com/maps/orbis/map-display/...` が 400 を返し、classic の + * `api.tomtom.com/map/1/tile/basic/main/...` は 200 を返す。Orbis のベクタ地図は + * 別建ての権限が要り、キーがそれを持たないと**エラーではなく無音の白紙**になる。 + * + * `MapOptions.mapStyle` の既定は `null`(SDK 内部の既定スタイル)で、これは描画される。 + * その挙動に合わせて classic の `StandardStyles.TomTomMaps.*` を使う。Orbis が要る + * 場合は、Orbis 権限付きのキーを用意したうえでここを差し替えること。 + * + * `id` / `getValue()` は安定キー(保存・復元に使用)で、実際に TomTom へ読み込ませる + * [StyleDescriptor] は [styleDescriptor] が保持する(ライト/ダークは各 [StandardStyles] + * の descriptor に内包され `StyleMode` で切り替わる)。 + */ +sealed class TomTomMapDesign( + override val id: String, + val styleDescriptor: StyleDescriptor, + override val attributionRules: List = emptyList(), +) : TomTomMapDesignType { + /** 既定(ブラウジング)スタイル。 */ + /** ベースマップ無し。背景色だけのスタイル(core の同梱アセット)。 */ + object None : TomTomMapDesign("none", StyleDescriptor(Uri.parse(BlankMapStyle.ASSET_URI))) + + object Standard : TomTomMapDesign("standard", StandardStyles.TomTomMaps.BROWSING) + + /** ナビゲーション向けスタイル。 */ + object Driving : TomTomMapDesign("driving", StandardStyles.TomTomMaps.DRIVING) + + /** 衛星写真スタイル。 */ + object Satellite : TomTomMapDesign("satellite", StandardStyles.TomTomMaps.SATELLITE) + + /** 任意の [StyleDescriptor](独自スタイル URI 等)を使うカスタムデザイン。 */ + class Custom( + id: String, + styleDescriptor: StyleDescriptor, + attributionRules: List = emptyList(), + ) : TomTomMapDesign(id, styleDescriptor, attributionRules) + + override fun getValue(): String = id + + companion object { + fun create(id: String): TomTomMapDesign = + when (id) { + None.id -> None + Standard.id -> Standard + Driving.id -> Driving + Satellite.id -> Satellite + else -> Standard + } + } +} diff --git a/src/main/java/com/mapconductor/tomtom/TomTomMapView.kt b/src/main/java/com/mapconductor/tomtom/TomTomMapView.kt index 9b38301..cd55210 100644 --- a/src/main/java/com/mapconductor/tomtom/TomTomMapView.kt +++ b/src/main/java/com/mapconductor/tomtom/TomTomMapView.kt @@ -18,6 +18,7 @@ import com.mapconductor.core.map.MapCameraPosition import com.mapconductor.core.map.MapCameraPositionInterface import com.mapconductor.core.map.MapCapability import com.mapconductor.core.map.MapCapabilityStatus +import com.mapconductor.core.map.MapViewStyle import com.mapconductor.core.map.MutableMapServiceRegistry import com.mapconductor.core.marker.MarkerEventControllerInterface import com.mapconductor.core.marker.MarkerOverlayRendererInterface @@ -54,6 +55,15 @@ fun TomTomMapView( onCameraMoveStart: OnCameraMoveHandler? = null, onCameraMove: OnCameraMoveHandler? = null, onCameraMoveEnd: OnCameraMoveHandler? = null, + /** + * How the map looks, when the app states it rather than naming a design. + * + * A vector style *is* the basemap. `com.mapconductor:vectorstyle` builds + * one; what happens underneath depends on this backend and the app does + * not have to know. + */ + style: MapViewStyle? = null, + onStyleDiagnostics: ((List) -> Unit)? = null, content: (@Composable TomTomMapViewScope.() -> Unit)? = null, ) { val scope = remember { TomTomMapViewScope() } @@ -182,6 +192,8 @@ fun TomTomMapView( // 地図が生成されない)。初期化が必要なのは検索/ルーティング等のナビ機能のみ。 sdkInitialize?.invoke(context) ?: true }, + style = style, + onStyleDiagnostics = onStyleDiagnostics, content = content, ) } diff --git a/src/main/java/com/mapconductor/tomtom/TomTomMapViewController.kt b/src/main/java/com/mapconductor/tomtom/TomTomMapViewController.kt index d14057f..e8dd877 100644 --- a/src/main/java/com/mapconductor/tomtom/TomTomMapViewController.kt +++ b/src/main/java/com/mapconductor/tomtom/TomTomMapViewController.kt @@ -364,6 +364,23 @@ class TomTomMapViewController internal constructor( applyPublicRasterLayer(state) } + /** + * content の外から載せるラスタ(マーカータイル、ベクタースタイルのラスタ化)。 + * + * 他プロバイダは [com.mapconductor.core.raster.RasterLayerController] を + * オーバーレイコントローラとして登録してあるので基底の実装で足りるが、 + * TomTom はラスタを自前の合成スタイルへ畳み込むのでそれが無い。 + * **`publicRasterLayerIds` に入れないこと** — 入れると次の composition が + * 「アプリが宣言していないレイヤ」として掃除してしまう。 + */ + override fun mountRasterLayer(state: RasterLayerState) { + mainCoroutine.launch { applyPublicRasterLayer(state) } + } + + override fun unmountRasterLayer(id: String) { + mainCoroutine.launch { removeRasterLayer(id) } + } + override fun hasRasterLayer(state: RasterLayerState): Boolean = composedRasterLayers.containsKey(state.id) override suspend fun upsertRasterLayer( diff --git a/src/main/java/com/mapconductor/tomtom/raster/TomTomStyleComposer.kt b/src/main/java/com/mapconductor/tomtom/raster/TomTomStyleComposer.kt index b750985..2b20c4d 100644 --- a/src/main/java/com/mapconductor/tomtom/raster/TomTomStyleComposer.kt +++ b/src/main/java/com/mapconductor/tomtom/raster/TomTomStyleComposer.kt @@ -1,261 +1,268 @@ -package com.mapconductor.tomtom.raster - -import com.mapconductor.core.map.BlankMapStyle -import org.json.JSONArray -import org.json.JSONObject -import java.io.File -import java.net.HttpURLConnection -import java.net.URL -import android.net.Uri -import android.util.Log -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext - -/** - * TomTom の browsing ベーススタイルへ「TomTom ラスタ地図(可視ベース)」と「自前マーカーラスタ」を - * 注入した合成 style JSON を組み立て、`loadStyle` 可能なローカル `file://` URI を返すユーティリティ。 - * - * ## 実機スパイクで確定した制約と、この構成の理由 - * TomTom Map Display SDK 1.26.7 には **実行時に source/layer を追加する公開 API が無い**ため、 - * マーカーラスタ層を足すにはスタイル全体を合成して `loadStyle` するしかない。さらに: - * - * 1. `loadStyle` は SDK オーバーレイ用の必須レイヤー(polyline-shape / polygon-shape / circle-shape / - * marker / route 等)がスタイルに存在することを要求する(無いと "Invalid layer mapping" で失敗)。 - * → **フルの browsing スタイルをベースにする**必要がある。 - * 2. ただし browsing の **proprietary ベクタタイルは `file://` ロードでは描画されない**(SDK 内部の - * ベクタタイルプロバイダ依存)。→ 可視ベース地図として **TomTom のラスタ地図タイル** - * (`map/1/tile/basic/main/{z}/{x}/{y}.png`)を最下層に足す(ラスタは `file://` でも描画される)。 - * 3. その上に自前マーカーラスタ(ローカルタイルサーバ)を重ねる。 - * - * ベーススタイル URL: `https://api.tomtom.com/style/1/style/25.2.*?key=...&map=gosdk/basic_street-light&...` - * (実機 preloadedStyles と `DefaultStyleUriTransformer` より確認。`25.2.*` はワイルドカードで CDN が - * 最新解決するため SDK パッチ更新に追随不要)。返却 JSON はキー埋め込み済み・MapLibre 互換 v8。 - * - * NOTE(security): 生成 JSON には API キーが平文で含まれる。書き出しは必ずアプリ private cacheDir に - * 限定し、URL/内容をログやリポジトリへ出力しないこと([[no-hardcoded-secrets]])。 - */ -object TomTomStyleComposer { - private const val TAG = "TomTomStyleComposer" - - /** browsing / light フルスタイルの実 URL テンプレート(%s = API キー)。SDK 必須レイヤーを含む。 */ - private const val BROWSING_STYLE_URL = - "https://api.tomtom.com/style/1/style/25.2.*" + - "?key=%s&map=gosdk/basic_street-light" + - "&traffic_incidents=gosdk/incidents_light&traffic_flow=gosdk/flow_relative-light" + - "&hillshade=2-test/hillshade_dem-light" - - /** 可視ベースに使う TomTom ラスタ地図タイル(basic/main)テンプレート(%s = API キー)。 */ - private const val BASE_RASTER_TILES = "https://api.tomtom.com/map/1/tile/basic/main/{z}/{x}/{y}.png?key=%s" - - /** - * The browsing style's skeleton, kept so a map with no network still has - * the SDK layers it insists on. Private cache only: the JSON carries the - * API key. - */ - private const val BASE_STYLE_CACHE_FILE = "tomtom_base_style.json" - - private const val TILE_SIZE = 256 - - /** 合成スタイルに載せる自前ラスタレイヤー1枚分の指定。 */ - data class RasterSpec( - val id: String, - val tilesUrl: String, - val opacity: Double, - val tileSize: Int = TILE_SIZE, - // ソースの minzoom/maxzoom。特に maxzoom を設定するとそれ以上のズームでは - // maxzoom タイルをオーバーズーム表示するため、実タイルが無い高ズームでの - // 404(=歯抜け)を防げる。null なら未指定(全ズームで実タイルを要求)。 - val minZoom: Int? = null, - val maxZoom: Int? = null, - ) - - /** - * 合成スタイル(フル browsing + ラスタベース + 任意のラスタレイヤー群)を生成し、その `file://` URI を返す。 - * マーカータイル・GroundImage など複数のラスタレイヤーを [layers] の順(=下→上)で重ねられる。 - * - * @param apiKey TomTom API キー - * @param cacheDir 書き出し先(アプリ private `context.cacheDir`) - * @param layers 追加するラスタレイヤー群(tiles URL テンプレートは `{z}/{x}/{y}` を含むローカルサーバ URL) - */ - suspend fun composeRasterStyle( - apiKey: String, - cacheDir: File, - layers: List, - outFile: File = File(cacheDir, "tomtom_composed_style.json"), - // [TomTomMapDesign.None]: ベースマップを描かない(本文のコメント参照)。 - withoutBasemap: Boolean = false, - ): Uri? = - withContext(Dispatchers.IO) { - // The SDK will not take a style that has none of its own layers - // (it reverts to its default), so every composed style is built - // on the browsing style's skeleton -- which lives on - // api.tomtom.com. Keeping the last copy on disk is what lets the - // map come up at all with no network: the skeleton is reused and - // only the raster layers on top, which are served from the - // device, are drawn. - val cachedBase = File(cacheDir, BASE_STYLE_CACHE_FILE) - val baseJson = - fetch(String.format(BROWSING_STYLE_URL, apiKey)) - ?.also { runCatching { cachedBase.writeText(it) } } - ?: cachedBase.takeIf { it.isFile }?.let { runCatching { it.readText() }.getOrNull() } - ?: run { - Log.e(TAG, "Failed to fetch base style JSON and no cached copy") - return@withContext null - } - - val root = - try { - JSONObject(baseJson) - } catch (e: Exception) { - Log.e(TAG, "Failed to parse base style JSON", e) - return@withContext null - } - - val sources = root.optJSONObject("sources") ?: JSONObject().also { root.put("sources", it) } - val styleLayers = root.optJSONArray("layers") ?: JSONArray().also { root.put("layers", it) } - if (withoutBasemap) { - // browsing スタイルを骨組みのまま残し(この SDK は自前のレイヤーを持たない - // スタイルを渡すと既定スタイルへ戻る)、background 以外の全レイヤーを - // 非表示にする。TomTom のタイルは取得されず、ラスタは背景色の上に載る。 - // この SDK は読み込み後に自分のレイヤーの visibility を戻す(実測: 非表示だけでは - // ベースマップが残った。iOS 版はそれで消える)ので、不透明度も 0 にする。 - for (i in 0 until styleLayers.length()) { - val layer = styleLayers.optJSONObject(i) ?: continue - val type = layer.optString("type") - if (type == "background") { - layer.put("paint", JSONObject().put("background-color", BlankMapStyle.BACKGROUND_COLOR)) - continue - } - val layout = layer.optJSONObject("layout") ?: JSONObject().also { layer.put("layout", it) } - layout.put("visibility", "none") - val paint = layer.optJSONObject("paint") ?: JSONObject().also { layer.put("paint", it) } - when (type) { - "fill" -> paint.put("fill-opacity", 0) - "line" -> paint.put("line-opacity", 0) - "symbol" -> { paint.put("text-opacity", 0); paint.put("icon-opacity", 0) } - "raster" -> paint.put("raster-opacity", 0) - "fill-extrusion" -> paint.put("fill-extrusion-opacity", 0) - "circle" -> { paint.put("circle-opacity", 0); paint.put("circle-stroke-opacity", 0) } - "heatmap" -> paint.put("heatmap-opacity", 0) - "hillshade" -> paint.put("hillshade-exaggeration", 0) - } - } - } else { - sources.put("mc-base-raster", rasterSource(String.format(BASE_RASTER_TILES, apiKey), TILE_SIZE, null, null)) - - // 可視ベース地図(ラスタ)は最下層付近("background" の直後)に挿入し、 - // ラベル/オーバーレイ/マーカーがその上に来るようにする。 - val baseLayer = rasterLayer("mc-base-raster-layer", "mc-base-raster", 1.0) - val insertAt = - if (styleLayers.length() > 0 && - styleLayers.optJSONObject(0)?.optString("type") == "background" - ) { - 1 - } else { - 0 - } - insertLayerAt(styleLayers, insertAt, baseLayer) - } - - // 自前ラスタレイヤー(マーカータイル / GroundImage など)を最前面(末尾)へ順に重ねる。 - layers.forEachIndexed { index, spec -> - val sourceId = "mc-raster-src-$index" - sources.put(sourceId, rasterSource(spec.tilesUrl, spec.tileSize, spec.minZoom, spec.maxZoom)) - styleLayers.put(rasterLayer("mc-raster-layer-$index", sourceId, spec.opacity)) - } - - try { - outFile.writeText(root.toString()) - } catch (e: Exception) { - Log.e(TAG, "Failed to write composed style", e) - return@withContext null - } - Log.i( - TAG, - "Composed style written (${outFile.length()} bytes, ${styleLayers.length()} layers, ${layers.size} raster)", - ) - Uri.fromFile(outFile) - } - - /** - * この SDK は宣言した `tileSize` の**倍の大きさ**でタイルを敷く。 - * - * MapConductor の `tileSize` は「タイル 1 枚が覆うレイアウト単位」で、MapLibre は - * そのとおりに敷く。同じスタイル JSON を渡しているのに、この SDK は 1 段粗いズームの - * タイルを取ってきて 2 倍に引き伸ばす。 - * - * 実測(GeoJSON Layer ページ、地図ズーム 13、`tileSize` 512、Lenovo 実機): - * MapLibre / MapTiler / Longdo が線 6px なのに対し TomTom だけ 12px。 - * iOS でも同じ倍率(MapLibre 18px に対し TomTom 35px)で、要求されるタイルが - * z=12 ではなく z=11 になっていることまで確認してある。半分を宣言すると揃う。 - * - * **ここを外すと GeoJSON レイヤ・ヒートマップ・タイル方式マーカーが揃って 2 倍に - * 描かれる**(線が異常に太く、しかもぼやける)。 - * ios-for-tomtom の `TomTomStyleComposer.tileSizeScale` と同じ扱い。 - */ - private const val TILE_SIZE_SCALE = 2 - - private fun rasterSource( - tilesUrl: String, - tileSize: Int, - minZoom: Int?, - maxZoom: Int?, - ): JSONObject = - JSONObject().apply { - put("type", "raster") - put("tiles", JSONArray().put(tilesUrl)) - put("tileSize", (tileSize / TILE_SIZE_SCALE).coerceAtLeast(1)) - minZoom?.let { put("minzoom", it) } - // maxzoom を設定すると、それ以上のズームでは maxzoom タイルをスケールして - // 表示する(オーバーズーム)ため、実タイルが無い高ズームでの歯抜けを防ぐ。 - maxZoom?.let { put("maxzoom", it) } - } - - private fun rasterLayer( - id: String, - source: String, - opacity: Double, - ): JSONObject = - JSONObject().apply { - put("id", id) - put("type", "raster") - put("source", source) - put("paint", JSONObject().put("raster-opacity", opacity)) - } - - /** JSONArray への位置指定挿入(org.json は insert が無いため末尾に足してからシフト)。 */ - private fun insertLayerAt( - layers: JSONArray, - index: Int, - layer: JSONObject, - ) { - layers.put(layer) - for (i in layers.length() - 1 downTo index + 1) { - layers.put(i, layers.get(i - 1)) - } - layers.put(index, layer) - } - - private fun fetch(url: String): String? = - try { - (URL(url).openConnection() as HttpURLConnection).run { - connectTimeout = 15_000 - readTimeout = 15_000 - requestMethod = "GET" - setRequestProperty("User-Agent", "MapConductor") - try { - if (responseCode in 200..299) { - inputStream.bufferedReader().use { it.readText() } - } else { - Log.e(TAG, "Base style fetch HTTP $responseCode") - null - } - } finally { - disconnect() - } - } - } catch (e: Exception) { - Log.e(TAG, "Base style fetch error", e) - null - } -} +package com.mapconductor.tomtom.raster + +import com.mapconductor.core.map.BlankMapStyle +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.net.HttpURLConnection +import java.net.URL +import android.net.Uri +import android.util.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * TomTom の browsing ベーススタイルへ「TomTom ラスタ地図(可視ベース)」と「自前マーカーラスタ」を + * 注入した合成 style JSON を組み立て、`loadStyle` 可能なローカル `file://` URI を返すユーティリティ。 + * + * ## 実機スパイクで確定した制約と、この構成の理由 + * TomTom Map Display SDK 1.26.7 には **実行時に source/layer を追加する公開 API が無い**ため、 + * マーカーラスタ層を足すにはスタイル全体を合成して `loadStyle` するしかない。さらに: + * + * 1. `loadStyle` は SDK オーバーレイ用の必須レイヤー(polyline-shape / polygon-shape / circle-shape / + * marker / route 等)がスタイルに存在することを要求する(無いと "Invalid layer mapping" で失敗)。 + * → **フルの browsing スタイルをベースにする**必要がある。 + * 2. ただし browsing の **proprietary ベクタタイルは `file://` ロードでは描画されない**(SDK 内部の + * ベクタタイルプロバイダ依存)。→ 可視ベース地図として **TomTom のラスタ地図タイル** + * (`map/1/tile/basic/main/{z}/{x}/{y}.png`)を最下層に足す(ラスタは `file://` でも描画される)。 + * 3. その上に自前マーカーラスタ(ローカルタイルサーバ)を重ねる。 + * + * ベーススタイル URL: `https://api.tomtom.com/style/1/style/25.2.*?key=...&map=gosdk/basic_street-light&...` + * (実機 preloadedStyles と `DefaultStyleUriTransformer` より確認。`25.2.*` はワイルドカードで CDN が + * 最新解決するため SDK パッチ更新に追随不要)。返却 JSON はキー埋め込み済み・MapLibre 互換 v8。 + * + * NOTE(security): 生成 JSON には API キーが平文で含まれる。書き出しは必ずアプリ private cacheDir に + * 限定し、URL/内容をログやリポジトリへ出力しないこと([[no-hardcoded-secrets]])。 + */ +object TomTomStyleComposer { + private const val TAG = "TomTomStyleComposer" + + /** browsing / light フルスタイルの実 URL テンプレート(%s = API キー)。SDK 必須レイヤーを含む。 */ + private const val BROWSING_STYLE_URL = + "https://api.tomtom.com/style/1/style/25.2.*" + + "?key=%s&map=gosdk/basic_street-light" + + "&traffic_incidents=gosdk/incidents_light&traffic_flow=gosdk/flow_relative-light" + + "&hillshade=2-test/hillshade_dem-light" + + /** 可視ベースに使う TomTom ラスタ地図タイル(basic/main)テンプレート(%s = API キー)。 */ + private const val BASE_RASTER_TILES = "https://api.tomtom.com/map/1/tile/basic/main/{z}/{x}/{y}.png?key=%s" + + /** + * The browsing style's skeleton, kept so a map with no network still has + * the SDK layers it insists on. Private cache only: the JSON carries the + * API key. + */ + private const val BASE_STYLE_CACHE_FILE = "tomtom_base_style.json" + + private const val TILE_SIZE = 256 + + /** 合成スタイルに載せる自前ラスタレイヤー1枚分の指定。 */ + data class RasterSpec( + val id: String, + val tilesUrl: String, + val opacity: Double, + val tileSize: Int = TILE_SIZE, + // ソースの minzoom/maxzoom。特に maxzoom を設定するとそれ以上のズームでは + // maxzoom タイルをオーバーズーム表示するため、実タイルが無い高ズームでの + // 404(=歯抜け)を防げる。null なら未指定(全ズームで実タイルを要求)。 + val minZoom: Int? = null, + val maxZoom: Int? = null, + ) + + /** + * 合成スタイル(フル browsing + ラスタベース + 任意のラスタレイヤー群)を生成し、その `file://` URI を返す。 + * マーカータイル・GroundImage など複数のラスタレイヤーを [layers] の順(=下→上)で重ねられる。 + * + * @param apiKey TomTom API キー + * @param cacheDir 書き出し先(アプリ private `context.cacheDir`) + * @param layers 追加するラスタレイヤー群(tiles URL テンプレートは `{z}/{x}/{y}` を含むローカルサーバ URL) + */ + suspend fun composeRasterStyle( + apiKey: String, + cacheDir: File, + layers: List, + outFile: File = File(cacheDir, "tomtom_composed_style.json"), + // [TomTomMapDesign.None]: ベースマップを描かない(本文のコメント参照)。 + withoutBasemap: Boolean = false, + ): Uri? = + withContext(Dispatchers.IO) { + // The SDK will not take a style that has none of its own layers + // (it reverts to its default), so every composed style is built + // on the browsing style's skeleton -- which lives on + // api.tomtom.com. Keeping the last copy on disk is what lets the + // map come up at all with no network: the skeleton is reused and + // only the raster layers on top, which are served from the + // device, are drawn. + val cachedBase = File(cacheDir, BASE_STYLE_CACHE_FILE) + val baseJson = + fetch(String.format(BROWSING_STYLE_URL, apiKey)) + ?.also { runCatching { cachedBase.writeText(it) } } + ?: cachedBase.takeIf { it.isFile }?.let { runCatching { it.readText() }.getOrNull() } + ?: run { + Log.e(TAG, "Failed to fetch base style JSON and no cached copy") + return@withContext null + } + + val root = + try { + JSONObject(baseJson) + } catch (e: Exception) { + Log.e(TAG, "Failed to parse base style JSON", e) + return@withContext null + } + + val sources = root.optJSONObject("sources") ?: JSONObject().also { root.put("sources", it) } + val styleLayers = root.optJSONArray("layers") ?: JSONArray().also { root.put("layers", it) } + if (withoutBasemap) { + // browsing スタイルを骨組みのまま残し(この SDK は自前のレイヤーを持たない + // スタイルを渡すと既定スタイルへ戻る)、background 以外の全レイヤーを + // 非表示にする。TomTom のタイルは取得されず、ラスタは背景色の上に載る。 + // この SDK は読み込み後に自分のレイヤーの visibility を戻す(実測: 非表示だけでは + // ベースマップが残った。iOS 版はそれで消える)ので、不透明度も 0 にする。 + for (i in 0 until styleLayers.length()) { + val layer = styleLayers.optJSONObject(i) ?: continue + val type = layer.optString("type") + if (type == "background") { + layer.put("paint", JSONObject().put("background-color", BlankMapStyle.BACKGROUND_COLOR)) + continue + } + val layout = layer.optJSONObject("layout") ?: JSONObject().also { layer.put("layout", it) } + layout.put("visibility", "none") + val paint = layer.optJSONObject("paint") ?: JSONObject().also { layer.put("paint", it) } + when (type) { + "fill" -> paint.put("fill-opacity", 0) + "line" -> paint.put("line-opacity", 0) + "symbol" -> { + paint.put("text-opacity", 0) + paint.put("icon-opacity", 0) + } + "raster" -> paint.put("raster-opacity", 0) + "fill-extrusion" -> paint.put("fill-extrusion-opacity", 0) + "circle" -> { + paint.put("circle-opacity", 0) + paint.put("circle-stroke-opacity", 0) + } + "heatmap" -> paint.put("heatmap-opacity", 0) + "hillshade" -> paint.put("hillshade-exaggeration", 0) + } + } + } else { + sources + .put("mc-base-raster", rasterSource(String.format(BASE_RASTER_TILES, apiKey), TILE_SIZE, null, null)) + + // 可視ベース地図(ラスタ)は最下層付近("background" の直後)に挿入し、 + // ラベル/オーバーレイ/マーカーがその上に来るようにする。 + val baseLayer = rasterLayer("mc-base-raster-layer", "mc-base-raster", 1.0) + val insertAt = + if (styleLayers.length() > 0 && + styleLayers.optJSONObject(0)?.optString("type") == "background" + ) { + 1 + } else { + 0 + } + insertLayerAt(styleLayers, insertAt, baseLayer) + } + + // 自前ラスタレイヤー(マーカータイル / GroundImage など)を最前面(末尾)へ順に重ねる。 + layers.forEachIndexed { index, spec -> + val sourceId = "mc-raster-src-$index" + sources.put(sourceId, rasterSource(spec.tilesUrl, spec.tileSize, spec.minZoom, spec.maxZoom)) + styleLayers.put(rasterLayer("mc-raster-layer-$index", sourceId, spec.opacity)) + } + + try { + outFile.writeText(root.toString()) + } catch (e: Exception) { + Log.e(TAG, "Failed to write composed style", e) + return@withContext null + } + Log.i( + TAG, + "Composed style written (${outFile.length()} bytes, ${styleLayers.length()} layers, ${layers.size} raster)", + ) + Uri.fromFile(outFile) + } + + /** + * この SDK は宣言した `tileSize` の**倍の大きさ**でタイルを敷く。 + * + * MapConductor の `tileSize` は「タイル 1 枚が覆うレイアウト単位」で、MapLibre は + * そのとおりに敷く。同じスタイル JSON を渡しているのに、この SDK は 1 段粗いズームの + * タイルを取ってきて 2 倍に引き伸ばす。 + * + * 実測(GeoJSON Layer ページ、地図ズーム 13、`tileSize` 512、Lenovo 実機): + * MapLibre / MapTiler / Longdo が線 6px なのに対し TomTom だけ 12px。 + * iOS でも同じ倍率(MapLibre 18px に対し TomTom 35px)で、要求されるタイルが + * z=12 ではなく z=11 になっていることまで確認してある。半分を宣言すると揃う。 + * + * **ここを外すと GeoJSON レイヤ・ヒートマップ・タイル方式マーカーが揃って 2 倍に + * 描かれる**(線が異常に太く、しかもぼやける)。 + * ios-for-tomtom の `TomTomStyleComposer.tileSizeScale` と同じ扱い。 + */ + private const val TILE_SIZE_SCALE = 2 + + private fun rasterSource( + tilesUrl: String, + tileSize: Int, + minZoom: Int?, + maxZoom: Int?, + ): JSONObject = + JSONObject().apply { + put("type", "raster") + put("tiles", JSONArray().put(tilesUrl)) + put("tileSize", (tileSize / TILE_SIZE_SCALE).coerceAtLeast(1)) + minZoom?.let { put("minzoom", it) } + // maxzoom を設定すると、それ以上のズームでは maxzoom タイルをスケールして + // 表示する(オーバーズーム)ため、実タイルが無い高ズームでの歯抜けを防ぐ。 + maxZoom?.let { put("maxzoom", it) } + } + + private fun rasterLayer( + id: String, + source: String, + opacity: Double, + ): JSONObject = + JSONObject().apply { + put("id", id) + put("type", "raster") + put("source", source) + put("paint", JSONObject().put("raster-opacity", opacity)) + } + + /** JSONArray への位置指定挿入(org.json は insert が無いため末尾に足してからシフト)。 */ + private fun insertLayerAt( + layers: JSONArray, + index: Int, + layer: JSONObject, + ) { + layers.put(layer) + for (i in layers.length() - 1 downTo index + 1) { + layers.put(i, layers.get(i - 1)) + } + layers.put(index, layer) + } + + private fun fetch(url: String): String? = + try { + (URL(url).openConnection() as HttpURLConnection).run { + connectTimeout = 15_000 + readTimeout = 15_000 + requestMethod = "GET" + setRequestProperty("User-Agent", "MapConductor") + try { + if (responseCode in 200..299) { + inputStream.bufferedReader().use { it.readText() } + } else { + Log.e(TAG, "Base style fetch HTTP $responseCode") + null + } + } finally { + disconnect() + } + } + } catch (e: Exception) { + Log.e(TAG, "Base style fetch error", e) + null + } +} From 15f7f72f29ae6e8d4cf5f88cba8e324ae65b10f8 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 24 Oct 2026 03:50:13 -0700 Subject: [PATCH 3/3] Keep each file's line endings as they are on develop The checkpoint commit rewrote whole files between LF and CRLF, which buried its real changes under line-ending noise. Content is unchanged. --- .../mapconductor/tomtom/TomTomMapDesign.kt | 136 ++--- .../tomtom/raster/TomTomStyleComposer.kt | 536 +++++++++--------- 2 files changed, 336 insertions(+), 336 deletions(-) diff --git a/src/main/java/com/mapconductor/tomtom/TomTomMapDesign.kt b/src/main/java/com/mapconductor/tomtom/TomTomMapDesign.kt index ac30fbd..372ae98 100644 --- a/src/main/java/com/mapconductor/tomtom/TomTomMapDesign.kt +++ b/src/main/java/com/mapconductor/tomtom/TomTomMapDesign.kt @@ -1,68 +1,68 @@ -package com.mapconductor.tomtom - -import com.mapconductor.core.map.AttributionRule -import com.mapconductor.core.map.BlankMapStyle -import com.mapconductor.core.map.MapDesignTypeInterface -import com.tomtom.sdk.map.display.style.StandardStyles -import com.tomtom.sdk.map.display.style.StyleDescriptor -import android.net.Uri - -typealias TomTomMapDesignType = MapDesignTypeInterface - -/** - * TomTom のマップデザイン(スタイル)。 - * - * ## ★ Orbis のスタイル記述子は使わない - * - * `StandardStyles.TomTomOrbisMaps.*`(`tomtom://styles/standard/orbismap/browsing` 等)を - * 渡すと `loadStyle` は **success を返すのに何も描画されない**(地色だけの地図になる)。 - * 実機(Pixel 5a / map-display 2.4.x)で確認したうえ、API 側も - * `api.tomtom.com/maps/orbis/map-display/...` が 400 を返し、classic の - * `api.tomtom.com/map/1/tile/basic/main/...` は 200 を返す。Orbis のベクタ地図は - * 別建ての権限が要り、キーがそれを持たないと**エラーではなく無音の白紙**になる。 - * - * `MapOptions.mapStyle` の既定は `null`(SDK 内部の既定スタイル)で、これは描画される。 - * その挙動に合わせて classic の `StandardStyles.TomTomMaps.*` を使う。Orbis が要る - * 場合は、Orbis 権限付きのキーを用意したうえでここを差し替えること。 - * - * `id` / `getValue()` は安定キー(保存・復元に使用)で、実際に TomTom へ読み込ませる - * [StyleDescriptor] は [styleDescriptor] が保持する(ライト/ダークは各 [StandardStyles] - * の descriptor に内包され `StyleMode` で切り替わる)。 - */ -sealed class TomTomMapDesign( - override val id: String, - val styleDescriptor: StyleDescriptor, - override val attributionRules: List = emptyList(), -) : TomTomMapDesignType { - /** 既定(ブラウジング)スタイル。 */ - /** ベースマップ無し。背景色だけのスタイル(core の同梱アセット)。 */ - object None : TomTomMapDesign("none", StyleDescriptor(Uri.parse(BlankMapStyle.ASSET_URI))) - - object Standard : TomTomMapDesign("standard", StandardStyles.TomTomMaps.BROWSING) - - /** ナビゲーション向けスタイル。 */ - object Driving : TomTomMapDesign("driving", StandardStyles.TomTomMaps.DRIVING) - - /** 衛星写真スタイル。 */ - object Satellite : TomTomMapDesign("satellite", StandardStyles.TomTomMaps.SATELLITE) - - /** 任意の [StyleDescriptor](独自スタイル URI 等)を使うカスタムデザイン。 */ - class Custom( - id: String, - styleDescriptor: StyleDescriptor, - attributionRules: List = emptyList(), - ) : TomTomMapDesign(id, styleDescriptor, attributionRules) - - override fun getValue(): String = id - - companion object { - fun create(id: String): TomTomMapDesign = - when (id) { - None.id -> None - Standard.id -> Standard - Driving.id -> Driving - Satellite.id -> Satellite - else -> Standard - } - } -} +package com.mapconductor.tomtom + +import com.mapconductor.core.map.AttributionRule +import com.mapconductor.core.map.BlankMapStyle +import com.mapconductor.core.map.MapDesignTypeInterface +import com.tomtom.sdk.map.display.style.StandardStyles +import com.tomtom.sdk.map.display.style.StyleDescriptor +import android.net.Uri + +typealias TomTomMapDesignType = MapDesignTypeInterface + +/** + * TomTom のマップデザイン(スタイル)。 + * + * ## ★ Orbis のスタイル記述子は使わない + * + * `StandardStyles.TomTomOrbisMaps.*`(`tomtom://styles/standard/orbismap/browsing` 等)を + * 渡すと `loadStyle` は **success を返すのに何も描画されない**(地色だけの地図になる)。 + * 実機(Pixel 5a / map-display 2.4.x)で確認したうえ、API 側も + * `api.tomtom.com/maps/orbis/map-display/...` が 400 を返し、classic の + * `api.tomtom.com/map/1/tile/basic/main/...` は 200 を返す。Orbis のベクタ地図は + * 別建ての権限が要り、キーがそれを持たないと**エラーではなく無音の白紙**になる。 + * + * `MapOptions.mapStyle` の既定は `null`(SDK 内部の既定スタイル)で、これは描画される。 + * その挙動に合わせて classic の `StandardStyles.TomTomMaps.*` を使う。Orbis が要る + * 場合は、Orbis 権限付きのキーを用意したうえでここを差し替えること。 + * + * `id` / `getValue()` は安定キー(保存・復元に使用)で、実際に TomTom へ読み込ませる + * [StyleDescriptor] は [styleDescriptor] が保持する(ライト/ダークは各 [StandardStyles] + * の descriptor に内包され `StyleMode` で切り替わる)。 + */ +sealed class TomTomMapDesign( + override val id: String, + val styleDescriptor: StyleDescriptor, + override val attributionRules: List = emptyList(), +) : TomTomMapDesignType { + /** 既定(ブラウジング)スタイル。 */ + /** ベースマップ無し。背景色だけのスタイル(core の同梱アセット)。 */ + object None : TomTomMapDesign("none", StyleDescriptor(Uri.parse(BlankMapStyle.ASSET_URI))) + + object Standard : TomTomMapDesign("standard", StandardStyles.TomTomMaps.BROWSING) + + /** ナビゲーション向けスタイル。 */ + object Driving : TomTomMapDesign("driving", StandardStyles.TomTomMaps.DRIVING) + + /** 衛星写真スタイル。 */ + object Satellite : TomTomMapDesign("satellite", StandardStyles.TomTomMaps.SATELLITE) + + /** 任意の [StyleDescriptor](独自スタイル URI 等)を使うカスタムデザイン。 */ + class Custom( + id: String, + styleDescriptor: StyleDescriptor, + attributionRules: List = emptyList(), + ) : TomTomMapDesign(id, styleDescriptor, attributionRules) + + override fun getValue(): String = id + + companion object { + fun create(id: String): TomTomMapDesign = + when (id) { + None.id -> None + Standard.id -> Standard + Driving.id -> Driving + Satellite.id -> Satellite + else -> Standard + } + } +} diff --git a/src/main/java/com/mapconductor/tomtom/raster/TomTomStyleComposer.kt b/src/main/java/com/mapconductor/tomtom/raster/TomTomStyleComposer.kt index 2b20c4d..ae86578 100644 --- a/src/main/java/com/mapconductor/tomtom/raster/TomTomStyleComposer.kt +++ b/src/main/java/com/mapconductor/tomtom/raster/TomTomStyleComposer.kt @@ -1,268 +1,268 @@ -package com.mapconductor.tomtom.raster - -import com.mapconductor.core.map.BlankMapStyle -import org.json.JSONArray -import org.json.JSONObject -import java.io.File -import java.net.HttpURLConnection -import java.net.URL -import android.net.Uri -import android.util.Log -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext - -/** - * TomTom の browsing ベーススタイルへ「TomTom ラスタ地図(可視ベース)」と「自前マーカーラスタ」を - * 注入した合成 style JSON を組み立て、`loadStyle` 可能なローカル `file://` URI を返すユーティリティ。 - * - * ## 実機スパイクで確定した制約と、この構成の理由 - * TomTom Map Display SDK 1.26.7 には **実行時に source/layer を追加する公開 API が無い**ため、 - * マーカーラスタ層を足すにはスタイル全体を合成して `loadStyle` するしかない。さらに: - * - * 1. `loadStyle` は SDK オーバーレイ用の必須レイヤー(polyline-shape / polygon-shape / circle-shape / - * marker / route 等)がスタイルに存在することを要求する(無いと "Invalid layer mapping" で失敗)。 - * → **フルの browsing スタイルをベースにする**必要がある。 - * 2. ただし browsing の **proprietary ベクタタイルは `file://` ロードでは描画されない**(SDK 内部の - * ベクタタイルプロバイダ依存)。→ 可視ベース地図として **TomTom のラスタ地図タイル** - * (`map/1/tile/basic/main/{z}/{x}/{y}.png`)を最下層に足す(ラスタは `file://` でも描画される)。 - * 3. その上に自前マーカーラスタ(ローカルタイルサーバ)を重ねる。 - * - * ベーススタイル URL: `https://api.tomtom.com/style/1/style/25.2.*?key=...&map=gosdk/basic_street-light&...` - * (実機 preloadedStyles と `DefaultStyleUriTransformer` より確認。`25.2.*` はワイルドカードで CDN が - * 最新解決するため SDK パッチ更新に追随不要)。返却 JSON はキー埋め込み済み・MapLibre 互換 v8。 - * - * NOTE(security): 生成 JSON には API キーが平文で含まれる。書き出しは必ずアプリ private cacheDir に - * 限定し、URL/内容をログやリポジトリへ出力しないこと([[no-hardcoded-secrets]])。 - */ -object TomTomStyleComposer { - private const val TAG = "TomTomStyleComposer" - - /** browsing / light フルスタイルの実 URL テンプレート(%s = API キー)。SDK 必須レイヤーを含む。 */ - private const val BROWSING_STYLE_URL = - "https://api.tomtom.com/style/1/style/25.2.*" + - "?key=%s&map=gosdk/basic_street-light" + - "&traffic_incidents=gosdk/incidents_light&traffic_flow=gosdk/flow_relative-light" + - "&hillshade=2-test/hillshade_dem-light" - - /** 可視ベースに使う TomTom ラスタ地図タイル(basic/main)テンプレート(%s = API キー)。 */ - private const val BASE_RASTER_TILES = "https://api.tomtom.com/map/1/tile/basic/main/{z}/{x}/{y}.png?key=%s" - - /** - * The browsing style's skeleton, kept so a map with no network still has - * the SDK layers it insists on. Private cache only: the JSON carries the - * API key. - */ - private const val BASE_STYLE_CACHE_FILE = "tomtom_base_style.json" - - private const val TILE_SIZE = 256 - - /** 合成スタイルに載せる自前ラスタレイヤー1枚分の指定。 */ - data class RasterSpec( - val id: String, - val tilesUrl: String, - val opacity: Double, - val tileSize: Int = TILE_SIZE, - // ソースの minzoom/maxzoom。特に maxzoom を設定するとそれ以上のズームでは - // maxzoom タイルをオーバーズーム表示するため、実タイルが無い高ズームでの - // 404(=歯抜け)を防げる。null なら未指定(全ズームで実タイルを要求)。 - val minZoom: Int? = null, - val maxZoom: Int? = null, - ) - - /** - * 合成スタイル(フル browsing + ラスタベース + 任意のラスタレイヤー群)を生成し、その `file://` URI を返す。 - * マーカータイル・GroundImage など複数のラスタレイヤーを [layers] の順(=下→上)で重ねられる。 - * - * @param apiKey TomTom API キー - * @param cacheDir 書き出し先(アプリ private `context.cacheDir`) - * @param layers 追加するラスタレイヤー群(tiles URL テンプレートは `{z}/{x}/{y}` を含むローカルサーバ URL) - */ - suspend fun composeRasterStyle( - apiKey: String, - cacheDir: File, - layers: List, - outFile: File = File(cacheDir, "tomtom_composed_style.json"), - // [TomTomMapDesign.None]: ベースマップを描かない(本文のコメント参照)。 - withoutBasemap: Boolean = false, - ): Uri? = - withContext(Dispatchers.IO) { - // The SDK will not take a style that has none of its own layers - // (it reverts to its default), so every composed style is built - // on the browsing style's skeleton -- which lives on - // api.tomtom.com. Keeping the last copy on disk is what lets the - // map come up at all with no network: the skeleton is reused and - // only the raster layers on top, which are served from the - // device, are drawn. - val cachedBase = File(cacheDir, BASE_STYLE_CACHE_FILE) - val baseJson = - fetch(String.format(BROWSING_STYLE_URL, apiKey)) - ?.also { runCatching { cachedBase.writeText(it) } } - ?: cachedBase.takeIf { it.isFile }?.let { runCatching { it.readText() }.getOrNull() } - ?: run { - Log.e(TAG, "Failed to fetch base style JSON and no cached copy") - return@withContext null - } - - val root = - try { - JSONObject(baseJson) - } catch (e: Exception) { - Log.e(TAG, "Failed to parse base style JSON", e) - return@withContext null - } - - val sources = root.optJSONObject("sources") ?: JSONObject().also { root.put("sources", it) } - val styleLayers = root.optJSONArray("layers") ?: JSONArray().also { root.put("layers", it) } - if (withoutBasemap) { - // browsing スタイルを骨組みのまま残し(この SDK は自前のレイヤーを持たない - // スタイルを渡すと既定スタイルへ戻る)、background 以外の全レイヤーを - // 非表示にする。TomTom のタイルは取得されず、ラスタは背景色の上に載る。 - // この SDK は読み込み後に自分のレイヤーの visibility を戻す(実測: 非表示だけでは - // ベースマップが残った。iOS 版はそれで消える)ので、不透明度も 0 にする。 - for (i in 0 until styleLayers.length()) { - val layer = styleLayers.optJSONObject(i) ?: continue - val type = layer.optString("type") - if (type == "background") { - layer.put("paint", JSONObject().put("background-color", BlankMapStyle.BACKGROUND_COLOR)) - continue - } - val layout = layer.optJSONObject("layout") ?: JSONObject().also { layer.put("layout", it) } - layout.put("visibility", "none") - val paint = layer.optJSONObject("paint") ?: JSONObject().also { layer.put("paint", it) } - when (type) { - "fill" -> paint.put("fill-opacity", 0) - "line" -> paint.put("line-opacity", 0) - "symbol" -> { - paint.put("text-opacity", 0) - paint.put("icon-opacity", 0) - } - "raster" -> paint.put("raster-opacity", 0) - "fill-extrusion" -> paint.put("fill-extrusion-opacity", 0) - "circle" -> { - paint.put("circle-opacity", 0) - paint.put("circle-stroke-opacity", 0) - } - "heatmap" -> paint.put("heatmap-opacity", 0) - "hillshade" -> paint.put("hillshade-exaggeration", 0) - } - } - } else { - sources - .put("mc-base-raster", rasterSource(String.format(BASE_RASTER_TILES, apiKey), TILE_SIZE, null, null)) - - // 可視ベース地図(ラスタ)は最下層付近("background" の直後)に挿入し、 - // ラベル/オーバーレイ/マーカーがその上に来るようにする。 - val baseLayer = rasterLayer("mc-base-raster-layer", "mc-base-raster", 1.0) - val insertAt = - if (styleLayers.length() > 0 && - styleLayers.optJSONObject(0)?.optString("type") == "background" - ) { - 1 - } else { - 0 - } - insertLayerAt(styleLayers, insertAt, baseLayer) - } - - // 自前ラスタレイヤー(マーカータイル / GroundImage など)を最前面(末尾)へ順に重ねる。 - layers.forEachIndexed { index, spec -> - val sourceId = "mc-raster-src-$index" - sources.put(sourceId, rasterSource(spec.tilesUrl, spec.tileSize, spec.minZoom, spec.maxZoom)) - styleLayers.put(rasterLayer("mc-raster-layer-$index", sourceId, spec.opacity)) - } - - try { - outFile.writeText(root.toString()) - } catch (e: Exception) { - Log.e(TAG, "Failed to write composed style", e) - return@withContext null - } - Log.i( - TAG, - "Composed style written (${outFile.length()} bytes, ${styleLayers.length()} layers, ${layers.size} raster)", - ) - Uri.fromFile(outFile) - } - - /** - * この SDK は宣言した `tileSize` の**倍の大きさ**でタイルを敷く。 - * - * MapConductor の `tileSize` は「タイル 1 枚が覆うレイアウト単位」で、MapLibre は - * そのとおりに敷く。同じスタイル JSON を渡しているのに、この SDK は 1 段粗いズームの - * タイルを取ってきて 2 倍に引き伸ばす。 - * - * 実測(GeoJSON Layer ページ、地図ズーム 13、`tileSize` 512、Lenovo 実機): - * MapLibre / MapTiler / Longdo が線 6px なのに対し TomTom だけ 12px。 - * iOS でも同じ倍率(MapLibre 18px に対し TomTom 35px)で、要求されるタイルが - * z=12 ではなく z=11 になっていることまで確認してある。半分を宣言すると揃う。 - * - * **ここを外すと GeoJSON レイヤ・ヒートマップ・タイル方式マーカーが揃って 2 倍に - * 描かれる**(線が異常に太く、しかもぼやける)。 - * ios-for-tomtom の `TomTomStyleComposer.tileSizeScale` と同じ扱い。 - */ - private const val TILE_SIZE_SCALE = 2 - - private fun rasterSource( - tilesUrl: String, - tileSize: Int, - minZoom: Int?, - maxZoom: Int?, - ): JSONObject = - JSONObject().apply { - put("type", "raster") - put("tiles", JSONArray().put(tilesUrl)) - put("tileSize", (tileSize / TILE_SIZE_SCALE).coerceAtLeast(1)) - minZoom?.let { put("minzoom", it) } - // maxzoom を設定すると、それ以上のズームでは maxzoom タイルをスケールして - // 表示する(オーバーズーム)ため、実タイルが無い高ズームでの歯抜けを防ぐ。 - maxZoom?.let { put("maxzoom", it) } - } - - private fun rasterLayer( - id: String, - source: String, - opacity: Double, - ): JSONObject = - JSONObject().apply { - put("id", id) - put("type", "raster") - put("source", source) - put("paint", JSONObject().put("raster-opacity", opacity)) - } - - /** JSONArray への位置指定挿入(org.json は insert が無いため末尾に足してからシフト)。 */ - private fun insertLayerAt( - layers: JSONArray, - index: Int, - layer: JSONObject, - ) { - layers.put(layer) - for (i in layers.length() - 1 downTo index + 1) { - layers.put(i, layers.get(i - 1)) - } - layers.put(index, layer) - } - - private fun fetch(url: String): String? = - try { - (URL(url).openConnection() as HttpURLConnection).run { - connectTimeout = 15_000 - readTimeout = 15_000 - requestMethod = "GET" - setRequestProperty("User-Agent", "MapConductor") - try { - if (responseCode in 200..299) { - inputStream.bufferedReader().use { it.readText() } - } else { - Log.e(TAG, "Base style fetch HTTP $responseCode") - null - } - } finally { - disconnect() - } - } - } catch (e: Exception) { - Log.e(TAG, "Base style fetch error", e) - null - } -} +package com.mapconductor.tomtom.raster + +import com.mapconductor.core.map.BlankMapStyle +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.net.HttpURLConnection +import java.net.URL +import android.net.Uri +import android.util.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * TomTom の browsing ベーススタイルへ「TomTom ラスタ地図(可視ベース)」と「自前マーカーラスタ」を + * 注入した合成 style JSON を組み立て、`loadStyle` 可能なローカル `file://` URI を返すユーティリティ。 + * + * ## 実機スパイクで確定した制約と、この構成の理由 + * TomTom Map Display SDK 1.26.7 には **実行時に source/layer を追加する公開 API が無い**ため、 + * マーカーラスタ層を足すにはスタイル全体を合成して `loadStyle` するしかない。さらに: + * + * 1. `loadStyle` は SDK オーバーレイ用の必須レイヤー(polyline-shape / polygon-shape / circle-shape / + * marker / route 等)がスタイルに存在することを要求する(無いと "Invalid layer mapping" で失敗)。 + * → **フルの browsing スタイルをベースにする**必要がある。 + * 2. ただし browsing の **proprietary ベクタタイルは `file://` ロードでは描画されない**(SDK 内部の + * ベクタタイルプロバイダ依存)。→ 可視ベース地図として **TomTom のラスタ地図タイル** + * (`map/1/tile/basic/main/{z}/{x}/{y}.png`)を最下層に足す(ラスタは `file://` でも描画される)。 + * 3. その上に自前マーカーラスタ(ローカルタイルサーバ)を重ねる。 + * + * ベーススタイル URL: `https://api.tomtom.com/style/1/style/25.2.*?key=...&map=gosdk/basic_street-light&...` + * (実機 preloadedStyles と `DefaultStyleUriTransformer` より確認。`25.2.*` はワイルドカードで CDN が + * 最新解決するため SDK パッチ更新に追随不要)。返却 JSON はキー埋め込み済み・MapLibre 互換 v8。 + * + * NOTE(security): 生成 JSON には API キーが平文で含まれる。書き出しは必ずアプリ private cacheDir に + * 限定し、URL/内容をログやリポジトリへ出力しないこと([[no-hardcoded-secrets]])。 + */ +object TomTomStyleComposer { + private const val TAG = "TomTomStyleComposer" + + /** browsing / light フルスタイルの実 URL テンプレート(%s = API キー)。SDK 必須レイヤーを含む。 */ + private const val BROWSING_STYLE_URL = + "https://api.tomtom.com/style/1/style/25.2.*" + + "?key=%s&map=gosdk/basic_street-light" + + "&traffic_incidents=gosdk/incidents_light&traffic_flow=gosdk/flow_relative-light" + + "&hillshade=2-test/hillshade_dem-light" + + /** 可視ベースに使う TomTom ラスタ地図タイル(basic/main)テンプレート(%s = API キー)。 */ + private const val BASE_RASTER_TILES = "https://api.tomtom.com/map/1/tile/basic/main/{z}/{x}/{y}.png?key=%s" + + /** + * The browsing style's skeleton, kept so a map with no network still has + * the SDK layers it insists on. Private cache only: the JSON carries the + * API key. + */ + private const val BASE_STYLE_CACHE_FILE = "tomtom_base_style.json" + + private const val TILE_SIZE = 256 + + /** 合成スタイルに載せる自前ラスタレイヤー1枚分の指定。 */ + data class RasterSpec( + val id: String, + val tilesUrl: String, + val opacity: Double, + val tileSize: Int = TILE_SIZE, + // ソースの minzoom/maxzoom。特に maxzoom を設定するとそれ以上のズームでは + // maxzoom タイルをオーバーズーム表示するため、実タイルが無い高ズームでの + // 404(=歯抜け)を防げる。null なら未指定(全ズームで実タイルを要求)。 + val minZoom: Int? = null, + val maxZoom: Int? = null, + ) + + /** + * 合成スタイル(フル browsing + ラスタベース + 任意のラスタレイヤー群)を生成し、その `file://` URI を返す。 + * マーカータイル・GroundImage など複数のラスタレイヤーを [layers] の順(=下→上)で重ねられる。 + * + * @param apiKey TomTom API キー + * @param cacheDir 書き出し先(アプリ private `context.cacheDir`) + * @param layers 追加するラスタレイヤー群(tiles URL テンプレートは `{z}/{x}/{y}` を含むローカルサーバ URL) + */ + suspend fun composeRasterStyle( + apiKey: String, + cacheDir: File, + layers: List, + outFile: File = File(cacheDir, "tomtom_composed_style.json"), + // [TomTomMapDesign.None]: ベースマップを描かない(本文のコメント参照)。 + withoutBasemap: Boolean = false, + ): Uri? = + withContext(Dispatchers.IO) { + // The SDK will not take a style that has none of its own layers + // (it reverts to its default), so every composed style is built + // on the browsing style's skeleton -- which lives on + // api.tomtom.com. Keeping the last copy on disk is what lets the + // map come up at all with no network: the skeleton is reused and + // only the raster layers on top, which are served from the + // device, are drawn. + val cachedBase = File(cacheDir, BASE_STYLE_CACHE_FILE) + val baseJson = + fetch(String.format(BROWSING_STYLE_URL, apiKey)) + ?.also { runCatching { cachedBase.writeText(it) } } + ?: cachedBase.takeIf { it.isFile }?.let { runCatching { it.readText() }.getOrNull() } + ?: run { + Log.e(TAG, "Failed to fetch base style JSON and no cached copy") + return@withContext null + } + + val root = + try { + JSONObject(baseJson) + } catch (e: Exception) { + Log.e(TAG, "Failed to parse base style JSON", e) + return@withContext null + } + + val sources = root.optJSONObject("sources") ?: JSONObject().also { root.put("sources", it) } + val styleLayers = root.optJSONArray("layers") ?: JSONArray().also { root.put("layers", it) } + if (withoutBasemap) { + // browsing スタイルを骨組みのまま残し(この SDK は自前のレイヤーを持たない + // スタイルを渡すと既定スタイルへ戻る)、background 以外の全レイヤーを + // 非表示にする。TomTom のタイルは取得されず、ラスタは背景色の上に載る。 + // この SDK は読み込み後に自分のレイヤーの visibility を戻す(実測: 非表示だけでは + // ベースマップが残った。iOS 版はそれで消える)ので、不透明度も 0 にする。 + for (i in 0 until styleLayers.length()) { + val layer = styleLayers.optJSONObject(i) ?: continue + val type = layer.optString("type") + if (type == "background") { + layer.put("paint", JSONObject().put("background-color", BlankMapStyle.BACKGROUND_COLOR)) + continue + } + val layout = layer.optJSONObject("layout") ?: JSONObject().also { layer.put("layout", it) } + layout.put("visibility", "none") + val paint = layer.optJSONObject("paint") ?: JSONObject().also { layer.put("paint", it) } + when (type) { + "fill" -> paint.put("fill-opacity", 0) + "line" -> paint.put("line-opacity", 0) + "symbol" -> { + paint.put("text-opacity", 0) + paint.put("icon-opacity", 0) + } + "raster" -> paint.put("raster-opacity", 0) + "fill-extrusion" -> paint.put("fill-extrusion-opacity", 0) + "circle" -> { + paint.put("circle-opacity", 0) + paint.put("circle-stroke-opacity", 0) + } + "heatmap" -> paint.put("heatmap-opacity", 0) + "hillshade" -> paint.put("hillshade-exaggeration", 0) + } + } + } else { + sources + .put("mc-base-raster", rasterSource(String.format(BASE_RASTER_TILES, apiKey), TILE_SIZE, null, null)) + + // 可視ベース地図(ラスタ)は最下層付近("background" の直後)に挿入し、 + // ラベル/オーバーレイ/マーカーがその上に来るようにする。 + val baseLayer = rasterLayer("mc-base-raster-layer", "mc-base-raster", 1.0) + val insertAt = + if (styleLayers.length() > 0 && + styleLayers.optJSONObject(0)?.optString("type") == "background" + ) { + 1 + } else { + 0 + } + insertLayerAt(styleLayers, insertAt, baseLayer) + } + + // 自前ラスタレイヤー(マーカータイル / GroundImage など)を最前面(末尾)へ順に重ねる。 + layers.forEachIndexed { index, spec -> + val sourceId = "mc-raster-src-$index" + sources.put(sourceId, rasterSource(spec.tilesUrl, spec.tileSize, spec.minZoom, spec.maxZoom)) + styleLayers.put(rasterLayer("mc-raster-layer-$index", sourceId, spec.opacity)) + } + + try { + outFile.writeText(root.toString()) + } catch (e: Exception) { + Log.e(TAG, "Failed to write composed style", e) + return@withContext null + } + Log.i( + TAG, + "Composed style written (${outFile.length()} bytes, ${styleLayers.length()} layers, ${layers.size} raster)", + ) + Uri.fromFile(outFile) + } + + /** + * この SDK は宣言した `tileSize` の**倍の大きさ**でタイルを敷く。 + * + * MapConductor の `tileSize` は「タイル 1 枚が覆うレイアウト単位」で、MapLibre は + * そのとおりに敷く。同じスタイル JSON を渡しているのに、この SDK は 1 段粗いズームの + * タイルを取ってきて 2 倍に引き伸ばす。 + * + * 実測(GeoJSON Layer ページ、地図ズーム 13、`tileSize` 512、Lenovo 実機): + * MapLibre / MapTiler / Longdo が線 6px なのに対し TomTom だけ 12px。 + * iOS でも同じ倍率(MapLibre 18px に対し TomTom 35px)で、要求されるタイルが + * z=12 ではなく z=11 になっていることまで確認してある。半分を宣言すると揃う。 + * + * **ここを外すと GeoJSON レイヤ・ヒートマップ・タイル方式マーカーが揃って 2 倍に + * 描かれる**(線が異常に太く、しかもぼやける)。 + * ios-for-tomtom の `TomTomStyleComposer.tileSizeScale` と同じ扱い。 + */ + private const val TILE_SIZE_SCALE = 2 + + private fun rasterSource( + tilesUrl: String, + tileSize: Int, + minZoom: Int?, + maxZoom: Int?, + ): JSONObject = + JSONObject().apply { + put("type", "raster") + put("tiles", JSONArray().put(tilesUrl)) + put("tileSize", (tileSize / TILE_SIZE_SCALE).coerceAtLeast(1)) + minZoom?.let { put("minzoom", it) } + // maxzoom を設定すると、それ以上のズームでは maxzoom タイルをスケールして + // 表示する(オーバーズーム)ため、実タイルが無い高ズームでの歯抜けを防ぐ。 + maxZoom?.let { put("maxzoom", it) } + } + + private fun rasterLayer( + id: String, + source: String, + opacity: Double, + ): JSONObject = + JSONObject().apply { + put("id", id) + put("type", "raster") + put("source", source) + put("paint", JSONObject().put("raster-opacity", opacity)) + } + + /** JSONArray への位置指定挿入(org.json は insert が無いため末尾に足してからシフト)。 */ + private fun insertLayerAt( + layers: JSONArray, + index: Int, + layer: JSONObject, + ) { + layers.put(layer) + for (i in layers.length() - 1 downTo index + 1) { + layers.put(i, layers.get(i - 1)) + } + layers.put(index, layer) + } + + private fun fetch(url: String): String? = + try { + (URL(url).openConnection() as HttpURLConnection).run { + connectTimeout = 15_000 + readTimeout = 15_000 + requestMethod = "GET" + setRequestProperty("User-Agent", "MapConductor") + try { + if (responseCode in 200..299) { + inputStream.bufferedReader().use { it.readText() } + } else { + Log.e(TAG, "Base style fetch HTTP $responseCode") + null + } + } finally { + disconnect() + } + } + } catch (e: Exception) { + Log.e(TAG, "Base style fetch error", e) + null + } +}