Files
Novel-Map/frontend/App.vue
2026-03-31 16:55:26 +08:00

743 lines
20 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 visibleRouteDefs"
: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="63"
@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 }} / 63
</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 / 63 * 100) + '%' }"></div>
<span class="loading-text">加载数据中 {{ loadedCount }}/63 </span>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
// ── 常量 ────────────────────────────────────────────────────
const TOTAL_VOLS = 63
// 分离路线定义(全卷 1-63 使用)
const KOUZHONG_NAME = '寇仲'
const KOUZHONG_COLOR = '#FF4500'
const XUZILING_NAME = '徐子陵'
const XUZILING_COLOR = '#FFA07A'
// ── 响应式状态 ──────────────────────────────────────────────
const allData = ref([]) // 原始卷数据index 0 = vol01
const currentVol = ref(1) // 1-63
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
}
// ── 滑块刻度每约10卷一个─────────────────────────────────
// 滑块范围 1-63共 62 步
// pct = (vol - 1) / (63 - 1) * 100
const volTicks = [
{ vol: 1, label: '卷一', pct: 0 },
{ vol: 10, label: '卷十', pct: (9 / 62 * 100).toFixed(3) * 1 },
{ vol: 20, label: '卷二十', pct: (19 / 62 * 100).toFixed(3) * 1 },
{ vol: 30, label: '卷三十', pct: (29 / 62 * 100).toFixed(3) * 1 },
{ vol: 40, label: '卷四十', pct: (39 / 62 * 100).toFixed(3) * 1 },
{ vol: 50, label: '卷五十', pct: (49 / 62 * 100).toFixed(3) * 1 },
{ vol: 63, 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 locMap = new Map()
for (let i = 0; i < currentVol.value; i++) {
const d = allData.value[i]
if (!d) continue
for (const loc of (d.locations || [])) {
locMap.set(loc.id, loc)
}
}
return Array.from(locMap.values())
})
// 势力:按 id 去重,取最新卷定义
const mergedFactions = computed(() => {
const facMap = new Map()
for (let i = 0; i < currentVol.value; i++) {
const d = allData.value[i]
if (!d) continue
for (const f of (d.factions || [])) {
facMap.set(f.id, f)
}
}
return Array.from(facMap.values())
})
/**
* 合并人物路线(全卷 1-63寇仲和徐子陵全程分开显示
*/
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 entry = charMap.get(name)
entry.color = r.color
for (const pt of (r.route || [])) {
entry.route.push({ ...pt, vol })
}
}
}
return Array.from(charMap.values())
})
/**
* 图例中展示的路线定义(用于点击切换显隐)
* 与 mergedRoutes 同步,但只保留 character/color
*/
const visibleRouteDefs = computed(() =>
mergedRoutes.value.map(r => ({ character: r.character, color: r.color }))
)
// 当前卷关键事件
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
loadedCount.value = 0
// 初始化数组长度,确保响应性
allData.value = new Array(TOTAL_VOLS).fill(null)
const promises = []
for (let i = 1; i <= TOTAL_VOLS; i++) {
const volStr = String(i).padStart(2, '0')
promises.push(
fetch(`/data/vol${volStr}.json`)
.then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`)
return 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
})
const TIANDITU_KEY = '5316253e2d2d21b707996f1f5748839d'
// 矢量底图(白底行政区划)
L.tileLayer(
`https://t{s}.tianditu.gov.cn/DataServer?T=vec_w&X={x}&Y={y}&L={z}&tk=${TIANDITU_KEY}`,
{ subdomains: '01234567', maxZoom: 18 }
).addTo(map)
// 中文地名标注层(叠加在底图上)
L.tileLayer(
`https://t{s}.tianditu.gov.cn/DataServer?T=cva_w&X={x}&Y={y}&L={z}&tk=${TIANDITU_KEY}`,
{ subdomains: '01234567', maxZoom: 18 }
).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 3px #fff,0 0 6px #fff,-1px -1px 0 #fff,1px -1px 0 #fff,-1px 1px 0 #fff,1px 1px 0 #fff;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:#333;font-size:11px;text-shadow:0 0 3px #fff,0 0 6px #fff,-1px -1px 0 #fff,1px -1px 0 #fff,-1px 1px 0 #fff,1px 1px 0 #fff;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>