拡張モジュールを作る
ヒートマップ・GeoJSON レイヤー・マーカークラスタリングは、どれも Core を書き換えずに外から足された別パッケージです。同じ口が公開されているので、第三者も同じやり方で自分の拡張を配布できます。このページはその手順です。
00 · 拡張モジュールの形
拡張モジュールとは、コアモジュールだけに依存する独立したパッケージのことです。プロバイダのパッケージ(react-for-*, android-for-*, ios-for-*)には依存しません。これが守られていれば、利用者がどの地図プロバイダを選んでいてもあなたの拡張は動きます。
出荷済みの 3 つがそのまま参考実装です。作りたいものに近いものを読むのが近道です。
heatmap ラスタタイルを自前で描く · タイルサーバ + RasterLayer geojson-layer 大量のベクタをタイル化する · タイルサーバ + RasterLayer marker-clustering マーカーを加工して差し戻す · MarkerState + サービスレジストリ
01 · パッケージを作る
依存はコアだけにします。プロバイダは利用者のアプリが選ぶものなので、あなたのパッケージからは見えません。
dependencies {
implementation("com.mapconductor:core")
implementation("com.mapconductor:compose") // Composable を公開するなら
// android-for-* には依存しない
}.package(url: "https://github.com/MapConductor/ios-sdk-core", from: "1.1.4"),
.target(
name: "MyMapExtension",
dependencies: [.product(name: "MapConductorCore", package: "ios-sdk-core")]
// ios-for-* には依存しない
){
"dependencies": {
"@mapconductor/js-sdk-core": "^0.1.1",
"@mapconductor/js-sdk-react": "^0.1.1"
},
"peerDependencies": { "react": "^18.0.0 || ^19.0.0" }
}02 · 状態を定義する
利用者が触るのは状態オブジェクトです。コアの状態型(MarkerState など)と同じ作法にしておくと、利用者が別のことを覚えずに済みます。値が変わったことを知らせる仕組み(フィンガープリント)を持たせておくと、コアのコレクタに載せて差分更新を受け取れます。
OverlayCollector id → 状態 のマップ。追加/削除/差分と、値の変化通知をまとめる ComponentState id を持つ状態の契約(Android) fingerPrint() 描画に効く値だけを集めた比較用の値
03 · 描画の出口を選ぶ
ここが一番大事な設計判断です。プロバイダごとに描画コードを書くのではなく、コアが既に持っている出口へ流し込みます。出口は 2 つあります。
自分でタイルを描く
ローカルのタイルサーバに描画関数を登録し、その URL テンプレートを指す RasterLayer を 1 枚置きます。ヒートマップと GeoJSON レイヤーがこれです。件数が多いほど有利で、どのプロバイダでも見た目が完全に一致します。
マーカーや図形として返す
入力を加工して MarkerState / PolygonState などに変換し、コレクタへ書き込みます。そこから先はプロバイダ通常の描画経路が処理します。クラスタリングがこれです。クリックやドラッグがそのまま効きます。
val tileServer = TileServerRegistry.get()
val renderer = MyTileRenderer(tileSize = 256) // タイル1枚を描く関数を持つ
DisposableEffect(groupId) {
tileServer.register(groupId, renderer)
onDispose { tileServer.unregister(groupId) }
}
val layer = remember {
RasterLayerState(
id = "my-ext-$groupId",
source = RasterLayerSource.UrlTemplate(
template = tileServer.urlTemplate(groupId, renderer.tileSize),
tileSize = renderer.tileSize,
scheme = TileScheme.XYZ,
),
)
}
RasterLayer(layer)public struct MyOverlay: MapOverlayItemProtocol, View {
let state: MyOverlayState
public func append(to content: inout MapViewContent) {
// ios-heatmap の HeatmapOverlay とまったく同じ差し込み方
content.rasterLayers.append(RasterLayer(state: state.rasterLayerState))
}
}import { createRasterLayerState, TileServerRegistry, TileScheme } from '@mapconductor/js-sdk-core';
import { RasterLayer } from '@mapconductor/js-sdk-react';
const tileServer = TileServerRegistry.get();
useEffect(() => {
tileServer.register(groupId, renderer); // renderer がタイル1枚を描く
return () => { tileServer.unregister(groupId); };
}, [groupId, tileServer, renderer]);
const state = useRef(createRasterLayerState({
id: `my-ext-${groupId}`,
source: {
kind: 'urlTemplate',
template: tileServer.urlTemplate(groupId, renderer.tileSize),
tileSize: renderer.tileSize,
scheme: TileScheme.XYZ,
},
})).current;
return <RasterLayer state={state} />;04 · カメラと後始末
ズームやパンに追従したい場合、地図コントローラのイベントを直接いじってはいけません。単一スロットのリスナーを奪い合うことになり、拡張を 2 つ載せた利用者の環境で壊れます。オーバーレイコントローラとして登録すれば、他のオーバーレイと同じ経路でカメラ変更が届きます。
class MyCameraController(
private val renderer: MyTileRenderer,
) : OverlayControllerInterface<Unit, Unit>, OnCameraChangeReceiverInterface {
override val zIndex: Int = 0
override suspend fun add(data: List<Unit>) {}
override suspend fun update(state: Unit) {}
override suspend fun clear() {}
override fun find(position: GeoPointInterface): Unit? = null
override suspend fun onCameraChanged(mapCameraPosition: MapCameraPosition) {
renderer.updateCameraZoom(mapCameraPosition.zoom)
}
override fun destroy() {}
}
// 登録
val mapController = LocalMapViewController.current
DisposableEffect(mapController, cameraController) {
mapController.registerOverlayController(cameraController)
onDispose { cameraController.destroy() }
}public final class MyCameraController: OverlayControllerProtocol {
public typealias StateType = Void
public typealias EntityType = Void
public typealias EventType = Void
public let zIndex: Int = 0
public var clickListener: ((Void) -> Void)?
public func add(data: [Void]) async {}
public func update(state: Void) async {}
public func clear() async {}
public func find(position: GeoPointProtocol) -> Void? { nil }
public func onCameraChanged(mapCameraPosition: MapCameraPosition) async {
renderer.updateCameraZoom(mapCameraPosition.zoom)
}
public func destroy() {}
}public func append(to content: inout MapViewContent) {
// iOS の地図コンテンツはビュー階層ではなく MapViewContent という値なので、
// コントローラへは MapServiceRegistryScope 経由で到達する
MapServiceRegistryScope.current
.get(OverlayControllerRegistryKey.self)?
.register(cameraController)
content.rasterLayers.append(RasterLayer(state: rasterLayerState))
}// カメラを読む正規の経路は state。表示範囲もここから取る:
// const camera = mapViewState.cameraPosition;
// const bounds = camera.visibleRegion?.bounds;
// 変化を追うなら onCameraMove / onCameraMoveEnd か、下のように
// オーバーレイコントローラを登録して onCameraChanged を受ける。
export class MyCameraController implements OverlayController<void, void, void> {
readonly zIndex = 0;
clickListener: ((event: void) => void) | null = null;
constructor(private readonly renderer: MyTileRenderer) {}
add(): Promise<void> { return Promise.resolve(); }
update(): Promise<void> { return Promise.resolve(); }
clear(): Promise<void> { return Promise.resolve(); }
find(): void | null { return null; }
onCameraChanged(camera: MapCameraPosition): void {
this.renderer.updateCameraZoom(camera.zoom);
}
destroy(): void {}
}
// 登録
const { controller } = useContext(MapContext) ?? {};
useEffect(() => {
if (!controller) return;
controller.registerOverlayController?.(cameraController);
return () => { controller.unregisterOverlayController?.(cameraController); };
}, [controller, cameraController]);05 · 機能を受け渡す
ここまでの 4 つで足りない場合だけ使います。プロバイダにしか作れないものが必要なとき、MapServiceRegistry を通してそれを受け取ります。型付きキーで登録・取得するので、キャストは要りません。
実例はマーカークラスタリングです。ネイティブのマーカー型はプロバイダごとに違うため、クラスタリングは自分ではレンダラを作れません。そこでプロバイダが登録した MarkerRenderingSupport を引き当て、そこからレンダラを作ってもらいます。
// キーは singleton object として定義する object MyCapabilityKey : MapServiceKey<MyCapability> // 取り出す。未登録なら null なので、機能を落として続けるか諦めるかを決める val services = LocalMapServiceRegistry.current val capability = services.get(MyCapabilityKey) ?: return
// マップ1つにつき1つのレジストリ。state が持っている(react / ios と同じ)
state.serviceRegistry.put(MyCapabilityKey, myCapability)
// 消えるときは clear() ではなく remove()。他の capability を巻き添えにしない
DisposableEffect(state) {
onDispose { state.serviceRegistry.remove(MyCapabilityKey) }
}// キーは MapServiceKey に準拠した型として定義する
public enum MyCapabilityKey: MapServiceKey {
public typealias Value = any MyCapability
}
// content を組み立てているあいだだけレジストリが見える
guard let capability = MapServiceRegistryScope.current.get(MyCapabilityKey.self) else { return }import { createMapServiceKey } from '@mapconductor/js-sdk-core';
import { useMapServiceRegistry } from '@mapconductor/js-sdk-react';
export const MyCapabilityKey = createMapServiceKey<MyCapability>();
const services = useMapServiceRegistry();
const capability = services.get(MyCapabilityKey);
if (!capability) return null; // 未登録のプロバイダでは静かに無効化する// マップ1つにつき1つのレジストリ。state が持っている state.serviceRegistry.put(MyCapabilityKey, myCapability); // アンマウント時は clear() ではなく remove()。他の capability を巻き添えにしない state.serviceRegistry.remove(MyCapabilityKey);
守ること
この 4 つを守っていれば、利用者がプロバイダを差し替えても、別の拡張と同時に使っても壊れません。