PM Project Day 02

thisisyjin·2022년 5월 28일
0

Dev Log 🐥

목록 보기
19/23

PM (Put on a Mask) Project

  • PM = 미세먼지 를 의미한다. 동시에 이 프로젝트의 이름인 Put on a Mask를 의미!

📌 기획안 보기
🎨 디자인 보기


📝 Day 02 - 220527

  • useState, useEffect, useRef
  • Axios 비동기 요청 (API)
  • SeoulMap, CitySelect 데이터 연동
  • 프로젝트 ver. 1 - 완성

Hooks 사용

CitySelect

import styled from 'styled-components';
import colors from '../lib/styles/colors';
import { useEffect, useState } from 'react';
import { ReactComponent as GeoICO } from '../assets/geo.svg';
import { Link, useParams } from 'react-router-dom';
import { cities } from '../lib/cities/cityName';

const CitySelectBlock = styled.div`
  height: 50px;
  width: 100%;
  display: flex;
`;

const GeoWrapper = styled.div`
  display: flex;
  margin-right: 80px;
  align-items: center;

  svg {
    width: 20px;
    fill: ${colors.gray[2]};
    margin-right: 8px;
  }

  h3 {
    color: ${colors.gray[2]};
    font-weight: 400;
    font-size: 24px;
    letter-spacing: 0.02em;
  }
`;

const Select = styled.select`
  cursor: pointer;
  width: 180px;
  height: 50px;
  border-radius: 8px;
  font-size: 18px;
  text-align: center;
  border: none;
  border-bottom: 2px solid ${colors.gray[2]};
  margin-right: 20px;

  &:active,
  &:hover {
    background-color: ${colors.gray[0]};
  }
`;

const StyledForm = styled.form`
  display: flex;
`;

const StyledLink = styled(Link)`
  cursor: pointer;
  background-color: ${colors.green[2]};
  color: #fff;
  padding: 10px 20px;
  border-radius: 10px;
  font-size: 17px;

  &:hover {
    background-color: ${colors.green[1]};
  }
`;

const CitySelect = () => {
  const { city } = useParams();
  console.log(cities[city]);
  const [select, setSelect] = useState(0);

  const onChangeSelect = (e) => {
    setSelect(e.target.value);
  };

  useEffect(() => {
    setSelect(city);
  }, [city]);

  return (
    <CitySelectBlock>
      <GeoWrapper>
        <GeoICO />
        <h3>Seoul</h3>
      </GeoWrapper>
      <StyledForm>
        <Select className="hello" value={select} onChange={onChangeSelect}>
          {cities.map((city, i) => (
            <option key={city} value={i}>
              {city}
            </option>
          ))}
        </Select>
        <StyledLink to={`/pm/${select}`}>조회</StyledLink>
      </StyledForm>
    </CitySelectBlock>
  );
};

export default CitySelect;
  • useState로 현재 select의 value를 관리한다.
    -> onChange 일때마다 setSelect 되도록.

  • params를 불러와 해당 도시가 바뀐 경우에 select 값을 params로 바꿔줌.
    -> useEffect를 이용함. 첫 렌더링시 + params 업데이트시

LevelInfo

import styled from 'styled-components';
import { ReactComponent as PmLevelICO } from '../assets/pmLevel.svg';
import colors from '../lib/styles/colors';

const LevelInfoBlock = styled.div`
  width: 400px;
  border-radius: 15px;
  border: 2.5px solid #000;
  text-align: center;
  margin-bottom: 20px;

  .level-title {
    padding: 8px 0;
    font-size: 20px;
    border-bottom: 2.5px solid #000;
    margin-bottom: 14px;
  }
`;

