小白从零开始:用 Python + 高德地图打造 JavaScript 网页地图应用

一、引言:为什么选择 Python + 高德地图 + JavaScript?

在数字化时代,地图应用已渗透到生活的方方面面 —— 从外卖 APP 的骑手定位,到旅游网站的景点导航,再到企业的物流追踪系统。而实现 “后端数据处理 + 前端地图可视化” 的组合,Python(灵活的后端数据处理能力)、高德地图(成熟的地图服务 API)、JavaScript(前端交互核心)无疑是性价比极高的技术栈。

本文将以 “周边餐饮查询工具” 为实战案例,带大家完整实现:Python 后端调用高德地图 Web 服务 API 获取地理数据 → 搭建 API 接口供前端调用 → 用 JavaScript 结合高德地图 JS API 在网页显示地图并加载数据,全程代码可复现,关键步骤用表格拆解,即使是编程新手也能跟随操作。

二、基础准备:工具、账号与技术栈选型

在开始开发前,我们需要完成 “账号申请 + 环境搭建 + 技术选型” 三大准备工作,这是后续开发的基础。

2.1 必备工具与账号

类别

具体内容

用途说明

获取方式

开发工具

VS Code

代码编写(支持 Python/JS/HTML,插件丰富)

官网下载

开发工具

Postman

测试 Python 后端 API 接口

官网下载

开发工具

Anaconda

管理 Python 虚拟环境(避免依赖冲突)

官网下载

账号资源

高德地图开发者账号

获取调用 API 的 Key(核心凭证)

高德开发者平台注册

浏览器

Chrome/Firefox

调试前端网页地图

电脑自带或官网下载

2.2 技术栈选型对比

不同技术栈适用于不同场景,下表为你分析各组件的选型理由,帮助你理解 “为什么选这些工具”:

技术模块

可选方案

本文选型

选型理由

Python 后端框架

Flask / Django / FastAPI

Flask

轻量级、学习成本低,适合小型 API 开发;无需复杂配置,快速上手

HTTP 请求库

requests / aiohttp

requests

同步请求足够满足需求,API 简洁,文档丰富,新手友好

前端地图 API

高德地图 JS API / 百度地图 JS API / 谷歌地图 JS API

高德地图 JS API

国内访问稳定,文档全中文,支持 POI 搜索、路径规划等功能,免费额度充足

前端数据交互

原生 JavaScript /jQuery/ Axios

原生 JavaScript + Axios

原生 JS 兼容性好,Axios 简化异步请求,避免 jQuery 的冗余代码

数据格式

JSON / XML / CSV

JSON

前后端数据交互的主流格式,Python 和 JS 都能轻松解析

2.3 高德地图 API Key 申请步骤(关键!)

调用高德地图的所有功能都需要 “API Key”,请严格按照以下步骤操作:

  1. 注册账号:访问高德开发者平台,用手机号注册并完成实名认证(个人开发者即可,免费额度足够测试)。
  1. 创建应用
    • 登录后进入「控制台」→「应用管理」→「创建应用」,输入应用名称(如 “Python 高德地图 Demo”),选择应用类型(如 “Web 前端”)。
  1. 申请 API Key
    • 在应用下点击「添加 Key」,选择 Key 类型(需区分 “Web 服务 API” 和 “JS API”,本文需同时申请两种):
      • Web 服务 API Key:用于 Python 后端调用(如获取 POI 数据、地理编码),需勾选 “Web 服务” 权限。
      • JS API Key:用于前端网页显示地图,需勾选 “JS API” 权限,并设置 “Referer 白名单”(开发阶段可填localhost或*,生产环境需填真实域名)。

Key 类型

用途

权限要求

白名单设置

Web 服务 API Key

Python 后端获取地理数据

勾选 “Web 服务”

无需设置

JS API Key

JavaScript 前端显示地图

勾选 “JS API”

开发:localhost;生产:真实域名(如www.example.com)

三、Python 后端开发:搭建地理数据 API 服务

Python 后端的核心作用是:调用高德地图 Web 服务 API 获取地理数据(如 POI、经纬度),并封装成 HTTP 接口供前端调用。本节将分 “环境搭建→核心功能实现→API 接口封装” 三部分讲解。

3.1 环境搭建:安装依赖库

打开 Anaconda Prompt,创建并激活虚拟环境,然后安装所需库:

 

# 创建虚拟环境(名为amap-demo,Python版本3.9)

conda create -n amap-demo python=3.9

# 激活虚拟环境

conda activate amap-demo

# 安装依赖库

pip install flask requests flask-cors python-dotenv

各库的用途如下表所示:

库名称

用途

关键功能

Flask

轻量级 Python Web 框架

搭建 HTTP 接口(如/api/poi)、处理前端请求

requests

发送 HTTP 请求

调用高德地图 Web 服务 API(如 POI 搜索接口)

flask-cors

解决跨域问题

允许前端(如localhost:5500)访问后端(如localhost:5000)

python-dotenv

管理环境变量

存储 API Key 等敏感信息(避免硬编码到代码中)

3.2 核心功能 1:调用高德地图 Web 服务 API 获取数据

首先,我们需要封装一个 Python 工具类,用于调用高德地图的 Web 服务 API(如 POI 搜索、地理编码)。创建amap_utils.py文件,代码如下:

import requests

from dotenv import load_dotenv

import os

# 加载环境变量(从.env文件读取API Key)

load_dotenv()

AMAP_WEB_KEY = os.getenv("AMAP_WEB_KEY") # 从.env文件获取Web服务API Key

class AMapWebAPI:

def __init__(self, api_key=AMAP_WEB_KEY):

self.api_key = api_key

# 高德地图Web服务API基础URL(参考官方文档)

self.base_url = "https://restapi.amap.com/v3"

def search_poi(self, keywords, city, page=1, page_size=20):

"""

搜索POI(兴趣点)数据(如周边餐厅、酒店)

:param keywords: 搜索关键词(如“餐厅”“咖啡馆”)

:param city: 城市名称(如“北京”“上海”)

:param page: 页码(默认第1页)

:param page_size: 每页结果数(默认20条,最大50条)

:return: 格式化后的POI数据(JSON)

"""

# POI搜索接口URL

url = f"{self.base_url}/place/text"

# 请求参数(参考高德Web服务API文档)

params = {

"key": self.api_key,

"keywords": keywords,

"city": city,

"page": page,

"offset": page_size,

"output": "json", # 返回格式:json/xml

"extensions": "all" # 返回详细信息(如地址、电话、经纬度)

}

try:

# 发送GET请求

response = requests.get(url, params=params)

response.raise_for_status() # 若状态码非200,抛出异常

data = response.json()

# 校验返回结果是否成功

if data["status"] == "1":

# 提取关键信息(过滤无用字段,方便前端使用)

result = {

"total": data["count"], # 总结果数

"page": page,

"page_size": page_size,

"pois": [

{

"id": poi["id"], # POI唯一ID

"name": poi["name"], # 名称

"address": poi["address"], # 地址

"location": poi["location"], # 经纬度(格式:lng,lat)

"tel": poi.get("tel", "无"), # 电话(若无则显示“无”)

"distance": poi.get("distance", 0) # 距离中心点的距离(米,若无则0)

}

for poi in data["pois"]

]

}

return {"success": True, "data": result}

else:

return {"success": False, "error": f"获取POI失败:{data['info']}"}

except Exception as e:

return {"success": False, "error": f"请求异常:{str(e)}"}

def geocode(self, address, city=None):

"""

地理编码(地址→经纬度)

:param address: 详细地址(如“北京市海淀区中关村大街1号”)

:param city: 城市(可选,缩小搜索范围,提高准确性)

:return: 经纬度数据(JSON)

"""

url = f"{self.base_url}/geocode/geo"

params = {

"key": self.api_key,

"address": address,

"city": city,

"output": "json"

}

try:

response = requests.get(url, params=params)

response.raise_for_status()

data = response.json()

if data["status"] == "1" and len(data["geocodes"]) > 0:

geocode_info = data["geocodes"][0]

result = {

"address": geocode_info["formatted_address"], # 格式化地址

"location": geocode_info["location"], # 经纬度(lng,lat)

"city": geocode_info["city"], # 城市名称

"adcode": geocode_info["adcode"] # 行政区划代码

}

return {"success": True, "data": result}

else:

return {"success": False, "error": f"地理编码失败:{data['info']}"}

except Exception as e:

return {"success": False, "error": f"请求异常:{str(e)}"}

def reverse_geocode(self, location):

"""

逆地理编码(经纬度→地址)

:param location: 经纬度(格式:lng,lat,如“116.481028,39.921983”)

:return: 地址数据(JSON)

"""

