@georeferencing/react 0.1.0 → 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.
package/README.md CHANGED
@@ -149,35 +149,88 @@ sidecars. Export completion never acknowledges host persistence. See the
149
149
  [plugins README](https://github.com/tobilg/georeferencing/blob/main/packages/plugins/README.md)
150
150
  for format settings, cancellation and limits.
151
151
 
152
- ## Guided matching and preview policy
153
-
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)
152
+ ## Guided four-step layout
153
+
154
+ Supply `referenceView` to `Georeferencer` to use the guided layout. It is a React
155
+ node containing the host's map target and optional map controls; the host still
156
+ owns the map and must attach its target before editor effects run (a React
157
+ callback ref is suitable). Without this prop, the classic workbench with every
158
+ control remains. See the
159
+ [complete demo host](https://github.com/tobilg/georeferencing/blob/main/packages/demo/main.tsx)
166
160
  for a typed integration with target attachment and cleanup.
167
161
 
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.
162
+ The guided layout leads users through four steps, each with one main action in a
163
+ sticky bar:
164
+
165
+ 1. **Load image**: drop zone with **Choose image** and any `emptyImageActions`
166
+ (for example a sample-image button).
167
+ 2. **Match points**: click a spot in the image, then the same spot on the map.
168
+ Progress shows how many points the model needs; **Run alignment** fits them.
169
+ 3. **Check alignment**: the overlay on the map, a plain-language accuracy summary
170
+ (average residual in image pixels, or a hint to add a point when the fit is
171
+ exact) and the point list. **Adjust points** goes back; **Looks good, continue**
172
+ confirms the alignment.
173
+ 4. **Export or draw**: download buttons for the configured formats and, with
174
+ `digitizing`, drawing tools and **Save features**. **Back to check** returns.
175
+
176
+ Completed steps in the step bar are clickable. Desktop keeps both views side by
177
+ side; below 720 px, Image/Map buttons switch panes and follow pending points.
178
+ In both views, dragging pans and scrolling zooms; a click without dragging places
179
+ a point.
180
+
181
+ OpenLayers' `Map` creates its default interactions with `onFocusOnly: true`. If
182
+ the map target has a `tabindex` (for keyboard navigation), mouse panning and wheel
183
+ zoom then only work after the map has focus. Create the host map with
184
+ `interactions: defaults({ onFocusOnly: false })` from `ol/interaction/defaults.js`,
185
+ as the demo does, so both work immediately.
186
+
187
+ ### Choose the controls
188
+
189
+ The guided layout starts minimal. Add expert controls individually with
190
+ `controls`; omitted flags fall back to `MINIMAL_CONTROLS`:
191
+
192
+ ```tsx
193
+ import { ALL_CONTROLS, Georeferencer } from "@georeferencing/react";
194
+
195
+ <Georeferencer
196
+ controller={controller}
197
+ referenceMap={map}
198
+ referenceView={mapView}
199
+ controls={{ transformation: true, outputSettings: true }}
200
+ />;
201
+ // Every control: controls={ALL_CONTROLS}
202
+ ```
203
+
204
+ | Flag | Default | Adds |
205
+ | --- | --- | --- |
206
+ | `history` | on | Undo and redo |
207
+ | `previewMode` | off | Manual/automatic preview selector (otherwise the controller's `previewMode` applies) |
208
+ | `transformation` | off | Transformation model selector while matching |
209
+ | `pointTable` | off | Full point table with editable coordinates, CRS and residuals (otherwise a compact list) |
210
+ | `manualEntry` | off | Add a point pair by typing coordinates, the keyboard alternative to clicking |
211
+ | `navigation` | off | Previous/next image and map views, linked navigation |
212
+ | `displayAdjustment` | off | Brightness, contrast and histogram stretch |
213
+ | `referenceStatus` | off | Load status of reference providers |
214
+ | `outputSettings` | off | Raster output CRS, resampling, compression, no-data, pixel size and bounds |
215
+ | `sessionFiles` | off | Restore session, import `.points`, save draft and early session downloads |
216
+ | `unsavedIndicator` | off | Unsaved-changes indicator |
217
+
218
+ Enable `manualEntry` when keyboard-only point entry is required.
219
+
220
+ ### Preview policy and panels
221
+
222
+ `previewMode: "manual"` on the controller waits for **Run alignment**;
223
+ `"automatic"` updates eligible previews after committed edits without advancing
224
+ the user's step. Worker validation reports singular or unusable arrangements when
225
+ a run is requested.
226
+
227
+ The composable panels keep every control by default and accept the same opt-outs:
228
+ `GcpPanel` takes `variant` (`"table"` or `"compact"`), `manualEntry` and `tools`;
229
+ `ImagePanel` takes `navigation`, `displayAdjustment` and `emptyActions`;
230
+ `PreviewControls` takes `modeSelector` and calls `onReview` only for a successful
231
+ preview matching the requested revision. `AlignmentPanel` accepts
232
+ `controls={false}` when transformation and preview controls are rendered
233
+ elsewhere. These panels never take map or engine ownership.
181
234
 
182
235
  ## References, projections and snapping
183
236
 
@@ -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,32 +13,39 @@ 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 = {};
16
18
  /**
17
19
  * Ready-made React editor for an existing OpenLayers map. Composes image, GCP, alignment, reference, output and optional drawing controls.
18
20
  *
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`.
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`.
20
22
  */
21
- export function Georeferencer({ controller, referenceMap, bindingOptions = DEFAULT_BINDING, t = identity, className = "", propertyEditor, onExport, formatError, referenceView, }) {
23
+ export function Georeferencer({ controller, referenceMap, bindingOptions = DEFAULT_BINDING, t = identity, className = "", propertyEditor, onExport, formatError, referenceView, controls: controlOverrides = NO_CONTROLS, emptyImageActions, }) {
22
24
  const guardTitle = useId();
23
25
  const s = useGeoreferencer(controller), binding = useRef(null);
24
- const [guard, setGuard] = useState(null), dialog = useRef(null), sessionInput = useRef(null);
25
- const pendingSession = useRef(null);
26
+ const [guard, setGuard] = useState(null), dialog = useRef(null);
26
27
  const guided = referenceView !== undefined;
27
- const [editing, setEditing] = useState(true);
28
- const [reviewedDocumentRevision, setReviewedDocumentRevision] = useState(null);
28
+ const controls = { ...MINIMAL_CONTROLS, ...controlOverrides };
29
+ const [stage, setStage] = useState("match");
29
30
  const [mobileView, setMobileView] = useState("image");
30
- const imageId = s.document.sourceImage?.id;
31
+ const d = s.document;
32
+ const imageId = d.sourceImage?.id;
31
33
  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;
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;
35
45
  useEffect(() => {
36
46
  if (!guided)
37
47
  return;
38
- setEditing(true);
39
- setReviewedDocumentRevision(null);
48
+ setStage("match");
40
49
  setMobileView("image");
41
50
  // A metadata-only restored document still needs its matching local bytes.
42
51
  if (imageId && imageReady && controller.getSnapshot().mode === "align")
@@ -46,6 +55,11 @@ export function Georeferencer({ controller, referenceMap, bindingOptions = DEFAU
46
55
  if (guided)
47
56
  setMobileView(s.pendingImagePoint ? "map" : "image");
48
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]);
49
63
  useEffect(() => {
50
64
  if (guided && mobileView === "map")
51
65
  referenceMap.updateSize();
@@ -92,6 +106,75 @@ export function Georeferencer({ controller, referenceMap, bindingOptions = DEFAU
92
106
  controller.reportError(e);
93
107
  }
94
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;
95
178
  return (_jsxs("section", { className: `rg-editor ${guided ? "rg-guided" : ""} ${className}`, "aria-label": t("Georeferencing editor"), onKeyDownCapture: (e) => {
96
179
  if (guided && e.key === "Escape" && s.pendingImagePoint) {
97
180
  controller.setPendingPoint(null);
@@ -103,173 +186,32 @@ export function Georeferencer({ controller, referenceMap, bindingOptions = DEFAU
103
186
  binding.current?.cancelDrawing();
104
187
  e.stopPropagation();
105
188
  }
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"
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"
269
205
  ? t("Inspecting local image…")
270
206
  : 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) => {
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) => {
273
215
  e.preventDefault();
274
216
  decide("cancel");
275
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"
@@ -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
+ }