feat: Enhance MapViewer with city detail and basic city components; integrate Pinia store for state management
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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 구현 및 완료 후 상태 갱신(장수 생성 감지)
|
||||
- 화면 라우트 매핑 표 및 데이터 계약 문서화
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user