最近用到Power BI来做数据报表的呈现,这个报表的目的是展现车辆在业务使用过程中的消息发送的时延。服务器端接收车辆上报的消息的时候,会根据消息生成的时间戳和接收到消息的时间戳来计算传输时延。对于计算后的时延,按照每小时的粒度来进行聚合,并且呈现在地图上。
这个报表会把数据展现在地图中,地图可以分级来进行聚合呈现,例如地图可以按照区域来进行指标的呈现,当点击区域的时候,可以进入到第二级,按照网格(例如200*200米)来进行呈现。
这里我采用了Power BI的Mapbox控件来进行开发。
1. 地图数据的准备
需要准备两个级别的地图数据。第一个级别是区域的数据,第二个级别是网格的数据
1.1 区域数据的准备
首先准备区域的数据,这里我以瑞典首都斯德哥尔摩市为例,从wiki page https://en.wikipedia.org/wiki/Districts_of_Sweden#Stockholm可以查到斯市共分为了14个区,然后可以上https://osm-boundaries.com/Map这个网站找到对应的这些区域的多边形的坐标数据(在这个网站中斯市只有13个区),下载之后得到geojson格式的文件,里面定义了每个区的多边形的坐标点,按照逆时针的顺序来排序。打开这个文件,可以看到里面的Feature的geometry的类型是polygon,这里需要更改为MultiPolygon的类型,并且把coordinates的数组增加多一个[]层级,不然Mapbox处理会有问题。
区域的Geojson文件准备好之后,就可以登录上Mapbox的网站https://studio.mapbox.com/tilesets/,用mapbox studio来创建新的tileset了。
1.2 网格数据的准备
现在准备第二个级别的网格数据。这里用到了http://turfjs.org/这个工具,提供了javascript来方便的对地图进行网格切分,只需要指定一个BBOX,网格大小,就可以进行切分,切分的网格有多种不同的形状,正方形,六角形,三角形等等。非常方便。
以下代码是调用turf的javascript脚本来进行网格切分的示例。
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="viewport" content="initial-scale=1.0, user-scalable=no" />
<style type="text/css">
body, html,#allmap {width: 100%;height: 100%;overflow: hidden;margin:0;font-family:"微软雅黑";}
</style>
<script src='https://unpkg.com/@turf/turf/turf.min.js'></script>
<title>turf</title>
</head>
<body>
<script type="text/javascript">
var bbox = [14.29, 57.669, 19.124, 60.555];
var cellSide = 0.2;
var options = {units: 'kilometers'};
var squareGrid = turf.squareGrid(bbox, cellSide, options);
console.log(squareGrid.features.length);
console.log(JSON.stringify(squareGrid));
</script>
</body>
把这个HTML文件放到Web服务器,然后打开这个HTML,在浏览器的控制台可以看到切分的网格数据的输出。把这些数据拷贝下来,保存为Geojson格式的文件。这里同样需要对Geojson文件进行修改,把Polygon的类型改为Multipolygon,以及给Coordinates数组增加多一个列表层级。
1.3 道路数据的准备
因为车辆是跑在道路上的,因此发送的位置坐标都应该是落在道路上的。因此我们需要把这些区域内的道路数据抓取出来。这里用到Openstreetmap的数据来提取道路信息。
在Openstreetmap上下载斯德哥尔摩的地图数据,数据格式是XML,里面的节点如果tag是node,表示是一个具体的点和对应的坐标,如果tag是way,需要进一步看其属性是否是highway,如果是就代表是一条道路,再获取其包含的node,这样就可以提取出这条道路的所有坐标点了。
为了之后能方便进行地图区域的处理,采用Google的S2地理库来进行处理,把提取出来的道路坐标点会转换为s2point的对象,然后把这条道路转换为s2polyline。以下python代码是处理过程
tree = ET.ElementTree(file = 'stockholm/stockholm_map.osm')
root = tree.getroot()
nodes = {}
for child in root:
if child.tag=='node':
nodes[child.attrib['id']] = (float(child.attrib['lat']), float(child.attrib['lon']))
ways = {}
ways_type = {'motorway':[], 'trunk':[], 'primary':[], 'secondary':[]}
for child in root:
if child.tag=='way':
waytag = child.findall("tag")
isRoad = False
roadPosition = []
for item in waytag:
if item.attrib['k'] == 'highway':
if item.attrib['v'] in ['motorway', 'trunk', 'primary', 'secondary']:
isRoad = True
ways_type[item.attrib['v']].append(child.attrib['id'])
break
s2points = []
if isRoad:
nds = child.findall("nd")
ways[child.attrib['id']] = []
for nd in nds:
s2point = s2.S2LatLng.FromDegrees(nodes[nd.attrib['ref']][0], nodes[nd.attrib['ref']][1]).ToPoint()
s2points.append(s2point)
s2polyline = s2.S2Polyline()
s2polyline.InitFromS2Points(s2points)
ways[child.attrib['id']] = s2polyline
1.4 区域,网格和道路的处理
需要把区域,网格以及道路的信息都关联起来,就是说根据区域来判断有哪些网格是落在区域里面的,然后判断哪些网格是覆盖道路的。
首先是把区域的数据转换为S2的s2loop
with open('stockholm/district/district.geojson', 'r') as f:
all_district = json.loads(f.readlines()[0])
s2districts = {}
for feature in all_district['features']:
coords = feature['geometry']['coordinates'][0][0]
s2points = [s2.S2LatLng.FromDegrees(a[1],a[0]).ToPoint() for a in coords]
s2loop = s2.S2Loop(s2points[:-1])
s2loop.Normalize()
s2districts[feature['properties']['name']] = s2loop
把网格的数据也转换为s2loop,然后判断哪些网格是包含在区域里面的,对这些在区域里面的s2loop,转换为s2polygon
with open('stockholm/stockholm_all_grids.geojson', 'r') as f:
all_grids = json.loads(f.readlines()[0])
selected_grids = {}
s2grids = []
gridid = 0
for feature in all_grids['features']:
coords = feature['geometry']['coordinates'][0]
s2points = [s2.S2LatLng.FromDegrees(a[1],a[0]).ToPoint() for a in coords]
s2loop = s2.S2Loop(s2points[:-1])
s2loop.Normalize()
for districtname in s2districts:
districtloop = s2districts[districtname]
if districtloop.Contains(s2loop):
feature['properties']['name'] = districtname
feature['properties']['gridid'] = 'grid'+str(gridid)
feature['s2polygon'] = s2.S2Polygon(s2loop)
selected_grids['grid'+str(gridid)]=feature
gridid += 1
判断上一步得到的网格中,哪些是覆盖道路的,这里用到了s2polygon的IntersectWithPolyline方法,来判断道路的polyline和网格的polygon是否相交,如果相交,那么返回的就是相交的polyline。
new_selected_grids = []
for k,v in tqdm(ways.items(), total=len(ways)):
for grid in selected_grids:
if len(selected_grids[grid]['s2polygon'].IntersectWithPolyline(v))>0:
new_selected_grids.append(selected_grids[grid])
del selected_grids[grid]
break
最后就是把这些网格写入为一个geojson的文件,这个文件制作完成之后,同样需要用Mapbox studio来创建新的tileset。
way_grids = {}
way_grids["type"] = "FeatureCollection"
way_grids["features"] = []
for grid in new_selected_grids:
feature = grid.copy()
feature["geometry"]["type"] = "MultiPolygon"
feature["geometry"]["coordinates"] = [feature["geometry"]["coordinates"]]
del feature["s2polygon"]
way_grids["features"].append(feature)
way_grids_str = json.dumps(way_grids)
with open("stockholm/way_grids_1209.geojson", "w") as f:
f.write(way_grids_str)
2. 测量数据的准备
有了网格和区域的数据之后,我们就可以准备测量数据了。这里模拟随机生成了一些测量数据,并关联到网格和区域中。因为我们是要制作二级地图,因此数据里面需要包括三个字段,分别是区域名称,网格名称和测量值。以下代码是随机生成一些测量数据
simulate_data = 'district,gridid,latency'
for grid in way_grids["features"]:
district = grid["properties"]["name"]
gridid = grid["properties"]["gridid"]
latency = random.randint(60, 150)
simulate_data += "\n"
simulate_data += district+","+gridid+","+str(latency)
with open("stockholm/simulate_data_1209.csv", "w") as f:
f.write(simulate_data)
3. 报表的制作
PowerBI里面有多个地图控件,例如Azure Map, ArcGIS map, Filled Map等等,但是能做分级地图的似乎只有Mapbox以及Drilldown Choropleth,其中Mapbox功能更强一些,因此这里选用Mapbox。Mapbox提供了比较友好的收费模式,在地图访问量小于50000的时候是免费的。
打开PowerBI desktop,默认是没有mapbox的,在Visualizations里面选择Get more visuals,然后在maps里面查找mapbox并安装。
点击Mapbox控件,点击Get Data,选择我们之前制作的模拟数据。然后在Mapbox的Fields里面,把数据的district和grid两个字段拖动到location,把latency字段拖动到color
点击Format,关闭Circle,开启Choropleth,Number of levels选择为2,然后选择level 1,Data Level选择Custom Tileset,Vector URL选择在Mapbox创建的区域Tileset的名称,Source Layer name选择Tileset的layter,Vector property输入district。之后按照同样的过程来设置level 2,只是换成网格Tileset。
最后的效果如下
stockholm