fix(map): Kartenkacheln laden wieder, Popup per Tastatur, Etiketten der Stände (T5b B8)
Backend Image / build-and-push (push) Successful in 14m52s
Frontend Image / build-and-push (push) Successful in 44m20s

- 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:
Nick.RosenloecherandClaude Opus 5.5 committed 2026-10-19 19:33:17 +02:00
1 parent ca95647bda
commit d2581abbd4
8 files changed
+275 -80

No files matched your search

+7
View File
@@ -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/);
}); });
+70 -30
View File
@@ -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='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>' attribution='&copy; <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 (
+7
View File
@@ -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'");
}); });
+1 -1
View File
@@ -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()};
+11 -12
View File
@@ -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) {