在地理信息系统相关的 Python 项目中,Folium 是非常轻量的地图渲染库,它基于 Leaflet.js 封装,能用很少代码画出带缩放、标记、热力层的网页地图。但当业务人员希望点击某个城市标记,就能看到这个城市近半年的销售折线或品类占比环形图时,单纯放文字弹窗远远不够,必须把交互式图表嵌进地图。下面从原理到代码,完整说明集成思路。

为什么 Folium 原生弹窗不够用
Folium 的 Popup 对象可以接收字符串或 HTML,表面上似乎能把图表塞进去。但实际上,Folium 弹窗默认只渲染静态内容,若直接丢一段依赖 JavaScript 的图表容器,常因 Leaflet 的弹窗懒加载机制导致脚本不执行。另外,原生 Popup 的尺寸和滚动条处理很粗糙,复杂图表会被截断。
从底层看,Folium 生成的每个标记都是 Leaflet 的 marker 实例,Popup 内容通过 innerHTML 注入。如果图表库需要在 DOM 就绪后调用渲染函数,而弹窗在用户点击前根本不在文档流里,就会引发找不到容器的报错。因此,我们需要用 branca 的 MacroElement 或者 Folium 的 Element 来更可控地注入带脚本的节点。
方案一:用 Folium.Vega 嵌入 Vega 规范图表
Folium 内置了 folium.Vega 组件,可以直接把 Vega 或 Vega-Lite 的 JSON 规范变成地图里的图表元素。这种方式适合已经用 Altair 出图的场景,转换成本低。下面的例子在地图中心放一个可交互的柱状图,而不是传统弹窗。
import folium
import json
# 构造 Vega-Lite 规范,展示两类产品销量
vega_spec = {
"$schema": "https://vega.github.io/schema/vega-lite/v5.json",
"data": {
"values": [
{"category": "A", "sales": 120},
{"category": "B", "sales": 85},
{"category": "C", "sales": 63}
]
},
"mark": "bar",
"encoding": {
"x": {"field": "category", "type": "nominal"},
"y": {"field": "sales", "type": "quantitative"}
}
}
m = folium.Map(location=[39.9, 116.4], zoom_start=10)
# 将 Vega 图表作为图层添加到地图
folium.Vega(vega_spec, width=350, height=200).add_to(m)
m.save("map_with_vega.html")
这段代码把图表直接铺在地图上,而不是藏在弹窗里,用户拖动地图时图表会固定在屏幕坐标。它的优点是无需关心 JS 依赖,Folium 已打包好 Vega 运行时;缺点是样式自由度低,且难以和具体标记绑定联动。
如果希望点击标记才显示对应图表,就要把 Vega 规范写进 Popup,并用 folium.Popup 的 html 参数配合 branca 渲染。不过 Vega 规范体积大,多个标记会让 HTML 文件迅速膨胀,只适合标记少的场景。
方案二:用 branca 注入 ECharts 实现标记联动
更灵活的方案是利用 branca.element.Element 把 ECharts 的初始化脚本写进页面,再把图表容器作为 Popup 内容。ECharts 中文社区活跃,动画与交互体验优于 Vega,且能响应地图事件。
<div id="chart_popup_1" style="width:300px;height:200px;"></div>
<script>
var chart = echarts.init(document.getElementById('chart_popup_1'));
chart.setOption({
xAxis: { type: 'category', data: ['Mon','Tue','Wed'] },
yAxis: { type: 'value' },
series: [{ type: 'line', data: [10, 22, 15] }]
});
</script>
在 Python 侧,我们把上面这段转义后的 HTML 字符串传给 Popup。注意 ECharts 的 JS 库要通过 folium.WebApi 或手动在 Map 的 get_root().html.add_child 里引入 CDN,否则弹窗脚本会报 echarts 未定义。
import folium
from branca.element import Element
m = folium.Map(location=[31.2, 121.5], zoom_start=11)
# 引入 echarts CDN
echarts_script = Element(
'<script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>'
)
m.get_root().html.add_child(echarts_script)
popup_html = (
'<div id="chart_popup_1" style="width:300px;height:200px;"></div>'
'<script>'
'var chart = echarts.init(document.getElementById("chart_popup_1"));'
'chart.setOption({xAxis:{type:"category",data:["Mon","Tue","Wed"]},'
'yAxis:{type:"value"},series:[{type:"line",data:[10,22,15]}]});'
'</script>'
)
folium.Marker(
location=[31.2, 121.5],
popup=folium.Popup(popup_html, max_width=320)
).add_to(m)
m.save("map_echarts.html")
这种写法的好处是每个标记可以携带完全不同的图表配置,且 ECharts 支持点击事件回传,可进一步让地图高亮其他区域。需要留意的是,Popup 里的 script 在 Folium 默认模板中不会被自动执行,某些版本要用 folium.Popup(html, parse_html=False) 并确认 Leaflet 的 popupopen 事件触发后再 init,否则容器宽度为 0。
实践中建议在 popupopen 回调里延迟 50 毫秒再调用 echarts.init,避开 Leaflet 动画未完成导致的尺寸异常。同时给图表容器加 overflow:auto,防止移动端弹窗溢出视口。
方案三:Plotly 与 Folium 的 JSON 嵌入对比
如果团队已经在用 Plotly,可以用 plotly.io.to_html 生成局部 DIV,再作为 Element 插入。Plotly 的交互如缩放、悬浮提示很完善,但产物体积比 ECharts 大。下面的表格列出三种方案差异:
| 方案 | 联动难度 | 文件体积 | 适用场景 |
|---|---|---|---|
| Folium.Vega | 低 | 小 | 静态规范图表、少标记 |
| ECharts+branca | 中 | 中 | 高定制弹窗、事件联动 |
| Plotly 嵌入 | 中高 | 大 | 已有 Plotly 报表体系 |
从坐标系角度,Folium 默认使用经纬度 WGS84,而 ECharts 和 Plotly 的图表本身不涉经纬度,只处理屏幕像素,所以不存在坐标转换坑。真正的坑在于弹窗生命周期:地图平移后弹窗销毁,图表实例未 dispose,会造成内存泄漏。应在 popupclose 事件里主动调 chart.dispose()。
另外,若把图表放在 folium.FeatureGroup 里批量管理,要注意 Group 的显隐切换也会触发 DOM 移除,必须监听 remove 事件清理实例。这些细节决定集成方案能否撑住几十个标记的真实业务页。
完整可运行示例
下面给出一个综合示例:地图上有三个城市标记,分别点击弹出不同的 ECharts 图表,并在关闭时释放资源。代码中用随机图床以外的本地逻辑,不涉及外部业务接口。
import folium
from branca.element import Element
m = folium.Map(location=[35.0, 105.0], zoom_start=4)
# 引入 echarts
m.get_root().html.add_child(Element(
'<script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>'
))
cities = {
'Beijing': [39.9, 116.4, [120, 90, 60]],
'Shanghai': [31.2, 121.5, [80, 110, 70]],
'Chengdu': [30.6, 104.0, [50, 40, 90]]
}
for name, (lat, lng, data) in cities.items():
html = (
'<div id="c_{0}" style="width:280px;height:180px;"></div>'
'<script>'
'setTimeout(function(){{'
'var ch = echarts.init(document.getElementById("c_{0}"));'
'ch.setOption({{xAxis:{{type:"category",data:["Q1","Q2","Q3"]}},'
'yAxis:{{type:"value"}},series:[{{type:"bar",data:{1}}}]}});'
'}}, 50);'
'</script>'
).format(name, data)
folium.Marker(
location=[lat, lng],
popup=folium.Popup(html, max_width=300)
).add_to(m)
m.save("interactive_folium.html")
运行后打开生成的 HTML,点击任一标记即可看到对应柱状图。若需进一步让图表点击后飞行到其它坐标,可在 ECharts 的 on 事件里调用 Leaflet 的 map.flyTo,这就实现了双向交互。
总体来看,Folium 集成交互式图表并不复杂,核心就是理解弹窗的 DOM 注入时机与图表库的初始化约束。选对方案,就能用纯 Python 写出带动态图表的地理看板,而不必切到重型前端框架。