Commit 894b3eb0 authored by boojack's avatar boojack

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

parent 25feef3a
...@@ -25,8 +25,8 @@ interface ClusterGroup { ...@@ -25,8 +25,8 @@ interface ClusterGroup {
const createClusterCustomIcon = (cluster: ClusterGroup) => { const createClusterCustomIcon = (cluster: ClusterGroup) => {
return new DivIcon({ 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>`, 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: "custom-marker-cluster", className: "border-none bg-transparent",
iconSize: L.point(32, 32, true), iconSize: L.point(32, 32, true),
}); });
}; };
...@@ -67,17 +67,41 @@ const UserMemoMap = ({ creator, className }: Props) => { ...@@ -67,17 +67,41 @@ const UserMemoMap = ({ creator, className }: Props) => {
const defaultCenter = { lat: 48.8566, lng: 2.3522 }; const defaultCenter = { lat: 48.8566, lng: 2.3522 };
return ( 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 && ( {memosWithLocation.length === 0 && (
<div className="absolute inset-0 z-[1000] flex items-center justify-center pointer-events-none"> <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"> <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-60" /> <MapPinIcon className="h-5 w-5 text-muted-foreground opacity-70" />
<p className="text-xs font-medium text-muted-foreground">No location data found</p> <p className="text-xs font-medium tracking-[0.02em] text-muted-foreground">No location data found</p>
</div> </div>
</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 /> <ThemedTileLayer />
<MarkerClusterGroup <MarkerClusterGroup
chunkedLoading chunkedLoading
...@@ -88,26 +112,36 @@ const UserMemoMap = ({ creator, className }: Props) => { ...@@ -88,26 +112,36 @@ const UserMemoMap = ({ creator, className }: Props) => {
> >
{memosWithLocation.map((memo) => ( {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}>
<Popup closeButton={false} className="w-48!"> <Popup closeButton={false} className="memo-map-popup w-64!">
<div className="flex flex-col p-0.5"> <div className="flex flex-col gap-2.5 p-3">
<div className="flex items-center justify-between border-b border-border pb-1 mb-1"> <div className="flex items-start justify-between gap-3">
<span className="text-[10px] font-medium text-muted-foreground"> <div className="space-y-1">
{memo.displayTime && <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">
timestampDate(memo.displayTime).toLocaleDateString(undefined, { Memo
year: "numeric", </span>
month: "short", <span className="block text-[11px] font-medium text-muted-foreground">
day: "numeric", {memo.displayTime &&
})} timestampDate(memo.displayTime).toLocaleDateString(undefined, {
</span> year: "numeric",
month: "short",
day: "numeric",
})}
</span>
</div>
<Link <Link
to={`/memos/${memo.name.split("/").pop()}`} 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 Open
<ArrowUpRightIcon className="h-3 w-3" /> <ArrowUpRightIcon className="h-3.5 w-3.5" />
</Link> </Link>
</div> </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> </div>
</Popup> </Popup>
</Marker> </Marker>
......
import L, { LatLng } from "leaflet"; import L, { LatLng } from "leaflet";
import { ExternalLinkIcon, MinusIcon, PlusIcon } from "lucide-react"; import { ExternalLinkIcon, MinusIcon, PlusIcon } from "lucide-react";
import { type ReactNode, useEffect, useRef, useState } from "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 { MapContainer, Marker, useMap, useMapEvents } from "react-leaflet";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { defaultMarkerIcon, ThemedTileLayer } from "./map-utils"; import { defaultMarkerIcon, ThemedTileLayer } from "./map-utils";
...@@ -135,7 +135,7 @@ interface MapControlsProps { ...@@ -135,7 +135,7 @@ interface MapControlsProps {
const MapControls = ({ position }: MapControlsProps) => { const MapControls = ({ position }: MapControlsProps) => {
const map = useMap(); const map = useMap();
const controlRef = useRef<MapControlsContainer | null>(null); const controlRef = useRef<MapControlsContainer | null>(null);
const rootRef = useRef<ReturnType<typeof createRoot> | null>(null); const [container, setContainer] = useState<HTMLDivElement | null>(null);
const handleOpenInGoogleMaps = () => { const handleOpenInGoogleMaps = () => {
if (!position) return; if (!position) return;
...@@ -156,39 +156,25 @@ const MapControls = ({ position }: MapControlsProps) => { ...@@ -156,39 +156,25 @@ const MapControls = ({ position }: MapControlsProps) => {
const control = new MapControlsContainer({ position: "topright" }); const control = new MapControlsContainer({ position: "topright" });
controlRef.current = control; controlRef.current = control;
control.addTo(map); control.addTo(map);
setContainer(control.getContainer() ?? null);
// 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} />,
);
}
return () => { return () => {
// Cleanup: unmount React component and remove control
if (rootRef.current) {
rootRef.current.unmount();
rootRef.current = null;
}
if (controlRef.current) { if (controlRef.current) {
controlRef.current.remove(); controlRef.current.remove();
controlRef.current = null; controlRef.current = null;
} }
setContainer(null);
}; };
}, [map]); }, [map]);
// Update rendered content when position changes if (!container) {
useEffect(() => { return null;
if (rootRef.current) { }
rootRef.current.render(
<ControlButtons position={position} onZoomIn={handleZoomIn} onZoomOut={handleZoomOut} onOpenGoogleMaps={handleOpenInGoogleMaps} />,
);
}
}, [position]);
return null; return createPortal(
<ControlButtons position={position} onZoomIn={handleZoomIn} onZoomOut={handleZoomOut} onOpenGoogleMaps={handleOpenInGoogleMaps} />,
container,
);
}; };
const MapCleanup = () => { const MapCleanup = () => {
...@@ -222,21 +208,30 @@ const DEFAULT_CENTER_LAT_LNG = new LatLng(48.8584, 2.2945); ...@@ -222,21 +208,30 @@ const DEFAULT_CENTER_LAT_LNG = new LatLng(48.8584, 2.2945);
const LeafletMap = (props: MapProps) => { const LeafletMap = (props: MapProps) => {
const position = props.latlng || DEFAULT_CENTER_LAT_LNG; const position = props.latlng || DEFAULT_CENTER_LAT_LNG;
const statusLabel = props.readonly ? "Pinned location" : props.latlng ? "Selected location" : "Choose a location";
return ( return (
<MapContainer <div className="memo-location-map relative isolate w-full overflow-hidden rounded-xl border border-border bg-background shadow-sm">
className="w-full h-72" <MapContainer
center={position} className="h-72 w-full"
zoom={13} center={position}
scrollWheelZoom={false} zoom={13}
zoomControl={false} scrollWheelZoom={false}
attributionControl={false} zoomControl={false}
> attributionControl={false}
<ThemedTileLayer /> >
<LocationMarker position={position} readonly={props.readonly} onChange={props.onChange ? props.onChange : () => {}} /> <ThemedTileLayer />
<MapControls position={props.latlng} /> <LocationMarker position={position} readonly={props.readonly} onChange={props.onChange ? props.onChange : () => {}} />
<MapCleanup /> <MapControls position={props.latlng} />
</MapContainer> <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>
); );
}; };
......
...@@ -7,7 +7,7 @@ import { useAuth } from "@/contexts/AuthContext"; ...@@ -7,7 +7,7 @@ import { useAuth } from "@/contexts/AuthContext";
import { resolveTheme } from "@/utils/theme"; import { resolveTheme } from "@/utils/theme";
const TILE_URLS = { 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", dark: "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png",
} as const; } as const;
...@@ -24,12 +24,17 @@ interface MarkerIconOptions { ...@@ -24,12 +24,17 @@ interface MarkerIconOptions {
} }
export const createMarkerIcon = (options?: MarkerIconOptions): DivIcon => { export const createMarkerIcon = (options?: MarkerIconOptions): DivIcon => {
const { fill = "orange", size = 28, className = "" } = options || {}; const { fill = "var(--primary)", size = 28, className = "" } = options || {};
return new DivIcon({ return new DivIcon({
className: "relative border-none", className: "relative border-none bg-transparent",
html: ReactDOMServer.renderToString( 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)],
}); });
}; };
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment