diff --git a/src/app/(main)/plan/_components/itinerary/PlanPlaceCard.tsx b/src/app/(main)/plan/_components/itinerary/PlanPlaceCard.tsx index 842af452..e8a1e9b6 100644 --- a/src/app/(main)/plan/_components/itinerary/PlanPlaceCard.tsx +++ b/src/app/(main)/plan/_components/itinerary/PlanPlaceCard.tsx @@ -27,7 +27,11 @@ import { import type { PlanPlace } from "@/lib/plan/types"; import { cn } from "@/lib/utils"; import { useSelectedPlace } from "@/contexts/SelectedPlaceContext"; -import { usePlanMapFocusStore } from "@/stores/plan-map-focus-store"; +import { + PLAN_MAP_FOCUS_NO_LOCATION_MESSAGE, + planPlaceFocusTarget, + usePlanMapFocusStore, +} from "@/stores/plan-map-focus-store"; import { PlanItemMemoEditor, PlanItemMemoReadOnly } from "./PlanItemMemoForm"; import { PlanItemTimeEditor } from "./PlanItemTimeForm"; @@ -80,9 +84,10 @@ export function PlanPlaceCard({ s.focus.scheduleId === focusScheduleId && s.focus.itemId === place.itemId, ); - /** 지도 핀으로 고른 경우에만 값이 있다 — 그때 일정 목록을 이 카드로 스크롤한다 */ - const focusSeqFromMap = usePlanMapFocusStore((s) => - s.focus?.source === "map" && + /** 카드가 아닌 곳(지도 핀·장소 이동 스테퍼)에서 고른 경우에만 값이 있다 — 그때 일정 목록을 이 카드로 스크롤한다 */ + const focusSeqToReveal = usePlanMapFocusStore((s) => + s.focus !== null && + s.focus.source !== "card" && s.focus.roomId === focusRoomId && s.focus.scheduleId === focusScheduleId && s.focus.itemId === place.itemId @@ -91,10 +96,10 @@ export function PlanPlaceCard({ ); const articleRef = useRef(null); useEffect(() => { - if (focusSeqFromMap === null) return; + if (focusSeqToReveal === null) return; const reduceMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches; articleRef.current?.scrollIntoView({ behavior: reduceMotion ? "auto" : "smooth", block: "center" }); - }, [focusSeqFromMap]); + }, [focusSeqToReveal]); const pointerDownRef = useRef<{ x: number; y: number } | null>(null); const blockCardDragRef = useRef(false); const [thumbnailRef, thumbnailInViewport] = useInViewport(); @@ -202,24 +207,12 @@ export function PlanPlaceCard({ setSelectedPlace(null); return; } - const loc = place.location; - const hasLocation = - loc != null && - typeof loc.lat === "number" && - typeof loc.lng === "number" && - Number.isFinite(loc.lat) && - Number.isFinite(loc.lng); + const target = planPlaceFocusTarget(place, focusRoomId, focusScheduleId, "card"); + if (!target) return; // 일정에 넣은 장소는 상세를 보려는 게 아니라 위치를 보려는 것 — 상세(조회 포함) 대신 카드·지도 핀만 강조한다 setSelectedPlace(null); - focusPlace({ - roomId: focusRoomId, - scheduleId: focusScheduleId, - itemId: place.itemId, - googlePlaceId: place.googlePlaceId?.trim() || null, - location: hasLocation ? { lat: loc.lat, lng: loc.lng } : null, - source: "card", - }); - if (!hasLocation) toast.info("지도에 표시할 위치 정보가 없어요."); + focusPlace(target); + if (!target.location) toast.info(PLAN_MAP_FOCUS_NO_LOCATION_MESSAGE); }, [ clearFocus, @@ -227,9 +220,7 @@ export function PlanPlaceCard({ focusRoomId, focusScheduleId, focused, - place.googlePlaceId, - place.itemId, - place.location, + place, setSelectedPlace, ], ); @@ -293,7 +284,7 @@ export function PlanPlaceCard({ aria-label={hasTime ? `시간 ${timeRange} 수정` : "시간 설정"} className={cn(PLAN_PLACE_CARD_TW.actionButton, "tabular-nums")} > - + {timeRange || "시간"} ) : null; @@ -429,7 +420,8 @@ export function PlanPlaceCard({ label="비용" // 금액이 길면 메모 버튼 앞에서 말줄임되도록 공통 버튼의 shrink-0를 푼다 className={cn(PLAN_PLACE_CARD_TW.actionButton, "min-w-0 shrink")} - icon={} + // 금액이 있을 때만 파랑 — 버튼의 `data-filled`로 바꾼다 + icon={} /> )} diff --git a/src/app/globals.css b/src/app/globals.css index f2b1b243..84be1375 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -22,6 +22,11 @@ overflow: auto !important; /* NOSONAR: S9423 — Google Maps의 인라인 overflow:scroll에 의한 불필요한 스크롤바를 제거한다. */ } +/* PC 지도 버튼은 지도 끝에서 아래·좌우 모두 24px — 확대·축소(+/−)의 아래는 저작권 줄과 기본 여백으로 이미 24px이라 오른쪽만 넓힌다. */ +[data-map-controls-inset] .gm-bundled-control { + margin-right: 24px !important; /* NOSONAR: S9423 — Google Maps가 주입하는 인라인 margin(10px)을 재정의한다. */ +} + @layer base { html { @apply font-sans; diff --git a/src/components/expenses/ExpenseProvider.tsx b/src/components/expenses/ExpenseProvider.tsx index 6192dae9..7052cd46 100644 --- a/src/components/expenses/ExpenseProvider.tsx +++ b/src/components/expenses/ExpenseProvider.tsx @@ -446,6 +446,7 @@ export function ExpenseEntryButton({ aria-label={`${scopeLabel} ${context.list.isSuccess && !scoped.length ? "비용 추가" : "비용 목록 열기"}`} aria-describedby={scheduleItemId === undefined ? undefined : summaryId} aria-haspopup="dialog" + data-filled={context.list.isSuccess && scoped.length > 0 ? "" : undefined} onMouseDown={(e) => e.stopPropagation()} onClick={(e) => { e.stopPropagation(); @@ -457,7 +458,7 @@ export function ExpenseEntryButton({ context.openScope(scope); } }} - className={cn(className, "min-w-0 max-w-full text-left", "disabled:cursor-not-allowed disabled:opacity-50")} + className={cn(className, "group min-w-0 max-w-full text-left", "disabled:cursor-not-allowed disabled:opacity-50")} > {icon}{summaryLabel} diff --git a/src/components/map/Map.tsx b/src/components/map/Map.tsx index 0192a6e1..2d8e7e5d 100644 --- a/src/components/map/Map.tsx +++ b/src/components/map/Map.tsx @@ -21,6 +21,7 @@ import { MAP_MAX_ZOOM, MAP_MIN_ZOOM, MAP_PAN_RESTRICTION, + moveMapTo, normalizeGooglePlaceResourceId, panToWithinWorld, readDestinationLatLngFromSession, @@ -35,6 +36,7 @@ import { readRoomMapViewport, writeRoomMapViewport, } from "@/lib/map-room-viewport-storage"; +import { MAP_DETAIL_PANEL_COVER_WIDTH_PX } from "@/lib/layout-tokens"; import { isPlanPath } from "@/lib/layout/mainChromeLayoutWidth"; import { isMobileMapPathname } from "@/lib/mobile-view"; import { clampPlacesSearchRadiusMeters } from "@/lib/places/placesSearchRadius"; @@ -62,6 +64,8 @@ import { MapDiscoverToolbar } from "./MapDiscoverToolbar"; import { RouteViewCameraController } from "./RouteViewCameraController"; import { MapDiscoverPlaces } from "./MapDiscoverPlaces"; import { PlanItineraryMapRoutes, type PlanItineraryRouteDay } from "./PlanItineraryMapRoutes"; +import { PlanRouteStepper } from "./PlanRouteStepper"; +import { useWebPlanRouteMode } from "./useWebPlanRouteMode"; import type { OpenValue, RatingValue } from "./map-filters"; /** 모바일 경로 보기 — 고른 일차만 그리고, 카드 선택에 맞춰 지도를 옮긴다 */ @@ -93,7 +97,10 @@ export type MapRouteView = PlanItineraryRouteDay & { // ─── 웹 일정에서 고른 장소로 지도 이동 ───────────────────────────────────────── -/** 일정 카드·핀으로 고르면 줌은 그대로 두고 그 장소로 부드럽게 옮긴다(모바일 경로 보기의 카드 넘김과 같은 이동) */ +/** + * 일정 카드·핀·장소 이동 스테퍼로 고르면 줌은 그대로 두고 그 장소로 부드럽게 옮긴다(모바일 경로 보기의 카드 넘김과 같은 이동). + * 지도 핀으로 고르면 함께 연 상세(`SelectedPlaceController`)도 같은 곳으로 옮기는데, 같은 곳으로 가는 이동은 하나로 이어진다 + */ function PlanFocusCameraController({ roomId }: Readonly<{ roomId: string | null }>) { const map = useMap(); const rid = typeof roomId === "string" ? roomId.trim() : ""; @@ -103,7 +110,7 @@ function PlanFocusCameraController({ roomId }: Readonly<{ roomId: string | null useEffect(() => { if (!map || focusSeq === null || lat === undefined || lng === undefined) return; - panToWithinWorld(map, { lat, lng }); + return moveMapTo(map, { lat, lng }); }, [map, focusSeq, lat, lng]); return null; @@ -113,6 +120,7 @@ function PlanFocusCameraController({ roomId }: Readonly<{ roomId: string | null function SelectedPlaceController() { const map = useMap(); + const { isMobileDevice } = useMobileView(); const { selectedPlace, placeSelectionCameraRef } = useSelectedPlace(); useEffect(() => { @@ -121,17 +129,24 @@ function SelectedPlaceController() { placeSelectionCameraRef.current = "full"; if (mode === "none") return; - panToWithinWorld(map, selectedPlace.location); - if (mode === "full") { - map.setZoom(16); + const zoom = mode === "full" ? 16 : undefined; + // 모바일은 아직 구글 이동(`panTo`)을 그대로 쓴다 + if (isMobileDevice) { + panToWithinWorld(map, selectedPlace.location); + if (zoom !== undefined) map.setZoom(zoom); + return; } - }, [map, selectedPlace?.location, placeSelectionCameraRef]); + moveMapTo(map, selectedPlace.location, { zoom }); + }, [map, isMobileDevice, selectedPlace?.location, placeSelectionCameraRef]); return null; } const VIEWPORT_PERSIST_DEBOUNCE_MS = 400; +/** 오른쪽 아래 확대·축소(+/−) 자리 — 지도 끝 24 + 버튼 40 + 간격 12. 장소 이동 스테퍼가 이보다 오른쪽으로 가지 않는다 */ +const ZOOM_CONTROL_CLEARANCE_PX = 24 + 40 + 12; + /** bootstrap 복원 직후 Zustand `mapCenter`·줌을 검색/일정 UI와 맞춤 */ function MapBootstrapSync({ ready, @@ -185,9 +200,13 @@ function MapBootstrapSync({ return null; } -/** 가로 월드 래핑(지도 타일 무한 복제) 시 마지막 유효 중심으로 되돌림 */ +/** + * 가로 월드 래핑(지도 타일 무한 복제) 시 마지막 유효 중심으로 되돌림. + * 되돌리는 동안에도 `center_changed`가 계속 오지만 같은 곳으로 가는 이동은 새로 시작하지 않는다(`moveMapTo`) + */ function MapPreventHorizontalWrap() { const map = useMap(); + const { isMobileDevice } = useMobileView(); const lastValidCenterRef = useRef(null); useEffect(() => { @@ -208,9 +227,10 @@ function MapPreventHorizontalWrap() { } const last = lastValidCenterRef.current; - if (last) { - panToWithinWorld(map, last); - } + if (!last) return; + // 모바일은 아직 구글 이동(`panTo`)을 그대로 쓴다 + if (isMobileDevice) panToWithinWorld(map, last); + else moveMapTo(map, last); }); const c = map.getCenter(); @@ -221,7 +241,7 @@ function MapPreventHorizontalWrap() { return () => { google.maps.event.removeListener(listener); }; - }, [map]); + }, [map, isMobileDevice]); return null; } @@ -319,8 +339,7 @@ function MapSearchResultPins() { if (!map || isMobileDevice || pinsFocus !== "search" || kind !== "whole" || status !== "done" || !items.length || usePlaceSearchStore.getState().selectedId || fittedItemsRef.current === items) return; fittedItemsRef.current = items; if (items.length === 1) { - panToWithinWorld(map, items[0].location); - map.setZoom(16); + moveMapTo(map, items[0].location, { zoom: 16 }); } else { const bounds = new google.maps.LatLngBounds(); items.forEach((item) => bounds.extend(item.location)); @@ -402,9 +421,10 @@ export default function TripMap({ const isPlanPage = isPlanPath(pathname ?? "") || isMobileMapPathname(pathname ?? ""); const planStopPlaceIds = usePlanItineraryStopNormalizedPlaceIds(isPlanPage); - const { setSelectedPlace } = useSelectedPlace(); + const { selectedPlace, setSelectedPlace } = useSelectedPlace(); const { isMobileDevice } = useMobileView(); const clearPlanFocus = usePlanMapFocusStore((s) => s.clearFocus); + const planRouteMode = useWebPlanRouteMode(); const setMapCamera = useMapCenterStore((s) => s.setMapCamera); const [selectedCategoryId, setSelectedCategoryId] = useState( null, @@ -557,7 +577,7 @@ export default function TripMap({ ); return ( -
+
{!bootstrap.ready ? (
} - {/* PC는 확대·축소(+/−) 바로 위 — 같은 오른쪽 아래 컨트롤 자리에 넣으면 지도가 +/− 위로 쌓아 준다 */} + {/* PC는 확대·축소(+/−) 바로 위 — 같은 오른쪽 아래 컨트롤 자리에 넣으면 지도가 +/− 위로 쌓아 준다. + 오른쪽은 +/−와 같이 지도 끝에서 24px(globals.css `[data-map-controls-inset]`) */} {routeView || isMobileDevice ? null : ( -
{bookmarkToggle}
+
{bookmarkToggle}
)} )}
+ {/* 웹 동선보기는 숨기기만 한다 — 상세 패널 위치를 계속 재서 필터 한 줄 아래(최솟값)에 두고, 풀면 고른 카테고리·필터 그대로 보인다 */} {routeView ? null : (