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 @@
@@ -116,8 +116,8 @@
@@ -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" +}