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 (
-
+
{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 && (
+
)}
- {isMobile && (
-
- )}
- {!isMobile && !isPointListOpen && !isDrawerOpen && (
+ {!isPointListOpen && !isDrawerOpen && (
)}
- {isMobile &&
- !isDrawerOpen &&
- !isPointListOpen &&
- !isOfflineMapModeActive &&
- mapInteraction.mode === "browse" && (
- }
- tooltip={{ title: "Punkt aufnehmen", placement: "left" }}
- onClick={openNewPointDrawer}
- aria-label="Punkt aufnehmen"
- style={{
- right: "max(20px, calc(env(safe-area-inset-right, 0px) + 20px))",
- bottom:
- "max(20px, calc(env(safe-area-inset-bottom, 0px) + 20px))",
- }}
- />
- )}
+ userLocation.locateUser(true)}
+ />
{!isMobile && !isPointListOpen && (
setMode("browse")}
+ isMobile={isMobile}
+ onCancel={cancelOfflineAreaSelection}
+ onDismiss={dismissOfflineAreaSelection}
onConfirm={handleCacheBasemapArea}
/>
)}
@@ -1002,7 +1043,8 @@ export default function PriwaFieldMap({
isSupported={isOfflineBasemapSupported}
needsRefresh={offlineBasemapNeedsRefresh}
syncSummary={syncSummary}
- onClose={() => setOfflineMapModeActive(false)}
+ isMobile={isMobile}
+ onClose={() => setActiveMapPanel(null)}
onStartSelection={startOfflineAreaSelection}
onClear={handleClearBasemapArea}
onRefresh={handleRefreshBasemapAreas}
@@ -1010,6 +1052,22 @@ export default function PriwaFieldMap({
/>
)}
+ {mapInteraction.mode === "browse" && isMobile && (
+ setActiveMapPanel(null)}
+ onBaseLayerChange={setBaseLayer}
+ onWarnkarteVisibilityChange={(visible) =>
+ onWarnkarteVisibilityChange?.(visible)
+ }
+ onZoomToWarnkarte={zoomToWarnkarteFromSheet}
+ />
+ )}
+
{mapInteraction.mode === "browse" && (
{locationHintLabel && (
@@ -1025,7 +1083,7 @@ export default function PriwaFieldMap({
isSupported={isOfflineBasemapSupported}
needsRefresh={offlineBasemapNeedsRefresh}
syncSummary={syncSummary}
- onToggle={() => setOfflineMapModeActive((current) => !current)}
+ onToggle={toggleOfflineMapPanel}
/>
)}
diff --git a/frontend/src/components/PriwaField/PriwaMapLayersSheet.test.ts b/frontend/src/components/PriwaField/PriwaMapLayersSheet.test.ts
new file mode 100644
index 00000000..83c85bdd
--- /dev/null
+++ b/frontend/src/components/PriwaField/PriwaMapLayersSheet.test.ts
@@ -0,0 +1,49 @@
+import { createElement } from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import { describe, expect, it } from "vitest";
+
+import PriwaMapLayersSheet from "./PriwaMapLayersSheet";
+
+describe("PriwaMapLayersSheet", () => {
+ it("combines base maps and Warnkarte in one compact mobile sheet", () => {
+ const html = renderToStaticMarkup(
+ createElement(PriwaMapLayersSheet, {
+ open: true,
+ baseLayer: "aerial",
+ warnkarteAvailable: true,
+ warnkarteLoading: false,
+ warnkarteVisible: true,
+ onClose: () => undefined,
+ onBaseLayerChange: () => undefined,
+ onWarnkarteVisibilityChange: () => undefined,
+ onZoomToWarnkarte: () => undefined,
+ }),
+ );
+
+ expect(html).toContain('data-mobile-bottom-sheet-snap="compact"');
+ expect(html).toContain("Luftbild");
+ expect(html).toContain("Topografische Karte");
+ expect(html).toContain("Warnkarte");
+ expect(html).toContain('aria-label="Zur Warnkarte zoomen"');
+ expect(html).toContain('aria-label="Kartenebenen schließen"');
+ });
+
+ it("keeps the Warnkarte toggle available when data must be loaded", () => {
+ const html = renderToStaticMarkup(
+ createElement(PriwaMapLayersSheet, {
+ open: true,
+ baseLayer: "topographic",
+ warnkarteAvailable: false,
+ warnkarteLoading: false,
+ warnkarteVisible: true,
+ onClose: () => undefined,
+ onBaseLayerChange: () => undefined,
+ onWarnkarteVisibilityChange: () => undefined,
+ onZoomToWarnkarte: () => undefined,
+ }),
+ );
+
+ expect(html).toContain("Beim Aktivieren laden");
+ expect(html).not.toContain('aria-label="Zur Warnkarte zoomen"');
+ });
+});
diff --git a/frontend/src/components/PriwaField/PriwaMapLayersSheet.tsx b/frontend/src/components/PriwaField/PriwaMapLayersSheet.tsx
new file mode 100644
index 00000000..a5282552
--- /dev/null
+++ b/frontend/src/components/PriwaField/PriwaMapLayersSheet.tsx
@@ -0,0 +1,117 @@
+import { Button, Tooltip } from "antd";
+import { FullscreenOutlined, WarningOutlined } from "@ant-design/icons";
+
+import MobileBottomSheet from "../MapControls/mobile/MobileBottomSheet";
+import MobileLayerRow from "../MapControls/mobile/MobileLayerRow";
+import MobileLayerTile from "../MapControls/mobile/MobileLayerTile";
+import MobileMapSectionHeading from "../MapControls/mobile/MobileMapSectionHeading";
+import { mobileMapThumbnails } from "../MapControls/mobile/mobileMapThumbnails";
+import type { PriwaBaseLayer } from "./types";
+
+interface PriwaMapLayersSheetProps {
+ open: boolean;
+ baseLayer: PriwaBaseLayer;
+ warnkarteAvailable: boolean;
+ warnkarteLoading: boolean;
+ warnkarteVisible: boolean;
+ onClose: () => void;
+ onBaseLayerChange: (layer: PriwaBaseLayer) => void;
+ onWarnkarteVisibilityChange: (visible: boolean) => void;
+ onZoomToWarnkarte: () => void;
+}
+
+const baseLayerOptions: Array<{
+ value: PriwaBaseLayer;
+ label: string;
+ image: string;
+}> = [
+ {
+ value: "aerial",
+ label: "Luftbild",
+ image: mobileMapThumbnails.satellite,
+ },
+ {
+ value: "topographic",
+ label: "Topografische Karte",
+ image: mobileMapThumbnails.streets,
+ },
+];
+
+export default function PriwaMapLayersSheet({
+ open,
+ baseLayer,
+ warnkarteAvailable,
+ warnkarteLoading,
+ warnkarteVisible,
+ onClose,
+ onBaseLayerChange,
+ onWarnkarteVisibilityChange,
+ onZoomToWarnkarte,
+}: PriwaMapLayersSheetProps) {
+ const isWarnkarteActive = warnkarteAvailable && warnkarteVisible;
+
+ return (
+
+
+
+ Basiskarte
+
+ {baseLayerOptions.map((option) => (
+ onBaseLayerChange(option.value)}
+ />
+ ))}
+
+
+
+
+ Zusätzliche Ebene
+ }
+ title="Warnkarte"
+ description={
+ warnkarteLoading
+ ? "Warnkarte wird geladen"
+ : warnkarteAvailable
+ ? "Gefahrenbereiche"
+ : "Beim Aktivieren laden"
+ }
+ checked={isWarnkarteActive}
+ toggleLabel={
+ isWarnkarteActive
+ ? "Warnkarte ausblenden"
+ : "Warnkarte einblenden"
+ }
+ onChange={onWarnkarteVisibilityChange}
+ secondaryAction={
+ isWarnkarteActive ? (
+
+ }
+ aria-label="Zur Warnkarte zoomen"
+ onClick={onZoomToWarnkarte}
+ />
+
+ ) : null
+ }
+ />
+
+
+
+ );
+}
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 (
<>
+
+ }
+ onClick={onOpenLayers}
+ aria-label="Kartenebenen öffnen"
+ aria-pressed={isLayersOpen}
+ />
+
}
- onClick={() => setTreeListOpen(true)}
+ onClick={onOpenTreeList}
aria-label="Baumliste öffnen"
aria-pressed={isTreeListOpen}
/>
@@ -60,7 +82,7 @@ export default function PriwaMobileFieldTools({
placement="bottom"
height="78dvh"
open={isTreeListOpen}
- onClose={() => setTreeListOpen(false)}
+ onClose={onCloseTreeList}
rootClassName="priwa-layer-sheet-root"
className="min-[992px]:hidden"
styles={{
diff --git a/frontend/src/components/PriwaField/PriwaMobilePrimaryActions.tsx b/frontend/src/components/PriwaField/PriwaMobilePrimaryActions.tsx
new file mode 100644
index 00000000..c7fde557
--- /dev/null
+++ b/frontend/src/components/PriwaField/PriwaMobilePrimaryActions.tsx
@@ -0,0 +1,59 @@
+import {
+ AimOutlined,
+ EnvironmentOutlined,
+ PlusOutlined,
+} from "@ant-design/icons";
+import { Button, Tooltip } from "antd";
+
+interface PriwaMobilePrimaryActionsProps {
+ hidden: boolean;
+ isLocating: boolean;
+ locationActive: boolean;
+ locationLabel: string;
+ onAddPoint: () => void;
+ onLocate: () => void;
+}
+
+const actionClass =
+ "pointer-events-auto !flex !h-[52px] !w-[52px] !min-w-[52px] !items-center !justify-center shadow-lg shadow-slate-950/20";
+
+export default function PriwaMobilePrimaryActions({
+ hidden,
+ isLocating,
+ locationActive,
+ locationLabel,
+ onAddPoint,
+ onLocate,
+}: PriwaMobilePrimaryActionsProps) {
+ if (hidden) return null;
+
+ return (
+
+
+ }
+ aria-label="Käferbaum aufnehmen"
+ onClick={onAddPoint}
+ />
+
+
+ : }
+ aria-label="Aktuelle Position aktivieren"
+ aria-pressed={locationActive}
+ onClick={onLocate}
+ />
+
+
+ );
+}
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}
+
+ )}
+
+
+ Abbrechen
+
+ {
+ if (plan) void onConfirm(plan);
+ }}
+ >
+ Bereich herunterladen
+
+
+
+ );
+
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}
-
- )}
-
-
- Abbrechen
-
- {
- if (plan) void onConfirm(plan);
- }}
- >
- Bereich herunterladen
-
-
-
+ )}
);
}
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.
-
-
-
}
- aria-label="Offline-Karten schließen"
- onClick={onClose}
- />
+ const content = (
+
+
+ {Math.round(coverageRatio * 100)} % der aktuellen Kartenansicht sind
+ offline verfügbar.
-
{hasAreas && (
-
+
{readyAreaCount} von {areas.length} Bereichen bereit · {totalAreaHa}{" "}
ha
@@ -164,6 +150,42 @@ export default function PriwaOfflineMapPanel({
Offline-Karten werden von diesem Browser nicht unterstützt.
)}
+
+ );
+
+ if (isMobile) {
+ return (
+
+ {content}
+
+ );
+ }
+
+ return (
+
+
+ Offline-Karten
+ }
+ aria-label="Offline-Karten schließen"
+ onClick={onClose}
+ />
+
+ {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 (
fetchPriwaWarnkarteVersions(projectId, token!),
});
+ const activeOverlay = activeQuery.data ?? null;
+ const refetchActiveOverlay = activeQuery.refetch;
const requireToken = () => {
if (!token) throw new Error("Die Anmeldung ist abgelaufen.");
@@ -119,17 +121,30 @@ export function usePriwaWarnkarte(projectId: string, canManage: boolean) {
setSelectedOverlay(null);
}, []);
+ const activateOverlay = useCallback(async () => {
+ if (selectedOverlay?.features.length || activeOverlay?.features.length) {
+ setVisible(true);
+ return true;
+ }
+
+ const result = await refetchActiveOverlay();
+ const isAvailable = !!result.data?.features.length;
+ setVisible(isAvailable);
+ return isAvailable;
+ }, [activeOverlay, refetchActiveOverlay, selectedOverlay]);
+
return {
- activeOverlay: activeQuery.data ?? null,
- displayedOverlay: selectedOverlay ?? activeQuery.data ?? null,
+ activeOverlay,
+ displayedOverlay: selectedOverlay ?? activeOverlay,
isVisible,
- overlayError: activeQuery.error,
+ isLoadingOverlay: activeQuery.isFetching,
selectedOverlay,
versions: versionsQuery.data ?? [],
versionsError: versionsQuery.error,
isLoadingVersions: versionsQuery.isLoading,
clearSelectedVersion,
- toggleVisibility: () => setVisible((visible) => !visible),
+ activateOverlay,
+ setVisibility: setVisible,
importFile,
archiveVersion,
restoreVersion,
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 4103b89b..03da1b04 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -406,49 +406,10 @@
}
}
-.priwa-add-point-fab {
- width: 52px !important;
- height: 52px !important;
- color: #2d6b3f !important;
- box-shadow:
- 0 8px 24px rgb(15 23 42 / 18%),
- 0 2px 6px rgb(15 23 42 / 14%) !important;
-}
-
-.priwa-add-point-fab .ant-float-btn-body {
- background: #ffffff !important;
- border: 1px solid rgb(226 232 240) !important;
-}
-
-.priwa-add-point-fab:hover .ant-float-btn-body,
-.priwa-add-point-fab:focus-visible .ant-float-btn-body {
- background: #f8fafc !important;
- border-color: #2d6b3f !important;
-}
-
-.priwa-add-point-fab .ant-float-btn-body,
-.priwa-add-point-fab .ant-float-btn-content,
-.priwa-add-point-fab .ant-float-btn-icon {
- width: 100%;
- height: 100%;
-}
-
-.priwa-add-point-fab .ant-float-btn-content,
-.priwa-add-point-fab .ant-float-btn-icon {
- display: flex;
- align-items: center;
- justify-content: center;
-}
-
.priwa-comment-form-item.ant-form-item {
margin-bottom: 30px !important;
}
-.priwa-add-point-fab .anticon {
- font-size: 20px;
- color: #2d6b3f !important;
-}
-
.priwa-point-table-row-focused > td,
.priwa-point-table-row-focused:hover > td {
background: #ecfdf5 !important;
diff --git a/frontend/src/pages/PriwaField.tsx b/frontend/src/pages/PriwaField.tsx
index b55a806a..a83cba91 100644
--- a/frontend/src/pages/PriwaField.tsx
+++ b/frontend/src/pages/PriwaField.tsx
@@ -5,7 +5,7 @@ import { usePriwaMosaics } from "../components/PriwaField/usePriwaMosaics";
import { usePriwaBefallsgruppen } from "../components/PriwaField/usePriwaBefallsgruppen";
import { usePriwaProjectMemberships } from "../hooks/usePriwaProjectMemberships";
import { useIsMobile } from "../hooks/useIsMobile";
-import { Alert, Button, Result, Spin } from "antd";
+import { Alert, App, Button, Result, Spin } from "antd";
import PriwaWarnkarteAdminPanel from "../components/PriwaField/PriwaWarnkarteAdminPanel";
import PriwaWarnkarteLegend from "../components/PriwaField/PriwaWarnkarteLegend";
import {
@@ -17,6 +17,7 @@ import type { PriwaReviewDetailMode } from "../components/PriwaField/PriwaReview
import { useCallback, useMemo, useState } from "react";
export default function PriwaField() {
+ const { message } = App.useApp();
const [isWarnkarteAdminOpen, setWarnkarteAdminOpen] = useState(false);
const { isOnline, serviceWorker } = usePriwaOfflineStatus();
const {
@@ -60,11 +61,31 @@ export default function PriwaField() {
activeMembership?.projectId ?? "",
canManageWarnkarte,
);
+ const activateWarnkarte = warnkarte.activateOverlay;
+ const setWarnkarteVisibility = warnkarte.setVisibility;
const { clearSelectedVersion } = warnkarte;
const closeWarnkarteAdmin = useCallback(() => {
clearSelectedVersion();
setWarnkarteAdminOpen(false);
}, [clearSelectedVersion]);
+ const handleWarnkarteVisibilityChange = useCallback(
+ async (visible: boolean) => {
+ if (!visible) {
+ setWarnkarteVisibility(false);
+ return;
+ }
+
+ const isAvailable = await activateWarnkarte();
+ if (!isAvailable) {
+ message.error({
+ content: "Warnkarte konnte nicht geladen werden",
+ duration: 3,
+ key: "priwa-warnkarte-load-error",
+ });
+ }
+ },
+ [activateWarnkarte, message, setWarnkarteVisibility],
+ );
const warnkarteDetailMode = useMemo(
() =>
canManageWarnkarte && isWarnkarteAdminOpen
@@ -169,12 +190,14 @@ export default function PriwaField() {
}
const hasWarnkarte = !!warnkarte.displayedOverlay?.features.length;
+ const isWarnkarteActive = hasWarnkarte && warnkarte.isVisible;
const warnkarteControls = (
<>
+ void handleWarnkarteVisibilityChange(!isWarnkarteActive)
+ }
/>
{canManageWarnkarte && (
+ void handleWarnkarteVisibilityChange(visible)
+ }
additionalMapControl={warnkarteControls}
reviewDetailMode={warnkarteDetailMode}
isLoadingPoints={isLoadingPoints || isRefetching}
@@ -229,14 +256,6 @@ export default function PriwaField() {
sourceDate={warnkarte.displayedOverlay?.source_date ?? null}
/>
)}
- {warnkarte.overlayError && (
-
- )}
);
}