Georeferencing API
    Preparing search index...

    React and map libraries

    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}
    />
    }
    />
    );
    }
    • Import ol/ol.css for the map controls.
    • A focusable map target (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.
    • The map may use any projection that proj4 knows. Pass custom definitions to the adapter options and the engine.
    • For PDF reports with a map page, use 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 }} />}
    />
    );
    }
    • Import maplibre-gl/dist/maplibre-gl.css; without it, control-point markers are placed incorrectly.
    • Set the worker URL once before creating maps. Bundlers do not emit MapLibre's worker on their own; the example shows the Vite syntax, which TypeScript knows through Vite's client types ("types": ["vite/client"]).
    • Previews are rendered in Web Mercator while the adapter is attached, whatever the working CRS.
    • Pass 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().
    • For PDF reports with a map page, use 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 }} />}
    />
    );
    }
    • Import leaflet/dist/leaflet.css.
    • Pass the Leaflet module as lib: the adapter never imports Leaflet itself, because Leaflet accesses window when it is imported.
    • Previews are rendered in the map CRS, Web Mercator by default. Proj4Leaflet maps need their CRS definition in definitions; L.CRS.Simple is not supported.
    • Leaflet renders tiles as DOM images, so PDF reports from a Leaflet map have no map page.

    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