세모세 프로젝트_crunch time

jkky98·2023년 11월 28일
0

Project

목록 보기
9/21

생각대로 추가하는 코딩이 결국 풀스택 프로젝트에서 한계에 도달했다. 기능을 붙이면 붙일수록 데이터 전달이 꼬이는 것을 느끼고, UI디자인의 한계도 느껴서 통신부분과 디자인을 다시 설계했다. 결국은 Vuetify다. 분명 부트스트랩도 지나간 유행이라고 말해주셨던 것을 결국 몸으로 느끼고야 바꾸었다.

진정한 SPA를 구현하기 위해 여러 강의를 들었고 가장 좋은 강의를 소개할까 한다.
https://www.youtube.com/watch?v=m2hjzOOxCG8&list=PLlaP-jSd-nK91TqXFJQ7PVX5pOKoOA9v3

새롭게 UI컴포넌트 구조를 짜면서 다음과 같은 라우터구조로 재설계 했다.

const routes = [
  {
    path: "/",
    component: DefaultLayout,
    children: [
      {
        path: "/",
        name: "DashBoard",
        component: DashBoard,
        meta: {
          title: "세권 조건 선택",
          dynamicTitle: "세권 조건 선택",
        },
      },
      {
        path: "/map",
        name: "MapComponent",
        component: MapComponent,
        meta: {
          title: "세권 지도",
          dynamicTitle: "세권 지도",
        },
      },
      {
        path: "/info",
        name: "InfoComponent",
        component: InfoComponent,
        meta: {
          title: "세권 상세정보",
          dynamicTitle: "세권 상세정보",
        },
      },
    ],
  },
];

디폴트 레이아웃을 구성하고 그 아래에 children으로 각각의 컴포넌트를 붙였다.

왼쪽 탭과 상단바가 디폴트에 해당하며, 상단바 왼쪽의 메뉴부분을 누르면 왼쪽 탭이 접히는 구조로 만들었다. 이런 간단한 기능도 구현하는 것에 시간이 꽤 걸렸다. 정말 힘든 것중 하나가 "최대한 기능 구현에 초점"을 맞추라고 해서 디자인을 후순위로 두었는데, 디자인은 정말 중요하다. 다들 본질의 중요성을 외치지만 결국 끌리는 것은 예쁨인 것이다. 뭐 나에게는 백엔드에 UI디자인에 프론트엔드 다 해볼 좋은 기회다하고 시간을 갈면 그만이다. 징징대는 것을 그만하고, 디폴트 레이아웃은 다음과 같다.

<template>
  <v-app>
    <v-app-bar app color="primary" dark>
      <v-app-bar-nav-icon @click="drawer = !drawer"></v-app-bar-nav-icon>
      <v-toolbar-title>
        {{ $route.meta.dynamicTitle }}
      </v-toolbar-title>
      <v-spacer></v-spacer>
    </v-app-bar>
    <v-navigation-drawer
      v-model="drawer"
      app
      src="https://cdn.vuetifyjs.com/images/backgrounds/bg-2.jpg"
    >
      <v-list-item>
        <v-list-item-content>
          <v-list-item-title class="text-h3 white--text">
            SEMOSE
          </v-list-item-title>
          <v-list-item-subtitle class="white--text">
            Capstone Project
          </v-list-item-subtitle>
        </v-list-item-content>
      </v-list-item>

      <v-divider></v-divider>

      <v-list dense nav>
        <v-list-item
          v-for="(item, index) in items"
          :key="index"
          link
          :to="item.to"
        >
          <v-list-item-icon>
            <v-icon class="white--text">{{ item.icon }}</v-icon>
          </v-list-item-icon>

          <v-list-item-content>
            <v-list-item-title class="white--text">{{
              item.title
            }}</v-list-item-title>
          </v-list-item-content>
        </v-list-item>
      </v-list>
      <template v-slot:append>
        <div class="pa-2">
          <v-btn :color="isDataAvailable ? 'success' : 'error'" block>
            {{ isDataAvailable ? "조건 선택 완료" : "조건 선택 필요" }}
          </v-btn>
        </div>
      </template>
    </v-navigation-drawer>
    <v-main>
      <v-container fluid>
        <router-view />
      </v-container>
    </v-main>
  </v-app>
</template>

<script>
export default {
  name: "DefaultLayout",
  data: () => ({
    drawer: false,
    gradient: "rgba(0, 0, 0, .7), rgba(0, 0, 0, .7)",
    items: [
      {
        title: "세권 조건 선택",
        icon: "mdi-account-multiple-plus",
        to: "/",
        dynamicTitle: "세권 조건 선택",
      },
      {
        title: "세권 지도",
        icon: "mdi-map-marker-distance",
        to: "/map",
        dynamicTitle: "세권 지도",
      },
      {
        title: "세권 상세정보",
        icon: "mdi-view-dashboard",
        to: "/info",
        dynamicTitle: "세권 상세정보",
      },
    ],
  }),
  computed: {
    isDataAvailable() {
      // Vuex에서 address와 zickbang_point 데이터가 있는지 확인하는 computed 속성
      const { address, zickbang_point } = this.$store.state;
      return !!address || (zickbang_point && zickbang_point.length > 0);
    },
  },
};
</script>

<style></style>

아래 computed로 만든 것은 클라이언트 입장을 생각해서 만들었다. 기획자야 프로그램의 목적을 알기에 실행 방법이 당연하지만 처음 서비스를 마주치는 사람들에게는 그렇지 않을테니까. 사실 저것도 부족하다. 안내에 해당하는 디자인을 더 만들고 충족시키지 못했다면 해야할 플레이를 하도록 여러 시나리오를 더 짜야하지만 이것도 여전히 후순위다.(한 달만 더 주세요.)