const LevelIcons = styled.div`
  width: 260px;
  height: 150px;
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  align-items: center;
  margin: 0 auto;
  margin-bottom: 14px;
  position: relative;

  svg {
    width: 40px;
    cursor: pointer;
    transition: all 0.2s ease-in-out;

    &:hover {
      transform: scale(1.1);
      + .level-detail {
        display: block;
      }
    }
  }

  .level-detail {
    position: absolute;
    display: none;
    background-color: ${colors.trans[0]};
    color: #fff;
    font-size: 14px;
    padding: 8px 18px;
    border-radius: 5px;

    .pm-title {
      font-size: 15px;
      font-weight: 500;
      margin-right: 9px;
    }
  }

  .very-bad {
    stroke: ${colors.level[0]};
  }
  .bad {
    stroke: ${colors.level[1]};
  }
  .soso {
    stroke: ${colors.level[2]};
  }

  .good {
    stroke: ${colors.level[3]};
  }

  .desc {
    width: 60px;
    font-size: 15px;
    letter-spacing: -0.03em;
    margin-right: 10px;
    margin-left: 6px;
    text-align: left;
    font-weight: 500;
  }
`;

const LevelInfo = () => {
  return (
    <LevelInfoBlock>
      <h3 className="level-title">미세먼지 Level</h3>
      <LevelIcons>
        <PmLevelICO className="very-bad" />
        <div className="level-detail">
          <span className="pm-title">PM 10</span>151~
          <br />
          <span className="pm-title">PM 2.5</span>76~
        </div>
        <span className="desc">매우 나쁨</span>
        <PmLevelICO className="bad" />
        <div className="level-detail">
          <span className="pm-title">PM 10</span>81~150
          <br />
          <span className="pm-title">PM 2.5</span>36~75
        </div>
        <span className="desc">나쁨</span>
        <PmLevelICO className="soso" />
        <div className="level-detail">
          <span className="pm-title">PM 10</span>31~80
          <br />
          <span className="pm-title">PM 2.5</span>16~35
        </div>
        <span className="desc">보통</span>
        <PmLevelICO className="good" />
        <div className="level-detail">
          <span className="pm-title">PM 10</span>0~30
          <br />
          <span className="pm-title">PM 2.5</span>0~15
        </div>
        <span className="desc">좋음</span>
      </LevelIcons>
    </LevelInfoBlock>
  );
};

export default LevelInfo;
  • Hooks를 추가하진 않았지만, 디자인을 수정해줌.
  • 각 level별로 pm10과 pm2.5의 기준치를 알 수 있게 해줌.

ModalButton

import styled from 'styled-components';
import colors from '../lib/styles/colors';
import { ReactComponent as EarthICO } from '../assets/earth.svg';
import Modal from './Modal';
import { useState } from 'react';

const ModalButtonBlock = styled.div`
  margin-top: 16px;
`;

const StyledButton = styled.button`
  display: flex;
  justify-content: center;
  align-items: center;
  width: 400px;
  border-radius: 10px;
  padding: 8px 0;
  text-align: canter;
  background-color: ${colors.green[2]};
  color: #fff;
  font-size: 20px;
  font-weight: 500;
  letter-spacing: 0.03em;
  cursor: pointer;
  svg {
    width: 26px;
    margin-left: 10px;
  }
  &:hover {
    background-color: ${colors.green[1]};
  }
`;

const ModalButton = () => {
  const [visible, setVisible] = useState(false);

  const onOpenModal = () => {
    setVisible(true);
  };

  const onCloseModal = () => {
    setVisible(false);
  };
  return (
    <ModalButtonBlock>
      <StyledButton onClick={onOpenModal}>
        For Earth
        <EarthICO />
      </StyledButton>
      <Modal
        visible={visible}
        randomIndex={Math.floor(Math.random() * 6)}
        onCloseModal={onCloseModal}
      />
    </ModalButtonBlock>
  );
};

export default ModalButton;
  • 모달을 열고 닫을 수 있게 visible이라는 state를 두어 전달해준다.
  • setVisible(true/false)의 역할을 하는 onOpenModal, onCloseModal을 Modal 컴포넌트로 전달해준다.

PmDetailImage

import { useEffect, useRef } from 'react';
import styled from 'styled-components';
import PmLevelICO from '../assets/pmLevel.svg';