url = f"{self.base_url}/geocode/regeo"

params = {

"key": self.api_key,

"location": location,

"output": "json",

"extensions": "all" # 返回详细信息(如街道、门牌号)

}

try:

response = requests.get(url, params=params)

response.raise_for_status()

data = response.json()

if data["status"] == "1":

regeo_info = data["regeocode"]

result = {

"address": regeo_info["formatted_address"], # 格式化地址

"street": regeo_info["addressComponent"]["street"], # 街道

"number": regeo_info["addressComponent"]["streetNumber"], # 门牌号

"city": regeo_info["addressComponent"]["city"], # 城市

"district": regeo_info["addressComponent"]["district"] # 区县

}

return {"success": True, "data": result}

else:

return {"success": False, "error": f"逆地理编码失败:{data['info']}"}

except Exception as e:

return {"success": False, "error": f"请求异常:{str(e)}"}

关键说明(用表格拆解):

功能方法

输入参数

输出结果

核心逻辑

search_poi

keywords(关键词)、city(城市)、page(页码)、page_size(每页条数)

包含总结果数、当前页 POI 列表(名称、地址、经纬度等)的 JSON

1. 拼接 POI 搜索接口 URL;2. 传入 API Key 和搜索参数;3. 解析响应,过滤无用字段;4. 返回格式化数据

geocode

address(地址)、city(可选城市)

包含格式化地址、经纬度、城市的 JSON

1. 调用地理编码接口;2. 提取第一个匹配结果的经纬度;3. 返回结构化地址信息

reverse_geocode

location(经纬度)

包含详细地址、街道、门牌号的 JSON

1. 调用逆地理编码接口;2. 解析响应中的地址组件;3. 返回人类可读的详细地址

环境变量配置(.env 文件):

为了避免将 API Key 硬编码到代码中(不安全,且便于切换环境),创建.env文件,内容如下:

 

# .env文件(与amap_utils.py同级目录)

AMAP_WEB_KEY=你的高德地图Web服务API Key # 替换为你申请的Key

3.3 核心功能 2:用 Flask 封装 API 接口

接下来,创建app.py文件,用 Flask 搭建 HTTP 接口,供前端调用 Python 后端的地理数据功能。代码如下:

from flask import Flask, request, jsonify

from flask_cors import CORS

from amap_utils import AMapWebAPI

# 初始化Flask应用

app = Flask(__name__)

# 允许跨域(解决前端和后端域名/端口不同导致的请求被拦截问题)

CORS(app, resources=r"/*") # 开发阶段允许所有请求跨域,生产环境需限制域名

# 初始化高德地图Web API工具类

amap_api = AMapWebAPI()

# 1. POI搜索接口(前端调用此接口获取周边POI数据)

@app.route("/api/poi", methods=["GET"])

def get_poi():

# 从前端请求中获取参数(若参数不存在,用默认值)

keywords = request.args.get("keywords", "餐厅") # 默认搜索“餐厅”

city = request.args.get("city", "北京") # 默认城市“北京”

page = int(request.args.get("page", 1)) # 默认第1页

page_size = int(request.args.get("page_size", 20)) # 默认每页20条

# 调用amap_utils中的search_poi方法

result = amap_api.search_poi(keywords, city, page, page_size)

# 返回JSON格式响应

return jsonify(result)

# 2. 地理编码接口(地址→经纬度)

@app.route("/api/geocode", methods=["GET"])

def get_geocode():

address = request.args.get("address") # 前端必须传入地址参数

city = request.args.get("city") # 可选参数

if not address:

return jsonify({"success": False, "error": "地址参数(address)不能为空"})

result = amap_api.geocode(address, city)

return jsonify(result)

# 3. 逆地理编码接口(经纬度→地址)

@app.route("/api/regeo", methods=["GET"])

def get_regeo():

location = request.args.get("location") # 前端必须传入经纬度(格式:lng,lat)

if not location:

return jsonify({"success": False, "error": "经纬度参数(location)不能为空"})

result = amap_api.reverse_geocode(location)

return jsonify(result)

# 启动Flask服务

if __name__ == "__main__":

# debug=True:开发阶段开启调试模式(代码修改后自动重启服务)

app.run(host="0.0.0.0", port=5000, debug=True)

API 接口详情表(前端对接指南):

接口 URL

请求方法

必选参数

可选参数

返回格式

功能描述

/api/poi

GET

无(默认搜索 “北京餐厅”)

keywords(关键词)、city(城市)、page(页码)、page_size(每页条数)

JSON

获取指定城市、关键词的 POI 数据(如北京的餐厅列表)

/api/geocode

GET

address(详细地址)

city(城市)

JSON

将地址转换为经纬度(如 “北京市中关村大街 1 号”→“116.481028,39.921983”)

/api/regeo

GET

location(经纬度,格式:lng,lat)

JSON

将经纬度转换为详细地址(如 “116.481028,39.921983”→“北京市海淀区中关村大街 1 号”)

测试 API 接口(用 Postman):
  1. 启动 Flask 服务:运行app.py,控制台显示 “Running on http://0.0.0.0:5000/”。
  1. 打开 Postman,发送 GET 请求:
    • 示例 1:测试 POI 搜索接口

URL:http://localhost:5000/api/poi?keywords=咖啡馆&city=上海&page=1&page_size=10

若返回{"success":true,"data":{"total":"123","page":1,...}},说明接口正常。

    • 示例 2:测试地理编码接口

URL:http://localhost:5000/api/geocode?address=上海市浦东新区张江高科技园区&city=上海

若返回{"success":true,"data":{"location":"121.618528,31.219342",...}},说明接口正常。

四、JavaScript 前端开发:实现网页地图显示与数据交互

前端的核心作用是:用高德地图 JS API 在网页中显示地图,并调用 Python 后端的 API 接口,将地理数据(如 POI、经纬度)可视化到地图上。本节将分 “地图初始化→数据对接→功能扩展” 三部分讲解。

4.1 前端项目结构

创建frontend文件夹,结构如下(清晰的目录便于维护):

frontend/

├─ index.html # 网页入口(地图显示、页面布局)

├─ css/

│ └─ style.css # 页面样式(美化地图容器、按钮、列表)

└─ js/

├─ map.js # 地图核心逻辑(初始化、标记POI、事件监听)

└─ api.js # 对接Python后端API(发送请求、处理响应)

4.2 步骤 1:编写 HTML 页面(基础布局)

创建index.html,包含地图容器、搜索框、POI 列表显示区域,代码如下:

<!DOCTYPE html>

<html lang="zh-CN">

<head>

<meta charset="UTF-8">

<meta name="viewport" content="width=device-width, initial-scale=1.0">

<title>Python+高德地图:周边餐饮查询工具</title>

<!-- 1. 引入高德地图JS API(替换为你的JS API Key) -->

<script type="text/javascript" src="https://webapi.amap.com/maps?v=2.0&key=你的高德地图JS API Key"></script>

<!-- 2. 引入Axios(简化异步请求) -->

<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>

<!-- 3. 引入自定义CSS -->

<link rel="stylesheet" href="./css/style.css">

</head>

<body>

<div class="container">

<!-- 顶部搜索栏 -->

<div class="search-bar">

<input type="text" id="city-input" placeholder="请输入城市(如:北京)" value="北京">

<input type="text" id="keyword-input" placeholder="请输入关键词(如:餐厅、咖啡馆)" value="餐厅">

<button id="search-btn">搜索周边</button>

<button id="clear-btn">清空标记</button>

</div>

<!-- 主体内容:左侧地图,右侧POI列表 -->

<div class="main-content">

<!-- 地图容器(必须设置宽高,否则地图无法显示) -->

<div id="map-container"></div>

<!-- POI列表容器 -->

<div class="poi-list">

<h3>搜索结果(<span id="total-count">0</span>条)</h3>

<div id="poi-items"></div> <!-- POI列表将通过JS动态生成 -->

<!-- 分页按钮 -->

<div class="pagination">

<button id="prev-page" disabled>上一页</button>

<span id="page-info">第1页 / 共0页</span>

<button id="next-page" disabled>下一页</button>

</div>

</div>

</div>

</div>

<!-- 引入自定义JS(先引入api.js,再引入map.js,保证依赖顺序) -->

<script src="./js/api.js"></script>

<script src="./js/map.js"></script>

</body>

</html>

关键说明:
  • 高德地图 JS API 引入:必须在<head>中引入,且key需替换为你申请的 “JS API Key”(若 Key 错误,地图会显示空白并报错)。
  • 地图容器:#map-container必须设置宽高(将在style.css中配置),否则地图无法渲染。
  • 依赖顺序:先引入api.js(对接后端 API),再引入map.js(使用 API 数据),避免变量未定义错误。

