欢迎光临
我们一直在努力

记录我的webgis项目(5):从零开始,构建电子地图网站 spring boot3 +vue3 + leaflet之前后端结合

(还是依旧叠甲,我也是菜鸟,悉知我的功力还很浅薄,如果有哪里做的不行欢迎批评指正,大部分代码都是借助AI辅助编写的,可能会有些问题,遇到可留言指正)
        Day 5:

咱的后端和前端静态都差不多了,话不多说,咱们直接开始实现数据交互。

前言:由于涉及到数据交互,肯定是会涉及到组件之间的数据通信,原教程用的原生的写的并且取消了drawer抽屉移出属性table,直接用的leaflet的bindPopup。我们采用pinia来进行状态管理 ,同时采用vue3推荐的组合式代码风格。

一、下载Pinia并创建Pinia Store 管理数据与选中状态

1. 下载Pinia

在cmd里输入:

npm install pinia

推荐大家在下一个谷歌的vue3devtool,要不然不能查看pinia状态,需要科学上网就不教了

2. 新建Pinia仓库

将所有状态抽离到 Pinia store 中,使组件更简洁,通信更清晰。

新建文件 src/stores/map.js:

// 导入 pinia 相关模块
import { defineStore } from 'pinia'
// 引入 vue 相关模块
import { ref, computed } from 'vue'
// 引入 axios 模块
import axios from 'axios'

