Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,19 @@ DEFAULT_TIMEZONE=UTC
# Map link providers (google, naver, kakao, ...)
MAP_LINK_PROVIDERS=google,naver

# User-facing map tile provider — osm (default, OSS-safe) | kakao | google
# `osm` requires no key; Leaflet + OpenStreetMap tiles.
# `kakao` requires KAKAO_MAP_APP_KEY (JavaScript key, issued at https://developers.kakao.com/).
# The key's allowed domains (Web platform → Site Domain) must include every origin
# the app runs on: e.g. http://localhost:3000 for dev, https://your.domain for prod.
# `google` is a future stub — selecting it throws until an adapter lands.
MAP_PROVIDER=osm
KAKAO_MAP_APP_KEY=''
# REST API key (server-side only) — enables POI name autocomplete on map click.
# Issue from the same Kakao Developers app under 앱 키 → REST API 키.
KAKAO_MAP_REST_KEY=''
GOOGLE_MAPS_API_KEY=''


# PostHog (optional)
POSTHOG_KEY=phc_xxx
Expand Down
124 changes: 19 additions & 105 deletions src/components/LeafletMap.tsx
Original file line number Diff line number Diff line change
@@ -1,35 +1,11 @@
import { useEffect, useRef, useState } from "react";
import { cn } from "~/lib/utils";
import { buildMarkerArtwork, buildSelectedPinArtwork } from "~/components/maps/icon";
import type { UserFacingMapProps } from "~/components/maps/types";

export type MarkerColor = "blue" | "red" | "green" | "gold" | "gray";
export type { MapMarker, MarkerColor } from "~/components/maps/types";

export type MapMarker = {
lat: number;
lng: number;
label: string;
id: string;
color?: MarkerColor;
glyph?: string | null;
highlighted?: boolean;
};

type CircleOverlay = {
center: [number, number];
radiusKm: number;
};

type LeafletMapProps = {
center?: [number, number];
zoom?: number;
markers?: MapMarker[];
circle?: CircleOverlay;
fitToMarkers?: boolean;
onMapClick?: (lat: number, lng: number) => void;
onMarkerClick?: (marker: MapMarker) => void;
onZoomEnd?: (zoom: number) => void;
height?: string;
className?: string;
};
type LeafletMapProps = UserFacingMapProps;

