GeoJSON 레이어
GeoJSON을 그대로 지도에 겹치기 위한 확장 패키지입니다. 피처를 타일로 래스터화해 그리기 때문에, 수만 건 규모여도 1피처 1오브젝트를 만들지 않아도 됩니다. Android・iOS・React에서 API 이름・스타일 기본값・히트 테스트의 거동을 맞춰 놓았습니다.
01 · 개요
GeoJSON을 파싱해 가벼운 피처 모델로 변환하고, MapConductor의 래스터 타일 파이프라인을 통해 그립니다. 프로바이더(Google Maps・MapLibre・MapKit・HERE 등)가 무엇이든, 같은 코드・같은 겉모습이 됩니다.
파싱
FeatureCollection・단일 Feature・순수 지오메트리, 나아가 RFC 8142의 텍스트 시퀀스에 대응. 스트리밍 파서도 있습니다.
타일 렌더링
피처를 512px 타일로 래스터화해, 래스터 레이어로 지도에 얹습니다. 프로바이더의 벡터 기능에 의존하지 않습니다.
히트 테스트
렌더링에 쓴 것과 같은 좌표로 클릭 판정합니다. 구멍 뚫린 폴리곤・멀티파트・지오메트리 컬렉션에 대응.
모든 지오메트리 타입을 3개 플랫폼에서 동일하게 지원합니다.
02 · 기본 사용법
레이어는 지도 뷰의 content 스코프 안에 놓기만 하면 됩니다. 파싱은 백그라운드에서 하고, 결과를 features에 넘깁니다.
dependencies {
implementation("com.mapconductor:geojson:<version>")
}val mapViewState = rememberMapLibreMapViewState(
cameraPosition = MapCameraPosition(
position = GeoPoint.fromLongLat(139.7671, 35.6812),
zoom = 12.0,
),
)
val layerState = remember { GeoJSONLayerState() }
var features by remember { mutableStateOf(emptyList<GeoJSONFeature>()) }
LaunchedEffect(Unit) {
features = withContext(Dispatchers.IO) {
assets.open("wards.geojson").use(GeoJSONParser::parseStream)
}
}
MapLibreMapView(state = mapViewState) {
GeoJSONLayer(state = layerState, features = features)
}.package(url: "https://github.com/MapConductor/ios-geojson-layer", from: "<version>") // iOS 15+ / depends on MapConductorCore
@StateObject private var mapViewState = MapKitViewState(
cameraPosition: MapCameraPosition(
position: GeoPoint.fromLongLat(longitude: 139.7671, latitude: 35.6812),
zoom: 12.0
)
)
@StateObject private var layerState = GeoJSONLayerState()
@State private var features: [GeoJSONFeature] = []
var body: some View {
MapKitMapView(state: mapViewState) {
GeoJSONLayer(state: layerState, features: features)
}
.task {
features = GeoJSONParser.parse(fileURL: wardsURL)
}
}npm install @mapconductor/react-geojson \
@mapconductor/js-sdk-core @mapconductor/js-sdk-reactconst state = useMapLibreViewState({
mapDesignType: MapLibreDesign.OsmBrightJa,
cameraPosition: createMapCameraPosition({
position: createGeoPoint({ latitude: 35.6812, longitude: 139.7671 }),
zoom: 12,
}),
});
const layerState = useMemo(() => new GeoJSONLayerState(), []);
const features = useMemo(() => GeoJSONParser.parseFeatures(GEOJSON), []);
return (
<MapLibreMapView2D state={state}>
<GeoJSONLayer state={layerState} features={features} />
</MapLibreMapView2D>
);피처의 내용이나 스타일이 바뀌면, 레이어는 내부에서 타일 URL을 무효화합니다. 지도 SDK 쪽의 래스터 캐시가 오래된 그림을 계속 반환하는 일은 없습니다.
03 · 스타일의 정하는 법
스타일은 3층으로 해결됩니다
「레이어 전체의 기본값」「피처마다의 덮어쓰기」「StyleProvider에 의한 동적인 결정」의 3층입니다. 아래 층일수록 강하고, 지정하지 않은 항목은 위 층에서 이어받습니다. 우선 레이어 기본값만으로 시작하고, 필요해진 만큼만 아래 층을 더해 가는 것이 기본적인 진행 방식입니다.
3-1. 스타일 프로퍼티
다루는 프로퍼티는 4개뿐입니다. 더해서 레이어 쪽에는 표시 제어의 opacity・visible・minZoom / maxZoom이 있습니다.
3-2. 레이어 기본값을 정한다
우선 여기서 시작합니다. GeoJSONLayerState에 넘긴 값이, 모든 피처의 토대가 됩니다. 상태는 observable이므로, 나중에 대입하면 다시 그려집니다.
val layerState = remember {
GeoJSONLayerState(
strokeColor = Color.argb(220, 30, 136, 229),
fillColor = Color.argb(60, 30, 136, 229),
strokeWidth = 1.5f,
pointRadius = 8f,
opacity = 1f,
minZoom = 8, maxZoom = 22,
)
}
// 상태는 observable. 나중에 대입하면 타일이 다시 생성됩니다
layerState.fillColor = Color.argb(90, 214, 64, 69)@StateObject private var layerState = GeoJSONLayerState(
opacity: 1.0,
layerStyle: GeoJSONTileRenderer.LayerStyle(
strokeColor: UIColor(red: 30/255, green: 136/255, blue: 229/255, alpha: 0.86),
fillColor: UIColor(red: 30/255, green: 136/255, blue: 229/255, alpha: 0.24),
strokeWidth: 1.5,
pointRadius: 8.0
)
)
// LayerStyle의 필드는 let이므로 구조체째로 교체합니다(alpha는 UIColor 쪽)
layerState.layerStyle = GeoJSONTileRenderer.LayerStyle(
strokeColor: layerState.layerStyle.strokeColor,
fillColor: UIColor.systemRed.withAlphaComponent(0.35),
strokeWidth: layerState.layerStyle.strokeWidth,
pointRadius: layerState.layerStyle.pointRadius
)import { colorArgb, colorRgb, argbToCss } from '@mapconductor/react-geojson';
const layerState = useMemo(() => new GeoJSONLayerState({
strokeColor: colorArgb(220, 30, 136, 229),
fillColor: colorArgb(60, 30, 136, 229),
strokeWidth: 1.5,
pointRadius: 8,
opacity: 1,
minZoom: 8, maxZoom: 22,
}), []);
// 불투명 색이면 colorRgb, CSS로 내릴 때는 argbToCss
const legend = argbToCss(colorRgb(30, 136, 229)); // "rgba(30,136,229,1.0000)"색의 지정 형식
Android와 React는 ARGB의 32비트 정수(알파가 최상위 바이트), iOS는 UIColor로 알파를 색 자신에게 가지게 합니다. React에는 colorArgb(a,r,g,b) / colorRgb(r,g,b) / argbToCss()의 헬퍼가 있고, Android의 Color.argb()와 같은 순서입니다. 3개 플랫폼의 기본색은 모두 #1E88E5(선은 불투명, 칠은 알파 128)로 맞춰 놓았습니다.
3-3. 피처마다 덮어쓴다
피처는 strokeColor / fillColor / strokeWidth / pointRadius / visible을 스스로 가질 수 있습니다. null인 채라면 기본값, 값이 들어 있으면 그쪽이 이깁니다. 데이터를 읽어 들인 시점에 스타일이 정해지는(나중에 바뀌지 않는) 경우에는, 이 방법이 가장 솔직하고 빠른 방법입니다.
val parsed = GeoJSONParser.parseStream(input)
// 파싱 후 프로퍼티를 보고 스타일을 굽는다
val styled = parsed.map { f ->
when (f.properties["status"]) {
"alert" -> f.copy(fillColor = Color.argb(120, 214, 64, 69), strokeWidth = 3f)
"closed" -> f.copy(visible = false)
else -> f // null인 채이므로 레이어 기본값이 쓰인다
}
}
GeoJSONLayer(state = layerState, features = styled)let parsed = GeoJSONParser.parse(data: data)
let styled = parsed.map { f -> GeoJSONFeature in
switch f.properties["status"] as? String {
case "alert":
var copy = f
copy.fillColor = UIColor.systemRed.withAlphaComponent(0.47)
copy.strokeWidth = 3.0
return copy
case "closed":
var copy = f
copy.visible = false
return copy
default:
return f // nil인 채라면 레이어 기본값
}
}const styled = useMemo(() =>
GeoJSONParser.parseFeatures(GEOJSON).map(f => {
const status = f.properties.status;
if (status === 'alert') {
return { ...f, fillColor: colorArgb(120, 214, 64, 69), strokeWidth: 3 };
}
if (status === 'closed') return { ...f, visible: false };
return f; // null인 채라면 레이어 기본값
}), []);
<GeoJSONLayer state={layerState} features={styled} />3-4. StyleProvider로 동적으로 정한다
properties의 값으로 색을 나누고 싶다, 선택 중인 피처만 강조하고 싶다, 임계값을 UI에서 바꾸고 싶다 ── 같은 「규칙으로 정해지는 스타일」은 StyleProvider에 씁니다. 피처 1건마다 호출되고, 레이어 기본값을 받아 최종적인 스타일을 반환합니다.
넘겨받는 것
피처 본체(properties 포함)와, 그 시점의 레이어 기본값. 기본값을 copy 해서 일부만 바꾸는 것이 정석입니다.
반환하는 것
4항목 모두가 채워진 LayerStyle. 건드리지 않는 항목은 기본값을 그대로 반환하면, 제1층의 설정이 살아납니다.
// fun interface이므로 람다 하나로 쓸 수 있습니다
val densityStyle = GeoJSONStyleProviderInterface { feature, defaultStyle ->
val pop = (feature.properties["population"] as? Number)?.toInt() ?: 0
val fill = when {
pop > 500_000 -> Color.argb(150, 173, 20, 87)
pop > 200_000 -> Color.argb(120, 244, 143, 177)
else -> Color.argb(80, 248, 187, 208)
}
defaultStyle.copy(fillColor = fill) // 건드리지 않은 항목은 기본값 그대로
}
val layerState = remember {
GeoJSONLayerState(styleProvider = densityStyle)
}
// 나중에 갈아 끼우면 모든 피처가 다시 평가됩니다
layerState.styleProvider = DefaultGeoJSONStyleProviderfinal class DensityStyleProvider: GeoJSONStyleProvider {
func style(
for feature: GeoJSONFeature,
defaultStyle: GeoJSONTileRenderer.LayerStyle
) -> GeoJSONTileRenderer.LayerStyle {
let pop = (feature.properties["population"] as? Int) ?? 0
let fill: UIColor =
pop > 500_000 ? UIColor(red: 173/255, green: 20/255, blue: 87/255, alpha: 0.59)
: pop > 200_000 ? UIColor(red: 244/255, green: 143/255, blue: 177/255, alpha: 0.47)
: UIColor(red: 248/255, green: 187/255, blue: 208/255, alpha: 0.31)
return GeoJSONTileRenderer.LayerStyle(
strokeColor: defaultStyle.strokeColor,
fillColor: fill,
strokeWidth: defaultStyle.strokeWidth,
pointRadius: defaultStyle.pointRadius
)
}
}
layerState.styleProvider = DensityStyleProvider()// Web에서는 StyleProvider 대신 파싱 결과를 map 해서
// 피처 단위 스타일을 갖게 합니다(결과는 같음)
const styleOf = (props: Record<string, unknown>) => {
const pop = Number(props.population ?? 0);
if (pop > 500_000) return colorArgb(150, 173, 20, 87);
if (pop > 200_000) return colorArgb(120, 244, 143, 177);
return colorArgb(80, 248, 187, 208);
};
const features = useMemo(
() => GeoJSONParser.parseFeatures(GEOJSON)
.map(f => ({ ...f, fillColor: styleOf(f.properties) })),
[],
);StyleProvider를 교체하거나, 참조하고 있는 상태가 바뀌었을 때는, 모든 피처의 스타일이 재평가되고 타일이 다시 만들어집니다. 1건마다 호출되므로, 무거운 처리(정규식・네트워크・날짜 파싱 등)는 provider의 밖에서 미리 계산해 두세요.
3-5. 어느 것을 쓸까
04 · 탭 판정
MapConductor의 클릭 리스너는 하나밖에 없기 때문에, 레이어로의 전송은 앱 쪽에서 합니다. 의도적으로 자동 등록하지 않습니다. processClick은, 피처에 맞았을 때만 true를 반환합니다.
val layerState = remember {
GeoJSONLayerState(
onClick = { feature, position -> selected = feature },
)
}
MapLibreMapView(
state = mapViewState,
onMapClick = { point ->
// 15px 상당의 허용 범위로 판정(줌에 따라 달라짐)
val consumed = layerState.processClick(point, 15.0, mapViewState.zoom)
if (!consumed) selected = null
},
) {
GeoJSONLayer(state = layerState, features = features)
}layerState.onClick = { feature, position in
selected = feature
}
MapKitMapView(
state: mapViewState,
onMapClick: { point in
selected = nil
layerState.processClick(geoPoint: point)
}
) {
GeoJSONLayer(state: layerState, features: features)
}const layerState = useMemo(() => new GeoJSONLayerState({
onClick: (feature, position) => setSelected(feature),
}), []);
// 지도의 클릭 핸들러에서 전달한다
const handleMapClick = (point: GeoPointInterface) => {
const consumed = layerState.processClick(point, 10, state.camera.zoom);
if (!consumed) setSelected(null);
};픽셀 허용 범위
processClick에 허용 픽셀과 현재의 줌을 넘기면, 줌에 따라가는 판정이 됩니다. 생략 시에는 세계 좌표의 기본 허용값(약 0.0002°)입니다.
겹쳤을 때
마지막에 렌더링된(=가장 위의) 피처가 반환됩니다.
대응 지오메트리
포인트・라인・구멍 뚫린 폴리곤・멀티파트・지오메트리 컬렉션.
05 · 데이터 양과 읽어 들이기
큰 데이터에서는 스트리밍 파서를 쓰고, 백그라운드에서 파싱하세요. 피처를 가지는 방식도 2가지 있습니다.
정적・대량 대상
불변의 데이터 객체. 수만 건이어도 상태 객체를 만들지 않기 때문에 가볍습니다. 큰 GeoJSON은 이쪽.
소수・자주 바뀌는 것 대상
1건씩 리액티브하게 갱신할 수 있습니다. 건수가 많으면 상태 관리의 비용이 커지므로, 필요한 만큼만.
// 큰 FeatureCollection은 parseStream
val features = withContext(Dispatchers.IO) {
GeoJSONParser.parseStream(input)
}
// RFC 8142의 GeoJSON Text Sequences
val seq = withContext(Dispatchers.IO) { GeoJSONSeqParser.parse(file) }
GeoJSONSeqParser.streamParse(file) { feature -> buffer.add(feature) }let features = GeoJSONParser.parse(fileURL: fileURL)
GeoJSONSeqParser.streamParse(fileURL: fileURL) { feature in
// 1건씩 추가・배치・영속화
}const features = GeoJSONParser.parseFeatures(text);
const seq = GeoJSONSeqParser.parse(text); // 행 구분 시퀀스
// tileSize는 기본 512. Retina 밀도까지 올릴 수 있다
<GeoJSONLayer state={layerState} features={features} tileSize={512} />val layerState = remember { GeoJSONLayerState() }
var features by remember { mutableStateOf(emptyList<GeoJSONFeature>()) }
var loading by remember { mutableStateOf(true) }
// parseStream returns immutable GeoJSONFeature values — no state object per feature
LaunchedEffect(Unit) {
features = withContext(Dispatchers.IO) {
context.assets.open("tokyo-buildings.geojson")
.use(GeoJSONParser::parseStream)
}
loading = false
}
MapLibreMapView(state = mapViewState) {
GeoJSONLayer(state = layerState, features = features)
}
if (loading) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}