The React editor works the same way with every map library: you create the map, wrap
it in the library's adapter and pass the adapter to Georeferencer. This guide shows a
complete integration for each library and the patterns they share. For the general
setup (controller, engine, exports and saving), start with
getting started.
| Map library | Packages |
|---|---|
| OpenLayers | @georeferencing/openlayers ol |
| MapLibre GL JS | @georeferencing/maplibre maplibre-gl, plus terra-draw terra-draw-maplibre-gl-adapter for drawing |
| Leaflet | @georeferencing/leaflet leaflet, plus terra-draw terra-draw-leaflet-adapter for drawing and @types/leaflet for TypeScript |
Install them next to @georeferencing/core, @georeferencing/react, react and
react-dom, all @georeferencing/* packages in the same version:
pnpm add @georeferencing/core @georeferencing/react @georeferencing/maplibre maplibre-gl react react-dom
In the guided layout, the map's container is part of the editor: you pass it as
referenceView, and the editor decides where to show it. The map can only be created
once that container is in the page, but the editor needs an adapter already when it
first renders.
The useHostMap hook below solves this. It returns a ref for the map container and a
stable adapter for the editor. React attaches refs before it runs effects, so the map
exists by the time the editor attaches the adapter. When the container unmounts, the
hook disposes the map only after the editor has detached from it. Copy it into your
project:
import type { GeoreferencerController } from "@georeferencing/core";
import type { MapAdapter, MapBinding } from "@georeferencing/core/map";
import { type RefCallback, useCallback, useRef, useState } from "react";
/** A host map created for a container element, with its adapter. */
export interface HostMap<M> {
/** The native map of your map library. */
map: M;
/** Adapter connecting the editor to `map`. */
adapter: MapAdapter;
/** Destroy the map; called after the editor has detached from it. */
dispose(): void;
}
/**
* Create a host map in a container rendered by React, for example inside the guided
* editor's `referenceView`, and connect the editor to it.
*
* React attaches refs before it runs effects, so the map exists when the editor attaches
* its adapter. The returned adapter is stable and forwards to the current map; the map
* is disposed only after the editor has detached from it.
* @param create - Creates the map; keep it stable, for example a module-level function.
*/
export function useHostMap<M>(
create: (container: HTMLDivElement) => HostMap<M>,
): {
ref: RefCallback<HTMLDivElement>;
adapter: MapAdapter;
current: () => M | null;
} {
const current = useRef<HostMap<M> | null>(null);
const [adapter] = useState<MapAdapter>(
() => (controller: GeoreferencerController) => {
if (!current.current)
throw new Error("The map container is not mounted.");
return current.current.adapter(controller);
},
);
const ref = useCallback(
(container: HTMLDivElement | null) => {
if (!container) return;
const created = create(container);
let attached = 0,
removed = false;
const entry: HostMap<M> = {
...created,
adapter(controller) {
const binding = created.adapter(controller);
let detached = false;
attached++;
return {
...binding,
detach() {
if (detached) return;
detached = true;
binding.detach();
if (--attached === 0 && removed) created.dispose();
},
} satisfies MapBinding;
},
};
current.current = entry;
return () => {
if (current.current === entry) current.current = null;
removed = true;
// React removes refs before effect cleanups: wait for the editor to detach.
if (attached === 0) created.dispose();
};
},
[create],
);
return { ref, adapter, current: () => current.current?.map ?? null };
}
Keep the function that creates the map stable, for example at module level as in the
examples below. A new function creates a new map. The hook also works in the classic
layout (without referenceView), as long as the container renders together with the
editor. If your map already exists, skip the hook and create the adapter with
useMemo(() => maplibre(map, options), [map]).
import { openLayers } from "@georeferencing/openlayers";
import {
Georeferencer,
type GeoreferencerController,
} from "@georeferencing/react";
import { defaults as defaultInteractions } from "ol/interaction/defaults.js";
import TileLayer from "ol/layer/Tile.js";
import OLMap from "ol/Map.js";
import { fromLonLat } from "ol/proj.js";
import OSM from "ol/source/OSM.js";
import View from "ol/View.js";
import "ol/ol.css";
import "@georeferencing/react/styles.css";
import { type HostMap, useHostMap } from "./use-host-map.js";
function createMap(container: HTMLDivElement): HostMap<OLMap> {
const map = new OLMap({
target: container,
// With a focusable target, OpenLayers otherwise pans and zooms only after focus.
interactions: defaultInteractions({ onFocusOnly: false }),
layers: [new TileLayer({ source: new OSM() })],
view: new View({ center: fromLonLat([9.986, 53.542]), zoom: 16 }),
});
return {
map,
adapter: openLayers(map),
dispose() {
map.setTarget(undefined);
map.dispose();
},
};
}
export function OpenLayersEditor({
controller,
}: {
controller: GeoreferencerController;
}) {
const hostMap = useHostMap(createMap);
return (
<Georeferencer
controller={controller}
map={hostMap.adapter}
referenceView={
<div
ref={hostMap.ref}
style={{ height: 480 }}
role="application"
aria-label="Reference map"
// biome-ignore lint/a11y/noNoninteractiveTabindex: OpenLayers adds keyboard navigation to the map target.
tabIndex={0}
/>
}
/>
);
}
ol/ol.css for the map controls.tabIndex) makes OpenLayers ignore mouse panning and wheel
zoom until the map has focus. Create the map with
interactions: defaults({ onFocusOnly: false }) so both work immediately.definitions to the
adapter options and the engine.captureOpenLayersMap(map).import { maplibre } from "@georeferencing/maplibre";
import {
Georeferencer,
type GeoreferencerController,
} from "@georeferencing/react";
import { Map as MapLibreMap, setWorkerUrl } from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
// Bundlers do not emit MapLibre's worker on their own; this is the Vite syntax.
import workerUrl from "maplibre-gl/dist/maplibre-gl-worker.mjs?url";
import "@georeferencing/react/styles.css";
import { type HostMap, useHostMap } from "./use-host-map.js";
setWorkerUrl(workerUrl);
function createMap(container: HTMLDivElement): HostMap<MapLibreMap> {
const map = new MapLibreMap({
container,
style: {
version: 8,
sources: {
osm: {
type: "raster",
tiles: ["https://tile.openstreetmap.org/{z}/{x}/{y}.png"],
tileSize: 256,
maxzoom: 19,
attribution: "© OpenStreetMap contributors",
},
},
layers: [{ id: "osm", type: "raster", source: "osm" }],
},
center: [9.986, 53.542],
zoom: 15,
});
return { map, adapter: maplibre(map), dispose: () => map.remove() };
}
export function MapLibreEditor({
controller,
}: {
controller: GeoreferencerController;
}) {
const hostMap = useHostMap(createMap);
return (
<Georeferencer
controller={controller}
map={hostMap.adapter}
referenceView={<div ref={hostMap.ref} style={{ height: 480 }} />}
/>
);
}
maplibre-gl/dist/maplibre-gl.css; without it, control-point markers are
placed incorrectly."types": ["vite/client"]).beforeId in the adapter options to insert the editor's layers below an
existing style layer, such as labels. The layers come back automatically when you
call map.setStyle().captureMapLibreMap(map).import { leaflet } from "@georeferencing/leaflet";
import {
Georeferencer,
type GeoreferencerController,
} from "@georeferencing/react";
import L from "leaflet";
import "leaflet/dist/leaflet.css";
import "@georeferencing/react/styles.css";
import { type HostMap, useHostMap } from "./use-host-map.js";
function createMap(container: HTMLDivElement): HostMap<L.Map> {
const map = L.map(container, { center: [53.542, 9.986], zoom: 16 });
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 19,
attribution: "© OpenStreetMap contributors",
}).addTo(map);
return {
map,
// The adapter takes the Leaflet module instead of importing it.
adapter: leaflet(map, { lib: L }),
dispose: () => map.remove(),
};
}
export function LeafletEditor({
controller,
}: {
controller: GeoreferencerController;
}) {
const hostMap = useHostMap(createMap);
return (
<Georeferencer
controller={controller}
map={hostMap.adapter}
referenceView={<div ref={hostMap.ref} style={{ height: 480 }} />}
/>
);
}
leaflet/dist/leaflet.css.lib: the adapter never imports Leaflet itself, because
Leaflet accesses window when it is imported.definitions; L.CRS.Simple is not supported.All adapters take the same core options, plus a few of their own:
const adapter = maplibre(map, {
references: [
{
kind: "geojson",
id: "parcels",
label: "Parcels",
data: parcels,
crs: "EPSG:4326",
snapping: { vertices: true, edges: true, tolerancePx: 12 },
},
],
definitions: { "EPSG:25832": "+proj=utm +zone=32 +ellps=GRS80 +units=m +no_defs" },
digitizingSnapping: { references: true, drafts: true },
onReferenceStatus: (id, status) => console.debug(id, status),
});
| Option | OpenLayers | MapLibre | Leaflet |
|---|---|---|---|
references: wfs (GeoJSON), geojson, custom |
Yes | Yes | Yes |
references: wfs with GML, existing-vector |
Yes | No | No |
definitions, datumGrids, initialView, debounceMs, digitizingSnapping, onReferenceStatus |
Yes | Yes | Yes |
| Library-specific | geometryTolerancePx |
beforeId |
lib (required) |
The adapter reads its options once when it attaches. Create them together with the map, or memoize them: a changed options object only takes effect with a new adapter. See reference data for WFS and custom loaders.
The host creates the controller and engine, keeps them across renders and disposes them when the user is done. Never dispose them in an effect cleanup: React Strict Mode runs cleanups during development while the editor stays in use. This example creates a session on demand, includes the map page in PDF reports, and disposes the session after the editor has unmounted:
import { createWorkerEngine } from "@georeferencing/core/engine";
import { captureMapLibreMap, maplibre } from "@georeferencing/maplibre";
import { geoTiff } from "@georeferencing/plugins/geotiff";
import { pdf } from "@georeferencing/plugins/pdf";
import {
type ControllerOptions,
Georeferencer,
GeoreferencerController,
} from "@georeferencing/react";
import { Map as MapLibreMap } from "maplibre-gl";
import { useEffect, useRef, useState } from "react";
import { type HostMap, useHostMap } from "./use-host-map.js";
function createMap(container: HTMLDivElement): HostMap<MapLibreMap> {
const map = new MapLibreMap({ container, style: "/map-style.json" });
return { map, adapter: maplibre(map), dispose: () => map.remove() };
}
/** One editor session: a controller and the engine it uses. */
interface EditorSession {
controller: GeoreferencerController;
dispose(): void;
}
function createSession(
persist: NonNullable<ControllerOptions["onSave"]>,
currentMap: () => MapLibreMap | null,
): EditorSession {
const engine = createWorkerEngine();
const controller = new GeoreferencerController({
workingCrs: "EPSG:3857",
engine,
digitizing: true,
onSave: persist,
exports: [
geoTiff(),
// Read the map when the report is made; without a map the page is omitted.
pdf({
capture: () => {
const map = currentMap();
return map ? captureMapLibreMap(map) : undefined;
},
}),
],
});
return {
controller,
dispose() {
controller.dispose();
engine.dispose();
},
};
}
export function EditorPage({
persist,
}: {
persist: NonNullable<ControllerOptions["onSave"]>;
}) {
const hostMap = useHostMap(createMap);
const [session, setSession] = useState<EditorSession | null>(null);
const closed = useRef<EditorSession | null>(null);
// Runs after the editor has unmounted and detached: now the session can be disposed.
useEffect(() => {
if (session) return;
closed.current?.dispose();
closed.current = null;
}, [session]);
if (!session)
return (
<button
type="button"
onClick={() => setSession(createSession(persist, hostMap.current))}
>
Georeference an image
</button>
);
return (
<>
<button
type="button"
onClick={() => {
closed.current = session;
setSession(null);
}}
>
Close editor
</button>
<Georeferencer
controller={session.controller}
map={hostMap.adapter}
referenceView={<div ref={hostMap.ref} style={{ height: 480 }} />}
/>
</>
);
}
The PDF capture callback reads the current map when the report is created. With
OpenLayers use captureOpenLayersMap(map); with Leaflet omit capture.
With digitizing: true on the controller, the export step offers point, line and
polygon tools after the alignment is confirmed. OpenLayers draws with its own
interactions. MapLibre and Leaflet use Terra Draw, loaded when
a drawing tool is first chosen; install terra-draw and the adapter package for your
map library. Without them, choosing a drawing tool shows an error and the rest of the
editor keeps working.
The panels (ImagePanel, GcpPanel, AlignmentPanel, ReferencePanel,
FeaturePanel) do not attach a map. Attach the adapter yourself and suspend the
controller on cleanup:
useEffect(() => {
controller.start();
const binding = adapter(controller);
return () => {
binding.detach();
controller.suspend();
};
}, [adapter, controller]);
Use useHostMap for the map as above; its adapter stays the same across renders. Keep
the returned binding if your layout offers "fit to image" (binding.fitOverlay()) or
map history buttons. See using core without React for the
controller methods behind each step.
The editor calls the binding's resize() when it shows the map on small screens. If
your layout changes the map container's size in other ways, tell the map:
map.updateSize() for OpenLayers, map.resize() for MapLibre and
map.invalidateSize() for Leaflet. Give the container an explicit height; the editor
does not size it.
| Symptom | Cause |
|---|---|
| "The map container is not mounted." | The editor attached before the map container rendered; render the container in referenceView or in the same render as the editor |
| The editor re-attaches on every render | The adapter or its options are recreated; use useHostMap, useMemo or module-level options |
| MapLibre shows no map and logs a worker error | The worker URL is not set; call setWorkerUrl before creating maps |
| MapLibre markers appear far from where you clicked | maplibre-gl.css is not imported |
| OpenLayers ignores dragging and the mouse wheel | The map target is focusable; use defaults({ onFocusOnly: false }) |
| Drawing tools report missing dependencies | Install terra-draw and the Terra Draw adapter for your map library |
| The PDF report has no map page | Leaflet maps cannot be captured; with other libraries, check capture and CORS on tile sources |