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:
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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
attribution='© <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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user