가장 중요한 대시보드 페이지의 코드는 다음과 같다.

<template>
  <div>
    <v-container fluid>
      <v-row>
        <v-col cols="6">
          <!-- 왼쪽 영역에 주소 정보를 감싸는 박스 -->
          <v-card class="pa-3">
            <v-row>
              <v-col>
                <!-- 주소 정보 -->
                <v-card>
                  <v-card-title class="text-h6">
                    <span v-if="address">{{ address }}</span>
                    <span v-else-if="zickbang_point.length">{{
                      zickbang_point
                    }}</span>
                    <span v-else>Enter your address</span>

                    <div>
                      <span
                        v-if="address || zickbang_point.length"
                        class="sub-title-message"
                      >
                        {{
                          address
                            ? "주소가 입력되었습니다."
                            : "zickbang_point가 입력되었습니다."
                        }}
                      </span>
                    </div>
                  </v-card-title>
                  <v-card-text :value="address">
                    <v-btn
                      block
                      :color="buttonClicked ? 'success' : ''"
                      @click="execDaumPostcode"
                      >주소검색</v-btn
                    >
                  </v-card-text>
                </v-card>
              </v-col>
            </v-row>
            <!-- 추가 행 -->
            <v-row>
              <v-col>
                <v-card color="basil">
                  <v-card-title class="text-center justify-center py-6">
                    <h1 class="font-weight-bold text-h2 basil--text">Store</h1>
                  </v-card-title>

                  <v-tabs
                    v-model="tab"
                    background-color="transparent"
                    color="basil"
                    grow
                  >
                    <v-tab v-for="item in items" :key="item.id">
                      {{ item }}
                    </v-tab>
                  </v-tabs>

                  <v-tabs-items v-model="tab">
                    <v-tab-item v-for="item in storeList" :key="item.id">
                      <v-card color="basil" flat>
                        <v-card-text>
                          <v-btn
                            v-for="(btn, index) in item"
                            :key="index"
                            :color="isSelected(btn) ? 'info' : ''"
                            @click="handleButtonClick(btn)"
                          >
                            {{ btn }}
                          </v-btn>
                        </v-card-text>
                      </v-card>
                    </v-tab-item>
                  </v-tabs-items>
                </v-card>
              </v-col>
            </v-row>
            <!-- 추가 행 -->
            <v-row>
              <v-col>
                <v-card>
                  <v-card-title class="text-h6">선택한 Store</v-card-title>
                  <v-card-text>
                    <div>
                      <v-btn v-for="(button, index) in buttons" :key="index">
                        {{ button }}
                      </v-btn>
                    </div>
                  </v-card-text>
                </v-card>
              </v-col>
            </v-row>
            <!-- 추가 행 -->
          </v-card>
          <v-footer dark padless>
            <v-card flat tile class="indigo lighten-1 white--text text-center">
              <v-card-text>
                <v-btn
                  v-for="icon in icons"
                  :key="icon"
                  class="mx-4 white--text"
                  icon
                >
                  <v-icon size="24px">
                    {{ icon }}
                  </v-icon>
                </v-btn>
              </v-card-text>

              <v-card-text class="white--text pt-0">
                Phasellus feugiat arcu sapien, et iaculis ipsum elementum sit
                amet. Mauris cursus commodo interdum. Praesent ut risus eget
                metus luctus accumsan id ultrices nunc. Sed at orci sed massa
                consectetur dignissim a sit amet dui. Duis commodo vitae velit
                et faucibus. Morbi vehicula lacinia malesuada. Nulla placerat
                augue vel ipsum ultrices, cursus iaculis dui sollicitudin.
                Vestibulum eu ipsum vel diam elementum tempor vel ut orci. Orci
                varius natoque penatibus et magnis dis parturient montes,
                nascetur ridiculus mus.
              </v-card-text>

              <v-divider></v-divider>

              <v-card-text class="white--text">
                {{ new Date().getFullYear() }} — <strong>Vuetify</strong>
              </v-card-text>
            </v-card>
          </v-footer>
        </v-col>
        <v-col cols="6">
          <!-- 오른쪽 영역 -->
          <v-card class="pa-3" v-if="success_zicbang">
            <v-row>
              <v-col
                v-for="(item, index) in visibleItems"
                :key="index"
                cols="12"
                md="6"
                lg="4"
              >
                <v-card>
                  <v-img
                    height="250"
                    :src="item.images_thumbnail + '?h=300'"
                  ></v-img>

                  <v-card-title class="title">
                    {{ item.title }}
                  </v-card-title>
                  <v-card-subtitle class="subtitle">
                    아주대로 부터 {{ item.distance.toFixed(1) }} 분 거리에
                    있습니다.
                  </v-card-subtitle>
                  <v-card-text>
                    <div>면적(평): {{ item.area_p }}평</div>
                    <div>층: {{ item.floor }}층</div>
                    <div>보증금(전,월세): {{ item.deposit }}만원</div>
                    <div>관리비 : {{ item.manage_cost }}만원</div>
                    <div>월세: {{ item.rent }}</div>
                    <div>전/월세: {{ item.sales_type }}</div>
                    <div>등록시간: {{ formatToLocalTime(item.reg_date) }}</div>
                    <v-btn
                      block
                      :href="
                        'https://www.zigbang.com/home/oneroom/items/' +
                        item.item_id
                      "
                      target="_blank"
                      >직방에서 보기</v-btn
                    >
                    <v-btn
                      block
                      @click="settingaddress(item.lat, item.lng, item.title)"
                      >이 매물 주소에 적용
                    </v-btn>
                  </v-card-text>
                </v-card>
              </v-col>
            </v-row>
            <v-pagination
              v-model="currentPage"
              :length="totalPages"
            ></v-pagination>
          </v-card>
        </v-col>
      </v-row>
    </v-container>
  </div>
