@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.
@@ -0,0 +1,39 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { coordinateUnits, MODELS } from "@georeferencing/core";
3
+ /**
4
+ * Whether residuals are necessarily zero: thin plate splines interpolate every point,
5
+ * and every other model matches exactly its minimum number of points.
6
+ */
7
+ export function exactFit(model, gcps) {
8
+ return (model === "thinPlateSpline" ||
9
+ gcps.filter((p) => p.enabled).length <= MODELS[model].minimum);
10
+ }
11
+ /** Plain-language fit quality from the root mean square of image-pixel residuals. */
12
+ export function AccuracySummary({ fit, model, gcps, workingCrs, t, }) {
13
+ if (exactFit(model, gcps))
14
+ return (_jsxs("div", { className: "rg-accuracy", "data-quality": "unknown", children: [_jsx("strong", { children: t("Accuracy not measured yet") }), _jsx("p", { children: t(model === "thinPlateSpline"
15
+ ? "Thin plate spline passes through every point, so it cannot report an error. Judge the overlay visually."
16
+ : "The alignment passes exactly through the minimum number of points. Add another point to measure accuracy.") })] }));
17
+ const pixels = fit.residuals
18
+ .map((r) => r.pixels)
19
+ .filter((v) => typeof v === "number");
20
+ const rms = pixels.length
21
+ ? Math.sqrt(pixels.reduce((sum, v) => sum + v * v, 0) / pixels.length)
22
+ : undefined;
23
+ const quality = rms === undefined
24
+ ? "unknown"
25
+ : rms <= 2
26
+ ? "good"
27
+ : rms <= 5
28
+ ? "fair"
29
+ : "poor";
30
+ return (_jsxs("div", { className: "rg-accuracy", "data-quality": quality, children: [_jsx("strong", { children: rms === undefined
31
+ ? t("Alignment ready")
32
+ : `${t(quality === "good"
33
+ ? "Good fit"
34
+ : quality === "fair"
35
+ ? "Fair fit"
36
+ : "Poor fit")} · ${t("average error")} ${rms.toFixed(1)} px` }), _jsx("p", { children: t(quality === "poor"
37
+ ? "Points disagree. Check the points with the largest residuals, or add more points."
38
+ : "Average distance between your map points and where the alignment places them, in image pixels.") }), _jsxs("small", { children: ["RMSE ", fit.rmse.toFixed(2), " ", coordinateUnits(workingCrs), " \u00B7 ", workingCrs] })] }));
39
+ }
@@ -0,0 +1,32 @@
1
+ import type { Definitions, ExportFormat, ExportResult, GeoreferencerController } from "@georeferencing/core";
2
+ import type { Translate } from "../localization.js";
3
+ /** Raster output settings shared by every raster export format. */
4
+ export declare function OutputFields({ controller, t, definitions, }: {
5
+ controller: GeoreferencerController;
6
+ t: Translate;
7
+ definitions?: Definitions;
8
+ }): import("react/jsx-runtime").JSX.Element;
9
+ /** One button per configured format; results go to `onExport` or browser downloads. */
10
+ export declare function ExportButtons({ controller, t, formats, onExport, primary, status, }: {
11
+ controller: GeoreferencerController;
12
+ t: Translate;
13
+ formats: readonly ExportFormat[];
14
+ onExport?: (result: ExportResult) => void | Promise<void>;
15
+ /** Formats rendered as primary buttons. */
16
+ primary?: (format: ExportFormat) => boolean;
17
+ /**
18
+ * Include the cancel button and progress bar. Disable when several button groups
19
+ * share one {@link ExportProgress}.
20
+ */
21
+ status?: boolean;
22
+ }): import("react/jsx-runtime").JSX.Element | null;
23
+ /** Progress bar and cancel button while an export runs; renders nothing when idle. */
24
+ export declare function ExportProgress({ controller, t, }: {
25
+ controller: GeoreferencerController;
26
+ t: Translate;
27
+ }): import("react/jsx-runtime").JSX.Element | null;
28
+ /** Restore a session with its image, import QGIS `.points` and save a host draft. */
29
+ export declare function SessionTools({ controller, t, }: {
30
+ controller: GeoreferencerController;
31
+ t: Translate;
32
+ }): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,152 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { coordinateUnits, importPoints } from "@georeferencing/core";
3
+ import { useRef } from "react";
4
+ import { useGeoreferencer } from "../hooks/useGeoreferencer.js";
5
+ import { downloadBlob } from "../utils/downloadBlob.js";
6
+ import { NumberField, TextField } from "./fields.js";
7
+ const run = (controller, fn) => {
8
+ try {
9
+ fn();
10
+ }
11
+ catch (e) {
12
+ controller.reportError(e);
13
+ }
14
+ };
15
+ /** Raster output settings shared by every raster export format. */
16
+ export function OutputFields({ controller, t, definitions, }) {
17
+ const s = useGeoreferencer(controller);
18
+ const output = s.document.output;
19
+ const geotiff = s.exportFormats.some((format) => format.id === "geotiff");
20
+ return (_jsxs("div", { className: "rg-output-fields", children: [_jsxs("label", { children: [t("Output CRS"), _jsx(TextField, { label: t("Output CRS"), value: output.crs, change: (crs) => run(controller, () => {
21
+ if (!crs)
22
+ throw Error("Output CRS is required.");
23
+ controller.setOutput({ ...output, crs });
24
+ }) })] }), _jsxs("label", { children: [t("Resampling"), _jsx("select", { value: output.resampler, onChange: (e) => controller.setOutput({
25
+ ...output,
26
+ resampler: e.target.value,
27
+ }), children: ["nearest", "bilinear", "cubic", "cubicSpline", "lanczos"].map((m) => (_jsx("option", { children: m }, m))) })] }), geotiff && (_jsxs("label", { children: [t("TIFF compression"), _jsxs("select", { value: output.compression ?? "none", onChange: (e) => controller.setOutput({
28
+ ...output,
29
+ compression: e.target.value,
30
+ predictor: e.target.value === "deflate" ? output.predictor : 1,
31
+ }), children: [_jsx("option", { value: "none", children: t("Uncompressed") }), _jsx("option", { value: "deflate", children: t("Deflate (lossless)") }), _jsx("option", { value: "packbits", children: t("PackBits (lossless)") })] })] })), _jsxs("label", { children: [t("Input no-data (byte or R,G,B; blank = alpha)"), _jsx(TextField, { label: t("Input no-data"), value: Array.isArray(output.sourceNoData)
32
+ ? output.sourceNoData.join(",")
33
+ : String(output.sourceNoData ?? ""), change: (value) => run(controller, () => {
34
+ const values = value.split(",").map(Number);
35
+ if (value &&
36
+ (![1, 3].includes(values.length) ||
37
+ values.some((v) => !Number.isInteger(v) || v < 0 || v > 255)))
38
+ throw Error("Input no-data requires one or three bytes (0–255).");
39
+ controller.setOutput({
40
+ ...output,
41
+ sourceNoData: value
42
+ ? values.length === 1
43
+ ? values[0]
44
+ : values
45
+ : undefined,
46
+ });
47
+ }) })] }), geotiff && (_jsxs("label", { children: [t("Output no-data (byte; blank = alpha)"), _jsx(TextField, { label: t("Output no-data"), value: String(output.noData ?? ""), change: (value) => run(controller, () => {
48
+ const n = Number(value);
49
+ if (value && (!Number.isInteger(n) || n < 0 || n > 255))
50
+ throw Error("Output no-data requires a byte (0–255).");
51
+ controller.setOutput({
52
+ ...output,
53
+ noData: value ? n : undefined,
54
+ });
55
+ }) })] })), geotiff && (_jsxs(_Fragment, { children: [_jsxs("label", { children: [t("Rows per TIFF strip"), _jsx(NumberField, { label: t("Rows per TIFF strip"), value: output.rowsPerStrip ?? 256, change: (n) => run(controller, () => {
56
+ if (!Number.isInteger(n) || n < 1 || n > 4096)
57
+ throw Error("Rows per strip must be 1–4096.");
58
+ controller.setOutput({ ...output, rowsPerStrip: n });
59
+ }) })] }), _jsxs("label", { children: [t("Horizontal TIFF predictor"), _jsx("input", { type: "checkbox", disabled: output.compression !== "deflate", checked: output.predictor === 2, onChange: (e) => controller.setOutput({
60
+ ...output,
61
+ predictor: e.target.checked ? 2 : 1,
62
+ }) })] })] })), _jsxs("label", { children: [t("Pixel size (x,y; blank = estimated)"), " \u00B7", " ", coordinateUnits(output.crs, definitions), _jsx(TextField, { label: t("Pixel size"), value: output.resolution?.join(",") ?? "", change: (value) => run(controller, () => {
63
+ const values = value.split(",").map(Number);
64
+ if (value &&
65
+ (values.length > 2 ||
66
+ values.some((v) => !Number.isFinite(v) || v <= 0)))
67
+ throw Error("Pixel size requires one or two positive numbers.");
68
+ controller.setOutput({
69
+ ...output,
70
+ resolution: value
71
+ ? [values[0], values[1] ?? values[0]]
72
+ : undefined,
73
+ });
74
+ }) })] }), _jsxs("label", { children: [t("Output bounds (minX,minY,maxX,maxY; blank = footprint)"), _jsx(TextField, { label: t("Output bounds"), value: output.bounds?.join(",") ?? "", change: (value) => run(controller, () => {
75
+ const values = value.split(",").map(Number);
76
+ if (value &&
77
+ (values.length !== 4 ||
78
+ values.some((v) => !Number.isFinite(v)) ||
79
+ values[0] >= values[2] ||
80
+ values[1] >= values[3]))
81
+ throw Error("Bounds require four finite numbers with min < max.");
82
+ controller.setOutput({
83
+ ...output,
84
+ bounds: value
85
+ ? values
86
+ : undefined,
87
+ });
88
+ }) })] })] }));
89
+ }
90
+ /** One button per configured format; results go to `onExport` or browser downloads. */
91
+ export function ExportButtons({ controller, t, formats, onExport, primary = () => false, status = true, }) {
92
+ const s = useGeoreferencer(controller);
93
+ if (!formats.length)
94
+ return null;
95
+ return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "rg-toolbar", children: [formats.map((format) => {
96
+ const unavailable = controller.getExportUnavailable(format.id);
97
+ return (_jsx("button", { type: "button", className: primary(format) ? "rg-primary" : undefined, "data-export-format": format.id, title: unavailable ? t(unavailable) : undefined, disabled: Boolean(unavailable) || s.exporting === "running", onClick: () => void controller
98
+ .export(format.id)
99
+ .then(async (result) => {
100
+ if (!result)
101
+ return;
102
+ if (onExport)
103
+ await onExport(result);
104
+ else
105
+ for (const file of result.files)
106
+ downloadBlob(file.blob, file.name);
107
+ })
108
+ .catch((error) => controller.reportError(error)), children: t(format.label) }, format.id));
109
+ }), status && (_jsx("button", { type: "button", disabled: s.exporting !== "running", onClick: () => controller.cancelExport(), children: t("Cancel export") }))] }), status && s.exporting === "running" && (_jsx("progress", { "aria-label": t("Export progress"), value: s.progress, max: "1" }))] }));
110
+ }
111
+ /** Progress bar and cancel button while an export runs; renders nothing when idle. */
112
+ export function ExportProgress({ controller, t, }) {
113
+ const s = useGeoreferencer(controller);
114
+ if (s.exporting !== "running")
115
+ return null;
116
+ return (_jsxs("div", { className: "rg-export-progress", children: [_jsx("progress", { "aria-label": t("Export progress"), value: s.progress, max: "1" }), _jsx("button", { type: "button", onClick: () => controller.cancelExport(), children: t("Cancel export") })] }));
117
+ }
118
+ /** Restore a session with its image, import QGIS `.points` and save a host draft. */
119
+ export function SessionTools({ controller, t, }) {
120
+ const s = useGeoreferencer(controller);
121
+ const sessionInput = useRef(null);
122
+ const pendingSession = useRef(null);
123
+ return (_jsxs("div", { className: "rg-toolbar", children: [_jsxs("label", { className: "rg-file", children: [t("Restore session"), _jsx("input", { type: "file", "aria-label": t("Restore session"), accept: ".json", onChange: async (e) => {
124
+ const file = e.target.files?.[0];
125
+ if (file) {
126
+ pendingSession.current = await file.text();
127
+ sessionInput.current?.click();
128
+ }
129
+ e.target.value = "";
130
+ } })] }), _jsx("input", { ref: sessionInput, "aria-label": t("Matching session image"), type: "file", hidden: true, accept: "image/*,.tif,.tiff", onChange: (e) => {
131
+ const file = e.target.files?.[0];
132
+ if (file && pendingSession.current)
133
+ void controller
134
+ .restoreSession(pendingSession.current, file)
135
+ .catch((e) => controller.reportError(e));
136
+ e.target.value = "";
137
+ } }), _jsxs("label", { className: "rg-file", children: [t("Import .points"), _jsx("input", { type: "file", "aria-label": t("Import .points"), accept: ".points,.csv", disabled: !s.document.sourceImage || s.mode === "draw", onChange: async (e) => {
138
+ const f = e.target.files?.[0];
139
+ if (f) {
140
+ try {
141
+ const imported = importPoints(await f.text(), s.document.workingCrs);
142
+ controller.replaceGcps(imported.gcps);
143
+ }
144
+ catch (e) {
145
+ controller.reportError(e);
146
+ }
147
+ }
148
+ e.target.value = "";
149
+ } })] }), controller.options.onSaveDraft && (_jsx("button", { type: "button", disabled: !s.document.sourceImage || s.saving === "running", onClick: () => void controller
150
+ .save("draft")
151
+ .catch((e) => controller.reportError(e)), children: t("Save draft") }))] }));
152
+ }
@@ -4,11 +4,14 @@ export declare function TextField({ value, label, change, }: {
4
4
  change: (value: string) => void;
5
5
  }): import("react/jsx-runtime").JSX.Element;
