作品概述
这个作品刚刚获得“中国电科杯”城市数据创新大赛的城市交通专项奖,现在作为案例分享出来供同行交流讨论。虚的就不说了,此文只讨论技术。
先上图:
实时道路交通可视化
实时道路拥堵排名
历史路况时间序列图
每日每小时道况热力图
每小时内道况南丁格尔玫瑰图
每小时内总体路况圆盘图
各道路每日拥堵时长排名
实时路况数据下载
这个作品的构建过程与设计工具如下:
- 原型设计:在构建dashboard之前要先思考要做哪些分析和可视化,这些图形如何布局,颜色风格如何,交互效果如何做……这些都需要设计原型,这里用的是Axure;
- 数据采集:这里主要采集的是深圳市道路交通委员会网站上的道路交通指数数据。主要是分两种采集方式:一种是在shinydashboard中内嵌的实时爬虫,这个用R实现;另一种是用Python爬虫程序在服务器上持续采集一个月的数据作为历史数据进行数据分析。政府网站一般技术含量都很低,没什么反爬措施,因此只要不是爬得太猛把他们服务器搞垮了,爬起来没有任何难度。
- 数据存储:主要用来存储历史数据,本作品以5分钟为间隔采集一次,一个月下来也有百万行数据。这里用到的是MySQL。
- 数据处理、分析和可视化:这步是核心,下文细说。
- CSS美化:原生的shinydashboard是很丑的,好在它支持引入CSS,所以可以通过CSS对颜色、文字大小等进行重新设计,以达到整体风格的统一、美观。
shiny与shinydashboard的特点
shiny是Rstudio出品的一个可以在R中构建交互式网页的引擎,shinydashboard则是基于shiny提供的一套快速搭建dashboard的工具。
先谈谈shiny的优缺点:
- 能以较低的学习成本实现交互式网页设计。数据分析师毕竟不是前端工程师,无需去学习HTML、CSS和JavaScript那一大堆东西(当然懂更好,也没必要完全区分开来,至少腾讯就希望招懂前端的数据分析师或懂数据分析的前端工程师)也能很快构建出一个交互式网页。
- 开发原型成本低。一个综合能力强的数据分析师(比如我),一个人就能快速地实现数据采集、数据处理、数据分析和数据可视化原型设计的全过程,这个时间成本远远小于在一支分工明确的团队上下游间的磨合和交接。
- 基本满足常用的交互式效果。所以作为一个原型也是够用了,产品经理可以直接提供给开发人员,分析师也可以直接拿来向领导汇报。
- 基本不支持动态效果。至少到现在shiny连个支持动画的接口都没有,更别说前端那些炫酷的动态效果,这些都实现不了。
- 计算速度慢。一旦数据量大或者数据处理过程复杂或者图形构造复杂,就会卡得比较久。不过速度慢是R的天生缺陷。
- 展示内容有限。想在shiny中展示什么,前提条件是shiny有对应的接口,否则白搭,这就是R中可以生成动画但却无法在shiny中展示的原因。
- 部署困难。R中有另一个做dashboard的包flexdashboard,比shiny简单而且直接生成一个html,但是shiny由于是Rstudio的商业软件,所以受到的限制很多。拿部署来说,目前有两种方式:一种是自己搭建局域服务器,不过这种方式只能内部人自己看,无法放在互联网上公开;另一种方式就是部署到shinyapps.io网站上去,不过这个网站本身奇卡无比,十分影响体验。
所用工具包
再谈谈R中所用的包,主要分类两类:数据处理和可视化。
数据处理包:
- rvest用来做实时爬虫,每次程序一启动就开始做***的实时爬虫。
- plyr和dplyr主要做数据筛选、排序、聚合计算等。
- stringr用来对字符串分割、转换等。
- data.table用来读取大量的历史数据并做一些简单的处理。
- reshape2用来对数据框做变形处理。
可视化包:
- shiny和shinydashboard用来搭建基本的网页结构和内容。
- ggplot2这个神器应该无人不知无人不晓,用来做基本的可视化。
- ggiraph这也是个神奇的包,能把ggplot2的图形转化成交互式图形,与ggplot2堪称绝配。
- dygraphs用来画交互式的时间序列折线图。
- DT用来呈现交互式表格。
构建shinydashboard
一个shiny程序基本包含两部分:ui.R和server.R。其中ui.R主要用来设计限定网页结构,比如每一行是什么图形或内容,尺寸大小如何设定,文字怎么插入,控件的位置和编号等等——基本上可以概括为:一切关于外在结构而不涉及内在计算的都在ui.R中设计。反之,server.R就主要用来做数据处理、计算和可视化,并把结果映射至ui.R中,所以它才是核心,代码也长得多。
本作品的ui.R核心代码如下:
- dashboardPage(
- dashboardHeader(title = "深圳道路的数据画像",titleWidth = 220),
- dashboardSidebar(
- sidebarMenu(
- menuItem(iconv("实时路况展示",to="UTF-8"), tabName = "realtime_traffic", icon = icon("road")),
- radioButtons(inputId = "choose_direction", label = "请选择一个方向:",selected=1,choiceNames=c("1(东->西 或 北->南)","2(西->东 或 南->北)"),choiceValues=1:2),
- radioButtons(inputId = "rank_class", label = "请选择道路排名类别:",selected="拥堵排名",choices=c("拥堵排名","通畅排名")),
- numericInput(inputId = "rank_num",label = "请输入道路交通排名数量:",min = 5,max = 100,step = 1,value=20),
- menuItem(iconv("道路画像分析",to="UTF-8"), icon = icon("area-chart"), tabName = "statistics"),
- selectInput(inputId = "choose_road",label = "请选择一条道路:",choices = all_roads),
- radioButtons(inputId = "choose_direction2", label = "请选择一个方向:",selected=1,choiceNames=c("1(东->西 或 北->南)","2(西->东 或 南->北)"),choiceValues=1:2),
- menuItem(iconv("历史路况回顾",to="UTF-8"), icon = icon("calendar"), tabName = "history"),
- dateInput(inputId = "choose_date", label = "请选择4月的一天:",value = "2017-04-01",min = "2017-04-01",max = "2017-04-30"),
- radioButtons(inputId = "choose_direction3", label = "请选择一个方向:",selected="东->西",choices=c("东->西","西->东","北->南","南->北")),
- radioButtons(inputId = "rank_class2", label = "请选择道路排名类别:",selected="拥堵排名",choices=c("拥堵排名","通畅排名")),
- numericInput(inputId = "rank_num2",label = "请输入道路交通排名数量:",min = 5,max = 100,step = 1,value=20),
- menuItem(iconv("实时数据下载",to="UTF-8"),tabName = "data_download",icon = icon("database"))
- ),width = 220),
- dashboardBody(
- tags$head(
- tags$link(rel = "stylesheet", type = "text/css", href = "custom.css")),
- tabItems(
- tabItem("realtime_traffic",
- fluidRow(
- box(ggiraphOutput("map"),width = 12,solidHeader = T,collapsible = T)
- ),
- fluidRow(
- box(ggiraphOutput("rank1"),width = 6,solidHeader = T,collapsible = T),
- box(ggiraphOutput("rank2"),width = 6,solidHeader = T,collapsible = T)
- ),
- fluidRow(
- box(ggiraphOutput("rank3"),width = 6,solidHeader = T,collapsible = T),
- box(ggiraphOutput("rank4"),width = 6,solidHeader = T,collapsible = T)
- )
- ),
- tabItem("statistics",
- fluidRow(
- box(dygraphOutput("ts_history"),width = 12,solidHeader = T,collapsible = T)
- ),
- fluidRow(
- box(ggiraphOutput("heat"),width = 12,solidHeader = T,collapsible = T)
- ),
- fluidRow(
- box(ggiraphOutput("polar_weekdays"),width = 6,solidHeader = T,collapsible = T),
- box(ggiraphOutput("polar_holidays"),width = 6,solidHeader = T,collapsible = T)
- )
- ),
- tabItem("history",
- # fluidRow(
- # box(img(src="https://ss1.bdstatic.com/70cFvXSh_Q1YnxGkpoWK1HF6hhy/it/u=3245526806,2208748886&fm=21&gp=0.jpg"),width = 12,solidHeader = T)
- # )),
- fluidRow(
- box(ggiraphOutput("history_bars"),width = 12,solidHeader = T,collapsible = T)
- ),
- fluidRow(
- box(ggiraphOutput("day_rank"),width = 12,solidHeader = T,collapsible = T)
- )),
- tabItem("data_download",
- fluidRow(
- box(
- dataTableOutput("rawdata"),width = 12
- )
- ),
- fluidRow(
- p("数据来源:",strong(a("深圳市交通运输委员会",href="http://sztocc.sztb.gov.cn/roadcongmore.aspx")))
- ),
- downloadButton("downloadCsv", "下载实时数据")
- )
- )
- ))
这些代码基本是在模板的基础上改,menuItem()是左侧sidebar里的一个子页面,tabName是这个页面的名称,它来指定后来的各个布局元素存放在哪个页面里。radioButtons是单选框,numericInput是数字输入框,dateInput是日期选择框,这些控件都有唯一一个id来标识,以input$id来取它们的值传输至server.R中。
在dashboardBody中,每个tabItem代表一个子页面,这跟前面的tabName一一对应;里面的fluidRow表示一行,box是基本的容器,可以放图形或其他输出内容(也可以不加box,不过这样没法设定宽度),每个box的宽度***为12,同一行的多个box可以各自设定总和为12的宽度(这一点比flexdashboard自由)。
注意到代码里的box的内容基本都有OutPut后缀(如ggiraphOutput、dataTableOutput、dygraphOutput等),这是那些包提供的shiny接口函数——换句话说如果一个包里有这种类似于以Output的shiny接口函数,它产出的内容才能放在shiny中,否则不行(这一点限制比不上flexdashboard)。
shinydashboard默认的风格很丑,好在它支持CSS,tag$head(tag$link())就可以引进CSS,不过这个CSS必须放在项目路径下的www文件夹中。CSS正常地写就好,如果遇到失效的,可能是优先级的问题,就多加点前缀。鉴于CSS是前端领域的知识,本文不多说。
加载包与读取数据都可以放在shinyServer()外面执行。关于数据,要分为两种:一种是固定不变的,这种按照常规的数据赋值方法即可;另一种是随着用户的交互而动态改变的,这种要加个reactive()函数,比如:
- traffic_choosen = reactive(traffic[traffic$direction_id == input$choose_direction, 1: 7])
- roads_map < - reactive({
- roads_map < - join(roads, traffic_choosen(), type="inner")
- roads_map
- })
这里的traffic_choosen和roads_map都是根据用户在名为direction_id的控件中的选择值筛选的子集,所以它是动态可变的,都加了一个reactive();不过要注意加了reactive()后,traffic_choosen和roads_map就不是一个变量,而是一个函数,如果要调用这个动态变量值,就必须要加括号。
后面就是给ui.R中的框架填充内容,一般都是以output$id来指定。
- output$ts_history <- renderDygraph({
- df1 <- (traffic_history %>% filter(road == input$choose_road & direction_id==1))[,c("time","index")]
- df2 <- (traffic_history %>% filter(road == input$choose_road & direction_id==2))[,c("time","index")]
- df <- full_join(df1,df2,by="time")
- df <- df[!duplicated(df$time),]
- rownames(df) <- df$time
- df$time <- NULL
- colnames(df) <- c("方向1","方向2")
- dygraph(df,main = paste(input$choose_road,"4月内历史交通指数",sep="")) %>%
- dyOptions(colors=c("orange","steelblue"),axisLineColor = "#FEFEFE",drawGrid = F) %>%
- dyLegend(show="onmouseover",labelsSeparateLines = T) %>%
- dyRangeSelector() %>%
- dyUnzoom()
- })
比如这段代码就指定了一个id为ts_history的用dygraph包画的时间序列交互图,而在ui.R中要与其呼应:
- box(dygraphOutput("ts_history"), width=12, solidHeader=T, collapsible=T)
比如这段代码就指定了一个id为ts_history的用dygraph包画的时间序列交互图,而在ui.R中要与其呼应:
- box(dygraphOutput(“ts_history”), width=12, solidHeader=T, collapsible=T)
注意到画图语句外层都有一个render为前缀的函数(renderDygraph、renderggiraph、renderDataTable),同样这也是shiny接口的标志,必须以这个函数转换之后才能在shiny中传输。
server.R中也可以捕获用户的在图形中的交互行为,联想到ggiraph交互图形中总有一个参数是data_id,之前以为填什么不重要,但是在这里起作用了——用户在某处点击一下,便捕获到该处的data_id值。
- selected_road <- reactive({
- if( is.null(input$map_selected)){
- NA
- } else input$map_selected
- })
这段代码的含义就是:如果用户没有选中某条道路,select_road就是NA;反之就是用户选中的那条道路。
通过响应捕捉,可以进一步强化交互性。
主要图形解析
下面开始对前面的几张图的设计思路与处理方法做个简单的介绍。
实时道路交通可视化
这个图最为复杂,它首先以深圳地图为底图,然后再叠加路径图。这个路径图是100+条道路的经纬度坐标画成的。而这些道路的经纬度坐标获取难度就比较大:首先在百度地图上查询道路起点和终点的坐标,然后调用百度地图API,输入起点和终点的坐标,查询驾车路线,会返回一串散点的坐标,在ggplot2中把这些散点连接成path就可以把道路可视化出来;不过这些点的坐标有可能不准,所以还需要人工校对。这些路径的颜色映射的是rvest采集的该道路实时的交通指数——由于交委网站上在任一时刻并不总是会有全量的数据,因此本图中只展示有数据的道路的交通状况。从这张图中可以一眼看出整个深圳市总体的道路状况,一眼可看出哪条道路最堵。
实时道路排名
这个比较简单,根据用户选择的排名种类和排名数据对实时数据进行筛选、排序,以条形图的方式展现即可。
历史路况时间序列图
这个时间序列图把一条道路一个月内每5分钟的交通指数都可视化出来了。这个图形很神奇,横轴纵轴都可以缩放选择,下方有缩略图。
每日每小时道况热力图
这个热力图放在大屏上就像一座墙,倒还是挺壮观的。这是一个30*24的热力图,每一块映射每小时内的平均交通指数,用以呈现周期性规律。
每小时内道况南丁格尔玫瑰图
这里分为工作日和节假日来呈现某条道路24小时内的总体交通状况,其中每一片花瓣映射着各种交通状况的比例。
每小时内总体路况圆盘图
这张图以每天为单位,汇总全部条道路的交通指数,用以研究每日全市总体的交通周期性规律。
各道路每日拥堵时长排名
原本以5min为间隔采集的数据都是离散的,无法计算连续时长。这里借鉴了微积分的思想,以每一个5min内的最终状态作为5min内的持续状态,于是多个5min累加来代表一天中的拥堵时长。
实时路况数据下载
这个就是展示一下实时数据并提供下载,如果你不会爬虫,可以在这上面来下载数据。
Shiny中的坑
无论是ui.R还是server.R,编码都强制是UTF-8,因此在输入中文之前***先将文件存成UTF-8,以免编码不对只能重新读入。同样,读入的数据如果包含中文,***读进来也要使其编码为UTF-8,不然显示会有乱码。
弹出的R本地服务器不够清晰,甚至在有的机器上图形一直无法加载——所以检查的时候***还是在浏览器中检查。
关于下载的控件,不要在弹出的R窗口中执行,总是报错;在浏览器中试一下就没问题。
按照shinyapps.io的流程,部署过程可以不出错,甚至它还在计算时间流量;不过基于shinydashboard做的东西,打开网页一看就会报错“没有名为shinydashboard的程楫包”,所以也许用纯粹的shiny来做的话可能能发布成功,但由于框架就是基于shinydashboard的,不能再推翻重来——所以就没办法部署。
所以shiny终究只能产出一个原型,它离真正企业级的数据可视化系统差距还是不小。因此,如果是专门做数据可视化的,前端是必须要前进的方向。