@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.
@@ -11,8 +11,14 @@ export function ImagePanel({
11
11
  /** Shared authoritative editor controller; the panel does not own its lifecycle. */
12
12
  controller,
13
13
  /** Optional translation function; defaults to returning English messages unchanged. */
14
- t = identity, }) {
15
- const s = useGeoreferencer(controller), image = s.document.sourceImage, svg = useRef(null);
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, imageWidth = image?.width, svg = useRef(null);
16
22
  const view = s.imageView ?? [
17
23
  0, 0, 100, 100,
18
24
  ];
@@ -24,6 +30,10 @@ t = identity, }) {
24
30
  ]);
25
31
  const filterId = `rg-stretch-${useId().replace(/:/g, "")}`;
26
32
  const displayPixels = useRef(null);
33
+ // Preview pixels per canonical image pixel, known once the preview has loaded.
34
+ const [previewScale, setPreviewScale] = useState(0);
35
+ // On-screen pixels per canonical image pixel for the current view.
36
+ const [screenScale, setScreenScale] = useState(0);
27
37
  const stretchHistogram = (local) => {
28
38
  const pixels = displayPixels.current;
29
39
  if (!pixels || !image)
@@ -91,6 +101,8 @@ t = identity, }) {
91
101
  const ctx = canvas.getContext("2d");
92
102
  ctx.drawImage(img, 0, 0);
93
103
  displayPixels.current = ctx.getImageData(0, 0, canvas.width, canvas.height);
104
+ if (imageWidth)
105
+ setPreviewScale(img.width / imageWidth);
94
106
  const data = displayPixels.current.data, bins = Array(32).fill(0);
95
107
  for (let i = 0; i < data.length; i += 4)
96
108
  if (data[i + 3])
@@ -102,10 +114,31 @@ t = identity, }) {
102
114
  return () => {
103
115
  disposed = true;
104
116
  displayPixels.current = null;
117
+ setPreviewScale(0);
105
118
  img.onload = null;
106
119
  img.src = "";
107
120
  };
108
- }, [s.imageUrl]);
121
+ }, [s.imageUrl, imageWidth]);
122
+ // Track the on-screen scale; the viewBox keeps its aspect ratio ("meet").
123
+ const viewWidth = view[2], viewHeight = view[3];
124
+ useEffect(() => {
125
+ const element = svg.current;
126
+ if (!element)
127
+ return;
128
+ const measure = () => setScreenScale(Math.min(element.clientWidth / viewWidth, element.clientHeight / viewHeight));
129
+ measure();
130
+ if (typeof ResizeObserver === "undefined")
131
+ return;
132
+ const observer = new ResizeObserver(measure);
133
+ observer.observe(element);
134
+ return () => observer.disconnect();
135
+ }, [viewWidth, viewHeight]);
136
+ // Zoomed beyond the preview resolution: show full-resolution detail for precise picking.
137
+ const needsDetail = previewScale > 0 && screenScale > previewScale * 1.25;
138
+ useEffect(() => {
139
+ if (needsDetail)
140
+ void controller.requestDetailImage();
141
+ }, [needsDetail, controller]);
109
142
  const point = (e) => {
110
143
  const matrix = svg.current?.getScreenCTM();
111
144
  if (!matrix)
@@ -113,31 +146,55 @@ t = identity, }) {
113
146
  const p = new DOMPoint(e.clientX, e.clientY).matrixTransform(matrix.inverse());
114
147
  return [p.x, p.y];
115
148
  };