6
6
  /**
7
- * Retain an editable numeric draft and commit only finite changed values on blur/Enter.
7
+ * Retain an editable numeric draft and commit only finite values the user changed, on
8
+ * blur/Enter. `decimals` rounds the displayed value; an untouched rounded display never
9
+ * overwrites the stored full-precision value.
8
10
  */
9
- export declare function NumberField({ value, label, change, disabled, }: {
11
+ export declare function NumberField({ value, label, change, disabled, decimals, }: {
10
12
  value: number;
11
13
  label: string;
12
14
  change: (value: number) => void;
13
15
  disabled?: boolean;
16
+ decimals?: number;
14
17
  }): import("react/jsx-runtime").JSX.Element;
@@ -12,17 +12,22 @@ export function TextField({ value, label, change, }) {
12
12
  } }));
13
13
  }
14
14
  /**
15
- * Retain an editable numeric draft and commit only finite changed values on blur/Enter.
15
+ * Retain an editable numeric draft and commit only finite values the user changed, on
16
+ * blur/Enter. `decimals` rounds the displayed value; an untouched rounded display never
17
+ * overwrites the stored full-precision value.
16
18
  */
17
- export function NumberField({ value, label, change, disabled = false, }) {
18
- const [draft, setDraft] = useState(String(value));
19
- useEffect(() => setDraft(String(value)), [value]);
19
+ export function NumberField({ value, label, change, disabled = false, decimals, }) {
20
+ const shown = decimals === undefined
21
+ ? String(value)
22
+ : String(Number(value.toFixed(decimals)));
23
+ const [draft, setDraft] = useState(shown);
24
+ useEffect(() => setDraft(shown), [shown]);
20
25
  return (_jsx("input", { "aria-label": label, type: "number", step: "any", disabled: disabled, value: draft, onChange: (e) => setDraft(e.target.value), onBlur: () => {
21
26
  const n = Number(draft);
22
- if (draft.trim() && Number.isFinite(n) && n !== value)
27
+ if (draft.trim() && draft.trim() !== shown && Number.isFinite(n))
23
28
  change(n);
24
29
  else
25
- setDraft(String(value));
30
+ setDraft(shown);
26
31
  }, onKeyDown: (e) => {
27
32
  if (e.key === "Enter")
28
33
  e.currentTarget.blur();
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Optional controls of the guided `Georeferencer` layout (used when `referenceView` is
3
+ * supplied). Each flag adds expert controls on top of the minimal step-by-step flow;
4
+ * omitted flags fall back to {@link MINIMAL_CONTROLS}. The classic layout always shows
5
+ * every control.
6
+ */
7
+ export interface GeoreferencerControls {
8
+ /** Undo and redo buttons for point and drawing edits. */
9
+ history: boolean;
10
+ /**
11
+ * Manual/automatic preview selector. When hidden, the controller's configured
12
+ * `previewMode` applies and users run alignment with the main button.
13
+ */
14
+ previewMode: boolean;
15
+ /** Transformation model selector while matching points. Hidden keeps the controller's model. */
16
+ transformation: boolean;
17
+ /**
18
+ * Full control point table with editable coordinates, target CRS and residual columns.
19
+ * When hidden, a compact list shows each point's use state and residual.
20
+ */
21
+ pointTable: boolean;
22
+ /** Add a point pair by typing coordinates; the keyboard alternative to clicking. */
23
+ manualEntry: boolean;
24
+ /** Previous/next image and map views and linked image/map navigation. */
25
+ navigation: boolean;
26
+ /** Brightness, contrast and histogram stretch for the source image display. */
27
+ displayAdjustment: boolean;
28
+ /** Load status of configured reference providers. */
29
+ referenceStatus: boolean;
30
+ /** Raster output settings: CRS, resampling, compression, no-data, pixel size and bounds. */
31
+ outputSettings: boolean;
32
+ /** Restore a session, import QGIS `.points` and save drafts; also offers session downloads before the last step. */
33
+ sessionFiles: boolean;
34
+ /** Indicator for work that has not been saved by the host. */
35
+ unsavedIndicator: boolean;
36
+ }
37
+ /** Default guided setup: the four-step flow with undo/redo and nothing else. */
38
+ export declare const MINIMAL_CONTROLS: Readonly<GeoreferencerControls>;
39
+ /** Every optional guided control enabled. */
40
+ export declare const ALL_CONTROLS: Readonly<GeoreferencerControls>;
@@ -0,0 +1,28 @@
1
+ /** Default guided setup: the four-step flow with undo/redo and nothing else. */
2
+ export const MINIMAL_CONTROLS = Object.freeze({
3
+ history: true,
4
+ previewMode: false,
5
+ transformation: false,
6
+ pointTable: false,
7
+ manualEntry: false,
8
+ navigation: false,
9
+ displayAdjustment: false,
10
+ referenceStatus: false,
11
+ outputSettings: false,
12
+ sessionFiles: false,
13
+ unsavedIndicator: false,
14
+ });
15
+ /** Every optional guided control enabled. */
16
+ export const ALL_CONTROLS = Object.freeze({
17
+ history: true,
18
+ previewMode: true,
19
+ transformation: true,
20
+ pointTable: true,
21
+ manualEntry: true,
22
+ navigation: true,
23
+ displayAdjustment: true,
24
+ referenceStatus: true,
25
+ outputSettings: true,
26
+ sessionFiles: true,
27
+ unsavedIndicator: true,
28
+ });
package/dist/index.d.ts CHANGED
@@ -1,10 +1,13 @@
1
1
  /**
2
2
  * Ready-made editor, composable panels and subscription hook with optional exports.
3
3
  * Import from `@georeferencing/react`.
4
- * @module react
4
+ * @module @georeferencing/react
5
+ * @group @georeferencing/react
5
6
  */
6
7
  export type { ControllerOptions, Document, Features, Gcp, ImageMetadata, SaveEnvelope, } from "@georeferencing/core";
7
8
  export { createDocument, GeoreferencerController } from "@georeferencing/core";
9
+ export type { GeoreferencerControls } from "./controls.js";
10
+ export { ALL_CONTROLS, MINIMAL_CONTROLS } from "./controls.js";
8
11
  export type { GeoreferencerProps } from "./Georeferencer.js";
9
12
  export { Georeferencer } from "./Georeferencer.js";
10
13
  export { useGeoreferencer } from "./hooks/useGeoreferencer.js";
package/dist/index.js CHANGED
@@ -1,9 +1,11 @@
1
1
  /**
2
2
  * Ready-made editor, composable panels and subscription hook with optional exports.
3
3
  * Import from `@georeferencing/react`.
4
- * @module react
4
+ * @module @georeferencing/react
5
+ * @group @georeferencing/react
5
6
  */
6
7
  export { createDocument, GeoreferencerController } from "@georeferencing/core";
8
+ export { ALL_CONTROLS, MINIMAL_CONTROLS } from "./controls.js";
7
9
  export { Georeferencer } from "./Georeferencer.js";
8
10
  export { useGeoreferencer } from "./hooks/useGeoreferencer.js";
9
11
  export { AlignmentPanel } from "./panels/AlignmentPanel.js";
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { coordinateUnits, MODELS } from "@georeferencing/core";
3
+ import { exactFit } from "../components/accuracy.js";
3
4
  import { useGeoreferencer } from "../hooks/useGeoreferencer.js";
4
5
  import { identity } from "../localization.js";
5
6
  import { PreviewControls } from "./PreviewControls.js";
@@ -18,7 +19,9 @@ t = identity, controls = true, }) {
18
19
  ? t("Fitting & rendering…")
19
20
  : s.fit
20
21
  ? `${t("RMSE")} ${s.fit.rmse.toPrecision(4)} ${coordinateUnits(d.workingCrs)} · ${d.workingCrs}`
21
- : t("Add distributed points to fit the image.") }), d.workingCrs === "EPSG:3857" && (_jsx("small", { children: t("Web Mercator units are projected metres, not ground distances.") })), _jsx("small", { children: t("RMSE = √(Σ‖T(p) − q‖² / enabled points). Training residuals do not measure independent accuracy. Preview uses reduced-resolution pixels.") }), _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")] }), _jsxs("label", { children: [t("Overlay opacity"), _jsx("input", { "aria-label": t("Overlay opacity"), type: "range", min: "0", max: "1", step: "0.05", value: s.opacity, onChange: (e) => controller.setDisplay({ opacity: Number(e.target.value) }) })] }), s.mode === "draw" ? (_jsx("button", { type: "button", onClick: () => controller.returnToAlignment(), children: t("Return to alignment") })) : (_jsx("button", { type: "button", className: "rg-primary", disabled: !s.fit || s.fitRevision !== d.alignmentRevision, onClick: () => controller.confirm(), children: t(controller.options.digitizing
22
+ : t("Add distributed points to fit the image.") }), s.fit && exactFit(d.model, d.gcps) && (_jsx("p", { className: "rg-hint", children: t(d.model === "thinPlateSpline"
23
+ ? "Thin plate spline passes through every point, so residuals cannot measure accuracy."
24
+ : "With the minimum number of points the fit matches them exactly. Add another point to measure accuracy.") })), d.workingCrs === "EPSG:3857" && (_jsx("p", { className: "rg-hint", children: t("Web Mercator units are projected metres, not ground distances.") })), _jsx("p", { className: "rg-hint", children: t("RMSE = √(Σ‖T(p) − q‖² / enabled points). Training residuals do not measure independent accuracy. Preview uses reduced-resolution pixels.") }), _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")] }), _jsxs("label", { children: [t("Overlay opacity"), _jsx("input", { "aria-label": t("Overlay opacity"), type: "range", min: "0", max: "1", step: "0.05", value: s.opacity, onChange: (e) => controller.setDisplay({ opacity: Number(e.target.value) }) })] }), s.mode === "draw" ? (_jsx("button", { type: "button", onClick: () => controller.returnToAlignment(), children: t("Return to alignment") })) : (_jsx("button", { type: "button", className: "rg-primary", disabled: !s.fit || s.fitRevision !== d.alignmentRevision, onClick: () => controller.confirm(), children: t(controller.options.digitizing
22
25
  ? "Confirm alignment and draw"
23
- : "Confirm alignment") })), d.confirmedAlignmentRevision === d.alignmentRevision && (_jsxs("p", { children: [t("Alignment confirmed"), " \u00B7 r", d.alignmentRevision] }))] }));
26
+ : "Confirm alignment") })), d.confirmedAlignmentRevision === d.alignmentRevision && (_jsx("p", { children: t("Alignment confirmed") }))] }));
24
27
  }
