很早以前写过一篇 用RequireJS包装AjaxChart ,当时用Highcharts做图表,在其上封装了一层ajax,最后只是简单套用了一下requireJS。由于当时自己才接触模块化,理解层面还太浅,后来经过其他项目的磨练以及实习获得的见识,想重新结合一个示例来写点前端模块化的开发方式。
最近在做一个安全运维监控的项目,其中有一条是根据设备获取到的攻击数据,在地图上做可视化。对比了 Highcharts 和 ECharts
ECharts对国内地图的支持更多
ECharts在模块化和扩展方面做的比Highcharts更好
所以最后我选择了基于ECharts去封装。类似的网络攻击的监控地图可看国外的 Norse Attack Map ,也算是同类的参照。
数据要求
提供的数据只有IP到IP的攻击,包括攻击时间、攻击类型等,需要自行根据IP定位到相应的经纬度。
展现要求
地图提供世界、中国、省份,这三种维度(只针对中国)
要在地图上表现出攻击的来源与目标之间的动画
需要强调出攻击受灾地区,可一眼看出哪里是重灾区
可以循环表现攻击,也可实时刷新攻击数据
- index.html 主页面 - assets - css - normalize.css 浏览器初始化样式 - common.css 从bootstrap里扒了一些基础样式 - img/ - js - app - mainMap.js index页面的主执行js - lib - echarts/ 用了源码包 - zrender/ 同样源码包,具体看echarts官方说明 - geo 一些地理数据的定义 - china/ - world/ - mods - attackMap/ 对echarts map的封装 - util.js 等等其他帮助或插件模块的封装 - xxxx.js - config.js
requirejs.config({ baseUrl: 'js/lib', paths: { jquery: 'http://cdn.staticfile.org/jquery/1.7.2/jquery.min', underscore: 'http://cdn.staticfile.org/underscore.js/1.7.0/underscore-min' }, packages: [ { name: 'echarts', location: 'echarts/src', main: 'echarts' }, { name: 'zrender', location: 'zrender/src', main: 'zrender' } ] });
define(function(require){ var U = require('underscore'); var EC = require('echarts/echarts'); var ecMap = require('echarts/chart/map'); var ecMapParams = require('echarts/util/mapData/params').params; var EVENT = require('echarts/config').EVENT; var MAP_TYPE_WORLD = 'world'; var MAP_TYPE_CHINA = 'china'; var AttackMap = function(config){ this.config = U.extend({ view: MAP_TYPE_WORLD }, config); this.el = document.getElementById(this.config.id); // 初始化echarts this._init(); }; // 不带下划线的为对外暴露的方法 AttackMap.prototype = { _init: function(){ // _chart对象私有 this._chart = EC.init(this.el); // default view var mapOption = U.extend({}, require('mods/attackMap/mapOption')); // 合并option U.extend(mapOption.series[0], this._getViewOption(this.config.view)); // render this._chart.setOption(mapOption); // 交互 this._bindEvents(); }, _bindEvents: function(){ var that = this; this._chart.on(EVENT.CLICK, function(e, chart){ // 仅对中国钻取 if(e.data.name === '中国' || e.data.name === 'China'){ that.setView(MAP_TYPE_CHINA); } // and中国省份钻取 else if(e.data.name in ecMapParams){ that.setView(e.data.name); } }); }, // view涉及到的series里需要设置的属性 _getViewOption: function(viewType){ if(viewType === MAP_TYPE_WORLD){ return { mapType: MAP_TYPE_WORLD, nameMap: require('geo/world/countryName') } } else if(viewType === MAP_TYPE_CHINA){ return { mapType: MAP_TYPE_CHINA }; } else if(viewType in ecMapParams){ return { mapType: viewType }; } return {}; }, _setOtherOption: function(viewType){ if(viewType === MAP_TYPE_WORLD){ this._chart.chart.map.series[0].itemStyle.normal.label.show = false; this._chart.chart.map.series[0].markLine.effect.period = 15; } else if(viewType === MAP_TYPE_CHINA){ this._chart.chart.map.series[0].itemStyle.normal.label.show = false; this._chart.chart.map.series[0].markLine.effect.period = 8; } else{ this._chart.chart.map.series[0].itemStyle.normal.label.show = true; this._chart.chart.map.series[0].markLine.effect.period = 4; } }, // 设置地图视图 setView: function(viewType){ // 上一次的view (typeof this._lastView === 'undefined') && (this._lastView = this.config.view); // 防止重复set if(viewType === this._lastView){ return false; } this._lastView = viewType; // 历史开过的view(string逗号分隔) (typeof this._historyViews === 'undefined') && (this._historyViews = this.config.view); // 用来判断是否加载过 if(this._historyViews.indexOf(viewType) === -1){ this._historyViews += (',' + viewType); // loading this._chart.showLoading(); // 假loading var that = this; setTimeout(function(){ that._chart.hideLoading(); }, 350); } // 要先reset再draw this.reset(); var viewOption = this._getViewOption(viewType); this._chart.setSeries([viewOption]); // 多级的option没法merge原来的,所以得手动设置 this._setOtherOption(viewType); }, // 攻击线 setAttacks: function(data, isLoop){ // 是否循环显示markline(暂未用到) isLoop = isLoop || true; // 留个data备份(暂未用到) this._mData = data; // TODO: 要对IP聚合 // 国内最小定位到市级,国外只能定位到国家 // 而markline只能通过 name-name 来标识 // 聚合后相同 name-name 的攻击累计次数视为强度 var lineData = U.map(data, function(v){ return [ {name: v['srcName'], geoCoord: [v['srcLocX'], v['srcLocY']]}, {name: v['destName'], geoCoord: [v['destLocX'], v['destLocY']]} ] }); var pointData = U.map(data, function(v){ return { name: v['destName'], geoCoord: [v['destLocX'], v['destLocY']] } }); // ECharts内部的核心变量 var _map = this._chart.chart.map; // 防止addMarkLine抛异常 seriesIndex 0 // _map.buildMark(0); try{ this._chart.addMarkLine(0, {data: lineData}); }catch(e){ // console.error(e); } try{ this._chart.addMarkPoint(0, {data: pointData}); }catch(e){ // console.error(e); } }, // 通用方法 refresh: function(){ this._chart.refresh(); }, reset: function(){ this._chart.restore(); } }; return AttackMap; });
这里我用echarts中的MarkLine作为攻击线,MarkPoint作为受害地点,AttackMap封装了对echarts的操作过程,对外只暴露 setView
和 setAttacks
两个方法,以实现地图维度的缩放以及攻击线的表现。其中echarts map的通用配置项都拎到了 mods/attactMap/mapOption.js
中,这里AttackMap只手工操作部分option,比如根据地图的维度修改MarkLine动画的速率。
require([ 'jquery', 'mods/attackMap/main', 'mods/attackMap/mock' ], function($, AttackMap, Mock){ var View = { // 作为一个视图模版来初始化 init: function(){ // 此View片段的root元素 // this.$el = $('body'); // 初始化成员 this.aMap = new AttackMap({ id: 'mapChart', view: 'world' }); // 绑定事件 this._bindEvents(); }, _bindEvents: function(){ var that = this; // 视图切换 this._bindMapViewEvents(); // 其他binding $(window).on('resize', function(){ that.aMap.resize(); }); }, // 视图切换事件 _bindMapViewEvents: function(){ var that = this; // NOTE: 会有动态生成的元素 $('.J_changeView').live('click', function(){ that.aMap.setView($(this).attr('data-type')); }); }, // 攻击数据展现 _renderAttacks: function(data){ // render map this.aMap.setAttacks(data); // render table var $tbody = $('#attacksTable').find('tbody'); // var $frags = []; $.each(data, function(i, v){ var $tr = $('<tr><td>'+v['srcIp']+'</td><td>'+v['srcName']+'</td><td>'+v['destIp']+'</td><td>'+v['destName']+'</td><td>'+v['type']+'</td><td>'+v['time']+'</td></tr>'); $tbody.append($tr); }); }, // 获取攻击数据 getAttacks: function(){ var that = this; // ajax TODO // 本地mock数据 that.attacksData = Mock.data; that._renderAttacks(that.attacksData); } }; // execution View.init(); // lazy load setTimeout(function(){ View.getAttacks(); }, 16); });
至此,在应用层页面上,可以通过点击 .J_changeView
按钮来切换地图的维度(世界/中国/省份),攻击数据的展现暂时没有ajax调用,只是简单用了mock数据来做,大体效果是一样的。
最终demo
在上面的demo链接中看到,不仅应用层页面的按钮可以切换地图维度,直接点击地图里的"中国"区域也能切换地图,同时又能通知到应用层页面的按钮改变状态。因此应用层页面是需要关心AttackMap的状态(事件)的,同样将鼠标放在攻击线上出现的攻击详情,也是通过监听AttackMap的事件实现的。
// 对外事件 AttackMap.EVENTS = { VIEW_CHANGED: 'viewChanged', LINE_HOVERED: 'marklineHovered', LINE_BLURED: 'marklineBlured' };
AttackMap.prototype = { on: function(type, fn){ (typeof this._handlers === 'undefined') && (this._handlers = {}); (typeof this._handlers[type] === 'undefined') && (this._handlers[type] = []); this._handlers[type].push(fn); }, fire: function(type, data, event){ if(typeof this._handlers === 'undefined' || typeof this._handlers[type] === 'undefined'){ return false; } var that = this; var eventObj = { type: type, data: data }; // 原生event对象 (typeof event !== 'undefined') && (eventObj.event = event); U.each(this._handlers[type], function(fn){ fn(eventObj, that); }); } };
fire
自定义事件 AttackMap.prototype = { _bindEvents: function(){ var that = this; // 省略... this._chart.on(EVENT.HOVER, function(e, chart){ // 是markline if(e.name.indexOf('>') !== -1){ // 阻止此时的tooltip that._chart.chart.map.component.tooltip.hideTip(); // 由外部去渲染 that.fire( AttackMap.EVENTS.LINE_HOVERED, { name: e.name }, e.event ); } // 不是markline,告诉外部 else{ // 效率有点低 每次hover都会触发 that.fire(AttackMap.EVENTS.LINE_BLURED); } }); }, setView: function(viewType){ // 省略... // 对外fire事件 this.fire( AttackMap.EVENTS.VIEW_CHANGED, { viewType: viewType } ); } };
当触发 AttackMap.EVENTS.LINE_HOVERED
事件时,由于应用层页面要绘制攻击详情的浮层,需要知道鼠标位置信息,所以这里 fire
时将原生的event对象也传了进去。(注意 fire
方法的实现中,传给回调函数的 eventObj
对象中,有事件类型type,自定义data,以及原生event对象)
// 别名 var MAP_EVENTS = AttackMap.EVENTS; var View = { // 视图切换事件 _bindMapViewEvents: function(){ var that = this; // AttackMap监听 this.aMap.on(MAP_EVENTS.VIEW_CHANGED, function(e){ var type = e.data.viewType; // 清空当前 $current = $('.view-nav.active'); $current.removeClass('active'); // 目标 var $target = $('.view-nav[data-type="' + type + '"]'); if($target.length == 0){ // 另起一个 var $copy = $current.clone(); $copy.addClass('active').attr('data-type', type).text(type); $('#dynamicNav').empty().append($copy); } else{ $target.addClass('active'); } }); // 省略... }, // 攻击线(地图markline)事件 _bindMapLineEvents: function(){ var that = this; this.aMap.on(MAP_EVENTS.LINE_HOVERED, function(e){ // 前提:srcName-destName 必须能唯一区分 // 国外IP目前只能定位到国家 var temps = (e.data.name).split(' > '); var source = temps[0]; var dest = temps[1]; var attacks = that.attacksData; // 遍历data for(var i=0; i<attacks.length; i++){ if(attacks[i]['srcName'] === source && attacks[i]['destName'] === dest){ that._drawMapLineDetail(attacks[i], e.event.pageX, e.event.pageY); break; } } }); this.aMap.on(MAP_EVENTS.LINE_BLURED, function(e){ that._hideMapLineDetail(); }); }, // 画攻击线详情 _drawMapLineDetail: function(){ // 细节省略... }, _hideMapLineDetail: function(){ // 细节省略... } };
再看一遍demo
比较简单,源码在 js/lib/mods/clock.js 中,下面只列出大体结构。
define(['jquery'], function($){ var Clock = function(config){ this.$el = $('#' + this.config.id); this._init(); }; Clock.prototype = { _init: function(){ // 细节省略... this.start(); }, _update: function(){ // 细节省略... }, start: function(){ // 先初始化时间 this._update(); var that = this; this.timer = setInterval(function(){ that._update(); }, 1000); }, stop: function(){ clearInterval(this.timer); this.timer = null; } }; return Clock; });
原理是采用的css中 transform
动画,我们原本的做法会是先定义两个css class,一个添加transform的各种css规则,另一个class添加与前一项相反(或清除动画)的css规则,然后通过js操控DOM元素,在两个class之间切换。但我觉得这种做法太挫了,可以把相同效果的transform封装起来(避免写大同小异的css class),于是我封装了一个只做move移动的动画util方法。
define(['jquery', 'underscore'], function($, U){ return { /* 移动动画 @param el {HTMLElement} @param x1 {number} @param y1 {number} @param x2 {number} @param y2 {number} @param config {Object} @param duration {number} @param ease {string} @param isShowEl {boolean} 动画结束后是否继续显示元素 @param isClear {boolean} 动画结束后是否清除动画属性 @param beforeAnim {Function} @param afterAnim {Function} */ moveAnim: function(el, x1, y1, x2, y2, config) { if(!el){ return; } if(!el.tagName && el.length){ // jquery节点 el = el[0]; } var style = el.style; config = U.extend({ duration: 400, ease: 'ease', isShowEl: true, isClear: false }, config); style.display = 'block'; style.transform = 'translate3d(' + x1 + 'px, ' + y1 + 'px, 0px)'; style.transitionDuration = '0ms'; style.webkitTransform = 'translate3d(' + x1 + 'px, ' + y1 + 'px, 0px)'; style.webkitTransitionDuration = '0ms'; // before animation config.beforeAnim && config.beforeAnim(); setTimeout(function() { style.transform = 'translate3d(' + x2 + 'px, ' + y2 + 'px, 0px)'; style.transitionDuration = config.duration + 'ms'; style.transitionTimingFunction = config.ease; style.webkitTransform = 'translate3d(' + x2 + 'px, ' + y2 + 'px, 0px)'; style.webkitTransitionDuration = config.duration + 'ms'; style.webkitTransitionTimingFunction = config.ease; // 下面不会有第二次setTimeout if(config.isShowEl && !config.isClear){ // after animation config.afterAnim && config.afterAnim(); } }, 0); // 动画结束后不显示元素 if(!config.isShowEl){ style.display = 'none'; } // 清空动画属性(下次show时显示在最初的位置) if(!config.isShowEl || config.isClear){ var that = this; setTimeout(function() { that._clearTransform(el); // after animation config.afterAnim && config.afterAnim(); }, config.duration + 10); } }, _clearTransform: function(el){ var style = el.style; style.transform = null; style.transitionDuration = null; style.transitionTimingFunction = null; style.webkitTransform = null; style.webkitTransitionDuration = null; style.webkitTransitionTimingFunction = null; } } });
在demo中可以看到屏幕下方的攻击数据的表格一直在滚动播放,现在已经很少人还在用 <marquee>
这种东西了,好比已经淘汰的用 <table>
做页面布局。我这里基于上面的动画util方法,实现了一个滚动播放的table组件。
实现思路是,先要对table元素做预处理,将thead拷贝一份,因为表格滚动时thead是不动的(相当于sticky)。代码结构类似上面的Clock类,主动画逻辑包在 setInterval
中。每次动画循环到来时,取出tbody的第一个 tr
元素的高度h,然后将table整体向上move这段高度h,move结束后将第一个 tr
追加到tbody的队尾。具体实现代码见 js/lib/mods/animTable.js
最初的展现需求都已实现了,在这过程中封装了AttackMap,并自己实现了自定义事件,完全将echarts对外透明了。同时还产出了几个非主要的js小组件,过程看似拉的很长,但都是一步步自然而然会产生的想法。这里还遗留着一个问题, 如何将html模板、样式和js模块捆绑起来 ,即只需reuqire一下模块,模块相应的css会一并载入。
<!-- 不需要 <link rel="stylesheet" href="moduleA.css"> --> <div> <!-- 引入组件的html模板 --> {% require moduleA %} </div> <script> require(['mods/moduleA'], function(A){ // something... }); </script>
我想达到的效果就像上面,应用层页面不需要引组件模块的css,只要inclue一份html模板,require一下对应的js模块。有知道具体做法的吗,我想进一步交流。
在线demo
demo源码
在繁忙的项目中抽出时间做些整理和总结,是件重要但不紧急的事情。
和以前写的文章一对比,明显感觉到自己这半年多的成长。
本文最早发表在我的个人博客上,转载请保留出处 http://fuxiaode.cn/blog/2015/12/05/attack-map-with-amd/