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 ( + <> +