摘要:最终自定义事件封装在上面的链接中看到,不仅应用层页面的按钮可以切换地图维度,直接点击地图里的中国区域也能切换地图,同时又能通知到应用层页面的按钮改变状态。
前言
很早以前写过一篇用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配置
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"
}
]
});
map封装过程
初步封装 mods/attackMap/main.js
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动画的速率。
应用层 js/app/mainMap.jsrequire([
"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 = $(""+v["srcIp"]+""+v["srcName"]+""+v["destIp"]+""+v["destName"]+""+v["type"]+""+v["time"]+"");
$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的事件实现的。
1、在 mods/attackMap/main.js 中定义事件类型// 对外事件
AttackMap.EVENTS = {
VIEW_CHANGED: "viewChanged",
LINE_HOVERED: "marklineHovered",
LINE_BLURED: "marklineBlured"
};
2、在AttackMap中实现事件触发器
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);
});
}
};
3、在AttackMap内部适当的方法中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对象)
4、在应用层js中监听自定义事件// 别名
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
再看一遍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;
});
move动画封装
原理是采用的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;
}
}
});
基于move动画的滚动表格
在demo中可以看到屏幕下方的攻击数据的表格一直在滚动播放,现在已经很少人还在用