☰
省级空气质量WebGIS可视化:Leaflet+Flask+MySQL从零跑通一张会呼吸的地图
2026/10/14 5:28:55 网站建设 项目流程

简介:一份面向WebGIS与前端开发学习者的可运行源码包,以湖南省空气质量为案例,借助百度天气接口与Leaflet地图库,实现了从空气质量数据获取、地图图层渲染到污染程度分级展示的完整流程,能够直接解决环保数据可视化中的关键环节。压缩包共三个文件,核心为HTML页面,包含地图初始化与样式配置,另附inscode运行配置和gitignore过滤规则,整体仅7KB,属于轻量级示例工程,便于部署调试与二次修改。当前已有四十六人学习下载,虽然代码规模不大,但覆盖了需求分析、技术选型、页面展示到区县排名统计的典型思路,适合作为课程设计、毕业设计或区域空气质量监测大屏的改造起点。借助此源码可重点理解百度天气数据接入方式、Leaflet底图初始化和污染等级着色逻辑,同时还能查看湖南省内中重度污染与空气质量优的前十名区县排名模块,直接运行即可观察交互效果,为其他地区同类WebGIS项目提供直观参考与复用基础。

1. 省级空气质量WebGIS可视化:拿到可运行源码后,怎么从零跑通一张会呼吸的地图

这份源码不是一张设计稿,而是一条能从数据抓取、MySQL 入库、Flask 接口到 Leaflet 渲染完整跑通的技术链路。它把分散在各省市监测站点的空气质量指标,变成浏览器里可缩放、可点击、可切换时间的地图服务,拿到手就能在本机复现。适合正在做环保数据展示、WebGIS 课程设计,或者想快速搭一个数据可视化 Demo 的开发者。

资源包的代码分成前后端两部分,后端负责定时拉取公开空气质量平台的实时数据,清洗后写入 MySQL;前端启动一个本地开发服务,通过 GeoJSON 接口把站点点位渲染到地图上。模块边界比较清楚,改数据源或者换地图底图都不用动整条链路。

需要提醒的是,这套东西的“坑”不在功能本身,而在工程细节:地图坐标系偏移、数据库字符集、定时任务的时区、浏览器缓存这几处最容易翻车。先把架构看懂再部署,能省不少排错时间。下面从选型开始逐层拆。

2. 技术架构与数据链路:为什么是 Leaflet + Flask + MySQL,而不是全家桶

2.1 选型理由:轻量组合为什么能扛住省级数据

解压源码先看依赖,前后端都没有引入重 GIS 框架,说明制作者有意控制了部署成本。省级空气质量可视化到底有多少数据?假设覆盖三百多个国控站点、每小时一条记录,一天约七千二百条,一年不到三百万行。这个量级在 MySQL 里就是一张普通表,在字段上建好复合索引,查询基本是毫秒级返回。空间计算也简单,只是点位打点和按城市聚合,不涉及面拓扑、缓冲区分析这类操作,PostGIS 在这里属于杀鸡用牛刀。

前端地图框架选 Leaflet 也很合理。Leaflet 核心包只有几十 KB,插件生态里有现成的 marker 聚合、时间轴、热力图,离线部署也容易。如果换成 OpenLayers 或 Mapbox GL,功能和渲染性能更强,但学习曲线和工程体积都会明显变大,对“把它跑起来看效果”的目标并不划算。ECharts 在项目里承担统计图表和 AQI 等级色阶,它本身支持多种坐标系,和地图联动比较顺手。整体看,这套选型遵循的是“够用就好”的原则。

技术点这份源码的选择常见替代方案选择理由
前端地图渲染LeafletOpenLayers、Mapbox GL轻量、插件多、离线可用,API 对新手友好
可视化图表EChartsChart.js、HighchartsAQI 色阶、散点图内置支持好
后端服务FlaskFastAPI、Django路由简单,JSON 输出直接,依赖少
数据库MySQLPostgreSQL / PostGIS数据量级用不上空间引擎,MySQL 部署更省事
数据采集requests + pandasScrapy、Airflow小时级拉取,不需要任务队列和流式计算

