Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,8 @@ public List<RegionAverageViewportItem> 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<String, Object> params = new HashMap<>();
Expand Down Expand Up @@ -192,21 +192,42 @@ WITH visible AS (
return jdbcTemplate.query(sql.toString(), params, regionAverageMapper());
}

private List<RegionAverageViewportItem> findVisibleSigunguAverageViewportItems(
private List<RegionAverageViewportItem> findVisibleRegionAverageViewportItems(
PropertySearchCriteria criteria,
String regionLevel,
int limit
) {
Map<String, Object> params = new HashMap<>();
params.put("west", criteria.west());
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,
Expand All @@ -217,15 +238,15 @@ private List<RegionAverageViewportItem> 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());
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.ssafy.salmanhae.model.dto.map;

public enum MapViewportMode {
SIDO_AVG,
SIGUNGU_AVG,
DONG_AVG,
PROPERTY_CLUSTER,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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)
Expand All @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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())
Expand All @@ -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
Expand Down
13 changes: 7 additions & 6 deletions docs/08_API_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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을 전달하고, 백엔드는 줌 레벨에 맞춰 지역 평균 또는 매물/클러스터 데이터를 반환합니다.
Expand All @@ -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` | 시/군/구 대표 가격 표시 |
Comment thread
coderabbitai[bot] marked this conversation as resolved.
| `12`-`13` | `DONG_AVG` | 읍/면/동 평균 표시 |
| `14`-`15` | `PROPERTY_CLUSTER` | 거리 수준 밀집 매물 클러스터 표시 |
| `>= 16` | `PROPERTY_MARKER` | 개별 매물 마커 표시 |
Expand Down
34 changes: 26 additions & 8 deletions frontend/src/store/mapStore.js
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -26,6 +26,7 @@ export default defineStore('map', {
currentRegion: 'seoul',
selectedPropertyId: null,
selectedProperty: null,
selectedViewportItem: null,
viewportMode: '',
viewportItems: [],
properties: [],
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
async sendChat(message) {
const text = String(message || '').trim()
if (!text || this.isChatLoading) return
Expand Down
55 changes: 55 additions & 0 deletions frontend/src/store/mapStore.test.mjs
Original file line number Diff line number Diff line change
@@ -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)
})
22 changes: 20 additions & 2 deletions frontend/src/utils/mapViewport.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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] || '지역')
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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'
Expand All @@ -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))
}
Expand Down
Loading