GeoJSON layer
An extension package for putting GeoJSON straight onto the map. Features are rasterised into tiles, so tens of thousands of them cost no per-feature objects. API names, style defaults and hit-testing behaviour are the same on Android, iOS and React.
01 · Overview
GeoJSON is parsed into lightweight feature models and drawn through MapConductor raster tile pipeline. Whatever the provider — Google Maps, MapLibre, MapKit, HERE — the code and the result are the same.
Parse
FeatureCollection, a bare Feature, a bare geometry, and RFC 8142 text sequences. Streaming parsers included.
Tile rendering
Features are rasterised into 512 px tiles and shown as a raster layer, with no dependency on provider vector features.
Hit testing
Clicks are tested against the same coordinates used to draw, including holes, multiparts and geometry collections.
Every geometry type is supported identically on all three platforms.
02 · Basic usage
Place the layer inside the map view content scope. Parse in the background and hand the result to 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>
);When feature data or style changes the layer invalidates its tile URL internally, so the map SDK raster cache never keeps serving a stale picture.
03 · Styling
Style resolves in three layers
Layer-wide defaults, per-feature overrides, and a StyleProvider that decides dynamically. Later layers win, and anything left unspecified is inherited from the one above. Start with the layer defaults and add the lower layers only as you need them.
3-1. Style properties
There are only four style properties, plus opacity, visible and minZoom / maxZoom on the layer for display control.
3-2. Setting layer defaults
Start here. Whatever you pass to GeoJSONLayerState becomes the base for every feature, and the state is observable, so assigning later re-renders.
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,
)
}
// The state is observable: assign later and the tiles are rebuilt
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's fields are let, so swap the whole struct (alpha lives on 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 for an opaque colour, argbToCss when dropping down to CSS
const legend = argbToCss(colorRgb(30, 136, 229)); // "rgba(30,136,229,1.0000)"Colour formats
Android and React use a 32-bit ARGB integer (alpha in the top byte); iOS carries alpha in the UIColor itself. React ships colorArgb(a,r,g,b), colorRgb(r,g,b) and argbToCss(), in the same argument order as Android Color.argb(). All three default to #1E88E5 — opaque stroke, alpha 128 fill.
3-3. Overriding per feature
A feature can carry its own strokeColor, fillColor, strokeWidth, pointRadius and visible. Null means the default; a value wins. When the style is settled at load time and never changes afterwards, this is the simplest and fastest route.
val parsed = GeoJSONParser.parseStream(input)
// After parsing, read the properties and bake the style in
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 // Left null, so the layer default is used
}
}
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 // Left nil means the layer default
}
}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; // Left null means the layer default
}), []);
<GeoJSONLayer state={layerState} features={styled} />3-4. Deciding dynamically with a StyleProvider
Colour by a property value, highlight the selected feature, move a threshold from the UI — style that follows a rule belongs in a StyleProvider. It is called once per feature, receives the layer defaults, and returns the final style.
You receive
The feature (properties included) and the current layer defaults. Copying the defaults and changing one field is the usual shape.
You return
A LayerStyle with all four fields filled. Return the default for anything you do not touch and the first layer keeps working.
// It is a fun interface, so one lambda is enough
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) // Anything you leave alone keeps its default
}
val layerState = remember {
GeoJSONLayerState(styleProvider = densityStyle)
}
// Swapping it later re-evaluates every feature
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()// On the web, instead of a StyleProvider, map over the parse result and
// give each feature its own style (the result is the same)
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) })),
[],
);Swapping the provider — or changing state it reads — re-evaluates every feature and rebuilds the tiles. Since it runs per feature, pre-compute anything expensive (regex, network, date parsing) outside the provider.
3-5. Which one to use
04 · Hit testing
MapConductor exposes a single map click slot, so forwarding to the layer is left to the app — deliberately not automatic. processClick returns true only when a feature was hit.
val layerState = remember {
GeoJSONLayerState(
onClick = { feature, position -> selected = feature },
)
}
MapLibreMapView(
state = mapViewState,
onMapClick = { point ->
// Hit-tested with a 15 px tolerance, which follows the zoom
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),
}), []);
// Forward it from the map's click handler
const handleMapClick = (point: GeoPointInterface) => {
const consumed = layerState.processClick(point, 10, state.camera.zoom);
if (!consumed) setSelected(null);
};Pixel tolerance
Pass a pixel tolerance and the current zoom to processClick for a zoom-aware threshold. Omitted, the world-coordinate default (~0.0002°) applies.
Overlaps
The last drawn — topmost — matching feature is returned.
Geometries
Points, lines, polygons with holes, multiparts and geometry collections.
05 · Data volume and loading
For large data use the streaming parsers and parse off the main thread. There are also two ways to hold features.
Static and bulk
Immutable data objects — no state object per feature, so tens of thousands stay light. Use this for big GeoJSON.
Few and frequently changing
Reactive per-feature updates. State management costs add up in volume, so use it only where needed.
// parseStream for a large FeatureCollection
val features = withContext(Dispatchers.IO) {
GeoJSONParser.parseStream(input)
}
// GeoJSON Text Sequences, RFC 8142
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
// Append one at a time, in batches, or persisted
}const features = GeoJSONParser.parseFeatures(text);
const seq = GeoJSONSeqParser.parse(text); // Line-delimited sequence
// tileSize defaults to 512; you can raise it to Retina density
<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())
}