用 wxPython + ECharts + 阿里矢量地图,做一个可离线兜底的上海雨量看板

一、写在前面

最近上海的梅雨季节让我萌生了做一个 分区雨量看板 的想法。市面上大部分方案要么是网页、要么依赖付费地图 API,我想要的是:

  • 桌面程序,双击就能用;
  • 真实的行政区划矢量地图(不是自己画的方块示意图);
  • 按雨量强度自动着色,一眼就能看出哪个区在下暴雨;
  • 配置和历史数据要持久化,下次打开能"接着用";
  • 网络不好也不能白屏,最好还能记住我上次手动放大到哪。

最后成品:wxPython + wx.html2.WebView + ECharts + 阿里 DataV.GeoAtlas 组合,约 500 行 Python。这篇博客把关键设计与代码逐个拆解一遍。

C:\myApp\shanghaiweather


二、技术选型

需求 方案 原因
桌面 UI wxPython 原生外观、支持嵌入 WebView、能被 PyInstaller 打包
地图渲染 ECharts 5 + 阿里 DataV.GeoAtlas 免 API Key、真实行政边界、支持缩放拖拽
天气数据 wttr.in 无需 Key、返回 JSON、覆盖全球城市
历史/兜底 SQLite 单文件、随程序目录走
配置持久化 config.json 人肉可读、易调试

选择 wx.html2.WebView 而不是 PyQt5 的 QWebEngineView,是因为 wxPython 打包出的 exe 体积更小,而且在 Windows 上会自动复用系统的 Edge WebView2 内核。


三、路径处理:让 .py 直接跑 和 PyInstaller 打包后跑 都能定位配置

这是很多初学者第一个踩坑点。如果你在代码里写:

python 复制代码
CONFIG_PATH = "config.json"

那么打包成 exe 后,程序会去 当前工作目录 找配置,而不是 exe 所在目录------用户从桌面右键"以管理员运行"时工作目录可能变成 C:\Windows\System32,配置直接就丢了。

正确姿势是判断 sys.frozen

python 复制代码
def get_base_dir() -> str:
    if getattr(sys, "frozen", False):
        # PyInstaller 打包后:sys.executable 是 exe 真实路径
        return os.path.dirname(os.path.abspath(sys.executable))
    else:
        return os.path.dirname(os.path.abspath(__file__))

BASE_DIR = get_base_dir()
CONFIG_PATH = os.path.join(BASE_DIR, "config.json")
DB_PATH = os.path.join(BASE_DIR, "weather.db")

无论怎么运行,config.jsonweather.db 永远和可执行文件放在一起。


四、雨量分级:气象部门标准 + 语义化配色

按国标 1 小时降雨量分级,映射到一组「越深越危险」的配色:

python 复制代码
def rainfall_level(mm: float):
    if mm <= 0:   return "无降雨", "#8a9099", "#f2f3f5"
    if mm < 2.5:  return "小雨",   "#3a8ee6", "#e6f2ff"
    if mm < 8:    return "中雨",   "#2266cc", "#d3e6ff"
    if mm < 15:   return "大雨",   "#f0a020", "#fff2d9"
    if mm < 30:   return "暴雨",   "#e6482f", "#ffe1db"
    return "大暴雨", "#8c1c13", "#f5d0cb"

这里刻意用了 蓝→黄→红→深红 的顺序,符合直觉:蓝色是水,黄色是警告,红色是危险。绝对不要用彩虹调色板做数值可视化------那会让"中雨"和"大雨"完全没有语义关联。


五、抓天气:主链路 + 模拟数据双保险

wttr.in 是一个非常适合 demo 的免 Key 天气服务,请求 http://wttr.in/Pudong,Shanghai?format=j1 就能拿到 JSON:

python 复制代码
def fetch_weather_one(code: str) -> dict:
    name, query = DISTRICT_MAP[code]
    now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")

    if HAS_REQUESTS:
        try:
            url = f"http://wttr.in/{query},Shanghai?format=j1"
            resp = requests.get(url, timeout=6)
            resp.raise_for_status()
            data = resp.json()
            cur = data["current_condition"][0]
            return {
                "code": code, "name": name,
                "temp": float(cur.get("temp_C", 0)),
                "rainfall": float(cur.get("precipMM", 0)),
                # ...
                "is_mock": False,
            }
        except Exception:
            pass  # 落到下面的兜底

    # 兜底:随机模拟数据,保证界面演示可用
    return {"is_mock": True, "...": "..."}

