Files
Novel-Map/frontend/App.vue
2026-03-30 19:15:41 +08:00

708 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="map-container">
<div id="map"></div>
<div class="map-title">大唐双龙传 - 势力分布地图</div>
<!-- 势力图例 -->
<div class="legend">
<h3>势力图例</h3>
<div
v-for="faction in mergedFactions"
:key="faction.id"
class="legend-item"
@click="showFactionInfo(faction)"
>
<div class="legend-dot" :style="{ background: faction.color }"></div>
<span>{{ faction.name }}{{ faction.leader }}</span>
</div>
<div class="legend-section">
<h3>人物路线</h3>
<div
v-for="route in mergedRoutes"
:key="route.character"
class="legend-item"
@click="toggleRoute(route)"
>
<div
class="legend-line"
:style="{
background: route.color,
opacity: selectedRoutes.includes(route.character) ? 1 : 0.4
}"
></div>
<span :style="{ opacity: selectedRoutes.includes(route.character) ? 1 : 0.5 }">{{ route.character }}</span>
</div>
</div>
</div>
<!-- 卷进度面板 -->
<div class="chapter-panel">
<h3>卷册进度</h3>
<div class="vol-slider-wrap">
<input
type="range"
class="chapter-slider"
v-model.number="currentVol"
:min="1"
:max="20"
@input="onVolSliderChange"
>
<div class="vol-ticks">
<span
v-for="tick in volTicks"
:key="tick.vol"
class="tick-label"
:style="{ left: tick.pct + '%' }"
>{{ tick.label }}</span>
</div>
</div>
<div class="vol-info">
当前{{ currentVolLabel }} &nbsp;|&nbsp; 已加载 {{ loadedCount }} / 20
</div>
<!-- 关键事件列表 -->
<div class="events-section" v-if="currentKeyEvents.length">
<div class="events-title">本卷关键事件</div>
<div
v-for="(ev, idx) in currentKeyEvents"
:key="idx"
class="event-item"
@click="flyToEvent(ev)"
>
<span class="event-ch">{{ ev.chapter }}</span>
<span class="event-text">{{ ev.event }}</span>
</div>
</div>
</div>
<!-- 势力详情面板 -->
<div class="info-panel" v-if="selectedFaction">
<span class="close-btn" @click="selectedFaction = null">&times;</span>
<h3 :style="{ color: selectedFaction.color }">{{ selectedFaction.name }}</h3>
<p><b>首领</b>{{ selectedFaction.leader }}</p>
<p>{{ selectedFaction.description }}</p>
<p v-if="selectedFaction.territory && selectedFaction.territory.length">
<b>据点</b>{{ getTerritoryNames(selectedFaction.territory).join('、') }}
</p>
<p v-if="selectedFaction.key_figures && selectedFaction.key_figures.length">
<b>关键人物</b>{{ selectedFaction.key_figures.join('、') }}
</p>
</div>
<!-- 加载进度 -->
<div class="loading-bar" v-if="isLoading">
<div class="loading-inner" :style="{ width: (loadedCount / 20 * 100) + '%' }"></div>
<span class="loading-text">加载数据中 {{ loadedCount }}/20 </span>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
// ── 响应式状态 ──────────────────────────────────────────────
const allData = ref([]) // 原始卷数据index 0 = vol01
const currentVol = ref(1) // 1-20
const selectedRoutes = ref([])
const selectedFaction = ref(null)
const isLoading = ref(true)
const loadedCount = ref(0)
// ── Leaflet 图层管理 ────────────────────────────────────────
let map = null
let layerGroups = {
territories: null,
locations: null,
routes: null,
events: null
}
// ── 滑块刻度 ────────────────────────────────────────────────
const volTicks = [
{ vol: 1, label: '卷一', pct: 0 },
{ vol: 5, label: '卷五', pct: 21.05 },
{ vol: 10, label: '卷十', pct: 47.37 },
{ vol: 15, label: '卷十五', pct: 73.68 },
{ vol: 20, label: '卷二十', pct: 100 }
]
// ── 计算属性:当前卷标签 ────────────────────────────────────
const currentVolLabel = computed(() => {
const d = allData.value[currentVol.value - 1]
return d ? d.volume : `${currentVol.value}`
})
// ── 跨卷合并数据(截至 currentVol──────────────────────────
// 地点:按 id 去重,取最新(最大卷号)的定义
const mergedLocations = computed(() => {
const map = new Map()
for (let i = 0; i < currentVol.value; i++) {
const d = allData.value[i]
if (!d) continue
for (const loc of (d.locations || [])) {
map.set(loc.id, loc)
}
}
return Array.from(map.values())
})
// 势力:按 id 去重,取最新卷定义
const mergedFactions = computed(() => {
const map = new Map()
for (let i = 0; i < currentVol.value; i++) {
const d = allData.value[i]
if (!d) continue
for (const f of (d.factions || [])) {
map.set(f.id, f)
}
}
return Array.from(map.values())
})
// 人物路线:同名人物路点合并,每个点附带 vol 标记
const mergedRoutes = computed(() => {
const charMap = new Map()
for (let i = 0; i < currentVol.value; i++) {
const d = allData.value[i]
if (!d) continue
const vol = i + 1
for (const r of (d.character_routes || [])) {
const name = r.character
if (!charMap.has(name)) {
charMap.set(name, { character: name, color: r.color, route: [] })
}
const existing = charMap.get(name)
// 合并颜色(取最新)
existing.color = r.color
for (const pt of (r.route || [])) {
existing.route.push({ ...pt, vol })
}
}
}
return Array.from(charMap.values())
})
// 当前卷关键事件
const currentKeyEvents = computed(() => {
const d = allData.value[currentVol.value - 1]
return d ? (d.key_events || []) : []
})
// ── 生命周期 ────────────────────────────────────────────────
onMounted(async () => {
initMap()
await loadAllData()
drawAll()
})
// 监听 currentVol 变化重绘
watch(currentVol, () => {
drawAll()
})
// ── 数据加载 ────────────────────────────────────────────────
async function loadAllData() {
isLoading.value = true
const promises = []
for (let i = 1; i <= 20; i++) {
const volStr = String(i).padStart(2, '0')
promises.push(
fetch(`/data/vol${volStr}.json`)
.then(r => r.json())
.then(data => {
// 按索引顺序写入(保证位置正确)
allData.value[i - 1] = data
loadedCount.value++
})
.catch(err => {
console.warn(`vol${volStr}.json 加载失败`, err)
loadedCount.value++
})
)
}
await Promise.all(promises)
isLoading.value = false
// 触发响应式更新
allData.value = [...allData.value]
}
// ── 地图初始化 ──────────────────────────────────────────────
function initMap() {
map = L.map('map', {
center: [33.0, 113.0],
zoom: 6,
zoomControl: true,
attributionControl: false
})
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
maxZoom: 18,
subdomains: 'abcd'
}).addTo(map)
L.control.attribution({ prefix: false, position: 'bottomright' })
.addAttribution('大唐双龙传 - 黄易')
.addTo(map)
// 创建图层组
layerGroups.territories = L.layerGroup().addTo(map)
layerGroups.locations = L.layerGroup().addTo(map)
layerGroups.routes = L.layerGroup().addTo(map)
layerGroups.events = L.layerGroup().addTo(map)
}
// ── 全量重绘 ────────────────────────────────────────────────
function drawAll() {
if (!map) return
clearLayerGroups()
drawTerritories()
drawLocations()
drawRoutes()
}
function clearLayerGroups() {
Object.values(layerGroups).forEach(lg => lg && lg.clearLayers())
}
// ── 绘制势力领地 ────────────────────────────────────────────
function drawTerritories() {
const locs = mergedLocations.value
mergedFactions.value.forEach(f => {
if (!f.territory || !f.territory.length) return
f.territory.forEach(tid => {
const loc = locs.find(l => l.id === tid)
if (!loc) return
const circle = L.circle([loc.lat, loc.lng], {
radius: 50000,
color: f.color,
fillColor: f.color,
fillOpacity: 0.12,
weight: 2,
opacity: 0.5,
dashArray: '6 4'
})
layerGroups.territories.addLayer(circle)
const label = L.marker([loc.lat + 0.35, loc.lng], {
icon: L.divIcon({
className: '',
html: `<div style="color:${f.color};font-size:11px;font-weight:bold;text-shadow:0 0 4px rgba(0,0,0,0.8);white-space:nowrap;text-align:center">${f.name}</div>`,
iconAnchor: [30, 8]
})
})
layerGroups.territories.addLayer(label)
})
})
}
// ── 绘制地点标记 ────────────────────────────────────────────
function drawLocations() {
const locs = mergedLocations.value
const facs = mergedFactions.value
locs.forEach(loc => {
let markerColor = '#aaa'
for (const f of facs) {
if (f.territory && f.territory.includes(loc.id)) {
markerColor = f.color
break
}
}
const size = loc.type === 'city' ? 10 : loc.type === 'landmark' ? 8 : 6
const icon = L.divIcon({
className: '',
html: `<div style="width:${size}px;height:${size}px;background:${markerColor};border:2px solid #fff;border-radius:50%;box-shadow:0 0 6px ${markerColor}"></div>`,
iconSize: [size, size],
iconAnchor: [size / 2, size / 2]
})
const marker = L.marker([loc.lat, loc.lng], { icon })
.bindPopup(`<b>${loc.name}</b><br>${loc.description || ''}`)
layerGroups.locations.addLayer(marker)
const nameLabel = L.marker([loc.lat - 0.15, loc.lng], {
icon: L.divIcon({
className: '',
html: `<div style="color:#ccc;font-size:11px;text-shadow:0 0 3px #000,0 0 6px #000;white-space:nowrap;text-align:center">${loc.name}</div>`,
iconAnchor: [30, 0]
})
})
layerGroups.locations.addLayer(nameLabel)
})
}
// ── 绘制人物路线 ────────────────────────────────────────────
function drawRoutes() {
const locs = mergedLocations.value
mergedRoutes.value.forEach(route => {
if (!selectedRoutes.value.includes(route.character)) return
const points = route.route || []
const resolved = points.map(p => {
const coords = getPointCoords(p, locs)
return coords ? { ...coords, chapter: p.chapter, vol: p.vol, event: p.event } : null
}).filter(Boolean)
for (let i = 0; i < resolved.length - 1; i++) {
const p1 = resolved[i]
const p2 = resolved[i + 1]
const line = L.polyline([[p1.lat, p1.lng], [p2.lat, p2.lng]], {
color: route.color,
weight: 3,
opacity: 0.8
})
layerGroups.routes.addLayer(line)
const midLat = (p1.lat + p2.lat) / 2
const midLng = (p1.lng + p2.lng) / 2
const angle = Math.atan2(p2.lat - p1.lat, p2.lng - p1.lng) * (180 / Math.PI)
const arrow = L.marker([midLat, midLng], {
icon: L.divIcon({
className: '',
html: `<div style="color:${route.color};font-size:12px;font-weight:bold;transform:rotate(${angle}deg);transform-origin:center;text-shadow:0 0 3px rgba(0,0,0,0.8)">→</div>`,
iconAnchor: [6, 6]
})
})
layerGroups.routes.addLayer(arrow)
}
resolved.forEach((p, idx) => {
const isEnd = idx === resolved.length - 1
const isStart = idx === 0
const size = (isStart || isEnd) ? 7 : 5
const dot = L.circleMarker([p.lat, p.lng], {
radius: size,
color: route.color,
fillColor: isEnd ? '#fff' : route.color,
fillOpacity: 0.9,
weight: 2
}).bindPopup(`<b>${route.character}</b><br>卷${p.vol || '?'}${p.chapter}章<br>${p.event || ''}`)
layerGroups.routes.addLayer(dot)
})
})
}
// ── 工具函数 ────────────────────────────────────────────────
function getPointCoords(point, locs) {
if (point.lat && point.lng) {
return { lat: point.lat, lng: point.lng }
}
if (point.location) {
const loc = locs.find(l => l.id === point.location)
if (loc) return { lat: loc.lat, lng: loc.lng }
}
return null
}
function toggleRoute(route) {
const idx = selectedRoutes.value.indexOf(route.character)
if (idx > -1) {
selectedRoutes.value.splice(idx, 1)
} else {
selectedRoutes.value.push(route.character)
}
layerGroups.routes.clearLayers()
drawRoutes()
}
function showFactionInfo(faction) {
selectedFaction.value = faction
}
function getTerritoryNames(territoryIds) {
return territoryIds.map(tid => {
const loc = mergedLocations.value.find(l => l.id === tid)
return loc ? loc.name : tid
})
}
function onVolSliderChange() {
// currentVol 已由 v-model 更新watch 会触发 drawAll
}
function flyToEvent(ev) {
const locs = mergedLocations.value
let coords = null
if (ev.lat && ev.lng) {
coords = [ev.lat, ev.lng]
} else if (ev.location) {
const loc = locs.find(l => l.id === ev.location)
if (loc) coords = [loc.lat, loc.lng]
}
if (coords && map) {
map.flyTo(coords, 8, { duration: 1.2 })
}
}
</script>
<style scoped>
.map-container {
position: relative;
width: 100%;
height: 100vh;
overflow: hidden;
}
#map {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
}
.map-title {
position: absolute;
top: 10px;
left: 50%;
transform: translateX(-50%);
z-index: 1000;
background: rgba(20, 20, 40, 0.9);
padding: 8px 24px;
border-radius: 8px;
border: 1px solid #c9a96e;
font-size: 20px;
font-weight: bold;
color: #c9a96e;
letter-spacing: 4px;
text-shadow: 0 0 10px rgba(201, 169, 110, 0.5);
pointer-events: none;
white-space: nowrap;
}
/* ====== 势力图例 ====== */
.legend {
position: absolute;
bottom: 20px;
left: 10px;
z-index: 1000;
background: rgba(20, 20, 40, 0.92);
padding: 12px 16px;
border-radius: 8px;
border: 1px solid #555;
max-width: 220px;
max-height: calc(100vh - 60px);
overflow-y: auto;
font-size: 13px;
line-height: 1.6;
color: #e0e0e0;
}
.legend h3 {
color: #c9a96e;
margin-bottom: 8px;
font-size: 14px;
border-bottom: 1px solid #444;
padding-bottom: 4px;
}
.legend-item {
display: flex;
align-items: center;
margin: 3px 0;
cursor: pointer;
}
.legend-item:hover {
opacity: 0.7;
}
.legend-dot {
width: 12px;
height: 12px;
border-radius: 50%;
margin-right: 8px;
flex-shrink: 0;
border: 1px solid rgba(255, 255, 255, 0.3);
}
.legend-line {
width: 20px;
height: 3px;
margin-right: 8px;
flex-shrink: 0;
border-radius: 2px;
}
.legend-section {
margin-top: 8px;
}
/* ====== 卷册面板 ====== */
.chapter-panel {
position: absolute;
bottom: 20px;
right: 10px;
z-index: 1000;
background: rgba(20, 20, 40, 0.92);
padding: 12px 16px;
border-radius: 8px;
border: 1px solid #555;
width: 340px;
max-height: calc(100vh - 60px);
overflow-y: auto;
color: #e0e0e0;
}
.chapter-panel h3 {
color: #c9a96e;
margin-bottom: 8px;
font-size: 14px;
border-bottom: 1px solid #444;
padding-bottom: 4px;
}
.vol-slider-wrap {
position: relative;
padding-bottom: 24px;
margin-bottom: 4px;
}
.chapter-slider {
width: 100%;
margin: 8px 0 0;
accent-color: #c9a96e;
}
.vol-ticks {
position: relative;
height: 20px;
margin-top: 2px;
}
.tick-label {
position: absolute;
transform: translateX(-50%);
font-size: 11px;
color: #888;
white-space: nowrap;
}
.vol-info {
font-size: 12px;
color: #aaa;
text-align: center;
margin-bottom: 8px;
}
/* ====== 关键事件 ====== */
.events-section {
border-top: 1px solid #444;
padding-top: 8px;
margin-top: 4px;
}
.events-title {
font-size: 12px;
color: #c9a96e;
margin-bottom: 6px;
font-weight: bold;
}
.event-item {
display: flex;
gap: 6px;
margin: 4px 0;
cursor: pointer;
padding: 3px 4px;
border-radius: 4px;
font-size: 12px;
line-height: 1.5;
}
.event-item:hover {
background: rgba(201, 169, 110, 0.12);
}
.event-ch {
color: #c9a96e;
white-space: nowrap;
flex-shrink: 0;
}
.event-text {
color: #ccc;
}
/* ====== 势力详情面板 ====== */
.info-panel {
position: absolute;
top: 60px;
right: 10px;
z-index: 1000;
background: rgba(20, 20, 40, 0.92);
padding: 12px 16px;
border-radius: 8px;
border: 1px solid #555;
width: 340px;
max-height: 50vh;
overflow-y: auto;
color: #e0e0e0;
}
.info-panel h3 {
color: #c9a96e;
margin-bottom: 6px;
font-size: 15px;
}
.info-panel .close-btn {
position: absolute;
top: 8px;
right: 12px;
cursor: pointer;
color: #aaa;
font-size: 16px;
}
.info-panel .close-btn:hover {
color: #fff;
}
.info-panel p {
font-size: 13px;
line-height: 1.6;
margin: 4px 0;
}
/* ====== 加载进度条 ====== */
.loading-bar {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: rgba(20, 20, 40, 0.6);
z-index: 2000;
overflow: hidden;
}
.loading-inner {
height: 100%;
background: #c9a96e;
transition: width 0.3s ease;
}
.loading-text {
position: absolute;
top: 6px;
left: 50%;
transform: translateX(-50%);
font-size: 12px;
color: #c9a96e;
background: rgba(20, 20, 40, 0.85);
padding: 2px 10px;
border-radius: 4px;
white-space: nowrap;
}
</style>