</template>

<script>
import { mapState } from "vuex";
import axios from "axios";

export default {
  computed: {
    ...mapState({
      address: (state) => state.address,
      buttons: (state) => state.buttons,
      zickbang_point: (state) => state.zickbang_point,
    }),
    visibleItems() {
      if (!this.success_zicbang) return []; // 데이터가 없는 경우 빈 배열 반환
      const startIndex = (this.currentPage - 1) * this.itemsPerPage;
      const endIndex = startIndex + this.itemsPerPage;

      return this.zicbang_source.slice(startIndex, endIndex);
    },
    totalPages() {
      if (!this.zicbang_source) return 0; // 데이터가 없는 경우 0 반환

      return Math.ceil(this.zicbang_source.length / this.itemsPerPage);
    },
  },
  async mounted() {
    try {
      if (this.buttons.length === 0) {
        this.$store.commit("SET_DEFAULT_BUTTONS", this.selectedButtons);
      }

      const response = await axios.get("http://localhost:8000/api/zicbang");
      const dataArray = JSON.parse(response.data.data);

      this.zicbang_source = dataArray;
      this.success_zicbang = true;
      console.log("axios success");
    } catch (error) {
      console.error("Error fetching data", error);
    }
  },

  data() {
    return {
      currentPage: 1,
      itemsPerPage: 6,
      zicbang_source: [],
      success_zicbang: false,
      choose_zickbang: [{ lat: null, lng: null }],
      icons: ["mdi-facebook", "mdi-twitter", "mdi-linkedin", "mdi-instagram"],
      buttonClicked: false,
      selectedButtons: [
        "편의점",
        "카페",
        "약국",
        "세탁소",
        "대형마켓",
        "아주대학교",
      ],
      tab: null,
      items: ["중요", "편의시설", "문화시설", "프랜차이즈", "카페"],
      storeList: {
        중요: ["편의점", "카페", "약국", "세탁소", "대형마켓", "아주대학교"],
        편의시설: ["인쇄", "PC방", "내과", "한의원", "우체국", "올리브영"],
        문화시설: ["영화관", "헬스장", "필라테스", "코인노래방", "공원"],
        프랜차이즈: [
          "맥도날드",
          "롯데리아",
          "버거킹",
          "베스킨라빈스",
          "파리바게뜨",
        ],
        카페: [
          "스타벅스",
          "메가커피",
          "컴포즈커피",
          "빽다방",
          "이디야커피",
          "투썸플레이스",
        ],
      },
      // daum post code data //
      postcode: "",
      dataAddress: "",
      extraAddress: "",
      // daum post code data //
    };
  },
  methods: {
    settingaddress(lat, lng, title) {
      // Vuex store에 값을 저장합니다.
      this.$set(this.choose_zickbang, 0, { lat, lng, title });
      this.$store.commit("SET_ZICKBANG_POINT", this.choose_zickbang);
      this.$store.dispatch("clearAddress");
    },
    formatToLocalTime(dateTimeString) {
      const dateObj = new Date(dateTimeString);
      return dateObj.toLocaleString(); // 현재 브라우저의 로컬 시간으로 변환하여 반환
    },
    executeFunction() {
      // 실행하고자 하는 기능을 여기에 작성
      console.log("주소 검색 버튼이 클릭되었습니다.");
      // 버튼 클릭 시 색 변경
      this.buttonClicked = !this.buttonClicked;
      // 기능 실행 로직을 추가하세요
    },
    handleButtonClick(storeName) {
      const index = this.buttons.indexOf(storeName);
      if (index !== -1) {
        // 이미 선택된 버튼인 경우 배열에서 제거하여 비활성화
        this.buttons.splice(index, 1);
      } else {
        // 선택되지 않은 경우 배열에 추가하여 활성화
        this.buttons.push(storeName);
      }
    },
    isSelected(storeName) {
      return this.buttons.includes(storeName);
    },
    // Daum 주소 검색기
    execDaumPostcode() {
      new window.daum.Postcode({
        oncomplete: (data) => {
          if (this.extraAddress !== "") {
            this.extraAddress = "";
          }
          if (data.userSelectedType === "R") {
            // 사용자가 도로명 주소를 선택했을 경우
            this.dataAddress = data.roadAddress;
          } else {
            // 사용자가 지번 주소를 선택했을 경우(J)
            this.dataAddress = data.jibunAddress;
          }

          // 사용자가 선택한 주소가 도로명 타입일때 참고항목을 조합한다.
          if (data.userSelectedType === "R") {
            // 법정동명이 있을 경우 추가한다. (법정리는 제외)
            // 법정동의 경우 마지막 문자가 "동/로/가"로 끝난다.
            if (data.bname !== "" && /[동|로|가]$/g.test(data.bname)) {
              this.extraAddress += data.bname;
            }
            // 건물명이 있고, 공동주택일 경우 추가한다.
            if (data.buildingName !== "" && data.apartment === "Y") {
              this.extraAddress +=
                this.extraAddress !== ""
                  ? `, ${data.buildingName}`
                  : data.buildingName;
            }
            // 표시할 참고항목이 있을 경우, 괄호까지 추가한 최종 문자열을 만든다.
            if (this.extraAddress !== "") {
              this.extraAddress = `(${this.extraAddress})`;
            }
          } else {
            this.extraAddress = "";
          }
          // 우편번호를 입력한다.
          this.postcode = data.zonecode;
          // Vuex 액션 호출하여 주소 저장
          this.$store.dispatch("saveAddress", this.dataAddress);
          this.$store.dispatch("clearZickbangPoint");
        },
      }).open();
    },
  },
};
</script>
<style scoped>
/* Helper classes */
.basil {
  background-color: white !important;
}
.basil--text {
  color: #010302 !important;
}
.sub-title-message {
  font-size: 0.8em; /* 작은 글씨 */
  color: #67c23a; /* 밝은 초록색 */
  display: block; /* 한 줄로 표시되도록 변경 */
  margin-top: 4px; /* 위쪽 여백 추가 */
}
</style>