export const useMapStore = defineStore('map', () => {
// —- 原始树数据(朝代列表) —-
const treedata = ref([
{
id: 0,
label: "全部内容",
ischeck: false,
level: 1,
timerange: [-5000, 1840],
children: [
{
id: 1,
label: "商周",
ischeck: false,
level: 2,
timerange: [-5000, -221],
children: [
{ id: 2, label: "cnty点", ischeck: false, level: 3, timerange: [-5000, -221] },
{ id: 3, label: "pref点", ischeck: false, level: 3, timerange: [-5000, -221] },
{ id: 4, label: "pref面", ischeck: false, level: 3, timerange: [-5000, -221] }
]
},
{
id: 5,
label: "秦",
ischeck: false,
level: 2,
timerange: [-220, -202],
children: [
{ id: 6, label: "cnty点", ischeck: false, level: 3, timerange: [-220, -202] },
{ id: 7, label: "pref点", ischeck: false, level: 3, timerange: [-220, -202] },
{ id: 8, label: "pref面", ischeck: false, level: 3, timerange: [-220, -202] }
]
},
{
id: 9,
label: "汉",
ischeck: false,
level: 2,
timerange: [-201, 220],
children: [
{ id: 10, label: "cnty点", ischeck: false, level: 3, timerange: [-201, 220] },
{ id: 11, label: "pref点", ischeck: false, level: 3, timerange: [-201, 220] },
{ id: 12, label: "pref面", ischeck: false, level: 3, timerange: [-201, 220] }
]
},
{
id: 13,
label: "晋三国",
ischeck: false,
level: 2,
timerange: [221, 589],
children: [
{ id: 14, label: "cnty点", ischeck: false, level: 3, timerange: [221, 589] },
{ id: 15, label: "pref点", ischeck: false, level: 3, timerange: [221, 589] },
{ id: 16, label: "pref面", ischeck: false, level: 3, timerange: [221, 589] }
]
},
{
id: 17,
label: "隋唐",
ischeck: false,
level: 2,
timerange: [590, 907],
children: [
{ id: 18, label: "cnty点", ischeck: false, level: 3, timerange: [590, 907] },
{ id: 19, label: "pref点", ischeck: false, level: 3, timerange: [590, 907] },
{ id: 20, label: "pref面", ischeck: false, level: 3, timerange: [590, 907] }
]
},
{
id: 21,
label: "五代十国",
ischeck: false,
level: 2,
timerange: [908, 960],
children: [
{ id: 22, label: "cnty点", ischeck: false, level: 3, timerange: [908, 960] },
{ id: 23, label: "pref点", ischeck: false, level: 3, timerange: [908, 960] },
{ id: 24, label: "pref面", ischeck: false, level: 3, timerange: [908, 960] }
]
},
{
id: 25,
label: "宋",
ischeck: false,
level: 2,
timerange: [961, 1276],
children: [
{ id: 26, label: "cnty点", ischeck: false, level: 3, timerange: [961, 1276] },
{ id: 27, label: "pref点", ischeck: false, level: 3, timerange: [961, 1276] },
{ id: 28, label: "pref面", ischeck: false, level: 3, timerange: [961, 1276] }
]
},
{
id: 29,
label: "元",
ischeck: false,
level: 2,
timerange: [1277, 1368],
children: [
{ id: 30, label: "cnty点", ischeck: false, level: 3, timerange: [1277, 1368] },
{ id: 31, label: "pref点", ischeck: false, level: 3, timerange: [1277, 1368] },
{ id: 32, label: "pref面", ischeck: false, level: 3, timerange: [1277, 1368] }
]
},
{
id: 33,
label: "明",
ischeck: false,
level: 2,
timerange: [1369, 1644],
children: [
{ id: 34, label: "cnty点", ischeck: false, level: 3, timerange: [1369, 1644] },
{ id: 35, label: "pref点", ischeck: false, level: 3, timerange: [1369, 1644] },
{ id: 36, label: "pref面", ischeck: false, level: 3, timerange: [1369, 1644] }
]
},
{
id: 37,
label: "清",
ischeck: false,
level: 2,
timerange: [1645, 1840],
children: [
{ id: 38, label: "cnty点", ischeck: false, level: 3, timerange: [1645, 1840] },
{ id: 39, label: "pref点", ischeck: false, level: 3, timerange: [1645, 1840] },
{ id: 40, label: "pref面", ischeck: false, level: 3, timerange: [1645, 1840] }
]
}
]
}
])

// —- 当前选中的叶子节点键 —-
const checkedKeys = ref([])

// —- 后端返回的几何数据 —-
const geometryData = ref({
prefpgn: [],
prefpts: [],
cntypts: []
})

// —- 后端基础地址 —-
const baseUrl = 'http://localhost:8081/history/geometry'

// —- 根据选中节点计算需要请求的朝代区间 —-
// 原教程通过 dynastylist 判断每个朝代的三个复选框是否勾选,这里用 computed 动态生成请求参数
const requestParams = computed(() => {
const dynasties = treedata.value[0].children // 朝代数组
const params = []
for (const dynasty of dynasties) {
const start = dynasty.timerange[0]
const end = dynasty.timerange[1]
// 检查该朝代下三个子节点的选中情况
const cntyChecked = dynasty.children[0]?.ischeck
const prefptsChecked = dynasty.children[1]?.ischeck
const prefpgnChecked = dynasty.children[2]?.ischeck

if (cntyChecked) {
params.push({ category: 'cntypts', start, end })
}
if (prefptsChecked) {
params.push({ category: 'prefpts', start, end })
}
if (prefpgnChecked) {
params.push({ category: 'prefpgn', start, end })
}
}
return params
})

// —- 获取几何数据 —-
async function fetchGeometryData() {
const promises = requestParams.value.map(param => {
const url = `${baseUrl}?category=${param.category}&start=${param.start}&end=${param.end}`
return axios.get(url).then(res => ({ category: param.category, data: res.data.list }))
})
const results = await Promise.all(promises)

// 清空原有数据
geometryData.value = { prefpgn: [], prefpts: [], cntypts: [] }

for (const result of results) {
if (result.category === 'prefpgn') geometryData.value.prefpgn = result.data
else if (result.category === 'prefpts') geometryData.value.prefpts = result.data
else if (result.category === 'cntypts') geometryData.value.cntypts = result.data
}
}

// —- 更新节点选中状态 —-
function updateChecked(e, data) {
// 沿用原 changecheck 逻辑,但操作的是 treedata 的 ischeck 属性
if (e['level'] === 1) {
e['ischeck'] = !e['ischeck']
for (let i = 0; i < e['children'].length; i++) {
e['children'][i]['ischeck'] = e['ischeck']
for (let j = 0; j < e['children'][i]['children'].length; j++) {
e['children'][i]['children'][j]['ischeck'] = e['ischeck']
}
}
} else if (e['level'] === 2) {
e['ischeck'] = !e['ischeck']
e['children'][0]['ischeck'] = e['ischeck']
e['children'][1]['ischeck'] = e['ischeck']
e['children'][2]['ischeck'] = e['ischeck']
} else {
e['ischeck'] = !e['ischeck']
}
}

return {
treedata,
checkedKeys,
geometryData,
requestParams,
fetchGeometryData,
updateChecked
}
})