关键设计

  1. HAS_REQUESTS 是启动时探测 import requests 是否成功------极端场景下即使连 requests 都没装,程序也不能崩;
  2. 网络失败走 is_mock=True 分支,UI 会顶部飘一条 ⚠ 提示条,用户不会误以为真的没下雨;
  3. 每个区独立请求 + 独立 try/except,一个区挂了不影响其他区。

六、SQLite 历史记录:断网兜底 + 时间序列基础

选择 SQLite 而不是 JSON 文件保存历史,是因为将来要做趋势图

sql 复制代码
CREATE TABLE weather_history (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    district_code TEXT NOT NULL,
    district_name TEXT NOT NULL,
    temp_c REAL,
    weather_desc TEXT,
    rainfall_mm REAL,
    humidity INTEGER,
    wind_kmph REAL,
    update_time TEXT NOT NULL
);
CREATE INDEX idx_district_time ON weather_history(district_code, update_time);

有了 (district_code, update_time) 复合索引,查最新一条 ORDER BY id DESC LIMIT 1 或者查最近 24 小时都会走索引。

巧思 :程序启动时 先从数据库读上次记录立刻渲染,再异步去抓新数据。用户永远不会看到白屏:

python 复制代码
def __init__(self):
    # ...
    self._show_from_db_or_placeholder()  # 秒开
    self.on_refresh(None)                # 后台刷新

七、核心亮点:接入阿里 DataV.GeoAtlas 的矢量行政区划地图

这是整个项目最有意思的部分。阿里云 DataV 提供了一个 完全免费、免鉴权 的中国行政区划 GeoJSON 服务:

复制代码
https://geo.datav.aliyun.com/areas_v3/bound/310000_full.json

其中 310000 是上海市的行政区划代码,_full 表示包含下辖 16 个区的边界。返回的 GeoJSON 里每个 feature.properties.name 就是中文区名("浦东新区"、"黄浦区"...)。

7.1 用 ECharts 注册并渲染地图

javascript 复制代码
fetch('https://geo.datav.aliyun.com/areas_v3/bound/310000_full.json')
    .then(r => r.json())
    .then(geoJson => {
        echarts.registerMap('shanghai', geoJson);

        const seriesData = geoJson.features.map(f => {
            const name = f.properties.name;
            const d = weatherData[name];  // Python 侧注入的雨量数据
            if (d) return {
                name: name,
                value: d.rainfall,
                itemStyle: { areaColor: d.color, borderColor: '#fff' },
                label: { show: true, color: '#fff', fontWeight: 700 }
            };
            return { name, itemStyle: { areaColor: '#e5e7eb' } };  // 未选择
        });

        chart.setOption({
            tooltip: { trigger: 'item', formatter: /* ... */ },
            series: [{
                type: 'map', map: 'shanghai', roam: true,
                zoom: INIT_ZOOM, center: INIT_CENTER,
                label: {
                    show: true,
                    formatter: p => {
                        const d = weatherData[p.name];
                        return d ? `${p.name}\n${d.rainfall.toFixed(1)} mm` : p.name;
                    }
                },
                data: seriesData
            }]
        });
    });

几个细节值得单独说

  • label.formatter 里用 \n 换行,让区名和雨量数值都直接画在地图上,无需悬停;
  • 未选择的区依然渲染成浅灰色,保证上海全域轮廓完整;
  • roam: true 打开拖拽 + 滚轮缩放,scaleLimit 防止用户缩过头;
  • emphasis 单独指定深色描边,鼠标悬停时区块会"跳出来"。

7.2 Python 端注入数据

Python 侧的数据以中文名为 key 直接 json.dumps 塞进 HTML:

python 复制代码
js_data[r["name"]] = {
    "code": r["code"], "level": level, "color": color,
    "rainfall": round(r["rainfall"], 1),
    # ...
}
# ...
html = f"""...
<script>
    const weatherData = {json.dumps(js_data, ensure_ascii=False)};
</script>
..."""

ensure_ascii=False 保留中文,否则 GeoJSON 的 "浦东新区" 和 Python 传过来的 "浦东新区" 匹配时容易出诡异 bug。


八、让地图"记住上次的缩放"------Python ↔ JS 单向消息通道

这是最有工程感的一个点。用户用滚轮把地图放大到某个区细看,下次打开程序希望还是那个视图。

