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
@@ -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 dynamic from "next/dynamic";
|
||||
import { useId } from "react";
|
||||
import { MapPin } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useQueries, useQuery } from "@tanstack/react-query";
|
||||
import { fetchApiaries } from "@/lib/apiaries/api";
|
||||
import { colonyOverviewKey, fetchColonyOverview } from "@/lib/apiaries/field-api";
|
||||
import { Apiary } from "@/lib/apiaries/types";
|
||||
import { useCurrentUserId } from "@/lib/auth/auth-context";
|
||||
import { useConnectivity } from "@/lib/offline/connectivity";
|
||||
import type { MapStand } from "@/components/apiary-map";
|
||||
import { CombSummary, cellModelsOf } from "@/components/honeycomb/apiary-comb";
|
||||
import { summarizeColonies } from "@/lib/honeycomb/cell-state";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { QueryState } from "@/components/query-state";
|
||||
@@ -16,17 +24,80 @@ const ApiaryMap = dynamic(
|
||||
{ ssr: false, loading: () => <p role="status" className="text-sm text-muted-foreground">Karte wird geladen…</p> },
|
||||
);
|
||||
|
||||
function hasCoordinates(
|
||||
apiary: Apiary,
|
||||
): apiary is Apiary & { latitude: number; longitude: number } {
|
||||
const OVERVIEW_STALE_MS = 60_000;
|
||||
|
||||
type LocatedApiary = Apiary & { latitude: number; longitude: number };
|
||||
|
||||
function hasCoordinates(apiary: Apiary): apiary is LocatedApiary {
|
||||
return apiary.latitude !== null && apiary.longitude !== null;
|
||||
}
|
||||
|
||||
interface ListedStand extends MapStand {
|
||||
/** The overview could not be loaded (no right, offline without pack): no summary. */
|
||||
failed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* One colony overview per apiary on the map, under the same key as the apiary pages (spec 7,
|
||||
* no second source); offline from the pack.
|
||||
*/
|
||||
function useMapStands(located: readonly LocatedApiary[]): ListedStand[] {
|
||||
const userId = useCurrentUserId();
|
||||
const results = useQueries({
|
||||
queries: located.map((apiary) => ({
|
||||
queryKey: colonyOverviewKey(apiary.id),
|
||||
queryFn: () => fetchColonyOverview(apiary.id, userId),
|
||||
networkMode: "always" as const,
|
||||
// The map is a look-around page: a minute old is fresh enough, no refetch on every visit.
|
||||
staleTime: OVERVIEW_STALE_MS,
|
||||
})),
|
||||
});
|
||||
return located.map((apiary, index) => {
|
||||
const overview = results[index]?.data;
|
||||
return { apiary, models: overview ? cellModelsOf(overview) : null, failed: results[index]?.isError ?? false };
|
||||
});
|
||||
}
|
||||
|
||||
/** „Stände auf der Karte“: the list alternative to the map, also when the tiles do not load (spec 7.3). */
|
||||
function StandList({ stands }: { stands: readonly ListedStand[] }) {
|
||||
const headingId = useId();
|
||||
return (
|
||||
<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() {
|
||||
const apiariesQuery = useQuery({
|
||||
queryKey: ["apiaries"],
|
||||
queryFn: () => fetchApiaries(),
|
||||
});
|
||||
const stands = useMapStands(apiariesQuery.data?.filter(hasCoordinates) ?? []);
|
||||
const isOffline = useConnectivity() === "offline";
|
||||
|
||||
return (
|
||||
<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">
|
||||
{(apiaries) => {
|
||||
const located = apiaries.filter(hasCoordinates);
|
||||
if (located.length === 0) {
|
||||
if (stands.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={MapPin}
|
||||
@@ -60,13 +136,14 @@ export default function ApiaryMapPage() {
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{located.length < apiaries.length ? (
|
||||
{stands.length < apiaries.length ? (
|
||||
<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.
|
||||
</p>
|
||||
) : 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";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, type RefObject } from "react";
|
||||
import L from "leaflet";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
import { MapContainer, Marker, Popup, TileLayer } from "react-leaflet";
|
||||
import { Apiary } from "@/lib/apiaries/types";
|
||||
import "@/styles/leaflet-theme.css";
|
||||
import { MapContainer, Marker, Popup, TileLayer, ZoomControl, useMapEvents } from "react-leaflet";
|
||||
import { CombSummary } from "@/components/honeycomb/apiary-comb";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { Apiary } from "@/lib/apiaries/types";
|
||||
import { summarizeColonies, type CellModel } from "@/lib/honeycomb/cell-state";
|
||||
import { markerLabel, miniCombSvg, type MiniCombSummary } from "@/lib/honeycomb/mini-comb-svg";
|
||||
import { useEffectiveMotionLevel } from "@/lib/motion/motion-preference";
|
||||
|
||||
const markerIcon = L.icon({
|
||||
iconUrl: "/leaflet/marker-icon.png",
|
||||
iconRetinaUrl: "/leaflet/marker-icon-2x.png",
|
||||
shadowUrl: "/leaflet/marker-shadow.png",
|
||||
iconSize: [25, 41],
|
||||
iconAnchor: [12, 41],
|
||||
popupAnchor: [1, -34],
|
||||
shadowSize: [41, 41],
|
||||
});
|
||||
/** From this zoom on the apiary names stand under the markers (spec 7.1). */
|
||||
const NAMES_FROM_ZOOM = 9;
|
||||
|
||||
interface ApiaryMapProps {
|
||||
apiaries: (Apiary & { latitude: number; longitude: number })[];
|
||||
export interface MapStand {
|
||||
apiary: Apiary & { latitude: number; longitude: number };
|
||||
/** The colonies as cells; null until the overview has loaded. */
|
||||
models: readonly CellModel[] | null;
|
||||
}
|
||||
|
||||
export function ApiaryMap({ apiaries }: ApiaryMapProps) {
|
||||
const center: [number, number] = [apiaries[0].latitude, apiaries[0].longitude];
|
||||
interface ApiaryMapProps {
|
||||
stands: readonly MapStand[];
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text.replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`);
|
||||
}
|
||||
|
||||
function miniSummaryOf(models: readonly CellModel[] | null): MiniCombSummary | null {
|
||||
return models === null ? null : { lights: models.map((model) => model.light), total: models.length };
|
||||
}
|
||||
|
||||
/** Hides the names under the markers below zoom 9, so they do not overlap. */
|
||||
function ZoomNames() {
|
||||
const update = () => {
|
||||
map.getContainer().toggleAttribute("data-stand-names", map.getZoom() >= NAMES_FROM_ZOOM);
|
||||
};
|
||||
const map = useMapEvents({ zoomend: update });
|
||||
useLayoutEffect(update, [map]);
|
||||
return null;
|
||||
}
|
||||
|
||||
interface PopupBodyProps extends MapStand {
|
||||
marker: RefObject<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 (
|
||||
<MapContainer
|
||||
center={center}
|
||||
zoom={9}
|
||||
zoom={NAMES_FROM_ZOOM}
|
||||
scrollWheelZoom
|
||||
zoomAnimation={animate}
|
||||
fadeAnimation={animate}
|
||||
markerZoomAnimation={animate}
|
||||
inertia={animate}
|
||||
zoomControl={false}
|
||||
className="h-[60vh] w-full rounded-2xl"
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
{apiaries.map((apiary) => (
|
||||
<Marker key={apiary.id} position={[apiary.latitude, apiary.longitude]} icon={markerIcon}>
|
||||
<Popup>
|
||||
<Link href={`/apiaries/${apiary.id}`} className="font-medium text-primary-ink underline">
|
||||
{apiary.name}
|
||||
</Link>
|
||||
{apiary.location ? <p className="text-sm text-muted-foreground">{apiary.location}</p> : null}
|
||||
</Popup>
|
||||
</Marker>
|
||||
<ZoomControl position="topleft" zoomInTitle="Vergrößern" zoomOutTitle="Verkleinern" />
|
||||
<ZoomNames />
|
||||
{stands.map((stand) => (
|
||||
<StandMarker key={stand.apiary.id} apiary={stand.apiary} models={stand.models} />
|
||||
))}
|
||||
</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/comb-legend.tsx",
|
||||
"src/styles/comb.css",
|
||||
// Task B2: the mini combs on the map stand still (spec 7, 17.4).
|
||||
"src/components/apiary-map.tsx",
|
||||
"src/styles/leaflet-theme.css",
|
||||
];
|
||||
/** Overlays of the kit (spec 17.4): enter with --motion-enter, leave with --motion-fade and --ease-in. */
|
||||
const OVERLAY_FILES = ["sheet", "dialog", "dropdown-menu", "select"].map((name) => `src/components/ui/${name}.tsx`);
|
||||
|
||||
@@ -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