@georeferencing/react 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -30,9 +30,10 @@ Importing the package is SSR-safe. Create maps and start browser processing only
30
30
  on the client.
31
31
 
32
32
  **Install the same version of `@georeferencing/react`, `@georeferencing/core` and
33
- `@georeferencing/plugins`**, and upgrade them together. React depends on core and
34
- re-exports its controller; a mismatched version can install a second copy of core
35
- with a separate projection registry. See
33
+ `@georeferencing/plugins`**, and upgrade them together. React declares core as a peer
34
+ dependency and re-exports its controller; a mismatched version is reported as a
35
+ peer-dependency conflict rather than installing a second core with a separate
36
+ projection registry. See
36
37
  [keep package versions aligned](https://georeferencing-api-docs.gh.tobilg.com/Getting_started/#keep-package-versions-aligned).
37
38
 
38
39
  ## Integrate with a host-owned map
@@ -176,7 +177,10 @@ sticky bar:
176
177
  Completed steps in the step bar are clickable. Desktop keeps both views side by
177
178
  side; below 720 px, Image/Map buttons switch panes and follow pending points.
178
179
  In both views, dragging pans and scrolling zooms; a click without dragging places
179
- a point.
180
+ a point. Zooming the image beyond the reduced preview's resolution loads the
181
+ full-resolution image (`controller.requestDetailImage()`), and source pixels are shown
182
+ crisply once each covers several screen pixels, so points can be placed at the
183
+ original resolution.
180
184
 
181
185
  OpenLayers' `Map` creates its default interactions with `onFocusOnly: true`. If
182
186
  the map target has a `tabindex` (for keyboard navigation), mouse panning and wheel
@@ -15,6 +15,15 @@ import { PreviewControls } from "./panels/PreviewControls.js";
15
15
  import { ReferencePanel } from "./panels/ReferencePanel.js";
16
16
  const DEFAULT_BINDING = {};
17
17
  const NO_CONTROLS = {};
18
+ /** Activate a tool from an effect, reporting instead of throwing into React. */
19
+ function selectTool(controller, tool) {
20
+ try {
21
+ controller.setTool(tool);
22
+ }
23
+ catch (error) {
24
+ controller.reportError(error);
25
+ }
26
+ }
18
27
  /**
19
28
  * Ready-made React editor for an existing OpenLayers map. Composes image, GCP, alignment, reference, output and optional drawing controls.
20
29
  *
@@ -49,7 +58,7 @@ export function Georeferencer({ controller, referenceMap, bindingOptions = DEFAU
49
58
  setMobileView("image");
50
59
  // A metadata-only restored document still needs its matching local bytes.
51
60
  if (imageId && imageReady && controller.getSnapshot().mode === "align")
52
- controller.setTool("gcp");
61
+ selectTool(controller, "gcp");
53
62
  }, [controller, guided, imageId, imageReady]);
54
63
  useEffect(() => {
55
64
  if (guided)
@@ -58,7 +67,7 @@ export function Georeferencer({ controller, referenceMap, bindingOptions = DEFAU
58
67
  // Matching always uses the point tool; dragging pans, so no separate pan mode is needed.
59
68
  useEffect(() => {
60
69
  if (guided && step === 1 && s.tool !== "gcp" && s.mode === "align")
61
- controller.setTool("gcp");
70
+ selectTool(controller, "gcp");
62
71
  }, [controller, guided, step, s.tool, s.mode]);
63
72
  useEffect(() => {
64
73
  if (guided && mobileView === "map")
@@ -66,7 +75,15 @@ export function Georeferencer({ controller, referenceMap, bindingOptions = DEFAU
66
75
  }, [guided, mobileView, referenceMap]);
67
76
  useEffect(() => {
68
77
  controller.start();
69
- binding.current = attachReferenceMap(referenceMap, controller, bindingOptions);
78
+ try {
79
+ binding.current = attachReferenceMap(referenceMap, controller, bindingOptions);
80
+ }
81
+ catch (error) {
82
+ // Invalid projection or datum-grid configuration: keep the editor mounted and
83
+ // show the error instead of unmounting the host tree.
84
+ binding.current = null;
85
+ controller.reportError(error);
86
+ }
70
87
  return () => {
71
88
  binding.current?.detach();
72
89
  binding.current = null;
package/dist/index.d.ts CHANGED
@@ -1,7 +1,8 @@
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";
package/dist/index.js CHANGED
@@ -1,7 +1,8 @@
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";
7
8
  export { ALL_CONTROLS, MINIMAL_CONTROLS } from "./controls.js";
@@ -1,5 +1,4 @@
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";
5
4
  /** Plain-language names for drawing tools and the geometry types they create. */
@@ -28,7 +27,7 @@ t = identity, }) {
28
27
  const s = useGeoreferencer(controller), d = s.document;
29
28
  if (!controller.options.digitizing)
30
29
  return null;
31
- 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_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 &&
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 &&
32
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 ||
33
32
  !controller.canSaveFeatures() ||
34
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"
@@ -18,7 +18,7 @@ navigation = true,
18
18
  displayAdjustment = true,
19
19
  /** Extra actions inside the empty drop zone, such as a sample image button. */
20
20
  emptyActions, }) {
21
- const s = useGeoreferencer(controller), image = s.document.sourceImage, svg = useRef(null);
21
+ const s = useGeoreferencer(controller), image = s.document.sourceImage, imageWidth = image?.width, svg = useRef(null);
22
22
  const view = s.imageView ?? [
23
23
  0, 0, 100, 100,
24
24
  ];
@@ -30,6 +30,10 @@ emptyActions, }) {
30
30
  ]);
31
31
  const filterId = `rg-stretch-${useId().replace(/:/g, "")}`;
32
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);
33
37
  const stretchHistogram = (local) => {
34
38
  const pixels = displayPixels.current;
35
39
  if (!pixels || !image)
@@ -97,6 +101,8 @@ emptyActions, }) {
97
101
  const ctx = canvas.getContext("2d");
98
102
  ctx.drawImage(img, 0, 0);
99
103
  displayPixels.current = ctx.getImageData(0, 0, canvas.width, canvas.height);
104
+ if (imageWidth)
105
+ setPreviewScale(img.width / imageWidth);
100
106
  const data = displayPixels.current.data, bins = Array(32).fill(0);
101
107
  for (let i = 0; i < data.length; i += 4)
102
108
  if (data[i + 3])
@@ -108,10 +114,31 @@ emptyActions, }) {
108
114
  return () => {
109
115
  disposed = true;
110
116
  displayPixels.current = null;
117
+ setPreviewScale(0);
111
118
  img.onload = null;
112
119
  img.src = "";
113
120
  };
114
- }, [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]);
115
142
  const point = (e) => {
116
143
  const matrix = svg.current?.getScreenCTM();
117
144
  if (!matrix)
@@ -240,6 +267,10 @@ emptyActions, }) {
240
267
  setDragPoint(null);
241
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: {
242
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",
243
274
  } })), s.document.gcps.map((gcp) => {
244
275
  const p = dragPoint?.id === gcp.id ? dragPoint.point : gcp.image;
245
276
  return (_jsxs("g", { transform: `translate(${p[0]} ${p[1]})`, onPointerDown: (e) => {
@@ -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.2.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.2.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",