https://github.com/znehraks/unibangcity-frontend
실시간 크롤링 데이터 fetch를 통한 서비스
kakaoMap api 활용
데이터 시각화 라이브러리 react-chartjs-2 활용
react-router-dom 활용
중 살펴볼 코드는
Frontend/components/kakao 하위 코드
Frontend/components/recommendationMode 하위 코드
Frontend/components/App.js
Frontend/components/ArticleButton.js
Frontend/components/RouterComponents.js
Frontend/screens/Recommendation/ 하위 코드
최상위 컴포넌트인 App.js이다.
- GlobalStyles로 초기 디자인 세팅을 하였다.
- BrowserRouter로 라우팅을 구현했다.
- RouterComponent에 url별로 노출될 컴포넌트를 설정했다.
// /Frontend/components/App.js
import React from "react";
import { BrowserRouter } from "react-router-dom";
import styled, { ThemeProvider } from "styled-components";
import Footer from "./Footer";
import Header from "./Header";
import RouterComponent from "./RouterComponent";
import GlobalStyle from "./styles/GlobalStyles";
import Theme from "./styles/Theme";
const Wrapper = styled.div`
width: 100vw;
height: 100vh;
background: #fff;
`;
const App = () => {
return (
<ThemeProvider theme={Theme}>
<GlobalStyle />
<Wrapper>
<BrowserRouter>
<Header />
<RouterComponent />
<Footer />
</BrowserRouter>
</Wrapper>
</ThemeProvider>
);
};
export default App;
추천페이지의 인트로 페이지를 담당하는 컴포넌트이다.
"다음"을 누르게 되면 setMode 훅에 의해 1단계로 넘어간다.
import { Q1 } from "../Enum";
import {
ButtonBox,
ButtonContainer,
MainArticleContainer,
MainSubTitleSpan,
MainTitle,
MainTitleContainer,
TextArticle,
TextArticleSpan,
} from "../styles/StyledComponents";
import PropTypes from "prop-types";
const Intro = ({ setMode }) => {
return (
<>
<MainTitleContainer>
<MainTitle>유니방시티 이용 안내입니다.</MainTitle>
<MainSubTitleSpan>
아래 내용을 숙지해주시고 '다음'버튼을 눌러주세요
</MainSubTitleSpan>
</MainTitleContainer>
<MainArticleContainer flexDirection={"column"}>
<TextArticle width={"35%"} height={"70%"} lineHeight="0.2vw">
<TextArticleSpan>1. 나의 학교 이름을 입력해 주세요.</TextArticleSpan>
<TextArticleSpan>
2. 원하는 최대 거리를 선택해 주세요.
</TextArticleSpan>
<TextArticleSpan>
3. 가장 많이 고려하는 요소를 1개 골라주세요.
</TextArticleSpan>
<TextArticleSpan>
4. 두 번째로 많이 고려하는 요소를 1개 골라주세요.
</TextArticleSpan>
<TextArticleSpan>
5. 세 번째로 많이 고려하는 요소를 1개 골라주세요.
</TextArticleSpan>
<TextArticleSpan>6. 조금만 기다리면 끝.</TextArticleSpan>
</TextArticle>
</MainArticleContainer>
<ButtonContainer>
<ButtonBox
onClick={() => {
setMode(Q1);
}}
>
다음
</ButtonBox>
</ButtonContainer>
</>
);
};
export default Intro;
Intro.propTypes = {
setMode: PropTypes.func.isRequired,
};
추천페이지의 1단계 페이지를 담당하는 컴포넌트이다.
검색어를 input에 입력하고, 밑에 자동완성되어 나오는 단어를 누르게 되면, Q1이 선택된다.
선택된 대학교의 이름을 key로 하여, 해당 대학교의 주소(위도,경도)를 universityList.js에서 가져오고, 이 위도,경도,대학 이름을 answers에 state로 등록한다.
import UniversityList from "../data/universityList";
import { INTRO, Q2 } from "../Enum";
import {
ButtonBox,
ButtonContainer,
HiddenSearchBox,
HiddenSearchLine,
Input,
MainArticleContainer,
MainSubTitleSpan,
MainTitle,
MainTitleContainer,
} from "../styles/StyledComponents";
import PropTypes from "prop-types";
const Q1Component = ({
schoolNameInputRef,
schoolNameInput,
setAnswers,
answers,
setMode,
}) => {
return (
<>
<MainTitleContainer>
<MainTitle>1. 나의 학교를 선택해주세요.</MainTitle>
<MainSubTitleSpan>
ex. '명지대학교'검색 시 '명지' 입력 후 아래에서 선택
</MainSubTitleSpan>
</MainTitleContainer>
<MainArticleContainer flexDirection="column" justifyContent="center">
<Input
ref={schoolNameInputRef}
autoFocus
type="text"
placeholder="ex. 명지대학교"
{...schoolNameInput}
//클릭 시, answers.Q1answer, answers.univ_lat, answers.univ_lon을 비워준다.
onClick={() => {
setAnswers({
...answers,
Q1Answer: "",
univ_lat: "",
univ_lon: "",
});
//schoolNameInput의 value도 비워준다.
schoolNameInput.setValue("");
}}
/>
//answers.Q1Answer이 공백이라면(선택되지 않았다면)
{answers.Q1Answer === "" && (
<HiddenSearchBox valueLength={schoolNameInput.value.length}>
//UniversityList에서 input에 입력된 글자를 갖고 있는 대학교가 있다면
{UniversityList.map((item) => {
if (
item.name.includes(schoolNameInput.value) &&
schoolNameInput.value !== ""
) {
//그 대학교 이름을 map으로 생성된 컴포넌트에 노출함
return (
<HiddenSearchLine
key={item.name}
onClick={() => {
setAnswers({
...answers,
Q1Answer: item.name,
univ_lat: item.address_lat,
univ_lon: item.address_lon,
});
schoolNameInput.setValue(item.name);
}}
>
{item.name}
</HiddenSearchLine>
);
} else {
return null;
}
})}
</HiddenSearchBox>
)}
</MainArticleContainer>
<ButtonContainer>
<ButtonBox
onClick={() => {
setMode(INTRO);
setAnswers({ ...answers, Q1Answer: "" });
}}
>
이전
</ButtonBox>
//answers.Q1Answer의 공백체크 후 다음 단계로 넘김
<ButtonBox
onClick={() => {
if (answers.Q1Answer !== "") {
setMode(Q2);
schoolNameInput.setValue("");
//answers.Q1Answer가 공백이라면 ref훅을 이용하여, input에 focus시킴.
} else {
alert("학교를 선택해주세요.");
schoolNameInputRef.current.focus();
}
}}
>
다음
</ButtonBox>
</ButtonContainer>
</>
);
};
export default Q1Component;
Q1Component.propTypes = {
schoolNameInputRef: PropTypes.object.isRequired,
schoolNameInput: PropTypes.object.isRequired,
setAnswers: PropTypes.func.isRequired,
answers: PropTypes.object.isRequired,
setMode: PropTypes.func.isRequired,
};
추천페이지의 2단계 페이지를 담당하는 컴포넌트이다.
카카오맵api를 통해 원 반경 거리를 입력받아 answers.Q2Answer에 저장한다.
import { Q1, Q3 } from "../Enum";
import {
ButtonBox,
ButtonContainer,
MainArticleContainer,
MainSubTitleSpan,
MainTitle,
MainTitleContainer,
} from "../styles/StyledComponents";
import Q2Map from "../kakao/Q2Map";
import PropTypes from "prop-types";
const Q2Component = ({ answers, setAnswers, setMode }) => {
return (
<>
<MainTitleContainer>
<MainTitle>2. 원하는 거리를 선택해주세요.</MainTitle>
<MainSubTitleSpan>
지도를 마우스로 클릭하면 원 그리기가 시작되고 마우스 우클릭하면 원
그리기가 종료됩니다.
</MainSubTitleSpan>
</MainTitleContainer>
<MainArticleContainer>
<Q2Map
answers={answers}
setAnswers={setAnswers}
mobile={false}
univ_lat={answers.univ_lat}
univ_lon={answers.univ_lon}
/>
</MainArticleContainer>
<ButtonContainer>
<ButtonBox
onClick={() => {
setMode(Q1);
setAnswers({ ...answers, Q2Answer: "" });
}}
>
이전
</ButtonBox>
<ButtonBox
onClick={() => {
if (answers.Q2Answer !== "") {
setMode(Q3);
} else {
alert("거리를 설정해주세요.");
}
}}
>
다음
</ButtonBox>
</ButtonContainer>
</>
);
};
export default Q2Component;
Q2Component.propTypes = {
answers: PropTypes.object.isRequired,
setAnswers: PropTypes.func.isRequired,
setMode: PropTypes.func.isRequired,
};
Q2 컴포넌트에 쓰인 카카오맵의 코드이다
Q1에서 선택된 대학교의 위도,경도를 중심으로 원을 그려 반지름을 Q2Answer로 정한다.
다른 부분은 크게 건들이지 않고 리액트에 맞게 변형하고, 훅을 추가했다.
import React, { useEffect } from "react";
const { kakao } = window;
const MapContainer = ({ answers, univ_lat, univ_lon, setAnswers, mobile }) => {
useEffect(() => {
//지도 넣을 컨테이너
const container = document.getElementById("myMap");
// 지도에 들어가는 옵션
const options = {
center: new kakao.maps.LatLng(univ_lat, univ_lon),
level: 6,
draggable: true,
scrollwheel: true,
};
//지도 객체 생성
const map = new kakao.maps.Map(container, options);
let drawingFlag = false; // 원이 그려지고 있는 상태를 가지고 있을 변수입니다
let centerPosition; // 원의 중심좌표 입니다
let drawingCircle; // 그려지고 있는 원을 표시할 원 객체입니다
let drawingLine; // 그려지고 있는 원의 반지름을 표시할 선 객체입니다
let drawingOverlay; // 그려지고 있는 원의 반경을 표시할 커스텀오버레이 입니다
let drawingDot; // 그려지고 있는 원의 중심점을 표시할 커스텀오버레이 입니다
let circles = [];
// 지도에 클릭 이벤트를 등록합니다
kakao.maps.event.addListener(map, "click", function (mouseEvent) {
// 클릭 이벤트가 발생했을 때 원을 그리고 있는 상태가 아니면 중심좌표를 클릭한 지점으로 설정합니다
if (!drawingFlag) {
if (circles.length !== 0) {
removeCircles();
}
// 상태를 그리고있는 상태로 변경합니다
drawingFlag = true;
// 원이 그려질 중심좌표를 클릭한 위치로 설정합니다
centerPosition = options.center;
console.log(centerPosition);
// 그려지고 있는 원의 반경을 표시할 선 객체를 생성합니다
if (!drawingLine) {
drawingLine = new kakao.maps.Polyline({
strokeWeight: 3, // 선의 두께입니다
strokeColor: "#00a0e9", // 선의 색깔입니다
strokeOpacity: 1, // 선의 불투명도입니다 0에서 1 사이값이며 0에 가까울수록 투명합니다
strokeStyle: "solid", // 선의 스타일입니다
});
}
// 그려지고 있는 원을 표시할 원 객체를 생성합니다
if (!drawingCircle) {
drawingCircle = new kakao.maps.Circle({
strokeWeight: 1, // 선의 두께입니다
strokeColor: "#00a0e9", // 선의 색깔입니다
strokeOpacity: 0.1, // 선의 불투명도입니다 0에서 1 사이값이며 0에 가까울수록 투명합니다
strokeStyle: "solid", // 선의 스타일입니다
fillColor: "#00a0e9", // 채우기 색깔입니다
fillOpacity: 0.2, // 채우기 불투명도입니다
});
}
// 그려지고 있는 원의 반경 정보를 표시할 커스텀오버레이를 생성합니다
if (!drawingOverlay) {
drawingOverlay = new kakao.maps.CustomOverlay({
xAnchor: 0,
yAnchor: 0,
zIndex: 1,
});
}
}
});
// 지도에 마우스무브 이벤트를 등록합니다
// 원을 그리고있는 상태에서 마우스무브 이벤트가 발생하면 그려질 원의 위치와 반경정보를 동적으로 보여주도록 합니다
kakao.maps.event.addListener(map, "mousemove", function (mouseEvent) {
// 마우스무브 이벤트가 발생했을 때 원을 그리고있는 상태이면
if (drawingFlag) {
// 마우스 커서의 현재 위치를 얻어옵니다
var mousePosition = mouseEvent.latLng;
// 그려지고 있는 선을 표시할 좌표 배열입니다. 클릭한 중심좌표와 마우스커서의 위치로 설정합니다
var linePath = [centerPosition, mousePosition];
// 그려지고 있는 선을 표시할 선 객체에 좌표 배열을 설정합니다
drawingLine.setPath(linePath);
// 원의 반지름을 선 객체를 이용해서 얻어옵니다
var length = drawingLine.getLength();
if (length > 0) {
// 그려지고 있는 원의 중심좌표와 반지름입니다
var circleOptions = {
center: centerPosition,
radius: length,
};
// 그려지고 있는 원의 옵션을 설정합니다
drawingCircle.setOptions(circleOptions);
// 반경 정보를 표시할 커스텀오버레이의 내용입니다
var radius = Math.round(drawingCircle.getRadius()),
content =
'<div class="info">반경 <span class="number">' +
radius +
"</span>m</div>";
// 반경 정보를 표시할 커스텀 오버레이의 좌표를 마우스커서 위치로 설정합니다
drawingOverlay.setPosition(mousePosition);
// 반경 정보를 표시할 커스텀 오버레이의 표시할 내용을 설정합니다
drawingOverlay.setContent(content);
// 그려지고 있는 원을 지도에 표시합니다
drawingCircle.setMap(map);
// 그려지고 있는 선을 지도에 표시합니다
drawingLine.setMap(map);
// 그려지고 있는 원의 반경정보 커스텀 오버레이를 지도에 표시합니다
drawingOverlay.setMap(map);
} else {
drawingCircle.setMap(null);
drawingLine.setMap(null);
drawingOverlay.setMap(null);
}
}
});
// 지도에 마우스 오른쪽 클릭이벤트를 등록합니다
// 원을 그리고있는 상태에서 마우스 오른쪽 클릭 이벤트가 발생하면
// 마우스 오른쪽 클릭한 위치를 기준으로 원과 원의 반경정보를 표시하는 선과 커스텀 오버레이를 표시하고 그리기를 종료합니다
kakao.maps.event.addListener(map, "rightclick", function (mouseEvent) {
if (drawingFlag) {
// 마우스로 오른쪽 클릭한 위치입니다
var rClickPosition = mouseEvent.latLng;
// 원의 반경을 표시할 선 객체를 생성합니다
var polyline = new kakao.maps.Polyline({
path: [centerPosition, rClickPosition], // 선을 구성하는 좌표 배열입니다. 원의 중심좌표와 클릭한 위치로 설정합니다
strokeWeight: 3, // 선의 두께 입니다
strokeColor: "#00a0e9", // 선의 색깔입니다
strokeOpacity: 1, // 선의 불투명도입니다 0에서 1 사이값이며 0에 가까울수록 투명합니다
strokeStyle: "solid", // 선의 스타일입니다
});
// 원 객체를 생성합니다
var circle = new kakao.maps.Circle({
center: centerPosition, // 원의 중심좌표입니다
radius: polyline.getLength(), // 원의 반지름입니다 m 단위 이며 선 객체를 이용해서 얻어옵니다
strokeWeight: 1, // 선의 두께입니다
strokeColor: "#00a0e9", // 선의 색깔입니다
strokeOpacity: 0.1, // 선의 불투명도입니다 0에서 1 사이값이며 0에 가까울수록 투명합니다
strokeStyle: "solid", // 선의 스타일입니다
fillColor: "#00a0e9", // 채우기 색깔입니다
fillOpacity: 0.2, // 채우기 불투명도입니다
});
var radius = Math.round(circle.getRadius()), // 원의 반경 정보를 얻어옵니다
content = getTimeHTML(radius); // 커스텀 오버레이에 표시할 반경 정보입니다
//setAnswers 훅을 통해 Q2Answer에 원 반경거리를 설정한다.
setAnswers({ ...answers, Q2Answer: radius });
// 반경정보를 표시할 커스텀 오버레이를 생성합니다
var radiusOverlay = new kakao.maps.CustomOverlay({
content: content, // 표시할 내용입니다
position: rClickPosition, // 표시할 위치입니다. 클릭한 위치로 설정합니다
xAnchor: 0,
yAnchor: 0,
zIndex: 1,
});
// 원을 지도에 표시합니다
circle.setMap(map);
// 선을 지도에 표시합니다
polyline.setMap(map);
// 반경 정보 커스텀 오버레이를 지도에 표시합니다
radiusOverlay.setMap(map);
// 배열에 담을 객체입니다. 원, 선, 커스텀오버레이 객체를 가지고 있습니다
var radiusObj = {
polyline: polyline,
circle: circle,
overlay: radiusOverlay,
};
// 배열에 추가합니다
// 이 배열을 이용해서 "모두 지우기" 버튼을 클릭했을 때 지도에 그려진 원, 선, 커스텀오버레이들을 지웁니다
circles.push(radiusObj);
// 그리기 상태를 그리고 있지 않는 상태로 바꿉니다
drawingFlag = false;
// 중심 좌표를 초기화 합니다
centerPosition = null;
// 그려지고 있는 원, 선, 커스텀오버레이를 지도에서 제거합니다
drawingCircle.setMap(null);
drawingLine.setMap(null);
drawingOverlay.setMap(null);
}
});
// 지도에 표시되어 있는 모든 원과 반경정보를 표시하는 선, 커스텀 오버레이를 지도에서 제거합니다
function removeCircles() {
for (var i = 0; i < circles.length; i++) {
circles[i].circle.setMap(null);
circles[i].polyline.setMap(null);
circles[i].overlay.setMap(null);
}
circles = [];
} // 마우스 우클릭 하여 원 그리기가 종료됐을 때 호출하여
// 그려진 원의 반경 정보와 반경에 대한 도보, 자전거 시간을 계산하여
// HTML Content를 만들어 리턴하는 함수입니다
function getTimeHTML(distance) {
// 도보의 시속은 평균 4km/h 이고 도보의 분속은 67m/min입니다
var walkkTime = (distance / 67) | 0;
var walkHour = "",
walkMin = "";
// 계산한 도보 시간이 60분 보다 크면 시간으로 표시합니다
if (walkkTime > 60) {
walkHour =
'<span class="number">' + Math.floor(walkkTime / 60) + "</span>시간 ";
}
walkMin = '<span class="number">' + (walkkTime % 60) + "</span>분";
// 자전거의 평균 시속은 16km/h 이고 이것을 기준으로 자전거의 분속은 267m/min입니다
var bycicleTime = (distance / 227) | 0;
var bycicleHour = "",
bycicleMin = "";
// 계산한 자전거 시간이 60분 보다 크면 시간으로 표출합니다
if (bycicleTime > 60) {
bycicleHour =
'<span class="number">' +
Math.floor(bycicleTime / 60) +
"</span>시간 ";
}
bycicleMin = '<span class="number">' + (bycicleTime % 60) + "</span>분";
// 거리와 도보 시간, 자전거 시간을 가지고 HTML Content를 만들어 리턴합니다
var content = '<ul class="info">';
content += " <li>";
content +=
' <span class="label">총거리</span><span class="number">' +
distance +
"</span>m";
content += " </li>";
content += " <li>";
content += ' <span class="label">도보</span>' + walkHour + walkMin;
content += " </li>";
content += " <li>";
content +=
' <span class="label">자전거</span>' + bycicleHour + bycicleMin;
content += " </li>";
content += "</ul>";
return content;
}
}, []);
return (
<div
id="myMap"
style={{
width: mobile ? "60vw" : "22vw",
height: mobile ? "60vw" : "22vw",
}}
></div>
);
};
export default MapContainer;
추천페이지의 3,4,5단계 페이지를 담당하는 컴포넌트이다.
1순위로 중요하게 여기는 요소를 택하여 Q3Answer에 할당한다.
2순위로 중요하게 여기는 요소를 택하여 Q4Answer에 할당한다.
3순위로 중요하게 여기는 요소를 택하여 Q5Answer에 할당한다.
Q5에서는 모든 문항의 유효성 체크를 한다. 공백이 있거나 입력되지 않은 문항이 있으면 해당 문항으로 mode를 변환한 뒤, 입력을 유도한다.
import ArticleButton from "../ArticleButton";
import { COST, DISTANCE, HOUSE, Q2, Q3, Q4, SAFETY, SUBWAY } from "../Enum";
import {
ButtonBox,
ButtonContainer,
MainArticleContainer,
MainSubTitleSpan,
MainTitle,
MainTitleContainer,
} from "../styles/StyledComponents";
import distance_img from "../styles/images/distance.png";
import subway_img from "../styles/images/subway.png";
import cost_img from "../styles/images/cost.png";
import safety_img from "../styles/images/safety.png";
import house_img from "../styles/images/house.png";
import distance_red_img from "../styles/images/distance_red.png";
import subway_red_img from "../styles/images/subway_red.png";
import cost_red_img from "../styles/images/cost_red.png";
import safety_red_img from "../styles/images/safety_red.png";
import house_red_img from "../styles/images/house_red.png";
import PropTypes from "prop-types";
const Q3Component = ({ answers, setAnswers, setMode }) => {
return (
<>
<MainTitleContainer>
<MainTitle>3. 가장 중요시 여기는 요소를 선택해주세요.</MainTitle>
<MainSubTitleSpan>아래 항목 중 한 개만 선택해 주세요.</MainSubTitleSpan>
</MainTitleContainer>
<MainArticleContainer>
<ArticleButton
current={Q3}
name={DISTANCE}
kr_name={"거리"}
black_img={distance_img}
red_img={distance_red_img}
answers={answers}
setAnswers={setAnswers}
isSelected={answers.Q3Answer === DISTANCE}
/>
<ArticleButton
current={Q3}
name={SUBWAY}
kr_name={"역세권"}
black_img={subway_img}
red_img={subway_red_img}
answers={answers}
setAnswers={setAnswers}
isSelected={answers.Q3Answer === SUBWAY}
/>
<ArticleButton
current={Q3}
name={COST}
kr_name={"가성비"}
black_img={cost_img}
red_img={cost_red_img}
answers={answers}
setAnswers={setAnswers}
isSelected={answers.Q3Answer === COST}
/>
<ArticleButton
current={Q3}
name={SAFETY}
kr_name={"안전"}
black_img={safety_img}
red_img={safety_red_img}
answers={answers}
setAnswers={setAnswers}
isSelected={answers.Q3Answer === SAFETY}
/>
<ArticleButton
current={Q3}
name={HOUSE}
kr_name={"주변 매물 수"}
black_img={house_img}
red_img={house_red_img}
answers={answers}
setAnswers={setAnswers}
isSelected={answers.Q3Answer === HOUSE}
/>
</MainArticleContainer>
<ButtonContainer>
<ButtonBox
onClick={() => {
setMode(Q2);
setAnswers({ ...answers, Q3Answer: "", Q3Answer_kr: "" });
}}
>
이전
</ButtonBox>
<ButtonBox
onClick={() => {
if (answers.Q3Answer !== "") {
setMode(Q4);
} else {
alert("항목을 선택해주세요.");
}
}}
>
다음
</ButtonBox>
</ButtonContainer>
</>
);
};
export default Q3Component;
Q3Component.propTypes = {
answers: PropTypes.object.isRequired,
setAnswers: PropTypes.func.isRequired,
setMode: PropTypes.func.isRequired,
};
import ArticleButton from "../ArticleButton";
import { COST, DISTANCE, HOUSE, Q3, Q4, Q5, SAFETY, SUBWAY } from "../Enum";
import {
ButtonBox,
ButtonContainer,
MainArticleContainer,
MainSubTitleSpan,
MainTitle,
MainTitleContainer,
} from "../styles/StyledComponents";
import distance_img from "../styles/images/distance.png";
import subway_img from "../styles/images/subway.png";
import cost_img from "../styles/images/cost.png";
import safety_img from "../styles/images/safety.png";
import house_img from "../styles/images/house.png";
import distance_red_img from "../styles/images/distance_red.png";
import subway_red_img from "../styles/images/subway_red.png";
import cost_red_img from "../styles/images/cost_red.png";
import safety_red_img from "../styles/images/safety_red.png";
import house_red_img from "../styles/images/house_red.png";
import PropTypes from "prop-types";
const Q4Component = ({ answers, setAnswers, setMode }) => {
return (
<>
<MainTitleContainer>
<MainTitle>4. 두 번째로 중요시 여기는 요소를 선택해주세요.</MainTitle>
<MainSubTitleSpan>아래 항목 중 한 개만 선택해 주세요.</MainSubTitleSpan>
</MainTitleContainer>
<MainArticleContainer>
<ArticleButton
current={Q4}
name={DISTANCE}
kr_name={"거리"}
black_img={distance_img}
red_img={distance_red_img}
answers={answers}
setAnswers={setAnswers}
isSelected={answers.Q4Answer === DISTANCE}
/>
<ArticleButton
current={Q4}
name={SUBWAY}
kr_name={"역세권"}
black_img={subway_img}
red_img={subway_red_img}
answers={answers}
setAnswers={setAnswers}
isSelected={answers.Q4Answer === SUBWAY}
/>
<ArticleButton
current={Q4}
name={COST}
kr_name={"가성비"}
black_img={cost_img}
red_img={cost_red_img}
answers={answers}
setAnswers={setAnswers}
isSelected={answers.Q4Answer === COST}
/>
<ArticleButton
current={Q4}
name={SAFETY}
kr_name={"안전"}
black_img={safety_img}
red_img={safety_red_img}
answers={answers}
setAnswers={setAnswers}
isSelected={answers.Q4Answer === SAFETY}
/>
<ArticleButton
current={Q4}
name={HOUSE}
kr_name={"주변 매물 수"}
black_img={house_img}
red_img={house_red_img}
answers={answers}
setAnswers={setAnswers}
isSelected={answers.Q4Answer === HOUSE}
/>
</MainArticleContainer>
<ButtonContainer>
<ButtonBox
onClick={() => {
setMode(Q3);
setAnswers({ ...answers, Q4Answer: "", Q4Answer_kr: "" });
}}
>
이전
</ButtonBox>
<ButtonBox
onClick={() => {
if (answers.Q4Answer !== "") {
setMode(Q5);
} else {
alert("항목을 선택해주세요.");
}
}}
>
다음
</ButtonBox>
</ButtonContainer>
</>
);
};
export default Q4Component;
Q4Component.propTypes = {
answers: PropTypes.object.isRequired,
setAnswers: PropTypes.func.isRequired,
setMode: PropTypes.func.isRequired,
};
import ArticleButton from "../ArticleButton";
import {
COST,
DISTANCE,
FINISH,
HOUSE,
Q1,
Q2,
Q3,
Q4,
Q5,
SAFETY,
SUBWAY,
} from "../Enum";
import {
ButtonBox,
ButtonContainer,
MainArticleContainer,
MainSubTitleSpan,
MainTitle,
MainTitleContainer,
} from "../styles/StyledComponents";
import distance_img from "../styles/images/distance.png";
import subway_img from "../styles/images/subway.png";
import cost_img from "../styles/images/cost.png";
import safety_img from "../styles/images/safety.png";
import house_img from "../styles/images/house.png";
import distance_red_img from "../styles/images/distance_red.png";
import subway_red_img from "../styles/images/subway_red.png";
import cost_red_img from "../styles/images/cost_red.png";
import safety_red_img from "../styles/images/safety_red.png";
import house_red_img from "../styles/images/house_red.png";
import PropTypes from "prop-types";
const Q5Component = ({ answers, setAnswers, setMode }) => {
return (
<>
<MainTitleContainer>
<MainTitle>5. 세 번째로 중요시 여기는 요소를 선택해주세요.</MainTitle>
<MainSubTitleSpan>아래 항목 중 한 개만 선택해 주세요.</MainSubTitleSpan>
</MainTitleContainer>
<MainArticleContainer>
<ArticleButton
current={Q5}
name={DISTANCE}
kr_name={"거리"}
black_img={distance_img}
red_img={distance_red_img}
answers={answers}
setAnswers={setAnswers}
isSelected={answers.Q5Answer === DISTANCE}
/>
<ArticleButton
current={Q5}
name={SUBWAY}
kr_name={"역세권"}
black_img={subway_img}
red_img={subway_red_img}
answers={answers}
setAnswers={setAnswers}
isSelected={answers.Q5Answer === SUBWAY}
/>
<ArticleButton
current={Q5}
name={COST}
kr_name={"가성비"}
black_img={cost_img}
red_img={cost_red_img}
answers={answers}
setAnswers={setAnswers}
isSelected={answers.Q5Answer === COST}
/>
<ArticleButton
current={Q5}
name={SAFETY}
kr_name={"안전"}
black_img={safety_img}
red_img={safety_red_img}
answers={answers}
setAnswers={setAnswers}
isSelected={answers.Q5Answer === SAFETY}
/>
<ArticleButton
current={Q5}
name={HOUSE}
kr_name={"주변 매물 수"}
black_img={house_img}
red_img={house_red_img}
answers={answers}
setAnswers={setAnswers}
isSelected={answers.Q5Answer === HOUSE}
/>
</MainArticleContainer>
<ButtonContainer>
<ButtonBox
onClick={() => {
setMode(Q4);
setAnswers({ ...answers, Q5Answer: "", Q5Answer_kr: "" });
}}
>
이전
</ButtonBox>
//유효성 체크
<ButtonBox
onClick={() => {
if (answers.Q1Answer === "") {
alert("1단계가 완료되지 않았습니다.");
setMode(Q1);
} else if (answers.Q2Answer === "") {
alert("2단계가 완료되지 않았습니다.");
setMode(Q2);
} else if (answers.Q3Answer === "") {
alert("3단계가 완료되지 않았습니다.");
setMode(Q3);
} else if (answers.Q4Answer === "") {
alert("4단계가 완료되지 않았습니다.");
setMode(Q4);
} else if (answers.Q5Answer === "") {
alert("항목을 선택해주세요.");
setMode(Q5);
} else {
setMode(FINISH);
}
}}
>
완료
</ButtonBox>
</ButtonContainer>
</>
);
};
export default Q5Component;
Q5Component.propTypes = {
answers: PropTypes.object.isRequired,
setAnswers: PropTypes.func.isRequired,
setMode: PropTypes.func.isRequired,
};
모든 문항 입력이 완료되고, 결과를 보기 직전 페이지이다.
크롤링을 통해 실시간으로 제공하는 서비스이다 보니, 속도가 다소 느린 점을 해소하기 위해, 이 페이지에서 fetch를 시작하며 시간을 버는 형태로 구현했다.
결과보기를 누르면 다음으로 넘어간다.
import { RESULT } from "../Enum";
import {
MainArticle,
MainArticleContainer,
MainSubTitleSpan,
MainTitle,
MainTitleContainer,
} from "../styles/StyledComponents";
import PropTypes from "prop-types";
const Finish = ({ setMode }) => {
return (
<>
<MainTitleContainer>
<MainTitle>나만의 자취방을 보러가실 시간입니다.</MainTitle>
<MainSubTitleSpan>아래 버튼을 눌러주세요.</MainSubTitleSpan>
</MainTitleContainer>
<MainArticleContainer>
<MainArticle
onClick={() => {
setMode(RESULT);
}}
>
결과 보기
</MainArticle>
</MainArticleContainer>
</>
);
};
export default Finish;
Finish.propTypes = {
setMode: PropTypes.func.isRequired,
};
체크한 문항에 따른 자취방 추천 결과를 볼 수 있다.
카카오맵api를 통해 상위 5개 자취지역을 마커로 띄우고, 5개 지역 평균과 해당 마커의 차이를 우측 레이더차트에서 비교할 수 있다.
하단에는 선택된 마커에 포함된 자취방들의 월세/전세 별 보증금, 세 차이와 핵심 키워드, 방 종류별 분포를 확인할 수 있다.
하단에 있는 차트를 클릭하면 보다 자세한 통계와, 해당 자취방(원룸, 투룸 등)이 정확히 지도상의 어디에 분포하는 지 확인 가능하다.
import Loader from "../Loader";
import {
BarChartSelect,
BarChartSelectContainer,
ResultArticleContainer,
ResultCell,
ResultDetailChartContainer,
ResultDetailContainer,
ResultDetailContentContainer,
ResultDetailImg,
ResultDetailImgContainer,
ResultDetailSpan,
ResultDetailSpanContainer,
ResultMainContainer,
ResultRow,
ResultSubContainer,
ResultSubTitleSpan,
ResultTable,
ResultTitleContainer,
ResultTitleSpan,
} from "../styles/StyledComponents";
import Map from "../kakao/Map";
import Map2 from "../kakao/Map2";
import RadarArticle from "../Visualization/RadarArticle";
import BarComponent from "../Visualization/BarRoom";
import PieComponent from "../Visualization/PieRoom";
import WordcloudDetailItem from "../Visualization/Detail/WordcloudDetailItem";
import {
ALL,
BAR,
MONTHPAY,
MONTHRESERV,
PIE,
RESERV,
WORDCLOUD,
} from "../Enum";
import PropTypes from "prop-types";
const Result = ({
answers,
data,
house,
setHouse,
setCurrentAddress,
setIsHovered,
setIsClicked,
aggregated,
isHovered,
isClicked,
isChecked,
chartData,
currentAddress,
chartmode,
setChartmode,
setIsChecked,
unitTransformer,
positions,
}) => {
return (
<>
{data.length === 0 ? (
<Loader />
) : (
<>
<ResultArticleContainer>
<ResultTitleContainer>
<ResultTitleSpan>
"{answers.Q1Answer}" 주변 추천 자취지역 Top5
</ResultTitleSpan>
<ResultSubTitleSpan>
마커에 마우스(손가락)를(을) 올리시면 해당 지역과 평균을 비교할
수 있습니다.
</ResultSubTitleSpan>
</ResultTitleContainer>
<ResultMainContainer>
<ResultSubContainer>
<ResultSubTitleSpan>
마커를 클릭하면 해당 지역의 상세정보를 확인할 수 있습니다.
</ResultSubTitleSpan>
{data.length !== 0 && (
<Map
mobile={window.innerWidth <= 500}
setHouse={setHouse}
setCurrentAddress={setCurrentAddress}
setIsHovered={setIsHovered}
setIsClicked={setIsClicked}
data={data}
univ_lat={answers.univ_lat}
univ_lon={answers.univ_lon}
/>
)}
</ResultSubContainer>
<ResultSubContainer width={"40%"}>
<ResultSubTitleSpan>
선택된 지역과 5개 지역 평균의 차이입니다.
</ResultSubTitleSpan>
{aggregated.length !== 0 && (
<RadarArticle
mobile={window.innerWidth <= 500}
data={aggregated}
isHovered={isHovered}
isClicked={isClicked}
/>
)}
</ResultSubContainer>
</ResultMainContainer>
</ResultArticleContainer>
{isClicked && chartData.hashtagsTotal.length !== 0 && (
<ResultArticleContainer>
<ResultTitleContainer>
<ResultTitleSpan>
"{isClicked.rank}위 지역(
{currentAddress ? `${currentAddress}` : ``})" 주변 매물 관련
통계
</ResultTitleSpan>
<ResultSubTitleSpan>
{chartmode === ALL
? "차트를 클릭하면 자세한 정보를 볼 수 있습니다."
: "차트를 클릭하면 이전 화면으로 돌아갈 수 있습니다."}
</ResultSubTitleSpan>
</ResultTitleContainer>
{chartmode === ALL && (
<ResultMainContainer>
<ResultSubContainer>
<ResultSubTitleSpan>
{isClicked.rank}위 지역과 전체 평균 간{" "}
<strong>
{isChecked === MONTHRESERV
? "월세 보증금"
: isChecked === MONTHPAY
? "월세"
: "전세 보증금"}
</strong>{" "}
보증금 비교(단위: 만 원)
</ResultSubTitleSpan>
<BarChartSelectContainer>
<BarChartSelect
isChecked={isChecked}
onClick={() => setIsChecked(MONTHRESERV)}
>
월세 보증금
</BarChartSelect>
<BarChartSelect
isChecked={isChecked}
onClick={() => setIsChecked(MONTHPAY)}
>
월세
</BarChartSelect>
<BarChartSelect
isChecked={isChecked}
onClick={() => setIsChecked(RESERV)}
>
전세
</BarChartSelect>
</BarChartSelectContainer>
<BarComponent
isChecked={isChecked}
chartmode={chartmode}
setChartmode={setChartmode}
monthlyDepositEachAggregated={
chartData.monthlyDepositEachAggregated
}
monthlyPayEachAggregated={
chartData.monthlyPayEachAggregated
}
reservDepositEachAggregated={
chartData.reservDepositEachAggregated
}
monthlyDepositTotalAggregated={
chartData.monthlyDepositTotalAggregated
}
monthlyPayTotalAggregated={
chartData.monthlyPayTotalAggregated
}
reservDepositTotalAggregated={
chartData.reservDepositTotalAggregated
}
clickedMarker={isClicked.rank - 1}
/>
</ResultSubContainer>
<ResultSubContainer>
<ResultSubTitleSpan>
{isClicked.rank}위 지역의 핵심 키워드
</ResultSubTitleSpan>
<BarChartSelectContainer></BarChartSelectContainer>
<WordcloudDetailItem
mobile={window.innerWidth <= 500}
hashtags={chartData.hashtagsEach[isClicked.rank - 1]}
chartmode={chartmode}
setChartmode={setChartmode}
/>
</ResultSubContainer>
<ResultSubContainer>
<ResultSubTitleSpan>
{isClicked.rank}위 지역의 매물 종류 분포
</ResultSubTitleSpan>
<BarChartSelectContainer></BarChartSelectContainer>
<PieComponent
isClicked={isClicked}
chartmode={chartmode}
setChartmode={setChartmode}
/>
</ResultSubContainer>
</ResultMainContainer>
)}
{chartmode === BAR && (
<ResultDetailContainer>
<ResultDetailChartContainer>
<ResultSubTitleSpan>
{isClicked.rank}위 지역과 전체 평균 간{" "}
<strong>
{isChecked === MONTHRESERV
? "월세 보증금"
: isChecked === MONTHPAY
? "월세"
: "전세 보증금"}
</strong>{" "}
보증금 비교(단위: 만 원)
</ResultSubTitleSpan>
<BarChartSelectContainer>
<BarChartSelect
isChecked={isChecked}
onClick={() => setIsChecked(MONTHRESERV)}
>
월세 보증금
</BarChartSelect>
<BarChartSelect
isChecked={isChecked}
onClick={() => setIsChecked(MONTHPAY)}
>
월세
</BarChartSelect>
<BarChartSelect
isChecked={isChecked}
onClick={() => setIsChecked(RESERV)}
>
전세
</BarChartSelect>
</BarChartSelectContainer>
<BarComponent
isChecked={isChecked}
chartmode={chartmode}
setChartmode={setChartmode}
monthlyDepositEachAggregated={
chartData.monthlyDepositEachAggregated
}
monthlyPayEachAggregated={
chartData.monthlyPayEachAggregated
}
reservDepositEachAggregated={
chartData.reservDepositEachAggregated
}
monthlyDepositTotalAggregated={
chartData.monthlyDepositTotalAggregated
}
monthlyPayTotalAggregated={
chartData.monthlyPayTotalAggregated
}
reservDepositTotalAggregated={
chartData.reservDepositTotalAggregated
}
clickedMarker={isClicked.rank - 1}
/>
</ResultDetailChartContainer>
<ResultDetailContentContainer>
{isClicked.rank}위 지역의 통계
<ResultTable>
<ResultRow>
<ResultCell></ResultCell>
<ResultCell>최고가</ResultCell>
<ResultCell>최저가</ResultCell>
<ResultCell>평균가</ResultCell>
<ResultCell>매물 수</ResultCell>
</ResultRow>
<ResultRow>
<ResultCell>월세 보증금</ResultCell>
<ResultCell>
{unitTransformer(
chartData.monthlyDepositEachAggregated.max[
isClicked.rank - 1
]
)}
</ResultCell>
<ResultCell>
{unitTransformer(
chartData.monthlyDepositEachAggregated.min[
isClicked.rank - 1
]
)}
</ResultCell>
<ResultCell>
{unitTransformer(
chartData.monthlyDepositEachAggregated.avg[
isClicked.rank - 1
]
)}
</ResultCell>
<ResultCell>
{
chartData.monthlyDepositEachAggregated.count[
isClicked.rank - 1
]
}
개
</ResultCell>
</ResultRow>
<ResultRow>
<ResultCell>월세</ResultCell>
<ResultCell>
{unitTransformer(
chartData.monthlyPayEachAggregated.max[
isClicked.rank - 1
]
)}
</ResultCell>
<ResultCell>
{unitTransformer(
chartData.monthlyPayEachAggregated.min[
isClicked.rank - 1
]
)}
</ResultCell>
<ResultCell>
{unitTransformer(
chartData.monthlyPayEachAggregated.avg[
isClicked.rank - 1
]
)}
</ResultCell>
<ResultCell>''</ResultCell>
</ResultRow>
<ResultRow>
<ResultCell>전세 보증금</ResultCell>
<ResultCell>
{unitTransformer(
chartData.reservDepositEachAggregated.max[
isClicked.rank - 1
]
)}
</ResultCell>
<ResultCell>
{unitTransformer(
chartData.reservDepositEachAggregated.min[
isClicked.rank - 1
]
)}
</ResultCell>
<ResultCell>
{unitTransformer(
chartData.reservDepositEachAggregated.avg[
isClicked.rank - 1
]
)}
</ResultCell>
<ResultCell>
{
chartData.reservDepositEachAggregated.count[
isClicked.rank - 1
]
}
개
</ResultCell>
</ResultRow>
</ResultTable>
<ResultDetailSpanContainer>
<ResultDetailSpan>
{isClicked.rank}위 지역은 <strong>월세 보증금</strong>이
평균에 비해{" "}
<strong>
{Math.abs(
chartData.monthlyDepositEachAggregated.avg[
isClicked.rank - 1
] - chartData.monthlyDepositTotalAggregated.avg
)}
만 원{" "}
</strong>
{chartData.monthlyDepositEachAggregated.avg[
isClicked.rank - 1
] -
chartData.monthlyDepositTotalAggregated.avg >=
0
? "비싸네요."
: "싸네요."}
</ResultDetailSpan>
<ResultDetailSpan>
{isClicked.rank}위 지역은 <strong>월세</strong>가 평균에
비해{" "}
<strong>
{Math.abs(
chartData.monthlyPayEachAggregated.avg[
isClicked.rank - 1
] - chartData.monthlyPayTotalAggregated.avg
)}
만 원{" "}
</strong>
{chartData.monthlyPayEachAggregated.avg[
isClicked.rank - 1
] -
chartData.monthlyPayTotalAggregated.avg >=
0
? "비싸네요."
: "싸네요."}
</ResultDetailSpan>
<ResultDetailSpan>
{isClicked.rank}위 지역은 <strong>전세 보증금</strong>이
평균에 비해{" "}
<strong>
{Math.abs(
chartData.reservDepositEachAggregated.avg[
isClicked.rank - 1
] - chartData.reservDepositTotalAggregated.avg
)}
만 원{" "}
</strong>
{chartData.reservDepositEachAggregated.avg[
isClicked.rank - 1
] -
chartData.reservDepositTotalAggregated.avg >=
0
? "비싸네요."
: "싸네요."}
</ResultDetailSpan>
<ResultDetailSpan>
그리고,{" "}
<strong>
{chartData.reservDepositEachAggregated.count[
isClicked.rank - 1
] +
chartData.monthlyDepositEachAggregated.count[
isClicked.rank - 1
]}
개의 전세/월세 매물
</strong>
이 있군요.
</ResultDetailSpan>
</ResultDetailSpanContainer>
</ResultDetailContentContainer>
</ResultDetailContainer>
)}
{chartmode === WORDCLOUD && (
<ResultDetailContainer>
<ResultDetailChartContainer>
<ResultSubTitleSpan>
{isClicked.rank}위 지역의 핵심 키워드
</ResultSubTitleSpan>
<BarChartSelectContainer></BarChartSelectContainer>
<WordcloudDetailItem
mobile={window.innerWidth <= 500}
hashtags={chartData.hashtagsEach[isClicked.rank - 1]}
chartmode={chartmode}
setChartmode={setChartmode}
/>
</ResultDetailChartContainer>
</ResultDetailContainer>
)}
{chartmode === PIE && (
<ResultDetailContainer>
{house ? (
<ResultDetailChartContainer>
<ResultSubTitleSpan>선택된 매물 정보</ResultSubTitleSpan>
<ResultDetailImgContainer>
<ResultDetailImg
onClick={() => setHouse()}
src={isClicked.rooms_img_url_01[house]}
alt="매물 사진"
/>
</ResultDetailImgContainer>
<ResultDetailSpan>
{isClicked.rooms_desc[house]}
</ResultDetailSpan>
<ResultDetailSpan>
{isClicked.rooms_desc2[house]}
</ResultDetailSpan>
<ResultDetailSpan>
{isClicked.rooms_price_title[house]}
</ResultDetailSpan>
</ResultDetailChartContainer>
) : (
<ResultDetailChartContainer>
<ResultSubTitleSpan>
{isClicked.rank}위 지역의 매물 종류 분포
</ResultSubTitleSpan>
<PieComponent
isClicked={isClicked}
chartmode={chartmode}
setChartmode={setChartmode}
/>
</ResultDetailChartContainer>
)}
<ResultDetailContentContainer>
{positions.length !== 0 && (
<>
<ResultSubTitleSpan>
{isClicked.rank}위 지역의 매물 분포도
</ResultSubTitleSpan>
<Map2
isClicked={isClicked}
univ_lat={answers.univ_lat}
univ_lon={answers.univ_lon}
residencePositions={positions}
setHouse={setHouse}
mobile={false}
/>
</>
)}
</ResultDetailContentContainer>
</ResultDetailContainer>
)}
</ResultArticleContainer>
)}
</>
)}
</>
);
};
export default Result;
Result.propTypes = {
answers: PropTypes.object.isRequired,
data: PropTypes.array.isRequired,
house: PropTypes.object,
setHouse: PropTypes.func.isRequired,
setCurrentAddress: PropTypes.func.isRequired,
setIsHovered: PropTypes.func.isRequired,
setIsClicked: PropTypes.func.isRequired,
aggregated: PropTypes.array.isRequired,
isHovered: PropTypes.string.isRequired,
isClicked: PropTypes.string.isRequired,
isChecked: PropTypes.string.isRequired,
chartData: PropTypes.object.isRequired,
currentAddress: PropTypes.string.isRequired,
chartmode: PropTypes.string.isRequired,
setChartmode: PropTypes.func.isRequired,
setIsChecked: PropTypes.func.isRequired,
unitTransformer: PropTypes.func.isRequired,
positions: PropTypes.array.isRequired,
};
Q3,Q4,Q5에서 버튼 부분의 컴포넌트이다.
클릭 여부에 따라 style과 png 파일이 달라지도록 hook으로 관리했다.
import { useEffect, useState } from "react";
import styled from "styled-components";
import { Q3, Q4, Q5 } from "./Enum";
import PropTypes from "prop-types";
const MainArticle = styled.div`
width: 12vw;
height: 12vw;
display: ${(props) => (props.display ? "flex" : "none")};
flex-direction: column;
justify-content: center;
align-items: center;
border: ${(props) =>
props.isSelected ? `4px solid #f7323f` : `4px solid rgba(0, 0, 0, 0.5)`};
border-radius: 10%;
font-size: 1.4vw;
font-weight: 600;
color: ${(props) => (props.isSelected ? `#f7323f` : `black`)};
cursor: pointer;
:hover {
border: 4px solid #f7323f;
color: #f7323f;
}
`;
const MainArticleButtonImg = styled.img`
height: 60%;
width: auto;
margin-bottom: 1vw;
`;
const ArticleButton = ({
current,
name,
kr_name,
black_img,
red_img,
answers,
setAnswers,
isSelected,
}) => {
const [buttonOnMouse, setButtonOnMouse] = useState(false);
const [display, setDisplay] = useState(true);
useEffect(() => {
//현재 이 버튼이 몇 단계에 쓰이는 요소의 버튼인지 확인하고,
//이전 단계에서 해당 버튼이 선택되었다면, 다음 단계에선 안보이게한다.
if (current === Q4) {
setDisplay(answers.Q3Answer !== name);
} else if (current === Q5) {
setDisplay(answers.Q3Answer !== name && answers.Q4Answer !== name);
}
}, [current, answers.Q3Answer, answers.Q4Answer, name]);
return (
<MainArticle
display={display}
isSelected={isSelected}
onClick={() => {
//이 버튼이 몇 단계에 쓰이는 요소의 버튼인지 확인하고,
//눌린 버튼을 각 단계의 Answer로 할당한다.
if (current === Q3) {
setAnswers({ ...answers, Q3Answer: name, Q3Answer_kr: kr_name });
} else if (current === Q4) {
setAnswers({ ...answers, Q4Answer: name, Q4Answer_kr: kr_name });
} else {
setAnswers({ ...answers, Q5Answer: name, Q5Answer_kr: kr_name });
}
}}
//버튼 hover 스타일을 위한 코드
onMouseLeave={() => setButtonOnMouse(false)}
onMouseEnter={() => setButtonOnMouse(true)}
>
//버튼 위에 커서가 있거나, 선택되었다면 빨간색으로 바꾼다.
<MainArticleButtonImg
src={buttonOnMouse || isSelected ? red_img : black_img}
/>
{kr_name}
</MainArticle>
);
};
export default ArticleButton;
ArticleButton.propTypes = {
current: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
kr_name: PropTypes.string.isRequired,
black_img: PropTypes.string.isRequired,
red_img: PropTypes.string.isRequired,
answers: PropTypes.object.isRequired,
setAnswers: PropTypes.func.isRequired,
isSelected: PropTypes.bool.isRequired,
};
react의 라우팅을 위해, 해당하는 url에 맞는 screen 컴포넌트를 노출시키기 위한 코드
마지막의 *는 그 외 다른 url을 NotFound로 보내는 코드이다.
import React from "react";
import { Route, Routes } from "react-router-dom";
import Aboutus from "../screens/Aboutus";
import Recommendation from "../screens/Recommendation";
import Lab from "../screens/Lab";
import History from "../screens/History";
import Home from "../screens/Home";
const RouterComponent = () => {
return (
<Routes>
//exact는 정확하게 path와 일치해야지만 라우팅시키겠다는 props다.
<Route exact path="/" element={<Home />} />
<Route exact path="/Aboutus" element={<Aboutus />} />
<Route exact path="/Recommendation" element={<Recommendation />} />
<Route exact path="/Lab" element={<Lab />} />
<Route exact path="/History" element={<History />} />
<Route exact path="*" element={<h1>NOT FOUND</h1>} />
</Routes>
);
};
export default RouterComponent;
이 프로젝트의 핵심 screen페이지인 추천의 처음과 결과창까지 품고 있는 컴포넌트이다.
추천 페이지에 쓰이는 모든 hook과 함수가 들어있다.
import React, { useEffect, useRef, useState } from "react";
import { FINISH, INTRO, ALL, MONTHRESERV } from "../../components/Enum";
import useInput from "../../components/hooks/useInput";
import { Api } from "../../api";
import RecommendationPresenter from "./RecommendationPresenter";
const { kakao } = window;
const RecommendationContainer = () => {
//현재 모드를 설정. INTRO, Q1~Q5, FINISH, RESULT 중
const [mode, setMode] = useState(INTRO);
//Q1에서 학교 이름을 검색할 때 쓰이는 hook
const schoolNameInput = useInput("");
//Q1에서 학교 이름을 검색하는 input에 쓰일 ref hook
const schoolNameInputRef = useRef();
//Q1~Q5 단계에서 선택된 항목들을 관리하는 hook
const [answers, setAnswers] = useState({
Q1Answer: "",
univ_lat: "",
univ_lon: "",
Q2Answer: "",
Q3Answer: "",
Q4Answer: "",
Q5Answer: "",
Q3Answer_kr: "",
Q4Answer_kr: "",
Q5Answer_kr: "",
});
//위에서 입력된 answers를 이용하여 fetch된 데이터를 저장한다.
const [data, setData] = useState([]);
//레이더차트에 쓰일 가중치의 평균값과 개별값들을 저장한다.
const [aggregated, setAggregated] = useState([]);
//RESULT페이지에서 선택된 마커의 위도,경도 값을 변환하여 동주소(ex.서울시 서대문구 북가좌동)을 담는다
const [currentAddress, setCurrentAddress] = useState("");
//파이차트 세부페이지(지도에 방 분포를 보여주는 페이지)에서 각 실제 자취방의 정보를 담음
const [house, setHouse] = useState();
//카카오맵api에서 선택된 마커의 정보를 담는 hook
const [isClicked, setIsClicked] = useState("");
//카카오맵api에서 hovering된 마커의 정보를 담는 hook
const [isHovered, setIsHovered] = useState("");
//선택된 마커의 정보와 5개 지역의 마커의 모든 정보를 이용하여,
//차트에 사용하기 쉬운 형태로 가공
const [chartData, setChartData] = useState({
//마커 각각의 해시태그
hashtagsEach: [],
//마커 각각의 월세보증금의 최대,최소,평균,월세방의 개수를 담음
monthlyDepositEachAggregated: {},
//마커 각각의 월세금의 최대,최소,평균,월세방의 개수를 담음
monthlyPayEachAggregated: {},
//마커 각각의 전세보증금의 최대,최소,평균,전세방의 개수를 담음
reservDepositEachAggregated: {},
//마커 5개에 포함된 모든 해시태그
hashtagsTotal: [],
//마커 5개 전체의 월세보증금의 최대,최소,평균,월세방의 개수를 담음
monthlyDepositTotalAggregated: {},
//마커 5개 전체의 월세금의 최대,최소,평균,월세방의 개수를 담음
monthlyPayTotalAggregated: {},
//마커 5개 전체의 전세보증금의 최대,최소,평균,전세방의 개수를 담음
reservDepositTotalAggregated: {},
});
//RESULT mode의 두번째 article에서 BarChart의 차트 카테고리를 담음
//MONTHRESERV는 월세보증금을 보여주는 막대그래프를 보여줌
//MONTHPAY는 월세금을 보여주는 막대그래프를 보여줌
//RESERV는 전세보증금을 보여주는 막대그래프를 보여줌
const [isChecked, setIsChecked] = useState(MONTHRESERV);
//RESULT mode의 두번째 article의 화면 모드 담당
//ALL이면 3개 차트를 작게 셋 다 보여줌
//BAR이면 bar차트와 통계자료 노출
//PIE이면 pie차트와 매물 실제 위치 지도에 노출 및 대략적인 정보제공
//WORDCLOUD이면 워드클라우드 크게 표시
const [chartmode, setChartmode] = useState(ALL);
//PIE모드에서 모든 매물들의 실제 위치를 카카오맵api 지도에 표시하기 위해 필요한 위치배열.
const [positions, setPositions] = useState([]);
//레이더차트 라이브러리에 맞는 형태로 가공하는 함수
const getAggregated = () => {
//마커 레이더차트
let weight_names = ["거리", "역세권", "가성비", "안전", "매물"];
let newArr = [];
for (let i = 0; i < data.length; i++) {
let tempObj = {};
tempObj["weight"] = weight_names[i];
for (let j = 0; j < 5; j++) {
tempObj[`${j + 1}위`] = data[j][`T${i + 1}`];
}
tempObj[`평균`] = Math.round(data[0][`T${i + 1}_avg`]);
newArr.push(tempObj);
}
console.log(newArr);
setAggregated(newArr);
};
//chartData를 얻기위해 데이터를 가공하는 함수
const getChartAggregated = () => {
//해시태그 모음
const hashtagsEach = [];
const hashtagsTotalTemp = [];
const hashtagsTotal = [];
//월세보증금
const monthlyDepositEachAggregated = {
max: [],
min: [],
avg: [],
count: [],
};
const monthlyDepositTotalTemp = [];
const monthlyDepositTotalAggregated = {
max: [],
min: [],
avg: [],
count: [],
};
//월세
const monthlyPayEachAggregated = { max: [], min: [], avg: [], count: [] };
const monthlyPayTotalTemp = [];
const monthlyPayTotalAggregated = { max: [], min: [], avg: [], count: [] };
//전세
const reservDepositEachAggregated = {
max: [],
min: [],
avg: [],
count: [],
};
const reservDepositTotalTemp = [];
const reservDepositTotalAggregated = {
max: [],
min: [],
avg: [],
count: [],
};
for (let i = 0; i < data.length; i++) {
const hashtagsTemp = [];
const monthlyDepositTemp = [];
const monthlyPayTemp = [];
const reservDepositTemp = [];
const priceTemp = [];
for (let j = 0; j < data[i].rooms_hash_tags.length; j++) {
hashtagsTemp.push(data[i].rooms_hash_tags[j]);
}
for (let j = 0; j < data[i].rooms_price_title.length; j++) {
hashtagsTemp.push(data[i].rooms_desc[j].split("|")[0].trim());
hashtagsTemp.push(data[i].rooms_desc2[j].split(",")[0].trim());
//가격에 '억'이라는 글자를 단위 만 원으로 환산하기 위해, 10000을 곱해서 단위를 통일시켜주는 작업
if (data[i].rooms_selling_type[j] === 0) {
if (data[i].rooms_price_title[j].split("/")[0].includes("억")) {
monthlyDepositTemp.push(
Number(
data[i].rooms_price_title[j].split("/")[0].split("억")[0]
) *
10000 +
Number(
data[i].rooms_price_title[j].split("/")[0].split("억")[1]
)
);
} else {
monthlyDepositTemp.push(
Number(data[i].rooms_price_title[j].split("/")[0])
);
}
if (data[i].rooms_price_title[j].split("/")[1].includes("억")) {
monthlyPayTemp.push(
Number(
data[i].rooms_price_title[j].split("/")[1].split("억")[0]
) *
10000 +
Number(
data[i].rooms_price_title[j].split("/")[1].split("억")[1]
)
);
} else {
monthlyPayTemp.push(
Number(data[i].rooms_price_title[j].split("/")[1])
);
}
} else if (data[i].rooms_selling_type[j] === 1) {
if (data[i].rooms_price_title[j].includes("억")) {
reservDepositTemp.push(
Number(data[i].rooms_price_title[j].split("억")[0]) * 10000 +
Number(data[i].rooms_price_title[j].split("억")[1])
);
} else {
reservDepositTemp.push(Number(data[i].rooms_price_title[j]));
}
} else {
if (data[i].rooms_price_title[j].includes("억")) {
priceTemp.push(
Number(data[i].rooms_price_title[j].split("억")[0]) * 10000 +
Number(data[i].rooms_price_title[j].split("억")[1])
);
} else {
priceTemp.push(Number(data[i].rooms_price_title[j]));
}
}
}
hashtagsEach.push(hashtagsTemp);
//최대,최소,평균값과 개수를 구하는 코드
monthlyDepositEachAggregated.max.push(Math.max(...monthlyDepositTemp));
monthlyPayEachAggregated.max.push(Math.max(...monthlyPayTemp));
reservDepositEachAggregated.max.push(Math.max(...reservDepositTemp));
monthlyDepositEachAggregated.min.push(Math.min(...monthlyDepositTemp));
monthlyPayEachAggregated.min.push(Math.min(...monthlyPayTemp));
reservDepositEachAggregated.min.push(Math.min(...reservDepositTemp));
monthlyDepositEachAggregated.avg.push(
Math.round(
monthlyDepositTemp.reduce((a, b) => a + b, 0) /
monthlyDepositTemp.length
)
);
monthlyPayEachAggregated.avg.push(
Math.round(
monthlyPayTemp.reduce((a, b) => a + b, 0) / monthlyPayTemp.length
)
);
reservDepositEachAggregated.avg.push(
Math.round(
reservDepositTemp.reduce((a, b) => a + b, 0) /
reservDepositTemp.length
)
);
monthlyDepositEachAggregated.count.push(monthlyDepositTemp.length);
monthlyPayEachAggregated.count.push(monthlyPayTemp.length);
reservDepositEachAggregated.count.push(reservDepositTemp.length);
hashtagsTotalTemp.push(...hashtagsTemp);
monthlyDepositTotalTemp.push(...monthlyDepositTemp);
monthlyPayTotalTemp.push(...monthlyPayTemp);
reservDepositTotalTemp.push(...reservDepositTemp);
}
hashtagsTotal.push(...hashtagsTotalTemp);
monthlyDepositTotalAggregated.max.push(
Math.max(...monthlyDepositTotalTemp)
);
monthlyPayTotalAggregated.max.push(Math.max(...monthlyPayTotalTemp));
reservDepositTotalAggregated.max.push(Math.max(...reservDepositTotalTemp));
monthlyDepositTotalAggregated.min.push(
Math.min(...monthlyDepositTotalTemp)
);
monthlyPayTotalAggregated.min.push(Math.min(...monthlyPayTotalTemp));
reservDepositTotalAggregated.min.push(Math.min(...reservDepositTotalTemp));
monthlyDepositTotalAggregated.avg.push(
Math.round(
monthlyDepositTotalTemp.reduce((a, b) => a + b, 0) /
monthlyDepositTotalTemp.length
)
);
monthlyPayTotalAggregated.avg.push(
Math.round(
monthlyPayTotalTemp.reduce((a, b) => a + b, 0) /
monthlyPayTotalTemp.length
)
);
reservDepositTotalAggregated.avg.push(
Math.round(
reservDepositTotalTemp.reduce((a, b) => a + b, 0) /
reservDepositTotalTemp.length
)
);
monthlyDepositTotalAggregated.count.push(monthlyDepositTotalTemp.length);
monthlyPayTotalAggregated.count.push(monthlyPayTotalTemp.length);
reservDepositTotalAggregated.count.push(reservDepositTotalTemp.length);
setChartData({
hashtagsEach,
monthlyDepositEachAggregated,
monthlyPayEachAggregated,
reservDepositEachAggregated,
hashtagsTotal,
monthlyDepositTotalAggregated,
monthlyPayTotalAggregated,
reservDepositTotalAggregated,
});
console.log(chartData);
};
//RESULT mode에서 chartmode===BAR일 때, 전월세보증금의 통계자료를 보여주는 페이지에서, 가격을 명시하는 부분을 함수화함
const unitTransformer = (value) => {
return value >= 10000
? `${Math.floor(value / 10000)}억 ${
value % 10000 === 0 ? "(원)" : `${value % 10000}(만 원)`
}`
: `${value}(만 원)`;
};
//RESULT mode에서 chartmode===PIE일 때, 매물들의 실제 위치를 카카오맵api의 지도에 뿌려주기 위해 실제 방의 위치를 position hook에 저장함
const getPositions = () => {
console.log(isClicked.rooms_location_lat);
if (isClicked) {
let temp = [];
for (let i = 0; i < isClicked.rooms_location_lat.length; i++) {
temp.push({
latlng: new kakao.maps.LatLng(
isClicked.rooms_location_lat[i],
isClicked.rooms_location_lon[i]
),
});
}
setPositions(temp);
}
};
useEffect(() => {
//모든 Answer의 유효성 검사가 끝나고, 현재 모드가 FINISH라면 fetch함
if (
answers.Q1Answer !== "" &&
answers.Q2Answer !== "" &&
answers.Q3Answer !== "" &&
answers.Q4Answer !== "" &&
answers.Q5Answer !== "" &&
answers.univ_lat !== "" &&
answers.univ_lon !== "" &&
mode === FINISH
) {
console.log(answers);
Api.getResidence(answers).then((res) => {
console.log(res.data);
//백엔드에서 보낸 success가 true라면 성공이므로
if (res.data.success) {
//json으로 파싱하기 위해 작은따옴표를 큰따옴표로 바꿈(안바꾸면 에러생김)
let parsed = res.data.data.replaceAll("'", '"');
parsed = JSON.parse(parsed);
//(디버깅할 때 안지우고 남겨뒀던 코드)
console.log(parsed);
console.log(typeof parsed);
setData(parsed);
//로그 용도로 바로 추천 이력을 DB에 저장함
if (parsed) {
Api.saveResult(
answers.Q1Answer,
answers.univ_lat,
answers.univ_lon,
//정규화에 어긋나지만 일단 이렇게 저장함
`[${parsed[0].code},${parsed[1].code},${parsed[2].code},${parsed[3].code},${parsed[4].code}]`,
`[${parsed[0].T1},${parsed[0].T2},${parsed[0].T3},${parsed[0].T4},${parsed[0].T5}]`,
`[${parsed[1].T1},${parsed[1].T2},${parsed[1].T3},${parsed[1].T4},${parsed[1].T5}]`,
`[${parsed[2].T1},${parsed[2].T2},${parsed[2].T3},${parsed[2].T4},${parsed[2].T5}]`,
`[${parsed[3].T1},${parsed[3].T2},${parsed[3].T3},${parsed[3].T4},${parsed[3].T5}]`,
`[${parsed[4].T1},${parsed[4].T2},${parsed[4].T3},${parsed[4].T4},${parsed[4].T5}]`,
`[${Math.round(parsed[0].T1_avg)},${Math.round(
parsed[0].T2_avg
)},${Math.round(parsed[0].T3_avg)},${Math.round(
parsed[0].T4_avg
)},${Math.round(parsed[0].T5_avg)}]`
).then((res) => {
console.log(res.data);
});
}
} else {
//DB저장에 실패했다면 백엔드에서 보낸 에러메시지 alert로 노출
alert(res.data.err_msg);
}
});
}
//데이터가 있고, 아직 aggregated에 데이터가 할당되지 않았다면,
if (data.length !== 0 && aggregated.length === 0) {
getAggregated();
}
//데이터가 있고, 리랜더링 되었을 때,
if (data.length !== 0) {
getChartAggregated();
}
//데이터가 있고, 리랜더링 되었을 때,
if (data.length !== 0) {
getPositions();
}
console.log(isClicked);
//mode와 isHovered와 isClicked가 바뀔때마다 리랜더링됨.
}, [mode, isHovered, isClicked]);
return (
<RecommendationPresenter
mode={mode}
answers={answers}
setMode={setMode}
setAnswers={setAnswers}
schoolNameInputRef={schoolNameInputRef}
schoolNameInput={schoolNameInput}
data={data}
house={house}
setHouse={setHouse}
setCurrentAddress={setCurrentAddress}
setIsHovered={setIsHovered}
setIsClicked={setIsClicked}
aggregated={aggregated}
isHovered={isHovered}
isClicked={isClicked}
isChecked={isChecked}
chartData={chartData}
currentAddress={currentAddress}
chartmode={chartmode}
setChartmode={setChartmode}
setIsChecked={setIsChecked}
unitTransformer={unitTransformer}
positions={positions}
/>
);
};
export default RecommendationContainer;
RecommendationContainer.js 로직으로 처리된 props들을 스타일링해서 뿌려주는 Presenter이다.
모든 스타일컴포넌트는 StyledComponents.js에 export문으로 모아두었다.
import Intro from "../../components/recommendationMode/Intro";
import Q1Component from "../../components/recommendationMode/Q1";
import Q2Component from "../../components/recommendationMode/Q2";
import Q3Component from "../../components/recommendationMode/Q3";
import Q4Component from "../../components/recommendationMode/Q4";
import Q5Component from "../../components/recommendationMode/Q5";
import Finish from "../../components/recommendationMode/Finish";
import Result from "../../components/recommendationMode/Result";
import {
FINISH,
INTRO,
Q1,
Q2,
Q3,
Q4,
Q5,
RESULT,
} from "../../components/Enum";
import {
MainContainer,
SelectedContainer,
SelectedSpan,
} from "../../components/styles/StyledComponents";
import Helmet from "react-helmet";
const RecommendationPresenter = ({
mode,
answers,
setMode,
setAnswers,
schoolNameInputRef,
schoolNameInput,
data,
house,
setHouse,
setCurrentAddress,
setIsHovered,
setIsClicked,
aggregated,
isHovered,
isClicked,
isChecked,
chartData,
currentAddress,
chartmode,
setChartmode,
setIsChecked,
unitTransformer,
positions,
}) => {
return (
<>
<Helmet>
<title>Unibangcity | Recommendation</title>
</Helmet>
<MainContainer mode={mode}>
{mode !== RESULT && (
<SelectedContainer>
<SelectedSpan>{answers.Q1Answer}</SelectedSpan>+
<SelectedSpan>
{answers.Q2Answer ? `${answers.Q2Answer}m` : ``}
</SelectedSpan>
+<SelectedSpan>{answers.Q3Answer_kr}</SelectedSpan>+
<SelectedSpan>{answers.Q4Answer_kr}</SelectedSpan>+
<SelectedSpan>{answers.Q5Answer_kr}</SelectedSpan>
</SelectedContainer>
)}
{mode === INTRO && <Intro setMode={setMode} />}
{mode === Q1 && (
<Q1Component
setMode={setMode}
answers={answers}
setAnswers={setAnswers}
schoolNameInputRef={schoolNameInputRef}
schoolNameInput={schoolNameInput}
/>
)}
{mode === Q2 && (
<Q2Component
answers={answers}
setAnswers={setAnswers}
setMode={setMode}
/>
)}
{mode === Q3 && (
<Q3Component
answers={answers}
setAnswers={setAnswers}
setMode={setMode}
/>
)}
{mode === Q4 && (
<Q4Component
answers={answers}
setAnswers={setAnswers}
setMode={setMode}
/>
)}
{mode === Q5 && (
<Q5Component
answers={answers}
setAnswers={setAnswers}
setMode={setMode}
/>
)}
{mode === FINISH && <Finish setMode={setMode} />}
{mode === RESULT && (
<Result
answers={answers}
data={data}
house={house}
setHouse={setHouse}
setCurrentAddress={setCurrentAddress}
setIsHovered={setIsHovered}
setIsClicked={setIsClicked}
aggregated={aggregated}
isHovered={isHovered}
isClicked={isClicked}
isChecked={isChecked}
chartData={chartData}
currentAddress={currentAddress}
chartmode={chartmode}
setChartmode={setChartmode}
setIsChecked={setIsChecked}
unitTransformer={unitTransformer}
positions={positions}
/>
)}
</MainContainer>
</>
);
};
export default RecommendationPresenter;