@georeferencing/react 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +400 -2
  3. package/dist/Georeferencer.d.ts +71 -0
  4. package/dist/Georeferencer.js +220 -0
  5. package/dist/components/accuracy.d.ts +15 -0
  6. package/dist/components/accuracy.js +39 -0
  7. package/dist/components/exports.d.ts +32 -0
  8. package/dist/components/exports.js +152 -0
  9. package/dist/components/fields.d.ts +17 -0
  10. package/dist/components/fields.js +35 -0
  11. package/dist/controls.d.ts +40 -0
  12. package/dist/controls.js +28 -0
  13. package/dist/hooks/useGeoreferencer.d.ts +6 -0
  14. package/dist/hooks/useGeoreferencer.js +8 -0
  15. package/dist/index.d.ts +20 -0
  16. package/dist/index.js +16 -0
  17. package/dist/licenses/APACHE-2.0.txt +202 -0
  18. package/dist/licenses/GDAL-NOTICE.txt +24 -0
  19. package/dist/licenses/inventory.json +1 -0
  20. package/dist/localization.d.ts +6 -0
  21. package/dist/localization.js +1 -0
  22. package/dist/panels/AlignmentPanel.d.ts +19 -0
  23. package/dist/panels/AlignmentPanel.js +27 -0
  24. package/dist/panels/FeaturePanel.d.ts +28 -0
  25. package/dist/panels/FeaturePanel.js +43 -0
  26. package/dist/panels/GcpPanel.d.ts +35 -0
  27. package/dist/panels/GcpPanel.js +81 -0
  28. package/dist/panels/ImagePanel.d.ts +30 -0
  29. package/dist/panels/ImagePanel.js +279 -0
  30. package/dist/panels/PreviewControls.d.ts +17 -0
  31. package/dist/panels/PreviewControls.js +37 -0
  32. package/dist/panels/ReferencePanel.d.ts +17 -0
  33. package/dist/panels/ReferencePanel.js +24 -0
  34. package/dist/styles.css +594 -0
  35. package/dist/utils/downloadBlob.d.ts +5 -0
  36. package/dist/utils/downloadBlob.js +11 -0
  37. package/package.json +57 -3