原教程将朝代数据放在 src/js/treedata.js 中导出,现直接置于 store 的 treedata 中,方便状态追踪。你也可以保留原文件并在 store 中 import { treedata } from '../js/treedata.js',然后 ref(treedata)。

updateChecked 方法实现原 changecheck 的全部逻辑,同时操作对象是 store 中的响应式数据,确保 UI 同步。

fetchGeometryData 替代了组件内的 getdata 方法,返回分类好的数据存入 geometryData。

3. HistoryMap.vue 整合地图与 Pinia

地图逻辑均写在 HistoryMap.vue 中,我们将其改造为组合式 API,并用 Pinia 管理状态。

<!– ▶ 修改为 <script setup> 组合式风格,引入 Pinia –>
<template>
<div class="history-map-wrapper">
<div class="sidebar">
<el-scrollbar style="height: 100%;">
<el-tree :data="mapStore.treedata" show-checkbox node-key="id" :props="defaultProps"
@check="handleCheck"></el-tree>
</el-scrollbar>
</div>
<div id="map"></div>
</div>
</template>

<script setup>
// 引入 Pinia store,vue原生组合式API,引入leaflet库等
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
import { TXTileLayer } from '../js/txTileLayer.js'
import { useMapStore } from '../stores/map'

// —- 初始化 Pinia store —-
const mapStore = useMapStore()

// 地图实例
const map = ref(null)

// el-tree 的配置(非响应式,使用常量)
const defaultProps = {
children: 'children',
label: 'label'
}

// —- 图标定义(用 circleMarker,不做 DOM marker) —-
const redStyle = { color: '#C00000', fillColor: '#C00000', fillOpacity: 1, radius: 3, weight: 1 }
const blackStyle = { color: '#000', fillColor: '#000', fillOpacity: 1, radius: 2, weight: 1 }

// —- 地图初始化 —-
function initLeaflet() {
map.value = L.map('map', {
center: [36.0, 114.0],
zoom: 4,
maxZoom: 23,
minZoom: 3,
zoomAnimation: false
})

// 加载腾讯瓦片
const txurl = 'http://rt1.map.gtimg.com/realtimerender/?z={z}&x={x}&y={y}&type=vector&style=1&v=1.1.1'
const getUrlArgs = function (tilePoint) {
return {
z: tilePoint.z,
x: tilePoint.x,
y: Math.pow(2, tilePoint.z) – 1 – tilePoint.y
}
}
const options = { subdomain: '012', getUrlArgs }
const txMap = new TXTileLayer(txurl, options)
txMap.addTo(map.value)

overlayGroup.addTo(map.value)
}

// LayerGroup——用一个组管理所有覆盖物,清除/添加都是原子操作
const overlayGroup = L.layerGroup()

// —- 处理树节点选中变化 —-
function handleCheck(dataNode, checkedInfo) {
console.log('[HistoryMap] @check fired:', dataNode.label, dataNode.level)
mapStore.updateChecked(dataNode, checkedInfo)
mapStore.fetchGeometryData()
}

// —- GeoJSON 坐标转 Leaflet [lat, lng] 的工具函数 —-
function toLatLng(coord) {
return [coord[1], coord[0]]
}

function extractPolygons(geometry) {
if (geometry.type === 'Polygon') {
return [geometry.coordinates.map(ring => ring.map(toLatLng))]
}
if (geometry.type === 'MultiPolygon') {
return geometry.coordinates.map(polygon =>
polygon.map(ring => ring.map(toLatLng))
)
}
return []
}

function extractPoints(geometry) {
if (geometry.type === 'Point') {
return [toLatLng(geometry.coordinates)]
}
if (geometry.type === 'MultiPoint') {
return geometry.coordinates.map(toLatLng)
}
return []
}

// —- 地图覆盖物清除与绘制 —-
function clearOverlays() {
overlayGroup.clearLayers()
}