@@ -1,7 +1,14 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { validateFeatures } from "@georeferencing/core";
3
2
  import { useGeoreferencer } from "../hooks/useGeoreferencer.js";
4
3
  import { identity } from "../localization.js";
4
+ /** Plain-language names for drawing tools and the geometry types they create. */
5
+ const TOOL_LABELS = {
6
+ Point: "Point",
7
+ LineString: "Line",
8
+ Polygon: "Area",
9
+ modify: "Edit shapes",
10
+ navigate: "Pan map",
11
+ };
5
12
  /**
6
13
  * Opt-in digitizing controls, feature deletion and host property editing. Returns null when digitizing is disabled; controller.save performs explicit persistence.
7
14
  *
@@ -20,20 +27,16 @@ t = identity, }) {
20
27
  const s = useGeoreferencer(controller), d = s.document;
21
28
  if (!controller.options.digitizing)
22
29
  return null;
23
- return (_jsxs("section", { children: [_jsxs("h2", { children: [t("Drawings"), " ", _jsx("small", { children: d.features.features.length })] }), validateFeatures(d.features).map((error) => (_jsx("p", { className: "rg-error", children: t(error) }, error))), _jsx("div", { className: "rg-toolbar", children: ["Point", "LineString", "Polygon", "modify", "navigate"].map((tool) => (_jsx("button", { type: "button", disabled: s.mode !== "draw", "aria-pressed": s.tool === tool, onClick: () => controller.setTool(tool), children: t(tool === "modify"
24
- ? "Edit vertices"
25
- : tool === "navigate"
26
- ? "Pan map"
27
- : tool) }, tool))) }), _jsx("small", { children: t("Double-click to finish lines/polygons. Escape cancels. Select a feature to edit vertices; Alt-click a vertex to remove it.") }), d.features.features.map((f, i) => (_jsxs("div", { className: "rg-feature", children: [_jsxs("span", { children: [i + 1, " \u00B7 ", f.geometry.type, " \u00B7 ", String(f.id).slice(0, 8)] }), _jsx("button", { type: "button", disabled: s.mode !== "draw", "aria-label": `${t("Delete feature")} ${i + 1}`, onClick: () => controller.deleteFeature(String(f.id)), children: "\u00D7" }), propertyEditor?.(f, (p) => controller.updateProperties(String(f.id), p))] }, f.id))), d.features.features.length > 0 &&
30
+ return (_jsxs("section", { children: [_jsxs("h2", { children: [t("Drawings"), " ", _jsx("small", { children: d.features.features.length })] }), controller.getFeatureErrors().map((error) => (_jsx("p", { className: "rg-error", children: t(error) }, error))), _jsx("div", { className: "rg-toolbar", children: ["Point", "LineString", "Polygon", "modify", "navigate"].map((tool) => (_jsx("button", { type: "button", disabled: s.mode !== "draw", "aria-pressed": s.tool === tool, onClick: () => controller.setTool(tool), children: t(TOOL_LABELS[tool]) }, tool))) }), _jsx("p", { className: "rg-hint", children: t("Double-click to finish lines and areas. Escape cancels. Use Edit shapes to move vertices; Alt-click a vertex to remove it.") }), d.features.features.map((f, i) => (_jsxs("div", { className: "rg-feature", children: [_jsxs("span", { children: [i + 1, " \u00B7 ", t(TOOL_LABELS[f.geometry.type])] }), _jsx("button", { type: "button", disabled: s.mode !== "draw", "aria-label": `${t("Delete feature")} ${i + 1}`, onClick: () => controller.deleteFeature(String(f.id)), children: "\u00D7" }), propertyEditor?.(f, (p) => controller.updateProperties(String(f.id), p))] }, f.id))), d.features.features.length > 0 &&
28
31
  d.featuresReviewedAgainstAlignmentRevision !== d.alignmentRevision && (_jsx("button", { type: "button", disabled: d.confirmedAlignmentRevision !== d.alignmentRevision, onClick: () => controller.reviewFeatures(), children: t("I reviewed drawings against this alignment") })), _jsx("button", { type: "button", className: "rg-primary", disabled: !controller.options.onSave ||
29
32
  !controller.canSaveFeatures() ||
30
33
  s.saving === "running", onClick: () => void controller.save().catch((e) => controller.reportError(e)), children: t(s.saving === "running" ? "Saving…" : "Save features") }), _jsx("p", { "aria-live": "polite", children: s.saving === "succeeded"
31
34
  ? t(s.dirty
32
- ? "Snapshot saved; newer changes remain unsaved."
33
- : "Saved by host.")
35
+ ? "Saved. Newer changes are not saved yet."
36
+ : "Features saved.")
34
37
  : s.saving === "failed"
35
38
  ? t("Save failed. Draft retained; retry when ready.")
36
39
  : !controller.options.onSave
37
40
  ? t("No host save adapter configured. Session export remains available.")
38
- : t("Saving features does not require raster export.") })] }));
41
+ : t("Draw on the map, then save your features.") })] }));
39
42
  }
@@ -9,9 +9,27 @@ export declare function GcpPanel({
9
9
  /** Shared authoritative editor controller; the panel does not own its lifecycle. */