4.3 步骤 2:编写 CSS 样式(美化页面)

创建css/style.css,优化页面布局和视觉效果,代码如下:

/* 重置默认样式(避免浏览器差异) */

* {

margin: 0;

padding: 0;

box-sizing: border-box;

font-family: "Microsoft YaHei", sans-serif;

}

body {

background-color: #f5f5f5;

padding: 20px;

}

/* 容器:限制页面宽度,居中显示 */

.container {

max-width: 1400px;

margin: 0 auto;

}

/* 搜索栏样式 */

.search-bar {

display: flex;

gap: 10px;

margin-bottom: 20px;

align-items: center;

}

.search-bar input {

padding: 10px 15px;

border: 1px solid #ddd;

border-radius: 4px;

font-size: 16px;

}

#city-input {

width: 200px;

}

#keyword-input {

flex: 1; /* 占满剩余宽度 */

max-width: 500px;

}

.search-bar button {

padding: 10px 20px;

background-color: #3f88c5;

color: white;

border: none;

border-radius: 4px;

font-size: 16px;

cursor: pointer;

transition: background-color 0.3s;

}

.search-bar button:hover {

background-color: #2a6faa;

}

#clear-btn {

background-color: #e63946;

}

#clear-btn:hover {

background-color: #c1121f;

}

/* 主体内容:地图+POI列表 */

.main-content {

display: flex;

gap: 20px;

height: 80vh; /* 占屏幕高度的80% */

}

/* 地图容器样式 */

#map-container {

flex: 2; /* 地图占2/3宽度 */

border-radius: 8px;

box-shadow: 0 2px 10px rgba(0,0,0,0.1);

z-index: 1; /* 确保地图在底层,不遮挡其他元素 */

}

/* POI列表容器样式 */

.poi-list {

flex: 1; /* 列表占1/3宽度 */

background-color: white;

border-radius: 8px;

box-shadow: 0 2px 10px rgba(0,0,0,0.1);

padding: 20px;

overflow-y: auto; /* 内容超出时滚动 */

}

.poi-list h3 {

margin-bottom: 15px;

color: #333;

border-bottom: 1px solid #eee;

padding-bottom: 10px;

}

/* POI列表项样式 */

.poi-item {

padding: 15px;

border-bottom: 1px solid #eee;

cursor: pointer;

transition: background-color 0.3s;

}

.poi-item:hover {

background-color: #f9f9f9;

}

.poi-item h4 {

color: #3f88c5;

margin-bottom: 5px;

}

.poi-item p {

color: #666;

font-size: 14px;

margin: 3px 0;

}

/* 分页按钮样式 */

.pagination {

display: flex;

justify-content: center;

align-items: center;

gap: 15px;

margin-top: 20px;

}

.pagination button {

padding: 8px 15px;

border: 1px solid #ddd;

border-radius: 4px;

background-color: white;

cursor: pointer;

font-size: 14px;

}

.pagination button:disabled {

background-color: #f5f5f5;

color: #999;

cursor: not-allowed;

}

#page-info {

color: #666;

font-size: 14px;

}

4.4 步骤 3:编写 API 对接逻辑(api.js)

创建js/api.js,封装调用 Python 后端 API 的函数,供map.js使用。代码如下:

 

// api.js:对接Python后端API的工具函数

const API_BASE_URL = "http://localhost:5000/api"; // 后端API基础URL

// 1. 获取POI数据(调用后端/api/poi接口)

export async function getPOIData(params) {

try {

// 发送GET请求(Axios会自动拼接参数到URL)

const response = await axios.get(`${API_BASE_URL}/poi`, { params });

return response.data; // 返回后端响应数据

} catch (error) {

console.error("获取POI数据失败:", error);

return { success: false, error: "网络异常,获取数据失败" };

}

}

// 2. 地理编码(地址→经纬度,调用后端/api/geocode接口)

export async function getGeocode(address, city = "") {

try {

const response = await axios.get(`${API_BASE_URL}/geocode`, {

params: { address, city }

});

return response.data;

} catch (error) {

console.error("地理编码失败:", error);

return { success: false, error: "网络异常,地理编码失败" };

}

}

// 3. 逆地理编码(经纬度→地址,调用后端/api/regeo接口)

export async function getRegeo(location) {

try {

const response = await axios.get(`${API_BASE_URL}/regeo`, {

params: { location }

});

return response.data;

} catch (error) {

console.error("逆地理编码失败:", error);

return { success: false, error: "网络异常,逆地理编码失败" };

}

}