import verybad from '../assets/photos/sky1.jpg';
import bad from '../assets/photos/sky2.jpg';
import soso from '../assets/photos/sky3.jpg';
import good from '../assets/photos/sky4.jpg';

const PmDetailImageBlock = styled.div`
  position: relative;
  width: 340px;
  height: 230px;
  overflow: hidden;
  margin: 0 auto;
  margin-bottom: 16px;

  /* &::after {
    content: '';
    display: block;
    position: absolute;
    width: 30px;
    height: 30px;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    background-image: url(${PmLevelICO});
    background-position: center center;
    background-size: contain;
    background-repeat: no-repeat;
  } */
`;

const PmDetailImage = ({ level }) => {
  const imgPath = useRef(null);

  useEffect(() => {
    console.log(level, imgPath);
    switch (level) {
      case '매우 나쁨':
        imgPath.current = verybad;
        return;
      case '나쁨':
        imgPath.current = bad;
        return;
      case '보통':
        imgPath.current = soso;
        return;
      case '좋음':
        imgPath.current = good;
        return;
      default:
        imgPath.current = soso;
    }
  }, [level]);

  return (
    <PmDetailImageBlock>
      {imgPath.current && (
        <img src={imgPath.current} alt="sky" className="sky-img" />
      )}
    </PmDetailImageBlock>
  );
};

export default PmDetailImage;
  • useRef 로 이미지 경로를 저장할 것임. (imgPath.current)
    -> img태그의 src속성으로 넣어줌.

  • PmDetail 에서 props로 보내준 level에 따라 이미지가 달라지도록 함.
    (switch문)


Axios 비동기 요청

PmDetail

import { useParams } from 'react-router-dom';
import styled from 'styled-components';
import PmDetailImage from './PmDetailImage';
import PmDetailText from './PmDetailText';
import axios from 'axios';
import { useEffect, useState } from 'react';
import colors from '../lib/styles/colors';

const PmDetailBlock = styled.div`
  width: 400px;
  min-height: 425px;
  border-radius: 15px;
  border: 2.5px solid #000;
  text-align: center;

  .detail-title {
    padding: 8px 0;
    font-size: 20px;
    border-bottom: 2.5px solid #000;
    margin-bottom: 14px;
  }

  .error-msg {
    padding: 150px 0;
    font-size: 22px;

    .error-point {
      background-color: #ffea2d;
      padding: 0 8px;
      font-size: 24px;
      font-weight: 700;
    }
    b {
      display: inline-block;
      background-color: ${colors.green[2]};
      color: #fff;
      padding: 2px 8px;
      border-radius: 6px;
    }
  }
`;

