简介:这份资源是面向高校WebGIS课程学习者的期末大作业完整方案,以青岛市房价分析展示系统为案例,适合需要完成课程设计、期末项目或想快速上手WebGIS开发的新手与进阶学生。压缩包共16个文件,约7.25MB,包含7个html页面、4张png图片、1个pptx演示文稿、1个css样式表、1个js脚本、1个md说明文档和1个docx实验报告,覆盖系统页面、图表展示、热力图、缓冲分析、留言模块及样式资源等核心内容。目前已有441人学习下载,说明该方案在同类课程作业中具有较高参考价值。代码注释完整,部署简单,下载后即可运行使用,能帮助读者快速理解WebGIS房价展示系统的整体架构与实现思路,同时配套实验报告与演示文稿,便于直接用于课程设计答辩或作为二次开发的基础模板,具有较高的实际应用与学习借鉴价值。
1. 房价展示系统到底在做什么:从一份课程大作业说起
很多人第一次接触 WebGIS 课程大作业,选题都会落到「房价展示系统」上。原因很直接:数据好找、地图好看、答辩时老师一眼就能看懂你在做什么。但真正动手之后才会发现,这个题目远不是「把点标在地图上」那么简单。它背后是一条完整的技术链路——数据清洗、坐标纠偏、空间索引、前后端接口、地图渲染、交互筛选,每一环都有坑。我见过太多同学把 GeoJSON 直接丢给前端,结果两万条数据一加载浏览器就卡死;也见过坐标没纠偏,房子全飘到海里去的翻车现场。
这篇文章面向三类人:正在做 WebGIS 课程大作业的学生、想快速搭一个房价可视化 Demo 的开发者、以及需要一套可复现技术方案来评估可行性的从业者。我会把「房价展示系统」拆成能落地的步骤,从数据准备到前端渲染,从空间查询到性能优化,每一步都给出可抄作业的代码和参数说明。你不需要有 GIS 专业背景,但需要会一点 Python 和 JavaScript。读完你应该能独立跑通一个带筛选、聚合、热力图的房价展示系统,并且知道哪些地方最容易翻车。
2. 数据从哪来、怎么洗:房价数据的获取与预处理
2.1 房价数据的常见来源与字段设计
课程大作业的数据来源通常有三类:公开数据平台下载、爬虫抓取、老师直接给一份 CSV。不管哪种,你拿到的原始数据大概率长这样:小区名称、挂牌单价、总价、面积、户型、楼层、经纬度、所属区域。字段看似齐全,但真正能用的没几个。我一般会先做一轮字段审计,把下面这些列出来:
| 字段 | 是否必需 | 常见问题 |
|---|---|---|
| 经纬度 | 必需 | 缺失、精度不够、坐标系不统一 |
| 单价 | 必需 | 单位混乱(元/平 vs 万元/平) |
| 总价 | 可选 | 大量空值 |
| 面积 | 可选 | 区间值如「80-100平」 |
| 小区名 | 可选 | 重复、别名 |
| 区域 | 可选 | 与行政区划对不上 |
字段审计做完,你会发现真正能支撑空间展示的核心字段只有经纬度和单价。其他字段要么缺失严重,要么需要额外清洗。我的建议是:先把经纬度和单价这两列做到 100% 可用,再考虑加其他维度。
2.2 用 Python 做数据清洗的最小脚本
下面这段脚本是我常用的清洗模板,处理缺失值、单位统一、异常值过滤三件事:
import pandas as pd import numpy as np # 读取原始数据,注意编码问题,中文 CSV 常见 gbk df = pd.read_csv("raw_house.csv", encoding="gbk") # 1. 经纬度缺失直接丢弃,这两列没法补 df = df.dropna(subset=["lng", "lat"]) # 2. 单价统一成 元/平米 # 如果原始单位是万元/平,乘以 10000 if df["unit_price"].median() < 1000: df["unit_price"] = df["unit_price"] * 10000 # 3. 过滤异常值:单价低于 2000 或高于 200000 的基本是错误数据 df = df[(df["unit_price"] >= 2000) & (df["unit_price"] <= 200000)] # 4. 经纬度范围校验,中国范围大致 lng 73-135, lat 3-54 df = df[(df["lng"] >= 73) & (df["lng"] <= 135)] df = df[(df["lat"] >= 3) & (df["lat"] <= 54)] # 5. 去重,按经纬度+单价组合去重 df = df.drop_duplicates(subset=["lng", "lat", "unit_price"]) print(f"清洗后剩余 {len(df)} 条记录") df.to_csv("clean_house.csv", index=False, encoding="utf-8-sig")这段脚本的逻辑很直白:先丢掉没法补的经纬度缺失,再统一单价单位,然后用硬阈值过滤明显错误的数据,最后去重。参数方面,单价上下限 2000 和 200000 是根据国内大部分城市行情定的,如果你做的是小城市,下限可以调到 1000。经纬度范围用的是中国大致边界,如果你只做某个城市,可以进一步收窄到该城市的 bounding box,这样能过滤掉坐标纠偏失败导致的漂移点。
注意:编码问题是最容易被忽略的坑。Windows 下 Excel 导出的 CSV 默认 gbk,直接 utf-8 读取会报 UnicodeDecodeError。我一般先试 utf-8,失败就换 gbk,再失败用 chardet 检测。
2.3 坐标系纠偏:为什么你的点会飘到海里
国内地图数据涉及两套坐标系:WGS84(GPS 原始坐标)和 GCJ02(国测局加密坐标)。高德、腾讯地图用的是 GCJ02,百度用的是 BD09,而很多公开数据给的是 WGS84。如果你不纠偏,直接把 WGS84 的点标在高德地图上,偏移量大概在几百米,密集区域看起来就像飘到海里或隔壁小区。
纠偏的常见做法是调用现成的转换库。Python 里可以用coord-convert或自己写一个简版转换函数。下面是一个 GCJ02 转 WGS84 的近似实现:
import math def gcj02_to_wgs84(lng, lat): """GCJ02 转 WGS84,近似算法,误差在米级""" a = 6378245.0 ee = 0.00669342162296594323 def transform_lat(x, y): ret = -100.0 + 2.0*x + 3.0*y + 0.2*y*y + 0.1*x*y + 0.2*math.sqrt(abs(x)) ret += (20.0*math.sin(6.0*x*math.pi) + 20.0*math.sin(2.0*x*math.pi)) * 2.0/3.0 ret += (20.0*math.sin(y*math.pi) + 40.0*math.sin(y/3.0*math.pi)) * 2.0/3.0 ret += (160.0*math.sin(y/12.0*math.pi) + 320*math.sin(y*math.pi/30.0)) * 2.0/3.0 return ret def transform_lng(x, y): ret = 300.0 + x + 2.0*y + 0.1*x*x + 0.1*x*y + 0.1*math.sqrt(abs(x)) ret += (20.0*math.sin(6.0*x*math.pi) + 20.0*math.sin(2.0*x*math.pi)) * 2.0/3.0 ret += (20.0*math.sin(x*math.pi) + 40.0*math.sin(x/3.0*math.pi)) * 2.0/3.0 ret += (150.0*math.sin(x/12.0*math.pi) + 300.0*math.sin(x/30.0*math.pi)) * 2.0/3.0 return ret dlat = transform_lat(lng - 105.0, lat - 35.0) dlng = transform_lng(lng - 105.0, lat - 35.0) radlat = lat / 180.0 * math.pi magic = math.sin(radlat) magic = 1 - ee * magic * magic sqrtmagic = math.sqrt(magic) dlat = (dlat * 180.0) / ((a * (1 - ee)) / (magic * sqrtmagic) * math.pi) dlng = (dlng * 180.0) / (a / sqrtmagic * math.cos(radlat) * math.pi) return lng - dlng, lat - dlat这个函数的参数就是经纬度,返回纠偏后的经纬度。误差在米级,对于房价展示这种场景完全够用。如果你用的是百度地图,还需要多做一步 BD09 和 GCJ02 之间的转换。实际项目中,我一般会在数据清洗阶段就把所有坐标统一成 GCJ02,这样前端不管用高德还是腾讯都不用再处理。
3. 后端怎么搭:从 CSV 到空间查询接口
3.1 技术选型:为什么我选 Flask + PostGIS 而不是纯前端
很多同学的第一反应是:前端直接读 GeoJSON 不就行了?小数据量确实可以,但房价数据动辄上万条,GeoJSON 文件轻松超过 10MB,浏览器加载和渲染都会卡。更关键的是,你需要按价格区间、区域、面积等条件筛选,纯前端做筛选意味着每次都要遍历全量数据,体验很差。
我的选型是 Flask 做 API 层,PostGIS 做空间存储和查询。PostGIS 是 PostgreSQL 的空间扩展,支持空间索引、距离查询、聚合统计,这些能力在房价展示里非常实用。比如「查询某个点 2 公里内的所有小区均价」这种需求,PostGIS 一条 SQL 就能搞定,纯前端做要写一堆几何计算。
如果你不想装 PostgreSQL,SQLite + SpatiaLite 也能凑合,但空间查询性能和函数丰富度差不少。课程大作业的话,我建议直接上 PostGIS,装一次一劳永逸。
3.2 建表和导入数据的完整流程
先建一张带空间字段的表:
CREATE TABLE house ( id SERIAL PRIMARY KEY, name VARCHAR(100), unit_price NUMERIC(10, 2), area NUMERIC(8, 2), district VARCHAR(50), geom GEOMETRY(Point, 4326) ); -- 建空间索引,这是查询性能的关键 CREATE INDEX idx_house_geom ON house USING GIST(geom);导入 CSV 数据可以用 Python 的 psycopg2:
import psycopg2 import pandas as pd conn = psycopg2.connect( host="localhost", database="webgis", user="postgres", password="your_password" ) cur = conn.cursor() df = pd.read_csv("clean_house.csv") for _, row in df.iterrows(): cur.execute( """INSERT INTO house (name, unit_price, area, district, geom) VALUES (%s, %s, %s, %s, ST_SetSRID(ST_MakePoint(%s, %s), 4326))""", (row["name"], row["unit_price"], row["area"], row["district"], row["lng"], row["lat"]) ) conn.commit() cur.close() conn.close()这里的关键是ST_MakePoint和ST_SetSRID。前者根据经纬度生成点几何,后者指定坐标系为 4326(WGS84)。注意,如果你前面已经纠偏成 GCJ02,这里应该用 4326 存储但实际是 GCJ02 坐标,前端渲染时直接用高德底图即可。空间索引GIST是必须建的,否则几万条数据的范围查询会慢到无法接受。
3.3 三个核心查询接口的 SQL 与参数说明
房价展示系统最常用的查询有三类:按范围查点、按条件筛选、按区域聚合。下面分别给出 SQL 和对应的 Flask 路由。
范围查询:给定地图当前视野的 bounding box,返回范围内的点。
SELECT id, name, unit_price, area, district, ST_X(geom) as lng, ST_Y(geom) as lat FROM house WHERE geom && ST_MakeEnvelope(%s, %s, %s, %s, 4326) LIMIT 2000;ST_MakeEnvelope接收左下角和右上角经纬度,&&是空间索引运算符,能快速过滤出相交的几何。LIMIT 2000是保护措施,防止视野太大时返回过多数据。参数顺序是 minLng, minLat, maxLng, maxLat。
条件筛选:按价格区间和区域筛选。
SELECT id, name, unit_price, area, district, ST_X(geom) as lng, ST_Y(geom) as lat FROM house WHERE unit_price BETWEEN %s AND %s AND district = %s AND geom && ST_MakeEnvelope(%s, %s, %s, %s, 4326) LIMIT 2000;区域聚合:按行政区统计均价和数量。
SELECT district, COUNT(*) as cnt, AVG(unit_price) as avg_price, MIN(unit_price) as min_price, MAX(unit_price) as max_price FROM house GROUP BY district ORDER BY avg_price DESC;这三个接口覆盖了房价展示系统 90% 的交互需求。Flask 路由里把参数接进来,拼 SQL 执行,返回 JSON 即可。注意参数化查询,不要用字符串拼接,否则会有 SQL 注入风险。
4. 前端怎么渲染:地图交互与性能优化
4.1 地图库选型:Leaflet 还是 Mapbox GL
前端地图库主流就两个选择:Leaflet 和 Mapbox GL。Leaflet 轻量、插件多、上手快,适合课程大作业这种场景。Mapbox GL 性能更好、支持矢量瓦片和 3D,但学习曲线陡,而且商用要收费。
我的建议是:如果你只是展示点数据和热力图,Leaflet 完全够用,配合高德或腾讯的底图瓦片,效果不差。如果你要做大规模数据渲染(十万级以上)或者 3D 效果,再考虑 Mapbox GL。课程大作业的话,Leaflet 是性价比最高的选择。
4.2 用 Leaflet 加载后端接口并渲染点标记
下面是一个完整的前端渲染示例,包含地图初始化、接口调用、点标记渲染:
// 初始化地图,中心点设在北京 const map = L.map('map').setView([39.9042, 116.4074], 12); // 加载高德底图瓦片 L.tileLayer('https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x={x}&y={y}&z={z}', { subdomains: ['1', '2', '3', '4'], attribution: '高德地图' }).addTo(map); // 用图层组管理标记,方便清空 let markerLayer = L.layerGroup().addTo(map); // 根据当前视野请求数据 function loadData() { const bounds = map.getBounds(); const params = new URLSearchParams({ minLng: bounds.getWest(), minLat: bounds.getSouth(), maxLng: bounds.getEast(), maxLat: bounds.getNorth(), minPrice: document.getElementById('minPrice').value || 0, maxPrice: document.getElementById('maxPrice').value || 999999 }); fetch(`/api/houses?${params}`) .then(res => res.json()) .then(data => { markerLayer.clearLayers(); data.forEach(item => { // 根据价格设置颜色,越贵越红 const color = item.unit_price > 80000 ? '#e74c3c' : item.unit_price > 50000 ? '#f39c12' : '#27ae60'; const marker = L.circleMarker([item.lat, item.lng], { radius: 5, fillColor: color, color: '#fff', weight: 1, fillOpacity: 0.8 }); marker.bindPopup( `<b>${item.name}</b><br>单价:${item.unit_price} 元/平<br>区域:${item.district}` ); markerLayer.addLayer(marker); }); }); } // 地图移动结束后重新加载 map.on('moveend', loadData); loadData();这段代码的核心逻辑是:监听地图的moveend事件,每次移动结束后根据当前视野的 bounding box 请求后端数据,然后清空旧标记、渲染新标记。颜色按单价分三档,这是最直观的可视化方式。circleMarker比默认的marker性能好,因为它是 Canvas 渲染而不是 DOM 元素。
参数方面,radius控制点大小,数据密集时可以调小到 3。fillOpacity控制透明度,密集区域建议 0.6 左右,能看到重叠。moveend事件有 200ms 左右的延迟,如果觉得卡顿,可以加 debounce。
4.3 热力图与聚合:两万条数据不卡的秘密
点标记超过五千条就会明显卡顿,这时候需要换渲染方式。两个方案:热力图和聚合。
热力图用 Leaflet.heat 插件,适合展示价格密度分布:
// 引入 leaflet-heat 后 const heatData = data.map(item => [item.lat, item.lng, item.unit_price / 100000]); L.heatLayer(heatData, { radius: 25, blur: 15, maxZoom: 17, gradient: {0.4: 'blue', 0.6: 'lime', 0.8: 'yellow', 1.0: 'red'} }).addTo(map);radius是热力点的影响半径,blur是模糊程度,gradient定义颜色梯度。热力图适合宏观展示,但看不到具体房源,所以通常和点标记配合使用,缩放级别低时用热力图,放大后切换到点标记。
聚合用 Leaflet.markercluster,把邻近的点合并成一个数字气泡:
const clusterGroup = L.markerClusterGroup({ maxClusterRadius: 50, spiderfyOnMaxZoom: true, showCoverageOnHover: false }); data.forEach(item => { const marker = L.marker([item.lat, item.lng]); marker.bindPopup(`<b>${item.name}</b><br>${item.unit_price} 元/平`); clusterGroup.addLayer(marker); }); map.addLayer(clusterGroup);maxClusterRadius控制聚合半径,越大聚合越激进。spiderfyOnMaxZoom在最大缩放级别时展开重叠点,这个体验很好。聚合方案能轻松支撑几万条数据,因为实际渲染的 DOM 元素只有聚合后的几十个。
5. 避坑与排查:那些让我熬夜的翻车现场
5.1 坐标飘移:点全跑到隔壁城市去了
现象:地图上加载出来的点整体偏移,明明应该是市中心的房子,全跑到郊区甚至隔壁城市。
原因:坐标系不统一。数据源是 WGS84,底图是高德 GCJ02,两者差了几百米。或者数据源是百度 BD09,差得更多。
解决:在数据清洗阶段统一转成 GCJ02。用前面给的gcj02_to_wgs84反向转换,或者直接用现成的转换库。转换后随机抽几个点在高德地图上验证,确认位置正确再导入数据库。
5.2 接口超时:视野一大就转圈
现象:地图缩小到全省范围时,接口请求超时,前端一直 loading。
原因:没有加LIMIT,或者LIMIT设得太大。全省范围的 bounding box 可能包含几十万条数据,一次性返回必然超时。
解决:接口层强制加LIMIT 2000,同时在前端做缩放级别判断。缩放级别小于 10 时只返回聚合数据,不返回原始点。聚合数据可以用 PostGIS 的ST_SnapToGrid或者按区域分组统计。
5.3 中文乱码:小区名全是问号
现象:前端弹窗里的小区名显示成???或者乱码。
原因:数据库编码不是 UTF-8,或者 Flask 返回 JSON 时没有设置ensure_ascii=False。
解决:建库时指定ENCODING 'UTF8',Flask 里用jsonify时加app.config['JSON_AS_ASCII'] = False。如果已经建库了,可以ALTER DATABASE修改编码,但已有数据可能需要重新导入。
5.4 空间索引失效:查询慢到怀疑人生
现象:建了 GIST 索引,但范围查询还是慢,几万条数据要好几秒。
原因:查询条件里用了ST_Contains或ST_Within而不是&&,或者对 geom 字段做了函数操作导致索引失效。
解决:范围查询用&&运算符,它直接走空间索引。如果必须用ST_Contains,确保它是索引辅助过滤之后的精确过滤,而不是唯一条件。用EXPLAIN ANALYZE看执行计划,确认走了 Index Scan 而不是 Seq Scan。
5.5 前端内存泄漏:切换筛选条件后越来越卡
现象:反复切换价格筛选,页面越来越卡,最后浏览器崩溃。
原因:每次请求都创建新的图层组,但没有销毁旧的。Leaflet 的图层组如果只是remove而没有清空内部引用,标记对象仍然占用内存。
解决:用全局的markerLayer,每次请求前clearLayers(),而不是重新创建。热力图和聚合图层同理,切换前先map.removeLayer()。另外,fetch请求加AbortController,快速切换时取消上一个未完成的请求。
6. 进阶技巧:让房价展示系统从及格到高分
课程大作业想拿高分,光有基础功能不够,得有一两个亮点。我推荐三个方向:等值线渲染、时间轴动画、以及基于空间查询的「找相似房源」。
等值线渲染是把离散的房价点插值成连续曲面,用turf.js的interpolate函数可以做到。核心思路是把点数据转成网格,再用isobands生成等值面。这个效果在答辩时非常抓眼球,但计算量不小,建议在后端做插值,前端只负责渲染 GeoJSON。
时间轴动画适合有历史成交数据的场景。用 Leaflet 的timeDimension插件,或者自己写一个滑块控件,按月份切换数据。实现上就是在接口里加一个month参数,前端滑块变化时重新请求。这个功能能体现你对时空数据的理解,加分不少。
「找相似房源」是我个人最喜欢的功能。用户点击某个房源后,系统返回单价、面积、区域最接近的五个房源,并在地图上高亮。SQL 实现如下:
SELECT id, name, unit_price, area, ST_X(geom) as lng, ST_Y(geom) as lat, ABS(unit_price - %s) + ABS(area - %s) * 100 as score FROM house WHERE id != %s AND geom && ST_Buffer(ST_MakePoint(%s, %s)::geography, 3000)::geometry ORDER BY score ASC LIMIT 5;这里用ST_Buffer限定 3 公里范围,score是单价差和面积差的加权和。面积差乘以 100 是为了让两个维度的量级接近。这个查询同时用到了空间索引和普通索引,性能很好。
最后一个技巧是关于数据可视化的细节。颜色梯度不要用默认的彩虹色,改用viridis或RdYlGn这类感知均匀的色带,看起来更专业。图例一定要加,不然老师看不懂颜色代表什么。弹窗里除了基本信息,可以加一个「周边均价对比」,用一条简单的 SQL 就能算出来,但展示效果很好。
我自己做这类项目最大的教训是:不要一开始就追求功能多,先把「数据准确 + 地图流畅 + 筛选可用」这三件事做到位。我见过太多同学花一周做 3D 效果,结果坐标是错的,答辩时被老师一句话问倒。先把基础打牢,再考虑亮点。希望帮到你。
本文还有配套的精品资源,点击获取