fix(map): Kartenkacheln laden wieder, Popup per Tastatur, Etiketten der Stände (T5b B8)
- CSP img-src erlaubt genau https://tile.openstreetmap.org; die Kachel-URL nutzt den einen Host (OSM-Kachelregeln). Vorher blockierte die CSP alle Kacheln, die Karte war grau (auch in Produktion). - Popup: Fokus folgt popupopen/popupclose des Markers (mit Wiederholung je Bild), Escape schließt, Schließen-Knopf 44 × 44; bei Bewegung „Aus“ keine Übergänge im Popup (sonst wurde der Knopf erst Bilder später sichtbar). - Stand-Namen, die einen anderen Marker verdecken würden, bleiben aus; Attribution „Leaflet | © OpenStreetMap-Mitwirkende“ in 13 px. - Lebenslinie: Ereignis-Symbole an „heute“ unter der Linie, der Königinnen- Punkt bleibt darauf. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
ca95647bda
commit
d2581abbd4
8 files changed
+275
-80
No files matched your search
@@ -589,6 +589,13 @@ html {
|
|||||||
transition-delay: 0ms !important;
|
transition-delay: 0ms !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Leaflet hides its popup while laying it out. With the 0.01 ms above, that `visibility` change would run as a
|
||||||
|
transition down the popup one frame per level, and focus could not move to „Stand öffnen“ (apiary-map). */
|
||||||
|
:root[data-motion="off"] .leaflet-popup,
|
||||||
|
:root[data-motion="off"] .leaflet-popup * {
|
||||||
|
transition-property: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Subtle / system reduce: keep fades, drop movement (spec 17.2). */
|
/* Subtle / system reduce: keep fades, drop movement (spec 17.2). */
|
||||||
:root[data-motion="subtle"] .animate-page-in {
|
:root[data-motion="subtle"] .animate-page-in {
|
||||||
animation-name: fade-in;
|
animation-name: fade-in;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { forwardRef, useImperativeHandle, useRef, useState, type ReactNode } from "react";
|
import { forwardRef, useEffect, useImperativeHandle, useRef, useState, type ReactNode } from "react";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import type { DivIcon, MarkerOptions } from "leaflet";
|
import type { DivIcon, MarkerOptions } from "leaflet";
|
||||||
import type { Apiary } from "@/lib/apiaries/types";
|
import type { Apiary } from "@/lib/apiaries/types";
|
||||||
@@ -14,16 +14,59 @@ const fakeMap = vi.hoisted(() => ({
|
|||||||
zoom: 9,
|
zoom: 9,
|
||||||
onZoomEnd: null as null | (() => void),
|
onZoomEnd: null as null | (() => void),
|
||||||
containerProps: {} as Record<string, unknown>,
|
containerProps: {} as Record<string, unknown>,
|
||||||
|
tileProps: {} as Record<string, unknown>,
|
||||||
|
attributionProps: null as null | Record<string, unknown>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Leaflet itself (L.divIcon) is real; react-leaflet is a thin stand-in that renders the marker element.
|
type PopupEvent = { popup: { getElement: () => HTMLElement } };
|
||||||
vi.mock("react-leaflet", () => {
|
type MarkerProps = MarkerOptions & {
|
||||||
const Marker = forwardRef<unknown, MarkerOptions & { children?: ReactNode; position: unknown }>(function Marker(
|
children?: ReactNode;
|
||||||
{ icon, title, keyboard, children },
|
position: unknown;
|
||||||
ref,
|
eventHandlers?: { popupopen?: (event: PopupEvent) => void; popupclose?: (event: PopupEvent) => void };
|
||||||
) {
|
};
|
||||||
|
|
||||||
|
// Leaflet itself (L.divIcon) is real; react-leaflet is a stand-in that behaves like the real pair where the
|
||||||
|
// component depends on it: the popup container is built on the first open and detached on close, the popup
|
||||||
|
// content is portalled into it from the first open on and stays mounted (react-leaflet never unmounts it),
|
||||||
|
// and the marker fires popupopen/popupclose before React has rendered anything.
|
||||||
|
vi.mock("react-leaflet", async () => {
|
||||||
|
const { createPortal } = await import("react-dom");
|
||||||
|
const Marker = forwardRef<unknown, MarkerProps>(function Marker({ icon, title, keyboard, children, eventHandlers }, ref) {
|
||||||
const element = useRef<HTMLDivElement>(null);
|
const element = useRef<HTMLDivElement>(null);
|
||||||
const [isOpen, setOpen] = useState(false);
|
const [popup] = useState(() => {
|
||||||
|
const shell = document.createElement("div");
|
||||||
|
shell.className = "leaflet-popup";
|
||||||
|
const close = document.createElement("a");
|
||||||
|
Object.assign(close, { href: "#close", className: "leaflet-popup-close-button", textContent: "×" });
|
||||||
|
close.setAttribute("role", "button");
|
||||||
|
close.setAttribute("aria-label", "Close popup");
|
||||||
|
const content = document.createElement("div");
|
||||||
|
content.className = "leaflet-popup-content";
|
||||||
|
shell.append(close, content);
|
||||||
|
return { shell, close, content };
|
||||||
|
});
|
||||||
|
const [hasContent, setHasContent] = useState(false);
|
||||||
|
const handlers = useRef(eventHandlers);
|
||||||
|
handlers.current = eventHandlers;
|
||||||
|
const event = { popup: { getElement: () => popup.shell } };
|
||||||
|
const closePopup = () => {
|
||||||
|
popup.shell.remove();
|
||||||
|
handlers.current?.popupclose?.(event);
|
||||||
|
};
|
||||||
|
const toggle = () => {
|
||||||
|
if (popup.shell.isConnected) return closePopup();
|
||||||
|
element.current?.parentElement?.append(popup.shell);
|
||||||
|
setHasContent(true);
|
||||||
|
handlers.current?.popupopen?.(event);
|
||||||
|
};
|
||||||
|
useEffect(() => {
|
||||||
|
const onClose = (click: Event) => {
|
||||||
|
click.preventDefault();
|
||||||
|
closePopup();
|
||||||
|
};
|
||||||
|
popup.close.addEventListener("click", onClose);
|
||||||
|
return () => popup.close.removeEventListener("click", onClose);
|
||||||
|
});
|
||||||
useImperativeHandle(ref, () => ({ getElement: () => element.current }));
|
useImperativeHandle(ref, () => ({ getElement: () => element.current }));
|
||||||
const options = (icon as DivIcon).options;
|
const options = (icon as DivIcon).options;
|
||||||
return (
|
return (
|
||||||
@@ -38,28 +81,13 @@ vi.mock("react-leaflet", () => {
|
|||||||
data-icon-size={String(options.iconSize)}
|
data-icon-size={String(options.iconSize)}
|
||||||
data-icon-anchor={String(options.iconAnchor)}
|
data-icon-anchor={String(options.iconAnchor)}
|
||||||
data-popup-anchor={String(options.popupAnchor)}
|
data-popup-anchor={String(options.popupAnchor)}
|
||||||
onClick={() => setOpen(true)}
|
onClick={toggle}
|
||||||
|
// Leaflet opens a marker's popup on Enter (keypress).
|
||||||
|
onKeyDown={(key) => key.key === "Enter" && toggle()}
|
||||||
// The divIcon html, inserted as Leaflet does.
|
// The divIcon html, inserted as Leaflet does.
|
||||||
dangerouslySetInnerHTML={{ __html: String(options.html) }}
|
dangerouslySetInnerHTML={{ __html: String(options.html) }}
|
||||||
/>
|
/>
|
||||||
{isOpen ? (
|
{hasContent ? createPortal(children, popup.content) : null}
|
||||||
// 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}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -74,7 +102,14 @@ vi.mock("react-leaflet", () => {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
TileLayer: () => null,
|
TileLayer: (props: Record<string, unknown>) => {
|
||||||
|
fakeMap.tileProps = props;
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
AttributionControl: (props: Record<string, unknown>) => {
|
||||||
|
fakeMap.attributionProps = props;
|
||||||
|
return null;
|
||||||
|
},
|
||||||
ZoomControl: ({ zoomInTitle, zoomOutTitle }: { zoomInTitle: string; zoomOutTitle: string }) => (
|
ZoomControl: ({ zoomInTitle, zoomOutTitle }: { zoomInTitle: string; zoomOutTitle: string }) => (
|
||||||
<div>
|
<div>
|
||||||
<a role="button" title={zoomInTitle} aria-label={zoomInTitle} href="#">+</a>
|
<a role="button" title={zoomInTitle} aria-label={zoomInTitle} href="#">+</a>
|
||||||
@@ -189,22 +224,70 @@ describe("ApiaryMap", () => {
|
|||||||
expect(screen.getByTestId("map")).not.toHaveAttribute("data-stand-names");
|
expect(screen.getByTestId("map")).not.toHaveAttribute("data-stand-names");
|
||||||
fakeMap.zoom = 9;
|
fakeMap.zoom = 9;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("hides a name that would run into another stand's comb or name, and shows it again once there is room", () => {
|
||||||
|
const boxes = new Map<string, DOMRect>([
|
||||||
|
["Nordhang:comb", new DOMRect(100, 100, 48, 52)],
|
||||||
|
["Nordhang:name", new DOMRect(90, 162, 70, 20)],
|
||||||
|
// Südhang's comb sits on Nordhang's name.
|
||||||
|
["Südhang:comb", new DOMRect(120, 140, 48, 52)],
|
||||||
|
["Südhang:name", new DOMRect(110, 202, 70, 20)],
|
||||||
|
]);
|
||||||
|
const spy = vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(function (this: Element) {
|
||||||
|
const stand = this.closest(".stand-marker")?.getAttribute("title");
|
||||||
|
return boxes.get(`${stand}:${this.matches("svg") ? "comb" : "name"}`) ?? new DOMRect();
|
||||||
|
});
|
||||||
|
render(<ApiaryMap stands={[NORDHANG, { apiary: apiaryOf("a2", "Südhang"), models: [] }]} />);
|
||||||
|
const [nordhang, suedhang] = screen.getAllByTestId("marker");
|
||||||
|
expect(nordhang).toHaveAttribute("data-name-hidden");
|
||||||
|
expect(suedhang).not.toHaveAttribute("data-name-hidden");
|
||||||
|
|
||||||
|
boxes.set("Südhang:comb", new DOMRect(400, 100, 48, 52)).set("Südhang:name", new DOMRect(390, 162, 70, 20));
|
||||||
|
fakeMap.onZoomEnd?.();
|
||||||
|
expect(nordhang).not.toHaveAttribute("data-name-hidden");
|
||||||
|
expect(suedhang).not.toHaveAttribute("data-name-hidden");
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("ApiaryMap keyboard and names", () => {
|
describe("ApiaryMap keyboard and names", () => {
|
||||||
it("moves focus to „Stand öffnen“ when the popup opens and names the close button in German", async () => {
|
it("moves focus to „Stand öffnen“ when the popup opens, also on every later open", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<ApiaryMap stands={[NORDHANG]} />);
|
render(<ApiaryMap stands={[NORDHANG]} />);
|
||||||
await user.click(screen.getByTestId("marker"));
|
const marker = screen.getByTestId("marker");
|
||||||
expect(screen.getByRole("button", { name: "Stand öffnen" })).toHaveFocus();
|
marker.focus();
|
||||||
|
await user.keyboard("{Enter}");
|
||||||
|
await waitFor(() => expect(screen.getByRole("button", { name: "Stand öffnen" })).toHaveFocus());
|
||||||
expect(screen.getByRole("button", { name: "Popup schließen" })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: "Popup schließen" })).toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Popup schließen" }));
|
||||||
|
marker.focus();
|
||||||
|
await user.keyboard("{Enter}");
|
||||||
|
// react-leaflet keeps the content mounted after the first open: focus must not depend on a remount.
|
||||||
|
await waitFor(() => expect(screen.getByRole("button", { name: "Stand öffnen" })).toHaveFocus());
|
||||||
});
|
});
|
||||||
|
|
||||||
it("gives focus back to the marker when the popup closes with focus inside", async () => {
|
it("gives focus back to the marker when the popup closes with focus inside, every time", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<ApiaryMap stands={[NORDHANG]} />);
|
||||||
|
const marker = screen.getByTestId("marker");
|
||||||
|
for (let round = 0; round < 2; round += 1) {
|
||||||
|
await user.click(marker);
|
||||||
|
await waitFor(() => expect(screen.getByRole("button", { name: "Stand öffnen" })).toHaveFocus());
|
||||||
|
await user.click(screen.getByRole("button", { name: "Popup schließen" }));
|
||||||
|
expect(screen.queryByRole("button", { name: "Stand öffnen" })).toBeNull();
|
||||||
|
expect(marker).toHaveFocus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes the popup on Escape and gives focus back to the marker", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<ApiaryMap stands={[NORDHANG]} />);
|
render(<ApiaryMap stands={[NORDHANG]} />);
|
||||||
await user.click(screen.getByTestId("marker"));
|
await user.click(screen.getByTestId("marker"));
|
||||||
await user.click(screen.getByRole("button", { name: "Popup schließen" }));
|
await waitFor(() => expect(screen.getByRole("button", { name: "Stand öffnen" })).toHaveFocus());
|
||||||
|
|
||||||
|
await user.keyboard("{Escape}");
|
||||||
|
|
||||||
expect(screen.queryByRole("button", { name: "Stand öffnen" })).toBeNull();
|
expect(screen.queryByRole("button", { name: "Stand öffnen" })).toBeNull();
|
||||||
expect(screen.getByTestId("marker")).toHaveFocus();
|
expect(screen.getByTestId("marker")).toHaveFocus();
|
||||||
});
|
});
|
||||||
@@ -218,12 +301,31 @@ describe("ApiaryMap keyboard and names", () => {
|
|||||||
</>,
|
</>,
|
||||||
);
|
);
|
||||||
await user.click(screen.getByTestId("marker"));
|
await user.click(screen.getByTestId("marker"));
|
||||||
|
await waitFor(() => expect(screen.getByRole("button", { name: "Stand öffnen" })).toHaveFocus());
|
||||||
const outside = screen.getByRole("button", { name: "Draußen" });
|
const outside = screen.getByRole("button", { name: "Draußen" });
|
||||||
outside.focus();
|
outside.focus();
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Popup schließen" }));
|
fireEvent.click(screen.getByRole("button", { name: "Popup schließen" }));
|
||||||
expect(outside).toHaveFocus();
|
expect(outside).toHaveFocus();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("loads the tiles from the one OpenStreetMap origin the CSP allows", () => {
|
||||||
|
render(<ApiaryMap stands={[NORDHANG]} />);
|
||||||
|
expect(fakeMap.tileProps.url).toBe("https://tile.openstreetmap.org/{z}/{x}/{y}.png");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("credits OpenStreetMap in German and drops Leaflet's flag from the prefix", () => {
|
||||||
|
render(<ApiaryMap stands={[NORDHANG]} />);
|
||||||
|
expect(fakeMap.containerProps.attributionControl).toBe(false);
|
||||||
|
const attribution = document.createElement("div");
|
||||||
|
attribution.innerHTML = String(fakeMap.tileProps.attribution);
|
||||||
|
expect(attribution).toHaveTextContent("© OpenStreetMap-Mitwirkende");
|
||||||
|
expect(attribution.querySelector("a")).toHaveAttribute("href", "https://www.openstreetmap.org/copyright");
|
||||||
|
const prefix = document.createElement("div");
|
||||||
|
prefix.innerHTML = String(fakeMap.attributionProps?.prefix);
|
||||||
|
expect(prefix).toHaveTextContent(/^Leaflet$/);
|
||||||
|
expect(prefix.querySelector("svg")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("names the zoom buttons in German instead of Leaflet's default control", () => {
|
it("names the zoom buttons in German instead of Leaflet's default control", () => {
|
||||||
render(<ApiaryMap stands={[NORDHANG]} />);
|
render(<ApiaryMap stands={[NORDHANG]} />);
|
||||||
expect(fakeMap.containerProps.zoomControl).toBe(false);
|
expect(fakeMap.containerProps.zoomControl).toBe(false);
|
||||||
@@ -261,9 +363,15 @@ describe("leaflet-theme.css", () => {
|
|||||||
expect(css).toMatch(/\[data-contrast="sun"\][^{]*\.mc-bg\s*\{\s*stroke-width:\s*3px/);
|
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", () => {
|
it("styles the popup with tokens and makes the close button itself 44 px", () => {
|
||||||
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-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/);
|
// The button box, not a pseudo-element: Leaflet's own 24 px box is what the browser measures and focuses.
|
||||||
|
expect(css).toMatch(/a\.leaflet-popup-close-button\s*\{[^}]*width:\s*44px;[^}]*height:\s*44px/);
|
||||||
|
expect(css).not.toMatch(/close-button::after/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the names the map marked as colliding", () => {
|
||||||
|
expect(css).toMatch(/\.stand-marker\[data-name-hidden\]\s+\.stand-marker-name\s*\{\s*display:\s*none/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses no colour literal, no transition and no text below 13 px", () => {
|
it("uses no colour literal, no transition and no text below 13 px", () => {
|
||||||
@@ -272,6 +380,13 @@ describe("leaflet-theme.css", () => {
|
|||||||
expect(css).not.toMatch(/font-size:\s*(?:[0-9]|1[0-2])px/);
|
expect(css).not.toMatch(/font-size:\s*(?:[0-9]|1[0-2])px/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("globals.css keeps the popup out of motion „Aus“'s 0.01 ms transitions, so focus can move into it at once", () => {
|
||||||
|
const globals = readFileSync(path.resolve(__dirname, "../app/globals.css"), "utf8");
|
||||||
|
expect(globals).toMatch(
|
||||||
|
/:root\[data-motion="off"\] \.leaflet-popup,\s*:root\[data-motion="off"\] \.leaflet-popup \*\s*\{\s*transition-property:\s*none;\s*\}/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps the marker readable in forced colours by its marks", () => {
|
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/);
|
expect(css).toMatch(/@media \(forced-colors: active\)[\s\S]*\.mc-mark[\s\S]*CanvasText/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect, useLayoutEffect, useMemo, useRef, type RefObject } from "react";
|
import { useEffect, useLayoutEffect, useMemo, useRef } from "react";
|
||||||
import L from "leaflet";
|
import L from "leaflet";
|
||||||
import "leaflet/dist/leaflet.css";
|
import "leaflet/dist/leaflet.css";
|
||||||
import "@/styles/leaflet-theme.css";
|
import "@/styles/leaflet-theme.css";
|
||||||
import { MapContainer, Marker, Popup, TileLayer, ZoomControl, useMapEvents } from "react-leaflet";
|
import { AttributionControl, MapContainer, Marker, Popup, TileLayer, ZoomControl, useMapEvents } from "react-leaflet";
|
||||||
import { CombSummary } from "@/components/honeycomb/apiary-comb";
|
import { CombSummary } from "@/components/honeycomb/apiary-comb";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import type { Apiary } from "@/lib/apiaries/types";
|
import type { Apiary } from "@/lib/apiaries/types";
|
||||||
@@ -34,42 +34,59 @@ function miniSummaryOf(models: readonly CellModel[] | null): MiniCombSummary | n
|
|||||||
return models === null ? null : { lights: models.map((model) => model.light), total: models.length };
|
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 overlaps(a: DOMRect, b: DOMRect): boolean {
|
||||||
|
return a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Names under the markers from zoom 9 on (spec 7.1). A name that would run into another stand's comb or
|
||||||
|
* an earlier name stays hidden; the marker's aria-label and popup still carry it.
|
||||||
|
*/
|
||||||
function ZoomNames() {
|
function ZoomNames() {
|
||||||
const update = () => {
|
const update = () => {
|
||||||
map.getContainer().toggleAttribute("data-stand-names", map.getZoom() >= NAMES_FROM_ZOOM);
|
const container = map.getContainer();
|
||||||
|
container.toggleAttribute("data-stand-names", map.getZoom() >= NAMES_FROM_ZOOM);
|
||||||
|
const markers = [...container.querySelectorAll<HTMLElement>(".stand-marker")];
|
||||||
|
markers.forEach((marker) => marker.removeAttribute("data-name-hidden"));
|
||||||
|
const taken = markers.flatMap((marker) => [...marker.querySelectorAll("svg")].map((comb) => comb.getBoundingClientRect()));
|
||||||
|
for (const marker of markers) {
|
||||||
|
const name = marker.querySelector(".stand-marker-name")?.getBoundingClientRect();
|
||||||
|
if (!name) continue;
|
||||||
|
if (taken.some((box) => overlaps(name, box))) marker.setAttribute("data-name-hidden", "");
|
||||||
|
else taken.push(name);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
const map = useMapEvents({ zoomend: update });
|
// Markers join the map after this effect; layeradd measures each one once Leaflet has placed it.
|
||||||
|
const map = useMapEvents({ zoomend: update, layeradd: update });
|
||||||
useLayoutEffect(update, [map]);
|
useLayoutEffect(update, [map]);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PopupBodyProps extends MapStand {
|
const FOCUS_TRIES = 5;
|
||||||
marker: RefObject<L.Marker | null>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The popup content (spec 7.2). react-leaflet mounts it only while the popup is open: focus goes
|
* Focuses „Stand öffnen“ once it is there: react-leaflet renders the content only after the first popupopen,
|
||||||
* to „Stand öffnen“, and back to the marker on close if it was inside the popup.
|
* so try again per frame (leaflet-theme.css keeps motion „Aus“ from hiding the popup for frames).
|
||||||
*/
|
*/
|
||||||
function PopupBody({ apiary, models, marker }: PopupBodyProps) {
|
function focusOpenButton(popup: HTMLElement, tries = FOCUS_TRIES) {
|
||||||
const body = useRef<HTMLDivElement>(null);
|
const button = popup.querySelector<HTMLElement>('[data-slot="button"]');
|
||||||
useEffect(() => {
|
button?.focus({ preventScroll: true });
|
||||||
const shell = body.current?.closest(".leaflet-popup");
|
if ((button === null || document.activeElement !== button) && popup.isConnected && tries > 1) {
|
||||||
const owner = marker.current;
|
requestAnimationFrame(() => focusOpenButton(popup, tries - 1));
|
||||||
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]);
|
|
||||||
|
|
||||||
|
/** Leaflet closes on Escape only while the map container itself has focus; this covers focus inside the popup. */
|
||||||
|
function closeOnEscape(event: KeyboardEvent) {
|
||||||
|
if (event.key !== "Escape") return;
|
||||||
|
event.stopPropagation();
|
||||||
|
(event.currentTarget as HTMLElement).querySelector<HTMLElement>(".leaflet-popup-close-button")?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The popup content (spec 7.2). react-leaflet keeps it mounted after the first open, so focus follows popup events. */
|
||||||
|
function PopupBody({ apiary, models }: MapStand) {
|
||||||
return (
|
return (
|
||||||
<div ref={body} className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<h2 className="pr-8 font-heading text-base font-semibold">{apiary.name}</h2>
|
<h2 className="pr-8 font-heading text-base font-semibold">{apiary.name}</h2>
|
||||||
{models === null ? null : models.length === 0 ? (
|
{models === null ? null : models.length === 0 ? (
|
||||||
<p>Noch keine Völker an diesem Stand.</p>
|
<p>Noch keine Völker an diesem Stand.</p>
|
||||||
@@ -104,11 +121,31 @@ function StandMarker({ apiary, models }: MapStand) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
marker.current?.getElement()?.setAttribute("aria-label", label);
|
marker.current?.getElement()?.setAttribute("aria-label", label);
|
||||||
}, [label, icon]);
|
}, [label, icon]);
|
||||||
|
// Focus into the popup on open; back to the marker on close if it was inside (or fell to the body with it).
|
||||||
|
const popupEvents = useMemo<L.LeafletEventHandlerFnMap>(
|
||||||
|
() => ({
|
||||||
|
popupopen: ({ popup }: L.PopupEvent) => {
|
||||||
|
const shell = popup.getElement();
|
||||||
|
if (!shell) return;
|
||||||
|
shell.querySelector(".leaflet-popup-close-button")?.setAttribute("aria-label", "Popup schließen");
|
||||||
|
// Leaflet reuses the container; the same function reference is added only once.
|
||||||
|
shell.addEventListener("keydown", closeOnEscape);
|
||||||
|
focusOpenButton(shell);
|
||||||
|
},
|
||||||
|
popupclose: ({ popup }: L.PopupEvent) => {
|
||||||
|
const active = document.activeElement;
|
||||||
|
if (active === null || active === document.body || popup.getElement()?.contains(active)) {
|
||||||
|
marker.current?.getElement()?.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Marker ref={marker} position={[apiary.latitude, apiary.longitude]} icon={icon} keyboard title={apiary.name}>
|
<Marker ref={marker} position={[apiary.latitude, apiary.longitude]} icon={icon} keyboard title={apiary.name} eventHandlers={popupEvents}>
|
||||||
<Popup>
|
<Popup>
|
||||||
<PopupBody apiary={apiary} models={models} marker={marker} />
|
<PopupBody apiary={apiary} models={models} />
|
||||||
</Popup>
|
</Popup>
|
||||||
</Marker>
|
</Marker>
|
||||||
);
|
);
|
||||||
@@ -131,12 +168,15 @@ export function ApiaryMap({ stands }: ApiaryMapProps) {
|
|||||||
markerZoomAnimation={animate}
|
markerZoomAnimation={animate}
|
||||||
inertia={animate}
|
inertia={animate}
|
||||||
zoomControl={false}
|
zoomControl={false}
|
||||||
|
attributionControl={false}
|
||||||
className="h-[60vh] w-full rounded-2xl"
|
className="h-[60vh] w-full rounded-2xl"
|
||||||
>
|
>
|
||||||
|
{/* One tile host without the a/b/c subdomains (OSM tile policy); proxy.ts's CSP allows exactly this origin. */}
|
||||||
<TileLayer
|
<TileLayer
|
||||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>-Mitwirkende'
|
||||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
url="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
/>
|
/>
|
||||||
|
<AttributionControl position="bottomright" prefix='<a href="https://leafletjs.com">Leaflet</a>' />
|
||||||
<ZoomControl position="topleft" zoomInTitle="Vergrößern" zoomOutTitle="Verkleinern" />
|
<ZoomControl position="topleft" zoomInTitle="Vergrößern" zoomOutTitle="Verkleinern" />
|
||||||
<ZoomNames />
|
<ZoomNames />
|
||||||
{stands.map((stand) => (
|
{stands.map((stand) => (
|
||||||
|
|||||||
@@ -296,6 +296,27 @@ describe("ColonyLifeline", () => {
|
|||||||
expect(screen.getByRole("img", { name: "20.04.: Königin" })).toBeInTheDocument();
|
expect(screen.getByRole("img", { name: "20.04.: Königin" })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sets a queen change of today under the „heute“ line, clear of the line and the queen dot", () => {
|
||||||
|
renderLifeline(
|
||||||
|
state({
|
||||||
|
model: model({
|
||||||
|
// to - FUTURE_DAYS = 2026-10-19 is today.
|
||||||
|
queen: { spans: [{ from: "2026-10-19", to: null, code: "K-26", color: "red", colorWord: "rot", hatchYear: 2026 }] },
|
||||||
|
events: [{ date: "2026-10-19", kind: "queens", count: 2 }],
|
||||||
|
strength: { points: [{ date: "2026-10-19", frames: 1 }], breaks: [], max: 20 },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const figure = screen.getByRole("figure");
|
||||||
|
const todayY = Number(figure.querySelector('[data-part="today"]')?.getAttribute("y1"));
|
||||||
|
const dot = figure.querySelector('[data-part="queen-dot"]');
|
||||||
|
expect(Number(dot?.getAttribute("cy"))).toBe(todayY);
|
||||||
|
const icon = screen.getByRole("img", { name: "19.10.: Königin und 1 weitere" });
|
||||||
|
// The 2 px line and the 6 px dot (plus its 2 px ring) end above the icon's top.
|
||||||
|
expect(parseFloat(icon.style.top)).toBeGreaterThanOrEqual(todayY + 7);
|
||||||
|
});
|
||||||
|
|
||||||
it("draws no treatment that starts after today", () => {
|
it("draws no treatment that starts after today", () => {
|
||||||
renderLifeline(
|
renderLifeline(
|
||||||
state({
|
state({
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ const BOTTOM_PAD = 8;
|
|||||||
const LABEL_GAP = 24;
|
const LABEL_GAP = 24;
|
||||||
/** Event icons are 20 px; closer ones merge into one icon. */
|
/** Event icons are 20 px; closer ones merge into one icon. */
|
||||||
const EVENT_MERGE_PX = 20;
|
const EVENT_MERGE_PX = 20;
|
||||||
|
/** An icon's centre at least this far under „heute“: clear of the line and a queen dot (6 px + 2 px ring) on it. */
|
||||||
|
const ICON_BELOW_TODAY = 7 + EVENT_MERGE_PX / 2;
|
||||||
const MIN_BAR = 4;
|
const MIN_BAR = 4;
|
||||||
const POINT_RADIUS = 4;
|
const POINT_RADIUS = 4;
|
||||||
const AXIS_STEP = 5;
|
const AXIS_STEP = 5;
|
||||||
@@ -202,7 +204,11 @@ export const LifelineSvg = memo(function LifelineSvg({ model }: { model: Lifelin
|
|||||||
const queenX = MONTH_W + 6;
|
const queenX = MONTH_W + 6;
|
||||||
const eventX = MONTH_W + QUEEN_W + EVENT_W / 2;
|
const eventX = MONTH_W + QUEEN_W + EVENT_W / 2;
|
||||||
const months = monthStarts(model.from, today).filter((day) => yOf(day) - futureH > 20);
|
const months = monthStarts(model.from, today).filter((day) => yOf(day) - futureH > 20);
|
||||||
const events = eventClusters(model.events.filter((event) => event.date <= today), yOf);
|
// Recent events sit under the „heute“ line instead of on it (merging uses the shifted place); the label keeps the date.
|
||||||
|
const events = eventClusters(
|
||||||
|
model.events.filter((event) => event.date <= today),
|
||||||
|
(day) => Math.max(yOf(day), futureH + ICON_BELOW_TODAY),
|
||||||
|
);
|
||||||
const hasUncoloredQueen = model.queen?.spans.some((span) => span.color === null) ?? false;
|
const hasUncoloredQueen = model.queen?.spans.some((span) => span.color === null) ?? false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ describe("proxy security headers", () => {
|
|||||||
expect(directives.filter((part) => part.startsWith("media-src"))).toHaveLength(1);
|
expect(directives.filter((part) => part.startsWith("media-src"))).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("loads the stand map's OpenStreetMap tiles (one tile origin) and no other remote image", () => {
|
||||||
|
const directives = csp().split(";").map((part) => part.trim());
|
||||||
|
|
||||||
|
expect(directives).toContain("img-src 'self' blob: data: https://tile.openstreetmap.org");
|
||||||
|
expect(directives.filter((part) => part.startsWith("img-src"))).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps the default of the same origin only", () => {
|
it("keeps the default of the same origin only", () => {
|
||||||
expect(csp()).toContain("default-src 'self'");
|
expect(csp()).toContain("default-src 'self'");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ function cspHeader(): string {
|
|||||||
default-src 'self';
|
default-src 'self';
|
||||||
script-src 'self' 'unsafe-inline';
|
script-src 'self' 'unsafe-inline';
|
||||||
style-src 'self' 'unsafe-inline';
|
style-src 'self' 'unsafe-inline';
|
||||||
img-src 'self' blob: data:;
|
img-src 'self' blob: data: https://tile.openstreetmap.org;
|
||||||
media-src 'self' blob:;
|
media-src 'self' blob:;
|
||||||
font-src 'self';
|
font-src 'self';
|
||||||
connect-src 'self' ${apiOrigin()};
|
connect-src 'self' ${apiOrigin()};
|
||||||
|
|||||||
@@ -69,6 +69,11 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A name that would run into another stand (ZoomNames measures it). */
|
||||||
|
.stand-marker[data-name-hidden] .stand-marker-name {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Popup: popover colours with an ink rim. */
|
/* Popup: popover colours with an ink rim. */
|
||||||
.leaflet-popup-content-wrapper,
|
.leaflet-popup-content-wrapper,
|
||||||
.leaflet-popup-tip {
|
.leaflet-popup-tip {
|
||||||
@@ -92,21 +97,15 @@
|
|||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 44 px for gloves on the button box itself (Leaflet's is 24 px); the heading keeps pr-8 clear of it. */
|
||||||
.leaflet-container a.leaflet-popup-close-button {
|
.leaflet-container a.leaflet-popup-close-button {
|
||||||
top: 4px;
|
top: 0;
|
||||||
right: 4px;
|
right: 0;
|
||||||
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;
|
width: 44px;
|
||||||
height: 44px;
|
height: 44px;
|
||||||
transform: translate(-50%, -50%);
|
font-size: 1.5rem;
|
||||||
|
line-height: 44px;
|
||||||
|
color: var(--popover-foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (forced-colors: active) {
|
@media (forced-colors: active) {
|
||||||
|
|||||||
Reference in new issue
Block a user