+ );
+}
diff --git a/src/app/(main)/map/_components/MobileRouteCardCarousel.tsx b/src/app/(main)/map/_components/MobileRouteCardCarousel.tsx
new file mode 100644
index 00000000..f7a1a87d
--- /dev/null
+++ b/src/app/(main)/map/_components/MobileRouteCardCarousel.tsx
@@ -0,0 +1,125 @@
+"use client";
+
+import { useEffect, useRef, type ReactNode } from "react";
+
+/** 손을 뗀 뒤 스냅이 멈출 때까지 기다렸다가 고른 카드를 알린다 — 넘기는 중 지도가 여러 번 움직이지 않게 */
+const SCROLL_SETTLE_MS = 120;
+
+type CarouselCard = { key: string; node: ReactNode };
+
+/**
+ * 카드를 가로로 넘겨 본다 — 가운데 카드가 고른 카드이고 양옆 카드가 조금 보인다.
+ * 카드를 넘기면 `onFocusChange`로 알리고, 바깥(지도 핀·일차 탭·처음 진입·삭제)에서 고른 카드가 바뀌면 그 카드로 옮긴다.
+ * 넘기는 중에는 `onPreviewChange`로 지금 가운데 카드를 바로 알린다 — 일차 탭처럼 가벼운 표시만 먼저 바꾸는 데 쓴다.
+ */
+export function MobileRouteCardCarousel({
+ cards,
+ focusedKey,
+ onFocusChange,
+ onPreviewChange,
+}: Readonly<{
+ cards: readonly CarouselCard[];
+ focusedKey: string | null;
+ onFocusChange: (index: number) => void;
+ /** 넘기는 중 가운데 카드(멈추면 null) */
+ onPreviewChange?: (index: number | null) => void;
+}>) {
+ const containerRef = useRef(null);
+ const settleTimerRef = useRef(null);
+ /** 카드를 넘겨서 고른 카드 — 이 경우엔 다시 스크롤하지 않는다(한 번 쓰고 비운다) */
+ const fromScrollRef = useRef(null);
+ /** 코드로 옮기는 중인 카드 — 도착할 때까지 스크롤로 고르지 않는다. 사용자가 카드 줄을 만지면 버린다 */
+ const scrollTargetRef = useRef(null);
+ const scrolledOnceRef = useRef(false);
+ const previewRef = useRef(null);
+ const setPreview = (index: number | null) => {
+ if (previewRef.current === index) return;
+ previewRef.current = index;
+ onPreviewChange?.(index);
+ };
+
+ const focusedIndex = cards.findIndex((card) => card.key === focusedKey);
+
+ useEffect(() => {
+ const el = containerRef.current;
+ if (!el || focusedIndex < 0) return;
+ // 넘겨서 고른 직후엔 이미 그 자리에 있다. 기록은 비워 두어, 나중에 코드로 같은 카드를 다시 고르거나
+ // 카드가 끼워지고 빠져 자리가 밀려도 그 카드로 옮기게 한다
+ const fromScroll = fromScrollRef.current === focusedKey;
+ fromScrollRef.current = null;
+ if (fromScroll) return;
+ const child = el.children[focusedIndex] as HTMLElement | undefined;
+ if (!child) return;
+ const left = child.offsetLeft - (el.clientWidth - child.clientWidth) / 2;
+ if (Math.abs(el.scrollLeft - left) < 2) return;
+ scrollTargetRef.current = focusedIndex;
+ el.scrollTo({ left, behavior: scrolledOnceRef.current ? "smooth" : "auto" });
+ scrolledOnceRef.current = true;
+ }, [focusedIndex, focusedKey]);
+
+ useEffect(
+ () => () => {
+ if (settleTimerRef.current !== null) window.clearTimeout(settleTimerRef.current);
+ },
+ [],
+ );
+
+ function nearestIndex(el: HTMLDivElement) {
+ const center = el.scrollLeft + el.clientWidth / 2;
+ let best = 0;
+ let bestDistance = Number.POSITIVE_INFINITY;
+ Array.from(el.children).forEach((node, index) => {
+ const child = node as HTMLElement;
+ const distance = Math.abs(child.offsetLeft + child.clientWidth / 2 - center);
+ if (distance < bestDistance) {
+ bestDistance = distance;
+ best = index;
+ }
+ });
+ return best;
+ }
+
+ function handleScroll() {
+ // 코드로 옮기는 중엔 목적지가 이미 골라져 있으니 지나가는 카드를 알리지 않는다
+ const current = containerRef.current;
+ if (current && scrollTargetRef.current === null) setPreview(nearestIndex(current));
+ if (settleTimerRef.current !== null) window.clearTimeout(settleTimerRef.current);
+ settleTimerRef.current = window.setTimeout(() => {
+ settleTimerRef.current = null;
+ const el = containerRef.current;
+ if (!el) return;
+ const index = nearestIndex(el);
+ // 같은 타이머 안이라 아래 onFocusChange와 함께 한 번에 그려진다
+ setPreview(null);
+ // 코드로 옮기는 중이면 이미 고른 카드다 — 도착했을 때만 표시를 지운다(도중에 멈칫한 건 무시)
+ if (scrollTargetRef.current !== null) {
+ if (scrollTargetRef.current === index) scrollTargetRef.current = null;
+ return;
+ }
+ const key = cards[index]?.key;
+ if (key === undefined || key === focusedKey) return;
+ fromScrollRef.current = key;
+ onFocusChange(index);
+ }, SCROLL_SETTLE_MS);
+ }
+
+ return (
+
{
+ scrollTargetRef.current = null;
+ }}
+ // relative: 카드 위치(offsetLeft)를 카드 줄 기준으로 재서 브라우저마다 같은 값이 나오게 한다
+ className="pointer-events-auto relative flex h-full snap-x snap-mandatory gap-2 overflow-x-auto overscroll-x-contain px-[18px] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
+ >
+ {cards.map((card) => (
+ // 카드 폭은 좌우 여백(18px)을 뺀 전체 — 393px 화면에서 Figma처럼 357px, 옆 카드가 10px 보인다
+
+ {card.node}
+
+ ))}
+
+ );
+}
diff --git a/src/app/(main)/map/_components/MobileRoutePlaceCard.tsx b/src/app/(main)/map/_components/MobileRoutePlaceCard.tsx
new file mode 100644
index 00000000..9ea90a49
--- /dev/null
+++ b/src/app/(main)/map/_components/MobileRoutePlaceCard.tsx
@@ -0,0 +1,197 @@
+"use client";
+
+import { useRef, useState, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
+
+import { MobilePlanPlaceCard } from "@/app/(main)/plan/_components/mobile/MobilePlanPlaceCard";
+import { DashedBorder, DashedPlaceholderBox } from "@/components/mobile/DashedPlaceholderBox";
+import type { PlanPlace } from "@/lib/plan/types";
+import { cn } from "@/lib/utils";
+
+/** 넘겨 보는 카드 칸 — 높이는 넘겨 보는 줄이 정한다(접힘: 가장 긴 카드, 펼침: 지도의 70%) */
+const CARD_CLASS = "relative h-full overflow-hidden rounded-md border border-border-subtle bg-fill-subtle";
+/** 펼친 카드를 이만큼 아래로 끌면 접는다 */
+const COLLAPSE_DRAG_PX = 56;
+
+type MobileRoutePlaceCardProps = Readonly<{
+ place: PlanPlace;
+ orderNumber: number;
+ /** 지도 핀·경로선과 같은 일차 색 */
+ badgeColor: string;
+ /** 방금 추가된 카드 — 잠깐 강조한다 */
+ highlighted: boolean;
+ /** `45,000 KRW 외 1건` — 없으면 비용 권한이 있을 때 "비용 추가"를 보인다 */
+ expenseSummary: string | null;
+ /** 비용을 추가할 수 있는지(비용 권한) */
+ canAddExpense: boolean;
+ expanded: boolean;
+ /** 접힌 카드 본문 — 상세를 펼친다 */
+ onExpand: () => void;
+ /** 펼친 카드의 ⌄·아래로 끌기 — 접는다 */
+ onCollapse: () => void;
+ onOpenActions: () => void;
+ onOpenExpenses: () => void;
+ onEditMemo: () => void;
+ onEditTime: () => void;
+ /** 펼쳤을 때 카드 아래에 이어지는 상세 */
+ detail: ReactNode;
+}>;
+
+/**
+ * 경로 보기 장소 카드 — 일정 화면의 장소 카드(`MobilePlanPlaceCard`)를 그대로 쓴다(시간·비용·메모·⋮ 동작 같음).
+ * 본문을 누르면 펼쳐 카드 아래에 상세를 잇고, 손잡이·⌄ 줄만 위에 남긴 채 카드부터 상세까지 함께 스크롤한다.
+ */
+export function MobileRoutePlaceCard({
+ place,
+ orderNumber,
+ badgeColor,
+ highlighted,
+ expenseSummary,
+ canAddExpense,
+ expanded,
+ onExpand,
+ onCollapse,
+ onOpenActions,
+ onOpenExpenses,
+ onEditMemo,
+ onEditTime,
+ detail,
+}: MobileRoutePlaceCardProps) {
+ const card = (
+
+ );
+
+ if (!expanded) return card;
+
+ return (
+
+ {card}
+ {detail}
+
+ );
+}
+
+/**
+ * 펼친 경로 카드의 틀 — 손잡이·⌄ 줄만 위에 남기고 나머지는 함께 스크롤한다.
+ * 손잡이 줄을 누르거나 아래로 끌면 접는다. 장소 카드와 지도에서 고른 후보 카드가 함께 쓴다.
+ */
+export function MobileRouteExpandedShell({
+ onCollapse,
+ tentative = false,
+ children,
+}: Readonly<{
+ onCollapse: () => void;
+ /** 아직 일정에 넣지 않은 후보 — 접힌 후보 카드처럼 점선 테두리 */
+ tentative?: boolean;
+ children: ReactNode;
+}>) {
+ // 가로 넘기기는 브라우저 스크롤에 맡긴다(`touch-action: pan-x`)
+ const dragStartYRef = useRef(null);
+ // 손을 뗄 때는 렌더 전 최신 거리가 필요해 ref에도 둔다(화면 이동은 상태로 그린다)
+ const dragOffsetRef = useRef(0);
+ const [dragOffset, setDragOffset] = useState(0);
+ // 끌다가 놓으면 이어서 click이 오므로, 끈 경우엔 그 click으로 접지 않는다(덜 끌었으면 제자리로)
+ const draggedRef = useRef(false);
+ const resetDrag = () => {
+ dragStartYRef.current = null;
+ dragOffsetRef.current = 0;
+ setDragOffset(0);
+ };
+
+ return (
+
+ );
+}
+
+/** 처음 불러오는 동안 — 카드와 같은 자리를 잡는다 */
+export function MobileRoutePlaceCardSkeleton() {
+ return (
+
+ );
+}
+
+/** Figma ⑤ 장소 없는 일차 — 어느 일차인지 제목을 두고, 일정 화면의 빈 일차처럼 점선 박스로 비어 있음을 보여 준다 */
+export function MobileRouteEmptyDayCard({ dayNumber }: Readonly<{ dayNumber: number }>) {
+ return (
+
+
Day {dayNumber}
+
+
장소가 없어요
+
지도에서 장소를 추가해 보세요.
+
+
+ );
+}
+
+/** 일정이 없거나 불러오지 못했을 때 — 장소카드 자리에 안내를 보여 준다 */
+export function MobileRouteMessageCard({ title, description, tone = "default" }: Readonly<{
+ title: string;
+ description: string;
+ tone?: "default" | "error";
+}>) {
+ return (
+
+
{title}
+
{description}
+
+ );
+}
diff --git a/src/app/(main)/map/_components/MobileRoutePlaceDetail.tsx b/src/app/(main)/map/_components/MobileRoutePlaceDetail.tsx
new file mode 100644
index 00000000..af8e2abc
--- /dev/null
+++ b/src/app/(main)/map/_components/MobileRoutePlaceDetail.tsx
@@ -0,0 +1,81 @@
+"use client";
+
+import { useEffect, useRef } from "react";
+
+import { HeroImage, HeroSkeleton } from "@/components/place/HeroSection";
+import { PlaceDetailTabs } from "@/components/place/PlaceDetailTabs";
+import { usePlaceDetailData } from "@/components/place/usePlaceDetailData";
+import { AnalyticsEvents, trackAnalyticsEvent, type AnalyticsSource } from "@/lib/analytics/track";
+import { normalizeGooglePlaceResourceId } from "@/lib/maps";
+import type { PlanPlace } from "@/lib/plan/types";
+
+/**
+ * 펼친 경로 카드의 상세(Figma ④) — 사진, 홈·리뷰 탭(장소 상세 시트와 같음).
+ * 시간·비용·메모는 카드 윗부분에 그대로 있다. 가운데 카드(`active`)일 때만 상세를 불러온다.
+ * 지도에서 고른 후보 장소도 같은 상세를 쓴다(`analyticsSource="map"`).
+ */
+export function MobileRoutePlaceDetail({
+ place,
+ active,
+ analyticsSource = "plan",
+}: Readonly<{
+ place: Pick;
+ active: boolean;
+ analyticsSource?: AnalyticsSource;
+}>) {
+ const rawId = place.googlePlaceId?.trim() ?? "";
+ const googlePlaceId = rawId ? normalizeGooglePlaceResourceId(rawId) : undefined;
+ const { data: detail, isLoading } = usePlaceDetailData(active ? googlePlaceId : undefined);
+ const category = detail?.primaryTypeDisplayName ?? place.primaryTypeDisplayName ?? "";
+
+ // 일정 상세를 본 것으로 한 번만 기록한다(장소 상세 패널과 같은 이벤트)
+ const trackedPlaceIdRef = useRef(null);
+ useEffect(() => {
+ if (!active || !googlePlaceId || isLoading || trackedPlaceIdRef.current === googlePlaceId) return;
+ trackedPlaceIdRef.current = googlePlaceId;
+ trackAnalyticsEvent(AnalyticsEvents.viewPlace, { place_category: category, interaction_source: analyticsSource });
+ }, [active, analyticsSource, category, googlePlaceId, isLoading]);
+
+ const showSkeleton = !active || (isLoading && !detail);
+
+ return (
+
+
+ {showSkeleton ? (
+
+ ) : (
+
+ )}
+
+
+ {showSkeleton ? (
+
+ ) : (
+ // 장소 상세 시트와 같은 홈·리뷰 탭 — 같은 상세 응답을 쓰므로 추가 호출이 없다
+
+
+
+ )}
+
+
+ );
+}
diff --git a/src/app/(main)/map/_components/useMobileRouteView.test.tsx b/src/app/(main)/map/_components/useMobileRouteView.test.tsx
new file mode 100644
index 00000000..806922ff
--- /dev/null
+++ b/src/app/(main)/map/_components/useMobileRouteView.test.tsx
@@ -0,0 +1,85 @@
+// @vitest-environment jsdom
+import { act, useEffect } from "react";
+import { createRoot } from "react-dom/client";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { expect, it, vi } from "vitest";
+
+const fixtures = vi.hoisted(() => ({
+ schedules: [{ scheduleId: 10, dayNumber: 1, date: "2026-10-10" }],
+ places: [
+ { id: "item-101", itemId: 101, title: "첫 장소", location: { lat: 37.5, lng: 127 } },
+ { id: "item-102", itemId: 102, title: "먼 장소", location: { lat: 37.7, lng: 127.2 } },
+ ],
+ setSelectedPlace: vi.fn(),
+ router: { replace: vi.fn() },
+}));
+
+vi.mock("next/navigation", () => ({ useRouter: () => fixtures.router }));
+vi.mock("@/hooks/use-room-id", () => ({ useCurrentRoomId: () => ({ roomId: "room-1" }) }));
+vi.mock("@/hooks/useRooms", () => ({
+ useRoomSchedules: () => ({ data: fixtures.schedules, isSuccess: true }),
+ useSchedulePlanPlaces: () => ({ data: fixtures.places, isSuccess: true, isFetching: false }),
+}));
+vi.mock("@/hooks/usePrefetchScheduleRoutes", () => ({ usePrefetchScheduleRoutes: () => true }));
+vi.mock("@/components/expenses/ExpenseProvider", () => ({
+ useExpenseContext: () => ({ list: { data: [] }, canManage: true }),
+}));
+vi.mock("@/contexts/SelectedPlaceContext", () => ({
+ useSelectedPlace: () => ({ setSelectedPlace: fixtures.setSelectedPlace }),
+}));
+vi.mock("@/components/place/usePlaceDetailData", () => ({ usePlaceDetailData: () => ({}) }));
+vi.mock("@/lib/plan/scheduleItemPlaces", () => ({ readSchedulePlanPlacesFromCache: () => fixtures.places }));
+vi.mock("@/app/(main)/plan/_components/mobile/useMobileAddPlaceSearch", () => ({
+ useMobileAddPlaceSearch: () => ({ recentlyAddedItemId: null, screen: null }),
+}));
+vi.mock("@/app/(main)/plan/_components/mobile/MobilePlanPlaceSheets", () => ({
+ MobilePlanPlaceSheets: () => null,
+ openPlaceExpenses: vi.fn(),
+}));
+vi.mock("@/components/map/MapCloseButton", () => ({ MapCloseButton: () => null }));
+vi.mock("./MobileRouteCandidateCard", () => ({ MobileRouteCandidateCard: () => null }));
+vi.mock("./MobileRouteCardCarousel", () => ({ MobileRouteCardCarousel: () => null }));
+vi.mock("./MobileRoutePlaceDetail", () => ({ MobileRoutePlaceDetail: () => null }));
+vi.mock("./MobileRoutePlaceCard", () => ({
+ MobileRouteEmptyDayCard: () => null,
+ MobileRouteMessageCard: () => null,
+ MobileRoutePlaceCard: () => null,
+ MobileRoutePlaceCardSkeleton: () => null,
+}));
+
+import { readMapRouteParams } from "@/lib/mobile-view";
+import { scheduleItemsQueryKey } from "@/lib/query-keys";
+import { useMobileRouteView } from "./useMobileRouteView";
+
+it.each([
+ { url: "/map?view=route&day=1", camera: "fit", item: "101" },
+ { url: "/map?view=route&day=1&item=102", camera: "place", item: "102" },
+])("$url 진입 후 URL에 선택 장소가 반영되면 해당 장소를 확대한다", async ({ url, camera, item }) => {
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
+ window.history.replaceState(null, "", url);
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ queryClient.setQueryData(scheduleItemsQueryKey("room-1", 10), fixtures.places);
+ const host = document.createElement("div");
+ document.body.append(host);
+ const root = createRoot(host);
+ let current: ReturnType | undefined;
+ function Harness() {
+ const route = readMapRouteParams(new URLSearchParams(window.location.search));
+ const result = useMobileRouteView({ active: route.active, day: route.day, initialItemId: route.itemId });
+ useEffect(() => { current = result; });
+ return null;
+ }
+ const render = () => root.render();
+ try {
+ await act(async () => render());
+ expect(current?.mapRouteView?.camera.kind).toBe(camera);
+ expect(new URLSearchParams(window.location.search).get("item")).toBe(item);
+ // Next.js reflects native replaceState into useSearchParams; emulate that rerender.
+ await act(async () => render());
+ expect(current?.mapRouteView?.camera.kind).toBe("place");
+ } finally {
+ await act(async () => root.unmount());
+ queryClient.clear();
+ host.remove();
+ }
+});
diff --git a/src/app/(main)/map/_components/useMobileRouteView.tsx b/src/app/(main)/map/_components/useMobileRouteView.tsx
new file mode 100644
index 00000000..1a0b967b
--- /dev/null
+++ b/src/app/(main)/map/_components/useMobileRouteView.tsx
@@ -0,0 +1,557 @@
+"use client";
+
+import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
+import { useRouter } from "next/navigation";
+import { useQueries, useQueryClient } from "@tanstack/react-query";
+
+import { useExpenseContext } from "@/components/expenses/ExpenseProvider";
+import type { MapRouteView } from "@/components/map";
+import { MapCloseButton } from "@/components/map/MapCloseButton";
+import { usePlaceDetailData } from "@/components/place/usePlaceDetailData";
+import { useSelectedPlace } from "@/contexts/SelectedPlaceContext";
+import { useCurrentRoomId } from "@/hooks/use-room-id";
+import { usePrefetchScheduleRoutes } from "@/hooks/usePrefetchScheduleRoutes";
+import { useRoomSchedules, useSchedulePlanPlaces } from "@/hooks/useRooms";
+import { expensesInScope } from "@/lib/expenses/expense-scope";
+import { normalizeGooglePlaceResourceId } from "@/lib/maps";
+import { buildMapRouteHref, MOBILE_MAP_PATH } from "@/lib/mobile-view";
+import { insertAnchorAfterItem } from "@/lib/plan/insertPosition";
+import { formatMonthDayKo, summarizeExpensesForMobile } from "@/lib/plan/mobilePlanFormat";
+import {
+ scheduleIdsToRouteColors,
+ sortedScheduleIdsForRouteColors,
+} from "@/lib/plan/planRouteDayColors";
+import { readSchedulePlanPlacesFromCache } from "@/lib/plan/scheduleItemPlaces";
+import { sortRoomSchedules } from "@/lib/plan/scheduleMerge";
+import type { PlanPlace } from "@/lib/plan/types";
+import { scheduleItemsQueryKey } from "@/lib/query-keys";
+import { cn } from "@/lib/utils";
+
+import {
+ MobilePlanPlaceSheets,
+ openPlaceExpenses,
+ type MobilePlanPlaceSheet,
+} from "@/app/(main)/plan/_components/mobile/MobilePlanPlaceSheets";
+import { useMobileAddPlaceSearch } from "@/app/(main)/plan/_components/mobile/useMobileAddPlaceSearch";
+
+import { MobileRouteCandidateCard } from "./MobileRouteCandidateCard";
+import { MobileRouteCardCarousel } from "./MobileRouteCardCarousel";
+import {
+ MobileRouteEmptyDayCard,
+ MobileRouteMessageCard,
+ MobileRoutePlaceCard,
+ MobileRoutePlaceCardSkeleton,
+} from "./MobileRoutePlaceCard";
+import { MobileRoutePlaceDetail } from "./MobileRoutePlaceDetail";
+
+const EMPTY_PLACES: PlanPlace[] = [];
+/** 일정 화면의 일차 색과 같은 기본값 */
+const FALLBACK_DAY_COLOR = "#f12d33";
+
+type PlaceEntry = {
+ kind: "place";
+ key: string;
+ scheduleId: number;
+ dayNumber: number;
+ orderNumber: number;
+ itemId: number;
+ place: PlanPlace;
+};
+/**
+ * 지도에서 고른 후보 — 넣을 자리(고른 카드 바로 뒤)에 끼운다. 장소 없는 일차면 빈 카드 자리를 대신한다.
+ * `prev`: 후보 바로 앞 장소(장소 없는 일차면 null) — 이 장소 뒤에 넣는다.
+ */
+type CandidateEntry = {
+ kind: "candidate";
+ key: string;
+ scheduleId: number;
+ dayNumber: number;
+ googlePlaceId: string;
+ prev: PlaceEntry | null;
+};
+/** 모든 일차를 이어 붙인 카드 한 칸 — 장소가 없는 일차는 안내 카드 한 칸을 차지한다 */
+type StripEntry = PlaceEntry | { kind: "empty"; key: string; scheduleId: number; dayNumber: number } | CandidateEntry;
+
+/** 지도에서 누른 Google 장소 — `anchorKey` 카드 바로 뒤에 넣을 후보. `id`는 후보마다 새 카드 키를 만든다 */
+type RouteCandidate = {
+ id: number;
+ googlePlaceId: string;
+ location: google.maps.LatLngLiteral | null;
+ anchorKey: string;
+};
+
+/**
+ * 고른 카드 — 키로 찾고, 그 카드가 사라지면(삭제 등) 같은 자리(index)의 카드로 이어 간다.
+ * `key`가 null이면 아직 고르지 않은 것으로 주소(`day`·`item`)를 따른다.
+ */
+type Focus = { key: string | null; index: number; camera: MapRouteView["camera"] };
+
+/**
+ * 모바일 지도의 경로 보기(`/map?view=route&day=N&item=ID`, Figma 5826:890).
+ * 모든 일차의 장소 카드를 한 줄로 이어 넘겨 보고(일차가 끝나면 다음 일차로), 고른 카드의 일차 경로만 지도에 그린다.
+ * 카드를 누르면 지도의 70%까지 펼쳐 상세를 보여 준다. 고른 카드는 주소에도 남겨 새로고침해도 이어 본다.
+ * 지도는 기본 지도와 같은 것을 쓰므로 지도에 넘길 `mapRouteView`와 지도 위에 얹을 `overlay`를 함께 돌려준다.
+ */
+export function useMobileRouteView({
+ active,
+ day,
+ initialItemId,
+}: {
+ active: boolean;
+ /** 주소의 일차(1부터) — 일차 수보다 크면 마지막 일차 */
+ day: number;
+ /** 주소의 장소 — 일정 카드에서 들어올 때 처음 고를 장소 */
+ initialItemId: number | null;
+}): {
+ mapRouteView: MapRouteView | undefined;
+ overlay: ReactNode;
+ /** 기본 지도의 경로 보기 버튼 — 1일차로 연다 */
+ open: () => void;
+} {
+ const router = useRouter();
+ const queryClient = useQueryClient();
+ const { roomId } = useCurrentRoomId();
+ const rid = typeof roomId === "string" ? roomId.trim() : "";
+ const { setSelectedPlace } = useSelectedPlace();
+ const expenses = useExpenseContext();
+
+ const schedulesQuery = useRoomSchedules(rid || null);
+ const schedules = useMemo(() => sortRoomSchedules(schedulesQuery.data ?? []), [schedulesQuery.data]);
+
+ // 일정 목록을 불러올 때 모든 일차의 장소(미리보기 포함)가 캐시에 채워지므로 캐시만 읽는다
+ const dayPlacesQueries = useQueries({
+ queries: schedules.map((s) => ({
+ queryKey: scheduleItemsQueryKey(rid || null, s.scheduleId),
+ queryFn: () => readSchedulePlanPlacesFromCache(queryClient, rid, s.scheduleId),
+ enabled: active && rid.length > 0 && schedulesQuery.isSuccess,
+ staleTime: Infinity,
+ refetchOnMount: false,
+ refetchOnWindowFocus: false,
+ })),
+ });
+
+ const [candidate, setCandidate] = useState(null);
+ const candidateSeqRef = useRef(0);
+ // 후보 카드가 불러오는 상세와 같은 조회 — 지도 핀은 누른 자리보다 정확한 장소 위치에 둔다
+ const { data: candidateDetail } = usePlaceDetailData(candidate?.googlePlaceId);
+
+ /** 후보를 끼우기 전 카드들 — 넣을 자리(`anchorKey`)는 여기서 찾는다 */
+ const baseEntries: StripEntry[] = [];
+ const placesBySchedule = new Map();
+ schedules.forEach((s, dayIndex) => {
+ const dayPlaces = dayPlacesQueries[dayIndex]?.data ?? EMPTY_PLACES;
+ placesBySchedule.set(s.scheduleId, dayPlaces);
+ const places = dayPlaces.filter(
+ (p): p is PlanPlace & { itemId: number } => typeof p.itemId === "number",
+ );
+ if (places.length === 0) {
+ baseEntries.push({ kind: "empty", key: `empty-${s.scheduleId}`, scheduleId: s.scheduleId, dayNumber: dayIndex + 1 });
+ return;
+ }
+ places.forEach((place, index) => {
+ baseEntries.push({
+ kind: "place",
+ key: `item-${place.itemId}`,
+ scheduleId: s.scheduleId,
+ dayNumber: dayIndex + 1,
+ orderNumber: index + 1,
+ itemId: place.itemId,
+ place,
+ });
+ });
+ });
+
+ const { entries, anchorIndex, ghost } = insertCandidate(baseEntries, candidate);
+
+ const [focus, setFocus] = useState({ key: null, index: 0, camera: { kind: "fit", seq: 0 } });
+ const [expanded, setExpanded] = useState(false);
+ /** 카드의 ⋮·시간·메모에서 연 시트 — 일정 목록과 같은 시트를 쓴다 */
+ const [routeSheet, setRouteSheet] = useState<{ scheduleId: number; sheet: MobilePlanPlaceSheet } | null>(null);
+
+ const focusedIndex = resolveFocusedIndex(entries, focus, initialItemId, day, schedules.length);
+ const focused = focusedIndex >= 0 ? entries[focusedIndex] : undefined;
+ const activeScheduleId = active ? (focused?.scheduleId ?? null) : null;
+ const activeDayNumber = focused?.dayNumber ?? 0;
+ // 카드를 넘기는 중 가운데 카드 — 일차 탭은 이걸로 먼저 바꾸고, 지도·URL은 스냅이 멈춘 뒤 바꾼다
+ const [previewIndex, setPreviewIndex] = useState(null);
+ const tabDayNumber = (previewIndex !== null ? entries[previewIndex]?.dayNumber : undefined) ?? activeDayNumber;
+ const isExpanded = active && expanded;
+
+ // ⋮ "일정 추가"는 일정 화면과 같은 전체 화면 검색 — 추가한 장소를 바로 고른다(목록에 생기기 전엔 같은 자리를 유지)
+ const addPlace = useMobileAddPlaceSearch({
+ roomId: rid,
+ backLabel: "경로로 돌아가기",
+ onAdded: (itemId) => focusKey(`item-${itemId}`, focusedIndex + 1),
+ });
+
+ // 고른 일차는 미리보기가 빠진 장소를 다시 채우고, 일정 화면처럼 구간 경로를 한 번에 받아 둔다
+ const activePlacesQuery = useSchedulePlanPlaces(rid || null, activeScheduleId);
+ const activePlaces = activePlacesQuery.data ?? EMPTY_PLACES;
+ const routesReady = usePrefetchScheduleRoutes(
+ rid,
+ activeScheduleId ?? -1,
+ activePlaces,
+ activeScheduleId !== null &&
+ activePlacesQuery.isSuccess &&
+ !activePlacesQuery.isFetching &&
+ activePlaces.length >= 2,
+ );
+ const locations = useMemo(
+ () =>
+ activePlaces.flatMap((p) =>
+ p.location && typeof p.itemId === "number"
+ ? [{ itemId: p.itemId, lat: p.location.lat, lng: p.location.lng }]
+ : [],
+ ),
+ [activePlaces],
+ );
+
+ const colorBySchedule = useMemo(
+ () =>
+ scheduleIdsToRouteColors(
+ sortedScheduleIdsForRouteColors(Object.fromEntries(schedules.map((s) => [s.scheduleId, true]))),
+ rid || undefined,
+ ),
+ [rid, schedules],
+ );
+ const dayColor = (scheduleId: number) => colorBySchedule.get(scheduleId) ?? FALLBACK_DAY_COLOR;
+
+ // 고른 카드를 주소에 남긴다 — 이동 기록은 쌓지 않는다(Next가 `useSearchParams`와 맞춰 준다)
+ const focusedItemId = focused?.kind === "place" ? focused.itemId : null;
+ useEffect(() => {
+ if (!active || activeDayNumber === 0) return;
+ window.history.replaceState(null, "", buildMapRouteHref(activeDayNumber, focusedItemId));
+ }, [active, activeDayNumber, focusedItemId]);
+
+ // 경로 보기에서는 장소 상세 시트를 쓰지 않는다 — 일반 지도에서 고른 장소가 남아 있으면 시트가 카드를 덮으므로
+ // 경로 보기가 켜질 때(버튼·일정 카드·주소 어느 쪽으로 들어와도) 지운다
+ useEffect(() => {
+ if (active) setSelectedPlace(null);
+ }, [active, setSelectedPlace]);
+
+ function focusKey(key: string, index: number) {
+ setFocus((prev) => ({ key, index, camera: { kind: "pan", seq: prev.camera.seq + 1 } }));
+ }
+
+ /** 일차가 바뀌면 그 일차 전체가 보이게, 같은 일차면 고른 장소로 지도를 옮긴다 */
+ const focusEntry = (index: number, cameraKind?: "fit" | "pan") => {
+ const entry = entries[index];
+ if (!entry) return;
+ const kind = cameraKind ?? (entry.scheduleId === focused?.scheduleId ? "pan" : "fit");
+ setFocus((prev) => ({ key: entry.key, index, camera: { kind, seq: prev.camera.seq + 1 } }));
+ };
+ const setExpandedAndRecenter = (next: boolean) => {
+ setExpanded(next);
+ // 펼치면 지도가 위쪽만 보이므로 고른 장소를 다시 그 가운데로 옮긴다
+ setFocus((prev) => ({
+ key: prev.key ?? focused?.key ?? null,
+ index: focusedIndex,
+ camera: { kind: "pan", seq: prev.camera.seq + 1 },
+ }));
+ };
+
+ const closeCandidate = () => {
+ if (!candidate) return;
+ if (focused?.kind === "candidate") {
+ // 보고 있던 후보를 닫으면 넣으려던 자리(앞 카드·빈 일차)로 돌아간다
+ focusKey(candidate.anchorKey, Math.max(anchorIndex, 0));
+ setExpanded(false);
+ }
+ setCandidate(null);
+ };
+
+ async function addCandidate(): Promise {
+ if (!candidate || !ghost) return false;
+ const anchor = ghost.prev
+ ? insertAnchorAfterItem(placesBySchedule.get(ghost.scheduleId) ?? EMPTY_PLACES, ghost.prev.itemId)
+ : null;
+ const itemId = await addPlace.addAt({ scheduleId: ghost.scheduleId, anchor }, candidate.googlePlaceId, "map");
+ if (itemId === null) return false;
+ // 새 카드는 `addPlace`의 onAdded가 골라 강조한다 — 그사이 다른 장소를 눌렀으면 그 후보는 남긴다
+ const addedId = candidate.id;
+ setCandidate((current) => (current?.id === addedId ? null : current));
+ return true;
+ }
+
+ // 후보 핀은 그 일차를 볼 때만 그린다
+ const ghostOnMap = ghost?.scheduleId === activeScheduleId ? ghost : undefined;
+ const candidateLocation = candidateDetail?.location ?? candidate?.location ?? null;
+
+ function buildRouteView(): MapRouteView | undefined {
+ if (!active) return undefined;
+ return {
+ scheduleId: activeScheduleId,
+ color: activeScheduleId === null ? FALLBACK_DAY_COLOR : dayColor(activeScheduleId),
+ focusedItemId,
+ routesReady,
+ onStopClick: (itemId) => {
+ const index = entries.findIndex((e) => e.kind === "place" && e.itemId === itemId);
+ if (index >= 0) focusEntry(index, "pan");
+ },
+ onCandidateClick: () => {
+ const index = entries.findIndex((e) => e.kind === "candidate");
+ if (index >= 0) focusEntry(index, "pan");
+ },
+ // 처음 들어올 때 — 일정 카드로 들어왔으면 그 장소를 가까이, 경로 보기 버튼이면 일차 전체를 보여 준다
+ camera: focus.key === null ? { kind: initialItemId !== null ? "place" : "fit", seq: 0 } : focus.camera,
+ locations,
+ expanded: isExpanded,
+ candidate:
+ ghostOnMap && candidate
+ ? {
+ googlePlaceId: candidate.googlePlaceId,
+ location: candidateLocation,
+ focused: focused?.kind === "candidate",
+ }
+ : null,
+ onPlaceClick: (googlePlaceId, location) => {
+ // 이미 일정에 있는 장소면 후보 대신 그 카드를 고른다
+ const placeId = normalizeGooglePlaceResourceId(googlePlaceId);
+ const index = entries.findIndex(
+ (e) => e.kind === "place" && normalizeGooglePlaceResourceId(e.place.googlePlaceId?.trim() ?? "") === placeId,
+ );
+ if (index >= 0) {
+ setCandidate(null);
+ focusEntry(index);
+ return;
+ }
+ // 넣을 자리 — 지금 고른 카드 바로 뒤. 후보를 보고 있었으면 같은 자리에서 장소만 바꾼다
+ if (!focused) return;
+ const anchorKey = focused.kind === "candidate" && candidate ? candidate.anchorKey : focused.key;
+ const nextAnchorIndex = baseEntries.findIndex((e) => e.key === anchorKey);
+ const nextAnchor = baseEntries[nextAnchorIndex];
+ if (!nextAnchor) return;
+ candidateSeqRef.current += 1;
+ const id = candidateSeqRef.current;
+ setExpanded(false);
+ setCandidate({ id, googlePlaceId: placeId, location, anchorKey });
+ // 끼운 후보 카드로 넘긴다 — 빈 일차면 빈 카드 자리, 아니면 앞 카드 바로 뒤
+ focusKey(`candidate-${id}`, nextAnchor.kind === "empty" ? nextAnchorIndex : nextAnchorIndex + 1);
+ },
+ onBackgroundClick: () => {
+ if (candidate) closeCandidate();
+ },
+ };
+ }
+
+ const goToDay = (dayNumber: number) => {
+ const index = entries.findIndex((e) => e.dayNumber === dayNumber);
+ if (index >= 0) focusEntry(index, "fit");
+ };
+ const close = () => {
+ setExpanded(false);
+ setCandidate(null);
+ setPreviewIndex(null);
+ setRouteSheet(null);
+ router.replace(MOBILE_MAP_PATH);
+ };
+ const open = () => {
+ setExpanded(false);
+ setCandidate(null);
+ setPreviewIndex(null);
+ setFocus((prev) => ({ key: null, index: 0, camera: { kind: "fit", seq: prev.camera.seq + 1 } }));
+ router.replace(buildMapRouteHref(1));
+ };
+
+ const tabsRef = useRef(null);
+ const selectedTabRef = useRef(null);
+ useEffect(() => {
+ const nav = tabsRef.current;
+ const tab = selectedTabRef.current;
+ if (!active || !nav || !tab) return;
+ // 고른 탭이 탭 줄 밖이면 탭 줄만 가로로 옮긴다 — scrollIntoView는 바깥 영역까지 스크롤해 화면이 밀릴 수 있다
+ const padding = 15;
+ let left: number | null = null;
+ if (tab.offsetLeft - padding < nav.scrollLeft) left = tab.offsetLeft - padding;
+ else if (tab.offsetLeft + tab.offsetWidth + padding > nav.scrollLeft + nav.clientWidth) {
+ left = tab.offsetLeft + tab.offsetWidth + padding - nav.clientWidth;
+ }
+ if (left !== null) nav.scrollTo({ left, behavior: "smooth" });
+ }, [active, tabDayNumber]);
+
+ const itemExpenses = (scheduleId: number, itemId: number) =>
+ expensesInScope(expenses.list.data ?? [], { scheduleId, scheduleItemId: itemId, label: "" });
+
+ function renderEntry(entry: StripEntry, index: number): ReactNode {
+ if (entry.kind === "empty") return (
+
+
+ );
+ if (entry.kind === "candidate") return (
+ {
+ if (index !== focusedIndex) focusEntry(index);
+ setExpandedAndRecenter(true);
+ }}
+ onCollapse={() => setExpandedAndRecenter(false)}
+ detailActive={index === focusedIndex}
+ />
+
+ );
+ return (
+ {
+ if (index !== focusedIndex) focusEntry(index);
+ setExpandedAndRecenter(true);
+ }}
+ onCollapse={() => setExpandedAndRecenter(false)}
+ onOpenActions={() => setRouteSheet({ scheduleId: entry.scheduleId, sheet: { kind: "actions", place: entry.place } })}
+ onOpenExpenses={() => openPlaceExpenses(expenses, entry.scheduleId, entry.place)}
+ onEditMemo={() => setRouteSheet({ scheduleId: entry.scheduleId, sheet: { kind: "memo", place: entry.place } })}
+ onEditTime={() => setRouteSheet({ scheduleId: entry.scheduleId, sheet: { kind: "time", place: entry.place } })}
+ detail={isExpanded ? : null}
+ />
+
+ );
+ }
+
+ // 경로 보기가 아닐 때(일반 지도)는 카드를 만들지 않는다 — 장소마다 비용 요약까지 계산하므로
+ function renderCards(): ReactNode {
+ if (!active) {
+ return null;
+ } else if (schedulesQuery.isLoading || (schedules.length > 0 && entries.length === 0)) {
+ return ;
+ } else if (schedulesQuery.isError) {
+ return (
+
+
+
+ );
+ } else if (entries.length === 0) {
+ return (
+
+
+
+ );
+ } else {
+ return (
+ focusEntry(index)}
+ onPreviewChange={setPreviewIndex}
+ cards={entries.map((entry, index) => ({
+ key: entry.key,
+ node: renderEntry(entry, index),
+ }))}
+ />
+ );
+ }
+
+ }
+
+ const sheetSchedule = routeSheet ? schedules.find((s) => s.scheduleId === routeSheet.scheduleId) : undefined;
+ const overlay = active ? (
+ <>
+
+ {/* 펼치면 X·일차 탭은 사라지고 카드가 그 자리까지 커진다 */}
+
+
+
+
+
+
+ {/* 접힘: 가장 긴 카드에 맞춰 모든 카드가 같은 높이 / 펼침: 지도의 70% */}
+