如果你在考虑替换技术栈,我建议先想清楚一个前提:实时性要求是分钟级还是小时级。空气质量国控站点的小时值更新频率本身不高,普通同步请求加 crontab 完全够用。引入消息队列或时序数据库,只会让排查链路变长,收益却很少。

2.2 目录结构与行为逻辑:拆开源码看数据怎么流

源码目录不算复杂,前后端分离但前端没有强行上 Vue 或 React,直接用原生 JavaScript 配合 Leaflet 的 CDN 文件,好处是少了构建环节,改完刷新就能看到效果。

webgis_project/ ├── backend/ │ ├── app.py # Flask 主服务,提供地图数据接口 │ ├── config.py # 数据库、城市编码、拉取频率配置 │ ├── fetch_data.py # 空气质量数据抓取与清洗 │ ├── init_db.py # 初始化数据库和表结构 │ ├── requirements.txt # Python 依赖清单 │ └── logs/ └── frontend/ ├── index.html # 地图页面入口 ├── main.js # Leaflet 地图逻辑,请求接口并渲染 ├── style.css # 页面样式 └── package.json

数据流是单向的:fetch_data.py从公开接口拉取小时值,解析成统一结构的字典列表,再批量写入 MySQL;Flask 提供/api/air接口,按城市和时间窗口查询数据库,组装成 GeoJSON 返回;前端main.js通过 fetch 拿到 GeoJSON,用L.geoJSON直接映射到地图上。整条链路里 MySQL 是唯一的数据中转站,所以只要保证入库数据正确,前后端联调就不会出现太大偏差。

2.3 接口契约:用 GeoJSON 把数据库行变成地图要素

前后端之间传输的格式不是普通二维数组,而是 GeoJSON 的 FeatureCollection。这个选择看起来多写了几层嵌套,但在地图场景里收益很明显:每个站点是独立的 Feature,几何信息和属性打包在一起,Leaflet 收到后可以直接绑定点击弹窗和样式函数,不用再按索引对齐经纬度和 AQI 值。

# 构造一个站点要素 feature = { "type": "Feature", "geometry": { "type": "Point", "coordinates": [row["lng"], row["lat"]] }, "properties": { "station": row["station_name"], "aqi": row["aqi"], "pm25": row["pm25"], "time": row["sample_time"] } }

开发模式下前后端在不同端口,所以接口层必须处理跨域。常见的做法是给 Flask 应用加 CORS 响应头,或者用 Flask-Cors 扩展统一放行。生产环境更推荐把前端构建后的静态文件交给 Flask 托管,让接口和页面同源,这一步能顺手省掉后续不少跨域问题。

3. 核心模块拆解:抓取、建表、接口三层都踩过哪些细节

3.1 数据抓取:requests 拉小时值 JSON 的完整对齐过程

抓取脚本是整个项目的数据源头。它做的事情不复杂,但最容易在字段名和坐标格式上出问题。不同公开数据源返回的 JSON 结构可能一个叫aqi,另一个叫AQI,还有的可能套了一层result.data再一层stations,所以统一做一层字段重命名很必要。

# fetch_data.py 核心逻辑(简化版) import requests from datetime import datetime def fetch_station_data(city_code: str) -> list[dict]: # 请求参数里的 city_code 需要替换成目标城市编码 api_url = "https://example-air-api.example/hourly" resp = requests.get(api_url, params={"city": city_code}, timeout=10, headers={"User-Agent": "Mozilla/5.0"}) resp.raise_for_status() raw = resp.json()["data"] rows = [] for station in raw["stations"]: rows.append({ "city_code": city_code, "station_code": station["station_code"], "station_name": station.get("station_name", ""), "aqi": station.get("aqi"), "pm25": station.get("pm2_5"), "lat": station["latitude"], "lng": station["longitude"], "sample_time": datetime.strptime(station["time"], "%Y-%m-%d %H:%M:%S"), }) return rows

