Files
Novel-Map/frontend/App.vue
2026-03-30 17:26:44 +08:00

508 lines
12 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 factions"
: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 routes"
: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>
<select v-model="selectedVolume" class="volume-select" @change="onVolumeChange">
<option v-for="(data, idx) in allData" :key="idx" :value="idx">
{{ data.volume }}
</option>
</select>
<input
type="range"
class="chapter-slider"
v-model.number="currentChapter"
:min="1"
:max="maxChapter"
@input="updateMap"
>
<div class="chapter-label"> {{ currentChapter }} / {{ maxChapter }} </div>
<div class="chapter-name">{{ currentChapterName }}</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>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
const allData = ref([])
const factions = ref([])
const locations = ref([])
const routes = ref([])
const currentChapter = ref(1)
const selectedVolume = ref(0)
const selectedRoutes = ref([])
const selectedFaction = ref(null)
let map = null
let routeLayers = []
let locationMarkers = []
let territoryLayers = []
const currentData = computed(() => allData.value[selectedVolume.value] || {})
const chapters = computed(() => currentData.value.chapters || [])
const maxChapter = computed(() => chapters.value.length || 1)
const currentChapterName = computed(() => chapters.value[currentChapter.value - 1] || '')
onMounted(async () => {
await loadAllData()
initMap()
updateMap()
})
async function loadAllData() {
for (let i = 1; i <= 5; i++) {
const response = await fetch(`/data/vol${String(i).padStart(2, '0')}.json`)
const data = await response.json()
allData.value.push(data)
}
// 初始化第一卷数据
factions.value = allData.value[0]?.factions || []
locations.value = allData.value[0]?.locations || []
routes.value = allData.value[0]?.character_routes || []
currentChapter.value = maxChapter.value
}
function initMap() {
map = L.map('map', {
center: [32.0, 116.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)
}
function onVolumeChange() {
factions.value = currentData.value.factions || []
locations.value = currentData.value.locations || []
routes.value = currentData.value.character_routes || []
currentChapter.value = maxChapter.value
selectedRoutes.value = []
updateMap()
}
function updateMap() {
if (!map) return
// 确保当前章节在有效范围内
if (currentChapter.value > maxChapter.value) {
currentChapter.value = maxChapter.value
}
if (currentChapter.value < 1) {
currentChapter.value = 1
}
clearLayers()
drawTerritories()
drawLocations()
drawRoutes()
}
function clearLayers() {
routeLayers.forEach(layer => map.removeLayer(layer))
locationMarkers.forEach(layer => map.removeLayer(layer))
territoryLayers.forEach(layer => map.removeLayer(layer))
routeLayers = []
locationMarkers = []
territoryLayers = []
}
function drawTerritories() {
factions.value.forEach(f => {
if (!f.territory || !f.territory.length) return
f.territory.forEach(tid => {
const loc = locations.value.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'
}).addTo(map)
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]
})
}).addTo(map)
territoryLayers.push(circle, label)
})
})
}
function drawLocations() {
locations.value.forEach(loc => {
let markerColor = '#aaa'
for (const f of factions.value) {
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 || ''}`)
.addTo(map)
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]
})
}).addTo(map)
locationMarkers.push(marker, nameLabel)
})
}
function drawRoutes() {
routes.value.forEach(route => {
if (!selectedRoutes.value.includes(route.character)) return
const points = route.route || []
for (let i = 0; i < points.length - 1; i++) {
const p1 = getPointCoords(points[i])
const p2 = getPointCoords(points[i + 1])
if (!p1 || !p2) continue
const line = L.polyline([[p1.lat, p1.lng], [p2.lat, p2.lng]], {
color: route.color,
weight: 3,
opacity: 0.8
}).addTo(map)
routeLayers.push(line)
}
points.forEach((p, idx) => {
const coords = getPointCoords(p)
if (!coords) return
const isEnd = idx === points.length - 1
const isStart = idx === 0
const size = (isStart || isEnd) ? 7 : 5
const dot = L.circleMarker([coords.lat, coords.lng], {
radius: size,
color: route.color,
fillColor: isEnd ? '#fff' : route.color,
fillOpacity: 0.9,
weight: 2
})
.bindPopup(`<b>${route.character}</b><br>第${p.chapter}章:${p.event || ''}`)
.addTo(map)
routeLayers.push(dot)
})
})
}
function getPointCoords(point) {
if (point.lat && point.lng) {
return { lat: point.lat, lng: point.lng }
}
if (point.location) {
const loc = locations.value.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)
}
clearLayers()
drawTerritories()
drawLocations()
drawRoutes()
}
function showFactionInfo(faction) {
selectedFaction.value = faction
}
function getTerritoryNames(territoryIds) {
return territoryIds.map(tid => {
const loc = locations.value.find(l => l.id === tid)
return loc ? loc.name : tid
})
}
</script>
<style scoped>
/* 根容器必须 position:relative使绝对定位子元素相对它定位 */
.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: 260px;
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: 320px;
color: #e0e0e0;
}
.chapter-panel h3 {
color: #c9a96e;
margin-bottom: 8px;
font-size: 14px;
border-bottom: 1px solid #444;
padding-bottom: 4px;
}
.volume-select {
width: 100%;
margin-bottom: 8px;
padding: 4px 8px;
background: #333;
color: #e0e0e0;
border: 1px solid #555;
border-radius: 4px;
}
.chapter-slider {
width: 100%;
margin: 8px 0;
accent-color: #c9a96e;
}
.chapter-label {
font-size: 12px;
color: #aaa;
text-align: center;
}
.chapter-name {
font-size: 13px;
color: #e0e0e0;
text-align: center;
margin-top: 4px;
}
/* ====== 势力详情面板 ====== */
.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: 320px;
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;
}
</style>