10
10
  controller,
11
11
  /** Optional translation function; defaults to returning English messages unchanged. */
12
- t, }: {
12
+ t,
13
+ /** Full editable table or a compact list of points and residuals. */
14
+ variant,
15
+ /** Show the "Enter coordinates manually" form for keyboard point entry. */
16
+ manualEntry,
17
+ /** Show the add/move and cancel buttons with their usage hint. */
18
+ tools, }: {
13
19
  /** Shared authoritative editor controller; the panel does not own its lifecycle. */
14
20
  controller: GeoreferencerController;
15
21
  /** Optional translation function; defaults to returning English messages unchanged. */
16
22
  t?: Translate;
23
+ /**
24
+ * `table` (default) edits every coordinate and shows target CRS and residuals; `compact`
25
+ * lists each point with its use state, residual and delete action.
26
+ */
27
+ variant?: "table" | "compact";
28
+ /**
29
+ * Show the "Enter coordinates manually" form, the keyboard alternative to clicking
30
+ * points. Defaults to true.
31
+ */
32
+ manualEntry?: boolean;
33
+ /** Show the add/move and cancel buttons with their usage hint. Defaults to true. */
34
+ tools?: boolean;
17
35
  }): import("react/jsx-runtime").JSX.Element;
@@ -1,8 +1,11 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { coordinateUnits } from "@georeferencing/core";
2
3
  import { useState } from "react";