难点是:HTML 端在 WebView 里跑,怎么把 zoom/center 告诉外面的 Python?

wxPython 的 wx.html2.WebView 没有像 PyWebView 那样的原生 JS Bridge。取巧方案 :用 document.title 当消息通道,因为 wxPython 会为 title 变化发出 EVT_WEBVIEW_TITLE_CHANGED 事件。

JS 端:debounce 后写 title

javascript 复制代码
let _titleTimer = null;
function reportMapState(zoom, center) {
    clearTimeout(_titleTimer);
    _titleTimer = setTimeout(() => {
        const cx = center ? center[0] : '';
        const cy = center ? center[1] : '';
        document.title = `MAPSTATE|${zoom}|${cx}|${cy}`;
    }, 250);
}

chart.on('georoam', () => {
    const s = chart.getOption().series[0];
    reportMapState(s.zoom, s.center);
});

250ms 的 debounce 是为了避免滚轮连滚 20 下就写 20 次 config.json

Python 端:绑事件 → 解析 → 落盘

python 复制代码
self.webview.Bind(wx.html2.EVT_WEBVIEW_TITLE_CHANGED, self.on_webview_title)

def on_webview_title(self, event):
    title = event.GetString() or ""
    if not title.startswith("MAPSTATE|"):
        return
    parts = title.split("|")
    try:
        zoom = float(parts[1])
    except (ValueError, IndexError):
        return
    try:
        center = [float(parts[2]), float(parts[3])]
    except ValueError:
        center = self.cfg.data.get("map_center")
    self.cfg.data["map_zoom"] = zoom
    self.cfg.data["map_center"] = center
    self.cfg.save()

下次启动时,build_map_html(map_zoom=..., map_center=...) 把这两个值再 f-string 进 HTML 的 INIT_ZOOM / INIT_CENTER 常量,用户拖到哪就恢复到哪。

这个模式很通用------任何嵌入 WebView 的桌面程序,都可以用 document.title 当廉价 IPC 通道 ,甚至可以协议化:SAVE|...OPEN|...LOG|...


九、线程模型:一个不太起眼但很重要的细节

刷新按钮点击后,如果同步调用 requests.get,主线程会卡 6 秒------UI 直接白屏。所以必须开子线程:

python 复制代码
def on_refresh(self, event):
    if getattr(self, "_refreshing", False):
        return                          # 防止重复触发
    self._refreshing = True
    self.btn_refresh.Disable()
    threading.Thread(target=self._fetch_worker,
                     args=(codes,), daemon=True).start()

def _fetch_worker(self, codes):
    try:
        records = []  # 抓取逻辑省略
        wx.CallAfter(self._on_fetch_done, records, any_mock, None)
    except Exception as e:
        wx.CallAfter(self._on_fetch_done, [], False, str(e))

三个易漏细节

  1. _refreshing 布尔锁:防止定时器和用户点击同时触发,导致按钮状态错乱;
  2. daemon=True:主窗口关闭时子线程不会拖着进程不退出;
  3. 必须wx.CallAfter 回主线程更新 UI------直接在子线程里 SetLabel 会随机崩溃或界面错乱。
相关推荐
索西引擎1 小时前
【React】Immer.js 在现代 Redux 生态中的角色:不可变性保障的工程化实现与开发体验优化
前端·javascript·react.js
只一1 小时前
端侧AI实战第二章:React组件工程化 + 事件系统 + 可复用进度条(WebGPU模型加载底座)
前端·react.js
只一1 小时前
拿捏大模型输出随机性:Temperature、Top-K 原理 + LangChain 工程落地实战
javascript·langchain
大卫陈1 小时前
微信小程序虚拟支付实战:从「支付能力被限制」到沙箱调通的全过程
前端·后端
武子康1 小时前
vLLM 0.25.1:服务没有报错,为什么仍会生成垃圾 Token(5 级正确性门禁 + 自动回滚条件)
前端·人工智能·后端
jyp201211071 小时前
Vue3 Diff 算法
前端·vue.js
胡萝卜术1 小时前
抽象的三级跳:从原生 DOM 到 React 组件树,我们到底在解决什么问题?
前端·javascript·面试
黄敬峰2 小时前
从零理解React:事件、组件与响应式——一个WebGPU Demo的前端笔记
前端·面试
jun_bai2 小时前
Orthanc for Windows版PACS服务器安装教程
javascript·扩展屏应用开发