const PmDetail = () => {
  const [level, setLevel] = useState(null);
  const [pm10, setPm10] = useState(0);
  const [pm25, setPm25] = useState(0);

  const { city } = useParams();
  // '좋음', '보통', '나쁨', '매우나쁨' - API 데이터에서 가져오기
  // response.RealtimeCityAir.row[도시인덱스].IDEX_NM

  useEffect(() => {
    if (city === 'undefined') return;
    axios
      .get(
        'http://openapi.seoul.go.kr:8088/6d4d776b466c656533356a4b4b5872/json/RealtimeCityAir/1/99'
      )
      .then((response) => {
        setLevel(response.data.RealtimeCityAir.row[city].IDEX_NM);
        setPm10(response.data.RealtimeCityAir.row[city].PM10);
        setPm25(response.data.RealtimeCityAir.row[city].PM25);
      });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [city]);

  return (
    <PmDetailBlock>
      {city !== 'undefined' ? (
        <>
          <h3 className="detail-title">Info</h3>
          <PmDetailImage level={level} />
          <PmDetailText level={level} city={city} pm10={pm10} pm25={pm25} />
        </>
      ) : (
        <>
          <h3 className="detail-title">Info</h3>
          <p className="error-msg">
            <span className="error-point">지역을 선택</span>한 후, <b>조회</b>를
            눌러주세요.
          </p>
        </>
      )}
    </PmDetailBlock>
  );
};

export default PmDetail;
  • useParams 로 현재 어떤 구인지 city 데이터를 가져온 후,
    city가 'undefined' 라면 axios 요청을 하지 않도록 함.
    -> UseEffect에서 필터링 한번 해줌.

  • axios.get(url).then( res => setState(res.data.~~)) 를 해줌

❗️ 추후 에러 로그를 따로 업로드 하겠지만,
https 환경에서 http를 요청하여 mixed content 에러가 발생한다.
-> 백엔드도 구현하여 API를 백엔드 단에서 Http 요청을 하고, 미들웨어를 사용해야할 것 같다.
-> ver 2 에서 redux 구현하면서 백엔드도 같이 해볼 예정임. (RESTapi)

✅ 참고로, localhost 환경에서는 mixed content 에러가 발생하지 않지만,
배포시 Https 로 배포하기 때문에 에러가 발생함.


SeoulMap 변경

  • useParams로 받아온 city 정보를 토대로 현재 어느 지역인지 표시함.
import styled from 'styled-components';
import map from '../assets/map.png';
import colors from '../lib/styles/colors';
import { mapPosition } from '../lib/cities/cityCord';
import { useParams } from 'react-router-dom';

const SeoulMapBlock = styled.div`
  display: flex;
  align-items: center;
  .map-wrap {
    position: relative;
  }

  .city-area-${(props) => props.current} {
    &::after {
      content: '';
      display: block;
      width: 40px;
      height: 40px;
      background-color: ${colors.level[0]};
      border-radius: 50%;
      position: absolute;
      top: 50%;
      left: 50%;
      transform: translate(-50%, -50%);
    }
  }
`;

const CityArea = styled.div`
  cursor: pointer;
  position: absolute;
  top: ${(props) => props.top}px;
  left: ${(props) => props.left}px;
  width: ${(props) => props.width}px;
  height: ${(props) => props.height}px;

  &::after {
    content: '';
    display: block;
    width: 25px;
    height: 25px;
    background-color: ${colors.gray[2]};
    border-radius: 50%;
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    transition: all 0.2s ease-in-out;
  }

  &:hover {
    &::after {
      width: 30px;
      height: 30px;
    }
  }
`;

const SeoulImg = styled.img`
  display: block;
  width: 700px;
  height: 700px;
  position: relative;
`;

const SeoulMap = () => {
  const { city } = useParams();
  console.log(city);

  return (
    <SeoulMapBlock current={city}>
      <div className="map-wrap">
        <SeoulImg src={map} alt="seoul map" />
        {mapPosition.map((p) => (
          <CityArea
            key={p.index}
            top={p.top}
            left={p.left}
            width={p.width}
            height={p.height}
            city={p.city}
            className={`city-area-${p.index}`}
          />
        ))}
      </div>
    </SeoulMapBlock>
  );
};

export default SeoulMap;
  • styled-components의 props를 이용한 스타일링. (top, left, width, height)
  • CityArea 컴포넌트에 클래스를 부여함. (인덱스, 즉 도시번호 이용)
{
.city-area-${(props) => props.current} {
    &::after {
      content: '';
      display: block;
      width: 40px;
      height: 40px;
      background-color: ${colors.level[0]};
      border-radius: 50%;
      position: absolute;
      top: 50%;
      left: 50%;
      transform: translate(-50%, -50%);
    }
  }
}

RESULT




Preview

1.

  • select가 바뀔 때 마다 주소 (params)가 바뀌고, 지도에 나타난 표식도 달라짐.
  • Info의 레벨 부분에 Hover하면, PM10, PM2.5가 나온다. (지역에 따라 실시간으로)
  • 미세먼지 Level 아이콘에 Hover 하면, 해당 기준치가 나옴.

2.

  • 매번 랜덤 문구가 나오는 모달창. (+modalButton)
profile
기억은 한계가 있지만, 기록은 한계가 없다.

0개의 댓글