@georeferencing/react 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +400 -2
  3. package/dist/Georeferencer.d.ts +71 -0
  4. package/dist/Georeferencer.js +220 -0
  5. package/dist/components/accuracy.d.ts +15 -0
  6. package/dist/components/accuracy.js +39 -0
  7. package/dist/components/exports.d.ts +32 -0
  8. package/dist/components/exports.js +152 -0
  9. package/dist/components/fields.d.ts +17 -0
  10. package/dist/components/fields.js +35 -0
  11. package/dist/controls.d.ts +40 -0
  12. package/dist/controls.js +28 -0
  13. package/dist/hooks/useGeoreferencer.d.ts +6 -0
  14. package/dist/hooks/useGeoreferencer.js +8 -0
  15. package/dist/index.d.ts +20 -0
  16. package/dist/index.js +16 -0
  17. package/dist/licenses/APACHE-2.0.txt +202 -0
  18. package/dist/licenses/GDAL-NOTICE.txt +24 -0
  19. package/dist/licenses/inventory.json +1 -0
  20. package/dist/localization.d.ts +6 -0
  21. package/dist/localization.js +1 -0
  22. package/dist/panels/AlignmentPanel.d.ts +19 -0
  23. package/dist/panels/AlignmentPanel.js +27 -0
  24. package/dist/panels/FeaturePanel.d.ts +28 -0
  25. package/dist/panels/FeaturePanel.js +43 -0
  26. package/dist/panels/GcpPanel.d.ts +35 -0
  27. package/dist/panels/GcpPanel.js +81 -0
  28. package/dist/panels/ImagePanel.d.ts +30 -0
  29. package/dist/panels/ImagePanel.js +279 -0
  30. package/dist/panels/PreviewControls.d.ts +17 -0
  31. package/dist/panels/PreviewControls.js +37 -0
  32. package/dist/panels/ReferencePanel.d.ts +17 -0
  33. package/dist/panels/ReferencePanel.js +24 -0
  34. package/dist/styles.css +594 -0
  35. package/dist/utils/downloadBlob.d.ts +5 -0
  36. package/dist/utils/downloadBlob.js +11 -0
  37. package/package.json +57 -3
