diff --git a/src/app/(main)/map/_components/useMobileRouteView.test.tsx b/src/app/(main)/map/_components/useMobileRouteView.test.tsx index 806922ff..996ec086 100644 --- a/src/app/(main)/map/_components/useMobileRouteView.test.tsx +++ b/src/app/(main)/map/_components/useMobileRouteView.test.tsx @@ -52,9 +52,11 @@ 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 }) => { + // 경로 보기 버튼 — 그 일차 첫 장소를 바로 확대한다(일차 전체를 먼저 맞췄다가 확대하지 않는다) + { url: "/map?view=route&day=1", item: "101" }, + // 일정 카드 — 누른 장소를 확대한다 + { url: "/map?view=route&day=1&item=102", item: "102" }, +])("$url 진입 시 처음부터 고른 장소를 확대하고, URL에 선택 장소가 반영돼도 그대로다", async ({ url, item }) => { Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); window.history.replaceState(null, "", url); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); @@ -72,7 +74,7 @@ it.each([ const render = () => root.render(); try { await act(async () => render()); - expect(current?.mapRouteView?.camera.kind).toBe(camera); + expect(current?.mapRouteView?.camera.kind).toBe("place"); expect(new URLSearchParams(window.location.search).get("item")).toBe(item); // Next.js reflects native replaceState into useSearchParams; emulate that rerender. await act(async () => render()); diff --git a/src/app/(main)/map/_components/useMobileRouteView.tsx b/src/app/(main)/map/_components/useMobileRouteView.tsx index 1a0b967b..38d44d87 100644 --- a/src/app/(main)/map/_components/useMobileRouteView.tsx +++ b/src/app/(main)/map/_components/useMobileRouteView.tsx @@ -293,7 +293,9 @@ export function useMobileRouteView({ if (index >= 0) focusEntry(index, "pan"); }, // 처음 들어올 때 — 일정 카드로 들어왔으면 그 장소를 가까이, 경로 보기 버튼이면 일차 전체를 보여 준다 - camera: focus.key === null ? { kind: initialItemId !== null ? "place" : "fit", seq: 0 } : focus.camera, + // 처음 들어올 때는 일정 카드로 왔든 경로 보기 버튼으로 왔든 고른 장소(버튼은 그 일차 첫 장소)를 바로 확대한다 — + // 일차 전체를 먼저 맞췄다가 확대하면 지도가 두 번 움직인다. 일차 전체(fit)는 일차 탭·다른 일차로 넘길 때만 쓴다 + camera: focus.key === null ? { kind: "place", seq: 0 } : focus.camera, locations, expanded: isExpanded, candidate: @@ -349,7 +351,7 @@ export function useMobileRouteView({ setExpanded(false); setCandidate(null); setPreviewIndex(null); - setFocus((prev) => ({ key: null, index: 0, camera: { kind: "fit", seq: prev.camera.seq + 1 } })); + setFocus((prev) => ({ key: null, index: 0, camera: { kind: "place", seq: prev.camera.seq + 1 } })); router.replace(buildMapRouteHref(1)); }; diff --git a/src/app/(main)/plan/_components/itinerary/PlanPageView.tsx b/src/app/(main)/plan/_components/itinerary/PlanPageView.tsx index d145cac1..02433d9e 100644 --- a/src/app/(main)/plan/_components/itinerary/PlanPageView.tsx +++ b/src/app/(main)/plan/_components/itinerary/PlanPageView.tsx @@ -9,6 +9,7 @@ import { useDeleteRoomSchedule, useRoomSchedules, } from "@/hooks/useRooms"; +import { usePlanMapFocusStore } from "@/stores/plan-map-focus-store"; import { useSessionStore } from "@/stores/session-store"; import { usePlanItineraryExpandedStore } from "@/stores/plan-itinerary-expanded-store"; import { usePlanScheduleRouteVisibilityStore } from "@/stores/plan-schedule-route-visibility-store"; @@ -39,6 +40,9 @@ import { PlanScheduleDayBlock } from "./PlanScheduleDayBlock"; export function PlanPageView() { const planContainerRef = useRef(null); const { isMobileDevice } = useMobileView(); + // 일정 화면을 떠나면 고른 장소(카드 강조·지도의 "이 일차만 보기")를 푼다 — 검색·북마크 탭에서도 지도는 그대로라서 + const clearPlanFocus = usePlanMapFocusStore((s) => s.clearFocus); + useEffect(() => () => clearPlanFocus(), [clearPlanFocus]); //room id 가져와서 room detail, schedules 가져오기 const storedId = useSessionStore((s) => s.currentRoomId); diff --git a/src/app/(main)/plan/_components/itinerary/PlanPlaceCard.tsx b/src/app/(main)/plan/_components/itinerary/PlanPlaceCard.tsx index a0dd0d98..8938dd26 100644 --- a/src/app/(main)/plan/_components/itinerary/PlanPlaceCard.tsx +++ b/src/app/(main)/plan/_components/itinerary/PlanPlaceCard.tsx @@ -4,7 +4,7 @@ import { ExpenseEntryButton } from "@/components/expenses/ExpenseProvider"; import { ExpenseIcon } from "@/components/icons/ExpenseIcon"; import type { DragEvent } from "react"; -import { useCallback, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import Image from "next/image"; import { Clock, Loader2, MapPin } from "lucide-react"; @@ -13,7 +13,6 @@ import { ConfirmDialog } from "@/components/settings/ConfirmDialog"; import { toast } from "sonner"; import { useInViewport } from "@/hooks/useInViewport"; -import { useOpenPlaceOnMap } from "@/hooks/useOpenPlaceOnMap"; import { useDeleteScheduleItem, useUpdateScheduleItem, @@ -21,7 +20,6 @@ import { import { usePlanPlaceCardPhoto } from "@/hooks/usePlanPlaceCardPhoto"; import { bucketItemCount } from "@/lib/analytics/context"; import { AnalyticsEvents, trackAnalyticsEvent } from "@/lib/analytics/track"; -import { normalizeGooglePlaceResourceId } from "@/lib/maps"; import { PLAN_PLACE_CARD_TW } from "@/lib/layout-tokens"; import { handlePlacePhotoImageError } from "@/lib/places/place-photo-refresh"; import { @@ -29,6 +27,8 @@ import { } from "@/lib/plan/scheduleTime"; 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 { PlanItemMemoEditor, PlanItemMemoReadOnly } from "./PlanItemMemoForm"; import { PlanItemTimeEditor } from "./PlanItemTimeForm"; @@ -68,7 +68,34 @@ export function PlanPlaceCard({ onDragStart, onDragEnd, }: PlanPlaceCardProps) { - const openPlaceOnMap = useOpenPlaceOnMap(); + const { setSelectedPlace } = useSelectedPlace(); + const focusPlace = usePlanMapFocusStore((s) => s.focusPlace); + const clearFocus = usePlanMapFocusStore((s) => s.clearFocus); + const focusRoomId = scheduleTimeEdit?.roomId.trim() ?? ""; + const focusScheduleId = scheduleTimeEdit?.scheduleId ?? null; + /** 고른 장소 — 다른 카드를 고르거나, 같은 카드를 다시 누르거나, 지도 빈 곳을 누를 때까지 강조해 둔다 */ + const focused = usePlanMapFocusStore( + (s) => + s.focus !== null && + s.focus.roomId === focusRoomId && + s.focus.scheduleId === focusScheduleId && + s.focus.itemId === place.itemId, + ); + /** 지도 핀으로 고른 경우에만 값이 있다 — 그때 일정 목록을 이 카드로 스크롤한다 */ + const focusSeqFromMap = usePlanMapFocusStore((s) => + s.focus?.source === "map" && + s.focus.roomId === focusRoomId && + s.focus.scheduleId === focusScheduleId && + s.focus.itemId === place.itemId + ? s.focus.seq + : null, + ); + const articleRef = useRef(null); + useEffect(() => { + if (focusSeqFromMap === null) return; + const reduceMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches; + articleRef.current?.scrollIntoView({ behavior: reduceMotion ? "auto" : "smooth", block: "center" }); + }, [focusSeqFromMap]); const pointerDownRef = useRef<{ x: number; y: number } | null>(null); const blockCardDragRef = useRef(false); const [thumbnailRef, thumbnailInViewport] = useInViewport(); @@ -169,42 +196,42 @@ export function PlanPlaceCard({ Math.abs(e.clientX - start.x) + Math.abs(e.clientY - start.y); if (moved > 12) return; } - const loc = place.location; - if ( - !loc || - typeof loc.lat !== "number" || - typeof loc.lng !== "number" || - !Number.isFinite(loc.lat) || - !Number.isFinite(loc.lng) - ) { - toast.info("지도에 표시할 위치 정보가 없어요."); + if (!focusRoomId || focusScheduleId === null || typeof place.itemId !== "number") return; + // 고른 카드를 다시 누르면 강조와 "이 일차 경로만 보기"를 푼다 + if (focused) { + clearFocus(); + setSelectedPlace(null); return; } - const rawId = - typeof place.googlePlaceId === "string" - ? place.googlePlaceId.trim() - : ""; - const gid = - rawId.length > 0 ? normalizeGooglePlaceResourceId(rawId) : undefined; - - openPlaceOnMap( - { - name: place.title, - category: "", - rating: null, - ...(gid ? { googlePlaceId: gid } : {}), - location: { lat: loc.lat, lng: loc.lng }, - address: place.subtitle, - }, - { analyticsSource: "plan" }, - ); + const loc = place.location; + const hasLocation = + loc != null && + typeof loc.lat === "number" && + typeof loc.lng === "number" && + Number.isFinite(loc.lat) && + Number.isFinite(loc.lng); + // 일정에 넣은 장소는 상세를 보려는 게 아니라 위치를 보려는 것 — 상세(조회 포함) 대신 카드·지도 핀만 강조한다 + 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("지도에 표시할 위치 정보가 없어요."); }, [ - openPlaceOnMap, + clearFocus, + focusPlace, + focusRoomId, + focusScheduleId, + focused, place.googlePlaceId, + place.itemId, place.location, - place.subtitle, - place.title, + setSelectedPlace, ], ); @@ -283,14 +310,19 @@ export function PlanPlaceCard({ return (
0 && - Number.isFinite(scheduleId) && - Number.isFinite(segmentSourceItemId); - const menuDeferredModes = useMemo( () => SCHEDULE_TRAVEL_MODES.map((m) => m.value).filter( @@ -144,6 +137,8 @@ export function PlanTravelTime({ [selectedMode], ); + // 고르지 않은 수단은 조회하지 않는다(경로 조회 비용) — 예전에 고른 적이 있어 캐시에 남은 값만 보여 주고, + // 나머지는 그 수단을 골라야 계산한다 const deferredRouteQueries = useQueries({ queries: menuDeferredModes.map((mode) => persistedScheduleItemRouteQueryOptions( @@ -154,7 +149,7 @@ export function PlanTravelTime({ fpTrim, { segmentReady: routeQueryEnabled, - networkEnabled: deferredLazyEnabled, + networkEnabled: false, }, ), ), @@ -222,27 +217,18 @@ export function PlanTravelTime({ s.distanceMeters >= 0 ); }; + // 고르지 않은 수단은 조회하지 않으니 고른 수단만 불러오는 중을 표시한다 if (routeQueryEnabled) { out[selectedMode] = !hasSummary(selectedMode) && (selectedPending || selectedFetching); - menuDeferredModes.forEach((mode, i) => { - const q = deferredRouteQueries[i]; - out[mode] = - deferredLazyEnabled && - !hasSummary(mode) && - Boolean(q?.isPending || q?.isFetching); - }); } return out; }, [ modeRouteSummaries, routeQueryEnabled, selectedMode, - deferredLazyEnabled, selectedPending, selectedFetching, - menuDeferredModes, - deferredRouteQueries, ]); const primarySettled = !selectedPending && !selectedFetching; diff --git a/src/app/(main)/plan/_components/travel-time/TravelDirectionsCard.tsx b/src/app/(main)/plan/_components/travel-time/TravelDirectionsCard.tsx index 471fcd8b..a1cba967 100644 --- a/src/app/(main)/plan/_components/travel-time/TravelDirectionsCard.tsx +++ b/src/app/(main)/plan/_components/travel-time/TravelDirectionsCard.tsx @@ -132,12 +132,14 @@ export function TravelDirectionsCard({

이동 수단

+ {/* 아이콘 | 수단 | 시간·거리 세 칸을 모든 줄이 함께 써서(subgrid), 시간·거리가 가장 긴 수단 이름 뒤 오른쪽 끝에 맞춰진다 */}
    {SCHEDULE_TRAVEL_MODES.map(({ value }) => { const rowSummary = modeRouteSummaries[value]; + const selected = value === effectiveMode; const row = rowSummary && rowSummary.durationSeconds >= 0 && @@ -151,12 +153,9 @@ export function TravelDirectionsCard({ - ) : ( - — - ); - const selected = value === effectiveMode; + ) : null; // 고르지 않은 수단은 골라야 계산한다(미리 조회하지 않음) const rowClass = cn( - "flex w-full items-center gap-2 rounded-md px-1.5 py-1.5 text-left text-body-s-regular mobile:text-body-xs-regular", + "col-span-3 grid grid-cols-subgrid items-center rounded-md px-1.5 py-1.5 text-left text-body-s-regular mobile:text-body-xs-regular", !readOnly && "hover:bg-gray-border/30", selected && (readOnly ? @@ -164,26 +163,25 @@ export function TravelDirectionsCard({ : "bg-primary/10 ring-1 ring-primary/25"), ); + // 고른 수단도 다른 줄처럼 시간·거리를 보이고, 배경 강조로만 구분한다 + const rowValue = {row}; + if (readOnly) { return ( -
  • +
  • {scheduleTravelModeLabel(value)} - {selected ? - - 적용 중 - - : {row}} + {rowValue}
  • ); } return ( -
  • +
  • ); })} {showUnknownOption && !readOnly ? ( -
  • +