@georeferencing/react 0.1.0 → 0.3.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.
package/README.md CHANGED
@@ -30,9 +30,10 @@ Importing the package is SSR-safe. Create maps and start browser processing only
30
30
  on the client.
31
31
 
32
32
  **Install the same version of `@georeferencing/react`, `@georeferencing/core` and
33
- `@georeferencing/plugins`**, and upgrade them together. React depends on core and
34
- re-exports its controller; a mismatched version can install a second copy of core
35
- with a separate projection registry. See
33
+ `@georeferencing/plugins`**, and upgrade them together. React declares core as a peer
34
+ dependency and re-exports its controller; a mismatched version is reported as a
35
+ peer-dependency conflict rather than installing a second core with a separate
36
+ projection registry. See
36
37
  [keep package versions aligned](https://georeferencing-api-docs.gh.tobilg.com/Getting_started/#keep-package-versions-aligned).
37
38
 
38
39
  ## Integrate with a host-owned map
@@ -149,35 +150,91 @@ sidecars. Export completion never acknowledges host persistence. See the
149
150
  [plugins README](https://github.com/tobilg/georeferencing/blob/main/packages/plugins/README.md)
150
151
  for format settings, cancellation and limits.
151
152
 
152
- ## Guided matching and preview policy
153
+ ## Guided four-step layout
153
154
 
154
- The Hamburg demo uses `previewMode: "manual"` in its core controller. The visible
155
- **Preview updates** selector changes that policy through `setPreviewMode`.
156
- `PreviewControls` is also exported for custom layouts; it displays minimum-point
157
- readiness and an explicit **Run alignment** action. Worker validation reports
158
- singular or unusable arrangements when a run is requested. Automatic mode updates
159
- eligible previews after committed edits without advancing the user's review step.
160
-
161
- Supply `referenceView` to `Georeferencer` to opt into the guided layout. It is a
162
- React node containing the host's map target and optional map controls; the host
163
- still owns the map and must attach its target before editor effects run (a React
164
- callback ref is suitable). Without this prop, the existing panel layout remains.
165
- See the [complete demo host](https://github.com/tobilg/georeferencing/blob/main/packages/demo/main.tsx)
155
+ Supply `referenceView` to `Georeferencer` to use the guided layout. It is a React
156
+ node containing the host's map target and optional map controls; the host still
157
+ owns the map and must attach its target before editor effects run (a React
158
+ callback ref is suitable). Without this prop, the classic workbench with every
159
+ control remains. See the
160
+ [complete demo host](https://github.com/tobilg/georeferencing/blob/main/packages/demo/main.tsx)
166
161
  for a typed integration with target attachment and cleanup.
167
162
 
168
- The guided layout automatically starts image-first/map-second matching after an
169
- accepted image load, announces the next endpoint, supports Escape to cancel an
170
- incomplete pair, and keeps both views side by side on desktop. Below 720 px,
171
- image/map buttons switch the visible pane and follow pending pairs without
172
- unmounting either viewer. The sticky run toolbar remains accessible while editing
173
- the point table. After running, review the overlay and residuals, edit/rerun as
174
- needed, then export or explicitly accept alignment to draw. Drawing controls are
175
- shown after acceptance; session files remain available during unfinished work.
176
-
177
- `AlignmentPanel` accepts `controls={false}` when transformation and preview
178
- controls are already rendered elsewhere. `PreviewControls.onReview` is called
179
- only for a successful preview matching the requested document/alignment revision.
180
- These panels never take map or engine ownership.
163
+ The guided layout leads users through four steps, each with one main action in a
164
+ sticky bar:
165
+
166
+ 1. **Load image**: drop zone with **Choose image** and any `emptyImageActions`
167
+ (for example a sample-image button).
168
+ 2. **Match points**: click a spot in the image, then the same spot on the map.
169
+ Progress shows how many points the model needs; **Run alignment** fits them.
170
+ 3. **Check alignment**: the overlay on the map, a plain-language accuracy summary
171
+ (average residual in image pixels, or a hint to add a point when the fit is
172
+ exact) and the point list. **Adjust points** goes back; **Looks good, continue**
173
+ confirms the alignment.
174
+ 4. **Export or draw**: download buttons for the configured formats and, with
175
+ `digitizing`, drawing tools and **Save features**. **Back to check** returns.
176
+
177
+ Completed steps in the step bar are clickable. Desktop keeps both views side by
178
+ side; below 720 px, Image/Map buttons switch panes and follow pending points.
179
+ In both views, dragging pans and scrolling zooms; a click without dragging places
180
+ a point. Zooming the image beyond the reduced preview's resolution loads the
181
+ full-resolution image (`controller.requestDetailImage()`), and source pixels are shown
182
+ crisply once each covers several screen pixels, so points can be placed at the
183
+ original resolution.
184
+
185
+ OpenLayers' `Map` creates its default interactions with `onFocusOnly: true`. If
186
+ the map target has a `tabindex` (for keyboard navigation), mouse panning and wheel
187
+ zoom then only work after the map has focus. Create the host map with
188
+ `interactions: defaults({ onFocusOnly: false })` from `ol/interaction/defaults.js`,
189
+ as the demo does, so both work immediately.
190
+
191
+ ### Choose the controls
192
+
193
+ The guided layout starts minimal. Add expert controls individually with
194
+ `controls`; omitted flags fall back to `MINIMAL_CONTROLS`:
195
+
196
+ ```tsx
197
+ import { ALL_CONTROLS, Georeferencer } from "@georeferencing/react";
198
+
199
+ <Georeferencer
200
+ controller={controller}
201
+ referenceMap={map}
202
+ referenceView={mapView}
203
+ controls={{ transformation: true, outputSettings: true }}
204
+ />;
205
+ // Every control: controls={ALL_CONTROLS}
206
+ ```
207
+
208
+ | Flag | Default | Adds |
209
+ | --- | --- | --- |
210
+ | `history` | on | Undo and redo |
211
+ | `previewMode` | off | Manual/automatic preview selector (otherwise the controller's `previewMode` applies) |
212
+ | `transformation` | off | Transformation model selector while matching |
213
+ | `pointTable` | off | Full point table with editable coordinates, CRS and residuals (otherwise a compact list) |
214
+ | `manualEntry` | off | Add a point pair by typing coordinates, the keyboard alternative to clicking |
215
+ | `navigation` | off | Previous/next image and map views, linked navigation |
216
+ | `displayAdjustment` | off | Brightness, contrast and histogram stretch |
217
+ | `referenceStatus` | off | Load status of reference providers |
218
+ | `outputSettings` | off | Raster output CRS, resampling, compression, no-data, pixel size and bounds |
219
+ | `sessionFiles` | off | Restore session, import `.points`, save draft and early session downloads |
220
+ | `unsavedIndicator` | off | Unsaved-changes indicator |
221
+
222
+ Enable `manualEntry` when keyboard-only point entry is required.
223
+
224
+ ### Preview policy and panels
225
+
226
+ `previewMode: "manual"` on the controller waits for **Run alignment**;
227
+ `"automatic"` updates eligible previews after committed edits without advancing
228
+ the user's step. Worker validation reports singular or unusable arrangements when
229
+ a run is requested.
230
+
231
+ The composable panels keep every control by default and accept the same opt-outs:
232
+ `GcpPanel` takes `variant` (`"table"` or `"compact"`), `manualEntry` and `tools`;
233
+ `ImagePanel` takes `navigation`, `displayAdjustment` and `emptyActions`;
234
+ `PreviewControls` takes `modeSelector` and calls `onReview` only for a successful
235
+ preview matching the requested revision. `AlignmentPanel` accepts
236
+ `controls={false}` when transformation and preview controls are rendered
237
+ elsewhere. These panels never take map or engine ownership.
181
238
 
182
239
  ## References, projections and snapping
183
240
 
@@ -2,6 +2,7 @@ import type { ExportResult, GeoreferencerController } from "@georeferencing/core
2
2
  import type { BindingOptions } from "@georeferencing/core/openlayers";
3
3
  import type OLMap from "ol/Map.js";
4
4
  import type { ReactNode } from "react";
5
+ import type { GeoreferencerControls } from "./controls.js";
5
6
  import type { Translate } from "./localization.js";
6
7
  import { FeaturePanel } from "./panels/FeaturePanel.js";
7
8
  /**
@@ -10,11 +11,23 @@ import { FeaturePanel } from "./panels/FeaturePanel.js";
10
11
  */
11
12
  export interface GeoreferencerProps {
12
13
  /**
13
- * Opt into a guided matching/review layout by supplying the host map's rendered
14
- * target and controls. The host still creates, targets and disposes the map. This
15
- * slot stays mounted across matching/review and responsive image/map tabs.
14
+ * Opt into the guided four-step layout (load, match, check, export/draw) by supplying
15
+ * the host map's rendered target and controls. The host still creates, targets and
16
+ * disposes the map. This slot stays mounted across all steps and responsive
17
+ * image/map tabs.
16
18
  */
17
19
  referenceView?: ReactNode;
20
+ /**
21
+ * Optional controls of the guided layout. Omitted flags use {@link MINIMAL_CONTROLS};
22
+ * pass {@link ALL_CONTROLS} for every expert control. The classic layout always shows
23
+ * all controls.
24
+ */
25
+ controls?: Partial<GeoreferencerControls>;
26
+ /**
27
+ * Extra actions shown in the empty image drop zone next to "Choose image", for example
28
+ * a button that loads a sample image.
29
+ */
30
+ emptyImageActions?: ReactNode;
18
31
  /**
19
32
  * Host-owned authoritative controller; the component resumes/suspends it but does not
20
33
  * permanently dispose it.
@@ -53,6 +66,6 @@ export interface GeoreferencerProps {
53
66
  /**
54
67
  * Ready-made React editor for an existing OpenLayers map. Composes image, GCP, alignment, reference, output and optional drawing controls.
55
68
  *
56
- * 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`.
69
+ * 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`.
57
70
  */
58
- export declare function Georeferencer({ controller, referenceMap, bindingOptions, t, className, propertyEditor, onExport, formatError, referenceView, }: GeoreferencerProps): import("react/jsx-runtime").JSX.Element;
71
+ export declare function Georeferencer({ controller, referenceMap, bindingOptions, t, className, propertyEditor, onExport, formatError, referenceView, controls: controlOverrides, emptyImageActions, }: GeoreferencerProps): import("react/jsx-runtime").JSX.Element;
@@ -1,8 +1,10 @@
1
- import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { coordinateUnits, importPoints, MODELS } from "@georeferencing/core";
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { MODELS } from "@georeferencing/core";
3
3
  import { attachReferenceMap } from "@georeferencing/core/openlayers";
4
4
  import { useEffect, useId, useRef, useState } from "react";
5
- import { NumberField, TextField } from "./components/fields.js";
5
+ import { AccuracySummary } from "./components/accuracy.js";
6
+ import { ExportButtons, ExportProgress, OutputFields, SessionTools, } from "./components/exports.js";
7
+ import { MINIMAL_CONTROLS } from "./controls.js";
6
8
  import { useGeoreferencer } from "./hooks/useGeoreferencer.js";
7
9
  import { identity } from "./localization.js";
8
10
  import { AlignmentPanel } from "./panels/AlignmentPanel.js";
@@ -11,48 +13,77 @@ import { GcpPanel } from "./panels/GcpPanel.js";
11
13
  import { ImagePanel } from "./panels/ImagePanel.js";
12
14
  import { PreviewControls } from "./panels/PreviewControls.js";
13
15
  import { ReferencePanel } from "./panels/ReferencePanel.js";
14
- import { downloadBlob } from "./utils/downloadBlob.js";
15
16
  const DEFAULT_BINDING = {};
17
+ const NO_CONTROLS = {};
18
+ /** Activate a tool from an effect, reporting instead of throwing into React. */
19
+ function selectTool(controller, tool) {
20
+ try {
21
+ controller.setTool(tool);
22
+ }
23
+ catch (error) {
24
+ controller.reportError(error);
25
+ }
26
+ }
16
27
  /**
17
28
  * Ready-made React editor for an existing OpenLayers map. Composes image, GCP, alignment, reference, output and optional drawing controls.
18
29
  *
19
- * 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`.
30
+ * 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`.
20
31
  */
21
- export function Georeferencer({ controller, referenceMap, bindingOptions = DEFAULT_BINDING, t = identity, className = "", propertyEditor, onExport, formatError, referenceView, }) {
32
+ export function Georeferencer({ controller, referenceMap, bindingOptions = DEFAULT_BINDING, t = identity, className = "", propertyEditor, onExport, formatError, referenceView, controls: controlOverrides = NO_CONTROLS, emptyImageActions, }) {
22
33
  const guardTitle = useId();
23
34
  const s = useGeoreferencer(controller), binding = useRef(null);
24
- const [guard, setGuard] = useState(null), dialog = useRef(null), sessionInput = useRef(null);
25
- const pendingSession = useRef(null);
35
+ const [guard, setGuard] = useState(null), dialog = useRef(null);
26
36
  const guided = referenceView !== undefined;
27
- const [editing, setEditing] = useState(true);
28
- const [reviewedDocumentRevision, setReviewedDocumentRevision] = useState(null);
37
+ const controls = { ...MINIMAL_CONTROLS, ...controlOverrides };
38
+ const [stage, setStage] = useState("match");
29
39
  const [mobileView, setMobileView] = useState("image");
30
- const imageId = s.document.sourceImage?.id;
40
+ const d = s.document;
41
+ const imageId = d.sourceImage?.id;
31
42
  const imageReady = Boolean(s.imageUrl);
32
- const review = !!s.fit && !editing;
33
- const visibleFormats = s.exportFormats.filter((format) => !guided || review || s.mode === "draw" || format.requiresFit === false);
34
- const nextPoint = Math.max(0, ...s.document.gcps.map((p) => p.label)) + 1;
43
+ const confirmed = d.confirmedAlignmentRevision === d.alignmentRevision;
44
+ // 0 load image · 1 match points · 2 check alignment · 3 export or draw
45
+ const step = !imageReady
46
+ ? 0
47
+ : s.mode === "draw" || (stage === "finish" && confirmed && s.fit)
48
+ ? 3
49
+ : stage !== "match" && s.fit
50
+ ? 2
51
+ : 1;
52
+ const nextPoint = Math.max(0, ...d.gcps.map((p) => p.label)) + 1;
53
+ const hasWork = d.gcps.length > 0 || d.features.features.length > 0;
35
54
  useEffect(() => {
36
55
  if (!guided)
37
56
  return;
38
- setEditing(true);
39
- setReviewedDocumentRevision(null);
57
+ setStage("match");
40
58
  setMobileView("image");
41
59
  // A metadata-only restored document still needs its matching local bytes.
42
60
  if (imageId && imageReady && controller.getSnapshot().mode === "align")
43
- controller.setTool("gcp");
61
+ selectTool(controller, "gcp");
44
62
  }, [controller, guided, imageId, imageReady]);
45
63
  useEffect(() => {
46
64
  if (guided)
47
65
  setMobileView(s.pendingImagePoint ? "map" : "image");
48
66
  }, [guided, s.pendingImagePoint]);
67
+ // Matching always uses the point tool; dragging pans, so no separate pan mode is needed.
68
+ useEffect(() => {
69
+ if (guided && step === 1 && s.tool !== "gcp" && s.mode === "align")
70
+ selectTool(controller, "gcp");
71
+ }, [controller, guided, step, s.tool, s.mode]);
49
72
  useEffect(() => {
50
73
  if (guided && mobileView === "map")
51
74
  referenceMap.updateSize();
52
75
  }, [guided, mobileView, referenceMap]);
53
76
  useEffect(() => {
54
77
  controller.start();
55
- binding.current = attachReferenceMap(referenceMap, controller, bindingOptions);
78
+ try {
79
+ binding.current = attachReferenceMap(referenceMap, controller, bindingOptions);
80
+ }
81
+ catch (error) {
82
+ // Invalid projection or datum-grid configuration: keep the editor mounted and
83
+ // show the error instead of unmounting the host tree.
84
+ binding.current = null;
85
+ controller.reportError(error);
86
+ }
56
87
  return () => {
57
88
  binding.current?.detach();
58
89
  binding.current = null;
@@ -92,6 +123,75 @@ export function Georeferencer({ controller, referenceMap, bindingOptions = DEFAU
92
123
  controller.reportError(e);
93
124
  }
94
125
  };
126
+ const error = s.error
127
+ ? s.errorDetail && formatError
128
+ ? formatError(s.errorDetail)
129
+ : t(s.error)
130
+ : null;
131
+ const unsaved = (_jsx("span", { className: "rg-dirty", children: t(s.dirty && hasWork ? "Unsaved changes" : "No unsaved changes") }));
132
+ const goToMatching = () => run(() => {
133
+ if (controller.getSnapshot().mode === "draw")
134
+ controller.returnToAlignment();
135
+ setStage("match");
136
+ setMobileView("image");
137
+ controller.setTool("gcp");
138
+ });
139
+ const goToReview = () => run(() => {
140
+ if (controller.getSnapshot().mode === "draw")
141
+ controller.returnToAlignment();
142
+ setStage("review");
143
+ setMobileView("map");
144
+ controller.setTool("navigate");
145
+ });
146
+ const finish = () => run(() => {
147
+ controller.confirm();
148
+ setStage("finish");
149
+ setMobileView("map");
150
+ });
151
+ const steps = [
152
+ "Load image",
153
+ "Match points",
154
+ "Check alignment",
155
+ controller.options.digitizing ? "Export or draw" : "Export",
156
+ ];
157
+ const instruction = step === 0
158
+ ? [
159
+ "Choose an image to begin",
160
+ "Drop an image or choose a file. It stays on this device.",
161
+ ]
162
+ : step === 1
163
+ ? [
164
+ s.pendingImagePoint
165
+ ? `${t("Now click the same spot on the map")} · ${t("Point")} ${nextPoint}`
166
+ : `${t("Click a recognizable spot in the image")} · ${t("Point")} ${nextPoint}`,
167
+ stage !== "match"
168
+ ? s.previewMode === "manual"
169
+ ? "Points or settings changed. Run alignment again to check the result."
170
+ : "Alignment changed. Wait for the updated preview."
171
+ : "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.",
172
+ ]
173
+ : step === 2
174
+ ? [
175
+ "Does the image line up with the map?",
176
+ "Compare roads, shorelines and building corners across the whole image. If something is off, adjust or add points.",
177
+ ]
178
+ : controller.options.digitizing
179
+ ? [
180
+ "Download the result or draw on the map",
181
+ "Your alignment is confirmed. Download files, or draw points, lines and areas on top of the aligned image.",
182
+ ]
183
+ : [
184
+ "Download the result",
185
+ "Your alignment is confirmed. Download the georeferenced image.",
186
+ ];
187
+ const primaryFormats = s.exportFormats.filter((format) => format.raster);
188
+ const otherFormats = s.exportFormats.filter((format) => !format.raster);
189
+ const toolRow = (step === 1 || step === 3) &&
190
+ (controls.history ||
191
+ controls.navigation ||
192
+ controls.transformation ||
193
+ controls.unsavedIndicator ||
194
+ ["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;
95
195
  return (_jsxs("section", { className: `rg-editor ${guided ? "rg-guided" : ""} ${className}`, "aria-label": t("Georeferencing editor"), onKeyDownCapture: (e) => {
96
196
  if (guided && e.key === "Escape" && s.pendingImagePoint) {
97
197
  controller.setPendingPoint(null);
@@ -103,173 +203,32 @@ export function Georeferencer({ controller, referenceMap, bindingOptions = DEFAU
103
203
  binding.current?.cancelDrawing();
104
204
  e.stopPropagation();
105
205
  }
106
- }, children: [guided ? (_jsxs(_Fragment, { children: [_jsx("ol", { className: "rg-steps", "aria-label": t("Georeferencing steps"), children: [
107
- "Load image",
108
- "Match points",
109
- "Review alignment",
110
- "Export or draw",
111
- ].map((label, i) => (_jsxs("li", { "aria-current": i === (!imageId ? 0 : s.mode === "draw" ? 3 : review ? 2 : 1)
112
- ? "step"
113
- : undefined, children: [i + 1, ". ", t(label)] }, label))) }), _jsxs("div", { className: "rg-matching-bar", children: [_jsxs("div", { className: "rg-instruction", role: "status", "aria-live": "polite", children: [_jsx("strong", { children: !imageReady
114
- ? t("Choose an image to begin")
115
- : s.mode === "draw"
116
- ? t("Draw features on the aligned map")
117
- : review
118
- ? t("Inspect the overlay against the reference map")
119
- : s.tool !== "gcp"
120
- ? t("Navigation active. Resume matching to add points.")
121
- : s.pendingImagePoint
122
- ? `${t("Select the matching location on the map")} · ${t("Point")} ${nextPoint}`
123
- : `${t("Select a point in the image")} · ${t("Point")} ${nextPoint}` }), _jsx("p", { children: t(reviewedDocumentRevision !== null &&
124
- reviewedDocumentRevision !== s.document.documentRevision &&
125
- !s.fit
126
- ? s.previewMode === "manual"
127
- ? "Points or settings changed. Run alignment again before reviewing or exporting."
128
- : "Alignment changed. Wait for the updated preview before reviewing."
129
- : review
130
- ? "Check landmarks across the image before exporting or accepting alignment."
131
- : "Match ground-level corners across the image. Shift-drag to pan; Escape cancels a pending pair.") })] }), !review && s.mode === "align" && (_jsx(PreviewControls, { controller: controller, t: t, onReview: () => {
132
- setEditing(false);
133
- setReviewedDocumentRevision(controller.getSnapshot().document.documentRevision);
134
- setMobileView("map");
135
- } })), review && s.mode === "align" && (_jsx("button", { type: "button", onClick: () => {
136
- setEditing(true);
137
- controller.setTool("gcp");
138
- }, children: t("Edit points") }))] }), s.error && (_jsx("p", { className: "rg-error", role: "alert", children: s.errorDetail && formatError
139
- ? formatError(s.errorDetail)
140
- : t(s.error) })), _jsxs("div", { className: "rg-toolbar rg-match-tools", children: [["LineString", "Polygon"].includes(s.tool) && (_jsx("button", { type: "button", onClick: () => binding.current?.finishDrawing(), children: t("Finish drawing") })), _jsx("button", { type: "button", disabled: !imageReady || s.mode === "draw", "aria-pressed": s.tool === "gcp", onClick: () => {
141
- setEditing(true);
142
- controller.setTool("gcp");
143
- }, children: t("Match points") }), _jsx("button", { type: "button", disabled: !imageReady || s.mode === "draw", "aria-pressed": s.tool === "navigate", onClick: () => controller.setTool("navigate"), children: t("Pan image") }), _jsx("button", { type: "button", disabled: !s.pendingImagePoint, onClick: () => controller.setPendingPoint(null), children: t("Cancel pending pair") }), _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") }), _jsxs("label", { children: [t("Transformation"), _jsx("select", { disabled: !imageId || s.mode === "draw", value: s.document.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))) })] }), _jsx("span", { className: "rg-dirty", children: t(s.dirty ? "Unsaved changes" : "No unsaved changes") })] }), _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 && !review, children: _jsx(ImagePanel, { controller: controller, t: t }) }), _jsx("div", { className: "rg-reference-pane", "data-active": !!s.pendingImagePoint, children: referenceView })] }), (review || s.mode === "draw") && (_jsxs("div", { className: "rg-review-panels", children: [_jsx(AlignmentPanel, { controller: controller, t: t, controls: false }), s.mode === "draw" && (_jsx(FeaturePanel, { controller: controller, propertyEditor: propertyEditor, t: t }))] })), _jsx(GcpPanel, { controller: controller, t: t }), _jsxs("details", { className: "rg-advanced", children: [_jsx("summary", { children: t("Advanced navigation & reference status") }), _jsxs("div", { className: "rg-toolbar", children: [_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") })] }), _jsx(ReferencePanel, { 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.") })] }), _jsx("span", { className: "rg-dirty", children: t(s.dirty ? "Unsaved changes" : "No unsaved changes") })] }), _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 }), _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(guided && !review && s.mode !== "draw"
144
- ? "Session files"
145
- : "Raster output & session files") }), visibleFormats.some((format) => format.raster) && (_jsxs("div", { className: "rg-output-fields", children: [_jsxs("label", { children: [t("Output CRS"), _jsx(TextField, { label: t("Output CRS"), value: s.document.output.crs, change: (crs) => run(() => {
146
- if (!crs)
147
- throw Error("Output CRS is required.");
148
- controller.setOutput({
149
- ...s.document.output,
150
- crs,
151
- });
152
- }) })] }), _jsxs("label", { children: [t("Resampling"), _jsx("select", { value: s.document.output.resampler, onChange: (e) => controller.setOutput({
153
- ...s.document.output,
154
- resampler: e.target.value,
155
- }), children: ["nearest", "bilinear", "cubic", "cubicSpline", "lanczos"].map((m) => (_jsx("option", { children: m }, m))) })] }), s.exportFormats.some((format) => format.id === "geotiff") && (_jsxs("label", { children: [t("TIFF compression"), _jsxs("select", { value: s.document.output.compression ?? "none", onChange: (e) => controller.setOutput({
156
- ...s.document.output,
157
- compression: e.target.value,
158
- predictor: e.target.value === "deflate"
159
- ? s.document.output.predictor
160
- : 1,
161
- }), 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(s.document.output.sourceNoData)
162
- ? s.document.output.sourceNoData.join(",")
163
- : String(s.document.output.sourceNoData ?? ""), change: (value) => run(() => {
164
- const values = value.split(",").map(Number);
165
- if (value &&
166
- (![1, 3].includes(values.length) ||
167
- values.some((v) => !Number.isInteger(v) || v < 0 || v > 255)))
168
- throw Error("Input no-data requires one or three bytes (0–255).");
169
- controller.setOutput({
170
- ...s.document.output,
171
- sourceNoData: value
172
- ? values.length === 1
173
- ? values[0]
174
- : values
175
- : undefined,
176
- });
177
- }) })] }), s.exportFormats.some((format) => format.id === "geotiff") && (_jsxs("label", { children: [t("Output no-data (byte; blank = alpha)"), _jsx(TextField, { label: t("Output no-data"), value: String(s.document.output.noData ?? ""), change: (value) => run(() => {
178
- const n = Number(value);
179
- if (value && (!Number.isInteger(n) || n < 0 || n > 255))
180
- throw Error("Output no-data requires a byte (0–255).");
181
- controller.setOutput({
182
- ...s.document.output,
183
- noData: value ? n : undefined,
184
- });
185
- }) })] })), s.exportFormats.some((format) => format.id === "geotiff") && (_jsxs(_Fragment, { children: [_jsxs("label", { children: [t("Rows per TIFF strip"), _jsx(NumberField, { label: t("Rows per TIFF strip"), value: s.document.output.rowsPerStrip ?? 256, change: (n) => run(() => {
186
- if (!Number.isInteger(n) || n < 1 || n > 4096)
187
- throw Error("Rows per strip must be 1–4096.");
188
- controller.setOutput({
189
- ...s.document.output,
190
- rowsPerStrip: n,
191
- });
192
- }) })] }), _jsxs("label", { children: [t("Horizontal TIFF predictor"), _jsx("input", { type: "checkbox", disabled: s.document.output.compression !== "deflate", checked: s.document.output.predictor === 2, onChange: (e) => controller.setOutput({
193
- ...s.document.output,
194
- predictor: e.target.checked ? 2 : 1,
195
- }) })] })] })), _jsxs("label", { children: [t("Pixel size (x,y; blank = estimated)"), " \u00B7", " ", coordinateUnits(s.document.output.crs, bindingOptions.definitions), _jsx(TextField, { label: t("Pixel size"), value: s.document.output.resolution?.join(",") ?? "", change: (value) => run(() => {
196
- const values = value.split(",").map(Number);
197
- if (value &&
198
- (values.length > 2 ||
199
- values.some((v) => !Number.isFinite(v) || v <= 0)))
200
- throw Error("Pixel size requires one or two positive numbers.");
201
- controller.setOutput({
202
- ...s.document.output,
203
- resolution: value
204
- ? [values[0], values[1] ?? values[0]]
205
- : undefined,
206
- });
207
- }) })] }), _jsxs("label", { children: [t("Output bounds (minX,minY,maxX,maxY; blank = footprint)"), _jsx(TextField, { label: t("Output bounds"), value: s.document.output.bounds?.join(",") ?? "", change: (value) => run(() => {
208
- const values = value.split(",").map(Number);
209
- if (value &&
210
- (values.length !== 4 ||
211
- values.some((v) => !Number.isFinite(v)) ||
212
- values[0] >= values[2] ||
213
- values[1] >= values[3]))
214
- throw Error("Bounds require four finite numbers with min < max.");
215
- controller.setOutput({
216
- ...s.document.output,
217
- bounds: value
218
- ? values
219
- : undefined,
220
- });
221
- }) })] })] })), visibleFormats.length > 0 && (_jsxs("div", { className: "rg-toolbar", children: [visibleFormats.map((format) => {
222
- const unavailable = controller.getExportUnavailable(format.id);
223
- return (_jsx("button", { type: "button", "data-export-format": format.id, title: unavailable ? t(unavailable) : undefined, disabled: Boolean(unavailable) || s.exporting === "running", onClick: () => void controller
224
- .export(format.id)
225
- .then(async (result) => {
226
- if (!result)
227
- return;
228
- if (onExport)
229
- await onExport(result);
230
- else
231
- for (const file of result.files)
232
- downloadBlob(file.blob, file.name);
233
- })
234
- .catch((error) => controller.reportError(error)), children: t(format.label) }, format.id));
235
- }), _jsx("button", { type: "button", disabled: s.exporting !== "running", onClick: () => controller.cancelExport(), children: t("Cancel export") })] })), s.exporting === "running" && (_jsx("progress", { "aria-label": t("Export progress"), value: s.progress, max: "1" })), _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) => {
236
- const file = e.target.files?.[0];
237
- if (file) {
238
- pendingSession.current = await file.text();
239
- sessionInput.current?.click();
240
- }
241
- e.target.value = "";
242
- } })] }), _jsx("input", { ref: sessionInput, "aria-label": t("Matching session image"), type: "file", hidden: true, accept: "image/*,.tif,.tiff", onChange: (e) => {
243
- const file = e.target.files?.[0];
244
- if (file && pendingSession.current)
245
- void controller
246
- .restoreSession(pendingSession.current, file)
247
- .catch((e) => controller.reportError(e));
248
- e.target.value = "";
249
- } }), _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) => {
250
- const f = e.target.files?.[0];
251
- if (f) {
252
- try {
253
- const imported = importPoints(await f.text(), s.document.workingCrs);
254
- controller.replaceGcps(imported.gcps);
255
- }
256
- catch (e) {
257
- controller.reportError(e);
258
- }
259
- }
260
- e.target.value = "";
261
- } })] }), controller.options.onSaveDraft && (_jsx("button", { type: "button", disabled: !s.document.sourceImage || s.saving === "running", onClick: () => void controller
262
- .save("draft")
263
- .catch((e) => controller.reportError(e)), children: t("Save draft") }))] })] }), _jsx("div", { className: "rg-status", role: "status", "aria-live": "polite", children: (s.error
264
- ? s.errorDetail && formatError
265
- ? formatError(s.errorDetail)
266
- : t(s.error)
267
- : null) ??
268
- (s.loading === "running"
206
+ }, children: [guided ? (_jsxs(_Fragment, { children: [_jsx("ol", { className: "rg-steps", "aria-label": t("Georeferencing steps"), children: steps.map((label, i) => {
207
+ const state = i < step ? "done" : i === step ? "current" : "todo";
208
+ const back = i < step && i === 1
209
+ ? goToMatching
210
+ : i < step && i === 2
211
+ ? goToReview
212
+ : undefined;
213
+ 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) })] }));
214
+ 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));
215
+ }) }), _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: () => {
216
+ setStage("review");
217
+ setMobileView("map");
218
+ } })), 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({
219
+ opacity: Number(e.target.value),
220
+ }) })] })] }), _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
221
+ ? s.loading === "running"
269
222
  ? t("Inspecting local image…")
270
223
  : s.exporting === "succeeded"
271
- ? t("Raster export complete.")
272
- : t("Image processing stays on this device.")) }), _jsxs("dialog", { ref: dialog, className: "rg-guard", onCancel: (e) => {
224
+ ? t("Export complete.")
225
+ : null
226
+ : (error ??
227
+ (s.loading === "running"
228
+ ? t("Inspecting local image…")
229
+ : s.exporting === "succeeded"
230
+ ? t("Raster export complete.")
231
+ : t("Image processing stays on this device."))) }), _jsxs("dialog", { ref: dialog, className: "rg-guard", onCancel: (e) => {
273
232
  e.preventDefault();
274
233
  decide("cancel");
275
234
  }, "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"
@@ -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;