116
- const zoom = (factor, center = [view[0] + view[2] / 2, view[1] + view[3] / 2]) => setView((v) => [
117
- center[0] + (v[0] - center[0]) * factor,
118
- center[1] + (v[1] - center[1]) * factor,
119
- Math.max(1, v[2] * factor),
120
- Math.max(1, v[3] * factor),
121
- ]);
149
+ // Read the latest view from the controller so rapid wheel events never use a stale render.
150
+ const zoom = (factor, center) => {
151
+ const v = controller.getSnapshot().imageView ?? view;
152
+ const c = center ?? [v[0] + v[2] / 2, v[1] + v[3] / 2];
153
+ controller.setImageView([
154
+ c[0] + (v[0] - c[0]) * factor,
155
+ c[1] + (v[1] - c[1]) * factor,
156
+ Math.max(1, v[2] * factor),
157
+ Math.max(1, v[3] * factor),
158
+ ]);
159
+ };
160
+ const zoomRef = useRef(zoom);
161
+ zoomRef.current = zoom;
162
+ const hasImage = Boolean(image);
163
+ // React registers wheel listeners as passive; a native one can stop the page scrolling.
164
+ useEffect(() => {
165
+ const element = svg.current;
166
+ if (!hasImage || !element)
167
+ return;
168
+ const wheel = (e) => {
169
+ e.preventDefault();
170
+ const matrix = element.getScreenCTM();
171
+ const center = matrix
172
+ ? new DOMPoint(e.clientX, e.clientY).matrixTransform(matrix.inverse())
173
+ : undefined;
174
+ zoomRef.current(e.deltaY > 0 ? 1.15 : 1 / 1.15, center ? [center.x, center.y] : undefined);
175
+ };
176
+ element.addEventListener("wheel", wheel, { passive: false });
177
+ return () => element.removeEventListener("wheel", wheel);
178
+ }, [hasImage]);
122
179
  const down = (e) => {
123
180
  if (!image)
124
181
  return;
125
182
  e.preventDefault();
126
183
  e.currentTarget.focus({ preventScroll: true });
127
184
  const p = point(e);
128
- if (s.tool === "gcp" && e.button === 0 && !e.shiftKey) {
129
- if (p[0] >= 0 && p[1] >= 0 && p[0] <= image.width && p[1] <= image.height)
130
- controller.setPendingPoint(p);
131
- return;
132
- }
185
+ const inside = p[0] >= 0 && p[1] >= 0 && p[0] <= image.width && p[1] <= image.height;
186
+ // Dragging always pans; a click without movement places a point while matching.
133
187
  drag.current = {
134
188
  start: [e.clientX, e.clientY],
135
189
  view,
136
190
  pointerId: e.pointerId,
191
+ click: s.tool === "gcp" && e.button === 0 && !e.shiftKey && inside
192
+ ? p
193
+ : undefined,
137
194
  };
138
195
  e.currentTarget.setPointerCapture(e.pointerId);
139
196
  };
140
- return (_jsxs("section", { className: "rg-image-panel", "aria-label": t("Source image"), onDragOver: (e) => e.preventDefault(), onDrop: (e) => {
197
+ return (_jsxs("section", { className: "rg-image-panel", "data-empty": image ? undefined : "", "aria-label": t("Source image"), onDragOver: (e) => e.preventDefault(), onDrop: (e) => {
141
198
  e.preventDefault();
142
199
  const file = e.dataTransfer.files[0];
143
200
  if (file)
@@ -183,6 +240,10 @@ t = identity, }) {
183
240
  setDragPoint({ id: d.id, point: d.point });
184
241
  }
185
242
  else {
243
+ if (!d.moved &&
244
+ Math.hypot(e.clientX - d.start[0], e.clientY - d.start[1]) < 4)
245
+ return;
246
+ d.moved = true;
186
247
  const scale = Math.max(view[2] / e.currentTarget.clientWidth, view[3] / e.currentTarget.clientHeight);
187
248
  setView([
188
249
  d.view[0] - (e.clientX - d.start[0]) * scale,
@@ -196,16 +257,20 @@ t = identity, }) {
196
257
  drag.current = null;
197
258
  if (d?.id && d.point)
198
259
  controller.updateGcp(d.id, { image: d.point });
260
+ else if (d?.click && !d.moved)
261
+ controller.setPendingPoint(d.click);
199
262
  setDragPoint(null);
200
263
  if (e.currentTarget.hasPointerCapture(e.pointerId))
201
264
  e.currentTarget.releasePointerCapture(e.pointerId);
202
265
  }, onPointerCancel: () => {
203
266
  drag.current = null;
204
267
  setDragPoint(null);
205
- }, onWheel: (e) => {
206
- zoom(e.deltaY > 0 ? 1.15 : 1 / 1.15, point(e));
207
268
  }, 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: {
208
269
  filter: `url(#${filterId}) brightness(${brightness}) contrast(${contrast})`,
270
+ } })), s.detailImageUrl && needsDetail && (_jsx("image", { href: s.detailImageUrl, width: image.width, height: image.height, "data-detail": "", style: {
271
+ filter: `url(#${filterId}) brightness(${brightness}) contrast(${contrast})`,
272
+ // Show individual source pixels once each covers several screen pixels.
273
+ imageRendering: screenScale >= 4 ? "pixelated" : "auto",
209
274
  } })), s.document.gcps.map((gcp) => {
210
275
  const p = dragPoint?.id === gcp.id ? dragPoint.point : gcp.image;
211
276
  return (_jsxs("g", { transform: `translate(${p[0]} ${p[1]})`, onPointerDown: (e) => {
@@ -228,7 +293,7 @@ t = identity, }) {
228
293
  -image.height * 0.06,
229
294
  image.width * 1.08,
230
295
  image.height * 1.12,
231
- ]), children: t("Fit image") }), _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") })] }), _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") })] })] }), _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: () => {
296
+ ]), 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: () => {
232
297
  setStretch([
233
298
  [0, 255],
234
299
  [0, 255],
@@ -236,5 +301,10 @@ t = identity, }) {
236
301
  ]);
