fix(map): restore keyless OpenStreetMap tiles (#6242)
This commit is contained in:
parent
3cb98f26ae
commit
171687c456
7 changed files with 254 additions and 45 deletions
|
|
@ -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) => {
|
|||
<MapContainer
|
||||
center={defaultCenter}
|
||||
zoom={2}
|
||||
className="h-full w-full z-0 !bg-muted"
|
||||
className="map-attribution-minimal h-full w-full z-0 !bg-muted"
|
||||
scrollWheelZoom
|
||||
zoomControl={false}
|
||||
attributionControl={false}
|
||||
>
|
||||
<ThemedTileLayer />
|
||||
<MinimalAttributionControl />
|
||||
<OpenStreetMapTileLayer />
|
||||
<MarkerClusterGroup
|
||||
chunkedLoading
|
||||
iconCreateFunction={createClusterCustomIcon}
|
||||
|
|
@ -115,7 +116,12 @@ const UserMemoMap = ({ creator, className }: Props) => {
|
|||
showCoverageOnHover={false}
|
||||
>
|
||||
{memosWithLocation.map((memo) => (
|
||||
<Marker key={memo.name} position={[memo.location!.latitude, memo.location!.longitude]} icon={defaultMarkerIcon}>
|
||||
<Marker
|
||||
key={memo.name}
|
||||
position={[memo.location!.latitude, memo.location!.longitude]}
|
||||
icon={defaultMarkerIcon}
|
||||
title={memo.snippet || "Memo location"}
|
||||
>
|
||||
<Popup
|
||||
closeButton={false}
|
||||
className={cn(
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
|
|||
import { createPortal } from "react-dom";
|
||||
import { MapContainer, Marker, useMap, useMapEvents } from "react-leaflet";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { defaultMarkerIcon, ThemedTileLayer } from "./map-utils";
|
||||
import { defaultMarkerIcon, MinimalAttributionControl, OpenStreetMapTileLayer } from "./map-utils";
|
||||
import type { MapPoint } from "./types";
|
||||
|
||||
const toLatLng = (point: MapPoint): LatLng => 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 : <Marker position={position} icon={defaultMarkerIcon}></Marker>;
|
||||
return position === undefined ? null : (
|
||||
<Marker position={position} icon={defaultMarkerIcon} interactive={false} keyboard={false}></Marker>
|
||||
);
|
||||
};
|
||||
|
||||
// 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
|
|||
)}
|
||||
>
|
||||
<MapContainer
|
||||
className="h-full w-full !bg-muted"
|
||||
className="map-attribution-minimal h-full w-full !bg-muted"
|
||||
center={mapCenter}
|
||||
zoom={13}
|
||||
scrollWheelZoom={false}
|
||||
zoomControl={false}
|
||||
attributionControl={false}
|
||||
>
|
||||
<ThemedTileLayer />
|
||||
<MinimalAttributionControl />
|
||||
<OpenStreetMapTileLayer />
|
||||
<LocationMarker position={markerPosition} readonly={readOnly} onChange={onChange} />
|
||||
<MapControls position={latlng} />
|
||||
<MapCleanup />
|
||||
|
|
|
|||
|
|
@ -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 <TileLayer url={isDark ? TILE_URLS.dark : TILE_URLS.light} />;
|
||||
};
|
||||
const OPENSTREETMAP_ATTRIBUTION = '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>';
|
||||
|
||||
export const OpenStreetMapTileLayer = () => <TileLayer url={OPENSTREETMAP_TILE_URL} attribution={OPENSTREETMAP_ATTRIBUTION} maxZoom={19} />;
|
||||
|
||||
export const MinimalAttributionControl = () => <AttributionControl prefix={false} />;
|
||||
|
||||
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(
|
||||
<div className={`relative flex items-center justify-center ${className}`.trim()}>
|
||||
<MapPinIcon fill={fill} size={size} strokeWidth={1.9} style={{ filter: "drop-shadow(0 6px 10px rgba(15, 23, 42, 0.22))" }} />
|
||||
<div aria-hidden="true" className={`grid place-items-center ${className}`.trim()} style={{ width: size, height: size }}>
|
||||
<span
|
||||
className="rounded-full border-2 border-white shadow-[0_2px_7px_rgba(15,23,42,0.4)]"
|
||||
style={{ width: size - 4, height: size - 4, backgroundColor: fill }}
|
||||
/>
|
||||
</div>,
|
||||
),
|
||||
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)],
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }) => <div data-testid="map">{children}</div>,
|
||||
Marker: ({ position }: { position: { lat: number; lng: number } }) => <div data-testid="marker">{`${position.lat},${position.lng}`}</div>,
|
||||
MapContainer: ({ children, attributionControl }: { children: ReactNode; attributionControl?: boolean }) => (
|
||||
<div data-testid="map" data-attribution-control={attributionControl}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Marker: ({ position, interactive, keyboard }: { position: { lat: number; lng: number }; interactive?: boolean; keyboard?: boolean }) => (
|
||||
<div data-testid="marker" data-interactive={interactive} data-keyboard={keyboard}>
|
||||
{`${position.lat},${position.lng}`}
|
||||
</div>
|
||||
),
|
||||
useMap: () => controlMap,
|
||||
useMapEvents: () => eventMap,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/map/map-utils", () => ({
|
||||
defaultMarkerIcon: {},
|
||||
ThemedTileLayer: () => <div data-testid="tile-layer" />,
|
||||
MinimalAttributionControl: () => <div data-testid="attribution" />,
|
||||
OpenStreetMapTileLayer: () => <div data-testid="map-layer" />,
|
||||
}));
|
||||
|
||||
describe("LocationPicker", () => {
|
||||
it("uses the explicit minimal attribution control", () => {
|
||||
const { getByTestId } = render(<LocationPicker />);
|
||||
|
||||
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(<LocationPicker latlng={{ lat: 1, lng: 2 }} />);
|
||||
|
||||
|
|
@ -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(<LocationPicker />);
|
||||
|
||||
expect(queryByTestId("marker")).not.toBeInTheDocument();
|
||||
|
||||
rerender(<LocationPicker latlng={{ lat: 1, lng: 2 }} />);
|
||||
|
||||
expect(getByTestId("marker")).toHaveTextContent("1,2");
|
||||
expect(getByTestId("marker")).toHaveAttribute("data-interactive", "false");
|
||||
expect(getByTestId("marker")).toHaveAttribute("data-keyboard", "false");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
52
web/tests/map-utils.test.tsx
Normal file
52
web/tests/map-utils.test.tsx
Normal file
|
|
@ -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 }) => <div data-testid="attribution-control" data-prefix={String(prefix)} />,
|
||||
TileLayer: ({ url, attribution, maxZoom }: { url: string; attribution: string; maxZoom: number }) => (
|
||||
<div data-testid="tile-layer" data-url={url} data-attribution={attribution} data-max-zoom={maxZoom} />
|
||||
),
|
||||
}));
|
||||
|
||||
describe("OpenStreetMapTileLayer", () => {
|
||||
it("uses the standard keyless tile endpoint with the required attribution", () => {
|
||||
render(<OpenStreetMapTileLayer />);
|
||||
|
||||
const tileLayer = screen.getByTestId("tile-layer");
|
||||
expect(tileLayer).toHaveAttribute("data-url", "https://tile.openstreetmap.org/{z}/{x}/{y}.png");
|
||||
expect(tileLayer).toHaveAttribute("data-attribution", '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>');
|
||||
expect(tileLayer).toHaveAttribute("data-max-zoom", "19");
|
||||
});
|
||||
|
||||
it("removes Leaflet branding from the attribution control", () => {
|
||||
render(<MinimalAttributionControl />);
|
||||
|
||||
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("<svg");
|
||||
expect(marker.options.html).toContain("rounded-full");
|
||||
expect(marker.options.iconSize).toEqual([24, 24]);
|
||||
expect(marker.options.iconAnchor).toEqual([12, 12]);
|
||||
});
|
||||
});
|
||||
105
web/tests/user-memo-map.test.tsx
Normal file
105
web/tests/user-memo-map.test.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import UserMemoMap from "@/components/UserMemoMap/UserMemoMap";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
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 }) => (
|
||||
<div data-testid="map" data-attribution-control={attributionControl}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Marker: ({ children, title }: { children: ReactNode; title?: string }) => (
|
||||
<div data-testid="memo-marker" data-title={title}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
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: () => <div data-testid="attribution" />,
|
||||
OpenStreetMapTileLayer: () => <div data-testid="map-layer" />,
|
||||
}));
|
||||
|
||||
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(
|
||||
<MemoryRouter>
|
||||
<UserMemoMap creator="users/1" />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
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(
|
||||
<MemoryRouter>
|
||||
<UserMemoMap creator="users/1" />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue