diff --git a/frontend/src/hooks/useMobileMapAutoFit.js b/frontend/src/hooks/useMobileMapAutoFit.js index 9e76e2d..c20bb5b 100644 --- a/frontend/src/hooks/useMobileMapAutoFit.js +++ b/frontend/src/hooks/useMobileMapAutoFit.js @@ -1,6 +1,8 @@ import { useCallback, useEffect, useMemo, useRef } from "react"; import { DEFAULT_MAP_SIZE, clampMapZoom } from "../lib/locationMapUtils"; +const COMPACT_MAP_LAYOUT_QUERY = "(max-width: 839px)"; + function getAutoFitKey(mapState, mapSize) { return [ mapState?.location?.id || "", @@ -14,8 +16,12 @@ function getAutoFitKey(mapState, mapSize) { function getFitZoom(mapSize, scrollElement) { const fallbackWidth = typeof window === "undefined" ? DEFAULT_MAP_SIZE.width : window.innerWidth; const fallbackHeight = typeof window === "undefined" ? DEFAULT_MAP_SIZE.height : window.innerHeight; - const availableWidth = Math.max(280, (scrollElement?.clientWidth || fallbackWidth) - 24); - const availableHeight = Math.max(220, (scrollElement?.clientHeight || fallbackHeight) - 24); + const isCompactLayout = typeof window !== "undefined" + && window.matchMedia(COMPACT_MAP_LAYOUT_QUERY).matches; + const minimumWidth = isCompactLayout ? 160 : 280; + const minimumHeight = isCompactLayout ? 120 : 220; + const availableWidth = Math.max(minimumWidth, (scrollElement?.clientWidth || fallbackWidth) - 24); + const availableHeight = Math.max(minimumHeight, (scrollElement?.clientHeight || fallbackHeight) - 24); return Number(clampMapZoom( Math.min(availableWidth / mapSize.width, availableHeight / mapSize.height) @@ -44,7 +50,7 @@ export default function useMobileMapAutoFit({ useEffect(() => { if (typeof window === "undefined") return undefined; if (!mapState || mode === "setup") return undefined; - if (!window.matchMedia("(max-width: 520px)").matches) return undefined; + if (!window.matchMedia(COMPACT_MAP_LAYOUT_QUERY).matches) return undefined; if (lastAutoFitKeyRef.current === autoFitKey) return undefined; const frameId = window.requestAnimationFrame(() => { diff --git a/frontend/src/styles/pages/LocationMapManager.css b/frontend/src/styles/pages/LocationMapManager.css index 6b99390..bb0add0 100644 --- a/frontend/src/styles/pages/LocationMapManager.css +++ b/frontend/src/styles/pages/LocationMapManager.css @@ -958,6 +958,17 @@ } } +@media (max-width: 839px) { + .location-map-bottom-sheet { + max-height: 34dvh; + } + + .location-map-svg { + min-width: 0; + min-height: 0; + } +} + @media (max-width: 520px) { .location-map-canvas-shell { padding: 0.5rem; diff --git a/frontend/tests/location-map-manager.spec.ts b/frontend/tests/location-map-manager.spec.ts index 5fd6477..64cf7b2 100644 --- a/frontend/tests/location-map-manager.spec.ts +++ b/frontend/tests/location-map-manager.spec.ts @@ -187,6 +187,25 @@ async function mockMapShell( }); } +async function expectMapFitsCanvas(page: Page, maxZoomPercent: number, context: string) { + const scroll = page.locator(".location-map-scroll"); + const zoomText = page.locator(".location-map-zoom-controls span"); + await expect.poll(async () => { + const value = await zoomText.textContent(); + return Number(value?.replace("%", "") || 0); + }).toBeLessThan(maxZoomPercent); + + const scrollBox = await scroll.boundingBox(); + const svgBox = await page.locator(".location-map-svg").boundingBox(); + expect(scrollBox).not.toBeNull(); + expect(svgBox).not.toBeNull(); + if (!scrollBox || !svgBox) throw new Error(`${context} fitted map was not measurable`); + expect(svgBox.width).toBeLessThanOrEqual(scrollBox.width + 1); + expect(svgBox.height).toBeLessThanOrEqual(scrollBox.height + 1); + + return { scroll }; +} + test("admin creates a map from zones, saves a draft, and publishes it", async ({ page }) => { await mockMapShell(page); @@ -3645,20 +3664,7 @@ test("mobile starts fitted and fit returns panned maps into the canvas", async ( }); await page.goto("/stores/100/locations/10/map"); - const scroll = page.locator(".location-map-scroll"); - const zoomText = page.locator(".location-map-zoom-controls span"); - await expect.poll(async () => { - const value = await zoomText.textContent(); - return Number(value?.replace("%", "") || 0); - }).toBeLessThan(45); - - let scrollBox = await page.locator(".location-map-scroll").boundingBox(); - let svgBox = await page.locator(".location-map-svg").boundingBox(); - expect(scrollBox).not.toBeNull(); - expect(svgBox).not.toBeNull(); - if (!scrollBox || !svgBox) throw new Error("Initial mobile fitted map was not measurable"); - expect(svgBox.width).toBeLessThanOrEqual(scrollBox.width + 1); - expect(svgBox.height).toBeLessThanOrEqual(scrollBox.height + 1); + const { scroll } = await expectMapFitsCanvas(page, 45, "Initial mobile"); const zoomIn = page.getByRole("button", { name: "Zoom in" }); await zoomIn.click(); @@ -3677,24 +3683,48 @@ test("mobile starts fitted and fit returns panned maps into the canvas", async ( await page.getByRole("button", { name: "Fit" }).click(); - await expect.poll(async () => { - const value = await zoomText.textContent(); - return Number(value?.replace("%", "") || 0); - }).toBeLessThan(45); - - scrollBox = await page.locator(".location-map-scroll").boundingBox(); - svgBox = await page.locator(".location-map-svg").boundingBox(); - expect(scrollBox).not.toBeNull(); - expect(svgBox).not.toBeNull(); - if (!scrollBox || !svgBox) throw new Error("Mobile fitted map was not measurable"); - expect(svgBox.width).toBeLessThanOrEqual(scrollBox.width + 1); - expect(svgBox.height).toBeLessThanOrEqual(scrollBox.height + 1); + await expectMapFitsCanvas(page, 45, "Mobile"); await expect.poll(async () => scroll.evaluate((element) => ({ left: element.scrollLeft, top: element.scrollTop, }))).toEqual({ left: 0, top: 0 }); }); +test("compact tablet map starts fitted on first load", async ({ page }) => { + await page.setViewportSize({ width: 768, height: 720 }); + await mockMapShell(page); + + const mapState = publishedMapState([ + { + id: 1586, + location_map_id: 901, + zone_id: 501, + zone_name: "Bakery", + type: "zone", + label: "Bakery", + x: 40, + y: 40, + width: 260, + height: 160, + rotation: 0, + locked: false, + visible: true, + sort_order: 1, + }, + ], true); + + await page.route("**/households/1/locations/10/map", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(mapState), + }); + }); + + await page.goto("/stores/100/locations/10/map"); + await expectMapFitsCanvas(page, 75, "Compact tablet"); +}); + test("zoom controls disable at map zoom limits", async ({ page }) => { await mockMapShell(page);