티스토리 뷰
안녕하세요 강정호입니다.
오늘은 제가 위치기반 푸드트럭 서비스 프로젝트에 참여했을 때 위치 기반 검색 기능을 어떻게 구현하였는지에 대해 알려드리겠습니다.
Github 주소 : https://github.com/wwwkang8/wheelwego_ver3.git
위치 기반 푸드트럭 서비스 개요
기존 문제점
소비자
푸드트럭 사업주
위치 기반 검색 1단계 : 사용자의 위도, 경도 값 찾기
사용자의 위치를 중심으로 푸드트럭을 검색하는 것이기 때문에 사용자의 위도, 경도 값을 알아야 합니다. HTML5의 navigator.geolocation 함수를 사용하여 위도, 경도 값을 받은 후 Controller로 전송하여 Session에 저장합니다.
사용자의 위치 찾기
: 어플리케이션 구동 시 자동으로 스크립트 파일이 실행되어 HTML5의 GPS 기능을 이용하여 위도, 경도 값을 가져온 후 Controller로 전송한다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | <script type="text/javascript"> if (!navigator.geolocation) { alert("지오로케이션을 지원하지 않습니다!"); document.getElementById('latitude').value = "37.566824"; document.getElementById('longitude').value= "126.978522"; var gpsForm = document.getElementById("gpsForm"); gpsForm.submit(); } function success(position) { var latitude = position.coords.latitude; var longitude = position.coords.longitude; document.getElementById('latitude').value = latitude; document.getElementById('longitude').value= longitude; var gpsForm = document.getElementById("gpsForm"); gpsForm.submit(); }; function error() { alert("사용자의 위치를 찾을 수 없습니다!"); document.getElementById('latitude').value = "37.566824"; document.getElementById('longitude').value= "126.978522"; var gpsForm = document.getElementById("gpsForm"); gpsForm.submit(); }; navigator.geolocation.getCurrentPosition(success, error); </script> <form method="post" action="${pageContext.request.contextPath}/main.do" id="gpsForm"> <input type = "hidden" id = "latitude" name = "latitude" value = ""> <input type = "hidden" id = "longitude" name = "longitude" value = ""> </form> | cs |
위치 기반 검색 2단계 : Session에 위도, 경도 저장
1단계에서 전송된 위도, 경도 값을 Session에 주입하여 사용자의 위치 정보를 세션에 저장합니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 | @RequestMapping("main.do") public ModelAndView foodtruckList(HttpServletRequest request, String latitude, String longitude) { HttpSession session=request.getSession(); //세션 생성 // 세션에 Double 타입의 위도 값 저장 session.setAttribute("latitude", Double.parseDouble(latitude)); // 세션에 Double 타입의 경도 값 저장. session.setAttribute("longitude", Double.parseDouble(longitude)); List<TruckVO> truckList = foodTruckService.foodtruckList(); Collections.shuffle(truckList); return new ModelAndView("main_home.tiles", "trucklist", truckList); } |
푸드 트럭은 2가지 방식으로 검색을 합니다. 첫 째는 사용자의 위치를 중심으로 검색하는 자동 검색과 둘 째는 사용자가 특정 위치를 입력하여 검색하는 수동 검색 방식입니다.
위와 같이 사용자는 자동검색 혹은 수동검색으로 검색할 수 있습니다. 이것의 코드는 다음과 같습니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | <div class="social col-lg-1"> <ul> <li> <a class="dropdown-toggle" href="#" data-toggle="dropdown"><i class="fa fa-map-marker fa-3x"></i></a> <div class="dropdown-menu" style="padding: 15px; padding-bottom: 15px;" id="roundCorner"> <form name = "mainForm" id="mainForm" method="post" action="${pageContext.request.contextPath}/searchFoodTruckByGPS.do"> <input type = "hidden" id = "latitude" name = "latitude" value = "${sessionScope.latitude}"> <input type = "hidden" id = "longitude" name = "longitude" value = "${sessionScope.longitude}"> <input type = "hidden" id = "address" name = "address" value = ""> <input type = "hidden" id = "foodtruckNo" name = "foodtruckNo" value = ""> <input class="btn btn-warning" onclick="searchFoodTruckByGPSManual()" style="width: 100%;" value="수동검색" style=""> <input class="btn btn-warning" onclick="searchFoodTruckByGPSAuto()" style="width: 100%;" value="자동검색" style=""> </form> </div> </li> </ul> </div> | cs |
자동 검색 방식
자동검색을 누르게 되면 2단계에서 세션에 저장한 사용자의 위도, 경도 값이 디폴트로 설정되어 사용자 위도, 경도를 바탕으로 푸드트럭이 검색이 됩니다. 위에 보시는것처럼 ${sessionScope.latitude}와 ${sessionScope.longitude}가 세션에 저장된 사용자의 위도, 경도입니다. 검색 쿼리문은 다음과 같습니다.
수동 검색방식
사용자가 현재 자신의 위치말고 다른 위치에 대해서 검색하고자 할 때 수동검색을 클릭합니다. 수동검색을 클릭하면 다음 주소API 팝업창이 뜨면서 위치를 검색할 수 있습니다. 사용자가 검색한 위치의 위도, 경도는
document.getElementById('latitude').value = latitude; document.getElementById('longitude').value= longitude;
2줄의 코드로 hidden 값에 위도, 경도가 입력됩니다. 이것은 session의 위도, 경도 값 대신 입력됩니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 | //사용자의 검색어에 따라 푸드트럭 리스트를 검색 (수동검색) function searchFoodTruckByGPSManual() { new daum.Postcode({ oncomplete: function(data) { // 팝업에서 검색결과 항목을 클릭했을때 실행할 코드를 작성하는 부분. // 각 주소의 노출 규칙에 따라 주소를 조합한다. // 내려오는 변수가 값이 없는 경우엔 공백('')값을 가지므로, 이를 참고하여 분기 한다. var fullAddr = ''; // 최종 주소 변수 var extraAddr = ''; // 조합형 주소 변수 // 사용자가 선택한 주소 타입에 따라 해당 주소 값을 가져온다. if (data.userSelectedType == 'R') { // 사용자가 도로명 주소를 선택했을 경우 fullAddr = data.roadAddress; } else { // 사용자가 지번 주소를 선택했을 경우(J) fullAddr = data.jibunAddress; } // 사용자가 선택한 주소가 도로명 타입일때 조합한다. if(data.userSelectedType == 'R'){ //법정동명이 있을 경우 추가한다. if(data.bname !== ''){ extraAddr += data.bname; } // 건물명이 있을 경우 추가한다. if(data.buildingName !== ''){ extraAddr += (extraAddr !== '' ? ', ' + data.buildingName : data.buildingName); } // 조합형주소의 유무에 따라 양쪽에 괄호를 추가하여 최종 주소를 만든다. fullAddr += (extraAddr !== '' ? ' ('+ extraAddr +')' : ''); } var mapInfo = naver.maps.Service.geocode({ address: fullAddr }, function(status, response) { if (status !== naver.maps.Service.Status.OK) { alert('입력한 주소를 찾을 수 없습니다.'); return; } var result = response.result, // 검색 결과의 컨테이너 items = result.items; // 검색 결과의 배열 var latitude = items[0].point.y; var longitude = items[0].point.x; document.getElementById('latitude').value = latitude; document.getElementById('longitude').value= longitude; var mainForm = document.getElementById("mainForm"); mainForm.submit(); }); } }).open(); } | cs |
위치기반 검색 4단계 : 위도, 경도값 Controller로 전송
home.jsp에서 사용자의 위도 경도값 혹은 검색된 특정 위치의 위도 경도값은 Controller로 전송이됩니다. 아래와 같이 Controller에서는 위치 정보를 받아서 filtering() 메서드에 매개변수로 넣어 호출합니다.
filtering() 메서드는 3가지 종류의 검색조건을 가지고 있습니다
1) 평점 조건
2) 즐겨찾기로 등록했는지 여부
3) 날짜
반경 1km 범위 검색조건은 기본으로 있고 추가적으로 1), 2), 3) 중 검색조건을 추가로 적용하여 푸드트럭을 검색합니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | @RequestMapping("searchFoodTruckByGPS.do") public ModelAndView searchFoodTruckByGPS(String latitude, String longitude, String pageNo,String _option,HttpServletRequest request) { String option = _option; if(option==null) option="ByDate"; TruckVO gpsInfo = new TruckVO(); gpsInfo.setLatitude(Double.parseDouble(latitude)); gpsInfo.setLongitude(Double.parseDouble(longitude)); ModelAndView modelAndView = new ModelAndView("foodtruck/foodtruck_location_select_list.tiles"); ListVO listVO =foodTruckService.filtering(option,null, pageNo,gpsInfo); HttpSession session=request.getSession(false); String id=null; List<WishlistVO> heartWishList=null; if(session != null){ MemberVO memberVO=(MemberVO)session.getAttribute("memberVO"); if(memberVO != null){ id = memberVO.getId(); heartWishList = mypageService.heartWishList(id); modelAndView.addObject("heartWishlist",heartWishList); } } modelAndView.addObject("pagingList", listVO); modelAndView.addObject("gpsInfo", gpsInfo); modelAndView.addObject("option", option); modelAndView.addObject("GPSflag", "true"); return modelAndView; } | cs |
아래 코드는 푸드트럭의 이름을 직접 입력하여 검색할 때의 Controller 코드입니다. 여기서 보시면 foodTruckService.filtering(option, name, pageNo, null); 코드를 확인하실 수 있습니다. null인 부분이 바로 위치정보 들어가야 하는 부분입니다. 하지만 키워드를 입력해서 검색을 할 경우 위치에 대한 검색조건은 제외한 후 검색어에 해당하는 푸드트럭만 찾을 수 있도록 하였습니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | @RequestMapping("searchFoodTruckByName.do") public ModelAndView searchFoodTruckByName(String name, String pageNo, String latitude, String longitude,HttpServletRequest request,String _option) { String option = _option; if(option==null) option="ByDate"; ModelAndView modelAndView = new ModelAndView("foodtruck/foodtruck_location_select_list.tiles"); ListVO listVO =foodTruckService.filtering(option, name, pageNo,null); modelAndView.addObject("pagingList", listVO); modelAndView.addObject("name", name); modelAndView.addObject("option", option); modelAndView.addObject("GPSflag", "false"); HttpSession session=request.getSession(false); if(session != null){ MemberVO memberVO=(MemberVO)session.getAttribute("memberVO"); if(memberVO != null){ modelAndView.addObject("heartWishlist",mypageService.heartWishList( memberVO.getId())); //사용자 id의 단골트럭 목록을 보낸다. } } return modelAndView; } | cs |
위치 기반 검색 5단계 : FoodTruckServiceImpl의 filtering() 메서드
Controller에서는 검색 조건을 매개변수로 하여 FoodTruckServiceImpl의 filtering() 메서드를 호출합니다. 아래보시는 것과 같이 pagingbean.setGpsInfo(gpsInfo); 코드에 의해서 pagingbean 객체에는 위치 정보가 저장되어 있습니다. 그래서 푸드트럭을 검색할 때 기본적으로 반경 1km 내푸드트럭 검색이라는 원칙을 가지고 있게됩니다. 그 후에 추가적인 조건으로 평점, 즐겨찾기 등록 여부, 날짜 등을 추가하여 검색을 합니다. 쿼리문은 다음 단계에서 보여드리겠습니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 | @Override public ListVO filtering(String option, String searchWord, String pageNo, TruckVO gpsInfo) { List<TruckVO> truckList=null; if(pageNo==null) pageNo="1"; ListVO pagingList=new ListVO(); int totalCount=0; if(searchWord!=null) totalCount=foodTruckDAO.getTruckListTotalContentCountByName(searchWord); if(gpsInfo!=null){ totalCount=foodTruckDAO.getTruckListTotalContentCountByGPS(gpsInfo); //pagingbean.setGpsInfo(gpsInfo); } PagingBean pagingbean = new PagingBean(Integer.parseInt(pageNo),totalCount,searchWord); if(gpsInfo!=null){ pagingbean.setGpsInfo(gpsInfo); } if(option.equals("byAvgGrade")){ truckList=foodTruckDAO.filteringByAvgGrade(pagingbean); }else if(option.equals("byWishlist")){ truckList=foodTruckDAO.filteringByWishlist(pagingbean); }else{ truckList=foodTruckDAO.filteringByDate(pagingbean); } for(int i=0; i<truckList.size();i++){ truckList.get(i).setAvgGrade(foodTruckDAO.findAvgGradeByTruckNumber(truckList.get(i).getFoodtruckNumber())); truckList.get(i).setWishlistCount(foodTruckDAO.findWishlistCountByTruckNumber(truckList.get(i).getFoodtruckNumber())); } pagingList.setTruckList(truckList); pagingList.setPagingBean(pagingbean); return pagingList; } | cs |
위치 기반 검색 6단계 : FoodTruckDAOImpl 푸드트럭 검색조건에 따른 쿼리 호출
위치 기반 검색은 베이스로 하고 평점, 즐겨찾기, 날짜 순서를 추가적으로 검색조건에 포함하여 쿼리를 날리게 됩니다.
즉 1) 위치 + 평점 2) 위치 + 즐겨찾기 여부 3) 위치 + 날짜 이렇게 3가지 조건입니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 | /** * 푸드트럭 - 최신순 필터링 * -------------------------------- * FOODTRUCK 테이블의 register_timeposted 을 기준으로 내림차순 정렬한다. * * 동적쿼리를 이용하여 이름검색과 위치기반 검색을 구분한다. */ @Override public List<TruckVO> filteringByDate(PagingBean pagingbean) { return sqlSessionTemplate.selectList("foodtruck.filteringByDate", pagingbean); } /** * 푸드트럭 - 즐겨찾기순 필터링 * ----------------------------------- * FOODTRUCK 테이블과 WISHLIST테이블을 foodtruck_number를 기준으로 * 조인하고 FOODTRUCK 테이블의 컬럼을 그룹으로 묶어 * 해당 푸드트럭의 customer_id수를 카운트한다 * 후에 wishlist count 순을 기준으로 내림차순한다. * * 동적쿼리를 이용하여 이름검색과 위치기반 검색을 구분한다. */ @Override public List<TruckVO> filteringByWishlist(PagingBean pagingbean) { return sqlSessionTemplate.selectList("foodtruck.filteringByWishlist", pagingbean); } /** * 푸드트럭 - 평점순 필터링 * ------------------------------ * FOODTRUCK 테이블과 REVIEW테이블을 foodtruck_number를 기준으로 * 조인하고 FOODTRUCK 테이블의 컬럼을 그룹으로 묶어 * 해당 푸드트럭의 평점을 구한다 * 후에 Avg grade를 기준으로 내림차순한다. * * 동적쿼리를 이용하여 이름검색과 위치기반 검색을 구분한다. * * nvl() : null값을 원하는 값으로 처리하기 위한 함수 * trunc() : 원하는 소수점까지 나타내주는 함수 */ @Override public List<TruckVO> filteringByAvgGrade(PagingBean pagingbean) { return sqlSessionTemplate.selectList("foodtruck.filteringByAvgGrade", pagingbean); } | cs |
위치기반 검색 7단계 : 검색 조건에 따른 쿼리문
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 | <select id="filteringByDate" resultMap="truckRM" parameterType="pagingBean"> select * from( select row_number() over(order by register_timeposted desc) as rnum, t.foodtruck_name, t.foodtruck_number, t.register_timeposted,t.latitude,t.longitude, f.foodtruck_filepath from foodtruck t, foodtruckfile f where f.foodtruck_number=t.foodtruck_number <if test='searchWord != null'> and t.foodtruck_name like '%' || #{searchWord} || '%' and t.latitude is not null and t.longitude is not null </if> <if test='gpsInfo !=null'> and (t.latitude between #{gpsInfo.latitude}-0.009 and #{gpsInfo.latitude}+0.009) and (t.longitude between #{gpsInfo.longitude}-0.012 and #{gpsInfo.longitude}+0.012) </if> order by register_timeposted desc ) where rnum between #{startRowNumber} and #{endRowNumber} </select> <select id="filteringByWishlist" resultMap="truckRM" parameterType="pagingBean"> select foodtruck_number, foodtruck_name, wishlist_cnt,foodtruck_filepath,latitude,longitude from( select row_number() over(order by wishlist_cnt desc) as rnum,foodtruck_number, foodtruck_name, wishlist_cnt, foodtruck_filepath,latitude,longitude from( select t.foodtruck_number, t.foodtruck_name, count(customer_id) as wishlist_cnt, f.foodtruck_filepath,t.latitude,t.longitude from foodtruck t, wishlist w, foodtruckfile f where t.foodtruck_number=w.foodtruck_number(+) and t.foodtruck_number=f.foodtruck_number <if test='searchWord!=null'> and t.foodtruck_name like '%' || #{searchWord} || '%' and t.latitude is not null and t.longitude is not null </if> <if test='gpsInfo !=null'> and (t.latitude between #{gpsInfo.latitude}-0.009 and #{gpsInfo.latitude}+0.009) and (t.longitude between #{gpsInfo.longitude}-0.012 and #{gpsInfo.longitude}+0.012) </if> group by t.foodtruck_number,t.foodtruck_name,f.foodtruck_filepath,t.latitude,t.longitude order by wishlist_cnt desc )) where rnum between #{startRowNumber} and #{endRowNumber} </select> <select id="filteringByAvgGrade" resultMap="truckRM" parameterType="pagingBean"> select * from( select row_number() over(order by avg_grade desc) as rnum,foodtruck_number, foodtruck_name, avg_grade, foodtruck_filepath,latitude,longitude from( select t.foodtruck_number, t.foodtruck_name, nvl(trunc(avg(r.grade),1),0) as avg_grade, f.foodtruck_filepath,t.latitude,t.longitude from foodtruck t, review r, foodtruckfile f where t.foodtruck_number=r.foodtruck_number(+) and t.foodtruck_number=f.foodtruck_number <if test='searchWord != null'> and t.foodtruck_name like '%' || #{searchWord} || '%' and t.latitude is not null and t.longitude is not null </if> <if test='gpsInfo !=null'> and (t.latitude between #{gpsInfo.latitude}-0.009 and #{gpsInfo.latitude}+0.009) and (t.longitude between #{gpsInfo.longitude}-0.012 and #{gpsInfo.longitude}+0.012) </if> group by t.foodtruck_number,t.foodtruck_name,f.foodtruck_filepath,t.latitude,t.longitude order by avg_grade desc )) where rnum between #{startRowNumber} and #{endRowNumber} </select> | cs |
위의 쿼리문에서 보면 searchWord와 gpsInfo 조건으로 쿼리문이 나누어져 있습니다. 쿼리문이 실행될 때는 둘 중 반드시 1가지만 실행됩니다.
그 이유는
1. 위치를 기반으로 검색할 경우 searchWord가 null이고 gpsInfo는 not null입니다.
2. 푸드트럭 이름을 입력하여 직접 검색할 경우 searchWord는 not null이고 gpsInfo는 null입니다.
그렇기 때문에 쿼리문 내부에서 searchWord 또는 gpsInfo를 가지는 if문 1가지가 실행되게 됩니다.
위치 기반 검색 8단계 : 지도 상에 푸드트럭 마킹
검색된 푸드트럭들을 네이버 지도상에 표시하는 것은 3가지 작업을 순서대로 진행해야 됩니다.
1 번째 : 푸드트럭 리스트를 배열로 생성.
2 번째 : 네이버 지도에 검색된 푸드트럭의 개수만큼 위치 정보 입력.
3 번째 : 네이버 지도에 입력된 위치에 for문을 돌려 마킹 표시를 한다.
1 번째 : 푸드트럭 리스트를 배열로 생성
1 2 3 4 5 6 7 8 9 10 11 | // 푸드트럭 정보들을 배열 형태로 생성. var foodTruckInfo = [ <c:forEach items="${requestScope.pagingList.truckList}" var="truckInfo" varStatus="status"> { latitude : "${truckInfo.latitude}", //위도 longtitude : "${truckInfo.longitude}", //경도 foodtruckName : "${truckInfo.foodtruckName}" //푸드트럭 이름 } <c:if test="${not status.last}">,</c:if> </c:forEach> ]; | cs |
2 번째 : 네이버 지도에 검색된 푸드트럭의 개수만큼 위치 정보 입력.
1 2 3 4 5 6 7 8 | // 네이버 지도에 푸드트럭의 위도, 경도 값 입력 var latlngs = []; for (var i = 0; i < foodTruckInfo.length; i++) { latlngs.push(new naver.maps.LatLng(foodTruckInfo[i].latitude, foodTruckInfo[i].longtitude)); } | cs |
3 번째 : 네이버 지도에 입력된 위치에 for문을 돌려 마킹 표시를 한다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | var markers = []; var infoWindows = []; // 네이버 지도에 입력된 위도, 경도 배열 크기만큼 // for문을 돌려 지도 위에 마킹 표시를 한다. for (var i=0, ii=latlngs.length; i<ii; i++) { var icon = { url: HOME_PATH +'/img/example/sp_pins_spot_v3.png', size: new naver.maps.Size(24, 37), anchor: new naver.maps.Point(12, 37), origin: new naver.maps.Point(i * 29, 0) }, marker = new naver.maps.Marker({ position: latlngs[i], map: map, icon: { url: HOME_PATH +'/img/example/sp_pins_spot_v3.png', size: new naver.maps.Size(24, 37), anchor: new naver.maps.Point(12, 37), origin: new naver.maps.Point(0, 0) }, zIndex: 100 }); var infoWindow = new naver.maps.InfoWindow({ content: '<div style="width:150px;text-align:center;padding:5px;">"' + foodTruckInfo[i].foodtruckName + '"</div>' }); markers.push(marker); infoWindows.push(infoWindow); } | cs |
위치 기반 푸드트럭 검색 결과 화면
위치 기반 검색기능을 구현하여 다음과 같은 모습이 되었어요.
'프로젝트' 카테고리의 다른 글
[인하우스키친] 위치 검색시 위치 자동완성 (0) | 2018.11.18 |
---|---|
[인하우스키친] Lazy 로딩으로 인한 JSON 오류 (1) | 2018.11.17 |
[인하우스키친] Ajax로 받아온 값 전역변수에 저장하기 (0) | 2018.11.15 |
[인하우스키친] 지도 위에 마커 보여주기 (1) | 2018.10.28 |
[서버 모니터링 시스템] Cloudwatch API 연동 코드 (0) | 2018.10.28 |
- Total
- Today
- Yesterday
- 개발자 회고
- 재테크공부
- Use case
- 파라메터
- 열반스쿨기초반
- 깃
- resize
- docker
- 항해플러스백엔드
- 부동산공부
- front
- ```````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````
- 항해솔직후기
- github
- 월부닷컴
- push_back
- pop_back
- 인셉션
- 깃허브
- 관계대수
- 유즈케이스
- Spring boot
- Inception
- 항해플러스후기
- GIT
- 폭포수
- 도커
- 내년은 빡세게!!
- 월급쟁이부자들
- 2023년
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 | 31 |