function renderGeometry() {
if (!map.value) return
clearOverlays()

const { prefpgn, prefpts, cntypts } = mapStore.geometryData
const loadedGid = new Set()

// 1. 绘制面(polygon)
prefpgn.forEach(feature => {
if (loadedGid.has(feature.gid)) return
loadedGid.add(feature.gid)

const polygonSets = extractPolygons(feature.geometry)
polygonSets.forEach(latlngs => {
const polygon = L.polygon(latlngs, {
color: '#C00000',
fillColor: '#C00000',
fillOpacity: 0.5
})
polygon.bindTooltip(feature.namech)
polygon.bindPopup(buildPopup(feature))
polygon.addTo(overlayGroup)
})
})

// 2. 绘制 pref 点(红点)
prefpts.forEach(feature => {
if (loadedGid.has(feature.gid)) return
loadedGid.add(feature.gid)

const points = extractPoints(feature.geometry)
points.forEach(latlng => {
const marker = L.circleMarker(latlng, redStyle)
marker.bindPopup(buildPopup(feature))
marker.addTo(overlayGroup)
})
})

// 3. 绘制 cnty 点(黑点)
cntypts.forEach(feature => {
if (loadedGid.has(feature.gid)) return
loadedGid.add(feature.gid)

const points = extractPoints(feature.geometry)
points.forEach(latlng => {
const marker = L.circleMarker(latlng, blackStyle)
marker.bindPopup(buildPopup(feature))
marker.addTo(overlayGroup)
})
})
}

function buildPopup(feature) {
return `
namech: ${feature.namech}<br>
nameft: ${feature.nameft}<br>
namepy: ${feature.namepy}<br>
typech: ${feature.typech}<br>
presloc: ${feature.presloc}<br>
begyr: ${feature.begyr}<br>
begrule: ${feature.begrule}<br>
begchgty: ${feature.begchgty}<br>
endyr: ${feature.endyr}<br>
endrule: ${feature.endrule}<br>
endchgty: ${feature.endchgty}<br>
geosrc: ${feature.geosrc}<br>
compiler: ${feature.compiler}<br>
gecomplr: ${feature.gecomplr}<br>
checker: ${feature.checker}
`
}

// —- 防抖:避免快速操作触发多次渲染 —-
let renderTimer = null
function debouncedRender() {
if (renderTimer) clearTimeout(renderTimer)
renderTimer = setTimeout(() => {
renderGeometry()
renderTimer = null
}, 100)
}

// —- 监听 geometryData 变化,自动更新地图 —-
watch(
() => mapStore.geometryData,
() => {
debouncedRender()
},
{ deep: true }
)

// —- 生命周期 —-
// 组件挂载时初始化 Leaflet 地图
onMounted(() => {
nextTick(() => {
initLeaflet()
})
})

onUnmounted(() => {
overlayGroup.clearLayers()
if (map.value) {
map.value.remove()
map.value = null
}
})
</script>

<style>
.history-map-wrapper {
display: flex;
flex-direction: row;
width: 100%;
height: 100%;
}

.sidebar {
width: 15%;
height: 100%;
background-color: #fff;
overflow-x: hidden;
flex-shrink: 0;
}

#map {
flex: 1;
height: 100%;
min-width: 0;
z-index: 80;
}
</style>

