1- import { useState , useRef } from "react" ;
1+ import { useState , useRef , useEffect } from "react" ;
22import api from "../api/api" ;
33
44// 점 → 선분(segment) 최단거리 (미터). 좌표계: equirectangular 근사
@@ -35,38 +35,112 @@ function sampleRouteCoords(coords, maxN) {
3535}
3636
3737export const ROUTE_MODES = [
38- { id : "right " , label : "바른 길" , endpoint : "/route/right" } ,
39- { id : "slow" , label : "여유로운 길" , endpoint : "/route/slow" } ,
40- { id : "findOut" , label : "발견하는 길" , endpoint : "/route/findOut" } ,
38+ { id : "findOut " , label : "바른 길" , endpoint : "/route/right" } ,
39+ { id : "slow" , label : "여유로운 길" , endpoint : "/route/slow" } ,
40+ { id : "right" , label : "발견하는 길" , endpoint : "/route/findOut" } ,
4141] ;
4242
43- export function useRouteSearch ( { userCoords, onDestinationSelect, onDrawRoute, onRouteRecs, onRecsHide, onRecsShow, onDestinationClear } ) {
44- const [ destText , setDestText ] = useState ( "" ) ;
45- const [ destFocused , setDestFocused ] = useState ( false ) ;
46- const [ deptText , setDeptText ] = useState ( "" ) ;
47- const [ deptFocused , setDeptFocused ] = useState ( false ) ;
48- const [ searchResults , setSearchResults ] = useState ( [ ] ) ;
49- const [ selectedResult , setSelectedResult ] = useState ( null ) ;
50- const [ isSearching , setIsSearching ] = useState ( false ) ;
51- const [ selectedMode , setSelectedMode ] = useState ( null ) ;
52- const [ routeInfo , setRouteInfo ] = useState ( { } ) ;
53-
54- const destInputRef = useRef ( null ) ;
55- const deptInputRef = useRef ( null ) ;
56-
57- const isSearchMode = destFocused || deptFocused ;
58- const showResults = searchResults . length > 0 ;
43+ export function useRouteSearch ( { userCoords, onDestinationSelect, onDrawRoute, onRecsHide, onRecsShow, onDestinationClear, onRouteLoadingChange } ) {
44+ const [ destText , setDestText ] = useState ( "" ) ;
45+ const [ destFocused , setDestFocused ] = useState ( false ) ;
46+ const [ deptText , setDeptText ] = useState ( "" ) ;
47+ const [ deptFocused , setDeptFocused ] = useState ( false ) ;
48+ const [ searchResults , setSearchResults ] = useState ( [ ] ) ;
49+ const [ deptSearchResults , setDeptSearchResults ] = useState ( [ ] ) ;
50+ const [ selectedResult , setSelectedResult ] = useState ( null ) ;
51+ const [ customDeptCoords , setCustomDeptCoords ] = useState ( null ) ; // null = userCoords 사용
52+ const [ isSearching , setIsSearching ] = useState ( false ) ;
53+ const [ selectedMode , setSelectedMode ] = useState ( "slow" ) ;
54+ const [ exploredMode , setExploredMode ] = useState ( null ) ;
55+ const [ routeStats , setRouteStats ] = useState ( { } ) ;
56+ const [ routeFeatures , setRouteFeatures ] = useState ( { } ) ;
57+
58+ const destInputRef = useRef ( null ) ;
59+ const deptInputRef = useRef ( null ) ;
60+ const debounceRef = useRef ( null ) ;
61+ const deptDebounceRef = useRef ( null ) ;
62+
63+ const isSearchMode = destFocused || deptFocused ;
64+ const showResults = searchResults . length > 0 ;
65+ const showDeptResults = deptSearchResults . length > 0 ;
66+
67+ // 목적지 debounce 자동완성 (350ms)
68+ useEffect ( ( ) => {
69+ if ( ! destText . trim ( ) || ! destFocused ) {
70+ setSearchResults ( [ ] ) ;
71+ return ;
72+ }
73+ clearTimeout ( debounceRef . current ) ;
74+ debounceRef . current = setTimeout ( ( ) => {
75+ if ( ! window . kakao ?. maps ?. services ) return ;
76+ setIsSearching ( true ) ;
77+ const ps = new window . kakao . maps . services . Places ( ) ;
78+ ps . keywordSearch ( destText , ( results , status ) => {
79+ setIsSearching ( false ) ;
80+ if ( status === window . kakao . maps . services . Status . OK ) setSearchResults ( results . slice ( 0 , 5 ) ) ;
81+ else setSearchResults ( [ ] ) ;
82+ } ) ;
83+ } , 350 ) ;
84+ return ( ) => clearTimeout ( debounceRef . current ) ;
85+ } , [ destText , destFocused ] ) ;
86+
87+ // 출발지 debounce 자동완성 (350ms)
88+ useEffect ( ( ) => {
89+ if ( ! deptText . trim ( ) || ! deptFocused ) {
90+ setDeptSearchResults ( [ ] ) ;
91+ return ;
92+ }
93+ clearTimeout ( deptDebounceRef . current ) ;
94+ deptDebounceRef . current = setTimeout ( ( ) => {
95+ if ( ! window . kakao ?. maps ?. services ) return ;
96+ const ps = new window . kakao . maps . services . Places ( ) ;
97+ ps . keywordSearch ( deptText , ( results , status ) => {
98+ if ( status === window . kakao . maps . services . Status . OK ) setDeptSearchResults ( results . slice ( 0 , 5 ) ) ;
99+ else setDeptSearchResults ( [ ] ) ;
100+ } ) ;
101+ } , 350 ) ;
102+ return ( ) => clearTimeout ( deptDebounceRef . current ) ;
103+ } , [ deptText , deptFocused ] ) ;
59104
60105 const handleDestFocus = ( ) => { setDestFocused ( true ) ; onRecsHide ?. ( ) ; } ;
61106 const handleDeptFocus = ( ) => { setDeptFocused ( true ) ; onRecsHide ?. ( ) ; } ;
62107
63108 const handleCancel = ( ) => {
64109 setDestText ( "" ) ; setDeptText ( "" ) ;
65110 setDestFocused ( false ) ; setDeptFocused ( false ) ;
66- setSearchResults ( [ ] ) ; setSelectedResult ( null ) ;
111+ setSearchResults ( [ ] ) ; setDeptSearchResults ( [ ] ) ;
112+ setSelectedResult ( null ) ; setCustomDeptCoords ( null ) ;
113+ setRouteStats ( { } ) ; setRouteFeatures ( { } ) ; setExploredMode ( null ) ;
67114 onRecsShow ?. ( ) ; onDestinationClear ?. ( ) ;
68115 } ;
69116
117+ // 출발지 결과 선택
118+ const handleDeptResultClick = ( result ) => {
119+ const newCoords = { lat : parseFloat ( result . y ) , lng : parseFloat ( result . x ) } ;
120+ setDeptText ( result . place_name ) ;
121+ setCustomDeptCoords ( newCoords ) ;
122+ setDeptSearchResults ( [ ] ) ;
123+ setDeptFocused ( false ) ;
124+ setRouteStats ( { } ) ; setRouteFeatures ( { } ) ; setExploredMode ( null ) ;
125+ // 목적지가 이미 선택된 상태면 새 출발지로 경로 자동 재탐색
126+ if ( selectedResult ) {
127+ Promise . all ( ROUTE_MODES . map ( m => fetchOne ( m . id , selectedResult , newCoords ) ) ) ;
128+ }
129+ } ;
130+
131+ // 출발지 초기화 (현재위치 / 부산대정문으로 복귀)
132+ const handleDeptClear = ( ) => {
133+ setDeptText ( "" ) ;
134+ setCustomDeptCoords ( null ) ;
135+ setDeptSearchResults ( [ ] ) ;
136+ setDeptFocused ( false ) ;
137+ setRouteStats ( { } ) ; setRouteFeatures ( { } ) ; setExploredMode ( null ) ;
138+ // 목적지가 이미 선택된 상태면 기본 출발지로 경로 자동 재탐색
139+ if ( selectedResult && userCoords ) {
140+ Promise . all ( ROUTE_MODES . map ( m => fetchOne ( m . id , selectedResult , userCoords ) ) ) ;
141+ }
142+ } ;
143+
70144 const handleDestSubmit = ( e ) => {
71145 e ?. preventDefault ( ) ;
72146 if ( ! destText . trim ( ) || ! window . kakao ?. maps ?. services ) return ;
@@ -81,113 +155,88 @@ export function useRouteSearch({ userCoords, onDestinationSelect, onDrawRoute, o
81155
82156 const handleDeptSubmit = ( e ) => { e ?. preventDefault ( ) ; } ;
83157
84- const fetchRouteData = async ( modeId , result , { draw = true } = { } ) => {
85- if ( ! userCoords || ! result ) {
86- console . warn ( "[경로] 중단 - userCoords:" , userCoords , "result:" , result ) ;
87- return ;
88- }
158+ // 단일 모드 fetch (startCoordsOverride: 출발지 변경 직후 state 반영 전 명시적으로 전달)
159+ const fetchOne = async ( modeId , result , startCoordsOverride ) => {
160+ const startCoords = startCoordsOverride ?? customDeptCoords ?? userCoords ;
161+ if ( ! startCoords || ! result ) return null ;
89162 const mode = ROUTE_MODES . find ( m => m . id === modeId ) ;
90- console . log ( `[경로] ${ mode . label } 요청` , { from : userCoords , to : { y : result . y , x : result . x } } ) ;
91163 try {
92164 const response = await api . post ( mode . endpoint , [
93- { lat : userCoords . lat , lon : userCoords . lng } ,
165+ { lat : startCoords . lat , lon : startCoords . lng } ,
94166 { lat : parseFloat ( result . y ) , lon : parseFloat ( result . x ) } ,
95167 ] ) ;
96- console . log ( "[경로] 응답:" , response . data ) ;
97168 const features = response . data . route ?. features ;
98- console . log ( "[경로] features:" , features ?. length , "개" ) ;
99- if ( features && Array . isArray ( features ) ) {
100- if ( draw ) onDrawRoute ?. ( features ) ;
101- const summary = features [ 0 ] ?. properties ;
102- if ( summary ?. totalTime != null ) {
103- setRouteInfo ( prev => ( {
104- ...prev ,
105- [ modeId ] : {
106- time : Math . round ( summary . totalTime / 60 ) ,
107- distance : ( summary . totalDistance / 1000 ) . toFixed ( 1 ) ,
108- } ,
109- } ) ) ;
110- }
111- const cats = response . data . recommendations ?. categories ?? [ ] ;
112- if ( modeId === "findOut" ) {
113- const routeCoords = features
114- . filter ( f => f . geometry . type === "LineString" )
115- . flatMap ( f => f . geometry . coordinates ) ; // [lng, lat]
116-
117- // 경로를 따라 최대 6개 지점 샘플 → 각 지점마다 /places/recommend 호출
118- const samples = sampleRouteCoords ( routeCoords , 6 ) ;
119- const results = await Promise . all (
120- samples . map ( ( [ lng , lat ] ) =>
121- api . get ( "/places/recommend" , { params : { lat, lng } } )
122- . then ( r => r . data . categories ?? [ ] )
123- . catch ( ( ) => [ ] )
124- )
125- ) ;
126-
127- // 전체 장소 합치기 + lat/lng 기준 중복 제거
128- const seen = new Set ( ) ;
129- const allPlaces = results
130- . flat ( )
131- . flatMap ( cat => cat . places ?? [ ] )
132- . filter ( p => {
133- if ( ! p . lat || ! p . lng ) return false ;
134- const key = `${ p . lat } ,${ p . lng } ` ;
135- if ( seen . has ( key ) ) return false ;
136- seen . add ( key ) ;
137- return true ;
138- } ) ;
139-
140- const nearby = filterPlacesNearRoute ( allPlaces , routeCoords , 50 ) ;
141-
142- // 카테고리별 1개씩만
143- const seenCat = new Set ( ) ;
144- const onePerCategory = nearby . filter ( p => {
145- if ( seenCat . has ( p . category ) ) return false ;
146- seenCat . add ( p . category ) ;
147- return true ;
148- } ) ;
149-
150- console . log ( "[발견하는 길] 수집 장소:" , allPlaces . length , "→ 50m 이내:" , nearby . length , "→ 카테고리별 1개:" , onePerCategory . length ) ;
151- if ( draw ) onRouteRecs ?. ( onePerCategory ) ;
152- } else {
153- if ( draw ) onRouteRecs ?. ( [ ] ) ;
154- }
155- } else {
156- console . warn ( "[경로] features 없음. 응답 구조:" , JSON . stringify ( response . data ) . slice ( 0 , 200 ) ) ;
169+ if ( ! features || ! Array . isArray ( features ) ) return null ;
170+
171+ const props = features [ 0 ] ?. properties ;
172+ if ( props ?. totalDistance != null && props ?. totalTime != null ) {
173+ setRouteStats ( prev => ( {
174+ ...prev ,
175+ [ modeId ] : { distance : props . totalDistance , time : props . totalTime } ,
176+ } ) ) ;
157177 }
178+ setRouteFeatures ( prev => ( { ...prev , [ modeId ] : features } ) ) ;
179+ return features ;
158180 } catch ( error ) {
159- console . error ( "[경로] API 에러:" , error . response ?. status , error . response ?. data ?? error . message ) ;
181+ console . error ( `[경로] ${ modeId } 에러:` , error . response ?. status , error . response ?. data ?? error . message ) ;
182+ return null ;
160183 }
161184 } ;
162185
163- const handleResultClick = ( result ) => {
186+ // 목적지 선택 시 3개 모드 동시 fetch (지도에는 그리지 않음)
187+ const handleResultClick = async ( result ) => {
164188 setSelectedResult ( result ) ;
165189 setSearchResults ( [ ] ) ;
166190 setDestText ( result . place_name ) ;
191+ setExploredMode ( null ) ;
167192 onDestinationSelect ?. ( result ) ;
193+ if ( ! customDeptCoords && ! userCoords ) { alert ( "현재 위치를 먼저 잡아주세요." ) ; return ; }
194+
195+ await Promise . all ( ROUTE_MODES . map ( m => fetchOne ( m . id , result ) ) ) ;
168196 } ;
169197
198+ // 모드 카드 클릭: 선택만 변경
170199 const handleModeChange = ( modeId ) => {
171200 setSelectedMode ( modeId ) ;
172201 } ;
173202
174- const handleSearch = ( ) => {
175- if ( ! userCoords ) { alert ( "현재 위치를 먼저 잡아주세요." ) ; return ; }
203+ // 탐색하기:
204+ // - 처음 or 다른 모드 → 캐시된 경로 즉시 사용
205+ // - 같은 모드로 재클릭 → 새로 fetch (랜덤 재탐색)
206+ const handleExplore = ( ) => {
176207 if ( ! selectedResult ) return ;
177- const activeMode = selectedMode ?? "slow" ;
178- ROUTE_MODES . forEach ( mode => {
179- fetchRouteData ( mode . id , selectedResult , { draw : mode . id === activeMode } ) ;
180- } ) ;
208+ if ( exploredMode === selectedMode ) {
209+ onRouteLoadingChange ?. ( true ) ;
210+ fetchOne ( selectedMode , selectedResult ) . then ( f => {
211+ if ( f ) onDrawRoute ?. ( f , selectedMode ) ;
212+ onRouteLoadingChange ?. ( false ) ;
213+ } ) ;
214+ } else {
215+ const cached = routeFeatures [ selectedMode ] ;
216+ if ( cached ) {
217+ onDrawRoute ?. ( cached , selectedMode ) ;
218+ setExploredMode ( selectedMode ) ;
219+ } else {
220+ onRouteLoadingChange ?. ( true ) ;
221+ fetchOne ( selectedMode , selectedResult ) . then ( f => {
222+ if ( f ) { onDrawRoute ?. ( f , selectedMode ) ; setExploredMode ( selectedMode ) ; }
223+ onRouteLoadingChange ?. ( false ) ;
224+ } ) ;
225+ }
226+ }
181227 } ;
182228
183229 return {
184230 destText, setDestText, destFocused,
185231 deptText, setDeptText, deptFocused,
186- searchResults, selectedResult, isSearching, selectedMode, routeInfo, handleSearch,
232+ searchResults, deptSearchResults,
233+ selectedResult, customDeptCoords,
234+ isSearching, selectedMode,
187235 destInputRef, deptInputRef,
188- isSearchMode, showResults,
236+ isSearchMode, showResults, showDeptResults , routeStats ,
189237 handleDestFocus, handleDeptFocus, handleCancel,
190238 handleDestSubmit, handleDeptSubmit,
191- handleResultClick, handleModeChange,
239+ handleDeptResultClick, handleDeptClear,
240+ handleResultClick, handleModeChange, handleExplore,
192241 } ;
193242}
0 commit comments