export function LeafletMap({
center = [37.5665, 126.978], // Seoul default
Expand Down Expand Up @@ -117,89 +93,25 @@ export function LeafletMap({
gpsMarkerRef.current = null;
}

// Colored marker icon factory
const MARKER_COLORS: Record<MarkerColor, { text: string }> = {
blue: {
text: "#1e3a8a",
},
red: {
text: "#991b1b",
},
green: {
text: "#166534",
},
gold: {
text: "#92400e",
},
gray: {
text: "#374151",
},
};
const MARKER_BORDER = "#6b7280";
function makeSelectedPin() {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="28" height="42" viewBox="0 0 28 42">
<defs>
<filter id="selected-pin-shadow" x="-30%" y="-20%" width="160%" height="180%">
<feDropShadow dx="0" dy="2" stdDeviation="2" flood-color="#7f1d1d" flood-opacity="0.22"/>
</filter>
</defs>
<g filter="url(#selected-pin-shadow)">
<path d="M14 40 C20.5 29, 25 23, 25 14 C25 7.925, 20.075 3, 14 3 C7.925 3, 3 7.925, 3 14 C3 23, 7.5 29, 14 40 Z" fill="#ef4444" stroke="#b91c1c" stroke-width="1.5"/>
<circle cx="14" cy="14" r="5.5" fill="#ffffff"/>
</g>
</svg>`;
function toDivIcon(artwork: ReturnType<typeof buildMarkerArtwork>, popupOffset: number) {
return L.divIcon({
html: svg,
iconSize: [28, 42],
iconAnchor: [14, 42],
popupAnchor: [0, -36],
className: "",
});
}
function makeIcon(color: MarkerColor, glyph?: string | null, highlighted = false) {
const palette = MARKER_COLORS[color];
const isHighlighted = highlighted && !!glyph;
const width = isHighlighted ? 44 : 44;
const height = isHighlighted ? 48 : 48;
const iconAnchorX = 22;
const iconAnchorY = 48;
const shadowId = isHighlighted ? "marker-shadow-active" : "marker-shadow";
const strokeColor = isHighlighted ? "#ef4444" : MARKER_BORDER;
const fillColor = "#ffffff";
const glyphMarkup = glyph
? `<text x="22" y="25.5" text-anchor="middle" font-size="18">${glyph}</text>`
: "";
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 48 54">
<defs>
<filter id="marker-shadow" x="-20%" y="-20%" width="140%" height="160%">
<feDropShadow dx="0" dy="2" stdDeviation="2" flood-color="#0f172a" flood-opacity="0.18"/>
</filter>
<filter id="marker-shadow-active" x="-30%" y="-30%" width="160%" height="200%">
<feDropShadow dx="0" dy="3" stdDeviation="3" flood-color="#7f1d1d" flood-opacity="0.18"/>
</filter>
</defs>
<g filter="url(#${shadowId})">
<rect x="4" y="4" width="40" height="32" rx="11" fill="${fillColor}" stroke="${strokeColor}" stroke-width="${isHighlighted ? 2.25 : 1.5}"/>
<path d="M19 36 L19 50 L29 36" fill="${fillColor}" stroke="${strokeColor}" stroke-width="${isHighlighted ? 2.25 : 1.5}" stroke-linejoin="round"/>
</g>
${glyphMarkup}
</svg>`;
return L.divIcon({
html: svg,
iconSize: [width, height],
iconAnchor: [iconAnchorX, iconAnchorY],
popupAnchor: [0, isHighlighted ? -50 : -42],
html: artwork.svg,
iconSize: [artwork.width, artwork.height],
iconAnchor: [artwork.anchorX, artwork.anchorY],
popupAnchor: [0, popupOffset],
className: "",
});
}

// Add new markers
for (const marker of markers) {
const icon = marker.glyph
? makeIcon(marker.color ?? "blue", marker.glyph, marker.highlighted)
: marker.highlighted
? makeSelectedPin()
: undefined;
let icon: any;
if (marker.glyph) {
const artwork = buildMarkerArtwork(marker.glyph, marker.highlighted);
icon = toDivIcon(artwork, marker.highlighted ? -50 : -42);
} else if (marker.highlighted) {
icon = toDivIcon(buildSelectedPinArtwork(), -36);
}
const m = L.marker([marker.lat, marker.lng], icon ? { icon } : {})
.addTo(mapRef.current)
.bindTooltip(marker.label);
Expand Down Expand Up @@ -250,7 +162,9 @@ export function LeafletMap({

// Place a green marker at the user's position
if (gpsMarkerRef.current) gpsMarkerRef.current.remove();
gpsMarkerRef.current = L.marker([lat, lng], { icon: makeIcon("green") })
gpsMarkerRef.current = L.marker([lat, lng], {
icon: toDivIcon(buildMarkerArtwork(null, false), -42),
})
.addTo(mapRef.current)
.bindPopup("You are here")
.openPopup();
Expand Down
165 changes: 165 additions & 0 deletions src/components/MapSearchOverlay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import { useEffect, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Search } from "lucide-react";
import { useMapConfig } from "~/components/maps/config";
import { cn } from "~/lib/utils";
import { zoomToRadius } from "~/lib/place";

export type PoiCandidate = {
externalId: string;
source: "kakao";
name: string;
address: string | null;
roadAddress: string | null;
lat: number;
lng: number;
categoryName: string | null;
distanceMeters: number | null;
};

type Props = {
/** Bias the search around this coord. Usually the current map center or the user's GPS. */
biasLat: number | null;
biasLng: number | null;
/** Current map zoom — used to scale the search radius.
Higher zoom (more detail) ⇒ smaller radius so suggestions stay relevant. */
biasZoom?: number;
onPick: (candidate: PoiCandidate) => void;
placeholder?: string;
className?: string;
};

// Kakao keyword-search radius in meters. zoomToRadius() is tuned tight for the
// nearby-check-in circle ("what's literally around me"); POI search wants a
// wider net that feels like the visible map viewport, so we 2× it and cap at
// Kakao's max of 20 km.
function zoomToRadiusMeters(zoom?: number): number {
if (zoom == null) return 5000;
return Math.min(20000, Math.round(zoomToRadius(zoom) * 2000));
}

export function MapSearchOverlay({
biasLat,
biasLng,
biasZoom,
onPick,
placeholder = "Search places…",
className,
}: Props) {
const { data: mapConfig } = useMapConfig();
const [value, setValue] = useState("");
const [debounced, setDebounced] = useState("");
const [open, setOpen] = useState(false);
const wrapperRef = useRef<HTMLDivElement>(null);

useEffect(() => {
const t = setTimeout(() => setDebounced(value), 250);
return () => clearTimeout(t);
}, [value]);

const enabled =
mapConfig?.provider === "kakao"
&& biasLat != null
&& biasLng != null
&& debounced.trim().length >= 1;

const radiusMeters = zoomToRadiusMeters(biasZoom);
const { data, isFetching, error } = useQuery<{ candidates: PoiCandidate[] }>({
queryKey: ["poi-search", debounced, biasLat, biasLng, radiusMeters],
queryFn: async () => {
const r = await fetch(
`/api/places/poi-search?q=${encodeURIComponent(debounced.trim())}&lat=${biasLat}&lng=${biasLng}&radius=${radiusMeters}`,
);
if (!r.ok) {
const body = (await r.json().catch(() => null)) as { error?: string } | null;
throw new Error(body?.error ?? `HTTP ${r.status}`);
}
return r.json();
},
enabled,
staleTime: 60_000,
retry: false,
});

useEffect(() => {
function onClickAway(e: MouseEvent) {
if (!wrapperRef.current?.contains(e.target as Node)) setOpen(false);
}
document.addEventListener("mousedown", onClickAway);
return () => document.removeEventListener("mousedown", onClickAway);
}, []);

if (mapConfig?.provider !== "kakao") return null;

const candidates = data?.candidates ?? [];
// Always show the dropdown once the user has typed + is focused, so empty
// or error states give visual feedback instead of silence.
const showDropdown = open && enabled;

return (
<div
ref={wrapperRef}
className={cn("absolute left-2 top-2 z-[1000] w-56 sm:left-3", className)}
style={{ position: "absolute", top: 8, zIndex: 1000 }}
>
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<input
type="text"
value={value}
onChange={(e) => {
setValue(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
placeholder={placeholder}
autoComplete="off"
className="h-9 w-full rounded-md border border-border bg-white pl-9 pr-3 text-sm text-foreground shadow-md outline-none placeholder:text-muted-foreground focus-visible:ring-1 focus-visible:ring-ring/30"
style={{ backgroundColor: "#ffffff" }}
/>
</div>
{showDropdown && (
<ul
className="mt-1 max-h-64 w-full overflow-auto rounded-md border border-border bg-white text-sm text-foreground shadow-md"
role="listbox"
style={{ backgroundColor: "#ffffff" }}
>
{isFetching && candidates.length === 0 && (
<li className="px-3 py-2 text-muted-foreground">Searching…</li>
)}
{error && !isFetching && (
<li className="px-3 py-2 text-destructive">
{error instanceof Error ? error.message : "Search failed"}
</li>
)}
{candidates.map((c) => (
<li key={c.externalId}>
<button
type="button"
className="flex w-full flex-col items-start gap-0.5 px-3 py-2 text-left hover:bg-accent hover:text-accent-foreground"
onClick={() => {
onPick(c);
setValue(c.name);
setOpen(false);
}}
>
<span className="font-medium">{c.name}</span>
<span className="text-xs text-muted-foreground">
{c.roadAddress ?? c.address ?? ""}
{c.distanceMeters != null && (
<span className="ml-2">· {c.distanceMeters}m</span>
)}
</span>
</button>
</li>
))}
{!isFetching && !error && candidates.length === 0 && (
<li className="px-3 py-2 text-muted-foreground">
No matches nearby. Try a different term or zoom out.
</li>
)}
</ul>
)}
</div>
);
}
Loading
Loading