237
302
  setBrightness(1);
238
303
  setContrast(1);
239
- }, 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("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") })] }))] }));
304
+ }, 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) => {
305
+ const file = e.target.files?.[0];
306
+ if (file)
307
+ void controller.loadImage(file);
308
+ e.target.value = "";
309
+ } })] }), emptyActions] })] })] }))] }));
240
310
  }
@@ -5,11 +5,13 @@ import type { Translate } from "../localization.js";
5
5
  * enough complete, enabled pairs; the worker validates rank and domain. `onReview`
6
6
  * fires only for a successful preview of the same document/alignment revision.
7
7
  */
8
- export declare function PreviewControls({ controller, t, onReview, }: {
8
+ export declare function PreviewControls({ controller, t, onReview, modeSelector, }: {
9
9
  /** Shared authoritative controller. */
10
10
  controller: GeoreferencerController;
11
11
  /** Translate default English labels. */
12
12
  t?: Translate;
13
13
  /** Optional transition from matching into review after a current preview is ready. */
14
14
  onReview?: () => void;
15
+ /** Show the manual/automatic preview selector. Defaults to true. */
16
+ modeSelector?: boolean;
15
17
  }): import("react/jsx-runtime").JSX.Element;
@@ -7,11 +7,12 @@ import { identity } from "../localization.js";
7
7
  * enough complete, enabled pairs; the worker validates rank and domain. `onReview`
8
8
  * fires only for a successful preview of the same document/alignment revision.
9
9
  */
10
- export function PreviewControls({ controller, t = identity, onReview, }) {
10
+ export function PreviewControls({ controller, t = identity, onReview, modeSelector = true, }) {
11
11
  const s = useGeoreferencer(controller), d = s.document;
12
12
  const count = d.gcps.filter((p) => p.enabled).length;
13
- const missing = Math.max(0, MODELS[d.model].minimum - count);
14
- return (_jsxs("div", { className: "rg-preview-controls", children: [_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 ||
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 ||
15
16
  !!s.pendingImagePoint ||
16
17
  missing > 0 ||
17
18
  s.fitting === "running" ||
@@ -30,7 +31,7 @@ export function PreviewControls({ controller, t = identity, onReview, }) {
30
31
  ? "Running alignment…"
31
32
  : s.fit && s.previewMode === "automatic"
32
33
  ? "Review alignment"
33
- : "Run alignment") }), _jsxs("small", { className: "rg-readiness", "aria-live": "polite", children: [count, " ", t("pairs"), missing > 0
34
- ? ` · ${t("Add at least")} ${missing} ${t("more")}`
35
- : ` · ${t("You can keep adding pairs")}`] })] })] }));
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")}` })] })] }));
36
37
  }
package/dist/styles.css CHANGED
@@ -277,18 +277,19 @@
277
277
  }
278
278
  }
279
279
 
280
- /* Guided two-view layout; all selectors remain scoped to the editor. */
281
- .rg-preview-controls {
282
- display: flex;
283
- align-items: center;
284
- gap: 16px;
285
- }
286
- .rg-preview-controls label {
287
- min-width: 115px;
280
+ /* Guided step-by-step layout; all selectors remain scoped to the editor. */
281
+ .rg-editor .rg-primary:hover:not(:disabled) {
282
+ background: #0e5a4e;
283
+ border-color: #0e5a4e;
284
+ color: white;
288
285
  }
289
- .rg-readiness {
290
- display: block;
291
- margin-top: 4px;
286
+ .rg-visually-hidden {
287
+ position: absolute;
288
+ width: 1px;
289
+ height: 1px;
290
+ overflow: hidden;
291
+ clip-path: inset(50%);
292
+ white-space: nowrap;
292
293
  }
293
294
  .rg-guided {
294
295
  padding: 18px;
@@ -296,45 +297,136 @@
296
297
  .rg-steps {
297
298
  display: flex;
298
299
  flex-wrap: wrap;
299
- gap: 12px 28px;
300
+ gap: 8px 22px;
300
301
  list-style: none;
301
302
  margin: 0;
302
303
  padding: 0 0 14px;
303
304
  border-bottom: 1px solid var(--rg-border);
304
- font-size: 12px;
305
+ font-size: 13px;
305
306
  color: #5b6c65;
306
307
  }
307
- .rg-steps [aria-current="step"] {
308
+ .rg-steps li,
309
+ .rg-editor .rg-step-button {
310
+ display: inline-flex;
311
+ align-items: center;
312
+ gap: 8px;
313
+ }
314
+ .rg-editor .rg-step-button {
315
+ padding: 0;
316
+ border: 0;
317
+ background: none;
318
+ color: inherit;
319
+ font-size: inherit;
320
+ }
321
+ .rg-editor .rg-step-button:hover .rg-step-label {
322
+ text-decoration: underline;
323
+ }
324
+ .rg-step-index {
325
+ display: inline-grid;
326
+ place-items: center;
327
+ width: 24px;
328
+ height: 24px;
329
+ border-radius: 50%;
330
+ border: 1.5px solid #9dab9e;
331
+ font-size: 12px;
332
+ font-weight: 700;
333
+ font-variant-numeric: tabular-nums;
334
+ }
335
+ .rg-steps [data-state="current"] {
308
336
  color: var(--rg-accent);
309
- font-weight: 750;
337
+ font-weight: 700;
338
+ }
339
+ .rg-steps [data-state="current"] .rg-step-index {
340
+ border-color: var(--rg-accent);
341
+ background: #e3efe9;
342
+ }
343
+ .rg-steps [data-state="done"] {
344
+ color: #20322f;
345
+ }
346
+ .rg-steps [data-state="done"] .rg-step-index {
347
+ border-color: var(--rg-accent);
348
+ background: var(--rg-accent);
349
+ color: white;
310
350
  }
311
351
  .rg-matching-bar {
312
- display: flex;
313
- justify-content: space-between;
352
+ display: grid;
353
+ grid-template-columns: minmax(0, 1fr) auto;
314
354
  align-items: center;
315
- gap: 20px;
316
- padding: 12px 0 4px;
355
+ gap: 8px 24px;
356
+ padding: 14px 0 10px;
357
+ }
358
+ .rg-guided .rg-matching-bar {
359
+ position: sticky;
360
+ top: 0;
361
+ z-index: 5;
362
+ background: var(--rg-paper);
363
+ border-bottom: 1px solid var(--rg-border);
317
364
  }
318
365
  .rg-instruction strong {
319
366
  font-size: 16px;
320
367
  }
321
368
  .rg-instruction p {
322
- max-width: 600px;
369
+ max-width: 640px;
370
+ margin: 4px 0 0;
323
371
  font-size: 12px;
324
372
  color: #5b6c65;
325
373
  }
374
+ .rg-editor .rg-link {
375
+ padding: 0;
376
+ border: 0;
377
+ background: none;
378
+ color: var(--rg-accent);
379
+ font-size: inherit;
380
+ text-decoration: underline;
381
+ }
382
+ .rg-step-actions {
383
+ display: flex;
384
+ flex-wrap: wrap;
385
+ align-items: center;
386
+ justify-content: flex-end;
387
+ gap: 8px;
388
+ }
389
+ .rg-preview-controls {
390
+ display: flex;
391
+ align-items: center;
392
+ gap: 16px;
393
+ }
394
+ .rg-preview-controls label {
395
+ display: flex;
396
+ align-items: center;
397
+ gap: 8px;
398
+ margin: 0;
399
+ }
400
+ .rg-preview-controls > div {
401
+ text-align: right;
402
+ }
403
+ .rg-readiness {
404
+ display: block;
405
+ margin-top: 4px;
406
+ }
326
407
  .rg-match-tools {
327
- margin: 0 0 12px;
408
+ margin: 10px 0 12px;
328
409
  }
329
- .rg-match-tools label {
410
+ .rg-tools-end {
330
411
  display: flex;
331
412
  align-items: center;
332
- gap: 10px;
413
+ gap: 14px;
333
414
  margin-left: auto;
334
415
  }
335
- .rg-match-tools select {
416
+ .rg-editor .rg-inline-field {
417
+ display: flex;
418
+ align-items: center;
419
+ gap: 8px;
420
+ margin: 0;
421
+ white-space: nowrap;
422
+ }
423
+ .rg-editor .rg-inline-field select {
424
+ width: auto;
336
425
  max-width: 225px;
337
426
  }
427
+ .rg-guided .rg-dirty {
428
+ line-height: 1.6;
429
+ }
338
430
  .rg-paired-views {
339
431
  display: grid;
340
432
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
@@ -355,54 +447,124 @@
355
447
  }
356
448
  .rg-guided .rg-image-panel .rg-panel-heading {
357
449
  min-height: 39px;
450
+ align-items: center;
358
451
  }
359
452
  .rg-guided .rg-image-panel .rg-panel-heading h2 {
360
453
  margin: 0;
361
454
  }
455
+ .rg-guided .rg-image-panel .rg-panel-heading .rg-file {
456
+ margin: 0;
457
+ }
458
+ .rg-guided .rg-image-panel[data-empty] .rg-panel-heading .rg-file {
459
+ display: none;
460
+ }
362
461
  .rg-guided .rg-image-meta {
363
462
  margin-top: 4px;
364
463
  }
464
+ .rg-empty-actions {
465
+ justify-content: center;
466
+ margin-top: 16px;
467
+ }
365
468
  .rg-view-tabs {
366
469
  display: none;
470
+ border: 0;
471
+ margin: 0;
472
+ padding: 0;
367
473
  }
368
474
  .rg-review-panels {
369
475
  display: grid;
370
476
  grid-template-columns: repeat(2, minmax(0, 1fr));
371
- gap: 24px;
372
- padding: 18px 0;
477
+ gap: 18px;
478
+ padding: 18px 0 0;
373
479
  }
374
- .rg-review-panels section + section {
480
+ .rg-editor .rg-review-panels section + section {
375
481
  margin-top: 0;
376
482
  padding-top: 0;
377
483
  border-top: none;
378
484
  }
485
+ .rg-card {
486
+ min-width: 0;
487
+ padding: 16px;
488
+ border: 1px solid var(--rg-border);
489
+ border-radius: 6px;
490
+ background: white;
491
+ }
492
+ .rg-editor .rg-review-panels .rg-card {
493
+ padding: 16px;
494
+ }
495
+ .rg-editor .rg-card h3 {
496
+ margin: 16px 0 0;
497
+ font-size: 13px;
498
+ font-weight: 650;
499
+ }
500
+ .rg-accuracy {
501
+ margin-bottom: 12px;
502
+ padding: 10px 12px;
503
+ border-left: 4px solid #9dab9e;
504
+ background: #f4f6f1;
505
+ }
506
+ .rg-accuracy[data-quality="good"] {
507
+ border-left-color: #2f8a5b;
508
+ }
509
+ .rg-accuracy[data-quality="fair"] {
510
+ border-left-color: #c48e45;
511
+ }
512
+ .rg-accuracy[data-quality="poor"] {
513
+ border-left-color: #b4452d;
514
+ }
515
+ .rg-accuracy p {
516
+ margin: 4px 0;
517
+ font-size: 12px;
518
+ }
379
519
  .rg-guided .rg-gcp-panel {
380
520
  border-top: 1px solid var(--rg-border);
381
521
  margin-top: 18px;
382
522
  padding-top: 16px;
383
523
  }
384
- .rg-guided .rg-gcp-panel > .rg-toolbar,
385
- .rg-guided .rg-gcp-panel > .rg-hint {
386
- display: none;
524
+ .rg-editor .rg-review-panels .rg-gcp-panel {
525
+ margin-top: 0;
526
+ padding-top: 0;
527
+ border-top: none;
528
+ }
529
+ .rg-editor .rg-point-list {
530
+ width: auto;
531
+ min-width: 280px;
387
532
  }
388
533
  .rg-advanced {
389
534
  margin-top: 16px;
390
535
  }
536
+ .rg-export-progress {
537
+ display: flex;
538
+ align-items: center;
539
+ gap: 12px;
540
+ margin: 10px 0;
541
+ }
542
+ .rg-status:empty {
543
+ margin: 0;
544
+ padding: 0;
545
+ border: 0;
546
+ }
391
547
  @media (max-width: 720px) {
392
548
  .rg-guided {
393
549
  padding: 12px;
394
550
  }
551
+ .rg-steps li:not([data-state="current"]) .rg-step-label {
552
+ display: none;
553
+ }
395
554
  .rg-matching-bar {
396
- flex-direction: column;
397
- align-items: stretch;
398
- gap: 4px;
555
+ grid-template-columns: minmax(0, 1fr);
399
556
  }
557
+ .rg-step-actions,
400
558
  .rg-preview-controls {
401
- justify-content: space-between;
559
+ justify-content: flex-start;
402
560
  }
403
- .rg-match-tools label {
561
+ .rg-preview-controls > div {
562
+ text-align: left;
563
+ }
564
+ .rg-tools-end {
404
565
  margin-left: 0;
405
566
  width: 100%;
567
+ flex-wrap: wrap;
406
568
  }
407
569
  .rg-paired-views {
408
570
  grid-template-columns: minmax(0, 1fr);
@@ -423,18 +585,10 @@
423
585
  grid-template-columns: minmax(0, 1fr);
424
586
  }
425
587
  }
426
-
427
- .rg-guided .rg-image-panel .rg-panel-heading .rg-file {
428
- margin: 0;
429
- }
430
- .rg-view-tabs {
431
- border: 0;
432
- margin: 0;
433
- padding: 0;
434
- }
435
- .rg-guided .rg-matching-bar {
436
- position: sticky;
437
- top: 0;
438
- z-index: 5;
439
- background: var(--rg-paper);
588
+ .rg-editor .rg-error {
589
+ padding: 8px 12px;
590
+ border-left: 3px solid #b4452d;
591
+ background: #fbefea;
592
+ color: #7a2614;
593
+ font-size: 12px;
440
594
  }
@@ -1,5 +1,6 @@
1
1
  /**
2
- * Start a browser download and revoke its temporary object URL after one second. Invoke
3
- * from a client-side user action; this utility performs no upload.
2
+ * Start a browser download and revoke its temporary object URL after one minute, which
3
+ * leaves large files (such as full-resolution GeoTIFFs) time to start saving in every
4
+ * browser. Invoke from a client-side user action; this utility performs no upload.
4
5
  */
5
6
  export declare function downloadBlob(blob: Blob, name: string): void;
@@ -1,11 +1,20 @@
1
1
  /**
2
- * Start a browser download and revoke its temporary object URL after one second. Invoke
3
- * from a client-side user action; this utility performs no upload.
2
+ * Start a browser download and revoke its temporary object URL after one minute, which
3
+ * leaves large files (such as full-resolution GeoTIFFs) time to start saving in every
4
+ * browser. Invoke from a client-side user action; this utility performs no upload.
4
5
  */
5
6
  export function downloadBlob(blob, name) {
6
7
  const url = URL.createObjectURL(blob), a = document.createElement("a");
7
8
  a.href = url;
8
9
  a.download = name;
9
- a.click();
10
- setTimeout(() => URL.revokeObjectURL(url), 1000);
10
+ a.style.display = "none";
11
+ // Some browsers ignore clicks on anchors outside the document.
12
+ document.body.append(a);
13
+ try {
14
+ a.click();
15
+ }
16
+ finally {
17
+ a.remove();
18
+ }
19
+ setTimeout(() => URL.revokeObjectURL(url), 60_000);
11
20
  }
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.1.0",
2
+ "version": "0.3.0",
3
3
  "license": "MIT",
4
4
  "type": "module",
5
5
  "engines": {
@@ -31,15 +31,14 @@
31
31
  },
32
32
  "./styles.css": "./dist/styles.css"
33
33
  },
34
- "dependencies": {
35
- "@georeferencing/core": "^0.1.0"
36
- },
37
34
  "peerDependencies": {
35
+ "@georeferencing/core": "^0.3.0",
38
36
  "react": ">=19.3.0 <20",
39
37
  "react-dom": ">=19.3.0 <20",
40
38
  "ol": ">=10.10.0 <11"
41
39
  },
42
40
  "devDependencies": {
41
+ "@georeferencing/core": "0.3.0",
43
42
  "typescript": "5.9.3",
44
43
  "react": "19.3.0",
45
44
  "react-dom": "19.3.0",