From f9e03a1695f881b6fbf81fbcbddf8d13197f7f99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nick=20Rosenl=C3=B6cher?= Date: Mon, 19 Oct 2026 17:02:15 +0200 Subject: [PATCH] feat(t5): Mini-Waben-Marker auf der Standkarte (T5b B2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Marker als SVG-Wabe mit sieben Zellen (schlechtestes Volk in der Mitte, Kreuz bzw. „!“ zusätzlich zur Farbe, Zähler ab 8 Völkern), nur var(--…)-Farben, 48 × 52 px, per Tastatur erreichbar; Enter öffnet das Popup mit Zusammenfassung und „Stand öffnen“, Fokus zurück zum Marker. - Eine Übersicht je Stand über denselben Query-Key wie die Standseiten (60 s staleTime), Liste und Offline-Hinweis bleiben ohne Leaflet nutzbar. - leaflet-theme.css: 16-px-Text, 44-px-Bedienelemente mit deutschen Namen, Kacheln in Dunkel gedämpft, Sonne ungefiltert; alte Marker-PNGs entfernt. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../public/leaflet/marker-icon-2x.png | Bin 2464 -> 0 bytes .../public/leaflet/marker-icon.png | Bin 1466 -> 0 bytes .../public/leaflet/marker-shadow.png | Bin 618 -> 0 bytes .../src/app/(app)/apiaries/map/page.spec.tsx | 158 ++++++++++ .../src/app/(app)/apiaries/map/page.tsx | 95 +++++- .../src/components/apiary-map.spec.tsx | 278 ++++++++++++++++++ .../src/components/apiary-map.tsx | 148 ++++++++-- .../src/lib/honeycomb/mini-comb-svg.spec.ts | 100 +++++++ .../src/lib/honeycomb/mini-comb-svg.ts | 131 +++++++++ .../src/styles/baukasten-guard.spec.ts | 3 + ApiCore-Frontend/src/styles/leaflet-theme.css | 134 +++++++++ 11 files changed, 1013 insertions(+), 34 deletions(-) delete mode 100644 ApiCore-Frontend/public/leaflet/marker-icon-2x.png delete mode 100644 ApiCore-Frontend/public/leaflet/marker-icon.png delete mode 100644 ApiCore-Frontend/public/leaflet/marker-shadow.png create mode 100644 ApiCore-Frontend/src/app/(app)/apiaries/map/page.spec.tsx create mode 100644 ApiCore-Frontend/src/components/apiary-map.spec.tsx create mode 100644 ApiCore-Frontend/src/lib/honeycomb/mini-comb-svg.spec.ts create mode 100644 ApiCore-Frontend/src/lib/honeycomb/mini-comb-svg.ts create mode 100644 ApiCore-Frontend/src/styles/leaflet-theme.css diff --git a/ApiCore-Frontend/public/leaflet/marker-icon-2x.png b/ApiCore-Frontend/public/leaflet/marker-icon-2x.png deleted file mode 100644 index 88f9e501888c9c6cb29ad340d9a888627dd1b6d8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2464 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 e-I|>x8~*?ZhF3snGbtJX0000cOl4 diff --git a/ApiCore-Frontend/public/leaflet/marker-icon.png b/ApiCore-Frontend/public/leaflet/marker-icon.png deleted file mode 100644 index 950edf24677ded147df13b26f91baa2b0fa70513..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1466 zcmV;r1x5OaP)P001cn1^@s6z>|W`000GnNklGNuHDcIX17Zdjl&3`L?0sTjIws<{((Dh&g-s0<@jYQyl?D*X^?%13;ml^gy> ziMrY_^1WI=(g@LMizu=zCoA>C`6|QEq1eV92k*7m>G65*&@&6)aC&e}G zI)pf-Za|N`DT&Cn1J|o`19mumxW~hiKiKyc-P`S@q)rdTo84@QI@;0yXrG%9uhI>A zG5QHb6s4=<6xy{1 z@NMxEkryp{LS44%z$3lP^cX!9+2-;CTt3wM4(k*#C{aiIiLuB>jJj;KPhPzIC00bL zU3a#;aJld94lCW=`4&aAy8M7PY=HQ>O%$YEP4c4UY#CRxfgbE~(|uiI=YS8q;O9y6 zmIkXzR`}p7ti|PrM3a}WMnR=3NVnWdAAR>b9X@)DKL6=YsvmH%?I24wdq?Gh54_;# z$?_LvgjEdspdQlft#4CQ z`2Zyvy?*)N1Ftw|{_hakhG9WjS?Az@I@+IZ8JbWewR!XUK4&6346+d#~gsE0SY(LX8&JfY>Aj)RxGy96nwhs2rv zzW6pTnMpFkDSkT*a*6Dx|u@ds6ISVn0@^RmIsKZ5Y;bazbc;tTSq(kg(=481ODrPyNB6n z-$+U}(w$m6U6H$w17Bw+wDaFIe~GvNMYvnw31MpY0eQKT9l>SU``8k7w4)z!GZKMI z#_cEKq7k~i%nlK@6c-K?+R;B#5$?T#YpKD`t_4bAs^#E+@5QW$@OX3*`;(#{U^d-vY)&xEE>n5lYl&T?Amke9$Lam@{1K@O ze*LXqlKQHiv=gx+V^Cbb2?z@ISBQ*3amF;9UJ3SBg(N|710TLamQmYZ&Qjn2LuO<* zCZlB4n%@pc&7NNnY1}x+NWpHlq`OJEo|`aYN9<`RBUB+79g;>dgb6YlfN#kGL?lO_ z!6~M^7sOnbsUkKk<@Ysie&`G>ruxH&Mgy&8;i=A zB9OO!xR{AyODw>DS-q5YM{0ExFEAzt zm>RdS+ssW(-8|?xr0(?$vBVB*%(xDLtq3Hf0I5yFm<_g=W2`QWAax{1rWVH=I!VrP zs(rTFX@W#t$hXNvbgX`gK&^w_YD;CQ!B@e0QbLIWaKAXQe2-kkloo;{iF#6}z!4=W zi$giRj1{ zt;2w`VSCF#WE&*ev7jpsC=6175@(~nTE2;7M-L((0bH@yG}-TB$R~WXd?tA$s3|%y zA`9$sA(>F%J3ioz<-LJl*^o1|w84l>HBR`>3l9c8$5Xr@xCiIQ7{x$fMCzOk_-M=% z+{a_Q#;42`#KfUte@$NT77uaTz?b-fBe)1s5XE$yA79fm?KqM^VgLXD07*qoM6N<$ Ef<_J(9smFU diff --git a/ApiCore-Frontend/src/app/(app)/apiaries/map/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/apiaries/map/page.spec.tsx new file mode 100644 index 0000000..2a0fdda --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/apiaries/map/page.spec.tsx @@ -0,0 +1,158 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, within } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactElement } from "react"; +import ApiaryMapPage from "./page"; +import { fetchApiaries } from "@/lib/apiaries/api"; +import { colonyOverviewKey, fetchColonyOverview } from "@/lib/apiaries/field-api"; +import type { Apiary } from "@/lib/apiaries/types"; +import type { ColonyOverviewRow } from "@/lib/apiaries/field-types"; +import type { MapStand } from "@/components/apiary-map"; + +vi.mock("@/lib/apiaries/api", () => ({ + fetchApiaries: vi.fn(), +})); +vi.mock("@/lib/apiaries/field-api", async (importOriginal) => ({ + ...(await importOriginal()), + fetchColonyOverview: vi.fn(), +})); +vi.mock("@/lib/auth/auth-context", () => ({ + useCurrentUserId: () => "user-1", + useAuth: () => ({ hasPermission: () => true }), +})); +// The Leaflet chunk: a stand-in that shows what the page hands to the map. +vi.mock("next/dynamic", () => ({ + default: () => + function MapStandIn({ stands }: { stands: readonly MapStand[] }) { + return ( +
    + {stands.map((stand) => ( +
  • + {stand.apiary.name}: {stand.models === null ? "loading" : stand.models.map((model) => model.light).join(",")} +
  • + ))} +
+ ); + }, +})); + +function apiaryOf(id: string, name: string, overrides: Partial = {}): Apiary { + return { + id, + name, + description: null, + location: null, + latitude: 50.1, + longitude: 8.6, + address: null, + status: "ACTIVE", + notes: null, + publicLocationLabel: null, + createdAt: "", + updatedAt: "", + deactivatedAt: null, + ...overrides, + }; +} + +function overviewRow(id: string, code: string, overrides: Partial = {}): ColonyOverviewRow { + return { + id, + code, + status: "ACTIVE", + strength: 6, + occupiedFrames: 9, + broodStatus: "GOOD", + feedStatus: "GOOD", + healthStatus: "GOOD", + lastInspectionDate: new Date().toISOString(), + inspectionDueOn: null, + segment: "SCHWARMZEIT", + nextStepTitle: null, + openWarnings: 0, + openCritical: 0, + ...overrides, + }; +} + +function renderWithClient(ui: ReactElement) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return { client, ...render({ui}) }; +} + +const onLine = Object.getOwnPropertyDescriptor(Navigator.prototype, "onLine"); + +describe("ApiaryMapPage", () => { + beforeEach(() => { + vi.mocked(fetchApiaries).mockReset(); + vi.mocked(fetchColonyOverview).mockReset(); + vi.mocked(fetchApiaries).mockResolvedValue([ + apiaryOf("a1", "Nordhang"), + apiaryOf("a2", "Wiese"), + apiaryOf("a3", "Ohne Ort", { latitude: null, longitude: null }), + ]); + vi.mocked(fetchColonyOverview).mockImplementation(async (apiaryId) => ({ + apiaryId, + inspectionsVisible: true, + rows: + apiaryId === "a1" + ? [ + overviewRow("c1", "V-1"), + overviewRow("c2", "V-2", { status: "QUEENLESS", lastInspectionDate: null, inspectionDueOn: "2026-01-01" }), + overviewRow("c3", "V-3", { feedStatus: "WEAK" }), + ] + : [], + })); + }); + + afterEach(() => { + if (onLine) Object.defineProperty(Navigator.prototype, "onLine", onLine); + }); + + it("loads one colony overview per apiary on the map and hands the cells to the map", async () => { + renderWithClient(); + const map = await screen.findByTestId("map"); + expect(await within(map).findByText("Nordhang: green,red,yellow")).toBeInTheDocument(); + expect(within(map).getByText("Wiese:")).toBeInTheDocument(); + expect(vi.mocked(fetchColonyOverview).mock.calls.map(([apiaryId]) => apiaryId).sort()).toEqual(["a1", "a2"]); + }); + + it("lists the apiaries below the map as the alternative", async () => { + renderWithClient(); + const list = await screen.findByRole("list", { name: "Stände auf der Karte" }); + const items = within(list).getAllByRole("listitem"); + expect(items).toHaveLength(2); + expect(within(items[0]).getByRole("link", { name: "Nordhang" })).toHaveAttribute("href", "/apiaries/a1"); + expect(await within(items[0]).findByText(/1 Durchsicht fällig/)).toBeInTheDocument(); + expect(items[0]).toHaveTextContent("3 Völker · 1 Gut · 1 Achtung · 1 Kritisch"); + expect(items[1]).toHaveTextContent("Noch keine Völker"); + }); + + it("shows the offline hint when the browser is offline", async () => { + Object.defineProperty(Navigator.prototype, "onLine", { configurable: true, get: () => false }); + renderWithClient(); + const hint = await screen.findByText("Die Karte braucht Internet. Die Stände stehen unten als Liste."); + expect(hint).toHaveAttribute("role", "status"); + }); + + it("shows no offline hint when no apiary is on the map", async () => { + Object.defineProperty(Navigator.prototype, "onLine", { configurable: true, get: () => false }); + vi.mocked(fetchApiaries).mockResolvedValue([apiaryOf("a3", "Ohne Ort", { latitude: null, longitude: null })]); + renderWithClient(); + expect(await screen.findByText("Noch kein Bienenstand auf der Karte")).toBeInTheDocument(); + expect(screen.queryByText(/Die Karte braucht Internet/)).toBeNull(); + }); + + it("keeps the overviews fresh for a minute (no refetch per visit)", async () => { + const { client } = renderWithClient(); + await screen.findByText("Nordhang: green,red,yellow"); + const query = client.getQueryCache().find({ queryKey: colonyOverviewKey("a1") }); + expect((query?.options as { staleTime?: number }).staleTime).toBe(60_000); + }); + + it("shows no offline hint when online", async () => { + renderWithClient(); + await screen.findByTestId("map"); + expect(screen.queryByText(/Die Karte braucht Internet/)).toBeNull(); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/apiaries/map/page.tsx b/ApiCore-Frontend/src/app/(app)/apiaries/map/page.tsx index 830eae0..33a5c55 100644 --- a/ApiCore-Frontend/src/app/(app)/apiaries/map/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/apiaries/map/page.tsx @@ -2,11 +2,19 @@ import Link from "next/link"; import dynamic from "next/dynamic"; +import { useId } from "react"; import { MapPin } from "lucide-react"; -import { useQuery } from "@tanstack/react-query"; +import { useQueries, useQuery } from "@tanstack/react-query"; import { fetchApiaries } from "@/lib/apiaries/api"; +import { colonyOverviewKey, fetchColonyOverview } from "@/lib/apiaries/field-api"; import { Apiary } from "@/lib/apiaries/types"; +import { useCurrentUserId } from "@/lib/auth/auth-context"; +import { useConnectivity } from "@/lib/offline/connectivity"; +import type { MapStand } from "@/components/apiary-map"; +import { CombSummary, cellModelsOf } from "@/components/honeycomb/apiary-comb"; +import { summarizeColonies } from "@/lib/honeycomb/cell-state"; import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; import { EmptyState } from "@/components/empty-state"; import { PageHeader } from "@/components/page-header"; import { QueryState } from "@/components/query-state"; @@ -16,17 +24,80 @@ const ApiaryMap = dynamic( { ssr: false, loading: () =>

Karte wird geladen…

}, ); -function hasCoordinates( - apiary: Apiary, -): apiary is Apiary & { latitude: number; longitude: number } { +const OVERVIEW_STALE_MS = 60_000; + +type LocatedApiary = Apiary & { latitude: number; longitude: number }; + +function hasCoordinates(apiary: Apiary): apiary is LocatedApiary { return apiary.latitude !== null && apiary.longitude !== null; } +interface ListedStand extends MapStand { + /** The overview could not be loaded (no right, offline without pack): no summary. */ + failed: boolean; +} + +/** + * One colony overview per apiary on the map, under the same key as the apiary pages (spec 7, + * no second source); offline from the pack. + */ +function useMapStands(located: readonly LocatedApiary[]): ListedStand[] { + const userId = useCurrentUserId(); + const results = useQueries({ + queries: located.map((apiary) => ({ + queryKey: colonyOverviewKey(apiary.id), + queryFn: () => fetchColonyOverview(apiary.id, userId), + networkMode: "always" as const, + // The map is a look-around page: a minute old is fresh enough, no refetch on every visit. + staleTime: OVERVIEW_STALE_MS, + })), + }); + return located.map((apiary, index) => { + const overview = results[index]?.data; + return { apiary, models: overview ? cellModelsOf(overview) : null, failed: results[index]?.isError ?? false }; + }); +} + +/** „Stände auf der Karte“: the list alternative to the map, also when the tiles do not load (spec 7.3). */ +function StandList({ stands }: { stands: readonly ListedStand[] }) { + const headingId = useId(); + return ( + + +

+ Stände auf der Karte +

+
    + {stands.map(({ apiary, models, failed }) => ( +
  • + + {apiary.name} + + {models === null ? ( + failed ? null :

    Völker werden geladen…

    + ) : models.length === 0 ? ( +

    Noch keine Völker an diesem Stand.

    + ) : ( + + )} +
  • + ))} +
+
+
+ ); +} + export default function ApiaryMapPage() { const apiariesQuery = useQuery({ queryKey: ["apiaries"], queryFn: () => fetchApiaries(), }); + const stands = useMapStands(apiariesQuery.data?.filter(hasCoordinates) ?? []); + const isOffline = useConnectivity() === "offline"; return (
@@ -41,10 +112,15 @@ export default function ApiaryMapPage() { } /> + {isOffline && stands.length > 0 ? ( +

+ Die Karte braucht Internet. Die Stände stehen unten als Liste. +

+ ) : null} + {(apiaries) => { - const located = apiaries.filter(hasCoordinates); - if (located.length === 0) { + if (stands.length === 0) { return ( - {located.length < apiaries.length ? ( + {stands.length < apiaries.length ? (

- {located.length} von {apiaries.length} Bienenständen haben Koordinaten. Die übrigen + {stands.length} von {apiaries.length} Bienenständen haben Koordinaten. Die übrigen fehlen auf der Karte.

) : null} - + + ); }} diff --git a/ApiCore-Frontend/src/components/apiary-map.spec.tsx b/ApiCore-Frontend/src/components/apiary-map.spec.tsx new file mode 100644 index 0000000..158a7ea --- /dev/null +++ b/ApiCore-Frontend/src/components/apiary-map.spec.tsx @@ -0,0 +1,278 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { forwardRef, useImperativeHandle, useRef, useState, type ReactNode } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { DivIcon, MarkerOptions } from "leaflet"; +import type { Apiary } from "@/lib/apiaries/types"; +import type { CellModel } from "@/lib/honeycomb/cell-state"; +import type { StatusLight } from "@/lib/colonies/status-light"; +import { ApiaryMap, type MapStand } from "./apiary-map"; + +const fakeMap = vi.hoisted(() => ({ + zoom: 9, + onZoomEnd: null as null | (() => void), + containerProps: {} as Record, +})); + +// Leaflet itself (L.divIcon) is real; react-leaflet is a thin stand-in that renders the marker element. +vi.mock("react-leaflet", () => { + const Marker = forwardRef(function Marker( + { icon, title, keyboard, children }, + ref, + ) { + const element = useRef(null); + const [isOpen, setOpen] = useState(false); + useImperativeHandle(ref, () => ({ getElement: () => element.current })); + const options = (icon as DivIcon).options; + return ( + <> +
setOpen(true)} + // The divIcon html, inserted as Leaflet does. + dangerouslySetInnerHTML={{ __html: String(options.html) }} + /> + {isOpen ? ( + // Leaflet's popup shell: its own (English) close button around the portal content. + + ) : null} + + ); + }); + return { + // Like react-leaflet: the children render once the map (here: its container) exists. + MapContainer: function MapContainer({ children, ...props }: { children: ReactNode }) { + fakeMap.containerProps = props; + const [isReady, setReady] = useState(false); + return ( +
void (node && !isReady && setReady(true))}> + {isReady ? children : null} +
+ ); + }, + TileLayer: () => null, + ZoomControl: ({ zoomInTitle, zoomOutTitle }: { zoomInTitle: string; zoomOutTitle: string }) => ( +
+ + + − +
+ ), + Marker, + Popup: ({ children }: { children: ReactNode }) =>
{children}
, + useMapEvents: (handlers: { zoomend?: () => void }) => { + fakeMap.onZoomEnd = handlers.zoomend ?? null; + return { getZoom: () => fakeMap.zoom, getContainer: () => document.querySelector('[data-testid="map"]') }; + }, + }; +}); + +function apiaryOf(id: string, name: string): Apiary & { latitude: number; longitude: number } { + return { + id, + name, + description: null, + location: null, + latitude: 50.1, + longitude: 8.6, + address: null, + status: "ACTIVE", + notes: null, + publicLocationLabel: null, + createdAt: "", + updatedAt: "", + deactivatedAt: null, + }; +} + +function modelOf(code: string, light: StatusLight, isDue = false): CellModel { + return { + colonyId: `c-${code}`, + code, + light, + reasons: light === "green" ? [] : ["Futter schwach"], + rim: 1, + daysSince: 3, + winterRest: false, + isDue, + name: code, + }; +} + +const NORDHANG: MapStand = { + apiary: apiaryOf("a1", "Nordhang"), + models: [ + modelOf("V-1", "green"), + modelOf("V-2", "red", true), + modelOf("V-3", "yellow", true), + modelOf("V-4", "green"), + ], +}; + +describe("ApiaryMap", () => { + it("a marker carries an aria-label and opens the popup with „Stand öffnen“", async () => { + const user = userEvent.setup(); + render(); + const marker = screen.getByTestId("marker"); + expect(marker).toHaveAttribute("aria-label", "Stand Nordhang: 4 Völker, 1 kritisch, 1 Achtung"); + expect(marker).toHaveAttribute("title", "Nordhang"); + expect(marker).toHaveAttribute("tabindex", "0"); + + await user.click(marker); + const popup = screen.getByTestId("popup"); + expect(within(popup).getByRole("heading", { name: "Nordhang" })).toBeInTheDocument(); + expect(popup).toHaveTextContent("4 Völker · 2 Gut · 1 Achtung · 1 Kritisch · 2 Durchsichten fällig"); + expect(within(popup).getByRole("button", { name: "Stand öffnen" })).toHaveAttribute("href", "/apiaries/a1"); + }); + + it("the marker is a 48 x 52 divIcon with the mini comb and the name chip", () => { + render(); + const marker = screen.getByTestId("marker"); + expect(marker).toHaveClass("stand-marker"); + expect(marker.dataset.iconSize).toBe("48,52"); + expect(marker.dataset.iconAnchor).toBe("24,26"); + expect(marker.dataset.popupAnchor).toBe("0,-24"); + expect(marker.querySelectorAll(".mc-cell")).toHaveLength(4); + expect(marker.querySelector(".stand-marker-name")).toHaveTextContent("Nordhang"); + }); + + it("escapes the apiary name in the marker html", () => { + render(Wiese & Hang"), models: [] }]} />); + const marker = screen.getByTestId("marker"); + expect(marker.querySelector("b")).toBeNull(); + expect(marker.querySelector(".stand-marker-name")).toHaveTextContent("Wiese & Hang"); + }); + + it("shows a single wax cell before the overview has loaded", () => { + render(); + const marker = screen.getByTestId("marker"); + expect(marker.querySelectorAll(".mc-wax")).toHaveLength(1); + expect(marker.querySelectorAll(".mc-cell")).toHaveLength(0); + expect(marker).toHaveAttribute("aria-label", "Stand Nordhang"); + }); + + it("the aria-label follows when the overview arrives", () => { + const { rerender } = render(); + rerender(); + expect(screen.getByTestId("marker")).toHaveAttribute("aria-label", "Stand Nordhang: 4 Völker, 1 kritisch, 1 Achtung"); + }); + + it("shows the names under the markers from zoom 9 on only", () => { + fakeMap.zoom = 9; + render(); + expect(screen.getByTestId("map")).toHaveAttribute("data-stand-names"); + fakeMap.zoom = 8; + fakeMap.onZoomEnd?.(); + expect(screen.getByTestId("map")).not.toHaveAttribute("data-stand-names"); + fakeMap.zoom = 9; + }); +}); + +describe("ApiaryMap keyboard and names", () => { + it("moves focus to „Stand öffnen“ when the popup opens and names the close button in German", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByTestId("marker")); + expect(screen.getByRole("button", { name: "Stand öffnen" })).toHaveFocus(); + expect(screen.getByRole("button", { name: "Popup schließen" })).toBeInTheDocument(); + }); + + it("gives focus back to the marker when the popup closes with focus inside", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByTestId("marker")); + await user.click(screen.getByRole("button", { name: "Popup schließen" })); + expect(screen.queryByRole("button", { name: "Stand öffnen" })).toBeNull(); + expect(screen.getByTestId("marker")).toHaveFocus(); + }); + + it("leaves focus where it is when the popup closes while focus is outside", async () => { + const user = userEvent.setup(); + render( + <> + + + , + ); + await user.click(screen.getByTestId("marker")); + const outside = screen.getByRole("button", { name: "Draußen" }); + outside.focus(); + fireEvent.click(screen.getByRole("button", { name: "Popup schließen" })); + expect(outside).toHaveFocus(); + }); + + it("names the zoom buttons in German instead of Leaflet's default control", () => { + render(); + expect(fakeMap.containerProps.zoomControl).toBe(false); + expect(screen.getByRole("button", { name: "Vergrößern" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Verkleinern" })).toBeInTheDocument(); + }); + + it("glides (zoom, fade, inertia) only at full motion", () => { + render(); + // jsdom: no reduced motion, default level „Voll“. + expect(fakeMap.containerProps).toMatchObject({ zoomAnimation: true, fadeAnimation: true, inertia: true }); + }); +}); + +describe("leaflet-theme.css", () => { + it("lets forced colours switch off the dark tile filter (same selector, later rule)", () => { + const css = readFileSync(path.resolve(__dirname, "../styles/leaflet-theme.css"), "utf8"); + const forced = css.slice(css.indexOf("@media (forced-colors: active)")); + expect(forced).toMatch(/\.dark:not\(\[data-contrast="sun"\]\)\s+\.leaflet-tile-pane\s*\{\s*filter:\s*none/); + }); + + const css = readFileSync(path.resolve(__dirname, "../styles/leaflet-theme.css"), "utf8"); + + it("is imported by the map component", () => { + const component = readFileSync(path.resolve(__dirname, "apiary-map.tsx"), "utf8"); + expect(component).toMatch(/import\s+["']@\/styles\/leaflet-theme\.css["']/); + }); + + it("dims the tiles in dark mode without inverting them, and leaves them alone in sun", () => { + expect(css).toMatch(/\.dark:not\(\[data-contrast="sun"\]\)\s+\.leaflet-tile-pane\s*\{\s*filter:\s*brightness\(0\.8\)\s+contrast\(1\.1\);?\s*\}/); + expect(css).not.toMatch(/invert\(/); + }); + + it("draws the marker rim 3 px in sun", () => { + expect(css).toMatch(/\[data-contrast="sun"\][^{]*\.mc-bg\s*\{\s*stroke-width:\s*3px/); + }); + + it("styles the popup with tokens and gives the close button a 44 px target", () => { + expect(css).toMatch(/\.leaflet-popup-content-wrapper,\s*\.leaflet-popup-tip\s*\{[^}]*background:\s*var\(--popover\)[^}]*color:\s*var\(--popover-foreground\)/); + expect(css).toMatch(/\.leaflet-popup-close-button::after\s*\{[^}]*width:\s*44px;[^}]*height:\s*44px/); + }); + + it("uses no colour literal, no transition and no text below 13 px", () => { + expect(css).not.toMatch(/#[0-9a-f]{3,8}\b|rgba?\(|oklch\(/i); + expect(css).not.toMatch(/transition|animation/); + expect(css).not.toMatch(/font-size:\s*(?:[0-9]|1[0-2])px/); + }); + + it("keeps the marker readable in forced colours by its marks", () => { + expect(css).toMatch(/@media \(forced-colors: active\)[\s\S]*\.mc-mark[\s\S]*CanvasText/); + }); +}); diff --git a/ApiCore-Frontend/src/components/apiary-map.tsx b/ApiCore-Frontend/src/components/apiary-map.tsx index 68f350b..3b3b6dc 100644 --- a/ApiCore-Frontend/src/components/apiary-map.tsx +++ b/ApiCore-Frontend/src/components/apiary-map.tsx @@ -1,48 +1,146 @@ "use client"; import Link from "next/link"; +import { useEffect, useLayoutEffect, useMemo, useRef, type RefObject } from "react"; import L from "leaflet"; import "leaflet/dist/leaflet.css"; -import { MapContainer, Marker, Popup, TileLayer } from "react-leaflet"; -import { Apiary } from "@/lib/apiaries/types"; +import "@/styles/leaflet-theme.css"; +import { MapContainer, Marker, Popup, TileLayer, ZoomControl, useMapEvents } from "react-leaflet"; +import { CombSummary } from "@/components/honeycomb/apiary-comb"; +import { Button } from "@/components/ui/button"; +import type { Apiary } from "@/lib/apiaries/types"; +import { summarizeColonies, type CellModel } from "@/lib/honeycomb/cell-state"; +import { markerLabel, miniCombSvg, type MiniCombSummary } from "@/lib/honeycomb/mini-comb-svg"; +import { useEffectiveMotionLevel } from "@/lib/motion/motion-preference"; -const markerIcon = L.icon({ - iconUrl: "/leaflet/marker-icon.png", - iconRetinaUrl: "/leaflet/marker-icon-2x.png", - shadowUrl: "/leaflet/marker-shadow.png", - iconSize: [25, 41], - iconAnchor: [12, 41], - popupAnchor: [1, -34], - shadowSize: [41, 41], -}); +/** From this zoom on the apiary names stand under the markers (spec 7.1). */ +const NAMES_FROM_ZOOM = 9; -interface ApiaryMapProps { - apiaries: (Apiary & { latitude: number; longitude: number })[]; +export interface MapStand { + apiary: Apiary & { latitude: number; longitude: number }; + /** The colonies as cells; null until the overview has loaded. */ + models: readonly CellModel[] | null; } -export function ApiaryMap({ apiaries }: ApiaryMapProps) { - const center: [number, number] = [apiaries[0].latitude, apiaries[0].longitude]; +interface ApiaryMapProps { + stands: readonly MapStand[]; +} + +function escapeHtml(text: string): string { + return text.replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`); +} + +function miniSummaryOf(models: readonly CellModel[] | null): MiniCombSummary | null { + return models === null ? null : { lights: models.map((model) => model.light), total: models.length }; +} + +/** Hides the names under the markers below zoom 9, so they do not overlap. */ +function ZoomNames() { + const update = () => { + map.getContainer().toggleAttribute("data-stand-names", map.getZoom() >= NAMES_FROM_ZOOM); + }; + const map = useMapEvents({ zoomend: update }); + useLayoutEffect(update, [map]); + return null; +} + +interface PopupBodyProps extends MapStand { + marker: RefObject; +} + +/** + * The popup content (spec 7.2). react-leaflet mounts it only while the popup is open: focus goes + * to „Stand öffnen“, and back to the marker on close if it was inside the popup. + */ +function PopupBody({ apiary, models, marker }: PopupBodyProps) { + const body = useRef(null); + useEffect(() => { + const shell = body.current?.closest(".leaflet-popup"); + const owner = marker.current; + shell?.querySelector(".leaflet-popup-close-button")?.setAttribute("aria-label", "Popup schließen"); + body.current?.querySelector('[data-slot="button"]')?.focus({ preventScroll: true }); + return () => { + // Without fade Leaflet has already removed the popup: focus fell to the body from inside it. + const active = document.activeElement; + if (active === null || active === document.body || shell?.contains(active)) { + owner?.getElement()?.focus({ preventScroll: true }); + } + }; + }, [marker]); + + return ( +
+

{apiary.name}

+ {models === null ? null : models.length === 0 ? ( +

Noch keine Völker an diesem Stand.

+ ) : ( + + )} + +
+ ); +} + +function StandMarker({ apiary, models }: MapStand) { + const marker = useRef(null); + const summary = miniSummaryOf(models); + const label = markerLabel(apiary.name, summary); + const svg = miniCombSvg(summary); + // A new icon object makes Leaflet redraw the marker; memoised, that happens only when comb or name change. + const icon = useMemo( + () => + L.divIcon({ + html: `${svg}${escapeHtml(apiary.name)}`, + className: "stand-marker", + iconSize: [48, 52], + iconAnchor: [24, 26], + popupAnchor: [0, -24], + }), + [svg, apiary.name], + ); + // Runs after the marker is on the map (child effects first) and again after every new icon. + useEffect(() => { + marker.current?.getElement()?.setAttribute("aria-label", label); + }, [label, icon]); + + return ( + + + + + + ); +} + +/** The apiaries on the map, each a mini comb (spec 7). The list under the map is the page's. */ +export function ApiaryMap({ stands }: ApiaryMapProps) { + const center: [number, number] = [stands[0].apiary.latitude, stands[0].apiary.longitude]; + // Zoom, fade and inertia glide only at full motion (spec 17.2); read when the map is created. + // shortcut: Leaflet animates keyboard pans and popup auto-pan regardless; it has no option for them. + const animate = useEffectiveMotionLevel() === "full"; return ( - {apiaries.map((apiary) => ( - - - - {apiary.name} - - {apiary.location ?

{apiary.location}

: null} -
-
+ + + {stands.map((stand) => ( + ))}
); diff --git a/ApiCore-Frontend/src/lib/honeycomb/mini-comb-svg.spec.ts b/ApiCore-Frontend/src/lib/honeycomb/mini-comb-svg.spec.ts new file mode 100644 index 0000000..fd3b072 --- /dev/null +++ b/ApiCore-Frontend/src/lib/honeycomb/mini-comb-svg.spec.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; +import type { StatusLight } from "@/lib/colonies/status-light"; +import { markerLabel, miniCombSvg, type MiniCombSummary } from "./mini-comb-svg"; + +function summaryOf(lights: StatusLight[]): MiniCombSummary { + return { lights, total: lights.length }; +} + +function parse(svg: string): SVGSVGElement { + const doc = new DOMParser().parseFromString(svg, "image/svg+xml"); + expect(doc.querySelector("parsererror")).toBeNull(); + return doc.documentElement as unknown as SVGSVGElement; +} + +/** Cell and wax places in drawing order: the middle first, then the six around it. */ +function places(svg: string): string[] { + return [...parse(svg).querySelectorAll(".mc-cell, .mc-wax")].map((place) => + place.classList.contains("mc-wax") ? "wax" : (place.getAttribute("data-light") ?? "?"), + ); +} + +describe("miniCombSvg", () => { + it("draws a 48 x 52 hexagon background in --card with a --foreground rim", () => { + const svg = parse(miniCombSvg(summaryOf(["green"]))); + expect(svg.getAttribute("viewBox")).toBe("0 0 48 52"); + expect(svg.getAttribute("aria-hidden")).toBe("true"); + const background = svg.querySelector(".mc-bg"); + expect(background?.getAttribute("style")).toMatch(/fill:\s*var\(--card\)/); + expect(background?.getAttribute("style")).toMatch(/stroke:\s*var\(--foreground\)/); + }); + + it("the worst colony sits in the middle", () => { + expect(places(miniCombSvg(summaryOf(["green", "yellow", "red", "green"])))[0]).toBe("red"); + expect(places(miniCombSvg(summaryOf(["green", "yellow", "green"])))[0]).toBe("yellow"); + }); + + it("free places are outlines", () => { + expect(places(miniCombSvg(summaryOf(["green", "red"])))).toEqual(["red", "green", "wax", "wax", "wax", "wax", "wax"]); + const wax = parse(miniCombSvg(summaryOf(["green"]))).querySelector(".mc-wax"); + expect(wax?.getAttribute("style")).toMatch(/fill:\s*var\(--muted\)/); + expect(wax?.getAttribute("style")).toMatch(/stroke:\s*var\(--input\)/); + }); + + it("more than seven colonies add a count chip with the seven worst", () => { + const lights: StatusLight[] = [...Array(7).fill("green"), "red", "yellow", "red", "yellow", "yellow"]; + const svg = miniCombSvg(summaryOf(lights)); + expect(places(svg)).toEqual(["red", "red", "yellow", "yellow", "yellow", "green", "green"]); + expect(parse(svg).querySelector(".mc-count")?.textContent).toBe("12"); + expect(parse(miniCombSvg(summaryOf(Array(7).fill("green")))).querySelector(".mc-count")).toBeNull(); + }); + + it("a cell never says its state by colour alone: red and yellow carry a mark", () => { + const svg = parse(miniCombSvg(summaryOf(["red", "yellow", "green"]))); + const marks = [...svg.querySelectorAll(".mc-mark")].map((mark) => mark.getAttribute("data-light")); + expect(marks).toEqual(["red", "yellow"]); + }); + + it("before the overview has loaded it is a single wax cell without a number", () => { + const svg = miniCombSvg(null); + expect(places(svg)).toEqual(["wax"]); + expect(parse(svg).querySelector(".mc-count")).toBeNull(); + }); + + it("an apiary without colonies shows seven wax places", () => { + expect(places(miniCombSvg({ lights: [], total: 0 }))).toEqual(Array(7).fill("wax")); + }); + + it("uses only var(--…) colours and no hex", () => { + const lights: StatusLight[] = ["red", "yellow", "green", ...Array(6).fill("green")]; + for (const svg of [miniCombSvg(summaryOf(lights)), miniCombSvg(null)]) { + expect(svg).not.toMatch(/#[0-9a-f]{3,8}\b|rgba?\(|oklch\(|hsla?\(/i); + const colours = [...svg.matchAll(/(?:fill|stroke):\s*([^;"]+)/g)].map((match) => match[1].trim()); + expect(colours.length).toBeGreaterThanOrEqual(4); + expect(colours.filter((colour) => !/^var\(--[\w-]+\)$/.test(colour) && colour !== "none")).toEqual([]); + } + expect(miniCombSvg(summaryOf(lights))).toMatch(/var\(--condition-critical\)/); + }); + + it("returns the same string for the same light counts", () => { + expect(miniCombSvg(summaryOf(["green", "red"]))).toBe(miniCombSvg(summaryOf(["red", "green"]))); + }); +}); + +describe("markerLabel", () => { + it("markerLabel names colonies, critical and attention counts", () => { + expect(markerLabel("Nordhang", summaryOf(["green", "green", "red", "yellow", "green", "green", "green"]))).toBe( + "Stand Nordhang: 7 Völker, 1 kritisch, 1 Achtung", + ); + }); + + it("says when all colonies are fine, and counts one colony in the singular", () => { + expect(markerLabel("Wiese", summaryOf(["green"]))).toBe("Stand Wiese: 1 Volk, alle in Ordnung"); + expect(markerLabel("Wiese", summaryOf(["red", "red"]))).toBe("Stand Wiese: 2 Völker, 2 kritisch"); + }); + + it("names only the apiary before the overview has loaded, and says when it has no colonies", () => { + expect(markerLabel("Wiese", null)).toBe("Stand Wiese"); + expect(markerLabel("Wiese", { lights: [], total: 0 })).toBe("Stand Wiese: keine Völker"); + }); +}); diff --git a/ApiCore-Frontend/src/lib/honeycomb/mini-comb-svg.ts b/ApiCore-Frontend/src/lib/honeycomb/mini-comb-svg.ts new file mode 100644 index 0000000..e4ace4f --- /dev/null +++ b/ApiCore-Frontend/src/lib/honeycomb/mini-comb-svg.ts @@ -0,0 +1,131 @@ +import type { StatusLight } from "@/lib/colonies/status-light"; + +/** The lights of an apiary's colonies, for its marker on the map (spec 7.1). */ +export interface MiniCombSummary { + lights: readonly StatusLight[]; + total: number; +} + +/** Marker box 48 x 52 (spec 7.1): background hexagon radius 24 around (24, 26), cells radius 7. */ +const CENTER = { x: 24, y: 26 }; +const BACKGROUND_RADIUS = 24; +const CELL_RADIUS = 7; +const PLACES = 7; +/** Centre distance of two neighbour cells: they touch at √3·r, plus a 1 px gap. */ +const NEIGHBOUR = Math.sqrt(3) * CELL_RADIUS + 1; +/** The six places around the middle, clockwise from top right. */ +const RING_ANGLES = [-60, 0, 60, 120, 180, 240]; + +const SEVERITY: Record = { red: 0, yellow: 1, green: 2 }; +const TONE: Record = { red: "critical", yellow: "average", green: "good" }; + +function round(value: number): string { + return String(Math.round(value * 100) / 100); +} + +/** A pointy-top hexagon path around (x, y). */ +function hexPath(x: number, y: number, radius: number): string { + const points = [-90, -30, 30, 90, 150, 210].map((degrees) => { + const angle = (degrees * Math.PI) / 180; + return `${round(x + radius * Math.cos(angle))} ${round(y + radius * Math.sin(angle))}`; + }); + return `M${points.join("L")}Z`; +} + +function placeOf(index: number): { x: number; y: number } { + if (index === 0) return CENTER; + const angle = (RING_ANGLES[index - 1] * Math.PI) / 180; + return { x: CENTER.x + NEIGHBOUR * Math.cos(angle), y: CENTER.y + NEIGHBOUR * Math.sin(angle) }; +} + +/** Red carries a cross, yellow an exclamation mark: the state never rests on colour alone. */ +function markPath(light: StatusLight, x: number, y: number): string | null { + if (light === "red") { + return `M${round(x - 2.5)} ${round(y - 2.5)}L${round(x + 2.5)} ${round(y + 2.5)}M${round(x + 2.5)} ${round(y - 2.5)}L${round(x - 2.5)} ${round(y + 2.5)}`; + } + if (light === "yellow") { + return `M${round(x)} ${round(y - 3.2)}V${round(y + 0.6)}M${round(x)} ${round(y + 2.9)}V${round(y + 3)}`; + } + return null; +} + +function cellSvg(light: StatusLight, index: number): string { + const { x, y } = placeOf(index); + const tone = TONE[light]; + const cell = ``; + const mark = markPath(light, x, y); + return mark + ? `${cell}` + : cell; +} + +function waxSvg(index: number): string { + const { x, y } = placeOf(index); + return ``; +} + +/** „12“ under the rosette when more colonies stand there than it has places. */ +function countChipSvg(total: number): string { + const width = 8 + 7.5 * String(total).length; + return ( + `` + + `${total}` + ); +} + +function buildSvg(summary: MiniCombSummary | null): string { + let places: string; + if (summary === null) { + places = waxSvg(0); + } else { + const worstFirst = [...summary.lights].sort((a, b) => SEVERITY[a] - SEVERITY[b]).slice(0, PLACES); + places = Array.from({ length: PLACES }, (_, index) => + index < worstFirst.length ? cellSvg(worstFirst[index], index) : waxSvg(index), + ).join(""); + } + const chip = summary !== null && summary.total > PLACES ? countChipSvg(summary.total) : ""; + return ( + `` + ); +} + +function countOf(lights: readonly StatusLight[], light: StatusLight): number { + return lights.filter((entry) => entry === light).length; +} + +// One string per light count (spec 7.5); the rosette depends on nothing else. +const svgCache = new Map(); + +/** + * The marker's mini comb (spec 7.1): a rosette of seven cells on a hexagon in --card, the worst + * colony in the middle, free places as wax outlines, a count chip beyond seven colonies. Colours + * only as var(--…), so theme and sun apply without a rebuild. `null` = overview not loaded yet. + */ +export function miniCombSvg(summary: MiniCombSummary | null): string { + const key = + summary === null + ? "loading" + : `${countOf(summary.lights, "red")}/${countOf(summary.lights, "yellow")}/${countOf(summary.lights, "green")}/${summary.total}`; + let svg = svgCache.get(key); + if (svg === undefined) { + svg = buildSvg(summary); + svgCache.set(key, svg); + } + return svg; +} + +/** The marker's accessible name: „Stand Nordhang: 7 Völker, 1 kritisch, 1 Achtung“. */ +export function markerLabel(name: string, summary: MiniCombSummary | null): string { + const stand = `Stand ${name}`; + if (summary === null) return stand; + if (summary.total === 0) return `${stand}: keine Völker`; + const parts = [`${summary.total} ${summary.total === 1 ? "Volk" : "Völker"}`]; + const red = countOf(summary.lights, "red"); + const yellow = countOf(summary.lights, "yellow"); + if (red > 0) parts.push(`${red} kritisch`); + if (yellow > 0) parts.push(`${yellow} Achtung`); + if (red === 0 && yellow === 0) parts.push("alle in Ordnung"); + return `${stand}: ${parts.join(", ")}`; +} diff --git a/ApiCore-Frontend/src/styles/baukasten-guard.spec.ts b/ApiCore-Frontend/src/styles/baukasten-guard.spec.ts index b866cbf..bc38588 100644 --- a/ApiCore-Frontend/src/styles/baukasten-guard.spec.ts +++ b/ApiCore-Frontend/src/styles/baukasten-guard.spec.ts @@ -599,6 +599,9 @@ const DATA_VALUE_FILES = [ "src/components/honeycomb/apiary-comb-card.tsx", "src/components/honeycomb/comb-legend.tsx", "src/styles/comb.css", + // Task B2: the mini combs on the map stand still (spec 7, 17.4). + "src/components/apiary-map.tsx", + "src/styles/leaflet-theme.css", ]; /** Overlays of the kit (spec 17.4): enter with --motion-enter, leave with --motion-fade and --ease-in. */ const OVERLAY_FILES = ["sheet", "dialog", "dropdown-menu", "select"].map((name) => `src/components/ui/${name}.tsx`); diff --git a/ApiCore-Frontend/src/styles/leaflet-theme.css b/ApiCore-Frontend/src/styles/leaflet-theme.css new file mode 100644 index 0000000..76c0390 --- /dev/null +++ b/ApiCore-Frontend/src/styles/leaflet-theme.css @@ -0,0 +1,134 @@ +/* Leaflet in the token style (spec 7). Colours only from tokens; the mini combs never move (spec 17.4). */ + +/* App type instead of Leaflet's 12 px Helvetica. */ +.leaflet-container { + font: inherit; + font-size: 1rem; + background: var(--muted); +} + +.leaflet-container a { + color: var(--primary-ink); +} + +.leaflet-container .leaflet-control-attribution { + background: var(--card); + color: var(--card-foreground); + font-size: var(--text-xs); +} + +/* Zoom buttons: 44 px for gloves, in card colours. */ +.leaflet-container .leaflet-bar a { + width: 44px; + height: 44px; + line-height: 44px; + background: var(--card); + color: var(--card-foreground); + border-color: var(--border); +} + +/* Dark: dim the tiles, never invert them (water and forest would change colour). Sun: tiles unchanged. */ +.dark:not([data-contrast="sun"]) .leaflet-tile-pane { + filter: brightness(0.8) contrast(1.1); +} + +/* The marker: no box of its own, the svg is the whole shape. */ +.stand-marker { + background: none; + border: none; +} + +.stand-marker svg { + display: block; + overflow: visible; +} + +:root[data-contrast="sun"] .stand-marker .mc-bg { + stroke-width: 3px; +} + +/* The apiary name under the marker, from zoom 9 (ZoomNames sets data-stand-names). */ +.stand-marker-name { + position: absolute; + top: 62px; + left: 50%; + transform: translateX(-50%); + padding: 0 6px; + border: 1px solid var(--foreground); + border-radius: 6px; + background: var(--card); + color: var(--card-foreground); + font-size: var(--text-xs); + font-weight: 600; + line-height: 1.4; + white-space: nowrap; + pointer-events: none; +} + +.leaflet-container:not([data-stand-names]) .stand-marker-name { + display: none; +} + +/* Popup: popover colours with an ink rim. */ +.leaflet-popup-content-wrapper, +.leaflet-popup-tip { + background: var(--popover); + color: var(--popover-foreground); + border: 2px solid var(--foreground); + box-shadow: var(--shadow-elevated); +} + +.leaflet-popup-content { + margin: 12px 16px; + font-size: 1rem; + line-height: 1.5; +} + +.leaflet-popup-content p { + margin: 0; +} + +.leaflet-popup-content a[data-slot="button"] { + color: var(--foreground); +} + +.leaflet-container a.leaflet-popup-close-button { + top: 4px; + right: 4px; + color: var(--popover-foreground); +} + +/* The close button stays small to look at, but takes taps on 44 px. */ +.leaflet-popup-close-button::after { + content: ""; + position: absolute; + top: 50%; + left: 50%; + width: 44px; + height: 44px; + transform: translate(-50%, -50%); +} + +@media (forced-colors: active) { + .stand-marker .mc-bg, + .stand-marker .mc-cell, + .stand-marker .mc-wax, + .stand-marker .mc-chip { + fill: Canvas !important; + stroke: CanvasText !important; + } + + /* Fills are gone; the cross and the exclamation mark still tell red from yellow. */ + .stand-marker .mc-mark { + stroke: CanvasText !important; + } + + .stand-marker .mc-count { + fill: CanvasText !important; + } + + /* Same selector as the dark rule above, so this later rule wins. */ + .dark:not([data-contrast="sun"]) .leaflet-tile-pane { + filter: none; + } +}