@@ -0,0 +1,220 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { MODELS } from "@georeferencing/core";
3
+ import { attachReferenceMap } from "@georeferencing/core/openlayers";
4
+ import { useEffect, useId, useRef, useState } from "react";
5
+ import { AccuracySummary } from "./components/accuracy.js";
6
+ import { ExportButtons, ExportProgress, OutputFields, SessionTools, } from "./components/exports.js";
7
+ import { MINIMAL_CONTROLS } from "./controls.js";
8
+ import { useGeoreferencer } from "./hooks/useGeoreferencer.js";
9
+ import { identity } from "./localization.js";
10
+ import { AlignmentPanel } from "./panels/AlignmentPanel.js";
11
+ import { FeaturePanel } from "./panels/FeaturePanel.js";
12
+ import { GcpPanel } from "./panels/GcpPanel.js";
13
+ import { ImagePanel } from "./panels/ImagePanel.js";
14
+ import { PreviewControls } from "./panels/PreviewControls.js";
15
+ import { ReferencePanel } from "./panels/ReferencePanel.js";
16
+ const DEFAULT_BINDING = {};
17
+ const NO_CONTROLS = {};
18
+ /**
19
+ * Ready-made React editor for an existing OpenLayers map. Composes image, GCP, alignment, reference, output and optional drawing controls.
20
+ *
21
+ * Supplying `referenceView` selects the guided four-step layout, whose optional controls are chosen with `controls`; otherwise the classic workbench shows every control. Its effects attach/detach owned map resources and resume/suspend the controller for React Strict Mode. It installs a Save/Discard/Cancel guard and cancels pending dialogs on cleanup. The host retains final ownership of map, controller and engine. Import the optional scoped stylesheet from `@georeferencing/react/styles.css`.
22
+ */
23
+ export function Georeferencer({ controller, referenceMap, bindingOptions = DEFAULT_BINDING, t = identity, className = "", propertyEditor, onExport, formatError, referenceView, controls: controlOverrides = NO_CONTROLS, emptyImageActions, }) {
24
+ const guardTitle = useId();
25
+ const s = useGeoreferencer(controller), binding = useRef(null);
26
+ const [guard, setGuard] = useState(null), dialog = useRef(null);
27
+ const guided = referenceView !== undefined;
28
+ const controls = { ...MINIMAL_CONTROLS, ...controlOverrides };
29
+ const [stage, setStage] = useState("match");
30
+ const [mobileView, setMobileView] = useState("image");
31
+ const d = s.document;
32
+ const imageId = d.sourceImage?.id;
33
+ const imageReady = Boolean(s.imageUrl);
34
+ const confirmed = d.confirmedAlignmentRevision === d.alignmentRevision;
35
+ // 0 load image · 1 match points · 2 check alignment · 3 export or draw
36
+ const step = !imageReady
37
+ ? 0
38
+ : s.mode === "draw" || (stage === "finish" && confirmed && s.fit)
39
+ ? 3
40
+ : stage !== "match" && s.fit
41
+ ? 2
42
+ : 1;
43
+ const nextPoint = Math.max(0, ...d.gcps.map((p) => p.label)) + 1;
44
+ const hasWork = d.gcps.length > 0 || d.features.features.length > 0;
45
+ useEffect(() => {
46
+ if (!guided)
47
+ return;
48
+ setStage("match");
49
+ setMobileView("image");
50
+ // A metadata-only restored document still needs its matching local bytes.
51
+ if (imageId && imageReady && controller.getSnapshot().mode === "align")
52
+ controller.setTool("gcp");
53
+ }, [controller, guided, imageId, imageReady]);
54
+ useEffect(() => {
55
+ if (guided)
56
+ setMobileView(s.pendingImagePoint ? "map" : "image");
57
+ }, [guided, s.pendingImagePoint]);
58
+ // Matching always uses the point tool; dragging pans, so no separate pan mode is needed.
59
+ useEffect(() => {
60
+ if (guided && step === 1 && s.tool !== "gcp" && s.mode === "align")
61
+ controller.setTool("gcp");
62
+ }, [controller, guided, step, s.tool, s.mode]);
63
+ useEffect(() => {
64
+ if (guided && mobileView === "map")
65
+ referenceMap.updateSize();
66
+ }, [guided, mobileView, referenceMap]);
67
+ useEffect(() => {
68
+ controller.start();
69
+ binding.current = attachReferenceMap(referenceMap, controller, bindingOptions);
70
+ return () => {
71
+ binding.current?.detach();
72
+ binding.current = null;
73
+ controller.suspend();
74
+ };
75
+ }, [referenceMap, controller, bindingOptions]);
76
+ useEffect(() => {
77
+ if (controller.options.guard)
78
+ return;
79
+ let pending = null;
80
+ controller.setGuard((context) => new Promise((resolve) => {
81
+ pending?.("cancel");
82
+ pending = resolve;
83
+ setGuard({ context, resolve });
84
+ }));
85
+ return () => {
86
+ pending?.("cancel");
87
+ controller.setGuard(undefined);
88
+ };
89
+ }, [controller]);
90
+ useEffect(() => {
91
+ if (guard) {
92
+ dialog.current?.showModal();
93
+ dialog.current?.querySelector("button")?.focus();
94
+ }
95
+ }, [guard]);
96
+ const decide = (choice) => {
97
+ guard?.resolve(choice);
98
+ dialog.current?.close();
99
+ setGuard(null);
100
+ };
101
+ const run = (fn) => {
102
+ try {
103
+ fn();
104
+ }
105
+ catch (e) {
106
+ controller.reportError(e);
107
+ }
108
+ };
109
+ const error = s.error
110
+ ? s.errorDetail && formatError
111
+ ? formatError(s.errorDetail)
112
+ : t(s.error)
113
+ : null;
114
+ const unsaved = (_jsx("span", { className: "rg-dirty", children: t(s.dirty && hasWork ? "Unsaved changes" : "No unsaved changes") }));
115
+ const goToMatching = () => run(() => {
116
+ if (controller.getSnapshot().mode === "draw")
117
+ controller.returnToAlignment();
118
+ setStage("match");
119
+ setMobileView("image");
120
+ controller.setTool("gcp");
121
+ });
122
+ const goToReview = () => run(() => {
123
+ if (controller.getSnapshot().mode === "draw")
124
+ controller.returnToAlignment();
125
+ setStage("review");
126
+ setMobileView("map");
127
+ controller.setTool("navigate");
128
+ });
129
+ const finish = () => run(() => {
130
+ controller.confirm();
131
+ setStage("finish");
132
+ setMobileView("map");
133
+ });
134
+ const steps = [
135
+ "Load image",
136
+ "Match points",
137
+ "Check alignment",
138
+ controller.options.digitizing ? "Export or draw" : "Export",
139
+ ];
140
+ const instruction = step === 0
141
+ ? [
142
+ "Choose an image to begin",
143
+ "Drop an image or choose a file. It stays on this device.",
144
+ ]
145
+ : step === 1
146
+ ? [
147
+ s.pendingImagePoint
148
+ ? `${t("Now click the same spot on the map")} · ${t("Point")} ${nextPoint}`
149
+ : `${t("Click a recognizable spot in the image")} · ${t("Point")} ${nextPoint}`,
150
+ stage !== "match"
151
+ ? s.previewMode === "manual"
152
+ ? "Points or settings changed. Run alignment again to check the result."
153
+ : "Alignment changed. Wait for the updated preview."
154
+ : "Use ground-level corners of buildings, quays or bridges, spread across the whole image. Drag to pan and scroll to zoom in both views; Esc cancels a point.",
155
+ ]
156
+ : step === 2
157
+ ? [
158
+ "Does the image line up with the map?",
159
+ "Compare roads, shorelines and building corners across the whole image. If something is off, adjust or add points.",
160
+ ]
161
+ : controller.options.digitizing
162
+ ? [
163
+ "Download the result or draw on the map",
164
+ "Your alignment is confirmed. Download files, or draw points, lines and areas on top of the aligned image.",
165
+ ]
166
+ : [
167
+ "Download the result",
168
+ "Your alignment is confirmed. Download the georeferenced image.",
169
+ ];
170
+ const primaryFormats = s.exportFormats.filter((format) => format.raster);
171
+ const otherFormats = s.exportFormats.filter((format) => !format.raster);
172
+ const toolRow = (step === 1 || step === 3) &&
173
+ (controls.history ||
174
+ controls.navigation ||
175
+ controls.transformation ||
176
+ controls.unsavedIndicator ||
177
+ ["LineString", "Polygon"].includes(s.tool)) ? (_jsxs("div", { className: "rg-toolbar rg-match-tools", children: [step === 3 && ["LineString", "Polygon"].includes(s.tool) && (_jsx("button", { type: "button", onClick: () => binding.current?.finishDrawing(), children: t("Finish drawing") })), controls.history && (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", disabled: !s.canUndo, onClick: () => controller.undo(), children: t("Undo") }), _jsx("button", { type: "button", disabled: !s.canRedo, onClick: () => controller.redo(), children: t("Redo") })] })), controls.navigation && (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", onClick: () => binding.current?.navigateHistory(-1), children: t("Previous map view") }), _jsx("button", { type: "button", onClick: () => binding.current?.navigateHistory(1), children: t("Next map view") })] })), (controls.transformation || controls.unsavedIndicator) && (_jsxs("div", { className: "rg-tools-end", children: [controls.transformation && step === 1 && (_jsxs("label", { className: "rg-inline-field", children: [t("Transformation"), _jsx("select", { value: d.model, onChange: (e) => controller.setModel(e.target.value), children: Object.entries(MODELS).map(([key, value]) => (_jsxs("option", { value: key, children: [t(value.label), " \u00B7 ", value.minimum, "+"] }, key))) })] })), controls.unsavedIndicator && unsaved] }))] })) : null;
178
+ return (_jsxs("section", { className: `rg-editor ${guided ? "rg-guided" : ""} ${className}`, "aria-label": t("Georeferencing editor"), onKeyDownCapture: (e) => {
179
+ if (guided && e.key === "Escape" && s.pendingImagePoint) {
180
+ controller.setPendingPoint(null);
181
+ e.preventDefault();
182
+ e.stopPropagation();
183
+ }
184
+ }, onKeyDown: (e) => {
185
+ if (e.key === "Escape") {
186
+ binding.current?.cancelDrawing();
187
+ e.stopPropagation();
188
+ }
189
+ }, children: [guided ? (_jsxs(_Fragment, { children: [_jsx("ol", { className: "rg-steps", "aria-label": t("Georeferencing steps"), children: steps.map((label, i) => {
190
+ const state = i < step ? "done" : i === step ? "current" : "todo";
191
+ const back = i < step && i === 1
192
+ ? goToMatching
193
+ : i < step && i === 2
194
+ ? goToReview
195
+ : undefined;
196
+ const content = (_jsxs(_Fragment, { children: [_jsx("span", { className: "rg-step-index", "aria-hidden": "true", children: state === "done" ? "✓" : i + 1 }), _jsx("span", { className: "rg-step-label", children: t(label) })] }));
197
+ return (_jsx("li", { "data-state": state, "aria-current": state === "current" ? "step" : undefined, children: back ? (_jsx("button", { type: "button", className: "rg-step-button", onClick: back, children: content })) : (content) }, label));
198
+ }) }), _jsxs("div", { className: "rg-matching-bar", children: [_jsxs("div", { className: "rg-instruction", role: "status", "aria-live": "polite", children: [_jsx("strong", { children: t(instruction[0]) }), _jsxs("p", { children: [t(instruction[1]), s.pendingImagePoint && (_jsxs(_Fragment, { children: [" ", _jsx("button", { type: "button", className: "rg-link", onClick: () => controller.setPendingPoint(null), children: t("Cancel this point") })] }))] })] }), _jsxs("div", { className: "rg-step-actions", children: [step === 1 && (_jsx(PreviewControls, { controller: controller, t: t, modeSelector: controls.previewMode, onReview: () => {
199
+ setStage("review");
200
+ setMobileView("map");
201
+ } })), step === 2 && (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", onClick: goToMatching, children: t("Adjust points") }), _jsx("button", { type: "button", onClick: () => binding.current?.fitOverlay(), children: t("Zoom map to image") }), _jsx("button", { type: "button", className: "rg-primary", disabled: !s.fit || s.fitRevision !== d.alignmentRevision, onClick: finish, children: t("Looks good, continue") })] })), step === 3 && (_jsx("button", { type: "button", onClick: goToReview, children: t("Back to check") }))] })] }), error && (_jsx("p", { className: "rg-error", role: "alert", children: error })), toolRow, _jsxs("fieldset", { className: "rg-view-tabs", "aria-label": t("Workspace view"), children: [_jsx("button", { type: "button", "aria-pressed": mobileView === "image", onClick: () => setMobileView("image"), children: t("Image") }), _jsxs("button", { type: "button", "aria-pressed": mobileView === "map", onClick: () => setMobileView("map"), children: [t("Map"), s.pendingImagePoint ? ` · ${t("Pick here next")}` : ""] })] }), _jsxs("div", { className: "rg-paired-views", "data-mobile-view": mobileView, children: [_jsx("div", { className: "rg-source-pane", "data-active": s.tool === "gcp" && !s.pendingImagePoint && step === 1, children: _jsx(ImagePanel, { controller: controller, t: t, navigation: controls.navigation, displayAdjustment: controls.displayAdjustment, emptyActions: emptyImageActions }) }), _jsx("div", { className: "rg-reference-pane", "data-active": !!s.pendingImagePoint, children: referenceView })] }), step === 2 && s.fit && (_jsxs("div", { className: "rg-review-panels", children: [_jsxs("section", { className: "rg-card", children: [_jsx("h2", { children: t("Accuracy") }), _jsx(AccuracySummary, { fit: s.fit, model: d.model, gcps: d.gcps, workingCrs: d.workingCrs, t: t }), _jsxs("label", { className: "rg-inline", children: [_jsx("input", { type: "checkbox", checked: s.visible, onChange: (e) => controller.setDisplay({ visible: e.target.checked }) }), t("Show aligned image on the map")] }), _jsxs("label", { children: [t("Image opacity"), _jsx("input", { "aria-label": t("Overlay opacity"), type: "range", min: "0", max: "1", step: "0.05", value: s.opacity, onChange: (e) => controller.setDisplay({
202
+ opacity: Number(e.target.value),
203
+ }) })] })] }), _jsx(GcpPanel, { controller: controller, t: t, variant: controls.pointTable ? "table" : "compact", manualEntry: false, tools: false })] })), step === 1 && (d.gcps.length > 0 || controls.manualEntry) && (_jsx(GcpPanel, { controller: controller, t: t, variant: controls.pointTable ? "table" : "compact", manualEntry: controls.manualEntry, tools: false })), step === 3 && (_jsxs("div", { className: "rg-review-panels", children: [s.exportFormats.length > 0 && (_jsxs("section", { className: "rg-card", children: [_jsx("h2", { children: t("Download") }), _jsx(ExportButtons, { controller: controller, t: t, formats: primaryFormats, onExport: onExport, primary: () => true, status: false }), otherFormats.length > 0 && (_jsxs(_Fragment, { children: [_jsx("h3", { children: t("More downloads") }), _jsx(ExportButtons, { controller: controller, t: t, formats: otherFormats, onExport: onExport, status: false })] })), _jsx(ExportProgress, { controller: controller, t: t }), controls.outputSettings && primaryFormats.length > 0 && (_jsxs("details", { children: [_jsx("summary", { children: t("Output settings") }), _jsx(OutputFields, { controller: controller, t: t, definitions: bindingOptions.definitions })] }))] })), controller.options.digitizing && (_jsx("div", { className: "rg-card", children: _jsx(FeaturePanel, { controller: controller, propertyEditor: propertyEditor, t: t }) }))] })), controls.referenceStatus && (_jsxs("details", { className: "rg-advanced", children: [_jsx("summary", { children: t("Reference sources") }), _jsx(ReferencePanel, { controller: controller, t: t })] })), controls.sessionFiles && (_jsxs("details", { className: "rg-export", children: [_jsx("summary", { children: t("Session files") }), step < 3 && (_jsx(ExportButtons, { controller: controller, t: t, formats: s.exportFormats.filter((format) => format.requiresFit === false), onExport: onExport, status: false })), step < 3 && _jsx(ExportProgress, { controller: controller, t: t }), _jsx(SessionTools, { controller: controller, t: t })] }))] })) : (_jsxs(_Fragment, { children: [_jsxs("header", { className: "rg-editor-heading", children: [_jsxs("div", { children: [_jsx("span", { className: "rg-eyebrow", children: t("GEOREFERENCE") }), _jsx("h1", { children: t("Place the image. Trace the detail.") })] }), unsaved] }), _jsxs("div", { className: "rg-toolbar rg-history", children: [_jsx("button", { type: "button", disabled: !s.canUndo, onClick: () => controller.undo(), children: t("Undo") }), _jsx("button", { type: "button", disabled: !s.canRedo, onClick: () => controller.redo(), children: t("Redo") }), _jsx("button", { type: "button", disabled: !s.preview, onClick: () => binding.current?.fitOverlay(), children: t("Fit map to image") }), _jsx("button", { type: "button", onClick: () => binding.current?.navigateHistory(-1), children: t("Previous map view") }), _jsx("button", { type: "button", onClick: () => binding.current?.navigateHistory(1), children: t("Next map view") }), ["LineString", "Polygon"].includes(s.tool) && (_jsx("button", { type: "button", onClick: () => binding.current?.finishDrawing(), children: t("Finish drawing") }))] }), _jsxs("div", { className: "rg-workbench", children: [_jsx(ImagePanel, { controller: controller, t: t, emptyActions: emptyImageActions }), _jsxs("div", { className: "rg-settings", children: [_jsx(AlignmentPanel, { controller: controller, t: t }), _jsx(FeaturePanel, { controller: controller, propertyEditor: propertyEditor, t: t })] })] }), _jsx(ReferencePanel, { controller: controller, t: t }), _jsx(GcpPanel, { controller: controller, t: t }), _jsxs("details", { className: "rg-export", children: [_jsx("summary", { children: t("Raster output & session files") }), s.exportFormats.some((format) => format.raster) && (_jsx(OutputFields, { controller: controller, t: t, definitions: bindingOptions.definitions })), _jsx(ExportButtons, { controller: controller, t: t, formats: s.exportFormats, onExport: onExport }), _jsx(SessionTools, { controller: controller, t: t })] })] })), _jsx("div", { className: "rg-status", role: "status", "aria-live": "polite", children: guided
204
+ ? s.loading === "running"
205
+ ? t("Inspecting local image…")
206
+ : s.exporting === "succeeded"
207
+ ? t("Export complete.")
208
+ : null
209
+ : (error ??
210
+ (s.loading === "running"
211
+ ? t("Inspecting local image…")
212
+ : s.exporting === "succeeded"
213
+ ? t("Raster export complete.")
214
+ : t("Image processing stays on this device."))) }), _jsxs("dialog", { ref: dialog, className: "rg-guard", onCancel: (e) => {
215
+ e.preventDefault();
216
+ decide("cancel");
217
+ }, "aria-labelledby": guardTitle, children: [_jsx("h2", { id: guardTitle, children: t("Protect unsaved work") }), _jsx("p", { children: t("Save or discard the current document before replacing it.") }), !guard?.context.canSave && (_jsx("p", { children: t("Saving is unavailable for this draft. Cancel to export a session, or discard.") })), _jsxs("div", { className: "rg-toolbar", children: [_jsx("button", { type: "button", onClick: () => decide("cancel"), children: t("Cancel") }), _jsx("button", { type: "button", onClick: () => decide("discard"), children: t("Discard") }), _jsx("button", { type: "button", disabled: !guard?.context.canSave, onClick: () => decide("save"), children: t(guard?.context.saveKind === "draft"
218
+ ? "Save draft and continue"
219
+ : "Save and continue") })] })] })] }));
220
+ }
@@ -0,0 +1,15 @@
1
+ import type { Fit, Gcp, Model } from "@georeferencing/core";
2
+ import type { Translate } from "../localization.js";
3
+ /**
4
+ * Whether residuals are necessarily zero: thin plate splines interpolate every point,
5
+ * and every other model matches exactly its minimum number of points.
6
+ */
7
+ export declare function exactFit(model: Model, gcps: readonly Gcp[]): boolean;
8
+ /** Plain-language fit quality from the root mean square of image-pixel residuals. */
9
+ export declare function AccuracySummary({ fit, model, gcps, workingCrs, t, }: {
10
+ fit: Fit;
11
+ model: Model;
12
+ gcps: readonly Gcp[];
13
+ workingCrs: string;
14
+ t: Translate;
15
+ }): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,39 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { coordinateUnits, MODELS } from "@georeferencing/core";
3
+ /**
4
+ * Whether residuals are necessarily zero: thin plate splines interpolate every point,
5
+ * and every other model matches exactly its minimum number of points.
6
+ */
7
+ export function exactFit(model, gcps) {
8
+ return (model === "thinPlateSpline" ||
9
+ gcps.filter((p) => p.enabled).length <= MODELS[model].minimum);
10
+ }
11
+ /** Plain-language fit quality from the root mean square of image-pixel residuals. */
12
+ export function AccuracySummary({ fit, model, gcps, workingCrs, t, }) {
13
+ if (exactFit(model, gcps))
14
+ return (_jsxs("div", { className: "rg-accuracy", "data-quality": "unknown", children: [_jsx("strong", { children: t("Accuracy not measured yet") }), _jsx("p", { children: t(model === "thinPlateSpline"
15
+ ? "Thin plate spline passes through every point, so it cannot report an error. Judge the overlay visually."
16
+ : "The alignment passes exactly through the minimum number of points. Add another point to measure accuracy.") })] }));
17
+ const pixels = fit.residuals
18
+ .map((r) => r.pixels)
19
+ .filter((v) => typeof v === "number");
20
+ const rms = pixels.length
21
+ ? Math.sqrt(pixels.reduce((sum, v) => sum + v * v, 0) / pixels.length)
22
+ : undefined;
23
+ const quality = rms === undefined
24
+ ? "unknown"
25
+ : rms <= 2
26
+ ? "good"
27
+ : rms <= 5
28
+ ? "fair"
29
+ : "poor";
30
+ return (_jsxs("div", { className: "rg-accuracy", "data-quality": quality, children: [_jsx("strong", { children: rms === undefined
31
+ ? t("Alignment ready")
32
+ : `${t(quality === "good"
33
+ ? "Good fit"
34
+ : quality === "fair"
35
+ ? "Fair fit"
36
+ : "Poor fit")} · ${t("average error")} ${rms.toFixed(1)} px` }), _jsx("p", { children: t(quality === "poor"
37
+ ? "Points disagree. Check the points with the largest residuals, or add more points."
38
+ : "Average distance between your map points and where the alignment places them, in image pixels.") }), _jsxs("small", { children: ["RMSE ", fit.rmse.toFixed(2), " ", coordinateUnits(workingCrs), " \u00B7 ", workingCrs] })] }));
39
+ }
@@ -0,0 +1,32 @@
1
+ import type { Definitions, ExportFormat, ExportResult, GeoreferencerController } from "@georeferencing/core";
2
+ import type { Translate } from "../localization.js";
3
+ /** Raster output settings shared by every raster export format. */
4
+ export declare function OutputFields({ controller, t, definitions, }: {
5
+ controller: GeoreferencerController;
6
+ t: Translate;
7
+ definitions?: Definitions;
8
+ }): import("react/jsx-runtime").JSX.Element;
9
+ /** One button per configured format; results go to `onExport` or browser downloads. */
10
+ export declare function ExportButtons({ controller, t, formats, onExport, primary, status, }: {
11
+ controller: GeoreferencerController;
12
+ t: Translate;
13
+ formats: readonly ExportFormat[];
14
+ onExport?: (result: ExportResult) => void | Promise<void>;
15
+ /** Formats rendered as primary buttons. */
16
+ primary?: (format: ExportFormat) => boolean;
17
+ /**
18
+ * Include the cancel button and progress bar. Disable when several button groups
19
+ * share one {@link ExportProgress}.
20
+ */
21
+ status?: boolean;
22
+ }): import("react/jsx-runtime").JSX.Element | null;
23
+ /** Progress bar and cancel button while an export runs; renders nothing when idle. */
24
+ export declare function ExportProgress({ controller, t, }: {
25
+ controller: GeoreferencerController;
26
+ t: Translate;
27
+ }): import("react/jsx-runtime").JSX.Element | null;
28
+ /** Restore a session with its image, import QGIS `.points` and save a host draft. */
29
+ export declare function SessionTools({ controller, t, }: {
30
+ controller: GeoreferencerController;
31
+ t: Translate;
32
+ }): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,152 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { coordinateUnits, importPoints } from "@georeferencing/core";
3
+ import { useRef } from "react";
4
+ import { useGeoreferencer } from "../hooks/useGeoreferencer.js";
5
+ import { downloadBlob } from "../utils/downloadBlob.js";
6
+ import { NumberField, TextField } from "./fields.js";
7
+ const run = (controller, fn) => {
8
+ try {
9
+ fn();
10
+ }
11
+ catch (e) {
12
+ controller.reportError(e);
13
+ }
14
+ };
15
+ /** Raster output settings shared by every raster export format. */
16
+ export function OutputFields({ controller, t, definitions, }) {
17
+ const s = useGeoreferencer(controller);
18
+ const output = s.document.output;
19
+ const geotiff = s.exportFormats.some((format) => format.id === "geotiff");
20
+ return (_jsxs("div", { className: "rg-output-fields", children: [_jsxs("label", { children: [t("Output CRS"), _jsx(TextField, { label: t("Output CRS"), value: output.crs, change: (crs) => run(controller, () => {
21
+ if (!crs)
22
+ throw Error("Output CRS is required.");
23
+ controller.setOutput({ ...output, crs });
24
+ }) })] }), _jsxs("label", { children: [t("Resampling"), _jsx("select", { value: output.resampler, onChange: (e) => controller.setOutput({
25
+ ...output,
26
+ resampler: e.target.value,
27
+ }), children: ["nearest", "bilinear", "cubic", "cubicSpline", "lanczos"].map((m) => (_jsx("option", { children: m }, m))) })] }), geotiff && (_jsxs("label", { children: [t("TIFF compression"), _jsxs("select", { value: output.compression ?? "none", onChange: (e) => controller.setOutput({
28
+ ...output,
29
+ compression: e.target.value,
30
+ predictor: e.target.value === "deflate" ? output.predictor : 1,
31
+ }), children: [_jsx("option", { value: "none", children: t("Uncompressed") }), _jsx("option", { value: "deflate", children: t("Deflate (lossless)") }), _jsx("option", { value: "packbits", children: t("PackBits (lossless)") })] })] })), _jsxs("label", { children: [t("Input no-data (byte or R,G,B; blank = alpha)"), _jsx(TextField, { label: t("Input no-data"), value: Array.isArray(output.sourceNoData)
32
+ ? output.sourceNoData.join(",")
33
+ : String(output.sourceNoData ?? ""), change: (value) => run(controller, () => {
34
+ const values = value.split(",").map(Number);
35
+ if (value &&
36
+ (![1, 3].includes(values.length) ||
37
+ values.some((v) => !Number.isInteger(v) || v < 0 || v > 255)))
38
+ throw Error("Input no-data requires one or three bytes (0–255).");
39
+ controller.setOutput({
40
+ ...output,
41
+ sourceNoData: value
42
+ ? values.length === 1
43
+ ? values[0]
44
+ : values
45
+ : undefined,
46
+ });
47
+ }) })] }), geotiff && (_jsxs("label", { children: [t("Output no-data (byte; blank = alpha)"), _jsx(TextField, { label: t("Output no-data"), value: String(output.noData ?? ""), change: (value) => run(controller, () => {
48
+ const n = Number(value);
49
+ if (value && (!Number.isInteger(n) || n < 0 || n > 255))
50
+ throw Error("Output no-data requires a byte (0–255).");
51
+ controller.setOutput({
52
+ ...output,
53
+ noData: value ? n : undefined,
54
+ });
55
+ }) })] })), geotiff && (_jsxs(_Fragment, { children: [_jsxs("label", { children: [t("Rows per TIFF strip"), _jsx(NumberField, { label: t("Rows per TIFF strip"), value: output.rowsPerStrip ?? 256, change: (n) => run(controller, () => {
56
+ if (!Number.isInteger(n) || n < 1 || n > 4096)
57
+ throw Error("Rows per strip must be 1–4096.");
58
+ controller.setOutput({ ...output, rowsPerStrip: n });
59
+ }) })] }), _jsxs("label", { children: [t("Horizontal TIFF predictor"), _jsx("input", { type: "checkbox", disabled: output.compression !== "deflate", checked: output.predictor === 2, onChange: (e) => controller.setOutput({
60
+ ...output,
61
+ predictor: e.target.checked ? 2 : 1,
62
+ }) })] })] })), _jsxs("label", { children: [t("Pixel size (x,y; blank = estimated)"), " \u00B7", " ", coordinateUnits(output.crs, definitions), _jsx(TextField, { label: t("Pixel size"), value: output.resolution?.join(",") ?? "", change: (value) => run(controller, () => {
63
+ const values = value.split(",").map(Number);
64
+ if (value &&
65
+ (values.length > 2 ||
66
+ values.some((v) => !Number.isFinite(v) || v <= 0)))
67
+ throw Error("Pixel size requires one or two positive numbers.");
68
+ controller.setOutput({
69
+ ...output,
70
+ resolution: value
71
+ ? [values[0], values[1] ?? values[0]]
72
+ : undefined,
73
+ });
74
+ }) })] }), _jsxs("label", { children: [t("Output bounds (minX,minY,maxX,maxY; blank = footprint)"), _jsx(TextField, { label: t("Output bounds"), value: output.bounds?.join(",") ?? "", change: (value) => run(controller, () => {
75
+ const values = value.split(",").map(Number);
76
+ if (value &&
77
+ (values.length !== 4 ||
78
+ values.some((v) => !Number.isFinite(v)) ||
79
+ values[0] >= values[2] ||
80
+ values[1] >= values[3]))
81
+ throw Error("Bounds require four finite numbers with min < max.");
82
+ controller.setOutput({
83
+ ...output,
84
+ bounds: value
85
+ ? values
86
+ : undefined,
87
+ });
88
+ }) })] })] }));
89
+ }
90
+ /** One button per configured format; results go to `onExport` or browser downloads. */
91
+ export function ExportButtons({ controller, t, formats, onExport, primary = () => false, status = true, }) {
92
+ const s = useGeoreferencer(controller);
93
+ if (!formats.length)
94
+ return null;
95
+ return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "rg-toolbar", children: [formats.map((format) => {
96
+ const unavailable = controller.getExportUnavailable(format.id);
97
+ return (_jsx("button", { type: "button", className: primary(format) ? "rg-primary" : undefined, "data-export-format": format.id, title: unavailable ? t(unavailable) : undefined, disabled: Boolean(unavailable) || s.exporting === "running", onClick: () => void controller
98
+ .export(format.id)
99
+ .then(async (result) => {
100
+ if (!result)
101
+ return;
102
+ if (onExport)
103
+ await onExport(result);
104
+ else
105
+ for (const file of result.files)
106
+ downloadBlob(file.blob, file.name);
107
+ })
108
+ .catch((error) => controller.reportError(error)), children: t(format.label) }, format.id));
109
+ }), status && (_jsx("button", { type: "button", disabled: s.exporting !== "running", onClick: () => controller.cancelExport(), children: t("Cancel export") }))] }), status && s.exporting === "running" && (_jsx("progress", { "aria-label": t("Export progress"), value: s.progress, max: "1" }))] }));
110
+ }
111
+ /** Progress bar and cancel button while an export runs; renders nothing when idle. */
112
+ export function ExportProgress({ controller, t, }) {
113
+ const s = useGeoreferencer(controller);
114
+ if (s.exporting !== "running")
115
+ return null;
116
+ return (_jsxs("div", { className: "rg-export-progress", children: [_jsx("progress", { "aria-label": t("Export progress"), value: s.progress, max: "1" }), _jsx("button", { type: "button", onClick: () => controller.cancelExport(), children: t("Cancel export") })] }));
117
+ }
118
+ /** Restore a session with its image, import QGIS `.points` and save a host draft. */
119
+ export function SessionTools({ controller, t, }) {
120
+ const s = useGeoreferencer(controller);
121
+ const sessionInput = useRef(null);
122
+ const pendingSession = useRef(null);
123
+ return (_jsxs("div", { className: "rg-toolbar", children: [_jsxs("label", { className: "rg-file", children: [t("Restore session"), _jsx("input", { type: "file", "aria-label": t("Restore session"), accept: ".json", onChange: async (e) => {
124
+ const file = e.target.files?.[0];
125
+ if (file) {
126
+ pendingSession.current = await file.text();
127
+ sessionInput.current?.click();
128
+ }
129
+ e.target.value = "";
130
+ } })] }), _jsx("input", { ref: sessionInput, "aria-label": t("Matching session image"), type: "file", hidden: true, accept: "image/*,.tif,.tiff", onChange: (e) => {
131
+ const file = e.target.files?.[0];
132
+ if (file && pendingSession.current)
133
+ void controller
134
+ .restoreSession(pendingSession.current, file)
135
+ .catch((e) => controller.reportError(e));
136
+ e.target.value = "";
137
+ } }), _jsxs("label", { className: "rg-file", children: [t("Import .points"), _jsx("input", { type: "file", "aria-label": t("Import .points"), accept: ".points,.csv", disabled: !s.document.sourceImage || s.mode === "draw", onChange: async (e) => {
138
+ const f = e.target.files?.[0];
139
+ if (f) {
140
+ try {
141
+ const imported = importPoints(await f.text(), s.document.workingCrs);
142
+ controller.replaceGcps(imported.gcps);
143
+ }
144
+ catch (e) {
145
+ controller.reportError(e);
146
+ }
147
+ }
148
+ e.target.value = "";
149
+ } })] }), controller.options.onSaveDraft && (_jsx("button", { type: "button", disabled: !s.document.sourceImage || s.saving === "running", onClick: () => void controller
150
+ .save("draft")
151
+ .catch((e) => controller.reportError(e)), children: t("Save draft") }))] }));
152
+ }
@@ -0,0 +1,17 @@
1
+ export declare function TextField({ value, label, change, }: {
2
+ value: string;
3
+ label: string;
4
+ change: (value: string) => void;
5
+ }): import("react/jsx-runtime").JSX.Element;
6
+ /**
7
+ * Retain an editable numeric draft and commit only finite values the user changed, on
8
+ * blur/Enter. `decimals` rounds the displayed value; an untouched rounded display never
9
+ * overwrites the stored full-precision value.
10
+ */
11
+ export declare function NumberField({ value, label, change, disabled, decimals, }: {
12
+ value: number;
13
+ label: string;
14
+ change: (value: number) => void;
15
+ disabled?: boolean;
16
+ decimals?: number;
17
+ }): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,35 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect, useState } from "react";
3
+ export function TextField({ value, label, change, }) {
4
+ const [draft, setDraft] = useState(value);
5
+ useEffect(() => setDraft(value), [value]);
6
+ return (_jsx("input", { "aria-label": label, value: draft, onChange: (e) => setDraft(e.target.value), onBlur: () => {
7
+ if (draft.trim() !== value)
8
+ change(draft.trim());
9
+ }, onKeyDown: (e) => {
10
+ if (e.key === "Enter")
11
+ e.currentTarget.blur();
12
+ } }));
13
+ }
14
+ /**
15
+ * Retain an editable numeric draft and commit only finite values the user changed, on
16
+ * blur/Enter. `decimals` rounds the displayed value; an untouched rounded display never
17
+ * overwrites the stored full-precision value.
18
+ */
19
+ export function NumberField({ value, label, change, disabled = false, decimals, }) {
20
+ const shown = decimals === undefined
21
+ ? String(value)
22
+ : String(Number(value.toFixed(decimals)));
23
+ const [draft, setDraft] = useState(shown);
24
+ useEffect(() => setDraft(shown), [shown]);
25
+ return (_jsx("input", { "aria-label": label, type: "number", step: "any", disabled: disabled, value: draft, onChange: (e) => setDraft(e.target.value), onBlur: () => {
26
+ const n = Number(draft);
27
+ if (draft.trim() && draft.trim() !== shown && Number.isFinite(n))
28
+ change(n);
29
+ else
30
+ setDraft(shown);
31
+ }, onKeyDown: (e) => {
32
+ if (e.key === "Enter")
33
+ e.currentTarget.blur();
34
+ } }));
35
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Optional controls of the guided `Georeferencer` layout (used when `referenceView` is
3
+ * supplied). Each flag adds expert controls on top of the minimal step-by-step flow;
4
+ * omitted flags fall back to {@link MINIMAL_CONTROLS}. The classic layout always shows
5
+ * every control.
6
+ */
7
+ export interface GeoreferencerControls {
8
+ /** Undo and redo buttons for point and drawing edits. */
9
+ history: boolean;
10
+ /**
11
+ * Manual/automatic preview selector. When hidden, the controller's configured
12
+ * `previewMode` applies and users run alignment with the main button.
13
+ */
14
+ previewMode: boolean;
15
+ /** Transformation model selector while matching points. Hidden keeps the controller's model. */
16
+ transformation: boolean;
17
+ /**
18
+ * Full control point table with editable coordinates, target CRS and residual columns.
19
+ * When hidden, a compact list shows each point's use state and residual.
20
+ */
21
+ pointTable: boolean;
22
+ /** Add a point pair by typing coordinates; the keyboard alternative to clicking. */
23
+ manualEntry: boolean;
24
+ /** Previous/next image and map views and linked image/map navigation. */
25
+ navigation: boolean;
26
+ /** Brightness, contrast and histogram stretch for the source image display. */
27
+ displayAdjustment: boolean;
28
+ /** Load status of configured reference providers. */
29
+ referenceStatus: boolean;
30
+ /** Raster output settings: CRS, resampling, compression, no-data, pixel size and bounds. */
31
+ outputSettings: boolean;
32
+ /** Restore a session, import QGIS `.points` and save drafts; also offers session downloads before the last step. */
33
+ sessionFiles: boolean;
34
+ /** Indicator for work that has not been saved by the host. */
35
+ unsavedIndicator: boolean;
36
+ }
37
+ /** Default guided setup: the four-step flow with undo/redo and nothing else. */
38
+ export declare const MINIMAL_CONTROLS: Readonly<GeoreferencerControls>;
39
+ /** Every optional guided control enabled. */
40
+ export declare const ALL_CONTROLS: Readonly<GeoreferencerControls>;
@@ -0,0 +1,28 @@
1
+ /** Default guided setup: the four-step flow with undo/redo and nothing else. */
2
+ export const MINIMAL_CONTROLS = Object.freeze({
3
+ history: true,
4
+ previewMode: false,
5
+ transformation: false,
6
+ pointTable: false,
7
+ manualEntry: false,
8
+ navigation: false,
9
+ displayAdjustment: false,
10
+ referenceStatus: false,
11
+ outputSettings: false,
12
+ sessionFiles: false,
13
+ unsavedIndicator: false,
14
+ });
15
+ /** Every optional guided control enabled. */
16
+ export const ALL_CONTROLS = Object.freeze({
17
+ history: true,
18
+ previewMode: true,
19
+ transformation: true,
20
+ pointTable: true,
21
+ manualEntry: true,
22
+ navigation: true,
23
+ displayAdjustment: true,
24
+ referenceStatus: true,
25
+ outputSettings: true,
26
+ sessionFiles: true,
27
+ unsavedIndicator: true,
28
+ });
@@ -0,0 +1,6 @@
1
+ import type { GeoreferencerController } from "@georeferencing/core";
2
+ /**
3
+ * Subscribe to a controller with React useSyncExternalStore and read its stable
4
+ * EditorSnapshot. The hook does not attach maps or own controller lifecycle.
5
+ */
6
+ export declare function useGeoreferencer(controller: GeoreferencerController): import("@georeferencing/core").EditorSnapshot;
@@ -0,0 +1,8 @@
1
+ import { useSyncExternalStore } from "react";
2
+ /**
3
+ * Subscribe to a controller with React useSyncExternalStore and read its stable
4
+ * EditorSnapshot. The hook does not attach maps or own controller lifecycle.
5
+ */
6
+ export function useGeoreferencer(controller) {
7
+ return useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);
8
+ }