diff --git a/README.md b/README.md index f2aba2c..84b74e7 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,8 @@ - AI API/DATA (ETRI) 사용 - pcm 형식의 비압축파일 (wav) 파일 지원 - AI가 인식한 발음과 내 대본을 비교 (1 ~ 5점) +- Kakao Map Api 연동 + - 사용자 주변의 스피치 학원 탐색 (5km 이내) --- ## 추가 해야 할 기능 diff --git a/src/main/java/com/speech/up/auth/config/WebSecurityConfig.java b/src/main/java/com/speech/up/auth/config/WebSecurityConfig.java index 2c45d3e..a84a292 100644 --- a/src/main/java/com/speech/up/auth/config/WebSecurityConfig.java +++ b/src/main/java/com/speech/up/auth/config/WebSecurityConfig.java @@ -50,8 +50,9 @@ protected SecurityFilterChain configure(HttpSecurity httpSecurity) throws Except .sessionManagement( sessionManagement -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(request -> request.requestMatchers( - "/", "api/v1/auth/**", "/oauth2/**", "/sign-up", "/css/**", "/js/**","/**","/logout", - "/static/images/**","/boards/**", "/api/upload", "/report", "/speech-record", "reports/**").permitAll() + "/", "api/v1/auth/**", "/oauth2/**", "/sign-up", "/js/**","/**","/logout", + "/static/images/**","/boards/**", "/api/upload", "/report", "/speech-record", "reports/**", + "/map").permitAll() .requestMatchers("/script-list").hasRole("GENERAL_USER") .requestMatchers("/script-write").hasRole("GENERAL_USER") .requestMatchers("/boards-write").hasRole("GENERAL_USER") diff --git a/src/main/java/com/speech/up/demo/HomePageController.java b/src/main/java/com/speech/up/demo/HomePageController.java index 002fbb0..448b5d4 100644 --- a/src/main/java/com/speech/up/demo/HomePageController.java +++ b/src/main/java/com/speech/up/demo/HomePageController.java @@ -27,4 +27,9 @@ public String login() { public String myPage(){ return "myPage"; } + + @GetMapping("/map") + public String mapPage() { + return "map"; + } } diff --git a/src/main/java/com/speech/up/report/repository/ReportRepository.java b/src/main/java/com/speech/up/report/repository/ReportRepository.java index 9232ae4..0e0b7d6 100644 --- a/src/main/java/com/speech/up/report/repository/ReportRepository.java +++ b/src/main/java/com/speech/up/report/repository/ReportRepository.java @@ -1,14 +1,10 @@ package com.speech.up.report.repository; -import java.util.Optional; - import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; -import com.speech.up.record.entity.RecordEntity; import com.speech.up.report.entity.ReportEntity; public interface ReportRepository extends JpaRepository { - @Query(value = "SELECT * FROM report WHERE `record_id` = :recordId", nativeQuery = true) - ReportEntity findByRecordId(Long recordId); + ReportEntity findReportEntityByReportId(Long recordId); } diff --git a/src/main/java/com/speech/up/report/service/ReportService.java b/src/main/java/com/speech/up/report/service/ReportService.java index 454b9ea..d94ad2c 100644 --- a/src/main/java/com/speech/up/report/service/ReportService.java +++ b/src/main/java/com/speech/up/report/service/ReportService.java @@ -25,7 +25,7 @@ public void saveReport(RecordEntity recordEntity, String recognized, double scor } public ReportEntity getReportFromRecordId(Long recordId) { - return reportRepository.findByRecordId(recordId); + return reportRepository.findReportEntityByReportId(recordId); } public String getScriptFromRecordId (Long recordId) { diff --git a/src/main/resources/static/css/kakaomap-style.css b/src/main/resources/static/css/kakaomap-style.css new file mode 100644 index 0000000..6b30bf1 --- /dev/null +++ b/src/main/resources/static/css/kakaomap-style.css @@ -0,0 +1,186 @@ +.map_wrap, .map_wrap * { + margin:0; + padding:0; + font-family:'Malgun Gothic',dotum,'돋움',sans-serif; + font-size:12px; +} + +.map_wrap a, .map_wrap a:hover, .map_wrap a:active{ + color:#000; + text-decoration: none; +} + +#map { + width: 95%; + height: 80%; + position: relative; + box-shadow: 14px 14px 10px 0 rgba(0, 0, 0, 0.1) +} + +.map_wrap { + flex: 2; /* 지도 섹션이 더 넓게 차지하게 설정 */ + position: relative; +} + +.container { + display: flex; + height: 100vh; /* 전체 화면을 차지하도록 설정 */ +} + +#menu_wrap { + flex: 1; /* 리스트 섹션의 크기 */ + padding: 10px; + overflow-y: auto; /* 스크롤 가능하게 설정 */ + height: 80%; + box-shadow: 14px 14px 10px 0 rgba(0, 0, 0, 0.1) /* 약간의 그림자 효과 */ +} + +.bg_white { + background:#fff; +} + +/* 옵션 폼 스타일 */ +.option { + margin-bottom: 10px; +} + +#menu_wrap hr { + display: block; height: 1px;border: 0; border-top: 2px solid #5F5F5F;margin:3px 0; +} + +#menu_wrap .option{ + text-align: center; +} + +#menu_wrap .option p { + margin:10px 0; +} + +#menu_wrap .option button { + margin-left:5px; +} + +#placesList li { + list-style: none; +} + +#placesList .item { + position:relative; + border-bottom:1px solid #888; + overflow: hidden; + cursor: pointer; + min-height: 65px; +} + +#placesList .item span { + display: block; + margin-top:4px; +} + +#placesList .item h5, #placesList .item .info { + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; +} + +#placesList .item .info{ + padding:10px 0 10px 55px; +} + +#placesList .info .gray { + color:#8a8a8a; +} + +#placesList .info .jibun { + padding-left:26px; + background:url(https://t1.daumcdn.net/localimg/localimages/07/mapapidoc/places_jibun.png) no-repeat; +} + +#placesList .info .tel { + color:#009900; +} + +#placesList .item .markerbg { + float:left; + position:absolute; + width:36px; + height:37px; + margin:10px 0 0 10px; + background:url(https://t1.daumcdn.net/localimg/localimages/07/mapapidoc/marker_number_blue.png) no-repeat; +} + +#placesList .item .marker_1 { + background-position: 0 -10px; +} + +#placesList .item .marker_2 { + background-position: 0 -56px; +} + +#placesList .item .marker_3 { + background-position: 0 -102px +} + +#placesList .item .marker_4 { + background-position: 0 -148px; +} + +#placesList .item .marker_5 { + background-position: 0 -194px; +} + +#placesList .item .marker_6 { + background-position: 0 -240px; +} + +#placesList .item .marker_7 { + background-position: 0 -286px; +} + +#placesList .item .marker_8 { + background-position: 0 -332px; +} + +#placesList .item .marker_9 { + background-position: 0 -378px; +} + +#placesList .item .marker_10 { + background-position: 0 -423px; +} + +#placesList .item .marker_11 { + background-position: 0 -470px; +} + +#placesList .item .marker_12 { + background-position: 0 -516px; +} + +#placesList .item .marker_13 { + background-position: 0 -562px; +} + +#placesList .item .marker_14 { + background-position: 0 -608px; +} + +#placesList .item .marker_15 { + background-position: 0 -654px; +} + +#pagination { + margin:10px auto; + text-align: center; +} + +#pagination a { + display:inline-block; + margin-right:10px; +} + +#pagination .on { + font-weight: bold; + cursor: default; + color:#777; +} \ No newline at end of file diff --git a/src/main/resources/static/scriptPage/js/analyticRecord.js b/src/main/resources/static/scriptPage/js/analyticRecord.js index 2790fea..1467f74 100644 --- a/src/main/resources/static/scriptPage/js/analyticRecord.js +++ b/src/main/resources/static/scriptPage/js/analyticRecord.js @@ -117,6 +117,7 @@ function navigate(record) { if (recordJson.analyzed) { const url = `/reports/${recordJson.record_id}`; + const name = 'popup-analyze' const options = 'top=10, left=10, width=500, height=600, status=no, menubar=no, toolbar=no, resizable=no'; window.open(url, options); } else { diff --git a/src/main/resources/static/scriptPage/js/map.js b/src/main/resources/static/scriptPage/js/map.js new file mode 100644 index 0000000..19da9e6 --- /dev/null +++ b/src/main/resources/static/scriptPage/js/map.js @@ -0,0 +1,291 @@ +document.addEventListener("DOMContentLoaded", function () { + const academyFeature = document.getElementById("academy-feature"); + + if (academyFeature) { + academyFeature.addEventListener("click", function () { + const url = `/map`; + const name = 'popup-map' + const options = 'top=10, left=10, width=800, height=600, status=no, menubar=no, toolbar=no, resizable=no, scrollbars=no, fullscreen=no'; + window.open(url, name, options); + }); + } +}); + +const loadingBar = document.getElementById('loading-bar'); +loadingBar.style.display = 'block'; + +// 마커를 담을 배열입니다 +var markers = []; + +var mapContainer = document.getElementById('map'), // 지도를 표시할 div + mapOption = { + center: new kakao.maps.LatLng(33.450701, 126.570667), // 지도의 중심좌표 + level: 10 // 지도의 확대 레벨 + }; + +var map = new kakao.maps.Map(mapContainer, mapOption); // 지도를 생성합니다 + +var userLocate = []; +// HTML5의 geolocation으로 사용할 수 있는지 확인합니다 +if (navigator.geolocation) { + + // GeoLocation을 이용해서 접속 위치를 얻어옵니다 + navigator.geolocation.getCurrentPosition(function(position) { + + var lat = position.coords.latitude, // 위도 + lon = position.coords.longitude; // 경도 + + var locPosition = new kakao.maps.LatLng(lat, lon), // 마커가 표시될 위치를 geolocation 으로 얻어온 좌표로 생성합니다 + message = '
여기에 계신가요?!
'; // 인포윈도우에 표시될 내용입니다 + + userLocate.push(lat); + userLocate.push(lon); + console.log('lat : ' + userLocate[0]); + console.log('lng : ' + userLocate[1]); + // 마커와 인포윈도우를 표시합니다 + displayMarker(locPosition, message); + + // 키워드로 장소를 검색합니다 + searchPlaces(); + }); + +} else { // HTML5의 GeoLocation 을 사용할 수 없을때 마커 표시 위치와 인포윈도우 내용을 설정합니다 + + var locPosition = new kakao.maps.LatLng(33.450701, 126.570667), + message = 'geolocation을 사용할수 없어요..' + + displayMarker(locPosition, message); +} + +// 지도에 마커와 인포윈도우를 표시하는 함수입니다 +function displayMarker(locPosition, message) { + + // 마커를 생성합니다 + var marker = new kakao.maps.Marker({ + map: map, + position: locPosition + }); + + var iwContent = message, // 인포윈도우에 표시할 내용 + iwRemoveable = true; + + // 인포윈도우를 생성합니다 + var infowindow = new kakao.maps.InfoWindow({ + content : iwContent, + removable : iwRemoveable + }); + + // 인포윈도우를 마커위에 표시합니다 + infowindow.open(map, marker); + + // 지도 중심좌표를 접속위치로 변경합니다 + map.setCenter(locPosition); +} + +// 장소 검색 객체를 생성합니다 +var ps = new kakao.maps.services.Places(); + +// 검색 결과 목록이나 마커를 클릭했을 때 장소명을 표출할 인포윈도우를 생성합니다 +var infowindow = new kakao.maps.InfoWindow({zIndex:1}); + +// 키워드 검색을 요청하는 함수입니다 +function searchPlaces() { + // 장소검색 객체를 통해 키워드로 장소검색을 요청합니다 + ps.keywordSearch( '스피치학원', placesSearchCB); +} + +// 장소검색이 완료됐을 때 호출되는 콜백 함수입니다 +function placesSearchCB(data, status, pagination) { + if (status === kakao.maps.services.Status.OK) { + + var filteredPlaces = data.filter(function(place) { + var placePosition = new kakao.maps.LatLng(place.y, place.x); + var distance = getDistance(userLocate[0], userLocate[1], placePosition.getLat(), placePosition.getLng()); + return distance <= 5000; // 5km 이내 필터링 + }); + + if (filteredPlaces.length === 0) { + alert('회원님 주변 5km 이내에 스피치학원이 없습니다.'); + return; + } + + displayPlaces(filteredPlaces); + displayPagination(pagination); + + } else if (status === kakao.maps.services.Status.ZERO_RESULT) { + alert('검색 결과가 존재하지 않습니다.'); + } else if (status === kakao.maps.services.Status.ERROR) { + alert('검색 결과 중 오류가 발생했습니다.'); + } + loadingBar.style.display = 'none'; +} + +// Haversine 공식을 적용한 거리계산 코드 +function getDistance(lat1, lon1, lat2, lon2) { + var R = 6371e3; // 지구 반지름 (미터 단위) + var φ1 = lat1 * Math.PI / 180; + var φ2 = lat2 * Math.PI / 180; + var Δφ = (lat2 - lat1) * Math.PI / 180; + var Δλ = (lon2 - lon1) * Math.PI / 180; + + var a = Math.sin(Δφ / 2) * Math.sin(Δφ / 2) + + Math.cos(φ1) * Math.cos(φ2) * + Math.sin(Δλ / 2) * Math.sin(Δλ / 2); + var c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + + var distance = R * c; // 두 좌표 간의 거리 (미터 단위) + return distance; +} + +// 검색 결과 목록과 마커를 표출하는 함수입니다 +function displayPlaces(places) { + var listEl = document.getElementById('placesList'), + menuEl = document.getElementById('menu_wrap'), + fragment = document.createDocumentFragment(), + bounds = new kakao.maps.LatLngBounds(); + + removeAllChildNods(listEl); // 이전 결과 목록을 제거 + removeMarker(); // 지도에서 이전 마커를 제거 + + for (var i = 0; i < places.length; i++) { + var placePosition = new kakao.maps.LatLng(places[i].y, places[i].x), + marker = addMarker(placePosition, i), + itemEl = getListItem(i, places[i]); + + bounds.extend(placePosition); + + (function (marker, title) { + itemEl.onmouseover = function () { + displayInfowindow(marker, title); + }; + itemEl.onmouseout = function () { + infowindow.close(); + }; + })(marker, places[i].place_name); + + fragment.appendChild(itemEl); + } + + listEl.appendChild(fragment); + menuEl.scrollTop = 0; + + map.setBounds(bounds); // 지도 범위 재설정 +} + +// 검색결과 항목을 Element로 반환하는 함수입니다 +function getListItem(index, places) { + + var el = document.createElement('li'), + itemStr = '' + + '
' + + '
' + places.place_name + '
'; + + if (places.road_address_name) { + itemStr += ' ' + places.road_address_name + '' + + ' ' + places.address_name + ''; + } else { + itemStr += ' ' + places.address_name + ''; + } + + itemStr += ' ' + places.phone + '' + + '
'; + + el.innerHTML = itemStr; + el.className = 'item'; + + return el; +} + +// 마커를 생성하고 지도 위에 마커를 표시하는 함수입니다 +function addMarker(position, idx, title) { + var imageSrc = 'https://t1.daumcdn.net/localimg/localimages/07/mapapidoc/marker_number_blue.png', // 마커 이미지 url, 스프라이트 이미지를 씁니다 + imageSize = new kakao.maps.Size(36, 37), // 마커 이미지의 크기 + imgOptions = { + spriteSize : new kakao.maps.Size(36, 691), // 스프라이트 이미지의 크기 + spriteOrigin : new kakao.maps.Point(0, (idx*46)+10), // 스프라이트 이미지 중 사용할 영역의 좌상단 좌표 + offset: new kakao.maps.Point(13, 37) // 마커 좌표에 일치시킬 이미지 내에서의 좌표 + }, + markerImage = new kakao.maps.MarkerImage(imageSrc, imageSize, imgOptions), + marker = new kakao.maps.Marker({ + position: position, // 마커의 위치 + image: markerImage + }); + + marker.setMap(map); // 지도 위에 마커를 표출합니다 + markers.push(marker); // 배열에 생성된 마커를 추가합니다 + + return marker; +} + +// 지도 위에 표시되고 있는 마커를 모두 제거합니다 +function removeMarker() { + for ( var i = 0; i < markers.length; i++ ) { + markers[i].setMap(null); + } + markers = []; +} + +// 검색 결과 목록을 페이지당 10개씩 나누는 함수입니다 +function paginateResults(results, pagination) { + var perPage = 10; // 페이지당 결과 개수 + var page = pagination.current; // 현재 페이지 번호 + var totalPages = Math.ceil(results.length / perPage); // 총 페이지 수 + + var start = (page - 1) * perPage; + var end = start + perPage; + + return { + results: results.slice(start, end), + pagination: { + current: page, + last: totalPages + } + }; +} + +// 검색결과 목록 하단에 페이지번호를 표시는 함수입니다 +function displayPagination(pagination) { + var paginationEl = document.getElementById('pagination'), + fragment = document.createDocumentFragment(); + + // 기존에 추가된 페이지번호를 삭제합니다 + while (paginationEl.hasChildNodes()) { + paginationEl.removeChild(paginationEl.lastChild); + } + + for (var i = 1; i <= pagination.last; i++) { + var el = document.createElement('a'); + el.href = "#"; + el.innerHTML = i; + + if (i === pagination.current) { + el.className = 'on'; + } else { + el.onclick = (function(i) { + return function() { + ps.keywordSearch('스피치학원', placesSearchCB, { page: i }); + }; + })(i); + } + + fragment.appendChild(el); + } + + paginationEl.appendChild(fragment); +} + +// 검색결과 목록 또는 마커를 클릭했을 때 호출되는 함수입니다 +// 인포윈도우에 장소명을 표시합니다 +function displayInfowindow(marker, title) { + var content = '
' + title + '
'; + + infowindow.setContent(content); + infowindow.open(map, marker); +} + +// 검색결과 목록의 자식 Element를 제거하는 함수입니다 +function removeAllChildNods(el) { + while (el.hasChildNodes()) { + el.removeChild (el.lastChild); + } +} \ No newline at end of file diff --git a/src/main/resources/templates/home.html b/src/main/resources/templates/home.html index 8ee9741..d6927a0 100644 --- a/src/main/resources/templates/home.html +++ b/src/main/resources/templates/home.html @@ -44,12 +44,18 @@

Cloud Storage

점수를 저장하여 발전한 모습을 스스로 확인해 보세요.

+
+
+
+

Speech Academy

+

회원님 주변의 스피치 학원을 찾아드릴게요.

+
+
-

Get Started with AI-Powered Script Recording Today!

Sign Up Now @@ -61,5 +67,6 @@

Get Started with AI-Powered Script Recording Today!

© 2024 AI Script Recorder & Processor. All rights reserved.

+ \ No newline at end of file diff --git a/src/main/resources/templates/map.html b/src/main/resources/templates/map.html new file mode 100644 index 0000000..faefdbc --- /dev/null +++ b/src/main/resources/templates/map.html @@ -0,0 +1,39 @@ + + + + + 내 주변 스피치 학원 + + + + + + + +
+ +
+
+
+ + + +
+
+ + + + + \ No newline at end of file