カメラ ― 抽象化のいちばん難しいところ
MapConductor でもっとも難しく、もっとも価値があるのがカメラの統一です。position・zoom・bearing・tilt・visibleRegion からなる1つのカメラモデルを、10種類以上の地図エンジンで同じ意味に揃えています。同じ MapCameraPosition を渡せば、どのプロバイダでも同じ景色が出ます。
01 · なぜ統一に意味があるのか
地図エンジンはそれぞれ別のカメラモデルを持っています。ズームで語るもの、カメラ高度で語るもの、地上の注視点を中心に回るもの、視点そのものを動かすもの。ネイティブSDKをそのまま使うと、プロバイダを差し替えた瞬間に「同じ数値なのに違う景色」になり、画面のロジックまで書き換えることになります。
MapConductor はこの差をアダプタ層に閉じ込め、アプリからは1つのカメラモデルだけを見せます。結果として、カメラ位置はプロバイダをまたいで持ち運べる値になります。サンプルアプリの Camera Sync ページでは、1つのカメラ位置を全プロバイダに流し込み、同じ地点・同じ縮尺・同じ傾きで並ぶことを実際に確認できます。
ズームか、高度か
注視点か、視点か
傾きの許容範囲
val camera = MapCameraPosition(
position = geoPoint,
zoom = 18.0,
tilt = 70.0,
)
moveCameraTo(camera)02 · 統一カメラモデル
カメラは MapCameraPosition ひとつで表します。プロバイダごとの型はなく、この値を作って moveCameraTo に渡すだけです。
copy() で一部だけ差し替えられ、比較はズーム 0.01・傾き 0.01 の許容誤差付きで行われます。プロバイダから返ってくる値のわずかな揺れで「動いた」と誤判定しないためです。
03 · ズームレベル=高さの統一
ズームは、内部では「カメラの高さ」と同じものとして扱われます。2Dタイル系のズーム、3D系の高度・視距離を相互に変換できるコンバータが core にあり、どのプロバイダの値もいったん共通のズーム(Google Maps 基準)に直してから公開されます。
この基準は理屈だけで決めたものではありません。サンプルアプリのキャリブレーションツールで、同じズームを指定したときに実際に見えている範囲を各エンジンで実測し、面積が一致するように基準高度を校正しています。だから zoom 14 は、どのプロバイダでも同じ広さです。
同じズームでも、緯度で必要な高さが変わる
Web メルカトルでは高緯度ほど地図が拡大されるため、同じズームでも赤道付近と高緯度では必要なカメラ高度が数倍違います。統一ズーム ⇄ 高度の変換にはこの補正が含まれています。
2倍ごとに1段という関係と、基準となる zoom 0 の高度だけが定数です。緯度が高いほど同じズームでの地上距離が縮む点も変換に含まれているため、東京とホノルルで縮尺がずれることはありません。
04 · tilt < 0 の擬似サポート
tilt は −60〜60 度を取ります。正の値は見慣れた斜め見下ろしですが、負の値は水平線より上を見る仰角ビューです。地図エンジンには、これを直接表現できるものがひとつもありません。どのSDKも pitch は 0 以上しか受け付けません。
そこで MapConductor は、カメラの目の位置と向きを保ったまま、エンジンには正の傾きとして与える形に置き換えます。アプリから見れば負の tilt はそのまま使えて、取得したカメラ位置も負の tilt として返ってきます。
React の Google Maps 2D(JavaScript API のラスタ地図)だけは対応外です。傾きがブラウザとタイル側の条件に依存し、擬似表現の前提となる高度と向きを制御できないためです。Google Maps 3D(Map3DElement)では負 tilt が使えます。
// −60〜60 のスライダーをそのまま渡すだけ。負の値でも同じ呼び出し currentPosition = currentPosition.copy(tilt = tilt) mapViewState.value?.moveCameraTo(currentPosition)
func setTilt(_ angle: Double, state: any MapViewStateProtocol) {
currentPosition = currentPosition.copy(tilt: angle)
state.moveCameraTo(cameraPosition: currentPosition)
}<SliderControl
label="Tilt" value={tilt} min={-60} max={60} step={1}
onChange={value => {
setTilt(value);
const next = cameraPositionRef.current.copy({ tilt: value });
cameraPositionRef.current = next;
mapViewState.moveCameraTo(next, 400);
}}
/>05 · VisibleRegion ― 実際に見えている範囲
カメラを傾けると、画面の四角形は地上では台形になります。南西と北東だけの矩形(bounds)では、手前と奥の広さの違いを表せません。VisibleRegion は矩形に加えて画面四隅の地上座標を持ち、傾いたカメラでも「本当に映っている範囲」を扱えるようにします。
near は画面の下辺、つまりカメラに近い側、far は上辺で遠い側です。left / right は画面基準なので、bearing で地図を回しても意味は変わりません。この4点が揃っているおかげで、傾いた地図でもデータ取得範囲や出典表記の判定を正しく行えます。
mapViewState.cameraPosition.visibleRegion?.let { region ->
val sw = region.bounds.southWest
val ne = region.bounds.northEast
// 傾いた画面の四隅。台形として扱える
val corners = listOf(region.nearLeft, region.nearRight, region.farLeft, region.farRight)
}if let region = cameraPosition.visibleRegion {
let bounds = region.bounds
let corners = [region.nearLeft, region.nearRight, region.farLeft, region.farRight]
}<MapViewContainer initialCamera={INIT_CAMERA} onCameraMove={setCameraPosition}>
{/* cameraPosition.visibleRegion の中身を並べて表示する */}
</MapViewContainer>
const visibleRegion = cameraPosition?.visibleRegion ?? null;
visibleRegion?.nearLeft?.toUrlValue(5);
visibleRegion?.farRight?.toUrlValue(5);