diff --git a/src/app/(main)/plan/_components/itinerary/PlanPlaceCard.tsx b/src/app/(main)/plan/_components/itinerary/PlanPlaceCard.tsx index 842af45..2e2d497 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, ], ); diff --git a/src/app/globals.css b/src/app/globals.css index a676dc5..f933c0a 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/map/Map.tsx b/src/components/map/Map.tsx index 0192a6e..8639122 100644 --- a/src/components/map/Map.tsx +++ b/src/components/map/Map.tsx @@ -35,6 +35,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 +63,7 @@ import { MapDiscoverToolbar } from "./MapDiscoverToolbar"; import { RouteViewCameraController } from "./RouteViewCameraController"; import { MapDiscoverPlaces } from "./MapDiscoverPlaces"; import { PlanItineraryMapRoutes, type PlanItineraryRouteDay } from "./PlanItineraryMapRoutes"; +import { PlanRouteStepper } from "./PlanRouteStepper"; import type { OpenValue, RatingValue } from "./map-filters"; /** 모바일 경로 보기 — 고른 일차만 그리고, 카드 선택에 맞춰 지도를 옮긴다 */ @@ -132,6 +134,9 @@ function SelectedPlaceController() { const VIEWPORT_PERSIST_DEBOUNCE_MS = 400; +/** 오른쪽 아래 확대·축소(+/−) 자리 — 지도 끝 24 + 버튼 40 + 간격 12. 장소 이동 스테퍼가 이보다 오른쪽으로 가지 않는다 */ +const ZOOM_CONTROL_CLEARANCE_PX = 24 + 40 + 12; + /** bootstrap 복원 직후 Zustand `mapCenter`·줌을 검색/일정 UI와 맞춤 */ function MapBootstrapSync({ ready, @@ -402,7 +407,7 @@ 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 setMapCamera = useMapCenterStore((s) => s.setMapCamera); @@ -557,7 +562,7 @@ export default function TripMap({ ); return ( -
+
{!bootstrap.ready ? (
} - {/* PC는 확대·축소(+/−) 바로 위 — 같은 오른쪽 아래 컨트롤 자리에 넣으면 지도가 +/− 위로 쌓아 준다 */} + {/* PC는 확대·축소(+/−) 바로 위 — 같은 오른쪽 아래 컨트롤 자리에 넣으면 지도가 +/− 위로 쌓아 준다. + 오른쪽은 +/−와 같이 지도 끝에서 24px(globals.css `[data-map-controls-inset]`) */} {routeView || isMobileDevice ? null : ( -
{bookmarkToggle}
+
{bookmarkToggle}
)} @@ -633,6 +639,24 @@ export default function TripMap({ /> )} + {/* 웹 동선보기 장소 이동 — 지도 아래 가운데, 다른 지도 버튼처럼 아래 24px. + 상세 패널이 열리면 패널 오른쪽 끝~지도 오른쪽 끝의 가운데로 옮긴다. + 오른쪽 칸은 +/− 자리만큼 줄어들지 않아, 지도가 좁아지면 +/−에 밀려 왼쪽으로 넘치고(justify-end) + z-[16]이 상세 패널(`MapWithDetailPanel` z-20)보다 낮아 패널 뒤로 가려진다 */} + {bootstrap.ready && !routeView && !isMobileDevice && isPlanPage ? ( +
+
+ +
+
+ ) : null} + {!bootstrap.ready || routeView ? null : ( <> diff --git a/src/components/map/PlanRouteStepper.test.tsx b/src/components/map/PlanRouteStepper.test.tsx new file mode 100644 index 0000000..5a5730c --- /dev/null +++ b/src/components/map/PlanRouteStepper.test.tsx @@ -0,0 +1,87 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; + +import type { PlanPlace } from "@/lib/plan/types"; +import { scheduleItemsQueryKey } from "@/lib/query-keys"; +import { PLAN_MAP_FOCUS_NO_LOCATION_MESSAGE, usePlanMapFocusStore } from "@/stores/plan-map-focus-store"; + +import { PlanRouteStepper } from "./PlanRouteStepper"; + +const state = vi.hoisted(() => ({ select: vi.fn(), toast: vi.fn() })); +vi.mock("@/hooks/useRooms", () => ({ useRoomSchedules: () => ({ isSuccess: true }) })); +vi.mock("@/contexts/SelectedPlaceContext", () => ({ useSelectedPlace: () => ({ setSelectedPlace: state.select }) })); +vi.mock("sonner", () => ({ toast: { info: state.toast } })); + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); +const places: PlanPlace[] = [ + { id: "a", title: "A", itemId: 1, googlePlaceId: "ga", location: { lat: 1, lng: 1 } }, + { id: "b", title: "B", itemId: 2, googlePlaceId: "gb", location: { lat: 2, lng: 2 } }, + { id: "c", title: "C", itemId: 3, googlePlaceId: "gc" }, +]; +let host: HTMLDivElement; +let root: Root; +const group = () => host.querySelector('[role="group"][aria-label="장소 이동"]'); +const button = (label: string) => group()!.querySelector(`button[aria-label="${label}"]`)!; +const position = () => group()!.querySelector("p > span[aria-hidden]")!.textContent; +function focusItem(itemId: number) { + usePlanMapFocusStore.setState({ + focus: { roomId: "room", scheduleId: 10, itemId, googlePlaceId: null, location: null, source: "card", seq: 1 }, + }); +} + +beforeEach(async () => { + state.select.mockReset(); + state.toast.mockReset(); + const queryClient = new QueryClient(); + queryClient.setQueryData(scheduleItemsQueryKey("room", 10), places); + host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); + usePlanMapFocusStore.setState({ focus: null }); + await act(async () => + root.render( + + + , + ), + ); +}); +afterEach(async () => { + await act(async () => root.unmount()); + host.remove(); +}); + +it("is hidden until a place of the itinerary is focused", () => { + expect(group()).toBeNull(); +}); + +it("shows the order within the day and disables the edge buttons", async () => { + await act(async () => focusItem(1)); + expect(position()).toBe("1/3"); + expect(button("이전 장소").disabled).toBe(true); + expect(button("다음 장소").disabled).toBe(false); + await act(async () => focusItem(3)); + expect(position()).toBe("3/3"); + expect(button("다음 장소").disabled).toBe(true); +}); + +it("moves the focus like a card click without opening the detail and asks the list to reveal it", async () => { + await act(async () => focusItem(1)); + await act(async () => button("다음 장소").click()); + expect(usePlanMapFocusStore.getState().focus).toMatchObject({ + roomId: "room", scheduleId: 10, itemId: 2, googlePlaceId: "gb", location: { lat: 2, lng: 2 }, source: "stepper", + }); + expect(state.select).toHaveBeenCalledWith(null); + expect(position()).toBe("2/3"); + expect(state.toast).not.toHaveBeenCalled(); +}); + +it("keeps places without a location in the order and tells the user the map cannot move", async () => { + await act(async () => focusItem(2)); + await act(async () => button("다음 장소").click()); + expect(usePlanMapFocusStore.getState().focus).toMatchObject({ itemId: 3, location: null, source: "stepper" }); + expect(state.toast).toHaveBeenCalledWith(PLAN_MAP_FOCUS_NO_LOCATION_MESSAGE); +}); diff --git a/src/components/map/PlanRouteStepper.tsx b/src/components/map/PlanRouteStepper.tsx new file mode 100644 index 0000000..c1d9b41 --- /dev/null +++ b/src/components/map/PlanRouteStepper.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; + +import { ChevronLeftIcon } from "@/assets/icons"; +import { useSelectedPlace } from "@/contexts/SelectedPlaceContext"; +import { useRoomSchedules } from "@/hooks/useRooms"; +import { readSchedulePlanPlacesFromCache } from "@/lib/plan/scheduleItemPlaces"; +import { scheduleItemsQueryKey } from "@/lib/query-keys"; +import { + PLAN_MAP_FOCUS_NO_LOCATION_MESSAGE, + planPlaceFocusTarget, + usePlanMapFocusStore, +} from "@/stores/plan-map-focus-store"; + +/** 좌우 버튼 — 누를 수 있는 영역이 숫자와 구분되도록 올리거나 누르면 둥근 배경을 깐다. 높이는 테두리 안쪽을 채운다 */ +const STEP_BUTTON_CLASS = + "flex h-full w-12 items-center justify-center rounded-full text-primary transition-colors enabled:cursor-pointer enabled:hover:bg-fill enabled:active:bg-fill-strong disabled:text-icon-disabled focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-primary"; + +/** + * 웹 동선보기 장소 이동 — 일정에서 고른 장소의 일차 안에서 이전·다음 장소로 넘긴다(Figma 6035:865). + * 넘기면 카드를 누른 것처럼 카드·번호 핀 강조와 지도 중심을 바꾸고 상세는 열지 않는다. 일정 목록은 그 카드로 스크롤한다. + * 순서는 카드·번호 핀과 같은 일차 장소 순서다. + */ +export function PlanRouteStepper({ roomId }: Readonly<{ roomId: string | null }>) { + const rid = typeof roomId === "string" ? roomId.trim() : ""; + const queryClient = useQueryClient(); + const { isSuccess: schedulesHydrated } = useRoomSchedules(rid || null); + const { setSelectedPlace } = useSelectedPlace(); + const focus = usePlanMapFocusStore((s) => (s.focus && s.focus.roomId === rid ? s.focus : null)); + const focusPlace = usePlanMapFocusStore((s) => s.focusPlace); + const scheduleId = focus?.scheduleId ?? null; + + // 지도 경로(`PlanItineraryMapRoutes`)와 같은 일정 항목 캐시를 읽는다 — 따로 조회하지 않는다 + const { data: places = [] } = useQuery({ + queryKey: scheduleItemsQueryKey(rid || null, scheduleId), + queryFn: () => (scheduleId === null ? [] : readSchedulePlanPlacesFromCache(queryClient, rid, scheduleId)), + enabled: rid.length > 0 && scheduleId !== null && schedulesHydrated, + staleTime: Infinity, + refetchOnMount: false, + refetchOnWindowFocus: false, + }); + + const index = focus ? places.findIndex((place) => place.itemId === focus.itemId) : -1; + if (scheduleId === null || index < 0) return null; + + const current = index + 1; + const total = places.length; + const moveTo = (nextIndex: number) => { + const place = places[nextIndex]; + const target = place ? planPlaceFocusTarget(place, rid, scheduleId, "stepper") : null; + if (!target) return; + setSelectedPlace(null); + focusPlace(target); + if (!target.location) toast.info(PLAN_MAP_FOCUS_NO_LOCATION_MESSAGE); + }; + + return ( +
+ +

+ + {current} + / + {total} + + + 전체 {total}곳 중 {current}번째 장소 + +

+ +
+ ); +} diff --git a/src/lib/layout-tokens.ts b/src/lib/layout-tokens.ts index d2cc50c..2c2eaef 100644 --- a/src/lib/layout-tokens.ts +++ b/src/lib/layout-tokens.ts @@ -15,6 +15,9 @@ export const CHAT_PANEL_DOCKED_WIDTH = width.s1; */ export const MAIN_MAP_MIN_WIDTH_PX = 380 as const; +/** 데스크톱 장소 상세 패널이 가리는 지도 왼쪽 폭 — `MapWithDetailPanel`의 `left-2.5`(10) + `w-[360px]`와 같게 유지 */ +export const MAP_DETAIL_PANEL_COVER_WIDTH_PX = 370 as const; + /** 데스크톱 준비물 상세 패널 폭 — `PackingPage.module.css`의 `.detail`과 같게 유지 */ export const PACKING_DETAIL_WIDTH_PX = 440 as const; diff --git a/src/stores/plan-map-focus-store.ts b/src/stores/plan-map-focus-store.ts index dfcbcf5..ece6e15 100644 --- a/src/stores/plan-map-focus-store.ts +++ b/src/stores/plan-map-focus-store.ts @@ -1,5 +1,7 @@ import { create } from "zustand"; +import type { PlanPlace } from "@/lib/plan/types"; + /** * 웹 일정에서 고른 장소 — 카드 강조, 지도 이동, 번호 핀 키우기, 그 일차 경로만 보기에 쓴다. * 장소 상세(선택 장소)와는 따로다: 일정 카드로 고르면 상세를 열지 않고(조회도 없음), 지도 핀으로 고르면 상세와 함께 고른다. @@ -11,8 +13,8 @@ export type PlanMapFocus = { /** 지도에서 같은 장소의 일반 선택 핀 대신 번호 핀을 보이려고 쓴다 */ googlePlaceId: string | null; location: { lat: number; lng: number } | null; - /** 어디서 골랐는지 — 지도 핀으로 고르면 일정 목록을 그 카드로 스크롤한다 */ - source: "card" | "map"; + /** 어디서 골랐는지 — 지도 핀·장소 이동 스테퍼로 고르면 일정 목록을 그 카드로 스크롤한다 */ + source: "card" | "map" | "stepper"; /** 같은 장소를 다시 골라도 지도를 다시 옮기도록 고를 때마다 바뀐다 */ seq: number; }; @@ -28,3 +30,25 @@ export const usePlanMapFocusStore = create((set, get) => ({ focusPlace: (target) => set({ focus: { ...target, seq: (get().focus?.seq ?? 0) + 1 } }), clearFocus: () => set({ focus: null }), })); + +export const PLAN_MAP_FOCUS_NO_LOCATION_MESSAGE = "지도에 표시할 위치 정보가 없어요."; + +/** 일정 장소를 고를 대상으로 바꾼다 — 좌표가 없으면 `location`이 null이라 지도는 그대로 두고 카드만 강조한다 */ +export function planPlaceFocusTarget( + place: PlanPlace, + roomId: string, + scheduleId: number, + source: PlanMapFocus["source"], +): Omit | null { + if (typeof place.itemId !== "number") return null; + const loc = place.location; + const hasLocation = loc != null && Number.isFinite(loc.lat) && Number.isFinite(loc.lng); + return { + roomId, + scheduleId, + itemId: place.itemId, + googlePlaceId: place.googlePlaceId?.trim() || null, + location: hasLocation ? { lat: loc.lat, lng: loc.lng } : null, + source, + }; +}