이전에 만들었던 다음주소 검색기를 그대로 이용했고, 특징은 더이상 데이터관리를 props와 data로 하지않는다는 것이다. Vuex라는 아주 좋은 기능이 있다는 것을 이제야 안 것이 너무 안타까운 현실이다. Vuex로 데이터를 업데이트하고 원할때마다 컴포넌트에서 state로 뽑아쓸 수 있는 것을 첫 프로젝트 만들때 활성화 했음에도 불구하고 이게뭐야 하고 지나친 것이 참 바보같다.(뭔지라도 알아보던가)

추가적으로 백엔드에서 직방 데이터베이스를 크롤링해서 구축하고(요청때마다 이 정보는 업데이트 된다.) 직방매물들을 보여줌과 동시에 주소적용과, 해당 직방 매물을 직방 어플리케이션에서 확인할 수 있는 버튼들을 넣었다. 직방매물을 주소적용 시키면 기존에 다음검색기로 검색한 데이터는 사라진다. 요청은 mounted를 이용해 페이지가 열리자마자 백엔드에 axios POST를 시켜 바로 화면단에 띄우게 된다.(이 과정이 속도가 약간 느린데.... 이 해결 역시 후순위지만 언젠간 해결해야지.)

그리고 둘 중하나라도 데이터가 존재한다면, 디폴트 레이아웃의 왼쪽탭 아래의 UI가 빨간색에서 초록색이 되며 안내문구가 나간다.

시설물 select의 경우 새롭게 vuetify ui를 활용해서 만들었다. 네이버 쇼핑을 참고해서 만들었다. 멘토님은 네이버 쇼핑의 카테고리박스가 체크박스 ui를 활용한 것이라는데... 계속 찾아봐도 그렇게는 구현이 힘들어서 active button을 직접 구현해서 만들었으며, 그렇다보니 뭔가 디자인은 구리다.

맵의 경우

<template>
  <div>
    <v-container fluid>
      <v-card class="pa-3">
        <v-row>
          <v-col>
            <v-card class="pa-3" outlined>
              <v-btn color="primary" block @click="postData"
                >Map Rendering</v-btn
              >
              <kakao-map ref="kakaoMap" />
            </v-card>
          </v-col>
        </v-row>
      </v-card>
    </v-container>
  </div>
</template>

<script>
import KakaoMap from "@/components/KakaoMap.vue";
import { mapState } from "vuex";
import axios from "axios";

export default {
  data: () => ({
    thenData: null,
  }),
  computed: {
    ...mapState({
      myAddress: "address",
      buttons: "buttons",
      zickbang_point: "zickbang_point",
      data: "receivedData",
    }),
  },
  components: {
    "kakao-map": KakaoMap,
  },
  methods: {
    async postData() {
      try {
        let dataToSend = null;
        console.log(this.myAddress);
        console.log(this.zickbang_point);

        if (
          !this.myAddress &&
          Array.isArray(this.zickbang_point) &&
          this.zickbang_point.length > 0
        ) {
          dataToSend = this.zickbang_point;
        } else if (
          this.myAddress &&
          (!this.zickbang_point ||
            (Array.isArray(this.zickbang_point) &&
              this.zickbang_point.length === 0))
        ) {
          dataToSend = this.myAddress;
        } else {
          throw new Error("주소 또는 zickbang_point가 필요합니다.");
        }

        const res = await axios.post(`http://localhost:8000/api/end`, {
          address: dataToSend,
          selected: JSON.stringify(this.buttons),
        });

        console.log("---axios Post 성공---- ");
        const receivedData = res.data;

        const processedData = {
          scorebox: receivedData.scorebox,
          info_store: JSON.parse(receivedData.info_store),
          address_latlng: receivedData.address_point,
          bus: JSON.parse(receivedData.bus_data),
        };

        // Vue 데이터에 할당
        this.thendata = processedData;

        // Vuex 스토어에 데이터 저장
        this.$store.commit("SET_RECEIVED_DATA", processedData);
        console.log("postthen vuex 저장 완료");
        this.$refs.kakaoMap.reLoadMap();
      } catch (error) {
        console.error(error.message);
        // 에러 메시지 처리 등
      }
    },
  },
};
</script>

다음과 랜더링기능을 담은 버튼 하나랑 카카오맵을 띄운다. 이 컴포넌트는 하위컴포넌트로 카카오맵 컴포넌트를 가지는데 다음과 같다.

<template>
  <div class="map-container">
    <h2 class="map-title">MAP INFO</h2>
    <div id="map"></div>
  </div>
</template>

<script>
import { mapState } from "vuex";

