diff --git a/web/src/components/UserMemoMap/UserMemoMap.tsx b/web/src/components/UserMemoMap/UserMemoMap.tsx index 4762e3b9..857913c9 100644 --- a/web/src/components/UserMemoMap/UserMemoMap.tsx +++ b/web/src/components/UserMemoMap/UserMemoMap.tsx @@ -9,7 +9,7 @@ import MarkerClusterGroup from "react-leaflet-cluster"; import { Link, useLocation } from "react-router-dom"; import MemoSpaceBadge from "@/components/MemoView/components/MemoSpaceBadge"; import { createMemoNavigationState } from "@/components/MemoView/navigation"; -import { defaultMarkerIcon, ThemedTileLayer } from "@/components/map/map-utils"; +import { defaultMarkerIcon, MinimalAttributionControl, OpenStreetMapTileLayer } from "@/components/map/map-utils"; import { useInfiniteMemos } from "@/hooks/useMemoQueries"; import { buildMemoCreatorFilter } from "@/lib/resource-names"; import { cn } from "@/lib/utils"; @@ -43,7 +43,7 @@ const MapFitBounds = ({ memos }: { memos: Memo[] }) => { if (validMemos.length === 0) return; const bounds = L.latLngBounds(validMemos.map((memo) => [memo.location!.latitude, memo.location!.longitude])); - map.fitBounds(bounds, { padding: [50, 50] }); + map.fitBounds(bounds, { padding: [50, 50], maxZoom: 15 }); }, [memos, map]); return null; @@ -101,12 +101,13 @@ const UserMemoMap = ({ creator, className }: Props) => { - + + { showCoverageOnHover={false} > {memosWithLocation.map((memo) => ( - + new LatLng(point.lat, point.lng); @@ -19,7 +19,6 @@ interface LocationMarkerProps { const LocationMarker = ({ position: initialPosition, onChange, readonly: readOnly }: LocationMarkerProps) => { const [position, setPosition] = useState(initialPosition); - const initializedRef = useRef(false); const map = useMapEvents({ click(e) { @@ -28,19 +27,10 @@ const LocationMarker = ({ position: initialPosition, onChange, readonly: readOnl } setPosition(e.latlng); - map.locate(); onChange(fromLatLng(e.latlng)); }, - locationfound() {}, }); - useEffect(() => { - if (!initializedRef.current) { - map.locate(); - initializedRef.current = true; - } - }, [map]); - useEffect(() => { if (initialPosition) { setPosition(initialPosition); @@ -50,7 +40,9 @@ const LocationMarker = ({ position: initialPosition, onChange, readonly: readOnl } }, [initialPosition, map]); - return position === undefined ? null : ; + return position === undefined ? null : ( + + ); }; // Reusable glass-style button component @@ -72,6 +64,7 @@ const GlassButton = ({ icon, onClick, ariaLabel, title }: GlassButtonProps) => { "inline-flex items-center justify-center h-8 w-8 rounded-lg", "border border-border/80 bg-background/88 text-foreground shadow-sm backdrop-blur-md", "hover:scale-105 hover:bg-background hover:shadow-md active:scale-95", + "focus-visible:ring-2 focus-visible:ring-primary/60 focus-visible:ring-offset-1 focus-visible:ring-offset-background", )} > {icon} @@ -210,7 +203,7 @@ const noopOnLocationChange = () => {}; const LocationPicker = ({ readonly: readOnly = false, latlng, onChange = noopOnLocationChange, className }: LocationPickerProps) => { const mapCenter = useMemo(() => toLatLng(latlng ?? DEFAULT_CENTER), [latlng?.lat, latlng?.lng]); - const markerPosition = mapCenter; + const markerPosition = latlng ? mapCenter : undefined; const statusLabel = readOnly ? "Pinned location" : latlng ? "Selected location" : "Choose a location"; return ( @@ -221,14 +214,15 @@ const LocationPicker = ({ readonly: readOnly = false, latlng, onChange = noopOnL )} > - + + diff --git a/web/src/components/map/map-utils.tsx b/web/src/components/map/map-utils.tsx index fe7c490d..6cbdc856 100644 --- a/web/src/components/map/map-utils.tsx +++ b/web/src/components/map/map-utils.tsx @@ -1,21 +1,14 @@ import { DivIcon } from "leaflet"; -import { MapPinIcon } from "lucide-react"; -import { useMemo } from "react"; import ReactDOMServer from "react-dom/server"; -import { TileLayer } from "react-leaflet"; -import { useAuth } from "@/contexts/AuthContext"; -import { resolveTheme } from "@/utils/theme"; +import { AttributionControl, TileLayer } from "react-leaflet"; -const TILE_URLS = { - light: "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png", - dark: "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png", -} as const; +const OPENSTREETMAP_TILE_URL = "https://tile.openstreetmap.org/{z}/{x}/{y}.png"; -export const ThemedTileLayer = () => { - const { userGeneralSetting } = useAuth(); - const isDark = useMemo(() => resolveTheme(userGeneralSetting?.theme || "system").includes("dark"), [userGeneralSetting?.theme]); - return ; -}; +const OPENSTREETMAP_ATTRIBUTION = '© OpenStreetMap'; + +export const OpenStreetMapTileLayer = () => ; + +export const MinimalAttributionControl = () => ; interface MarkerIconOptions { fill?: string; @@ -24,17 +17,20 @@ interface MarkerIconOptions { } export const createMarkerIcon = (options?: MarkerIconOptions): DivIcon => { - const { fill = "var(--primary)", size = 28, className = "" } = options || {}; + const { fill = "var(--primary)", size = 24, className = "" } = options || {}; return new DivIcon({ className: "relative border-none bg-transparent", html: ReactDOMServer.renderToString( -
- + , ), - iconSize: [size + 8, size + 8], - iconAnchor: [(size + 8) / 2, size + 4], - popupAnchor: [0, -(size * 0.7)], + iconSize: [size, size], + iconAnchor: [size / 2, size / 2], + popupAnchor: [0, -(size / 2 + 6)], }); }; diff --git a/web/src/index.css b/web/src/index.css index 532aba00..ae4689f2 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -31,3 +31,31 @@ @apply bg-background text-foreground; } } + +@layer components { + .map-attribution-minimal .leaflet-control-attribution { + margin: 0 0.25rem 0.25rem 0 !important; + border-radius: 0.25rem; + background: color-mix(in oklch, var(--background) 90%, transparent); + color: var(--muted-foreground); + font-size: 10px; + line-height: 1rem; + padding: 0 0.25rem; + } + + .map-attribution-minimal .leaflet-control-attribution a { + color: inherit; + text-decoration: none; + } + + .map-attribution-minimal .leaflet-control-attribution a:hover { + color: var(--foreground); + text-decoration: underline; + } + + .map-attribution-minimal .leaflet-control-attribution a:focus-visible { + border-radius: 0.125rem; + outline: 2px solid var(--ring); + outline-offset: 1px; + } +} diff --git a/web/tests/location-picker.test.tsx b/web/tests/location-picker.test.tsx index e31d5491..12ba3339 100644 --- a/web/tests/location-picker.test.tsx +++ b/web/tests/location-picker.test.tsx @@ -4,10 +4,9 @@ import { describe, expect, it, vi } from "vitest"; import LocationPicker from "@/components/map/LocationPicker"; const setView = vi.fn(); -const locate = vi.fn(); const zoomIn = vi.fn(); const zoomOut = vi.fn(); -const eventMap = { setView, locate }; +const eventMap = { setView }; const controlMap = { zoomIn, zoomOut }; vi.mock("leaflet", () => { @@ -45,18 +44,35 @@ vi.mock("leaflet", () => { }); vi.mock("react-leaflet", () => ({ - MapContainer: ({ children }: { children: ReactNode }) =>
{children}
, - Marker: ({ position }: { position: { lat: number; lng: number } }) =>
{`${position.lat},${position.lng}`}
, + MapContainer: ({ children, attributionControl }: { children: ReactNode; attributionControl?: boolean }) => ( +
+ {children} +
+ ), + Marker: ({ position, interactive, keyboard }: { position: { lat: number; lng: number }; interactive?: boolean; keyboard?: boolean }) => ( +
+ {`${position.lat},${position.lng}`} +
+ ), useMap: () => controlMap, useMapEvents: () => eventMap, })); vi.mock("@/components/map/map-utils", () => ({ defaultMarkerIcon: {}, - ThemedTileLayer: () =>
, + MinimalAttributionControl: () =>
, + OpenStreetMapTileLayer: () =>
, })); describe("LocationPicker", () => { + it("uses the explicit minimal attribution control", () => { + const { getByTestId } = render(); + + expect(getByTestId("map")).toHaveAttribute("data-attribution-control", "false"); + expect(getByTestId("attribution")).toBeInTheDocument(); + expect(getByTestId("map-layer")).toBeInTheDocument(); + }); + it("does not recenter when rerendered with the same coordinates", () => { const { rerender } = render(); @@ -70,4 +86,16 @@ describe("LocationPicker", () => { expect(setView).toHaveBeenCalledTimes(2); }); + + it("does not show a fake marker at the default map center", () => { + const { queryByTestId, rerender, getByTestId } = render(); + + expect(queryByTestId("marker")).not.toBeInTheDocument(); + + rerender(); + + expect(getByTestId("marker")).toHaveTextContent("1,2"); + expect(getByTestId("marker")).toHaveAttribute("data-interactive", "false"); + expect(getByTestId("marker")).toHaveAttribute("data-keyboard", "false"); + }); }); diff --git a/web/tests/map-utils.test.tsx b/web/tests/map-utils.test.tsx new file mode 100644 index 00000000..d8a226d0 --- /dev/null +++ b/web/tests/map-utils.test.tsx @@ -0,0 +1,52 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { createMarkerIcon, MinimalAttributionControl, OpenStreetMapTileLayer } from "@/components/map/map-utils"; + +vi.mock("leaflet", () => ({ + DivIcon: class { + options: unknown; + + constructor(options: unknown) { + this.options = options; + } + }, +})); + +vi.mock("react-leaflet", () => ({ + AttributionControl: ({ prefix }: { prefix: string | false }) =>
, + TileLayer: ({ url, attribution, maxZoom }: { url: string; attribution: string; maxZoom: number }) => ( +
+ ), +})); + +describe("OpenStreetMapTileLayer", () => { + it("uses the standard keyless tile endpoint with the required attribution", () => { + render(); + + const tileLayer = screen.getByTestId("tile-layer"); + expect(tileLayer).toHaveAttribute("data-url", "https://tile.openstreetmap.org/{z}/{x}/{y}.png"); + expect(tileLayer).toHaveAttribute("data-attribution", '© OpenStreetMap'); + expect(tileLayer).toHaveAttribute("data-max-zoom", "19"); + }); + + it("removes Leaflet branding from the attribution control", () => { + render(); + + expect(screen.getByTestId("attribution-control")).toHaveAttribute("data-prefix", "false"); + }); + + it("uses a compact location dot instead of an arrow-shaped pin", () => { + const marker = createMarkerIcon() as unknown as { + options: { + html: string; + iconSize: [number, number]; + iconAnchor: [number, number]; + }; + }; + + expect(marker.options.html).not.toContain(" ({ + bounds: {}, + fitBounds: vi.fn(), + latLngBounds: vi.fn(), + useInfiniteMemos: vi.fn(), +})); + +vi.mock("leaflet", () => ({ + default: { + latLngBounds: mocks.latLngBounds, + point: vi.fn(), + }, + DivIcon: class { + constructor(_options: unknown) {} + }, +})); + +vi.mock("react-leaflet", () => ({ + MapContainer: ({ children, attributionControl }: { children: ReactNode; attributionControl?: boolean }) => ( +
+ {children} +
+ ), + Marker: ({ children, title }: { children: ReactNode; title?: string }) => ( +
+ {children} +
+ ), + Popup: ({ children }: { children: ReactNode }) => children, + useMap: () => ({ fitBounds: mocks.fitBounds }), +})); + +vi.mock("react-leaflet-cluster", () => ({ + default: ({ children }: { children: ReactNode }) => children, +})); + +vi.mock("@/components/map/map-utils", () => ({ + defaultMarkerIcon: {}, + MinimalAttributionControl: () =>
, + OpenStreetMapTileLayer: () =>
, +})); + +vi.mock("@/components/MemoView/components/MemoSpaceBadge", () => ({ + default: () => null, +})); + +vi.mock("@/hooks/useMemoQueries", () => ({ + useInfiniteMemos: mocks.useInfiniteMemos, +})); + +describe("UserMemoMap", () => { + beforeEach(() => { + mocks.fitBounds.mockReset(); + mocks.latLngBounds.mockReset(); + mocks.latLngBounds.mockReturnValue(mocks.bounds); + mocks.useInfiniteMemos.mockReturnValue({ data: { pages: [] }, isLoading: false }); + }); + + it("uses the explicit minimal attribution control", () => { + render( + + + , + ); + + expect(screen.getByTestId("map")).toHaveAttribute("data-attribution-control", "false"); + expect(screen.getByTestId("attribution")).toBeInTheDocument(); + expect(screen.getByTestId("map-layer")).toBeInTheDocument(); + }); + + it("caps automatic zoom and gives memo markers an accessible name", () => { + mocks.useInfiniteMemos.mockReturnValue({ + data: { + pages: [ + { + memos: [ + { + name: "memos/1", + snippet: "Lunch spot", + location: { latitude: 1, longitude: 2 }, + }, + ], + }, + ], + }, + isLoading: false, + }); + + render( + + + , + ); + + expect(mocks.latLngBounds).toHaveBeenCalledWith([[1, 2]]); + expect(mocks.fitBounds).toHaveBeenCalledWith(mocks.bounds, { padding: [50, 50], maxZoom: 15 }); + expect(screen.getByTestId("memo-marker")).toHaveAttribute("data-title", "Lunch spot"); + }); +});