这段代码做了几件关键的事。timeout=10防止某个站点响应慢让整个脚本卡住;resp.raise_for_status()在 HTTP 状态异常时直接抛错,方便日志排查;station.get()带默认值,避免个别站点缺字段导致 KeyError。sample_time统一转成datetime对象,后续写数据库时可以让 PyMySQL 自动格式化成 DATETIME。

字段对齐时要注意:空气质量数据里 PM2.5 的字段名五花八门,常见的有pm2.5、pm2_5、PM25,在 Python 字典里带点号的 key 访问起来也麻烦。我的习惯是先在脚本里打印一条原始数据,确认字段名后再写映射关系,不要凭经验猜。经纬度字段同理,有的接口用lat/lon,有的用lat/lng,一旦取错,地图上的点位会全部跑到海里去。

3.2 MySQL 建表与批量插入:一条 SQL 踩掉中文乱码和重复数据

给空气质量小时值建表,需要同时考虑存储精度和查询效率。AQI 和污染物浓度用数值类型,经纬度用定点数而不是浮点数,避免精度漂移。

CREATE TABLE air_quality ( id BIGINT AUTO_INCREMENT PRIMARY KEY, city_code CHAR(6) NOT NULL COMMENT '城市编码', station_code VARCHAR(32) NOT NULL COMMENT '站点编码', aqi SMALLINT NULL COMMENT '空气质量指数', pm25 DECIMAL(6,1) NULL COMMENT 'PM2.5 浓度', lat DECIMAL(8,5) NOT NULL COMMENT '纬度', lng DECIMAL(8,5) NOT NULL COMMENT '经度', sample_time DATETIME NOT NULL COMMENT '采样时间', create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '记录写入时间', KEY idx_city_time (city_code, sample_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='空气质量小时值表';

几个容易忽略的细节。DEFAULT CHARSET=utf8mb4一定要在建表时指定,否则中文字段默认用 latin1,写入中文直接报错或变成乱码。采样时间用DATETIME而不是VARCHAR,是为了后续能直接写 SQL 范围查询。idx_city_time这个复合索引配合接口里最常见的WHERE city_code=? AND sample_time BETWEEN ? AND ?,能把查询从全表扫描降到索引范围扫描。如果站点数量多、查询频繁,还可以考虑加唯一索引UNIQUE KEY uk_station_time (station_code, sample_time)来兜底重复写入。

批量插入不要用 for 循环一条一条 execute,那样一千条记录可能要好几秒。改用executemany把数据一次性交给 MySQL,再配合INSERT IGNORE跳过重复主键或唯一键。

# fetch_data.py 批量入库 import pymysql def batch_insert(conn, rows: list[dict]): sql = """ INSERT IGNORE INTO air_quality (city_code, station_code, aqi, pm25, lat, lng, sample_time) VALUES (%s, %s, %s, %s, %s, %s, %s) """ data = [ (r["city_code"], r["station_code"], r["aqi"], r["pm25"], r["lat"], r["lng"], r["sample_time"]) for r in rows ] with conn.cursor() as cur: cur.executemany(sql, data) conn.commit()

参数顺序必须和 SQL 里的占位符一一对应,这是最容易写反的地方。INSERT IGNORE的缺点是静默忽略错误,如果数据本身有问题,可能查不到报错信息,所以调试阶段建议先去掉 IGNORE 跑一次,确认无误再改回来。

3.3 Flask 接口输出:参数校验、缓存头与 FeatureCollection

后端接口是整个前端的地图数据源,设计上不需要做复杂计算,把数据库行转换成 GeoJSON 返回即可。接口参数建议至少支持city_code、start、end三个,默认值设置为空字符或合理的范围。

# app.py 片段 from flask import Flask, jsonify, request import pymysql app = Flask(__name__) def get_conn(): return pymysql.connect( host="127.0.0.1", user="root", password="your_password", database="air_quality", charset="utf8mb4", cursorclass=pymysql.cursors.DictCursor ) @app.route("/api/air") def api_air(): city = request.args.get("city_code", "") start = request.args.get("start", "2024-01-01 00:00:00") end = request.args.get("end", "2024-12-31 23:59:59") features = [] # 简单的空参校验,避免用户传空值导致 SQL 全表扫 if city and start <= end: features = query_air(city, start, end) resp = jsonify({ "type": "FeatureCollection", "features": features }) # 禁止浏览器缓存,定时任务更新后前端才能及时看到新数据 resp.headers["Cache-Control"] = "no-store" return resp

cursorclass设置成DictCursor后,查询出来的每行是字典,直接用row["lng"]取值,比元组下标清晰得多。响应头里的Cache-Control: no-store很重要:浏览器对同一次 GET 请求可能会自动缓存,导致数据源已经更新,页面却还停在旧值。加上这个头之后,每次刷新都会重新请求接口。

前端调用时,start和end的格式必须和数据库里的sample_time匹配。常见坑是前端传了带毫秒的 ISO 时间串,比如2024-01-01T00:00:00.000Z,Python 比较字符串时会把T和空格当作不同字符,直接导致查不到数据。接口层可以加一段简单的时间格式转换,把T替换成空格,再截断到秒,能省掉不少联调时间。

4. 部署与运行:两行命令装完依赖,十分钟内看到第一个图层

4.1 环境准备:Python 虚拟环境和 npm 依赖

本地跑这套源码,要求并不高。Python 3.8 以上、Node.js 14 以上就能覆盖绝大多数依赖。建议先把后端依赖装进虚拟环境,避免和系统其他 Python 项目互相污染。

cd /path/to/webgis_project/backend python3 -m venv venv source venv/bin/activate pip install -r requirements.txt
cd ../frontend npm install

如果 Node 版本装的是最新 LTS,npm install有概率报ERESOLVE unable to resolve dependency tree。这是因为某些依赖的 peerDependencies 还没适配新版本,不用换 Node,执行npm install --legacy-peer-deps就能跳过严格校验。出现这个报错不代表代码有问题,只是包管理器的解析规则变严格了。

4.2 配置与初始化:改三个参数就把数据接进来

初始化之前先看backend/config.py,数据库连接和采集频率都在这里配。需要改的主要是 MySQL 的账号密码和库名,其他参数保持默认即可。

# config.py MYSQL_HOST = "127.0.0.1" MYSQL_PORT = 3306 MYSQL_USER = "root" MYSQL_PASSWORD = "your_password" MYSQL_DB = "air_quality" FETCH_INTERVAL = 3600 # 数据拉取间隔,单位秒,3600 表示每小时一次 DEFAULT_CITY = "CITY_CODE" # 目标城市编码,按源码说明替换

建库语句最好手动执行,因为init_db.py通常只负责建表,不会自动创建数据库。字符集必须显式指定,否则容易踩第 5 章说的乱码坑。

mysql -uroot -p -e "CREATE DATABASE air_quality DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" python init_db.py

FETCH_INTERVAL这里写的是秒,如果你打算用系统 crontab 控制调度,这个参数可以设成 0。如果准备用源码自带的调度器跑,它会按这个间隔循环请求数据源。城市编码要填真实行政区划代码,不同数据源的编码体系可能不一样,建议先手工请求一次数据源接口,看它接受的城市编码是几位的。

4.3 启动前后端与三个检查点

后端和前端是两个进程,分开启动。后端监听 5000 端口,前端开发服务器监听 8080,浏览器访问前端地址。

# 终端 1,启动后端 cd backend source venv/bin/activate python app.py --port 5000 # 终端 2,启动前端 cd frontend npm start

启动后不要急着看地图,按三个检查点逐个验证。第一个检查点是接口本身,浏览器直接访问http://127.0.0.1:5000/api/air?city_code=CITY_CODE&start=2024-01-01%2000:00:00&end=2024-01-01%2023:59:59,如果返回 JSON 里features是空数组,说明数据库里没有对应时间范围的数据,这时候排查方向是抓取脚本或入库逻辑;如果返回 500,打开 Flask 终端看详细堆栈。第二个检查点是前端控制台有没有 CORS 红色报错,有的话按 2.3 节的方法加响应头。第三个检查点才是地图上有多少个 marker 或色斑,这一步通过了,链路才算真正打通。

5. 避坑与常见问题排查:坐标系、字符集、定时任务和瓦片源

5.1 点位整体偏出几百米:坐标系加密问题

现象:地图能打开,站点 marker 也能显示,但所有点位相对真实位置整体偏移几百米甚至更远,而且偏移方向并不是统一的。原因:公开数据源返回的经纬度大多是加密漂移后的坐标,也就是俗称的 GCJ-02 坐标,而 Leaflet 默认按标准 WGS-84 坐标渲染。两个坐标系之间的差值在城市区域通常有几百米,肉眼很容易看出来。解决:入库前做一次坐标转换,把墩后的 GCJ-02 转回标准坐标。如果源码没有内置转换函数,可以用coordTransform这个库。

# 坐标转换示例 from coordTransform import gcj02_to_wgs84 lat_wgs, lng_wgs = gcj02_to_wgs84(lat_gcj, lng_gcj) row["lat"] = lat_wgs row["lng"] = lng_wgs

判断数据是否被加密,最简单的方法是用手机地图 App 在已知位置打开定位,记录 App 显示的经纬度,再和你的站点表比对。如果差值在两三百米左右,基本可以确认是 GCJ-02。另一个判断思路是看数据源的说明文档,标了“火星坐标”或“高德坐标”的都必须转。这里有个反向注意点:如果数据源本身已经是标准经纬度,再转一次反而会偏,所以转换时要确认数据是真的被加密过,不要盲目套用。

5.2 中文全变问号:MySQL 建表字符集没配对

现象:站点名称和城市名称在浏览器页面上显示成???,或者写入数据库时就报Incorrect string value错误。原因:MySQL 建库建表时没有统一使用utf8mb4。如果数据库默认字符集还是老的latin1,中文根本存不进去。解决:先改数据库和表,再把连接字符串的字符集参数带上。

ALTER DATABASE air_quality CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; ALTER TABLE air_quality CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

Python 连接时,在pymysql.connect()里显式加charset="utf8mb4"。注意不能只改数据库不改连接参数,因为 PyMySQL 默认可能用utf8mb4,但有些老代码没指定会回退到utf8,而utf8在 MySQL 里并不完全等价于utf8mb4,生僻字或特殊符号依然会失败。这个坑的排查成本很低,但踩过的都知道,所有显示中文的地方都要同时检查建表语句和连接参数。

5.3 定时任务不执行或数据一直是旧的:时区和缓存联合惹的祸

现象:手动执行python fetch_data.py能拉到数据,但定时任务到点后不跑,或者跑了以后前端页面数据还是昨天的。原因有两个层面。定时任务层面,调度器没有设置时区,服务器默认 UTC 时区时,每天 0 点任务实际上对应北京时间早上 8 点。缓存层面,前端请求 GeoJSON 时浏览器走了缓存,后端数据已经更新但没有刷新页面。解决:调度器显式指定时区,接口层加禁止缓存响应头,前端请求时再加个时间戳参数做双保险。

# 调度器配置示例 from apscheduler.schedulers.blocking import BlockingScheduler scheduler = BlockingScheduler() scheduler.add_job( fetch_and_store, "interval", hours=1, timezone="Asia/Shanghai" ) scheduler.start()

前端请求时拼一个随机参数,比如?ts=${Date.now()},能有效绕过绝大多数浏览器缓存策略。如果用了 nginx 代理,还要检查 nginx 是否在location里配置了expires缓存,有的话需要改成expires -1或直接去掉。排错时注意区分“数据没入库”和“数据入库了但前端没刷新”,后者在数据库里跑一条SELECT MAX(sample_time) FROM air_quality就能立刻确认。

5.4 地图底图加载不出来:瓦片服务域名限制和 HTTPS 混合内容

现象:站点 marker 能看到,但底图是空白网格或灰色方块,看起来像整个地图崩了。原因:标配的瓦片服务需要申请 token,或者做了域名白名单;源码里的示例 key 早已失效。此外,如果网站开了 HTTPS,而瓦片地址还是 HTTP,浏览器会因混合内容拦截加载。解决:换一个自己能访问的瓦片源,或者干脆切到本地离线瓦片。

L.tileLayer("http://localhost:8081/tiles/{z}/{x}/{y}.png", { maxZoom: 12, attribution: "local tiles" }).addTo(map);

离线瓦片目录通常按z/x/y.png的层级结构存放,用 nginx 起一个静态文件服务就能当瓦片源。公网瓦片服务要注意坐标系匹配:Leaflet 默认用 EPSG:3857,虽然看起来“能用”,但和在线瓦片叠加时如果投影不一致,会出现缩放后错位。这个问题最容易出现在混合使用多个底图源的时候,排查时先锁定一个瓦片源,确认正常后再叠加其他图层。

6. 进阶玩法:把静态地图升级成动态告警与时间回放

6.1 AQI 等级着色和图例联动

基础版本地图只是把所有站点显示成同一个颜色的圆点,看不出污染程度差异。进阶做法是按 AQI 等级分段着色,同时把图例放到页面角落。国标 AQI 分级是 0-50 为优、51-100 为良、101-150 为轻度污染、151-200 为中度、201-300 为重度、300 以上为严重,可以在前端用一个函数映射颜色。

// main.js 中定义 AQI 颜色映射 function getColor(aqi) { if (aqi <= 50) return "#00e400"; if (aqi <= 100) return "#ffff00"; if (aqi <= 150) return "#ff7e00"; if (aqi <= 200) return "#ff0000"; if (aqi <= 300) return "#99004c"; return "#7e0023"; }

渲染时在L.geoJSON的pointToLayer里把半径和颜色套进L.circleMarker,再通过bindPopup把站点名、AQI、PM2.5 放进去,地图的可用性立刻上一个台阶。这个改动不涉及后端,只改前端图层样式函数,风险很低。

6.2 时间轴切换与历史回放

单张图只能看到当前小时的数据,把时间维度加进去才是真正的可视化。后端接口已经支持start和end,前端可以做一个日期滑块,每次切换只请求该时间点的数据并重新渲染图层。

// 按选择时间请求数据并重绘 async function loadTime(cityCode, time) { const url = `/api/air?city_code=${cityCode}&start=${time}&end=${time}`; const resp = await fetch(url + `&ts=${Date.now()}`); const geo = await resp.json(); layerGroup.clearLayers(); L.geoJSON(geo, { pointToLayer: createCircleMarker }).addTo(layerGroup); }

做历史回放时要注意接口性能。如果一次把 24 小时所有数据全拉下来再前端切片,数据量大会拖慢首屏;更合理的做法是后端新增一个只返回指定sample_time的接口,前端每次只取一个时间片。通过setInterval配合一个时间数组,就能实现简单的自动回放。

6.3 把告警阈值做成可配置项

最后一个实用技巧是把告警逻辑从写死的代码里抽出来,做成配置文件。比如 PM2.5 超过 75 时,图层上的点闪烁并弹出气泡,这个阈值在不同季节、不同城市可能都不一样。

我可以在config.py里加一个ALERT_THRESHOLDS字典,包含pm25、pm10、o3等污染物的阈值,前端通过一个额外的接口读取。虽然这个项目的数据量不大,但提前用配置驱动的方式组织代码,后续换城市、换污染物、调整阈值都只需要改配置而不动代码。

以前我部署类似项目时,总觉得能在地图上看到点就万事大吉,结果被坐标偏移和字符集问题各绊过一次。从那以后,每次换数据源都强制过一遍三项检查:坐标系是否加密、数据库字符集是否统一、定时任务时区是否合法。这套源码本身不难,难的是让它在各种环境下稳定运行,希望这些排坑经验能帮到你少走一段弯路,尽快看到浏览器里那一张会呼吸的地图。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询