最近为导师的本科生制作数据结构实验教材,这个过程,用白岩松老师一句话来讲:“痛并快乐着”。数据结构实验,对学生来讲,实现完每个低层算法后,如果能够做一些实际有趣的应用,相信一定会增加学习的乐趣和信心。对作为助教的笔者来说,更是一个考验——首先得会做这个东西,不至于实验时被学生问倒,再者希望能够输出尽可能优质的内容,对本科没学过计算机的自己是一种锻炼,更是对自己助教工作的负责。
我们对相关的几个实验开发了GUI编程,使用了python的PyQt5框架。为了给学生界面编程的压力降到尽可能低,我们将所有GUI部分全部实现,并制作了相关教程。前两个已经做完的工作:
栈实现计算器 用递归实现迷宫访问 图的这一个章节作业要求是实现最短路径算法,并能用鼠标在中国地图上进行交互式选点,接着求最短路径。python画地图有许多库可选择,在对常用框架进行比较后,最终选择了plotly。如下图,plotly开源绘图库有四大板斧,其中笔者使用过plotly for python 和 dash。知乎上有一些介绍的帖子可以作为参考,但许多具体细节国内的内容较少,还是需要参考官方文档。处理和地图的交互是本次任务的难点,笔者没有系统学过前端知识,而网络上也没有关于这个细节的详细介绍,最后在dash官方文档找到了这个方法,并将其应用到了地图交互上。
对任务目标进行细分:
首先需要建立一个可视化界面,可供数据输入、展示。笔者使用plotly的dash产品,当然,不使用dash可以吗?当然可以。但相信看完dash gallery后,你会喜欢上这个框架。官网对dash的介绍是
Build beautiful, web-based analytic apps. No JavaScript required.
构建一个dash app需要以下几个流程:
准备工作之一:导入相关的库
import dash
import dash_core_components as dcc
import dash_html_components as html
import plotly.graph_objs as go
from dash.dependencies import Input, Output
准备工作之二:准备相关数据
地点用经纬度表示,将经度和纬度各放到一个list里,注意每个位置上要对应起来。从网上搜到了一些地名——经纬度的对应,然后给对应经、纬度、地名提取出来。
places = {
'山东': [117.000923, 36.675807],
...
}
logs = []
lats = []
names = []
for k, v in places.items():
names.append(k)
logs.append(v[0])
lats.append(v[1])
external_stylesheets = ['https://codepen.io/chriddyp/pen/bWLwgP.css'] #app样式
画图三部曲之一:每个图层用一个类or函数表示
可以看到plotly dash画图,要先把每个图层作为一个函数或类表示,并把所有图层都放到一个list里面。这个matplotlib有些许差别,后者是直接把数据放进一个大函数里面。我们使用plotly.graph_objs.Scattermapbox
类来对地图上画点。官方api参考后,使用了下面几个参数:
trace = list()
trace.append(
go.Scattermapbox(
mode="markers",
text=names,
lon=logs,
lat=lats,
marker={
'size': 10},
marker_color='red'
)
)
画图三部曲之二:使用Figure
将上面构建的数据列表传到plotly.graph_objs.Figure
类里面,并设计其layout。
fig = go.Figure(data=trace)
fig.update_layout(
margin={
'l': 3, 't': 3 'b': 3, 'r': 3}, # 边缘距离
mapbox={
'center': {
'lon': places['河南'][0], 'lat': places['河南'][1]}, #图的中心在河南这里
'style': "stamen-terrain",
'zoom': 3 # 放大倍数
},
)
画图三部曲之三:使用dash.Dash构建app
dash app相当于有若干html组件构成,下面传入三个组件。上面得到的fig
传入dcc.Graph
类,这个类是dash的核心组件类。
app = dash.Dash(__name__, external_stylesheets=external_stylesheets)
app.layout = html.Div(children=[
html.H1(id="title",children="中国地图"), #1
dcc.Graph( #2
id='example-graph',
figure=fig # 地图从这里传入
),
html.Div(id="div", children="") #3
])
点击响应
地图画好了之后,我们希望能够有点击响应,点一个marker点,得到其经纬度,还需要对点击进行响应。我们点击的其实上面app.layout
层中example-graph
的标签,点击响应对应的函数用app.callback()
进行装饰,
输入要用一个Input
类来包装,第一个参数是html对应id为example-graph
,第二个参数是该组件的点击响应参数clickData
。可以看example-graph
的参数有
['id', 'responsive', 'clickData', 'clickAnnotationData', 'hoverData', 'clear_on_unhover', 'selectedData', 'relayoutData', 'extendData', 'restyleData', 'figure', 'style', 'className', 'animate', 'animation_options', 'config', 'loading_state']
输出要用一个Output
类来包装,分别对应id和属性
@app.callback(.
Output('div', 'children'), #输出第一个是 ,第二是
[Input('example-graph', 'clickData')]) #输入第一个参数是id名称,第二个是函数参数
def display_click_data(clickData):
global two_points
if clickData:
point_dict = clickData['points'][0]
lon = point_dict['lon']
lat = point_dict['lat']
text = point_dict['text']
return "您选择了 {}:({},{})".format(text, lon, lat)
else:
return ""
最后
if __name__ == '__main__':
app.run_server(debug=True, port=8052)
到此,画图、显示、点击响应,就做完了。我们希望点击两个点后就去计算最短路径,因此需要处理点击的点。
假设我们实现了最短路径算法:
def solve_shortest_path():
print("solving shortest path")
return path
新增一个选点的函数
two_points = [] # 需要选择两个点
SELECTED = ""
def selectPoint(point, double_points):
if len(double_points) == 1 and double_points[0] == point:
return
else:
double_points.append(point)
print("Points you selected:{}".format(point))
再修改点击响应:
@app.callback(
Output('div', 'children'),
[Input('example-graph', 'clickData')])
def display_click_data(clickData):
global two_points, SELECTED
if clickData:
point_dict = clickData['points'][0]
lon = point_dict['lon']
lat = point_dict['lat']
text = point_dict['text']
selectPoint([lon, lat], two_points)
SELECTED += "{}:({},{}) ".format(text, lon, lat)
MSG = "您选择了" + SELECTED
if len(two_points) == 2:
solve_shortest_path() # 算法求解
two_points = []
SELECTED = ""
return MSG
else:
return "请选择两个点"
到此GUI交互已经设计完毕,效果如下
最后放一下全部代码:
# -*- coding: utf-8 -*-
import dash
import dash_core_components as dcc
import dash_html_components as html
import plotly.graph_objs as go
from dash.dependencies import Input, Output
# 1.准备数据
places = {
'山东': [117.000923, 36.675807],
'河北': [115.48333, 38.03333],
'吉林': [125.35000, 43.88333],
'黑龙江': [127.63333, 47.75000],
'辽宁': [123.38333, 41.80000],
'内蒙古': [111.670801, 41.818311],
'新疆': [87.68333, 43.76667],
'甘肃': [103.73333, 36.03333],
'宁夏': [106.26667, 37.46667],
'山西': [112.53333, 37.86667],
'陕西': [108.95000, 34.26667],
'河南': [113.65000, 34.76667],
'安徽': [117.283042, 31.86119],
'江苏': [119.78333, 32.05000],
'浙江': [120.20000, 30.26667],
'福建': [118.30000, 26.08333],
'广东': [113.23333, 23.16667],
'江西': [115.90000, 28.68333],
'海南': [110.35000, 20.01667],
'广西': [108.320004, 22.82402],
'贵州': [106.71667, 26.56667],
'湖南': [113.00000, 28.21667],
'湖北': [114.298572, 30.584355],
'四川': [104.06667, 30.66667],
'云南': [102.73333, 25.05000],
'西藏': [91.00000, 30.60000],
'青海': [96.75000, 36.56667],
'天津': [117.20000, 39.13333],
'上海': [121.55333, 31.20000],
'重庆': [106.45000, 29.56667],
'北京': [116.41667, 39.91667],
'台湾': [121.30, 25.03],
'香港': [114.10000, 22.20000],
'澳门': [113.50000, 22.20000],
}
logs = []
lats = []
names = []
for k, v in places.items():
names.append(k)
logs.append(v[0])
lats.append(v[1])
external_stylesheets = ['https://codepen.io/chriddyp/pen/bWLwgP.css']
# 2 画图三部曲
trace = list()
trace.append(
go.Scattermapbox(
mode="markers",
text=names,
lon=logs,
lat=lats,
marker={
'size': 10},
# marker_color='red'
)
)
fig = go.Figure(data=trace)
fig.update_layout(
margin={
'l': 3, 't': 3, 'b': 3, 'r': 3},
mapbox={
'center': {
'lon': places['河南'][0], 'lat': places['河南'][1]},
'style': "stamen-terrain",
'zoom': 3
},
)
app = dash.Dash(__name__, external_stylesheets=external_stylesheets)
app.layout = html.Div(children=[
html.H1(id="title", children="中国地图"), # 1
dcc.Graph( # 2
id='example-graph',
figure=fig # 地图从这里传入
),
html.Div(id="div", children="") # 3
])
def solve_shortest_path():
print("shortest path solving")
return None
two_points = [] # 需要选择两个点
SELECTED = ""
def selectPoint(point, double_points):
if len(double_points) == 1 and double_points[0] == point:
return
else:
double_points.append(point)
print("Points you selected:{}".format(point))
@app.callback(
Output('div', 'children'),
[Input('example-graph', 'clickData')])
def display_click_data(clickData):
global two_points, SELECTED
if clickData:
point_dict = clickData['points'][0]
lon = point_dict['lon']
lat = point_dict['lat']
text = point_dict['text']
selectPoint([lon, lat], two_points)
SELECTED += "{}:({},{}) ".format(text, lon, lat)
MSG = "您选择了" + SELECTED
if len(two_points) == 2:
solve_shortest_path()
two_points = []
SELECTED = ""
return MSG
else:
return "请选择两个点"
if __name__ == '__main__':
app.run_server(debug=True, port=8052)