From d2581abbd4b835c075704f87a34164b807df79cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nick=20Rosenl=C3=B6cher?= Date: Mon, 19 Oct 2026 19:33:17 +0200 Subject: [PATCH] =?UTF-8?q?fix(map):=20Kartenkacheln=20laden=20wieder,=20P?= =?UTF-8?q?opup=20per=20Tastatur,=20Etiketten=20der=20St=C3=A4nde=20(T5b?= =?UTF-8?q?=20B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- ApiCore-Frontend/src/app/globals.css | 7 + .../src/components/apiary-map.spec.tsx | 187 ++++++++++++++---- .../src/components/apiary-map.tsx | 100 +++++++--- .../colonies/colony-lifeline.spec.tsx | 21 ++ .../src/components/colonies/lifeline-svg.tsx | 8 +- ApiCore-Frontend/src/proxy.spec.ts | 7 + ApiCore-Frontend/src/proxy.ts | 2 +- ApiCore-Frontend/src/styles/leaflet-theme.css | 23 ++- 8 files changed, 275 insertions(+), 80 deletions(-) diff --git a/ApiCore-Frontend/src/app/globals.css b/ApiCore-Frontend/src/app/globals.css index 9f3f49c..80fddb8 100644 --- a/ApiCore-Frontend/src/app/globals.css +++ b/ApiCore-Frontend/src/app/globals.css @@ -589,6 +589,13 @@ html { 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). */ :root[data-motion="subtle"] .animate-page-in { animation-name: fade-in; diff --git a/ApiCore-Frontend/src/components/apiary-map.spec.tsx b/ApiCore-Frontend/src/components/apiary-map.spec.tsx index 158a7ea..7a966e6 100644 --- a/ApiCore-Frontend/src/components/apiary-map.spec.tsx +++ b/ApiCore-Frontend/src/components/apiary-map.spec.tsx @@ -1,8 +1,8 @@ import { readFileSync } from "node:fs"; 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 { 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 type { DivIcon, MarkerOptions } from "leaflet"; import type { Apiary } from "@/lib/apiaries/types"; @@ -14,16 +14,59 @@ const fakeMap = vi.hoisted(() => ({ zoom: 9, onZoomEnd: null as null | (() => void), containerProps: {} as Record, + tileProps: {} as Record, + attributionProps: null as null | Record, })); -// 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(function Marker( - { icon, title, keyboard, children }, - ref, - ) { +type PopupEvent = { popup: { getElement: () => HTMLElement } }; +type MarkerProps = MarkerOptions & { + children?: ReactNode; + position: unknown; + 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(function Marker({ icon, title, keyboard, children, eventHandlers }, ref) { const element = useRef(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 })); const options = (icon as DivIcon).options; return ( @@ -38,28 +81,13 @@ vi.mock("react-leaflet", () => { data-icon-size={String(options.iconSize)} data-icon-anchor={String(options.iconAnchor)} 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. dangerouslySetInnerHTML={{ __html: String(options.html) }} /> - {isOpen ? ( - // Leaflet's popup shell: its own (English) close button around the portal content. - - ) : null} + {hasContent ? createPortal(children, popup.content) : null} ); }); @@ -74,7 +102,14 @@ vi.mock("react-leaflet", () => { ); }, - TileLayer: () => null, + TileLayer: (props: Record) => { + fakeMap.tileProps = props; + return null; + }, + AttributionControl: (props: Record) => { + fakeMap.attributionProps = props; + return null; + }, ZoomControl: ({ zoomInTitle, zoomOutTitle }: { zoomInTitle: string; zoomOutTitle: string }) => (
+ @@ -189,22 +224,70 @@ describe("ApiaryMap", () => { expect(screen.getByTestId("map")).not.toHaveAttribute("data-stand-names"); 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([ + ["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(); + 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", () => { - 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(); render(); - await user.click(screen.getByTestId("marker")); - expect(screen.getByRole("button", { name: "Stand öffnen" })).toHaveFocus(); + const marker = screen.getByTestId("marker"); + 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(); + + 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(); + 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(); render(); 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.getByTestId("marker")).toHaveFocus(); }); @@ -218,12 +301,31 @@ describe("ApiaryMap keyboard and names", () => { , ); await user.click(screen.getByTestId("marker")); + await waitFor(() => expect(screen.getByRole("button", { name: "Stand öffnen" })).toHaveFocus()); const outside = screen.getByRole("button", { name: "Draußen" }); outside.focus(); fireEvent.click(screen.getByRole("button", { name: "Popup schließen" })); expect(outside).toHaveFocus(); }); + it("loads the tiles from the one OpenStreetMap origin the CSP allows", () => { + render(); + 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(); + 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", () => { render(); 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/); }); - 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-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", () => { @@ -272,6 +380,13 @@ describe("leaflet-theme.css", () => { 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", () => { expect(css).toMatch(/@media \(forced-colors: active\)[\s\S]*\.mc-mark[\s\S]*CanvasText/); }); diff --git a/ApiCore-Frontend/src/components/apiary-map.tsx b/ApiCore-Frontend/src/components/apiary-map.tsx index 3b3b6dc..74ba36e 100644 --- a/ApiCore-Frontend/src/components/apiary-map.tsx +++ b/ApiCore-Frontend/src/components/apiary-map.tsx @@ -1,11 +1,11 @@ "use client"; 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 "leaflet/dist/leaflet.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 { Button } from "@/components/ui/button"; 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 }; } -/** 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() { 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(".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]); return null; } -interface PopupBodyProps extends MapStand { - marker: RefObject; -} +const FOCUS_TRIES = 5; /** - * 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. + * Focuses „Stand öffnen“ once it is there: react-leaflet renders the content only after the first popupopen, + * so try again per frame (leaflet-theme.css keeps motion „Aus“ from hiding the popup for frames). */ -function PopupBody({ apiary, models, marker }: PopupBodyProps) { - const body = useRef(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('[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]); +function focusOpenButton(popup: HTMLElement, tries = FOCUS_TRIES) { + const button = popup.querySelector('[data-slot="button"]'); + button?.focus({ preventScroll: true }); + if ((button === null || document.activeElement !== button) && popup.isConnected && tries > 1) { + requestAnimationFrame(() => focusOpenButton(popup, tries - 1)); + } +} +/** 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(".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 ( -
+

{apiary.name}

{models === null ? null : models.length === 0 ? (

Noch keine Völker an diesem Stand.

@@ -104,11 +121,31 @@ function StandMarker({ apiary, models }: MapStand) { useEffect(() => { marker.current?.getElement()?.setAttribute("aria-label", label); }, [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( + () => ({ + 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 ( - + - + ); @@ -131,12 +168,15 @@ export function ApiaryMap({ stands }: ApiaryMapProps) { markerZoomAnimation={animate} inertia={animate} zoomControl={false} + attributionControl={false} 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. */} + {stands.map((stand) => ( diff --git a/ApiCore-Frontend/src/components/colonies/colony-lifeline.spec.tsx b/ApiCore-Frontend/src/components/colonies/colony-lifeline.spec.tsx index 8f1297e..ca2ee70 100644 --- a/ApiCore-Frontend/src/components/colonies/colony-lifeline.spec.tsx +++ b/ApiCore-Frontend/src/components/colonies/colony-lifeline.spec.tsx @@ -296,6 +296,27 @@ describe("ColonyLifeline", () => { 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", () => { renderLifeline( state({ diff --git a/ApiCore-Frontend/src/components/colonies/lifeline-svg.tsx b/ApiCore-Frontend/src/components/colonies/lifeline-svg.tsx index 501d2ea..ce0b73c 100644 --- a/ApiCore-Frontend/src/components/colonies/lifeline-svg.tsx +++ b/ApiCore-Frontend/src/components/colonies/lifeline-svg.tsx @@ -31,6 +31,8 @@ const BOTTOM_PAD = 8; const LABEL_GAP = 24; /** Event icons are 20 px; closer ones merge into one icon. */ 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 POINT_RADIUS = 4; const AXIS_STEP = 5; @@ -202,7 +204,11 @@ export const LifelineSvg = memo(function LifelineSvg({ model }: { model: Lifelin const queenX = MONTH_W + 6; const eventX = MONTH_W + QUEEN_W + EVENT_W / 2; 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; return ( diff --git a/ApiCore-Frontend/src/proxy.spec.ts b/ApiCore-Frontend/src/proxy.spec.ts index 048f84d..7b1d515 100644 --- a/ApiCore-Frontend/src/proxy.spec.ts +++ b/ApiCore-Frontend/src/proxy.spec.ts @@ -13,6 +13,13 @@ describe("proxy security headers", () => { 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", () => { expect(csp()).toContain("default-src 'self'"); }); diff --git a/ApiCore-Frontend/src/proxy.ts b/ApiCore-Frontend/src/proxy.ts index 61f826c..08bdb01 100644 --- a/ApiCore-Frontend/src/proxy.ts +++ b/ApiCore-Frontend/src/proxy.ts @@ -36,7 +36,7 @@ function cspHeader(): string { default-src 'self'; script-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:; font-src 'self'; connect-src 'self' ${apiOrigin()}; diff --git a/ApiCore-Frontend/src/styles/leaflet-theme.css b/ApiCore-Frontend/src/styles/leaflet-theme.css index 76c0390..4e24429 100644 --- a/ApiCore-Frontend/src/styles/leaflet-theme.css +++ b/ApiCore-Frontend/src/styles/leaflet-theme.css @@ -69,6 +69,11 @@ 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. */ .leaflet-popup-content-wrapper, .leaflet-popup-tip { @@ -92,21 +97,15 @@ 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 { - 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%; + top: 0; + right: 0; width: 44px; height: 44px; - transform: translate(-50%, -50%); + font-size: 1.5rem; + line-height: 44px; + color: var(--popover-foreground); } @media (forced-colors: active) {