@@ -0,0 +1,279 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useEffect, useId, useRef, useState } from "react";
3
+ import { useGeoreferencer } from "../hooks/useGeoreferencer.js";
4
+ import { identity } from "../localization.js";
5
+ /**
6
+ * Image selection/drop, independent image navigation and source GCP picking. Display stretch affects the image panel only; it does not change export pixels.
7
+ *
8
+ * This composable panel subscribes to the controller but does not attach the reference map or install an unsaved-work guard; manage those in the host when composing panels yourself.
9
+ */
10
+ export function ImagePanel({
11
+ /** Shared authoritative editor controller; the panel does not own its lifecycle. */
12
+ controller,
13
+ /** Optional translation function; defaults to returning English messages unchanged. */
14
+ t = identity,
15
+ /** Show previous/next view history and linked image/map navigation. */
16
+ navigation = true,
17
+ /** Show brightness, contrast and histogram display adjustment. */
18
+ displayAdjustment = true,
19
+ /** Extra actions inside the empty drop zone, such as a sample image button. */
20
+ emptyActions, }) {
21
+ const s = useGeoreferencer(controller), image = s.document.sourceImage, svg = useRef(null);
22
+ const view = s.imageView ?? [
23
+ 0, 0, 100, 100,
24
+ ];
25
+ const setView = (next) => controller.setImageView(typeof next === "function" ? next(view) : next);
26
+ const [brightness, setBrightness] = useState(1), [contrast, setContrast] = useState(1), [histogram, setHistogram] = useState([]), [stretch, setStretch] = useState([
27
+ [0, 255],
28
+ [0, 255],
29
+ [0, 255],
30
+ ]);
31
+ const filterId = `rg-stretch-${useId().replace(/:/g, "")}`;
32
+ const displayPixels = useRef(null);
33
+ const stretchHistogram = (local) => {
34
+ const pixels = displayPixels.current;
35
+ if (!pixels || !image)
36
+ return;
37
+ const x0 = local
38
+ ? Math.max(0, Math.floor((view[0] * pixels.width) / image.width))
39
+ : 0, y0 = local
40
+ ? Math.max(0, Math.floor((view[1] * pixels.height) / image.height))
41
+ : 0, x1 = local
42
+ ? Math.min(pixels.width, Math.ceil(((view[0] + view[2]) * pixels.width) / image.width))
43
+ : pixels.width, y1 = local
44
+ ? Math.min(pixels.height, Math.ceil(((view[1] + view[3]) * pixels.height) / image.height))
45
+ : pixels.height;
46
+ const ranges = [
47
+ [255, 0],
48
+ [255, 0],
49
+ [255, 0],
50
+ ], bins = Array(32).fill(0);
51
+ for (let y = y0; y < y1; y++)
52
+ for (let x = x0; x < x1; x++) {
53
+ const i = (y * pixels.width + x) * 4;
54
+ if (!pixels.data[i + 3])
55
+ continue;
56
+ for (let c = 0; c < 3; c++) {
57
+ ranges[c][0] = Math.min(ranges[c][0], pixels.data[i + c]);
58
+ ranges[c][1] = Math.max(ranges[c][1], pixels.data[i + c]);
59
+ }
60
+ bins[Math.min(31, Math.floor((pixels.data[i] + pixels.data[i + 1] + pixels.data[i + 2]) / 24))]++;
61
+ }
62
+ setStretch(ranges.map(([lo, hi]) => (hi > lo ? [lo, hi] : [0, 255])));
63
+ setHistogram(bins);
64
+ };
65
+ const drag = useRef(null);
66
+ const [dragPoint, setDragPoint] = useState(null);
67
+ // biome-ignore lint/correctness/useExhaustiveDependencies: Reset only when the immutable source image ID changes, not on document clones during edits.
68
+ useEffect(() => {
69
+ if (image)
70
+ controller.setImageView([
71
+ -image.width * 0.04,
72
+ -image.height * 0.06,
73
+ image.width * 1.08,
74
+ image.height * 1.12,
75
+ ]);
76
+ setBrightness(1);
77
+ setContrast(1);
78
+ setStretch([
79
+ [0, 255],
80
+ [0, 255],
81
+ [0, 255],
82
+ ]);
83
+ }, [image?.id, controller]);
84
+ useEffect(() => {
85
+ if (!s.imageUrl) {
86
+ setHistogram([]);
87
+ return;
88
+ }
89
+ let disposed = false;
90
+ const img = new Image();
91
+ img.onload = () => {
92
+ if (disposed)
93
+ return;
94
+ const canvas = document.createElement("canvas");
95
+ canvas.width = img.width;
96
+ canvas.height = img.height;
97
+ const ctx = canvas.getContext("2d");
98
+ ctx.drawImage(img, 0, 0);
99
+ displayPixels.current = ctx.getImageData(0, 0, canvas.width, canvas.height);
100
+ const data = displayPixels.current.data, bins = Array(32).fill(0);
101
+ for (let i = 0; i < data.length; i += 4)
102
+ if (data[i + 3])
103
+ bins[Math.min(31, Math.floor((data[i] + data[i + 1] + data[i + 2]) / 24))]++;
104
+ setHistogram(bins);
105
+ canvas.width = 0;
106
+ };
107
+ img.src = s.imageUrl;
108
+ return () => {
109
+ disposed = true;
110
+ displayPixels.current = null;
111
+ img.onload = null;
112
+ img.src = "";
113
+ };
114
+ }, [s.imageUrl]);
115
+ const point = (e) => {
116
+ const matrix = svg.current?.getScreenCTM();
117
+ if (!matrix)
118
+ return [0, 0];
119
+ const p = new DOMPoint(e.clientX, e.clientY).matrixTransform(matrix.inverse());
120
+ return [p.x, p.y];
121
+ };
122
+ // Read the latest view from the controller so rapid wheel events never use a stale render.
123
+ const zoom = (factor, center) => {
124
+ const v = controller.getSnapshot().imageView ?? view;
125
+ const c = center ?? [v[0] + v[2] / 2, v[1] + v[3] / 2];
126
+ controller.setImageView([
127
+ c[0] + (v[0] - c[0]) * factor,
128
+ c[1] + (v[1] - c[1]) * factor,
129
+ Math.max(1, v[2] * factor),
130
+ Math.max(1, v[3] * factor),
131
+ ]);
132
+ };
133
+ const zoomRef = useRef(zoom);
134
+ zoomRef.current = zoom;
135
+ const hasImage = Boolean(image);
136
+ // React registers wheel listeners as passive; a native one can stop the page scrolling.
137
+ useEffect(() => {
138
+ const element = svg.current;
139
+ if (!hasImage || !element)
140
+ return;
141
+ const wheel = (e) => {
142
+ e.preventDefault();
143
+ const matrix = element.getScreenCTM();
144
+ const center = matrix
145
+ ? new DOMPoint(e.clientX, e.clientY).matrixTransform(matrix.inverse())
146
+ : undefined;
147
+ zoomRef.current(e.deltaY > 0 ? 1.15 : 1 / 1.15, center ? [center.x, center.y] : undefined);
148
+ };
149
+ element.addEventListener("wheel", wheel, { passive: false });
150
+ return () => element.removeEventListener("wheel", wheel);
151
+ }, [hasImage]);
152
+ const down = (e) => {
153
+ if (!image)
154
+ return;
155
+ e.preventDefault();
156
+ e.currentTarget.focus({ preventScroll: true });
157
+ const p = point(e);
158
+ const inside = p[0] >= 0 && p[1] >= 0 && p[0] <= image.width && p[1] <= image.height;
159
+ // Dragging always pans; a click without movement places a point while matching.
160
+ drag.current = {
161
+ start: [e.clientX, e.clientY],
162
+ view,
163
+ pointerId: e.pointerId,
164
+ click: s.tool === "gcp" && e.button === 0 && !e.shiftKey && inside
165
+ ? p
166
+ : undefined,
167
+ };
168
+ e.currentTarget.setPointerCapture(e.pointerId);
169
+ };
170
+ return (_jsxs("section", { className: "rg-image-panel", "data-empty": image ? undefined : "", "aria-label": t("Source image"), onDragOver: (e) => e.preventDefault(), onDrop: (e) => {
171
+ e.preventDefault();
172
+ const file = e.dataTransfer.files[0];
173
+ if (file)
174
+ void controller.loadImage(file);
175
+ }, children: [_jsxs("div", { className: "rg-panel-heading", children: [_jsx("h2", { children: t("Source image") }), _jsxs("label", { className: "rg-file", children: [t(image ? "Replace image" : "Choose image"), _jsx("input", { "aria-label": t("Choose image"), type: "file", accept: "image/png,image/jpeg,image/webp,image/tiff,.tif,.tiff", onChange: (e) => {
176
+ const file = e.target.files?.[0];
177
+ if (file)
178
+ void controller.loadImage(file);
179
+ e.target.value = "";
180
+ } })] })] }), image ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: "rg-image-meta", children: [image.name, " \u00B7 ", image.width, " \u00D7 ", image.height, " px", image.georeferenced && (_jsxs("span", { children: [" ", "\u00B7 ", t("Existing georeferencing detected; not adopted")] }))] }), _jsxs("svg", { ref: svg, className: "rg-image-view", role: "img",
181
+ // biome-ignore lint/a11y/noNoninteractiveTabindex: This image viewer supports focused arrow-key panning and +/- zoom; numeric inputs provide GCP editing.
182
+ tabIndex: 0, onKeyDown: (e) => {
183
+ const steps = {
184
+ ArrowLeft: [-0.1, 0],
185
+ ArrowRight: [0.1, 0],
186
+ ArrowUp: [0, -0.1],
187
+ ArrowDown: [0, 0.1],
188
+ };
189
+ if (steps[e.key]) {
190
+ e.preventDefault();
191
+ const [x, y] = steps[e.key];
192
+ setView([
193
+ view[0] + x * view[2],
194
+ view[1] + y * view[3],
195
+ view[2],
196
+ view[3],
197
+ ]);
198
+ }
199
+ if (e.key === "+" || e.key === "=") {
200
+ e.preventDefault();
201
+ zoom(0.8);
202
+ }
203
+ if (e.key === "-") {
204
+ e.preventDefault();
205
+ zoom(1.25);
206
+ }
207
+ }, "aria-label": t("Image control point viewer; use numeric table for keyboard placement"), viewBox: view.join(" "), onPointerDown: down, onPointerMove: (e) => {
208
+ const d = drag.current;
209
+ if (!d)
210
+ return;
211
+ if (d.id) {
212
+ d.point = point(e);
213
+ setDragPoint({ id: d.id, point: d.point });
214
+ }
215
+ else {
216
+ if (!d.moved &&
217
+ Math.hypot(e.clientX - d.start[0], e.clientY - d.start[1]) < 4)
218
+ return;
219
+ d.moved = true;
220
+ const scale = Math.max(view[2] / e.currentTarget.clientWidth, view[3] / e.currentTarget.clientHeight);
221
+ setView([
222
+ d.view[0] - (e.clientX - d.start[0]) * scale,
223
+ d.view[1] - (e.clientY - d.start[1]) * scale,
224
+ d.view[2],
225
+ d.view[3],
226
+ ]);
227
+ }
228
+ }, onPointerUp: (e) => {
229
+ const d = drag.current;
230
+ drag.current = null;
231
+ if (d?.id && d.point)
232
+ controller.updateGcp(d.id, { image: d.point });
233
+ else if (d?.click && !d.moved)
234
+ controller.setPendingPoint(d.click);
235
+ setDragPoint(null);
236
+ if (e.currentTarget.hasPointerCapture(e.pointerId))
237
+ e.currentTarget.releasePointerCapture(e.pointerId);
238
+ }, onPointerCancel: () => {
239
+ drag.current = null;
240
+ setDragPoint(null);
241
+ }, children: [_jsx("defs", { children: _jsx("filter", { id: filterId, colorInterpolationFilters: "sRGB", children: _jsxs("feComponentTransfer", { children: [_jsx("feFuncR", { type: "linear", slope: 255 / (stretch[0][1] - stretch[0][0]), intercept: -stretch[0][0] / (stretch[0][1] - stretch[0][0]) }), _jsx("feFuncG", { type: "linear", slope: 255 / (stretch[1][1] - stretch[1][0]), intercept: -stretch[1][0] / (stretch[1][1] - stretch[1][0]) }), _jsx("feFuncB", { type: "linear", slope: 255 / (stretch[2][1] - stretch[2][0]), intercept: -stretch[2][0] / (stretch[2][1] - stretch[2][0]) })] }) }) }), s.imageUrl && (_jsx("image", { href: s.imageUrl, width: image.width, height: image.height, style: {
242
+ filter: `url(#${filterId}) brightness(${brightness}) contrast(${contrast})`,
243
+ } })), s.document.gcps.map((gcp) => {
244
+ const p = dragPoint?.id === gcp.id ? dragPoint.point : gcp.image;
245
+ return (_jsxs("g", { transform: `translate(${p[0]} ${p[1]})`, onPointerDown: (e) => {
246
+ if (s.mode !== "align")
247
+ return;
248
+ e.stopPropagation();
249
+ e.preventDefault();
250
+ svg.current?.focus({ preventScroll: true });
251
+ drag.current = {
252
+ id: gcp.id,
253
+ start: p,
254
+ point: p,
255
+ view,
256
+ pointerId: e.pointerId,
257
+ };
258
+ svg.current.setPointerCapture(e.pointerId);
259
+ }, children: [_jsx("circle", { r: view[2] / 90, fill: gcp.enabled ? "#c95722" : "#666", stroke: "white", strokeWidth: view[2] / 450 }), _jsx("text", { y: -view[2] / 55, textAnchor: "middle", fill: "#132f29", stroke: "white", paintOrder: "stroke", strokeWidth: view[2] / 400, fontSize: view[2] / 35, children: gcp.label })] }, gcp.id));
260
+ }), s.pendingImagePoint && (_jsx("circle", { cx: s.pendingImagePoint[0], cy: s.pendingImagePoint[1], r: view[2] / 70, fill: "none", stroke: "#12695b", strokeWidth: view[2] / 300 }))] }), _jsxs("div", { className: "rg-toolbar", children: [_jsx("button", { type: "button", onClick: () => zoom(0.8), "aria-label": t("Zoom image in"), children: "+" }), _jsx("button", { type: "button", onClick: () => zoom(1.25), "aria-label": t("Zoom image out"), children: "\u2212" }), _jsx("button", { type: "button", onClick: () => setView([
261
+ -image.width * 0.04,
262
+ -image.height * 0.06,
263
+ image.width * 1.08,
264
+ image.height * 1.12,
265
+ ]), children: t("Fit image") }), navigation && (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", onClick: () => controller.navigateImageHistory(-1), children: t("Previous image view") }), _jsx("button", { type: "button", onClick: () => controller.navigateImageHistory(1), children: t("Next image view") })] })), _jsx("button", { type: "button", onClick: () => void controller.removeImage(), children: t("Remove image") })] }), navigation && (_jsxs("label", { children: [t("Linked navigation"), _jsxs("select", { disabled: !s.fit, value: s.linkedNavigation, onChange: (e) => controller.setLinkedNavigation(e.target.value), children: [_jsx("option", { value: "off", children: t("Independent views") }), _jsx("option", { value: "image-to-map", children: t("Map follows image") }), _jsx("option", { value: "map-to-image", children: t("Image follows map") })] })] })), displayAdjustment && (_jsxs("details", { children: [_jsx("summary", { children: t("Display adjustment & histogram") }), _jsxs("div", { className: "rg-toolbar", children: [_jsx("button", { type: "button", onClick: () => stretchHistogram(false), children: t("Full histogram stretch") }), _jsx("button", { type: "button", onClick: () => stretchHistogram(true), children: t("Local histogram stretch") }), _jsx("button", { type: "button", onClick: () => {
266
+ setStretch([
267
+ [0, 255],
268
+ [0, 255],
269
+ [0, 255],
270
+ ]);
271
+ setBrightness(1);
272
+ setContrast(1);
273
+ }, children: t("Reset display") })] }), _jsxs("label", { children: [t("Brightness"), _jsx("input", { type: "range", min: "0.25", max: "2", step: "0.05", value: brightness, onChange: (e) => setBrightness(Number(e.target.value)) })] }), _jsxs("label", { children: [t("Contrast"), _jsx("input", { type: "range", min: "0.25", max: "2", step: "0.05", value: contrast, onChange: (e) => setContrast(Number(e.target.value)) })] }), _jsx("svg", { viewBox: "0 0 320 50", "aria-label": t("RGB intensity histogram of preview"), className: "rg-histogram", children: histogram.map((v, i) => (_jsx("rect", { x: i * 10, y: 50 - (50 * v) / Math.max(1, ...histogram), width: "9", height: (50 * v) / Math.max(1, ...histogram), fill: "currentColor" }, i))) }), _jsx("small", { children: t("Display only. Original samples are preserved for export.") })] }))] })) : (_jsxs(_Fragment, { children: [_jsx("div", { className: "rg-image-meta", children: t("No image selected") }), _jsxs("div", { className: "rg-empty", children: [_jsx("span", { className: "rg-cross", children: "\uFF0B" }), _jsx("p", { children: t("Drop a map, plan, or photograph here.") }), _jsx("small", { children: t("PNG, JPEG, WebP, ordinary TIFF · up to 25 MiB · stays on this device") }), _jsxs("div", { className: "rg-toolbar rg-empty-actions", children: [_jsxs("label", { className: "rg-file rg-primary", children: [t("Choose image"), _jsx("input", { "aria-label": t("Choose image file"), type: "file", accept: "image/png,image/jpeg,image/webp,image/tiff,.tif,.tiff", onChange: (e) => {
274
+ const file = e.target.files?.[0];
275
+ if (file)
276
+ void controller.loadImage(file);
277
+ e.target.value = "";
278
+ } })] }), emptyActions] })] })] }))] }));
279
+ }
@@ -0,0 +1,17 @@
1
+ import type { GeoreferencerController } from "@georeferencing/core";
2
+ import type { Translate } from "../localization.js";
3
+ /**
4
+ * Explicit manual/automatic preview policy and run action. Automatic mode waits for
5
+ * enough complete, enabled pairs; the worker validates rank and domain. `onReview`
6
+ * fires only for a successful preview of the same document/alignment revision.
7
+ */
8
+ export declare function PreviewControls({ controller, t, onReview, modeSelector, }: {
9
+ /** Shared authoritative controller. */
10
+ controller: GeoreferencerController;
11
+ /** Translate default English labels. */
12
+ t?: Translate;
13
+ /** Optional transition from matching into review after a current preview is ready. */
14
+ onReview?: () => void;
15
+ /** Show the manual/automatic preview selector. Defaults to true. */
16
+ modeSelector?: boolean;
17
+ }): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,37 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { MODELS } from "@georeferencing/core";
3
+ import { useGeoreferencer } from "../hooks/useGeoreferencer.js";
4
+ import { identity } from "../localization.js";
5
+ /**
6
+ * Explicit manual/automatic preview policy and run action. Automatic mode waits for
7
+ * enough complete, enabled pairs; the worker validates rank and domain. `onReview`
8
+ * fires only for a successful preview of the same document/alignment revision.
9
+ */
10
+ export function PreviewControls({ controller, t = identity, onReview, modeSelector = true, }) {
11
+ const s = useGeoreferencer(controller), d = s.document;
12
+ const count = d.gcps.filter((p) => p.enabled).length;
13
+ const minimum = MODELS[d.model].minimum;
14
+ const missing = Math.max(0, minimum - count);
15
+ return (_jsxs("div", { className: "rg-preview-controls", children: [modeSelector && (_jsxs("label", { children: [t("Preview updates"), _jsxs("select", { value: s.previewMode, onChange: (e) => controller.setPreviewMode(e.target.value), children: [_jsx("option", { value: "manual", children: t("Manual") }), _jsx("option", { value: "automatic", children: t("Automatic") })] })] })), _jsxs("div", { children: [_jsx("button", { type: "button", className: "rg-primary", disabled: !s.imageUrl ||
16
+ !!s.pendingImagePoint ||
17
+ missing > 0 ||
18
+ s.fitting === "running" ||
19
+ s.mode === "draw", onClick: async () => {
20
+ const id = d.id, revision = d.alignmentRevision;
21
+ if (!s.fit || s.previewMode === "manual")
22
+ await controller.refit();
23
+ const current = controller.getSnapshot();
24
+ if (current.document.id === id &&
25
+ current.fitRevision === revision &&
26
+ current.fit) {
27
+ controller.setTool("navigate");
28
+ onReview?.();
29
+ }
30
+ }, children: t(s.fitting === "running"
31
+ ? "Running alignment…"
32
+ : s.fit && s.previewMode === "automatic"
33
+ ? "Review alignment"
34
+ : "Run alignment") }), _jsx("small", { className: "rg-readiness", "aria-live": "polite", children: missing > 0
35
+ ? `${count} ${t("of")} ${minimum} ${t("points matched")}`
36
+ : `${count} ${t(count === 1 ? "point matched" : "points matched")} · ${t("more points improve accuracy")}` })] })] }));
37
+ }
@@ -0,0 +1,17 @@
1
+ import type { GeoreferencerController } from "@georeferencing/core";
2
+ import type { Translate } from "../localization.js";
3
+ /**
4
+ * Accessible loading/error/partial-result feedback for configured references; returns null when no provider statuses exist.
5
+ *
6
+ * This composable panel subscribes to the controller but does not attach the reference map or install an unsaved-work guard; manage those in the host when composing panels yourself.
7
+ */
8
+ export declare function ReferencePanel({
9
+ /** Shared authoritative editor controller; the panel does not own its lifecycle. */
10
+ controller,
11
+ /** Optional translation function; defaults to returning English messages unchanged. */
12
+ t, }: {
13
+ /** Shared authoritative editor controller; the panel does not own its lifecycle. */
14
+ controller: GeoreferencerController;
15
+ /** Optional translation function; defaults to returning English messages unchanged. */
16
+ t?: Translate;
17
+ }): import("react/jsx-runtime").JSX.Element | null;
@@ -0,0 +1,24 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useGeoreferencer } from "../hooks/useGeoreferencer.js";
3
+ import { identity } from "../localization.js";
4
+ /**
5
+ * Accessible loading/error/partial-result feedback for configured references; returns null when no provider statuses exist.
6
+ *
7
+ * This composable panel subscribes to the controller but does not attach the reference map or install an unsaved-work guard; manage those in the host when composing panels yourself.
8
+ */
9
+ export function ReferencePanel({
10
+ /** Shared authoritative editor controller; the panel does not own its lifecycle. */
11
+ controller,
12
+ /** Optional translation function; defaults to returning English messages unchanged. */
13
+ t = identity, }) {
14
+ const { references } = useGeoreferencer(controller);
15
+ if (!Object.keys(references).length)
16
+ return null;
17
+ return (_jsx("section", { className: "rg-references", "aria-label": t("Reference sources"), "aria-live": "polite", children: Object.entries(references).map(([id, status]) => (_jsxs("p", { className: status.state === "error" ? "rg-error" : "", children: [_jsx("strong", { children: status.label }), " · ", t(status.state === "loading"
18
+ ? "Loading reference"
19
+ : status.state === "error"
20
+ ? "Reference unavailable"
21
+ : status.partial
22
+ ? "Incomplete reference results"
23
+ : "Reference ready"), status.message && _jsxs(_Fragment, { children: [" \u00B7 ", t(status.message)] })] }, id))) }));
24
+ }