前40卷地图

This commit is contained in:
龙澳
2026-03-30 21:14:10 +08:00
parent f8e9ed6a00
commit 0a18caaecb
32 changed files with 9691 additions and 50 deletions

View File

@@ -20,7 +20,7 @@
<div class="legend-section">
<h3>人物路线</h3>
<div
v-for="route in mergedRoutes"
v-for="route in visibleRouteDefs"
:key="route.character"
class="legend-item"
@click="toggleRoute(route)"
@@ -37,7 +37,7 @@
</div>
</div>
<!-- 卷进度面板 -->
<!-- 进度面板 -->
<div class="chapter-panel">
<h3>卷册进度</h3>
<div class="vol-slider-wrap">
@@ -46,7 +46,7 @@
class="chapter-slider"
v-model.number="currentVol"
:min="1"
:max="20"
:max="40"
@input="onVolSliderChange"
>
<div class="vol-ticks">
@@ -59,7 +59,7 @@
</div>
</div>
<div class="vol-info">
当前{{ currentVolLabel }} &nbsp;|&nbsp; 已加载 {{ loadedCount }} / 20
当前{{ currentVolLabel }} &nbsp;|&nbsp; 已加载 {{ loadedCount }} / 40
</div>
<!-- 关键事件列表 -->
@@ -91,10 +91,10 @@
</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 class="loading-inner" :style="{ width: (loadedCount / 40 * 100) + '%' }"></div>
<span class="loading-text">加载数据中 {{ loadedCount }}/40 </span>
</div>
</div>
</template>
@@ -104,30 +104,46 @@ 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-20
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)
const isLoading = ref(true)
const loadedCount = ref(0)
// ── Leaflet 图层管理 ────────────────────────────────────────
let map = null
let layerGroups = {
territories: null,
locations: null,
routes: null,
events: 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: 5, label: '卷', pct: 21.05 },
{ vol: 10, label: '卷十', pct: 47.37 },
{ vol: 15, label: '卷十', pct: 73.68 },
{ vol: 20, label: '卷十', pct: 100 }
{ 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 }
]
// ── 计算属性:当前卷标签 ────────────────────────────────────
@@ -138,55 +154,174 @@ const currentVolLabel = computed(() => {
// ── 跨卷合并数据(截至 currentVol──────────────────────────
// 地点:按 id 去重,取最新(最大卷号)的定义
// 地点:按 id 去重,取最新定义
const mergedLocations = computed(() => {
const map = new Map()
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 || [])) {
map.set(loc.id, loc)
locMap.set(loc.id, loc)
}
}
return Array.from(map.values())
return Array.from(locMap.values())
})
// 势力:按 id 去重,取最新卷定义
const mergedFactions = computed(() => {
const map = new Map()
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 || [])) {
map.set(f.id, f)
facMap.set(f.id, f)
}
}
return Array.from(map.values())
return Array.from(facMap.values())
})
// 人物路线:同名人物路点合并,每个点附带 vol 标记
/**
* 合并人物路线,处理寇仲/徐子陵在 vol21 前后的分合逻辑:
*
* 卷 1-20数据中是 "寇仲 & 徐子陵",直接按名合并。
*
* 卷 21+(当 currentVol >= SPLIT_VOL
* - 数据中是独立的 "寇仲" 和 "徐子陵"。
* - 前20卷的 "寇仲 & 徐子陵" 路线同时挂到这两条线上,
* 这样路线就连续(两人共同行走的历史 → 各自后续独立路线)。
*
* 当 currentVol < SPLIT_VOL只展示合并路线。
* 当 currentVol >= SPLIT_VOL只展示分离路线前20卷合并段分别并入两人。
*/
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 })
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())
]
}
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]
@@ -202,20 +337,52 @@ onMounted(async () => {
// 监听 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 <= 20; i++) {
for (let i = 1; i <= TOTAL_VOLS; i++) {
const volStr = String(i).padStart(2, '0')
promises.push(
fetch(`/data/vol${volStr}.json`)
.then(r => r.json())
.then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`)
return r.json()
})
.then(data => {
// 按索引顺序写入(保证位置正确)
allData.value[i - 1] = data
loadedCount.value++
})
@@ -227,7 +394,7 @@ async function loadAllData() {
}
await Promise.all(promises)
isLoading.value = false
// 触发响应式更新
// 触发响应式更新(替换整个数组引用)
allData.value = [...allData.value]
}
@@ -374,9 +541,9 @@ function drawRoutes() {
}
resolved.forEach((p, idx) => {
const isEnd = idx === resolved.length - 1
const isEnd = idx === resolved.length - 1
const isStart = idx === 0
const size = (isStart || isEnd) ? 7 : 5
const size = (isStart || isEnd) ? 7 : 5
const dot = L.circleMarker([p.lat, p.lng], {
radius: size,