@georeferencing/react 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +347 -2
- package/dist/Georeferencer.d.ts +58 -0
- package/dist/Georeferencer.js +278 -0
- package/dist/components/fields.d.ts +14 -0
- package/dist/components/fields.js +30 -0
- package/dist/hooks/useGeoreferencer.d.ts +6 -0
- package/dist/hooks/useGeoreferencer.js +8 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +15 -0
- package/dist/licenses/APACHE-2.0.txt +202 -0
- package/dist/licenses/GDAL-NOTICE.txt +24 -0
- package/dist/licenses/inventory.json +1 -0
- package/dist/localization.d.ts +6 -0
- package/dist/localization.js +1 -0
- package/dist/panels/AlignmentPanel.d.ts +19 -0
- package/dist/panels/AlignmentPanel.js +24 -0
- package/dist/panels/FeaturePanel.d.ts +28 -0
- package/dist/panels/FeaturePanel.js +39 -0
- package/dist/panels/GcpPanel.d.ts +17 -0
- package/dist/panels/GcpPanel.js +65 -0
- package/dist/panels/ImagePanel.d.ts +17 -0
- package/dist/panels/ImagePanel.js +240 -0
- package/dist/panels/PreviewControls.d.ts +15 -0
- package/dist/panels/PreviewControls.js +36 -0
- package/dist/panels/ReferencePanel.d.ts +17 -0
- package/dist/panels/ReferencePanel.js +24 -0
- package/dist/styles.css +440 -0
- package/dist/utils/downloadBlob.d.ts +5 -0
- package/dist/utils/downloadBlob.js +11 -0
- package/package.json +57 -3
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { GeoreferencerController } from "@georeferencing/core";
|
|
2
|
+
import type { Translate } from "../localization.js";
|
|
3
|
+
/**
|
|
4
|
+
* Model selection, fit feedback and explicit confirmation/review controls backed by the authoritative controller.
|
|
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 AlignmentPanel({
|
|
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, controls, }: {
|
|
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
|
+
/** Show transformation/preview controls; disable when already provided by a guided toolbar. */
|
|
18
|
+
controls?: boolean;
|
|
19
|
+
}): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { coordinateUnits, MODELS } from "@georeferencing/core";
|
|
3
|
+
import { useGeoreferencer } from "../hooks/useGeoreferencer.js";
|
|
4
|
+
import { identity } from "../localization.js";
|
|
5
|
+
import { PreviewControls } from "./PreviewControls.js";
|
|
6
|
+
/**
|
|
7
|
+
* Model selection, fit feedback and explicit confirmation/review controls backed by the authoritative controller.
|
|
8
|
+
*
|
|
9
|
+
* 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.
|
|
10
|
+
*/
|
|
11
|
+
export function AlignmentPanel({
|
|
12
|
+
/** Shared authoritative editor controller; the panel does not own its lifecycle. */
|
|
13
|
+
controller,
|
|
14
|
+
/** Optional translation function; defaults to returning English messages unchanged. */
|
|
15
|
+
t = identity, controls = true, }) {
|
|
16
|
+
const s = useGeoreferencer(controller), d = s.document;
|
|
17
|
+
return (_jsxs("section", { children: [_jsx("h2", { children: t("Alignment") }), controls && _jsx(PreviewControls, { controller: controller, t: t }), controls && (_jsxs("label", { children: [t("Transformation"), _jsx("select", { disabled: !d.sourceImage || s.mode === "draw", value: d.model, onChange: (e) => controller.setModel(e.target.value), children: Object.entries(MODELS).map(([key, value]) => (_jsxs("option", { value: key, children: [t(value.label), " \u00B7 ", value.minimum, "+"] }, key))) })] })), _jsx("p", { className: "rg-fit-summary", children: s.fitting === "running"
|
|
18
|
+
? t("Fitting & rendering…")
|
|
19
|
+
: s.fit
|
|
20
|
+
? `${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
|
+
? "Confirm alignment and draw"
|
|
23
|
+
: "Confirm alignment") })), d.confirmedAlignmentRevision === d.alignmentRevision && (_jsxs("p", { children: [t("Alignment confirmed"), " \u00B7 r", d.alignmentRevision] }))] }));
|
|
24
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Features, GeoreferencerController } from "@georeferencing/core";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import type { Translate } from "../localization.js";
|
|
4
|
+
/**
|
|
5
|
+
* Opt-in digitizing controls, feature deletion and host property editing. Returns null when digitizing is disabled; controller.save performs explicit persistence.
|
|
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 declare function FeaturePanel({
|
|
10
|
+
/** Shared authoritative editor controller; the panel does not own its lifecycle. */
|
|
11
|
+
controller,
|
|
12
|
+
/**
|
|
13
|
+
* Render a property editor for the frozen feature. Use update to commit replacement
|
|
14
|
+
* JSON properties without mutating feature identity or geometry.
|
|
15
|
+
*/
|
|
16
|
+
propertyEditor,
|
|
17
|
+
/** Optional translation function; defaults to returning English messages unchanged. */
|
|
18
|
+
t, }: {
|
|
19
|
+
/** Shared authoritative editor controller; the panel does not own its lifecycle. */
|
|
20
|
+
controller: GeoreferencerController;
|
|
21
|
+
/**
|
|
22
|
+
* Render a property editor for the frozen feature. Use update to commit replacement
|
|
23
|
+
* JSON properties without mutating feature identity or geometry.
|
|
24
|
+
*/
|
|
25
|
+
propertyEditor?: (feature: Features["features"][number], update: (properties: Record<string, unknown>) => void) => ReactNode;
|
|
26
|
+
/** Optional translation function; defaults to returning English messages unchanged. */
|
|
27
|
+
t?: Translate;
|
|
28
|
+
}): import("react/jsx-runtime").JSX.Element | null;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { validateFeatures } from "@georeferencing/core";
|
|
3
|
+
import { useGeoreferencer } from "../hooks/useGeoreferencer.js";
|
|
4
|
+
import { identity } from "../localization.js";
|
|
5
|
+
/**
|
|
6
|
+
* Opt-in digitizing controls, feature deletion and host property editing. Returns null when digitizing is disabled; controller.save performs explicit persistence.
|
|
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 FeaturePanel({
|
|
11
|
+
/** Shared authoritative editor controller; the panel does not own its lifecycle. */
|
|
12
|
+
controller,
|
|
13
|
+
/**
|
|
14
|
+
* Render a property editor for the frozen feature. Use update to commit replacement
|
|
15
|
+
* JSON properties without mutating feature identity or geometry.
|
|
16
|
+
*/
|
|
17
|
+
propertyEditor,
|
|
18
|
+
/** Optional translation function; defaults to returning English messages unchanged. */
|
|
19
|
+
t = identity, }) {
|
|
20
|
+
const s = useGeoreferencer(controller), d = s.document;
|
|
21
|
+
if (!controller.options.digitizing)
|
|
22
|
+
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 &&
|
|
28
|
+
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
|
+
!controller.canSaveFeatures() ||
|
|
30
|
+
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
|
+
? t(s.dirty
|
|
32
|
+
? "Snapshot saved; newer changes remain unsaved."
|
|
33
|
+
: "Saved by host.")
|
|
34
|
+
: s.saving === "failed"
|
|
35
|
+
? t("Save failed. Draft retained; retry when ready.")
|
|
36
|
+
: !controller.options.onSave
|
|
37
|
+
? t("No host save adapter configured. Session export remains available.")
|
|
38
|
+
: t("Saving features does not require raster export.") })] }));
|
|
39
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { GeoreferencerController } from "@georeferencing/core";
|
|
2
|
+
import type { Translate } from "../localization.js";
|
|
3
|
+
/**
|
|
4
|
+
* 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.
|
|
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 GcpPanel({
|
|
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;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { NumberField } from "../components/fields.js";
|
|
4
|
+
import { useGeoreferencer } from "../hooks/useGeoreferencer.js";
|
|
5
|
+
import { identity } from "../localization.js";
|
|
6
|
+
/**
|
|
7
|
+
* 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
|
+
*
|
|
9
|
+
* 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.
|
|
10
|
+
*/
|
|
11
|
+
export function GcpPanel({
|
|
12
|
+
/** Shared authoritative editor controller; the panel does not own its lifecycle. */
|
|
13
|
+
controller,
|
|
14
|
+
/** Optional translation function; defaults to returning English messages unchanged. */
|
|
15
|
+
t = identity, }) {
|
|
16
|
+
const s = useGeoreferencer(controller), [entry, setEntry] = useState([0, 0, 0, 0]);
|
|
17
|
+
const locked = s.mode !== "align" || !s.document.sourceImage;
|
|
18
|
+
const edit = (p, index, value) => {
|
|
19
|
+
const image = [...p.image], target = [...p.target];
|
|
20
|
+
if (index < 2)
|
|
21
|
+
image[index] = value;
|
|
22
|
+
else
|
|
23
|
+
target[index - 2] = value;
|
|
24
|
+
controller.updateGcp(p.id, { image, target });
|
|
25
|
+
};
|
|
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: [
|
|
29
|
+
"Use",
|
|
30
|
+
"#",
|
|
31
|
+
"Image X",
|
|
32
|
+
"Image Y",
|
|
33
|
+
"Target X",
|
|
34
|
+
"Target Y",
|
|
35
|
+
"Target CRS",
|
|
36
|
+
"Residual",
|
|
37
|
+
"Pixels",
|
|
38
|
+
"",
|
|
39
|
+
].map((h) => (_jsx("th", { scope: "col", children: t(h) }, h))) }) }), _jsx("tbody", { children: s.document.gcps.map((p) => {
|
|
40
|
+
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
|
|
45
|
+
? s.pendingImagePoint[i]
|
|
46
|
+
: entry[i], change: (n) => {
|
|
47
|
+
if (i < 2 && s.pendingImagePoint) {
|
|
48
|
+
const p = [...s.pendingImagePoint];
|
|
49
|
+
p[i] = n;
|
|
50
|
+
controller.setPendingPoint(p);
|
|
51
|
+
}
|
|
52
|
+
else
|
|
53
|
+
setEntry((v) => v.map((x, j) => (i === j ? n : x)));
|
|
54
|
+
} })] }, label))) }), _jsxs("p", { children: [t("Target CRS"), ": ", s.document.workingCrs] }), _jsx("button", { type: "button", disabled: locked, onClick: () => {
|
|
55
|
+
try {
|
|
56
|
+
controller.addGcp(s.pendingImagePoint ?? [entry[0], entry[1]], [
|
|
57
|
+
entry[2],
|
|
58
|
+
entry[3],
|
|
59
|
+
]);
|
|
60
|
+
}
|
|
61
|
+
catch (e) {
|
|
62
|
+
controller.reportError(e);
|
|
63
|
+
}
|
|
64
|
+
}, children: t("Add coordinate pair") })] })] }));
|
|
65
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { GeoreferencerController } from "@georeferencing/core";
|
|
2
|
+
import type { Translate } from "../localization.js";
|
|
3
|
+
/**
|
|
4
|
+
* Image selection/drop, independent image navigation and source GCP picking. Display stretch affects the image panel only; it does not change export pixels.
|
|
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 ImagePanel({
|
|
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;
|
|
@@ -0,0 +1,240 @@
|
|
|
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
|
+
const s = useGeoreferencer(controller), image = s.document.sourceImage, svg = useRef(null);
|
|
16
|
+
const view = s.imageView ?? [
|
|
17
|
+
0, 0, 100, 100,
|
|
18
|
+
];
|
|
19
|
+
const setView = (next) => controller.setImageView(typeof next === "function" ? next(view) : next);
|
|
20
|
+
const [brightness, setBrightness] = useState(1), [contrast, setContrast] = useState(1), [histogram, setHistogram] = useState([]), [stretch, setStretch] = useState([
|
|
21
|
+
[0, 255],
|
|
22
|
+
[0, 255],
|
|
23
|
+
[0, 255],
|
|
24
|
+
]);
|
|
25
|
+
const filterId = `rg-stretch-${useId().replace(/:/g, "")}`;
|
|
26
|
+
const displayPixels = useRef(null);
|
|
27
|
+
const stretchHistogram = (local) => {
|
|
28
|
+
const pixels = displayPixels.current;
|
|
29
|
+
if (!pixels || !image)
|
|
30
|
+
return;
|
|
31
|
+
const x0 = local
|
|
32
|
+
? Math.max(0, Math.floor((view[0] * pixels.width) / image.width))
|
|
33
|
+
: 0, y0 = local
|
|
34
|
+
? Math.max(0, Math.floor((view[1] * pixels.height) / image.height))
|
|
35
|
+
: 0, x1 = local
|
|
36
|
+
? Math.min(pixels.width, Math.ceil(((view[0] + view[2]) * pixels.width) / image.width))
|
|
37
|
+
: pixels.width, y1 = local
|
|
38
|
+
? Math.min(pixels.height, Math.ceil(((view[1] + view[3]) * pixels.height) / image.height))
|
|
39
|
+
: pixels.height;
|
|
40
|
+
const ranges = [
|
|
41
|
+
[255, 0],
|
|
42
|
+
[255, 0],
|
|
43
|
+
[255, 0],
|
|
44
|
+
], bins = Array(32).fill(0);
|
|
45
|
+
for (let y = y0; y < y1; y++)
|
|
46
|
+
for (let x = x0; x < x1; x++) {
|
|
47
|
+
const i = (y * pixels.width + x) * 4;
|
|
48
|
+
if (!pixels.data[i + 3])
|
|
49
|
+
continue;
|
|
50
|
+
for (let c = 0; c < 3; c++) {
|
|
51
|
+
ranges[c][0] = Math.min(ranges[c][0], pixels.data[i + c]);
|
|
52
|
+
ranges[c][1] = Math.max(ranges[c][1], pixels.data[i + c]);
|
|
53
|
+
}
|
|
54
|
+
bins[Math.min(31, Math.floor((pixels.data[i] + pixels.data[i + 1] + pixels.data[i + 2]) / 24))]++;
|
|
55
|
+
}
|
|
56
|
+
setStretch(ranges.map(([lo, hi]) => (hi > lo ? [lo, hi] : [0, 255])));
|
|
57
|
+
setHistogram(bins);
|
|
58
|
+
};
|
|
59
|
+
const drag = useRef(null);
|
|
60
|
+
const [dragPoint, setDragPoint] = useState(null);
|
|
61
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: Reset only when the immutable source image ID changes, not on document clones during edits.
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (image)
|
|
64
|
+
controller.setImageView([
|
|
65
|
+
-image.width * 0.04,
|
|
66
|
+
-image.height * 0.06,
|
|
67
|
+
image.width * 1.08,
|
|
68
|
+
image.height * 1.12,
|
|
69
|
+
]);
|
|
70
|
+
setBrightness(1);
|
|
71
|
+
setContrast(1);
|
|
72
|
+
setStretch([
|
|
73
|
+
[0, 255],
|
|
74
|
+
[0, 255],
|
|
75
|
+
[0, 255],
|
|
76
|
+
]);
|
|
77
|
+
}, [image?.id, controller]);
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (!s.imageUrl) {
|
|
80
|
+
setHistogram([]);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
let disposed = false;
|
|
84
|
+
const img = new Image();
|
|
85
|
+
img.onload = () => {
|
|
86
|
+
if (disposed)
|
|
87
|
+
return;
|
|
88
|
+
const canvas = document.createElement("canvas");
|
|
89
|
+
canvas.width = img.width;
|
|
90
|
+
canvas.height = img.height;
|
|
91
|
+
const ctx = canvas.getContext("2d");
|
|
92
|
+
ctx.drawImage(img, 0, 0);
|
|
93
|
+
displayPixels.current = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
|
94
|
+
const data = displayPixels.current.data, bins = Array(32).fill(0);
|
|
95
|
+
for (let i = 0; i < data.length; i += 4)
|
|
96
|
+
if (data[i + 3])
|
|
97
|
+
bins[Math.min(31, Math.floor((data[i] + data[i + 1] + data[i + 2]) / 24))]++;
|
|
98
|
+
setHistogram(bins);
|
|
99
|
+
canvas.width = 0;
|
|
100
|
+
};
|
|
101
|
+
img.src = s.imageUrl;
|
|
102
|
+
return () => {
|
|
103
|
+
disposed = true;
|
|
104
|
+
displayPixels.current = null;
|
|
105
|
+
img.onload = null;
|
|
106
|
+
img.src = "";
|
|
107
|
+
};
|
|
108
|
+
}, [s.imageUrl]);
|
|
109
|
+
const point = (e) => {
|
|
110
|
+
const matrix = svg.current?.getScreenCTM();
|
|
111
|
+
if (!matrix)
|
|
112
|
+
return [0, 0];
|
|
113
|
+
const p = new DOMPoint(e.clientX, e.clientY).matrixTransform(matrix.inverse());
|
|
114
|
+
return [p.x, p.y];
|
|
115
|
+
};
|
|
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
|
+
]);
|
|
122
|
+
const down = (e) => {
|
|
123
|
+
if (!image)
|
|
124
|
+
return;
|
|
125
|
+
e.preventDefault();
|
|
126
|
+
e.currentTarget.focus({ preventScroll: true });
|
|
127
|
+
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
|
+
}
|
|
133
|
+
drag.current = {
|
|
134
|
+
start: [e.clientX, e.clientY],
|
|
135
|
+
view,
|
|
136
|
+
pointerId: e.pointerId,
|
|
137
|
+
};
|
|
138
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
139
|
+
};
|
|
140
|
+
return (_jsxs("section", { className: "rg-image-panel", "aria-label": t("Source image"), onDragOver: (e) => e.preventDefault(), onDrop: (e) => {
|
|
141
|
+
e.preventDefault();
|
|
142
|
+
const file = e.dataTransfer.files[0];
|
|
143
|
+
if (file)
|
|
144
|
+
void controller.loadImage(file);
|
|
145
|
+
}, 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) => {
|
|
146
|
+
const file = e.target.files?.[0];
|
|
147
|
+
if (file)
|
|
148
|
+
void controller.loadImage(file);
|
|
149
|
+
e.target.value = "";
|
|
150
|
+
} })] })] }), 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",
|
|
151
|
+
// biome-ignore lint/a11y/noNoninteractiveTabindex: This image viewer supports focused arrow-key panning and +/- zoom; numeric inputs provide GCP editing.
|
|
152
|
+
tabIndex: 0, onKeyDown: (e) => {
|
|
153
|
+
const steps = {
|
|
154
|
+
ArrowLeft: [-0.1, 0],
|
|
155
|
+
ArrowRight: [0.1, 0],
|
|
156
|
+
ArrowUp: [0, -0.1],
|
|
157
|
+
ArrowDown: [0, 0.1],
|
|
158
|
+
};
|
|
159
|
+
if (steps[e.key]) {
|
|
160
|
+
e.preventDefault();
|
|
161
|
+
const [x, y] = steps[e.key];
|
|
162
|
+
setView([
|
|
163
|
+
view[0] + x * view[2],
|
|
164
|
+
view[1] + y * view[3],
|
|
165
|
+
view[2],
|
|
166
|
+
view[3],
|
|
167
|
+
]);
|
|
168
|
+
}
|
|
169
|
+
if (e.key === "+" || e.key === "=") {
|
|
170
|
+
e.preventDefault();
|
|
171
|
+
zoom(0.8);
|
|
172
|
+
}
|
|
173
|
+
if (e.key === "-") {
|
|
174
|
+
e.preventDefault();
|
|
175
|
+
zoom(1.25);
|
|
176
|
+
}
|
|
177
|
+
}, "aria-label": t("Image control point viewer; use numeric table for keyboard placement"), viewBox: view.join(" "), onPointerDown: down, onPointerMove: (e) => {
|
|
178
|
+
const d = drag.current;
|
|
179
|
+
if (!d)
|
|
180
|
+
return;
|
|
181
|
+
if (d.id) {
|
|
182
|
+
d.point = point(e);
|
|
183
|
+
setDragPoint({ id: d.id, point: d.point });
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
const scale = Math.max(view[2] / e.currentTarget.clientWidth, view[3] / e.currentTarget.clientHeight);
|
|
187
|
+
setView([
|
|
188
|
+
d.view[0] - (e.clientX - d.start[0]) * scale,
|
|
189
|
+
d.view[1] - (e.clientY - d.start[1]) * scale,
|
|
190
|
+
d.view[2],
|
|
191
|
+
d.view[3],
|
|
192
|
+
]);
|
|
193
|
+
}
|
|
194
|
+
}, onPointerUp: (e) => {
|
|
195
|
+
const d = drag.current;
|
|
196
|
+
drag.current = null;
|
|
197
|
+
if (d?.id && d.point)
|
|
198
|
+
controller.updateGcp(d.id, { image: d.point });
|
|
199
|
+
setDragPoint(null);
|
|
200
|
+
if (e.currentTarget.hasPointerCapture(e.pointerId))
|
|
201
|
+
e.currentTarget.releasePointerCapture(e.pointerId);
|
|
202
|
+
}, onPointerCancel: () => {
|
|
203
|
+
drag.current = null;
|
|
204
|
+
setDragPoint(null);
|
|
205
|
+
}, onWheel: (e) => {
|
|
206
|
+
zoom(e.deltaY > 0 ? 1.15 : 1 / 1.15, point(e));
|
|
207
|
+
}, 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
|
+
filter: `url(#${filterId}) brightness(${brightness}) contrast(${contrast})`,
|
|
209
|
+
} })), s.document.gcps.map((gcp) => {
|
|
210
|
+
const p = dragPoint?.id === gcp.id ? dragPoint.point : gcp.image;
|
|
211
|
+
return (_jsxs("g", { transform: `translate(${p[0]} ${p[1]})`, onPointerDown: (e) => {
|
|
212
|
+
if (s.mode !== "align")
|
|
213
|
+
return;
|
|
214
|
+
e.stopPropagation();
|
|
215
|
+
e.preventDefault();
|
|
216
|
+
svg.current?.focus({ preventScroll: true });
|
|
217
|
+
drag.current = {
|
|
218
|
+
id: gcp.id,
|
|
219
|
+
start: p,
|
|
220
|
+
point: p,
|
|
221
|
+
view,
|
|
222
|
+
pointerId: e.pointerId,
|
|
223
|
+
};
|
|
224
|
+
svg.current.setPointerCapture(e.pointerId);
|
|
225
|
+
}, 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));
|
|
226
|
+
}), 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([
|
|
227
|
+
-image.width * 0.04,
|
|
228
|
+
-image.height * 0.06,
|
|
229
|
+
image.width * 1.08,
|
|
230
|
+
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: () => {
|
|
232
|
+
setStretch([
|
|
233
|
+
[0, 255],
|
|
234
|
+
[0, 255],
|
|
235
|
+
[0, 255],
|
|
236
|
+
]);
|
|
237
|
+
setBrightness(1);
|
|
238
|
+
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") })] }))] }));
|
|
240
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
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, }: {
|
|
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
|
+
}): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,36 @@
|
|
|
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, }) {
|
|
11
|
+
const s = useGeoreferencer(controller), d = s.document;
|
|
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 ||
|
|
15
|
+
!!s.pendingImagePoint ||
|
|
16
|
+
missing > 0 ||
|
|
17
|
+
s.fitting === "running" ||
|
|
18
|
+
s.mode === "draw", onClick: async () => {
|
|
19
|
+
const id = d.id, revision = d.alignmentRevision;
|
|
20
|
+
if (!s.fit || s.previewMode === "manual")
|
|
21
|
+
await controller.refit();
|
|
22
|
+
const current = controller.getSnapshot();
|
|
23
|
+
if (current.document.id === id &&
|
|
24
|
+
current.fitRevision === revision &&
|
|
25
|
+
current.fit) {
|
|
26
|
+
controller.setTool("navigate");
|
|
27
|
+
onReview?.();
|
|
28
|
+
}
|
|
29
|
+
}, children: t(s.fitting === "running"
|
|
30
|
+
? "Running alignment…"
|
|
31
|
+
: s.fit && s.previewMode === "automatic"
|
|
32
|
+
? "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")}`] })] })] }));
|
|
36
|
+
}
|
|
@@ -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
|
+
}
|