feat: Enhance MapViewer with city detail and basic city components; integrate Pinia store for state management

This commit is contained in:
2026-01-16 16:51:35 +00:00
parent 4f44e76ef1
commit 5af2799662
6 changed files with 325 additions and 79 deletions
@@ -0,0 +1,76 @@
<script setup lang="ts">
interface MapCityView {
id: number;
name: string;
level: number;
state: number;
nationId: number;
nationName: string;
color: string;
x: number;
y: number;
isCapital: boolean;
isMyCity: boolean;
}
const props = defineProps<{
city: MapCityView;
showName: boolean;
}>();
const emit = defineEmits<{
(event: 'hover', cityId: number): void;
(event: 'leave'): void;
}>();
</script>
<template>
<div
class="map-city"
:class="{ mine: props.city.isMyCity }"
:style="{ left: `${props.city.x}px`, top: `${props.city.y}px` }"
@mouseenter="emit('hover', props.city.id)"
@mouseleave="emit('leave')"
>
<div class="city-dot" :style="{ backgroundColor: props.city.color }">
<span v-if="props.city.isCapital" class="capital" />
</div>
<div v-if="props.showName" class="city-name">{{ props.city.name }}</div>
</div>
</template>
<style scoped>
.map-city {
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
transform: translate(-50%, -50%);
font-size: 0.65rem;
color: rgba(232, 221, 196, 0.8);
}
.city-dot {
width: 12px;
height: 12px;
border: 1px solid rgba(232, 221, 196, 0.6);
display: flex;
align-items: center;
justify-content: center;
}
.capital {
width: 6px;
height: 6px;
background: rgba(232, 221, 196, 0.9);
}
.map-city.mine .city-dot {
box-shadow: 0 0 0 2px rgba(201, 164, 90, 0.6);
}
.city-name {
white-space: nowrap;
}
</style>
@@ -0,0 +1,91 @@
<script setup lang="ts">
interface MapCityView {
id: number;
name: string;
level: number;
state: number;
nationId: number;
nationName: string;
color: string;
x: number;
y: number;
isCapital: boolean;
isMyCity: boolean;
}
const props = defineProps<{
city: MapCityView;
showName: boolean;
}>();
const emit = defineEmits<{
(event: 'hover', cityId: number): void;
(event: 'leave'): void;
}>();
</script>
<template>
<div
class="map-city detail"
:class="{ mine: props.city.isMyCity }"
:style="{ left: `${props.city.x}px`, top: `${props.city.y}px` }"
@mouseenter="emit('hover', props.city.id)"
@mouseleave="emit('leave')"
>
<div class="city-card">
<div class="header">
<span class="dot" :style="{ backgroundColor: props.city.color }" />
<span class="name">{{ props.city.name }}</span>
<span v-if="props.city.isCapital" class="capital">수도</span>
</div>
<div class="meta">Lv {{ props.city.level }} · {{ props.city.nationName }}</div>
</div>
</div>
</template>
<style scoped>
.map-city.detail {
position: absolute;
transform: translate(-50%, -50%);
font-size: 0.65rem;
}
.city-card {
border: 1px solid rgba(201, 164, 90, 0.4);
background: rgba(16, 16, 16, 0.8);
padding: 4px 6px;
display: flex;
flex-direction: column;
gap: 2px;
}
.header {
display: flex;
align-items: center;
gap: 4px;
}
.dot {
width: 10px;
height: 10px;
border: 1px solid rgba(232, 221, 196, 0.6);
}
.name {
font-weight: 600;
color: rgba(232, 221, 196, 0.9);
}
.capital {
font-size: 0.6rem;
color: rgba(232, 221, 196, 0.7);
}
.meta {
color: rgba(232, 221, 196, 0.6);
}
.map-city.detail.mine .city-card {
box-shadow: 0 0 0 1px rgba(201, 164, 90, 0.7);
}
</style>
@@ -1,6 +1,11 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { storeToRefs } from 'pinia';
import { useMediaQuery, useMouseInElement } from '@vueuse/core';
import SkeletonLines from '../ui/SkeletonLines.vue';
import MapCityBasic from './MapCityBasic.vue';
import MapCityDetail from './MapCityDetail.vue';
import { useMapViewerStore } from '../../stores/mapViewer';
interface MapSummary {
year: number;
@@ -11,42 +16,80 @@ interface MapSummary {
myNation?: number | null;
}
interface CityView {
id: number;
name: string;
level: number;
state: number;
nationId: number;
nationName: string;
color: string;
region: number;
supply: boolean;
x: number;
y: number;
isCapital: boolean;
isMyCity: boolean;
}
const props = defineProps<{
mapData: MapSummary | null;
loading: boolean;
}>();
const showCityName = ref(true);
const detailMode = ref(false);
const isWide = useMediaQuery('(min-width: 1024px)');
const mapStore = useMapViewerStore();
const { showCityName, detailMode, hoveredCityId } = storeToRefs(mapStore);
const mapArea = ref<HTMLElement | null>(null);
const { elementX, elementY } = useMouseInElement(mapArea);
const nationById = computed(() => {
const map = new Map<number, { name: string; color: string }>();
const map = new Map<number, { name: string; color: string; capitalCityId: number }>();
if (!props.mapData) {
return map;
}
for (const nation of props.mapData.nationList) {
const [id, name, color] = nation;
map.set(id, { name, color });
const [id, name, color, capitalCityId] = nation;
map.set(id, {
name,
color,
capitalCityId: capitalCityId ?? 0,
});
}
return map;
});
const cityEntries = computed(() => {
const cityViews = computed<CityView[]>(() => {
if (!props.mapData) {
return [];
}
return props.mapData.cityList.map((entry) => {
const columns = isWide.value ? 12 : 8;
const spacing = isWide.value ? 46 : 36;
return props.mapData.cityList.map((entry, index) => {
const [id, level, state, nationId, region, supplyFlag] = entry;
const nation = nationById.value.get(nationId);
const column = index % columns;
const row = Math.floor(index / columns);
const x = column * spacing + 20 + (region % 3) * 6;
const y = row * spacing + 20 + (region % 4) * 4;
return {
id,
name: `도시 ${id}`,
level,
state,
nationId,
region,
supply: supplyFlag > 0,
nationName: nation?.name ?? '무주',
color: nation?.color ?? '#444444',
region,
supply: supplyFlag > 0,
x,
y,
isCapital: nation?.capitalCityId === id,
isMyCity: props.mapData?.myCity === id,
};
});
});
@@ -57,6 +100,27 @@ const mapSummary = computed(() => {
}
return `${props.mapData.year}${props.mapData.month}`;
});
const mapHeight = computed(() => {
if (!cityViews.value.length) {
return '240px';
}
const columns = isWide.value ? 12 : 8;
const rows = Math.ceil(cityViews.value.length / columns);
const spacing = isWide.value ? 46 : 36;
return `${rows * spacing + 40}px`;
});
const hoveredCity = computed(() => {
if (!hoveredCityId.value) {
return null;
}
return cityViews.value.find((city) => city.id === hoveredCityId.value) ?? null;
});
const setHoveredCity = (cityId: number | null) => {
mapStore.setHoveredCity(cityId);
};
</script>
<template>
@@ -64,10 +128,10 @@ const mapSummary = computed(() => {
<div class="map-top">
<div class="map-title">{{ mapSummary }}</div>
<div class="map-controls">
<button class="map-toggle" :class="{ active: showCityName }" @click="showCityName = !showCityName">
<button class="map-toggle" :class="{ active: showCityName }" @click="mapStore.toggleCityName">
도시명
</button>
<button class="map-toggle" :class="{ active: detailMode }" @click="detailMode = !detailMode">
<button class="map-toggle" :class="{ active: detailMode }" @click="mapStore.toggleDetailMode">
상세
</button>
</div>
@@ -79,26 +143,28 @@ const mapSummary = computed(() => {
지도 데이터를 불러오지 못했습니다.
</div>
<div v-else class="map-body">
<div class="map-placeholder">
<div class="map-placeholder-text">레거시 지도 렌더러 이식 대기</div>
<div class="map-meta">
<span>도시 {{ props.mapData.cityList.length }}</span>
<span>세력 {{ props.mapData.nationList.length }}</span>
<div ref="mapArea" class="map-area" :style="{ height: mapHeight }">
<div class="map-placeholder">지도 렌더러 이식 </div>
<component
:is="detailMode ? MapCityDetail : MapCityBasic"
v-for="city in cityViews"
:key="city.id"
:city="city"
:show-name="showCityName"
@hover="setHoveredCity"
@leave="setHoveredCity(null)"
/>
<div v-if="hoveredCity" class="map-tooltip" :style="{ left: `${elementX + 16}px`, top: `${elementY + 16}px` }">
<div class="tooltip-title">{{ hoveredCity.name }}</div>
<div class="tooltip-body">{{ hoveredCity.nationName }} · Lv {{ hoveredCity.level }}</div>
</div>
</div>
<div class="city-list">
<div
v-for="city in cityEntries.slice(0, detailMode ? 20 : 10)"
:key="city.id"
class="city-row"
:class="{ mine: props.mapData.myCity === city.id }"
>
<span class="nation" :style="{ backgroundColor: city.color }" />
<span class="name">{{ showCityName ? `도시 ${city.id}` : `#${city.id}` }}</span>
<span class="meta">Lv {{ city.level }} · 지역 {{ city.region }}</span>
<span class="state">보급 {{ city.supply ? 'O' : 'X' }}</span>
</div>
<div v-if="cityEntries.length === 0" class="city-empty">표시할 도시가 없습니다.</div>
<div class="map-meta">
<span>도시 {{ props.mapData.cityList.length }}</span>
<span>세력 {{ props.mapData.nationList.length }}</span>
</div>
<div class="map-footnote">
도시명/좌표 데이터는 추후 서버 API로 치환 예정
</div>
</div>
</div>
@@ -140,22 +206,41 @@ const mapSummary = computed(() => {
}
.map-body {
display: grid;
gap: 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.map-area {
position: relative;
border: 1px dashed rgba(201, 164, 90, 0.4);
background: rgba(16, 16, 16, 0.6);
overflow: hidden;
}
.map-placeholder {
border: 1px dashed rgba(201, 164, 90, 0.4);
padding: 12px;
display: flex;
flex-direction: column;
gap: 6px;
background: rgba(16, 16, 16, 0.6);
position: absolute;
top: 8px;
left: 8px;
font-size: 0.7rem;
color: rgba(232, 221, 196, 0.6);
}
.map-placeholder-text {
font-size: 0.85rem;
color: rgba(232, 221, 196, 0.8);
.map-tooltip {
position: absolute;
pointer-events: none;
border: 1px solid rgba(201, 164, 90, 0.4);
background: rgba(16, 16, 16, 0.9);
padding: 4px 6px;
font-size: 0.65rem;
}
.tooltip-title {
font-weight: 600;
}
.tooltip-body {
color: rgba(232, 221, 196, 0.6);
}
.map-meta {
@@ -165,44 +250,9 @@ const mapSummary = computed(() => {
color: rgba(232, 221, 196, 0.6);
}
.city-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.city-row {
display: grid;
grid-template-columns: 14px 1fr auto auto;
gap: 8px;
align-items: center;
padding: 4px 6px;
border: 1px solid rgba(201, 164, 90, 0.2);
font-size: 0.75rem;
}
.city-row.mine {
background: rgba(201, 164, 90, 0.15);
}
.city-row .nation {
width: 12px;
height: 12px;
border: 1px solid rgba(232, 221, 196, 0.6);
}
.city-row .name {
color: rgba(232, 221, 196, 0.9);
}
.city-row .meta,
.city-row .state {
color: rgba(232, 221, 196, 0.6);
}
.city-empty {
font-size: 0.8rem;
color: rgba(232, 221, 196, 0.6);
.map-footnote {
font-size: 0.65rem;
color: rgba(232, 221, 196, 0.5);
}
.map-empty {
+26
View File
@@ -0,0 +1,26 @@
import { defineStore } from 'pinia';
interface MapViewerState {
showCityName: boolean;
detailMode: boolean;
hoveredCityId: number | null;
}
export const useMapViewerStore = defineStore('mapViewer', {
state: (): MapViewerState => ({
showCityName: true,
detailMode: false,
hoveredCityId: null,
}),
actions: {
toggleCityName() {
this.showCityName = !this.showCityName;
},
toggleDetailMode() {
this.detailMode = !this.detailMode;
},
setHoveredCity(cityId: number | null) {
this.hoveredCityId = cityId;
},
},
});
+3 -1
View File
@@ -125,13 +125,15 @@
- 라우트: Public/Login/Join/Main 기본 가드 및 분기 처리
- 메인 화면 스켈레톤: 지도/명령/장수/도시/국가/메시지 패널 + 반응형 레이아웃 + 실시간 토글 UI
- API 보강: 게임 API에 `general.me` 추가 (메인 화면 컨텍스트 제공)
- MapViewer 1차 이식: 지도 토글/툴팁/도시 마커/디테일 모드와 Pinia 상태 연결
## Next Frontend Tasks
- 게이트웨이 로그인/프로필 선택 플로우 정리 (토큰 전달 방식, 자동 로그인, 쿠키 기반 전환 고려)
- 공개(Public) 화면 구현: 캐싱된 지도/중원정세/세력일람 + 제한된 장수일람
- 실시간 업데이트(SSE) 연결 설계 및 메인 화면 토글과 연동
- 레거시 주요 컴포넌트 이식(MapViewer/CommandSelectForm/MessagePanel 등)
- MapViewer 데이터 보강: 도시명/좌표 제공 API 및 레거시 맵 렌더링 이식
- CommandSelectForm/MessagePanel 이식 마무리(예약/전송 플로우 연결)
- Join/빙의 UI 구현 및 완료 후 상태 갱신(장수 생성 감지)
- 화면 라우트 매핑 표 및 데이터 계약 문서화
+1
View File
@@ -22,6 +22,7 @@ Move items into the main docs once they are finalized.
- [AI suggestion] Implement Public 화면: 캐싱된 지도/중원정세/세력일람 + 제한된 장수일람 API/뷰.
- [AI suggestion] Define main screen SSE contract + 실시간 동기화 토글 연동 (지도/명령/도시/국가/장수/메시지/동향/기록).
- [AI suggestion] Port legacy main UI components into `app/game-frontend` (MapViewer, CommandSelectForm, MessagePanel 등).
- [AI suggestion] Provide map city name/position data for MapViewer (API or scenario export) and replace placeholder layout.
- [AI suggestion] Implement join/빙의 UI and post-creation refresh flow.
- [AI suggestion] Build and maintain a legacy-to-SPA route mapping table with data requirements.