확장 모듈 만들기
히트맵・GeoJSON 레이어・마커 클러스터링은 모두 Core를 바꿔 쓰지 않고 밖에서 더한 별도 패키지입니다. 같은 창구가 공개되어 있으므로, 제3자도 같은 방식으로 자신의 확장을 배포할 수 있습니다. 이 페이지는 그 절차입니다.
00 · 확장 모듈의 형태
확장 모듈이란 코어 모듈에만 의존하는 독립된 패키지를 말합니다. 프로바이더의 패키지(react-for-*, android-for-*, ios-for-*)에는 의존하지 않습니다. 이것이 지켜져 있으면, 이용자가 어느 지도 프로바이더를 골라도 당신의 확장은 동작합니다.
코어의 공개 타입(상태・컬렉터・컨트롤러 계약・타일 서버・서비스 레지스트리)만 씁니다.
프로바이더의 패키지를 import 한다. 컨트롤러를 다른 타입으로 캐스트해 내부 필드를 들여다본다.
출하된 3가지가 그대로 참고 구현입니다. 만들고 싶은 것에 가까운 것을 읽는 것이 지름길입니다.
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 · 패키지를 만든다
의존은 코어만으로 합니다. 프로바이더는 이용자의 앱이 고르는 것이므로, 당신의 패키지에서는 보이지 않습니다.
dependencies {
implementation("com.mapconductor:core")
implementation("com.mapconductor:compose") // Composable을 공개한다면
// 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")]
// 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 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 · 렌더링의 출구를 고른다
여기가 가장 중요한 설계 판단입니다. 프로바이더마다 렌더링 코드를 쓰는 것이 아니라, 코어가 이미 가지고 있는 출구로 흘려 넣습니다. 출구는 2가지 있습니다.
직접 타일을 그린다
로컬 타일 서버에 렌더링 함수를 등록하고, 그 URL 템플릿을 가리키는 RasterLayer를 한 장 놓습니다. 히트맵과 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
// 맵 하나당 레지스트리 하나. 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; // 미등록 프로바이더에서는 조용히 비활성화한다// 맵 하나당 레지스트리 하나. state가 가지고 있다 state.serviceRegistry.put(MyCapabilityKey, myCapability); // 언마운트 때는 clear()가 아니라 remove(). 다른 capability를 말려들게 하지 않는다 state.serviceRegistry.remove(MyCapabilityKey);
지킬 것
이 4가지를 지키고 있으면, 이용자가 프로바이더를 교체해도, 다른 확장과 동시에 써도 망가지지 않습니다.
1 · 프로바이더의 패키지를 import 하지 않는다
필요해졌다면, 그것은 서비스 레지스트리로 받아야 할 기능입니다.
2 · 컨트롤러를 캐스트해 내부를 들여다보지 않는다
react-heatmap은 이전에 이것을 하고 있어서, 단일 슬롯의 카메라 리스너를 대피・복원하고 있었습니다. 확장이 2개 얹히면 서로 덮어씁니다. 공개된 registerOverlayController로 교체했습니다.
3 · 등록한 것은 반드시 해제한다
오버레이 컨트롤러, 타일 서버의 그룹, 서비스 레지스트리의 키. remove()는 1건만 취소합니다.
4 · 미등록이면 조용히 비활성화한다
필요한 capability가 없는 프로바이더에서는, 예외를 던지지 말고 아무것도 그리지 마세요. 이용자는 다른 부분을 계속 동작시킬 수 있습니다.