修改标注说明:

  • 组件写法:改为 <script setup>,移除 export default,所有变量自动暴露给模板。

  • 状态管理:全部通过 mapStore,代码中不再出现 this。

  • 地图数据获取: handleCheck 触发 mapStore.fetchGeometryData(),数据请求由 store 完成,组件只负责渲染。

  • 覆盖物清除:现在独立为 clearOverlays 方法,在数据变化时调用。

  • 去重逻辑:改用 Set 记录已加载的 gid,更简洁。

  • 坐标处理:后端 GeoJSON 中坐标字段为 {lng, lat},统一转为 Leaflet 要求的 [lat, lng] 数组。

  • Leaflet 导入:明确使用 import L from 'leaflet',图片用 import 导入,符合 Vite 规范。

  • 4. 全局引入 Pinia

    在 main.js 中添加 Pinia 实例:

    import { createApp } from 'vue' // ▶ Vue 3 使用 createApp
    import App from './App.vue'
    import ElementPlus from 'element-plus'
    import 'element-plus/dist/index.css' // ▶ 样式路径变化
    import 'normalize.css'
    // ▶ 不再使用 vue-awesome,改用 element-plus 图标
    import * as ElementPlusIconsVue from '@element-plus/icons-vue'
    import router from './router'
    import Header from './components/Header.vue'
    // 引入 Pinia store
    import { createPinia } from 'pinia'
    const app = createApp(App)
    const pinia = createPinia()
    // 全局注册 Pinia store
    app.use(pinia)
    // 全局注册所有图标,方便在模板中直接使用 <el-icon><Menu /></el-icon>
    for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
    app.component(key, component)
    }

    app.use(ElementPlus)
    app.use(router)
    app.mount('#app')

    二、其他文件的说明

    Header.vue、SpecialMap.vue、HelperDocument.vue 无需改动。

    App.vue 保持不变。

    treedata.js 可弃用(数据已移入 store),也可保留作为备份,store 中改为 import { treedata } from '../js/treedata.js' 再 ref(treedata)。

    三、运行

    启动后端(GismapApplication),端口 8081。 启动前端 npm run dev,端口 8082。 勾选左侧树形朝代,右侧地图自动清除原有覆盖物并加载新数据,点、面均有 Popup 显示属性。

    效果如上,但是实际运行时发现很卡,原因是点位数据太多造成的。尝试了以下这几个方案

    1、使用Canvas渲染,leaflet默认使用svg渲染每一个要素都是一个DOM节点

    2、设置一个特定的缩放等级,仅在达到预设的特定缩放等级阈值后才显示点位元素,在未达到该缩放等级时不渲染任何点位,这样就可避免一次性渲染上千DOM的问题

    代码实现如下

    <!– ▶ 修改为 <script setup> 组合式风格,引入 Pinia –>
    <template>
    <div class="history-map-wrapper">
    <div class="sidebar">
    <el-scrollbar style="height: 100%;">
    <el-tree :data="mapStore.treedata" show-checkbox node-key="id" :props="defaultProps"
    @check="handleCheck"></el-tree>
    </el-scrollbar>
    </div>
    <div id="map"></div>
    </div>
    </template>

    <script setup>
    // 引入 Pinia store,vue原生组合式API,引入leaflet库等
    import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
    import L from 'leaflet'
    import 'leaflet/dist/leaflet.css'
    import { TXTileLayer } from '../js/txTileLayer.js'
    import { useMapStore } from '../stores/map'

    // —- 初始化 Pinia store —-
    const mapStore = useMapStore()

    // 地图实例
    const map = ref(null)

    // el-tree 的配置(非响应式,使用常量)
    const defaultProps = {
    children: 'children',
    label: 'label'
    }

    // —- 图标定义(用 circleMarker,不做 DOM marker) —-
    const redStyle = { color: '#C00000', fillColor: '#C00000', fillOpacity: 1, radius: 4, weight: 1 }
    const blackStyle = { color: '#000', fillColor: '#000', fillOpacity: 1, radius: 3, weight: 1 }

    // —- 点位可见性管理参数 —-
    // 缩放等级阈值:达到此等级后才显示点位元素,低于此等级时不渲染任何点位
    const POINT_ZOOM_THRESHOLD = 6

    // 存储点位特征数据(用于视口过滤,不直接渲染到地图)
    const pointDataStore = { prefpts: [], cntypts: [] }

    // 点位专用 LayerGroup,与面(polygon)分开管理,支持独立 clear/add
    const pointGroup = L.layerGroup()

    // —- 地图初始化 —-
    function initLeaflet() {
    map.value = L.map('map', {
    center: [36.0, 114.0],
    zoom: 4,
    maxZoom: 23,
    minZoom: 3,
    preferCanvas: true
    })

    // 加载腾讯瓦片
    const txurl = 'http://rt1.map.gtimg.com/realtimerender/?z={z}&x={x}&y={y}&type=vector&style=1&v=1.1.1'
    const getUrlArgs = function (tilePoint) {
    return {
    z: tilePoint.z,
    x: tilePoint.x,
    y: Math.pow(2, tilePoint.z) – 1 – tilePoint.y
    }
    }
    const options = { subdomain: '012', getUrlArgs }
    const txMap = new TXTileLayer(txurl, options)
    txMap.addTo(map.value)

    overlayGroup.addTo(map.value)

    // 点位图层组添加到地图
    pointGroup.addTo(map.value)

    // 注册视图变化事件:缩放或平移结束后动态更新可见点位
    map.value.on('moveend', updateVisiblePoints)
    }

    // LayerGroup——用一个组管理所有覆盖物,清除/添加都是原子操作
    const overlayGroup = L.layerGroup()

    // —- 处理树节点选中变化 —-
    function handleCheck(dataNode, checkedInfo) {
    console.log('[HistoryMap] @check fired:', dataNode.label, dataNode.level)
    mapStore.updateChecked(dataNode, checkedInfo)
    mapStore.fetchGeometryData()
    }

    // —- GeoJSON 坐标转 Leaflet [lat, lng] 的工具函数 —-
    function toLatLng(coord) {
    return [coord[1], coord[0]]
    }

    function extractPolygons(geometry) {
    if (geometry.type === 'Polygon') {
    return [geometry.coordinates.map(ring => ring.map(toLatLng))]
    }
    if (geometry.type === 'MultiPolygon') {
    return geometry.coordinates.map(polygon =>
    polygon.map(ring => ring.map(toLatLng))
    )
    }
    return []
    }

    function extractPoints(geometry) {
    if (geometry.type === 'Point') {
    return [toLatLng(geometry.coordinates)]
    }
    if (geometry.type === 'MultiPoint') {
    return geometry.coordinates.map(toLatLng)
    }
    return []
    }

    // —- 地图覆盖物清除与绘制 —-
    // 清除标记:防止缩放动画结束前重复注册多个 zoomend 监听器
    let clearDeferred = false

    function clearOverlays() {
    if (!map.value) return

    // 策略 A:检查地图是否正在缩放动画中
    // 如果 _animatingZoom 为 true,说明 Leaflet 正在执行缩放动画的过渡帧,
    // 此时清除图层会断开图层的 _map 引用,导致后续动画帧在调用
    // _latLngToNewLayerPoint 时因 this._map 为 null 而崩溃
    if (map.value._animatingZoom) {
    // 延迟清除:仅第一次进入时注册 zoomend 监听器,避免重复注册
    if (!clearDeferred) {
    clearDeferred = true
    // 使用 once(true) 注册一次性事件:动画结束后自动解绑,无需手动移除
    // zoomend 是 Leaflet 缩放动画完成后触发的标准事件
    map.value.once('zoomend', function () {
    clearDeferred = false
    // 动画已结束,此时 _map 引用稳定,可以安全清除
    overlayGroup.clearLayers()
    })
    }
    return
    }

    // 未在动画中:直接清除,无竞态风险
    overlayGroup.clearLayers()
    }

    function renderGeometry() {
    if (!map.value) return
    clearOverlays()
    pointGroup.clearLayers()
    pointDataStore.prefpts = []
    pointDataStore.cntypts = []

    const { prefpgn, prefpts, cntypts } = mapStore.geometryData
    const loadedGid = new Set()

    // 1. 绘制面(polygon)— 面数量少,直接全量渲染
    prefpgn.forEach(feature => {
    if (loadedGid.has(feature.gid)) return
    loadedGid.add(feature.gid)

    const polygonSets = extractPolygons(feature.geometry)
    polygonSets.forEach(latlngs => {
    const polygon = L.polygon(latlngs, {
    color: '#C00000',
    fillColor: '#C00000',
    fillOpacity: 0.5
    })
    polygon.bindTooltip(feature.namech)
    polygon.bindPopup(buildPopup(feature))
    polygon.addTo(overlayGroup)
    })
    })

    // 2. 存储 pref 点数据(暂不渲染,由 updateVisiblePoints 按需渲染)
    loadedGid.clear()
    prefpts.forEach(feature => {
    if (loadedGid.has(feature.gid)) return
    loadedGid.add(feature.gid)
    pointDataStore.prefpts.push(feature)
    })

    // 3. 存储 cnty 点数据(暂不渲染)
    loadedGid.clear()
    cntypts.forEach(feature => {
    if (loadedGid.has(feature.gid)) return
    loadedGid.add(feature.gid)
    pointDataStore.cntypts.push(feature)
    })

    // 4. 根据当前缩放等级和视口范围渲染可见点位
    updateVisiblePoints()
    }

    // —- 动态更新可见点位(缩放等级 + 视口范围双重过滤) —-
    function updateVisiblePoints() {
    if (!map.value) return
    pointGroup.clearLayers()

    const zoom = map.value.getZoom()

    // 条件 1:缩放等级未达到阈值 → 不渲染任何点位
    if (zoom < POINT_ZOOM_THRESHOLD) {
    return
    }

    // 条件 2:达到阈值,获取当前视口范围(西南-东北矩形边界)
    const bounds = map.value.getBounds()

    // 按视口范围过滤并渲染郡治点(红点)
    renderPointsInBounds(pointDataStore.prefpts, bounds, redStyle)
    // 按视口范围过滤并渲染县级点(黑点)
    renderPointsInBounds(pointDataStore.cntypts, bounds, blackStyle)
    }

    // —- 在指定视口范围内渲染点位 —- // 改
    function renderPointsInBounds(features, bounds, style) {
    if (!features || features.length === 0) return

    const loadedGid = new Set()

    for (let i = 0; i < features.length; i++) {
    const feature = features[i]
    if (loadedGid.has(feature.gid)) continue
    loadedGid.add(feature.gid)

    const points = extractPoints(feature.geometry)
    for (let j = 0; j < points.length; j++) {
    const latlng = points[j]
    // 视口范围检测:仅当坐标在当前可视区域内时才渲染
    if (bounds.contains(latlng)) {
    const marker = L.circleMarker(latlng, style)
    marker.bindPopup(buildPopup(feature))
    marker.addTo(pointGroup)
    }
    }
    }
    }

    function buildPopup(feature) {
    return `
    namech: ${feature.namech}<br>
    nameft: ${feature.nameft}<br>
    namepy: ${feature.namepy}<br>
    typech: ${feature.typech}<br>
    presloc: ${feature.presloc}<br>
    begyr: ${feature.begyr}<br>
    begrule: ${feature.begrule}<br>
    begchgty: ${feature.begchgty}<br>
    endyr: ${feature.endyr}<br>
    endrule: ${feature.endrule}<br>
    endchgty: ${feature.endchgty}<br>
    geosrc: ${feature.geosrc}<br>
    compiler: ${feature.compiler}<br>
    gecomplr: ${feature.gecomplr}<br>
    checker: ${feature.checker}
    `
    }

    // —- 防抖:避免快速操作触发多次渲染 —-
    let renderTimer = null
    function debouncedRender() {
    if (renderTimer) clearTimeout(renderTimer)
    renderTimer = setTimeout(() => {
    renderGeometry()
    renderTimer = null
    }, 100)
    }

    // —- 监听 geometryData 变化,自动更新地图 —-
    watch(
    () => mapStore.geometryData,
    () => {
    debouncedRender()
    },
    { deep: true }
    )

    // —- 生命周期 —-
    // 组件挂载时初始化 Leaflet 地图
    onMounted(() => {
    nextTick(() => {
    initLeaflet()
    })
    })

    onUnmounted(() => {
    overlayGroup.clearLayers()
    pointGroup.clearLayers()
    if (map.value) {
    map.value.off('moveend', updateVisiblePoints)
    map.value.remove()
    map.value = null
    }
    })
    </script>

    <style>
    .history-map-wrapper {
    display: flex;
    flex-direction: row;
    width: 100%;
    height: 100%;
    }

    .sidebar {
    width: 15%;
    height: 100%;
    background-color: #fff;
    overflow-x: hidden;
    flex-shrink: 0;
    }

    #map {
    flex: 1;
    height: 100%;
    min-width: 0;
    z-index: 80;
    }
    </style>

    ok,整个项目到这就差不多了,算是做完了,虽然有些粗糙,但是有核心功能就行。大家下个项目再见

    该项目并非原创(侵权了私聊删QAQ),而是翻新一个知乎上的大佬的spring boot 2 + vue 2 的项目, 项目教程:知乎的才华横溢吴道简 大佬的教程 
    ————————————————
    版权声明:本文为CSDN博主「euphoria_713」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
    原文链接:https://blog.csdn.net/euphoria7777/article/details/160690354


    赞(0)
    未经允许不得转载:171主机测评 » 记录我的webgis项目(5):从零开始,构建电子地图网站 spring boot3 +vue3 + leaflet之前后端结合
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址