From 95f13774847395e780b23ad05f5c712e7dc0f7c2 Mon Sep 17 00:00:00 2001 From: JesJehle Date: Wed, 2 Sep 2026 12:16:55 +0200 Subject: [PATCH] feat(priwa): unify mobile map controls --- .../e2e-local/priwa-field-write-flows.spec.ts | 37 ++++- .../e2e-local/priwa-warnkarte-local.spec.ts | 127 ++++++++++++++++- .../mobile/MobileAnalysisDrawer.tsx | 2 +- .../DeadwoodMap/mobile/MobileLayerDrawer.tsx | 29 ++-- .../DeadwoodMap/mobile/MobileMapControls.tsx | 32 +---- .../DeadwoodMap/mobile/MobileTimeDrawer.tsx | 2 +- .../MapControls/mobile/MapLayersIcon.tsx | 32 +++++ .../mobile/MobileBottomSheet.tsx | 48 ++++--- .../mobile/MobileLayerRow.tsx | 54 ++++--- .../mobile/MobileLayerTile.tsx | 24 +--- .../mobile/MobileMapSectionHeading.tsx | 15 ++ .../mobile/mobileMapThumbnails.ts | 0 .../components/PriwaField/PriwaFieldMap.tsx | 132 +++++++++++++----- .../PriwaField/PriwaMapLayersSheet.test.ts | 49 +++++++ .../PriwaField/PriwaMapLayersSheet.tsx | 117 ++++++++++++++++ .../PriwaField/PriwaMobileFieldTools.tsx | 30 +++- .../PriwaField/PriwaMobilePrimaryActions.tsx | 59 ++++++++ .../PriwaOfflineAreaSelection.test.ts | 26 ++++ .../PriwaField/PriwaOfflineAreaSelection.tsx | 129 ++++++++++------- .../PriwaField/PriwaOfflineMapPanel.test.ts | 24 ++++ .../PriwaField/PriwaOfflineMapPanel.tsx | 68 ++++++--- .../PriwaField/PriwaWarnkarteMapControls.tsx | 4 - .../PriwaField/usePriwaWarnkarte.ts | 23 ++- frontend/src/index.css | 39 ------ frontend/src/pages/PriwaField.tsx | 43 ++++-- 25 files changed, 853 insertions(+), 292 deletions(-) create mode 100644 frontend/src/components/MapControls/mobile/MapLayersIcon.tsx rename frontend/src/components/{DeadwoodMap => MapControls}/mobile/MobileBottomSheet.tsx (80%) rename frontend/src/components/{DeadwoodMap => MapControls}/mobile/MobileLayerRow.tsx (53%) rename frontend/src/components/{DeadwoodMap => MapControls}/mobile/MobileLayerTile.tsx (59%) create mode 100644 frontend/src/components/MapControls/mobile/MobileMapSectionHeading.tsx rename frontend/src/components/{DeadwoodMap => MapControls}/mobile/mobileMapThumbnails.ts (100%) create mode 100644 frontend/src/components/PriwaField/PriwaMapLayersSheet.test.ts create mode 100644 frontend/src/components/PriwaField/PriwaMapLayersSheet.tsx create mode 100644 frontend/src/components/PriwaField/PriwaMobilePrimaryActions.tsx diff --git a/frontend/e2e-local/priwa-field-write-flows.spec.ts b/frontend/e2e-local/priwa-field-write-flows.spec.ts index 54f8c845..1be07eae 100644 --- a/frontend/e2e-local/priwa-field-write-flows.spec.ts +++ b/frontend/e2e-local/priwa-field-write-flows.spec.ts @@ -94,20 +94,42 @@ test.describe("PRIWA local field write flows", () => { ).toHaveCount(0); await expect( - page.getByRole("button", { name: "Zu Karte wechseln" }), + page.getByRole("button", { name: "Kartenebenen öffnen" }), ).toBeVisible(); await expect( page.getByRole("button", { name: "Baumliste öffnen" }), ).toBeVisible(); await expect( - page.getByRole("button", { name: "Punkt aufnehmen" }), + page.getByRole("button", { name: "Käferbaum aufnehmen" }), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Aktuelle Position aktivieren" }), ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Zu Karte wechseln" }), + ).toHaveCount(0); await expect( page.getByRole("navigation", { name: "PRIWA Feldaktionen" }), ).toHaveCount(0); await expectPersistedOfflineAreaVisualization(page); - await page.getByRole("button", { name: "Punkt aufnehmen" }).click(); + const addPointButton = page.getByRole("button", { + name: "Käferbaum aufnehmen", + }); + const locateButton = page.getByRole("button", { + name: "Aktuelle Position aktivieren", + }); + const [addPointBox, locateBox] = await Promise.all([ + addPointButton.boundingBox(), + locateButton.boundingBox(), + ]); + expect(addPointBox).not.toBeNull(); + expect(locateBox).not.toBeNull(); + expect(addPointBox!.width).toBe(locateBox!.width); + expect(addPointBox!.height).toBe(locateBox!.height); + expect(addPointBox!.y).toBeLessThan(locateBox!.y); + + await addPointButton.click(); const captureDrawer = page.locator( ".priwa-point-drawer-root .ant-drawer-content-wrapper", ); @@ -379,6 +401,9 @@ async function expectOfflineBasemapControl(page: Page) { page.getByRole("button", { name: "Bereich herunterladen" }), ).toBeVisible(); await page.getByRole("button", { name: "Abbrechen" }).click(); + await expect(offlineMapButton).toHaveAttribute("aria-pressed", "true"); + await offlineMapButton.click(); + await expect(offlineMapButton).toHaveAttribute("aria-pressed", "false"); } async function expectPersistedOfflineAreaVisualization(page: Page) { @@ -480,6 +505,12 @@ async function expectOfflineSelectionSuppressesPointInteraction(page: Page) { page.locator('[data-priwa-offline-selection-frame="true"]'), ).toBeVisible(); await page.getByRole("button", { name: "Abbrechen" }).click(); + const offlineMapButton = page.getByRole("button", { + name: /Offline-Karte laden/, + }); + await expect(offlineMapButton).toHaveAttribute("aria-pressed", "true"); + await offlineMapButton.click(); + await expect(offlineMapButton).toHaveAttribute("aria-pressed", "false"); } async function expectResizableDesktopPointTable(page: Page) { diff --git a/frontend/e2e-local/priwa-warnkarte-local.spec.ts b/frontend/e2e-local/priwa-warnkarte-local.spec.ts index f4dfd3dd..4c71f250 100644 --- a/frontend/e2e-local/priwa-warnkarte-local.spec.ts +++ b/frontend/e2e-local/priwa-warnkarte-local.spec.ts @@ -31,13 +31,19 @@ const polygon = { }; async function installWarnkarteAdmin(page: Page) { - await installLocalSession(page, { + const session = await installLocalSession(page, { user: admin, supabaseUrl: localSupabaseUrl, refreshToken: "priwa-warnkarte-refresh-token", }); - await page.route(`${localSupabaseUrl}/rest/v1/**`, fulfillSupabaseRequest); + // Keep the mocked journey independent from the worktree's generated + // Supabase endpoint. The application and test runner can legitimately read + // that endpoint from different env files. + await page.route("**/auth/v1/user", (route) => + route.fulfill({ contentType: "application/json", json: session.user }), + ); + await page.route("**/rest/v1/**", fulfillSupabaseRequest); } async function fulfillSupabaseRequest(route: Route) { @@ -289,7 +295,9 @@ test.describe("PRIWA Warnkarte local UI", () => { await installWarnkarteApi(page); await page.goto("/priwa-field"); - await expect(page.locator("[data-priwa-review-queue-panel]")).toHaveCount(0); + await expect(page.locator("[data-priwa-review-queue-panel]")).toHaveCount( + 0, + ); await expect( page.getByRole("button", { name: "Warnkarte verwalten" }), ).toHaveCount(0); @@ -301,6 +309,7 @@ test.describe("PRIWA Warnkarte local UI", () => { }); await expect(page.getByText("Käferbaum bearbeiten")).toBeVisible(); await page.getByRole("button", { name: "Close" }).click(); + await page.getByRole("button", { name: "Kartenebenen öffnen" }).click(); await page.getByRole("button", { name: "Zur Warnkarte zoomen" }).click(); await page.waitForTimeout(600); await page.getByTestId("priwa-field-map").click({ @@ -580,8 +589,17 @@ test.describe("PRIWA Warnkarte local UI", () => { }); await page.route("**/priwa/warnkarte/versions?*", unavailable); await page.route("**/priwa/warnkarte/validate", unavailable); + await page.route("**/priwa/warnkarte/active?*", unavailable); await page.goto("/priwa-field"); + const loadError = page.getByText("Warnkarte konnte nicht geladen werden", { + exact: true, + }); + await expect(loadError).toHaveCount(0); + await page.getByRole("button", { name: "Warnkarte einblenden" }).click(); + await expect(loadError).toBeVisible(); + await expect(loadError).toBeHidden({ timeout: 5_000 }); + await page.getByRole("button", { name: "Warnkarte verwalten" }).click(); const explanation = "Die Warnkarten-Funktion ist in dieser Umgebung noch nicht verfügbar. Die Datei wurde nicht validiert."; @@ -614,6 +632,40 @@ test.describe("PRIWA Warnkarte local UI", () => { ).toHaveCount(0); await expect(page.locator(".dt-map-zoom-control")).toBeHidden(); await expect(page.locator(".dt-map-scale-control")).toBeVisible(); + await page.getByRole("button", { name: "Kartenebenen öffnen" }).click(); + await page.getByRole("button", { name: "Baumliste öffnen" }).click(); + await expect(page.getByLabel("Kartenebenen", { exact: true })).toHaveCount( + 0, + ); + await expect( + page.getByText("Käferbäume (1)", { exact: true }), + ).toBeVisible(); + await page.keyboard.press("Escape"); + await page.getByRole("button", { name: "Kartenebenen öffnen" }).click(); + const layerSheet = page.getByLabel("Kartenebenen", { exact: true }); + const layerSheetBox = await layerSheet.boundingBox(); + expect(layerSheetBox).not.toBeNull(); + expect(layerSheetBox!.height / 852).toBeGreaterThan(0.22); + expect(layerSheetBox!.height / 852).toBeLessThan(0.36); + const layerSheetHeaderBox = await layerSheet + .locator("header") + .boundingBox(); + expect(layerSheetHeaderBox).not.toBeNull(); + await page.mouse.move( + layerSheetHeaderBox!.x + layerSheetHeaderBox!.width / 2, + layerSheetHeaderBox!.y + 12, + ); + await page.mouse.down(); + await page.mouse.move( + layerSheetHeaderBox!.x + layerSheetHeaderBox!.width / 2, + layerSheetHeaderBox!.y - 280, + { steps: 4 }, + ); + await page.mouse.up(); + await expect(layerSheet).toHaveAttribute( + "data-mobile-bottom-sheet-snap", + "expanded", + ); await page.getByRole("button", { name: "Zur Warnkarte zoomen" }).click(); await page.waitForTimeout(600); await page.getByTestId("priwa-field-map").click({ @@ -622,14 +674,79 @@ test.describe("PRIWA Warnkarte local UI", () => { await expect(page.locator(".priwa-warnkarte-tooltip")).toContainText( "Wahrscheinlichkeit: 60 %", ); - await page.getByRole("button", { name: "Warnkarte ausblenden" }).click(); + await page.getByRole("button", { name: "Kartenebenen öffnen" }).click(); + await page.getByRole("switch", { name: "Warnkarte ausblenden" }).click(); await expect(page.getByTestId("priwa-warnkarte-legend")).toHaveCount(0); await expect( page.getByRole("button", { name: "Zur Warnkarte zoomen" }), ).toHaveCount(0); await expect( - page.getByRole("button", { name: "Warnkarte einblenden" }), + page.getByRole("switch", { name: "Warnkarte einblenden" }), ).toBeVisible(); await expect(page.getByTestId("priwa-field-map")).toBeVisible(); + await page.getByRole("button", { name: "Kartenebenen schließen" }).click(); + await page.getByRole("button", { name: /Offline-Karten öffnen/ }).click(); + await expect( + page.getByLabel("Offline-Karten", { exact: true }), + ).toBeVisible(); + await page.getByRole("button", { name: "Neuen Bereich auswählen" }).click(); + const selectionFrame = page.locator( + '[data-priwa-offline-selection-frame="true"]', + ); + const selectionSheet = page.getByLabel("Offline-Bereich auswählen", { + exact: true, + }); + const [selectionFrameBox, selectionSheetBox] = await Promise.all([ + selectionFrame.boundingBox(), + selectionSheet.boundingBox(), + ]); + expect(selectionFrameBox).not.toBeNull(); + expect(selectionSheetBox).not.toBeNull(); + expect( + Math.abs(selectionFrameBox!.width - selectionFrameBox!.height), + ).toBeLessThan(2); + expect(selectionFrameBox!.y + selectionFrameBox!.height).toBeLessThan( + selectionSheetBox!.y, + ); + const [selectionTitleBox, selectionCloseBox] = await Promise.all([ + page + .getByRole("heading", { name: "Offline-Bereich auswählen" }) + .boundingBox(), + page + .getByRole("button", { name: "Bereichsauswahl schließen" }) + .boundingBox(), + ]); + expect(selectionTitleBox).not.toBeNull(); + expect(selectionCloseBox).not.toBeNull(); + expect(selectionTitleBox!.x).toBeGreaterThanOrEqual(12); + expect(selectionCloseBox!.x + selectionCloseBox!.width).toBeLessThanOrEqual( + 381, + ); + }); + + test("mobile only reports an unavailable Warnkarte after activation", async ({ + page, + }) => { + await page.setViewportSize({ width: 393, height: 852 }); + await installWarnkarteAdmin(page); + await page.route("**/priwa/warnkarte/active?*", (route) => + route.fulfill({ + status: 503, + contentType: "application/json", + json: { detail: "Offline" }, + }), + ); + await page.goto("/priwa-field"); + await page.getByRole("button", { name: "Accept" }).click(); + + const errorMessage = page.getByText( + "Warnkarte konnte nicht geladen werden", + { exact: true }, + ); + await expect(errorMessage).toHaveCount(0); + await page.getByRole("button", { name: "Kartenebenen öffnen" }).click(); + await page.getByRole("switch", { name: "Warnkarte einblenden" }).click(); + await expect(errorMessage).toBeVisible(); + await expect(errorMessage).toBeHidden({ timeout: 5_000 }); }); }); diff --git a/frontend/src/components/DeadwoodMap/mobile/MobileAnalysisDrawer.tsx b/frontend/src/components/DeadwoodMap/mobile/MobileAnalysisDrawer.tsx index 047a6acb..6efdb3a3 100644 --- a/frontend/src/components/DeadwoodMap/mobile/MobileAnalysisDrawer.tsx +++ b/frontend/src/components/DeadwoodMap/mobile/MobileAnalysisDrawer.tsx @@ -5,7 +5,7 @@ import { LoginOutlined, } from "@ant-design/icons"; -import { mobileMapThumbnails } from "./mobileMapThumbnails"; +import { mobileMapThumbnails } from "../../MapControls/mobile/mobileMapThumbnails"; interface MobileAnalysisDrawerProps { open: boolean; diff --git a/frontend/src/components/DeadwoodMap/mobile/MobileLayerDrawer.tsx b/frontend/src/components/DeadwoodMap/mobile/MobileLayerDrawer.tsx index 679ecd6f..ac07a2e8 100644 --- a/frontend/src/components/DeadwoodMap/mobile/MobileLayerDrawer.tsx +++ b/frontend/src/components/DeadwoodMap/mobile/MobileLayerDrawer.tsx @@ -2,10 +2,11 @@ import { Button, Slider } from "antd"; import { DownloadOutlined } from "@ant-design/icons"; import { mapColors } from "../../../theme/mapColors"; -import MobileLayerRow from "./MobileLayerRow"; -import MobileLayerTile from "./MobileLayerTile"; -import MobileBottomSheet from "./MobileBottomSheet"; -import { mobileMapThumbnails } from "./mobileMapThumbnails"; +import MobileLayerRow from "../../MapControls/mobile/MobileLayerRow"; +import MobileLayerTile from "../../MapControls/mobile/MobileLayerTile"; +import MobileBottomSheet from "../../MapControls/mobile/MobileBottomSheet"; +import MobileMapSectionHeading from "../../MapControls/mobile/MobileMapSectionHeading"; +import { mobileMapThumbnails } from "../../MapControls/mobile/mobileMapThumbnails"; interface MobileLayerDrawerProps { open: boolean; @@ -37,12 +38,6 @@ const baseMapOptions = [ }, ]; -const SectionHeading = ({ children }: { children: string }) => ( -
- {children} -
-); - const MobileLayerDrawer = ({ open, mapStyle, @@ -62,10 +57,16 @@ const MobileLayerDrawer = ({ const modelLayersVisible = showForest || showDeadwood; return ( - +
- Map style + Map style
{baseMapOptions.map((option) => (
- Map layers + Map layers
- Feedback + Feedback
( - -); - const MobileMapControls = ({ activePanel, hidden = false, diff --git a/frontend/src/components/DeadwoodMap/mobile/MobileTimeDrawer.tsx b/frontend/src/components/DeadwoodMap/mobile/MobileTimeDrawer.tsx index f10d072d..6c4d8ef4 100644 --- a/frontend/src/components/DeadwoodMap/mobile/MobileTimeDrawer.tsx +++ b/frontend/src/components/DeadwoodMap/mobile/MobileTimeDrawer.tsx @@ -1,5 +1,5 @@ import type { WaybackItemWithMetadata } from "../../../hooks/useWaybackItems"; -import MobileBottomSheet from "./MobileBottomSheet"; +import MobileBottomSheet from "../../MapControls/mobile/MobileBottomSheet"; import MobileTimeCard from "./MobileTimeCard"; interface MobileTimeDrawerProps { diff --git a/frontend/src/components/MapControls/mobile/MapLayersIcon.tsx b/frontend/src/components/MapControls/mobile/MapLayersIcon.tsx new file mode 100644 index 00000000..df6d0bb0 --- /dev/null +++ b/frontend/src/components/MapControls/mobile/MapLayersIcon.tsx @@ -0,0 +1,32 @@ +const MapLayersIcon = () => ( + +); + +export default MapLayersIcon; diff --git a/frontend/src/components/DeadwoodMap/mobile/MobileBottomSheet.tsx b/frontend/src/components/MapControls/mobile/MobileBottomSheet.tsx similarity index 80% rename from frontend/src/components/DeadwoodMap/mobile/MobileBottomSheet.tsx rename to frontend/src/components/MapControls/mobile/MobileBottomSheet.tsx index 8479e763..0eeb50bb 100644 --- a/frontend/src/components/DeadwoodMap/mobile/MobileBottomSheet.tsx +++ b/frontend/src/components/MapControls/mobile/MobileBottomSheet.tsx @@ -1,23 +1,20 @@ -import { - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { PointerEvent as ReactPointerEvent, ReactNode } from "react"; import { Button } from "antd"; import { CloseOutlined } from "@ant-design/icons"; -type SheetSnap = "compact" | "expanded"; +export type MobileBottomSheetSnap = "compact" | "expanded"; interface MobileBottomSheetProps { children: ReactNode; open: boolean; title: string; onClose: () => void; + closeLabel?: string; compactRatio?: number; expandedRatio?: number; + initialSnap?: MobileBottomSheetSnap; + hideFrom?: "md" | "lg"; } const clamp = (value: number, min: number, max: number) => @@ -26,18 +23,26 @@ const clamp = (value: number, min: number, max: number) => const getViewportHeight = () => typeof window === "undefined" ? 844 : window.innerHeight; const CLOSE_THRESHOLD_PX = 160; +const MIN_CLOSE_HEIGHT_PX = 96; const CLOSE_ANIMATION_MS = 220; +const hideFromClass = { + md: "md:hidden", + lg: "min-[992px]:hidden", +} as const; const MobileBottomSheet = ({ children, open, title, onClose, + closeLabel = `Close ${title}`, compactRatio = 0.52, expandedRatio = 0.86, + initialSnap = "expanded", + hideFrom = "md", }: MobileBottomSheetProps) => { const [viewportHeight, setViewportHeight] = useState(getViewportHeight); - const [snap, setSnap] = useState("expanded"); + const [snap, setSnap] = useState(initialSnap); const [dragHeight, setDragHeight] = useState(null); const [isClosing, setIsClosing] = useState(false); const dragStartRef = useRef<{ @@ -56,17 +61,21 @@ const MobileBottomSheet = ({ useEffect(() => { if (open) { - setSnap("expanded"); + setSnap(initialSnap); setDragHeight(null); setIsClosing(false); } - }, [open]); + }, [initialSnap, open]); const compactHeight = useMemo( () => Math.round(viewportHeight * compactRatio), [compactRatio, viewportHeight], ); - const closeHeight = Math.max(180, compactHeight - CLOSE_THRESHOLD_PX); + const closeDragDistance = Math.min(CLOSE_THRESHOLD_PX, compactHeight * 0.4); + const closeHeight = Math.max( + MIN_CLOSE_HEIGHT_PX, + compactHeight - closeDragDistance, + ); const expandedHeight = useMemo( () => Math.round(viewportHeight * expandedRatio), [expandedRatio, viewportHeight], @@ -147,7 +156,7 @@ const MobileBottomSheet = ({ return (
-
-

- {title} -

-
+

+ {title} +

-
+
{children}
diff --git a/frontend/src/components/DeadwoodMap/mobile/MobileLayerRow.tsx b/frontend/src/components/MapControls/mobile/MobileLayerRow.tsx similarity index 53% rename from frontend/src/components/DeadwoodMap/mobile/MobileLayerRow.tsx rename to frontend/src/components/MapControls/mobile/MobileLayerRow.tsx index cc12db51..36da9bb1 100644 --- a/frontend/src/components/DeadwoodMap/mobile/MobileLayerRow.tsx +++ b/frontend/src/components/MapControls/mobile/MobileLayerRow.tsx @@ -1,47 +1,52 @@ +import type { ReactNode } from "react"; import { Switch } from "antd"; interface MobileLayerRowProps { - /** Small square thumbnail illustrating the layer */ - thumb: string; + thumb?: string; + icon?: ReactNode; title: string; description?: string; checked: boolean; - /** Legend swatch tying the row to the layer's color on the map */ swatchColor?: string; - /** Optional count shown after the title (e.g. number of points) */ count?: number; + secondaryAction?: ReactNode; + toggleLabel?: string; onChange: (checked: boolean) => void; } -/** - * Compact toggleable row for a data layer in the mobile settings drawer. - * The whole row is tappable; the switch is the visible state affordance - * and remains the keyboard/screen-reader control. - */ const MobileLayerRow = ({ thumb, + icon, title, description, checked, swatchColor, count, + secondaryAction, + toggleLabel, onChange, }: MobileLayerRowProps) => (
onChange(!checked)} > - - - + {(thumb || icon) && ( + + {thumb ? ( + + ) : ( + icon + )} + + )} {swatchColor && ( @@ -63,11 +68,16 @@ const MobileLayerRow = ({ )} + {secondaryAction && ( + event.stopPropagation()}> + {secondaryAction} + + )} event.stopPropagation()}>
diff --git a/frontend/src/components/DeadwoodMap/mobile/MobileLayerTile.tsx b/frontend/src/components/MapControls/mobile/MobileLayerTile.tsx similarity index 59% rename from frontend/src/components/DeadwoodMap/mobile/MobileLayerTile.tsx rename to frontend/src/components/MapControls/mobile/MobileLayerTile.tsx index 8977ad41..74d60b19 100644 --- a/frontend/src/components/DeadwoodMap/mobile/MobileLayerTile.tsx +++ b/frontend/src/components/MapControls/mobile/MobileLayerTile.tsx @@ -1,19 +1,12 @@ import { CheckOutlined } from "@ant-design/icons"; interface MobileLayerTileProps { - /** Thumbnail image source */ thumb: string; - /** Primary label */ title: string; - /** Whether this tile is currently active/selected */ active: boolean; onClick: () => void; } -/** - * Tappable thumbnail tile for single-select choices (e.g. base map style) - * in the mobile settings drawer. - */ const MobileLayerTile = ({ thumb, title, @@ -24,34 +17,29 @@ const MobileLayerTile = ({ type="button" onClick={onClick} aria-pressed={active} - className={`group relative min-w-0 overflow-hidden rounded-[18px] border bg-white p-0 text-left shadow-[0_1px_2px_rgba(15,23,42,0.06)] transition ${ + className={`group relative min-w-0 overflow-hidden rounded-2xl border bg-slate-100 p-0 text-left shadow-[0_1px_2px_rgba(15,23,42,0.06)] transition ${ active ? "border-emerald-700 ring-2 ring-emerald-700/20" : "border-slate-200 active:border-slate-300" }`} > - + + + + {title} + {active && ( )} - - - {title} - - ); diff --git a/frontend/src/components/MapControls/mobile/MobileMapSectionHeading.tsx b/frontend/src/components/MapControls/mobile/MobileMapSectionHeading.tsx new file mode 100644 index 00000000..f6f5ce94 --- /dev/null +++ b/frontend/src/components/MapControls/mobile/MobileMapSectionHeading.tsx @@ -0,0 +1,15 @@ +import type { ReactNode } from "react"; + +interface MobileMapSectionHeadingProps { + children: ReactNode; +} + +export default function MobileMapSectionHeading({ + children, +}: MobileMapSectionHeadingProps) { + return ( +
+ {children} +
+ ); +} diff --git a/frontend/src/components/DeadwoodMap/mobile/mobileMapThumbnails.ts b/frontend/src/components/MapControls/mobile/mobileMapThumbnails.ts similarity index 100% rename from frontend/src/components/DeadwoodMap/mobile/mobileMapThumbnails.ts rename to frontend/src/components/MapControls/mobile/mobileMapThumbnails.ts diff --git a/frontend/src/components/PriwaField/PriwaFieldMap.tsx b/frontend/src/components/PriwaField/PriwaFieldMap.tsx index 6f11b6ec..63543983 100644 --- a/frontend/src/components/PriwaField/PriwaFieldMap.tsx +++ b/frontend/src/components/PriwaField/PriwaFieldMap.tsx @@ -1,4 +1,4 @@ -import { Alert, App, Button, FloatButton, Tooltip } from "antd"; +import { Alert, App, Button, Tooltip } from "antd"; import { AimOutlined, EnvironmentOutlined, @@ -42,7 +42,9 @@ import PriwaPointListPanel from "./PriwaPointListPanel"; import PriwaOfflineStatus from "./PriwaOfflineStatus"; import PriwaBefallsgruppeEditor from "./PriwaBefallsgruppeEditor"; import PriwaBaseLayerControl from "./PriwaBaseLayerControl"; +import PriwaMapLayersSheet from "./PriwaMapLayersSheet"; import PriwaMobileFieldTools from "./PriwaMobileFieldTools"; +import PriwaMobilePrimaryActions from "./PriwaMobilePrimaryActions"; import PriwaOfflineAreaSelection from "./PriwaOfflineAreaSelection"; import PriwaOfflineMapPanel from "./PriwaOfflineMapPanel"; import PriwaReviewWorkbench, { @@ -89,6 +91,8 @@ interface PriwaFieldMapProps { projectName: string; warnkarteOverlay?: IPriwaWarnkarteOverlay | null; warnkarteVisible?: boolean; + warnkarteLoading?: boolean; + onWarnkarteVisibilityChange?: (visible: boolean) => void; additionalMapControl?: ReactNode; reviewDetailMode?: PriwaReviewDetailMode; mosaics?: IPriwaMosaic[]; @@ -117,6 +121,8 @@ interface PriwaFieldMapProps { onSyncNow?: () => Promise; } +type PriwaMapPanel = "layers" | "offline" | "trees"; + export default function PriwaFieldMap({ points, projectId, @@ -125,6 +131,8 @@ export default function PriwaFieldMap({ projectName, warnkarteOverlay = null, warnkarteVisible = true, + warnkarteLoading = false, + onWarnkarteVisibilityChange, additionalMapControl, reviewDetailMode, mosaics = [], @@ -198,7 +206,9 @@ export default function PriwaFieldMap({ const [focusedPointId, setFocusedPointId] = useState(null); const [reviewPointId, setReviewPointId] = useState(null); const [baseLayer, setBaseLayer] = useState("aerial"); - const [isOfflineMapModeActive, setOfflineMapModeActive] = useState(false); + const [activeMapPanel, setActiveMapPanel] = useState( + null, + ); const [viewportExtent3857, setViewportExtent3857] = useState(null); const mapInteraction = usePriwaMapInteractionMode(); @@ -226,6 +236,9 @@ export default function PriwaFieldMap({ mapRef, mapInteraction.isSelectingOfflineArea, ); + const isOfflineMapModeActive = activeMapPanel === "offline"; + const isMapLayersOpen = activeMapPanel === "layers"; + const isTreeListOpen = activeMapPanel === "trees"; useEffect(() => { isMobileRef.current = isMobile; @@ -589,6 +602,11 @@ export default function PriwaFieldMap({ ); }, [isMobile]); + const zoomToWarnkarteFromSheet = useCallback(() => { + zoomToWarnkarte(); + setActiveMapPanel(null); + }, [zoomToWarnkarte]); + const focusPointOnMap = useCallback( (point: IPriwaPoint) => { selectMatchedMosaicForPoint(point); @@ -714,10 +732,28 @@ export default function PriwaFieldMap({ const startOfflineAreaSelection = useCallback(() => { setDrawerOpen(false); setPointListOpen(false); - setOfflineMapModeActive(false); + setActiveMapPanel("offline"); setMode("select-offline-area"); }, [setMode]); + const cancelOfflineAreaSelection = useCallback(() => { + setMode("browse"); + setActiveMapPanel("offline"); + }, [setMode]); + + const dismissOfflineAreaSelection = useCallback(() => { + setMode("browse"); + setActiveMapPanel(null); + }, [setMode]); + + const toggleOfflineMapPanel = useCallback(() => { + setActiveMapPanel((current) => (current === "offline" ? null : "offline")); + }, []); + + const toggleMapLayersPanel = useCallback(() => { + setActiveMapPanel((current) => (current === "layers" ? null : "layers")); + }, []); + const requestDeferredOrientationPermission = useCallback( (event: PointerEvent) => { if (!userLocation.needsOrientationPermission) return; @@ -790,7 +826,7 @@ export default function PriwaFieldMap({ `Basiskarte offline gespeichert (${area.cachedTileCount}/${area.tileCount} Kacheln)`, ); setMode("browse"); - setOfflineMapModeActive(true); + setActiveMapPanel("offline"); } catch (error) { message.error( error instanceof Error @@ -841,8 +877,24 @@ export default function PriwaFieldMap({ >
- {mapInteraction.mode === "browse" && ( -
+ {mapInteraction.mode === "browse" && isMobile && ( +
+ setActiveMapPanel("trees")} + onCloseTreeList={() => setActiveMapPanel(null)} + onEditPoint={openPointForEditing} + onZoomToPoint={focusPointOnMap} + /> +
+ )} + + {mapInteraction.mode === "browse" && !isMobile && ( +
+
+ + ); +} diff --git a/frontend/src/components/PriwaField/PriwaMobileFieldTools.tsx b/frontend/src/components/PriwaField/PriwaMobileFieldTools.tsx index ccea415e..b7946fd2 100644 --- a/frontend/src/components/PriwaField/PriwaMobileFieldTools.tsx +++ b/frontend/src/components/PriwaField/PriwaMobileFieldTools.tsx @@ -2,6 +2,7 @@ import { SearchOutlined, UnorderedListOutlined } from "@ant-design/icons"; import { Button, Drawer, Empty, Input, Tooltip } from "antd"; import { useMemo, useState } from "react"; +import MapLayersIcon from "../MapControls/mobile/MapLayersIcon"; import { indexPriwaBefallsgruppenByTreeId } from "./priwaBefallsgruppenState"; import PriwaPointCompactList from "./PriwaPointCompactList"; import type { IPriwaBefallsgruppe, IPriwaPoint } from "./types"; @@ -9,6 +10,11 @@ import type { IPriwaBefallsgruppe, IPriwaPoint } from "./types"; interface PriwaMobileFieldToolsProps { points: IPriwaPoint[]; groups: IPriwaBefallsgruppe[]; + isLayersOpen: boolean; + isTreeListOpen: boolean; + onOpenLayers: () => void; + onOpenTreeList: () => void; + onCloseTreeList: () => void; onEditPoint: (point: IPriwaPoint) => void; onZoomToPoint: (point: IPriwaPoint) => void; } @@ -16,10 +22,14 @@ interface PriwaMobileFieldToolsProps { export default function PriwaMobileFieldTools({ points, groups, + isLayersOpen, + isTreeListOpen, + onOpenLayers, + onOpenTreeList, + onCloseTreeList, onEditPoint, onZoomToPoint, }: PriwaMobileFieldToolsProps) { - const [isTreeListOpen, setTreeListOpen] = useState(false); const [query, setQuery] = useState(""); const groupByTreeId = useMemo( () => indexPriwaBefallsgruppenByTreeId(groups), @@ -38,18 +48,30 @@ export default function PriwaMobileFieldTools({ const showPointOnMap = (point: IPriwaPoint) => { onZoomToPoint(point); - setTreeListOpen(false); + onCloseTreeList(); }; return ( <> + +
+ ); +} diff --git a/frontend/src/components/PriwaField/PriwaOfflineAreaSelection.test.ts b/frontend/src/components/PriwaField/PriwaOfflineAreaSelection.test.ts index c0c83d7b..bab3ac51 100644 --- a/frontend/src/components/PriwaField/PriwaOfflineAreaSelection.test.ts +++ b/frontend/src/components/PriwaField/PriwaOfflineAreaSelection.test.ts @@ -24,7 +24,9 @@ describe("PriwaOfflineAreaSelection", () => { total: 0, errorMessage: null, }, + isMobile: false, onCancel: () => undefined, + onDismiss: () => undefined, onConfirm: async () => undefined, }), ); @@ -37,4 +39,28 @@ describe("PriwaOfflineAreaSelection", () => { expect(html).toContain("Karte verschieben oder zoomen"); expect(html).toContain("Bereich herunterladen"); }); + + it("keeps mobile selection actions in a compact dismissible sheet", () => { + const html = renderToStaticMarkup( + createElement(PriwaOfflineAreaSelection, { + plan: null, + cacheState: { + isCaching: false, + cached: 0, + failed: 0, + total: 0, + errorMessage: null, + }, + isMobile: true, + onCancel: () => undefined, + onDismiss: () => undefined, + onConfirm: async () => undefined, + }), + ); + + expect(html).toContain("Offline-Bereich auswählen"); + expect(html).toContain('data-mobile-bottom-sheet-snap="compact"'); + expect(html).toContain('aria-label="Bereichsauswahl schließen"'); + expect(html).toContain('data-priwa-offline-selection-panel="true"'); + }); }); diff --git a/frontend/src/components/PriwaField/PriwaOfflineAreaSelection.tsx b/frontend/src/components/PriwaField/PriwaOfflineAreaSelection.tsx index b6e09518..3c8e12fd 100644 --- a/frontend/src/components/PriwaField/PriwaOfflineAreaSelection.tsx +++ b/frontend/src/components/PriwaField/PriwaOfflineAreaSelection.tsx @@ -1,5 +1,6 @@ import { Button, Progress } from "antd"; +import MobileBottomSheet from "../MapControls/mobile/MobileBottomSheet"; import { PRIWA_BASEMAP_MAX_AREA_KM2, PRIWA_BASEMAP_MAX_TILES, @@ -12,7 +13,9 @@ import type { IPriwaOfflineSelectionPlan } from "./usePriwaOfflineSelectionPlan" interface PriwaOfflineAreaSelectionProps { plan: IPriwaOfflineSelectionPlan | null; cacheState: IPriwaBasemapCacheState; + isMobile: boolean; onCancel: () => void; + onDismiss: () => void; onConfirm: (plan: IPriwaOfflineSelectionPlan) => Promise; } @@ -24,7 +27,9 @@ const selectionMaxHeight = `${Math.round( export default function PriwaOfflineAreaSelection({ plan, cacheState, + isMobile, onCancel, + onDismiss, onConfirm, }: PriwaOfflineAreaSelectionProps) { const isValid = @@ -39,6 +44,59 @@ export default function PriwaOfflineAreaSelection({ ) : 0; + const content = ( +
+
+ Karte verschieben oder zoomen +
+
+ Der klare Rahmen wird für Luftbild und topografische Karte gespeichert. +
+ {plan && ( +
+ {Math.round(plan.areaKm2 * 100)} ha ·{" "} + {plan.tileCount.toLocaleString("de-DE")} Kacheln + {!isValid && " · Bereich bitte verkleinern"} +
+ )} + {cacheState.isCaching && ( + 0 ? "exception" : "active"} + /> + )} + {cacheState.errorMessage && ( +
+ {cacheState.errorMessage} +
+ )} +
+ + +
+
+ ); + return (
- -
-
- Karte verschieben oder zoomen + {isMobile ? ( + + {content} + + ) : ( +
+ {content}
-
- Der klare Rahmen wird für Luftbild und topografische Karte - gespeichert. -
- {plan && ( -
- {Math.round(plan.areaKm2 * 100)} ha ·{" "} - {plan.tileCount.toLocaleString("de-DE")} Kacheln - {!isValid && " · Bereich bitte verkleinern"} -
- )} - {cacheState.isCaching && ( - 0 ? "exception" : "active"} - /> - )} - {cacheState.errorMessage && ( -
- {cacheState.errorMessage} -
- )} -
- - -
-
+ )}
); } diff --git a/frontend/src/components/PriwaField/PriwaOfflineMapPanel.test.ts b/frontend/src/components/PriwaField/PriwaOfflineMapPanel.test.ts index 6923a049..5c60b935 100644 --- a/frontend/src/components/PriwaField/PriwaOfflineMapPanel.test.ts +++ b/frontend/src/components/PriwaField/PriwaOfflineMapPanel.test.ts @@ -22,6 +22,7 @@ describe("PriwaOfflineMapPanel", () => { isSupported: true, needsRefresh: false, readyAreaCount: 0, + isMobile: false, onClose: () => undefined, onStartSelection: () => undefined, onClear: async () => undefined, @@ -62,6 +63,7 @@ describe("PriwaOfflineMapPanel", () => { isSupported: true, needsRefresh: true, readyAreaCount: 0, + isMobile: false, onClose: () => undefined, onStartSelection: () => undefined, onClear: async () => undefined, @@ -72,4 +74,26 @@ describe("PriwaOfflineMapPanel", () => { expect(html).toContain("auf dem iPhone ohne Netz funktionieren"); expect(html).toContain("Offline-Karten aktualisieren"); }); + + it("uses a compact dismissible sheet on mobile", () => { + const html = renderToStaticMarkup( + createElement(PriwaOfflineMapPanel, { + areas: [], + cacheState, + coverageRatio: 0.5, + isSupported: true, + needsRefresh: false, + readyAreaCount: 0, + isMobile: true, + onClose: () => undefined, + onStartSelection: () => undefined, + onClear: async () => undefined, + onRefresh: async () => undefined, + }), + ); + + expect(html).toContain('data-mobile-bottom-sheet-snap="compact"'); + expect(html).toContain('aria-label="Offline-Karten schließen"'); + expect(html).toContain("50 % der aktuellen Kartenansicht"); + }); }); diff --git a/frontend/src/components/PriwaField/PriwaOfflineMapPanel.tsx b/frontend/src/components/PriwaField/PriwaOfflineMapPanel.tsx index b4bd5dd1..d0844121 100644 --- a/frontend/src/components/PriwaField/PriwaOfflineMapPanel.tsx +++ b/frontend/src/components/PriwaField/PriwaOfflineMapPanel.tsx @@ -7,6 +7,7 @@ import { } from "@ant-design/icons"; import { Button, Progress, Typography } from "antd"; +import MobileBottomSheet from "../MapControls/mobile/MobileBottomSheet"; import type { IPriwaSyncSummary } from "./priwaOfflineSync"; import type { IPriwaOfflineBasemapArea } from "./priwaOfflineStore"; import type { IPriwaBasemapCacheState } from "./usePriwaOfflineBasemap"; @@ -19,6 +20,7 @@ interface PriwaOfflineMapPanelProps { needsRefresh: boolean; readyAreaCount: number; syncSummary?: IPriwaSyncSummary; + isMobile: boolean; onClose: () => void; onStartSelection: () => void; onClear: () => Promise; @@ -41,6 +43,7 @@ export default function PriwaOfflineMapPanel({ needsRefresh, readyAreaCount, syncSummary, + isMobile, onClose, onStartSelection, onClear, @@ -59,31 +62,14 @@ export default function PriwaOfflineMapPanel({ : 0; const syncLabel = getSyncLabel(syncSummary); - return ( -
-
-
- Offline-Karten -
- {Math.round(coverageRatio * 100)} % der aktuellen Kartenansicht sind - offline verfügbar. -
-
-
+ {content}
); } diff --git a/frontend/src/components/PriwaField/PriwaWarnkarteMapControls.tsx b/frontend/src/components/PriwaField/PriwaWarnkarteMapControls.tsx index bc4729e0..656243de 100644 --- a/frontend/src/components/PriwaField/PriwaWarnkarteMapControls.tsx +++ b/frontend/src/components/PriwaField/PriwaWarnkarteMapControls.tsx @@ -7,20 +7,16 @@ import { import { Button, Tooltip } from "antd"; interface PriwaWarnkarteVisibilityControlProps { - hasOverlay: boolean; isVisible: boolean; onToggle: () => void; } export function PriwaWarnkarteVisibilityControl({ - hasOverlay, isVisible, onToggle, }: PriwaWarnkarteVisibilityControlProps) { const label = isVisible ? "Warnkarte ausblenden" : "Warnkarte einblenden"; - if (!hasOverlay) return null; - return (
); }