3
4
  import { NumberField } from "../components/fields.js";
4
5
  import { useGeoreferencer } from "../hooks/useGeoreferencer.js";
5
6
  import { identity } from "../localization.js";
7
+ /** Display a residual without floating-point noise such as `6.355e-14`. */
8
+ const residual = (value, decimals) => typeof value === "number" ? Math.abs(value).toFixed(decimals) : "—";
6
9
  /**
7
10
  * Editable paired-point table with numeric entry, enabled state, deletion and residual diagnostics. Coordinates remain in original canonical pixels and each target's declared CRS.
8
11
  *
@@ -12,9 +15,16 @@ export function GcpPanel({
12
15
  /** Shared authoritative editor controller; the panel does not own its lifecycle. */
13
16
  controller,
14
17
  /** Optional translation function; defaults to returning English messages unchanged. */
15
- t = identity, }) {
18
+ t = identity,
19
+ /** Full editable table or a compact list of points and residuals. */
20
+ variant = "table",
21
+ /** Show the "Enter coordinates manually" form for keyboard point entry. */
22
+ manualEntry = true,
23
+ /** Show the add/move and cancel buttons with their usage hint. */
24
+ tools = true, }) {
16
25
  const s = useGeoreferencer(controller), [entry, setEntry] = useState([0, 0, 0, 0]);
17
26
  const locked = s.mode !== "align" || !s.document.sourceImage;
27
+ const enabled = s.document.gcps.filter((p) => p.enabled).length;
18
28
  const edit = (p, index, value) => {
19
29
  const image = [...p.image], target = [...p.target];
20
30
  if (index < 2)
@@ -23,9 +33,16 @@ t = identity, }) {
23
33
  target[index - 2] = value;
24
34
  controller.updateGcp(p.id, { image, target });
25
35
  };
