fix(map): refine Leaflet controls and memo map styling

This commit is contained in:
boojack 2026-04-06 08:45:54 +08:00
parent 25feef3aad
commit 894b3eb045
3 changed files with 98 additions and 64 deletions

View File

@ -25,8 +25,8 @@ interface ClusterGroup {
const createClusterCustomIcon = (cluster: ClusterGroup) => {
return new DivIcon({
html: `<span class="flex items-center justify-center w-full h-full bg-primary text-primary-foreground text-xs font-bold rounded-full shadow-md border-2 border-background">${cluster.getChildCount()}</span>`,
className: "custom-marker-cluster",
html: `<span class="flex h-8 w-8 items-center justify-center rounded-full border border-border bg-background/95 text-xs font-semibold text-foreground shadow-sm backdrop-blur-sm">${cluster.getChildCount()}</span>`,
className: "border-none bg-transparent",
iconSize: L.point(32, 32, true),
});
};
@ -67,17 +67,41 @@ const UserMemoMap = ({ creator, className }: Props) => {
const defaultCenter = { lat: 48.8566, lng: 2.3522 };
return (
<div className={cn("relative z-0 w-full h-[380px] rounded-xl overflow-hidden border border-border shadow-sm", className)}>
<div
className={cn(
"memo-user-map relative z-0 h-[380px] w-full overflow-hidden rounded-xl border border-border bg-background shadow-sm",
className,
)}
>
{memosWithLocation.length === 0 && (
<div className="absolute inset-0 z-[1000] flex items-center justify-center pointer-events-none">
<div className="flex flex-col items-center gap-1 rounded-2xl border border-border bg-background/70 px-4 py-2 shadow-sm backdrop-blur-sm">
<MapPinIcon className="h-5 w-5 text-muted-foreground opacity-60" />
<p className="text-xs font-medium text-muted-foreground">No location data found</p>
<div className="flex flex-col items-center gap-1.5 rounded-xl border border-border bg-background/92 px-5 py-3 shadow-sm backdrop-blur-sm">
<MapPinIcon className="h-5 w-5 text-muted-foreground opacity-70" />
<p className="text-xs font-medium tracking-[0.02em] text-muted-foreground">No location data found</p>
</div>
</div>
)}
<MapContainer center={defaultCenter} zoom={2} className="h-full w-full z-0" scrollWheelZoom attributionControl={false}>
<div className="pointer-events-none absolute left-4 top-4 z-[950] flex items-start justify-between gap-3 rounded-xl border border-border bg-background/92 px-3 py-2.5 shadow-sm backdrop-blur-sm">
<div className="flex items-center gap-2">
<span className="grid size-7 place-items-center rounded-full bg-primary/10 text-primary">
<MapPinIcon className="size-3.5" />
</span>
<div className="min-w-0">
<p className="text-[11px] font-medium uppercase tracking-[0.18em] text-muted-foreground">Mapped memos</p>
<p className="text-sm font-semibold text-foreground">{memosWithLocation.length} places pinned</p>
</div>
</div>
</div>
<MapContainer
center={defaultCenter}
zoom={2}
className="h-full w-full z-0"
scrollWheelZoom
zoomControl={false}
attributionControl={false}
>
<ThemedTileLayer />
<MarkerClusterGroup
chunkedLoading
@ -88,26 +112,36 @@ const UserMemoMap = ({ creator, className }: Props) => {
>
{memosWithLocation.map((memo) => (
<Marker key={memo.name} position={[memo.location!.latitude, memo.location!.longitude]} icon={defaultMarkerIcon}>
<Popup closeButton={false} className="w-48!">
<div className="flex flex-col p-0.5">
<div className="flex items-center justify-between border-b border-border pb-1 mb-1">
<span className="text-[10px] font-medium text-muted-foreground">
{memo.displayTime &&
timestampDate(memo.displayTime).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
})}
</span>
<Popup closeButton={false} className="memo-map-popup w-64!">
<div className="flex flex-col gap-2.5 p-3">
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<span className="inline-flex rounded-full border border-border/70 bg-muted/50 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
Memo
</span>
<span className="block text-[11px] font-medium text-muted-foreground">
{memo.displayTime &&
timestampDate(memo.displayTime).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
})}
</span>
</div>
<Link
to={`/memos/${memo.name.split("/").pop()}`}
className="flex items-center gap-0.5 text-[10px] text-primary hover:opacity-80"
className="inline-flex items-center gap-1 rounded-full border border-border bg-background px-2.5 py-1 text-[11px] font-medium text-foreground transition-all hover:border-primary/40 hover:text-primary"
>
View
<ArrowUpRightIcon className="h-3 w-3" />
Open
<ArrowUpRightIcon className="h-3.5 w-3.5" />
</Link>
</div>
<div className="line-clamp-3 py-0.5 text-xs font-sans leading-snug text-foreground">{memo.snippet || "No content"}</div>
<div className="space-y-1">
<div className="line-clamp-3 text-sm leading-snug font-medium text-foreground">{memo.snippet || "No content"}</div>
<div className="text-[11px] text-muted-foreground">
{memo.location!.latitude.toFixed(2)}°, {memo.location!.longitude.toFixed(2)}°
</div>
</div>
</div>
</Popup>
</Marker>

View File

@ -1,7 +1,7 @@
import L, { LatLng } from "leaflet";
import { ExternalLinkIcon, MinusIcon, PlusIcon } from "lucide-react";
import { type ReactNode, useEffect, useRef, useState } from "react";
import { createRoot } from "react-dom/client";
import { createPortal } from "react-dom";
import { MapContainer, Marker, useMap, useMapEvents } from "react-leaflet";
import { cn } from "@/lib/utils";
import { defaultMarkerIcon, ThemedTileLayer } from "./map-utils";
@ -135,7 +135,7 @@ interface MapControlsProps {
const MapControls = ({ position }: MapControlsProps) => {
const map = useMap();
const controlRef = useRef<MapControlsContainer | null>(null);
const rootRef = useRef<ReturnType<typeof createRoot> | null>(null);
const [container, setContainer] = useState<HTMLDivElement | null>(null);
const handleOpenInGoogleMaps = () => {
if (!position) return;
@ -156,39 +156,25 @@ const MapControls = ({ position }: MapControlsProps) => {
const control = new MapControlsContainer({ position: "topright" });
controlRef.current = control;
control.addTo(map);
// Get container and render React component into it
const container = control.getContainer();
if (container) {
rootRef.current = createRoot(container);
rootRef.current.render(
<ControlButtons position={position} onZoomIn={handleZoomIn} onZoomOut={handleZoomOut} onOpenGoogleMaps={handleOpenInGoogleMaps} />,
);
}
setContainer(control.getContainer() ?? null);
return () => {
// Cleanup: unmount React component and remove control
if (rootRef.current) {
rootRef.current.unmount();
rootRef.current = null;
}
if (controlRef.current) {
controlRef.current.remove();
controlRef.current = null;
}
setContainer(null);
};
}, [map]);
// Update rendered content when position changes
useEffect(() => {
if (rootRef.current) {
rootRef.current.render(
<ControlButtons position={position} onZoomIn={handleZoomIn} onZoomOut={handleZoomOut} onOpenGoogleMaps={handleOpenInGoogleMaps} />,
);
}
}, [position]);
if (!container) {
return null;
}
return null;
return createPortal(
<ControlButtons position={position} onZoomIn={handleZoomIn} onZoomOut={handleZoomOut} onOpenGoogleMaps={handleOpenInGoogleMaps} />,
container,
);
};
const MapCleanup = () => {
@ -222,21 +208,30 @@ const DEFAULT_CENTER_LAT_LNG = new LatLng(48.8584, 2.2945);
const LeafletMap = (props: MapProps) => {
const position = props.latlng || DEFAULT_CENTER_LAT_LNG;
const statusLabel = props.readonly ? "Pinned location" : props.latlng ? "Selected location" : "Choose a location";
return (
<MapContainer
className="w-full h-72"
center={position}
zoom={13}
scrollWheelZoom={false}
zoomControl={false}
attributionControl={false}
>
<ThemedTileLayer />
<LocationMarker position={position} readonly={props.readonly} onChange={props.onChange ? props.onChange : () => {}} />
<MapControls position={props.latlng} />
<MapCleanup />
</MapContainer>
<div className="memo-location-map relative isolate w-full overflow-hidden rounded-xl border border-border bg-background shadow-sm">
<MapContainer
className="h-72 w-full"
center={position}
zoom={13}
scrollWheelZoom={false}
zoomControl={false}
attributionControl={false}
>
<ThemedTileLayer />
<LocationMarker position={position} readonly={props.readonly} onChange={props.onChange ? props.onChange : () => {}} />
<MapControls position={props.latlng} />
<MapCleanup />
</MapContainer>
<div className="pointer-events-none absolute left-3 top-3 z-[450] flex items-center gap-2">
<div className="rounded-full border border-border bg-background/92 px-2.5 py-1 text-[11px] font-medium tracking-[0.02em] text-foreground/80 shadow-sm backdrop-blur-sm">
{statusLabel}
</div>
</div>
</div>
);
};

View File

@ -7,7 +7,7 @@ import { useAuth } from "@/contexts/AuthContext";
import { resolveTheme } from "@/utils/theme";
const TILE_URLS = {
light: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
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;
@ -24,12 +24,17 @@ interface MarkerIconOptions {
}
export const createMarkerIcon = (options?: MarkerIconOptions): DivIcon => {
const { fill = "orange", size = 28, className = "" } = options || {};
const { fill = "var(--primary)", size = 28, className = "" } = options || {};
return new DivIcon({
className: "relative border-none",
className: "relative border-none bg-transparent",
html: ReactDOMServer.renderToString(
<MapPinIcon className={`absolute bottom-1/2 -left-1/2 ${className}`.trim()} fill={fill} size={size} />,
<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>,
),
iconSize: [size + 8, size + 8],
iconAnchor: [(size + 8) / 2, size + 4],
popupAnchor: [0, -(size * 0.7)],
});
};