技术栈Vue3 OpenLayers 10 高德 Web 服务 API 大模型DeepSeek / 通义千问OpenAI 兼容接口适合读者前端开发者、GIS 入门者、想做AI地图Demo 的同学前置知识了解 Vue3 组合式 API、基础的 HTTP 请求即可目录一、效果与核心功能二、技术选型2.1 为什么选 OpenLayers2.2 为什么接大模型2.3 大模型选型建议三、整体架构3.1 架构图3.2 时序流程四、环境准备4.1 创建项目并安装依赖4.2 申请密钥4.3 配置环境变量五、核心模块一大模型语义解析5.1 Prompt 工程设计5.2 请求封装5.3 容错与重试六、核心模块二地理编码与 POI 检索6.1 地理编码地址 → 坐标6.2 周边 POI 检索坐标 半径 → 兴趣点列表6.3 坐标系问题重点必看七、核心模块三OpenLayers 渲染与交互7.1 初始化地图 高德底图7.2 点位样式内联 SVG 图标7.3 渲染函数缓冲区 中心点 POI 标注 视图适配九、运行与效果验证十、踩坑记录建议收藏十一、拓展方向十二、总结一、效果与核心功能先想象一个场景用户输入一句大白话——“帮我找北京南站附近500米内的咖啡馆”系统自动完成「理解语义 → 定位 → 检索 → 标绘」全流程地图自动定位到北京南站画出一个 500 米的圆形缓冲区把缓冲区内的咖啡馆全部标注出来点击点位弹出详情卡片左侧列表点击可飞行定位本文实现的三个核心能力能力说明技术载体① 自然语言意图解析把口语化表达转成结构化 JSON城市 / 关键词 / 中心点 / 半径大模型 Chat Completions Prompt 工程② 地理编码与周边检索地址转坐标、按半径查询周边兴趣点POI高德 Web 服务 API③ 地图标绘与交互点位标注、缓冲区、信息弹窗、视图自动适配、列表联动OpenLayers本质是一条流水线LLM 结构化输出 → 地理编码 → 空间渲染。二、技术选型2.1 为什么选 OpenLayers方案开源免费中文资料矢量渲染学习成本扩展性OpenLayers✅丰富强中等高Leaflet✅丰富弱需插件低中Mapbox GL❌商用收费一般极强高中百度 / 高德 JS SDK✅丰富中低低封闭OpenLayers 开源免费、无使用费支持 WMS/XYZ/矢量等多种数据源与前端框架解耦且文档和社区成熟——非常适合做AI GIS这类需要自由组合能力的应用。2.2 为什么接大模型传统地图搜索的交互是填表单城市下拉框 关键词输入 半径滑块 筛选条件门槛高、表达受限。大模型把交互升级为说人话外滩3公里内能停车的火锅店→ 自动拆出关键词火锅店、中心外滩、半径 3000 米广州哪里有24小时营业的药店→ 把24小时营业这种描述转成有效检索词关键不在模型多大而在 Prompt 工程能否让模型稳定输出机器可读的结构化数据2.3 大模型选型建议DeepSeek性价比高、JSON 输出稳定、支持response_format个人项目首选通义千问 qwen-max中文语义理解好、阿里生态完善只要兼容 OpenAI 的Chat Completions接口本文代码可以直接复用三、整体架构3.1 架构图3.2 时序流程四、环境准备4.1 创建项目并安装依赖node -v # 要求 ≥ 18 npm create vitelatest ai-map -- --template vue cd ai-map npm install ol npm run dev # 启动开发服务器说明缓冲区直接用 OpenLayers 的Circle几何即可本文不需要 Turf.js如后续做更复杂的空间分析相交、聚合、路径再安装turf/turf。4.2 申请密钥高德开放平台 → 控制台 → 应用管理 → 创建应用 → 添加 Key类型选「Web 服务」拿到 Web 服务 Key。大模型平台DeepSeek / 通义千问等创建 API Key。4.3 配置环境变量新建.env文件已被 Vite 默认 gitignore不会提交到仓库VITE_AMAP_KEY你的高德Web服务KeyVITE_LLM_BASEhttps://api.deepseek.comVITE_LLM_KEYsk-你的大模型KeyVITE_LLM_MODELdeepseek-chat项目结构ai-map/├── .env├── index.html└── src/├── main.js # Vue 入口保持默认├── App.vue # 本文核心代码└── style.css # 清空默认样式可选五、核心模块一大模型语义解析5.1 Prompt 工程设计大模型输出格式不稳定是最大痛点。用三个手段保证稳定明确角色与任务告诉模型它是地理查询意图解析器限定输出格式只输出 JSON禁止任何解释文字Few-shot 示例给出 2 个输入 → 输出对模型会模仿格式const SYSTEM_PROMPT 你是一个地理查询意图解析器。把用户输入解析为 JSON 并只输出 JSON不要输出任何解释文字。 字段说明 - city城市名必填 - keyword要搜索的地点/兴趣点关键词必填 - center中心参考点名称如西湖外滩可为空字符串 - radius搜索半径单位米未明确说明时默认 3000 示例 用户帮我找上海外滩附近3公里内的火锅店 输出{city:上海,keyword:火锅店,center:外滩,radius:3000} 用户广州哪里有24小时营业的药店 输出{city:广州,keyword:24小时营业的药店,center:,radius:3000}5.2 请求封装async function parseIntent(text) { const resp await fetch(${import.meta.env.VITE_LLM_BASE}/chat/completions, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${import.meta.env.VITE_LLM_KEY} }, body: JSON.stringify({ model: import.meta.env.VITE_LLM_MODEL, messages: [ { role: system, content: SYSTEM_PROMPT }, { role: user, content: text } ], temperature: 0.1, // 低温输出更稳定 response_format: { type: json_object } // 强制 JSON 输出 }) }) if (!resp.ok) throw new Error(大模型请求失败HTTP ${resp.status}) const data await resp.json() return JSON.parse(data.choices[0].message.content) }关键点temperature: 0.1降低随机性避免同一个问题每次解析结果不同response_format: { type: json_object }强制模型输出合法 JSONDeepSeek、通义千问、OpenAI 均支持若模型不支持response_format去掉该字段靠 Prompt 约束 下面的容错兜底5.3 容错与重试网络抖动、模型偶尔抽风都可能让解析失败加一层重试async function parseIntentWithRetry(text, retries 2) { for (let i 0; i retries; i) { try { return await parseIntent(text) } catch (e) { if (i retries) throw new Error(意图解析失败${e.message}) await new Promise(r setTimeout(r, 500)) } } }六、核心模块二地理编码与 POI 检索6.1 地理编码地址 → 坐标async function geocode(address) { const url https://restapi.amap.com/v3/geocode/geo?key${import.meta.env.VITE_AMAP_KEY}address${encodeURIComponent(address)} const resp await fetch(url) const data await resp.json() if (data.status ! 1 || !data.geocodes?.length) { throw new Error(地理编码失败${data.info}) } const [lng, lat] data.geocodes[0].location.split(,).map(Number) return { lng, lat } }6.2 周边 POI 检索坐标 半径 → 兴趣点列表async function searchPoiAround(keyword, center, radius) { const url https://restapi.amap.com/v3/place/around?key${import.meta.env.VITE_AMAP_KEY} keywords${encodeURIComponent(keyword)} location${center.lng},${center.lat} radius${radius}offset25page1extensionsall const resp await fetch(url) const data await resp.json() if (data.status ! 1) throw new Error(POI 检索失败${data.info}) return data.pois.map(poi ({ id: poi.id, name: poi.name, address: poi.address || , lng: Number(poi.location.split(,)[0]), lat: Number(poi.location.split(,)[1]), distance: Math.round(Number(poi.distance)) })) }6.3 坐标系问题重点必看高德 Web 服务返回的坐标是 GCJ-02火星坐标系而 OpenLayers 默认坐标系是 EPSG:3857Web 墨卡托OSM 等底图使用 WGS84。三者不互通直接混用会导致地图点位偏移 300~500 米。本文方案直接使用高德栅格瓦片做底图GCJ-02 坐标与高德底图天然匹配无需任何转换。如果你换成 OSM / Google 底图必须先把 GCJ-02 反算成 WGS84这是最容易踩的坑。七、核心模块三OpenLayers 渲染与交互7.1 初始化地图 高德底图// 高德路网瓦片style8 为路网图style7 为卫星图 const AMAP_TILES [01, 02, 03, 04].map(n https://webrd${n}.is.autonavi.com/appmaptile?langzh_cnsize1scale1style8x{x}y{y}z{z} ) view new View({ center: fromLonLat([116.397, 39.909]), zoom: 11 }) map new Map({ target: map, layers: [ new TileLayer({ source: new XYZ({ urls: AMAP_TILES, wrapX: true }) }), new VectorLayer({ source: bufferSource, zIndex: 5 }), // 缓冲区层 new VectorLayer({ source: poiSource, zIndex: 10 }) // 点位层 ], view })7.2 点位样式内联 SVG 图标不用下载图标文件把 SVG 转成 data URI 内联部署零资源、零请求const MARKER_ICON new Icon({ src: data:image/svgxml;utf8, encodeURIComponent( svg xmlnshttp://www.w3.org/2000/svg width30 height40 viewBox0 0 30 40 path dM15 0C6.7 0 0 6.7 0 15c0 10.5 15 25 15 25s15-14.5 15-25C30 6.7 23.3 0 15 0z fill#E74C3C stroke#FFF stroke-width1.5/ circle cx15 cy15 r5.5 fill#FFF/ /svg ), anchor: [0.5, 1], // 锚点对准图钉底部 scale: 0.9 })7.3 渲染函数缓冲区 中心点 POI 标注 视图适配function renderResult(center, keyword, radius) { // 每次搜索前必须清空旧图层否则新旧结果叠加 poiSource.clear() bufferSource.clear() // ① 缓冲区面直接用 OpenLayers 的 Circle 几何半径单位是米 const bufferFeature new Feature({ geometry: new Circle(fromLonLat([center.lng, center.lat]), radius), name: keyword }) bufferFeature.setStyle(new Style({ fill: new Fill({ color: rgba(64,158,255,0.12) }), stroke: new Stroke({ color: #409EFF, width: 2, lineDash: [8, 4] }) })) bufferSource.addFeature(bufferFeature) // ② 中心点 const centerFeature new Feature({ geometry: new Point(fromLonLat([center.lng, center.lat])), name: 中心点 }) centerFeature.setStyle(new Style({ image: new CircleStyle({ radius: 7, fill: new Fill({ color: #409EFF }), stroke: new Stroke({ color: #fff, width: 2 }) }) })) poiSource.addFeature(centerFeature) // ③ POI 点位 poiFeatureList pois.value.map(poi { const f new Feature({ geometry: new Point(fromLonLat([poi.lng, poi.lat])), id: poi.id, name: poi.name, address: poi.address, distance: poi.distance }) f.setStyle(new Style({ image: MARKER_ICON, text: new Text({ text: poi.name, font: 12px sans-serif, fill: new Fill({ color: #333 }), stroke: new Stroke({ color: #fff, width: 3 }), // 文字描白边底图复杂时更清晰 offsetY: -26 }) })) return f }) poiSource.addFeatures(poiFeatureList) // ④ 视图自动适配让所有要素都在视野内 const extent new VectorSource({ features: [bufferFeature, centerFeature, ...poiFeatureList] }).getExtent() view.fit(extent, { padding: [60, 60, 60, 60], maxZoom: 16, duration: 600 }) } 7.4 信息弹窗与点击交互 // 创建 Overlay 弹窗 popup new Overlay({ element: document.getElementById(popup), positioning: bottom-center, stopEvent: false // 允许点击事件穿透到地图 }) map.addOverlay(popup) // 点击地图要素显示弹窗 map.on(click, evt { const feature map.forEachFeatureAtPixel(evt.pixel, f f) if (feature feature.get(name)) { showPopup(feature, evt.coordinate) } else { popup.setPosition(undefined) } }) // 安全提示用 textContent 渲染不要用 innerHTML 拼接外部数据防止 XSS function showPopup(feature, coordinate) { popup.setPosition(coordinate) const el document.getElementById(popup) el.innerHTML const h3 document.createElement(h3) h3.textContent feature.get(name) const p document.createElement(p) p.textContent [ feature.get(address), feature.get(distance) ? 距离约 ${feature.get(distance)} 米 : ].filter(Boolean).join( · ) el.append(h3, p) } 7.5 左侧列表点击飞行联动 function flyTo(poi) { const feature poiFeatureList.find(f f.get(id) poi.id) view.animate({ center: fromLonLat([poi.lng, poi.lat]), zoom: 16, duration: 500 }) if (feature) showPopup(feature, fromLonLat([poi.lng, poi.lat])) } 八、完整页面App.vue 把上面所有模块组装进一个 Vue 组件直接替换 src/App.vue template div classcontainer aside classpanel h2AI 语义搜索/h2 div classsearch-box input v-modelquery placeholder例帮我找北京南站附近500米的咖啡馆 keyup.enterhandleSearch / button :disabledloading clickhandleSearch {{ loading ? 解析中… : 搜索 }} /button /div p classtip试试找北京南站附近能停车的火锅店/p div v-iferror classerror{{ error }}/div div v-ifintent classintent p大模型解析结果/p code{{ JSON.stringify(intent) }}/code /div ul classpoi-list li v-forpoi in pois :keypoi.id clickflyTo(poi) span classpoi-name{{ poi.name }}/span span classpoi-dist{{ poi.distance }}m/span /li li v-if!pois.length !loading classempty暂无结果试试上面的例子/li /ul /aside div idmap classmap/div div idpopup classol-popup/div /div /template script setup import { onMounted, ref } from vue import Map from ol/Map import View from ol/View import TileLayer from ol/layer/Tile import XYZ from ol/source/XYZ import VectorLayer from ol/layer/Vector import VectorSource from ol/source/Vector import Feature from ol/Feature import Point from ol/geom/Point import Circle from ol/geom/Circle import { Style, Icon, Fill, Stroke,, Text } from ol/style import CircleStyle from ol/style/Circle import Overlay from ol/Overlay import { fromLonLat } from ol/proj // 响应式状态 const query ref() const loading ref(false) const error ref() const intent ref(null) const pois ref([]) // 地图实例与图层 let map, view let poiSource, bufferSource let poiFeatureList [] let popup // ① 大模型语义解析 const SYSTEM_PROMPT 你是一个地理查询意图解析器。把用户输入解析为 JSON 并只输出 JSON不要输出任何解释文字。 字段说明 - city城市名必填 - keyword要搜索的地点/兴趣点关键词必填 - center中心参考点名称如西湖外滩可为空字符串 - radius搜索半径单位米未明确说明时默认 3000 示例 用户帮我找上海外滩附近3公里内的火锅店 输出{city:上海,keyword:火锅店,center:外滩,radius:3000} 用户广州哪里有24小时营业的药店 输出{city:广州,keyword:24小时营业的药店,center:,radius:3000} async function parseIntent(text) { const resp await fetch(${import.meta.env.VITE_LLM_BASE}/chat/completions, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${import.meta.env.VITE_LLM_KEY} }, body: JSON.stringify({ model: import.meta.env.VITE_LLM_MODEL, messages: [ { role: system, content: SYSTEM_PROMPT }, { role: user, content: text } ], temperature: 0.1, response_format: { type: json_object } }) }) if (!resp.ok) throw new Error(大模型请求失败HTTP ${resp.status}) const data await resp.json() return JSON.parse(data.choices[0].message.content) } async function parseIntentWithRetry(text, retries 2) { for (let i 0; i retries; i) { try { return await parseIntent(text) } catch (e) { if (i retries) throw new Error(意图解析失败${e.message}) await new Promise(r setTimeout(r, 500)) } } } // ② 高德地理编码 周边检索 async function geocode(address) { const url https://restapi.amap.com/v3/geocode/geo?key${import.meta.env.VITE_AMAP_KEY}address${encodeURIComponent(address)} const resp await fetch(url) const data await resp.json() if (data.status ! 1 || !data.geocodes?.length) { throw new Error(地理编码失败${data.info}) } const [lng, lat] data.geocodes[0].location.split(,).map(Number) return { lng, lat } } async function searchPoiAround(keyword, center, radius) { const url https://restapi.amap.com/v3/place/around?key${import.meta.env.VITE_AMAP_KEY} keywords${encodeURIComponent(keyword)} location${center.lng},${center.lat} radius${radius}offset25page1extensionsall const resp await fetch(url) const data await resp.json() if (data.status ! 1) throw new Error(POI 检索失败${data.info}) return data.pois.map(poi ({ id: poi.id, name: poi.name, address: poi.address || , lng: Number(poi.location.split(,)[0]), lat: Number(poi.location.split(,)[1]), distance: Math.round(Number(poi.distance)) })) } // ③ 地图初始化 const AMAP_TILES [01, 02, 03, 04].map(n https://webrd${n}.is.autonavi.com/appmaptile?langzh_cnsize1scale1style8x{x}y{y}z{z} ) const MARKER_ICON new Icon({ src: data:image/svgxml;utf8, encodeURIComponent( svg xmlnshttp://www.w3.org/2000/svg width30 height40 viewBox0 0 30 40path dM15 0C6.7 0 0 6.7 0 15c0 10.5 15 25 15 25s15-14.5 15-25C30 6.7 23.3 0 15 0z fill#E74C3C stroke#FFF stroke-width1.5/circle cx15 cy15 r5.5 fill#FFF//svg ), anchor: [0.5, 1], scale: 0.9 }) function initMap() { view new View({ center: fromLonLat([116.397, 39.909]), zoom: 11 }) poiSource new VectorSource() bufferSource new VectorSource() map new Map({ target: map, layers: [ new TileLayer({ source: new XYZ({ urls: AMAP_TILES, wrapX: true }) }), new VectorLayer({ source: bufferSource, zIndex: 5 }), new VectorLayer({ source: poiSource, zIndex: 10 }) ], view }) popup new Overlay({ element: document.getElementById(popup), positioning: bottom-center, stopEvent: false }) map.addOverlay(popup) map.on(click, evt { const feature map.forEachFeatureAtPixel(evt.pixel, f f) if (feature feature.get(name)) { showPopup(feature, evt.coordinate) } else { popup.setPosition(undefined) } }) } // ④ 渲染 function renderResult(center, keyword, radius) { poiSource.clear() bufferSource.clear() const bufferFeature new Feature({ geometry: new Circle(fromLonLat([center.lng, center.lat]), radius), name: keyword }) bufferFeature.setStyle(new Style({ fill: new Fill({ color: rgba(64,158,255,0.12) }), stroke: new Stroke({ color: #409EFF, width: 2, lineDash: [8, 4] }) })) bufferSource.addFeature(bufferFeature) const centerFeature new Feature({ geometry: new Point(fromLonLat([center.lng, center.lat])), name: 中心点 }) centerFeature.setStyle(new Style({ image: new CircleStyle({ radius: 7, fill: new Fill({ color: #409EFF }), stroke: new Stroke({ color: #fff, width: 2 }) }) })) poiSource.addFeature(centerFeature) poiFeatureList pois.value.map(poi { const f new Feature({ geometry: new Point(fromLonLat([poi.lng, poi.lat])), id: poi.id, name: poi.name, address: poi.address, distance: poi.distance }) f.setStyle(new Style({ image: MARKER_ICON, text: new Text({ text: poi.name, font: 12px sans-serif, fill: new Fill({ color: #333 }), stroke: new Stroke({ color: #fff, width: 3 }), offsetY: -26 }) })) return f }) poiSource.addFeatures(poiFeatureList) const extent new VectorSource({ features: [bufferFeature, centerFeature, ...poiFeatureList] }).getExtent() view.fit(extent, { padding: [60, 60, 60, 60], maxZoom: 16, duration: 600 }) } function showPopup(feature, coordinate) { popup.setPosition(coordinate) const el document.getElementById(popup) el.innerHTML const h3 document.createElement(h3) h3.textContent feature.get(name) const p document.createElement(p) p.textContent [ feature.get(address), feature.get(distance) ? 距离约 ${feature.get(distance)} 米 : ].filter(Boolean).join( · ) el.append(h3, p) } // ⑤ 主流程 async function handleSearch() { const text query.value.trim() if (!text) return loading.value true error.value intent.value null pois.value [] try { // 1. 大模型解析自然语言 const parsed await parseIntentWithRetry(text) intent.value parsed // 2. 地理编码定位中心点 const center await geocode(parsed.center || parsed.city) // 3. 周边 POI 检索 pois.value await searchPoiAround(parsed.keyword, center, parsed.radius || 3000) // 4. 地图标绘 renderResult(center, parsed.keyword, parsed.radius || 3000) } catch (e) { error.value e.message } finally { loading.value false } } function flyTo(poi) { const feature poiFeatureList.find(f f.get(id) poi.id) view.animate({ center: fromLonLat([poi.lng, poi.lat]), zoom: 16, duration: 500 }) if (feature) showPopup(feature, fromLonLat([poi.lng, poi.lat])) } onMounted(initMap) /script style scoped .container { display: flex; height: 100vh; font-family: PingFang SC, Microsoft YaHei, sans-serif; } .panel { width: 320px; padding: 16px; background: #fff; box-shadow: 2px 0 8px rgba(0, 0, 0, .08); z-index: 1; overflow-y: auto; } .panel h2 { margin: 0 0 12px; font-size: 18px; } .search-box { display: flex; gap: 8px; } .search-box input { flex: 1; padding: 8px 10px; border: 1px solid #dcdfe6; border-radius: 4px; font-size: 14px; } .search-box button { padding: 8px 14px; background: #409eff; color: #fff; border: none; border-radius: 4px; cursor: pointer; } .search-box button:disabled { background: #a0cfff; cursor: not-allowed; } .tip { color: #909399; font-size: 12px; margin: 8px 0; } .error { color: #f56c6c; background: #fef0f0; padding: 8px; border-radius: 4px; font-size: 13px; margin: 8px 0; } .intent { background: #f4f4f5; padding: 8px; border-radius: 4px; font-size: 12px; margin: 8px 0; word-break: break-all; } .intent code { color: #409eff; } .poi-list { list-style: none; margin: 8px 0 0; padding: 0; } .poi-list li { display: flex; justify-content: space-between; align-items: center; padding: 10px 8px; border-bottom: 1px solid #f0f0f0; cursor: pointer; } .poi-list li:hover { background: #f5f7fa; } .poi-name { font-size: 14px; color: #333; } .poi-dist { font-size: 12px; color: #909399; } .empty { justify-content: center !important; color: #c0c4cc; cursor: default !important; } .map { flex: 1; } /* 弹窗 */ .ol-popup { position: absolute; background: #fff; border-radius: 6px; box-shadow: 0 2px 12px rgba(0, 0, 0, .2); padding: 10px 14px; font-size: 13px; min-width: 160px; } .ol-popup::after { content: ; position: absolute; left: 50%; bottom: -8px; transform: translateX(-50%); border: 8px solid transparent; border-top-color: #fff; border-bottom: none; } .ol-popup h3 { margin: 0 0 4px; font-size: 14px; color: #333; } .ol-popup p { margin: 0; color: #666; } /stylesrc/main.js保持默认即可import { createApp } from vue import App from ./App.vue createApp(App).mount(#app)九、运行与效果验证npm run dev打开http://localhost:5173按下面用例逐一测试输入期望结果帮我找北京南站附近500米内的咖啡馆定位北京南站500m 缓冲区咖啡馆全部标绘找北京南站附近能停车的火锅店定位北京南站关键词自动拆出火锅店广州哪里有24小时营业的药店定位广州无中心点半径默认 3000上海外滩3公里内的观景台定位外滩半径 3000十、踩坑记录建议收藏坐标系混乱最常见高德坐标是 GCJ-02配 OSM/Google 底图会偏移 300~500 米。要么底图用高德瓦片本文方案要么写 GCJ-02 → WGS84 转换函数。大模型 JSON 不稳定加response_format: json_object 低温 重试兜底不要相信一次解析一定成功。图层不清空每次搜索前必须source.clear()否则新旧结果叠加在图上越搜越乱。API Key 泄露Web 服务 Key 放前端只适合 Demo。生产环境把高德请求、大模型请求放到后端代理前端只调自己的接口Key 留在服务端。跨域高德 Web 服务 API 支持浏览器跨域调用Demo 可用若线上被拦截用 Vite/Nginx 代理或后端转发。XSS 风险弹窗内容来自 POI 外部数据必须用textContent渲染不要用innerHTML直接拼接。配额与限流高德个人版有每日调用配额批量测试注意控量大模型接口也注意并发限制。十一、拓展方向语音输入接 Web Speech API 或 ASR说一句话直接出图多轮对话让模型记住上下文支持再缩小到 500 米“换成加油站”路径规划结合高德路径规划 API让 AI 输出先去 A 再去 B并在地图上画线数据可视化接入 ECharts 做统计面板对 POI 做分类聚合、热力图上传照片定位用多模态模型识别地标自动打点生成旅行足迹地图十二、总结本文用约 300 行代码完成了一个会说人话的地图应用大模型负责理解OpenLayers 负责表达。核心方法论可以迁移到任何自然语言 空间数据的场景——本质是 LLM 结构化输出 → 地理编码 → 空间渲染 三段式流水线。掌握了这条流水线路径规划、轨迹分析、空间问答都是同一套骨架的变体。如果你运行出了效果不错的 Demo欢迎评论区贴图交流。