export default {
  name: "KakaoMap",
  data() {
    return {
      scorebox: null,
      address: { name: "my house", lat: 37.0781534, lng: 127.0427976 },
      stores: [
        { name: "Starbucks", lat: 37.2796352, lng: 127.043346 },
        { name: "Burger King", lat: 37.2745815, lng: 127.045215 },
        { name: "Daiso", lat: 37.2754895, lng: 127.0423236 },
      ],
      map: null,
    };
  },
  created() {
    this.loadScript();
  },
  mounted() {
    if (window.kakao && window.kakao.maps) {
      this.loadMap();
    } else {
      this.loadScript();
    }
  },
  computed: {
    ...mapState({
      receivedData: "receivedData",
    }),
  },
  methods: {
    loadScript() {
      const script = document.createElement("script");
      script.src =
        "https://dapi.kakao.com/v2/maps/sdk.js?appkey=14d78dd0b00ee306c710756383b6e3e7&autoload=false&libraries=services,clusterer,drawing";
      script.type = "text/javascript";
      script.onload = () => window.kakao.maps.load(() => this.loadMap());
      document.head.appendChild(script);
    },
    loadMap() {
      const container = document.getElementById("map");

      // 맵 기본위치 설정 및 사이즐
      const options = {
        center: new window.kakao.maps.LatLng(37.282972, 127.045545),
        level: 3,
      };
      // 기본 맵 띄우기
      this.map = new window.kakao.maps.Map(container, options);
    },
    reLoadMap() {
      const container = document.getElementById("map");
      // 맵 기본위치 설정 및 사이즈
      const options = {
        center: new window.kakao.maps.LatLng(
          this.receivedData.address_latlng.lat,
          this.receivedData.address_latlng.lng
        ),
        level: 3,
      };
      this.map = new window.kakao.maps.Map(container, options);
      this.$nextTick(() => {
        this.addSetFull(
          this.receivedData.info_store,
          this.receivedData.address_latlng,
          this.receivedData.bus
        );
      });
    },

    addMarker(lat, lng) {
      // 마커가 표시될 위치입니다
      var markerPosition = new window.kakao.maps.LatLng(lat, lng);

      // 마커를 생성합니다
      var marker = new window.kakao.maps.Marker({
        position: markerPosition,
        clickable: true,
      });

      // 마커가 지도 위에 표시되도록 설정합니다
      marker.setMap(this.map);
    },

    addLine(address, lat_end, lng_end, color = "#FFAE00") {
      var linePath = [
        new window.kakao.maps.LatLng(address.lat, address.lng),
        new window.kakao.maps.LatLng(lat_end, lng_end),
      ];
      // 지도에 표시할 선을 생성합니다
      var polyline = new window.kakao.maps.Polyline({
        path: linePath, // 선을 구성하는 좌표배열 입니다
        strokeWeight: 5, // 선의 두께 입니다
        strokeColor: color, // 선의 색깔입니다
        strokeOpacity: 0.7, // 선의 불투명도 입니다 1에서 0 사이의 값이며 0에 가까울수록 투명합니다
        strokeStyle: "solid", // 선의 스타일입니다
      });
      polyline.setMap(this.map);
    },
    customOverlay_house(lat, lng) {
      var iwContent = "당신의 세권 점수 : " + this.receivedData.scorebox.score, // 인포윈도우에 표출될 내용으로 HTML 문자열이나 document element가 가능합니다
        iwPosition = new window.kakao.maps.LatLng(lat, lng), //인포윈도우 표시 위치입니다
        iwRemoveable = true; // removeable 속성을 ture 로 설정하면 인포윈도우를 닫을 수 있는 x버튼이 표시됩니다

      // 인포윈도우를 생성하고 지도에 표시합니다
      new window.kakao.maps.InfoWindow({
        map: this.map, // 인포윈도우가 표시될 지도
        position: iwPosition,
        content: iwContent,
        removable: iwRemoveable,
      });
    },

    // 지도 확대 축소를 제어할 수 있는  줌 컨트롤을 생성합니다
    optionZoomControl() {
      var zoomControl = new window.kakao.maps.ZoomControl();
      this.map.addControl(zoomControl, window.kakao.maps.ControlPosition.RIGHT);
    },
    optionTopographical() {
      this.map.addOverlayMapTypeId(window.kakao.maps.MapTypeId.TERRAIN);
    },
    addSetFull(stores, address, bus) {
      // Setting Option
      this.optionZoomControl();
      this.optionTopographical();
      // About address point
      this.addMarker(address.lat, address.lng);
      this.customOverlay_house(address.lat, address.lng);
      // About stores point
      stores.forEach((store) => {
        const { name, lat, lng, distance, phone, place_url } = store;
        this.addMarker(lat, lng);
        this.addLine(address, lat, lng);
        this.customOverlay(lat, lng, distance, name, phone, place_url);
      });
      // About bus point
      bus.forEach((bus) => {
        const { lat, lng, distance, name } = bus;
        this.addMarker(lat, lng);
        this.addLine(address, lat, lng, "#29B6F6");
        this.customOverlayBUS(lat, lng, distance, name);
      });
    },

    customOverlay(lat, lng, distance, name, number, placeurl) {
      const content = document.createElement("div");
      content.className = "over-wrap";
      const chunks = [];
      for (let i = 0; i < name.length; i += 10) {
        chunks.push(name.substring(i, i + 10));
      }
      const modifiedName = chunks.join("<br>");
      content.innerHTML = `
      <div class="over-info">
        <div class="over-title">
          ${modifiedName}
          <div class="over-close" id="closeBtn" title="닫기" style="right: 5px"></div>
        </div>
        <div class="over-body">
          <div class="over-desc">
            <div class="over-ellipsis">${distance}분 거리</div>
            <div class="over-jibun ellipsis">${number}</div>
            <a href="${placeurl}" target="_blank" class="over-link">홈페이지 바로가기</a>
          </div>
        </div>
      </div>
      `;

      // X 버튼 추가
      const closeBtn = content.querySelector("#closeBtn");

      const overlay = new window.kakao.maps.CustomOverlay({
        map: this.map,
        content: content,
        position: new window.kakao.maps.LatLng(lat, lng),
        yAnchor: 1,
        clickable: true,
      });

      const onMouseDown = (e) => {
        if (e.preventDefault) {
          e.preventDefault();
        } else {
          e.returnValue = false;
        }

        const proj = this.map.getProjection();
        const overlayPos = overlay.getPosition();
        window.kakao.maps.event.preventMap();

        this.startX = e.clientX;
        this.startY = e.clientY;
        this.startOverlayPoint = proj.containerPointFromCoords(overlayPos);
        document.addEventListener("mousemove", onMouseMove);
      };

      const onMouseMove = (e) => {
        if (e.preventDefault) {
          e.preventDefault();
        } else {
          e.returnValue = false;
        }

        const proj = this.map.getProjection();
        const deltaX = this.startX - e.clientX;
        const deltaY = this.startY - e.clientY;
        const newPoint = new window.kakao.maps.Point(
          this.startOverlayPoint.x - deltaX,
          this.startOverlayPoint.y - deltaY
        );
        const newPos = proj.coordsFromContainerPoint(newPoint);
        overlay.setPosition(newPos);
      };

      const onMouseUp = () => {
        document.removeEventListener("mousemove", onMouseMove);
      };

      // X 버튼 클릭 시 오버레이 제거
      const onCloseClick = () => {
        overlay.setMap(null);
      };

      content.addEventListener("mousedown", onMouseDown);
      document.addEventListener("mouseup", onMouseUp);
      closeBtn.addEventListener("click", onCloseClick);
    },
    customOverlayBUS(lat, lng, distance, name) {
      const content = document.createElement("div");
      content.className = "bus-wrap";
      const chunks = [];
      for (let i = 0; i < name.length; i += 10) {
        chunks.push(name.substring(i, i + 10));
      }
      const modifiedName = chunks.join("<br>");
      content.innerHTML = `
      <div class="bus-info">
        <div class="bus-title">
          ${modifiedName} BUS STATION!!
          <div class="bus-close" id="closeBtn" title="닫기" style="right: 5px"></div>
        </div>
        <div class="bus-body">
          <div class="bus-desc">
            <div class="bus-ellipsis">${distance}분 거리</div>
          </div>
        </div>
      </div>
      `;

      // X 버튼 추가
      const closeBtn = content.querySelector("#closeBtn");

      const overlay = new window.kakao.maps.CustomOverlay({
        map: this.map,
        content: content,
        position: new window.kakao.maps.LatLng(lat, lng),
        yAnchor: 1,
        clickable: true,
      });

      const onMouseDown = (e) => {
        if (e.preventDefault) {
          e.preventDefault();
        } else {
          e.returnValue = false;
        }

        const proj = this.map.getProjection();
        const overlayPos = overlay.getPosition();
        window.kakao.maps.event.preventMap();

        this.startX = e.clientX;
        this.startY = e.clientY;
        this.startOverlayPoint = proj.containerPointFromCoords(overlayPos);
        document.addEventListener("mousemove", onMouseMove);
      };

      const onMouseMove = (e) => {
        if (e.preventDefault) {
          e.preventDefault();
        } else {
          e.returnValue = false;
        }

        const proj = this.map.getProjection();
        const deltaX = this.startX - e.clientX;
        const deltaY = this.startY - e.clientY;
        const newPoint = new window.kakao.maps.Point(
          this.startOverlayPoint.x - deltaX,
          this.startOverlayPoint.y - deltaY
        );
        const newPos = proj.coordsFromContainerPoint(newPoint);
        overlay.setPosition(newPos);
      };

      const onMouseUp = () => {
        document.removeEventListener("mousemove", onMouseMove);
      };

      // X 버튼 클릭 시 오버레이 제거
      const onCloseClick = () => {
        overlay.setMap(null);
      };

      content.addEventListener("mousedown", onMouseDown);
      document.addEventListener("mouseup", onMouseUp);
      closeBtn.addEventListener("click", onCloseClick);
    },
  },
};
</script>