26
- return (_jsxs("section", { className: "rg-gcp-panel", children: [_jsxs("div", { className: "rg-panel-heading", children: [_jsx("h2", { children: t("Control points") }), _jsxs("span", { children: [s.document.gcps.filter((p) => p.enabled).length, " ", t("enabled")] })] }), _jsxs("div", { className: "rg-toolbar", children: [_jsx("button", { type: "button", disabled: locked, "aria-pressed": s.tool === "gcp", onClick: () => controller.setTool(s.tool === "gcp" ? "navigate" : "gcp"), children: t("Add / move points") }), _jsx("button", { type: "button", disabled: !s.pendingImagePoint, onClick: () => controller.cancelPending(), children: t("Cancel pair") })] }), _jsx("p", { className: "rg-hint", children: t(s.pendingImagePoint
27
- ? "Pick the matching map location, or enter target coordinates below."
28
- : "Pick image first, map second. Shift-drag to pan while adding points.") }), _jsx("div", { className: "rg-table-scroll", children: _jsxs("table", { children: [_jsx("caption", { children: t("Original image pixels → target coordinates. Residuals are T(image) − target.") }), _jsx("thead", { children: _jsx("tr", { children: [
36
+ const enable = (p) => (_jsx("input", { "aria-label": `${t("Enable point")} ${p.label}`, type: "checkbox", disabled: locked, checked: p.enabled, onChange: (e) => controller.updateGcp(p.id, { enabled: e.target.checked }) }));
37
+ const remove = (p) => (_jsx("button", { type: "button", "aria-label": `${t("Delete point")} ${p.label}`, disabled: locked, onClick: () => controller.removeGcp(p.id), children: "\u00D7" }));
38
+ return (_jsxs("section", { className: "rg-gcp-panel", "data-variant": variant, children: [_jsxs("div", { className: "rg-panel-heading", children: [_jsx("h2", { children: t(variant === "compact" ? "Points" : "Control points") }), _jsxs("span", { children: [enabled, " ", t(enabled === 1 ? "point used" : "points used")] })] }), tools && (_jsxs(_Fragment, { children: [_jsxs("div", { className: "rg-toolbar", children: [_jsx("button", { type: "button", disabled: locked, "aria-pressed": s.tool === "gcp", onClick: () => controller.setTool(s.tool === "gcp" ? "navigate" : "gcp"), children: t("Add / move points") }), _jsx("button", { type: "button", disabled: !s.pendingImagePoint, onClick: () => controller.cancelPending(), children: t("Cancel pair") })] }), _jsx("p", { className: "rg-hint", children: t(s.pendingImagePoint
39
+ ? "Pick the matching map location, or enter target coordinates below."
40
+ : "Click the image first, then the map. Drag to pan and scroll to zoom.") })] })), variant === "compact" ? (s.document.gcps.length > 0 && (_jsx("div", { className: "rg-table-scroll", children: _jsxs("table", { className: "rg-point-list", children: [_jsx("caption", { children: t("Residual: distance between a point's map location and where the alignment puts it, in image pixels.") }), _jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { scope: "col", children: "#" }), _jsx("th", { scope: "col", children: t("Use") }), _jsx("th", { scope: "col", children: t("Residual") }), _jsx("th", { scope: "col", children: _jsx("span", { className: "rg-visually-hidden", children: t("Delete") }) })] }) }), _jsx("tbody", { children: s.document.gcps.map((p) => {
41
+ const r = s.fit?.residuals.find((r) => r.id === p.id);
42
+ return (_jsxs("tr", { children: [_jsx("th", { scope: "row", children: p.label }), _jsx("td", { children: enable(p) }), _jsx("td", { children: typeof r?.pixels === "number"
43
+ ? `${residual(r.pixels, 1)} px`
44
+ : "—" }), _jsx("td", { children: remove(p) })] }, p.id));
45
+ }) })] }) }))) : (_jsx("div", { className: "rg-table-scroll", children: _jsxs("table", { children: [_jsx("caption", { children: t("Original image pixels → target coordinates. Residuals are T(image) − target.") }), _jsx("thead", { children: _jsx("tr", { children: [
29
46
  "Use",
30
47
  "#",
31
48
  "Image X",
@@ -38,10 +55,9 @@ t = identity, }) {
38
55
  "",
39
56
  ].map((h) => (_jsx("th", { scope: "col", children: t(h) }, h))) }) }), _jsx("tbody", { children: s.document.gcps.map((p) => {
40
57
  const r = s.fit?.residuals.find((r) => r.id === p.id);
41
- return (_jsxs("tr", { children: [_jsx("td", { children: _jsx("input", { "aria-label": `${t("Enable point")} ${p.label}`, type: "checkbox", disabled: locked, checked: p.enabled, onChange: (e) => controller.updateGcp(p.id, {
42
- enabled: e.target.checked,
43
- }) }) }), _jsx("th", { scope: "row", children: p.label }), [...p.image, ...p.target].map((v, i) => (_jsx("td", { children: _jsx(NumberField, { label: `${t(["Image X", "Image Y", "Target X", "Target Y"][i])} ${p.label}`, value: v, disabled: locked, change: (n) => edit(p, i, n) }) }, ["image-x", "image-y", "target-x", "target-y"][i]))), _jsx("td", { children: p.crs }), _jsx("td", { title: r?.vector.join(", "), children: r ? r.distance.toPrecision(4) : "—" }), _jsx("td", { children: r?.pixels?.toPrecision(4) ?? "—" }), _jsx("td", { children: _jsx("button", { type: "button", "aria-label": `${t("Delete point")} ${p.label}`, disabled: locked, onClick: () => controller.removeGcp(p.id), children: "\u00D7" }) })] }, p.id));
44
- }) })] }) }), _jsxs("details", { children: [_jsx("summary", { children: t("Enter coordinates manually") }), _jsx("div", { className: "rg-coordinate-entry", children: ["Image X", "Image Y", "Target X", "Target Y"].map((label, i) => (_jsxs("label", { children: [t(label), _jsx(NumberField, { label: `${t("New")} ${t(label)}`, value: i < 2 && s.pendingImagePoint
58
+ const targetDecimals = coordinateUnits(p.crs) === "degrees" ? 8 : 3;
59
+ return (_jsxs("tr", { children: [_jsx("td", { children: enable(p) }), _jsx("th", { scope: "row", children: p.label }), [...p.image, ...p.target].map((v, i) => (_jsx("td", { children: _jsx(NumberField, { label: `${t(["Image X", "Image Y", "Target X", "Target Y"][i])} ${p.label}`, value: v, decimals: i < 2 ? 3 : targetDecimals, disabled: locked, change: (n) => edit(p, i, n) }) }, ["image-x", "image-y", "target-x", "target-y"][i]))), _jsx("td", { children: p.crs }), _jsx("td", { title: r?.vector.join(", "), children: residual(r?.distance, 3) }), _jsx("td", { children: residual(r?.pixels, 3) }), _jsx("td", { children: remove(p) })] }, p.id));
60
+ }) })] }) })), manualEntry && (_jsxs("details", { children: [_jsx("summary", { children: t("Enter coordinates manually") }), _jsx("div", { className: "rg-coordinate-entry", children: ["Image X", "Image Y", "Target X", "Target Y"].map((label, i) => (_jsxs("label", { children: [t(label), _jsx(NumberField, { label: `${t("New")} ${t(label)}`, value: i < 2 && s.pendingImagePoint
45
61
  ? s.pendingImagePoint[i]
46
62
  : entry[i], change: (n) => {
47
63
  if (i < 2 && s.pendingImagePoint) {
@@ -61,5 +77,5 @@ t = identity, }) {
61
77
  catch (e) {
62
78
  controller.reportError(e);
63
79
  }
64
- }, children: t("Add coordinate pair") })] })] }));
80
+ }, children: t("Add coordinate pair") })] }))] }));
65
81
  }
@@ -1,4 +1,5 @@
1
1
  import type { GeoreferencerController } from "@georeferencing/core";
2
+ import type { ReactNode } from "react";
2
3
  import type { Translate } from "../localization.js";
3
4
  /**
4
5
  * Image selection/drop, independent image navigation and source GCP picking. Display stretch affects the image panel only; it does not change export pixels.
@@ -9,9 +10,21 @@ export declare function ImagePanel({
9
10
  /** Shared authoritative editor controller; the panel does not own its lifecycle. */
10
11
  controller,
11
12
  /** Optional translation function; defaults to returning English messages unchanged. */
12
- t, }: {
13
+ t,
14
+ /** Show previous/next view history and linked image/map navigation. */
15
+ navigation,
16
+ /** Show brightness, contrast and histogram display adjustment. */
17
+ displayAdjustment,
18
+ /** Extra actions inside the empty drop zone, such as a sample image button. */
19
+ emptyActions, }: {
13
20
  /** Shared authoritative editor controller; the panel does not own its lifecycle. */
14
21
  controller: GeoreferencerController;
15
22
  /** Optional translation function; defaults to returning English messages unchanged. */
16
23
  t?: Translate;
24
+ /** Show previous/next image view history and linked image/map navigation. Defaults to true. */
25
+ navigation?: boolean;
26
+ /** Show brightness, contrast and histogram display adjustment. Defaults to true. */
27
+ displayAdjustment?: boolean;
28
+ /** Extra actions rendered inside the empty drop zone, such as a sample image button. */
29
+ emptyActions?: ReactNode;
17
30
  }): import("react/jsx-runtime").JSX.Element;