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.
Use only the core's public types — states, collectors, the controller contracts, the tile server, the service registry.
Import a provider package. Cast the controller to a different shape to reach an internal field.
The three shipped extensions are the reference implementations. Reading whichever is closest to what you want is the shortest path.
heatmap draws raster tiles itself · tile server + RasterLayer geojson-layer turns bulk vectors into tiles · tile server + RasterLayer marker-clustering reworks markers and hands back · MarkerState + service registry
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") // If you expose a Composable
// Do not depend on android-for-*
}.package(url: "https://github.com/MapConductor/ios-sdk-core", from: "1.3.1"),
.target(
name: "MyMapExtension",
dependencies: [.product(name: "MapConductorCore", package: "ios-sdk-core")]
// Do not depend on 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 an id → state map; gathers add/remove/diff and change notifications ComponentState the contract for a state that carries an id (Android) fingerPrint() a comparison value holding only what affects drawing
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) // Holds the function that draws a single tile
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) {
// Plugged in exactly the way ios-heatmap's HeatmapOverlay is
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); // The renderer draws one tile
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() {}
}
// Register it
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) {
// On iOS the map content is a MapViewContent value rather than a view hierarchy, so
// the controller is reached through MapServiceRegistryScope
MapServiceRegistryScope.current
.get(OverlayControllerRegistryKey.self)?
.register(cameraController)
content.rasterLayers.append(RasterLayer(state: rasterLayerState))
}// The proper way to read the camera is the state; the visible region comes from here too:
// const camera = mapViewState.cameraPosition;
// const bounds = camera.visibleRegion?.bounds;
// To follow changes use onCameraMove / onCameraMoveEnd, or as below
// register an overlay controller and receive 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 {}
}
// Register it
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.
// Define the key as a singleton object object MyCapabilityKey : MapServiceKey<MyCapability> // Fetch it. Unregistered means null, so decide whether to carry on without the feature or stop val services = LocalMapServiceRegistry.current val capability = services.get(MyCapabilityKey) ?: return
// One registry per map, held by the state (same as react and ios)
state.serviceRegistry.put(MyCapabilityKey, myCapability)
// When it goes away use remove(), not clear(), so other capabilities are not taken down with it
DisposableEffect(state) {
onDispose { state.serviceRegistry.remove(MyCapabilityKey) }
}// Define the key as a type conforming to MapServiceKey
public enum MyCapabilityKey: MapServiceKey {
public typealias Value = any MyCapability
}
// The registry is visible only while content is being assembled
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; // Silently disable it on providers where it is not registered// One registry per map, held by the state state.serviceRegistry.put(MyCapabilityKey, myCapability); // On unmount use remove(), not clear(), so other capabilities are not taken down with it state.serviceRegistry.remove(MyCapabilityKey);
Rules to keep
Keep these four and your extension survives a provider swap and coexists with other extensions.
1 · Never import a provider package
If you think you need to, that is a capability you should be taking through the service registry.
2 · Never cast the controller to reach internals
react-heatmap used to do exactly this, saving and restoring the single-slot camera listener. Two extensions would overwrite each other. It now uses the public registerOverlayController instead.
3 · Unregister everything you register
Overlay controllers, tile server groups, registry keys. remove() takes back exactly one entry.
4 · Degrade quietly when unresolved
On a provider that does not register what you need, draw nothing rather than throwing. The rest of the user's screen keeps working.