<style>
#map {
  width: 1200px; /* 지도의 크기 지정 */
  height: 800px;
  margin: auto; /* 가운데 정렬을 위한 margin 속성 */
  display: block; /* 블록 요소로 표시하여 가로폭 전체를 차지하도록 설정 */
}
.map-title {
  color: black;
}

.map-container {
  background-color: #f0f0f0; /* 회색 배경색 설정 */
  border: 1px solid #ccc; /* 테두리 설정 */
  padding: 20px; /* 위 아래 여백 설정 */
  margin-top: 20px; /* 위쪽 간격 설정 */
  margin-bottom: 20px; /* 아래쪽 간격 설정 */
  border-radius: 10px; /* 모서리를 둥글게 설정 */
  /* 원하는 스타일을 추가할 수 있습니다. */
}
.over-wrap {
  position: absolute;
  left: 0;
  bottom: 20px;
  width: auto;
  height: 66px;
  margin-left: -48px;
  text-align: left;
  overflow: hidden;
  font-size: 4px;
  font-family: "Malgun Gothic", dotum, "돋움", sans-serif;
  line-height: 1.5;
}
.over-wrap .over-info {
  width: 120px;
  max-height: 60px;
  overflow: auto;
  height: 60px;
  border-radius: 5px;
  border-bottom: 1px solid #ccc;
  border-right: 1px solid #ccc;
  overflow: hidden;
  background: #fff;
  padding: 0;
}
.over-info .over-title {
  display: flex; /* title을 flex로 설정하여 x 버튼이 항상 첫번째 줄에 위치하도록 합니다. */
  align-items: center; /* x 버튼을 가운데 정렬합니다. */
  padding: 2px 0 0 3px;
  width: 120px; /* title의 width를 고정값으로 설정합니다. */
  height: auto;
  background: #eee;
  border-bottom: 1px solid #ddd;
  font-size: 10px;
  font-weight: bold;
  word-break: break-word; /* 긴 단어가 다음 줄로 넘어가도록 설정합니다. */
}
.over-info .over-close {
  position: absolute;
  top: 0px;
  right: 5px;
  color: #888;
  width: 16px;
  height: 16px;
  background: url("https://t1.daumcdn.net/localimg/localimages/07/mapapidoc/overlay_close.png");
}
.over-info .over-body {
  position: relative;
  overflow: hidden;
}
.over-info .over-desc {
  position: relative;
  margin: 1px 0 0 5px;
  height: 38px;
}
.over-desc .over-ellipsis {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: 8px;
}
.over-desc .over-jibun {
  font-size: 8px;
  color: #888;
  margin-top: -1px;
}
.over-info .over-img {
  position: absolute;
  top: 3px;
  left: 2px;
  width: 24px;
  height: 24px;
  border: 1px solid #ddd;
  color: #888;
  overflow: hidden;
}
.over-info:after {
  content: "";
  position: absolute;
  margin-left: -4px;
  left: 50%;
  bottom: 0;
  width: 7px;
  height: 6px;
  background: url("https://t1.daumcdn.net/localimg/localimages/07/mapapidoc/vertex_white.png");
}
.over-info .over-link {
  color: #5085bb;
  font-size: 8px;
}
/* bus style */
.bus-wrap {
  position: absolute;
  left: 0;
  bottom: 20px;
  width: auto;
  height: 66px;
  margin-left: -48px;
  text-align: left;
  overflow: hidden;
  font-size: 4px;
  font-family: "Malgun Gothic", dotum, "돋움", sans-serif;
  line-height: 1.5;
}
.bus-wrap .bus-info {
  width: 120px;
  max-height: 60px;
  overflow: auto;
  height: 60px;
  border-radius: 5px;
  border-bottom: 1px solid #ccc;
  border-right: 1px solid #ccc;
  overflow: hidden;
  background: #fff;
  padding: 0;
}
.bus-info .bus-title {
  display: flex; /* title을 flex로 설정하여 x 버튼이 항상 첫번째 줄에 위치하도록 합니다. */
  align-items: center; /* x 버튼을 가운데 정렬합니다. */
  padding: 2px 0 0 3px;
  width: 120px; /* title의 width를 고정값으로 설정합니다. */
  height: auto;
  background: #87ceeb;
  border-bottom: 1px solid #ddd;
  font-size: 10px;
  font-weight: bold;
  word-break: break-word; /* 긴 단어가 다음 줄로 넘어가도록 설정합니다. */
}
.bus-info .bus-close {
  position: absolute;
  top: 0px;
  right: 5px;
  color: #888;
  width: 16px;
  height: 16px;
  background: url("https://t1.daumcdn.net/localimg/localimages/07/mapapidoc/overlay_close.png");
}
.bus-info .bus-body {
  position: relative;
  overflow: hidden;
}
.bus-info .bus-desc {
  position: relative;
  margin: 1px 0 0 5px;
  height: 38px;
}
.bus-desc .bus-ellipsis {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: 8px;
}
.bus-desc .bus-jibun {
  font-size: 8px;
  color: #888;
  margin-top: -1px;
}
.bus-info .bus-img {
  position: absolute;
  top: 3px;
  left: 2px;
  width: 24px;
  height: 24px;
  border: 1px solid #ddd;
  color: #888;
  overflow: hidden;
}
.bus-info:after {
  content: "";
  position: absolute;
  margin-left: -4px;
  left: 50%;
  bottom: 0;
  width: 7px;
  height: 6px;
  background: url("https://t1.daumcdn.net/localimg/localimages/07/mapapidoc/vertex_white.png");
}
.bus-info .bus-link {
  color: #5085bb;
  font-size: 8px;
}
</style>

정말 길다... 맵 안의 윈도우만 팀원 한명이 소스코드를 만들어 주었고 나머지는 모두 혼자서 작업했다. 중복되는 코드가 많아 길어보여도 복사한 부분이 꽤 있다. 카카오맵을 기본적으로 로드한 뒤에 맵위에 올라갈 기능들을 메소드로 모두 만들어 놓고 addSetFull메소드가 맵로드 메소드에서 최종적으로 실행하는 방식이다. forEach문을 통해 요청으로 들어온 모든 데이터에 대해 정보를 화면에 출력한다.

Map 경로에서 대시보드 경로로 돌아가더라도 Vuex덕에 대시보드에서 구성한 데이터들이 손상되지 않는다. 대시보드의 상태들은 모두 vuex에 의존하고 있기에 vuex에 직접적으로 변화를 주는 대시보드 경로에서의 버튼클릭이나 주소변화가 없다면 변화가 발생하지 않는다. 이제야 SPA느낌이 나는 것 같다.

MAP은 한계가 존재한다. 맵위에 띄워진 윈도우들은 다른 정보들을 가리거나 서로를 가리게 된다. 이것을 개선하는 방향도 좋지만, 상세정보 페이지에서 그래프등으로 새롭게 제시하기로 한다(최우선순위 작업)

