fix(map): restore keyless OpenStreetMap tiles (#6242)

This commit is contained in:
boojack 2026-08-28 22:41:36 +08:00 committed by GitHub
parent 3cb98f26ae
commit 171687c456
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 254 additions and 45 deletions

View file

@ -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(

View file

@ -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 />

View file

@ -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 = '&copy; <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)],
});
};

View file

@ -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;
}
}

View file

@ -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");
});
});

View 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", '&copy; <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]);
});
});

View 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");
});
});