diff --git a/backend/src/main/java/com/ssafy/salmanhae/model/dao/property/JdbcPropertyDao.java b/backend/src/main/java/com/ssafy/salmanhae/model/dao/property/JdbcPropertyDao.java index 7201c90..7588697 100644 --- a/backend/src/main/java/com/ssafy/salmanhae/model/dao/property/JdbcPropertyDao.java +++ b/backend/src/main/java/com/ssafy/salmanhae/model/dao/property/JdbcPropertyDao.java @@ -112,8 +112,8 @@ public List findRegionAverageViewportItems( String regionLevel, int limit ) { - if ("SIGUNGU".equals(regionLevel)) { - return findVisibleSigunguAverageViewportItems(criteria, limit); + if ("SIDO".equals(regionLevel) || "SIGUNGU".equals(regionLevel) || "DONG".equals(regionLevel)) { + return findVisibleRegionAverageViewportItems(criteria, regionLevel, limit); } Map params = new HashMap<>(); @@ -192,8 +192,9 @@ WITH visible AS ( return jdbcTemplate.query(sql.toString(), params, regionAverageMapper()); } - private List findVisibleSigunguAverageViewportItems( + private List findVisibleRegionAverageViewportItems( PropertySearchCriteria criteria, + String regionLevel, int limit ) { Map params = new HashMap<>(); @@ -201,12 +202,32 @@ private List findVisibleSigunguAverageViewportItems( params.put("east", criteria.east()); params.put("south", criteria.south()); params.put("north", criteria.north()); + params.put("regionLevel", regionLevel); params.put("limit", limit); + String regionCodeExpression = switch (regionLevel) { + case "SIDO" -> "MIN(SUBSTRING(legal_dong_code, 1, 2))"; + case "SIGUNGU" -> "MIN(SUBSTRING(legal_dong_code, 1, 5))"; + case "DONG" -> "legal_dong_code"; + default -> throw new IllegalArgumentException("Unsupported region level: " + regionLevel); + }; + String regionNameExpression = switch (regionLevel) { + case "SIDO" -> "sido"; + case "SIGUNGU" -> "sigungu"; + case "DONG" -> "dong"; + default -> throw new IllegalArgumentException("Unsupported region level: " + regionLevel); + }; + String groupBy = switch (regionLevel) { + case "SIDO" -> "sido"; + case "SIGUNGU" -> "sido, sigungu"; + case "DONG" -> "sido, sigungu, dong, legal_dong_code"; + default -> throw new IllegalArgumentException("Unsupported region level: " + regionLevel); + }; + StringBuilder sql = new StringBuilder(""" - SELECT 'SIGUNGU' AS region_level, - MIN(SUBSTRING(legal_dong_code, 1, 5)) AS region_code, - sigungu AS region_name, + SELECT :regionLevel AS region_level, + %s AS region_code, + %s AS region_name, CAST(AVG(deposit) AS BIGINT) AS avg_deposit, CAST(AVG(monthly_rent) AS BIGINT) AS avg_monthly_rent, CAST(AVG(price) AS BIGINT) AS avg_sale_price, @@ -217,15 +238,15 @@ private List findVisibleSigunguAverageViewportItems( WHERE is_active = true AND longitude BETWEEN :west AND :east AND latitude BETWEEN :south AND :north - AND sigungu IS NOT NULL - """); + AND %s IS NOT NULL + """.formatted(regionCodeExpression, regionNameExpression, regionNameExpression)); appendPropertyFilters(sql, params, "", criteria); sql.append(""" - GROUP BY sido, sigungu + GROUP BY %s ORDER BY transaction_count DESC, region_name ASC LIMIT :limit - """); + """.formatted(groupBy)); return jdbcTemplate.query(sql.toString(), params, regionAverageMapper()); } diff --git a/backend/src/main/java/com/ssafy/salmanhae/model/dto/map/MapViewportMode.java b/backend/src/main/java/com/ssafy/salmanhae/model/dto/map/MapViewportMode.java index 0645e81..8b7a7d2 100644 --- a/backend/src/main/java/com/ssafy/salmanhae/model/dto/map/MapViewportMode.java +++ b/backend/src/main/java/com/ssafy/salmanhae/model/dto/map/MapViewportMode.java @@ -1,6 +1,7 @@ package com.ssafy.salmanhae.model.dto.map; public enum MapViewportMode { + SIDO_AVG, SIGUNGU_AVG, DONG_AVG, PROPERTY_CLUSTER, diff --git a/backend/src/main/java/com/ssafy/salmanhae/service/map/MapViewportServiceImpl.java b/backend/src/main/java/com/ssafy/salmanhae/service/map/MapViewportServiceImpl.java index 7306379..9b564ac 100644 --- a/backend/src/main/java/com/ssafy/salmanhae/service/map/MapViewportServiceImpl.java +++ b/backend/src/main/java/com/ssafy/salmanhae/service/map/MapViewportServiceImpl.java @@ -22,8 +22,8 @@ public class MapViewportServiceImpl implements MapViewportService { private static final int REGION_ITEM_LIMIT = 200; private static final int CLUSTER_ITEM_LIMIT = 500; private static final int PROPERTY_ITEM_LIMIT = 500; - private static final BigDecimal CLUSTER_GRID_ZOOM_14 = new BigDecimal("0.01"); - private static final BigDecimal CLUSTER_GRID_ZOOM_15 = new BigDecimal("0.005"); + private static final BigDecimal CLUSTER_GRID_ZOOM_14 = new BigDecimal("0.005"); + private static final BigDecimal CLUSTER_GRID_ZOOM_15 = new BigDecimal("0.0025"); private final PropertyDao propertyDao; @@ -39,6 +39,10 @@ public MapViewportResponse getViewport(MapViewportRequest request) { request.criteria().validateBounds(); MapViewportMode mode = resolveMode(request.zoom()); return switch (mode) { + case SIDO_AVG -> MapViewportResponse.from( + mode, + propertyDao.findRegionAverageViewportItems(request.criteria(), "SIDO", REGION_ITEM_LIMIT) + ); case SIGUNGU_AVG -> MapViewportResponse.from( mode, propertyDao.findRegionAverageViewportItems(request.criteria(), "SIGUNGU", REGION_ITEM_LIMIT) @@ -59,6 +63,9 @@ public MapViewportResponse getViewport(MapViewportRequest request) { } private MapViewportMode resolveMode(int zoom) { + if (zoom <= 9) { + return MapViewportMode.SIDO_AVG; + } if (zoom <= 11) { return MapViewportMode.SIGUNGU_AVG; } diff --git a/backend/src/test/java/com/ssafy/salmanhae/controller/map/MapViewportControllerTest.java b/backend/src/test/java/com/ssafy/salmanhae/controller/map/MapViewportControllerTest.java index 5656f13..fcc272c 100644 --- a/backend/src/test/java/com/ssafy/salmanhae/controller/map/MapViewportControllerTest.java +++ b/backend/src/test/java/com/ssafy/salmanhae/controller/map/MapViewportControllerTest.java @@ -21,8 +21,23 @@ class MapViewportControllerTest { private MockMvc mockMvc; @Test - void getViewportIsPublicAndReturnsSigunguModeAtWideZoom() throws Exception { + void getViewportIsPublicAndReturnsRegionModesAtWideZoom() throws Exception { mockMvc.perform(baseViewportRequest().param("zoom", "0")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.mode").value("SIDO_AVG")) + .andExpect(jsonPath("$.data.totalCount").value(1)) + .andExpect(jsonPath("$.data.items[0].type").value("REGION_AVG")) + .andExpect(jsonPath("$.data.items[0].regionLevel").value("SIDO")) + .andExpect(jsonPath("$.data.items[0].avgDeposit").value(10000000)) + .andExpect(jsonPath("$.data.items[0].avgMonthlyRent").value(550000)) + .andExpect(jsonPath("$.data.items[0].avgSalePrice").value(720000000)) + .andExpect(jsonPath("$.data.items[0].transactionCount").value(2)); + + mockMvc.perform(baseViewportRequest().param("zoom", "9")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.mode").value("SIDO_AVG")); + + mockMvc.perform(baseViewportRequest().param("zoom", "10")) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.mode").value("SIGUNGU_AVG")) .andExpect(jsonPath("$.data.totalCount").value(1)) @@ -63,7 +78,9 @@ void getViewportReturnsDongModeAtMiddleZoom() throws Exception { .andExpect(jsonPath("$.data.items[0].type").value("REGION_AVG")) .andExpect(jsonPath("$.data.items[0].regionLevel").value("DONG")) .andExpect(jsonPath("$.data.items[0].regionName").value("대학동")) - .andExpect(jsonPath("$.data.items[0].avgMonthlyRent").value(520000)); + .andExpect(jsonPath("$.data.items[0].avgDeposit").value(10000000)) + .andExpect(jsonPath("$.data.items[0].avgMonthlyRent").value(550000)) + .andExpect(jsonPath("$.data.items[0].transactionCount").value(1)); mockMvc.perform(baseViewportRequest().param("zoom", "13")) .andExpect(status().isOk()) @@ -80,9 +97,9 @@ void getViewportRegionAverageUsesOnlyStatsMatchingFilteredVisiblePropertyCombina .andExpect(jsonPath("$.data.totalCount").value(1)) .andExpect(jsonPath("$.data.items[0].type").value("REGION_AVG")) .andExpect(jsonPath("$.data.items[0].regionLevel").value("DONG")) - .andExpect(jsonPath("$.data.items[0].avgDeposit").value(10500000)) - .andExpect(jsonPath("$.data.items[0].avgMonthlyRent").value(520000)) - .andExpect(jsonPath("$.data.items[0].transactionCount").value(3)); + .andExpect(jsonPath("$.data.items[0].avgDeposit").value(10000000)) + .andExpect(jsonPath("$.data.items[0].avgMonthlyRent").value(550000)) + .andExpect(jsonPath("$.data.items[0].transactionCount").value(1)); } @Test diff --git a/docs/08_API_SPEC.md b/docs/08_API_SPEC.md index b9d3706..228da9a 100644 --- a/docs/08_API_SPEC.md +++ b/docs/08_API_SPEC.md @@ -140,7 +140,7 @@ F-1 MVP에서는 실거래가 건물 anchor 기반 `MVP_SYNTHETIC` 더미 매물 ### 지도 줌 레벨별 표시 데이터 조회 ```http -GET /api/v1/map/viewport?west=126.91&east=127.02&south=37.45&north=37.55&zoom=12 +GET /api/v1/map/viewport?west=126.91&east=127.02&south=37.45&north=37.55&zoom=10 ``` 프론트는 네이버지도 SDK의 현재 bounds와 zoom을 전달하고, 백엔드는 줌 레벨에 맞춰 지역 평균 또는 매물/클러스터 데이터를 반환합니다. @@ -162,19 +162,20 @@ GET /api/v1/map/viewport?west=126.91&east=127.02&south=37.45&north=37.55&zoom=12 | mode | 지도 범위 | 반환 데이터 | | --- | --- | --- | -| `SIDO_AVG` | 시/도 수준 | 시/도 실거래가 평균 | -| `SIGUNGU_AVG` | 시/군/구 수준 | 시/군/구 실거래가 평균 | -| `DONG_AVG` | 읍/면/동 수준 | 읍/면/동 실거래가 평균 | +| `SIDO_AVG` | 시/도 수준 | 시/도 대표 가격 | +| `SIGUNGU_AVG` | 시/군/구 수준 | 시/군/구 대표 가격 | +| `DONG_AVG` | 읍/면/동 수준 | 읍/면/동 대표 가격 | | `PROPERTY_CLUSTER` | 거리/밀집 수준 | 원형 클러스터 | | `PROPERTY_MARKER` | 상세 확대 | 개별 매물 | -`SIGUNGU_AVG`는 넓은 줌에서 시군구 단위 대표 마커를 안정적으로 표시하기 위해 현재 bounds 안의 활성 매물을 시군구별로 직접 집계합니다. `DONG_AVG`는 사용 가능한 `region_price_stat` 기준 지역 평균을 반환합니다. +`SIDO_AVG`, `SIGUNGU_AVG`, `DONG_AVG`는 지도에 표시된 지역 마커를 확대했을 때 실제 매물이 비지 않도록 현재 bounds 안의 활성 매물을 행정구역별로 직접 집계합니다. 월세 대표 가격은 보증금이 아니라 `avgMonthlyRent` 기준으로 표시합니다. 초기 운영 threshold는 네이버지도 zoom 숫자를 기준으로 서버에서 결정합니다. | zoom | mode | 설명 | | --- | --- | --- | -| `<= 11` | `SIGUNGU_AVG` | 시/군/구 평균 표시 | +| `<= 9` | `SIDO_AVG` | 시/도 대표 가격 표시 | +| `10`-`11` | `SIGUNGU_AVG` | 시/군/구 대표 가격 표시 | | `12`-`13` | `DONG_AVG` | 읍/면/동 평균 표시 | | `14`-`15` | `PROPERTY_CLUSTER` | 거리 수준 밀집 매물 클러스터 표시 | | `>= 16` | `PROPERTY_MARKER` | 개별 매물 마커 표시 | diff --git a/frontend/src/store/mapStore.js b/frontend/src/store/mapStore.js index 2386f3b..93fe089 100644 --- a/frontend/src/store/mapStore.js +++ b/frontend/src/store/mapStore.js @@ -1,7 +1,7 @@ import { defineStore } from 'pinia' -import { sendChatMessage } from '../api/chat' -import { fetchMapViewport, fetchPropertyDetail } from '../api/properties' -import { isPropertyItem } from '../utils/mapViewport' +import { sendChatMessage } from '../api/chat.js' +import { fetchMapViewport, fetchPropertyDetail } from '../api/properties.js' +import { isPropertyItem, VIEWPORT_MODES } from '../utils/mapViewport.js' const DEFAULT_BOUNDS = { west: 126.76, @@ -26,6 +26,7 @@ export default defineStore('map', { currentRegion: 'seoul', selectedPropertyId: null, selectedProperty: null, + selectedViewportItem: null, viewportMode: '', viewportItems: [], properties: [], @@ -128,6 +129,18 @@ export default defineStore('map', { maxPrice: '' } this.searchKeyword = '' + this.selectedViewportItem = null + }, + applyViewportData(data) { + this.viewportMode = data.mode || '' + this.viewportItems = data.items || [] + this.properties = this.viewportItems.filter(isPropertyItem) + this.totalCount = data.totalCount ?? this.viewportItems.length + this.lastFetchedAt = new Date().toISOString() + + if (this.viewportMode === VIEWPORT_MODES.PROPERTY_MARKER || this.properties.length > 0) { + this.selectedViewportItem = null + } }, async fetchViewport(bounds = this.bounds) { const seq = ++this.requestSeq @@ -144,11 +157,7 @@ export default defineStore('map', { if (seq !== this.requestSeq) return - this.viewportMode = data.mode || '' - this.viewportItems = data.items || [] - this.properties = this.viewportItems.filter(isPropertyItem) - this.totalCount = data.totalCount ?? this.viewportItems.length - this.lastFetchedAt = new Date().toISOString() + this.applyViewportData(data) if (this.selectedPropertyId && !this.properties.some((property) => property.id === this.selectedPropertyId)) { this.selectedPropertyId = null @@ -173,6 +182,7 @@ export default defineStore('map', { async selectProperty(id) { const seq = ++this.detailRequestSeq this.selectedPropertyId = id + this.selectedViewportItem = null this.detailError = '' this.selectedProperty = this.properties.find((property) => property.id === id) || null this.isDetailLoading = true @@ -197,6 +207,14 @@ export default defineStore('map', { this.detailError = '' this.isDetailLoading = false }, + selectViewportItem(item) { + this.detailRequestSeq += 1 + this.selectedPropertyId = null + this.selectedProperty = null + this.detailError = '' + this.isDetailLoading = false + this.selectedViewportItem = item + }, async sendChat(message) { const text = String(message || '').trim() if (!text || this.isChatLoading) return diff --git a/frontend/src/store/mapStore.test.mjs b/frontend/src/store/mapStore.test.mjs new file mode 100644 index 0000000..1cc731b --- /dev/null +++ b/frontend/src/store/mapStore.test.mjs @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { createPinia, setActivePinia } from 'pinia' + +import useMapStore from './mapStore.js' + +const createStore = () => { + setActivePinia(createPinia()) + return useMapStore() +} + +test('viewport summary selection clears when property marker results arrive', () => { + const store = createStore() + store.selectViewportItem({ type: 'REGION_AVG', regionLevel: 'SIDO', regionName: '서울특별시' }) + + store.applyViewportData({ + mode: 'PROPERTY_MARKER', + items: [ + { + type: 'PROPERTY', + id: 1, + title: '관악구 원룸', + latitude: 37.470123, + longitude: 126.936456 + } + ], + totalCount: 1 + }) + + assert.equal(store.selectedViewportItem, null) + assert.equal(store.properties.length, 1) + assert.equal(store.properties[0].id, 1) +}) + +test('viewport summary selection remains for non-property viewport results', () => { + const store = createStore() + const selectedItem = { type: 'REGION_AVG', regionLevel: 'SIDO', regionName: '서울특별시' } + store.selectViewportItem(selectedItem) + + store.applyViewportData({ + mode: 'SIDO_AVG', + items: [ + { + type: 'REGION_AVG', + regionLevel: 'SIDO', + regionName: '서울특별시', + avgMonthlyRent: 550000 + } + ], + totalCount: 1 + }) + + assert.deepEqual(store.selectedViewportItem, selectedItem) + assert.equal(store.properties.length, 0) +}) diff --git a/frontend/src/utils/mapViewport.js b/frontend/src/utils/mapViewport.js index b0972d2..e6f93ae 100644 --- a/frontend/src/utils/mapViewport.js +++ b/frontend/src/utils/mapViewport.js @@ -5,6 +5,7 @@ export const VIEWPORT_ITEM_TYPES = { } export const VIEWPORT_MODES = { + SIDO_AVG: 'SIDO_AVG', SIGUNGU_AVG: 'SIGUNGU_AVG', DONG_AVG: 'DONG_AVG', PROPERTY_CLUSTER: 'PROPERTY_CLUSTER', @@ -15,9 +16,26 @@ export const isPropertyItem = (item) => item?.type === VIEWPORT_ITEM_TYPES.PROPE export const getPrimaryPriceValue = (item) => { if (!item) return null - return item.avgSalePrice ?? item.price ?? item.avgDeposit ?? item.deposit ?? item.avgMonthlyRent ?? item.monthlyRent ?? null + if (item.transactionType === 'SALE') return item.avgSalePrice ?? item.price ?? null + if (item.transactionType === 'JEONSE') return item.avgDeposit ?? item.deposit ?? null + if (item.transactionType === 'MONTHLY_RENT') return item.avgMonthlyRent ?? item.monthlyRent ?? null + return item.avgMonthlyRent ?? item.monthlyRent ?? item.avgSalePrice ?? item.price ?? item.avgDeposit ?? item.deposit ?? null } +export const regionLevelLabel = (level) => ({ + SIDO: '시/도', + SIGUNGU: '시/군/구', + DONG: '읍/면/동' +}[level] || '지역') + +export const propertyTypeLabel = (type) => ({ + ONE_ROOM: '원룸', + OFFICETEL: '오피스텔', + APARTMENT: '아파트', + VILLA: '빌라', + MULTI_FAMILY: '다세대주택' +}[type] || '주거') + export const viewportMarkerKind = (item) => { if (item?.type === VIEWPORT_ITEM_TYPES.REGION_AVG) return 'region' if (item?.type === VIEWPORT_ITEM_TYPES.CLUSTER) return 'cluster' @@ -36,7 +54,7 @@ export const viewportMarkerLabel = (item, { formatWons, transactionLabel } = {}) if (item?.type === VIEWPORT_ITEM_TYPES.REGION_AVG) { const regionName = item.regionName || item.regionCode || '지역' return { - eyebrow: item.regionLevel || 'REGION', + eyebrow: regionLevelLabel(item.regionLevel), title: regionName, value: formatPrice(getPrimaryPriceValue(item)) } diff --git a/frontend/src/utils/mapViewport.test.mjs b/frontend/src/utils/mapViewport.test.mjs index 7b4abde..bbeb84e 100644 --- a/frontend/src/utils/mapViewport.test.mjs +++ b/frontend/src/utils/mapViewport.test.mjs @@ -4,6 +4,8 @@ import { test } from 'node:test' import { getPrimaryPriceValue, isPropertyItem, + propertyTypeLabel, + regionLevelLabel, viewportMarkerAnchor, viewportMarkerKind, viewportMarkerLabel @@ -28,15 +30,29 @@ test('region average marker uses region metadata and representative price', () = assert.equal(viewportMarkerKind(item), 'region') assert.deepEqual(viewportMarkerAnchor(item), { x: 58, y: 54 }) - assert.equal(getPrimaryPriceValue(item), 98000000) + assert.equal(getPrimaryPriceValue(item), 620000) assert.deepEqual(viewportMarkerLabel(item, { formatWons }), { - eyebrow: 'SIGUNGU', + eyebrow: '시/군/구', title: '관악구', - value: '98000000' + value: '620000' }) }) -test('cluster marker shows count and representative price', () => { +test('monthly rent viewport items use monthly rent as representative price', () => { + assert.equal(getPrimaryPriceValue({ + type: 'PROPERTY', + transactionType: 'MONTHLY_RENT', + deposit: 10000000, + monthlyRent: 500000 + }), 500000) + assert.equal(getPrimaryPriceValue({ + type: 'REGION_AVG', + avgDeposit: 10000000, + avgMonthlyRent: 500000 + }), 500000) +}) + +test('cluster marker shows count and monthly rent representative price first', () => { const item = { type: 'CLUSTER', count: 42, @@ -49,7 +65,7 @@ test('cluster marker shows count and representative price', () => { assert.deepEqual(viewportMarkerLabel(item, { formatWons }), { eyebrow: '42개', title: '매물 묶음', - value: '12000000' + value: '580000' }) }) @@ -74,3 +90,10 @@ test('property marker keeps transaction label behavior', () => { } ) }) + +test('viewport labels translate region levels and property types', () => { + assert.equal(regionLevelLabel('SIDO'), '시/도') + assert.equal(regionLevelLabel('SIGUNGU'), '시/군/구') + assert.equal(regionLevelLabel('DONG'), '읍/면/동') + assert.equal(propertyTypeLabel('MULTI_FAMILY'), '다세대주택') +}) diff --git a/frontend/src/views/MapExplorer.vue b/frontend/src/views/MapExplorer.vue index 0acca57..58ae566 100644 --- a/frontend/src/views/MapExplorer.vue +++ b/frontend/src/views/MapExplorer.vue @@ -36,7 +36,7 @@ - +