@georeferencing/core contains the whole editor except its user interface. You can
build your own UI on it with plain DOM code, another framework or a server-rendered
page that adds client scripts. This guide shows how the pieces fit together and walks
through a complete session. For the ready-made React editor, see
React and map libraries.
| Piece | Created by | Responsibility |
|---|---|---|
| Engine | createWorkerEngine() from @georeferencing/core/engine |
Decodes images, fits transformations and renders previews and exports in browser workers |
| Controller | new GeoreferencerController(options) |
Holds the session state, validates every edit, runs operations on the engine and calls your persistence callbacks |
| Map adapter | openLayers(map), maplibre(map) or leaflet(map, { lib: L }) |
Shows control points, the aligned preview and drawings on your map, and turns map clicks into control points |
| Your UI | You | Shows the image, lets users click on it and offers buttons for running, confirming, saving and exporting |
The controller is the single source of truth. The adapter and your UI read its snapshot and call its methods. They never change the state directly.
pnpm add @georeferencing/core
# one map adapter with its map library, for example:
pnpm add @georeferencing/maplibre maplibre-gl
# optional output formats:
pnpm add @georeferencing/plugins
Create one engine and one controller per editing session:
import { GeoreferencerController } from "@georeferencing/core";
import { createWorkerEngine } from "@georeferencing/core/engine";
import { geoTiff } from "@georeferencing/plugins/geotiff";
const engine = createWorkerEngine();
const controller = new GeoreferencerController({
workingCrs: "EPSG:3857",
engine,
previewMode: "manual",
exports: [geoTiff()],
});
| Option | Purpose |
|---|---|
workingCrs |
Required. CRS used for fitting, residuals and the aligned preview, for example "EPSG:25832" for UTM data. Register custom definitions with the engine and the adapter |
engine |
Required. The engine runs all image work; one engine can serve several controllers |
previewMode |
"automatic" (default) fits after every eligible edit; "manual" waits for refit() |
exports |
Output formats from @georeferencing/plugins; none are enabled by default |
digitizing |
Allow drawing points, lines and polygons after the alignment is confirmed |
onSave, onSaveDraft |
Persist accepted features or unfinished work; see lifecycle and saving |
onChange |
Mirror every committed document, for example for autosave indicators; not a save receipt |
guard |
Decide "save", "discard" or "cancel" before unsaved work is replaced; without it, such replacements are cancelled |
onError |
Structured errors with a code, for logging or localized messages |
onActiveToolChange |
Coordinate your own map interactions with the editor's active tool |
initialDocument, drawingBounds |
Start from a stored document; restrict where accepted drawings may lie |
controller.subscribe(listener) calls the listener after every change and returns an
unsubscribe function. Read the current state with controller.getSnapshot(). The
snapshot is frozen: render from it, never modify it.
| Snapshot field | Meaning |
|---|---|
document |
The serializable session: sourceImage, gcps, model, workingCrs, features, output and revision counters |
imageUrl, detailImageUrl |
Object URLs of the reduced preview image and, once requested, the full-resolution image |
tool, mode |
Active tool ("navigate", "gcp", "Point", "LineString", "Polygon", "modify") and stage ("align" or "draw") |
pendingImagePoint |
Image point waiting for its map location, or null |
fit, fitRevision |
Current transformation with per-point residuals and rmse; valid only while fitRevision equals document.alignmentRevision |
preview |
The rendered overlay shown by the map adapter |
loading, fitting, exporting, saving |
Operation states: "idle", "running", "succeeded", "failed" or "cancelled" |
error, errorDetail |
Last error message, and its code and recoverability |
dirty, canUndo, canRedo |
Unsaved changes and undo/redo availability |
await controller.loadImage(file) reads the file in a worker,
creates a reduced preview (snapshot.imageUrl) and returns false if the guard
cancelled the replacement or the image is not supported.controller.setTool("gcp").
When the user clicks the image, call controller.setPendingPoint([x, y]) with
original-resolution pixel coordinates. The next click on the map completes the pair:
the adapter projects it to the working CRS and calls addGcp, snapping to reference
data where configured. For typed coordinates, call
controller.addGcp(imagePoint, target, crs) yourself. updateGcp, removeGcp,
undo and redo edit existing pairs.controller.setModel("polynomial1") (see the
model table), then
await controller.refit() in manual mode. On success snapshot.fit holds the
transformation and residuals and the adapter shows the aligned preview;
binding.fitOverlay() zooms the map to it. On failure snapshot.error explains why,
for example too few points or a degenerate arrangement.controller.confirm() accepts the current alignment. With
digitizing: true the controller enters the drawing stage, where
setTool("Polygon") and the other drawing tools draw on the map through the adapter.
returnToAlignment() goes back; drawings stay where they are.await controller.save() sends the accepted features to
onSave. await controller.export("geotiff") returns the produced files as
{ name, blob } entries; getExportUnavailable(id) explains why an export cannot run
yet.The image shown in your UI is the reduced preview, not the original. Convert a click
from display pixels to original pixels with document.sourceImage.width and .height.
Image coordinates start at the top-left corner of the image, with y pointing down.
An adapter is a function that attaches the controller to one map. Calling it returns a
MapBinding; detach() removes everything it added and leaves your map as it was.
import { maplibre } from "@georeferencing/maplibre";
const binding = maplibre(map, { references })(controller);
// later
binding.detach();
Every adapter package also exports an attach function that does the same:
attachReferenceMap(map, controller, options) for OpenLayers,
attachMapLibre(map, controller, options) and
attachLeaflet(map, controller, { lib: L, ...options }). Attach a controller to only
one map at a time. Besides detach, a binding offers fitOverlay() and
cancelDrawing(), and depending on the adapter resize(), finishDrawing(),
navigateHistory(direction) and capture(); see the
map adapters guide for what each adapter supports.
This example wires the controller to plain DOM elements and any map adapter: choosing a file loads it, clicking the image starts a point pair, clicking the map completes it, and two buttons run the alignment and download a GeoTIFF.
import type { ControllerOptions, XY } from "@georeferencing/core";
import { GeoreferencerController } from "@georeferencing/core";
import { createWorkerEngine } from "@georeferencing/core/engine";
import type { MapAdapter } from "@georeferencing/core/map";
import { geoTiff } from "@georeferencing/plugins/geotiff";
/** Elements and services of a host page without React. */
export interface HostPage {
/** Shows `snapshot.imageUrl`, the reduced preview of the loaded image. */
image: HTMLImageElement;
status: HTMLElement;
fileInput: HTMLInputElement;
alignButton: HTMLButtonElement;
exportButton: HTMLButtonElement;
/** Adapter for the host map, for example `maplibre(map)` or `leaflet(map, { lib: L })`. */
adapter: MapAdapter;
/** Ask the user before replacing unsaved work. */
confirmDiscard: () => Promise<boolean>;
persist: NonNullable<ControllerOptions["onSave"]>;
download: (blob: Blob, name: string) => void;
}
export function mountEditor(page: HostPage) {
const engine = createWorkerEngine();
const controller = new GeoreferencerController({
workingCrs: "EPSG:3857",
engine,
previewMode: "manual",
exports: [geoTiff()],
onSave: page.persist,
guard: async () => ((await page.confirmDiscard()) ? "discard" : "cancel"),
});
const binding = page.adapter(controller);
// Render every snapshot. Snapshots are frozen; never mutate them.
const unsubscribe = controller.subscribe(() => {
const s = controller.getSnapshot();
if (s.imageUrl && page.image.src !== s.imageUrl)
page.image.src = s.imageUrl;
page.alignButton.disabled = s.document.gcps.length < 3;
page.exportButton.disabled =
controller.getExportUnavailable("geotiff") !== null;
page.status.textContent =
s.error ??
(s.pendingImagePoint
? "Now click the same spot on the map."
: `${s.document.gcps.length} control points`);
});
const onFile = async () => {
const file = page.fileInput.files?.[0];
if (file && (await controller.loadImage(file))) controller.setTool("gcp");
};
// A click on the image starts a pair; the map adapter completes it on a map click.
const onImageClick = (event: MouseEvent) => {
const source = controller.getSnapshot().document.sourceImage;
if (!source) return;
// The displayed image is a reduced preview: convert to original-resolution pixels.
const box = page.image.getBoundingClientRect();
const point: XY = [
((event.clientX - box.left) / box.width) * source.width,
((event.clientY - box.top) / box.height) * source.height,
];
controller.setTool("gcp");
controller.setPendingPoint(point);
};
const onAlign = async () => {
await controller.refit();
if (controller.getSnapshot().fit) binding.fitOverlay();
};
const onExport = async () => {
const result = await controller.export("geotiff");
for (const file of result?.files ?? []) page.download(file.blob, file.name);
};
page.fileInput.addEventListener("change", onFile);
page.image.addEventListener("click", onImageClick);
page.alignButton.addEventListener("click", onAlign);
page.exportButton.addEventListener("click", onExport);
return function unmount() {
page.fileInput.removeEventListener("change", onFile);
page.image.removeEventListener("click", onImageClick);
page.alignButton.removeEventListener("click", onAlign);
page.exportButton.removeEventListener("click", onExport);
unsubscribe();
binding.detach();
controller.dispose();
engine.dispose();
};
}
snapshot.document is plain JSON: store JSON.stringify(controller.getSnapshot().document),
or register the session() export from @georeferencing/plugins/data to download it.
A session never contains image bytes. To resume, keep the original file and call
await controller.restoreSession(json, file); the controller checks that the file
matches the stored fingerprint. onSaveDraft and the guard integrate the same
document with your own storage; see lifecycle and saving.
When a view is hidden only temporarily, controller.suspend() cancels running work
and releases image URLs, and controller.start() resumes. When the session is over:
binding.detach(),controller.dispose(),engine.dispose() unless other controllers share the engine.The controller never disposes your map or the engine.
The transformation functions work without a controller, an image or workers, for example to transform coordinates on a server:
import { fitTransform, forward, type Gcp } from "@georeferencing/core";
const gcps: Gcp[] = [
{ id: "a", label: 1, enabled: true, image: [0, 0], target: [1000, 2000], crs: "EPSG:3857" },
{ id: "b", label: 2, enabled: true, image: [100, 0], target: [1200, 2000], crs: "EPSG:3857" },
{ id: "c", label: 3, enabled: true, image: [0, 100], target: [1000, 1700], crs: "EPSG:3857" },
];
const fit = fitTransform(gcps, "polynomial1");
forward(fit, [50, 50]); // approximately [1100, 1850]
backward(fit, target) maps the other way, validateDomain(fit, width, height) checks
that a fit can be rendered over an image, and project/createConverter convert
coordinates between CRSs. All targets passed to fitTransform must already be in one
CRS.