Files
Novel-Map/frontend/App.vue
2026-03-30 21:14:10 +08:00

875 lines
24 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="40"
@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 }} / 40
</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 / 40 * 100) + '%' }"></div>
<span class="loading-text">加载数据中 {{ loadedCount }}/40 </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 = 40
const SPLIT_VOL = 21 // 从该卷起寇仲/徐子陵分离
// 合并路线名称(卷 1-20 使用)
const MERGED_NAME = '寇仲 & 徐子陵'
const MERGED_COLOR = '#FF6347'
// 分离路线定义(卷 21+ 使用)
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-40
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-40共 39 步
// pct = (vol - 1) / (40 - 1) * 100
const volTicks = [
{ vol: 1, label: '卷一', pct: 0 },
{ vol: 10, label: '卷十', pct: (9 / 39 * 100).toFixed(3) * 1 },
{ vol: 20, label: '卷二十', pct: (19 / 39 * 100).toFixed(3) * 1 },
{ vol: 30, label: '卷三十', pct: (29 / 39 * 100).toFixed(3) * 1 },
{ vol: 40, 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())
})
/**
* 合并人物路线,处理寇仲/徐子陵在 vol21 前后的分合逻辑:
*
* 卷 1-20数据中是 "寇仲 & 徐子陵",直接按名合并。
*
* 卷 21+(当 currentVol >= SPLIT_VOL
* - 数据中是独立的 "寇仲" 和 "徐子陵"。
* - 前20卷的 "寇仲 & 徐子陵" 路线同时挂到这两条线上,
* 这样路线就连续(两人共同行走的历史 → 各自后续独立路线)。
*
* 当 currentVol < SPLIT_VOL只展示合并路线。
* 当 currentVol >= SPLIT_VOL只展示分离路线前20卷合并段分别并入两人。
*/
const mergedRoutes = computed(() => {
const isSplit = currentVol.value >= SPLIT_VOL
if (!isSplit) {
// ── 模式一:合并路线(卷 1-20──────────────────────────
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())
} else {
// ── 模式二:分离路线(卷 21+)───────────────────────────
// 先收集前20卷的合并路线路点
const earlyMergedPts = []
for (let i = 0; i < SPLIT_VOL - 1; i++) {
const d = allData.value[i]
if (!d) continue
const vol = i + 1
for (const r of (d.character_routes || [])) {
if (r.character === MERGED_NAME) {
for (const pt of (r.route || [])) {
earlyMergedPts.push({ ...pt, vol })
}
}
}
}
// 再按角色名合并 vol21+ 的独立路线
const splitMap = new Map()
// 初始化两条分离路线,并把早期合并路点作为前缀
const initSplitRoute = (name, color) => {
splitMap.set(name, {
character: name,
color,
route: [...earlyMergedPts] // 共享早期合并路点
})
}
initSplitRoute(KOUZHONG_NAME, KOUZHONG_COLOR)
initSplitRoute(XUZILING_NAME, XUZILING_COLOR)
// 收集其他角色(非寇仲/徐子陵)在 vol21+ 中的路线
const otherMap = new Map()
for (let i = SPLIT_VOL - 1; 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 (name === KOUZHONG_NAME || name === XUZILING_NAME) {
const entry = splitMap.get(name)
entry.color = r.color
for (const pt of (r.route || [])) {
entry.route.push({ ...pt, vol })
}
} else {
// 其他角色正常合并
if (!otherMap.has(name)) {
otherMap.set(name, { character: name, color: r.color, route: [] })
}
const entry = otherMap.get(name)
entry.color = r.color
for (const pt of (r.route || [])) {
entry.route.push({ ...pt, vol })
}
}
}
}
// 同时也收集前20卷中其他角色非寇仲&徐子陵合并)的路线
const otherEarlyMap = new Map()
for (let i = 0; i < SPLIT_VOL - 1; i++) {
const d = allData.value[i]
if (!d) continue
const vol = i + 1
for (const r of (d.character_routes || [])) {
if (r.character === MERGED_NAME) continue
const name = r.character
if (!otherEarlyMap.has(name)) {
otherEarlyMap.set(name, { character: name, color: r.color, route: [] })
}
const entry = otherEarlyMap.get(name)
entry.color = r.color
for (const pt of (r.route || [])) {
entry.route.push({ ...pt, vol })
}
}
}
// 合并早期其他角色和后期其他角色
const allOther = new Map([...otherEarlyMap])
for (const [name, entry] of otherMap) {
if (allOther.has(name)) {
allOther.get(name).color = entry.color
allOther.get(name).route.push(...entry.route)
} else {
allOther.set(name, entry)
}
}
return [
...Array.from(splitMap.values()),
...Array.from(allOther.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, () => {
syncSelectedRoutesOnModeChange()
drawAll()
})
/**
* 切换到卷21时将已选中的 "寇仲 & 徐子陵" 替换为两个分离路线;
* 切换回20以前时将分离路线替换为合并路线。
*/
function syncSelectedRoutesOnModeChange() {
const isSplit = currentVol.value >= SPLIT_VOL
const hasMerged = selectedRoutes.value.includes(MERGED_NAME)
const hasKouzhong = selectedRoutes.value.includes(KOUZHONG_NAME)
const hasXuziling = selectedRoutes.value.includes(XUZILING_NAME)
if (isSplit && hasMerged) {
// 合并 → 分离:把合并路线换成两条分离路线
const idx = selectedRoutes.value.indexOf(MERGED_NAME)
selectedRoutes.value.splice(idx, 1, KOUZHONG_NAME, XUZILING_NAME)
} else if (!isSplit && (hasKouzhong || hasXuziling)) {
// 分离 → 合并:把两条分离路线换成合并路线
selectedRoutes.value = selectedRoutes.value.filter(
n => n !== KOUZHONG_NAME && n !== XUZILING_NAME
)
if (!selectedRoutes.value.includes(MERGED_NAME)) {
selectedRoutes.value.push(MERGED_NAME)
}
}
}
// ── 数据加载 ────────────────────────────────────────────────
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
})
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>