From e082af1fb8d844cd2a654ef35223e88488d9ba83 Mon Sep 17 00:00:00 2001 From: Corey Krewson Date: Mon, 17 Aug 2026 17:52:33 -0700 Subject: [PATCH 1/2] Move vector features with the view when a GeoTIFF auto-fit changes projection Dynamic map layers painted, then vanished, on a dashboard whose rasters are UTM 15N. The layers kept the right feature count, stayed visible at full opacity with the correct z-index -- they were simply drawn far off screen. Features are parsed into the map's projection when they are added, so a later change of view projection leaves them holding the outgoing projection's numbers. The GeoTIFF auto-fit adopts the raster's projection and calls map.setView, which is legitimate: OpenLayers 10.4 ships a UTM projection factory, so EPSG:32615 resolves and transforms correctly. What was missing is that nothing moved the features already on the map. Web Mercator coordinates around (-10078522, 1629726) were then read as UTM metres, which is thousands of kilometres away. reprojectVectorFeatures walks the map's vector sources and transforms their geometries, and the auto-fit calls it whenever the adopted projection differs from the outgoing one. Tile sources are skipped: they have no geometry to move. The deferred swap in runtimeLayerFetcher now reads the projection when it fires rather than when the fetch resolved. An auto-fit can land in between, and parsing into a projection the map has already left produces the same stranding. Investigated with temporary logging rather than by inspection: three earlier theories -- the layer being rebuilt empty, the features being cleared, and a z-order conflict -- were each disproved by it. A fourth, that OpenLayers could not resolve the projection at all, was disproved by checking ol/proj, which resolves UTM codes through a built-in factory. Co-Authored-By: Claude Opus 5 --- reactapp/__tests__/components/map/Map.test.js | 52 +++++++++ .../components/map/utilities.test.js | 101 ++++++++++++++++++ reactapp/components/map/Map.js | 10 ++ reactapp/components/map/utilities.js | 32 ++++++ .../visualizations/runtimeLayerFetcher.js | 22 ++-- 5 files changed, 202 insertions(+), 15 deletions(-) diff --git a/reactapp/__tests__/components/map/Map.test.js b/reactapp/__tests__/components/map/Map.test.js index 4a005675..f8841da9 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 () => { 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/map/Map.js b/reactapp/components/map/Map.js index 607b2272..f9647fcb 100644 --- a/reactapp/components/map/Map.js +++ b/reactapp/components/map/Map.js @@ -12,6 +12,7 @@ import { legendPropType, configurationPropType, mapDrawingPropType, + reprojectVectorFeatures, updateOlLayerProps, wrapMercatorX, } from "components/map/utilities"; @@ -530,7 +531,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}":`, 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); }) From 9c8aeef9a9ae0fff0fb5943078ce503085958e07 Mon Sep 17 00:00:00 2001 From: Corey Krewson Date: Mon, 17 Aug 2026 19:12:49 -0700 Subject: [PATCH 2/2] Float the map's controls out of the tile instead of raising the whole tile Replaces the approach in 547dd1bf. Raising the fill-viewport tile while a control was open did clear the overlapping grid items, but the map is opaque, so those items disappeared for as long as the control stayed open. On a dashboard where widgets are deliberately floated over a full-screen map that is most of the dashboard. The legend and the other grid items need to be visible at the same time. Only the control floats now. A new FloatingMapControl leaves an anchor in place carrying the caller's existing positioning CSS, and portals the control itself to document.body pinned to whatever rectangle that anchor occupies. The offsets stay in one place, and a non-fill map lands exactly where it always did. This is the only way out: position:fixed makes the tile a stacking context, and no descendant z-index can escape one -- so the control has to leave the tile in the DOM. It follows PopupModal, the app's other portal: createPortal into document.body with position:fixed. Deliberately not react-bootstrap Overlay, which is the right tool for a popover anchored to a trigger but whose popper flip/shift would move a control that must stay pinned to a map corner. Applied to the legend, the layer control, and the map's error alert. The coordinate readout is left alone: it renders only under `dataviewerViz`, where nothing overlaps the map. The alert is pinned on both sides, so the anchor spans a real width and the floated copy carries it across rather than shrinking to its content. z-index 1029 clears grid items and Bootstrap dropdowns (1000) and stays 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. The DashboardItem :has() rule, the data-map-control-open attributes and the tests that asserted them are all removed with the approach they served. Co-Authored-By: Claude Opus 5 --- .../dashboard/DashboardItem.test.js | 92 -------- .../components/map/FloatingMapControl.test.js | 210 ++++++++++++++++++ .../components/map/LayersControl.test.js | 21 -- .../__tests__/components/map/Legend.test.js | 18 -- reactapp/__tests__/components/map/Map.test.js | 9 + .../components/dashboard/DashboardItem.js | 23 -- reactapp/components/map/FloatingMapControl.js | 151 +++++++++++++ reactapp/components/map/LayersControl.js | 9 +- reactapp/components/map/LegendControl.js | 11 +- reactapp/components/map/Map.js | 29 ++- 10 files changed, 403 insertions(+), 170 deletions(-) create mode 100644 reactapp/__tests__/components/map/FloatingMapControl.test.js create mode 100644 reactapp/components/map/FloatingMapControl.js 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 f8841da9..837274d3 100644 --- a/reactapp/__tests__/components/map/Map.test.js +++ b/reactapp/__tests__/components/map/Map.test.js @@ -2253,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/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 ( + <> +