背景
大数据时代商业智能(BI)和数据可视化诉求更为强烈,淘宝大屏更是风靡全球!数据可视化是大数据『最后一公里』。
数据可视化分析工具大致分为3类,开源、商业和传统bi工具。 业界目前比较流行的
开源BI工具:Superset、metabase、Redash、Cboard、d3js、Spagobi等,
商业BI工具:帆软、tableau、PowerBI、SmartBI、QlinkView、QuickBI、fineBI、阿里dataV等
传统BI工具:Congos、BIEE、BO、MicroStrategydeng等。
superset简介
Superset是一款轻量级的BI工具,由Airbnb的数据部门开源。截止目前,github累计31.3K个star。
开发部署
官方文档:
环境
superset: 0.36.0
windows10
拉取镜像需配置代理:
docker部署安装步骤
docker search superset docker pull amancevice/superset docker run -d -p 8088:8088 --name superset amancevice/superset:latest --生成容器ID 60946ede506b docker exec -it -u root 60946ede506b fabmanager create-admin --app superset docker exec -it 60946ede506b superset db upgrade docker exec -it 60946ede506b superset load_examples docker exec -it 60946ede506b superset init docker exec -it 60946ede506b superset runserver
mac下部署步骤
pip install -r requirements-dev.txt -i 国内源url
python -m flask run 默认端口起的是 5000
npm install
npm run dev-server
默认端口是 9000
本地启动部署 (mac\win)
mac 启动无需修改,windows启动需要修改两处。
Copy {
test: /(\.jsx|\.js)$/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-react', '@babel/preset-env'],
plugins: ['@babel/plugin-transform-runtime'],
},
},
exclude: /node_modules/,
},
2. 在package.json中"dev-server"命令前加入 cross-env
通过该参数来设置环境
二次开发已实现的功能
v1.0
增加和具体用户相关联的权限访问,不同用户看到的数据不同
添加数值单位,在原有支持K的基础上增加中文 千,万,亿等
新增两张自定义图表,top Bar (排行榜条形图)和 Mix line Bar (混合柱状条形图)
v2.0
图表联动,在对一张图表配置了联动后,类似下钻的点击会影响到其他图表
v3.0
给FilterBox新增级联属性,支持类似‘省份-区域-城市’这样的层级筛选
添加统一登录,接入ldap账号体系内部实现无差别登陆,默认为普通用户权限
v4.0
添加集成多个dashboard的门户层,根据层级菜单将多个dashboard加进来
v5.0
在top bar中支持指定分组值用某个具体颜色,例如支付方式‘支付宝’蓝色,'微信'绿色
v6.0
v7.0
增加bar chart,pie chart的antV图表
源码改动处
config.py
Copy 数据源配置 SQLALCHEMY_DATABASE_URI = 'mysql+pymysql://root:123456@192.168.16.47:3306/superset'
sql打印,SQLALCHEMY_ECHO = True
关闭CSRF验证 WTF_CSRF_ENABLED = False
调整查询LIMIT QUERY_SEARCH_LIMIT = 1000
superset/views/core.py
Copy 新增接口/iframe/<token>/<dashboard_id>
新增页面 /templates/superset/dashboard_hf.html
新增自定义图表
新增数据接口(以TopBar为例)
Copy 新增Class TopBar继承BaseViz
class TopBar(BaseViz):
"""DIY insert chart"""
viz_type = "top_bar"
verbose_name = _("top bar View")
credits = 'a <a href="https://github.com/airbnb/superset">Superset</a> original'
is_timeseries = False
enforce_numerical_metrics = False
def query_obj(self):
d = super().query_obj()
fd = self.form_data # form_data中包含界面左侧组件内容1
if not fd.get('all_columns'): #这个字段对应×××组件,不为空
raise Exception('Choose Columns')
if fd.get('all_columns'):
d['columns'] = fd.get('all_columns') # all_columns是左侧组件名,后面会提到
## 修改top bar 返回数据条数,以免数据过多前段卡死
if fd.get('chooseN'):
d["row_limit"] = fd.get("chooseN") * 100
else :
d["row_limit"]=100
return d
def get_data(self, df: pd.DataFrame) -> VizData:
# df是pandas的DataFrame类型
data = {'plot_data':np.array(df).tolist(),'legend':'数据的指标名称'}
return data
def json_dumps(self, obj, sort_keys=False):
return json.dumps(
obj, default=utils.json_iso_dttm_ser, sort_keys=sort_keys, ignore_nan=True
)
TopBar图表组件
文件目录
Copy - /visualizations/TopBar/images 可选图表的缩略图
- .../ReactTopBar.js
- .../TopBar.js
- .../TopBarChartPlugin.js
- .../transformProps.js
依赖关系 TopBar -> ReactTopBar -> TopBarChartPlugin -> transformProps 以下依次为各个文件的源码
ReactTopBar.js
Copy import { reactify } from '@superset-ui/chart'; import Component from './TopBar'; export default reactify(Component); ``` --- TopBar.js ``` import echarts from 'echarts'; import d3 from 'd3'; import PropTypes from 'prop-types'; import { Chart } from '@antv/g2'; import { getColor, groupbysum } from '../MixLineBar/MixLineBarUtil'; const propTypes = { width: PropTypes.number, height: PropTypes.number, }; // 检查类型,其中data包含viz.py中返回的数据,width和height为图表宽高 function TopBar(element, props) { const { chartData, chooseN, colorScheme, labelColors, yAxisFormat, yAxisHide, barStacked, xseries, isBreakDown, orderBars, showLegend, metricLabel, width, height, } = props; let yData = chartData.plot_data; const divid = 'top_bar_' + new Date().getTime(); if (yData.length == 0) { // 没有数据的处理 const div = d3.select(element, props); var html = "No Data"; html = '暂无数据'; div.html(html); return; } const index = yData[0].length - 1; function ascend(x, y) { return y[yData[0].length - 1] - x[yData[0].length - 1]; } const antData = []; //排序以topN为主 if (orderBars) { yData.sort(function([x, y], [x1, y1]) { return x.localeCompare(x1); }); } if (chooseN != undefined) { //按照xserise分组求和取top yData = groupbysum(yData, xseries, chooseN); } // 记录最大刻度值 const maxV = {}; let maxValue = 0; for (let i = 0; i maxValue) { maxValue = yData[i][n]; } } else { // 第 xseries 列为label ant_item.label = yData[i][xseries]; if (isBreakDown) { // 其他列为分组 ant_item.type = yData[i][1 - xseries]; } } } antData.push(ant_item); } if (barStacked) { for (const k in maxV) { if (maxV[k] > maxValue) { maxValue = maxV[k]; } } } const div = d3.select(element, props); var html = ""; div.html(html); // 给echarts添加div // AntV实现 const chart = new Chart({ container: divid, autoFit: true, height: 500, }); chart.data(antData); chart .coordinate() .transpose() .scale(1, -1); chart.scale(metricLabel, { min: 0, max: maxValue * 1.1, }); chart.axis(metricLabel, { position: 'right', }); chart.axis('label', { label: { offset: 12, }, }); chart.tooltip({ shared: true, showMarkers: false, }); let adjust_type = 'dodge'; if (barStacked) { adjust_type = 'stack'; } if (!showLegend) { chart.legend(false); } // 创建图形,由type 和 value两个属性决定图形x-y chart .interval() .position('label*' + metricLabel) .label(!yAxisHide ? metricLabel : '', { style: { fill: '#8d8d8d', fontSize: '14', }, offset: 10, }) .color({ fields: [isBreakDown ? 'type' : metricLabel], values: isBreakDown ? getColor(colorScheme) : getColor(colorScheme)[0], callback:(val)=>{ return labelColors[val] }, }) .adjust([ { type: adjust_type, marginRatio: 0, }, ]); if (yAxisFormat != null && yAxisFormat != 'SMART_NUMBER') { let yAxisFormat_ = yAxisFormat; if (yAxisFormat_ == 'zh') { yAxisFormat_ = '.0万亿'; } else if (yAxisFormat_ == '.2zh') { yAxisFormat_ = '.2万亿'; } let flag0 = yAxisFormat_.indexOf('+') >= 0; const flag1 = yAxisFormat_.indexOf(',') >= 0; const flag2 = yAxisFormat_.indexOf('.') >= 0; const flag3 = yAxisFormat_.indexOf('%') >= 0; const flag3_1 = yAxisFormat_.indexOf('万亿') >= 0; let suffix = ''; // 数据格式 chart.scale(metricLabel, { formatter(num) { let result = []; let counter = 0; if (num 8) { num /= 100000000; suffix = '亿'; } else if (num.toString().split('.')[0].length > 4) { num /= 10000; suffix = '万'; } } if (flag3) { // 开启百分比 num *= 100; } num = (num || 0).toString().split(''); result = num; let flag4 = num.indexOf('.'); if (flag2) { // 保留小数点后几位 const ii = yAxisFormat_[yAxisFormat_.indexOf('.') + 1]; const a = parseFloat(num.join('')); const b = ( Math.round(a * Math.pow(10, ii)) / Math.pow(10, ii) ).toFixed(ii); num = b.toString().split(''); result = num; } flag4 = num.indexOf('.'); if (flag1) { result = []; for (var i = flag4 - 1; i >= 0; i--) { counter++; result.unshift(num[i]); // 开启千分位 if (!(counter % 3) && i != 0) { result.unshift(','); } } for (var i = flag4; i TopBarChartPlugin.js ``` import { t } from '@superset-ui/translation'; import { ChartMetadata, ChartPlugin } from '@superset-ui/chart'; import transformProps from './transformProps'; import thumbnail from './images/thumbnail.png'; const metadata = new ChartMetadata({ name: t('Top Bar'), description: 'diy', credits: ['http://echarts.baidu.com/examples/editor.html?c=scatter-effect'], thumbnail, }); export default class TopBarChartPlugin extends ChartPlugin { constructor() { super({ metadata, transformProps, loadChart: () => import('./ReactTopBar.js'), }); } } ``` --- transformProps.js ``` export default function transformProps(chartProps) { const {width, height, queryData, formData} = chartProps; console.log(chartProps); var xseries_name = formData["groupby"]; var breakdown_name = formData["columns"]; var index1 = queryData.query.indexOf(xseries_name); var index2 = queryData.query.indexOf(breakdown_name); var xseries = 0; if (breakdown_name.length > 0) { xseries = 1; if (index1 0; const metricLabel = formData.metrics[0].label; const chartData = queryData.data; const { chooseN, colorScheme, zoomData, yAxisFormat, yAxisHide, barStacked, showLegend, orderBars, labelColors, } = formData; return { chartData, chooseN, colorScheme, labelColors, zoomData, yAxisFormat, yAxisHide, barStacked, xseries, isBreakDown, orderBars, showLegend, metricLabel, width, height, }; }
TopBar的演示图如下
中国地图
Copy CountryMap.js 'ISO'替换成 'NL_NAME_1' ,
Line:110 'NAME_2'替换为NL_NAME_1 ,'NAME_1'替换为NL_NAME_1
将china.geojson文件中的'黑龙江省'和繁体字换一下位置。
在data.forEach(..)里修改colorMap[d.country_id]=..改为colorMap[d.country_id.replace('省')]=..
在const colorFn = d => {..}
修改为 colorFn = d=>{
var namearr = d.properties.NL_NAME_1.split('|')
colorMap[namearr]||'none'
}
地图演示
类似地,添加Mix Line Bar、Funnel
Mix Line Bar演示
借助echarts已有的api,在control panel中新增了两个功能
Funnel演示
## 图表的下钻、上卷 以下钻饼图为例,修改superset-ui 中的NVD3Vis.js 添加两个点击事件,
Copy chart.pie.dispatch.on('elementClick',function(ele){clicked(ele,uid)}) ``` --- xiazuan.js ``` import {xiazuan} from "./reducer/xiazuanReducer"; import {shangjuan} from "./reducer/shangjuanReducer"; var ischart = true; var chart_id = {}; export const clicked = function clicked(d,uid) { // var elearr = d.element.parentElement.parentElement.parentElement.parentElement.parentElement.parentElement.parentElement.parentElement.parentElement.getAttribute("id").split('-') // console.log(elearr) if(ischart){ import('../explore/index').then(e=>{ e.dashStore.dispatch(xiazuan(chart_id[uid],true,-1,d.data)) }) }else{ import('../dashboard/index').then(e=>{ e.dashStore.dispatch(xiazuan(chart_id[uid],true,-1,d.data)) }) } }; export const clicked2 = function clicked2(uid) { if(ischart){ import('../explore/index').then(e=>{ e.dashStore.dispatch(shangjuan(chart_id[uid],true,-1)) }) }else{ import('../dashboard/index').then(e=>{ e.dashStore.dispatch(shangjuan(chart_id[uid],true,-1)) }) } } export function import_store (element,vizType,uid) { var viz_type=["pie","dist_bar"]; var intype= viz_type.indexOf(vizType); if( intype'+chart_id) return chart_id[uid] } ``` --- /action/xiazuanAction.js ``` import {postChartFormData} from '../../chart/chartAction'; export const initLink = {}; export function xiazuanAction(chartKey, force, dashboardId, dData) { return (dispatch, getState) => { const charts = getState().charts; var chart = (getState().charts || {})[chartKey]; var fd = chart.latestQueryFormData; if (!fd || Object.keys(fd).length === 0) { return; } console.log('xiazuan') if ((fd.xiazuan != null && fd.xiazuan.length > 0) || fd.liandong == true) { // 序列的下钻链路 var curr = fd.groupby[0]; var nextnode = getNext(curr, fd.xiazuan); console.log('nextnode=>' + nextnode); if (nextnode != null) { // 进行单个图表的下钻 console.log('chartkey>>' + chartKey); if (typeof initLink[chartKey] === 'undefined') { initLink[chartKey] = []; } initLink[chartKey].push(dData.x); fd.groupby.pop(); fd.groupby.push(nextnode); fd.adhoc_filters.push(buildFilter(curr, dData.x)); dispatch(postChartFormData(fd, force, 10000, chart.id, dashboardId)); } if (fd.liandong) { // 如果有联动,触发其他同数据源图表的变化 const dataSource = fd.datasource; const id = chart.id; for (const chartOne in charts) { if ( charts[chartOne].formData.datasource === dataSource && charts[chartOne].id !== id ) { chart = charts[chartOne]; fd = chart.latestQueryFormData; if (!fd || Object.keys(fd).length === 0) { return; } console.log('触发序列联动') if (fd.adhoc_filters.length > 0 && fd.adhoc_filters[fd.adhoc_filters.length - 1].subject == curr) { //如果 点击的维度还是 最后联动追加的维度,则只改变值 fd.adhoc_filters[fd.adhoc_filters.length - 1].comparator = dData.x; } else { fd.adhoc_filters.push(buildFilter(curr, dData.x)); } dispatch(postChartFormData(fd, force, 10000, chart.id, dashboardId)); } } } } else if ((fd.xiazuan2 != null && fd.xiazuan2.length > 0) || fd.liandong2 == true) { // 带有分解的下钻链路 var curr2 = fd.columns[0]; var nextnode2 = getNext(curr2, fd.xiazuan2); console.log('nextnode2=>' + nextnode2) if (nextnode2 != null) { console.log('chartkey>>' + chartKey); if (typeof initLink[chartKey] === 'undefined') { initLink[chartKey] = []; } initLink[chartKey].push(dData.key); fd.columns.pop(); fd.columns.push(nextnode2); fd.adhoc_filters.push(buildFilter(curr2, dData.key)); dispatch(postChartFormData(fd, force, 10000, chart.id, dashboardId)); } else if (!fd.liandong2) { return; } if (fd.liandong2) { const dataSource = fd.datasource; const id = chart.id; for (const chartOne in charts) { if ( charts[chartOne].formData.datasource === dataSource && charts[chartOne].id !== id ) { chart = charts[chartOne]; fd = chart.latestQueryFormData; if (!fd || Object.keys(fd).length === 0) { return; } console.log('触发分解联动') if (fd.adhoc_filters.length > 0 && fd.adhoc_filters[fd.adhoc_filters.length - 1].subject == curr2) { //如果 点击的维度还是 最后联动追加的维度,则只改变值 fd.adhoc_filters[fd.adhoc_filters.length - 1].comparator = dData.key; } else { fd.adhoc_filters.push(buildFilter(curr2, dData.key)); } dispatch(postChartFormData(fd, force, 10000, chart.id, dashboardId)); } } } } }; } /** * 下钻衍生的过滤条件 * @param name * @param val */ function buildFilter(name, val) { const adhoc_filter = {}; adhoc_filter.clause = 'WHERE'; adhoc_filter.comparator = val; adhoc_filter.expressionType = 'SIMPLE'; adhoc_filter.fromFormData = true; adhoc_filter.isExtra = false; adhoc_filter.operator = '=='; adhoc_filter.subject = name; return adhoc_filter; } /** * 获取链路的指定节点的下一节点 * @param curr 当前节点 * @param link 链路 */ function getNext(curr, link) { const index = link.indexOf(curr); if (index == -1 || index == link.length - 1) { return null; } return link[index + 1]; } ``` --- /reducer/xiazuanReducer.js ``` import * as actions from '../action/xiazuanAction'; export function xiazuan(chartList = 1, force = false, dashboardId,dData) { return (dispatch, getState) => { // eslint-disable-next-line no-undef dispatch(actions.xiazuanAction(chartList, force, dashboardId,dData)); }; }
饼图演示
左下角显示已经下钻的层级,支持多层级下钻,点击左下角显示的层级为上卷到上一层。
图表使用实践
导入数据源、表
添加数据源,支持多数据源,导入数据源后从已有的表中添加一张,可以设置cache,支持SQL修改
创建charts
选择一个数据源,选择一个图表类型
SQL lab编写SQL
选择数据库、模式,直接写SQL模式发起查询
添加图表到dashboard
将制作好的图表添加到已有或新建的dashboard,完成看板制作
丰富dashboard
添加已有图表到dashboard中,自定义调整位置和大小; 支持图表联动配置; 支持过滤器指定图表变化; 支持markdown,可以添加视频; 支持修改css样式,夜间模式;
源码分析
这里以WordCloud为例。主要涉及到的js有如下 /src/explore/controlPanels/WordCloud 这是控制层 ,里面定义了charts左侧的组件。
superset已有的组件整理如下:
组件的代码 罗列几个属性
controlPanelSections 为左侧组件列表,label的值为组件名称,实现代码在 /src/explore/controls.jsx
(在ui-plugin里,则是对应NVD3Controls.tsx 或其他图表包下的Controls)
controlOverrides 是你需要覆盖已有组件里的属性值
sectionOverrides 看Dual Line里的这块内容
当然,如果现有组件不能满足需求,可以自定义组件,自定义组件的代码放到和其他组件一样的位置 /src/explore/controls.jsx
注册左侧组件层 setupPlugins.ts registerValue('top_bar',TopBar) 注册图表type,有顺序 VizTypeControl.jsx DEFAULT_ORDER=[top_bar,....]
/visualizations 下新建一个文件夹取名自己的图表名称 TopBar
TopBar.jsx react组件,主要实现图表的样式,检测数据类型(prop-types)
TopBarChartPlugin.js 继承图表插件父类ChartPlugin,构造方法里有 metaData(super-ui库的ChartMetaData对象【 界面上的图表名称显示为"Top Bar",描述等】),loadChart为加载React组件TopBar
ps:如果是在ui-plugin里,则是对应TopBar/index.js
加载顺序 : TopBarChartPlugin -> ReactTopBar -> transformProps -> TOpBar
技术栈学习了解
摘出几句敲黑板的话,加深一下理解: 1、HTML 语言直接写在 JavaScript 语言之中,不加任何引号,这就是 JSX 的语法,它允许 HTML 与 JavaScript 的混写 2、React 允许将代码封装成组件(component),然后像插入普通 HTML 标签一样,在网页中插入这个组件。 3、组件并不是真实的 DOM 节点,而是存在于内存之中的一种数据结构,叫做虚拟 DOM (virtual DOM)只有当它插入文档以后,才会变成真实的 DOM 。根据 React 的设计,所有的 DOM 变动,都先在虚拟 DOM 上发生,然后再将实际发生变动的部分,反映在真实 DOM上,这种算法叫做 DOM diff ,它可以极大提高网页的性能表现。 4、
对于代码的修改最好先在线平台操作学习,改完即可知道哪里变了
对图表下钻开发, 需要对redux有所了解,需要知道redux的一些概念:
State 数据,就是一个对象。redux中的state是不能直接修改的,只能通过action来修改,相当于我们在单例中定义setter方法。
Action redux 将每一个更改动作描述为一个action,要更改state中的内容,你需要发送action。一个action是一个简单的对象,用来描述state发生了什么变更。
Copy const INCREMENT = 'INCREMENT'
const incrementAction = {"type": INCREMENT, "count": 2}
上面这就是一个action,说白了就是一个对象,根据里面的type判断做什么操作。
Reducer 数据state,指示action都有了那么就是实现了。reducer就是根据action来对state进行操作。
```
const calculate = (state: ReduxState = initData, action: Action ) => {
switch (action.type) {
Copy case INCREMENT:
return {num: state.num + action.count}
case REDUCE:
return {num: state.num - action.count}
default:
return state
}
}
export {calculate}
Copy 通过reducer操作后返回一个新的state,比如这里根据action的type分别对state.num进行加减。
- Store store就是整个项目保存数据的地方,并且只能有一个,可以理解为一个数据库。创建store就是把所有reducer给它。
import { createStore, combineReducers } from "redux"; import { calculate } from "./calculate";
// 全局你可以创建多个reducer 在这里统一在一起 const rootReducers = combineReducers({calculate}) // 全局就管理一个store export const store = createStore(rootReducers)
Copy - dispatch store.dispatch()是组件发出action的唯一方法。
store.dispatch(incrementAction);
Copy 通过store调用incrementAction,那么就直接把store里的数据修改了。
通常是同步的,通过redux中间件 redux-thunk可以实现异步
,如果用了中间件,所有action都会有个dispatch参数
调用action的方式
store.dispatch(async function(dispatch){ dispatch({ type:'', payload: })//派发一个action })
npm安装echarts-for-react npm install --save echarts-for-react npm install echarts --save
Copy import echarts from 'echarts/lib/echarts' //必须
import 'echarts/lib/component/tooltip'
import 'echarts/lib/component/grid'
import 'echarts/lib/chart/bar'
Copy import { pieOption, barOption, lineOption, scatterOption, mapOption, radarOption, candlestickOption } from './optionConfig/options'
const PieReact = asyncComponent(() => import(/* webpackChunkName: "PieReact" */'./EchartsDemo/PieReact')) //饼图组件
const BarReact = asyncComponent(() => import(/* webpackChunkName: "BarReact" */'./EchartsDemo/BarReact')) //柱状图组件
const LineReact = asyncComponent(() => import(/* webpackChunkName: "LineReact" */'./EchartsDemo/LineReact')) //折线图组件
const ScatterReact = asyncComponent(() => import(/* webpackChunkName: "ScatterReact" */'./EchartsDemo/ScatterReact')) //散点图组件
const MapReact = asyncComponent(() => import(/* webpackChunkName: "MapReact" */'./EchartsDemo/MapReact')) //地图组件
const RadarReact = asyncComponent(() => import(/* webpackChunkName: "RadarReact" */'./EchartsDemo/RadarReact')) //雷达图组件
const CandlestickReact = asyncComponent(() => import(/* webpackChunkName: "CandlestickReact" */'./EchartsDemo/CandlestickReact')) //k线图组件
echarts-for-react 是一个非常简单的针对于ReaCt的Echarts封装插件
用法:
import ReactEcharts from 'echarts-for-react'
celery 并行分布式框架
定时发送报表邮件功能用到,celery是python开发的分布式任务调度模块。
celery本身不包含消息服务,而是利用第三方broker,可以用redis或mq,官方推荐mq。
我们在superset中用的是redis,只需要简单的配置,然后启动celery即可。
一些常用的命令如下:
celery -A task worker --loglevel=DEBUG -P eventlet
eventlet是在windows系统需要加上的
因为启动都是命令行查看,这里推荐一个监控工具flower, 安装非常简单pip install flower, 启动命令 在启动celery后执行 celery flower --address=0.0.0.0 --port=5555 --broker=redis://localhost:6379/1