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
2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/store/mapStore.js
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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()

Expand Down
58 changes: 58 additions & 0 deletions frontend/src/utils/mapViewport.js
Original file line number Diff line number Diff line change
@@ -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))
}
}
76 changes: 76 additions & 0 deletions frontend/src/utils/mapViewport.test.mjs
Original file line number Diff line number Diff line change
@@ -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'
}
)
})
138 changes: 126 additions & 12 deletions frontend/src/views/MapExplorer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@

<div class="absolute bottom-3 left-3 z-20 flex flex-wrap items-center gap-2 rounded-2xl border border-slate-200 bg-white/95 px-3 py-2 text-xs font-bold text-slate-600 shadow-sm backdrop-blur">
<MapPin class="h-4 w-4 text-brand" aria-hidden="true" />
<span>{{ store.totalCount.toLocaleString() }}개 매물</span>
<span>{{ viewportCountLabel }}</span>
<span v-if="store.lastFetchedAt" class="text-slate-400">{{ formattedFetchedAt }}</span>
</div>
</section>
Expand Down Expand Up @@ -116,8 +116,8 @@
<div v-else-if="store.filteredProperties.length === 0" class="flex h-full items-center justify-center p-6 text-center">
<div>
<Home class="mx-auto h-8 w-8 text-slate-300" aria-hidden="true" />
<p class="mt-3 text-sm font-black text-slate-900">표시할 매물이 없습니다</p>
<p class="mt-2 text-xs leading-5 text-slate-500">지도를 조금 넓히거나 필터를 초기화해보세요.</p>
<p class="mt-3 text-sm font-black text-slate-900">{{ emptyListText.title }}</p>
<p class="mt-2 text-xs leading-5 text-slate-500">{{ emptyListText.description }}</p>
</div>
</div>

Expand Down Expand Up @@ -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)
Expand All @@ -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) => ({
Expand Down Expand Up @@ -299,6 +326,13 @@ const floorText = (property) => {
return property.totalFloor ? `${floor} / ${property.totalFloor}층` : floor
}

const escapeHtml = (value) => String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;')

const getMapBounds = () => {
const bounds = map.getBounds()
const sw = bounds.getSW()
Expand All @@ -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)

Expand All @@ -331,7 +365,7 @@ const markerContent = (property, isSelected) => {
cursor: pointer;
white-space: nowrap;
">
${transactionLabel(property.transactionType)} ${label}
${escapeHtml(transactionLabel(property.transactionType))} ${escapeHtml(label)}
<span style="
position: absolute;
left: 50%;
Expand All @@ -347,6 +381,74 @@ const markerContent = (property, isSelected) => {
`
}

const regionMarkerContent = (item) => {
const label = viewportMarkerLabel(item, { formatWons, transactionLabel })
return `
<button type="button" style="
position: relative;
max-width: 116px;
border: 2px solid #fff;
border-radius: 12px;
background: #17283A;
color: #fff;
padding: 7px 10px 8px;
font-size: 11px;
font-weight: 900;
line-height: 1.15;
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.24);
cursor: pointer;
text-align: center;
white-space: nowrap;
">
<span style="display:block; overflow:hidden; text-overflow:ellipsis;">${escapeHtml(label.title)}</span>
<span style="display:block; margin-top:3px; color:#D9F3DD;">${escapeHtml(label.value)}</span>
<span style="
position: absolute;
left: 50%;
bottom: -6px;
width: 10px;
height: 10px;
transform: translateX(-50%) rotate(45deg);
background: #17283A;
border-right: 2px solid #fff;
border-bottom: 2px solid #fff;
"></span>
</button>
`
}

const clusterMarkerContent = (item) => {
const label = viewportMarkerLabel(item, { formatWons, transactionLabel })
return `
<button type="button" style="
width: 84px;
height: 84px;
border: 3px solid #fff;
border-radius: 999px;
background: rgba(26, 188, 156, 0.92);
color: #fff;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
box-shadow: 0 14px 34px rgba(15, 23, 42, 0.22);
cursor: pointer;
text-align: center;
line-height: 1.1;
">
<span style="font-size: 16px; font-weight: 950;">${escapeHtml(label.eyebrow)}</span>
<span style="margin-top:4px; max-width:68px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size: 11px; font-weight: 900;">${escapeHtml(label.value)}</span>
</button>
`
}

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 = []
Expand All @@ -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()
Expand All @@ -399,7 +513,7 @@ const resetFilters = async () => {
}

watch(
[() => store.filteredProperties, () => store.selectedPropertyId],
[() => store.viewportItems, () => store.filteredProperties, () => store.selectedPropertyId],
() => renderMarkers(),
{ deep: true }
)
Expand Down
Loading