diff --git a/frontend/package.json b/frontend/package.json
index a31f0ce..b4dfe2f 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -6,7 +6,7 @@
"scripts": {
"dev": "vite",
"build": "vite build",
- "test": "node --test src/api/*.test.mjs",
+ "test": "node --test \"src/**/*.test.mjs\"",
"preview": "vite preview"
},
"dependencies": {
diff --git a/frontend/src/store/mapStore.js b/frontend/src/store/mapStore.js
index c035fa0..2386f3b 100644
--- a/frontend/src/store/mapStore.js
+++ b/frontend/src/store/mapStore.js
@@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
import { sendChatMessage } from '../api/chat'
import { fetchMapViewport, fetchPropertyDetail } from '../api/properties'
+import { isPropertyItem } from '../utils/mapViewport'
const DEFAULT_BOUNDS = {
west: 126.76,
@@ -145,7 +146,7 @@ export default defineStore('map', {
this.viewportMode = data.mode || ''
this.viewportItems = data.items || []
- this.properties = this.viewportItems.filter((item) => item.type === 'PROPERTY')
+ this.properties = this.viewportItems.filter(isPropertyItem)
this.totalCount = data.totalCount ?? this.viewportItems.length
this.lastFetchedAt = new Date().toISOString()
diff --git a/frontend/src/utils/mapViewport.js b/frontend/src/utils/mapViewport.js
new file mode 100644
index 0000000..b0972d2
--- /dev/null
+++ b/frontend/src/utils/mapViewport.js
@@ -0,0 +1,58 @@
+export const VIEWPORT_ITEM_TYPES = {
+ REGION_AVG: 'REGION_AVG',
+ CLUSTER: 'CLUSTER',
+ PROPERTY: 'PROPERTY'
+}
+
+export const VIEWPORT_MODES = {
+ SIGUNGU_AVG: 'SIGUNGU_AVG',
+ DONG_AVG: 'DONG_AVG',
+ PROPERTY_CLUSTER: 'PROPERTY_CLUSTER',
+ PROPERTY_MARKER: 'PROPERTY_MARKER'
+}
+
+export const isPropertyItem = (item) => item?.type === VIEWPORT_ITEM_TYPES.PROPERTY
+
+export const getPrimaryPriceValue = (item) => {
+ if (!item) return null
+ return item.avgSalePrice ?? item.price ?? item.avgDeposit ?? item.deposit ?? item.avgMonthlyRent ?? item.monthlyRent ?? null
+}
+
+export const viewportMarkerKind = (item) => {
+ if (item?.type === VIEWPORT_ITEM_TYPES.REGION_AVG) return 'region'
+ if (item?.type === VIEWPORT_ITEM_TYPES.CLUSTER) return 'cluster'
+ return 'property'
+}
+
+export const viewportMarkerAnchor = (item) => {
+ const kind = viewportMarkerKind(item)
+ if (kind === 'region') return { x: 58, y: 54 }
+ if (kind === 'cluster') return { x: 42, y: 42 }
+ return { x: 42, y: 44 }
+}
+
+export const viewportMarkerLabel = (item, { formatWons, transactionLabel } = {}) => {
+ const formatPrice = formatWons || ((value) => String(value ?? '-'))
+ if (item?.type === VIEWPORT_ITEM_TYPES.REGION_AVG) {
+ const regionName = item.regionName || item.regionCode || '지역'
+ return {
+ eyebrow: item.regionLevel || 'REGION',
+ title: regionName,
+ value: formatPrice(getPrimaryPriceValue(item))
+ }
+ }
+
+ if (item?.type === VIEWPORT_ITEM_TYPES.CLUSTER) {
+ return {
+ eyebrow: `${Number(item.count || 0).toLocaleString()}개`,
+ title: '매물 묶음',
+ value: formatPrice(getPrimaryPriceValue(item))
+ }
+ }
+
+ return {
+ eyebrow: transactionLabel ? transactionLabel(item?.transactionType) : item?.transactionType || '매물',
+ title: '',
+ value: formatPrice(getPrimaryPriceValue(item))
+ }
+}
diff --git a/frontend/src/utils/mapViewport.test.mjs b/frontend/src/utils/mapViewport.test.mjs
new file mode 100644
index 0000000..7b4abde
--- /dev/null
+++ b/frontend/src/utils/mapViewport.test.mjs
@@ -0,0 +1,76 @@
+import assert from 'node:assert/strict'
+import { test } from 'node:test'
+
+import {
+ getPrimaryPriceValue,
+ isPropertyItem,
+ viewportMarkerAnchor,
+ viewportMarkerKind,
+ viewportMarkerLabel
+} from './mapViewport.js'
+
+const formatWons = (value) => `${value}`
+
+test('viewport utilities identify property items for side-list rendering', () => {
+ assert.equal(isPropertyItem({ type: 'PROPERTY', id: 1 }), true)
+ assert.equal(isPropertyItem({ type: 'REGION_AVG', regionName: '관악구' }), false)
+ assert.equal(isPropertyItem({ type: 'CLUSTER', count: 12 }), false)
+})
+
+test('region average marker uses region metadata and representative price', () => {
+ const item = {
+ type: 'REGION_AVG',
+ regionLevel: 'SIGUNGU',
+ regionName: '관악구',
+ avgDeposit: 98000000,
+ avgMonthlyRent: 620000
+ }
+
+ assert.equal(viewportMarkerKind(item), 'region')
+ assert.deepEqual(viewportMarkerAnchor(item), { x: 58, y: 54 })
+ assert.equal(getPrimaryPriceValue(item), 98000000)
+ assert.deepEqual(viewportMarkerLabel(item, { formatWons }), {
+ eyebrow: 'SIGUNGU',
+ title: '관악구',
+ value: '98000000'
+ })
+})
+
+test('cluster marker shows count and representative price', () => {
+ const item = {
+ type: 'CLUSTER',
+ count: 42,
+ avgDeposit: 12000000,
+ avgMonthlyRent: 580000
+ }
+
+ assert.equal(viewportMarkerKind(item), 'cluster')
+ assert.deepEqual(viewportMarkerAnchor(item), { x: 42, y: 42 })
+ assert.deepEqual(viewportMarkerLabel(item, { formatWons }), {
+ eyebrow: '42개',
+ title: '매물 묶음',
+ value: '12000000'
+ })
+})
+
+test('property marker keeps transaction label behavior', () => {
+ const item = {
+ type: 'PROPERTY',
+ transactionType: 'SALE',
+ price: 720000000
+ }
+
+ assert.equal(viewportMarkerKind(item), 'property')
+ assert.equal(getPrimaryPriceValue(item), 720000000)
+ assert.deepEqual(
+ viewportMarkerLabel(item, {
+ formatWons,
+ transactionLabel: (type) => (type === 'SALE' ? '매매' : type)
+ }),
+ {
+ eyebrow: '매매',
+ title: '',
+ value: '720000000'
+ }
+ )
+})
diff --git a/frontend/src/views/MapExplorer.vue b/frontend/src/views/MapExplorer.vue
index 7f5e811..0acca57 100644
--- a/frontend/src/views/MapExplorer.vue
+++ b/frontend/src/views/MapExplorer.vue
@@ -82,7 +82,7 @@
- {{ store.totalCount.toLocaleString() }}개 매물
+ {{ viewportCountLabel }}
{{ formattedFetchedAt }}
@@ -116,8 +116,8 @@
-
표시할 매물이 없습니다
-
지도를 조금 넓히거나 필터를 초기화해보세요.
+
{{ emptyListText.title }}
+
{{ emptyListText.description }}
@@ -234,6 +234,7 @@ import {
} from '@lucide/vue'
import useMapStore from '../store/mapStore'
import { loadNaverMaps } from '../utils/naverMaps'
+import { VIEWPORT_MODES, viewportMarkerAnchor, viewportMarkerKind, viewportMarkerLabel } from '../utils/mapViewport'
const store = useMapStore()
const mapElement = ref(null)
@@ -254,6 +255,32 @@ const formattedFetchedAt = computed(() => {
}).format(new Date(store.lastFetchedAt))
})
+const isPropertyMode = computed(() => store.viewportMode === VIEWPORT_MODES.PROPERTY_MARKER)
+
+const visibleMapItems = computed(() => (
+ isPropertyMode.value ? store.filteredProperties : store.viewportItems
+))
+
+const viewportCountLabel = computed(() => {
+ const count = store.totalCount.toLocaleString()
+ if (store.viewportMode === VIEWPORT_MODES.PROPERTY_CLUSTER) return `${count}개 그룹`
+ if ([VIEWPORT_MODES.SIGUNGU_AVG, VIEWPORT_MODES.DONG_AVG].includes(store.viewportMode)) return `${count}개 지역`
+ return `${count}개 매물`
+})
+
+const emptyListText = computed(() => {
+ if (!isPropertyMode.value && store.viewportItems.length > 0) {
+ return {
+ title: '상세 매물은 확대 후 표시됩니다',
+ description: '현재 줌에서는 지역 평균 또는 매물 묶음을 지도에 표시합니다.'
+ }
+ }
+ return {
+ title: '표시할 매물이 없습니다',
+ description: '지도를 조금 넓히거나 필터를 초기화해보세요.'
+ }
+})
+
const displayTitle = (property) => property.title || property.buildingName || `매물 ${property.id}`
const transactionLabel = (type) => ({
@@ -299,6 +326,13 @@ const floorText = (property) => {
return property.totalFloor ? `${floor} / ${property.totalFloor}층` : floor
}
+const escapeHtml = (value) => String(value ?? '')
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"')
+ .replaceAll("'", ''')
+
const getMapBounds = () => {
const bounds = map.getBounds()
const sw = bounds.getSW()
@@ -311,7 +345,7 @@ const getMapBounds = () => {
}
}
-const markerContent = (property, isSelected) => {
+const propertyMarkerContent = (property, isSelected) => {
const background = isSelected ? '#101311' : '#1ABC9C'
const label = property.transactionType === 'SALE' ? formatWons(property.price) : formatWons(property.deposit)
@@ -331,7 +365,7 @@ const markerContent = (property, isSelected) => {
cursor: pointer;
white-space: nowrap;
">
- ${transactionLabel(property.transactionType)} ${label}
+ ${escapeHtml(transactionLabel(property.transactionType))} ${escapeHtml(label)}
+ ${escapeHtml(label.title)}
+ ${escapeHtml(label.value)}
+
+
+ `
+}
+
+const clusterMarkerContent = (item) => {
+ const label = viewportMarkerLabel(item, { formatWons, transactionLabel })
+ return `
+
+ `
+}
+
+const markerContent = (item) => {
+ const kind = viewportMarkerKind(item)
+ if (kind === 'region') return regionMarkerContent(item)
+ if (kind === 'cluster') return clusterMarkerContent(item)
+ return propertyMarkerContent(item, store.selectedPropertyId === item.id)
+}
+
const clearMarkers = () => {
markerListeners.forEach((listener) => mapsApi?.Event.removeListener(listener))
markerListeners = []
@@ -358,23 +460,35 @@ const renderMarkers = () => {
if (!mapsApi || !map) return
clearMarkers()
- store.filteredProperties.forEach((property) => {
- if (!property.latitude || !property.longitude) return
+ visibleMapItems.value.forEach((item) => {
+ if (!item.latitude || !item.longitude) return
+ const anchor = viewportMarkerAnchor(item)
const marker = new mapsApi.Marker({
- position: new mapsApi.LatLng(property.latitude, property.longitude),
+ position: new mapsApi.LatLng(item.latitude, item.longitude),
map,
icon: {
- content: markerContent(property, store.selectedPropertyId === property.id),
- anchor: new mapsApi.Point(42, 44)
+ content: markerContent(item),
+ anchor: new mapsApi.Point(anchor.x, anchor.y)
}
})
- const listener = mapsApi.Event.addListener(marker, 'click', () => store.selectProperty(property.id))
+ const listener = mapsApi.Event.addListener(marker, 'click', () => handleMarkerClick(item))
markers.push(marker)
markerListeners.push(listener)
})
}
+const handleMarkerClick = (item) => {
+ if (item.type === 'PROPERTY') {
+ store.selectProperty(item.id)
+ return
+ }
+ if (!mapsApi || !map || !item.latitude || !item.longitude) return
+ map.panTo(new mapsApi.LatLng(item.latitude, item.longitude))
+ const zoomIncrement = item.type === 'CLUSTER' ? 1 : 2
+ map.setZoom(Math.min(21, map.getZoom() + zoomIncrement))
+}
+
const refreshFromMapBounds = async () => {
if (!map) {
await store.fetchViewport()
@@ -399,7 +513,7 @@ const resetFilters = async () => {
}
watch(
- [() => store.filteredProperties, () => store.selectedPropertyId],
+ [() => store.viewportItems, () => store.filteredProperties, () => store.selectedPropertyId],
() => renderMarkers(),
{ deep: true }
)
diff --git a/phases/map-viewport-zoom/phase4-frontend-zoom-rendering.md b/phases/map-viewport-zoom/phase4-frontend-zoom-rendering.md
index de47cc1..b63e27e 100644
--- a/phases/map-viewport-zoom/phase4-frontend-zoom-rendering.md
+++ b/phases/map-viewport-zoom/phase4-frontend-zoom-rendering.md
@@ -4,15 +4,17 @@
Render different marker styles for region averages, clusters, and individual properties so the map stays readable at every zoom level.
## Files
-- `frontend/src/components/map/*` - Add marker rendering for `REGION_AVG`, `CLUSTER`, and `PROPERTY`
-- `frontend/src/stores/*` - Maintain counts and selected item behavior per viewport mode
-- `frontend/src/utils/*` - Add formatting helpers if existing helpers are insufficient
+- `frontend/src/views/MapExplorer.vue` - Render `REGION_AVG`, `CLUSTER`, and `PROPERTY` markers by viewport item type
+- `frontend/src/store/mapStore.js` - Keep side-list properties derived from property viewport items only
+- `frontend/src/utils/mapViewport.js` - Add viewport item classification, label, and marker anchor helpers
+- `frontend/src/utils/mapViewport.test.mjs` - Verify marker type, label, price, anchor, and property-only list behavior
+- `frontend/package.json` - Run all frontend `.test.mjs` files
## Done When
-- [ ] broad zoom levels show region average markers instead of thousands of property labels.
-- [ ] cluster mode shows compact cluster markers with count and representative price.
-- [ ] detailed zoom shows individual property markers.
-- [ ] side list behavior remains useful and does not try to render region-average items as property cards.
+- [x] broad zoom levels show region average markers instead of thousands of property labels.
+- [x] cluster mode shows compact cluster markers with count and representative price.
+- [x] detailed zoom shows individual property markers.
+- [x] side list behavior remains useful and does not try to render region-average items as property cards.
## Architecture Rules
- UI follows `docs/04_UI_GUIDE.md`: region averages and clusters should reduce clutter and keep the map dominant.
diff --git a/phases/map-viewport-zoom/phase4.status.json b/phases/map-viewport-zoom/phase4.status.json
new file mode 100644
index 0000000..473b1dd
--- /dev/null
+++ b/phases/map-viewport-zoom/phase4.status.json
@@ -0,0 +1,8 @@
+{
+ "status": "completed",
+ "phase": "phase4-frontend-zoom-rendering.md",
+ "issue_number": 59,
+ "timestamp": "2026-06-24T16:29:04+09:00",
+ "detail": "Rendered region average, cluster, and property viewport items with distinct marker styles, preserved property-only side list behavior, and added viewport marker utility coverage.",
+ "runner": "codex"
+}