diff --git a/reactapp/__tests__/components/dashboard/DashboardItem.test.js b/reactapp/__tests__/components/dashboard/DashboardItem.test.js
index 3c2ad5f0..721a45f2 100644
--- a/reactapp/__tests__/components/dashboard/DashboardItem.test.js
+++ b/reactapp/__tests__/components/dashboard/DashboardItem.test.js
@@ -5,7 +5,6 @@ import {
within,
fireEvent,
waitFor,
- cleanup,
} from "@testing-library/react";
import DashboardItem, {
handleGridItemExport,
@@ -1656,97 +1655,6 @@ test("Dashboard attribution and not show", async () => {
).not.toBeInTheDocument();
});
-// jsdom's computed style does not resolve :has(), so the raise cannot be read
-// back through getComputedStyle. Inspecting the injected rule is the next best
-// thing: it pins that the rule ships, what it raises to, and that it is scoped to
-// the fill-viewport branch rather than applied to every grid item.
-const injectedCss = () =>
- Array.from(document.styleSheets)
- .flatMap((sheet) => {
- try {
- return Array.from(sheet.cssRules).map((rule) => rule.cssText);
- } catch {
- return [];
- }
- })
- .join("\n");
-
-const raiseRule = () =>
- injectedCss()
- .split("\n")
- .find((rule) => rule.includes('data-map-control-open="true"'));
-
-// The class styled-components generated for the fill-viewport block. Asserting
-// class membership on the element is order-independent, unlike asserting the rule
-// is absent from the stylesheet -- styled-components keeps injected rules for the
-// whole test file, so a rule from an earlier test is still present.
-const raiseRuleClass = () => {
- const rule = raiseRule();
- const match = rule && rule.match(/^\.([\w-]+)/);
- return match ? match[1] : null;
-};
-
-const renderGridItem = ({ fillViewport }) => {
- const mockedDashboard = JSON.parse(JSON.stringify(userDashboard));
- const gridItem = mockedDashboard.tabs[0].gridItems[0];
- gridItem.metadata_string = JSON.stringify(
- fillViewport ? { fillViewport: true } : {},
- );
-
- return render(
- createLoadedComponent({
- children: (
-
-
-
- ),
- options: { initialDashboard: mockedDashboard },
- }),
- );
-};
-
-test("Dashboard Item fill viewport raises the tile for an open map control", async () => {
- // position:fixed seals the item into its own stacking context, so a map's
- // legend or layer control cannot paint above a later grid item on its own.
- renderGridItem({ fillViewport: true });
- const item = await screen.findByLabelText("gridItemDiv");
-
- const rule = raiseRule();
- expect(rule).toBeDefined();
- expect(rule).toMatch(/z-index:\s*1029/);
- // Below the fixed header and every modal layer, so a modal still covers the map.
- expect(rule).not.toMatch(/z-index:\s*10[4-9]\d/);
- // And the rule actually applies to this item.
- expect(item.classList.contains(raiseRuleClass())).toBe(true);
-});
-
-test("Dashboard Item without fill viewport is not covered by the raise rule", async () => {
- // A non-fill item is position:relative / z-index:auto, so it is not a stacking
- // context and the control's own z-index already escapes. Scoping the rule to
- // the fill branch keeps that path untouched.
- renderGridItem({ fillViewport: true });
- await screen.findByLabelText("gridItemDiv");
- const fillClass = raiseRuleClass();
- expect(fillClass).not.toBeNull();
- cleanup();
-
- renderGridItem({ fillViewport: false });
- const item = await screen.findByLabelText("gridItemDiv");
- expect(window.getComputedStyle(item).getPropertyValue("position")).toBe(
- "relative",
- );
- expect(item.classList.contains(fillClass)).toBe(false);
-});
-
test("Dashboard Item fill viewport fills the content area in view mode", async () => {
const mockedDashboard = JSON.parse(JSON.stringify(userDashboard));
const gridItem = mockedDashboard.tabs[0].gridItems[0];
diff --git a/reactapp/__tests__/components/map/FloatingMapControl.test.js b/reactapp/__tests__/components/map/FloatingMapControl.test.js
new file mode 100644
index 00000000..4ec870bb
--- /dev/null
+++ b/reactapp/__tests__/components/map/FloatingMapControl.test.js
@@ -0,0 +1,210 @@
+import { render, screen, act } from "@testing-library/react";
+import FloatingMapControl, {
+ FLOATING_CONTROL_Z_INDEX,
+ styleFromAnchor,
+} from "components/map/FloatingMapControl";
+
+// jsdom does no layout, so every rect is stubbed. These tests pin the mapping
+// from anchor rect to fixed-position style and the escape from the parent tree;
+// they cannot prove paint order.
+const VIEWPORT = { width: 1000, height: 800 };
+
+const stubRect = (rect) =>
+ jest
+ .spyOn(Element.prototype, "getBoundingClientRect")
+ .mockReturnValue({ ...rect, toJSON: () => ({}) });
+
+beforeEach(() => {
+ window.innerWidth = VIEWPORT.width;
+ window.innerHeight = VIEWPORT.height;
+});
+
+afterEach(() => {
+ jest.restoreAllMocks();
+});
+
+describe("styleFromAnchor", () => {
+ // A bottom-left anchor collapses to a point once its content is portalled
+ // away, so only left/bottom carry meaning -- right/top are the same point and
+ // say nothing about the control's size.
+ test("bottom-left pins the corner and leaves size to the content", () => {
+ const style = styleFromAnchor(
+ { left: 16, right: 16, top: 700, bottom: 700, width: 0, height: 0 },
+ ["bottom", "left"],
+ VIEWPORT,
+ );
+ expect(style).toEqual({ left: "16px", bottom: "100px" });
+ });
+
+ test("bottom-right measures from the far edges", () => {
+ const style = styleFromAnchor(
+ { left: 984, right: 984, top: 700, bottom: 700, width: 0, height: 0 },
+ ["bottom", "right"],
+ VIEWPORT,
+ );
+ expect(style).toEqual({ right: "16px", bottom: "100px" });
+ });
+
+ test("pinned on both sides carries the width across", () => {
+ // The alert spans the map, so the floated copy must not shrink to content.
+ const style = styleFromAnchor(
+ { left: 16, right: 984, top: 16, bottom: 16, width: 968, height: 0 },
+ ["top", "left", "right"],
+ VIEWPORT,
+ );
+ expect(style).toEqual({ left: "16px", top: "16px", width: "968px" });
+ expect(style.right).toBeUndefined();
+ });
+
+ test("no rect yields no style", () => {
+ expect(styleFromAnchor(null, ["bottom", "left"], VIEWPORT)).toBeNull();
+ });
+});
+
+describe("FloatingMapControl", () => {
+ test("renders its children outside the parent tree", () => {
+ stubRect({
+ left: 16,
+ right: 16,
+ top: 700,
+ bottom: 700,
+ width: 0,
+ height: 0,
+ });
+ render(
+
+
+
+
+
,
+ );
+
+ const control = screen.getByRole("button", { name: "Show Legend" });
+ expect(control).toBeInTheDocument();
+ // The whole point: it must not be a descendant of the tile, or it stays
+ // sealed inside that tile's stacking context.
+ expect(screen.getByTestId("map-tile")).not.toContainElement(control);
+ expect(document.body).toContainElement(control);
+ });
+
+ test("positions the floated copy from the anchor's rect", () => {
+ stubRect({
+ left: 16,
+ right: 16,
+ top: 700,
+ bottom: 700,
+ width: 0,
+ height: 0,
+ });
+ render(
+
+ content
+ ,
+ );
+
+ const floated = screen.getByTestId("floating-map-control");
+ expect(floated).toHaveStyle({
+ position: "fixed",
+ left: "16px",
+ bottom: "100px",
+ });
+ expect(floated).toHaveStyle({ zIndex: String(FLOATING_CONTROL_Z_INDEX) });
+ });
+
+ test("repositions when the window resizes", () => {
+ const rect = stubRect({
+ left: 16,
+ right: 16,
+ top: 700,
+ bottom: 700,
+ width: 0,
+ height: 0,
+ });
+ render(
+
+ content
+ ,
+ );
+ expect(screen.getByTestId("floating-map-control")).toHaveStyle({
+ bottom: "100px",
+ });
+
+ // The map got shorter: same anchor offset from the bottom, different
+ // viewport, so the computed `bottom` has to change.
+ rect.mockReturnValue({
+ left: 16,
+ right: 16,
+ top: 500,
+ bottom: 500,
+ width: 0,
+ height: 0,
+ toJSON: () => ({}),
+ });
+ window.innerHeight = 600;
+ act(() => {
+ window.dispatchEvent(new Event("resize"));
+ });
+
+ expect(screen.getByTestId("floating-map-control")).toHaveStyle({
+ bottom: "100px",
+ left: "16px",
+ });
+ });
+
+ test("removes its listeners and observer on unmount", () => {
+ stubRect({
+ left: 16,
+ right: 16,
+ top: 700,
+ bottom: 700,
+ width: 0,
+ height: 0,
+ });
+ const addSpy = jest.spyOn(window, "addEventListener");
+ const removeSpy = jest.spyOn(window, "removeEventListener");
+ const disconnect = jest.fn();
+ const observe = jest.fn();
+ const original = global.ResizeObserver;
+ global.ResizeObserver = jest.fn(() => ({ observe, disconnect }));
+
+ const { unmount } = render(
+
+ content
+ ,
+ );
+ expect(addSpy).toHaveBeenCalledWith("resize", expect.any(Function));
+ expect(addSpy).toHaveBeenCalledWith("scroll", expect.any(Function), true);
+
+ unmount();
+ expect(removeSpy).toHaveBeenCalledWith("resize", expect.any(Function));
+ expect(removeSpy).toHaveBeenCalledWith(
+ "scroll",
+ expect.any(Function),
+ true,
+ );
+ global.ResizeObserver = original;
+ });
+
+ test("the anchor stays behind and is inert", () => {
+ stubRect({
+ left: 16,
+ right: 16,
+ top: 700,
+ bottom: 700,
+ width: 0,
+ height: 0,
+ });
+ render(
+
+ content
+ ,
+ );
+
+ // The caller's positioning CSS rides on the anchor, so it has to remain in
+ // place rather than move to the portal.
+ const anchor = screen.getByTestId("floating-map-control-anchor");
+ expect(anchor).toHaveClass("anchor-class");
+ expect(anchor).toHaveAttribute("aria-hidden", "true");
+ expect(anchor).toBeEmptyDOMElement();
+ });
+});
diff --git a/reactapp/__tests__/components/map/LayersControl.test.js b/reactapp/__tests__/components/map/LayersControl.test.js
index daffb54c..02fe2b27 100644
--- a/reactapp/__tests__/components/map/LayersControl.test.js
+++ b/reactapp/__tests__/components/map/LayersControl.test.js
@@ -251,24 +251,3 @@ describe("parseProgress", () => {
expect(parseProgress(message)).toBeNull();
});
});
-
-test("LayersControl flags itself only while expanded", async () => {
- render(
- ,
- );
-
- expect(screen.getByLabelText("Layers Control")).not.toHaveAttribute(
- "data-map-control-open",
- );
-
- fireEvent.click(await screen.findByLabelText("Show Layers Control"));
- expect(screen.getByLabelText("Layers Control")).toHaveAttribute(
- "data-map-control-open",
- "true",
- );
-
- fireEvent.click(await screen.findByLabelText("Close Layers Control"));
- expect(screen.getByLabelText("Layers Control")).not.toHaveAttribute(
- "data-map-control-open",
- );
-});
diff --git a/reactapp/__tests__/components/map/Legend.test.js b/reactapp/__tests__/components/map/Legend.test.js
index a0af313d..42f04a0c 100644
--- a/reactapp/__tests__/components/map/Legend.test.js
+++ b/reactapp/__tests__/components/map/Legend.test.js
@@ -44,21 +44,3 @@ test("LegendControl", async () => {
fireEvent.click(closeLegendButton);
expect(screen.queryByText("Some New Title")).not.toBeInTheDocument();
});
-
-test("LegendControl flags itself only while expanded", async () => {
- // The flag is what DashboardItem's fill-viewport rule keys off to raise the
- // whole tile. Raising the tile is the only lever available: position:fixed
- // makes it a stacking context, so no z-index on the control can escape it.
- render();
-
- fireEvent.click(await screen.findByLabelText("Show Legend Control"));
- expect(await screen.findByLabelText("Legend Control")).toHaveAttribute(
- "data-map-control-open",
- "true",
- );
-
- fireEvent.click(await screen.findByLabelText("Close Legend Control"));
- expect(screen.getByLabelText("Legend Control")).not.toHaveAttribute(
- "data-map-control-open",
- );
-});
diff --git a/reactapp/__tests__/components/map/Map.test.js b/reactapp/__tests__/components/map/Map.test.js
index 4a005675..837274d3 100644
--- a/reactapp/__tests__/components/map/Map.test.js
+++ b/reactapp/__tests__/components/map/Map.test.js
@@ -1545,6 +1545,31 @@ describe("WebGLTile ramp-style render path (Unit 7)", () => {
zIndex: 0,
},
},
+ // A vector layer alongside it: its features are parsed into the map's
+ // projection when added, so the auto-fit has to move them with the view
+ // or they are left holding the outgoing projection's numbers.
+ {
+ type: "VectorLayer",
+ props: {
+ name: "Vector Alongside",
+ zIndex: 1,
+ source: {
+ type: "GeoJSON",
+ props: {},
+ geojson: {
+ type: "FeatureCollection",
+ crs: { type: "name", properties: { name: "EPSG:4326" } },
+ features: [
+ {
+ type: "Feature",
+ properties: {},
+ geometry: { type: "Point", coordinates: [-90.54, 14.48] },
+ },
+ ],
+ },
+ },
+ },
+ },
];
render(
@@ -1573,6 +1598,33 @@ describe("WebGLTile ramp-style render path (Unit 7)", () => {
?.getCode();
expect(projCode).toBe("EPSG:4326");
});
+
+ // And the vector features moved with it. Parsed into EPSG:3857 when the
+ // layer was added, they would otherwise still hold metres (~-1e7) while the
+ // view now reads degrees -- far off screen, which is what stranded the
+ // dynamic layers on a dashboard whose rasters are UTM.
+ const findVector = () =>
+ capturedRef.current
+ .getLayers()
+ .getArray()
+ .find((l) => l.get("name") === "Vector Alongside");
+ await waitFor(() => {
+ expect(findVector()).toBeDefined();
+ });
+ await waitFor(() => {
+ const [x] = findVector()
+ .getSource()
+ .getFeatures()[0]
+ .getGeometry()
+ .getCoordinates();
+ expect(Math.abs(x - -90.54)).toBeLessThan(0.01);
+ });
+ const [, y] = findVector()
+ .getSource()
+ .getFeatures()[0]
+ .getGeometry()
+ .getCoordinates();
+ expect(Math.abs(y - 14.48)).toBeLessThan(0.01);
});
test("WebGLTile layer without a style does not apply a ramp expression or call applyStyle", async () => {
@@ -2201,6 +2253,15 @@ describe("WebGLTile ramp-style render path (Unit 7)", () => {
expect(await screen.findByText("Map Ready")).toBeInTheDocument();
expect(await screen.findByLabelText("Map Legend")).toBeInTheDocument();
+
+ // The control itself must sit outside the map div. A fill-viewport tile is
+ // position:fixed, which seals its subtree into a stacking context that no
+ // descendant z-index can escape -- so a control rendered inside the map
+ // cannot paint above a grid item overlapping it, whatever its z-index.
+ const mapDiv = await screen.findByLabelText("Map Div");
+ const control = await screen.findByLabelText("Show Legend Control");
+ expect(mapDiv).not.toContainElement(control);
+ expect(document.body).toContainElement(control);
});
test("Auto-fit skips inner extent block when clampedPrev is non-finite", async () => {
diff --git a/reactapp/__tests__/components/map/utilities.test.js b/reactapp/__tests__/components/map/utilities.test.js
index e5ba6b64..4528bad6 100644
--- a/reactapp/__tests__/components/map/utilities.test.js
+++ b/reactapp/__tests__/components/map/utilities.test.js
@@ -1,4 +1,5 @@
import {
+ reprojectVectorFeatures,
createMarkerLayer,
createHighlightLayer,
addHighlightFeatures,
@@ -21,6 +22,7 @@ import {
coerceOptionalNumber,
} from "components/map/utilities";
import VectorSource from "ol/source/Vector.js";
+import Feature from "ol/Feature.js";
import { LineString, Point, MultiPolygon, Polygon } from "ol/geom";
import VectorLayer from "ol/layer/Vector.js";
import {
@@ -4087,3 +4089,102 @@ describe("shiftEPSG3857ExtentAndPoint", () => {
expect(newCenterX).toBeLessThan(MERCATOR_HALF_WORLD);
});
});
+
+describe("reprojectVectorFeatures", () => {
+ // Features are parsed into the map's projection when added, so a later change
+ // of view projection leaves them holding the old projection's numbers. A
+ // GeoTIFF auto-fit does exactly that: adopting a UTM raster's projection left
+ // Web Mercator coordinates being read as UTM metres, stranding the dynamic
+ // layers off screen while the layer still reported the right feature count.
+ const SANTA_INES_3857 = [-10078522, 1629726];
+ const SANTA_INES_32615 = [765498, 1602608];
+
+ const mapWith = (layers) => ({
+ getLayers: () => ({ getArray: () => layers }),
+ });
+
+ const vectorLayer = (coords) => {
+ const source = new VectorSource({
+ features: coords.map((c) => new Feature(new Point(c))),
+ });
+ return { getSource: () => source, _source: source };
+ };
+
+ test("moves features from the old projection into the new one", () => {
+ const layer = vectorLayer([SANTA_INES_3857]);
+ const moved = reprojectVectorFeatures(
+ mapWith([layer]),
+ "EPSG:3857",
+ "EPSG:32615",
+ );
+
+ expect(moved).toBe(1);
+ const [x, y] = layer._source
+ .getFeatures()[0]
+ .getGeometry()
+ .getCoordinates();
+ // Within a metre of the known UTM position of the site.
+ expect(Math.abs(x - SANTA_INES_32615[0])).toBeLessThan(1);
+ expect(Math.abs(y - SANTA_INES_32615[1])).toBeLessThan(1);
+ });
+
+ test("is a no-op when the projection has not changed", () => {
+ const layer = vectorLayer([SANTA_INES_3857]);
+ const moved = reprojectVectorFeatures(
+ mapWith([layer]),
+ "EPSG:3857",
+ "EPSG:3857",
+ );
+
+ expect(moved).toBe(0);
+ expect(
+ layer._source.getFeatures()[0].getGeometry().getCoordinates(),
+ ).toEqual(SANTA_INES_3857);
+ });
+
+ test("leaves tile layers alone", () => {
+ // A tile source has no getFeatures; touching it would throw.
+ const tileLayer = { getSource: () => ({ getTileGrid: () => ({}) }) };
+ const layer = vectorLayer([SANTA_INES_3857]);
+
+ expect(() =>
+ reprojectVectorFeatures(
+ mapWith([tileLayer, layer]),
+ "EPSG:3857",
+ "EPSG:32615",
+ ),
+ ).not.toThrow();
+ expect(
+ reprojectVectorFeatures(mapWith([tileLayer]), "EPSG:3857", "EPSG:32615"),
+ ).toBe(0);
+ });
+
+ test("survives a feature with no geometry", () => {
+ const source = new VectorSource({ features: [new Feature()] });
+ const layer = { getSource: () => source };
+
+ expect(
+ reprojectVectorFeatures(mapWith([layer]), "EPSG:3857", "EPSG:32615"),
+ ).toBe(0);
+ });
+
+ test("guards missing arguments", () => {
+ expect(reprojectVectorFeatures(null, "EPSG:3857", "EPSG:32615")).toBe(0);
+ expect(reprojectVectorFeatures(mapWith([]), null, "EPSG:32615")).toBe(0);
+ expect(reprojectVectorFeatures(mapWith([]), "EPSG:3857", null)).toBe(0);
+ });
+
+ test("a round trip returns the original coordinates", () => {
+ const layer = vectorLayer([SANTA_INES_3857]);
+ const map = mapWith([layer]);
+ reprojectVectorFeatures(map, "EPSG:3857", "EPSG:32615");
+ reprojectVectorFeatures(map, "EPSG:32615", "EPSG:3857");
+
+ const [x, y] = layer._source
+ .getFeatures()[0]
+ .getGeometry()
+ .getCoordinates();
+ expect(Math.abs(x - SANTA_INES_3857[0])).toBeLessThan(1);
+ expect(Math.abs(y - SANTA_INES_3857[1])).toBeLessThan(1);
+ });
+});
diff --git a/reactapp/components/dashboard/DashboardItem.js b/reactapp/components/dashboard/DashboardItem.js
index 99a56c18..d87a8586 100644
--- a/reactapp/components/dashboard/DashboardItem.js
+++ b/reactapp/components/dashboard/DashboardItem.js
@@ -78,29 +78,6 @@ const StyledDiv = styled.div`
left: 0;
width: 100vw;
height: calc(100vh - (${props.$fillOffset}));
-
- /* position:fixed creates a stacking context even at z-index:auto, which
- seals everything inside this item in. A map's legend, layer control,
- error alert and coordinate readout all set z-index:1000, but that only
- orders them against each other, never against another grid item -- the
- item paints as one unit in gridItems order, so a tile ordered after this
- one covered the map's own controls. In edit mode the bug disappears
- because fillViewportActive is gated on !isEditing, leaving the item
- position:relative and therefore not a stacking context.
-
- Raising the item while a control is open is the only way out: no
- descendant z-index can escape a stacking context, so lifting the whole
- subtree is the lever available. The trade is that an overlapping tile
- ordered after this one is hidden for as long as the control is open. It
- reverts on close, so the DOM-order layering described above still holds
- the rest of the time.
-
- 1029 clears dropdowns (1000) and sticky (1020) but stays below the fixed
- header (1030) and all modal chrome (backdrop 1040, modal 1050, popover
- 1070, tooltip 1080, app alerts 1081), so a modal still covers the map. */
- &:has([data-map-control-open="true"]) {
- z-index: 1029;
- }
`}
`;
diff --git a/reactapp/components/map/FloatingMapControl.js b/reactapp/components/map/FloatingMapControl.js
new file mode 100644
index 00000000..a1698cf8
--- /dev/null
+++ b/reactapp/components/map/FloatingMapControl.js
@@ -0,0 +1,151 @@
+import {
+ useCallback,
+ useEffect,
+ useLayoutEffect,
+ useRef,
+ useState,
+} from "react";
+import ReactDOM from "react-dom";
+import PropTypes from "prop-types";
+import styled from "styled-components";
+
+/**
+ * Lift a map control out of the map tile so it can paint above other grid items.
+ *
+ * A fill-viewport grid item is `position: fixed`, which creates a stacking
+ * context even at `z-index: auto`. Everything inside it is sealed in: the
+ * legend, layer control and error alert all set `z-index: 1000`, but inside a
+ * stacking context that only orders them against each other, never against
+ * another grid item. No descendant z-index can escape a stacking context, so the
+ * control has to leave the tile in the DOM.
+ *
+ * The alternative -- raising the whole tile while a control is open -- works but
+ * the map is opaque, so every overlapping grid item disappears for as long as
+ * the control is open. Floating only the control keeps both visible.
+ *
+ * An anchor element stays behind, carrying the caller's original positioning CSS
+ * so the offsets are not duplicated here, and the floated copy is pinned to
+ * whatever rectangle that anchor occupies.
+ *
+ * This mirrors PopupModal, the app's other portal: createPortal into
+ * document.body, `position: fixed`. Deliberately not react-bootstrap Overlay --
+ * that is for a popover anchored to a trigger, and its popper flip/shift would
+ * move a control that must stay pinned to a map corner.
+ */
+
+// Above grid items and Bootstrap dropdowns (1000), below $zindex-fixed (1030),
+// $zindex-modal-backdrop (1050) and $zindex-modal (1055) as configured here, so
+// the fixed header and any modal still cover it.
+export const FLOATING_CONTROL_Z_INDEX = 1029;
+
+const Anchor = styled.div`
+ /* Occupies the position the control would have had. Never interactive: the
+ real control is the floated copy. */
+ pointer-events: none;
+`;
+
+const Floating = styled.div`
+ position: fixed;
+ z-index: ${FLOATING_CONTROL_Z_INDEX};
+`;
+
+/**
+ * Turn the anchor's rectangle into fixed-position styles.
+ *
+ * Only the pinned edges carry meaning. Once the content is portalled away the
+ * anchor collapses, so an anchor pinned bottom-left is a zero-size point whose
+ * `left`/`bottom` are the corner the control should sit in -- its `right` and
+ * `top` are the same point and say nothing about the control's size.
+ */
+export function styleFromAnchor(rect, edges, viewport) {
+ if (!rect) return null;
+ const style = {};
+ if (edges.includes("left")) style.left = `${rect.left}px`;
+ if (edges.includes("right")) style.right = `${viewport.width - rect.right}px`;
+ if (edges.includes("top")) style.top = `${rect.top}px`;
+ if (edges.includes("bottom")) {
+ style.bottom = `${viewport.height - rect.bottom}px`;
+ }
+ // Pinned on both sides: the anchor spans a real width, so carry it across
+ // rather than letting the floated copy shrink to its content.
+ if (edges.includes("left") && edges.includes("right")) {
+ style.width = `${rect.width}px`;
+ delete style.right;
+ }
+ return style;
+}
+
+const FloatingMapControl = ({ edges, className, children, ...rest }) => {
+ const anchorRef = useRef(null);
+ const [style, setStyle] = useState(null);
+
+ const reposition = useCallback(() => {
+ const anchor = anchorRef.current;
+ if (!anchor) return;
+ setStyle(
+ styleFromAnchor(anchor.getBoundingClientRect(), edges, {
+ width: window.innerWidth,
+ height: window.innerHeight,
+ }),
+ );
+ // edges is a literal array at every call site, so compare by value rather
+ // than identity or this recreates on every render.
+ }, [edges.join(",")]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ // Layout effect so the first paint of the floated copy is already positioned,
+ // rather than flashing at the top-left corner.
+ useLayoutEffect(() => {
+ reposition();
+ }, [reposition, children]);
+
+ useEffect(() => {
+ window.addEventListener("resize", reposition);
+ // Capture phase: a non-fill map scrolls with the grid, and the scroll may
+ // happen on an ancestor rather than the window.
+ window.addEventListener("scroll", reposition, true);
+
+ // Track the tile itself being moved or resized, which happens while editing
+ // the dashboard layout.
+ let observer;
+ const observed = anchorRef.current?.offsetParent;
+ if (observed && typeof ResizeObserver !== "undefined") {
+ observer = new ResizeObserver(reposition);
+ observer.observe(observed);
+ }
+
+ return () => {
+ window.removeEventListener("resize", reposition);
+ window.removeEventListener("scroll", reposition, true);
+ observer?.disconnect();
+ };
+ }, [reposition]);
+
+ return (
+ <>
+
+ {style &&
+ ReactDOM.createPortal(
+
+ {children}
+ ,
+ document.body,
+ )}
+ >
+ );
+};
+
+FloatingMapControl.propTypes = {
+ /** Which sides the anchor's CSS pins, e.g. ["bottom", "left"]. */
+ edges: PropTypes.arrayOf(PropTypes.oneOf(["top", "bottom", "left", "right"]))
+ .isRequired,
+ /** Applied to the anchor: this is where the caller's positioning CSS goes. */
+ className: PropTypes.string,
+ children: PropTypes.node,
+};
+
+export default FloatingMapControl;
diff --git a/reactapp/components/map/LayersControl.js b/reactapp/components/map/LayersControl.js
index af60d4ad..da86d35c 100644
--- a/reactapp/components/map/LayersControl.js
+++ b/reactapp/components/map/LayersControl.js
@@ -8,12 +8,16 @@ import {
FaRedo,
} from "react-icons/fa";
import { WebsocketContext } from "components/contexts/WebSocketContext";
+import FloatingMapControl from "components/map/FloatingMapControl";
-const ControlWrapper = styled.div`
+// See LegendControl: the positioning lives on the anchor, the control itself is
+// portalled out of the map tile's stacking context.
+const ControlWrapper = styled(FloatingMapControl)`
position: absolute;
bottom: 1rem;
right: 1rem;
`;
+const LAYERS_EDGES = ["bottom", "right"];
const ProgressBar = styled.div`
height: 4px;
@@ -159,11 +163,10 @@ const LayersControl = ({ updater, visualizationRef, runtimeLayerState }) => {
}
return (
-
+
{isexpanded ? (
<>
diff --git a/reactapp/components/map/LegendControl.js b/reactapp/components/map/LegendControl.js
index e72907ce..fbb83eec 100644
--- a/reactapp/components/map/LegendControl.js
+++ b/reactapp/components/map/LegendControl.js
@@ -1,14 +1,20 @@
import { useState } from "react";
import styled from "styled-components";
import LegendRenderer from "components/map/LegendRenderer";
+import FloatingMapControl from "components/map/FloatingMapControl";
import { FaTimes, FaListUl } from "react-icons/fa";
import PropTypes from "prop-types";
-const LegendWrapper = styled.div`
+// Positioning stays here, on the anchor FloatingMapControl leaves behind, so
+// the corner is defined in one place. The control itself is portalled to the
+// body: a fill-viewport tile is position:fixed and seals its subtree into a
+// stacking context, which no descendant z-index can escape.
+const LegendWrapper = styled(FloatingMapControl)`
position: absolute;
bottom: 1rem;
left: 1rem;
`;
+const LEGEND_EDGES = ["bottom", "left"];
const LegendControlContainer = styled.div`
background-color: white;
@@ -51,11 +57,10 @@ const LegendControl = ({ legendItems }) => {
return (
{legendItems.filter((item) => item !== null).length > 0 && (
-
+
{isexpanded ? (
diff --git a/reactapp/components/map/Map.js b/reactapp/components/map/Map.js
index 607b2272..9d0cebf7 100644
--- a/reactapp/components/map/Map.js
+++ b/reactapp/components/map/Map.js
@@ -5,6 +5,7 @@ import moduleLoader, {
createJsonStyleFunction,
} from "components/map/ModuleLoader";
import LayersControl from "components/map/LayersControl";
+import FloatingMapControl from "components/map/FloatingMapControl";
import LegendControl from "components/map/LegendControl";
import DrawInteractions from "components/map/DrawInteractions";
import ExtentInteraction from "components/map/ExtentInteraction";
@@ -12,6 +13,7 @@ import {
legendPropType,
configurationPropType,
mapDrawingPropType,
+ reprojectVectorFeatures,
updateOlLayerProps,
wrapMercatorX,
} from "components/map/utilities";
@@ -26,12 +28,18 @@ import { VariableInputsContext } from "components/contexts/Contexts";
import GeoJSON from "ol/format/GeoJSON";
import { valuesEqual } from "components/modals/utilities";
-const StyledAlert = styled(Alert)`
+// Pinned on both sides, so the anchor spans the map's width and the floated copy
+// inherits it. Same stacking-context escape as the legend and layer control.
+const AlertAnchor = styled(FloatingMapControl)`
position: absolute;
top: 1rem;
left: 1rem;
right: 1rem;
- z-index: 1000;
+`;
+const ALERT_EDGES = ["top", "left", "right"];
+
+const StyledAlert = styled(Alert)`
+ margin: 0;
`;
const InfoDiv = styled.div`
@@ -530,7 +538,16 @@ const MapComponent = ({
if (targetExtent && haveMapSize) {
newView.fit(targetExtent, { size: mapSize });
}
+ // Features already on the map were parsed into the outgoing
+ // projection, so adopting the raster's leaves them holding the
+ // wrong numbers -- a UTM raster over Guatemala left Web
+ // Mercator coordinates being read as UTM metres, stranding the
+ // dynamic layers off screen while still reporting the right
+ // feature count. Move them with the view.
+ const previousCode = prevProjection.getCode();
+ const adoptedCode = newView.getProjection().getCode();
map.setView(newView);
+ reprojectVectorFeatures(map, previousCode, adoptedCode);
} catch (err) {
console.warn(
`GeoTIFF auto-fit failed for layer "${name}":`,
@@ -705,14 +722,16 @@ const MapComponent = ({
<>
{errorMessage && (
- setErrorMessage("")}
- >
- {errorMessage}
-
+
+ setErrorMessage("")}
+ >
+ {errorMessage}
+
+
)}
{dataviewerViz && (
diff --git a/reactapp/components/map/utilities.js b/reactapp/components/map/utilities.js
index f980a632..ef331413 100644
--- a/reactapp/components/map/utilities.js
+++ b/reactapp/components/map/utilities.js
@@ -263,6 +263,38 @@ export const layerPropertiesOptions = {
},
};
+/**
+ * Re-project every vector feature already on the map.
+ *
+ * Features are parsed into the map's projection when they are added, so a later
+ * change of view projection leaves them holding the old projection's numbers.
+ * A GeoTIFF auto-fit does exactly that: adopting a UTM raster's projection left
+ * Web Mercator coordinates being read as UTM metres, putting the features far
+ * off screen while the layer still reported the right feature count.
+ *
+ * Only sources that actually hold features are touched; tile sources have no
+ * geometry to move. Returns the number of geometries transformed.
+ */
+export function reprojectVectorFeatures(map, from, to) {
+ if (!map || !from || !to || from === to) return 0;
+ let transformed = 0;
+ map
+ .getLayers()
+ .getArray()
+ .forEach((layer) => {
+ const source = layer.getSource?.();
+ if (typeof source?.getFeatures !== "function") return;
+ source.getFeatures().forEach((feature) => {
+ const geometry = feature.getGeometry?.();
+ if (!geometry) return;
+ geometry.transform(from, to);
+ transformed += 1;
+ });
+ source.changed?.();
+ });
+ return transformed;
+}
+
/**
* Swap the features on a preserved OpenLayers VectorLayer in place.
*
diff --git a/reactapp/components/visualizations/runtimeLayerFetcher.js b/reactapp/components/visualizations/runtimeLayerFetcher.js
index 6f16eaa4..89ef8917 100644
--- a/reactapp/components/visualizations/runtimeLayerFetcher.js
+++ b/reactapp/components/visualizations/runtimeLayerFetcher.js
@@ -31,20 +31,18 @@ function cancelPendingSwap(state) {
* never refetched -- the features only appeared once an argument genuinely
* changed.
*/
-function swapWhenLayerAppears(
- state,
- map,
- layerId,
- featureCollection,
- mapProjection,
-) {
+function swapWhenLayerAppears(state, map, layerId, featureCollection) {
const collection = map.getLayers();
cancelPendingSwap(state);
const onAdd = () => {
const olLayer = findOlLayer(map, layerId);
if (!olLayer) return;
cancelPendingSwap(state);
- swapVectorLayerFeatures(olLayer, featureCollection, mapProjection);
+ // Read the projection now rather than when the fetch resolved: a GeoTIFF
+ // auto-fit can change the view in between, and parsing into a projection
+ // the map has already left strands the features off screen.
+ const projection = map.getView().getProjection().getCode();
+ swapVectorLayerFeatures(olLayer, featureCollection, projection);
};
state.pendingSwap = () => collection.un("add", onAdd);
collection.on("add", onAdd);
@@ -166,13 +164,7 @@ export default function useRuntimeLayerFetcher({
if (olLayer) {
swapVectorLayerFeatures(olLayer, featureCollection, mapProjection);
} else {
- swapWhenLayerAppears(
- state,
- map,
- layerId,
- featureCollection,
- mapProjection,
- );
+ swapWhenLayerAppears(state, map, layerId, featureCollection);
}
clearError(layerId);
})