关键说明:
  • API_BASE_URL:后端服务的基础地址(若后端部署到服务器,需替换为服务器 IP / 域名,如http://192.168.1.100:5000/api)。
  • async/await:用于处理异步请求,避免回调地狱,代码更易读。
  • 错误处理:每个函数都有try/catch,捕获网络异常并返回错误信息,便于前端提示用户。

4.5 步骤 4:编写地图核心逻辑(map.js)

创建js/map.js,实现地图初始化、POI 标记显示、搜索交互等核心功能。代码如下:

 

// 导入api.js中的工具函数

import { getPOIData, getGeocode, getRegeo } from "./api.js";

// 全局变量(存储地图实例、POI标记等)

let map = null; // 高德地图实例

let poiMarkers = []; // 存储POI标记的数组(用于清空标记)

let currentPage = 1; // 当前页码

let totalPage = 0; // 总页数

let totalCount = 0; // 总POI数量

let currentParams = { // 当前搜索参数(关键词、城市等)

keywords: "餐厅",

city: "北京",

page_size: 10

};

// 页面加载完成后初始化地图

document.addEventListener("DOMContentLoaded", async () => {

// 1. 初始化高德地图

initMap();

// 2. 绑定搜索、清空、分页按钮的点击事件

bindEvents();

// 3. 初始加载第一页POI数据

await loadPOIData(currentParams, currentPage);

});

/**

* 1. 初始化高德地图

*/

function initMap() {

// 创建地图实例(中心点默认设为北京:116.404, 39.915)

map = new AMap.Map("map-container", {

zoom: 13, // 地图缩放级别(1-20,越大越详细)

center: [116.404, 39.915], // 地图中心点经纬度

layers: [new AMap.TileLayer.Satellite()], // 卫星图层(可替换为默认图层:AMap.TileLayer.RoadNet)

resizeEnable: true // 允许地图随容器大小变化而调整

});

// 添加地图控件(缩放控件、比例尺、定位控件)

map.addControl(new AMap.Scale()); // 比例尺控件(显示地图比例尺)

map.addControl(new AMap.Zoom()); // 缩放控件(放大/缩小地图)

map.addControl(new AMap.Geolocation({ // 定位控件(获取用户当前位置)

enableHighAccuracy: true, // 开启高精度定位

timeout: 10000, // 定位超时时间(毫秒)

buttonPosition: "RB" // 控件位置(右下角)

}));

// 监听定位成功事件(获取用户位置后,将地图中心点移到用户位置)

map.on("geolocationComplete", (data) => {

const { position } = data;

map.setCenter([position.lng, position.lat]);

console.log("用户当前位置:", position);

});

}

/**

* 2. 绑定页面交互事件(搜索、清空、分页)

*/

function bindEvents() {

// 搜索按钮点击事件

document.getElementById("search-btn").addEventListener("click", async () => {

const city = document.getElementById("city-input").value.trim();

const keywords = document.getElementById("keyword-input").value.trim();

// 校验输入(城市和关键词不能为空)

if (!city || !keywords) {

alert("请输入城市和搜索关键词!");

return;

}

// 更新当前搜索参数

currentParams = {

keywords: keywords,

city: city,

page_size: currentParams.page_size // 保持每页条数不变

};

currentPage = 1; // 重置为第一页

// 加载新的POI数据

await loadPOIData(currentParams, currentPage);

});

// 清空标记按钮点击事件

document.getElementById("clear-btn").addEventListener("click", () => {

clearPOIMarkers(); // 清空地图上的POI标记

document.getElementById("poi-items").innerHTML = ""; // 清空POI列表

document.getElementById("total-count").textContent = "0"; // 重置总条数

document.getElementById("page-info").textContent = "第1页 / 共0页"; // 重置分页信息

currentPage = 1;

totalPage = 0;

totalCount = 0;

});

// 上一页按钮点击事件

document.getElementById("prev-page").addEventListener("click", async () => {

if (currentPage > 1) {

currentPage--;

await loadPOIData(currentParams, currentPage);

}

});

// 下一页按钮点击事件

document.getElementById("next-page").addEventListener("click", async () => {

if (currentPage < totalPage) {

currentPage++;

await loadPOIData(currentParams, currentPage);

}

});

// POI列表项点击事件(通过事件委托,监听动态生成的列表项)

document.getElementById("poi-items").addEventListener("click", (e) => {

const poiItem = e.target.closest(".poi-item");

if (poiItem) {

// 获取POI的经纬度(从data-location属性中获取)

const location = poiItem.getAttribute("data-location");

if (location) {

const [lng, lat] = location.split(",").map(Number);

// 将地图中心点移到该POI,并放大到15级

map.setCenter([lng, lat]);

map.setZoom(15);

}

}

});

}

/**

* 3. 加载POI数据(调用后端API,更新地图标记和列表)

* @param {Object} params - 搜索参数(keywords, city, page_size)

* @param {number} page - 当前页码

*/

async function loadPOIData(params, page) {

// 显示加载提示

document.getElementById("poi-items").innerHTML = '<div style="text-align:center;padding:20px;">加载中...</div>';

// 调用后端API获取POI数据

const result = await getPOIData({ ...params, page });

if (result.success) {

const { total, page: currentPageRes, pois } = result.data;

totalCount = total;

currentPage = currentPageRes;

totalPage = Math.ceil(totalCount / params.page_size); // 计算总页数(向上取整)

// 更新页面UI(总条数、分页信息)

document.getElementById("total-count").textContent = totalCount;

document.getElementById("page-info").textContent = `第${currentPage}页 / 共${totalPage}页`;

// 更新分页按钮状态(是否禁用)

document.getElementById("prev-page").disabled = currentPage === 1;

document.getElementById("next-page").disabled = currentPage === totalPage;

// 清空之前的POI标记

clearPOIMarkers();

// 1. 在地图上添加POI标记

addPOIMarkers(pois);

// 2. 生成POI列表

renderPOIList(pois);

} else {

// 显示错误提示

document.getElementById("poi-items").innerHTML = `<div style="text-align:center;padding:20px;color:red;">${result.error}</div>`;

}

}

/**

* 4. 在地图上添加POI标记

* @param {Array} pois - POI数据数组

*/

function addPOIMarkers(pois) {

pois.forEach(poi => {

const { location, name, address } = poi;

const [lng, lat] = location.split(",").map(Number);

// 创建自定义图标(替换默认标记图标)

const markerIcon = new AMap.Icon({

size: new AMap.Size(32, 32), // 图标大小

image: "https://webapi.amap.com/theme/v1.3/markers/n/mark_b.png", // 图标地址(高德默认蓝色标记)

imageSize: new AMap.Size(32, 32) // 图标图片大小

});

// 创建标记实例

const marker = new AMap.Marker({

position: [lng, lat], // 标记位置(经纬度)

icon: markerIcon, // 自定义图标

title: name, // 鼠标 hover 时显示的标题

anchor: "bottom-center" // 图标锚点(标记点对应图标底部中心)

});

// 添加信息窗口(点击标记时显示POI详情)

const infoWindow = new AMap.InfoWindow({

content: `

<div style="padding:10px;">

<h4 style="color:#3f88c5;margin-bottom:5px;">${name}</h4>

<p style="margin:3px 0;font-size:14px;">地址:${address}</p>

<p style="margin:3px 0;font-size:14px;">电话:${poi.tel}</p>

</div>

`, // 信息窗口内容

offset: new AMap.Pixel(0, -30) // 信息窗口偏移(避免遮挡标记)

});

// 绑定标记的点击事件(显示信息窗口)

marker.on("click", () => {

infoWindow.open(map, marker.getPosition());

});

// 将标记添加到地图

marker.setMap(map);

// 存储标记到数组(用于后续清空)

poiMarkers.push({ marker, infoWindow });

});

}

/**

* 5. 生成POI列表(动态渲染到页面)

* @param {Array} pois - POI数据数组

*/

function renderPOIList(pois) {

if (pois.length === 0) {

document.getElementById("poi-items").innerHTML = '<div style="text-align:center;padding:20px;">未找到相关POI数据</div>';

return;

}

// 拼接POI列表HTML

let poiHtml = "";

pois.forEach(poi => {

poiHtml += `

<div class="poi-item" data-location="${poi.location}">

<h4>${poi.name}</h4>

<p>地址:${poi.address}</p>

<p>电话:${poi.tel}</p>

${poi.distance > 0 ? `<p>距离:${poi.distance}米</p>` : ""}

</div>

`;

});

// 将HTML插入到POI列表容器

document.getElementById("poi-items").innerHTML = poiHtml;

}

/**

* 6. 清空地图上的POI标记和信息窗口

*/

function clearPOIMarkers() {

poiMarkers.forEach(({ marker, infoWindow }) => {

marker.setMap(null); // 从地图上移除标记

infoWindow.close(); // 关闭信息窗口

});

poiMarkers = []; // 清空标记数组

}

地图核心功能拆解表:

功能模块

关键函数

实现逻辑

前端交互效果

地图初始化

initMap()

1. 创建地图实例,设置中心点和缩放级别;2. 添加缩放、比例尺、定位控件;3. 监听定位事件

页面加载后显示北京地图,右下角有定位按钮,点击可获取用户位置并居中

POI 搜索

loadPOIData()

1. 调用后端/api/poi接口获取数据;2. 清空旧标记;3. 调用addPOIMarkers()和renderPOIList()更新地图和列表

点击 “搜索周边” 后,地图显示 POI 标记,右侧显示列表,支持分页

POI 标记

addPOIMarkers()

1. 为每个 POI 创建自定义图标标记;2. 绑定点击事件(显示信息窗口);3. 将标记添加到地图

地图上显示蓝色标记,鼠标 hover 显示名称,点击显示详情(地址、电话)

POI 列表交互

renderPOIList() + 事件委托

1. 动态生成 POI 列表 HTML;2. 列表项绑定点击事件(地图居中到该 POI)

点击右侧列表项,地图自动定位到对应 POI 并放大

清空标记

clearPOIMarkers()

1. 遍历标记数组,移除地图上的标记;2. 关闭信息窗口;3. 清空数组

点击 “清空标记” 后,地图标记和列表全部清空

五、功能扩展:从基础到进阶(可选)

基础版本实现后,你可以根据需求扩展功能。以下是几个常用的进阶功能,同样用表格和代码说明实现思路。

5.1 扩展 1:添加 “地址搜索定位” 功能

需求:用户输入详细地址(如 “上海市浦东新区张江高科技园区”),点击按钮后地图定位到该地址,并显示 POI。

实现步骤:
  1. 在index.html的搜索栏添加地址输入框和定位按钮:
 

<div class="search-bar">

<!-- 原有代码 -->

<input type="text" id="address-input" placeholder="请输入详细地址(如:北京市中关村大街1号)">

<button id="locate-address-btn">地址定位</button>

</div>

  1. 在map.js的bindEvents()函数中添加按钮点击事件:
 

// 地址定位按钮点击事件

document.getElementById("locate-address-btn").addEventListener("click", async () => {

const address = document.getElementById("address-input").value.trim();

const city = document.getElementById("city-input").value.trim();

if (!address) {

alert("请输入详细地址!");

return;

}

// 调用地理编码接口(地址→经纬度)

const result = await getGeocode(address, city);

if (result.success) {

const { location, formatted_address } = result.data;

const [lng, lat] = location.split(",").map(Number);

// 1. 地图定位到该地址

map.setCenter([lng, lat]);

map.setZoom(15);

// 2. 在该地址添加定位标记

clearPOIMarkers(); // 清空原有标记

const locateMarker = new AMap.Marker({

position: [lng, lat],

icon: new AMap.Icon({

size: new AMap.Size(32, 32),

image: "https://webapi.amap.com/theme/v1.3/markers/n/mark_r.png", // 红色定位标记

imageSize: new AMap.Size(32, 32)

}),

title: "定位地址"

});

locateMarker.setMap(map);

poiMarkers.push({ marker: locateMarker, infoWindow: new AMap.InfoWindow() });

// 3. 显示地址信息窗口

const infoWindow = new AMap.InfoWindow({

content: `<div style="padding:10px;">定位地址:${formatted_address}</div>`,

offset: new AMap.Pixel(0, -30)

});

infoWindow.open(map, [lng, lat]);

// 4. 可选:搜索该地址周边的POI(如周边500米内的餐厅)

currentParams = {

keywords: currentParams.keywords,

city: city,

page_size: currentParams.page_size,

location: location, // 新增:以定位地址为中心点

radius: 500 // 新增:搜索半径500米

};

currentPage = 1;

await loadPOIData(currentParams, currentPage);

} else {

alert(`地址定位失败:${result.error}`);

}

});

  1. 更新 Python 后端amap_utils.py的search_poi()方法,支持按中心点和半径搜索:
 

def search_poi(self, keywords, city, page=1, page_size=20, location=None, radius=None):

url = f"{self.base_url}/place/text"

params = {

"key": self.api_key,

"keywords": keywords,

"city": city,

"page": page,

"offset": page_size,

"output": "json",

"extensions": "all"

}

# 新增:若传入location和radius,添加到请求参数(按中心点搜索)

if location and radius:

params["location"] = location

params["radius"] = radius # 搜索半径(米)

# 后续代码不变...

5.2 扩展 2:添加 “热力图” 显示 POI 分布

需求:用热力图直观展示 POI 的分布密度(如北京餐厅的分布情况)。

实现步骤:
  1. 在index.html的高德地图 JS API 引入中添加热力图插件:
 

<script type="text/javascript" src="https://webapi.amap.com/maps?v=2.0&key=你的JS API Key&plugins=HeatMap"></script>

  1. 在map.js中添加热力图相关全局变量和函数:
 

let heatMap = null; // 热力图实例

let isHeatMapShow = false; // 热力图是否显示的开关

// 在initMap()函数中初始化热力图(默认不显示)

function initMap() {

// 原有代码...

// 初始化热力图(隐藏状态)

heatMap = new AMap.HeatMap(map, {

radius: 25, // 热力图点半径(越大,热力范围越广)

opacity: [0, 0.8], // 透明度范围

gradient: { // 热力图颜色梯度(从低到高:蓝→绿→黄→红)

0.2: '#00fffc',

0.4: '#4dff4d',

0.6: '#ffff00',

0.8: '#ff9900',

1.0: '#ff0000'

}

});

heatMap.hide(); // 默认隐藏热力图

}

// 添加热力图切换按钮事件(在bindEvents()中)

function bindEvents() {

// 原有代码...

// 热力图切换按钮(需在index.html中添加按钮)

document.getElementById("toggle-heatmap-btn").addEventListener("click", () => {

isHeatMapShow = !isHeatMapShow;

const btn = document.getElementById("toggle-heatmap-btn");

if (isHeatMapShow) {

// 显示热力图:隐藏POI标记,显示热力图

clearPOIMarkers();

heatMap.show();

btn.textContent = "隐藏热力图";

// 加载POI数据并生成热力图数据

if (totalCount > 0) {

loadHeatMapData(currentParams);

} else {

alert("请先搜索POI数据,再显示热力图!");

isHeatMapShow = false;

btn.textContent = "显示热力图";

}

} else {

// 隐藏热力图:显示POI标记

heatMap.hide();

btn.textContent = "显示热力图";

loadPOIData(currentParams, currentPage); // 重新加载POI标记

}

});

}

// 加载热力图数据

async function loadHeatMapData(params) {

// 获取所有POI数据(不分页,最多1000条,高德API限制)

const result = await getPOIData({ ...params, page: 1, page_size: 1000 });

if (result.success) {

const pois = result.data.pois;

// 转换为热力图所需格式:[[lng, lat, weight], ...](weight为权重,这里用1)

const heatData = pois.map(poi => {

const [lng, lat] = poi.location.split(",").map(Number);

return [lng, lat, 1]; // 权重为1,所有POI同等重要

});

// 设置热力图数据

heatMap.setData(heatData);

}

}

  1. 在index.html的搜索栏添加热力图切换按钮:
 

<div class="search-bar">

<!-- 原有代码 -->

<button id="toggle-heatmap-btn">显示热力图</button>

</div>

六、常见问题排查与优化(避坑指南)

在开发过程中,你可能会遇到各种问题(如地图空白、API 调用失败)。下表整理了高频问题及解决方案:

常见问题

可能原因

解决方案

地图显示空白,控制台报错 “Invalid Key”

1. JS API Key 错误;2. Key 未勾选 “JS API” 权限;3. Referer 白名单未设置

1. 检查 Key 是否为 “JS API Key”,而非 “Web 服务 API Key”;2. 进入高德开发者平台,确认 Key 已勾选 “JS API” 权限;3. 开发阶段将 Referer 白名单设为localhost或*

前端调用后端 API 报错 “CORS Error”

后端未配置跨域,浏览器拦截前端请求

1. 确保 Flask 已安装flask-cors;2. 在app.py中添加CORS(app, resources=r"/*");3. 生产环境需限制允许跨域的域名(如CORS(app, resources={"r/*": {"origins": "https://www.example.com"}}))

后端调用高德 Web 服务 API 返回 “INVALID_USER_KEY”

1. Web 服务 API Key 错误;2. Key 未勾选 “Web 服务” 权限;3. Key 已过期

1. 检查.env文件中的AMAP_WEB_KEY是否正确;2. 确认 Key 已勾选 “Web 服务” 权限;3. 进入高德开发者平台,检查 Key 是否有效(未被禁用)

地图标记不显示,但 POI 列表正常

1. 经纬度格式错误(如 “lat,lng” 顺序颠倒);2. 标记图标地址错误;3. 地图容器未设置宽高

1. 确认 POI 的location格式为 “lng,lat”(如 “116.404,39.915”);2. 检查addPOIMarkers()中的图标地址是否可访问;3. 确认#map-container在 CSS 中设置了width和height

前端请求后端 API 时,控制台显示 “404 Not Found”

1. 后端服务未启动;2. API URL 错误;3. 请求方法错误(如用 POST 请求 GET 接口)

1. 确保app.py已启动,且端口为 5000;2. 检查api.js中的API_BASE_URL是否为http://localhost:5000/api;3. 确认请求方法与后端接口一致(如/api/poi为 GET 请求)

热力图不显示,控制台无报错

1. 热力图插件未引入;2. 热力图数据格式错误;3. 热力图被 POI 标记覆盖

1. 确认 JS API 引入时添加了plugins=HeatMap;2. 检查heatData格式是否为[[lng, lat, weight], ...];3. 显示热力图前需调用clearPOIMarkers()清空标记

七、项目部署:从本地到线上(可选)

本地开发完成后,若想让他人访问你的地图应用,需要将项目部署到服务器。以下是简易部署方案(适合个人测试):

7.1 部署 Python 后端(用 PythonAnywhere)

  1. 注册PythonAnywhere账号(免费版支持 1 个 Web 应用)。
  1. 上传后端代码(app.pyamap_utils.py、.env)到 PythonAnywhere 的mysite目录。
  1. 安装依赖:在 PythonAnywhere 的 “Consoles” 中运行pip install flask requests flask-cors python-dotenv。
  1. 配置 Web 应用:
    • 选择 “Flask” 框架,Python 版本 3.9+。
    • 设置 “Source code” 路径为你的代码目录(如/home/你的用户名/mysite)。
    • 设置 “WSGI configuration file” 为/home/你的用户名/mysite/flask_app.wsgi。
  1. 启动 Web 应用:点击 “Reload”,后端 API 地址为https://你的用户名.pythonanywhere.com/api

7.2 部署前端页面(用 GitHub Pages)

  1. 创建 GitHub 仓库(如amap-demo-frontend),上传前端代码(index.html、css/、js/)。
  1. 进入仓库 “Settings”→“Pages”,设置 “Source” 为 “main branch”,“Folder” 为 “/”。
  1. 保存后,前端页面地址为https://你的用户名.github.io/amap-demo-frontend/
  1. 修改前端api.js中的API_BASE_URL为 PythonAnywhere 的后端地址(如https://你的用户名.pythonanywhere.com/api)。

八、总结与展望

8.1 项目总结

本文通过 “周边餐饮查询工具” 实战案例,完整讲解了 “Python 后端 + 高德地图 + JavaScript 前端” 的开发流程,核心收获如下:

  1. 技术栈整合:Python(Flask)负责后端数据处理和 API 封装,高德地图提供地理数据和地图渲染能力,JavaScript 负责前端交互,三者协同实现 “数据→接口→可视化” 的完整链路。
  1. 关键知识点
    • 高德地图 API 的两种 Key(Web 服务 API Key 用于后端,JS API Key 用于前端)的申请与配置。
    • Python 后端如何调用第三方 API(高德 Web 服务 API)并封装成 HTTP 接口。
    • JavaScript 前端如何用高德 JS API 初始化地图、添加标记、实现交互,并对接后端 API。
  1. 实战能力:掌握了 POI 搜索、地理编码、地图标记、热力图等常用功能的实现,以及跨域问题、API 错误排查等实战技巧。

8.2 未来扩展方向

  1. 功能扩展
    • 添加 “路径规划” 功能(步行、驾车、公交路线)。
    • 结合数据库(如 MySQL)存储 POI 数据,支持历史搜索记录。
    • 实现用户认证(如微信登录),保存用户的收藏 POI。
  1. 性能优化
    • 后端添加缓存(如 Redis),减少重复调用高德 API 的次数(节省额度)。
    • 前端实现 POI 数据懒加载,优化大数据量下的渲染速度。
  1. 平台扩展
    • 基于此项目开发移动端应用(用 React Native 或 Flutter)。
    • 集成其他地图服务(如百度地图、谷歌地图),支持多地图切换。

<think>我们计划使用Python和PyQt5来开发桌面应用,因为PyQt5功能强大且跨平台。同时,我们将使用folium生成离线地图(基于OpenStreetMap),或者使用高德/百度的离线瓦片(需要自行下载)。由于用户要求离线地图,我们需要下载离线地图瓦片,并修改folium的源码使其支持离线模式。步骤概览:1.环境搭建:安装Python、VSCode,配置虚拟环境,安装依赖库(PyQt5,folium,Jinja2等)2.下载离线地图瓦片(以高德地图为例)3.创建主窗口,集成地图(使用folium生成html,通过PyQt5的QWebEngineView加载)4.实现标记的添加、编辑、删除功能5.实现标记文件夹的自动创建和删除6.保存地图状态(中心坐标、缩放级别)和标记数据详细步骤:###一、环境搭建1.安装Python(建议3.7以上版本)-从Python官网下载:https://www.python.org/downloads/-安装时勾选“AddPythontoPATH”2.安装VSCode-从官网下载:https://code.visualstudio.com/3.在VSCode中创建项目文件夹,并设置Python虚拟环境:-打开VSCode,按`Ctrl+Shift+P`,输入“Python:CreateEnvironment”,选择“Venv”-选择Python解释器4.安装依赖库:-打开终端(Terminal),激活虚拟环境(如果未自动激活)-安装以下库:```bashpipinstallPyQt5foliumjinja2pillow```###二、下载离线地图瓦片1.使用工具下载离线地图瓦片,推荐使用MobileAtlasCreator(MOBAC):-下载地址:https://mobac.sourceforge.io/-安装并运行MOBAC2.在MOBAC中选择地图源(例如高德地图),然后选择区域,设置缩放级别(3-18级),导出为“OsmdroidSQLite”格式,导出目录为项目下的`tiles`文件夹。3.将导出的瓦片文件(通常是许多.png图片)按照`{z}/{x}/{y}.png`的目录结构存放于项目目录的`tiles`文件夹下。###三、创建主窗口和地图1.在项目目录下创建`main.py`文件。2.编写主窗口类,包含一个QWebEngineView用于显示地图。3.使用folium生成地图HTML,并修改其源码以支持离线瓦片。4.由于folium默认使用在线地图,我们需要自定义一个离线瓦片图层。我们创建一个继承自`folium.raster_layers.TileLayer`的类,指向本地瓦片路径。**注意**:由于folium默认使用在线地图,我们需要修改其模板以使用本地瓦片。下面是一个自定义的离线地图类:```pythonimportfoliumfromfolium.utilitiesimportvalidate_location,validate_zoom_startclassOfflineTileLayer(folium.raster_layers.TileLayer):def__init__(self,tiles,attr='OfflineMap',name=None,overlay=True,control=True,**kwargs):super().__init__(tiles=tiles,attr=attr,name=name,overlay=overlay,control=control,**kwargs)```然后在创建地图时使用这个类。###四、代码实现以下是`main.py`的完整代码,包含详细的注释:```pythonimportosimportjsonimportshutilfromPyQt5.QtWidgetsimport(QApplication,QMainWindow,QWidget,QVBoxLayout,QHBoxLayout,QPushButton,QLineEdit,QTextEdit,QFileDialog,QLabel,QDialog,QMessageBox,QAction,QToolBar)fromPyQt5.QtCoreimportQUrl,QPointFfromPyQt5.QtWebEngineWidgetsimportQWebEngineViewfromPyQt5.QtGuiimportQIcon,QPixmapfromPILimportImageimportfoliumfromfoliumimportMap,Markerfromfolium.pluginsimportDrawimportsysimportioimportbase64fromjinja2importEnvironment,FileSystemLoader#自定义离线地图图层classOfflineTileLayer(folium.raster_layers.TileLayer):def__init__(self,tiles,attr='OfflineMap',name=None,overlay=True,control=True,**kwargs):super().__init__(tiles=tiles,attr=attr,name=name,overlay=overlay,control=control,**kwargs)classAddEditMarkDialog(QDialog):"""添加/编辑标记的对话框"""def__init__(self,parent=None,mark=None):super().__init__(parent)self.setWindowTitle("添加标记"ifmarkisNoneelse"编辑标记")self.mark=mark#如果是编辑,传入mark字典self.images=[]#存储图片路径layout=QVBoxLayout()#标记名称self.name_edit=QLineEdit()self.name_edit.setPlaceholderText("标记名称")layout.addWidget(QLabel("名称:"))layout.addWidget(self.name_edit)#备注信息self.note_edit=QTextEdit()self.note_edit.setPlaceholderText("备注信息")layout.addWidget(QLabel("备注:"))layout.addWidget(self.note_edit)#图片上传self.upload_btn=QPushButton("上传图片")self.upload_btn.clicked.connect(self.upload_image)layout.addWidget(self.upload_btn)#图片预览self.image_preview=QLabel()self.image_preview.setFixedSize(200,200)layout.addWidget(self.image_preview)#按钮btn_layout=QHBoxLayout()self.save_btn=QPushButton("保存")self.save_btn.clicked.connect(self.accept)btn_layout.addWidget(self.save_btn)self.cancel_btn=QPushButton("取消")self.cancel_btn.clicked.connect(self.reject)btn_layout.addWidget(self.cancel_btn)layout.addLayout(btn_layout)self.setLayout(layout)#如果是编辑模式,填充已有数据ifmarkisnotNone:self.name_edit.setText(mark.get('name',''))self.note_edit.setText(mark.get('note',''))ifmark.get('images',[]):self.images=mark['images']pixmap=QPixmap(self.images[0])self.image_preview.setPixmap(pixmap.scaled(200,200))defupload_image(self):file_path,_=QFileDialog.getOpenFileName(self,"选择图片","","Images(*.png*.jpg*.jpeg)")iffile_path:self.images=[file_path]#目前只支持一张图片pixmap=QPixmap(file_path)self.image_preview.setPixmap(pixmap.scaled(200,200))defget_data(self):name=self.name_edit.text()note=self.note_edit.toPlainText()returnname,note,self.imagesclassMapWindow(QMainWindow):def__init__(self):super().__init__()self.setWindowTitle("离线地图标记软件")self.setGeometry(100,100,1200,800)#中心窗口和布局central_widget=QWidget()self.setCentralWidget(central_widget)layout=QVBoxLayout(central_widget)#地图视图self.map_view=QWebEngineView()layout.addWidget(self.map_view)#初始化状态self.map_center=[39.9042,116.4074]#默认北京self.zoom=10self.markers=[]#存储标记数据:[{'id':str,'position':[lat,lng],'name':str,'note':str,'images':[图片路径],'folder':str}]self.current_marker_id=0#用于生成标记IDself.load_state()#加载保存的状态#地图初始化self.init_map()#创建工具条self.toolbar=QToolBar()self.addToolBar(self.toolbar)#添加标记模式按钮self.add_mark_action=QAction("添加标记模式",self)self.add_mark_action.setCheckable(True)self.add_mark_action.triggered.connect(self.toggle_add_mark_mode)self.toolbar.addAction(self.add_mark_action)#退出按钮exit_action=QAction("退出",self)exit_action.triggered.connect(self.close)self.toolbar.addAction(exit_action)#初始化状态:非添加标记模式self.add_mark_mode=Falseself.add_mark_action.setChecked(False)#连接地图加载完成信号self.map_view.loadFinished.connect(self.on_map_loaded)definit_map(self):"""创建离线地图并保存为HTML文件,然后加载到QWebEngineView中"""#创建地图对象self.map=Map(location=self.map_center,zoom_start=self.zoom,control_scale=True)#添加离线地图瓦片图层OfflineTileLayer(tiles='tiles/{z}/{x}/{y}.png',name='离线地图').add_to(self.map)#添加folium的绘图插件(用于手动绘制标记)Draw(export=True).add_to(self.map)#添加已有的标记formarkinself.markers:self.add_marker_to_map(mark)#保存地图为HTML字符串self.map.save('offline_map.html')self.map_view.load(QUrl.fromLocalFile(os.path.abspath('offline_map.html')))defadd_marker_to_map(self,mark):"""将一个标记添加到地图上"""#创建弹出内容popup_content=f"""<b>{mark['name']}</b><p>{mark['note']}</p>"""ifmark.get('images'):#显示第一张图片img_path=mark['images'][0]withopen(img_path,'rb')asimg_file:img_data=base64.b64encode(img_file.read()).decode('utf-8')popup_content+=f'<imgsrc="data:image/jpeg;base64,{img_data}"width=200>'popup=folium.Popup(popup_content,max_width=300)#添加标记Marker(location=mark['position'],popup=popup,icon=folium.Icon(color='red')).add_to(self.map)defon_map_loaded(self,ok):"""地图加载完成后,给地图添加点击事件监听"""ifok:#注入JavaScript来捕获地图的点击事件js="""//监听地图的点击事件map.on('click',function(e){//将点击的坐标发送到Python端letdata={lat:e.latlng.lat,lng:e.latlng.lng};window.PythonBridge.onMapClick(JSON.stringify(data));});//监听标记的点击事件(通过事件代理)map.addEventListener('click',function(e){if(e.originalEvent&&e.originalEvent.target&&e.originalEvent.target.closest('.leaflet-popup')){return;//点击的是popup内容,则忽略}letmarker=e.target;//可能是Markerif(markerinstanceofL.Marker){//标记点击,发送标记的自定义属性(这里需要事先在Marker对象上存储marker_id)letmarker_id=marker.options.marker_id;if(marker_id){window.PythonBridge.onMarkerClick(marker_id);}}},true);//使用捕获//为现有的标记添加点击事件(对于已有标记)window.setTimeout(function(){map.eachLayer(function(layer){if(layerinstanceofL.Marker){layer.on('click',function(e){//在Marker上存储marker_idif(this.options.marker_id){window.PythonBridge.onMarkerClick(this.options.marker_id);}});}});},1000);//延迟执行以确保所有图层加载完毕"""self.map_view.page().runJavaScript(js)#建立通信通道self.map_view.page().runJavaScript("window.PythonBridge={}")self.map_view.page().runJavaScript("window.PythonBridge.onMapClick=function(data){console.log(data);}")#将JS函数绑定到Python回调self.map_view.page().exposeObject('PythonBridge',self)#暴露Python对象到JavaScriptdefonMapClick(self,data):"""地图点击事件处理(暴露给JS调用的函数)"""data=json.loads(data)ifself.add_mark_mode:#添加标记模式,弹出对话框self.add_mark(data['lat'],data['lng'])defonMarkerClick(self,marker_id):"""标记点击事件处理"""ifnotself.add_mark_mode:#非添加标记模式下,点击标记显示编辑对话框self.edit_mark(marker_id)deftoggle_add_mark_mode(self,checked):"""切换添加标记模式"""self.add_mark_mode=checkedself.add_mark_action.setChecked(checked)ifchecked:QMessageBox.information(self,"提示","已进入添加标记模式,点击地图可添加新标记。")defadd_mark(self,lat,lng):"""添加新标记"""dialog=AddEditMarkDialog(self)ifdialog.exec_()==QDialog.Accepted:name,note,images=dialog.get_data()ifname:mark_id=f"mark_{self.current_marker_id}"self.current_marker_id+=1#创建对应的文件夹folder=f"marks/{mark_id}"os.makedirs(folder,exist_ok=True)mark={'id':mark_id,'position':[lat,lng],'name':name,'note':note,'folder':folder}#处理图片:复制到文件夹ifimages:image_files=[]fori,img_pathinenumerate(images):#复制图片到标记文件夹ext=os.path.splitext(img_path)[1]target_path=os.path.join(folder,f"image_{i}{ext}")shutil.copy(img_path,target_path)image_files.append(target_path)mark['images']=image_filesself.markers.append(mark)self.save_state()#保存状态self.refresh_map()defedit_mark(self,marker_id):"""编辑标记"""mark=next((mforminself.markersifm['id']==marker_id),None)ifmarkisNone:returndialog=AddEditMarkDialog(self,mark)ifdialog.exec_()==QDialog.Accepted:new_name,new_note,new_images=dialog.get_data()ifnew_name:#更新标记信息mark['name']=new_namemark['note']=new_note#如果上传了新图片ifnew_images:#先删除原图片ifos.path.exists(mark['folder']):shutil.rmtree(mark['folder'])os.makedirs(mark['folder'],exist_ok=True)#复制新图片image_files=[]fori,img_pathinenumerate(new_images):ext=os.path.splitext(img_path)[1]target_path=os.path.join(mark['folder'],f"image_{i}{ext}")shutil.copy(img_path,target_path)image_files.append(target_path)mark['images']=image_filesself.save_state()self.refresh_map()defrefresh_map(self):"""刷新地图:重新生成地图HTML并重新加载"""self.init_map()defsave_state(self):"""保存状态到文件"""state={'center':self.map_center,'zoom':self.zoom,'markers':self.markers}withopen('map_state.json','w')asf:json.dump(state,f,indent=4)defload_state(self):"""从文件加载状态"""ifos.path.exists('map_state.json'):withopen('map_state.json','r')asf:state=json.load(f)self.map_center=state.get('center',self.map_center)self.zoom=state.get('zoom',self.zoom)self.markers=state.get('markers',[])defcloseEvent(self,event):"""关闭窗口时保存状态"""self.save_state()event.accept()if__name__=="__main__":app=QApplication(sys.argv)window=MapWindow()window.show()sys.exit(app.exec_())```###五、项目结构```project/├──tiles/#离线地图瓦片├──marks/#存储每个标记的文件夹├──map_state.json#保存地图状态├──offline_map.html#自动生成的离线地图├──main.py#主程序└──(其他文件)```###六、运行程序在VSCode中打开项目文件夹,运行`main.py`文件。###七、功能测试1.点击“添加标记模式”,然后点击地图,会弹出标记编辑对话框。2.输入名称、备注、上传图片后保存,会发现`marks`文件夹下生成了对应标记的文件夹,里面包含上传的图片。3.点击已添加的标记,会弹出编辑对话框,可以修改或删除(删除功能在对话框中没有实现,请自行添加删除按钮)。4.关闭程序后重新打开,会恢复之前的状态。###注意事项1.由于folium的弹出窗口内容中直接使用了base64嵌入图片,在离线模式下可以正常显示。2.在保存状态时,我们保存的是图片的路径(相对路径),所以如果整个项目目录移动,可能会影响图片显示。###关于删除标记在编辑对话框中,可以添加一个删除按钮,点击后删除标记并关闭对话框。在`edit_mark`方法里,我们也要删除对应的文件夹。在`AddEditMarkDialog`中添加删除按钮,并在主窗口的`edit_mark`方法中处理删除操作。由于代码较长,以上实现已经是一个基本的可用程序。对于编程小白,建议先按照步骤运行,然后逐步调试。§§相关问题§§1.如何优化离线地图瓦片的加载速度?2.如何在编辑标记对话框中实现多张图片上传?3.如何实现标记的拖拽位置调整?4.如何加密存储标记数据以保护隐私?5.如何将软件打包成可执行文件?</think> ### 从零开始开发地图标记软件的详细教程 我将在PyQt5框架下创建一个完整的地图标记应用,适合编程新手。以下是详细步骤: #### 一、环境搭建 1. 安装Python 3.9+ 官网下载:https://www.python.org/downloads/ 2. 安装VSCode 官网下载:https://code.visualstudio.com/ 3. 在VSCode中安装扩展: - Python (Microsoft) - Pylance - Qt for Python 4. 创建项目文件夹(如`map_marker_app`)并打开终端: ```bash cd map_marker_app python -m venv venv source venv/bin/activate # Windows: venv\Scripts\activate pip install pyqt5 folium jinja2 pillow ``` #### 二、项目结构 创建以下文件: ``` map_marker_app/ ├── app.py # 主程序 ├── map_template.html # 地图模板 ├── data/ │ ├── map_state.json # 保存地图状态 │ └── markers/ # 标记数据 └── tiles/ # 离线地图瓦片(后述如何生成) ``` #### 三、核心代码实现 ##### 1. `app.py` - 主应用框架 ```python import sys import os import json import shutil import base64 from PIL import Image import folium from jinja2 import Environment, FileSystemLoader from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLineEdit, QTextEdit, QFileDialog, QLabel, QDialog) from PyQt5.QtWebEngineWidgets import QWebEngineView from PyQt5.QtCore import QUrl class AddEditMarkDialog(QDialog): """添加/编辑标记的对话框""" def __init__(self, parent=None, mark=None): super().__init__(parent) self.setWindowTitle("添加标记" if mark is None else "编辑标记") self.mark = mark self.images = [] layout = QVBoxLayout() # 标记名称 self.name_edit = QLineEdit() self.name_edit.setPlaceholderText("标记名称") layout.addWidget(QLabel("名称:")) layout.addWidget(self.name_edit) # 备注信息 self.note_edit = QTextEdit() self.note_edit.setPlaceholderText("备注信息") layout.addWidget(QLabel("备注:")) layout.addWidget(self.note_edit) # 图片上传 self.upload_btn = QPushButton("上传图片") self.upload_btn.clicked.connect(self.upload_image) layout.addWidget(self.upload_btn) # 图片预览 self.image_preview = QLabel() self.image_preview.setFixedSize(200, 200) layout.addWidget(self.image_preview) # 按钮 btn_layout = QHBoxLayout() self.save_btn = QPushButton("保存") self.save_btn.clicked.connect(self.accept) btn_layout.addWidget(self.save_btn) self.cancel_btn = QPushButton("取消") self.cancel_btn.clicked.connect(self.reject) btn_layout.addWidget(self.cancel_btn) layout.addLayout(btn_layout) self.setLayout(layout) # 编辑模式下填充数据 if mark: self.name_edit.setText(mark.get('name', '')) self.note_edit.setText(mark.get('note', '')) if mark.get('image_data'): pixmap = QPixmap() pixmap.loadFromData(base64.b64decode(mark['image_data'])) self.image_preview.setPixmap(pixmap) self.images = [mark['image_data']] def upload_image(self): file_path, _ = QFileDialog.getOpenFileName(self, "选择图片", "", "Images (*.png *.jpg *.jpeg)") if file_path: img = Image.open(file_path) img.thumbnail((800, 800)) # 压缩图片 buffered = img.tobytes() self.images.append(base64.b64encode(buffered).decode('utf-8')) pixmap = QPixmap(file_path) self.image_preview.setPixmap(pixmap.scaled(200, 200)) def get_data(self): return { 'name': self.name_edit.text(), 'note': self.note_edit.toPlainText(), 'image_data': self.images[0] if self.images else None } class MapWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("离线地图标记软件") self.setGeometry(100, 100, 1200, 800) # 初始化数据 self.map_center = [39.9042, 116.4074] # 默认北京坐标 self.zoom_level = 12 self.markers = [] self.markers_path = "data/markers" self.load_state() # 主窗口布局 central_widget = QWidget() self.setCentralWidget(central_widget) layout = QVBoxLayout(central_widget) # 创建地图视图 self.map_view = QWebEngineView() layout.addWidget(self.map_view) # 生成初始地图 self.generate_map() # 连接地图点击事件 self.map_view.page().runJavaScript(""" map.on('click', function(e) { window.mapClicked(e.latlng.lat, e.latlng.lng); }); """) # 暴露Python函数给JavaScript self.map_view.page().exposeObject('pythonHandler', self) # 加载地图 current_dir = os.path.dirname(os.path.abspath(__file__)) map_path = os.path.join(current_dir, "map_template.html") self.map_view.load(QUrl.fromLocalFile(map_path)) # 添加模式按钮 self.add_mark_btn = QPushButton("添加标记模式") self.add_mark_btn.clicked.connect(self.toggle_add_mode) layout.addWidget(self.add_mark_btn) self.adding_mode = False def load_state(self): """加载上次地图状态和标记数据""" try: with open('data/map_state.json') as f: data = json.load(f) self.map_center = data['center'] self.zoom_level = data['zoom'] for file in os.listdir('data/markers'): if file.endswith('.json'): with open(os.path.join('data/markers', file)) as f: self.markers.append(json.load(f)) except (FileNotFoundError, json.JSONDecodeError): os.makedirs('data/markers', exist_ok=True) def save_state(self): """保存地图状态和标记数据""" with open('data/map_state.json', 'w') as f: json.dump({ 'center': self.map_center, 'zoom': self.zoom_level }, f) for marker in self.markers: file_path = os.path.join('data/markers', f"{marker['id']}.json") with open(file_path, 'w') as f: json.dump(marker, f) def generate_map(self): """生成Folium地图HTML文件""" m = folium.Map( location=self.map_center, zoom_start=self.zoom_level, tiles="tiles/{z}/{x}/{y}.png", # 指向离线瓦片 attr='Offline Map Data' ) # 添加标记 for marker in self.markers: html = f""" <div style="font-weight:bold; font-size:16px">{marker['name']}</div> <p>{marker['note']}</p> """ if marker.get('image_data'): html += f'<img src="data:image/png;base64,{marker["image_data"]}" style="max-width:300px; max-height:300px">' folium.Marker( location=marker['position'], popup=folium.Popup(html, max_width=350), icon=folium.Icon(color='red', icon='info-sign') ).add_to(m) # 使用Jinja2模板 env = Environment(loader=FileSystemLoader('.')) template = env.get_template('map_template.html') output = template.render(map_html=m._repr_html_()) with open('map_template.html', 'w', encoding='utf-8') as f: f.write(output) def map_clicked(self, lat, lng): """处理地图点击事件(从JS调用)""" if self.adding_mode: self.add_mark(lat, lng) def toggle_add_mode(self): """切换添加标记模式""" self.adding_mode = not self.adding_mode status = "ON" if self.adding_mode else "OFF" self.add_mark_btn.setText(f"添加标记模式 ({status})") def add_mark(self, lat, lng): """添加新标记""" dialog = AddEditMarkDialog(self) if dialog.exec_() == QDialog.Accepted: mark_data = dialog.get_data() mark_id = f"mark_{len(self.markers) + 1}" mark = { 'id': mark_id, 'name': mark_data['name'], 'position': [lat, lng], 'note': mark_data['note'], 'image_data': mark_data['image_data'] } self.markers.append(mark) self.generate_map() self.map_view.reload() # 刷新地图 def closeEvent(self, event): """关闭时保存状态""" self.save_state() event.accept() if __name__ == "__main__": app = QApplication(sys.argv) window = MapWindow() window.show() sys.exit(app.exec_()) ``` ##### 2. `map_template.html` - 地图模板 ```html <!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>离线地图</title> <script> // Python通信处理 class PythonHandler { mapClicked(lat, lng) { window.pyBridge.map_clicked(lat, lng); } } window.onload = function() { window.pyBridge = {}; {{map_html}} // 暴露接口给Python window.mapClicked = function(lat, lng) { if (window.pyBridge.map_clicked) { window.pyBridge.map_clicked(lat, lng); } }; } </script> </head> <body> <div id="map" style="width:100%; height:100vh;"></div> </body> </html> ``` #### 四、离线地图准备 1. **下载离线地图瓦片**(使用Mobile Atlas Creator) - 下载安装:https://mobac.sourceforge.io/ - 步骤: 1. 选择地图源(高德地图) 2. 框选所需区域 3. 缩放级别设为14-18(街道级) 4. 输出格式:Osmdroid SQLite 5. 导出到项目`tiles`文件夹 2. **转换瓦片格式** - 将导出的`.mbtiles`文件用SQLite浏览器打开 - 导出所有图片到`tiles/{z}/{x}/{y}.png`目录结构 - 确保瓦片命名符合`z/x/y.png`格式 #### 五、运行步骤 1. 创建项目目录结构 2. 将离线瓦片放入`tiles/`目录 3. 启动应用程序: ```bash python app.py ``` 4. 功能使用: - 点击"添加标记模式"按钮进入添加状态 - 在地图上点击添加新标记 - 在对话框中填写信息并上传图片 - 点击已有标记查看详情 - 关闭程序自动保存状态 #### 六、功能说明 1. **数据持久化** - 地图位置和缩放级别保存在`data/map_state.json` - 每个标记保存为`data/markers/mark_X.json` 2. **图片处理** - 使用base64编码存储图片数据 - 使用PIL压缩图片尺寸 3. **通信机制** - 通过PyQt的`exposeObject`实现Python-JS双向通信 - 使用自定义HTML模板嵌入Folium地图 4. **离线支持** - 所有资源本地存储 - 无需网络连接即可使用 #### 七、优化建议 1. 添加标记删除功能: - 在编辑对话框中添加删除按钮 - 删除对应JSON文件 2. 添加地图控件: ```python folium.LatLngPopup().add_to(m) folium.LayerControl().add_to(m) ``` 3. 提高地图加载速度: - 使用更小的瓦片尺寸 - 限制地图显示范围
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值