Building an extension module
Heatmaps, the GeoJSON layer and marker clustering are all separate packages bolted on from the outside without changing Core. The same seams are public, so anyone can ship their own extension the same way. This page is that procedure.
00 · What an extension is
An extension module is a standalone package that depends on the core module only — never on a provider package (react-for-*, android-for-*, ios-for-*). Hold that line and your extension works whichever map provider the user picked.
The three shipped extensions are the reference implementations. Reading whichever is closest to what you want is the shortest path.
heatmap ラスタタイルを自前で描く · タイルサーバ + RasterLayer geojson-layer 大量のベクタをタイル化する · タイルサーバ + RasterLayer marker-clustering マーカーを加工して差し戻す · MarkerState + サービスレジストリ
01 · Create the package
Depend on core alone. The provider is chosen by the consuming app, so your package never sees it.
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 · Define your state
Your users hold a state object. Follow the same shape as the core state types (MarkerState and friends) and they have nothing new to learn. Give it a fingerprint so it can ride the core collector and you get diffed updates for free.
OverlayCollector id → 状態 のマップ。追加/削除/差分と、値の変化通知をまとめる ComponentState id を持つ状態の契約(Android) fingerPrint() 描画に効く値だけを集めた比較用の値
03 · Pick a rendering output
This is the design decision that matters. Do not write per-provider drawing code — feed an output core already has. There are two.
Draw your own tiles
Register a render function with the local tile server and place one RasterLayer pointing at its URL template. Heatmap and the GeoJSON layer both work this way. It scales with data size and looks identical on every provider.
Emit markers or shapes
Transform your input into MarkerState / PolygonState and write it into the collector; the provider's normal path takes it from there. Clustering works this way, and clicks and drags keep working.
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 · Camera and teardown
If you need to follow zoom and pan, do not touch the map controller's own listeners: they are single-slot and two extensions will fight over them in a user's app. Register as an overlay controller and camera changes arrive through the same path every other overlay uses.
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 · Hand over a capability
Only needed when the four steps above are not enough: when you need something only the provider can build, take it through the MapServiceRegistry. Registration and lookup are keyed by a typed key, so nothing gets cast.
Marker clustering is the live example: native marker types differ per provider, so clustering cannot build a renderer itself. It resolves the MarkerRenderingSupport the provider registered and has that build the renderer.
// キーは 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);
Rules to keep
Keep these four and your extension survives a provider swap and coexists with other extensions.