feat(t5): Mini-Waben-Marker auf der Standkarte (T5b B2)

- 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) <[email protected]>
This commit is contained in:
Nick.RosenloecherandClaude Opus 5.5 committed 2026-10-19 17:02:15 +02:00
1 parent 1688e77a89
commit f9e03a1695
11 files changed
+1013 -34

No files matched your search

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 618 B

@@ -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<typeof import("@/lib/apiaries/field-api")>()),
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 (
<ul data-testid="map">
{stands.map((stand) => (
<li key={stand.apiary.id}>
{stand.apiary.name}: {stand.models === null ? "loading" : stand.models.map((model) => model.light).join(",")}
</li>
))}
</ul>
);
},
}));
function apiaryOf(id: string, name: string, overrides: Partial<Apiary> = {}): 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> = {}): 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(<QueryClientProvider client={client}>{ui}</QueryClientProvider>) };
}
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(<ApiaryMapPage />);
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(<ApiaryMapPage />);
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(<ApiaryMapPage />);
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(<ApiaryMapPage />);
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(<ApiaryMapPage />);
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(<ApiaryMapPage />);
await screen.findByTestId("map");
expect(screen.queryByText(/Die Karte braucht Internet/)).toBeNull();
});
});
@@ -2,11 +2,19 @@
import Link from "next/link"; import Link from "next/link";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { useId } from "react";
import { MapPin } from "lucide-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 { fetchApiaries } from "@/lib/apiaries/api";
import { colonyOverviewKey, fetchColonyOverview } from "@/lib/apiaries/field-api";
import { Apiary } from "@/lib/apiaries/types"; 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 { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { EmptyState } from "@/components/empty-state"; import { EmptyState } from "@/components/empty-state";
import { PageHeader } from "@/components/page-header"; import { PageHeader } from "@/components/page-header";
import { QueryState } from "@/components/query-state"; import { QueryState } from "@/components/query-state";
@@ -16,17 +24,80 @@ const ApiaryMap = dynamic(
{ ssr: false, loading: () => <p role="status" className="text-sm text-muted-foreground">Karte wird geladen…</p> }, { ssr: false, loading: () => <p role="status" className="text-sm text-muted-foreground">Karte wird geladen…</p> },
); );
function hasCoordinates( const OVERVIEW_STALE_MS = 60_000;
apiary: Apiary,
): apiary is Apiary & { latitude: number; longitude: number } { type LocatedApiary = Apiary & { latitude: number; longitude: number };
function hasCoordinates(apiary: Apiary): apiary is LocatedApiary {
return apiary.latitude !== null && apiary.longitude !== null; 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 (
<Card>
<CardContent className="flex flex-col gap-3">
<h2 id={headingId} className="font-heading text-lg font-semibold">
Stände auf der Karte
</h2>
<ul aria-labelledby={headingId} className="flex flex-col divide-y divide-border">
{stands.map(({ apiary, models, failed }) => (
<li key={apiary.id} className="flex flex-col gap-1 py-2">
<Link
href={`/apiaries/${apiary.id}`}
className="inline-flex min-h-11 items-center self-start font-medium text-primary-ink underline underline-offset-4"
>
{apiary.name}
</Link>
{models === null ? (
failed ? null : <p className="text-sm text-muted-foreground">Völker werden geladen…</p>
) : models.length === 0 ? (
<p className="text-base text-muted-foreground">Noch keine Völker an diesem Stand.</p>
) : (
<CombSummary summary={summarizeColonies(models)} total={models.length} />
)}
</li>
))}
</ul>
</CardContent>
</Card>
);
}
export default function ApiaryMapPage() { export default function ApiaryMapPage() {
const apiariesQuery = useQuery({ const apiariesQuery = useQuery({
queryKey: ["apiaries"], queryKey: ["apiaries"],
queryFn: () => fetchApiaries(), queryFn: () => fetchApiaries(),
}); });
const stands = useMapStands(apiariesQuery.data?.filter(hasCoordinates) ?? []);
const isOffline = useConnectivity() === "offline";
return ( return (
<main className="flex flex-col gap-4 p-4 sm:p-6"> <main className="flex flex-col gap-4 p-4 sm:p-6">
@@ -41,10 +112,15 @@ export default function ApiaryMapPage() {
} }
/> />
{isOffline && stands.length > 0 ? (
<p role="status" className="text-base">
Die Karte braucht Internet. Die Stände stehen unten als Liste.
</p>
) : null}
<QueryState query={apiariesQuery} what="Bienenstände"> <QueryState query={apiariesQuery} what="Bienenstände">
{(apiaries) => { {(apiaries) => {
const located = apiaries.filter(hasCoordinates); if (stands.length === 0) {
if (located.length === 0) {
return ( return (
<EmptyState <EmptyState
icon={MapPin} icon={MapPin}
@@ -60,13 +136,14 @@ export default function ApiaryMapPage() {
} }
return ( return (
<> <>
{located.length < apiaries.length ? ( {stands.length < apiaries.length ? (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{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. fehlen auf der Karte.
</p> </p>
) : null} ) : null}
<ApiaryMap apiaries={located} /> <ApiaryMap stands={stands} />
<StandList stands={stands} />
</> </>
); );
}} }}
@@ -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<string, unknown>,
}));
// 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<unknown, MarkerOptions & { children?: ReactNode; position: unknown }>(function Marker(
{ icon, title, keyboard, children },
ref,
) {
const element = useRef<HTMLDivElement>(null);
const [isOpen, setOpen] = useState(false);
useImperativeHandle(ref, () => ({ getElement: () => element.current }));
const options = (icon as DivIcon).options;
return (
<>
<div
ref={element}
data-testid="marker"
role="button"
tabIndex={keyboard === false ? -1 : 0}
title={title}
className={options.className}
data-icon-size={String(options.iconSize)}
data-icon-anchor={String(options.iconAnchor)}
data-popup-anchor={String(options.popupAnchor)}
onClick={() => 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.
<div className="leaflet-popup">
<a
href="#close"
role="button"
aria-label="Close popup"
className="leaflet-popup-close-button"
onClick={(event) => {
event.preventDefault();
setOpen(false);
}}
>
×
</a>
{children}
</div>
) : 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 (
<div data-testid="map" ref={(node) => void (node && !isReady && setReady(true))}>
{isReady ? children : null}
</div>
);
},
TileLayer: () => null,
ZoomControl: ({ zoomInTitle, zoomOutTitle }: { zoomInTitle: string; zoomOutTitle: string }) => (
<div>
<a role="button" title={zoomInTitle} aria-label={zoomInTitle} href="#">+</a>
<a role="button" title={zoomOutTitle} aria-label={zoomOutTitle} href="#">−</a>
</div>
),
Marker,
Popup: ({ children }: { children: ReactNode }) => <div data-testid="popup">{children}</div>,
useMapEvents: (handlers: { zoomend?: () => void }) => {
fakeMap.onZoomEnd = handlers.zoomend ?? null;
return { getZoom: () => fakeMap.zoom, getContainer: () => document.querySelector<HTMLElement>('[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(<ApiaryMap stands={[NORDHANG]} />);
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(<ApiaryMap stands={[NORDHANG]} />);
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(<ApiaryMap stands={[{ apiary: apiaryOf("a2", "<b>Wiese</b> & Hang"), models: [] }]} />);
const marker = screen.getByTestId("marker");
expect(marker.querySelector("b")).toBeNull();
expect(marker.querySelector(".stand-marker-name")).toHaveTextContent("<b>Wiese</b> & Hang");
});
it("shows a single wax cell before the overview has loaded", () => {
render(<ApiaryMap stands={[{ apiary: apiaryOf("a1", "Nordhang"), models: null }]} />);
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(<ApiaryMap stands={[{ apiary: apiaryOf("a1", "Nordhang"), models: null }]} />);
rerender(<ApiaryMap stands={[NORDHANG]} />);
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(<ApiaryMap stands={[NORDHANG]} />);
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(<ApiaryMap stands={[NORDHANG]} />);
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(<ApiaryMap stands={[NORDHANG]} />);
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(
<>
<button type="button">Draußen</button>
<ApiaryMap stands={[NORDHANG]} />
</>,
);
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(<ApiaryMap stands={[NORDHANG]} />);
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(<ApiaryMap stands={[NORDHANG]} />);
// 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/);
});
});
+123 -25
View File
@@ -1,48 +1,146 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { useEffect, useLayoutEffect, useMemo, useRef, type RefObject } from "react";
import L from "leaflet"; import L from "leaflet";
import "leaflet/dist/leaflet.css"; import "leaflet/dist/leaflet.css";
import { MapContainer, Marker, Popup, TileLayer } from "react-leaflet"; import "@/styles/leaflet-theme.css";
import { Apiary } from "@/lib/apiaries/types"; 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({ /** From this zoom on the apiary names stand under the markers (spec 7.1). */
iconUrl: "/leaflet/marker-icon.png", const NAMES_FROM_ZOOM = 9;
iconRetinaUrl: "/leaflet/marker-icon-2x.png",
shadowUrl: "/leaflet/marker-shadow.png",
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41],
});
interface ApiaryMapProps { export interface MapStand {
apiaries: (Apiary & { latitude: number; longitude: number })[]; 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) { interface ApiaryMapProps {
const center: [number, number] = [apiaries[0].latitude, apiaries[0].longitude]; 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<L.Marker | null>;
}
/**
* 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<HTMLDivElement>(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<HTMLElement>('[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 (
<div ref={body} className="flex flex-col gap-2">
<h2 className="pr-8 font-heading text-base font-semibold">{apiary.name}</h2>
{models === null ? null : models.length === 0 ? (
<p>Noch keine Völker an diesem Stand.</p>
) : (
<CombSummary summary={summarizeColonies(models)} total={models.length} />
)}
<Button variant="outline" size="touch" nativeButton={false} render={<Link href={`/apiaries/${apiary.id}`} />}>
Stand öffnen
</Button>
</div>
);
}
function StandMarker({ apiary, models }: MapStand) {
const marker = useRef<L.Marker>(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}<span class="stand-marker-name">${escapeHtml(apiary.name)}</span>`,
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 (
<Marker ref={marker} position={[apiary.latitude, apiary.longitude]} icon={icon} keyboard title={apiary.name}>
<Popup>
<PopupBody apiary={apiary} models={models} marker={marker} />
</Popup>
</Marker>
);
}
/** 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 ( return (
<MapContainer <MapContainer
center={center} center={center}
zoom={9} zoom={NAMES_FROM_ZOOM}
scrollWheelZoom scrollWheelZoom
zoomAnimation={animate}
fadeAnimation={animate}
markerZoomAnimation={animate}
inertia={animate}
zoomControl={false}
className="h-[60vh] w-full rounded-2xl" className="h-[60vh] w-full rounded-2xl"
> >
<TileLayer <TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>' attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/> />
{apiaries.map((apiary) => ( <ZoomControl position="topleft" zoomInTitle="Vergrößern" zoomOutTitle="Verkleinern" />
<Marker key={apiary.id} position={[apiary.latitude, apiary.longitude]} icon={markerIcon}> <ZoomNames />
<Popup> {stands.map((stand) => (
<Link href={`/apiaries/${apiary.id}`} className="font-medium text-primary-ink underline"> <StandMarker key={stand.apiary.id} apiary={stand.apiary} models={stand.models} />
{apiary.name}
</Link>
{apiary.location ? <p className="text-sm text-muted-foreground">{apiary.location}</p> : null}
</Popup>
</Marker>
))} ))}
</MapContainer> </MapContainer>
); );
@@ -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");
});
});
@@ -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<StatusLight, number> = { red: 0, yellow: 1, green: 2 };
const TONE: Record<StatusLight, string> = { 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 = `<path class="mc-cell" data-light="${light}" d="${hexPath(x, y, CELL_RADIUS)}" style="fill:var(--condition-${tone});stroke:none"/>`;
const mark = markPath(light, x, y);
return mark
? `${cell}<path class="mc-mark" data-light="${light}" d="${mark}" stroke-width="1.6" stroke-linecap="round" style="fill:none;stroke:var(--condition-${tone}-foreground)"/>`
: cell;
}
function waxSvg(index: number): string {
const { x, y } = placeOf(index);
return `<path class="mc-wax" d="${hexPath(x, y, CELL_RADIUS)}" stroke-width="1" stroke-dasharray="2 1.5" style="fill:var(--muted);stroke:var(--input)"/>`;
}
/** „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 (
`<rect class="mc-chip" x="${round(CENTER.x - width / 2)}" y="45" width="${round(width)}" height="16" rx="4" stroke-width="1.5" style="fill:var(--card);stroke:var(--foreground)"/>` +
`<text class="mc-count" x="${CENTER.x}" y="57.5" text-anchor="middle" font-size="13" font-weight="700" style="fill:var(--foreground)">${total}</text>`
);
}
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 (
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 52" width="48" height="52" overflow="visible" aria-hidden="true" focusable="false">` +
`<path class="mc-bg" d="${hexPath(CENTER.x, CENTER.y, BACKGROUND_RADIUS)}" stroke-width="2" stroke-linejoin="round" style="fill:var(--card);stroke:var(--foreground)"/>` +
`${places}${chip}</svg>`
);
}
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<string, string>();
/**
* 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(", ")}`;
}
@@ -599,6 +599,9 @@ const DATA_VALUE_FILES = [
"src/components/honeycomb/apiary-comb-card.tsx", "src/components/honeycomb/apiary-comb-card.tsx",
"src/components/honeycomb/comb-legend.tsx", "src/components/honeycomb/comb-legend.tsx",
"src/styles/comb.css", "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. */ /** 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`); const OVERLAY_FILES = ["sheet", "dialog", "dropdown-menu", "select"].map((name) => `src/components/ui/${name}.tsx`);
@@ -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;
}
}