상세정보 페이지에서 머신러닝 클러스터링 기능을 활용할 것이다. 모델은 이미 준비되어 있으며, 간단하다. 직방 데이터가 평균적으로 900개를 가지고 있다. 이것은 계속 쌓이게 되므로 나중에는 모델을 어떻게 수정할지는 모르겠으나 현재 기준으로 딥러닝의 클러스터링 알고리즘을 사용하기에는 데이터가 너무 적다. 그래서 그냥 simple is best라고 sklearn에서 제공하는 몇가지 클러스터링 기법을 사용하다가 k-means가 가장 우수해서 k-means로 직방 데이터 피처에 세권점수를 활용하고 세권분포상황정보를 추가해서 10개의 클러스터를 만들도록 모델링하고 .pkl로 백엔드에 저장해두었다. 클라이언트가 부동산 매물을 선택하면 요청->백엔드->k-means모델->결과로 900개 매물에 대한 클러스터링 분류를 끝내고 같은 클러스터에서 유사도 순으로 매물을 추천할 예정이다.

Feature selection
클러스터를 유심히 살펴본 결과 세권개념으로 만들어낸 피처들보다 기존 부동산 가격 피처들에 높은 연관성을 보였다. 그래서 가격 피처들의 수를 줄이기 위해 월세로 모든 것을 통합하고, 평 피처로 나누어서 월세per평만 사용해서 차원을 줄이기로 했다. 전세->월세 환산작업은 팀원이 인터넷 조사를 통해 완료했으며
월세per평 개념을 통해 시간별로 클러스터마다 평균 월세per평을 나타내서 테이블형태로 화면에 제시할 것이다.

백엔드 view.py함수는 크게 달라진 것은 없다.

from django.shortcuts import render
from django.http import HttpResponse, JsonResponse
from rest_framework.decorators import api_view
import pandas as pd
from .dis_min import haversine, change_min
from .change import get_lat_lng_from_address
from .Query_condition import QuerySemose, distance_weight, score_accesibility, serve_latlng
from .bus_dis import get_bus_nearby
import json
from .zicbang import oneroom

from rest_framework.views import APIView
from rest_framework.response import Response
# Create your views here.

# Cache
from django.core.cache import cache

# Logging
from rest_framework import status

def index(request):
    return HttpResponse("Hello, world. You're at the api index.")

@api_view(['GET'])
def index2(request):
    return JsonResponse({'message': 'hello world'}, status=status.HTTP_200_OK)

class DataView(APIView):
    def post(self, request):
        # POST 데이터를 JSON 형식으로 받아옵니다.
        data = request.data

        # 필요한 데이터 추출
        address = data.get("address")
        selected = data.get("selected", [])
        lat_zic = None
        lng_zic = None

        if isinstance(address, list):
            # address가 배열인 경우 처리
            address = address[0]
            lat_zic = address.get('lat')
            lng_zic = address.get('lng')
            addr_info = {
                "name": 000,
                "lat": lat_zic,
                "lng": lng_zic,
            }
            # 처리할 작업 수행
        elif isinstance(address, str):
            # address가 문자열인 경우 처리
            addr_lat, addr_lng = get_lat_lng_from_address(address)
            addr_info = {
                "name": 000,
                "lat": addr_lat,
                "lng": addr_lng,
            }
        else:
            pass
        selected = json.loads(selected)
        score = score_accesibility(address, selected, lat_zic=lat_zic, lng_zic=lng_zic)
        scorebox = {'score':score}
        info_store = serve_latlng(address, selected, lat_zic=lat_zic, lng_zic=lng_zic)
        if lat_zic == None and lng_zic == None:
            bus_data = get_bus_nearby(addr_lat, addr_lng)
        else:
            bus_data = get_bus_nearby(lat_zic, lng_zic)
        res = {
            'scorebox': scorebox,
            'info_store': info_store,
            'address_point': addr_info,
            'bus_data': bus_data,
        }
        return Response(res)

백엔드 내부 계산 로직이 좀 더러운 것은 안비밀이다. 속도적으로 n(o) 가n(o^2)가 되는 사태는 아니지만 클린코드는 아닌것 같다. 뭐 그것이 지금 중요한 건 아니니까 마지막 컴포넌트를 완성시키고 기능 테스트 시나리오를 진행하면서 수정해야겠다.

하나 이상한 점이, 왜 mysql은 우리집 와이파이와 내 아이폰 셀룰러에선 파이썬에서 mysql.connector로 전달하는 쿼리문이 잘 먹히는데, 카페나 학교 와이파이에서는 먹통인지 모르겠다. ip를 전역허용 시켜줘야 하나라고 생각해본다면 우리집 와이파이와 셀룰러를 허용시켜준 적도 없고 학교 와이파이에서도 100%먹통은 아니고 70%정도이다.(그래서 인터넷 연결문제인지 모르고 1시간을 컴포넌트 수정한 적도 있다.)

거의 막바지까지 왔다. 처음으로 사실상 혼자서 풀스택 개발을 해본다. django, Vue.js 모두 처음인데 어떻게 어떻게 굴러가게는 만든 것 같다. 하면서 여러 vscode들의 편의 기능 추천과 열심히 하라고 예쁜 서브 모니터까지 선물로 주신 비밀 멘토?님께 감사드린다.(코드도 좀 알려주시면 얼마나 좋았을까요.)

백엔드 개발자는 정말 알아줘야한다.... 보이지 않는 본질을 만드는 형님들이다. 분명 위기였는데 역시 위기라고 지름길 찾는 짓은 하면 안될 것 같다. 모르면 공부하고 공부한 내용을 적용하는 방향으로, 망했으면 살려둘 건 살려두고 다시. 이것만 하고 살 수도 없지만 시간은 부족하지 않다. 잠은 죽어서 자는 것이 개발자 마인드 :) 재미를 느끼면 밥 먹으면서든 버스를 타도 개발을 한다.

profile
자바집사의 거북이 수련법

0개의 댓글